跳到正文
DEV Community· Lindenwerk Data·· 3 小時前AI 評分56

如何用 Python 和免費 Find a Tender API 建立每日英國招標提示

Build a daily UK tender alert in Python with the free Find a Tender API

AI 導讀

教程示範如何用 Python 3 呼叫免費 Find a Tender OCDS API,建立每日英國公共招標提示並輸出 CSV 和 JSON;讀取端不需帳戶或 API key。

正文

When UK public bodies buy goods, works or services, they have to publish notices about it. Since 24 February 2025, when the Procurement Act 2023 came into force, the official place for those notices is Find a Tender, which the Act's regulations call the central digital platform. Government guidance describes it as the one place where contracting authorities publish their procurement notices. Every notice is also published as structured JSON in the Open Contracting Data Standard (OCDS), under the Open Government Licence. You don't need an account or an API key.

The website has saved searches with email alerts, and they're fine for a quick look. If you want the data in your own pipeline (a spreadsheet, a CRM, a Slack channel, your own ranking), the API is the better route. In this tutorial we'll build a small Python script that:

  1. fetches every notice updated in a date window, following cursor pagination,
  2. keeps tender notices and drops awards, plans and cancellations,
  3. deduplicates notices and their updates (there's a framework trap here),
  4. filters by CPV code, region, value and excluded words,
  5. scores what's left with transparent keyword rules,
  6. writes CSV and JSON, remembers what it has already reported, and runs every morning.

It needs only Python 3 (we tested 3.13) and requests. The complete script is at the end of the article.

The endpoint

There's one URL to know:

GET https://www.find-tender.service.gov.uk/api/1.0/ocdsReleasePackages

The documented query parameters:

Parameter Meaning
updatedFrom, updatedTo Window on the "last updated" time, format YYYY-MM-DDTHH:MM:SS
limit Releases per page, 1 to 100 (default 100)
cursor Token for the next page
stages planning, tender or award (see the quirk below)

You can also fetch one notice (/ocdsReleasePackages/097351-2026) or one whole procurement by its OCID (/ocdsReleasePackages/ocds-h6vhtk-029a8d).

Try it:

curl -s "https://www.find-tender.service.gov.uk/api/1.0/ocdsReleasePackages?updatedFrom=2026-10-15T10:00:00&updatedTo=2026-10-15T11:00:00&limit=5" \
  | python -m json.tool | head -40

The response is an OCDS release package. At the top it has license (the OGL v3.0 URL), publicationPolicy, and publisher (Cabinet Office). Then come releases, the notices themselves, and links.next if there are more pages. The next URL already contains the cursor, so you just follow it until it's gone.

The docs say nothing about a request quota. They do say that on HTTP 429 or 503 you should send no further requests until the number of seconds in the Retry-After header has passed. We'll build that in.

(Find a Tender also has a separate notice submission API for eSenders, documented under /apidocumentation. That one needs a CDP API key. The read-only OCDS endpoints above don't.)

What one release looks like

A release is one published notice. These are the fields a tender monitor needs, shown on a real below-threshold tender from 15 October 2026 (notice 097351-2026, a legal research service for two London councils):

{
  "id": "097351-2026",
  "ocid": "ocds-h6vhtk-078847",
  "date": "2026-10-15T10:58:38+01:00",
  "tag": ["tender"],
  "tender": {
    "title": "PRJ_31142 - Legal Online Research and Library",
    "legalBasis": {"id": "2023/54", "scheme": "UKPGA"},
    "value": {"amount": 90000, "amountGross": 108000, "currency": "GBP"},
    "tenderPeriod": {"endDate": "2026-11-06T17:00:00+00:00"},
    "items": [{"additionalClassifications": [{"scheme": "CPV", "id": "48100000"}, "..."]}],
    "procurementMethodDetails": "Below threshold - open competition"
  },
  "parties": [{"name": "Westminster City Council", "roles": ["buyer"],
               "address": {"region": "UKI32"}, "...": "..."}]
}

A few things to note:

  • id vs ocid. id is the notice (format nnnnnn-yyyy). The OCID is the whole contracting process, which can have a planning notice, a tender, several updates, an award and a contract. The OCDS docs call the OCID "a globally unique identifier used to join up data on all stages of a contracting process".
  • tag says what kind of release it is: tender, tenderUpdate, award, contract, planning, tenderCancellation and so on.
  • legalBasis.id 2023/54 is the Procurement Act 2023. You'll still see older-regime notices with CELEX ids such as 32014L0024 (the Public Contracts Regulations 2015).
  • Value. The UK OCDS extension defines amountGross as "the amount including all taxes", so amount is the figure without VAT. Here that's £90,000 against £108,000.
  • CPV codes often aren't on tender.classification. In this notice they're only in items[].additionalClassifications.
  • Regions are ITL/NUTS-style codes such as UKI32 (inside London, UKI).
  • Contact data. parties[].contactPoint often holds a named officer with a direct email address and phone number. The OGL explicitly doesn't cover personal data, so our monitor keeps organisation names and leaves contact persons out.

Step 1: fetch a window and paginate politely

API = "https://www.find-tender.service.gov.uk/api/1.0/ocdsReleasePackages"
USER_AGENT = "LindenwerkData-fts-monitor/1.0 (+https://dev.to/lindenwerkdata; open-data tutorial)"

def fetch_releases(updated_from, updated_to, stages=None, session=None):
    s = session or requests.Session()
    s.headers.update({"User-Agent": USER_AGENT, "Accept": "application/json"})
    params = {"updatedFrom": updated_from, "updatedTo": updated_to, "limit": 100}
    if stages:
        params["stages"] = stages
    url, releases, pages = API, [], 0
    while url and pages < MAX_PAGES:
        resp = get_with_retry(s, url, params if pages == 0 else None)
        package = resp.json()
        releases.extend(package.get("releases") or [])
        pages += 1
        url = (package.get("links") or {}).get("next")  # already carries the cursor
        if url:
            time.sleep(PAUSE_BETWEEN_PAGES)              # 1 s between pages
    return releases, pages

def get_with_retry(s, url, params, tries=5):
    for attempt in range(tries):
        try:
            resp = s.get(url, params=params, timeout=(10, 60))
        except (requests.ConnectionError, requests.Timeout):
            time.sleep(2 ** attempt * 5)
            continue
        if resp.status_code == 200:
            return resp
        if resp.status_code in (429, 503):               # the docs: wait Retry-After seconds
            ra = resp.headers.get("Retry-After", "")
            time.sleep(int(ra) if ra.isdigit() else 30)
            continue
        resp.raise_for_status()                          # e.g. 400 for an unknown parameter
    raise RuntimeError(f"giving up on {url}")

Three habits worth keeping: identify your client in the User-Agent, always set a timeout, and pause between pages even though no limit is published. A whole day costs only a handful of requests anyway. On 16 October 2026 we fetched the window 2026-10-15T00:00:00 to 2026-10-16T00:00:00. It returned 443 releases on 5 pages in about 7 seconds.

The docs don't say which time zone updatedFrom/updatedTo use. Back-to-back daily windows still cover everything, and the deduplication and state file below absorb any overlap, so it's safest not to depend on it.

Step 2: don't rely on stages=tender, filter on tag

stages=tender looks like the obvious way to fetch only tenders. In our test it wasn't. For the same 15 October window:

  • stages=tender returned 10 releases.
  • The unfiltered feed had 60 releases tagged tender, plus 34 tenderUpdate.
  • All 10 releases from stages=tender were older-regime notices (PCR 2015 or UCR 2016 legal basis). The 50 Procurement Act 2023 tenders from that day weren't among them.

We haven't found documentation that explains the difference, so the script fetches everything and filters on the OCDS tag itself:

wanted = [r for r in releases if set(r.get("tag") or []) & {"tender", "tenderUpdate"}]

For context, here's how the 443 releases of that Thursday split by tag: 274 award+contract, 60 tender, 40 planning, 34 tenderUpdate, 17 planningUpdate, 11 award/contract updates, 4 cancellations, 2 contract amendments and 1 implementation. About a fifth of the feed is tender notices or tender updates (94 of 443). If you want competitor intelligence instead, switch the tags to award and contract.

Step 3: flatten the fields you care about

def cpv_codes(tender):
    found = []
    for b in [tender] + list(tender.get("items") or []) + list(tender.get("lots") or []):
        for c in [b.get("classification")] + list(b.get("additionalClassifications") or []):
            if c and str(c.get("scheme", "")).upper() == "CPV" and c.get("id"):
                code = str(c["id"]).split("-")[0]
                if code not in found:
                    found.append(code)
    return found

Look for CPV codes in all three places. Of the 94 tender releases, 78 had no tender.classification at all. A script that reads only that field would miss most of the day.

For regions, prefer where the work happens (items[].deliveryAddresses[].region) and fall back to the buyer's address. Mixing the two causes false hits. One framework notice that day had its buyer in London (UKI74) but delivery in Scotland (UKM), so a London filter shouldn't match it.

Expect gaps: 27 of the 94 had no tender.value.amount and 11 had no tenderPeriod.endDate. Treat missing values as unknown, not zero.

Step 4: deduplicate, and watch out for frameworks

A tender and its later updates share an OCID, so "keep the newest release per OCID" is the textbook approach. On Find a Tender it has a catch. Frameworks and dynamic purchasing systems can reuse one OCID for many separate competitions. The procurement ocds-h6vhtk-029a8d (a research and insights DPS) had 55 releases when we looked it up. Three of them were tenderUpdates on 15 October with three different titles: one for the DPS itself and two for separate research projects run under it.

So the key is OCID plus normalised title:

def dedupe_key(r):
    return f"{r['ocid'] or r['notice_id']}|{' '.join(r['title'].lower().split())}"

def latest_per_procurement(rows):
    best = {}
    for r in rows:
        k = dedupe_key(r)
        if k not in best or (r["release_date"] or "") > (best[k]["release_date"] or ""):
            best[k] = r
    return list(best.values())

On the test day this reduced 94 tender releases to 88 rows (86 distinct OCIDs). OCID alone would have given 86 and silently dropped two of those three. The trade-off: if a buyer renames a tender in an update, you'll see it twice. For an alert, a duplicate is cheaper than a miss.

Step 5: filter and score

Hard filters remove rows. The score only orders what's left:

def score(r, cpv_prefixes, keywords):
    points, reasons = 0, []
    cpvs = [c for c in r["cpv"].split("|") if c]
    hits = [p for p in cpv_prefixes if any(c.startswith(p) for c in cpvs)]
    if hits:
        points += 50
        reasons.append(f"CPV {','.join(hits)}")
    for kw in keywords:
        pattern = re.compile(rf"\b{re.escape(kw)}\b", re.IGNORECASE)
        if pattern.search(r["title"]):
            points += 25
            reasons.append(f"'{kw}' in title")
        elif pattern.search(r["description"]):
            points += 10
            reasons.append(f"'{kw}' in description")
    return min(points, 100), reasons

CPV codes are hierarchical, so a prefix match does a lot: 72 covers IT services, 48 software packages, 45 construction works. Use word boundaries for keywords, or data will also match database. Every point comes with a reason, so you can see why something ranked where it did.

On the test day, an IT profile (--cpv 72 48 --keyword software cloud cyber data) kept 12 of 88 notices. The top one was a cloud-hosted programme management platform for a council (60 points: CPV 72 plus "cloud" in the description). The two lowest, at 25 points, were keyword-only hits on "data" ("Electrical and Data Installation Services" and an energy-meter tender whose title mentions "Data Collector"). So use a short list of distinctive keywords, or raise --min-score to 50 so CPV evidence is required. A works profile (--cpv 45 --region UKI UKJ --min-value 100000) kept 3.

Step 6: output, attribution and "only new"

The script writes a CSV for humans and a JSON file whose first key is the attribution line. The OGL requires you to "acknowledge the source of the Information", and where the provider gives no specific attribution statement you must use: "Contains public sector information licensed under the Open Government Licence v3.0." Put that line wherever the data ends up, including emails and dashboards. The licence also says you must not suggest any official status or endorsement, so make it clear your alert isn't the government's.

For a daily alert, remember what you've already sent:

seen = set(json.loads(state_file.read_text())) if state_file.exists() else set()
rows = [r for r in rows if dedupe_key(r) not in seen]
state_file.write_text(json.dumps(sorted(seen | {dedupe_key(r) for r in rows})))

In our test, the first run reported 12 new matches. A second run over the same saved data reported 0.

During development, add --save-raw raw.json once and then tune your filters with --from-file raw.json. That way you're not downloading the same day again for every tweak.

Step 7: run it every morning

Cron (uses the machine's local time; % must be escaped in crontab):

17 6 * * 1-5  cd $HOME/fts-monitor && TZ=Europe/London ./venv/bin/python fts_monitor.py --yesterday --cpv 72 48 --keyword software cloud --state seen.json --csv "out/$(date +\%F).csv" >> monitor.log 2>&1

GitHub Actions (.github/workflows/fts-monitor.yml):

name: UK tender monitor
on:
  schedule:
    - cron: "17 6 * * 1-5"
      timezone: "Europe/London"
  workflow_dispatch: {}
jobs:
  monitor:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      TZ: Europe/London            # so --yesterday means the UK calendar day
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.12"}
      - run: pip install -r requirements.txt
      - run: python fts_monitor.py --yesterday --cpv 72 48 --keyword software cloud --csv tenders.csv --json tenders.json
      - uses: actions/upload-artifact@v4
        with: {name: "tenders-${{ github.run_id }}", path: "tenders.*", retention-days: 30}

Some GitHub details worth knowing:

  • Schedules run in UTC unless you set timezone.
  • Scheduled runs can be delayed at busy times, especially at the start of the hour, which is why the example runs at :17.
  • In a public repository, scheduled workflows are disabled automatically after 60 days without repository activity.

The Actions version is stateless on purpose. Each run covers exactly yesterday, so it doesn't need a state file. If you want overlapping windows there, you'll need to persist seen.json yourself.

Where to take it next

  • Send tenders.json to Slack or email (an incoming webhook takes a few lines of requests).
  • Track competitors: switch to --tags award contract and read the suppliers of each award.
  • Add more signals: aboveThreshold, lots[].suitability.sme / .vcse, tenderPeriod.endDate for "closing soon".
  • Always open the official notice before you bid. Your copy is only a convenience, and the official notice prevails.

Full script: fts_monitor.py (240 lines, requests only)

#!/usr/bin/env python3
"""Daily UK public tender monitor built on the Find a Tender Service (FTS) OCDS release API.

Free, official, no API key. Notice data: Open Government Licence v3.0.
Contains public sector information licensed under the Open Government Licence v3.0.

Usage examples:
    python fts_monitor.py --from 2026-10-15 --to 2026-10-16 --cpv 72 48 --keyword software cloud
    python fts_monitor.py --yesterday --cpv 45 --region UKD --min-value 100000 --csv out.csv
    python fts_monitor.py --yesterday --keyword cleaning --state seen.json   # only what you haven't seen
"""
from __future__ import annotations

import argparse
import csv
import json
import re
import sys
import time
from datetime import date, timedelta
from pathlib import Path

import requests

API = "https://www.find-tender.service.gov.uk/api/1.0/ocdsReleasePackages"
NOTICE_URL = "https://www.find-tender.service.gov.uk/Notice/{}"
USER_AGENT = "LindenwerkData-fts-monitor/1.0 (+https://dev.to/lindenwerkdata; open-data tutorial)"
ATTRIBUTION = "Contains public sector information licensed under the Open Government Licence v3.0."
PAUSE_BETWEEN_PAGES = 1.0  # seconds; be polite even though no limit is published
MAX_PAGES = 200            # safety stop (100 releases per page)


# ---------------------------------------------------------------- 1. fetch + paginate
def fetch_releases(updated_from: str, updated_to: str, stages: str | None = None,
                   session: requests.Session | None = None) -> tuple[list[dict], int]:
    """Return every OCDS release updated in [updated_from, updated_to]. Follows links.next (cursor pagination)."""
    s = session or requests.Session()
    s.headers.update({"User-Agent": USER_AGENT, "Accept": "application/json"})
    params = {"updatedFrom": updated_from, "updatedTo": updated_to, "limit": 100}
    if stages:
        params["stages"] = stages
    url, releases, pages = API, [], 0
    while url and pages < MAX_PAGES:
        resp = get_with_retry(s, url, params if pages == 0 else None)
        package = resp.json()
        releases.extend(package.get("releases") or [])
        pages += 1
        url = (package.get("links") or {}).get("next")  # the next URL already carries the cursor
        if url:
            time.sleep(PAUSE_BETWEEN_PAGES)
    if url:
        print(f"warning: stopped after {MAX_PAGES} pages, window is incomplete", file=sys.stderr)
    return releases, pages


def get_with_retry(s: requests.Session, url: str, params: dict | None, tries: int = 5) -> requests.Response:
    """GET with a timeout. On 429/503 wait the number of seconds in Retry-After, as the API docs ask."""
    for attempt in range(tries):
        try:
            resp = s.get(url, params=params, timeout=(10, 60))
        except (requests.ConnectionError, requests.Timeout) as exc:
            wait = 2 ** attempt * 5
            print(f"network error ({exc.__class__.__name__}), retrying in {wait}s", file=sys.stderr)
            time.sleep(wait)
            continue
        if resp.status_code == 200:
            return resp
        if resp.status_code in (429, 503):
            retry_after = resp.headers.get("Retry-After", "")
            wait = int(retry_after) if retry_after.isdigit() else 30
            print(f"HTTP {resp.status_code}, waiting {wait}s (Retry-After)", file=sys.stderr)
            time.sleep(wait)
            continue
        resp.raise_for_status()  # 400 = unknown parameter etc.: a bug, fail loudly
    raise RuntimeError(f"giving up on {url} after {tries} attempts")


# ---------------------------------------------------------------- 2. flatten one release
def cpv_codes(tender: dict) -> list[str]:
    """CPV codes can sit on tender.classification, items[].classification/additionalClassifications and lots."""
    found = []
    blocks = [tender] + list(tender.get("items") or []) + list(tender.get("lots") or [])
    for b in blocks:
        for c in [b.get("classification")] + list(b.get("additionalClassifications") or []):
            if c and str(c.get("scheme", "")).upper() == "CPV" and c.get("id"):
                code = str(c["id"]).split("-")[0]
                if code not in found:
                    found.append(code)
    return found


def flatten(rel: dict) -> dict:
    t = rel.get("tender") or {}
    buyers = [p for p in rel.get("parties") or [] if "buyer" in (p.get("roles") or [])]
    # Where the work happens (items[].deliveryAddresses) beats where the buyer sits (parties[].address).
    regions = [a.get("region") for it in t.get("items") or [] for a in it.get("deliveryAddresses") or []]
    if not any(regions):
        regions = [(b.get("address") or {}).get("region") for b in buyers]
    value = t.get("value") or {}
    return {
        "ocid": rel.get("ocid"),
        "notice_id": rel.get("id"),
        "release_date": rel.get("date"),
        "tags": "|".join(rel.get("tag") or []),
        "title": (t.get("title") or "").strip(),
        "description": " ".join((t.get("description") or "").split()),
        "buyer": "; ".join(b.get("name", "") for b in buyers),   # organisation names only, no contact persons
        "regions": "|".join(sorted({r for r in regions if r})),
        "cpv": "|".join(cpv_codes(t)),
        "value_gbp": value.get("amount"),                        # excl. VAT; amountGross = incl. VAT
        "currency": value.get("currency"),
        "deadline": (t.get("tenderPeriod") or {}).get("endDate"),
        "procedure": t.get("procurementMethodDetails"),
        "url": NOTICE_URL.format(rel.get("id")),
    }


# ---------------------------------------------------------------- 3. dedupe by OCID
def dedupe_key(r: dict) -> str:
    """OCID first. Frameworks and dynamic purchasing systems reuse one OCID for many separate
    competitions, so the title is part of the key: updates to the same competition collapse,
    different competitions under the same framework survive."""
    return f"{r['ocid'] or r['notice_id']}|{' '.join(r['title'].lower().split())}"


def latest_per_procurement(rows: list[dict]) -> list[dict]:
    """A tender notice and its updates share a key. Keep the newest release."""
    best: dict[str, dict] = {}
    for r in rows:
        key = dedupe_key(r)
        if key not in best or (r["release_date"] or "") > (best[key]["release_date"] or ""):
            best[key] = r
    return list(best.values())


# ---------------------------------------------------------------- 4. filter + score
def passes_filters(r: dict, args: argparse.Namespace) -> bool:
    if args.min_value and (r["value_gbp"] is None or r["value_gbp"] < args.min_value):
        return False
    if args.region and not any(code.startswith(tuple(args.region)) for code in r["regions"].split("|") if code):
        return False
    text = f"{r['title']} {r['description']}".lower()
    if any(x.lower() in text for x in args.exclude):
        return False
    return True


def score(r: dict, cpv_prefixes: list[str], keywords: list[str]) -> tuple[int, list[str]]:
    """Additive points, capped at 100. Transparent on purpose: every point has a reason."""
    points, reasons = 0, []
    cpvs = [c for c in r["cpv"].split("|") if c]
    hits = [p for p in cpv_prefixes if any(c.startswith(p) for c in cpvs)]
    if hits:
        points += 50
        reasons.append(f"CPV {','.join(hits)}")
    title, desc = r["title"], r["description"]
    for kw in keywords:
        pattern = re.compile(rf"\b{re.escape(kw)}\b", re.IGNORECASE)  # whole words: 'data' != 'database'
        if pattern.search(title):
            points += 25
            reasons.append(f"'{kw}' in title")
        elif pattern.search(desc):
            points += 10
            reasons.append(f"'{kw}' in description")
    return min(points, 100), reasons


# ---------------------------------------------------------------- 5. output
def write_outputs(rows: list[dict], csv_path: str | None, json_path: str | None) -> None:
    if csv_path:
        with open(csv_path, "w", newline="", encoding="utf-8") as f:
            w = csv.DictWriter(f, fieldnames=list(rows[0].keys()) if rows else ["ocid"])
            w.writeheader()
            w.writerows(rows)
    if json_path:
        Path(json_path).write_text(json.dumps({"attribution": ATTRIBUTION, "notices": rows},
                                              indent=2, ensure_ascii=False), encoding="utf-8")


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--from", dest="date_from", help="YYYY-MM-DD (start of window)")
    ap.add_argument("--to", dest="date_to", help="YYYY-MM-DD (end of window, exclusive)")
    ap.add_argument("--yesterday", action="store_true", help="window = yesterday 00:00 to today 00:00")
    ap.add_argument("--stages", default="", help="server-side stages filter (default: none, see article)")
    ap.add_argument("--tags", nargs="*", default=["tender", "tenderUpdate"],
                    help="client-side filter on OCDS release tags (default: tender tenderUpdate)")
    ap.add_argument("--cpv", nargs="*", default=[], help="CPV prefixes, e.g. 72 48 (IT), 45 (works)")
    ap.add_argument("--keyword", nargs="*", default=[], help="keywords, case-insensitive")
    ap.add_argument("--exclude", nargs="*", default=[], help="drop notices containing any of these")
    ap.add_argument("--region", nargs="*", default=[], help="ITL/NUTS prefixes, e.g. UKI (London) UKD (North West)")
    ap.add_argument("--min-value", type=float, default=0, help="minimum estimated value in GBP excl. VAT")
    ap.add_argument("--min-score", type=int, default=25)
    ap.add_argument("--state", help="JSON file of procurements already reported; only new ones are output")
    ap.add_argument("--save-raw", help="also save the raw releases to this JSON file")
    ap.add_argument("--from-file", help="replay releases from a --save-raw file instead of calling the API")
    ap.add_argument("--csv", default="tenders.csv")
    ap.add_argument("--json", default="tenders.json")
    args = ap.parse_args()

    if args.from_file:  # tune filters offline without re-downloading
        releases, pages = json.loads(Path(args.from_file).read_text()), 0
    else:
        if args.yesterday:
            start, end = date.today() - timedelta(days=1), date.today()
        else:
            start, end = date.fromisoformat(args.date_from), date.fromisoformat(args.date_to)
        releases, pages = fetch_releases(f"{start}T00:00:00", f"{end}T00:00:00", args.stages or None)
    if args.save_raw:
        Path(args.save_raw).write_text(json.dumps(releases), encoding="utf-8")
    wanted = [r for r in releases if not args.tags or set(r.get("tag") or []) & set(args.tags)]
    rows = latest_per_procurement([flatten(r) for r in wanted])
    print(f"{len(releases)} releases on {pages} page(s), {len(wanted)} with tags {args.tags} "
          f"-> {len(rows)} after dedupe "
          f"({len({r['ocid'] for r in rows})} distinct OCIDs)")

    rows = [r for r in rows if passes_filters(r, args)]
    if args.cpv or args.keyword:
        for r in rows:
            r["score"], why = score(r, args.cpv, args.keyword)
            r["reasons"] = "; ".join(why)
        rows = [r for r in rows if r["score"] >= args.min_score]
        rows.sort(key=lambda r: (-r["score"], r["deadline"] or "9999"))
    print(f"{len(rows)} after filters/scoring")

    if args.state:
        state_file = Path(args.state)
        seen = set(json.loads(state_file.read_text())) if state_file.exists() else set()
        rows = [r for r in rows if dedupe_key(r) not in seen]
        state_file.write_text(json.dumps(sorted(seen | {dedupe_key(r) for r in rows}), indent=0))
        print(f"{len(rows)} not reported before")

    write_outputs(rows, args.csv, args.json)
    for r in rows[:10]:
        print(f"{r.get('score', '-'):>3}  {(r['deadline'] or '')[:10]}  {r['title'][:70]}  {r['url']}")
    print(ATTRIBUTION)


if __name__ == "__main__":
    main()

Data: Find a Tender Service, Cabinet Office. Contains public sector information licensed under the Open Government Licence v3.0. This is an independent tutorial, not an official service.

This article was written with the help of AI. Every number comes from our own runs against the live API on 16 October 2026, and the API, OCDS and licence details were checked against the official documentation linked above.

來源:DEV Community · dev.to