mcp-scraper 0.76.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/dist/{analytics-repository-AUTJK4HK.js → analytics-repository-D5LEGQG3.js} +3 -3
  4. package/dist/bin/api-server.js +4 -4
  5. package/dist/bin/mcp-scraper-cli.js +3 -3
  6. package/dist/bin/mcp-scraper-core.js +7 -7
  7. package/dist/bin/mcp-scraper-install.js +2 -2
  8. package/dist/bin/mcp-stdio-server.js +7 -7
  9. package/dist/bin/paa-harvest.js +4 -4
  10. package/dist/{chunk-BOGRXBH2.js → chunk-2P2ZVRPK.js} +189 -57
  11. package/dist/{chunk-E6Y3VCOJ.js → chunk-3PUMNEOC.js} +3 -3
  12. package/dist/{chunk-KIPIBPUB.js → chunk-47ZCUBM5.js} +1 -1
  13. package/dist/{chunk-IBQNOZ7E.js → chunk-74CCAY6T.js} +1 -1
  14. package/dist/{chunk-7RQULQF3.js → chunk-B6L73AO4.js} +1 -1
  15. package/dist/{chunk-WFQ2E4WE.js → chunk-BTQ23KCT.js} +1 -1
  16. package/dist/{chunk-SDX6TRJ6.js → chunk-FODYEDZK.js} +3 -3
  17. package/dist/{chunk-PJEEKOUM.js → chunk-FUMG43EN.js} +1 -1
  18. package/dist/{chunk-XSYEDC25.js → chunk-IYYRSVRQ.js} +1 -1
  19. package/dist/{chunk-A66DGFOU.js → chunk-KAGHJQFO.js} +1 -1
  20. package/dist/{chunk-CXY5WV45.js → chunk-NGLZ6UJR.js} +1 -1
  21. package/dist/{chunk-WEFPBAAG.js → chunk-OU4FV2RP.js} +140 -4
  22. package/dist/{chunk-F5TK4KMT.js → chunk-OUBH6XMY.js} +1 -1
  23. package/dist/{chunk-77LQV73B.js → chunk-OWF2JJKN.js} +13 -1
  24. package/dist/{chunk-F6MUGMRN.js → chunk-UFJJGXVO.js} +702 -165
  25. package/dist/{chunk-KBTUPRTF.js → chunk-VEQPA65D.js} +1 -1
  26. package/dist/{chunk-D2O7V2CT.js → chunk-VMWSXKUQ.js} +1 -1
  27. package/dist/{chunk-JNFOBQVH.js → chunk-ZUCU2A6V.js} +1 -1
  28. package/dist/{db-566MOHHD.js → db-TA567JLA.js} +17 -1
  29. package/dist/{extract-bundle-OO74L67T.js → extract-bundle-P2DTUVY3.js} +3 -3
  30. package/dist/{gmail-service-TIU4V5JA.js → gmail-service-HN57R2GS.js} +4 -4
  31. package/dist/index.cjs +714 -165
  32. package/dist/index.d.cts +83 -1
  33. package/dist/index.d.ts +83 -1
  34. package/dist/index.js +4 -4
  35. package/dist/{lead-list-enrichment-repository-WWEMJ7EN.js → lead-list-enrichment-repository-5LEWW4X7.js} +2 -2
  36. package/dist/{location-data-repository-H4B2BWGY.js → location-data-repository-AHKDXTEQ.js} +2 -2
  37. package/dist/{server-IAGYCPN6.js → server-6B2OG3NN.js} +1434 -983
  38. package/dist/{site-extract-repository-B47JB62U.js → site-extract-repository-XKSVXSSY.js} +2 -2
  39. package/dist/{worker-DR6R7J46.js → worker-2BYACLNF.js} +6 -6
  40. package/package.json +1 -1
@@ -8,22 +8,23 @@ import {
8
8
  currentCostContext,
9
9
  recordKernelSession,
10
10
  runWithCostContext
11
- } from "./chunk-CXY5WV45.js";
11
+ } from "./chunk-NGLZ6UJR.js";
12
12
  import {
13
13
  DEFAULT_PROXY_MODE,
14
14
  HarvestOptionsSchema,
15
15
  RawPAAItemSchema,
16
16
  postToMemoryLibrary,
17
17
  recencyToTbs
18
- } from "./chunk-77LQV73B.js";
18
+ } from "./chunk-OWF2JJKN.js";
19
19
  import {
20
20
  CaptchaError,
21
21
  ExtractionError,
22
22
  LocationMismatchError,
23
+ PaaInteractionInterruptedError,
23
24
  RECAPTCHA_INSTRUCTIONS,
24
25
  RequestAbortedError,
25
26
  sanitizeVendorName
26
- } from "./chunk-WEFPBAAG.js";
27
+ } from "./chunk-OU4FV2RP.js";
27
28
 
28
29
  // src/driver/BrowserDriver.ts
29
30
  import { chromium } from "playwright-extra";
@@ -1724,16 +1725,15 @@ async function extractAISurfacesFromDocument(config) {
1724
1725
  if (textOf(heading) === "AI Overview") return true;
1725
1726
  const header = el.querySelector(selectors.aio.header);
1726
1727
  if (textOf(header).split(/\n|\s{2,}/).some((part) => part.trim() === "AI Overview")) return true;
1727
- return textOf(el).includes("AI Overview");
1728
+ return false;
1728
1729
  }
1729
1730
  function findAIORoot() {
1730
1731
  const primaryRoots = Array.from(document.querySelectorAll(selectors.aio.root));
1731
1732
  const labeledPrimary = primaryRoots.find(hasAIOverviewLabel);
1732
1733
  if (labeledPrimary) return labeledPrimary;
1733
- if (primaryRoots.length > 0) return primaryRoots[0];
1734
1734
  if (selectors.aio.legacyRoot) {
1735
1735
  const legacy = document.querySelector(selectors.aio.legacyRoot);
1736
- if (legacy) return legacy;
1736
+ if (legacy && hasAIOverviewLabel(legacy)) return legacy;
1737
1737
  }
1738
1738
  const headings = document.querySelectorAll(`${selectors.aio.heading}, h1, h2, h3, [role="heading"]`);
1739
1739
  for (const h of headings) {
@@ -1812,26 +1812,12 @@ async function extractAISurfacesFromDocument(config) {
1812
1812
  if (!raw || /not available|try again|can't generate/i.test(raw)) return null;
1813
1813
  return raw;
1814
1814
  }
1815
- function normalizeHref(rawHref) {
1815
+ function absoluteHref(rawHref) {
1816
1816
  if (!rawHref || rawHref.startsWith("javascript:")) return null;
1817
- let href = rawHref;
1818
1817
  try {
1819
1818
  const absolute = new URL(rawHref, window.location.href);
1820
- const q = absolute.searchParams.get("q") ?? absolute.searchParams.get("url");
1821
- if (/(\.|^)google\./i.test(absolute.hostname) && q?.startsWith("http")) {
1822
- href = q;
1823
- } else {
1824
- href = absolute.href;
1825
- }
1826
- } catch {
1827
- return null;
1828
- }
1829
- if (!/^https?:\/\//i.test(href)) return null;
1830
- try {
1831
- const url = new URL(href);
1832
- const isGoogleInternal = /(\.|^)google\./i.test(url.hostname);
1833
- if (isGoogleInternal) return null;
1834
- return url.href;
1819
+ if (!["http:", "https:"].includes(absolute.protocol) || absolute.username || absolute.password) return null;
1820
+ return { href: absolute.href, rawHref: absolute.href };
1835
1821
  } catch {
1836
1822
  return null;
1837
1823
  }
@@ -1841,17 +1827,18 @@ async function extractAISurfacesFromDocument(config) {
1841
1827
  const seen = /* @__PURE__ */ new Set();
1842
1828
  const citations = [];
1843
1829
  for (const a of Array.from(root.querySelectorAll("a[href]"))) {
1844
- const href = normalizeHref(a.getAttribute("href") ?? "");
1845
- if (!href || seen.has(href)) continue;
1846
- seen.add(href);
1830
+ const link = absoluteHref(a.getAttribute("href") ?? "");
1831
+ if (!link || seen.has(link.rawHref)) continue;
1832
+ seen.add(link.rawHref);
1847
1833
  let fallbackHost = "";
1848
1834
  try {
1849
- fallbackHost = new URL(href).hostname.replace(/^www\./, "");
1835
+ fallbackHost = new URL(link.href).hostname.replace(/^www\./, "");
1850
1836
  } catch {
1851
1837
  }
1852
1838
  citations.push({
1853
- text: textOf(a) || fallbackHost || href,
1854
- href
1839
+ text: textOf(a) || fallbackHost || link.href,
1840
+ href: link.href,
1841
+ rawHref: link.rawHref
1855
1842
  });
1856
1843
  }
1857
1844
  return citations;
@@ -1906,6 +1893,286 @@ async function extractAISurfacesFromDocument(config) {
1906
1893
  };
1907
1894
  }
1908
1895
 
1896
+ // src/extractor/google-outbound-links.ts
1897
+ import { createHash } from "crypto";
1898
+ var MAX_LINK_LENGTH = 8e3;
1899
+ var DEFAULT_TIMEOUT_MS = 3e3;
1900
+ var DEFAULT_TOTAL_BUDGET_MS = 1e4;
1901
+ var DEFAULT_HTTP_CONCURRENCY = 8;
1902
+ function isGoogleHost(hostname) {
1903
+ return /(^|\.)google\.(?:com|[a-z]{2,3}(?:\.[a-z]{2})?)$/i.test(hostname);
1904
+ }
1905
+ function safeHttpUrl(value) {
1906
+ if (!value || value.length > MAX_LINK_LENGTH) return null;
1907
+ try {
1908
+ const url = new URL(value);
1909
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
1910
+ if (url.username || url.password) return null;
1911
+ return url;
1912
+ } catch {
1913
+ return null;
1914
+ }
1915
+ }
1916
+ function absoluteHttpUrl(value, baseUrl) {
1917
+ if (!value || value.length > MAX_LINK_LENGTH || /^javascript:/i.test(value)) return null;
1918
+ try {
1919
+ const absolute = new URL(value, baseUrl);
1920
+ return safeHttpUrl(absolute.href);
1921
+ } catch {
1922
+ return null;
1923
+ }
1924
+ }
1925
+ function parsedGoogleGoto(value) {
1926
+ const url = safeHttpUrl(value);
1927
+ if (!url || url.protocol !== "https:" || !isGoogleHost(url.hostname) || url.pathname !== "/goto") return null;
1928
+ return url.searchParams.get("url") ? url : null;
1929
+ }
1930
+ function questionIdFor(question) {
1931
+ const normalized = question.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim();
1932
+ return `paa_${createHash("sha256").update(normalized).digest("hex").slice(0, 16)}`;
1933
+ }
1934
+ function classifyGoogleOutboundLink(rawHref, baseUrl) {
1935
+ const absolute = absoluteHttpUrl(rawHref, baseUrl);
1936
+ if (!absolute) return null;
1937
+ const rawUrl = absolute.href;
1938
+ if (!isGoogleHost(absolute.hostname)) {
1939
+ return {
1940
+ url: rawUrl,
1941
+ rawUrl,
1942
+ resolvedUrl: rawUrl,
1943
+ linkType: "plain",
1944
+ resolutionStatus: "not_needed"
1945
+ };
1946
+ }
1947
+ if (absolute.pathname === "/url") {
1948
+ const target = safeHttpUrl(absolute.searchParams.get("q") ?? absolute.searchParams.get("url") ?? "");
1949
+ if (!target || isGoogleHost(target.hostname)) return null;
1950
+ return {
1951
+ url: target.href,
1952
+ rawUrl,
1953
+ resolvedUrl: target.href,
1954
+ linkType: "google_url_redirect",
1955
+ resolutionStatus: "resolved"
1956
+ };
1957
+ }
1958
+ if (absolute.pathname === "/goto" && absolute.searchParams.get("url")) {
1959
+ return {
1960
+ url: rawUrl,
1961
+ rawUrl,
1962
+ resolvedUrl: null,
1963
+ linkType: "google_goto_redirect",
1964
+ resolutionStatus: "unresolved"
1965
+ };
1966
+ }
1967
+ return null;
1968
+ }
1969
+ function tokenFor(value) {
1970
+ return parsedGoogleGoto(value)?.searchParams.get("url") ?? null;
1971
+ }
1972
+ function externalRedirectTarget(source, location) {
1973
+ try {
1974
+ const sourceUrl = new URL(source);
1975
+ const target = safeHttpUrl(new URL(location, sourceUrl).href);
1976
+ if (!target || isGoogleHost(target.hostname)) return null;
1977
+ if (!target.hash && sourceUrl.hash) target.hash = sourceUrl.hash;
1978
+ return target.href;
1979
+ } catch {
1980
+ return null;
1981
+ }
1982
+ }
1983
+ async function resolveGoogleGotoUrls(rawUrls, options = {}) {
1984
+ const inputs = [...new Set([...rawUrls].flatMap((value) => parsedGoogleGoto(value) ? [value] : []))];
1985
+ const records = new Map(inputs.map((rawUrl) => [rawUrl, {
1986
+ rawUrl,
1987
+ resolvedUrl: null,
1988
+ resolutionStatus: "unresolved"
1989
+ }]));
1990
+ const fallbackFetch = typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : null;
1991
+ const fetchImpl = options.fetchImpl ?? fallbackFetch;
1992
+ if (inputs.length === 0 || !fetchImpl) {
1993
+ return { resolutions: [...records.values()], nonGoogleRequestsObserved: 0 };
1994
+ }
1995
+ const timeoutMs = Math.max(1, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
1996
+ const deadlineMs = Date.now() + Math.max(1, options.totalBudgetMs ?? DEFAULT_TOTAL_BUDGET_MS);
1997
+ const concurrency = Math.max(1, Math.min(16, Math.floor(options.concurrency ?? DEFAULT_HTTP_CONCURRENCY)));
1998
+ let cursor = 0;
1999
+ const worker = async () => {
2000
+ while (cursor < inputs.length) {
2001
+ const rawUrl = inputs[cursor++];
2002
+ const source = parsedGoogleGoto(rawUrl);
2003
+ const expectedToken = source?.searchParams.get("url");
2004
+ if (!source || !expectedToken) {
2005
+ records.set(rawUrl, { rawUrl, resolvedUrl: null, resolutionStatus: "rejected" });
2006
+ continue;
2007
+ }
2008
+ source.hash = "";
2009
+ let currentUrl = source.href;
2010
+ for (let hop = 0; hop < 2; hop++) {
2011
+ const remainingMs = deadlineMs - Date.now();
2012
+ if (remainingMs <= 0) return;
2013
+ const controller = new AbortController();
2014
+ const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, remainingMs));
2015
+ let response;
2016
+ try {
2017
+ response = await fetchImpl(currentUrl, {
2018
+ method: "GET",
2019
+ redirect: "manual",
2020
+ headers: {
2021
+ accept: "*/*",
2022
+ "user-agent": "Mozilla/5.0"
2023
+ },
2024
+ signal: controller.signal
2025
+ });
2026
+ } catch {
2027
+ break;
2028
+ } finally {
2029
+ clearTimeout(timer);
2030
+ }
2031
+ if (response.status < 300 || response.status >= 400) break;
2032
+ const location = response.headers.get("location");
2033
+ if (!location) break;
2034
+ const destination = externalRedirectTarget(rawUrl, location);
2035
+ if (destination) {
2036
+ records.set(rawUrl, { rawUrl, resolvedUrl: destination, resolutionStatus: "resolved" });
2037
+ break;
2038
+ }
2039
+ let next;
2040
+ try {
2041
+ next = new URL(location, currentUrl);
2042
+ } catch {
2043
+ records.set(rawUrl, { rawUrl, resolvedUrl: null, resolutionStatus: "rejected" });
2044
+ break;
2045
+ }
2046
+ if (!isGoogleHost(next.hostname) || tokenFor(next.href) !== expectedToken) {
2047
+ records.set(rawUrl, { rawUrl, resolvedUrl: null, resolutionStatus: "rejected" });
2048
+ break;
2049
+ }
2050
+ currentUrl = next.href;
2051
+ }
2052
+ }
2053
+ };
2054
+ await Promise.all(Array.from({ length: Math.min(concurrency, inputs.length) }, () => worker()));
2055
+ return { resolutions: [...records.values()], nonGoogleRequestsObserved: 0 };
2056
+ }
2057
+ async function resolveGoogleOutboundLinks(page, values, options = {}) {
2058
+ const byRawUrl = /* @__PURE__ */ new Map();
2059
+ for (const value of values) byRawUrl.set(value.rawUrl, value);
2060
+ const inputs = [...byRawUrl.values()].filter((value) => value.linkType === "google_goto_redirect");
2061
+ if (inputs.length === 0) return byRawUrl;
2062
+ const deadlineMs = Date.now() + (options.totalBudgetMs ?? DEFAULT_TOTAL_BUDGET_MS);
2063
+ const direct = await resolveGoogleGotoUrls(inputs.map((value) => value.rawUrl), options);
2064
+ for (const record of direct.resolutions) {
2065
+ const input = byRawUrl.get(record.rawUrl);
2066
+ if (!input) continue;
2067
+ byRawUrl.set(record.rawUrl, record.resolutionStatus === "resolved" && record.resolvedUrl ? { ...input, url: record.resolvedUrl, resolvedUrl: record.resolvedUrl, resolutionStatus: "resolved" } : { ...input, resolutionStatus: record.resolutionStatus });
2068
+ }
2069
+ const unresolvedInputs = inputs.filter((input) => byRawUrl.get(input.rawUrl)?.resolutionStatus === "unresolved");
2070
+ if (unresolvedInputs.length === 0 || options.browserFallback === false || Date.now() >= deadlineMs) {
2071
+ options.onNetworkAudit?.({ nonGoogleRequestsObserved: 0 });
2072
+ return byRawUrl;
2073
+ }
2074
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2075
+ const resolverPage = await page.context().newPage().catch(() => null);
2076
+ if (!resolverPage) {
2077
+ options.onNetworkAudit?.({ nonGoogleRequestsObserved: 0 });
2078
+ return byRawUrl;
2079
+ }
2080
+ let active = null;
2081
+ let nonGoogleRequestsObserved = 0;
2082
+ try {
2083
+ await resolverPage.route("**/*", async (route) => {
2084
+ const requestUrl = route.request().url();
2085
+ try {
2086
+ if (!isGoogleHost(new URL(requestUrl).hostname)) nonGoogleRequestsObserved += 1;
2087
+ } catch {
2088
+ }
2089
+ const requestToken = tokenFor(requestUrl);
2090
+ if (!active || requestToken !== active.token) {
2091
+ await route.abort("blockedbyclient").catch(() => {
2092
+ });
2093
+ return;
2094
+ }
2095
+ const target = active;
2096
+ try {
2097
+ const response = await route.fetch({ maxRedirects: 0, timeout: target.timeoutMs });
2098
+ target.location = response.headers().location ?? null;
2099
+ } finally {
2100
+ await route.abort("blockedbyclient").catch(() => {
2101
+ });
2102
+ target.finish();
2103
+ }
2104
+ });
2105
+ for (const input of unresolvedInputs) {
2106
+ const remainingMs = deadlineMs - Date.now();
2107
+ if (remainingMs <= 0) break;
2108
+ const source = parsedGoogleGoto(input.rawUrl);
2109
+ const expectedToken = source?.searchParams.get("url");
2110
+ if (!source || !expectedToken) {
2111
+ byRawUrl.set(input.rawUrl, { ...input, resolutionStatus: "rejected" });
2112
+ continue;
2113
+ }
2114
+ const attemptTimeoutMs = Math.min(timeoutMs, remainingMs);
2115
+ source.hash = "";
2116
+ let currentUrl = source.href;
2117
+ let rejected = false;
2118
+ for (let hop = 0; hop < 2; hop++) {
2119
+ let finish;
2120
+ const intercepted = new Promise((resolve) => {
2121
+ finish = resolve;
2122
+ });
2123
+ const frameId = `mcp-goto-${Math.random().toString(36).slice(2)}`;
2124
+ active = { token: expectedToken, timeoutMs: attemptTimeoutMs, location: null, finish };
2125
+ await resolverPage.evaluate(({ url, id }) => {
2126
+ const frame = document.createElement("iframe");
2127
+ frame.id = id;
2128
+ frame.hidden = true;
2129
+ frame.src = url;
2130
+ document.body.append(frame);
2131
+ }, { url: currentUrl, id: frameId }).catch(() => null);
2132
+ await Promise.race([
2133
+ intercepted,
2134
+ new Promise((resolve) => setTimeout(resolve, attemptTimeoutMs))
2135
+ ]);
2136
+ const location = active.location;
2137
+ active = null;
2138
+ await resolverPage.evaluate((id) => document.getElementById(id)?.remove(), frameId).catch(() => null);
2139
+ if (!location) break;
2140
+ const destination = externalRedirectTarget(input.rawUrl, location);
2141
+ if (destination) {
2142
+ byRawUrl.set(input.rawUrl, {
2143
+ ...input,
2144
+ url: destination,
2145
+ resolvedUrl: destination,
2146
+ resolutionStatus: "resolved"
2147
+ });
2148
+ break;
2149
+ }
2150
+ let next;
2151
+ try {
2152
+ next = new URL(location, currentUrl);
2153
+ } catch {
2154
+ rejected = true;
2155
+ break;
2156
+ }
2157
+ if (!isGoogleHost(next.hostname) || tokenFor(next.href) !== expectedToken) {
2158
+ rejected = true;
2159
+ break;
2160
+ }
2161
+ currentUrl = next.href;
2162
+ }
2163
+ if (rejected && byRawUrl.get(input.rawUrl)?.resolutionStatus !== "resolved") {
2164
+ byRawUrl.set(input.rawUrl, { ...input, resolutionStatus: "rejected" });
2165
+ }
2166
+ }
2167
+ } finally {
2168
+ active = null;
2169
+ await resolverPage.close().catch(() => {
2170
+ });
2171
+ options.onNetworkAudit?.({ nonGoogleRequestsObserved });
2172
+ }
2173
+ return byRawUrl;
2174
+ }
2175
+
1909
2176
  // src/extractor/PAAExtractor.ts
1910
2177
  var PAAExtractor = class {
1911
2178
  constructor(driver, reporter, onPaaProgress) {
@@ -1923,10 +2190,118 @@ var PAAExtractor = class {
1923
2190
  aioShareCaptured: null
1924
2191
  };
1925
2192
  aioShareUrlEarly = null;
2193
+ linkDiagnostics = this.emptyLinkDiagnostics();
1926
2194
  collectedRows = [];
1927
2195
  partialContext = null;
1928
2196
  progressSequence = 0;
1929
2197
  clickedQuestions = /* @__PURE__ */ new Set();
2198
+ paaLifecycle = this.emptyPaaLifecycle(0);
2199
+ checkpointMaterial = null;
2200
+ emptyPaaLifecycle(requestedQuestions) {
2201
+ return {
2202
+ requestedQuestions,
2203
+ questionsFound: 0,
2204
+ targetStatus: "not_met",
2205
+ discoveryStatus: "in_progress",
2206
+ materialStatus: "partial",
2207
+ completionReason: "PAA expansion is still running.",
2208
+ automaticRetries: 0,
2209
+ knownQuestions: 0,
2210
+ processedQuestions: 0,
2211
+ failedInteractions: 0,
2212
+ noGrowthConfirmations: 0
2213
+ };
2214
+ }
2215
+ emptyLinkDiagnostics() {
2216
+ return {
2217
+ linkCandidatesObserved: 0,
2218
+ plainLinksObserved: 0,
2219
+ googleUrlRedirectsObserved: 0,
2220
+ googleGotoRedirectsObserved: 0,
2221
+ redirectsResolved: 0,
2222
+ redirectsUnresolved: 0,
2223
+ redirectsRejected: 0,
2224
+ resolverNonGoogleRequestsObserved: 0,
2225
+ paaSourcesPreserved: 0,
2226
+ aiOverviewLabelValidated: false
2227
+ };
2228
+ }
2229
+ mergePaaSources(existing = [], incoming = []) {
2230
+ const byRawUrl = /* @__PURE__ */ new Map();
2231
+ for (const source of [...existing, ...incoming]) {
2232
+ const current = byRawUrl.get(source.rawUrl);
2233
+ if (!current) {
2234
+ byRawUrl.set(source.rawUrl, source);
2235
+ continue;
2236
+ }
2237
+ const selected = source.resolutionStatus === "resolved" && current.resolutionStatus !== "resolved" ? source : current;
2238
+ byRawUrl.set(source.rawUrl, { ...selected, title: selected.title ?? current.title ?? source.title, site: selected.site ?? current.site ?? source.site });
2239
+ }
2240
+ return [...byRawUrl.values()];
2241
+ }
2242
+ normalizeAICitations(pageUrl, citations) {
2243
+ const seen = /* @__PURE__ */ new Set();
2244
+ return citations.flatMap((citation) => {
2245
+ const link = classifyGoogleOutboundLink(citation.rawHref ?? citation.href, pageUrl);
2246
+ if (!link || seen.has(link.rawUrl)) return [];
2247
+ seen.add(link.rawUrl);
2248
+ const { url, ...metadata } = link;
2249
+ return [{ ...metadata, text: citation.text, href: url }];
2250
+ });
2251
+ }
2252
+ async resolveMaterialLinks(page, flat, organicResults, aiOverview, aiMode) {
2253
+ const candidates = [
2254
+ ...flat.flatMap((row) => row.sources ?? []),
2255
+ ...organicResults,
2256
+ ...aiOverview.citations.map(({ text: _text, href, ...link }) => ({ ...link, url: href })),
2257
+ ...aiMode.citations.map(({ text: _text, href, ...link }) => ({ ...link, url: href }))
2258
+ ];
2259
+ const unique = new Map(candidates.map((link) => [link.rawUrl, link]));
2260
+ let resolverNonGoogleRequestsObserved = 0;
2261
+ const resolved = await resolveGoogleOutboundLinks(page, unique.values(), {
2262
+ onNetworkAudit: (audit) => {
2263
+ resolverNonGoogleRequestsObserved = audit.nonGoogleRequestsObserved;
2264
+ }
2265
+ });
2266
+ const replace = (link) => ({ ...link, ...resolved.get(link.rawUrl) ?? link });
2267
+ for (const row of flat) {
2268
+ row.sources = (row.sources ?? []).map(replace);
2269
+ const primary = row.sources[0];
2270
+ row.source_title = primary?.title ?? row.source_title;
2271
+ row.source_site = primary?.site ?? row.source_site;
2272
+ row.source_cite = primary?.url ?? row.source_cite;
2273
+ }
2274
+ for (let index = 0; index < organicResults.length; index++) {
2275
+ const next = replace(organicResults[index]);
2276
+ if (next.resolvedUrl) {
2277
+ try {
2278
+ next.domain = new URL(next.resolvedUrl).hostname.replace(/^www\./, "");
2279
+ } catch {
2280
+ }
2281
+ }
2282
+ organicResults[index] = next;
2283
+ }
2284
+ const replaceCitation = (citation) => {
2285
+ const link = replace({ url: citation.href, rawUrl: citation.rawUrl, resolvedUrl: citation.resolvedUrl, linkType: citation.linkType, resolutionStatus: citation.resolutionStatus });
2286
+ const { url, ...metadata } = link;
2287
+ return { ...metadata, text: citation.text, href: url };
2288
+ };
2289
+ aiOverview.citations = aiOverview.citations.map(replaceCitation);
2290
+ aiMode.citations = aiMode.citations.map(replaceCitation);
2291
+ const values = [...resolved.values()];
2292
+ this.linkDiagnostics = {
2293
+ linkCandidatesObserved: unique.size,
2294
+ plainLinksObserved: values.filter((link) => link.linkType === "plain").length,
2295
+ googleUrlRedirectsObserved: values.filter((link) => link.linkType === "google_url_redirect").length,
2296
+ googleGotoRedirectsObserved: values.filter((link) => link.linkType === "google_goto_redirect").length,
2297
+ redirectsResolved: values.filter((link) => link.linkType !== "plain" && link.resolutionStatus === "resolved").length,
2298
+ redirectsUnresolved: values.filter((link) => link.resolutionStatus === "unresolved").length,
2299
+ redirectsRejected: values.filter((link) => link.resolutionStatus === "rejected").length,
2300
+ resolverNonGoogleRequestsObserved,
2301
+ paaSourcesPreserved: flat.reduce((total, row) => total + (row.sources ?? []).length, 0),
2302
+ aiOverviewLabelValidated: aiOverview.detected
2303
+ };
2304
+ }
1930
2305
  normalizeQuestion(q) {
1931
2306
  return q.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim();
1932
2307
  }
@@ -1963,34 +2338,6 @@ var PAAExtractor = class {
1963
2338
  function cleanSourceLabel(value) {
1964
2339
  return (value ?? "").replace(/\s*\(\+\d+\)\s*-\s*View related links.*$/i, "").replace(/\s*-\s*Opens in new tab.*$/i, "").trim();
1965
2340
  }
1966
- function isGoogleHost(host) {
1967
- const normalized = host.toLowerCase().replace(/\.$/, "");
1968
- return normalized === "google.com" || normalized.endsWith(".google.com") || /^google\.[a-z.]+$/.test(normalized) || /\.google\.[a-z.]+$/.test(normalized) || normalized === "googleusercontent.com" || normalized.endsWith(".googleusercontent.com");
1969
- }
1970
- function safeCitationTarget(rawHref) {
1971
- let parsed;
1972
- try {
1973
- parsed = new URL(rawHref);
1974
- } catch {
1975
- return null;
1976
- }
1977
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
1978
- if (parsed.username || parsed.password) return null;
1979
- if (isGoogleHost(parsed.hostname)) {
1980
- if (parsed.pathname !== "/url") return null;
1981
- const redirected = parsed.searchParams.get("q") ?? parsed.searchParams.get("url");
1982
- if (!redirected) return null;
1983
- try {
1984
- parsed = new URL(redirected);
1985
- } catch {
1986
- return null;
1987
- }
1988
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
1989
- if (parsed.username || parsed.password) return null;
1990
- }
1991
- if (isGoogleHost(parsed.hostname)) return null;
1992
- return { href: parsed.href, host: parsed.hostname };
1993
- }
1994
2341
  return Array.from(document.querySelectorAll(selectors.item)).filter((pair) => !onlyQuestion2 || (pair.getAttribute(selectors.itemDataQ) || pair.getAttribute(selectors.itemDataInitQ) || pair.querySelector(selectors.itemQuestionEl)?.innerText?.trim() || "") === onlyQuestion2).map((pair) => {
1995
2342
  const clickTarget = pair.querySelector(selectors.clickTarget);
1996
2343
  const expanded = pair.classList.contains(selectors.expandedClass) || clickTarget?.getAttribute("aria-expanded") === "true";
@@ -1998,50 +2345,62 @@ var PAAExtractor = class {
1998
2345
  const aiAnswerBody = answerContainer?.querySelector(selectors.aiAnswerBody);
1999
2346
  const answerElement = aiAnswerBody ?? answerContainer;
2000
2347
  const answerText = cleanText(answerElement);
2001
- const hasLoadedAnswer = expanded || answerText.length > 30 && !/an error has occurred/i.test(answerText.slice(0, 80));
2348
+ const citationCandidates = [
2349
+ ...Array.from((answerElement ?? pair).querySelectorAll("a[href]")),
2350
+ ...Array.from(pair.querySelectorAll(selectors.aiCitation)),
2351
+ ...Array.from(pair.querySelectorAll(selectors.aiSourceCard))
2352
+ ];
2353
+ const hasLoadedAnswer = expanded || answerText.length > 30 && !/an error has occurred/i.test(answerText.slice(0, 80)) && citationCandidates.some((anchor) => /^https?:\/\//.test(anchor.href));
2002
2354
  let sourceTitle = hasLoadedAnswer ? pair.querySelector(selectors.sourceTitle)?.innerText?.trim() || void 0 : void 0;
2003
2355
  let sourceSite = hasLoadedAnswer ? pair.querySelector(selectors.sourceSite)?.innerText?.trim() || void 0 : void 0;
2004
- let sourceCite = hasLoadedAnswer ? pair.querySelector(selectors.sourceCite)?.innerText?.trim() || void 0 : void 0;
2005
- if (hasLoadedAnswer && !sourceCite) {
2356
+ const sources = [];
2357
+ if (hasLoadedAnswer) {
2006
2358
  const seenHrefs = /* @__PURE__ */ new Set();
2007
- const anchors = [];
2008
- const anchorRoot = answerElement ?? pair;
2009
- const prioritized = [
2010
- ...Array.from(anchorRoot.querySelectorAll(selectors.aiCitation)),
2011
- ...Array.from(anchorRoot.querySelectorAll(selectors.aiSourceCard)),
2012
- ...Array.from(anchorRoot.querySelectorAll("a[href]"))
2013
- ];
2014
- for (const a of prioritized) {
2015
- const target = safeCitationTarget(a.href);
2016
- if (!target || seenHrefs.has(target.href)) continue;
2017
- seenHrefs.add(target.href);
2018
- anchors.push({
2019
- text: (a.textContent || "").trim(),
2020
- label: cleanSourceLabel(a.getAttribute("aria-label")),
2021
- href: target.href,
2022
- host: target.host
2023
- });
2024
- }
2025
- const primary = anchors.find((x) => x.label.length > 2 || x.text.length > 2) ?? anchors[0];
2026
- if (primary) {
2027
- sourceTitle = sourceTitle || primary.label || primary.text || void 0;
2028
- sourceSite = sourceSite || primary.host.replace(/^www\./, "");
2029
- sourceCite = primary.href;
2359
+ for (const a of citationCandidates) {
2360
+ if (!/^https?:\/\//.test(a.href) || seenHrefs.has(a.href)) continue;
2361
+ seenHrefs.add(a.href);
2362
+ sources.push({ title: cleanSourceLabel(a.getAttribute("aria-label")) || (a.textContent || "").trim(), rawHref: a.href });
2030
2363
  }
2364
+ const primary = sources.find((x) => x.title.length > 2) ?? sources[0];
2365
+ if (primary) sourceTitle = sourceTitle || primary.title || void 0;
2031
2366
  }
2032
2367
  return {
2368
+ googleLinkId: pair.getAttribute("data-lk") || pair.querySelector("[data-lk]")?.getAttribute("data-lk") || null,
2369
+ googleEvidenceId: pair.getAttribute("data-ved") || pair.querySelector("[data-ved]")?.getAttribute("data-ved") || null,
2033
2370
  question: pair.getAttribute(selectors.itemDataQ) || pair.getAttribute(selectors.itemDataInitQ) || pair.querySelector(selectors.itemQuestionEl)?.innerText?.trim() || "",
2034
2371
  answer: hasLoadedAnswer ? answerText || void 0 : void 0,
2035
2372
  sourceTitle,
2036
2373
  sourceSite,
2037
- sourceCite
2374
+ sources
2038
2375
  };
2039
2376
  });
2040
2377
  }, { selectors: sels, onlyQuestion });
2378
+ const baseUrl = typeof page.url === "function" ? page.url() : "https://www.google.com/";
2041
2379
  return raw.flatMap((item) => {
2380
+ const sources = [];
2381
+ const seen = /* @__PURE__ */ new Set();
2382
+ for (const candidate of item.sources) {
2383
+ const link = classifyGoogleOutboundLink(candidate.rawHref, baseUrl);
2384
+ if (!link || seen.has(link.rawUrl)) continue;
2385
+ seen.add(link.rawUrl);
2386
+ let site = item.sourceSite ?? null;
2387
+ try {
2388
+ site ||= new URL(link.resolvedUrl ?? link.rawUrl).hostname.replace(/^www\./, "");
2389
+ } catch {
2390
+ }
2391
+ sources.push({ ...link, title: candidate.title || item.sourceTitle || null, site });
2392
+ }
2393
+ const primary = sources[0];
2042
2394
  const cleaned = {
2043
- ...item,
2044
- answer: cleanPAAAnswerText(item.answer, item.question, item.sourceTitle)
2395
+ questionId: questionIdFor(item.question),
2396
+ googleLinkId: item.googleLinkId,
2397
+ googleEvidenceId: item.googleEvidenceId,
2398
+ question: item.question,
2399
+ answer: cleanPAAAnswerText(item.answer, item.question, item.sourceTitle),
2400
+ sourceTitle: primary?.title ?? item.sourceTitle,
2401
+ sourceSite: primary?.site ?? item.sourceSite,
2402
+ sourceCite: primary?.url,
2403
+ sources
2045
2404
  };
2046
2405
  const result = RawPAAItemSchema.safeParse(cleaned);
2047
2406
  if (!result.success) {
@@ -2066,11 +2425,15 @@ var PAAExtractor = class {
2066
2425
  toFlatRow(item, depth, parentQuestion, seed) {
2067
2426
  return {
2068
2427
  seed_query: seed,
2428
+ question_id: item.questionId ?? questionIdFor(item.question),
2429
+ google_link_id: item.googleLinkId ?? "",
2430
+ google_evidence_id: item.googleEvidenceId ?? "",
2069
2431
  question: item.question,
2070
2432
  answer: item.answer ?? "",
2071
2433
  source_title: item.sourceTitle ?? "",
2072
2434
  source_site: item.sourceSite ?? "",
2073
2435
  source_cite: item.sourceCite ?? "",
2436
+ sources: item.sources ?? [],
2074
2437
  depth,
2075
2438
  parent_question: parentQuestion ?? "",
2076
2439
  extracted_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -2091,20 +2454,23 @@ var PAAExtractor = class {
2091
2454
  source_title: row.source_title || existing.source_title,
2092
2455
  source_site: row.source_site || existing.source_site,
2093
2456
  source_cite: row.source_cite || existing.source_cite,
2457
+ sources: this.mergePaaSources(existing.sources, row.sources),
2094
2458
  extracted_at: row.extracted_at || existing.extracted_at
2095
2459
  };
2096
- const changed = merged.answer !== existing.answer || merged.source_title !== existing.source_title || merged.source_site !== existing.source_site || merged.source_cite !== existing.source_cite;
2460
+ const changed = merged.answer !== existing.answer || merged.source_title !== existing.source_title || merged.source_site !== existing.source_site || merged.source_cite !== existing.source_cite || JSON.stringify(merged.sources) !== JSON.stringify(existing.sources);
2097
2461
  if (changed) this.collectedRows[index] = merged;
2098
2462
  return changed;
2099
2463
  }
2100
2464
  async emitProgress(phase) {
2101
- if (!this.onPaaProgress || this.collectedRows.length === 0) return;
2465
+ if (!this.onPaaProgress || this.collectedRows.length === 0 && !this.checkpointMaterial) return;
2102
2466
  const snapshot = {
2103
2467
  sequence: ++this.progressSequence,
2104
2468
  observedAt: (/* @__PURE__ */ new Date()).toISOString(),
2105
2469
  clickedQuestions: [...this.clickedQuestions],
2106
2470
  phase,
2107
- records: this.collectedRows.map((row) => ({ ...row }))
2471
+ records: this.collectedRows.map((row) => ({ ...row })),
2472
+ lifecycle: { ...this.paaLifecycle },
2473
+ ...this.checkpointMaterial ? { material: structuredClone(this.checkpointMaterial) } : {}
2108
2474
  };
2109
2475
  try {
2110
2476
  await this.onPaaProgress(snapshot);
@@ -2119,10 +2485,11 @@ var PAAExtractor = class {
2119
2485
  }
2120
2486
  getPartialResult() {
2121
2487
  const ctx = this.partialContext;
2122
- if (!ctx || this.collectedRows.length === 0) return null;
2488
+ if (!ctx || this.collectedRows.length === 0 && !this.checkpointMaterial) return null;
2123
2489
  const flat = this.collectedRows.map((row) => ({ ...row }));
2490
+ const material = this.checkpointMaterial;
2124
2491
  const paaWithoutAnswer = ctx.questionsOnly ? 0 : flat.filter((row) => !row.answer?.trim()).length;
2125
- const paaWithoutSource = ctx.questionsOnly ? 0 : flat.filter((row) => !row.source_cite?.trim()).length;
2492
+ const paaWithoutSource = ctx.questionsOnly ? 0 : flat.filter((row) => (row.sources ?? []).length === 0).length;
2126
2493
  const stats = {
2127
2494
  seed: ctx.seed,
2128
2495
  totalQuestions: flat.length,
@@ -2140,6 +2507,14 @@ var PAAExtractor = class {
2140
2507
  resultQuality: "partial",
2141
2508
  degradedResult: false,
2142
2509
  retryRecommended: true,
2510
+ paaLifecycle: {
2511
+ ...this.paaLifecycle,
2512
+ questionsFound: flat.length,
2513
+ targetStatus: flat.length >= (ctx.maxQuestions ?? 0) ? "met" : "not_met",
2514
+ discoveryStatus: "interrupted",
2515
+ materialStatus: paaWithoutAnswer > 0 || paaWithoutSource > 0 ? "partial" : "complete",
2516
+ completionReason: "The browser attempt ended before frontier exhaustion could be proven."
2517
+ },
2143
2518
  completeness: {
2144
2519
  ...this.completeness,
2145
2520
  paaWithoutAnswer,
@@ -2156,17 +2531,17 @@ var PAAExtractor = class {
2156
2531
  }]
2157
2532
  },
2158
2533
  totalQuestions: flat.length,
2159
- surface: "web",
2160
- aiOverview: { detected: false, text: null, citations: [] },
2161
- aiMode: { detected: false, text: null, citations: [] },
2162
- whatPeopleSaying: [],
2534
+ surface: material?.surface ?? "web",
2535
+ aiOverview: material?.aiOverview ?? { detected: false, text: null, citations: [] },
2536
+ aiMode: material?.aiMode ?? { detected: false, text: null, citations: [] },
2537
+ whatPeopleSaying: material?.whatPeopleSaying ?? [],
2163
2538
  tree: this.buildTree(flat, ctx.seed),
2164
2539
  flat,
2165
- videos: [],
2166
- forums: [],
2167
- organicResults: [],
2168
- localPack: [],
2169
- entityIds: { entities: [], kgIds: [], cids: [], gcids: [] },
2540
+ videos: material?.videos ?? [],
2541
+ forums: material?.forums ?? [],
2542
+ organicResults: material?.organicResults ?? [],
2543
+ localPack: material?.localPack ?? [],
2544
+ entityIds: material?.entityIds ?? { entities: [], kgIds: [], cids: [], gcids: [] },
2170
2545
  stats
2171
2546
  };
2172
2547
  }
@@ -2175,12 +2550,14 @@ var PAAExtractor = class {
2175
2550
  const seenKeys = /* @__PURE__ */ new Set();
2176
2551
  const seenQs = /* @__PURE__ */ new Set();
2177
2552
  const orderedQs = [];
2178
- this.collectedRows = [];
2179
- this.progressSequence = 0;
2180
2553
  this.clickedQuestions = /* @__PURE__ */ new Set();
2181
2554
  const abandonedNeverReclick = /* @__PURE__ */ new Set();
2182
- const clickedOnceEver = /* @__PURE__ */ new Set();
2555
+ const clickAttempts = /* @__PURE__ */ new Map();
2556
+ const processedQuestions = /* @__PURE__ */ new Set();
2557
+ const failedInteractions = /* @__PURE__ */ new Set();
2183
2558
  const capturedItems = /* @__PURE__ */ new Map();
2559
+ const MAX_CLICK_ATTEMPTS = 2;
2560
+ const EXHAUSTION_CONFIRMATIONS = 3;
2184
2561
  const itemArgs = {
2185
2562
  sel: PAASelectors.item,
2186
2563
  dataQ: PAASelectors.itemDataQ,
@@ -2191,15 +2568,15 @@ var PAAExtractor = class {
2191
2568
  clickTarget: PAASelectors.clickTarget
2192
2569
  };
2193
2570
  const readItemStates = () => page.evaluate(
2194
- ({ sel, dataQ, dataInitQ, questionEl, expandedClass }) => {
2571
+ ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget, answerSel }) => {
2195
2572
  return Array.from(document.querySelectorAll(sel)).map((el) => ({
2196
2573
  q: el.getAttribute(dataQ) || el.getAttribute(dataInitQ) || el.querySelector(questionEl)?.innerText?.trim() || "",
2197
- done: el.classList.contains(expandedClass)
2574
+ done: el.classList.contains(expandedClass) || el.querySelector(clickTarget)?.getAttribute("aria-expanded") === "true" || Boolean((el.querySelector(answerSel)?.textContent ?? "").trim())
2198
2575
  })).filter((s) => s.q);
2199
2576
  },
2200
2577
  itemArgs
2201
2578
  );
2202
- const expandOneItemSerially = async (q) => {
2579
+ const expandOneItemSerially = async (q, beforeCount) => {
2203
2580
  const clickStartedMs = Date.now();
2204
2581
  const items = page.locator(itemArgs.sel);
2205
2582
  const itemIndex = await items.evaluateAll(
@@ -2214,49 +2591,118 @@ var PAAExtractor = class {
2214
2591
  await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
2215
2592
  return true;
2216
2593
  })().catch(() => false) : false;
2217
- if (!clicked) return "failed";
2594
+ if (!clicked) return { dispatched: false, confirmed: false };
2218
2595
  const remainingHumanClickDelayMs = 700 - (Date.now() - clickStartedMs);
2219
2596
  if (remainingHumanClickDelayMs > 0) await page.waitForTimeout(remainingHumanClickDelayMs);
2220
- return "ok";
2597
+ const confirmed = await page.waitForFunction(
2598
+ ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget: clickTarget2, answerSel, target, min }) => {
2599
+ const items2 = Array.from(document.querySelectorAll(sel));
2600
+ const item = items2.find((element) => (element.getAttribute(dataQ) || element.getAttribute(dataInitQ) || element.querySelector(questionEl)?.innerText?.trim()) === target);
2601
+ if (!item) return items2.length > min;
2602
+ return item.classList.contains(expandedClass) || item.querySelector(clickTarget2)?.getAttribute("aria-expanded") === "true" || Boolean((item.querySelector(answerSel)?.textContent ?? "").trim()) || items2.length > min;
2603
+ },
2604
+ { ...itemArgs, target: q, min: beforeCount },
2605
+ { timeout: 2500, polling: 100 }
2606
+ ).catch(() => null);
2607
+ return { dispatched: true, confirmed: Boolean(confirmed) };
2221
2608
  };
2222
2609
  let round = 0;
2223
- let growthWaits = 0;
2224
- while (true) {
2225
- if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) break;
2610
+ expansion: while (true) {
2611
+ if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) {
2612
+ this.paaLifecycle = {
2613
+ ...this.paaLifecycle,
2614
+ discoveryStatus: "interrupted",
2615
+ completionReason: "The execution deadline arrived before frontier exhaustion could be proven."
2616
+ };
2617
+ throw new PaaInteractionInterruptedError(this.paaLifecycle.completionReason);
2618
+ }
2226
2619
  this.throwIfAborted(signal);
2227
2620
  const states = await readItemStates();
2228
2621
  let discovered = false;
2229
- for (const s of states) {
2230
- if (!seenQs.has(s.q)) {
2231
- seenQs.add(s.q);
2232
- orderedQs.push(s.q);
2622
+ for (const state of states) {
2623
+ if (!seenQs.has(state.q)) {
2624
+ seenQs.add(state.q);
2625
+ orderedQs.push(state.q);
2233
2626
  if (orderedQs.length <= options.maxQuestions) {
2234
- discovered = this.upsertCollectedRow(this.toFlatRow({ question: s.q }, 1, null, options.query)) || discovered;
2627
+ discovered = this.upsertCollectedRow(this.toFlatRow({ questionId: questionIdFor(state.q), googleLinkId: null, googleEvidenceId: null, question: state.q, sources: [] }, 1, null, options.query)) || discovered;
2235
2628
  }
2236
2629
  }
2630
+ if (state.done) processedQuestions.add(state.q);
2237
2631
  }
2632
+ this.paaLifecycle = {
2633
+ ...this.paaLifecycle,
2634
+ questionsFound: Math.min(orderedQs.length, options.maxQuestions),
2635
+ targetStatus: orderedQs.length >= options.maxQuestions ? "met" : "not_met",
2636
+ knownQuestions: orderedQs.length,
2637
+ processedQuestions: processedQuestions.size,
2638
+ failedInteractions: failedInteractions.size
2639
+ };
2238
2640
  if (discovered) await this.emitProgress("discovered");
2239
- if (options.questionsOnly && seenQs.size >= options.maxQuestions) break;
2641
+ if (options.questionsOnly && seenQs.size >= options.maxQuestions) {
2642
+ this.paaLifecycle = {
2643
+ ...this.paaLifecycle,
2644
+ discoveryStatus: "target_reached",
2645
+ targetStatus: "met",
2646
+ completionReason: "The requested unique-question count was observed."
2647
+ };
2648
+ break;
2649
+ }
2240
2650
  const kept = new Set(orderedQs.slice(0, options.maxQuestions));
2241
- const clickable = states.filter((s) => kept.has(s.q) && !clickedOnceEver.has(s.q) && !abandonedNeverReclick.has(s.q));
2242
- const target = clickable.find((s) => !s.done) ?? (seenQs.size < options.maxQuestions ? clickable[0] : void 0);
2651
+ const clickable = states.filter((state) => kept.has(state.q) && !state.done && (clickAttempts.get(state.q) ?? 0) < MAX_CLICK_ATTEMPTS && !abandonedNeverReclick.has(state.q));
2652
+ const target = clickable[0];
2243
2653
  if (!target) {
2244
- if (seenQs.size >= options.maxQuestions) break;
2245
- if (++growthWaits > 1) break;
2246
- const grew = await page.waitForFunction(
2247
- ({ sel, min }) => document.querySelectorAll(sel).length > min,
2248
- { sel: PAASelectors.item, min: states.length },
2249
- { timeout: 2e3, polling: 100 }
2250
- ).catch(() => null);
2251
- if (!grew) continue;
2252
- continue;
2654
+ if (seenQs.size >= options.maxQuestions) {
2655
+ this.paaLifecycle = {
2656
+ ...this.paaLifecycle,
2657
+ discoveryStatus: "target_reached",
2658
+ targetStatus: "met",
2659
+ completionReason: "The requested unique-question count was observed."
2660
+ };
2661
+ break;
2662
+ }
2663
+ const visible = new Set(states.map((state) => state.q));
2664
+ const unresolved2 = orderedQs.slice(0, options.maxQuestions).filter(
2665
+ (question) => !processedQuestions.has(question) && (failedInteractions.has(question) || !visible.has(question))
2666
+ );
2667
+ if (unresolved2.length > 0) {
2668
+ this.paaLifecycle = {
2669
+ ...this.paaLifecycle,
2670
+ discoveryStatus: "interrupted",
2671
+ failedInteractions: (/* @__PURE__ */ new Set([...failedInteractions, ...unresolved2])).size,
2672
+ completionReason: "At least one known PAA control failed or disappeared before it could be confirmed."
2673
+ };
2674
+ await this.emitProgress("expansion_finished");
2675
+ throw new PaaInteractionInterruptedError(this.paaLifecycle.completionReason);
2676
+ }
2677
+ for (let confirmation = 1; confirmation <= EXHAUSTION_CONFIRMATIONS; confirmation++) {
2678
+ this.throwIfAborted(signal);
2679
+ await this.throwIfCaptcha(page, "Google PAA exhaustion confirmation");
2680
+ await page.mouse.wheel(0, 320);
2681
+ const grew = await page.waitForFunction(
2682
+ ({ sel, min }) => document.querySelectorAll(sel).length > min,
2683
+ { sel: PAASelectors.item, min: states.length },
2684
+ { timeout: 1500, polling: 100 }
2685
+ ).catch(() => null);
2686
+ this.paaLifecycle = { ...this.paaLifecycle, noGrowthConfirmations: confirmation };
2687
+ if (grew) continue expansion;
2688
+ const confirmationStates = await readItemStates();
2689
+ if (confirmationStates.some((state) => !seenQs.has(state.q))) continue expansion;
2690
+ }
2691
+ this.paaLifecycle = {
2692
+ ...this.paaLifecycle,
2693
+ discoveryStatus: "frontier_exhausted",
2694
+ targetStatus: "not_met",
2695
+ failedInteractions: 0,
2696
+ noGrowthConfirmations: EXHAUSTION_CONFIRMATIONS,
2697
+ completionReason: "Every known eligible PAA control was processed and three healthy no-growth confirmations found no additional questions."
2698
+ };
2699
+ break;
2253
2700
  }
2254
- growthWaits = 0;
2255
2701
  this.reporter.onDepth(++round);
2256
2702
  await this.throwIfCaptcha(page, "Google PAA expansion");
2257
- clickedOnceEver.add(target.q);
2258
- this.clickedQuestions.add(target.q);
2259
- const expansionStatus = await expandOneItemSerially(target.q);
2703
+ clickAttempts.set(target.q, (clickAttempts.get(target.q) ?? 0) + 1);
2704
+ const expansionResult = await expandOneItemSerially(target.q, states.length);
2705
+ if (expansionResult.dispatched) this.clickedQuestions.add(target.q);
2260
2706
  if (!options.questionsOnly) {
2261
2707
  let clickedItem = (await this.extractVisibleItems(page, target.q))[0];
2262
2708
  if (!clickedItem?.answer) {
@@ -2270,27 +2716,21 @@ var PAAExtractor = class {
2270
2716
  }
2271
2717
  }
2272
2718
  }
2273
- if (expansionStatus === "failed" && !capturedItems.has(target.q)) {
2719
+ const captured = capturedItems.get(target.q);
2720
+ if (expansionResult.confirmed || Boolean(captured?.answer?.trim())) {
2721
+ processedQuestions.add(target.q);
2722
+ failedInteractions.delete(target.q);
2723
+ } else if ((clickAttempts.get(target.q) ?? 0) >= MAX_CLICK_ATTEMPTS) {
2724
+ failedInteractions.add(target.q);
2274
2725
  abandonedNeverReclick.add(target.q);
2275
2726
  }
2276
- }
2277
- if (options.questionsOnly && seenQs.size < options.maxQuestions) {
2278
- for (let recoveryRound = 0; recoveryRound < options.questionGrowthRecoveryRounds && seenQs.size < options.maxQuestions; recoveryRound++) {
2279
- if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) break;
2280
- const reExpanded = await this.reExpandCollapsedItems(page).catch(() => false);
2281
- if (!reExpanded) break;
2282
- await page.waitForTimeout(900);
2283
- for (const state of await readItemStates()) {
2284
- if (!seenQs.has(state.q)) {
2285
- seenQs.add(state.q);
2286
- orderedQs.push(state.q);
2287
- if (orderedQs.length <= options.maxQuestions) {
2288
- this.upsertCollectedRow(this.toFlatRow({ question: state.q }, 1, null, options.query));
2289
- }
2290
- }
2291
- }
2292
- await this.emitProgress("discovered");
2293
- }
2727
+ this.paaLifecycle = {
2728
+ ...this.paaLifecycle,
2729
+ processedQuestions: processedQuestions.size,
2730
+ failedInteractions: failedInteractions.size,
2731
+ noGrowthConfirmations: 0
2732
+ };
2733
+ await this.emitProgress("click_observed");
2294
2734
  }
2295
2735
  const itemMap = options.questionsOnly ? /* @__PURE__ */ new Map() : new Map(capturedItems);
2296
2736
  if (!options.questionsOnly) {
@@ -2307,24 +2747,34 @@ var PAAExtractor = class {
2307
2747
  const row = this.toFlatRow(item, 1, null, options.query);
2308
2748
  this.upsertCollectedRow(row);
2309
2749
  results.push(row);
2310
- this.reporter.onQuestion({ question: item.question, answer: item.answer ?? null, sourceTitle: item.sourceTitle ?? null, sourceSite: item.sourceSite ?? null, sourceCite: item.sourceCite ?? null, depth: 1, parentQuestion: null, children: [] });
2750
+ this.reporter.onQuestion({ questionId: item.questionId, googleLinkId: item.googleLinkId, googleEvidenceId: item.googleEvidenceId, question: item.question, answer: item.answer ?? null, sourceTitle: item.sourceTitle ?? null, sourceSite: item.sourceSite ?? null, sourceCite: item.sourceCite ?? null, sources: item.sources, depth: 1, parentQuestion: null, children: [] });
2311
2751
  } else {
2312
- const row = this.toFlatRow({ question: q, answer: void 0, sourceTitle: void 0, sourceSite: void 0, sourceCite: void 0 }, 1, null, options.query);
2752
+ const row = this.toFlatRow({ questionId: questionIdFor(q), googleLinkId: null, googleEvidenceId: null, question: q, answer: void 0, sourceTitle: void 0, sourceSite: void 0, sourceCite: void 0, sources: [] }, 1, null, options.query);
2313
2753
  this.upsertCollectedRow(row);
2314
2754
  results.push(row);
2315
2755
  }
2316
2756
  }
2317
2757
  this.completeness.paaWithoutAnswer = options.questionsOnly ? 0 : results.filter((r) => !r.answer).length;
2318
- this.completeness.paaWithoutSource = options.questionsOnly ? 0 : results.filter((r) => !r.source_cite).length;
2758
+ this.completeness.paaWithoutSource = options.questionsOnly ? 0 : results.filter((r) => (r.sources ?? []).length === 0).length;
2319
2759
  this.completeness.paaRequested = options.maxQuestions;
2320
2760
  this.completeness.paaReturned = results.length;
2321
2761
  this.completeness.paaUnique = seenKeys.size;
2762
+ const materialPartial = !options.questionsOnly && (this.completeness.paaWithoutAnswer > 0 || this.completeness.paaWithoutSource > 0);
2763
+ this.paaLifecycle = {
2764
+ ...this.paaLifecycle,
2765
+ questionsFound: results.length,
2766
+ targetStatus: results.length >= options.maxQuestions ? "met" : "not_met",
2767
+ materialStatus: materialPartial ? "partial" : "complete",
2768
+ processedQuestions: processedQuestions.size,
2769
+ failedInteractions: failedInteractions.size
2770
+ };
2322
2771
  await this.emitProgress("fill_incomplete");
2772
+ await this.emitProgress("expansion_finished");
2323
2773
  return results;
2324
2774
  }
2325
2775
  async fillIncompleteItems(page, orderedQs, itemMap, options, abandonedNeverReclick) {
2326
2776
  const kept = new Set(orderedQs.slice(0, options.maxQuestions));
2327
- const isIncomplete = (item) => !item?.answer || !(item.sourceTitle || item.sourceSite || item.sourceCite);
2777
+ const isIncomplete = (item) => !item?.answer || (item.sources ?? []).length === 0;
2328
2778
  const missingBefore = new Set(
2329
2779
  [...kept].filter((q) => isIncomplete(itemMap.get(q)))
2330
2780
  );
@@ -2342,7 +2792,8 @@ var PAAExtractor = class {
2342
2792
  answer: item.answer ?? existing?.answer,
2343
2793
  sourceTitle: item.sourceTitle ?? existing?.sourceTitle,
2344
2794
  sourceSite: item.sourceSite ?? existing?.sourceSite,
2345
- sourceCite: item.sourceCite ?? existing?.sourceCite
2795
+ sourceCite: item.sourceCite ?? existing?.sourceCite,
2796
+ sources: this.mergePaaSources(existing?.sources, item.sources)
2346
2797
  });
2347
2798
  const upgraded = itemMap.get(q);
2348
2799
  if (upgraded && this.upsertCollectedRow(this.toFlatRow(upgraded, 1, null, options.query))) {
@@ -2529,7 +2980,7 @@ var PAAExtractor = class {
2529
2980
  }
2530
2981
  async extractOrganicResults(page) {
2531
2982
  const sels = OrganicSelectors;
2532
- return page.evaluate((s) => {
2983
+ const raw = await page.evaluate((s) => {
2533
2984
  const out = [];
2534
2985
  let pos = 0;
2535
2986
  document.querySelectorAll(s.result).forEach((card) => {
@@ -2555,6 +3006,17 @@ var PAAExtractor = class {
2555
3006
  });
2556
3007
  return out;
2557
3008
  }, sels);
3009
+ const baseUrl = typeof page.url === "function" ? page.url() : "https://www.google.com/";
3010
+ return raw.flatMap((item) => {
3011
+ const link = classifyGoogleOutboundLink(item.url, baseUrl);
3012
+ if (!link) return [];
3013
+ let domain = item.domain;
3014
+ try {
3015
+ domain = new URL(link.resolvedUrl ?? link.rawUrl).hostname.replace(/^www\./, "");
3016
+ } catch {
3017
+ }
3018
+ return [{ ...item, ...link, domain }];
3019
+ });
2558
3020
  }
2559
3021
  async extractLocalPack(page) {
2560
3022
  const sels = LocalPackSelectors;
@@ -2706,15 +3168,21 @@ var PAAExtractor = class {
2706
3168
  aim: AIModeSelectors,
2707
3169
  expandWaitMs: 1500
2708
3170
  });
2709
- if (!surfaces.aiOverview.detected) {
3171
+ const pageUrl = typeof page.url === "function" ? page.url() : "https://www.google.com/";
3172
+ const normalized = {
3173
+ surface: surfaces.surface,
3174
+ aiOverview: { ...surfaces.aiOverview, citations: this.normalizeAICitations(pageUrl, surfaces.aiOverview.citations) },
3175
+ aiMode: { ...surfaces.aiMode, citations: this.normalizeAICitations(pageUrl, surfaces.aiMode.citations) }
3176
+ };
3177
+ if (!normalized.aiOverview.detected) {
2710
3178
  this.completeness.aioShareCaptured = null;
2711
- return surfaces;
3179
+ return normalized;
2712
3180
  }
2713
3181
  const shareUrl = this.aioShareUrlEarly ?? await this.captureAIOverviewShareUrl(page, options).catch(() => null);
2714
3182
  this.completeness.aioShareCaptured = shareUrl !== null;
2715
3183
  return {
2716
- ...surfaces,
2717
- aiOverview: { ...surfaces.aiOverview, shareUrl }
3184
+ ...normalized,
3185
+ aiOverview: { ...normalized.aiOverview, shareUrl }
2718
3186
  };
2719
3187
  }
2720
3188
  async captureAIOverviewShareUrl(page, options) {
@@ -2759,11 +3227,15 @@ var PAAExtractor = class {
2759
3227
  const nodeMap = /* @__PURE__ */ new Map();
2760
3228
  for (const row of flat) {
2761
3229
  const node = {
3230
+ questionId: row.question_id,
3231
+ googleLinkId: row.google_link_id || null,
3232
+ googleEvidenceId: row.google_evidence_id || null,
2762
3233
  question: row.question,
2763
3234
  answer: row.answer || null,
2764
3235
  sourceTitle: row.source_title || null,
2765
3236
  sourceSite: row.source_site || null,
2766
3237
  sourceCite: row.source_cite || null,
3238
+ sources: row.sources ?? [],
2767
3239
  depth: row.depth,
2768
3240
  parentQuestion: row.parent_question || null,
2769
3241
  children: []
@@ -2810,10 +3282,13 @@ var PAAExtractor = class {
2810
3282
  async extract(options, signal) {
2811
3283
  const startMs = Date.now();
2812
3284
  this.completeness = { paaWithoutAnswer: 0, paaWithoutSource: 0, paaAnswersRecovered: 0, aioShareCaptured: null };
3285
+ this.linkDiagnostics = this.emptyLinkDiagnostics();
2813
3286
  this.aioShareUrlEarly = null;
2814
3287
  this.collectedRows = [];
2815
3288
  this.progressSequence = 0;
3289
+ this.checkpointMaterial = null;
2816
3290
  this.clickedQuestions = /* @__PURE__ */ new Set();
3291
+ this.paaLifecycle = this.emptyPaaLifecycle(options.maxQuestions);
2817
3292
  this.partialContext = {
2818
3293
  seed: options.query,
2819
3294
  location: options.location ?? null,
@@ -2876,8 +3351,17 @@ var PAAExtractor = class {
2876
3351
  this.throwIfAborted(signal);
2877
3352
  const page = this.driver.getPage();
2878
3353
  await this.throwIfCaptcha(page, "Google SERP");
3354
+ if (!hasPaa) {
3355
+ this.paaLifecycle = {
3356
+ ...this.paaLifecycle,
3357
+ discoveryStatus: "no_paa_observed",
3358
+ materialStatus: "complete",
3359
+ completionReason: "A readable Google SERP was observed without a People Also Ask block."
3360
+ };
3361
+ }
2879
3362
  if (options.questionsOnly) {
2880
3363
  const flat2 = hasPaa ? await this.runBFS(page, executionOptions, signal) : [];
3364
+ await this.resolveMaterialLinks(page, flat2, [], emptyAiSurfaces.aiOverview, emptyAiSurfaces.aiMode);
2881
3365
  const stats2 = {
2882
3366
  seed: executionOptions.query,
2883
3367
  totalQuestions: flat2.length,
@@ -2894,7 +3378,9 @@ var PAAExtractor = class {
2894
3378
  completionStatus: hasPaa ? "paa_found" : "no_paa",
2895
3379
  ...!hasPaa ? { noPaaObserved: true } : {},
2896
3380
  problem: null,
3381
+ paaLifecycle: { ...this.paaLifecycle },
2897
3382
  completeness: { ...this.completeness },
3383
+ links: { ...this.linkDiagnostics },
2898
3384
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, void 0, locationResolution) } : {}
2899
3385
  },
2900
3386
  totalQuestions: flat2.length,
@@ -2937,6 +3423,7 @@ var PAAExtractor = class {
2937
3423
  locationEvidence2 = inferSerpLocationEvidence(canonicalLocation, allOrganic2, localPack2);
2938
3424
  }
2939
3425
  }
3426
+ await this.resolveMaterialLinks(page, [], allOrganic2, aiSurfaces2.aiOverview, aiSurfaces2.aiMode);
2940
3427
  const stats2 = {
2941
3428
  seed: executionOptions.query,
2942
3429
  totalQuestions: 0,
@@ -2953,6 +3440,7 @@ var PAAExtractor = class {
2953
3440
  completionStatus: "serp_only",
2954
3441
  problem: null,
2955
3442
  completeness: { ...this.completeness },
3443
+ links: { ...this.linkDiagnostics },
2956
3444
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence2, locationResolution) } : {}
2957
3445
  },
2958
3446
  totalQuestions: 0,
@@ -2998,6 +3486,7 @@ var PAAExtractor = class {
2998
3486
  }
2999
3487
  }
3000
3488
  const aiSurfaces2 = includeAiOverview ? await this.extractAISurfaces(page, options) : emptyAiSurfaces;
3489
+ await this.resolveMaterialLinks(page, [], noPaaOrganic, aiSurfaces2.aiOverview, aiSurfaces2.aiMode);
3001
3490
  const stats2 = {
3002
3491
  seed: executionOptions.query,
3003
3492
  totalQuestions: 0,
@@ -3014,7 +3503,9 @@ var PAAExtractor = class {
3014
3503
  completionStatus: "no_paa",
3015
3504
  noPaaObserved: true,
3016
3505
  problem: null,
3506
+ paaLifecycle: { ...this.paaLifecycle },
3017
3507
  completeness: { ...this.completeness },
3508
+ links: { ...this.linkDiagnostics },
3018
3509
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence2, locationResolution) } : {}
3019
3510
  },
3020
3511
  totalQuestions: 0,
@@ -3032,9 +3523,21 @@ var PAAExtractor = class {
3032
3523
  stats: stats2
3033
3524
  };
3034
3525
  }
3526
+ const aiSurfaces = includeAiOverview ? await this.extractAISurfaces(page, options) : emptyAiSurfaces;
3527
+ this.checkpointMaterial = {
3528
+ surface: aiSurfaces.surface,
3529
+ aiOverview: aiSurfaces.aiOverview,
3530
+ aiMode: aiSurfaces.aiMode,
3531
+ whatPeopleSaying,
3532
+ videos,
3533
+ forums,
3534
+ organicResults,
3535
+ localPack,
3536
+ entityIds
3537
+ };
3538
+ await this.emitProgress("serp_captured");
3035
3539
  const flat = await this.runBFS(page, executionOptions, signal);
3036
3540
  this.throwIfAborted(signal);
3037
- const aiSurfaces = includeAiOverview ? await this.extractAISurfaces(page, options) : emptyAiSurfaces;
3038
3541
  const shortVidsParams = new URLSearchParams({ q: executionOptions.query, gl: options.gl, hl: options.hl, pws: "0", udm: ShortVideoSelectors.udm });
3039
3542
  if (uule) shortVidsParams.set("uule", uule);
3040
3543
  let shortVideos = [];
@@ -3067,6 +3570,15 @@ var PAAExtractor = class {
3067
3570
  locationEvidence = inferSerpLocationEvidence(canonicalLocation, allOrganic, localPack);
3068
3571
  }
3069
3572
  }
3573
+ await this.resolveMaterialLinks(page, flat, allOrganic, aiSurfaces.aiOverview, aiSurfaces.aiMode);
3574
+ this.checkpointMaterial = {
3575
+ ...this.checkpointMaterial,
3576
+ surface: aiSurfaces.surface,
3577
+ aiOverview: aiSurfaces.aiOverview,
3578
+ aiMode: aiSurfaces.aiMode,
3579
+ videos: [...videos, ...shortVideos],
3580
+ organicResults: allOrganic
3581
+ };
3070
3582
  const allVideos = [...videos, ...shortVideos];
3071
3583
  const tree = this.buildTree(flat, executionOptions.query);
3072
3584
  const stats = {
@@ -3084,7 +3596,9 @@ var PAAExtractor = class {
3084
3596
  diagnostics: {
3085
3597
  completionStatus: "paa_found",
3086
3598
  problem: null,
3599
+ paaLifecycle: { ...this.paaLifecycle },
3087
3600
  completeness: { ...this.completeness },
3601
+ links: { ...this.linkDiagnostics },
3088
3602
  ...diagnosticWarnings.length > 0 ? { warnings: diagnosticWarnings } : {},
3089
3603
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence, locationResolution) } : {}
3090
3604
  },
@@ -3104,6 +3618,13 @@ var PAAExtractor = class {
3104
3618
  };
3105
3619
  } catch (err) {
3106
3620
  errorCount++;
3621
+ if (this.paaLifecycle.discoveryStatus === "in_progress") {
3622
+ this.paaLifecycle = {
3623
+ ...this.paaLifecycle,
3624
+ discoveryStatus: "interrupted",
3625
+ completionReason: "The browser attempt ended before frontier exhaustion could be proven."
3626
+ };
3627
+ }
3107
3628
  await this.emitProgress("attempt_ending").catch(() => {
3108
3629
  });
3109
3630
  this.reporter.onError(err instanceof Error ? err : new Error(String(err)));
@@ -3130,7 +3651,10 @@ var OutputSerializer = class {
3130
3651
  await fs.mkdir(outputDir, { recursive: true });
3131
3652
  const seedRaw = rows[0]?.seed_query ?? "paa";
3132
3653
  const slug = seedRaw.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
3133
- const csv = Papa.unparse(rows, { header: true });
3654
+ const csv = Papa.unparse(rows.map(({ sources, ...row }) => ({
3655
+ ...row,
3656
+ sources_json: JSON.stringify(sources)
3657
+ })), { header: true });
3134
3658
  const filename = `${slug}-${Date.now()}.csv`;
3135
3659
  const fullPath = path.join(outputDir, filename);
3136
3660
  await fs.writeFile(fullPath, csv, "utf8");
@@ -3810,6 +4334,7 @@ function classifyAttemptError(err) {
3810
4334
  if (err instanceof CaptchaError) return "captcha";
3811
4335
  if (err instanceof LocationMismatchError) return "location_mismatch";
3812
4336
  if (err instanceof RequestAbortedError) return "request_aborted";
4337
+ if (err instanceof PaaInteractionInterruptedError) return "browser_session_interrupted";
3813
4338
  if (err instanceof DOMException && (err.name === "TimeoutError" || err.name === "AbortError")) return "timeout";
3814
4339
  const message = err instanceof Error ? err.message : String(err);
3815
4340
  if (/browser (?:has been )?closed|context (?:has been )?closed|target page, context or browser has been closed|session closed|page has been closed/i.test(message)) {
@@ -3834,7 +4359,9 @@ function withResultQuality(result, requestedPaaCount, questionsOnly) {
3834
4359
  const paaWithoutSource = questionsOnly ? 0 : result.flat.filter((row) => !row.source_cite?.trim()).length;
3835
4360
  const noPaa = result.diagnostics.completionStatus === "no_paa" && (result.diagnostics.noPaaObserved === true || hasUsableContent);
3836
4361
  const hasPaa = result.diagnostics.completionStatus === "paa_found";
3837
- const paaIncomplete = hasPaa && (uniquePaaCount < requestedPaaCount || paaWithoutAnswer > 0 || paaWithoutSource > 0 || Boolean(result.diagnostics.interruption));
4362
+ const discoveryStatus = result.diagnostics.paaLifecycle?.discoveryStatus;
4363
+ const discoveryIncomplete = discoveryStatus === "interrupted" || discoveryStatus === "recovery_exhausted";
4364
+ const paaIncomplete = hasPaa && (uniquePaaCount < requestedPaaCount && discoveryStatus !== "frontier_exhausted" || paaWithoutAnswer > 0 || paaWithoutSource > 0 || Boolean(result.diagnostics.interruption) || discoveryIncomplete);
3838
4365
  const resultQuality = noPaa ? "complete" : hasPaa && uniquePaaCount > 0 ? paaIncomplete || hasWarnings ? "partial" : "complete" : hasUsableContent ? hasWarnings ? "partial" : "complete" : "degraded";
3839
4366
  const degradedResult = resultQuality === "degraded";
3840
4367
  result.diagnostics = {
@@ -3851,7 +4378,15 @@ function withResultQuality(result, requestedPaaCount, questionsOnly) {
3851
4378
  paaRequested: requestedPaaCount,
3852
4379
  paaReturned: result.flat.length,
3853
4380
  paaUnique: uniquePaaCount
3854
- }
4381
+ },
4382
+ ...result.diagnostics.paaLifecycle ? {
4383
+ paaLifecycle: {
4384
+ ...result.diagnostics.paaLifecycle,
4385
+ questionsFound: uniquePaaCount,
4386
+ targetStatus: uniquePaaCount >= requestedPaaCount ? "met" : "not_met",
4387
+ materialStatus: paaWithoutAnswer > 0 || paaWithoutSource > 0 ? "partial" : "complete"
4388
+ }
4389
+ } : {}
3855
4390
  };
3856
4391
  return result;
3857
4392
  }
@@ -4258,6 +4793,8 @@ export {
4258
4793
  BrowserDriver,
4259
4794
  validateProviderSessionId,
4260
4795
  brightDataSerpEnabled,
4796
+ questionIdFor,
4797
+ classifyGoogleOutboundLink,
4261
4798
  BACKUP_LAST_RESORT_ATTEMPTS,
4262
4799
  backupProxyAvailable,
4263
4800
  createBackupProxyIdSafe,