Find UK public sector contract notices worth money. Reads the government's official OCDS feed and returns each notice as a lead: contract value, CPV industry code, buyer organisation, deadline, and the buyer's direct contact details.
The Store's publish flow requires an output schema. This one declares where the
run's results live, which is also what an AI agent reads to chain this Actor
into a larger workflow.
Added
.actor/output_schema.json, referenced from actor.json as output. It
names three outputs, all resolved per run against the default dataset:
Contract leads (the lead-shaped view), All fields (the full field
set), and a CSV download. The first two select the views the dataset
schema already defines, so the Output tab opens on the lead view and can be
switched to the full one. The CSV one is the lead view too: the un-viewed
CSV export carries the nested objects, and this was verified against a real
run's dataset, not assumed.
The Apify output-schema definition is vendored as
tests/fixtures/apify-output.schema.json, like the input and dataset ones,
so the tests stay hermetic. It comes from
apify-projects.github.io/actor-json-schemas, not apify-shared-js.
The Store logo is in the repo as branding/store-logo.png — the envelope
with the coral @ seal, on the brand's navy badge, generated locally with
FLUX.3 Klein. branding/ is in .dockerignore: the logo is a Store asset,
not part of the Actor image, and the rejected candidates were removed from
the repo before they could double the weight.
Fixed, by the tests that came with it
A template that names a view the dataset schema does not define, a format
the dataset API does not serve, or a placeholder outside the documented
vocabulary — any of the three ships an empty Output tab or a broken link.
All three are now checked. The views and formats are checked against the
schema files and the documented format set; the placeholder vocabulary is
read from the output-schema documentation, as prose with its source named,
because no machine-readable form of it exists to vendor.
That view works on the dataset items endpoint was verified against a real
run, not assumed: the raw export has the nested objects, the viewed one is
the flat lead shape, and the CSV of the viewed export is the flat columns.
The evidence is recorded as the observed key lists inside
scripts/verify_dataset_view.sh, which repeats the observation against a
fresh dataset — retention is 7 days. The schema's user-facing copy now
states only the behaviour.
The output schema is Store-visible text, so it carries the house em-dash
rule like the input schema does.
[0.1.15] — 2026-10-22
An SEO pass on the site, and the titles in the company's house style.
Fixed
The titles were a lowercase run of words. "UK public sector contract notices
with buyer contact details: UK Contract Leads" read as a sentence, and the
trade pages were worse: "Roofing: Roof repairs, flat roof systems, guttering,
leadwork and roof replacement.: UK Contract Leads", which is 102 characters,
repeats the trade name, and ends in a full stop before a colon.
The company's own pages are titled "Website Design & Build for Small
Businesses | Code & Canvas", so the feed site now uses the same style: Title
Case, the brand after a pipe, the headline leading. A trade page is titled
"Roofing Tenders and Contract Notices | UK Contract Leads", and the longest
trade name falls back to a shorter form rather than being cut off in a
search result.
There was no social card. A shared link showed a blank rectangle. There
is now a card in the brand, rendered with the company's own typeface, and the
og: and twitter: tags point at it.
There was no structured data. Every page now carries a schema.org graph:
the site and its publisher everywhere, a breadcrumb trail on the pages that
have one, the list of trades on the index, the dataset on the about page, and
the Actor on the API page.
There was no favicon, so every page load 404'd on /favicon.ico and the
browser tab showed a default icon. There is now an icon in the brand, as an
SVG for the browser, as PNGs for the touch icon and the manifest, and as a
real /favicon.ico at the root, because some crawlers and older clients ask
for that path rather than reading the link in the head.
robots.txt did not point at the sitemap, and said nothing about the
feeds, which are the part of the site meant to be fetched.
The trade pages did not link to each other. Each one now lists the other
trades, so a crawler can reach the whole set from any page.
Fixed, by the review of this pass
The trade pages linked to pages the build does not produce. A trade with
no notices in the window has no page, and the block linked to all of them, so
most of the links were 404s: the opposite of what the block was for. The
pages now link only to the trades that were built.
The title budget was counted twice, so most trade pages silently lost the
brand. The headline was sized against the limit on its own and the brand
was added afterwards, and dropped when the pair was too long. Only a trade
with a name of about eleven characters kept it. The headline is now sized
with the brand's room left over, and every page carries it.
The 404 page said index, follow, and carried a canonical link to the
home page. It is now noindex, follow with no canonical.
The API page's schema claimed a price of $0.01 while its own copy says
every feed is free. The $0.01 is the Apify event price for the Actor, not
the price of the feeds. The claim is gone.
The dataset's coverage was a single date where a coverage is an
interval, and it was read with a default that hid a missing key.
The feed titles were half in the house style. The per-trade ones and the
one in the head still read as a sentence.
The SEO tests never asked for the build. They read whatever was in
web/dist, which is gitignored and survives across revisions, so they were
either checking a stale build or passing vacuously. Every test now takes the
session's build fixture, and asserts the page and trade sets are not empty.
The tests restated the site's limits instead of importing them, so they
could drift apart silently. The limits and the word list now come from the
site itself.
Fixed, by the review of the fixes
The trades index was still linking to pages the build does not produce.
The trade pages had been given the fix, but the discovery page a crawler
uses for the trade set was not: it listed every trade, while the item list
right next to it named only the built ones, so the page contradicted its own
structured data. Both now come from the same list, and the item list's
positions count from one without the gaps the filter used to leave.
There were two different rules for "this trade page exists". The build
always wrote the catch-all page, and the sitemap listed it only when it had
notices, so on any window where nothing landed in "Other services" the
site carried an empty, orphaned page and the tests failed on the next run.
One rule now decides everywhere — the build, the sitemap, the index cards,
the item list and the "Other trades" links — and a trade with no notices,
the catch-all included, has no page anywhere.
A trade name longer than the headline budget would have been cut
mid-word. The fallback truncated at the character limit; it now cuts at
the last space. Nothing exercises it today, so a unit test covers it with a
made-up longer name.
The structured data was injected unescaped into its script tag. Any
value containing </script> would have terminated the element. The < is
now escaped on the way in, and a test proves the value survives the escape
without breaking out.
The dataset's coverage interval was a zero-day interval after the last
round turned it into start and end: both were the same date. It is now the
real fetch window — the days before the last notice — and the tests
check the interval against the window the page itself names.
The social card's size was the only geometry checked. The icons the
head and the webmanifest both declare sizes for are now checked against
the actual files too.
The busiest-trades chips were naming the slug — "plumbing heating" in
lowercase — where every other page uses the trade's name. They use the
name now.
The order check on the changelog was blind to headings it did not
recognise and lived in the pull-request-only job, so a malformed heading
tripped a misleading error and pushes to main were never checked. It now
reports what it skipped, never crashes on one, and runs in the job that
runs on every push.
Changed
The trade page's description leads with the keyword and the count, and the
blurb is dropped when the pair would be cut off in a search result.
The changelog's order is now checked by CI, so two entries sharing a
date cannot make it ambiguous: the top entry has to be the current version
and the versions have to descend.
[0.1.14] — 2026-10-22
The review of the publishing pass. One of these would have cost a user money.
Fixed
The input schema advertised a stage the Actor silently ignored, which
broadened the filter and mis-billed the user. It listed awardUpdate as a
stage value. The Actor filters on the three families — tender, award,
planning — so awardUpdate was dropped, and a user who asked for it and
nothing else was left with no stage filter at all: the run returned every
stage and charged for all of them.
Two things were wrong, and both are fixed. The schema now documents the
three families it can actually filter on, and the Actor now rejects an
unknown stage with a clear message before the first request, rather than
dropping it. A typo has to fail, not cost money.
The previous schema had an enum on the items, which stopped it. The enum
could not be put back: Apify's schema allows an array property only type,
title, description, editor and isSecret. So the description is the
contract, and the code has to honour it.
The surfaces disagreed about the stages. The input schema listed four
values, both READMEs and the dataset schema listed three. They now all name
the same three families, and a test checks that they agree.
The em-dash rule was not applied to the run output. The platform shows
the Actor's log and status messages in the Console, so they are copy the user
reads, and four of them still had em dashes in them. The copy test now
covers every source file whose strings a user can see.
The daysBack description described a default the schema no longer
declares. The default keyword is not permitted on a number, so the
wording now says what the Actor does when the field is left out, which is
the same seven days.
Changed
The schema tests validate against Apify's own schemas, vendored into
tests/fixtures/. They used to hand-copy the allowed keywords into a
constant, which is a second copy of Apify's specification: it can be wrong
in both directions, and it was written after the fact to match a schema that
had already been edited. The two files in tests/fixtures/ are Apify's own,
fetched from apify-shared-js. Vendoring rather than fetching keeps the
tests hermetic, so a network outage cannot turn a guard into a skip.
The Actor.fail check reads the source with the parser. The regular
expression stopped at the first closing bracket, so a call that grew an
argument could have slipped past it. The calls are now read with ast and
checked against the SDK's own parameter list.
The dataset schema's test checks the required list, which it promised
to and did not. Nothing that can be null may be required, and the two fields
that are always present must be.
[0.1.13] — 2026-10-22
The publishing pass. The pricing, checked against the market, and the
defects that only a real run on Apify's platform could find.
Fixed
The Actor would have double-charged every notice. The Apify SDK charges the
explicit event and the synthetic apify-default-dataset-item event on every
push_data — the SDK's own source says so: "Accounts for both the explicit
event and the synthetic apify-default-dataset-item event". The pricing config
has to remove the synthetic event, and the README now says why.
The notice and the contact were priced as two events, which put half the
real price in the headline the Store shows and the other half in a line
underneath. Apify's guidance is that events map to a visible result and that
the number of events should be kept small. It is now one event,
contract-notice, charged once per notice, with the contact details named in
the event's title and description rather than in a second price line.
The input schema used keywords Apify rejects, so the build failed on their
platform three times over: defaultDescription is not a keyword, examples is
not one either (the specification has a single example), and every property
needs an editor whose value depends on its type. The schema is now checked
against Apify's own JSON schema, vendored into tests/fixtures/ from
apify-shared-js, in tests/test_actor_definition.py. The schemas are
vendored rather than fetched so the tests stay hermetic and a network
outage cannot turn a guard into a skip.
The dataset schema declared draft 2020-12, which their compiler has not
got. It is draft-07.
The dataset schema rejected 269 of every 300 records. It declared the
fields as strings and numbers, and the record model produces null for a
missing phone, website, contract value, deadline or procurement method. The
platform refused the push, the crawler retried the page five times, and the run
reported that it had published one notice. The schema now allows the nulls
the model produces, and tests/test_dataset_schema.py checks the whole
captured sample against it.
The Dockerfile moved the working directory away from the code. The base
image's WorkingDir is /usr/src/app and its command is python -m src, so
the WORKDIR /app that came after the copies pointed the runtime at an empty
directory and the container died with "No module named src" before the
Actor ran at all.
Actor.fail() was called with message= instead of status_message=, so
every failure path raised a TypeError inside the failure handler: a failed run
died with a traceback and an empty status message instead of the explanation
the user was supposed to get.
Changed
The price is $0.010 per notice ($10 per 1,000), tiered down to $0.006 at
Diamond. The five other Actors covering UK tenders charge between $3 and $8 per
1,000, and the closest of them charges $5 per 1,000 for both government
portals. None of them includes the buyer's contact details, which is the whole
difference between a list and a lead. A full month of the entire UK feed is
about $18.
The earlier figure of $0.015 came from a market reading that had the leader at
$8 per 1,000; the actual floor is $3 and the closest two-portal competitor is
$5, so $0.015 was too high to defend on the listing.
[0.1.12] — 2026-10-22
The review of 0.1.11 came back clean, with nine notes. None blocked, all worth
having.
Fixed
The trade copy was never checked.web/trades.py holds the trade names
and blurbs, which is the largest body of prose the site publishes, and it is
now part of the copy scan. It passed as it stands: the only em dashes in that
file are in a docstring and a comment, which the scan ignores by design.
A stale page could fail the tests for the wrong reason. The build
directory is gitignored, so it survives across revisions, and the site build
only ever creates it. A page built from an earlier commit would be left
behind, and the tests that check every page's canonical link would fail on
it. The fixture clears the directory first.
The style parser did not exclude at-rule blocks. It could not match a
block containing braces, so the rules inside each media query were parsed as
ordinary rules. The consequence was quiet: a colour declared for a phone
would take the last word in source order and become the value the contrast
test believed it had measured. The at-rule blocks are dropped now, so the
desktop value is what gets measured.
The secondary button's hover measured the wrong foreground. The table
pointed at the base rule for the colour and the hover rule for the
background. The pairing passed either way, so it was a coverage gap rather
than a defect, and it was the same class of gap that let the primary
button's hover ship.
The focus rule's border-radius: 2px overrode every element's own
radius, so a focused tag pill went from 20px to 2px. The outline already
rounds.
The dead buyer variable disagreed with the cell that renders it. One
said "not published" and the other an empty string. The cell now uses the
variable.
Changed
--coral-deep is gone. Nothing referenced it once the hover moved to
--accent, and a test was pinning the dead declaration.
The user agent names the product the site is on. It still said
ukcontractleads/1.0 while pointing at the new host, and the user agent is
what the government feed logs.
Checked
The fonts are variable. The font-weight: 400 700 in the @font-face
rules claims a range. Inter is a variable font with a wght axis from 100
to 900; Playfair Display has one from 400 to 900. Both claims are correct,
and a heading at 700 is a real bold rather than a synthesised one.
[0.1.11] — 2026-10-21
The second review of the brand work found two real misses and three smaller ones.
Fixed
The primary button's hover state failed WCAG AA. The hover background was
--coral-deep (#d95f3f) with the button's white text on it, which is 3.71:1
for 14px text. It is now --accent (#9c3e25), the same hue a step darker,
which is 6.71:1 with white. The claim that every pairing was "measured, not
eyeballed" was true of the pairs that were measured and not of this one.
The contrast test could not have caught it. The old test listed the
colours in a table and compared them, which is a table of claims about the
CSS rather than a check of it: the table still said the hover was fine while
the stylesheet said otherwise. The test now reads the foreground and the
background out of the stylesheet, resolving the cascade the way a browser
does, so the colour of a button's text and the background of its hover state
are checked as one pairing. It is mutation-tested: putting the failing
colour back makes it fail, with the ratio in the message.
The focus ring failed the 3:1 that WCAG 1.4.11 asks of a focus
indicator. The outline was the brand coral, 2.5:1 against white. It is now
--accent, 6.71:1. The test reads the colour out of the outline
shorthand, because it is not a color declaration.
The font licences now ship with the fonts. The SIL Open Font License
requires the copyright notice and the licence to accompany any redistributed
copy, and the two woff2 files were being redistributed without them.
web/fonts/LICENSE-Inter.txt and web/fonts/LICENSE-PlayfairDisplay.txt are
the upstream licence texts.
The em-dash test was asserting on third-party data. It scanned every
rendered line, which includes notice titles and buyer names published
unchanged from the government feed. It passed only because the captured
sample happens to contain no em dashes, and it would have broken the day a
notice arrived with one in its title. It also claimed to cover the JSON and
the CSV while excluding both extensions. It is now split: one check over the
site's own copy, read from the source with the module's own AST, and checks
over the parts of the output that carry only our copy — the pages with no
notices, the feed header, the feed metadata, the CSV header.
Changed
The style tests are not brittle any more. The heading check split the
stylesheet on a selector string, which raises on ordinary reformatting. The
rules are parsed instead, comments stripped first, because a comment before
a rule is part of what the parser sees as the selector.
The site is built once per test session, not once per test. Eleven
separate builds were running the same subprocess. A session-scoped fixture
does it once.
[0.1.10] — 2026-10-21
The site moves house, and the copy loses its dashes.
Changed
The subdomain is now ukleads.codeandcanvas.eu. The old
leads.codeandcanvas.eu is off. The name follows a convention so the same
shape works for other territories: nlleads, deleads, frleads, each its
own Vercel project and its own subdomain on the same apex. The Vercel project
is renamed to ukleads to match, so the alias reads ukleads.vercel.app.
Fixed
The em dashes are out of the copy. They were in the page titles, the
headings, the footers, the contact-type list and the feed's own description.
They are replaced with colons, commas and full stops, which read better in
the plain-English register the site is written in. The CSS comments ship
inline in every page, so those went too.
tests/test_site_urls.py now checks the whole rendered output, pages,
sitemap, feed and CSV, and fails if an em dash reappears. A notice's own
title and buyer name come from the government feed and are published
unchanged: rewriting them would make the record stop matching the source.
[0.1.9] — 2026-10-21
The site wears the company brand.
Changed
The feed site now carries the Code & Canvas brand, taken from
codeandcanvas.eu: Playfair Display for display type, Inter for body, the navy
and coral palette, the coral brushstroke under the wordmark, the navy footer,
the small-caps coral eyebrow above each heading, and the company's own
rounded controls. The header carries the company wordmark linking to
codeandcanvas.eu with the product name beside it, and the footer repeats it.
The brand's fonts are self-hosted — web/fonts/ holds the same two woff2
files the apex serves, and the build copies them into the output. The site
must not depend on codeandcanvas.eu being up to render its own type.
The site is light-only. The apex has no dark mode, and a site that flips
to a different palette on a dark-mode OS reads as a different brand.
Spacing is on an 8px grid throughout, and the dates in the notices table
align with the value column instead of wrapping mid-value.
Fixed
The brand's coral was carrying text it cannot carry.#fa8166 on white is
2.5:1 — it fails WCAG AA for body text, and the apex uses it for filled
buttons with white text, which fails too. On this site the coral is
decoration only — the underline, borders and washes — and text uses a darker
step of the same hue: #cb4b2a for the eyebrow label (4.59:1), #9c3e25 for
links (6.71:1) and #6c2e1e for hover states (10.26:1). It is the brand's
hue at a compliant step, not a second accent.
Added
tests/test_brand.py holds the brand in place: the wordmark on every page,
the two typefaces in the stylesheet, the font files copied into the build,
and the contrast of every text colour. The last of those was measured, not
eyeballed, and it caught two hover states that would have failed.
[0.1.8] — 2026-10-21
The site is now on its own domain.
Fixed
The site's own absolute URLs pointed at a domain that does not exist.SITE_ORIGIN was https://ukcontractleads.co.uk — a placeholder that was
never registered, so it resolves to nothing. Every canonical URL, the
sitemap, the feed's self link and the examples on the API page carried it.
The pages rendered correctly and nothing failed; the machine-readable half
was simply wrong, which is the worst way for it to be wrong. It is now
https://leads.codeandcanvas.eu, the domain the site is served on.
tests/test_site_urls.py holds the line. It checks the pages' <head>,
the canonical links, the sitemap and the feed's self link against the
configured origin, and one test pins the origin to the domain the site is
served on — the others compare the output against the constant, so they
cannot catch the constant itself being wrong.
Changed
The feed's user agent names the real address.
[0.1.7] — 2026-10-21
The third review pass, and the one that mattered most.
Fixed
A failed run exited 0 — the platform marked it as successful.Actor.exit()
assigns self.exit_code and self.status_messagebefore it tears the run
down, and both default to None. So the finally: await Actor.exit() in the
Actor's own failure path reset the exit code from 1 back to 0, and wiped the
terminal status message on the way past. The exit code and the message are now
owned by run() and passed explicitly. tests/test_run_exit_code.py runs the
real path in a subprocess and checks what the process actually exits with, so
this cannot regress silently.
The status message leaked an internal token. The cause was never copied into
reason_code, so the message fell through to the raw value and the run ended
saying "Published 312 notices. spend-limit". The cause is now carried through,
and status_message() is a separate function with tests over every cause.
The re-ask bypassed the pacing floor. Re-asking for the same page went
straight back onto the request queue, so it was the second request to the same
host inside one tick — the behaviour that earns the Retry-After penalty. Both
the Actor and the site now pause before asking again.
The Store listing still documented includeAwards, an input that does not
exist. Removed.
The site wrote an incomplete flag into the feed metadata and never showed
it. The About page still presented a confirmed-short pull's counts as a
complete window. It now says so.
The pricing and the volumes were inconsistent across the files — "1,799",
"1,899" and "~1,800" for the same month, and the billing claim derived from
one of them. All now use "about 1,800" for the month and $27 for the same
window.
An unqualified "100%" claim survived in one place in the Store listing.
Every mention now says what was measured.
The README carried a test count that changed with every commit. Removed,
with a note saying why.
The changelog described the CI pin check as reading TOML with tomllib. It
reads the dependencies array with a regex, so it runs on whatever interpreter
the runner has; the description and the now-stale comment in the workflow were
both corrected.
[0.1.6] — 2026-10-21
Fixed
The site build imported the Actor SDK.src/__init__.py imported main
eagerly, so import src.client — which the site generator does — pulled in
apify and crawlee. The Vercel build installs nothing by design, so it
died with ModuleNotFoundError: No module named 'apify'. The package now
imports the Actor entry points lazily, and
tests/test_site_build_is_standalone.py holds that line: it imports the site
code in a clean interpreter and asserts the Actor SDK is not loaded.
[0.1.5] — 2026-10-21
The second review pass. It confirmed the 0.1.4 fixes and found the rest.
Fixed
scripts/smoke_test.py passed stages=None to build_search_url, which no
longer takes that argument — the documented smoke test died before crawling. A
regression from the 0.1.4 change to the client signature.
An unparseable publishedFrom or publishedTo was passed straight to the feed,
which answered 400 five times over. The date is now rejected before the first
request, with a message naming the field and showing the expected format.
The status message gave the wrong advice. A run that stopped on the spend limit
was told to narrow the date window. The message is now chosen by cause: spend limit,
page budget, or a failed request.
The site's CSV omitted buyer_contact_type and delivery_postcodes, so the
acceptable-use page told people to filter on a field the CSV did not carry.
The site could cache a short pull and present it as complete. When the feed
confirms the window was cut short, the build now says so in the feed metadata and
does not cache it.
The CI check on the dependency pins was itself wrong — it compared a set built
from the wrong fields, included the dev extra that is deliberately not shipped, and
failed on every run. It now reads the runtime dependencies with a regex and
compares like for like, so it runs on whatever interpreter the runner has.
The pricing was still documented three ways and the Store listing carried a
stale "~$76 per month" figure next to the corrected "$27". The competitor price
claims now agree across all three files. The unverifiable claims in the Store
listing — that the official keyword search is broken, and an unqualified "100% of
notices" — are now stated as what was measured.
Changed
pyproject.toml is at 0.1.5. The Actor's own manifest keeps the two-part 0.1
that Apify requires.
[0.1.4] — 2026-10-21
Fixes from the automated PR review. The reviewer found real bugs; all of them are
regressions this project would have shipped with.
Fixed
Dedupe ran before the filter, so a release that did not match the user's
filters could shadow one that did. The feed publishes several releases per notice
under one id — a planning notice, then a tender, then an award — so with
stages: ["award"] an award release was dropped because a tender release had
already claimed the id, and with minValue a valueless planning release
shadowed the tender carrying the value. The filter now runs first.
The dataset schema had drifted from the record. It still declared the old
scalar deliveryRegion and deliveryCountry, and omitted sme and
deliveryPostcodes, so the "All fields" view showed two permanently empty
columns and hid four real ones.
The trade map had the wrong CPV prefixes — a bug that put notices on the
wrong trade page. 70 is real estate, not consultancy; 77 is agricultural and
forestry, not cleaning; 42 is industrial machinery, not healthcare; 63 is travel
agency services, not information services; facilities management is 79993, not
983. The map is now checked against the UK CPV divisions, and "Other services"
fell from 20% of notices to 5%.
A page that exhausted its retries was skipped silently. The crawler logs the
failure and carries on, so a partial dataset was reported as a complete one. The
run now checks the crawl statistics and says so in the status message.
An empty result was reported as "no notices matched" even when the walk had
failed. Incompleteness is now checked first.
minValue was currency-blind — a €50,000 notice cleared a £100,000 floor.
Notices in another currency are now excluded rather than compared.
initiationType was used as a stage fallback. It is "tender" for almost
every release, so an awarded contract could be published as an open one. The
stage now comes from the release tag only, and an unrecognised tag is no stage
rather than a guess.
A naive date mixed with an aware one, so the window was split across two
timezone interpretations — the same class of bug as the microsecond one. Dates
without an offset are now read as UTC.
daysBack: 0 produced an empty window although the schema says one day.
The console script pointed at a coroutine, so it exited silently.
src/__init__.py declared exports it did not import.
The dependency pins were duplicated in two files that could drift; CI now checks
they agree.
Security
CSV formula injection. The public feeds wrote the feed's own title, buyer
and notice_url into the CSV verbatim, and a cell beginning with =, +, - or
@ executes when the file is opened in Excel — which is the headline use of the
feed. Those cells are now prefixed so a spreadsheet treats them as text.
Changed
Contact addresses carry a buyer_contact_type — role, person or unknown
— so a consumer can prefer role mailboxes. 61% of notices name an individual;
the flag is what makes the choice available.
The site has an acceptable use policy at /terms/, and the About page states
the basis the data is published under and what the consumer is responsible for.
The site build re-asks the feed once when it stops on a full page with no next
link, rather than caching a short pull.
The site build can run offline from a captured sample, so CI does not depend on
a rate-limited third party.
The stages parameter is no longer sent to the feed at all, and build_search_url
no longer offers it.
A .dockerignore was added. The published Actor image was carrying .git,
local .env files, the virtualenv, local storage and the research data.
[0.1.3] — 2026-10-21
Added
A free, no-sign-up feed site: one page and one JSON/CSV/RSS feed per trade, built
from the same parsing code the Actor uses so the two cannot drift apart.
24 trades, filed by the CPV code the buyer published. A division-level code with
specific words in the title is resolved by the words, because they are the better
evidence. 3% of notices land in "Other services".
No third-party dependencies in the build: it reuses src/client.py and
src/normalise.py and fetches the feed with the standard library, so it runs
anywhere Python 3.12 does — including a Cloudflare Pages build with no install step.
A local feed cache, so rebuilding the site does not re-fetch the feed.
web/deploy.sh and wrangler.toml for publishing to Cloudflare Pages.
Notes
Every notice in the feed carries a buyer contact email: 100% over a seven-day
window. 63% resolve to the buyer's own domain by a strict name match, and the most
repeated addresses are named procurement officers rather than generic mailboxes.
Around 4% list an e-tendering platform as the contact, which the About page says.
[0.1.2] — 2026-10-21
Fixed
The 3-second pause between feed requests was a crawl-delay on Crawlee's throttling
manager. A crawl-delay puts the domain into a cooldown, and the manager's
is_empty() then reports the queue empty while is_finished() is still false —
the crawler read that as "nothing left to do" and ended the walk after one page.
It reproduced intermittently: the same seven-day window returned 8 pages on one
run and 1 on the next. The pause is now an explicit sleep in the request handler.
The feed publishes several releases per notice and sometimes repeats a release
outright: 706 records over 8 pages carried only 679 distinct notice ids. The
Actor now returns one row per notice, keeping the first release seen.
The feed's stages parameter returned a different set of notices than an
unfiltered walk of the same window, and was observed returning a truncated first
page with no link to the next one. Stage is now filtered by the Actor itself.
Changed
Priced against the market: $15 per 1,000 notices with contact details, against
the visible Store leader's $8 per 1,000 for both portals without them.
[0.1.1] — 2026-10-21
Fixed
The feed's cursor cannot replay a window that has both microseconds and a UTC
offset — it answers 400 on the second page.
datetime.now(timezone.utc).isoformat()
produces exactly that form, so timestamps are truncated to whole
seconds.
push_data writes nothing and charges nothing when the spend limit is already
reached. The contact add-on was charged unconditionally, which would have billed
users for a record they never received. The run now stops on
event_charge_limit_reached before charging the add-on.
charged_count is zero in two different situations — the spend limit being
reached, and the run not being on pay-per-event pricing at all — and in the second
case the record is written. Using it as the "was it published" signal stopped the
Actor after its first record outside a pay-per-event run. The limit signal is
event_charge_limit_reached.
deliveryAddresses holds lists, not a single place. Collapsing it to a scalar
reported "British Oversea Territories" as the delivery country for a UK-wide tender.
[0.1.0] — 2026-10-21
First version. Not yet published to Apify Store.
Added
Contracts Finder OCDS client with cursor pagination, payload validation and a
silent-failure guard that fails loudly when the feed returns an unexpected shape.
Normalisation of OCDS releases into flat, lead-gen shaped records: contract value,
CPV classification, buyer organisation with published contact details, deadline,
procurement stage and notice type, SME suitability and delivery locations.
Filtering by minimum contract value, CPV code, buyer name, keyword and stage.
Pay-per-event charging: notice-scraped ($0.005/notice) and buyer-contact
($0.01/notice where the buyer published contact details) — $0.015 for a notice with
contact details, against $8/1k for the visible Store leader (which omits contacts).
Per-domain throttling that keeps the Actor inside the feed's rate limit and honours
its Retry-After header.
Terminal status messages so an empty or truncated result is never silent.
Input schema with defaults that produce a useful run with no configuration, and a
dataset schema with Leads and All fields views.
Unit tests over captured OCDS payloads, and scripts/smoke_test.py for running the
pipeline against the live feed.
Notes
The feed rate-limits with HTTP 429 and Retry-After: 120. The Actor paces itself at
one request every 3 seconds and lets Crawlee's throttling manager honour the header.
EU (TED) coverage is planned for v2 but not implemented.