admin / Bid-Sentinel
publicBid Scrape and Tracking Application with AI Capability
Bid-Sentinel / bid-sentinel-v2 / backend / app / scraper / generic_html.py
6153 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 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | """Accurate best-effort HTML scraper for portals without a JSON/OCDS API. Strategy: fetch the portal's search/listing page once (polite), find the result cards, and extract structured fields from each — title, link, buyer, value, and published/closing dates — rather than naively harvesting links. Only results whose text matches the cyber keyword list are kept. Degrades gracefully (returns [] on any failure) and never aborts the run. Where a portal exposes a JSON/OCDS API, prefer ``ocds_api`` instead — it's far more reliable. This module is the fallback for HTML-only sites; the result-card selectors and ``search_url`` may still need per-site tuning, and auth-gated portals need credentials before they return anything. """ from __future__ import annotations import logging import re from urllib.parse import urljoin import httpx from bs4 import BeautifulSoup from app.scraper.base import TenderRecord, parse_datetime, parse_value from app.scraper.keywords import match_keyword logger = logging.getLogger("scraper.generic") HEADERS = { "User-Agent": "CyberTenderTracker/1.0 (+https://example.com)", "Accept": "text/html,application/xhtml+xml", } _MAX_RESULTS = 60 # Selectors that commonly wrap one search result / notice on procurement portals. _RESULT_SELECTORS = [ "[class*=search-result]", "[class*=result-item]", "[class*=resultitem]", "[class*=notice-item]", "[class*=opportunity]", "[class*=tender-item]", "li[class*=result]", "article", ] _DATE = r"\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}" _MONEY_RE = re.compile(r"£\s?[\d,]+(?:\.\d+)?\s?(?:million|billion|bn|m|k)?", re.IGNORECASE) def _norm(text: str) -> str: return " ".join((text or "").split()) def _labelled(text: str, labels: list[str], width: int = 80) -> str | None: # Capture up to the next field/sentence boundary (period, pipe, newline, bullet). for label in labels: m = re.search(rf"{label}\s*[:\-]\s*([^.|\n•;]{{1,{width}}})", text, re.IGNORECASE) if m: value = _norm(m.group(1)).strip(" .;,-") if value: return value return None def _labelled_date(text: str, labels: list[str]): for label in labels: m = re.search(rf"{label}[^0-9]{{0,25}}({_DATE})", text, re.IGNORECASE) if m: return parse_datetime(m.group(1)) return None def _money(text: str): m = _MONEY_RE.search(text) return parse_value(m.group(0)) if m else (None, None) def _parse_card(card, base: str, source: str, keywords: list[str] | None) -> TenderRecord | None: link = card.find("a", href=True) if not link: return None heading = card.find(["h1", "h2", "h3", "h4"]) title = _norm((heading.get_text(" ") if heading else "") or link.get_text(" ")) if len(title) < 10: return None text = _norm(card.get_text(" ")) matched = match_keyword(title, text, keywords=keywords) if matched is None: return None url = urljoin(base, link["href"]) if not url.startswith("http"): return None amount, value_text = _money(text) return TenderRecord( url=url, title=title[:1024], source=source, buyer=_labelled(text, ["buyer", "contracting authority", "authority", "organisation", "organization"]), reference=_labelled(text, ["reference", "notice id", "ref no", "ref"], width=40), value_amount=amount, value_text=value_text, published_date=_labelled_date(text, ["published", "date published", "publication"]), closing_date=_labelled_date(text, ["closing", "deadline", "closes", "closing date"]), matched_keyword=matched, ) def make_scraper(source_name: str, search_url: str, query: dict | None = None): """Build an ``async fetch(keywords)`` for a portal's HTML search/listing page.""" async def fetch(keywords: list[str] | None = None) -> list[TenderRecord]: out: list[TenderRecord] = [] seen: set[str] = set() try: async with httpx.AsyncClient(timeout=30.0, headers=HEADERS, follow_redirects=True) as client: resp = await client.get(search_url, params=query or None) resp.raise_for_status() soup = BeautifulSoup(resp.text, "lxml") base = str(resp.url) # 1. Try to find structured result cards. cards = [] for selector in _RESULT_SELECTORS: cards = soup.select(selector) if cards: break def add(rec: TenderRecord | None) -> None: if rec and rec.url not in seen: seen.add(rec.url) out.append(rec) if cards: for card in cards: if len(out) >= _MAX_RESULTS: break add(_parse_card(card, base, source_name, keywords)) # 2. Fallback: harvest keyword-matching links (still records source). if not out: for anchor in soup.find_all("a", href=True): if len(out) >= _MAX_RESULTS: break title = _norm(anchor.get_text(" ")) if len(title) < 12: continue matched = match_keyword(title, keywords=keywords) if not matched: continue url = urljoin(base, anchor["href"]) if not url.startswith("http"): continue add(TenderRecord(url=url, title=title[:1024], source=source_name, matched_keyword=matched)) except Exception as exc: # noqa: BLE001 - a portal must never abort the run logger.info("%s: generic scrape returned no results (%s)", source_name, exc) logger.info("%s: HTML scraper found %d candidate(s)", source_name, len(out)) return out return fetch |