Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ carries facts the renderer read off the settled DOM (before `postProcess`), so t
the cached page's own claims against its sources of truth without parsing HTML on its write path. Both
fields share one wire contract: **absent** means the renderer predates the field (a consumer may alarm on
it); **`null`** means the extraction ran and found nothing to claim or failed benignly — it is posted as
`null`, never omitted, and an extraction failure never fails the render. The extraction runs only on
`null`, never omitted, and an extraction failure never fails the render. Both are read in one in-page
pass (one `page.evaluate`, each JSON-LD block parsed once): a failure in one reader costs only that
field, and a failure of the pass itself posts `null` for both. The extraction runs only on
the path that produces content, so a variant without content (a redirect, a verdict, an error) may carry
neither — absence is meaningful only on a variant that carries content.

Expand Down
2 changes: 1 addition & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harperfast/prerender-browser",
"version": "1.37.0",
"version": "1.38.0",
"type": "module",
"description": "Headless-browser render library for Harper Prerender: claims render jobs from the @harperfast/prerender queue, renders pages in headless Chrome (Puppeteer), and posts the HTML back. Embedded by a render service and configured entirely via startWorker() options.",
"keywords": [
Expand Down
523 changes: 328 additions & 195 deletions packages/browser/src/pageFacts.ts

Large diffs are not rendered by default.

105 changes: 9 additions & 96 deletions packages/browser/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
type ReadinessResult,
} from './readiness.js';
import { responseLogSource } from './responseLog.js';
import { extractPageFacts, PAGE_FACT_BOUNDS } from './pageFacts.js';
import { extractPageClaims, PAGE_FACT_BOUNDS, STRUCTURED_OFFER_CAP } from './pageFacts.js';
import {
DOCUMENT_REUSE_HEADER,
cookieHeaderOf,
Expand Down Expand Up @@ -1150,11 +1150,14 @@ const renderer: Renderer = async (page, job) => {
// offers (or it failed benignly)" while an ABSENT field means "renderer predates
// this feature" — the consumer alarms on the latter, so collapsing null into
// undefined would make every offerless page impersonate an outdated renderer.
job.structuredOffers = await page.evaluate(extractStructuredOffers, STRUCTURED_OFFER_CAP).catch(() => null);
// The page's own SEO facts, with exactly the same absent-vs-null contract and the same
// never-fail-the-render handling. A separate evaluate on purpose: a throw in one must not
// cost the consumer the other.
job.pageFacts = await page.evaluate(extractPageFacts, PAGE_FACT_BOUNDS).catch(() => null);
//
// The page's own SEO facts ride the same evaluate, under exactly the same contract: one CDP
// round trip and one JSON-LD parse for both. Each reader is caught inside the page, so a
// throw in one still costs the consumer only that claim; a failure of the evaluate itself
// posts null for both. `?? null` keeps a missing key from ever reading as absent.
const claims = await page.evaluate(extractPageClaims, STRUCTURED_OFFER_CAP, PAGE_FACT_BOUNDS).catch(() => null);
job.structuredOffers = claims?.structuredOffers ?? null;
job.pageFacts = claims?.pageFacts ?? null;
const ppStart = Date.now();
const content = await page.evaluate(postProcess, config.postProcess, config.block.urlPatterns);
timings.postProcess = Date.now() - ppStart;
Expand Down Expand Up @@ -1301,96 +1304,6 @@ export function indexVerdict(
};
}

// Refusal threshold, not a sample size: a variant-heavy PDP legitimately carries dozens of offer
// triples, but past this the page posts NO claim rather than a truncated one (see
// extractStructuredOffers — a deterministic sample can systematically disagree with the
// consumer's endpoint).
const STRUCTURED_OFFER_CAP = 200;

/**
* The page's schema.org Product offers, flattened to [price, currency, availability] triples and
* sorted so the sequence is stable across renders of unchanged content.
*
* Runs IN THE PAGE against the settled DOM, which is the whole point: the consumer would otherwise
* have to regex-scan and JSON-parse the serialized document (~1MB on a commerce PDP) on its hottest
* write path to recover values this process can read directly. Returns null when the page declares
* no Product offers, so "no structured data" stays distinguishable from "no offers".
*
* A page carrying MORE offers than the cap also returns null — no claim, never a truncated one. A
* deterministic sample that happens to omit the offer the consumer's endpoint reports would
* disagree with it on every comparison, and a systematic disagreement means the consumer expires
* and re-renders that page forever. "Too many offers to read confidently" degrades to exactly
* what "no offers" does: nothing.
*
* Self-contained (passed to page.evaluate) — no imports, no closure over module scope.
*/
function extractStructuredOffers(cap: number): Array<string | null> | null {
const triples: Array<Array<string | null>> = [];
let overflowed = false;
// Field-level byte bound: the cap bounds triple COUNT, so without this a single pathological
// field (a megabyte "price" string) would still inflate every posted result for the page. No
// legitimate price, currency code, or availability token approaches 64 characters.
const bound = (value: string): string | null => (value === '' ? null : value.slice(0, 64));
const collect = (node: unknown) => {
if (overflowed || !node || typeof node !== 'object') return;
const record = node as Record<string, unknown>;
const type = record['@type'];
const isProduct = type === 'Product' || (Array.isArray(type) && type.includes('Product'));
if (!isProduct) return;
const raw = record.offers;
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
for (const entry of list) {
if (!entry || typeof entry !== 'object') continue;
if (triples.length >= cap) {
overflowed = true;
return;
}
const offer = entry as Record<string, unknown>;
// filter(Boolean) before pop: a trailing slash (https://schema.org/InStock/) would
// otherwise pop the empty segment and read as no availability at all.
const availability =
typeof offer.availability === 'string'
? bound(offer.availability.split('/').filter(Boolean).pop() ?? '')
: null;
const price = offer.price === undefined || offer.price === null ? null : bound(String(offer.price));
const currency = typeof offer.priceCurrency === 'string' ? bound(offer.priceCurrency) : null;
triples.push([price, currency, availability]);
}
};
document.querySelectorAll('script[type="application/ld+json"]').forEach((el) => {
if (overflowed) return;
let data: unknown;
try {
data = JSON.parse(el.textContent || '');
} catch {
return; // one malformed block must not cost the page its other blocks
}
const graph = (data as Record<string, unknown>)?.['@graph'];
const nodes = Array.isArray(data) ? data : Array.isArray(graph) ? graph : [data];
for (const node of nodes) {
if (overflowed) break;
collect(node);
}
});
if (overflowed || !triples.length) return null;
// Field-wise, not JSON.stringify per comparison: sorting is O(n log n) COMPARISONS, so
// stringifying inside the comparator serialises every triple many times over. Code-unit
// comparison, not localeCompare: the whole point of the sort is a sequence that is identical
// across renders, and collation varies with the browser's locale/ICU.
triples.sort((a, b) => {
for (let i = 0; i < 3; i++) {
const x = a[i];
const y = b[i];
if (x === y) continue;
if (x === null) return -1;
if (y === null) return 1;
return x < y ? -1 : 1;
}
return 0;
});
return triples.flat();
}

function extractIndexSignals(): { canonicalHref: string | null; noindex: boolean } {
let canonicalHref: string | null = null;
let noindex = false;
Expand Down
Loading