admin / Bid-Sentinel
publicBid Scrape and Tracking Application with AI Capability
Bid-Sentinel / bid-sentinel-v2 / backend / app / scraper / ocds_api.py
4558 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | """Generic OCDS (Open Contracting) JSON API scraper. UK procurement data is widely published as OCDS release packages. Where a portal exposes such a JSON feed, prefer it over HTML scraping — it's structured and reliable. This helper builds an ``async fetch(keywords)`` for any OCDS endpoint; point a portal at it in ``portals.py`` by giving its ``api_url``. (Find a Tender and Contracts Finder keep their own bespoke OCDS modules because their URL/notice-link conventions differ; this is the reusable path for others.) """ from __future__ import annotations import logging import httpx from app.scraper.base import ( TenderRecord, cpv_text, is_award_or_closed, parse_datetime, parse_value, ) from app.scraper.keywords import match_keyword logger = logging.getLogger("scraper.ocds") HEADERS = { "User-Agent": "CyberTenderTracker/1.0 (+https://example.com)", "Accept": "application/json", } def make_scraper(source_name: str, api_url: str, params: dict | None = None, max_pages: int = 5): """Build an ``async fetch(keywords)`` for an OCDS release-package endpoint.""" async def fetch(keywords: list[str] | None = None) -> list[TenderRecord]: records: list[TenderRecord] = [] try: async with httpx.AsyncClient(timeout=30.0, headers=HEADERS, follow_redirects=True) as client: url: str | None = api_url page = 0 while url and page < max_pages: page += 1 resp = await client.get(url, params=params if url == api_url else None) resp.raise_for_status() data = resp.json() for release in data.get("releases", []): rec = _parse_release(release, source_name, keywords) if rec: records.append(rec) url = (data.get("links") or {}).get("next") except Exception as exc: # noqa: BLE001 - never abort the whole run logger.warning("%s: OCDS fetch failed: %s", source_name, exc) logger.info("%s: OCDS scraper found %d candidate(s)", source_name, len(records)) return records return fetch def _parse_release(release: dict, source_name: str, keywords: list[str] | None) -> TenderRecord | None: try: tender = release.get("tender", {}) or {} title = tender.get("title") or release.get("title") description = tender.get("description") if not title: return None # Only keep opportunities currently open for tender — skip award/contract # notices and closed/cancelled tenders (closing date is checked later). if is_award_or_closed(release, tender): return None # Keyword relevance is always required; the CPV AND-filter is applied # later in the runner using the classification captured below. matched = match_keyword(title, description, keywords=keywords) if matched is None: return None ocid = release.get("ocid") or release.get("id") url = release.get("uri") or (tender.get("documents") or [{}])[0].get("url", "") if not url and ocid: url = f"ocds:{ocid}" if not url: return None value = (tender.get("value") or {}).get("amount") amount, value_text = parse_value(value) tender_period = tender.get("tenderPeriod") or {} contract_period = tender.get("contractPeriod") or {} duration = None if contract_period.get("startDate") and contract_period.get("endDate"): duration = f"{contract_period['startDate'][:10]} to {contract_period['endDate'][:10]}" return TenderRecord( url=url, title=title.strip()[:1024], source=source_name, reference=str(ocid) if ocid else None, description=(description or "").strip() or None, published_date=parse_datetime(release.get("date")), closing_date=parse_datetime(tender_period.get("endDate")), value_amount=amount, value_text=value_text, currency=(tender.get("value") or {}).get("currency", "GBP"), duration=duration, buyer=(release.get("buyer") or {}).get("name"), matched_keyword=matched, cpv=cpv_text(tender), ) except Exception as exc: # noqa: BLE001 logger.debug("%s: could not parse release: %s", source_name, exc) return None |