Files
boc/projects/landvex/datafabrik/skordare/upphandlings_skordare.py
T
Bernt f4f853d94b landvex: Datafabrik + Vision + Infrastruktur för 50GB skalning
Datafabrik:
- Skördare: crawler, källvitlista, upphandlingsskördare
- Extraktor: LLM-baserad schemastyrd extraktion
- Upplösare: Entitetsupplösning och deduplicering
- Köer: Schemalagd / kunddriven / fält
- Agentorkestrering: 20+ parallella agenter

Vision:
- Identify-modell: ResNet50 + kontrastivt lärande
- Träningspipeline: NT-Xent loss
- Vektordatabas: FAISS för snabb sökning
- OCR-pipeline: Typskyltsläsning

Infrastruktur:
- Docker Compose production
- Terraform för AWS ECS
- Prometheus + Grafana monitorering
- Neo4j + FAISS + MinIO + Redis
2026-07-05 06:25:51 +00:00

192 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""
Landvex Skördare — Offentliga upphandlingar
Guld för installerad-bas-data: produkt, tillverkare, plats, tidpunkt, volym.
"""
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional
from dataclasses import dataclass
import aiohttp
from bs4 import BeautifulSoup
@dataclass
class UpphandlingsPost:
dokument_id: str
kalla: str # URL till upphandlingen
land: str
myndighet: str
produkt: str # Vad som upphandlades
tillverkare: str # Angiven eller vinnande tillverkare
plats: str # Geografisk plats
tidpunkt: str # Upphandlingsdatum
volym: Optional[str] # Antal, värde, etc.
konfidens: float
class UpphandlingsSkordare:
"""Skördar offentliga upphandlingar för Landvex."""
KALLOR = {
"Sverige": [
"https://www.mercell.com/",
"https://www.opic.com/",
],
"EU": [
"https://ted.europa.eu/",
],
"Norge": [
"https://doffin.no/",
],
"Danmark": [
"https://www.udbudsportalen.dk/",
],
"Tyskland": [
"https://www.vergabe24.de/",
],
"USA": [
"https://sam.gov/",
"https://www.usaspending.gov/",
],
}
def __init__(self, output_dir: Path):
self.output_dir = output_dir
self.output_dir.mkdir(parents=True, exist_ok=True)
self.session: Optional[aiohttp.ClientSession] = None
async def __aenter__(self):
self.session = aiohttp.ClientSession(
headers={
"User-Agent": "LandvexBot/1.0 (Research; https://landvex.io/bot)"
}
)
return self
async def __aexit__(self, *args):
if self.session:
await self.session.close()
async def hamta_sida(self, url: str) -> Optional[str]:
"""Hämta HTML från URL."""
try:
async with self.session.get(url, timeout=30) as resp:
if resp.status == 200:
return await resp.text()
print(f" ⚠️ HTTP {resp.status}: {url}")
return None
except Exception as e:
print(f" ⚠️ Fel: {e}")
return None
def extrahera_upphandlingar(self, html: str, kalla: str, land: str) -> List[UpphandlingsPost]:
"""Extrahera upphandlingsposter från HTML."""
soup = BeautifulSoup(html, 'html.parser')
poster = []
# Hitta upphandlingslistningar (anpassas per källa)
for item in soup.find_all(['tr', 'div', 'article'], class_=lambda x: x and ('tender' in x.lower() or 'contract' in x.lower() or 'upphandling' in x.lower())):
try:
# Extrahera fält
produkt = self._extrahera_text(item, ['title', 'description', 'objekt'])
myndighet = self._extrahera_text(item, ['authority', 'buyer', 'upphandlande'])
plats = self._extrahera_text(item, ['place', 'location', 'ort'])
datum = self._extrahera_text(item, ['date', 'deadline', 'publicerad'])
if produkt: # Minst produkt måste finnas
post = UpphandlingsPost(
dokument_id=self._generera_id(produkt + datum),
kalla=kalla,
land=land,
myndighet=myndighet or "Okänd",
produkt=produkt,
tillverkare="", # Kräver djupanalys
plats=plats or "",
tidpunkt=datum or "",
volym=None,
konfidens=0.7 if myndighet else 0.5,
)
poster.append(post)
except Exception as e:
continue
return poster
def _extrahera_text(self, element, klasser: List[str]) -> Optional[str]:
"""Hjälp: extrahera text från element med matchande klass."""
for klass in klasser:
found = element.find(class_=lambda x: x and klass in x.lower())
if found:
return found.get_text(strip=True)
return None
def _generera_id(self, text: str) -> str:
"""Generera unikt ID från text."""
import hashlib
return hashlib.sha256(text.encode()).hexdigest()[:16]
def spara_poster(self, poster: List[UpphandlingsPost], land: str):
"""Spara upphandlingsposter."""
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
fil_path = self.output_dir / f"upphandlingar_{land}_{timestamp}.json"
data = [
{
"dokument_id": p.dokument_id,
"kalla": p.kalla,
"land": p.land,
"myndighet": p.myndighet,
"produkt": p.produkt,
"tillverkare": p.tillverkare,
"plats": p.plats,
"tidpunkt": p.tidpunkt,
"volym": p.volym,
"konfidens": p.konfidens,
"skordad": datetime.utcnow().isoformat(),
}
for p in poster
]
with open(fil_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f" 💾 {len(poster)} poster sparade: {fil_path}")
async def skorda_land(self, land: str, max_sidor: int = 5) -> dict:
"""Skörda upphandlingar för ett land."""
print(f"\n🌾 Skördar upphandlingar: {land}")
kallor = self.KALLOR.get(land, [])
alla_poster = []
for kalla in kallor[:2]: # Begränsa initialt
print(f" 🔍 {kalla}")
html = await self.hamta_sida(kalla)
if html:
poster = self.extrahera_upphandlingar(html, kalla, land)
alla_poster.extend(poster)
print(f"{len(poster)} poster")
# Spara
if alla_poster:
self.spara_poster(alla_poster, land)
return {
"land": land,
"poster": len(alla_poster),
"kallor": len(kallor),
}
async def main():
"""Demo: skörda svenska upphandlingar."""
output = Path("/tmp/landvex-upphandlingar")
async with UpphandlingsSkordare(output) as skordare:
resultat = await skordare.skorda_land("Sverige", max_sidor=2)
print(f"\n📊 Resultat: {resultat}")
if __name__ == "__main__":
asyncio.run(main())