Files
tessera-ctl/.planning/phases/13-scraping-adapters-cross-source-dedup/13-RESEARCH.md
T
2026-07-23 08:14:25 +02:00

41 KiB

Phase 13: Scraping Adapters & Cross-Source Deduplication - Research

Researched: 2026-07-23 Domain: Multi-Source-Ingestion (HTML-Scraping-Adapter), Cross-Source-Deduplizierung (Fuzzy-Fingerprint), Prisma-Schema-Migration Confidence: HIGH (Codebase + Live-DB verifiziert; Portal-Erreichbarkeit live geprüft)

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Robuster Kern zuerst, Scraping best-effort. Adapter-Framework + Fuzzy-Dedup + harte Denylist voll gebaut + getestet. Scraping-Selektoren nur so weit füllen, wie das Portal es zulässt; blockende/instabile Portale dokumentieren, nicht erzwingen. Kein fragiles All-or-Nothing.
  • D-02: Portal-Machbarkeit (NetServer lhs-vpbw/tender24/vergabe.landbw, cosinex/DTVP) ist Research-Punkt — live prüfen (HTML vs. Export/Feed, Rate-Limits, robots/AGB). Ergebnis steuert Selektor-Tiefe.
  • D-03: Ein Eintrag + Liste ALLER Quell-Links. Deduplizierte Ausschreibung = EIN Tender mit allen Quell-Portalen/Links/NoticeIds via neuer Kind-Tabelle TenderSource (1:n). NICHT "Primärquelle gewinnt, Rest verworfen".
  • D-04: Dedup-Schlüssel-Hierarchie: OCID → Quelle:NoticeId → Fuzzy-Fingerprint (Auftraggeber + Titel + CPV + Frist + Wert). Match → zusätzliche TenderSource anhängen statt neuen Tender.
  • D-05: Dedup greift erst ab der 2. aktiven Quelle. Mit nur DÖE aktiv läuft keine Fuzzy-Logik (Erfolgskriterium 3). Backfill: jeder Bestands-Tender bekommt eine TenderSource-Zeile.
  • D-06: vergabe24 + aumass = harte Denylist im Adapter-/Quellen-Registry. Registrierungs-Versuch wird im Code abgelehnt (Exception), nicht nur per Doku. Test beweist Ablehnung. Grund: AGB-Verbot automatisierter Zugriffe; Oberschwellen-Daten kommen eh via DÖE.

Claude's Discretion

  • Fingerprint-Algorithmus: Normalisierung (lowercase, Umlaute, Whitespace, CPV-Divisions-Präfix, Wert-Bucketing, Frist-Datum) + Hash oder Ähnlichkeits-Schwellwert; Felder-Gewichtung. Muss NULL-tolerant sein.
  • Adapter-Framework: Verallgemeinerung TenderSourceAdapter + Registry mit Denylist-Gate; pollDueSources/TenderSourcePollConfig auf mehrere sourceType (poll-once-fan-out-many bleibt).
  • Scraping-Client pro Portal (native fetch, evtl. HTML-Parser), Pagination/Rate-Limit, Fehlertoleranz.
  • Datenmodell TenderSource: Felder, Unique-Constraints, Beziehung zu Tender, Migration von dedupKey/sourcePortal.

Deferred Ideas (OUT OF SCOPE)

  • RSS/E-Mail-Quellen (Phase 14), Admin-Quellen-UI + ausgeschlossene-Portale-Anzeige UI-06 (Phase 14), i18n-Rollout (Phase 14). Keine neuen Benachrichtigungs-/Filter-Features. </user_constraints>

<phase_requirements>

Phase Requirements

ID Beschreibung Research Support
INGEST-02 NetServer-Adapter (lhs-vpbw, tender24, vergabe.landbw) via EINEN config-getriebenen Adapter Live: alle drei sind AI-AG-NetServer; PublicationSearchControllerServlet liefert öffentliche HTML-Trefferliste ohne Auth → EIN config-getriebener Adapter (Base-URL pro Portal). Siehe Portal-Feasibility + Pattern "Config-driven multi-portal adapter".
INGEST-03 cosinex/DTVP-Adapter Live: cosinex VMP Satellite/company/welcome.do öffentlich erreichbar (40 KB HTML), kein öffentliches API/Feed → separater HTML-Adapter (cosinex ≠ NetServer HTML).
INGEST-07 vergabe24/aumass harte Denylist im Code Registry mit assertNotDenied()-Gate wirft bei Registrierung; Test beweist Refusal. AGB-Verbot dokumentiert in feasibility.md.
SCHEMA-03 Dedup-Hierarchie OCID → source:noticeId → Fuzzy-Fingerprint; Dedup ab 2. Quelle; TenderSource 1:n TenderSource-Modell + Fingerprint-Algorithmus + Ingestion-Upsert-Hook + Backfill. Alle Details unten.
</phase_requirements>

Summary

Phase 13 verallgemeinert das in Phase 10 gebaute Single-Source-DÖE-Ingestion-Fundament zu einem Multi-Source-Framework mit Cross-Source-Deduplizierung. Drei tragende Säulen (alle voll getestet, unabhängig von Live-Scrapebarkeit — D-01): (1) ein Adapter-Registry mit Denylist-Gate, über das pollDueSources über mehrere sourceType fächert (poll-once-fan-out-many, KEIN findFirst), (2) ein Fuzzy-Fingerprint-Dedup im Upsert-Pfad, das ab der 2. Quelle greift, und (3) eine neue Kind-Tabelle TenderSource (1:n zu Tender), die pro Ausschreibung die Liste aller Quell-Portale + Links führt.

Live-Befunde stützen die Machbarkeit: Die drei NetServer-Portale (tender24, lhs-vpbw, vergabe.landbw) laufen alle auf AI-AG-Vergabe@Net; tender24.de/NetServer/PublicationSearchControllerServlet?function=SearchPublications liefert öffentlich (HTTP 200, 161 KB) eine <table>-Trefferliste ohne Auth-Wall → HTML-scrapebar jetzt über EINEN config-getriebenen Adapter. cosinex/DTVP (www.dtvp.de/Satellite/company/welcome.do, HTTP 200, 40 KB) ist ebenfalls öffentlich, braucht aber einen eigenen Adapter (cosinex-HTML ≠ NetServer). Kein Portal bietet ein maschinenlesbares Export-Feed (OCDS/RSS/CSV) — reines HTML-Parsing. Die Live-DB zählt 2851 DÖE-Tender (0% NULL-OCID, 92.4% NULL-Wert, 15.7% NULL-Frist, 100% sourceUrl) — das ist die Backfill-Menge und bestätigt: Fingerprint MUSS NULL-tolerant sein.

Primary recommendation: Registry-getriebenes Fan-out-Framework + TenderSource-1:n-Modell + normalisierter kanonischer Fingerprint-String (nicht Ähnlichkeits-Threshold), gehängt an einen dreistufigen Match im Upsert-Pfad (OCID → source:noticeId → Fingerprint), aktiv nur ab ≥2 aktiven Quellen. HTML-Parser: node-html-parser (leichtgewichtig) für NetServer/cosinex-Selektoren.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Portal-Fetch (HTTP + HTML-Parse) API / Backend (Adapter) — native fetch in Adapter, wie DoeOpenDataAdapter; kein Frontend-Zugriff auf Portale
Source-Registry + Denylist-Gate API / Backend — Sicherheits-/Compliance-Grenze gehört in DI-Layer, testbar
Fan-out-Scheduling API / Backend (Scheduler+Ingestion) — ein globaler Cron, platform-global (D-03 Phase 10)
Dedup / Fingerprint API / Backend (Ingestion-Upsert) Database (unique constraints) Match-Logik im Service, Integrität via DB-Constraints
TenderSource-Persistenz Database / Storage — 1:n-Relation, Backfill-Migration
Multi-Source-Link-Anzeige Frontend (TenderDetail) API (Read-Endpoint include) Detailansicht listet Quellen; Endpoint liefert sie via Prisma include

Standard Stack

Core

Library Version Purpose Why Standard
native fetch + AbortController Node 20+ built-in HTTP-Client pro Portal Projekt-Konvention (kein axios); exakt DoeOpenDataAdapter/icon-discovery-Idiom [VERIFIED: codebase]
Prisma 7.8.x Schema + Migration TenderSource Bestehender ORM; handgeschriebene Migrationen lokal via docker compose exec [VERIFIED: codebase]
Node crypto (createHash) built-in Fingerprint-Hash (sha256) Bereits für contentHash genutzt (tender-normalizer) [VERIFIED: codebase]

Supporting

Library Version Purpose When to Use
node-html-parser ^7.x HTML-Trefferlisten der NetServer/cosinex-Portale parsen Wenn Selektoren gefüllt werden. Leichtgewichtig, CSS-Selektor-API, keine Browser-Engine. [ASSUMED] — vor Install verifizieren
fast-xml-parser bereits vorhanden Falls ein Portal per-Notice-XML liefert (aumass-Muster, hier N/A) Bereits Dependency (DÖE); NetServer/cosinex liefern aber HTML, nicht XML [VERIFIED: codebase]

Alternatives Considered

Instead of Could Use Tradeoff
node-html-parser cheerio cheerio hat vollere jQuery-API, aber schwerer + mehr Deps. Für einfache <table>-Extraktion reicht node-html-parser. Beide erfüllen den Zweck.
Normalisierter Fingerprint-String (Hash) Ähnlichkeits-Threshold (Levenshtein/Trigram) Threshold braucht O(n)-Scan pro Ingest gegen Bestand (2851+ Rows) + Tuning; Hash ist O(1)-Lookup und deterministisch/testbar. Empfehlung: Hash. Siehe Pattern 3.
HTML-Scraping Portal-natives E-Mail-Alert-Ingest Phase 14 (deferred). Diese Phase: HTML best-effort.

Installation:

# Nur falls Selektoren gefüllt werden (D-01 best-effort):
pnpm --filter @tessera/api add node-html-parser

Version verification: node-html-parser vor Verwendung mit npm view node-html-parser version prüfen und Legitimitäts-Gate durchlaufen (siehe Audit).

Package Legitimacy Audit

Package Registry Age Downloads Source Repo Verdict Disposition
node-html-parser npm reif (>7 Jahre) mehrere Mio/Woche github.com/taoqf/node-html-parser [ASSUMED] bis Gate-Lauf Planner: checkpoint:human-verify vor Install ODER cheerio (bereits weit verbreitet)

Packages removed due to [SLOP] verdict: keine Packages flagged as suspicious [SUS]: keine Der Planner muss den node-html-parser-Install hinter checkpoint:human-verify (Package-Legitimacy-Gate: gsd-tools query package-legitimacy check --ecosystem npm node-html-parser cheerio) setzen, da der Name aus Trainingswissen stammt, nicht aus autoritativer Quelle. Alternativ cheerio wählen. Der Kern (Registry, Dedup, Denylist, TenderSource) braucht KEIN Scraping-Package und ist unabhängig davon voll testbar (D-01).

Architecture Patterns

System Architecture Diagram

                       TenderSchedulerService (1 globaler Cron-Tick)
                                     │
                                     ▼
                    TenderIngestionService.pollDueSources()
                                     │  liest ALLE aktiven TenderSourcePollConfig
                                     │  (fan-out über sourceType, kein findFirst)
                     ┌───────────────┼───────────────┐
                     ▼               ▼               ▼
             SourceRegistry.get(sourceType) → adapter
                     │               │               │
             DoeOpenDataAdapter  NetServerAdapter  CosinexAdapter
             (Batch-XML/OCDS)   (HTML-Table,        (HTML,
                     │           config: portalId)   Satellite)
                     ▼               ▼               ▼
                RawTenderRecord[]  (pro Adapter)
                     │
                     ▼
             TenderNormalizerService.normalize()  → NormalizedTenderFields
                     │
                     ▼
             ┌──────────────────────────────────────────┐
             │  Dedup-Resolver (nur wenn ≥2 aktive       │
             │  Quellen — sonst inert, D-05):            │
             │   1. OCID-Match?          ─┐              │
             │   2. source:noticeId?      ├─► Tender X   │
             │   3. Fingerprint-Match?   ─┘   gefunden   │
             │        │ ja                     │ nein    │
             │        ▼                        ▼         │
             │  TenderSource.create      Tender.create   │
             │  (an Tender X hängen)     + TenderSource   │
             └──────────────────────────────────────────┘
                     │
                     ▼
             Tender (1) ──1:n──► TenderSource (n)   [DB]
                     │
                     ▼
             GET /modules/tender-radar/:id  (include: { sources })
                     │
                     ▼
             TenderDetail.tsx → Liste aller Quell-Links

Denylist-Gate: SourceRegistry.register(adapter) wirft, wenn adapter.sourcePortal ∈ {vergabe24, aumass} — greift bereits bei DI-Boot, bevor irgendein Poll läuft.

apps/api/src/tenders/
├── adapters/
│   ├── tender-source-adapter.interface.ts   # erweitern (siehe Pattern 1)
│   ├── doe-opendata.adapter.ts              # unverändert
│   ├── netserver.adapter.ts                 # NEU: config-driven, 3 Portale
│   ├── cosinex.adapter.ts                   # NEU: DTVP
│   └── *.spec.ts
├── source-registry.ts                        # NEU: Registry + Denylist-Gate
├── source-registry.spec.ts                   # NEU: beweist Denylist-Refusal
├── tender-fingerprint.ts                     # NEU: pure Fingerprint-Fn
├── tender-fingerprint.spec.ts                # NEU: NULL-Toleranz, Kollisionen
├── tender-dedup.service.ts                   # NEU: 3-stufiger Resolver
├── tender-ingestion.service.ts               # erweitern: fan-out + dedup-hook
└── tender-normalizer.service.ts              # ggf. Fingerprint-Felder ergänzen
apps/api/prisma/
├── schema.prisma                             # + model TenderSource
└── migrations/<ts>_tender_source/            # Schema + Backfill (2 Steps)

Pattern 1: Adapter-Interface verallgemeinern (INGEST-02/03)

What: SourceType wird von einem String-Literal zu einer offenen Union; Adapter deklariert zusätzlich sourcePortal(s). Config-driven NetServer-Adapter serviert 3 Portale. When to use: Basis für alle neuen Adapter.

// tender.types.ts — SourceType erweitern (aktuell: nur 'doe-opendata')
export type SourceType = 'doe-opendata' | 'ai-netserver' | 'cosinex-dtvp';

// tender-source-adapter.interface.ts — Adapter kennt seine bedienten Portale
export interface TenderSourceAdapter {
  readonly sourceType: SourceType;
  /** Ein Adapter kann mehrere Portale bedienen (NetServer: 3). */
  readonly portals: readonly string[]; // z.B. ['tender24','lhs-vpbw','vergabe.landbw']
  fetchTenders(dayCursor: string): Promise<RawTenderRecord[]>;
}

// netserver.adapter.ts — EIN Adapter, portal-Config injiziert
const NETSERVER_PORTALS = {
  'tender24':        { baseUrl: 'https://www.tender24.de' },
  'lhs-vpbw':        { baseUrl: 'https://lhs-vpbw.vmstart.de' },
  'vergabe.landbw':  { baseUrl: 'https://vergabe.landbw.de' },
} as const;
// fetchTenders() iteriert die Portale, ruft je
//   `${baseUrl}/NetServer/PublicationSearchControllerServlet?function=SearchPublications`
// (live verifiziert: tender24 → HTTP 200, 161 KB <table>), parst die Trefferzeilen,
// setzt sourcePortal je Zeile korrekt.

Source: Live-Probe tender24 NetServer [VERIFIED: live curl 2026-07-23]; interface aus [VERIFIED: codebase].

Pattern 2: Source-Registry mit Denylist-Gate (INGEST-07)

What: Zentrale Registry, die Adapter nach sourceType bereitstellt und bei Denylist-Portalen die Registrierung ablehnt.

// source-registry.ts
export const DENYLISTED_PORTALS = ['vergabe24', 'aumass'] as const;

export class DeniedPortalError extends Error {
  constructor(portal: string) {
    super(`Portal '${portal}' ist AGB-seitig für automatisierten Zugriff gesperrt und darf nicht registriert werden.`);
    this.name = 'DeniedPortalError';
  }
}

@Injectable()
export class SourceRegistry {
  private readonly adapters = new Map<SourceType, TenderSourceAdapter>();

  register(adapter: TenderSourceAdapter): void {
    for (const portal of adapter.portals) {
      if ((DENYLISTED_PORTALS as readonly string[]).includes(portal)) {
        throw new DeniedPortalError(portal); // D-06: harte Ablehnung im Code
      }
    }
    this.adapters.set(adapter.sourceType, adapter);
  }

  get(type: SourceType): TenderSourceAdapter | undefined {
    return this.adapters.get(type);
  }
  activeAdapters(): TenderSourceAdapter[] { return [...this.adapters.values()]; }
}

Test (beweist D-06 / Erfolgskriterium 4): Ein Fake-Adapter mit portals: ['vergabe24'] → expect(() => registry.register(fake)).toThrow(DeniedPortalError). Source: D-06 [CITED: 13-CONTEXT.md]; AGB-Verbot [CITED: ausschreibungs-portale-feasibility.md].

Pattern 3: Fan-out in pollDueSources (poll-once-fan-out-many bleiben)

What: Statt findUnique({ sourceType: 'doe-opendata' }) alle aktiven Configs laden und je Adapter pollen. Der bestehende Day-Cursor-Gate + Delta-Matching bleibt PRO Quelle erhalten.

async pollDueSources(): Promise<void> {
  const configs = await this.prisma.tenderSourcePollConfig.findMany({
    where: { isActive: true },              // fan-out, NICHT findFirst/findUnique
  });
  const activePortalCount = configs.length; // D-05 Dedup-Gate-Signal
  const newTenderIds: string[] = [];
  for (const config of configs) {
    const adapter = this.registry.get(config.sourceType as SourceType);
    if (!adapter) continue;                 // Adapter fehlt → skip, kein Crash
    try {
      // ... bestehender Day-Cursor-Gate + fetch + normalize PRO Quelle ...
      // Upsert läuft jetzt über den Dedup-Resolver (Pattern 5), der
      // activePortalCount kennt (D-05).
    } catch (err) { this.logger.error(...); } // catch-per-source: eine kaputte
    // Quelle killt die anderen nicht (D-01 Fehlertoleranz)
  }
  if (newTenderIds.length) await this.matching.matchDelta(newTenderIds);
}

Wichtig: catch-per-source ist neu und zentral für D-01 („robuster Kern, ein blockendes Portal darf den Tick nicht killen"). Source: bestehendes pollDueSources [VERIFIED: codebase].

Pattern 4: Fingerprint (SCHEMA-03, NULL-tolerant)

What: Pure Funktion → deterministischer normalisierter kanonischer String → sha256. KEINE null-Felder in den Hash mischen, die instabil sind; NULL wird zu leerem Segment, sodass zwei Records mit denselben vorhandenen Feldern matchen.

// tender-fingerprint.ts
import { createHash } from 'crypto';

function normText(s: string | null): string {
  if (!s) return '';
  return s.toLowerCase()
    .replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
    .replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' ');
}
function cpvDivisionKey(cpvDivisions: string[]): string {
  return [...new Set(cpvDivisions)].sort().join(',');  // 2-stellige Division, stabil
}
function valueBucket(v: number | null): string {
  if (v === null) return '';                 // NULL-tolerant (92.4% NULL live!)
  // grobe Buckets gegen Rundungs-/Formatdifferenzen zwischen Quellen
  return String(Math.floor(Math.log10(Math.max(v, 1))));   // Größenordnung
}
function deadlineKey(d: Date | null): string {
  return d ? d.toISOString().slice(0, 10) : ''; // nur Datum, Tageskorn
}

export function tenderFingerprint(f: {
  buyerName: string | null; title: string; cpvDivisions: string[];
  deadlineAt: Date | null; estimatedValue: number | null;
}): string {
  const canonical = [
    normText(f.buyerName),
    normText(f.title),
    cpvDivisionKey(f.cpvDivisions),
    deadlineKey(f.deadlineAt),
    valueBucket(f.estimatedValue),
  ].join('|');
  return createHash('sha256').update(canonical).digest('hex');
}

Design-Begründung (Discretion-Bereich):

  • Titel + Auftraggeber tragen das Hauptgewicht (fast immer vorhanden). CPV-Division statt voller CPV-Code: robuster gegen Quell-Formatdifferenzen (Normalizer liefert cpvDivisions bereits, Pitfall-2-Muster). Wert nur als Größenordnungs-Bucket (Quellen runden unterschiedlich, 92.4% eh NULL). Frist auf Tageskorn (Zeitzonen/Uhrzeit-Differenzen).
  • NULL-Toleranz: NULL → leeres Segment. Zwei Records mit gleichem Titel/Auftraggeber/CPV matchen auch wenn beide Wert=NULL. Risiko: zu grob → Kollision unähnlicher Ausschreibungen desselben Auftraggebers. Gegenmaßnahme: Titel ist im Hash → verschiedene Titel = verschiedener Fingerprint. Wenn Kollisionen auftreten, Feld-Granularität erhöhen (voller CPV statt Division), NICHT Threshold-Matching einführen.
  • Warum Hash statt Ähnlichkeits-Threshold: deterministisch, O(1)-Lookup via @unique-Spalte, unit-testbar. Threshold bräuchte O(n)-Scan gegen 2851+ Rows pro Ingest + Tuning. Source: NULL-Statistik [VERIFIED: live DB 2026-07-23 — 92.4% NULL value, 15.7% NULL deadline]; cpvDivisions-Muster [VERIFIED: codebase normalizer].

Pattern 5: Dreistufiger Dedup-Resolver im Upsert-Pfad (D-04/D-05)

What: Ersetzt den direkten tender.upsert für Nicht-DÖE-Quellen. Reihenfolge: OCID → source:noticeId → Fingerprint. Nur aktiv wenn ≥2 Quellen.

// tender-dedup.service.ts (Pseudo)
async resolve(n: NormalizedTenderFields, opts: { dedupActive: boolean }): Promise<{ tenderId: string; created: boolean }> {
  // 1) OCID (falls vorhanden)
  let match = n.ocid ? await this.prisma.tender.findFirst({ where: { ocid: n.ocid } }) : null;
  // 2) source:noticeId — exakt gleiche Quelle re-seen (idempotenter Re-Poll)
  if (!match) match = await this.prisma.tenderSource.findUnique({
    where: { sourcePortal_sourceNoticeId: { sourcePortal: n.sourcePortal, sourceNoticeId: n.sourceNoticeId } },
  }).then(s => s ? this.prisma.tender.findUnique({ where: { id: s.tenderId } }) : null);
  // 3) Fingerprint — NUR ab 2. Quelle (D-05); mit nur DÖE ist dedupActive=false → übersprungen
  if (!match && opts.dedupActive) {
    match = await this.prisma.tender.findFirst({ where: { fingerprint: tenderFingerprint(n) } });
  }
  if (match) {                              // → als zusätzliche Quelle anhängen (D-03)
    await this.prisma.tenderSource.upsert({
      where: { sourcePortal_sourceNoticeId: { sourcePortal: n.sourcePortal, sourceNoticeId: n.sourceNoticeId } },
      create: { tenderId: match.id, sourcePortal: n.sourcePortal, sourceNoticeId: n.sourceNoticeId, sourceUrl: n.sourceUrl, ocid: n.ocid },
      update: { sourceUrl: n.sourceUrl },
    });
    // Tender-Felder ggf. anreichern (z.B. Wert/Frist füllen wenn bisher NULL) — optional
    return { tenderId: match.id, created: false };
  }
  const created = await this.prisma.tender.create({ data: { ...n /* + fingerprint */ } });
  await this.prisma.tenderSource.create({ data: { tenderId: created.id, sourcePortal: n.sourcePortal, sourceNoticeId: n.sourceNoticeId, sourceUrl: n.sourceUrl, ocid: n.ocid } });
  return { tenderId: created.id, created: true };
}

D-05-Inert-Beweis: dedupActive = (activePortalCount >= 2). Mit nur DÖE aktiv wird Stufe 3 nie erreicht → bestehendes DÖE-Verhalten unverändert (Erfolgskriterium 3). Test: nur DÖE aktiv → zwei Tender mit gleichem Fingerprint bleiben zwei Tender. Source: D-04/D-05 [CITED: 13-CONTEXT.md].

Anti-Patterns to Avoid

  • findFirst/findUnique statt fan-out: würde nur eine Quelle pollen. Muss findMany({ where: { isActive: true } }) sein.
  • Threshold-Matching gegen ganzen Bestand pro Ingest: O(n)-Kosten, nicht deterministisch. Fingerprint-Hash mit @unique-Lookup.
  • Dedup mit nur einer Quelle laufen lassen: verletzt D-05/Erfolgskriterium 3. dedupActive-Gate hart an activePortalCount >= 2.
  • Ein blockendes Portal wirft den ganzen Tick: catch-per-source Pflicht (D-01).
  • Denylist nur per Doku: muss Code-Exception sein (D-06). Ein Test ohne geworfene Exception = Requirement nicht erfüllt.

Don't Hand-Roll

Problem Don't Build Use Instead Why
HTML-Trefferliste parsen Regex über HTML node-html-parser/cheerio CSS-Selektoren Regex-HTML-Parsing bricht bei jeder Markup-Änderung; Selektoren sind robuster + lesbar
Fingerprint-Hash eigene Hash-Impl Node crypto.createHash('sha256') Bereits für contentHash genutzt, gleiche Konvention
Umlaut-Normalisierung ad-hoc überall eine normText()-Fn in tender-fingerprint.ts Eine Quelle der Wahrheit, testbar
Unique-Enforcement Quelle App-seitige Prüfung Prisma @@unique([sourcePortal, sourceNoticeId]) DB garantiert Integrität auch bei Races

Key insight: Der Kern (Registry, Fingerprint, Dedup, TenderSource, Backfill) hat KEINE externe Abhängigkeit und ist 100% unit-testbar — genau das fordert D-01. Nur das Selektor-Füllen der HTML-Adapter ist best-effort und darf hinter der Adapter-Grenze instabil bleiben.

Runtime State Inventory

Rename/Migration-Aspekt: dedupKey @unique → TenderSource-Ebene + neue fingerprint-Spalte. Backfill der Bestands-Tender.

Category Items Found Action Required
Stored data 2851 Tender-Rows (alle sourcePortal='doe-opendata', 100% OCID, 100% sourceUrl) [VERIFIED: live DB] Backfill: pro Tender EINE TenderSource-Zeile aus sourcePortal/sourceNoticeId/sourceUrl/ocid. Datenmigration (kein Code-Edit).
Stored data Tender.dedupKey @unique (aktuell ocid | sourcePortal:sourceNoticeId) Bleibt vorerst (SCHEMA-02-Upsert-Target für DÖE). NEUE fingerprint-Spalte (nullable) additiv; Backfill berechnet Fingerprint für Bestand. dedupKey NICHT droppen in dieser Phase (Risiko), nur ergänzen.
Live service config TenderSourcePollConfig hat nur 1 Row (doe-opendata) [VERIFIED: codebase — findUnique-Muster] Neue Rows für ai-netserver/cosinex-dtvp werden vom Admin (Phase 14 UI) oder Seed angelegt; isActive=false Default. Kein Zwang, sie in P13 zu aktivieren.
OS-registered state Ein globaler Cron tender-doe-poll (SchedulerRegistry) Job-Name ist DÖE-spezifisch. Empfehlung: umbenennen zu tender-poll (generisch) ODER belassen — es bleibt EIN globaler Job für alle Quellen (poll-once-fan-out-many). Kein Multi-Job.
Secrets/env vars Keine — Portale sind öffentlich, keine Auth [VERIFIED: live — kein Login-Wall auf Suchergebnissen] Keine.
Build artifacts Prisma Client muss nach TenderSource-Schema neu generiert werden pnpm --filter @tessera/api prisma generate nach Migration.

Nothing found in category: Secrets — verifiziert: NetServer/cosinex-Suchtreffer sind ohne Login erreichbar (Live-Probe HTTP 200 ohne Auth-Redirect).

Common Pitfalls

Pitfall 1: Fingerprint-Kollision desselben Auftraggebers

What goes wrong: Zwei verschiedene Ausschreibungen desselben Auftraggebers mit NULL-Wert/NULL-Frist könnten kollidieren, wenn Titel zu grob normalisiert wird. Why it happens: NULL-Toleranz entfernt unterscheidende Felder; über-aggressive Titel-Normalisierung. How to avoid: Titel bleibt im Hash (nach normText, aber vollständig). Wert nur als Bucket, nicht weggelassen. Test mit realen Auftraggeber-Titel-Paaren. Warning signs: Zwei sichtbar verschiedene Ausschreibungen erscheinen als ein Eintrag mit zwei Quellen.

Pitfall 2: NetServer-HTML-Struktur bricht Selektoren

What goes wrong: AI-AG ändert Markup → Adapter liefert leere/falsche Records. Why it happens: HTML-Scraping ist inhärent fragil (D-01 erkennt das an). How to avoid: Adapter fängt Parse-Fehler pro Zeile (wie DoeOpenDataAdapter logger.warn pro Entry), liefert [] bei Totalausfall statt zu werfen; catch-per-source im Ingestion-Tick. Selektoren zentral als Konstanten. Warning signs: Poll-Tick loggt „0 records" für ein zuvor funktionierendes Portal.

Pitfall 3: Dedup läuft versehentlich mit einer Quelle

What goes wrong: Fingerprint-Stufe aktiv bei nur DÖE → Erfolgskriterium 3 verletzt, evtl. falsche Merges im Bestand. Why it happens: dedupActive-Gate vergessen oder falsch (z.B. an >=1 statt >=2). How to avoid: dedupActive = activePortalCount >= 2, explizit getestet (nur-DÖE-Fall). Warning signs: Tender-Anzahl sinkt nach Ingest ohne zweite Quelle.

Pitfall 4: NetServer www. vs. non-www. / Redirect

What goes wrong: tender24.de/NetServer/… redirected (301); vergabe.landbw.de/ liefert nur meta-refresh-Stub (641 B) auf /NetServer. Why it happens: Portale haben unterschiedliche Host-/Pfad-Konventionen. [VERIFIED: live — tender24 www→301, landbw meta-refresh] How to avoid: Base-URL pro Portal in Config exakt setzen (inkl. www. wo nötig); fetch mit redirect: 'follow'; direkt den /NetServer/PublicationSearchControllerServlet-Pfad ansteuern statt der Root. Warning signs: 301/302 oder 641-Byte-Stub statt Trefferliste.

Pitfall 5: TenderSource-Backfill vor Unique-Constraint

What goes wrong: Migration setzt @@unique([sourcePortal, sourceNoticeId]) bevor Backfill läuft → Backfill-Insert schlägt fehl bei Alt-Duplikaten (unwahrscheinlich, aber DÖE hat theoretisch eindeutige noticeIds). Why it happens: Reihenfolge Schema-Change vs. Data-Backfill. How to avoid: Backfill in zwei Migrations-Schritten: (1) Tabelle + Spalten anlegen, (2) Daten backfillen, dann Constraint. DÖE-noticeIds sind live eindeutig (0% NULL OCID) → geringes Risiko, trotzdem Reihenfolge sichern.

Code Examples

TenderSource-Modell (Prisma)

// SCHEMA-03 — Quell-Ebene einer Ausschreibung. 1:n zu Tender (D-03).
// Ein Tender = ein Trefferlisten-Eintrag; mehrere TenderSource = mehrere Portale.
model TenderSource {
  id             String   @id @default(uuid())
  tenderId       String
  sourcePortal   String   // 'doe-opendata' | 'tender24' | 'lhs-vpbw' | 'vergabe.landbw' | 'cosinex-dtvp'
  sourceNoticeId String
  ocid           String?
  sourceUrl      String?
  createdAt      DateTime @default(now())
  tender         Tender   @relation(fields: [tenderId], references: [id], onDelete: Cascade)

  @@unique([sourcePortal, sourceNoticeId]) // eine Quell-Notiz gehört zu genau einem Tender
  @@index([tenderId])
}

// In model Tender ergänzen:
//   fingerprint String?  // SCHEMA-03 Fuzzy-Dedup-Schlüssel (nullable, backfilled)
//   sources     TenderSource[]
//   @@index([fingerprint])
// dedupKey @unique bleibt vorerst (DÖE-Upsert-Target, SCHEMA-02).

Backfill-Migration (SQL-Skizze)

-- Step 2 der Migration (nach Tabellen-Anlage): eine TenderSource pro Bestands-Tender
INSERT INTO "TenderSource" (id, "tenderId", "sourcePortal", "sourceNoticeId", ocid, "sourceUrl", "createdAt")
SELECT gen_random_uuid(), t.id, t."sourcePortal", t."sourceNoticeId", t.ocid, t."sourceUrl", now()
FROM "Tender" t;
-- fingerprint-Backfill erfolgt im TS-Migrations-Script (braucht normText/cpvDivisions-Logik),
-- nicht in reinem SQL — Umlaut-/CPV-Normalisierung lebt im Code.
// tenders.controller.ts — getTender include sources
@Get(':id')
@UseModule('tender-radar')
async getTender(@Param('id') id: string) {
  const tender = await this.prisma.tender.findUnique({
    where: { id },
    include: { sources: { select: { sourcePortal: true, sourceUrl: true, sourceNoticeId: true } } },
  });
  if (!tender) throw new NotFoundException('Tender not found');
  return tender;  // tender.sources[] → Frontend rendert Liste
}

TenderDetail: Multi-Source-Liste (Display)

// TenderDetail.tsx — ersetzt den einzelnen sourceUrl-Block durch eine Liste.
// Fallback: wenn tender.sources leer/undefined (alte API), auf tender.sourceUrl zurückfallen.
{tender.sources && tender.sources.length > 0 ? (
  <ul className="mt-2 space-y-1">
    {tender.sources.map((s) => (
      <li key={s.sourceNoticeId}>
        <a href={s.sourceUrl ?? '#'} target="_blank" rel="noopener noreferrer"
           className="text-sm font-medium text-primary underline underline-offset-2">
          {portalLabel(s.sourcePortal)} → {/* z.B. "tender24", "DÖE", "DTVP" */}
        </a>
      </li>
    ))}
  </ul>
) : ( /* bestehender Einzel-sourceUrl-Block als Fallback */ )}

State of the Art

Old Approach (Phase 10) Current Approach (Phase 13) Impact
pollDueSources → findUnique({ sourceType:'doe-opendata' }) findMany({ where:{ isActive:true } }) fan-out Mehrere Quellen pro Tick
Single DoeOpenDataAdapter injiziert SourceRegistry mit Denylist-Gate INGEST-07 strukturell erzwungen
dedupKey @unique = einziger Match 3-stufig OCID→source:noticeId→Fingerprint Cross-Source-Merge
sourcePortal/sourceNoticeId auf Tender zusätzlich TenderSource 1:n Liste aller Quellen pro Tender
Detail zeigt ein sourceUrl Detail zeigt sources[]-Liste D-03-Anzeige

Deprecated/outdated: nichts entfernt — additive Migration. SourceType-Literal 'doe-opendata' wird zu Union erweitert (breaking für Typen, aber lokal begrenzt).

Assumptions Log

# Claim Section Risk if Wrong
A1 node-html-parser ist das passende, legitime HTML-Parse-Package Standard Stack Planner gated via checkpoint:human-verify; cheerio als Alternative. Kern unabhängig.
A2 NetServer-Trefferliste ist ohne Auth vollständig parsebar (nicht nur Teaser) Portal-Feasibility Selektoren liefern evtl. nur Teil-Felder → best-effort (D-01), Framework bleibt grün
A3 Fingerprint-Feld-Auswahl (Titel+Buyer dominant, CPV-Division, Wert-Bucket, Frist-Tag) balanciert Kollision vs. Match korrekt Pattern 4 Bei Kollisionen Granularität erhöhen; Threshold NICHT einführen
A4 cosinex DTVP-Suche ist per HTTP paginierbar ohne JS-Rendering Portal-Feasibility cosinex evtl. JS-lastig → Selektoren nur teilweise füllbar (best-effort, D-01)
A5 Ein globaler Cron-Job bleibt (kein Multi-Job pro Quelle) Runtime State Falls per-Quelle-Intervalle nötig → mehr Jobs; MVP: ein Job reicht

Open Questions

  1. cosinex DTVP JS-Rendering-Grad
    • Was wir wissen: Satellite/company/welcome.do liefert 40 KB HTML (HTTP 200, öffentlich).
    • Was unklar ist: Ob die Trefferliste server-seitig gerendert ist oder per JS/AJAX nachlädt (kein Headless-Browser im Stack, D-01 verbietet fragiles Erzwingen).
    • Empfehlung: Selektoren gegen die server-gerenderte Seite füllen; wenn JS-abhängig → als „needs-JS, deferred" dokumentieren, Adapter-Skelett + Test trotzdem liefern.
  2. NetServer Pagination + Detail-URL pro Notiz
    • Was wir wissen: Suchservlet liefert <table> mit Ausschreibungszeilen (161 KB).
    • Was unklar ist: Exakte Query-Parameter für Blättern und der stabile Deep-Link je Notiz (sourceUrl/sourceNoticeId).
    • Empfehlung: Beim Selektor-Füllen die Zeilen-Anchor-hrefs als sourceUrl + eine stabile ID daraus als sourceNoticeId extrahieren; Pagination best-effort (erste Seite reicht für MVP-Nachweis).
  3. dedupKey-Zukunft
    • Was wir wissen: dedupKey @unique ist DÖE-Upsert-Target (SCHEMA-02).
    • Was unklar ist: Ob es langfristig durch fingerprint ersetzt wird.
    • Empfehlung: In P13 belassen (additive fingerprint-Spalte). Konsolidierung ist späterer Refactor, nicht MVP-Scope.

Environment Availability

Dependency Required By Available Version Fallback
tender24.de NetServer INGEST-02 ✓ HTTP 200, <table> öffentlich Vergabe@Net HTML-Parse
lhs-vpbw.vmstart.de INGEST-02 ✓ (NetServer, robots 404) Vergabe@Net HTML-Parse
vergabe.landbw.de INGEST-02 ✓ meta-refresh→/NetServer Vergabe@Net Base-URL auf /NetServer zeigen
dtvp.de cosinex VMP INGEST-03 ✓ HTTP 200, 40 KB (Satellite/company/welcome.do) cosinex HTML-Parse, ggf. JS-limitiert (OQ1)
DÖE OpenData API Bestand ✓ HTTP 200 (live re-verifiziert) ocds-mnwr74 —
vergabe24 / aumass INGEST-07 (Denylist) ✗ absichtlich NICHT genutzt — Denylist-Refusal, KEIN Zugriff (D-06)
PostgreSQL (tessera db) Migration/Backfill ✓ (Container, db tessera, user tessera) 16 —

Missing dependencies with no fallback: keine — alle scrapebaren Portale live erreichbar; Kern (Registry/Dedup/TenderSource) braucht keine externen Deps. Missing dependencies with fallback: node-html-parser (via Gate; cheerio alternativ). Portale ohne stabile Selektoren → best-effort, Adapter-Skelett + Test bleiben grün (D-01).

Validation Architecture

Test Framework

Property Value
Framework Vitest 3.x
Config file vorhanden (bestehende *.spec.ts im tenders-Modul)
Quick run command pnpm --filter @tessera/api test src/tenders
Full suite command pnpm --filter @tessera/api test

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
INGEST-07 Denylist-Portal-Registrierung wirft unit pnpm --filter @tessera/api test source-registry ❌ Wave 0
SCHEMA-03 Fingerprint NULL-tolerant + kollisionsarm unit pnpm --filter @tessera/api test tender-fingerprint ❌ Wave 0
SCHEMA-03 Dedup inert bei nur DÖE (D-05) unit/integration pnpm --filter @tessera/api test tender-dedup ❌ Wave 0
SCHEMA-03 2. Quelle hängt TenderSource an statt neuem Tender integration pnpm --filter @tessera/api test tender-ingestion ⚠️ erweitern
INGEST-02 NetServer-Adapter parst <table> → RawTenderRecord[] unit (Fixture) pnpm --filter @tessera/api test netserver.adapter ❌ Wave 0
INGEST-03 cosinex-Adapter parst Trefferliste unit (Fixture) pnpm --filter @tessera/api test cosinex.adapter ❌ Wave 0
SCHEMA-03 getTender liefert sources[] integration pnpm --filter @tessera/api test tenders.controller ⚠️ erweitern

Sampling Rate

  • Per task commit: pnpm --filter @tessera/api test src/tenders
  • Per wave merge: pnpm --filter @tessera/api test
  • Phase gate: Volle Suite grün + Backfill-Migration lokal gegen tessera-DB verifiziert.

Wave 0 Gaps

  • source-registry.spec.ts — INGEST-07 Denylist-Refusal
  • tender-fingerprint.spec.ts — SCHEMA-03 NULL-Toleranz + Kollisions-Fälle
  • tender-dedup.service.spec.ts — D-04/D-05 (inert bei 1 Quelle, Merge bei 2)
  • netserver.adapter.spec.ts + HTML-Fixture (live-gecapturte Trefferliste)
  • cosinex.adapter.spec.ts + HTML-Fixture
  • Fixtures: __fixtures__/netserver-search.html, __fixtures__/cosinex-search.html (live speichern für stabile Tests)

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V5 Input Validation yes Extern gefetchtes HTML ist untrusted — Parser-Ausgabe validieren/coercen (keine Roh-HTML-Ausgabe an Frontend), Titel/Buyer als Text behandeln
V5 SSRF yes Portal-Base-URLs sind hardcodierte Konstanten (wie DOE_BASE_URL), NIE aus User-/Admin-Input interpoliert (T-10-06-Muster)
V6 Cryptography yes (Hash) crypto.createHash('sha256') — kein Selbstbau
V12 Denial of Service yes AbortController-Timeout pro Fetch (15s, DoeOpenData-Muster); catch-per-source; Rate-Limit-Politeness-Delay (bestehende politeDelayMs)
V4 Access Control yes Read-Endpoint bleibt @UseModule('tender-radar'); Tender global (kein Tenant-Leak)

Known Threat Patterns for NestJS/HTML-Scraping

Pattern STRIDE Standard Mitigation
SSRF via konfigurierbare Portal-URL Tampering/Info-Disclosure Hardcodierte Portal-Konstanten, kein Admin-URL-Input
Stored XSS via Portal-HTML in Titel/Buyer Tampering Frontend rendert als Text (React escaped default), keine dangerouslySetInnerHTML
Malformed HTML → DoS/Crash DoS try/catch pro Zeile + pro Source, Timeout, []-Fallback
externer Quell-Link Tampering rel="noopener noreferrer" + target="_blank" (bestehendes TenderDetail-Muster)

Sources

Primary (HIGH confidence)

  • Codebase: doe-opendata.adapter.ts, tender-source-adapter.interface.ts, tender.types.ts, tender-ingestion.service.ts, tender-scheduler.service.ts, tender-normalizer.service.ts, schema.prisma, tenders.controller.ts, TenderDetail.tsx — Adapter-Vertrag, Upsert-Pfad, Normalizer, Schema.
  • Live-DB (tessera): 2851 DÖE-Tender, 0% NULL OCID, 92.4% NULL Wert, 15.7% NULL Frist, 100% sourceUrl [VERIFIED 2026-07-23].
  • Live-Probes: tender24 NetServer-Suchservlet HTTP 200/161 KB <table>; vergabe.landbw meta-refresh→/NetServer; dtvp cosinex welcome.do HTTP 200/40 KB; DÖE API HTTP 200 [VERIFIED 2026-07-23].

Secondary (MEDIUM confidence)

  • .planning/research/ausschreibungs-portale-feasibility.md — Plattform-Zuordnung (AI-AG NetServer = 4/8/9; cosinex = DTVP), AGB-Verbote vergabe24/aumass, kein zentrales Feed pro Portal.

Tertiary (LOW confidence)

  • node-html-parser/cheerio-Eignung — Trainingswissen, vor Install via Gate verifizieren.

Metadata

Confidence breakdown:

  • Standard Stack: HIGH — Kern nutzt bestehende Deps; nur HTML-Parser neu (gated).
  • Architecture: HIGH — direkt aus bestehendem Ingestion-/Adapter-Muster abgeleitet + CONTEXT-Decisions.
  • Portal-Feasibility: HIGH — live geprüft (Erreichbarkeit, Auth-Freiheit, HTML-Struktur-Indiz); MEDIUM für Selektor-Vollständigkeit/Pagination (best-effort, D-01).
  • Pitfalls: HIGH — codebasiert + live-verifizierte Redirect-/NULL-Fälle.

Research date: 2026-07-23 Valid until: 2026-08-22 (Portal-HTML kann sich ändern — Selektoren periodisch prüfen)