返回 AiToEarn
detect-url.mjs
1 import fs from 'node:fs';
2 import path from 'node:path';
3 import { fileURLToPath } from 'node:url';
4
5 import { finding } from '../../findings.mjs';
6 import { filterByProviders } from '../../registry/antipatterns.mjs';
7 import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
8 import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
9
10 async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
11 if (options?.visualContrast === false) return [];
12 const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
13 ? options.visualContrastMaxCandidates
14 : 12;
15 const scrollOffscreen = options?.visualContrastScrollOffscreen !== false;
16 const existingLowContrastSelectors = new Set(
17 serializedGroups
18 .filter(group => group.findings?.some(f => f.type === 'low-contrast'))
19 .map(group => group.selector)
20 .filter(Boolean)
21 );
22
23 let browserAnalyses = [];
24 const findings = [];
25 if (options?.visualContrastBrowser !== false) {
26 const browserFindings = await profileFindingsAsync(profile, {
27 engine: 'browser',
28 phase: 'visual-contrast',
29 ruleId: 'browser-fallback',
30 target,
31 }, async () => {
32 browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => {
33 if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return [];
34 return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen });
35 }, { maxCandidates, scrollOffscreen });
36 return browserAnalyses
37 .filter(result => result.finding && !existingLowContrastSelectors.has(result.selector))
38 .map(result => result.finding);
39 });
40 findings.push(...browserFindings);
41 }
42
43 let candidates = browserAnalyses.length > 0 ? browserAnalyses : [];
44 if (candidates.length === 0) {
45 candidates = await profileStepAsync(profile, {
46 engine: 'browser',
47 phase: 'visual-contrast',
48 ruleId: 'collect-candidates',
49 target,
50 }, () => page.evaluate(({ maxCandidates }) => {
51 if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return [];
52 return window.impeccableCollectVisualContrastCandidates({ maxCandidates });
53 }, { maxCandidates }));
54 }
55
56 const viewport = options?.viewport || { width: 1280, height: 800 };
57 const browserResolvedSelectors = new Set(
58 browserAnalyses
59 .filter(result => result.status === 'fail' || result.status === 'pass')
60 .map(result => result.selector)
61 .filter(Boolean)
62 );
63 const filtered = candidates.filter(candidate =>
64 !existingLowContrastSelectors.has(candidate.selector) &&
65 !browserResolvedSelectors.has(candidate.selector)
66 );
67 if (options?.visualContrastPixel === false) return findings;
68 for (const candidate of filtered) {
69 const result = await profileFindingsAsync(profile, {
70 engine: 'browser',
71 phase: 'visual-contrast',
72 ruleId: 'pixel-diff',
73 target,
74 }, async () => {
75 const finding = await captureVisualContrastCandidate(page, candidate, viewport);
76 return finding ? [finding] : [];
77 });
78 findings.push(...result);
79 }
80 return findings;
81 }
82
83 // ---------------------------------------------------------------------------
84 // Puppeteer detection (for URLs)
85 // ---------------------------------------------------------------------------
86
87 async function detectUrl(url, options = {}) {
88 const profile = options?.profile;
89 const waitUntil = options?.waitUntil || 'networkidle0';
90 const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
91 const viewport = options?.viewport || { width: 1280, height: 800 };
92 const externalBrowser = options?.browser || null;
93 let puppeteer;
94 if (!externalBrowser) {
95 try {
96 puppeteer = await profileStepAsync(profile, {
97 engine: 'browser',
98 phase: 'setup',
99 ruleId: 'import-puppeteer',
100 target: url,
101 }, () => import('puppeteer'));
102 } catch {
103 throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
104 }
105 }
106
107 // Read the browser detection script — reuse it instead of reimplementing
108 const browserScriptPath = path.resolve(
109 path.dirname(fileURLToPath(import.meta.url)),
110 '..',
111 '..',
112 'detect-antipatterns-browser.js'
113 );
114 let browserScript;
115 try {
116 browserScript = profileStep(profile, {
117 engine: 'browser',
118 phase: 'setup',
119 ruleId: 'read-browser-script',
120 target: url,
121 }, () => fs.readFileSync(browserScriptPath, 'utf-8'));
122 } catch {
123 throw new Error(`Browser script not found at ${browserScriptPath}`);
124 }
125
126 // CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
127 // Chrome can't initialize its sandbox there. Disable the sandbox only when
128 // running in CI; local users keep the default hardened launch.
129 const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
130 const browser = externalBrowser || await profileStepAsync(profile, {
131 engine: 'browser',
132 phase: 'load',
133 ruleId: 'launch-browser',
134 target: url,
135 }, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
136 const page = await profileStepAsync(profile, {
137 engine: 'browser',
138 phase: 'load',
139 ruleId: 'new-page',
140 target: url,
141 }, () => browser.newPage());
142 let results = [];
143 try {
144 await profileStepAsync(profile, {
145 engine: 'browser',
146 phase: 'load',
147 ruleId: 'set-viewport',
148 target: url,
149 }, () => page.setViewport(viewport));
150 await profileStepAsync(profile, {
151 engine: 'browser',
152 phase: 'load',
153 ruleId: `goto:${waitUntil}`,
154 target: url,
155 }, () => page.goto(url, { waitUntil, timeout: 30000 }));
156 if (settleMs > 0) {
157 await profileStepAsync(profile, {
158 engine: 'browser',
159 phase: 'load',
160 ruleId: 'settle',
161 target: url,
162 }, () => new Promise(resolve => setTimeout(resolve, settleMs)));
163 }
164
165 // Inject the browser detection script and collect results
166 await profileStepAsync(profile, {
167 engine: 'browser',
168 phase: 'scan',
169 ruleId: 'configure-pure-detect',
170 target: url,
171 }, () => page.evaluate(() => {
172 window.__IMPECCABLE_CONFIG__ = {
173 ...(window.__IMPECCABLE_CONFIG__ || {}),
174 autoScan: false,
175 };
176 }));
177 await profileStepAsync(profile, {
178 engine: 'browser',
179 phase: 'scan',
180 ruleId: 'inject-browser-script',
181 target: url,
182 }, () => page.evaluate(browserScript));
183 let serializedGroups = [];
184 results = await profileFindingsAsync(profile, {
185 engine: 'browser',
186 phase: 'scan',
187 ruleId: 'browser-scan',
188 target: url,
189 }, async () => {
190 serializedGroups = await page.evaluate(() => {
191 if (!window.impeccableDetect) return [];
192 return window.impeccableDetect({ decorate: false, serialize: true });
193 });
194 return serializedGroups.flatMap(({ findings }) =>
195 findings.map(f => ({ id: f.type, snippet: f.detail }))
196 );
197 });
198 const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
199 results.push(...visualFindings);
200 } finally {
201 await profileStepAsync(profile, {
202 engine: 'browser',
203 phase: 'load',
204 ruleId: 'close-page',
205 target: url,
206 }, () => page.close().catch(() => {}));
207 if (!externalBrowser) {
208 await profileStepAsync(profile, {
209 engine: 'browser',
210 phase: 'load',
211 ruleId: 'close-browser',
212 target: url,
213 }, () => browser.close());
214 }
215 }
216 return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
217 }
218
219 async function createBrowserDetector(options = {}) {
220 let puppeteer;
221 try {
222 puppeteer = await import('puppeteer');
223 } catch {
224 throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
225 }
226 const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
227 const browser = options.browser || await puppeteer.default.launch({
228 headless: options.headless ?? true,
229 args: launchArgs,
230 });
231 const ownsBrowser = !options.browser;
232 const defaults = {
233 waitUntil: options.waitUntil || 'load',
234 settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100,
235 viewport: options.viewport || { width: 1280, height: 800 },
236 };
237 return {
238 browser,
239 async detectUrl(url, scanOptions = {}) {
240 return detectUrl(url, {
241 ...defaults,
242 ...scanOptions,
243 browser,
244 });
245 },
246 async close() {
247 if (ownsBrowser) await browser.close().catch(() => {});
248 },
249 };
250 }
251
252 export { runVisualContrastFallback, detectUrl, createBrowserDetector };
253
253 lines Plain Text