samey-cli 0.0.0-stage → 0.1.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 +43 -0
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/dist/ai/local-model.d.ts +26 -0
- package/dist/ai/local-model.js +91 -0
- package/dist/audit.d.ts +18 -0
- package/dist/audit.js +250 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +166 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/render/crawl.d.ts +34 -0
- package/dist/render/crawl.js +337 -0
- package/dist/render/extract.d.ts +8 -0
- package/dist/render/extract.js +441 -0
- package/dist/render/interact.d.ts +8 -0
- package/dist/render/interact.js +76 -0
- package/dist/render/network.d.ts +33 -0
- package/dist/render/network.js +92 -0
- package/dist/render/robots.d.ts +8 -0
- package/dist/render/robots.js +61 -0
- package/dist/render/server.d.ts +15 -0
- package/dist/render/server.js +174 -0
- package/dist/report/html.d.ts +2 -0
- package/dist/report/html.js +368 -0
- package/dist/report/prompt.d.ts +12 -0
- package/dist/report/prompt.js +62 -0
- package/dist/report/terminal.d.ts +6 -0
- package/dist/report/terminal.js +73 -0
- package/dist/rules/content.d.ts +8 -0
- package/dist/rules/content.js +227 -0
- package/dist/rules/functional.d.ts +17 -0
- package/dist/rules/functional.js +467 -0
- package/dist/rules/helpers.d.ts +11 -0
- package/dist/rules/helpers.js +29 -0
- package/dist/rules/index.d.ts +13 -0
- package/dist/rules/index.js +100 -0
- package/dist/rules/normalize.d.ts +18 -0
- package/dist/rules/normalize.js +114 -0
- package/dist/rules/static.d.ts +9 -0
- package/dist/rules/static.js +314 -0
- package/dist/rules/types.d.ts +39 -0
- package/dist/rules/types.js +16 -0
- package/dist/rules/visual.d.ts +15 -0
- package/dist/rules/visual.js +463 -0
- package/dist/score.d.ts +31 -0
- package/dist/score.js +66 -0
- package/dist/security/fetch.d.ts +26 -0
- package/dist/security/fetch.js +86 -0
- package/dist/security/net-policy.d.ts +46 -0
- package/dist/security/net-policy.js +178 -0
- package/dist/static/scan.d.ts +15 -0
- package/dist/static/scan.js +191 -0
- package/dist/types.d.ts +316 -0
- package/dist/types.js +1 -0
- package/dist/util/color.d.ts +25 -0
- package/dist/util/color.js +180 -0
- package/dist/util/text.d.ts +22 -0
- package/dist/util/text.js +106 -0
- package/package.json +69 -4
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `fn` inside the page. The function is shipped as source text so that bundler
|
|
3
|
+
* helpers (esbuild's `__name`, used by tsx and vitest) never leak into the browser.
|
|
4
|
+
*/
|
|
5
|
+
export async function runInPage(page, fn, arg) {
|
|
6
|
+
const src = `(() => { var __name = (f) => f; return (${fn.toString()})(${JSON.stringify(arg)}); })()`;
|
|
7
|
+
return page.evaluate(src);
|
|
8
|
+
}
|
|
9
|
+
export async function extractViewportFacts(page, viewport) {
|
|
10
|
+
return runInPage(page, extractInPage, { viewport });
|
|
11
|
+
}
|
|
12
|
+
// Everything below executes in the browser. It must not reference anything outside its own body.
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
function extractInPage(opts) {
|
|
15
|
+
// Mobile emulation zooms out to fit overflowing content, which inflates innerWidth; clientWidth stays at the device width.
|
|
16
|
+
const W = document.documentElement.clientWidth || window.innerWidth;
|
|
17
|
+
const H = window.innerHeight;
|
|
18
|
+
const sx = window.scrollX;
|
|
19
|
+
const sy = window.scrollY;
|
|
20
|
+
const MAX_ELEMENTS = 8000;
|
|
21
|
+
const rectOf = (el) => {
|
|
22
|
+
const r = el.getBoundingClientRect();
|
|
23
|
+
return { x: Math.round(r.left + sx), y: Math.round(r.top + sy), width: Math.round(r.width), height: Math.round(r.height) };
|
|
24
|
+
};
|
|
25
|
+
const styleCache = new Map();
|
|
26
|
+
const css = (el) => {
|
|
27
|
+
let s = styleCache.get(el);
|
|
28
|
+
if (!s) {
|
|
29
|
+
s = getComputedStyle(el);
|
|
30
|
+
styleCache.set(el, s);
|
|
31
|
+
}
|
|
32
|
+
return s;
|
|
33
|
+
};
|
|
34
|
+
const visible = (el) => {
|
|
35
|
+
const r = el.getBoundingClientRect();
|
|
36
|
+
if (r.width < 1 || r.height < 1)
|
|
37
|
+
return false;
|
|
38
|
+
const s = css(el);
|
|
39
|
+
if (s.visibility === 'hidden' || s.display === 'none' || parseFloat(s.opacity) < 0.05)
|
|
40
|
+
return false;
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
const cssPath = (el) => {
|
|
44
|
+
if (el.id && /^[A-Za-z][\w-]*$/.test(el.id))
|
|
45
|
+
return `#${el.id}`;
|
|
46
|
+
const parts = [];
|
|
47
|
+
let cur = el;
|
|
48
|
+
while (cur && cur !== document.body && parts.length < 5) {
|
|
49
|
+
let part = cur.tagName.toLowerCase();
|
|
50
|
+
if (cur.id && /^[A-Za-z][\w-]*$/.test(cur.id)) {
|
|
51
|
+
parts.unshift(`#${cur.id}`);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
const parent = cur.parentElement;
|
|
55
|
+
if (parent) {
|
|
56
|
+
const same = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
57
|
+
if (same.length > 1)
|
|
58
|
+
part += `:nth-of-type(${same.indexOf(cur) + 1})`;
|
|
59
|
+
}
|
|
60
|
+
parts.unshift(part);
|
|
61
|
+
cur = parent;
|
|
62
|
+
}
|
|
63
|
+
return parts.join(' > ');
|
|
64
|
+
};
|
|
65
|
+
const textOf = (el) => (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
66
|
+
const ownText = (el) => Array.from(el.childNodes)
|
|
67
|
+
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
|
68
|
+
.map((n) => n.textContent || '')
|
|
69
|
+
.join(' ')
|
|
70
|
+
.replace(/\s+/g, ' ')
|
|
71
|
+
.trim();
|
|
72
|
+
const bump = (rec, key) => {
|
|
73
|
+
rec[key] = (rec[key] || 0) + 1;
|
|
74
|
+
};
|
|
75
|
+
const accessibleName = (el) => {
|
|
76
|
+
const aria = el.getAttribute('aria-label');
|
|
77
|
+
if (aria && aria.trim())
|
|
78
|
+
return aria.trim();
|
|
79
|
+
const by = el.getAttribute('aria-labelledby');
|
|
80
|
+
if (by) {
|
|
81
|
+
const t = by
|
|
82
|
+
.split(/\s+/)
|
|
83
|
+
.map((id) => document.getElementById(id)?.textContent || '')
|
|
84
|
+
.join(' ')
|
|
85
|
+
.trim();
|
|
86
|
+
if (t)
|
|
87
|
+
return t;
|
|
88
|
+
}
|
|
89
|
+
const t = textOf(el);
|
|
90
|
+
if (t)
|
|
91
|
+
return t;
|
|
92
|
+
const title = el.getAttribute('title');
|
|
93
|
+
if (title && title.trim())
|
|
94
|
+
return title.trim();
|
|
95
|
+
const img = el.querySelector('img[alt]');
|
|
96
|
+
if (img && img.getAttribute('alt').trim())
|
|
97
|
+
return img.getAttribute('alt').trim();
|
|
98
|
+
const svgTitle = el.querySelector('svg title');
|
|
99
|
+
if (svgTitle && svgTitle.textContent.trim())
|
|
100
|
+
return svgTitle.textContent.trim();
|
|
101
|
+
if (el instanceof HTMLInputElement && el.value)
|
|
102
|
+
return el.value;
|
|
103
|
+
return '';
|
|
104
|
+
};
|
|
105
|
+
const styles = {
|
|
106
|
+
colors: {},
|
|
107
|
+
backgrounds: {},
|
|
108
|
+
fontSizes: {},
|
|
109
|
+
fontFamilies: {},
|
|
110
|
+
radii: {},
|
|
111
|
+
shadows: {},
|
|
112
|
+
spacing: {},
|
|
113
|
+
};
|
|
114
|
+
const gradients = [];
|
|
115
|
+
const glows = [];
|
|
116
|
+
const tinyText = [];
|
|
117
|
+
const all = Array.from(document.body.querySelectorAll('*')).slice(0, MAX_ELEMENTS);
|
|
118
|
+
for (const el of all) {
|
|
119
|
+
const tag = el.tagName.toLowerCase();
|
|
120
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template')
|
|
121
|
+
continue;
|
|
122
|
+
if (el.closest('svg') && tag !== 'svg')
|
|
123
|
+
continue;
|
|
124
|
+
if (!visible(el))
|
|
125
|
+
continue;
|
|
126
|
+
const s = css(el);
|
|
127
|
+
const own = ownText(el);
|
|
128
|
+
if (own.length > 0) {
|
|
129
|
+
bump(styles.colors, s.color);
|
|
130
|
+
bump(styles.fontSizes, s.fontSize);
|
|
131
|
+
bump(styles.fontFamilies, s.fontFamily.split(',')[0].replace(/["']/g, '').trim());
|
|
132
|
+
const fs = parseFloat(s.fontSize);
|
|
133
|
+
if (fs < 12 && own.length > 3 && tinyText.length < 20) {
|
|
134
|
+
tinyText.push({ selector: cssPath(el), rect: rectOf(el), text: own.slice(0, 80), size: fs });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)' && s.backgroundColor !== 'transparent') {
|
|
138
|
+
bump(styles.backgrounds, s.backgroundColor);
|
|
139
|
+
}
|
|
140
|
+
if (s.borderTopLeftRadius && s.borderTopLeftRadius !== '0px')
|
|
141
|
+
bump(styles.radii, s.borderTopLeftRadius);
|
|
142
|
+
if (s.boxShadow && s.boxShadow !== 'none') {
|
|
143
|
+
bump(styles.shadows, s.boxShadow);
|
|
144
|
+
// Large-blur shadows are glow candidates; saturation is judged on the Node side.
|
|
145
|
+
const blurs = Array.from(s.boxShadow.matchAll(/(-?\d+(?:\.\d+)?)px\s+(-?\d+(?:\.\d+)?)px\s+(\d+(?:\.\d+)?)px/g)).map((m) => parseFloat(m[3]));
|
|
146
|
+
if (blurs.some((b) => b >= 24) && glows.length < 60) {
|
|
147
|
+
glows.push({ selector: cssPath(el), rect: rectOf(el), value: s.boxShadow, kind: 'shadow' });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const prop of ['paddingTop', 'paddingLeft', 'marginTop', 'rowGap']) {
|
|
151
|
+
const v = s[prop];
|
|
152
|
+
if (v && v !== '0px' && v !== 'normal' && v.endsWith('px'))
|
|
153
|
+
bump(styles.spacing, v);
|
|
154
|
+
}
|
|
155
|
+
const bgImage = s.backgroundImage;
|
|
156
|
+
if (bgImage && bgImage.includes('gradient(') && gradients.length < 80) {
|
|
157
|
+
const r = rectOf(el);
|
|
158
|
+
const clip = s.backgroundClip || s.webkitBackgroundClip || '';
|
|
159
|
+
gradients.push({ selector: cssPath(el), rect: r, value: bgImage, area: r.width * r.height, isText: clip === 'text' });
|
|
160
|
+
}
|
|
161
|
+
const filter = s.filter || '';
|
|
162
|
+
const blurMatch = filter.match(/blur\((\d+(?:\.\d+)?)px\)/);
|
|
163
|
+
if (blurMatch && parseFloat(blurMatch[1]) >= 30 && glows.length < 60) {
|
|
164
|
+
glows.push({ selector: cssPath(el), rect: rectOf(el), value: `filter: ${filter}; background: ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor}`, kind: 'blur-blob' });
|
|
165
|
+
}
|
|
166
|
+
const backdrop = s.backdropFilter || s.webkitBackdropFilter || '';
|
|
167
|
+
if (backdrop.includes('blur(') && glows.length < 60) {
|
|
168
|
+
glows.push({ selector: cssPath(el), rect: rectOf(el), value: `backdrop-filter: ${backdrop}`, kind: 'backdrop' });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Headings
|
|
172
|
+
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'))
|
|
173
|
+
.filter(visible)
|
|
174
|
+
.map((el) => ({ selector: cssPath(el), rect: rectOf(el), level: parseInt(el.tagName[1], 10), text: textOf(el).slice(0, 200) }));
|
|
175
|
+
// Controls
|
|
176
|
+
const controlEls = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"], a[href]')).filter(visible);
|
|
177
|
+
const controls = controlEls
|
|
178
|
+
.filter((el) => el.tagName !== 'A' || /btn|button/i.test(el.className.toString()) || el.getAttribute('role') === 'button')
|
|
179
|
+
.slice(0, 200)
|
|
180
|
+
.map((el) => ({
|
|
181
|
+
selector: cssPath(el),
|
|
182
|
+
rect: rectOf(el),
|
|
183
|
+
tag: el.tagName.toLowerCase(),
|
|
184
|
+
text: textOf(el).slice(0, 120),
|
|
185
|
+
name: accessibleName(el).slice(0, 120),
|
|
186
|
+
href: el.getAttribute('href') ?? undefined,
|
|
187
|
+
type: el.getAttribute('type') ?? undefined,
|
|
188
|
+
inForm: !!el.closest('form'),
|
|
189
|
+
}));
|
|
190
|
+
// Links
|
|
191
|
+
const links = Array.from(document.querySelectorAll('a'))
|
|
192
|
+
.filter(visible)
|
|
193
|
+
.slice(0, 400)
|
|
194
|
+
.map((a) => {
|
|
195
|
+
const raw = a.getAttribute('href');
|
|
196
|
+
let internal = false;
|
|
197
|
+
try {
|
|
198
|
+
internal = raw != null && new URL(a.href, location.href).origin === location.origin;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
internal = false;
|
|
202
|
+
}
|
|
203
|
+
return { selector: cssPath(a), rect: rectOf(a), href: raw ?? '', text: accessibleName(a).slice(0, 120), internal };
|
|
204
|
+
});
|
|
205
|
+
// Images
|
|
206
|
+
const images = Array.from(document.images)
|
|
207
|
+
.filter((img) => visible(img) || (img.complete && img.naturalWidth === 0 && img.getAttribute('src')))
|
|
208
|
+
.slice(0, 200)
|
|
209
|
+
.map((img) => ({
|
|
210
|
+
selector: cssPath(img),
|
|
211
|
+
rect: rectOf(img),
|
|
212
|
+
src: img.currentSrc || img.getAttribute('src') || '',
|
|
213
|
+
alt: img.getAttribute('alt'),
|
|
214
|
+
broken: img.complete && img.naturalWidth === 0 && !!img.getAttribute('src'),
|
|
215
|
+
decorative: img.getAttribute('alt') === '' || img.getAttribute('role') === 'presentation' || img.getAttribute('aria-hidden') === 'true',
|
|
216
|
+
}));
|
|
217
|
+
// Text blocks for copy analysis
|
|
218
|
+
const textBlocks = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,blockquote,figcaption,dd,dt,td,button,a,label,span,div'))
|
|
219
|
+
.filter((el) => {
|
|
220
|
+
const own = ownText(el);
|
|
221
|
+
if (own.length < 2)
|
|
222
|
+
return false;
|
|
223
|
+
const tag = el.tagName.toLowerCase();
|
|
224
|
+
if ((tag === 'span' || tag === 'div') && own.length < 12)
|
|
225
|
+
return false;
|
|
226
|
+
return visible(el);
|
|
227
|
+
})
|
|
228
|
+
.slice(0, 600)
|
|
229
|
+
.map((el) => ({ selector: cssPath(el), rect: rectOf(el), tag: el.tagName.toLowerCase(), text: textOf(el).slice(0, 600) }));
|
|
230
|
+
// Small touch targets (WCAG 2.5.8 minimum is 24x24 CSS px); inline links inside running text are exempt.
|
|
231
|
+
const smallTargets = [];
|
|
232
|
+
for (const el of controlEls.concat(Array.from(document.querySelectorAll('input:not([type=hidden]), select, textarea')).filter(visible))) {
|
|
233
|
+
const r = el.getBoundingClientRect();
|
|
234
|
+
if (r.width >= 24 && r.height >= 24)
|
|
235
|
+
continue;
|
|
236
|
+
if (el.tagName === 'A') {
|
|
237
|
+
const parent = el.parentElement;
|
|
238
|
+
if (parent && textOf(parent).length > textOf(el).length + 20)
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (smallTargets.length < 30) {
|
|
242
|
+
smallTargets.push({ selector: cssPath(el), rect: rectOf(el), text: accessibleName(el).slice(0, 60), w: Math.round(r.width), h: Math.round(r.height) });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Horizontal overflow
|
|
246
|
+
const scrollWidth = document.documentElement.scrollWidth;
|
|
247
|
+
const overflowing = [];
|
|
248
|
+
if (scrollWidth > W + 1) {
|
|
249
|
+
const clipped = (el) => {
|
|
250
|
+
let p = el.parentElement;
|
|
251
|
+
while (p && p !== document.documentElement) {
|
|
252
|
+
const ox = css(p).overflowX;
|
|
253
|
+
if ((ox === 'hidden' || ox === 'auto' || ox === 'scroll' || ox === 'clip') && p.getBoundingClientRect().right <= W + 1)
|
|
254
|
+
return true;
|
|
255
|
+
p = p.parentElement;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
};
|
|
259
|
+
const offenders = all.filter((el) => {
|
|
260
|
+
const r = el.getBoundingClientRect();
|
|
261
|
+
return r.width > 0 && r.right > W + 1 && visible(el) && !clipped(el);
|
|
262
|
+
});
|
|
263
|
+
// Keep the outermost offenders: the element whose parent does not itself overflow.
|
|
264
|
+
const outer = offenders.filter((el) => !offenders.includes(el.parentElement));
|
|
265
|
+
for (const el of outer.slice(0, 10)) {
|
|
266
|
+
overflowing.push({ selector: cssPath(el), rect: rectOf(el), right: Math.round(el.getBoundingClientRect().right) });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Unlabeled form fields
|
|
270
|
+
const unlabeledInputs = Array.from(document.querySelectorAll('input, select, textarea'))
|
|
271
|
+
.filter((el) => {
|
|
272
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
273
|
+
if (['hidden', 'submit', 'button', 'reset', 'image'].includes(type))
|
|
274
|
+
return false;
|
|
275
|
+
if (!visible(el))
|
|
276
|
+
return false;
|
|
277
|
+
const labelled = (el.labels?.length ?? 0) > 0 || el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title');
|
|
278
|
+
return !labelled;
|
|
279
|
+
})
|
|
280
|
+
.slice(0, 20)
|
|
281
|
+
.map((el) => ({ selector: cssPath(el), rect: rectOf(el), type: el.getAttribute('type') || el.tagName.toLowerCase() }));
|
|
282
|
+
// Sections
|
|
283
|
+
const shapeSig = (el, depth) => {
|
|
284
|
+
const tag = el.tagName.toLowerCase();
|
|
285
|
+
if (depth === 0)
|
|
286
|
+
return tag;
|
|
287
|
+
const kids = Array.from(el.children).filter((c) => !['script', 'style'].includes(c.tagName.toLowerCase())).slice(0, 6);
|
|
288
|
+
return tag + (kids.length ? '[' + kids.map((k) => shapeSig(k, depth - 1)).join(',') + ']' : '');
|
|
289
|
+
};
|
|
290
|
+
const isStyled = (el, parent) => {
|
|
291
|
+
const s = css(el);
|
|
292
|
+
const ps = css(parent);
|
|
293
|
+
return (parseFloat(s.borderTopWidth) > 0 ||
|
|
294
|
+
(s.boxShadow && s.boxShadow !== 'none') ||
|
|
295
|
+
(s.backgroundColor !== 'rgba(0, 0, 0, 0)' && s.backgroundColor !== ps.backgroundColor) ||
|
|
296
|
+
(s.backgroundImage && s.backgroundImage !== 'none') ||
|
|
297
|
+
parseFloat(s.borderTopLeftRadius) >= 6);
|
|
298
|
+
};
|
|
299
|
+
const findRepeatGroup = (root) => {
|
|
300
|
+
let best = null;
|
|
301
|
+
const queue = [[root, 0]];
|
|
302
|
+
while (queue.length) {
|
|
303
|
+
const [node, depth] = queue.shift();
|
|
304
|
+
const kids = Array.from(node.children).filter(visible);
|
|
305
|
+
if (kids.length >= 3) {
|
|
306
|
+
const counts = new Map();
|
|
307
|
+
for (const k of kids) {
|
|
308
|
+
const sig = shapeSig(k, 2);
|
|
309
|
+
if (!counts.has(sig))
|
|
310
|
+
counts.set(sig, []);
|
|
311
|
+
counts.get(sig).push(k);
|
|
312
|
+
}
|
|
313
|
+
for (const [sig, items] of counts) {
|
|
314
|
+
if (items.length >= 3 && items.length / kids.length >= 0.6 && sig.includes('[')) {
|
|
315
|
+
const styled = items.filter((i) => isStyled(i, node)).length >= items.length * 0.6;
|
|
316
|
+
if (!best || items.length > best.items.length || (styled && !best.styled))
|
|
317
|
+
best = { container: node, items, sig, styled };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (depth < 6)
|
|
322
|
+
for (const k of kids)
|
|
323
|
+
queue.push([k, depth + 1]);
|
|
324
|
+
}
|
|
325
|
+
return best;
|
|
326
|
+
};
|
|
327
|
+
const sectionInfo = (el, index) => {
|
|
328
|
+
const tag = el.tagName.toLowerCase();
|
|
329
|
+
const r = rectOf(el);
|
|
330
|
+
const role = el.getAttribute('role') || '';
|
|
331
|
+
// A <header> that holds the h1 and takes real height is a hero, not site chrome.
|
|
332
|
+
const heroLike = !!el.querySelector('h1') && r.height >= 240;
|
|
333
|
+
const isChrome = ((tag === 'header' || role === 'banner') && !heroLike) || tag === 'footer' || tag === 'nav' || role === 'contentinfo' || role === 'navigation';
|
|
334
|
+
const h = el.querySelector('h1,h2,h3');
|
|
335
|
+
const heading = h ? textOf(h).slice(0, 160) : undefined;
|
|
336
|
+
let centered = false;
|
|
337
|
+
if (h) {
|
|
338
|
+
const hs = css(h);
|
|
339
|
+
const hr = h.getBoundingClientRect();
|
|
340
|
+
const er = el.getBoundingClientRect();
|
|
341
|
+
centered = hs.textAlign === 'center' || (hr.width < er.width * 0.8 && Math.abs(hr.left + hr.width / 2 - (er.left + er.width / 2)) < er.width * 0.03);
|
|
342
|
+
}
|
|
343
|
+
const group = findRepeatGroup(el);
|
|
344
|
+
let columns = 1;
|
|
345
|
+
if (group) {
|
|
346
|
+
const firstTop = group.items[0].getBoundingClientRect().top;
|
|
347
|
+
columns = group.items.filter((i) => Math.abs(i.getBoundingClientRect().top - firstTop) < 8).length;
|
|
348
|
+
}
|
|
349
|
+
const btns = Array.from(el.querySelectorAll('button, a, input[type=submit]')).filter((b) => {
|
|
350
|
+
if (!visible(b))
|
|
351
|
+
return false;
|
|
352
|
+
if (b.tagName !== 'A')
|
|
353
|
+
return true;
|
|
354
|
+
const s = css(b);
|
|
355
|
+
return (s.backgroundColor !== 'rgba(0, 0, 0, 0)' || parseFloat(s.borderTopWidth) > 0) && parseFloat(s.paddingLeft) >= 8;
|
|
356
|
+
});
|
|
357
|
+
const imgs = Array.from(el.querySelectorAll('img, svg, video, picture')).filter((m) => visible(m) && m.getBoundingClientRect().width > 16);
|
|
358
|
+
const text = textOf(el);
|
|
359
|
+
const cardCount = group ? group.items.length : 0;
|
|
360
|
+
const flags = {
|
|
361
|
+
hasBlockquote: !!el.querySelector('blockquote, q, figure > blockquote'),
|
|
362
|
+
hasDetails: !!el.querySelector('details, [aria-expanded]'),
|
|
363
|
+
hasPrice: /[$€£]\s?\d/.test(text) && /\/\s?(?:mo|month|yr|year)|per (?:month|year|user)|monthly|annually/i.test(text),
|
|
364
|
+
};
|
|
365
|
+
const sig = [
|
|
366
|
+
isChrome ? tag : 'sec',
|
|
367
|
+
`h${heading ? 1 : 0}`,
|
|
368
|
+
`c${centered ? 1 : 0}`,
|
|
369
|
+
`cols${Math.min(columns, 6)}`,
|
|
370
|
+
`cards${Math.min(cardCount, 8)}`,
|
|
371
|
+
group ? (group.styled ? 'styled' : 'plain') + ':' + group.sig.length.toString(36) : '-',
|
|
372
|
+
`b${Math.min(btns.length, 3)}`,
|
|
373
|
+
`i${imgs.length > 0 ? 1 : 0}`,
|
|
374
|
+
flags.hasBlockquote ? 'q' : '',
|
|
375
|
+
flags.hasDetails ? 'd' : '',
|
|
376
|
+
flags.hasPrice ? 'p' : '',
|
|
377
|
+
].join('|');
|
|
378
|
+
return {
|
|
379
|
+
index,
|
|
380
|
+
selector: cssPath(el),
|
|
381
|
+
rect: r,
|
|
382
|
+
tag,
|
|
383
|
+
signature: sig,
|
|
384
|
+
heading,
|
|
385
|
+
text: text.slice(0, 500),
|
|
386
|
+
centered,
|
|
387
|
+
columns,
|
|
388
|
+
cardCount,
|
|
389
|
+
cardSignature: group ? group.sig : undefined,
|
|
390
|
+
buttonCount: btns.length,
|
|
391
|
+
imageCount: imgs.length,
|
|
392
|
+
...flags,
|
|
393
|
+
isChrome,
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
const blockKids = (el) => Array.from(el.children).filter((c) => visible(c) && c.getBoundingClientRect().height >= 40);
|
|
397
|
+
let root = document.body;
|
|
398
|
+
for (let i = 0; i < 6; i++) {
|
|
399
|
+
const kids = blockKids(root);
|
|
400
|
+
if (kids.length === 1 && kids[0].getBoundingClientRect().height >= root.getBoundingClientRect().height * 0.8)
|
|
401
|
+
root = kids[0];
|
|
402
|
+
else
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
const sectionEls = [];
|
|
406
|
+
for (const k of blockKids(root)) {
|
|
407
|
+
const kids = blockKids(k);
|
|
408
|
+
const tall = k.getBoundingClientRect().height > H * 2;
|
|
409
|
+
if ((k.tagName === 'MAIN' || tall) && kids.length >= 3 && !['HEADER', 'FOOTER', 'NAV'].includes(k.tagName))
|
|
410
|
+
sectionEls.push(...kids);
|
|
411
|
+
else
|
|
412
|
+
sectionEls.push(k);
|
|
413
|
+
}
|
|
414
|
+
const sections = sectionEls.slice(0, 40).map(sectionInfo);
|
|
415
|
+
const body = document.body;
|
|
416
|
+
const wordCount = textOf(body).split(/\s+/).filter(Boolean).length;
|
|
417
|
+
return {
|
|
418
|
+
viewport: opts.viewport,
|
|
419
|
+
width: W,
|
|
420
|
+
height: H,
|
|
421
|
+
docHeight: Math.max(body.scrollHeight, document.documentElement.scrollHeight),
|
|
422
|
+
scrollWidth,
|
|
423
|
+
lang: document.documentElement.getAttribute('lang'),
|
|
424
|
+
title: document.title,
|
|
425
|
+
metaDescription: document.querySelector('meta[name="description"]')?.getAttribute('content') ?? null,
|
|
426
|
+
sections,
|
|
427
|
+
headings,
|
|
428
|
+
controls,
|
|
429
|
+
links,
|
|
430
|
+
images,
|
|
431
|
+
textBlocks,
|
|
432
|
+
gradients,
|
|
433
|
+
glows,
|
|
434
|
+
styles,
|
|
435
|
+
smallTargets,
|
|
436
|
+
tinyText,
|
|
437
|
+
overflowing,
|
|
438
|
+
unlabeledInputs,
|
|
439
|
+
wordCount,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import type { ControlFact, InteractionResult } from '../types.js';
|
|
3
|
+
export declare function isSafeControl(c: ControlFact): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Clicks a handful of safe, visible buttons and records whether anything observable
|
|
6
|
+
* happened (navigation, DOM change). Restores the page after navigations.
|
|
7
|
+
*/
|
|
8
|
+
export declare function testInteractions(page: Page, controls: ControlFact[], limit: number, timeoutMs: number): Promise<InteractionResult[]>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { runInPage } from './extract.js';
|
|
2
|
+
/** Labels that suggest a click could change data, spend money, or end a session. Never clicked. */
|
|
3
|
+
const UNSAFE = /\b(?:delete|remove|destroy|erase|buy|pay|purchase|checkout|order|subscribe|unsubscribe|log ?out|sign ?out|submit|send|confirm|cancel (?:plan|subscription)|reset|deploy|publish|charge|transfer|donate)\b/i;
|
|
4
|
+
export function isSafeControl(c) {
|
|
5
|
+
if (c.inForm)
|
|
6
|
+
return false;
|
|
7
|
+
if (c.type === 'submit' || c.type === 'reset')
|
|
8
|
+
return false;
|
|
9
|
+
if (c.tag === 'a')
|
|
10
|
+
return false; // links are covered by the crawler and link checks
|
|
11
|
+
if (c.href)
|
|
12
|
+
return false;
|
|
13
|
+
if (!c.name && !c.text)
|
|
14
|
+
return false; // empty buttons are reported separately
|
|
15
|
+
if (UNSAFE.test(c.name) || UNSAFE.test(c.text))
|
|
16
|
+
return false;
|
|
17
|
+
if (c.rect.width < 4 || c.rect.height < 4)
|
|
18
|
+
return false;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
function installObserver() {
|
|
22
|
+
const w = window;
|
|
23
|
+
w.__sameyMutations = 0;
|
|
24
|
+
w.__sameyObs?.disconnect();
|
|
25
|
+
w.__sameyObs = new MutationObserver((list) => {
|
|
26
|
+
w.__sameyMutations = (w.__sameyMutations ?? 0) + list.length;
|
|
27
|
+
});
|
|
28
|
+
w.__sameyObs.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
29
|
+
}
|
|
30
|
+
function readMutations() {
|
|
31
|
+
return window.__sameyMutations ?? 0;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Clicks a handful of safe, visible buttons and records whether anything observable
|
|
35
|
+
* happened (navigation, DOM change). Restores the page after navigations.
|
|
36
|
+
*/
|
|
37
|
+
export async function testInteractions(page, controls, limit, timeoutMs) {
|
|
38
|
+
const results = [];
|
|
39
|
+
const startUrl = page.url();
|
|
40
|
+
const candidates = controls.filter(isSafeControl).slice(0, limit);
|
|
41
|
+
for (const c of candidates) {
|
|
42
|
+
const base = { selector: c.selector, rect: c.rect, text: c.name || c.text };
|
|
43
|
+
try {
|
|
44
|
+
if (page.url() !== startUrl)
|
|
45
|
+
await page.goto(startUrl, { waitUntil: 'load', timeout: timeoutMs });
|
|
46
|
+
const loc = page.locator(c.selector).first();
|
|
47
|
+
if (!(await loc.isVisible({ timeout: 500 }).catch(() => false))) {
|
|
48
|
+
results.push({ ...base, outcome: 'skipped', detail: 'not visible at click time' });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
await runInPage(page, installObserver, undefined);
|
|
52
|
+
const errors = [];
|
|
53
|
+
const onError = (e) => errors.push(e.message);
|
|
54
|
+
page.on('pageerror', onError);
|
|
55
|
+
await loc.click({ timeout: Math.min(timeoutMs, 3000) });
|
|
56
|
+
await page.waitForTimeout(600);
|
|
57
|
+
page.off('pageerror', onError);
|
|
58
|
+
if (page.url() !== startUrl) {
|
|
59
|
+
results.push({ ...base, outcome: 'navigated', detail: page.url() });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (errors.length) {
|
|
63
|
+
results.push({ ...base, outcome: 'error', detail: errors[0].slice(0, 200) });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const mutations = await runInPage(page, readMutations, undefined);
|
|
67
|
+
results.push({ ...base, outcome: mutations > 0 ? 'changed' : 'no-effect', detail: mutations > 0 ? `${mutations} DOM mutations` : undefined });
|
|
68
|
+
// Close anything the click opened so the next control starts from a similar state.
|
|
69
|
+
await page.keyboard.press('Escape').catch(() => undefined);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
results.push({ ...base, outcome: 'skipped', detail: err.message.split('\n')[0].slice(0, 160) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BrowserContext } from 'playwright';
|
|
2
|
+
import { type NetworkPolicy } from '../security/net-policy.js';
|
|
3
|
+
export interface NetworkLimits {
|
|
4
|
+
maxResponseBytes: number;
|
|
5
|
+
maxTotalBytes: number;
|
|
6
|
+
maxRequests: number;
|
|
7
|
+
requestTimeoutMs: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_LIMITS: NetworkLimits;
|
|
10
|
+
export interface NetworkLog {
|
|
11
|
+
blocked: {
|
|
12
|
+
url: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
}[];
|
|
15
|
+
/**
|
|
16
|
+
* Navigation redirects the guard stopped, keyed by the requesting URL. Chromium follows a
|
|
17
|
+
* redirect from a fulfilled route without routing the next hop, so navigations are aborted
|
|
18
|
+
* here and the crawler re-navigates to the validated target itself.
|
|
19
|
+
*/
|
|
20
|
+
navRedirects: Map<string, string>;
|
|
21
|
+
/** Full URLs the guard refused. Their failures are samey's doing, not the site's. */
|
|
22
|
+
blockedUrls: Set<string>;
|
|
23
|
+
requests: number;
|
|
24
|
+
bytes: number;
|
|
25
|
+
budgetExceeded: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function newNetworkLog(): NetworkLog;
|
|
28
|
+
/**
|
|
29
|
+
* Serves every browser request from Node through the guarded client, so the browser never
|
|
30
|
+
* resolves DNS or opens a connection on its own. Blocked requests are aborted and logged.
|
|
31
|
+
* Shared state (budget, log) spans all contexts of one crawl.
|
|
32
|
+
*/
|
|
33
|
+
export declare function guardContext(context: BrowserContext, policy: NetworkPolicy, limits: NetworkLimits, log: NetworkLog): Promise<void>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { guardedRequest, TooLargeError } from '../security/fetch.js';
|
|
2
|
+
import { BlockedError, checkUrlSyntax, displayUrl } from '../security/net-policy.js';
|
|
3
|
+
export const DEFAULT_LIMITS = {
|
|
4
|
+
maxResponseBytes: 20 * 1024 * 1024,
|
|
5
|
+
maxTotalBytes: 250 * 1024 * 1024,
|
|
6
|
+
maxRequests: 3000,
|
|
7
|
+
requestTimeoutMs: 20_000,
|
|
8
|
+
};
|
|
9
|
+
export function newNetworkLog() {
|
|
10
|
+
return { blocked: [], navRedirects: new Map(), blockedUrls: new Set(), requests: 0, bytes: 0, budgetExceeded: false };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Serves every browser request from Node through the guarded client, so the browser never
|
|
14
|
+
* resolves DNS or opens a connection on its own. Blocked requests are aborted and logged.
|
|
15
|
+
* Shared state (budget, log) spans all contexts of one crawl.
|
|
16
|
+
*/
|
|
17
|
+
export async function guardContext(context, policy, limits, log) {
|
|
18
|
+
const block = async (route, url, reason, code = 'blockedbyclient') => {
|
|
19
|
+
log.blockedUrls.add(url);
|
|
20
|
+
if (log.blocked.length < 200)
|
|
21
|
+
log.blocked.push({ url: displayUrl(url), reason });
|
|
22
|
+
await route.abort(code).catch(() => undefined);
|
|
23
|
+
};
|
|
24
|
+
await context.route('**/*', async (route) => {
|
|
25
|
+
const req = route.request();
|
|
26
|
+
const url = req.url();
|
|
27
|
+
if (!/^https?:/i.test(url))
|
|
28
|
+
return block(route, url, 'unsupported protocol');
|
|
29
|
+
if (log.requests >= limits.maxRequests || log.bytes >= limits.maxTotalBytes) {
|
|
30
|
+
log.budgetExceeded = true;
|
|
31
|
+
return block(route, url, 'crawl network budget exhausted', 'failed');
|
|
32
|
+
}
|
|
33
|
+
log.requests++;
|
|
34
|
+
try {
|
|
35
|
+
const headersIn = await req.allHeaders();
|
|
36
|
+
const send = (u, method, body) => guardedRequest(u, { policy, method, headers: headersIn, body, maxBytes: limits.maxResponseBytes, timeoutMs: limits.requestTimeoutMs });
|
|
37
|
+
let res = await send(url, req.method(), req.postDataBuffer());
|
|
38
|
+
const isRedirect = () => res.status >= 300 && res.status < 400 && !!res.headers['location'];
|
|
39
|
+
if (isRedirect() && req.isNavigationRequest()) {
|
|
40
|
+
log.navRedirects.set(url, new URL(String(res.headers['location']), url).href);
|
|
41
|
+
await route.abort('aborted').catch(() => undefined);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// Subresources: follow redirects here, re-validating every hop, and hand back the final response.
|
|
45
|
+
let current = url;
|
|
46
|
+
for (let hop = 0; isRedirect(); hop++) {
|
|
47
|
+
if (hop >= 5)
|
|
48
|
+
throw new BlockedError('more than 5 redirects', url);
|
|
49
|
+
current = new URL(String(res.headers['location']), current).href;
|
|
50
|
+
const keep = res.status === 307 || res.status === 308;
|
|
51
|
+
res = await send(current, keep ? req.method() : 'GET', keep ? req.postDataBuffer() : null);
|
|
52
|
+
}
|
|
53
|
+
log.bytes += res.body.length;
|
|
54
|
+
const headers = {};
|
|
55
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
56
|
+
if (['transfer-encoding', 'connection', 'keep-alive'].includes(k))
|
|
57
|
+
continue;
|
|
58
|
+
headers[k] = Array.isArray(v) ? v.join('\n') : v;
|
|
59
|
+
}
|
|
60
|
+
await route.fulfill({ status: res.status, headers, body: res.body });
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err instanceof BlockedError)
|
|
64
|
+
return block(route, url, err.message);
|
|
65
|
+
if (err instanceof TooLargeError)
|
|
66
|
+
return block(route, url, err.message, 'failed');
|
|
67
|
+
await route.abort('failed').catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
// WebSockets bypass request routing. Remote audits never need them; dev servers use them for hot reload.
|
|
71
|
+
await context.routeWebSocket(/.*/, (ws) => {
|
|
72
|
+
try {
|
|
73
|
+
checkUrlSyntax(ws.url().replace(/^ws/, 'http'), policy);
|
|
74
|
+
if (policy.allowLoopback) {
|
|
75
|
+
ws.connectToServer();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* fall through */
|
|
81
|
+
}
|
|
82
|
+
if (log.blocked.length < 200)
|
|
83
|
+
log.blocked.push({ url: displayUrl(ws.url().replace(/^ws/, 'http')), reason: 'websocket' });
|
|
84
|
+
ws.close();
|
|
85
|
+
});
|
|
86
|
+
context.on('page', (p) => {
|
|
87
|
+
// Pages opened by the site (window.open, target=_blank clicks) are closed immediately.
|
|
88
|
+
p.opener()
|
|
89
|
+
.then((opener) => (opener ? p.close() : undefined))
|
|
90
|
+
.catch(() => undefined);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Minimal robots.txt support: Disallow/Allow rules for `User-agent: *` (or our own agent). */
|
|
2
|
+
export interface RobotsRules {
|
|
3
|
+
allow: string[];
|
|
4
|
+
disallow: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function parseRobots(txt: string, agent?: string): RobotsRules;
|
|
7
|
+
/** Longest matching rule wins; Allow wins ties, per RFC 9309. */
|
|
8
|
+
export declare function isAllowed(rules: RobotsRules, path: string): boolean;
|