# Changelog of UK Public Contracts & Tenders Finder (`likeable_seal/uk-public-contracts-finder`) Actor

- **URL**: https://apify.com/likeable_seal/uk-public-contracts-finder/changelog.md
- **Full Actor documentation**: https://apify.com/likeable_seal/uk-public-contracts-finder.md

## Changelog

All notable changes to this project are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### \[0.1.16] — 2026-10-22

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_message` *before* 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.
