如何用 Python 和免費 Find a Tender API 建立每日英國招標提示
Build a daily UK tender alert in Python with the free Find a Tender API
教程示範如何用 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:
- fetches every notice updated in a date window, following cursor pagination,
- keeps tender notices and drops awards, plans and cancellations,
- deduplicates notices and their updates (there's a framework trap here),
- filters by CPV code, region, value and excluded words,
- scores what's left with transparent keyword rules,
- 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:
-
idvsocid.idis the notice (formatnnnnnn-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". -
tagsays what kind of release it is:tender,tenderUpdate,award,contract,planning,tenderCancellationand so on. -
legalBasis.id2023/54is the Procurement Act 2023. You'll still see older-regime notices with CELEX ids such as32014L0024(the Public Contracts Regulations 2015). -
Value. The UK OCDS extension defines
amountGrossas "the amount including all taxes", soamountis 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 initems[].additionalClassifications. -
Regions are ITL/NUTS-style codes such as
UKI32(inside London,UKI). -
Contact data.
parties[].contactPointoften 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=tenderreturned 10 releases. - The unfiltered feed had 60 releases tagged
tender, plus 34tenderUpdate. - All 10 releases from
stages=tenderwere 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.jsonto Slack or email (an incoming webhook takes a few lines ofrequests). - Track competitors: switch to
--tags award contractand read thesuppliersof each award. - Add more signals:
aboveThreshold,lots[].suitability.sme/.vcse,tenderPeriod.endDatefor "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