framewatch-mcp-server 0.1.0 → 0.2.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 (133) hide show
  1. package/README.md +895 -15
  2. package/dist/constants.d.ts +274 -0
  3. package/dist/constants.js +279 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/engine/browser.d.ts +20 -4
  6. package/dist/engine/browser.js +26 -9
  7. package/dist/engine/browser.js.map +1 -1
  8. package/dist/engine/clicks.d.ts +221 -0
  9. package/dist/engine/clicks.js +801 -0
  10. package/dist/engine/clicks.js.map +1 -0
  11. package/dist/engine/forms.d.ts +137 -0
  12. package/dist/engine/forms.js +474 -0
  13. package/dist/engine/forms.js.map +1 -0
  14. package/dist/engine/hmr.d.ts +41 -0
  15. package/dist/engine/hmr.js +91 -0
  16. package/dist/engine/hmr.js.map +1 -0
  17. package/dist/engine/inspect.d.ts +31 -0
  18. package/dist/engine/inspect.js +383 -0
  19. package/dist/engine/inspect.js.map +1 -0
  20. package/dist/engine/interaction.d.ts +12 -7
  21. package/dist/engine/interaction.js +110 -18
  22. package/dist/engine/interaction.js.map +1 -1
  23. package/dist/engine/links.d.ts +134 -0
  24. package/dist/engine/links.js +384 -0
  25. package/dist/engine/links.js.map +1 -0
  26. package/dist/engine/mocks.d.ts +53 -0
  27. package/dist/engine/mocks.js +148 -0
  28. package/dist/engine/mocks.js.map +1 -0
  29. package/dist/engine/rtl.d.ts +129 -0
  30. package/dist/engine/rtl.js +540 -0
  31. package/dist/engine/rtl.js.map +1 -0
  32. package/dist/engine/seo.d.ts +189 -0
  33. package/dist/engine/seo.js +398 -0
  34. package/dist/engine/seo.js.map +1 -0
  35. package/dist/engine/snapshot.d.ts +29 -0
  36. package/dist/engine/snapshot.js +10 -0
  37. package/dist/engine/snapshot.js.map +1 -0
  38. package/dist/engine/vue.d.ts +54 -0
  39. package/dist/engine/vue.js +419 -0
  40. package/dist/engine/vue.js.map +1 -0
  41. package/dist/index.js +45 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/tools/accessibility.d.ts +4 -0
  44. package/dist/tools/accessibility.js +9 -2
  45. package/dist/tools/accessibility.js.map +1 -1
  46. package/dist/tools/api-mock.d.ts +405 -0
  47. package/dist/tools/api-mock.js +186 -0
  48. package/dist/tools/api-mock.js.map +1 -0
  49. package/dist/tools/capture.d.ts +90 -26
  50. package/dist/tools/capture.js +109 -58
  51. package/dist/tools/capture.js.map +1 -1
  52. package/dist/tools/compare.d.ts +4 -0
  53. package/dist/tools/compare.js +16 -5
  54. package/dist/tools/compare.js.map +1 -1
  55. package/dist/tools/dead-clicks.d.ts +128 -0
  56. package/dist/tools/dead-clicks.js +570 -0
  57. package/dist/tools/dead-clicks.js.map +1 -0
  58. package/dist/tools/form-test.d.ts +112 -0
  59. package/dist/tools/form-test.js +477 -0
  60. package/dist/tools/form-test.js.map +1 -0
  61. package/dist/tools/index.d.ts +17 -1
  62. package/dist/tools/index.js +45 -1
  63. package/dist/tools/index.js.map +1 -1
  64. package/dist/tools/inspect.d.ts +78 -0
  65. package/dist/tools/inspect.js +136 -0
  66. package/dist/tools/inspect.js.map +1 -0
  67. package/dist/tools/interact.d.ts +37 -18
  68. package/dist/tools/interact.js +113 -13
  69. package/dist/tools/interact.js.map +1 -1
  70. package/dist/tools/links.d.ts +129 -0
  71. package/dist/tools/links.js +640 -0
  72. package/dist/tools/links.js.map +1 -0
  73. package/dist/tools/responsive.d.ts +10 -6
  74. package/dist/tools/responsive.js +21 -4
  75. package/dist/tools/responsive.js.map +1 -1
  76. package/dist/tools/rtl.d.ts +241 -0
  77. package/dist/tools/rtl.js +410 -0
  78. package/dist/tools/rtl.js.map +1 -0
  79. package/dist/tools/save-auth.d.ts +263 -0
  80. package/dist/tools/save-auth.js +253 -0
  81. package/dist/tools/save-auth.js.map +1 -0
  82. package/dist/tools/screenshot.d.ts +4 -0
  83. package/dist/tools/screenshot.js +15 -4
  84. package/dist/tools/screenshot.js.map +1 -1
  85. package/dist/tools/seo.d.ts +113 -0
  86. package/dist/tools/seo.js +281 -0
  87. package/dist/tools/seo.js.map +1 -0
  88. package/dist/tools/snapshot.d.ts +122 -0
  89. package/dist/tools/snapshot.js +183 -0
  90. package/dist/tools/snapshot.js.map +1 -0
  91. package/dist/tools/wait-for.d.ts +107 -0
  92. package/dist/tools/wait-for.js +167 -0
  93. package/dist/tools/wait-for.js.map +1 -0
  94. package/dist/utils/arabic-text.d.ts +14 -0
  95. package/dist/utils/arabic-text.js +193 -0
  96. package/dist/utils/arabic-text.js.map +1 -0
  97. package/dist/utils/budget.d.ts +41 -0
  98. package/dist/utils/budget.js +182 -0
  99. package/dist/utils/budget.js.map +1 -0
  100. package/dist/utils/format.d.ts +11 -1
  101. package/dist/utils/format.js +27 -4
  102. package/dist/utils/format.js.map +1 -1
  103. package/dist/utils/highlight.d.ts +69 -0
  104. package/dist/utils/highlight.js +181 -0
  105. package/dist/utils/highlight.js.map +1 -0
  106. package/dist/utils/link-rules.d.ts +100 -0
  107. package/dist/utils/link-rules.js +284 -0
  108. package/dist/utils/link-rules.js.map +1 -0
  109. package/dist/utils/mock-rules.d.ts +144 -0
  110. package/dist/utils/mock-rules.js +224 -0
  111. package/dist/utils/mock-rules.js.map +1 -0
  112. package/dist/utils/rtl-rules.d.ts +142 -0
  113. package/dist/utils/rtl-rules.js +296 -0
  114. package/dist/utils/rtl-rules.js.map +1 -0
  115. package/dist/utils/seo-rules.d.ts +129 -0
  116. package/dist/utils/seo-rules.js +726 -0
  117. package/dist/utils/seo-rules.js.map +1 -0
  118. package/dist/utils/snapshot-rules.d.ts +33 -0
  119. package/dist/utils/snapshot-rules.js +111 -0
  120. package/dist/utils/snapshot-rules.js.map +1 -0
  121. package/dist/utils/storage-state.d.ts +76 -0
  122. package/dist/utils/storage-state.js +195 -0
  123. package/dist/utils/storage-state.js.map +1 -0
  124. package/dist/utils/style-rules.d.ts +107 -0
  125. package/dist/utils/style-rules.js +223 -0
  126. package/dist/utils/style-rules.js.map +1 -0
  127. package/dist/utils/test-data.d.ts +75 -0
  128. package/dist/utils/test-data.js +294 -0
  129. package/dist/utils/test-data.js.map +1 -0
  130. package/dist/utils/vue-rules.d.ts +72 -0
  131. package/dist/utils/vue-rules.js +108 -0
  132. package/dist/utils/vue-rules.js.map +1 -0
  133. package/package.json +6 -4
@@ -0,0 +1,189 @@
1
+ import type { BrowserContext, Page } from "playwright";
2
+ /**
3
+ * SEO engine.
4
+ *
5
+ * Everything here talks to the page or the network; nothing here decides
6
+ * whether what it found is good or bad — that is `utils/seo-rules.ts`, which
7
+ * is pure and therefore testable without a browser.
8
+ *
9
+ * The split matters more than it looks. What a crawler sees is a fact about
10
+ * one particular page load: the DOM *after* the app has rendered (which is why
11
+ * this reads the live document rather than the HTML source), the response
12
+ * headers, robots.txt, and whether the share image actually resolves. What
13
+ * counts as a problem is a judgement, and judgements need unit tests.
14
+ */
15
+ /** A `<link rel=…>`, with its href resolved against the document. */
16
+ export interface LinkTag {
17
+ href: string;
18
+ resolved?: string;
19
+ hreflang?: string;
20
+ type?: string;
21
+ }
22
+ export interface HeadingNode {
23
+ level: number;
24
+ text: string;
25
+ /** Not rendered — a visually-hidden h1 is still crawled, so this is context, not a verdict. */
26
+ hidden: boolean;
27
+ empty: boolean;
28
+ }
29
+ export interface ImageNode {
30
+ src: string;
31
+ /** How the element is named in the report — `#hero`, `.logo`, or its source. */
32
+ description: string;
33
+ }
34
+ export interface ImageSummary {
35
+ total: number;
36
+ /** Images with no `alt` attribute at all (not the same as `alt=""`). */
37
+ missing_alt: ImageNode[];
38
+ missing_alt_total: number;
39
+ /** `alt=""` — a decorative image, correctly marked. */
40
+ empty_alt: number;
41
+ /** No `width`/`height` attributes, so the browser cannot reserve space (layout shift). */
42
+ no_dimensions: number;
43
+ lazy: number;
44
+ }
45
+ export interface JsonLdBlock {
46
+ /** The raw script contents, elided at MAX_SEO_JSONLD_LENGTH. */
47
+ text: string;
48
+ truncated: boolean;
49
+ }
50
+ export interface AnchorSummary {
51
+ total: number;
52
+ internal: number;
53
+ external: number;
54
+ nofollow: number;
55
+ /** Links with neither text nor an accessible name — a dead end for a crawler. */
56
+ empty: number;
57
+ }
58
+ /**
59
+ * Everything read out of the rendered document, unjudged.
60
+ *
61
+ * Meta tags are kept as three raw maps rather than a curated set of named
62
+ * fields. `<meta name>` and `<meta property>` are different attributes and
63
+ * pages use them interchangeably (`og:title` shows up under both), so the
64
+ * rules look a key up in whichever map has it instead of the extraction
65
+ * deciding in advance which spellings exist.
66
+ */
67
+ export interface PageSeo {
68
+ url: string;
69
+ title: string | null;
70
+ title_count: number;
71
+ lang: string;
72
+ dir: string;
73
+ charset: string;
74
+ named: Record<string, string[]>;
75
+ properties: Record<string, string[]>;
76
+ http_equiv: Record<string, string[]>;
77
+ links: Record<string, LinkTag[]>;
78
+ headings: HeadingNode[];
79
+ heading_total: number;
80
+ heading_counts: Record<string, number>;
81
+ images: ImageSummary;
82
+ jsonld: JsonLdBlock[];
83
+ jsonld_total: number;
84
+ /** Elements carrying microdata / RDFa, so "no JSON-LD" can say whether there is markup elsewhere. */
85
+ microdata: number;
86
+ word_count: number;
87
+ anchors: AnchorSummary;
88
+ /** Nodes in the document — Lighthouse's DOM-size warning, and a decent proxy for a page a crawler will struggle with. */
89
+ dom_nodes: number;
90
+ }
91
+ interface ExtractOptions {
92
+ max_headings: number;
93
+ max_images: number;
94
+ max_text: number;
95
+ max_jsonld: number;
96
+ max_jsonld_length: number;
97
+ }
98
+ /**
99
+ * Read the SEO-relevant parts of the rendered document.
100
+ *
101
+ * One `evaluate`, and everything it can find in one pass. It runs against the
102
+ * live DOM on purpose: a single-page app serves an empty `<div id="app">` in
103
+ * its HTML and fills in the title, the description and the structured data
104
+ * afterwards, so reading the source would report every client-rendered page as
105
+ * having no SEO at all.
106
+ *
107
+ * Written against `globalThis` because this package compiles with the Node lib
108
+ * only, and because a page can be mid-teardown when this lands in it.
109
+ */
110
+ export declare function extractSeo(page: Page, options?: Partial<ExtractOptions>): Promise<PageSeo>;
111
+ export interface RobotsFetch {
112
+ url: string;
113
+ status?: number;
114
+ text?: string;
115
+ /** Why there is no text: unreachable, timed out, or a protocol with no robots.txt. */
116
+ error?: string;
117
+ }
118
+ /**
119
+ * Fetch the origin's robots.txt.
120
+ *
121
+ * Through the browser context, so it goes out with the same cookies and proxy
122
+ * settings as the page did. A 404 is not a failure — it is the answer "there
123
+ * are no rules", which is what the rules module does with it.
124
+ */
125
+ export declare function fetchRobots(context: BrowserContext, pageUrl: string, timeoutMs?: number): Promise<RobotsFetch>;
126
+ export interface ImageProbe {
127
+ url: string;
128
+ ok: boolean;
129
+ status?: number;
130
+ content_type?: string;
131
+ bytes?: number;
132
+ width?: number;
133
+ height?: number;
134
+ error?: string;
135
+ /** The image itself, when it is small enough to hand back to the client. */
136
+ data?: Buffer;
137
+ }
138
+ /**
139
+ * Fetch a share image and measure it.
140
+ *
141
+ * An `og:image` pointing at a 404, or at a 40x40 favicon, is invisible from
142
+ * the page itself and shows up as a blank card on every network that renders
143
+ * the link — which is exactly the sort of thing nobody notices until someone
144
+ * shares the page. So it is fetched and measured rather than merely reported
145
+ * as present.
146
+ */
147
+ export declare function probeImage(context: BrowserContext, url: string, timeoutMs?: number, maxBytes?: number): Promise<ImageProbe>;
148
+ export interface ResourceGroup {
149
+ type: string;
150
+ count: number;
151
+ bytes: number;
152
+ }
153
+ export interface PerfMetrics {
154
+ ttfb_ms?: number;
155
+ dom_content_loaded_ms?: number;
156
+ load_ms?: number;
157
+ fcp_ms?: number;
158
+ lcp_ms?: number;
159
+ lcp_element?: string;
160
+ cls?: number;
161
+ long_tasks: number;
162
+ requests: number;
163
+ transfer_bytes: number;
164
+ /** True when at least one resource reported a transferSize of 0 — see `readPerformance`. */
165
+ transfer_incomplete: boolean;
166
+ resources: ResourceGroup[];
167
+ }
168
+ /**
169
+ * The observers that have to be running before the page loads.
170
+ *
171
+ * LCP, CLS and long tasks are only observable as they happen. `buffered: true`
172
+ * covers the gap between document start and this script running, but nothing
173
+ * covers a script that arrives after the event, so this goes in through
174
+ * `addInitScript` before the first navigation.
175
+ */
176
+ export declare const PERF_INIT_SCRIPT: () => void;
177
+ /**
178
+ * Read the metrics back once the page has settled.
179
+ *
180
+ * Everything else — navigation timing, paint timing, resource weight — is
181
+ * still in the performance buffer at this point and needs no observer.
182
+ *
183
+ * `transferSize` is 0 for a cross-origin resource that does not send
184
+ * `Timing-Allow-Origin`, which is indistinguishable from a cached response, so
185
+ * the total is reported alongside a flag saying it is a floor rather than a
186
+ * total.
187
+ */
188
+ export declare function readPerformance(page: Page): Promise<PerfMetrics | undefined>;
189
+ export {};
@@ -0,0 +1,398 @@
1
+ import { MAX_SEO_HEADINGS, MAX_SEO_IMAGES_LISTED, MAX_SEO_IMAGE_BYTES, MAX_SEO_JSONLD_BLOCKS, MAX_SEO_JSONLD_LENGTH, MAX_SEO_TEXT_LENGTH, SEO_FETCH_TIMEOUT_MS, } from "../constants.js";
2
+ import { getDimensions } from "../utils/image.js";
3
+ /**
4
+ * Read the SEO-relevant parts of the rendered document.
5
+ *
6
+ * One `evaluate`, and everything it can find in one pass. It runs against the
7
+ * live DOM on purpose: a single-page app serves an empty `<div id="app">` in
8
+ * its HTML and fills in the title, the description and the structured data
9
+ * afterwards, so reading the source would report every client-rendered page as
10
+ * having no SEO at all.
11
+ *
12
+ * Written against `globalThis` because this package compiles with the Node lib
13
+ * only, and because a page can be mid-teardown when this lands in it.
14
+ */
15
+ export async function extractSeo(page, options = {}) {
16
+ const opts = {
17
+ max_headings: options.max_headings ?? MAX_SEO_HEADINGS,
18
+ max_images: options.max_images ?? MAX_SEO_IMAGES_LISTED,
19
+ max_text: options.max_text ?? MAX_SEO_TEXT_LENGTH,
20
+ max_jsonld: options.max_jsonld ?? MAX_SEO_JSONLD_BLOCKS,
21
+ max_jsonld_length: options.max_jsonld_length ?? MAX_SEO_JSONLD_LENGTH,
22
+ };
23
+ return page.evaluate((o) => {
24
+ const g = globalThis;
25
+ const doc = g.document;
26
+ const flat = (value) => String(value ?? "")
27
+ .replace(/\s+/g, " ")
28
+ .trim();
29
+ const elide = (value, max) => {
30
+ const text = flat(value);
31
+ return text.length > max ? `${text.slice(0, max)}…` : text;
32
+ };
33
+ const resolve = (href) => {
34
+ try {
35
+ return new g.URL(href, doc.baseURI ?? g.location.href).href;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ };
41
+ const push = (map, key, value) => {
42
+ if (!key)
43
+ return;
44
+ (map[key] ??= []).push(value);
45
+ };
46
+ /* Meta tags — three maps, keyed exactly as the page spelled them. */
47
+ const named = {};
48
+ const properties = {};
49
+ const httpEquiv = {};
50
+ let charset = "";
51
+ for (const meta of doc.querySelectorAll("meta")) {
52
+ if (meta.hasAttribute("charset"))
53
+ charset = flat(meta.getAttribute("charset"));
54
+ const content = flat(meta.getAttribute("content"));
55
+ const name = flat(meta.getAttribute("name")).toLowerCase();
56
+ const property = flat(meta.getAttribute("property")).toLowerCase();
57
+ const equiv = flat(meta.getAttribute("http-equiv")).toLowerCase();
58
+ if (name)
59
+ push(named, name, content);
60
+ if (property)
61
+ push(properties, property, content);
62
+ if (equiv) {
63
+ push(httpEquiv, equiv, content);
64
+ if (equiv === "content-type" && !charset) {
65
+ const match = /charset=([\w-]+)/i.exec(content);
66
+ if (match)
67
+ charset = match[1];
68
+ }
69
+ }
70
+ }
71
+ /* <link rel=…>, grouped by rel. One tag can carry several rels. */
72
+ const links = {};
73
+ for (const link of doc.querySelectorAll("link[rel]")) {
74
+ const href = flat(link.getAttribute("href"));
75
+ const tag = { href };
76
+ const resolved = href ? resolve(href) : undefined;
77
+ if (resolved)
78
+ tag.resolved = resolved;
79
+ const hreflang = flat(link.getAttribute("hreflang"));
80
+ if (hreflang)
81
+ tag.hreflang = hreflang;
82
+ const type = flat(link.getAttribute("type"));
83
+ if (type)
84
+ tag.type = type;
85
+ for (const rel of flat(link.getAttribute("rel")).toLowerCase().split(/\s+/)) {
86
+ if (rel)
87
+ (links[rel] ??= []).push(tag);
88
+ }
89
+ }
90
+ /* Headings, in document order — the outline a crawler builds. */
91
+ const headings = [];
92
+ const headingCounts = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 };
93
+ const headingElements = doc.querySelectorAll("h1, h2, h3, h4, h5, h6");
94
+ for (const heading of headingElements) {
95
+ const level = Number(heading.tagName.slice(1));
96
+ headingCounts[`h${level}`] = (headingCounts[`h${level}`] ?? 0) + 1;
97
+ if (headings.length >= o.max_headings)
98
+ continue;
99
+ // offsetParent is null for display:none and for position:fixed, so the
100
+ // rect is checked too — a fixed header's h1 is visible, not hidden.
101
+ const rect = heading.getBoundingClientRect?.();
102
+ const hidden = (heading.offsetParent === null && (!rect || (rect.width === 0 && rect.height === 0))) || false;
103
+ const text = flat(heading.textContent);
104
+ headings.push({ level, text: elide(text, o.max_text), hidden, empty: text.length === 0 });
105
+ }
106
+ /* Images. A missing alt attribute and alt="" are different findings. */
107
+ const imageElements = doc.querySelectorAll("img");
108
+ const missingAlt = [];
109
+ let missingAltTotal = 0;
110
+ let emptyAlt = 0;
111
+ let noDimensions = 0;
112
+ let lazy = 0;
113
+ for (const image of imageElements) {
114
+ const src = flat(image.getAttribute("src") || image.getAttribute("data-src") || image.currentSrc);
115
+ if (!image.hasAttribute("alt")) {
116
+ missingAltTotal++;
117
+ if (missingAlt.length < o.max_images) {
118
+ const id = flat(image.getAttribute("id"));
119
+ const cls = flat(image.getAttribute("class")).split(" ")[0];
120
+ const description = id ? `#${id}` : cls ? `img.${cls}` : elide(src || "img", o.max_text);
121
+ missingAlt.push({ src: elide(src, o.max_text), description });
122
+ }
123
+ }
124
+ else if (flat(image.getAttribute("alt")) === "") {
125
+ emptyAlt++;
126
+ }
127
+ if (!image.hasAttribute("width") || !image.hasAttribute("height"))
128
+ noDimensions++;
129
+ if (flat(image.getAttribute("loading")).toLowerCase() === "lazy")
130
+ lazy++;
131
+ }
132
+ /* Structured data: collected raw, parsed in Node where a syntax error can be reported. */
133
+ const scripts = doc.querySelectorAll('script[type="application/ld+json"], script[type="application/ld+json;charset=utf-8"]');
134
+ const jsonld = [];
135
+ for (const script of scripts) {
136
+ if (jsonld.length >= o.max_jsonld)
137
+ break;
138
+ const text = String(script.textContent ?? "");
139
+ jsonld.push({
140
+ text: text.length > o.max_jsonld_length ? text.slice(0, o.max_jsonld_length) : text,
141
+ truncated: text.length > o.max_jsonld_length,
142
+ });
143
+ }
144
+ /* Links out of the page. */
145
+ const anchors = doc.querySelectorAll("a[href]");
146
+ let internal = 0;
147
+ let external = 0;
148
+ let nofollow = 0;
149
+ let emptyAnchors = 0;
150
+ const origin = g.location?.origin;
151
+ for (const anchor of anchors) {
152
+ const href = flat(anchor.getAttribute("href"));
153
+ const resolved = resolve(href);
154
+ if (resolved) {
155
+ try {
156
+ if (new g.URL(resolved).origin === origin)
157
+ internal++;
158
+ else
159
+ external++;
160
+ }
161
+ catch {
162
+ /* not a URL we can classify */
163
+ }
164
+ }
165
+ if (/\bnofollow\b/i.test(flat(anchor.getAttribute("rel"))))
166
+ nofollow++;
167
+ const label = flat(anchor.textContent) || flat(anchor.getAttribute("aria-label")) || flat(anchor.getAttribute("title"));
168
+ if (!label && anchor.querySelector("img[alt]:not([alt=''])") === null)
169
+ emptyAnchors++;
170
+ }
171
+ const bodyText = flat(doc.body?.innerText ?? "");
172
+ return {
173
+ url: String(g.location?.href ?? ""),
174
+ title: doc.title === undefined || doc.title === null ? null : flat(doc.title),
175
+ title_count: doc.querySelectorAll("title").length,
176
+ lang: flat(doc.documentElement?.getAttribute("lang")),
177
+ dir: flat(doc.documentElement?.getAttribute("dir")),
178
+ charset,
179
+ named,
180
+ properties,
181
+ http_equiv: httpEquiv,
182
+ links,
183
+ headings,
184
+ heading_total: headingElements.length,
185
+ heading_counts: headingCounts,
186
+ images: {
187
+ total: imageElements.length,
188
+ missing_alt: missingAlt,
189
+ missing_alt_total: missingAltTotal,
190
+ empty_alt: emptyAlt,
191
+ no_dimensions: noDimensions,
192
+ lazy,
193
+ },
194
+ jsonld,
195
+ jsonld_total: scripts.length,
196
+ microdata: doc.querySelectorAll("[itemscope]").length,
197
+ word_count: bodyText ? bodyText.split(" ").length : 0,
198
+ anchors: { total: anchors.length, internal, external, nofollow, empty: emptyAnchors },
199
+ dom_nodes: doc.getElementsByTagName("*").length,
200
+ };
201
+ }, opts);
202
+ }
203
+ /**
204
+ * Fetch the origin's robots.txt.
205
+ *
206
+ * Through the browser context, so it goes out with the same cookies and proxy
207
+ * settings as the page did. A 404 is not a failure — it is the answer "there
208
+ * are no rules", which is what the rules module does with it.
209
+ */
210
+ export async function fetchRobots(context, pageUrl, timeoutMs = SEO_FETCH_TIMEOUT_MS) {
211
+ let robotsUrl;
212
+ try {
213
+ const parsed = new URL(pageUrl);
214
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
215
+ return { url: pageUrl, error: `${parsed.protocol}// has no robots.txt` };
216
+ }
217
+ robotsUrl = new URL("/robots.txt", parsed.origin).href;
218
+ }
219
+ catch (error) {
220
+ return { url: pageUrl, error: firstLine(error) };
221
+ }
222
+ try {
223
+ const response = await context.request.get(robotsUrl, { timeout: timeoutMs, failOnStatusCode: false });
224
+ const status = response.status();
225
+ if (status >= 400)
226
+ return { url: robotsUrl, status };
227
+ return { url: robotsUrl, status, text: await response.text() };
228
+ }
229
+ catch (error) {
230
+ return { url: robotsUrl, error: firstLine(error) };
231
+ }
232
+ }
233
+ /**
234
+ * Fetch a share image and measure it.
235
+ *
236
+ * An `og:image` pointing at a 404, or at a 40x40 favicon, is invisible from
237
+ * the page itself and shows up as a blank card on every network that renders
238
+ * the link — which is exactly the sort of thing nobody notices until someone
239
+ * shares the page. So it is fetched and measured rather than merely reported
240
+ * as present.
241
+ */
242
+ export async function probeImage(context, url, timeoutMs = SEO_FETCH_TIMEOUT_MS, maxBytes = MAX_SEO_IMAGE_BYTES) {
243
+ try {
244
+ const response = await context.request.get(url, { timeout: timeoutMs, failOnStatusCode: false });
245
+ const status = response.status();
246
+ const contentType = response.headers()["content-type"];
247
+ const probe = { url, ok: status < 400, status };
248
+ if (contentType)
249
+ probe.content_type = contentType;
250
+ if (!probe.ok)
251
+ return probe;
252
+ const body = await response.body();
253
+ probe.bytes = body.byteLength;
254
+ try {
255
+ const { width, height } = await getDimensions(body);
256
+ probe.width = width;
257
+ probe.height = height;
258
+ if (body.byteLength <= maxBytes)
259
+ probe.data = body;
260
+ }
261
+ catch (error) {
262
+ // Reachable but not an image this build can decode (an SVG on a libvips
263
+ // without librsvg, an HTML error page served as 200). Worth saying.
264
+ probe.error = firstLine(error);
265
+ }
266
+ return probe;
267
+ }
268
+ catch (error) {
269
+ return { url, ok: false, error: firstLine(error) };
270
+ }
271
+ }
272
+ /**
273
+ * The observers that have to be running before the page loads.
274
+ *
275
+ * LCP, CLS and long tasks are only observable as they happen. `buffered: true`
276
+ * covers the gap between document start and this script running, but nothing
277
+ * covers a script that arrives after the event, so this goes in through
278
+ * `addInitScript` before the first navigation.
279
+ */
280
+ export const PERF_INIT_SCRIPT = () => {
281
+ const g = globalThis;
282
+ // Whole-page metrics: the main frame owns them.
283
+ if (g.top && g.top !== g)
284
+ return;
285
+ if (g.__framewatch_seo_perf)
286
+ return;
287
+ const state = { lcp: 0, lcp_element: "", cls: 0, long_tasks: 0 };
288
+ g.__framewatch_seo_perf = state;
289
+ if (typeof g.PerformanceObserver !== "function")
290
+ return;
291
+ const describe = (element) => {
292
+ if (!element || !element.tagName)
293
+ return "";
294
+ const tag = String(element.tagName).toLowerCase();
295
+ const id = element.id ? `#${element.id}` : "";
296
+ const cls = element.classList && element.classList.length > 0 ? `.${element.classList[0]}` : "";
297
+ return `${tag}${id}${cls}`;
298
+ };
299
+ const observe = (type, handle) => {
300
+ try {
301
+ new g.PerformanceObserver((list) => handle(list.getEntries())).observe({ type, buffered: true });
302
+ }
303
+ catch {
304
+ // This browser does not report this entry type; the metric is simply absent.
305
+ }
306
+ };
307
+ observe("largest-contentful-paint", (entries) => {
308
+ for (const entry of entries) {
309
+ // Each entry supersedes the last: LCP is the final one before interaction.
310
+ state.lcp = entry.startTime;
311
+ state.lcp_element = describe(entry.element);
312
+ }
313
+ });
314
+ observe("layout-shift", (entries) => {
315
+ for (const entry of entries) {
316
+ // Shifts within 500ms of a real interaction are the user's doing, and
317
+ // Chrome's CLS excludes them for the same reason.
318
+ if (!entry.hadRecentInput)
319
+ state.cls += entry.value;
320
+ }
321
+ });
322
+ observe("longtask", (entries) => {
323
+ state.long_tasks += entries.length;
324
+ });
325
+ };
326
+ /**
327
+ * Read the metrics back once the page has settled.
328
+ *
329
+ * Everything else — navigation timing, paint timing, resource weight — is
330
+ * still in the performance buffer at this point and needs no observer.
331
+ *
332
+ * `transferSize` is 0 for a cross-origin resource that does not send
333
+ * `Timing-Allow-Origin`, which is indistinguishable from a cached response, so
334
+ * the total is reported alongside a flag saying it is a floor rather than a
335
+ * total.
336
+ */
337
+ export async function readPerformance(page) {
338
+ try {
339
+ return await page.evaluate(() => {
340
+ const g = globalThis;
341
+ const perf = g.performance;
342
+ const state = g.__framewatch_seo_perf ?? { lcp: 0, lcp_element: "", cls: 0, long_tasks: 0 };
343
+ const round = (value) => {
344
+ const n = Number(value);
345
+ return Number.isFinite(n) && n >= 0 ? Math.round(n) : undefined;
346
+ };
347
+ const metrics = {
348
+ long_tasks: state.long_tasks ?? 0,
349
+ requests: 0,
350
+ transfer_bytes: 0,
351
+ transfer_incomplete: false,
352
+ resources: [],
353
+ };
354
+ const nav = perf?.getEntriesByType?.("navigation")?.[0];
355
+ if (nav) {
356
+ metrics.ttfb_ms = round(nav.responseStart);
357
+ metrics.dom_content_loaded_ms = round(nav.domContentLoadedEventEnd);
358
+ metrics.load_ms = round(nav.loadEventEnd) || undefined;
359
+ if (nav.transferSize > 0)
360
+ metrics.transfer_bytes += nav.transferSize;
361
+ }
362
+ for (const paint of perf?.getEntriesByType?.("paint") ?? []) {
363
+ if (paint.name === "first-contentful-paint")
364
+ metrics.fcp_ms = round(paint.startTime);
365
+ }
366
+ if (state.lcp > 0) {
367
+ metrics.lcp_ms = round(state.lcp);
368
+ if (state.lcp_element)
369
+ metrics.lcp_element = state.lcp_element;
370
+ }
371
+ if (typeof state.cls === "number")
372
+ metrics.cls = Math.round(state.cls * 1000) / 1000;
373
+ const byType = new Map();
374
+ for (const resource of perf?.getEntriesByType?.("resource") ?? []) {
375
+ metrics.requests++;
376
+ const size = Number(resource.transferSize) || 0;
377
+ if (size === 0)
378
+ metrics.transfer_incomplete = true;
379
+ metrics.transfer_bytes += size;
380
+ const type = String(resource.initiatorType || "other");
381
+ const group = byType.get(type) ?? { type, count: 0, bytes: 0 };
382
+ group.count++;
383
+ group.bytes += size;
384
+ byType.set(type, group);
385
+ }
386
+ metrics.resources = [...byType.values()].sort((a, b) => b.bytes - a.bytes || b.count - a.count);
387
+ return metrics;
388
+ });
389
+ }
390
+ catch {
391
+ // Cosmetic: a page that dies before this is read still gets its audit.
392
+ return undefined;
393
+ }
394
+ }
395
+ function firstLine(error) {
396
+ return (error instanceof Error ? error.message : String(error)).split("\n")[0];
397
+ }
398
+ //# sourceMappingURL=seo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo.js","sourceRoot":"","sources":["../../src/engine/seo.ts"],"names":[],"mappings":"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","sourcesContent":["import type { BrowserContext, Page } from \"playwright\";\nimport {\n MAX_SEO_HEADINGS,\n MAX_SEO_IMAGES_LISTED,\n MAX_SEO_IMAGE_BYTES,\n MAX_SEO_JSONLD_BLOCKS,\n MAX_SEO_JSONLD_LENGTH,\n MAX_SEO_TEXT_LENGTH,\n SEO_FETCH_TIMEOUT_MS,\n} from \"../constants.js\";\nimport { getDimensions } from \"../utils/image.js\";\n\n/**\n * SEO engine.\n *\n * Everything here talks to the page or the network; nothing here decides\n * whether what it found is good or bad — that is `utils/seo-rules.ts`, which\n * is pure and therefore testable without a browser.\n *\n * The split matters more than it looks. What a crawler sees is a fact about\n * one particular page load: the DOM *after* the app has rendered (which is why\n * this reads the live document rather than the HTML source), the response\n * headers, robots.txt, and whether the share image actually resolves. What\n * counts as a problem is a judgement, and judgements need unit tests.\n */\n\n/* ── What the page says about itself ──────────────────────────────────── */\n\n/** A `<link rel=…>`, with its href resolved against the document. */\nexport interface LinkTag {\n href: string;\n resolved?: string;\n hreflang?: string;\n type?: string;\n}\n\nexport interface HeadingNode {\n level: number;\n text: string;\n /** Not rendered — a visually-hidden h1 is still crawled, so this is context, not a verdict. */\n hidden: boolean;\n empty: boolean;\n}\n\nexport interface ImageNode {\n src: string;\n /** How the element is named in the report — `#hero`, `.logo`, or its source. */\n description: string;\n}\n\nexport interface ImageSummary {\n total: number;\n /** Images with no `alt` attribute at all (not the same as `alt=\"\"`). */\n missing_alt: ImageNode[];\n missing_alt_total: number;\n /** `alt=\"\"` — a decorative image, correctly marked. */\n empty_alt: number;\n /** No `width`/`height` attributes, so the browser cannot reserve space (layout shift). */\n no_dimensions: number;\n lazy: number;\n}\n\nexport interface JsonLdBlock {\n /** The raw script contents, elided at MAX_SEO_JSONLD_LENGTH. */\n text: string;\n truncated: boolean;\n}\n\nexport interface AnchorSummary {\n total: number;\n internal: number;\n external: number;\n nofollow: number;\n /** Links with neither text nor an accessible name — a dead end for a crawler. */\n empty: number;\n}\n\n/**\n * Everything read out of the rendered document, unjudged.\n *\n * Meta tags are kept as three raw maps rather than a curated set of named\n * fields. `<meta name>` and `<meta property>` are different attributes and\n * pages use them interchangeably (`og:title` shows up under both), so the\n * rules look a key up in whichever map has it instead of the extraction\n * deciding in advance which spellings exist.\n */\nexport interface PageSeo {\n url: string;\n title: string | null;\n title_count: number;\n lang: string;\n dir: string;\n charset: string;\n named: Record<string, string[]>;\n properties: Record<string, string[]>;\n http_equiv: Record<string, string[]>;\n links: Record<string, LinkTag[]>;\n headings: HeadingNode[];\n heading_total: number;\n heading_counts: Record<string, number>;\n images: ImageSummary;\n jsonld: JsonLdBlock[];\n jsonld_total: number;\n /** Elements carrying microdata / RDFa, so \"no JSON-LD\" can say whether there is markup elsewhere. */\n microdata: number;\n word_count: number;\n anchors: AnchorSummary;\n /** Nodes in the document — Lighthouse's DOM-size warning, and a decent proxy for a page a crawler will struggle with. */\n dom_nodes: number;\n}\n\ninterface ExtractOptions {\n max_headings: number;\n max_images: number;\n max_text: number;\n max_jsonld: number;\n max_jsonld_length: number;\n}\n\n/**\n * Read the SEO-relevant parts of the rendered document.\n *\n * One `evaluate`, and everything it can find in one pass. It runs against the\n * live DOM on purpose: a single-page app serves an empty `<div id=\"app\">` in\n * its HTML and fills in the title, the description and the structured data\n * afterwards, so reading the source would report every client-rendered page as\n * having no SEO at all.\n *\n * Written against `globalThis` because this package compiles with the Node lib\n * only, and because a page can be mid-teardown when this lands in it.\n */\nexport async function extractSeo(page: Page, options: Partial<ExtractOptions> = {}): Promise<PageSeo> {\n const opts: ExtractOptions = {\n max_headings: options.max_headings ?? MAX_SEO_HEADINGS,\n max_images: options.max_images ?? MAX_SEO_IMAGES_LISTED,\n max_text: options.max_text ?? MAX_SEO_TEXT_LENGTH,\n max_jsonld: options.max_jsonld ?? MAX_SEO_JSONLD_BLOCKS,\n max_jsonld_length: options.max_jsonld_length ?? MAX_SEO_JSONLD_LENGTH,\n };\n\n return page.evaluate((o: ExtractOptions): PageSeo => {\n const g = globalThis as any;\n const doc = g.document;\n\n const flat = (value: unknown): string =>\n String(value ?? \"\")\n .replace(/\\s+/g, \" \")\n .trim();\n const elide = (value: unknown, max: number): string => {\n const text = flat(value);\n return text.length > max ? `${text.slice(0, max)}…` : text;\n };\n const resolve = (href: string): string | undefined => {\n try {\n return new g.URL(href, doc.baseURI ?? g.location.href).href;\n } catch {\n return undefined;\n }\n };\n const push = (map: Record<string, string[]>, key: string, value: string): void => {\n if (!key) return;\n (map[key] ??= []).push(value);\n };\n\n /* Meta tags — three maps, keyed exactly as the page spelled them. */\n const named: Record<string, string[]> = {};\n const properties: Record<string, string[]> = {};\n const httpEquiv: Record<string, string[]> = {};\n let charset = \"\";\n for (const meta of doc.querySelectorAll(\"meta\")) {\n if (meta.hasAttribute(\"charset\")) charset = flat(meta.getAttribute(\"charset\"));\n const content = flat(meta.getAttribute(\"content\"));\n const name = flat(meta.getAttribute(\"name\")).toLowerCase();\n const property = flat(meta.getAttribute(\"property\")).toLowerCase();\n const equiv = flat(meta.getAttribute(\"http-equiv\")).toLowerCase();\n if (name) push(named, name, content);\n if (property) push(properties, property, content);\n if (equiv) {\n push(httpEquiv, equiv, content);\n if (equiv === \"content-type\" && !charset) {\n const match = /charset=([\\w-]+)/i.exec(content);\n if (match) charset = match[1];\n }\n }\n }\n\n /* <link rel=…>, grouped by rel. One tag can carry several rels. */\n const links: Record<string, LinkTag[]> = {};\n for (const link of doc.querySelectorAll(\"link[rel]\")) {\n const href = flat(link.getAttribute(\"href\"));\n const tag: LinkTag = { href };\n const resolved = href ? resolve(href) : undefined;\n if (resolved) tag.resolved = resolved;\n const hreflang = flat(link.getAttribute(\"hreflang\"));\n if (hreflang) tag.hreflang = hreflang;\n const type = flat(link.getAttribute(\"type\"));\n if (type) tag.type = type;\n for (const rel of flat(link.getAttribute(\"rel\")).toLowerCase().split(/\\s+/)) {\n if (rel) (links[rel] ??= []).push(tag);\n }\n }\n\n /* Headings, in document order — the outline a crawler builds. */\n const headings: HeadingNode[] = [];\n const headingCounts: Record<string, number> = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 };\n const headingElements = doc.querySelectorAll(\"h1, h2, h3, h4, h5, h6\");\n for (const heading of headingElements) {\n const level = Number(heading.tagName.slice(1));\n headingCounts[`h${level}`] = (headingCounts[`h${level}`] ?? 0) + 1;\n if (headings.length >= o.max_headings) continue;\n // offsetParent is null for display:none and for position:fixed, so the\n // rect is checked too — a fixed header's h1 is visible, not hidden.\n const rect = heading.getBoundingClientRect?.();\n const hidden = (heading.offsetParent === null && (!rect || (rect.width === 0 && rect.height === 0))) || false;\n const text = flat(heading.textContent);\n headings.push({ level, text: elide(text, o.max_text), hidden, empty: text.length === 0 });\n }\n\n /* Images. A missing alt attribute and alt=\"\" are different findings. */\n const imageElements = doc.querySelectorAll(\"img\");\n const missingAlt: ImageNode[] = [];\n let missingAltTotal = 0;\n let emptyAlt = 0;\n let noDimensions = 0;\n let lazy = 0;\n for (const image of imageElements) {\n const src = flat(image.getAttribute(\"src\") || image.getAttribute(\"data-src\") || image.currentSrc);\n if (!image.hasAttribute(\"alt\")) {\n missingAltTotal++;\n if (missingAlt.length < o.max_images) {\n const id = flat(image.getAttribute(\"id\"));\n const cls = flat(image.getAttribute(\"class\")).split(\" \")[0];\n const description = id ? `#${id}` : cls ? `img.${cls}` : elide(src || \"img\", o.max_text);\n missingAlt.push({ src: elide(src, o.max_text), description });\n }\n } else if (flat(image.getAttribute(\"alt\")) === \"\") {\n emptyAlt++;\n }\n if (!image.hasAttribute(\"width\") || !image.hasAttribute(\"height\")) noDimensions++;\n if (flat(image.getAttribute(\"loading\")).toLowerCase() === \"lazy\") lazy++;\n }\n\n /* Structured data: collected raw, parsed in Node where a syntax error can be reported. */\n const scripts = doc.querySelectorAll('script[type=\"application/ld+json\"], script[type=\"application/ld+json;charset=utf-8\"]');\n const jsonld: JsonLdBlock[] = [];\n for (const script of scripts) {\n if (jsonld.length >= o.max_jsonld) break;\n const text = String(script.textContent ?? \"\");\n jsonld.push({\n text: text.length > o.max_jsonld_length ? text.slice(0, o.max_jsonld_length) : text,\n truncated: text.length > o.max_jsonld_length,\n });\n }\n\n /* Links out of the page. */\n const anchors = doc.querySelectorAll(\"a[href]\");\n let internal = 0;\n let external = 0;\n let nofollow = 0;\n let emptyAnchors = 0;\n const origin = g.location?.origin;\n for (const anchor of anchors) {\n const href = flat(anchor.getAttribute(\"href\"));\n const resolved = resolve(href);\n if (resolved) {\n try {\n if (new g.URL(resolved).origin === origin) internal++;\n else external++;\n } catch {\n /* not a URL we can classify */\n }\n }\n if (/\\bnofollow\\b/i.test(flat(anchor.getAttribute(\"rel\")))) nofollow++;\n const label = flat(anchor.textContent) || flat(anchor.getAttribute(\"aria-label\")) || flat(anchor.getAttribute(\"title\"));\n if (!label && anchor.querySelector(\"img[alt]:not([alt=''])\") === null) emptyAnchors++;\n }\n\n const bodyText = flat(doc.body?.innerText ?? \"\");\n\n return {\n url: String(g.location?.href ?? \"\"),\n title: doc.title === undefined || doc.title === null ? null : flat(doc.title),\n title_count: doc.querySelectorAll(\"title\").length,\n lang: flat(doc.documentElement?.getAttribute(\"lang\")),\n dir: flat(doc.documentElement?.getAttribute(\"dir\")),\n charset,\n named,\n properties,\n http_equiv: httpEquiv,\n links,\n headings,\n heading_total: headingElements.length,\n heading_counts: headingCounts,\n images: {\n total: imageElements.length,\n missing_alt: missingAlt,\n missing_alt_total: missingAltTotal,\n empty_alt: emptyAlt,\n no_dimensions: noDimensions,\n lazy,\n },\n jsonld,\n jsonld_total: scripts.length,\n microdata: doc.querySelectorAll(\"[itemscope]\").length,\n word_count: bodyText ? bodyText.split(\" \").length : 0,\n anchors: { total: anchors.length, internal, external, nofollow, empty: emptyAnchors },\n dom_nodes: doc.getElementsByTagName(\"*\").length,\n };\n }, opts);\n}\n\n/* ── robots.txt ───────────────────────────────────────────────────────── */\n\nexport interface RobotsFetch {\n url: string;\n status?: number;\n text?: string;\n /** Why there is no text: unreachable, timed out, or a protocol with no robots.txt. */\n error?: string;\n}\n\n/**\n * Fetch the origin's robots.txt.\n *\n * Through the browser context, so it goes out with the same cookies and proxy\n * settings as the page did. A 404 is not a failure — it is the answer \"there\n * are no rules\", which is what the rules module does with it.\n */\nexport async function fetchRobots(context: BrowserContext, pageUrl: string, timeoutMs = SEO_FETCH_TIMEOUT_MS): Promise<RobotsFetch> {\n let robotsUrl: string;\n try {\n const parsed = new URL(pageUrl);\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n return { url: pageUrl, error: `${parsed.protocol}// has no robots.txt` };\n }\n robotsUrl = new URL(\"/robots.txt\", parsed.origin).href;\n } catch (error) {\n return { url: pageUrl, error: firstLine(error) };\n }\n\n try {\n const response = await context.request.get(robotsUrl, { timeout: timeoutMs, failOnStatusCode: false });\n const status = response.status();\n if (status >= 400) return { url: robotsUrl, status };\n return { url: robotsUrl, status, text: await response.text() };\n } catch (error) {\n return { url: robotsUrl, error: firstLine(error) };\n }\n}\n\n/* ── The share image ──────────────────────────────────────────────────── */\n\nexport interface ImageProbe {\n url: string;\n ok: boolean;\n status?: number;\n content_type?: string;\n bytes?: number;\n width?: number;\n height?: number;\n error?: string;\n /** The image itself, when it is small enough to hand back to the client. */\n data?: Buffer;\n}\n\n/**\n * Fetch a share image and measure it.\n *\n * An `og:image` pointing at a 404, or at a 40x40 favicon, is invisible from\n * the page itself and shows up as a blank card on every network that renders\n * the link — which is exactly the sort of thing nobody notices until someone\n * shares the page. So it is fetched and measured rather than merely reported\n * as present.\n */\nexport async function probeImage(\n context: BrowserContext,\n url: string,\n timeoutMs = SEO_FETCH_TIMEOUT_MS,\n maxBytes = MAX_SEO_IMAGE_BYTES,\n): Promise<ImageProbe> {\n try {\n const response = await context.request.get(url, { timeout: timeoutMs, failOnStatusCode: false });\n const status = response.status();\n const contentType = response.headers()[\"content-type\"];\n const probe: ImageProbe = { url, ok: status < 400, status };\n if (contentType) probe.content_type = contentType;\n if (!probe.ok) return probe;\n\n const body = await response.body();\n probe.bytes = body.byteLength;\n try {\n const { width, height } = await getDimensions(body);\n probe.width = width;\n probe.height = height;\n if (body.byteLength <= maxBytes) probe.data = body;\n } catch (error) {\n // Reachable but not an image this build can decode (an SVG on a libvips\n // without librsvg, an HTML error page served as 200). Worth saying.\n probe.error = firstLine(error);\n }\n return probe;\n } catch (error) {\n return { url, ok: false, error: firstLine(error) };\n }\n}\n\n/* ── Performance ──────────────────────────────────────────────────────── */\n\nexport interface ResourceGroup {\n type: string;\n count: number;\n bytes: number;\n}\n\nexport interface PerfMetrics {\n ttfb_ms?: number;\n dom_content_loaded_ms?: number;\n load_ms?: number;\n fcp_ms?: number;\n lcp_ms?: number;\n lcp_element?: string;\n cls?: number;\n long_tasks: number;\n requests: number;\n transfer_bytes: number;\n /** True when at least one resource reported a transferSize of 0 — see `readPerformance`. */\n transfer_incomplete: boolean;\n resources: ResourceGroup[];\n}\n\n/**\n * The observers that have to be running before the page loads.\n *\n * LCP, CLS and long tasks are only observable as they happen. `buffered: true`\n * covers the gap between document start and this script running, but nothing\n * covers a script that arrives after the event, so this goes in through\n * `addInitScript` before the first navigation.\n */\nexport const PERF_INIT_SCRIPT = (): void => {\n const g = globalThis as any;\n // Whole-page metrics: the main frame owns them.\n if (g.top && g.top !== g) return;\n if (g.__framewatch_seo_perf) return;\n const state = { lcp: 0, lcp_element: \"\", cls: 0, long_tasks: 0 };\n g.__framewatch_seo_perf = state;\n if (typeof g.PerformanceObserver !== \"function\") return;\n\n const describe = (element: any): string => {\n if (!element || !element.tagName) return \"\";\n const tag = String(element.tagName).toLowerCase();\n const id = element.id ? `#${element.id}` : \"\";\n const cls = element.classList && element.classList.length > 0 ? `.${element.classList[0]}` : \"\";\n return `${tag}${id}${cls}`;\n };\n\n const observe = (type: string, handle: (entries: any[]) => void): void => {\n try {\n new g.PerformanceObserver((list: any) => handle(list.getEntries())).observe({ type, buffered: true });\n } catch {\n // This browser does not report this entry type; the metric is simply absent.\n }\n };\n\n observe(\"largest-contentful-paint\", (entries) => {\n for (const entry of entries) {\n // Each entry supersedes the last: LCP is the final one before interaction.\n state.lcp = entry.startTime;\n state.lcp_element = describe(entry.element);\n }\n });\n observe(\"layout-shift\", (entries) => {\n for (const entry of entries) {\n // Shifts within 500ms of a real interaction are the user's doing, and\n // Chrome's CLS excludes them for the same reason.\n if (!entry.hadRecentInput) state.cls += entry.value;\n }\n });\n observe(\"longtask\", (entries) => {\n state.long_tasks += entries.length;\n });\n};\n\n/**\n * Read the metrics back once the page has settled.\n *\n * Everything else — navigation timing, paint timing, resource weight — is\n * still in the performance buffer at this point and needs no observer.\n *\n * `transferSize` is 0 for a cross-origin resource that does not send\n * `Timing-Allow-Origin`, which is indistinguishable from a cached response, so\n * the total is reported alongside a flag saying it is a floor rather than a\n * total.\n */\nexport async function readPerformance(page: Page): Promise<PerfMetrics | undefined> {\n try {\n return await page.evaluate((): PerfMetrics => {\n const g = globalThis as any;\n const perf = g.performance;\n const state = g.__framewatch_seo_perf ?? { lcp: 0, lcp_element: \"\", cls: 0, long_tasks: 0 };\n const round = (value: unknown): number | undefined => {\n const n = Number(value);\n return Number.isFinite(n) && n >= 0 ? Math.round(n) : undefined;\n };\n\n const metrics: PerfMetrics = {\n long_tasks: state.long_tasks ?? 0,\n requests: 0,\n transfer_bytes: 0,\n transfer_incomplete: false,\n resources: [],\n };\n\n const nav = perf?.getEntriesByType?.(\"navigation\")?.[0];\n if (nav) {\n metrics.ttfb_ms = round(nav.responseStart);\n metrics.dom_content_loaded_ms = round(nav.domContentLoadedEventEnd);\n metrics.load_ms = round(nav.loadEventEnd) || undefined;\n if (nav.transferSize > 0) metrics.transfer_bytes += nav.transferSize;\n }\n\n for (const paint of perf?.getEntriesByType?.(\"paint\") ?? []) {\n if (paint.name === \"first-contentful-paint\") metrics.fcp_ms = round(paint.startTime);\n }\n if (state.lcp > 0) {\n metrics.lcp_ms = round(state.lcp);\n if (state.lcp_element) metrics.lcp_element = state.lcp_element;\n }\n if (typeof state.cls === \"number\") metrics.cls = Math.round(state.cls * 1000) / 1000;\n\n const byType = new Map<string, ResourceGroup>();\n for (const resource of perf?.getEntriesByType?.(\"resource\") ?? []) {\n metrics.requests++;\n const size = Number(resource.transferSize) || 0;\n if (size === 0) metrics.transfer_incomplete = true;\n metrics.transfer_bytes += size;\n const type = String(resource.initiatorType || \"other\");\n const group = byType.get(type) ?? { type, count: 0, bytes: 0 };\n group.count++;\n group.bytes += size;\n byType.set(type, group);\n }\n metrics.resources = [...byType.values()].sort((a, b) => b.bytes - a.bytes || b.count - a.count);\n\n return metrics;\n });\n } catch {\n // Cosmetic: a page that dies before this is read still gets its audit.\n return undefined;\n }\n}\n\nfunction firstLine(error: unknown): string {\n return (error instanceof Error ? error.message : String(error)).split(\"\\n\")[0];\n}\n"]}