| 13-scraping-adapters-cross-source-dedup |
04 |
backend |
| cheerio |
| html-scraping |
| adapter-pattern |
| netserver |
| package-legitimacy-gate |
|
| phase |
plan |
provides |
| 13-scraping-adapters-cross-source-dedup |
01 |
SourceType open union (includes 'ai-netserver'), 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 |
03 |
pollDueSources fan-out + TenderDedupService, TendersModule onModuleInit registration hook |
|
|
| NetServerAdapter — ONE config-driven adapter serving all 3 AI-AG Vergabe@Net portals (tender24, lhs-vpbw, vergabe.landbw) |
| cheerio (^1.2.0) as the project's HTML-parsing dependency (package-legitimacy-gated, human-approved) |
| Live-captured netserver-search.html fixture for stable, network-free adapter tests |
| NetServerAdapter registered as a TendersModule provider + at DI boot via SourceRegistry.register(); ai-netserver TenderSourcePollConfig seeded (isActive: false) |
|
| 13-05-cosinex-adapter |
| phase-14-admin-source-activation-ui |
|
| added |
patterns |
| cheerio ^1.2.0 (HTML parsing, package-legitimacy-gated: human approved over node-html-parser after gate flagged the latter's most-recent-version-publish-date as a 'too-new' false positive) |
|
| Config-driven multi-portal adapter: one class + a hardcoded portal-to-baseUrl Record, iterated per fetchTenders() call, each portal wrapped in its own try/catch so one blocked/broken portal never prevents the other two from being polled in the same tick |
| Per-row try/catch inside cheerio .each() — a single malformed table row is skipped (logger.warn) rather than aborting the whole portal's parse; cheerio.load() itself is also wrapped, so a totally unparsable page returns [] instead of throwing |
| RawTenderRecord.ocdsPayload used as a generic field bag (title/buyerName/procedureType/legalFramework/deadlineAt) for a non-OCDS source — deliberately NOT reshaping the raw eForms/OCDS-shaped RawTenderRecord interface; the shared TenderNormalizerService does not yet understand this shape (out of scope for this plan, see Known Stubs) |
|
|
| created |
modified |
| apps/api/src/tenders/adapters/netserver.adapter.ts |
| apps/api/src/tenders/adapters/netserver.adapter.spec.ts |
| apps/api/src/tenders/__fixtures__/netserver-search.html |
|
| apps/api/package.json |
| pnpm-lock.yaml |
| apps/api/src/tenders/tenders.module.ts |
|
|
| cheerio chosen over node-html-parser at the blocking-human package-legitimacy checkpoint — the automated gate flagged node-html-parser 'SUS'/'too-new' purely because it measures the latest-*version*-publish date (2026-07-06), not true package age (first published 2017-06-14, 129 versions, v9.0.0); cheerio passed the gate cleanly (OK, 27M weekly downloads, published since 2011) so the human approver picked the unambiguous option |
| sourceUrl falls back to the portal's search-results URL, NOT a per-notice deep link — live inspection of the captured HTML found NO <a href> per result row; navigation to a notice's detail page is client-side-JS-only (data-oid + an external click handler script), with no statically-derivable detail URL anywhere in the search response. This resolves 13-RESEARCH.md Open Question 2 with a documented limitation rather than a guessed/unverified URL pattern (avoiding a fabricated, unverifiable servlet path). |
| sourceNoticeId = the row's data-oid attribute (e.g. '54321-NetTender-19f800ce3b1-...') — stable and unique per notice, confirmed unique across all rows in the live fixture |
| dayCursor parameter is accepted (interface conformance) but not used as a query filter — the NetServer search servlet has no documented incremental/date-range parameter (unlike DÖE's day-batch export); first-page-only fetch is accepted as sufficient for the MVP proof (13-RESEARCH.md Open Question 2 recommendation), pagination is out of scope for this plan |
| NetServer's '24:00' deadline convention (meaning midnight at the END of the stated day) is normalized to 00:00 of the next day, rather than left as an invalid Date |
| TenderSourcePollConfig row for 'ai-netserver' is seeded with isActive: false — this plan builds, registers, and tests the adapter; it does not force-activate live polling of the 3 real portals (D-02: activation is a later admin/seed decision, Phase 14 UI) |
|
|
| id |
description |
requirement |
verification |
human_judgment |
| D1 |
ONE config-driven NetServerAdapter declares portals=['tender24','lhs-vpbw','vergabe.landbw'] and a hardcoded per-portal baseUrl map; fetchTenders() fetches and parses all 3 portals independently, sourcePortal set correctly per parsed row |
INGEST-02 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/adapters/netserver.adapter.spec.ts — 'declares sourceType ai-netserver and the 3 AI-AG NetServer portals', 'fetches all 3 portals and aggregates their parsed records' (14/14 tests pass) |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D2 |
Adapter parses the live-captured NetServer <table> results into RawTenderRecord[] with correct sourceNoticeId (data-oid), umlaut/&-entity-decoded buyer names, and deadline mapped to a Date or null (never throwing on empty deadlines) |
INGEST-02 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/adapters/netserver.adapter.spec.ts — 'decodes an umlaut + &-entity buyer name correctly', 'uses the row data-oid attribute as sourceNoticeId', 'maps a populated deadline row to a Date and an empty-deadline row to null' |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D3 |
Parse-Totalausfall (empty HTML, no matching rows, malformed row, portal fetch throwing, portal responding non-2xx) returns [] instead of throwing, per portal and in aggregate (D-01 error tolerance) |
INGEST-02 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/adapters/netserver.adapter.spec.ts — 'returns [] for empty HTML', 'returns [] for HTML with no matching result rows', 'skips a row missing data-oid without throwing', 'returns [] for a portal whose fetch throws, without aborting the other portals', 'returns [] for every portal when fetch always throws', 'returns [] without throwing when a portal responds non-2xx' |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D4 |
NetServerAdapter is a TendersModule provider and is registered with SourceRegistry at DI boot alongside DoeOpenDataAdapter; the INGEST-07 denylist gate structurally applies (tender24/lhs-vpbw/vergabe.landbw are not denylisted, so registration succeeds); project-wide typecheck and the full tenders test slice remain green |
INGEST-02 |
| 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 (20 files, 205/205 pass, includes source-registry.spec.ts 6/6 unchanged and netserver.adapter.spec.ts 14/14 new) |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D5 |
Package-legitimacy checkpoint (blocking-human) ran BEFORE any install; cheerio was human-approved and installed only after explicit confirmation, never auto-approved |
INGEST-02 |
| kind |
ref |
status |
| manual_gate |
gsd-tools query package-legitimacy check --ecosystem npm node-html-parser cheerio (run pre-install) + npm view node-html-parser time.created / npm view cheerio time.created (cross-checked); human approved 'cheerio' via explicit resume instruction |
pass |
|
|
true |
|
|
55min |
2026-07-23 |
complete |