docs(13): create phase plan — scraping adapters + cross-source dedup

6 plans (INGEST-02/03/07, SCHEMA-03) + Nyquist validation.
Core (registry, fingerprint, dedup, TenderSource, backfill) lands
first and is green independent of live scraping (D-01).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:24:16 +02:00
parent f2e0fc8cef
commit 74c00166e2
8 changed files with 827 additions and 1 deletions
@@ -0,0 +1,124 @@
---
phase: 13-scraping-adapters-cross-source-dedup
plan: 04
type: execute
wave: 3
depends_on: ["13-02", "13-03"]
files_modified:
- apps/api/package.json
- 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/src/tenders/tenders.module.ts
autonomous: false
requirements: [INGEST-02]
user_setup: []
must_haves:
truths:
- "EIN config-getriebener NetServer-Adapter bedient tender24, lhs-vpbw und vergabe.landbw (portals-Array, Base-URL je Portal)."
- "Der Adapter parst die NetServer-<table>-Trefferliste zu RawTenderRecord[] und setzt sourcePortal je Zeile korrekt."
- "Bei Parse-Totalausfall liefert der Adapter [] statt zu werfen (D-01, catch-per-source im Ingestion-Tick faengt den Rest)."
artifacts:
- apps/api/src/tenders/adapters/netserver.adapter.ts
- apps/api/src/tenders/__fixtures__/netserver-search.html
key_links:
- "SourceRegistry.register(netServerAdapter) bei Boot; pollDueSources fan-out ruft fetchTenders je aktiver Config."
---
<objective>
Baut den NetServer-Adapter (INGEST-02 / Erfolgskriterium 1): EIN config-getriebener HTML-Adapter fuer die drei AI-AG-Vergabe@Net-Portale (tender24, lhs-vpbw, vergabe.landbw), der die oeffentliche `PublicationSearchControllerServlet`-Trefferliste (`<table>`, live verifiziert HTTP 200/161 KB, keine Auth) parst. Selektoren best-effort gefuellt (D-01); die Adapter-Grenze kapselt Fragilitaet — Test laeuft gegen eine live-gecapturte HTML-Fixture, unabhaengig von Live-Erreichbarkeit. Zuerst ein blockierender Package-Legitimacy-Checkpoint vor dem HTML-Parser-Install.
Purpose: Zweite reale Quelle neben DÖE — erst dadurch wird der Dedup-Kern (13-03) live wirksam (activePortalCount >= 2).
Output: node-html-parser (gated), netserver.adapter.ts (+ spec + fixture), Modul-Registrierung.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/13-scraping-adapters-cross-source-dedup/13-CONTEXT.md
@.planning/phases/13-scraping-adapters-cross-source-dedup/13-RESEARCH.md
@apps/api/src/tenders/adapters/doe-opendata.adapter.ts
@apps/api/src/tenders/adapters/tender-source-adapter.interface.ts
@apps/api/src/tenders/source-registry.ts
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 1: Checkpoint — Package-Legitimacy-Gate HTML-Parser (vor Install)</name>
<action>Blockierender Package-Legitimacy-Checkpoint VOR jedem Install (13-RESEARCH.md § Package Legitimacy Audit). node-html-parser ist [ASSUMED] aus Trainingswissen, nicht autoritativ — Registry/Alter/Downloads/Repo pruefen, Wahl (node-html-parser oder cheerio) bestaetigen, erst dann installieren. Nie auto-approve.</action>
<what-built>Package-Legitimacy-Gate fuer den HTML-Parser VOR jedem Install (13-RESEARCH.md § Package Legitimacy Audit; node-html-parser ist [ASSUMED] aus Trainingswissen, nicht autoritativ).</what-built>
<how-to-verify>
1. Fuehre `gsd-tools query package-legitimacy check --ecosystem npm node-html-parser cheerio` aus (bzw. `npm view node-html-parser` + `npm view cheerio`): Alter, Wochendownloads, Repo pruefen (github.com/taoqf/node-html-parser).
2. Entscheide: `node-html-parser` (leichtgewichtig, CSS-Selektoren, empfohlen) ODER `cheerio` (vollere jQuery-API, weit verbreitet). Beide erfuellen den Zweck.
3. Bestaetige die Wahl; erst dann installiert der Executor `pnpm --filter @tessera/api add <gewaehltes-package>`.
</how-to-verify>
<resume-signal>Tippe "node-html-parser", "cheerio", oder beschreibe Bedenken.</resume-signal>
</task>
<task type="auto">
<name>Task 2: NetServer-Adapter (config-getrieben, 3 Portale) + HTML-Fixture + Spec</name>
<files>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</files>
<action>
Installiere zuerst das im Checkpoint bestaetigte Package. Implementiere `netserver.adapter.ts` (`@Injectable`, implements TenderSourceAdapter, 13-RESEARCH.md Pattern 1): `sourceType = 'ai-netserver'`, `portals = ['tender24','lhs-vpbw','vergabe.landbw'] as const`, plus interne `NETSERVER_PORTALS`-Config-Map mit exakter Base-URL je Portal (tender24: `https://www.tender24.de`; lhs-vpbw: `https://lhs-vpbw.vmstart.de`; vergabe.landbw: `https://vergabe.landbw.de` — Base-URL MUSS direkt auf `/NetServer` zeigen, meta-refresh-Stub vermeiden, Pitfall 4). `fetchTenders(dayCursor)` iteriert die Portale, ruft je `${baseUrl}/NetServer/PublicationSearchControllerServlet?function=SearchPublications` via native `fetch` + `AbortController` 15s-Timeout (DoeOpenData-Muster, kein axios), `redirect:'follow'`, parst die `<table>`-Trefferzeilen mit dem HTML-Parser zu `RawTenderRecord[]`, setzt `sourcePortal` je Zeile korrekt, extrahiert Zeilen-Anchor-href als `sourceUrl` und eine stabile ID daraus als `sourceNoticeId` (Open Question 2). Fehlertoleranz (Pitfall 2 / D-01): try/catch pro Zeile (`logger.warn`, Zeile ueberspringen), `[]` bei Totalausfall statt Throw; Pagination best-effort (erste Seite genuegt fuer MVP-Nachweis).
Speichere eine live-gecapturte NetServer-Trefferliste als `__fixtures__/netserver-search.html` (stabile Testgrundlage). Schreibe `netserver.adapter.spec.ts`: Adapter parst die Fixture -> N RawTenderRecord[] mit korrektem sourcePortal/sourceUrl/sourceNoticeId; leeres/kaputtes HTML -> []; Fetch wird gemockt (kein Live-Netz im Test). SSRF-Schutz (V5): Base-URLs sind hardcodierte Konstanten, NIE aus Input interpoliert (T-10-06).
</action>
<verify>
<automated>pnpm --filter @tessera/api test -- netserver.adapter</automated>
</verify>
<done>Adapter parst die Fixture zu RawTenderRecord[] mit korrektem sourcePortal je Zeile; []-Fallback bei kaputtem HTML; Spec gruen. Falls ein Portal live nicht parsebar: als "needs-JS/blocked, deferred" im SUMMARY dokumentieren (D-01), Adapter-Skelett + Test bleiben gruen.</done>
</task>
<task type="auto">
<name>Task 3: NetServer-Adapter im Modul registrieren</name>
<files>apps/api/src/tenders/tenders.module.ts</files>
<action>
Ergaenze `NetServerAdapter` als Provider in `tenders.module.ts` und haenge ihn additiv in die in 13-03 gebaute Boot-Registrierungsstelle (`SourceRegistry.register(netServerAdapter)`). Portale tender24/lhs-vpbw/vergabe.landbw sind NICHT auf der Denylist -> Registrierung erlaubt. Lege optional eine `TenderSourcePollConfig`-Row fuer `ai-netserver` (isActive default false, kein Zwang zur Aktivierung in P13 — Aktivierung ist Admin/Seed). DI-Graph muss aufloesbar bleiben; dieser Plan ist der einzige Wave-3-Schreiber von tenders.module.ts (13-05 folgt serialisiert in Wave 4).
</action>
<verify>
<automated>cd apps/api && npx tsc --noEmit -p tsconfig.json && pnpm --filter @tessera/api test -- src/tenders</automated>
</verify>
<done>NetServerAdapter ist Provider + bei Boot registriert; DI aufloesbar; tenders-Suite gruen.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| NetServer-Portal (Internet) -> Adapter | Extern gefetchtes HTML ist untrusted (V5). |
| Package-Install -> Build | Neue Dependency node-html-parser/cheerio (Supply-Chain). |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-13-04-SC | Tampering | node-html-parser/cheerio Install | high | mitigate | Blocking-human Package-Legitimacy-Checkpoint VOR Install (nie auto-approve); [ASSUMED] bis Gate-Lauf. |
| T-13-04-01 | Tampering (SSRF) | Portal-Base-URL | high | mitigate | Hardcodierte Konstanten je Portal, NIE aus Input interpoliert (T-10-06). |
| T-13-04-02 | Denial of Service | malformed HTML / haengender Fetch | high | mitigate | AbortController-15s-Timeout, try/catch pro Zeile + []-Fallback, catch-per-source im Tick (D-01). |
| T-13-04-03 | Info Disclosure | Roh-HTML an Frontend | medium | mitigate | Parser-Ausgabe als Text coercen (Titel/Buyer), kein Roh-HTML weiterreichen (V5). |
</threat_model>
<verification>
- Checkpoint bestaetigt (Package-Wahl), erst dann Install.
- `pnpm --filter @tessera/api test -- netserver.adapter` gruen (gegen Fixture).
- `npx tsc --noEmit` + `pnpm --filter @tessera/api test -- src/tenders` gruen.
- Manuell (13-VALIDATION.md): optionaler Live-Poll gegen tender24 liefert >0 Records ODER dokumentierter "needs-JS/blocked"-Vermerk.
</verification>
<success_criteria>
EIN config-getriebener Adapter bedient die 3 NetServer-Portale, parst die Trefferliste, ist fehlertolerant. Erfuellt INGEST-02 / Erfolgskriterium 1.
</success_criteria>
<output>
Create `.planning/phases/13-scraping-adapters-cross-source-dedup/13-04-SUMMARY.md` when done
</output>