f4f853d94b
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
192 lines
6.5 KiB
Python
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())
|