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
+
+
+
+
+
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.2026 |
+ Ohne OID |
+ Test-Vergabestelle |
+ Offenes Verfahren |
+ VOB |
+ |
+
+
+ | 22.07.2026 |
+ Mit OID |
+ Test-Vergabestelle 2 |
+ Offenes Verfahren |
+ VOB |
+ |
+
+
+ `;
+ 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: {}