@nextcommerce/campaigns-os 1.50.0 → 1.53.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.
- package/CHANGELOG.md +474 -0
- package/agents/claude/CLAUDE.md +4 -2
- package/agents/codex/AGENTS.md +3 -1
- package/agents/copilot/copilot-instructions.md +3 -1
- package/agents/cursor/campaigns-os.mdc +3 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +282 -14
- package/contracts/release-ledger.json +1233 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +148 -23
- package/docs/campaign-build-brief.md +57 -2
- package/docs/effects.md +14 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +12 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +47 -4
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +11 -20
- package/skills/next-campaigns-os/references/session-intake.md +17 -9
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +8 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +33 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +229 -30
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +467 -143
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +415 -33
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +16 -7
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +660 -141
- package/src/polish-capture.mjs +1 -1
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +400 -28
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +19 -3
- package/src/qa-browser.mjs +439 -180
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +116 -13
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +482 -0
- package/src/qc-check-registry.mjs +91 -0
- package/src/qc-results.mjs +1341 -0
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +165 -33
- package/src/tooling-setup.mjs +1 -1
package/src/polish-browser.mjs
CHANGED
|
@@ -17,6 +17,12 @@ import {
|
|
|
17
17
|
singleResponseRecord,
|
|
18
18
|
} from "./polish-capture.mjs";
|
|
19
19
|
import { launchPackageChromium, PLAYWRIGHT_INSTALL_HINT } from "./browser-launch.mjs";
|
|
20
|
+
import { isProbeClock } from "./polish-media-weight.mjs";
|
|
21
|
+
import {
|
|
22
|
+
readabilityCropTargets,
|
|
23
|
+
readabilityObservationOk,
|
|
24
|
+
readabilityProbeSource,
|
|
25
|
+
} from "./polish-readability.mjs";
|
|
20
26
|
import {
|
|
21
27
|
boundedPolishDeadline,
|
|
22
28
|
POLISH_BROWSER_CELL_DEADLINE_MS,
|
|
@@ -511,6 +517,393 @@ async function collectMediaElements(page) {
|
|
|
511
517
|
});
|
|
512
518
|
}
|
|
513
519
|
|
|
520
|
+
// The <img> reader the media-weight image probe runs after network
|
|
521
|
+
// observation closed, in an isolated world: every DOM method, getter and
|
|
522
|
+
// window property it uses is that world's own, so page scripts can neither
|
|
523
|
+
// change what it reads nor see that it ran. It walks the document in
|
|
524
|
+
// shadow-including tree order (an element, then its shadow tree, then its
|
|
525
|
+
// children), reaching open shadow roots through element.shadowRoot and
|
|
526
|
+
// closed ones through `closedRoots` (resolved into this world over CDP); it
|
|
527
|
+
// does not enter an iframe's document. It returns the <img> count,
|
|
528
|
+
// window.devicePixelRatio and, for the first `limits.images` <img>: the
|
|
529
|
+
// element path (the CSS child path from <body>, every step
|
|
530
|
+
// "tag:nth-of-type(n)", with a "#shadow-root" step after a shadow host),
|
|
531
|
+
// currentSrc (http(s) as is; any other scheme as the scheme alone), loading,
|
|
532
|
+
// complete, natural size, the rendered box in CSS px, computed object-fit and
|
|
533
|
+
// whether it is hidden (checkVisibility: it or a flat-tree ancestor is not
|
|
534
|
+
// displayed, or it is not visible). It reads only; it never scrolls and
|
|
535
|
+
// starts no load.
|
|
536
|
+
function readImageElements(limits, ...closedRoots) {
|
|
537
|
+
const closedRootOf = new Map(closedRoots.filter((root) => root?.host).map((root) => [root.host, root]));
|
|
538
|
+
const source = (value) => {
|
|
539
|
+
if (typeof value !== "string" || value === "") return { url: null, svg: false };
|
|
540
|
+
if (/^https?:/i.test(value)) return { url: value.length <= limits.urlLength ? value : "[url-too-long]", svg: false };
|
|
541
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value);
|
|
542
|
+
return { url: scheme ? `${scheme[1].toLowerCase()}:` : null, svg: /^data:image\/svg\+xml[;,]/i.test(value) };
|
|
543
|
+
};
|
|
544
|
+
const found = [];
|
|
545
|
+
let observedCount = 0;
|
|
546
|
+
// Work still to do, last first: an element to visit, or a parent (the
|
|
547
|
+
// document, an element or a shadow root) whose children are still to list.
|
|
548
|
+
const pending = [{ parent: document, path: "" }];
|
|
549
|
+
while (pending.length) {
|
|
550
|
+
const item = pending.pop();
|
|
551
|
+
if (item.element) {
|
|
552
|
+
if (item.element instanceof HTMLImageElement) {
|
|
553
|
+
observedCount += 1;
|
|
554
|
+
if (found.length < limits.images) found.push(item);
|
|
555
|
+
}
|
|
556
|
+
pending.push({ parent: item.element, path: item.path });
|
|
557
|
+
const shadowRoot = item.element.shadowRoot ?? closedRootOf.get(item.element);
|
|
558
|
+
if (shadowRoot) pending.push({ parent: shadowRoot, path: `${item.path}>#shadow-root` });
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const children = [];
|
|
562
|
+
const counts = new Map();
|
|
563
|
+
for (let child = item.parent.firstElementChild; child; child = child.nextElementSibling) {
|
|
564
|
+
const index = (counts.get(child.tagName) ?? 0) + 1;
|
|
565
|
+
counts.set(child.tagName, index);
|
|
566
|
+
const tag = child.tagName.toLowerCase();
|
|
567
|
+
const path = child === document.body || child === document.documentElement
|
|
568
|
+
? tag
|
|
569
|
+
: `${item.path ? `${item.path}>` : ""}${tag}:nth-of-type(${index})`;
|
|
570
|
+
children.push({ element: child, path });
|
|
571
|
+
}
|
|
572
|
+
for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]);
|
|
573
|
+
}
|
|
574
|
+
const images = found.map(({ element: image, path }) => {
|
|
575
|
+
const src = source(image.currentSrc);
|
|
576
|
+
const style = getComputedStyle(image);
|
|
577
|
+
const bounds = image.getBoundingClientRect();
|
|
578
|
+
return {
|
|
579
|
+
element_path: path,
|
|
580
|
+
current_src: src.url,
|
|
581
|
+
svg_data: src.svg,
|
|
582
|
+
loading: image.loading,
|
|
583
|
+
complete: image.complete,
|
|
584
|
+
natural: [image.naturalWidth, image.naturalHeight],
|
|
585
|
+
rendered: [bounds.width, bounds.height],
|
|
586
|
+
object_fit: style.objectFit,
|
|
587
|
+
hidden: !image.checkVisibility({ visibilityProperty: true }),
|
|
588
|
+
};
|
|
589
|
+
});
|
|
590
|
+
return { dpr: window.devicePixelRatio, observed_count: observedCount, images };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// The backend node ids of the main document's closed shadow roots, from a
|
|
594
|
+
// DOM.getDocument tree read with pierce. Open roots the read reaches itself
|
|
595
|
+
// and user-agent roots are not the page's. An iframe's document is entered
|
|
596
|
+
// only with `frameOwners`: a closed root inside it, which the main frame's
|
|
597
|
+
// world cannot resolve, is named by the main document's iframe that holds
|
|
598
|
+
// it (once per iframe).
|
|
599
|
+
function closedShadowRoots(root, { frameOwners = false } = {}) {
|
|
600
|
+
const found = [];
|
|
601
|
+
const pending = [[root, null]];
|
|
602
|
+
while (pending.length) {
|
|
603
|
+
const [node, owner] = pending.pop();
|
|
604
|
+
if (!node || typeof node !== "object") continue;
|
|
605
|
+
if (node.shadowRootType === "closed" && Number.isInteger(owner ?? node.backendNodeId)) found.push(owner ?? node.backendNodeId);
|
|
606
|
+
if (Array.isArray(node.shadowRoots)) pending.push(...node.shadowRoots.map((child) => [child, owner]));
|
|
607
|
+
if (Array.isArray(node.children)) pending.push(...node.children.map((child) => [child, owner]));
|
|
608
|
+
if (frameOwners && node.contentDocument) pending.push([node.contentDocument, owner ?? node.backendNodeId]);
|
|
609
|
+
}
|
|
610
|
+
return [...new Set(found)];
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const BOUND_ENDED = Symbol("bound ended");
|
|
614
|
+
// Readability work in a cell ends this long before the adapter cell deadline,
|
|
615
|
+
// so a readability bound, not that deadline, is what stops it.
|
|
616
|
+
const READABILITY_CELL_HEADROOM_MS = 1_000;
|
|
617
|
+
const realProbeClock = Object.freeze({
|
|
618
|
+
now: () => performance.now(),
|
|
619
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
620
|
+
const timer = setTimeout(resolve, ms);
|
|
621
|
+
timer.unref?.();
|
|
622
|
+
}),
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
function imageRead(value, imageCap) {
|
|
626
|
+
return value && typeof value === "object" && Number.isInteger(value.observed_count) && Array.isArray(value.images)
|
|
627
|
+
&& value.images.length <= imageCap
|
|
628
|
+
? value
|
|
629
|
+
: null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// The media-weight image probe, run after collector.finish so it cannot
|
|
633
|
+
// delay the network window or add to the ledger. Its steps, in order: an
|
|
634
|
+
// isolated world (Page.createIsolatedWorld); the node tree (DOM.getDocument,
|
|
635
|
+
// piercing shadow roots) and each closed shadow root resolved into that world
|
|
636
|
+
// (DOM.resolveNode); the one read (Runtime.callFunctionOn of
|
|
637
|
+
// readImageElements in that world), which is the only source of the listing,
|
|
638
|
+
// geometry, computed style and device pixel ratio; and the Page.getFrameTree
|
|
639
|
+
// re-read. The probe's bound is the smaller of the cell bound and the run
|
|
640
|
+
// budget left. Every step races one deadline on `clock` at that bound, and
|
|
641
|
+
// none starts once it has passed. spent_ms, on `clock` from the first step to
|
|
642
|
+
// the return, is what the run budget is charged. The probe is cancelled when
|
|
643
|
+
// its bound ends it, on its timer or on `clock` whichever is first, or when it
|
|
644
|
+
// returns: every CDP command, and every step's result, checks that first, so
|
|
645
|
+
// a step that settles late issues nothing more. A probe its bound ends reads
|
|
646
|
+
// probe_timeout, or probe_budget_exhausted where the run budget left was the
|
|
647
|
+
// smaller bound, never complete; it names its images only when its read had
|
|
648
|
+
// returned. A cell that starts once the run budget is spent starts no step
|
|
649
|
+
// and reads probe_budget_exhausted with no images. The re-read discards the
|
|
650
|
+
// probe as document_context_changed when the main frame has another id or
|
|
651
|
+
// loaderId than page_load's own re-read found; so does a step that failed
|
|
652
|
+
// (its execution context destroyed).
|
|
653
|
+
// Returns { status, dpr, images, spent_ms }.
|
|
654
|
+
async function probeImageElements(options) {
|
|
655
|
+
const token = { cancelled: false };
|
|
656
|
+
try {
|
|
657
|
+
return await runImageProbe(options, token);
|
|
658
|
+
} finally {
|
|
659
|
+
token.cancelled = true;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// One probe's bound on `clock`, from `started`: the guard run before every
|
|
664
|
+
// command the probe issues and before it acts on any step's result (the probe
|
|
665
|
+
// is cancelled once the token is, or once the clock reaches the bound; the
|
|
666
|
+
// clock is read even when no timer has fired, so a step that settles past the
|
|
667
|
+
// bound ahead of a queued timer issues nothing more, and a passed bound
|
|
668
|
+
// cancels the token too), the guarded CDP send, and one step: started only
|
|
669
|
+
// inside the bound and raced against it. A step that settles past the bound
|
|
670
|
+
// reads BOUND_ENDED, not its result. `hardMs`, when given, is a second bound
|
|
671
|
+
// in real time from now (the adapter cell deadline's headroom), whatever
|
|
672
|
+
// `clock` is; `hardEnded()` tells whether it is the one that ended. Which
|
|
673
|
+
// bound ended is recorded when it ends: by the timer that fired, or by the
|
|
674
|
+
// clock read that found it passed (the real-time bound first), so a timer
|
|
675
|
+
// that fires before performance.now() reaches its end still names its bound.
|
|
676
|
+
function boundedProbeSteps({ clock, session, token, started, boundMs, hardMs = Infinity }) {
|
|
677
|
+
const boundEnd = started + boundMs;
|
|
678
|
+
const hardEnd = performance.now() + hardMs;
|
|
679
|
+
let endedBy = null;
|
|
680
|
+
const end = (by) => {
|
|
681
|
+
if (token.cancelled) return;
|
|
682
|
+
token.cancelled = true;
|
|
683
|
+
endedBy = by;
|
|
684
|
+
};
|
|
685
|
+
const hardEnded = () => endedBy === "hard";
|
|
686
|
+
const cancelled = () => {
|
|
687
|
+
if (performance.now() >= hardEnd) end("hard");
|
|
688
|
+
else if (clock.now() >= boundEnd) end("clock");
|
|
689
|
+
return token.cancelled;
|
|
690
|
+
};
|
|
691
|
+
const send = (method, params) => (cancelled()
|
|
692
|
+
? Promise.reject(new Error("The probe was cancelled."))
|
|
693
|
+
: session.send(method, params));
|
|
694
|
+
const deadline = Promise.race([
|
|
695
|
+
clock.sleep(boundMs).then(() => end("clock")),
|
|
696
|
+
...(Number.isFinite(hardMs) ? [realProbeClock.sleep(Math.max(0, hardMs)).then(() => end("hard"))] : []),
|
|
697
|
+
]).then(() => BOUND_ENDED);
|
|
698
|
+
const step = async (start) => {
|
|
699
|
+
if (cancelled()) return BOUND_ENDED;
|
|
700
|
+
const settled = await Promise.race([Promise.resolve().then(start).then((value) => ({ value }), (error) => ({ error })), deadline]);
|
|
701
|
+
return cancelled() ? BOUND_ENDED : settled;
|
|
702
|
+
};
|
|
703
|
+
return { cancelled, hardEnded, send, step };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// The isolated-world read a probe makes, each step inside its bound: an
|
|
707
|
+
// isolated world on the main frame (Page.createIsolatedWorld); the node tree
|
|
708
|
+
// (DOM.getDocument, piercing shadow roots) and each closed shadow root
|
|
709
|
+
// resolved into that world (DOM.resolveNode); and the one read
|
|
710
|
+
// (Runtime.callFunctionOn of `functionDeclaration` in that world, with
|
|
711
|
+
// `leadingArgs` and then the closed roots, passed by object id so the
|
|
712
|
+
// function receives the root nodes themselves; with `frameOwners`, also the
|
|
713
|
+
// iframes holding closed roots, see closedShadowRoots). Returns BOUND_ENDED
|
|
714
|
+
// when the bound ended a step, null when a step failed (its execution
|
|
715
|
+
// context destroyed), or { value } of the read.
|
|
716
|
+
async function isolatedWorldRead({ steps, mainFrame, worldName, functionDeclaration, leadingArgs, frameOwners = false }) {
|
|
717
|
+
const { send, step } = steps;
|
|
718
|
+
const world = await step(() => send("Page.createIsolatedWorld", {
|
|
719
|
+
frameId: mainFrame.id,
|
|
720
|
+
worldName,
|
|
721
|
+
grantUniveralAccess: false,
|
|
722
|
+
}));
|
|
723
|
+
if (world === BOUND_ENDED) return BOUND_ENDED;
|
|
724
|
+
const contextId = world.value?.executionContextId;
|
|
725
|
+
if (world.error || !Number.isInteger(contextId)) return null;
|
|
726
|
+
const tree = await step(() => send("DOM.getDocument", { depth: -1, pierce: true }));
|
|
727
|
+
if (tree === BOUND_ENDED) return BOUND_ENDED;
|
|
728
|
+
if (tree.error) return null;
|
|
729
|
+
const roots = await step(() => Promise.all(closedShadowRoots(tree.value?.root, { frameOwners })
|
|
730
|
+
.map((backendNodeId) => send("DOM.resolveNode", { backendNodeId, executionContextId: contextId }))));
|
|
731
|
+
if (roots === BOUND_ENDED) return BOUND_ENDED;
|
|
732
|
+
const rootIds = roots.error ? null : roots.value.map((resolved) => resolved?.object?.objectId);
|
|
733
|
+
if (!rootIds || !rootIds.every((objectId) => typeof objectId === "string")) return null;
|
|
734
|
+
const evaluated = await step(() => send("Runtime.callFunctionOn", {
|
|
735
|
+
functionDeclaration,
|
|
736
|
+
executionContextId: contextId,
|
|
737
|
+
arguments: [...leadingArgs, ...rootIds.map((objectId) => ({ objectId }))],
|
|
738
|
+
returnByValue: true,
|
|
739
|
+
}));
|
|
740
|
+
if (evaluated === BOUND_ENDED) return BOUND_ENDED;
|
|
741
|
+
if (evaluated.error || evaluated.value?.exceptionDetails) return null;
|
|
742
|
+
return { value: evaluated.value?.result?.value };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// The Page.getFrameTree re-read inside the bound: BOUND_ENDED, or whether the
|
|
746
|
+
// main frame still has `mainFrame`'s id and loaderId (false when it does not,
|
|
747
|
+
// or the re-read failed).
|
|
748
|
+
async function sameMainDocument({ steps, mainFrame }) {
|
|
749
|
+
const reread = await steps.step(() => steps.send("Page.getFrameTree"));
|
|
750
|
+
if (reread === BOUND_ENDED) return BOUND_ENDED;
|
|
751
|
+
if (reread.error) return false;
|
|
752
|
+
const frame = reread.value?.frameTree?.frame;
|
|
753
|
+
return frame?.id === mainFrame.id && frame?.loaderId === mainFrame.loaderId;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function runImageProbe({ session, mainFrame, documentContextChanged, probe }, token) {
|
|
757
|
+
const clock = isProbeClock(probe.clock) ? probe.clock : realProbeClock;
|
|
758
|
+
const limits = { images: probe.imageCap, urlLength: MAX_POLISH_CAPTURE_URL_LENGTH };
|
|
759
|
+
const started = clock.now();
|
|
760
|
+
const spent = () => Math.max(0, clock.now() - started);
|
|
761
|
+
let read = null;
|
|
762
|
+
const outcome = (status) => ({ status, dpr: read?.dpr ?? null, images: read?.images ?? [], spent_ms: spent() });
|
|
763
|
+
if (!(probe.remainingMs > 0)) return { ...outcome("probe_budget_exhausted"), spent_ms: 0 };
|
|
764
|
+
if (documentContextChanged || typeof mainFrame?.id !== "string") return outcome("document_context_changed");
|
|
765
|
+
const boundMs = Math.min(probe.cellBoundMs, probe.remainingMs);
|
|
766
|
+
// The status of a probe its bound ended.
|
|
767
|
+
const boundEnded = probe.remainingMs < probe.cellBoundMs ? "probe_budget_exhausted" : "probe_timeout";
|
|
768
|
+
const steps = boundedProbeSteps({ clock, session, token, started, boundMs });
|
|
769
|
+
|
|
770
|
+
const called = await isolatedWorldRead({
|
|
771
|
+
steps,
|
|
772
|
+
mainFrame,
|
|
773
|
+
worldName: "campaigns-os-polish-image-probe",
|
|
774
|
+
functionDeclaration: readImageElements.toString(),
|
|
775
|
+
leadingArgs: [{ value: limits }],
|
|
776
|
+
});
|
|
777
|
+
if (called === BOUND_ENDED) return outcome(boundEnded);
|
|
778
|
+
if (called === null) return outcome("document_context_changed");
|
|
779
|
+
read = imageRead(called.value, probe.imageCap);
|
|
780
|
+
if (!read) return outcome("document_context_changed");
|
|
781
|
+
const same = await sameMainDocument({ steps, mainFrame });
|
|
782
|
+
if (same === BOUND_ENDED) return outcome(boundEnded);
|
|
783
|
+
if (!same) return outcome("document_context_changed");
|
|
784
|
+
if (read.observed_count > probe.imageCap) return outcome("image_cap_reached");
|
|
785
|
+
return steps.cancelled() ? outcome(boundEnded) : outcome("complete");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// The readability probe of one cell (src/polish-readability.mjs), after the
|
|
789
|
+
// page has loaded, and the cell's crops after it. The probe is the image
|
|
790
|
+
// probe's isolated-world read of readabilityProbeSource and its main-frame
|
|
791
|
+
// re-read: a main frame with another id or loaderId than `mainFrame`, or a
|
|
792
|
+
// failed step, reads document_changed. Its bound is the smaller of the cell
|
|
793
|
+
// bound (READABILITY_PROBE_CELL_MS) and what is left of the run's probe and
|
|
794
|
+
// added budgets, on `clock`, and it also ends at `cellLeftMs` (the adapter
|
|
795
|
+
// cell deadline's headroom, in real time); a probe its bound ends reads
|
|
796
|
+
// probe_timeout, or run_budget_exhausted where a run budget was the smaller
|
|
797
|
+
// bound. A measured cell then takes its crops (readabilityCropTargets) as
|
|
798
|
+
// viewport-clip screenshots, at most `cropsPerCell`, and reads the main frame
|
|
799
|
+
// once more, every one of those steps inside one crop bound: what is left of
|
|
800
|
+
// the run's crop and added budgets on `clock`, and of the cell headroom. A crop target outside the
|
|
801
|
+
// viewport reads outside_viewport; one past the count or the bound, or whose
|
|
802
|
+
// screenshot failed, crop_unavailable; so does every crop taken when the
|
|
803
|
+
// bound ends before the re-read. crop_ms runs from the first crop step to the
|
|
804
|
+
// end of the re-read.
|
|
805
|
+
// Returns { status, capped, coverage_gaps, elements, crops, probe_ms, crop_ms }.
|
|
806
|
+
async function probeReadabilityDocument(options) {
|
|
807
|
+
const token = { cancelled: false };
|
|
808
|
+
const cropToken = { cancelled: false };
|
|
809
|
+
try {
|
|
810
|
+
return await runReadabilityProbe(options, token, cropToken);
|
|
811
|
+
} finally {
|
|
812
|
+
token.cancelled = true;
|
|
813
|
+
cropToken.cancelled = true;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function runReadabilityProbe({ session, mainFrame, documentContextChanged, probe }, token, cropToken) {
|
|
818
|
+
const clock = isProbeClock(probe.clock) ? probe.clock : realProbeClock;
|
|
819
|
+
const started = clock.now();
|
|
820
|
+
const spent = () => Math.max(0, clock.now() - started);
|
|
821
|
+
const unmeasured = (status) => ({ status, capped: false, coverage_gaps: [], elements: [], crops: [], probe_ms: spent(), crop_ms: 0 });
|
|
822
|
+
const runLeft = Math.min(probe.probeRemainingMs, probe.addedRemainingMs);
|
|
823
|
+
// The cell deadline's headroom, in real time.
|
|
824
|
+
const cellEndsAt = performance.now() + (Number.isFinite(probe.cellLeftMs) ? probe.cellLeftMs : Infinity);
|
|
825
|
+
const cellLeft = () => cellEndsAt - performance.now();
|
|
826
|
+
if (!(runLeft > 0)) return { ...unmeasured("run_budget_exhausted"), probe_ms: 0 };
|
|
827
|
+
if (!(cellLeft() > 0)) return { ...unmeasured("probe_timeout"), probe_ms: 0 };
|
|
828
|
+
if (documentContextChanged || typeof mainFrame?.id !== "string") return unmeasured("document_changed");
|
|
829
|
+
const boundMs = Math.min(probe.cellBoundMs, runLeft);
|
|
830
|
+
const steps = boundedProbeSteps({ clock, session, token, started, boundMs, hardMs: cellLeft() });
|
|
831
|
+
// The status of a probe its bound ended.
|
|
832
|
+
const bound = () => (!steps.hardEnded() && runLeft < probe.cellBoundMs ? "run_budget_exhausted" : "probe_timeout");
|
|
833
|
+
|
|
834
|
+
const called = await isolatedWorldRead({
|
|
835
|
+
steps,
|
|
836
|
+
mainFrame,
|
|
837
|
+
worldName: "campaigns-os-polish-readability-probe",
|
|
838
|
+
functionDeclaration: readabilityProbeSource(),
|
|
839
|
+
leadingArgs: [{ value: { elements: probe.elementCap } }],
|
|
840
|
+
frameOwners: true,
|
|
841
|
+
});
|
|
842
|
+
if (called === BOUND_ENDED) return unmeasured(bound());
|
|
843
|
+
if (called === null || !readabilityObservationOk(called.value)) return unmeasured("document_changed");
|
|
844
|
+
const same = await sameMainDocument({ steps, mainFrame });
|
|
845
|
+
if (same === BOUND_ENDED) return unmeasured(bound());
|
|
846
|
+
if (!same) return unmeasured("document_changed");
|
|
847
|
+
if (steps.cancelled()) return unmeasured(bound());
|
|
848
|
+
const read = called.value;
|
|
849
|
+
const probeMs = spent();
|
|
850
|
+
if (read.status !== "measured") return { ...unmeasured(read.status), probe_ms: probeMs };
|
|
851
|
+
|
|
852
|
+
// Crops, after every measurement in the cell.
|
|
853
|
+
const cropStarted = clock.now();
|
|
854
|
+
const cropSpent = () => Math.max(0, clock.now() - cropStarted);
|
|
855
|
+
const cropLeft = Math.min(probe.cropRemainingMs, probe.addedRemainingMs - probeMs);
|
|
856
|
+
const crops = [];
|
|
857
|
+
const elements = read.elements;
|
|
858
|
+
const viewport = read.viewport || {};
|
|
859
|
+
const targets = readabilityCropTargets(elements);
|
|
860
|
+
const cropSteps = cropLeft > 0 && cellLeft() > 0 ? boundedProbeSteps({ clock, session, token: cropToken, started: cropStarted, boundMs: cropLeft, hardMs: cellLeft() }) : null;
|
|
861
|
+
for (const [taken, index] of targets.entries()) {
|
|
862
|
+
const element = elements[index];
|
|
863
|
+
const rect = element.rect || {};
|
|
864
|
+
const x = Math.max(0, rect.x);
|
|
865
|
+
const y = Math.max(0, rect.y);
|
|
866
|
+
const width = Math.min(viewport.width, rect.x + rect.width) - x;
|
|
867
|
+
const height = Math.min(viewport.height, rect.y + rect.height) - y;
|
|
868
|
+
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
|
|
869
|
+
element.crop_reason = "outside_viewport";
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (taken >= probe.cropsPerCell || !cropSteps || cropSteps.cancelled()) {
|
|
873
|
+
element.crop_reason = "crop_unavailable";
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
const shot = await cropSteps.step(() => cropSteps.send("Page.captureScreenshot", {
|
|
877
|
+
format: "png",
|
|
878
|
+
clip: { x, y, width, height, scale: 1 },
|
|
879
|
+
captureBeyondViewport: false,
|
|
880
|
+
}));
|
|
881
|
+
if (shot === BOUND_ENDED || shot.error || typeof shot.value?.data !== "string" || shot.value.data === "") {
|
|
882
|
+
element.crop_reason = "crop_unavailable";
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
crops.push({ element: index, data: shot.value.data });
|
|
886
|
+
}
|
|
887
|
+
// The crops are of the measured document only: the re-read is a crop step.
|
|
888
|
+
// When the bound ends first, no crop taken can be shown to be of it.
|
|
889
|
+
const cropped = crops.length ? await sameMainDocument({ steps: cropSteps, mainFrame }) : true;
|
|
890
|
+
const cropMs = targets.length ? cropSpent() : 0;
|
|
891
|
+
if (cropped === BOUND_ENDED) {
|
|
892
|
+
for (const crop of crops.splice(0)) elements[crop.element].crop_reason = "crop_unavailable";
|
|
893
|
+
} else if (!cropped) {
|
|
894
|
+
return { ...unmeasured("document_changed"), probe_ms: probeMs, crop_ms: cropMs };
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
status: "measured",
|
|
898
|
+
capped: read.capped,
|
|
899
|
+
coverage_gaps: read.coverage_gaps,
|
|
900
|
+
elements,
|
|
901
|
+
crops,
|
|
902
|
+
probe_ms: probeMs,
|
|
903
|
+
crop_ms: cropMs,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
514
907
|
function preferredResolvedValue(finalValue, initialValue) {
|
|
515
908
|
return typeof finalValue === "string" && finalValue !== "" ? finalValue : initialValue;
|
|
516
909
|
}
|
|
@@ -675,164 +1068,290 @@ export async function createPolishBrowserAdapter({
|
|
|
675
1068
|
let closed = false;
|
|
676
1069
|
let poisonCode = null;
|
|
677
1070
|
let closePromise = null;
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1071
|
+
// One cell in a fresh browser context: the context (viewport, service
|
|
1072
|
+
// workers blocked, the auth cookies), its page and CDP session, `work`
|
|
1073
|
+
// under the adapter cell deadline, and the context closed under the cleanup
|
|
1074
|
+
// bound. A timeout or a failed cleanup poisons the adapter for every later
|
|
1075
|
+
// cell. `work` also gets `cellLeftMs()`, the time left before the cell
|
|
1076
|
+
// deadline less the readability headroom. With `setup` ({ step, ended }),
|
|
1077
|
+
// each setup call is a `step` of the caller's bound (boundedProbeSteps); a
|
|
1078
|
+
// bound that ends during setup makes the cell return `ended()` instead of
|
|
1079
|
+
// running `work`, and a context created after that is closed.
|
|
1080
|
+
const inCaptureContext = async ({ url, viewport, signal, setup = null }, work) => {
|
|
1081
|
+
if (closed) throw new Error("Campaigns OS polish capture browser adapter is already closed.");
|
|
1082
|
+
if (poisonCode === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw polishProducerTimeoutError();
|
|
1083
|
+
if (poisonCode === POLISH_PRODUCER_CLEANUP_ERROR_CODE) throw polishProducerCleanupError();
|
|
1084
|
+
if (typeof url !== "string" || url.length > MAX_POLISH_CAPTURE_URL_LENGTH) {
|
|
1085
|
+
throw new Error("Campaigns OS polish capture requires a bounded HTTP(S) capture URL.");
|
|
1086
|
+
}
|
|
1087
|
+
if (!Number.isInteger(viewport?.width) || viewport.width <= 0
|
|
1088
|
+
|| !Number.isInteger(viewport?.height) || viewport.height <= 0) {
|
|
1089
|
+
throw new Error("Campaigns OS polish capture requires a positive integer viewport.");
|
|
1090
|
+
}
|
|
1091
|
+
let context;
|
|
1092
|
+
let session;
|
|
1093
|
+
let timedOut = false;
|
|
1094
|
+
let detachPromise = null;
|
|
1095
|
+
let contextClosePromise = null;
|
|
1096
|
+
const cleanupResources = () => {
|
|
1097
|
+
if (typeof session?.detach === "function" && !detachPromise) {
|
|
1098
|
+
detachPromise = Promise.resolve().then(() => session.detach());
|
|
1099
|
+
// Context closure is authoritative. CDP detach commonly rejects when
|
|
1100
|
+
// that same close wins the race, so observe but never await or expose it.
|
|
1101
|
+
void detachPromise.catch(() => {});
|
|
685
1102
|
}
|
|
686
|
-
if (
|
|
687
|
-
|
|
688
|
-
throw new Error("Campaigns OS polish capture requires a positive integer viewport.");
|
|
1103
|
+
if (typeof context?.close === "function" && !contextClosePromise) {
|
|
1104
|
+
contextClosePromise = Promise.resolve().then(() => context.close());
|
|
689
1105
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1106
|
+
if (!context) return Promise.resolve();
|
|
1107
|
+
if (!contextClosePromise) return Promise.reject(polishProducerCleanupError());
|
|
1108
|
+
return contextClosePromise.catch(() => { throw polishProducerCleanupError(); });
|
|
1109
|
+
};
|
|
1110
|
+
const assertActive = () => {
|
|
1111
|
+
if (!timedOut) return;
|
|
1112
|
+
poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
1113
|
+
void cleanupResources().catch(() => {});
|
|
1114
|
+
throw polishProducerTimeoutError();
|
|
1115
|
+
};
|
|
1116
|
+
const awaitActive = async (promise) => {
|
|
1117
|
+
try {
|
|
1118
|
+
const value = await promise;
|
|
1119
|
+
assertActive();
|
|
1120
|
+
return value;
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
assertActive();
|
|
1123
|
+
throw error;
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
const triggerTimeout = () => {
|
|
1127
|
+
timedOut = true;
|
|
1128
|
+
poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
1129
|
+
void cleanupResources().catch(() => {});
|
|
1130
|
+
};
|
|
1131
|
+
if (signal?.aborted) triggerTimeout();
|
|
1132
|
+
else if (typeof signal?.addEventListener === "function") {
|
|
1133
|
+
signal.addEventListener("abort", triggerTimeout, { once: true });
|
|
1134
|
+
}
|
|
1135
|
+
let setupEnded = false;
|
|
1136
|
+
// One setup call: under the caller's bound when there is one.
|
|
1137
|
+
const settle = async (start) => {
|
|
1138
|
+
if (!setup) return awaitActive(start());
|
|
1139
|
+
const settled = await setup.step(start);
|
|
1140
|
+
assertActive();
|
|
1141
|
+
if (settled === BOUND_ENDED) {
|
|
1142
|
+
setupEnded = true;
|
|
1143
|
+
throw BOUND_ENDED;
|
|
1144
|
+
}
|
|
1145
|
+
if (settled.error) throw settled.error;
|
|
1146
|
+
return settled.value;
|
|
1147
|
+
};
|
|
1148
|
+
const cellStartedAt = performance.now();
|
|
1149
|
+
const cellLeftMs = () => boundedCellDeadlineMs - (performance.now() - cellStartedAt) - READABILITY_CELL_HEADROOM_MS;
|
|
1150
|
+
let observation;
|
|
1151
|
+
let operationError = null;
|
|
1152
|
+
try {
|
|
1153
|
+
observation = await runWithPolishProducerDeadline(async () => {
|
|
1154
|
+
assertActive();
|
|
1155
|
+
// The context is kept as soon as it exists, so cleanup closes it; one
|
|
1156
|
+
// that arrives after the setup bound ended is closed at once.
|
|
1157
|
+
const newContext = () => Promise.resolve(browser.newContext({
|
|
1158
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
1159
|
+
serviceWorkers: "block",
|
|
1160
|
+
})).then((created) => {
|
|
1161
|
+
if (setupEnded) void Promise.resolve().then(() => created?.close()).catch(() => {});
|
|
1162
|
+
else context = created;
|
|
1163
|
+
return created;
|
|
1164
|
+
});
|
|
1165
|
+
if (setup) await settle(newContext);
|
|
1166
|
+
else await newContext();
|
|
1167
|
+
assertActive();
|
|
1168
|
+
if (authCookies.length > 0) {
|
|
1169
|
+
const origin = captureOrigin(url);
|
|
1170
|
+
// Fixed message: a URL containing credentials or query data is never echoed.
|
|
1171
|
+
if (origin === null) throw new Error("Campaigns OS polish capture requires an HTTP(S) capture URL before applying --auth-cookie.");
|
|
1172
|
+
await settle(() => context.addCookies(authCookies.map((cookie) => ({ ...cookie, url: origin }))));
|
|
704
1173
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1174
|
+
const page = await settle(() => context.newPage());
|
|
1175
|
+
session = setup ? await settle(() => context.newCDPSession(page)) : await context.newCDPSession(page);
|
|
1176
|
+
assertActive();
|
|
1177
|
+
return work({ page, session, awaitActive, cellLeftMs });
|
|
1178
|
+
}, {
|
|
1179
|
+
timeoutMs: boundedCellDeadlineMs,
|
|
1180
|
+
onTimeout: triggerTimeout,
|
|
1181
|
+
signal,
|
|
1182
|
+
});
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
if (error === BOUND_ENDED && setup) observation = setup.ended();
|
|
1185
|
+
else operationError = error;
|
|
1186
|
+
if (error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
|
|
711
1187
|
poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1188
|
+
}
|
|
1189
|
+
} finally {
|
|
1190
|
+
if (typeof signal?.removeEventListener === "function") {
|
|
1191
|
+
signal.removeEventListener("abort", triggerTimeout);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
let cleanupError = null;
|
|
1195
|
+
try {
|
|
1196
|
+
await runWithPolishProducerDeadline(cleanupResources, { timeoutMs: boundedCleanupDeadlineMs });
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
cleanupError = error;
|
|
1199
|
+
}
|
|
1200
|
+
if (cleanupError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
|
|
1201
|
+
poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
1202
|
+
throw cleanupError;
|
|
1203
|
+
}
|
|
1204
|
+
if (operationError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
|
|
1205
|
+
poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
1206
|
+
throw operationError;
|
|
1207
|
+
}
|
|
1208
|
+
if (cleanupError) {
|
|
1209
|
+
poisonCode = POLISH_PRODUCER_CLEANUP_ERROR_CODE;
|
|
1210
|
+
throw polishProducerCleanupError();
|
|
1211
|
+
}
|
|
1212
|
+
if (operationError) throw operationError;
|
|
1213
|
+
return observation;
|
|
1214
|
+
};
|
|
1215
|
+
return {
|
|
1216
|
+
// `imageProbe` ({ clock, remainingMs, cellBoundMs, imageCap }) opts the
|
|
1217
|
+
// cell into the media-weight image probe; the observation then carries
|
|
1218
|
+
// `imageProbe` beside the page-load fields. `readabilityProbe` ({ clock,
|
|
1219
|
+
// cellBoundMs, probeRemainingMs, cropRemainingMs, addedRemainingMs,
|
|
1220
|
+
// elementCap, cropsPerCell }) opts it into the readability probe, run
|
|
1221
|
+
// after the image probe; the observation then carries `readability`.
|
|
1222
|
+
async captureRoute({ url, viewport, signal, imageProbe: probeOptions = null, readabilityProbe: readabilityOptions = null } = {}) {
|
|
1223
|
+
return inCaptureContext({ url, viewport, signal }, async ({ page, session, awaitActive, cellLeftMs }) => {
|
|
1224
|
+
const collector = createNetworkCollector();
|
|
1225
|
+
await awaitActive(session.send("Network.enable"));
|
|
1226
|
+
await awaitActive(session.send("Network.setCacheDisabled", { cacheDisabled: true }));
|
|
1227
|
+
await awaitActive(session.send("Network.setBypassServiceWorker", { bypass: true }));
|
|
1228
|
+
collector.listen(session);
|
|
1229
|
+
|
|
1230
|
+
const navigationStartedAt = performance.now();
|
|
1231
|
+
await awaitActive(page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS }));
|
|
1232
|
+
const initialFrameTree = await awaitActive(session.send("Page.getFrameTree"));
|
|
1233
|
+
const initialMainFrame = initialFrameTree?.frameTree?.frame;
|
|
1234
|
+
const initialMediaElements = await awaitActive(collectMediaElements(page));
|
|
1235
|
+
let networkidle;
|
|
716
1236
|
try {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
return value;
|
|
1237
|
+
await awaitActive(page.waitForLoadState("networkidle", { timeout: NETWORKIDLE_TIMEOUT_MS }));
|
|
1238
|
+
networkidle = { status: "settled", duration_ms: durationSince(navigationStartedAt) };
|
|
720
1239
|
} catch (error) {
|
|
721
|
-
|
|
722
|
-
|
|
1240
|
+
if (!timeoutError(error)) throw error;
|
|
1241
|
+
networkidle = { status: "timeout", duration_ms: durationSince(navigationStartedAt) };
|
|
723
1242
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1243
|
+
const finalMediaElements = await awaitActive(collectMediaElements(page));
|
|
1244
|
+
await awaitActive(drainProtocolEvents());
|
|
1245
|
+
const frameTree = await awaitActive(session.send("Page.getFrameTree"));
|
|
1246
|
+
const mainFrame = frameTree?.frameTree?.frame;
|
|
1247
|
+
const finalDocumentUrl = page.url();
|
|
1248
|
+
const documentContextChanged = typeof initialMainFrame?.id !== "string"
|
|
1249
|
+
|| typeof initialMainFrame?.loaderId !== "string"
|
|
1250
|
+
|| initialMainFrame.id !== mainFrame?.id
|
|
1251
|
+
|| initialMainFrame.loaderId !== mainFrame?.loaderId;
|
|
1252
|
+
const mediaElements = mergeMediaElementSnapshots(
|
|
1253
|
+
documentContextChanged ? { observed_element_count: 0, elements: [] } : initialMediaElements,
|
|
1254
|
+
finalMediaElements,
|
|
1255
|
+
);
|
|
1256
|
+
const network = collector.finish({
|
|
1257
|
+
mainFrameId: mainFrame?.id,
|
|
1258
|
+
mainLoaderId: mainFrame?.loaderId,
|
|
1259
|
+
finalDocumentUrl,
|
|
1260
|
+
});
|
|
1261
|
+
if (documentContextChanged) {
|
|
1262
|
+
network.responseCollectionStatus = "failed";
|
|
1263
|
+
network.responses.push(captureProblemRecord("document_context_changed"));
|
|
1264
|
+
}
|
|
1265
|
+
const imageProbe = probeOptions && typeof probeOptions === "object"
|
|
1266
|
+
? await awaitActive(probeImageElements({ session, mainFrame, documentContextChanged, probe: probeOptions }))
|
|
1267
|
+
: null;
|
|
1268
|
+
const readability = readabilityOptions && typeof readabilityOptions === "object"
|
|
1269
|
+
? await awaitActive(probeReadabilityDocument({ session, mainFrame, documentContextChanged, probe: { ...readabilityOptions, cellLeftMs: cellLeftMs() } }))
|
|
1270
|
+
: null;
|
|
1271
|
+
return {
|
|
1272
|
+
finalDocumentUrl,
|
|
1273
|
+
responseCollectionStatus: network.responseCollectionStatus,
|
|
1274
|
+
networkidle,
|
|
1275
|
+
mediaElements,
|
|
1276
|
+
responses: network.responses,
|
|
1277
|
+
...(imageProbe ? { imageProbe } : {}),
|
|
1278
|
+
...(readability ? { readability } : {}),
|
|
1279
|
+
};
|
|
1280
|
+
});
|
|
1281
|
+
},
|
|
758
1282
|
|
|
1283
|
+
// A readability-only cell: a fresh context like captureRoute's, `goto`
|
|
1284
|
+
// until domcontentloaded, the network-idle wait (a timeout is recorded,
|
|
1285
|
+
// not a failure), then the readability probe (`probe` as captureRoute's
|
|
1286
|
+
// readabilityProbe). Navigation is bounded by the added run budget left
|
|
1287
|
+
// as well; a navigation that fails, or answers with an HTTP error, reads
|
|
1288
|
+
// navigation_failed, and one the budget cut, run_budget_exhausted.
|
|
1289
|
+
// Returns the probe's result and `networkidle`.
|
|
1290
|
+
async probeReadabilityRoute(route, viewport, { signal, probe = {} } = {}) {
|
|
1291
|
+
const clock = isProbeClock(probe.clock) ? probe.clock : realProbeClock;
|
|
1292
|
+
const cellStarted = clock.now();
|
|
1293
|
+
const addedLeft = () => probe.addedRemainingMs - Math.max(0, clock.now() - cellStarted);
|
|
1294
|
+
const unmeasured = (status, networkidle = null) => ({ status, capped: false, coverage_gaps: [], elements: [], crops: [], probe_ms: 0, crop_ms: 0, networkidle });
|
|
1295
|
+
// Every browser call of the cell is a step of a bound: the added budget
|
|
1296
|
+
// left on `clock` (a cell that starts with none makes no call), and the
|
|
1297
|
+
// cell deadline's headroom in real time. A bound the added budget ends
|
|
1298
|
+
// reads run_budget_exhausted; one the cell deadline's headroom ends,
|
|
1299
|
+
// probe_timeout. The setup calls (context, page, CDP session) have their
|
|
1300
|
+
// own bound, so it never cancels the page's steps.
|
|
1301
|
+
const token = { cancelled: false };
|
|
1302
|
+
const setupToken = { cancelled: false };
|
|
1303
|
+
let cdp = null;
|
|
1304
|
+
const bounded = (boundMs, hardMs, stepsToken = token) => boundedProbeSteps({ clock, session: { send: (method, params) => cdp.send(method, params) }, token: stepsToken, started: clock.now(), boundMs, hardMs });
|
|
1305
|
+
try {
|
|
1306
|
+
const setupSteps = bounded(probe.addedRemainingMs, boundedCellDeadlineMs - READABILITY_CELL_HEADROOM_MS, setupToken);
|
|
1307
|
+
const setup = { step: setupSteps.step, ended: () => unmeasured(setupSteps.hardEnded() ? "probe_timeout" : "run_budget_exhausted") };
|
|
1308
|
+
return await inCaptureContext({ url: route?.url, viewport, signal, setup }, async ({ page, session, awaitActive, cellLeftMs }) => {
|
|
1309
|
+
cdp = session;
|
|
1310
|
+
const addedAtStart = addedLeft();
|
|
1311
|
+
const cellAtStart = cellLeftMs();
|
|
1312
|
+
if (!(addedAtStart > 0)) return unmeasured("run_budget_exhausted");
|
|
1313
|
+
if (!(cellAtStart > 0)) return unmeasured("probe_timeout");
|
|
1314
|
+
const steps = bounded(addedAtStart, cellAtStart);
|
|
1315
|
+
const boundEnded = () => (steps.hardEnded() ? "probe_timeout" : "run_budget_exhausted");
|
|
1316
|
+
const navigationTimeout = Math.max(1, Math.min(NAVIGATION_TIMEOUT_MS, Math.floor(addedAtStart), Math.floor(cellAtStart)));
|
|
759
1317
|
const navigationStartedAt = performance.now();
|
|
760
|
-
await awaitActive(page.goto(url, { waitUntil: "domcontentloaded", timeout:
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
await awaitActive(page.waitForLoadState("networkidle", { timeout: NETWORKIDLE_TIMEOUT_MS }));
|
|
767
|
-
networkidle = { status: "settled", duration_ms: durationSince(navigationStartedAt) };
|
|
768
|
-
} catch (error) {
|
|
769
|
-
if (!timeoutError(error)) throw error;
|
|
770
|
-
networkidle = { status: "timeout", duration_ms: durationSince(navigationStartedAt) };
|
|
1318
|
+
const navigated = await awaitActive(steps.step(() => page.goto(route.url, { waitUntil: "domcontentloaded", timeout: navigationTimeout })));
|
|
1319
|
+
if (navigated === BOUND_ENDED) return unmeasured(boundEnded());
|
|
1320
|
+
if (navigated.error) {
|
|
1321
|
+
if (navigated.error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw navigated.error;
|
|
1322
|
+
const cutByAdded = navigationTimeout < NAVIGATION_TIMEOUT_MS && navigationTimeout === Math.max(1, Math.floor(addedAtStart));
|
|
1323
|
+
return unmeasured(timeoutError(navigated.error) && cutByAdded ? "run_budget_exhausted" : "navigation_failed");
|
|
771
1324
|
}
|
|
772
|
-
|
|
773
|
-
await awaitActive(
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const
|
|
1325
|
+
if (!navigated.value || navigated.value.status() >= 400) return unmeasured("navigation_failed");
|
|
1326
|
+
const initialTree = await awaitActive(steps.step(() => steps.send("Page.getFrameTree")));
|
|
1327
|
+
if (initialTree === BOUND_ENDED) return unmeasured(boundEnded());
|
|
1328
|
+
if (initialTree.error) return unmeasured("document_changed");
|
|
1329
|
+
const initialMainFrame = initialTree.value?.frameTree?.frame;
|
|
1330
|
+
const idleTimeout = Math.max(1, Math.min(NETWORKIDLE_TIMEOUT_MS, Math.floor(addedLeft()), Math.floor(cellLeftMs())));
|
|
1331
|
+
const idle = await awaitActive(steps.step(() => page.waitForLoadState("networkidle", { timeout: idleTimeout })));
|
|
1332
|
+
if (idle === BOUND_ENDED) return unmeasured(boundEnded());
|
|
1333
|
+
if (idle.error && !timeoutError(idle.error)) throw idle.error;
|
|
1334
|
+
const networkidle = { status: idle.error ? "timeout" : "settled", duration_ms: durationSince(navigationStartedAt) };
|
|
1335
|
+
const finalTree = await awaitActive(steps.step(() => steps.send("Page.getFrameTree")));
|
|
1336
|
+
if (finalTree === BOUND_ENDED) return unmeasured(boundEnded(), networkidle);
|
|
1337
|
+
if (finalTree.error) return unmeasured("document_changed", networkidle);
|
|
1338
|
+
const mainFrame = finalTree.value?.frameTree?.frame;
|
|
777
1339
|
const documentContextChanged = typeof initialMainFrame?.id !== "string"
|
|
778
1340
|
|| typeof initialMainFrame?.loaderId !== "string"
|
|
779
1341
|
|| initialMainFrame.id !== mainFrame?.id
|
|
780
1342
|
|| initialMainFrame.loaderId !== mainFrame?.loaderId;
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
finalDocumentUrl,
|
|
789
|
-
});
|
|
790
|
-
if (documentContextChanged) {
|
|
791
|
-
network.responseCollectionStatus = "failed";
|
|
792
|
-
network.responses.push(captureProblemRecord("document_context_changed"));
|
|
793
|
-
}
|
|
794
|
-
return {
|
|
795
|
-
finalDocumentUrl,
|
|
796
|
-
responseCollectionStatus: network.responseCollectionStatus,
|
|
797
|
-
networkidle,
|
|
798
|
-
mediaElements,
|
|
799
|
-
responses: network.responses,
|
|
800
|
-
};
|
|
801
|
-
}, {
|
|
802
|
-
timeoutMs: boundedCellDeadlineMs,
|
|
803
|
-
onTimeout: triggerTimeout,
|
|
804
|
-
signal,
|
|
1343
|
+
const readability = await awaitActive(probeReadabilityDocument({
|
|
1344
|
+
session,
|
|
1345
|
+
mainFrame,
|
|
1346
|
+
documentContextChanged,
|
|
1347
|
+
probe: { ...probe, addedRemainingMs: addedLeft(), cellLeftMs: cellLeftMs() },
|
|
1348
|
+
}));
|
|
1349
|
+
return { ...readability, networkidle };
|
|
805
1350
|
});
|
|
806
|
-
} catch (error) {
|
|
807
|
-
operationError = error;
|
|
808
|
-
if (error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
|
|
809
|
-
poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
810
|
-
}
|
|
811
1351
|
} finally {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
let cleanupError = null;
|
|
817
|
-
try {
|
|
818
|
-
await runWithPolishProducerDeadline(cleanupResources, { timeoutMs: boundedCleanupDeadlineMs });
|
|
819
|
-
} catch (error) {
|
|
820
|
-
cleanupError = error;
|
|
821
|
-
}
|
|
822
|
-
if (cleanupError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
|
|
823
|
-
poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
824
|
-
throw cleanupError;
|
|
825
|
-
}
|
|
826
|
-
if (operationError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
|
|
827
|
-
poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
|
|
828
|
-
throw operationError;
|
|
829
|
-
}
|
|
830
|
-
if (cleanupError) {
|
|
831
|
-
poisonCode = POLISH_PRODUCER_CLEANUP_ERROR_CODE;
|
|
832
|
-
throw polishProducerCleanupError();
|
|
1352
|
+
token.cancelled = true;
|
|
1353
|
+
setupToken.cancelled = true;
|
|
833
1354
|
}
|
|
834
|
-
if (operationError) throw operationError;
|
|
835
|
-
return observation;
|
|
836
1355
|
},
|
|
837
1356
|
|
|
838
1357
|
async close() {
|