diff --git a/apps/api/package.json b/apps/api/package.json index b8cf9c3..dc1e93d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/tenders/__fixtures__/netserver-search.html b/apps/api/src/tenders/__fixtures__/netserver-search.html new file mode 100644 index 0000000..59e8f54 --- /dev/null +++ b/apps/api/src/tenders/__fixtures__/netserver-search.html @@ -0,0 +1,140 @@ + + +Vergabe@Net - Suchergebnisse + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Suchergebnisse
+ Erschienen am + + Ausschreibung + + Vergabestelle + + Verfahrensart + + Rechtsrahmen + + Abgabefrist +
22.07.2026 + Wärmenetz Wunsiedel Planungs- und Koordinierungsleistungen (STKP-BY-2026-11) + + Sterr-Kölln & PartnerVerhandlungsverfahren mit TeilnahmewettbewerbVOL20.08.2026 24:00
22.07.2026 + POLY_BAU_2026_0003_000023 (Sanierung BT4) Stahlbauarbeiten (POLY_BAU_2026_0003) + + Max-Planck-Institut für Polymerforschung (IPF)Freihändige VergabeVOB
22.07.2026 + Zellanalyse- und Zellsortiersystem mit spektraler und bildbasierter Echtzeitanalyse (PFOR_2026_02) + + Max-Planck-Institut für Herz- und Lungenforschung (IHL)Verhandlungsverfahren ohne TeilnahmewettbewerbUVgO/VgV
22.07.2026 + VE117_1 Malerarbeiten (VE117_1) + + Drees & Sommer SE Smart InfrastructureOffenes VerfahrenVOB
22.07.2026 + ZV - Lautertal - Geschlossene Kanalsanierung (Schmutzwasserhauptkanal) Tiefenlauter (1200-0452-2026/000656) + + Stadt Coburg - BeschaffungsamtFreihändige VergabeVOB
22.07.2026 + Planungsleistungen Wärmenetz Obertürkheim (SWS-2026-0001) + + Stadtwerke Stuttgart GmbHVerhandlungsverfahren mit TeilnahmewettbewerbVOL
+ + diff --git a/apps/api/src/tenders/adapters/netserver.adapter.spec.ts b/apps/api/src/tenders/adapters/netserver.adapter.spec.ts new file mode 100644 index 0000000..8ec3f1a --- /dev/null +++ b/apps/api/src/tenders/adapters/netserver.adapter.spec.ts @@ -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 = '

Keine Treffer

'; + 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 = ` + + + + + + + + + + + + + + + + + +
22.07.2026Ohne OIDTest-VergabestelleOffenes VerfahrenVOB
22.07.2026Mit OIDTest-Vergabestelle 2Offenes VerfahrenVOB
+ `; + 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'/); + }); +}); diff --git a/apps/api/src/tenders/adapters/netserver.adapter.ts b/apps/api/src/tenders/adapters/netserver.adapter.ts new file mode 100644 index 0000000..6c3508d --- /dev/null +++ b/apps/api/src/tenders/adapters/netserver.adapter.ts @@ -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 ``, 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 `` — 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 = { + '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 { + 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 { + 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 `
` 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe04811..7b5727f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}