Repository navigation
feat(browser): report the page's own SEO facts with each result — canonical, title, meta description, h1, product JSON-LD (per-SKU offers, rating), breadcrumbs; v1.37.0 - #208
Conversation
…onical, title, meta description, h1, product JSON-LD (per-SKU offers, rating), breadcrumbs; v1.37.0 Each rendered variant now carries `pageFacts`, read in the page off the settled DOM beside `structuredOffers`, with the same absent-vs-null wire contract (absent = renderer predates it; null = extraction ran and failed benignly) and the same never-fail-the-render handling. A separate evaluate, so a throw in one never costs the other. Bounds are refusals, not truncations: a string over 2048 chars is null, more than 200 offers is offers: null, more than 30 breadcrumbs (or one overlong name) is breadcrumbs: null, an offer field over 64 chars is that field null. Posted on the legacy envelope, every multi-device variant, and the result-build-failed variant. Types exported. reuseParity snapshots carry it and report differing facts informationally (not part of pass). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the extraction of SEO facts (pageFacts) from the settled DOM, including canonical URLs, titles, meta descriptions, headings, product JSON-LD, and breadcrumbs. It implements strict boundaries where values exceeding limits are treated as refusals (null) to avoid continuous re-rendering. The review feedback suggests optimizing key merging in reuseParity.ts to avoid temporary array allocations, and wrapping the page.evaluate call in renderer.ts within a promise chain to catch potential synchronous throws.
| /** Which page facts differ between two renders (`product.*` per field) — informational, not in `pass`. */ | ||
| const differingFacts = (a: VariantSnapshot['pageFacts'], b: VariantSnapshot['pageFacts']): string[] => { | ||
| if (!a || !b) return JSON.stringify(a ?? null) === JSON.stringify(b ?? null) ? [] : ['pageFacts']; | ||
| const keys = (x: object, y: object) => [...new Set([...Object.keys(x), ...Object.keys(y)])]; |
There was a problem hiding this comment.
Avoid using the spread operator to merge keys into a Set as it allocates an unnecessary temporary array. Instead, initialize the Set with one object's keys and iteratively add the other's keys.
const keys = (x: object, y: object) => {
const set = new Set(Object.keys(x));
for (const key of Object.keys(y)) {
set.add(key);
}
return [...set];
};References
- Avoid using the spread operator to merge map keys into a Set when processing large datasets, as it allocates an unnecessary temporary array. Instead, initialize the Set with one map's keys and iteratively add the other map's keys.
There was a problem hiding this comment.
Applied in 8ce233d: the key set is built from one object's keys and the other's are added in a loop. (This is local audit tooling that compares two ~6-key objects, so it doesn't matter for performance, but it's no less clear.)
| // 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); |
There was a problem hiding this comment.
When executing asynchronous operations that may throw synchronous errors (such as page.evaluate when the page or browser context is closed), wrap the execution in a promise chain starting with Promise.resolve().then(...) to ensure any synchronous throws are caught by the .catch() block instead of escaping.
| job.pageFacts = await page.evaluate(extractPageFacts, PAGE_FACT_BOUNDS).catch(() => null); | |
| job.pageFacts = await Promise.resolve() | |
| .then(() => page.evaluate(extractPageFacts, PAGE_FACT_BOUNDS)) | |
| .catch(() => null); |
References
- When executing asynchronous operations that may throw synchronous errors, wrap the execution in a promise chain starting with Promise.resolve().then(...) to ensure any synchronous throws are caught by the .catch() block instead of escaping.
There was a problem hiding this comment.
Not changing this one. Puppeteer's Page.evaluate is declared async evaluate(pageFunction, ...args) (puppeteer-core/lib/esm/puppeteer/api/Page.js), so a closed page or a destroyed context comes back as a rejection, never a synchronous throw, and .catch(() => null) already handles it. page here is always a live Page, never an arbitrary value. The line also mirrors the existing structuredOffers call right above it on purpose, so the two extractions keep identical failure handling. The test an extraction that throws posts pageFacts: null covers the in-page throw path from start to finish.
…roup variants and one nesting level Same pageFacts shape; three additions to where the product facts come from: - A product with exactly ONE offer that has no sku names it with the product's own sku (bounded like any offer field). Never with several offers. - A ProductGroup stating no offers of its own reports its hasVariant offers, sku = offer.sku ?? variant.sku, under the same 200-offer refusal; a group whose own offers are refused does not fall through. name/brand/image/rating stay the group's, from the first variant only where the group states none (picked raw, then bounded, so an overlong group value is refused, not swapped). - When no top-level node matches, a page node's (WebPage or a subtype) mainEntity / mainEntityOfPage object is searched for the product and its breadcrumb object for the trail. One level only; a top-level node always wins. Also: reuseParity merges fact keys without a temporary spread (review), and the README's pageFacts description is restructured (prettier had mangled a wrapped code span). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Pushed 8ce233d. The
The Set-merge suggestion is applied. The |
What
Every rendered variant result now includes
pageFacts: what the cached page itself claims. The renderer reads it in the page off the settled DOM, next tostructuredOffers.The extractor, its types and
PAGE_FACT_BOUNDSlive insrc/pageFacts.ts.renderer.tscalls it in the same branch asstructuredOffers.Why
The plugin will compare a cached page's own claims against a product API, and re-render only a page that is wrong. It never parses HTML on its hot write path, so every fact it compares has to come from the renderer. The plugin side is a separate, later PR written against this contract.
Wire contract
structuredOffers. ABSENT means the renderer predates the field.nullmeans the extraction ran and failed benignly (the page was not evaluable).null. The extraction runs as a separatepage.evaluate, so a throw in one extraction never costs the other.structuredOffers.structuredOffers:resultMetadata)result-build-failedvariant (unreportableMetadata)nulloffers: null, counted across an AggregateOffer's list and across ProductGroup variantsbreadcrumbs: nullnull, for that field onlyPageFacts,PageFactsProduct,PageFactOffer,PageFactBounds.Sourcing
The rules below decide which node and which values feed the fields. The shape above never changes.
<script type="application/ld+json">, in document order@graphcounts as a list of nodesWebPageor one of its schema.org subtypes, e.g.ItemPage,CollectionPage,SearchResultsPage) has itsmainEntity(an object or an array) and itsmainEntityOfPage(only if it is an object) searched for the Product/ProductGroup.breadcrumbobject is searched for the BreadcrumbList. That object must be typedBreadcrumbList.offers: an object, an array, or anAggregateOffer'sofferslist.structuredOffers:https://schema.org/InStockbecomesInStock.skuorpricebecomes its string form. Any other non-string value isnull.nullor"") takes the product's ownsku, under the same 64-character refusal.null; the product sku does not replace it.hasVariantproducts. For each offer,sku = offer.sku ?? variant.sku.name,brand,imageandratingstay the group's. They come from the first variant only where the group states none.null; there is one way to say "no claim".ratingisnullwhen neither value is numeric. Numeric strings are accepted, and a null count never becomesNumber(null) === 0.breadcrumbs:null, not[].null, because astring[]cannot hold a null.link[rel="canonical"]selector asextractIndexSignals, so it reports the canonical the indexability verdict judged.Plugin compatibility: checked, it ignores the field
In
packages/plugin/src/resources/RenderQueue.js:normalizeJobResultcopies each variant into an in-memory object with...variant.processDecodedJobResultreads only named fields:outcome,statusCode,headers,isIndexable,structuredOffers,readiness, and so on.PrerenderedPage.putstores an explicit field list.So
pageFactsis carried in memory and dropped.Local tooling
reuseParityCheck: snapshots carrypageFacts. The report printspage facts differ (informational): …, listing the differing keys (product.*per field). This is outsidepass, so the verdict is unchanged.renderOnce/renderMatrix: they expose it asresult.job.pageFacts, alongsidestructuredOffers.Tests
The full suite is 346/346; the baseline was 317.
test/pageFacts.test.tshas 27 tests. Most run the real extractor throughpage.evaluateagainst small example.com fixtures.test/jobResult.test.tsadds 2 wire tests.What the fixtures cover:
@graph, a top-level array,@typeas an array, the first product winningratingCountvsreviewCount, and a null count not read as 0nulland string JSON-LD blocks next to valid onesmainEntityas an object and as an array, amainEntityOfPageobject,breadcrumbon a CollectionPage, top-level beating nested, and nothing read two levels deep, under non-page nodes, from a stringmainEntityOfPageor from an untyped breadcrumbEnd to end through
renderOnce:job.pageFactsis set andstructuredOffersis unchanged.document.titlegetter throws still rendersok, keeps its offers, and postspageFacts: null, not absent.Revert check (patched
dist/, reran). Each change below fails the tests noted:dist/pageFactsin the builders@graphsearchhasVariantoffersmainEntitysearchednpm run lint,npm run format:checkandtscare clean.Known limitation, kept on purpose
structuredOffersstill truncates. It slices an over-long offer field to 64 characters, which contradicts the refusal rule this PR follows. It is left as is because changing it would change the claim values the change probe already stores. The README says so.Rollout
The change is additive and does nothing until a plugin reads it. The fleet can deploy before the plugin. An older plugin ignores the field (see above), and a newer plugin will treat its absence as "renderer predates it".
🤖 Generated with Claude Code