| 13-scraping-adapters-cross-source-dedup |
05 |
backend |
| cheerio |
| html-scraping |
| adapter-pattern |
| cosinex |
| dtvp |
| encoding |
|
| phase |
plan |
provides |
| 13-scraping-adapters-cross-source-dedup |
01 |
SourceType open union (includes 'cosinex-dtvp'), RawTenderRecord contract |
|
| phase |
plan |
provides |
| 13-scraping-adapters-cross-source-dedup |
02 |
TenderSourceAdapter.portals[], SourceRegistry with DeniedPortalError denylist gate |
|
| phase |
plan |
provides |
| 13-scraping-adapters-cross-source-dedup |
04 |
cheerio dependency (already installed, package-legitimacy-approved), reference adapter pattern (fetch/parse/error-isolation/registration shape) |
|
|
| CosinexAdapter — a SEPARATE single-portal HTML adapter for the cosinex/DTVP Vergabemarktplatz satellite (sourceType='cosinex-dtvp'), structurally distinct from NetServerAdapter |
| Live-captured cosinex-search.html fixture (20 rows, transcoded to UTF-8) for stable, network-free adapter tests |
| CosinexAdapter registered as a TendersModule provider + at DI boot via SourceRegistry.register(); cosinex-dtvp TenderSourcePollConfig seeded (isActive: false) |
|
| phase-14-admin-source-activation-ui |
|
| added |
patterns |
|
|
| Single-portal HTML adapter (contrast with NetServerAdapter's config-driven multi-portal shape): one class, one hardcoded base URL, one listing fetch+parse call — the fetch/parse/per-row-try-catch/registration shape repeats from 13-04, portal fan-out does not apply since cosinex/DTVP is one portal |
| Explicit charset decoding via arrayBuffer() + TextDecoder(charset) instead of Response.text() — required whenever a source serves a non-UTF-8 charset (cosinex/DTVP: ISO-8859-1 with raw Latin-1 bytes, not HTML entities), since Response.text() always UTF-8-decodes per the WHATWG Fetch spec regardless of the declared Content-Type charset |
| <br>-separated table-cell splitting via cell.contents().each() node-type walk (text vs. <br> vs. nested tag) rather than cell.html().split(/<br>/) — avoids re-parsing HTML fragments out of context and correctly text-extracts nested tags (e.g. an <abbr title=...>TNW</abbr> procedure-type abbreviation) |
|
|
| created |
modified |
| apps/api/src/tenders/adapters/cosinex.adapter.ts |
| apps/api/src/tenders/adapters/cosinex.adapter.spec.ts |
| apps/api/src/tenders/__fixtures__/cosinex-search.html |
|
| apps/api/src/tenders/tenders.module.ts |
|
|
| Selectors fully populated, NOT deferred as 'needs-JS' — 13-05-PLAN.md's D-01 best-effort fallback anticipated a JS-dependent listing (Open Question 1), but live inspection (2026-07-23) found the 'Aktuelle Bekanntmachungen' results table (table.csx-new-table) is fully server-rendered HTML with real <tr> rows, real dates, and a real pid=-bearing detail link per row. The plan's contingency (adapter skeleton + [] fallback + 'needs-JS, deferred' documentation) was not needed; this is a strictly better outcome than the plan's worst case. |
| sourceUrl IS a genuine per-notice deep link (unlike NetServerAdapter's search-results-URL fallback, 13-04) — each row's 'Aktion' column links to /Satellite/public/company/projectForwarding.do?pid=<id>, giving both a stable sourceNoticeId (the pid) and a real navigable sourceUrl. This resolves 13-RESEARCH.md's open question about cosinex deep-linkability in a strictly better way than NetServer's portal-only fallback. |
| Rule 1 bugfix: decode the fetch response via arrayBuffer()+TextDecoder('iso-8859-1'), not res.text() — cosinex/DTVP's live HTTP response header is 'Content-Type: text/html;charset=ISO-8859-1' and umlauts are raw Latin-1 bytes (0xE4/0xF6/0xFC/0xDF), not ä-style HTML entities (unlike NetServer). Per the WHATWG Fetch spec, Response.text() unconditionally UTF-8-decodes the body regardless of the declared charset, which would silently mojibake every umlaut buyer name/title. This was caught during fixture capture (grep/file treated the raw curl output as binary — a tell that it wasn't UTF-8) and fixed before any test was written, not discovered via a failing test. |
| On-disk fixture is transcoded to UTF-8 (iconv -f ISO-8859-1 -t UTF-8) rather than stored as raw Latin-1 bytes — keeps the spec's readFileSync(path, 'utf8') convention identical to netserver-search.html. The adapter's real ISO-8859-1 decode path is exercised separately by two dedicated tests that stub fetch's arrayBuffer() with Latin-1-encoded bytes (Buffer.from(html, 'latin1')), including a raw-umlaut-byte round-trip assertion. |
| dayCursor is accepted for interface conformance but not used as a query filter, same rationale as NetServerAdapter (13-04) — the cosinex listing has no documented incremental/date-range parameter; it is a 'most recent first' paginated table (page 1 of ~331 live, 2026-07-23). First-page-only fetch is accepted as sufficient for the MVP proof; pagination is out of scope for this plan. |
| cosinex-dtvp TenderSourcePollConfig seeded with isActive: false — this plan builds, registers, and tests the adapter; it does not force-activate live polling (D-02: activation is a later admin/seed decision, Phase 14 UI), matching the ai-netserver precedent. |
|
|
| id |
description |
requirement |
verification |
human_judgment |
| D1 |
A SEPARATE CosinexAdapter (not a NetServerAdapter reuse) declares sourceType='cosinex-dtvp' and portals=['cosinex-dtvp'], parsing the cosinex/DTVP 'Aktuelle Bekanntmachungen' listing into RawTenderRecord[] with sourcePortal/sourceNoticeId/sourceUrl set correctly per row |
INGEST-03 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/adapters/cosinex.adapter.spec.ts — 'declares sourceType cosinex-dtvp and the single cosinex-dtvp portal', 'parses all rows of the fixture into RawTenderRecord[]...' (16/16 tests pass) |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D2 |
Adapter parses the live-captured cosinex table into RawTenderRecord[] with correct publishedAt/deadlineAt Date-or-null mapping (including the 'nv'/nicht-vorhanden no-deadline case), nested-<abbr> procedure-type extraction, and umlaut-correct buyer names — server-rendered, no JS-dependency fallback needed (D-01 resolved favorably) |
INGEST-03 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/adapters/cosinex.adapter.spec.ts — 'parses a populated publishedAt and an nv deadline to null', 'parses a populated deadline row to a non-null ISO deadlineAt', 'extracts a nested-<abbr> procedure-type abbreviation', 'decodes a real umlaut buyer name correctly (Münster AöR)' |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D3 |
Parse-Totalausfall (empty HTML, no matching table/rows, malformed row without a pid-bearing detail link, listing fetch throwing, listing responding non-2xx) returns [] instead of throwing (D-01 error tolerance) |
INGEST-03 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/adapters/cosinex.adapter.spec.ts — 'returns [] for empty HTML', 'returns [] for HTML with no matching result table', 'skips a row without a pid-bearing detail link', 'returns [] when fetch throws', 'returns [] without throwing when the listing responds non-2xx' |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D4 |
CosinexAdapter correctly decodes the source's real ISO-8859-1 charset (arrayBuffer + TextDecoder, not Response.text()) rather than mojibaking umlaut content — a Rule 1 bugfix discovered before any test was written |
INGEST-03 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/adapters/cosinex.adapter.spec.ts — 'fetches and parses the listing, decoding the real fixture bytes correctly', 'decodes raw Latin-1 umlaut bytes correctly (Rule 1 fix...)' |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D5 |
CosinexAdapter is a TendersModule provider and is registered with SourceRegistry at DI boot alongside DoeOpenDataAdapter/NetServerAdapter; the INGEST-07 denylist gate structurally applies (cosinex-dtvp is not denylisted, so registration succeeds); project-wide typecheck and the full tenders test slice remain green |
INGEST-03 |
| kind |
ref |
status |
| unit |
cd apps/api && npx tsc --noEmit -p tsconfig.json (clean) |
pass |
|
| kind |
ref |
status |
| unit |
cd apps/api && npx vitest run src/tenders (21 files, 221/221 pass, includes source-registry.spec.ts 6/6 unchanged and cosinex.adapter.spec.ts 16/16 new) |
pass |
|
|
false |
|
|
40min |
2026-07-23 |
complete |