import type { QualityField } from './types.js'; /** Extraction quality weights (§199). */ export const QUALITY_WEIGHTS: Record = { title: 0.2, price: 0.2, status: 0.15, date: 0.1, images: 0.1, description: 0.05, identifiers: 0.1, category: 0.05, currency: 0.05, }; export const MIN_QUALITY = 0.6; function present(v: unknown): boolean { if (v === null || v === undefined) return false; if (typeof v === 'string') return v.trim().length > 0; if (Array.isArray(v)) return v.length > 0; if (typeof v === 'number') return Number.isFinite(v); if (v instanceof Date) return !Number.isNaN(v.getTime()); if (typeof v === 'object') return Object.keys(v as object).length > 0; return Boolean(v); } /** * Score 0–1 = weighted share of expected fields that were found. When `expect` is omitted all * weighted fields count; otherwise weights are renormalised over the expected subset so that a * catalog page (no price/status) can still reach 1.0. */ export function qualityScore(found: Partial> | null | undefined, expect?: QualityField[]): number { if (!found) return 0; const fields = (expect && expect.length ? expect : (Object.keys(QUALITY_WEIGHTS) as QualityField[])); let total = 0; let got = 0; for (const f of fields) { const w = QUALITY_WEIGHTS[f] ?? 0; total += w; if (present(found[f])) got += w; } return total > 0 ? Math.round((got / total) * 1000) / 1000 : 0; } const CHALLENGE_RE = /access denied|captcha|verify you are human|attention required|just a moment\.\.\.|request blocked|unusual traffic|enable javascript and cookies to continue|\s*(?:blocked|forbidden|challenge|security check)|_cf_chl_opt|cf-challenge|px-captcha|distil_r_captcha|akamai.*?bot manager|are you a robot|pardon our interruption/i; /** * Detect anti-bot challenge / block pages (Cloudflare, Akamai, PerimeterX, DataDome…). Counted in * health as "challenge events" (SPEC §12). Short pages with challenge markers are challenges; long * pages that merely mention "cloudflare" in a footer are not. */ export function isChallengePage(doc: { html?: string | null; markdown?: string | null; json?: unknown; httpStatus?: number | null }): boolean { if (doc.json && present(doc.json)) return false; const text = doc.markdown ?? doc.html ?? ''; if (!text) return doc.httpStatus === 403 || doc.httpStatus === 429 || doc.httpStatus === 503; const head = text.slice(0, 6000); const hit = CHALLENGE_RE.test(head); if (!hit) return false; return text.length < 25_000 || doc.httpStatus === 403 || doc.httpStatus === 429 || doc.httpStatus === 503; } /** Heuristic score for a document when the connector provides no parser: did we get real content? */ export function documentQuality(doc: { html?: string | null; markdown?: string | null; json?: unknown; httpStatus?: number | null; buffer?: Uint8Array | null }): number { if (doc.httpStatus && (doc.httpStatus >= 400 || doc.httpStatus === 0)) return 0; if (doc.json && present(doc.json)) return 0.9; if (doc.buffer && doc.buffer.byteLength > 0) return 0.9; const text = doc.markdown ?? doc.html ?? ''; if (!text) return 0; const len = text.length; const blocked = isChallengePage(doc); if (blocked && len < 20_000) return 0.1; if (len < 500) return 0.2; if (len < 2_000) return 0.5; return 0.8; }