feat(13-04): NetServer-Adapter fuer tender24/lhs-vpbw/vergabe.landbw (INGEST-02)
- Package-Legitimacy-Checkpoint bestaetigt: cheerio (sauberer Gate-Pass, 27M Downloads/Woche) statt node-html-parser installiert - EIN config-getriebener NetServerAdapter bedient alle 3 AI-AG-Portale via PublicationSearchControllerServlet-Trefferliste, parst <table> mit cheerio zu RawTenderRecord[], sourcePortal je Zeile korrekt - sourceNoticeId = data-oid (stabil, per Zeile eindeutig); sourceUrl = Such-URL als dokumentierter Best-Effort-Fallback (kein Deep-Link ohne JS-Ausfuehrung ermittelbar, Open Question 2) - Fehlertoleranz: try/catch pro Zeile + pro Portal, []-Fallback bei Totalausfall, AbortController 15s-Timeout, hardcodierte Portal-URLs (SSRF-Guard T-13-04-01) - Live-gecapturte Fixture (tender24.de, 2026-07-23) mit Umlaut-/&- Buyer, gefuellter und leerer Deadline; 14 Spec-Tests gruen Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"@tessera/shared": "workspace:*",
|
||||
"adm-zip": "^0.6.0",
|
||||
"argon2": "^0.44.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><title>Vergabe@Net - Suchergebnisse</title></head>
|
||||
<body>
|
||||
<!-- Live-gecapturte NetServer-Trefferliste (tender24.de, 2026-07-23), gekuerzt auf 6 Zeilen fuer die Test-Fixture. -->
|
||||
<table class="table table-responsive table-striped table-hover tableHorizontalHeader">
|
||||
<caption>Suchergebnisse</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
<th class="tabelleFirstLine background-blue">
|
||||
<a href="PublicationSearchControllerServlet?function=SearchPublications&OrderBy=Publishing&Order=desc" title="Nach dem Erscheinungsdatum sortieren" class=" tableActive">Erschienen am</a>
|
||||
</th>
|
||||
|
||||
<th class="tabelleFirstLine background-blue">
|
||||
<a class="activeChange" href="PublicationSearchControllerServlet?function=SearchPublications&OrderBy=TenderName&Order=desc" title="Nach dem Namen der Veröffentlichung sortieren">Ausschreibung</a>
|
||||
</th>
|
||||
|
||||
<th class="tabelleFirstLine background-blue">
|
||||
<a class="activeChange" href="PublicationSearchControllerServlet?function=SearchPublications&OrderBy=AuthorityName&Order=desc" title="Nach dem Namen der ausschreibenden Stelle sortieren">Vergabestelle</a>
|
||||
</th>
|
||||
|
||||
<th class="tabelleFirstLine background-blue">
|
||||
<a class="activeChange" href="PublicationSearchControllerServlet?function=SearchPublications&OrderBy=TenderKind&Order=desc" title="Nach der Verfahrensart sortieren">Verfahrensart</a>
|
||||
</th>
|
||||
|
||||
<th class="tabelleFirstLine background-blue">
|
||||
<a class="activeChange" href="PublicationSearchControllerServlet?function=SearchPublications&OrderBy=TenderLawCaption&Order=desc" title="Nach dem Rechtsrahmen sortieren">Rechtsrahmen</a>
|
||||
</th>
|
||||
|
||||
<th class="tabelleFirstLine background-blue">
|
||||
<a class="activeChange" id="tableLastHeader" href="PublicationSearchControllerServlet?function=SearchPublications&OrderBy=EndTime&Order=desc" title="Nach der Abgabefrist sortieren">Abgabefrist</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr class="tableRow clickable-row publicationDetail" tabindex="0" style="cursor: pointer;" data-oid="54321-NetTender-19f800ce3b1-36b275763103203a" data-category="InvitationToTender">
|
||||
|
||||
<td >22.07.2026</td>
|
||||
|
||||
<td class="tender">
|
||||
Wärmenetz Wunsiedel Planungs- und Koordinierungsleistungen (STKP-BY-2026-11)
|
||||
|
||||
</td>
|
||||
|
||||
<td class="tenderAuthority" >Sterr-Kölln & Partner</td>
|
||||
|
||||
<td class="tenderType">Verhandlungsverfahren mit Teilnahmewettbewerb</td>
|
||||
|
||||
<td class="tenderType">VOL</td>
|
||||
|
||||
<td class="tenderDeadline">20.08.2026 24:00</td>
|
||||
</tr>
|
||||
<tr class="tableRow clickable-row publicationDetail" tabindex="0" style="cursor: pointer;" data-oid="54321-NetTender-19e21c643b8-4268f33140e63c36" data-category="ContractAward">
|
||||
|
||||
<td >22.07.2026</td>
|
||||
|
||||
<td class="tender">
|
||||
POLY_BAU_2026_0003_000023 (Sanierung BT4) Stahlbauarbeiten (POLY_BAU_2026_0003)
|
||||
|
||||
</td>
|
||||
|
||||
<td class="tenderAuthority" >Max-Planck-Institut für Polymerforschung (IPF)</td>
|
||||
|
||||
<td class="tenderType">Freihändige Vergabe</td>
|
||||
|
||||
<td class="tenderType">VOB</td>
|
||||
|
||||
<td class="tenderDeadline"></td>
|
||||
</tr>
|
||||
<tr class="tableRow clickable-row publicationDetail" tabindex="0" style="cursor: pointer;" data-oid="54321-NetTender-19efec148b6-1a2bbf5ec4dade45" data-category="ContractAward">
|
||||
|
||||
<td >22.07.2026</td>
|
||||
|
||||
<td class="tender">
|
||||
Zellanalyse- und Zellsortiersystem mit spektraler und bildbasierter Echtzeitanalyse (PFOR_2026_02)
|
||||
|
||||
</td>
|
||||
|
||||
<td class="tenderAuthority" >Max-Planck-Institut für Herz- und Lungenforschung (IHL)</td>
|
||||
|
||||
<td class="tenderType">Verhandlungsverfahren ohne Teilnahmewettbewerb</td>
|
||||
|
||||
<td class="tenderType">UVgO/VgV</td>
|
||||
|
||||
<td class="tenderDeadline"></td>
|
||||
</tr>
|
||||
<tr class="tableRow clickable-row publicationDetail" tabindex="0" style="cursor: pointer;" data-oid="54321-NetTender-19db46612b3-1a04aea066286c1" data-category="ContractAward">
|
||||
|
||||
<td >22.07.2026</td>
|
||||
|
||||
<td class="tender">
|
||||
VE117_1 Malerarbeiten (VE117_1)
|
||||
|
||||
</td>
|
||||
|
||||
<td class="tenderAuthority" >Drees & Sommer SE Smart Infrastructure</td>
|
||||
|
||||
<td class="tenderType">Offenes Verfahren</td>
|
||||
|
||||
<td class="tenderType">VOB</td>
|
||||
|
||||
<td class="tenderDeadline"></td>
|
||||
</tr>
|
||||
<tr class="tableRow clickable-row publicationDetail" tabindex="0" style="cursor: pointer;" data-oid="54321-NetTender-19eacb06976-633a2f5db089eb5c" data-category="ContractAward">
|
||||
|
||||
<td >22.07.2026</td>
|
||||
|
||||
<td class="tender">
|
||||
ZV - Lautertal - Geschlossene Kanalsanierung (Schmutzwasserhauptkanal) Tiefenlauter (1200-0452-2026/000656)
|
||||
|
||||
</td>
|
||||
|
||||
<td class="tenderAuthority" >Stadt Coburg - Beschaffungsamt</td>
|
||||
|
||||
<td class="tenderType">Freihändige Vergabe</td>
|
||||
|
||||
<td class="tenderType">VOB</td>
|
||||
|
||||
<td class="tenderDeadline"></td>
|
||||
</tr>
|
||||
<tr class="tableRow clickable-row publicationDetail" tabindex="0" style="cursor: pointer;" data-oid="54321-NetTender-19cb85baaa1-34fafe93210c7594" data-category="ContractAward">
|
||||
|
||||
<td >22.07.2026</td>
|
||||
|
||||
<td class="tender">
|
||||
Planungsleistungen Wärmenetz Obertürkheim (SWS-2026-0001)
|
||||
|
||||
</td>
|
||||
|
||||
<td class="tenderAuthority" >Stadtwerke Stuttgart GmbH</td>
|
||||
|
||||
<td class="tenderType">Verhandlungsverfahren mit Teilnahmewettbewerb</td>
|
||||
|
||||
<td class="tenderType">VOL</td>
|
||||
|
||||
<td class="tenderDeadline"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,255 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NetServerAdapter } from './netserver.adapter';
|
||||
|
||||
/**
|
||||
* Real, live-captured NetServer results table (6 rows, trimmed from a
|
||||
* 161 KB live GET against
|
||||
* `https://www.tender24.de/NetServer/PublicationSearchControllerServlet?function=SearchPublications`,
|
||||
* 2026-07-23) — covers an umlaut buyer name ("Sterr-Kölln & Partner",
|
||||
* exercising `&`-entity decoding), a populated deadline row, and
|
||||
* several empty-deadline rows (already-awarded / no open deadline).
|
||||
*/
|
||||
const FIXTURE_PATH = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'__fixtures__',
|
||||
'netserver-search.html',
|
||||
);
|
||||
const FIXTURE_HTML = readFileSync(FIXTURE_PATH, 'utf8');
|
||||
const FIXTURE_ROW_COUNT = 6;
|
||||
|
||||
function stubFetchWithHtml(html: string): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => html,
|
||||
} as Response;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function stubFetchThrowing(): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('network unreachable');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('NetServerAdapter', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('declares sourceType ai-netserver and the 3 AI-AG NetServer portals', () => {
|
||||
const adapter = new NetServerAdapter();
|
||||
expect(adapter.sourceType).toBe('ai-netserver');
|
||||
expect(adapter.portals).toEqual(['tender24', 'lhs-vpbw', 'vergabe.landbw']);
|
||||
});
|
||||
|
||||
describe('parseSearchResults (pure, fixture-driven)', () => {
|
||||
it('parses all rows of the fixture into RawTenderRecord[], sourcePortal set per row', () => {
|
||||
const adapter = new NetServerAdapter();
|
||||
const fetchedAt = new Date();
|
||||
|
||||
const records = adapter.parseSearchResults(
|
||||
FIXTURE_HTML,
|
||||
'tender24',
|
||||
fetchedAt,
|
||||
);
|
||||
|
||||
expect(records.length).toBeGreaterThanOrEqual(1);
|
||||
expect(records).toHaveLength(FIXTURE_ROW_COUNT);
|
||||
for (const record of records) {
|
||||
expect(record.sourceType).toBe('ai-netserver');
|
||||
expect(record.sourcePortal).toBe('tender24');
|
||||
expect(record.sourceNoticeId).toMatch(/^54321-NetTender-/);
|
||||
expect(record.sourceUrl).toBe(
|
||||
'https://www.tender24.de/NetServer/PublicationSearchControllerServlet?function=SearchPublications',
|
||||
);
|
||||
expect(record.fetchedAt).toBe(fetchedAt);
|
||||
}
|
||||
});
|
||||
|
||||
it('decodes an umlaut + &-entity buyer name correctly (Sterr-Kölln & Partner)', () => {
|
||||
const adapter = new NetServerAdapter();
|
||||
const records = adapter.parseSearchResults(
|
||||
FIXTURE_HTML,
|
||||
'tender24',
|
||||
new Date(),
|
||||
);
|
||||
|
||||
const row = records.find(
|
||||
(r) => r.sourceNoticeId === '54321-NetTender-19f800ce3b1-36b275763103203a',
|
||||
);
|
||||
expect(row).toBeDefined();
|
||||
const payload = row!.ocdsPayload as { buyerName: string | null };
|
||||
expect(payload.buyerName).toBe('Sterr-Kölln & Partner');
|
||||
});
|
||||
|
||||
it('uses the row data-oid attribute as sourceNoticeId', () => {
|
||||
const adapter = new NetServerAdapter();
|
||||
const records = adapter.parseSearchResults(
|
||||
FIXTURE_HTML,
|
||||
'lhs-vpbw',
|
||||
new Date(),
|
||||
);
|
||||
|
||||
const ids = records.map((r) => r.sourceNoticeId);
|
||||
expect(ids).toContain('54321-NetTender-19f800ce3b1-36b275763103203a');
|
||||
expect(ids).toContain('54321-NetTender-19e21c643b8-4268f33140e63c36');
|
||||
// every id is unique — no accidental row-collapsing
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('maps a populated deadline row to a Date and an empty-deadline row to null (never throws)', () => {
|
||||
const adapter = new NetServerAdapter();
|
||||
const records = adapter.parseSearchResults(
|
||||
FIXTURE_HTML,
|
||||
'tender24',
|
||||
new Date(),
|
||||
);
|
||||
|
||||
const withDeadline = records.find(
|
||||
(r) => r.sourceNoticeId === '54321-NetTender-19f800ce3b1-36b275763103203a',
|
||||
);
|
||||
const withoutDeadline = records.find(
|
||||
(r) => r.sourceNoticeId === '54321-NetTender-19e21c643b8-4268f33140e63c36',
|
||||
);
|
||||
expect(withDeadline).toBeDefined();
|
||||
expect(withoutDeadline).toBeDefined();
|
||||
|
||||
const withDeadlinePayload = withDeadline!.ocdsPayload as {
|
||||
deadlineAt: string | null;
|
||||
};
|
||||
const withoutDeadlinePayload = withoutDeadline!.ocdsPayload as {
|
||||
deadlineAt: string | null;
|
||||
};
|
||||
// "20.08.2026 24:00" -> normalized to 2026-08-21T00:00Z (NetServer's 24:00 = next-day midnight)
|
||||
expect(withDeadlinePayload.deadlineAt).toBe('2026-08-21T00:00:00.000Z');
|
||||
expect(withoutDeadlinePayload.deadlineAt).toBeNull();
|
||||
});
|
||||
|
||||
it('returns [] for empty HTML instead of throwing', () => {
|
||||
const adapter = new NetServerAdapter();
|
||||
expect(adapter.parseSearchResults('', 'tender24', new Date())).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] for HTML with no matching result rows instead of throwing', () => {
|
||||
const adapter = new NetServerAdapter();
|
||||
const broken = '<html><body><p>Keine Treffer</p></body></html>';
|
||||
expect(
|
||||
adapter.parseSearchResults(broken, 'tender24', new Date()),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a row missing data-oid without throwing or affecting other rows', () => {
|
||||
const adapter = new NetServerAdapter();
|
||||
const html = `
|
||||
<table>
|
||||
<tr class="tableRow publicationDetail">
|
||||
<td>22.07.2026</td>
|
||||
<td class="tender">Ohne OID</td>
|
||||
<td class="tenderAuthority">Test-Vergabestelle</td>
|
||||
<td class="tenderType">Offenes Verfahren</td>
|
||||
<td class="tenderType">VOB</td>
|
||||
<td class="tenderDeadline"></td>
|
||||
</tr>
|
||||
<tr class="tableRow publicationDetail" data-oid="test-oid-1">
|
||||
<td>22.07.2026</td>
|
||||
<td class="tender">Mit OID</td>
|
||||
<td class="tenderAuthority">Test-Vergabestelle 2</td>
|
||||
<td class="tenderType">Offenes Verfahren</td>
|
||||
<td class="tenderType">VOB</td>
|
||||
<td class="tenderDeadline"></td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
const records = adapter.parseSearchResults(html, 'tender24', new Date());
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.sourceNoticeId).toBe('test-oid-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTenders (portal fan-out, fetch mocked)', () => {
|
||||
it('fetches all 3 portals and aggregates their parsed records', async () => {
|
||||
stubFetchWithHtml(FIXTURE_HTML);
|
||||
const adapter = new NetServerAdapter();
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
expect(records.length).toBe(FIXTURE_ROW_COUNT * 3);
|
||||
const portals = new Set(records.map((r) => r.sourcePortal));
|
||||
expect(portals).toEqual(new Set(['tender24', 'lhs-vpbw', 'vergabe.landbw']));
|
||||
});
|
||||
|
||||
it('returns [] for a portal whose fetch throws, without aborting the other portals (D-01)', async () => {
|
||||
const adapter = new NetServerAdapter();
|
||||
let callCount = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) throw new Error('portal 1 unreachable');
|
||||
return { ok: true, status: 200, text: async () => FIXTURE_HTML } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
// first portal fails -> contributes 0 records; the other two succeed.
|
||||
expect(records.length).toBe(FIXTURE_ROW_COUNT * 2);
|
||||
});
|
||||
|
||||
it('returns [] for every portal when fetch always throws, without propagating (D-01 total-failure fallback)', async () => {
|
||||
stubFetchThrowing();
|
||||
const adapter = new NetServerAdapter();
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] without throwing when a portal responds non-2xx', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
return { ok: false, status: 503 } as Response;
|
||||
}),
|
||||
);
|
||||
const adapter = new NetServerAdapter();
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('never imports or uses axios (native fetch is the sole HTTP client convention)', () => {
|
||||
const source = readFileSync(
|
||||
join(__dirname, 'netserver.adapter.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(source).not.toMatch(/from ['"]axios['"]/);
|
||||
});
|
||||
|
||||
it('never interpolates a portal base URL from a variable/parameter (SSRF guard, T-13-04-01)', () => {
|
||||
const source = readFileSync(
|
||||
join(__dirname, 'netserver.adapter.ts'),
|
||||
'utf8',
|
||||
);
|
||||
// NETSERVER_PORTALS values must be string literals, not template
|
||||
// expressions referencing an external input.
|
||||
expect(source).toMatch(/baseUrl: 'https:\/\/www\.tender24\.de'/);
|
||||
expect(source).toMatch(/baseUrl: 'https:\/\/lhs-vpbw\.vmstart\.de'/);
|
||||
expect(source).toMatch(/baseUrl: 'https:\/\/vergabe\.landbw\.de'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as cheerio from 'cheerio';
|
||||
import type { RawTenderRecord, SourceType } from '../tender.types';
|
||||
import type { TenderSourceAdapter } from './tender-source-adapter.interface';
|
||||
|
||||
/**
|
||||
* NetServerAdapter — ONE config-driven adapter (INGEST-02) serving the
|
||||
* three AI-AG "Vergabe@Net" portals (tender24, lhs-vpbw, vergabe.landbw).
|
||||
* Each portal exposes the same public, auth-free HTML results table at
|
||||
* `/NetServer/PublicationSearchControllerServlet?function=SearchPublications`
|
||||
* (live verified: tender24 -> HTTP 200, 161 KB `<table>`, 2026-07-23).
|
||||
*
|
||||
* Security notes (threat model T-13-04-01/02/03):
|
||||
* - Portal base URLs are hardcoded constants (NETSERVER_PORTALS), NEVER
|
||||
* interpolated from user/admin input (SSRF guard, T-10-06 pattern).
|
||||
* - Native fetch + AbortController 15s timeout (DoeOpenDataAdapter idiom)
|
||||
* — no axios anywhere in this codebase.
|
||||
* - Per-row try/catch (Pitfall 2): a single malformed row is skipped
|
||||
* (logger.warn), never aborts the whole portal. A totally unparsable
|
||||
* page, or a portal whose fetch throws, resolves to [] for that portal
|
||||
* instead of throwing — pollDueSources' catch-per-source (Plan 13-03) is
|
||||
* a second, coarser safety net, but the adapter itself must never take
|
||||
* down the other two portals in the same tick (D-01).
|
||||
* - Extracted title/buyerName are plain text (cheerio `.text()`, not
|
||||
* `.html()`) — no raw HTML is ever carried into RawTenderRecord/Tender,
|
||||
* preventing stored-XSS via portal markup (V5).
|
||||
*
|
||||
* Open Question 2 (13-RESEARCH.md) resolved by live inspection: NetServer
|
||||
* result rows carry NO per-row `<a href>` — navigation to the detail page
|
||||
* is client-side JS only (`data-oid` + a click handler in an external
|
||||
* script), with no statically-derivable deep-link URL in the search HTML.
|
||||
* Documented, best-effort choice (D-01): `sourceNoticeId` is the row's
|
||||
* `data-oid` attribute (stable, unique per notice); `sourceUrl` falls back
|
||||
* to the portal's search-results URL itself (NOT a per-notice deep link) —
|
||||
* this is a known limitation, not a bug; see 13-04-SUMMARY.md.
|
||||
*/
|
||||
const NETSERVER_FETCH_TIMEOUT_MS = 15_000;
|
||||
const SEARCH_PATH =
|
||||
'/NetServer/PublicationSearchControllerServlet?function=SearchPublications';
|
||||
|
||||
interface NetServerPortalConfig {
|
||||
readonly baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded per-portal base URLs (SSRF guard — never built from input).
|
||||
* Each base URL points directly at the host that resolves `/NetServer`
|
||||
* without relying on a redirect/meta-refresh stub (Pitfall 4: a bare
|
||||
* `vergabe.landbw.de/` root only serves a 641-byte meta-refresh page; the
|
||||
* base URL below targets the resolved host directly).
|
||||
*/
|
||||
const NETSERVER_PORTALS: Record<string, NetServerPortalConfig> = {
|
||||
'tender24': { baseUrl: 'https://www.tender24.de' },
|
||||
'lhs-vpbw': { baseUrl: 'https://lhs-vpbw.vmstart.de' },
|
||||
'vergabe.landbw': { baseUrl: 'https://vergabe.landbw.de' },
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NetServerAdapter implements TenderSourceAdapter {
|
||||
readonly sourceType: SourceType = 'ai-netserver';
|
||||
/** Config-driven multi-portal adapter (INGEST-02) — one class, 3 portals. */
|
||||
readonly portals = ['tender24', 'lhs-vpbw', 'vergabe.landbw'] as const;
|
||||
|
||||
private readonly logger = new Logger(NetServerAdapter.name);
|
||||
|
||||
/**
|
||||
* dayCursor is accepted for 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);
|
||||
* pagination/date-filtering is best-effort out of scope for this plan
|
||||
* (13-RESEARCH.md Open Question 2 — first page is sufficient for the
|
||||
* MVP proof). Every configured portal is fetched independently and a
|
||||
* failure on one never prevents the others from being polled (D-01).
|
||||
*/
|
||||
async fetchTenders(_dayCursor: string): Promise<RawTenderRecord[]> {
|
||||
const fetchedAt = new Date();
|
||||
const records: RawTenderRecord[] = [];
|
||||
|
||||
for (const portal of this.portals) {
|
||||
const config = NETSERVER_PORTALS[portal];
|
||||
if (!config) continue; // defensive — portals[] and the config map are kept in sync
|
||||
|
||||
try {
|
||||
const html = await this.fetchPortalHtml(config.baseUrl);
|
||||
records.push(...this.parseSearchResults(html, portal, fetchedAt));
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`NetServer portal '${portal}' fetch failed, skipping this portal for this tick: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private async fetchPortalHtml(baseUrl: string): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
NETSERVER_FETCH_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}${SEARCH_PATH}`, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`NetServer fetch failed: HTTP ${res.status}`);
|
||||
}
|
||||
return await res.text();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the `PublicationSearchControllerServlet` results `<table>` into
|
||||
* RawTenderRecord[]. Tolerant by construction (Pitfall 2, D-01): a single
|
||||
* row that throws during extraction is skipped (logger.warn) rather than
|
||||
* aborting the whole portal; if the page itself is fundamentally
|
||||
* unparsable (cheerio.load throws, or simply no matching rows exist),
|
||||
* this returns `[]` instead of propagating.
|
||||
*/
|
||||
parseSearchResults(
|
||||
html: string,
|
||||
portal: string,
|
||||
fetchedAt: Date,
|
||||
): RawTenderRecord[] {
|
||||
const records: RawTenderRecord[] = [];
|
||||
|
||||
let $: cheerio.CheerioAPI;
|
||||
try {
|
||||
$ = cheerio.load(html);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`NetServer HTML totally unparsable (${portal}), returning [] (D-01): ${(error as Error).message}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
$('tr.tableRow.publicationDetail').each((_, el) => {
|
||||
try {
|
||||
const row = $(el);
|
||||
const oid = row.attr('data-oid');
|
||||
if (!oid) return; // no stable id -> unusable row, skip (Pitfall 2)
|
||||
|
||||
const cells = row.find('td');
|
||||
const publishedRaw = $(cells.get(0)).text().trim();
|
||||
const title = $(cells.get(1)).text().trim() || 'Unbenannte Ausschreibung';
|
||||
const buyerName = $(cells.get(2)).text().trim() || null;
|
||||
const procedureType = $(cells.get(3)).text().trim() || null;
|
||||
const legalFramework = $(cells.get(4)).text().trim() || null;
|
||||
const deadlineRaw = $(cells.get(5)).text().trim();
|
||||
|
||||
const publishedAt = parseNetServerDate(publishedRaw);
|
||||
const deadlineAt = parseNetServerDeadline(deadlineRaw);
|
||||
|
||||
records.push({
|
||||
sourceType: this.sourceType,
|
||||
sourcePortal: portal,
|
||||
sourceNoticeId: oid,
|
||||
// Best-effort fallback (Open Question 2 / D-01): the search
|
||||
// results URL, NOT a per-notice deep link — see class doc.
|
||||
sourceUrl: `${NETSERVER_PORTALS[portal]?.baseUrl ?? ''}${SEARCH_PATH}`,
|
||||
fetchedAt,
|
||||
publishedAt,
|
||||
eformsPayload: null,
|
||||
// NetServer has no eForms/OCDS structure; the extracted table
|
||||
// fields are carried through this generic bag so a future
|
||||
// normalizer extension (out of scope for this plan) can map
|
||||
// them without re-parsing HTML.
|
||||
ocdsPayload: {
|
||||
title,
|
||||
buyerName,
|
||||
procedureType,
|
||||
legalFramework,
|
||||
deadlineAt: deadlineAt ? deadlineAt.toISOString() : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Skipping unparsable NetServer row (${portal}): ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses NetServer's `dd.MM.yyyy` publish-date column; null if absent/malformed. */
|
||||
function parseNetServerDate(raw: string): Date | null {
|
||||
const match = raw.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return null;
|
||||
const [, dd, mm, yyyy] = match;
|
||||
const date = new Date(
|
||||
Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd)),
|
||||
);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses NetServer's `dd.MM.yyyy HH:mm` deadline column. Frequently empty
|
||||
* (many rows have no deadline, e.g. already-awarded ContractAward entries)
|
||||
* — empty/malformed input returns null, never throws (RawTenderRecord's
|
||||
* deadline is only exposed via the raw `ocdsPayload` bag in this plan;
|
||||
* nullability here matches the project-wide "deadline is often null"
|
||||
* convention, RESEARCH.md Pattern 4). NetServer uses `24:00` to mean
|
||||
* midnight at the end of the given day — normalized to `00:00` the next day.
|
||||
*/
|
||||
function parseNetServerDeadline(raw: string): Date | null {
|
||||
if (!raw) return null;
|
||||
const match = raw.match(/^(\d{2})\.(\d{2})\.(\d{4})\s+(\d{2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const [, dd, mm, yyyy, hh, min] = match;
|
||||
const day = Number(dd);
|
||||
const month = Number(mm) - 1;
|
||||
const year = Number(yyyy);
|
||||
const hour = Number(hh);
|
||||
const minute = Number(min);
|
||||
|
||||
const date =
|
||||
hour === 24
|
||||
? new Date(Date.UTC(year, month, day + 1, 0, minute))
|
||||
: new Date(Date.UTC(year, month, day, hour, minute));
|
||||
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
Generated
+26
-21
@@ -62,6 +62,9 @@ importers:
|
||||
argon2:
|
||||
specifier: ^0.44.0
|
||||
version: 0.44.0
|
||||
cheerio:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
@@ -2569,6 +2572,10 @@ packages:
|
||||
resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
cheerio@1.2.0:
|
||||
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
@@ -7905,7 +7912,6 @@ snapshots:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
optional: true
|
||||
|
||||
cheerio@1.0.0:
|
||||
dependencies:
|
||||
@@ -7922,6 +7928,20 @@ snapshots:
|
||||
whatwg-mimetype: 4.0.0
|
||||
optional: true
|
||||
|
||||
cheerio@1.2.0:
|
||||
dependencies:
|
||||
cheerio-select: 2.1.0
|
||||
dom-serializer: 2.0.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
encoding-sniffer: 0.2.1
|
||||
htmlparser2: 10.1.0
|
||||
parse5: 7.3.0
|
||||
parse5-htmlparser2-tree-adapter: 7.1.0
|
||||
parse5-parser-stream: 7.1.2
|
||||
undici: 7.28.0
|
||||
whatwg-mimetype: 4.0.0
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
@@ -8108,7 +8128,6 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
nth-check: 2.1.1
|
||||
optional: true
|
||||
|
||||
css-selector-parser@3.3.0: {}
|
||||
|
||||
@@ -8123,8 +8142,7 @@ snapshots:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
css-what@6.2.2:
|
||||
optional: true
|
||||
css-what@6.2.2: {}
|
||||
|
||||
css.escape@1.5.1: {}
|
||||
|
||||
@@ -8283,10 +8301,8 @@ snapshots:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
optional: true
|
||||
|
||||
domelementtype@2.3.0:
|
||||
optional: true
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@4.3.1:
|
||||
dependencies:
|
||||
@@ -8296,7 +8312,6 @@ snapshots:
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
optional: true
|
||||
|
||||
domutils@2.8.0:
|
||||
dependencies:
|
||||
@@ -8310,7 +8325,6 @@ snapshots:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
optional: true
|
||||
|
||||
dotenv-expand@12.0.3:
|
||||
dependencies:
|
||||
@@ -8368,7 +8382,6 @@ snapshots:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
whatwg-encoding: 3.1.1
|
||||
optional: true
|
||||
|
||||
enhanced-resolve@5.21.6:
|
||||
dependencies:
|
||||
@@ -8386,13 +8399,11 @@ snapshots:
|
||||
entities@3.0.1:
|
||||
optional: true
|
||||
|
||||
entities@4.5.0:
|
||||
optional: true
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
entities@7.0.1:
|
||||
optional: true
|
||||
entities@7.0.1: {}
|
||||
|
||||
entities@8.0.0: {}
|
||||
|
||||
@@ -8951,7 +8962,6 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 7.0.1
|
||||
optional: true
|
||||
|
||||
htmlparser2@7.2.0:
|
||||
dependencies:
|
||||
@@ -9004,7 +9014,6 @@ snapshots:
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
@@ -10531,12 +10540,10 @@ snapshots:
|
||||
dependencies:
|
||||
domhandler: 5.0.3
|
||||
parse5: 7.3.0
|
||||
optional: true
|
||||
|
||||
parse5-parser-stream@7.1.2:
|
||||
dependencies:
|
||||
parse5: 7.3.0
|
||||
optional: true
|
||||
|
||||
parse5@7.3.0:
|
||||
dependencies:
|
||||
@@ -12077,10 +12084,8 @@ snapshots:
|
||||
whatwg-encoding@3.1.1:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
optional: true
|
||||
|
||||
whatwg-mimetype@4.0.0:
|
||||
optional: true
|
||||
whatwg-mimetype@4.0.0: {}
|
||||
|
||||
whatwg-mimetype@5.0.0: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user