Catalyst / admin/Bid-Sentinel 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Bid-Sentinel

public

Bid Scrape and Tracking Application with AI Capability

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
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