/** * Filter state crawling and pagination capture. * * Discovers filter controls (select, checkbox-group, radio-group, search), * applies each meaningful option and records result counts and first-row * values, then detects and walks pagination (numbered, load-more, infinite * scroll). Also captures sort column behaviour. * * All per-filter / per-page errors are caught and skipped so one bad control * never aborts the full crawl. */ import type { Page } from 'playwright'; // --------------------------------------------------------------------------- // Public interfaces // --------------------------------------------------------------------------- export interface FilterOption { value: string; label: string; } export interface FilterDef { kind: 'select' | 'checkbox-group' | 'radio-group' | 'search'; label: string; locatorHint: string; options: FilterOption[]; } export interface FilterState { filterLabel: string; filterValue: string; resultCount: number | null; firstRowValues: string[]; } export interface PaginationCapture { kind: 'numbered' | 'load-more' | 'infinite-scroll' | 'none'; totalPages?: number; totalItems?: number; pageSizes?: number[]; pages: Array<{ pageNum: number; rowValues: string[] }>; } export interface FilterPaginationResult { filters: FilterDef[]; filterStates: FilterState[]; pagination: PaginationCapture; sortColumns: Array<{ header: string; ariaSortAfterClick: string | null }>; searchCapture?: { noResultsText: string | null; sampleSuggestions: string[] }; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** Pause for up to `ms` ms or until network is idle, whichever comes first. */ async function waitForSettle(page: Page, ms = 1500): Promise { await Promise.race([ page.waitForLoadState('networkidle').catch(() => undefined), new Promise(r => setTimeout(r, ms)), ]); } /** Extract visible cell text from the first tbody row, if any. */ async function captureFirstRow(page: Page): Promise { try { return await page.evaluate(() => { const row = document.querySelector('tbody tr'); if (!row) return []; return Array.from(row.querySelectorAll('td')).map(td => td.innerText.trim()); }); } catch { return []; } } /** Read a result-count hint from visible page text using common patterns. */ async function readResultCount(page: Page): Promise { try { const text = await page.evaluate(() => document.body.innerText); const match = text.match(/(\d[\d,]*)\s*(result|item|entr|record)/i); if (!match) return null; return parseInt(match[1].replace(/,/g, ''), 10); } catch { return null; } } // --------------------------------------------------------------------------- // discoverFilters // --------------------------------------------------------------------------- async function discoverFilters(page: Page): Promise { const filters: FilterDef[] = []; // --- native