@apmantza/greedysearch-pi 2.1.3 → 2.1.5

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.
@@ -0,0 +1,663 @@
1
+ #!/usr/bin/env node
2
+ // scripts/stealth-check.mjs — informational fingerprint smoke check for GreedySearch Chrome.
3
+ //
4
+ // This is intentionally non-gating in v1: it prints observations from direct
5
+ // fingerprint probes plus public test pages (Sannysoft, Intoli, CreepJS) and
6
+ // exits 0 unless the browser/check infrastructure itself fails.
7
+ //
8
+ // Usage:
9
+ // npm run stealth-check
10
+ // npm run stealth-check -- --visible
11
+ // node scripts/stealth-check.mjs --json
12
+
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const ROOT = join(__dirname, "..");
21
+ const PROFILE_DIR = join(tmpdir(), "greedysearch-chrome-profile");
22
+ const args = new Set(process.argv.slice(2));
23
+ const visible = args.has("--visible");
24
+ const jsonOutput = args.has("--json");
25
+ const strictMode = args.has("--strict");
26
+ const diffMode = args.has("--diff") || args.has("--baseline-compare");
27
+
28
+ process.env.CDP_PROFILE_DIR = PROFILE_DIR;
29
+ if (visible) process.env.GREEDY_SEARCH_VISIBLE = "1";
30
+
31
+ const TESTS = [
32
+ {
33
+ id: "sannysoft",
34
+ name: "Sannysoft Bot Detection",
35
+ url: "https://bot.sannysoft.com/",
36
+ settleMs: 8000,
37
+ },
38
+ {
39
+ id: "intoli",
40
+ name: "Intoli Headless Detection",
41
+ url: "https://intoli.com/blog/not-possible-to-block-chrome-headless/chrome-headless-test.html",
42
+ settleMs: 4000,
43
+ },
44
+ {
45
+ id: "creepjs",
46
+ name: "CreepJS Fingerprint",
47
+ url: "https://abrahamjuliot.github.io/creepjs/",
48
+ settleMs: 9000,
49
+ },
50
+ ];
51
+
52
+ const BASELINE_DIR = join(
53
+ process.env.HOME || process.env.USERPROFILE || process.cwd(),
54
+ ".greedysearch",
55
+ );
56
+ const BASELINE_FILE = join(BASELINE_DIR, "stealth-baseline.json");
57
+ const BASELINE_VERSION = 1;
58
+
59
+ function runNode(script, scriptArgs = [], options = {}) {
60
+ const result = spawnSync(process.execPath, [script, ...scriptArgs], {
61
+ cwd: ROOT,
62
+ encoding: "utf8",
63
+ env: process.env,
64
+ ...options,
65
+ });
66
+ if (result.status !== 0) {
67
+ const detail = [result.stdout, result.stderr]
68
+ .filter((value) => typeof value === "string" && value)
69
+ .join("\n")
70
+ .trim();
71
+ throw new Error(
72
+ `${script} ${scriptArgs.join(" ")} failed${detail ? `:\n${detail}` : ""}`,
73
+ );
74
+ }
75
+ return typeof result.stdout === "string" ? result.stdout.trim() : "";
76
+ }
77
+
78
+ function ensureChrome() {
79
+ const launch = join(ROOT, "bin", "launch.mjs");
80
+ const launchArgs = visible ? [] : ["--headless"];
81
+ runNode(launch, launchArgs, { stdio: jsonOutput ? "pipe" : "inherit" });
82
+ const activePort = join(PROFILE_DIR, "DevToolsActivePort");
83
+ if (!existsSync(activePort)) {
84
+ throw new Error(
85
+ `Chrome launched but DevToolsActivePort is missing at ${activePort}`,
86
+ );
87
+ }
88
+ }
89
+
90
+ function tryJson(value) {
91
+ try {
92
+ return JSON.parse(value);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function summarizeSannysoft(text) {
99
+ const scannerText = text.includes("Fingerprint Scanner tests")
100
+ ? text.slice(text.indexOf("Fingerprint Scanner tests"))
101
+ : text;
102
+ const scannerMatches = [
103
+ ...scannerText.matchAll(
104
+ /\b([A-Z][A-Z0-9_]{2,})\s+(ok|failed|warning)\b/gim,
105
+ ),
106
+ ];
107
+ const scannerTests = scannerMatches.map((m) => ({
108
+ name: m[1],
109
+ status: m[2].toLowerCase(),
110
+ }));
111
+
112
+ const tableFind = (label) => {
113
+ const lines = text.split(/\r?\n/).map((line) => line.trim());
114
+ const target = label.toLowerCase();
115
+ for (let index = 0; index < lines.length; index += 1) {
116
+ const line = lines[index];
117
+ const lower = line.toLowerCase();
118
+ if (lower.startsWith(`${target}\t`) || lower.startsWith(`${target} `)) {
119
+ return line.slice(label.length).trim();
120
+ }
121
+ if (lower !== target) continue;
122
+ for (let offset = 1; offset <= 3; offset += 1) {
123
+ const candidate = lines[index + offset];
124
+ if (!candidate || /^\([^)]*\)$/.test(candidate)) continue;
125
+ return candidate;
126
+ }
127
+ }
128
+ return null;
129
+ };
130
+
131
+ return {
132
+ textLength: text.length,
133
+ sample: text.slice(0, 500),
134
+ total: scannerTests.length,
135
+ ok: scannerTests.filter((t) => t.status === "ok").length,
136
+ failed: scannerTests.filter((t) => t.status === "failed").length,
137
+ warning: scannerTests.filter((t) => t.status === "warning").length,
138
+ tests: scannerTests,
139
+ intoliAdditions: {
140
+ webdriver: tableFind("WebDriver"),
141
+ webdriverAdvanced: tableFind("WebDriver Advanced"),
142
+ chrome: tableFind("Chrome"),
143
+ pluginsType: tableFind("Plugins is of type PluginArray"),
144
+ },
145
+ };
146
+ }
147
+
148
+ function summarizeIntoli(text) {
149
+ const lowered = text.toLowerCase();
150
+ const rows = [];
151
+ for (const line of text
152
+ .split(/\r?\n/)
153
+ .map((l) => l.trim())
154
+ .filter(Boolean)) {
155
+ if (
156
+ /\b(pass|failed?|present|missing|yes|no)\b/i.test(line) &&
157
+ line.length < 180
158
+ ) {
159
+ rows.push(line);
160
+ }
161
+ }
162
+ return {
163
+ mentionsHeadless: lowered.includes("headless"),
164
+ mentionsWebdriver: lowered.includes("webdriver"),
165
+ passCount: (text.match(/\bpass(?:ed)?\b/gi) || []).length,
166
+ failCount: (text.match(/\bfail(?:ed)?\b/gi) || []).length,
167
+ rows: rows.slice(0, 20),
168
+ };
169
+ }
170
+
171
+ function summarizeCreepjs(text) {
172
+ const lines = text
173
+ .split(/\r?\n/)
174
+ .map((l) => l.trim())
175
+ .filter(Boolean);
176
+ const interesting = lines.filter((line) =>
177
+ /headless|stealth|webdriver|trust|bot|lie|lied|score|rating/i.test(line),
178
+ );
179
+ // Structured score extraction
180
+ const likeHeadlessMatch = text.match(/(\d+)%\s*like headless\s*:\s*(\w+)/i);
181
+ const headlessMatch = text.match(/(\d+)%\s*headless\s*:\s*(\w+)/i);
182
+ const stealthMatch = text.match(/(\d+)%\s*stealth\s*:\s*(\w+)/i);
183
+ const trustMatch = text.match(/trust[:\s]*(\d+)%/i);
184
+ const botMatch = text.match(/bot[:\s]*(\d+)%/i);
185
+ return {
186
+ textLength: text.length,
187
+ interesting: interesting.slice(0, 30),
188
+ scores: {
189
+ likeHeadless: likeHeadlessMatch
190
+ ? {
191
+ percent: parseInt(likeHeadlessMatch[1], 10),
192
+ fingerprint: likeHeadlessMatch[2],
193
+ }
194
+ : null,
195
+ headless: headlessMatch
196
+ ? {
197
+ percent: parseInt(headlessMatch[1], 10),
198
+ fingerprint: headlessMatch[2],
199
+ }
200
+ : null,
201
+ stealth: stealthMatch
202
+ ? {
203
+ percent: parseInt(stealthMatch[1], 10),
204
+ fingerprint: stealthMatch[2],
205
+ }
206
+ : null,
207
+ trust: trustMatch ? parseInt(trustMatch[1], 10) : null,
208
+ bot: botMatch ? parseInt(botMatch[1], 10) : null,
209
+ },
210
+ };
211
+ }
212
+
213
+ function summarizeGeneric(testId, text) {
214
+ if (testId === "sannysoft") return summarizeSannysoft(text);
215
+ if (testId === "intoli") return summarizeIntoli(text);
216
+ if (testId === "creepjs") return summarizeCreepjs(text);
217
+ return { textLength: text.length, sample: text.slice(0, 1000) };
218
+ }
219
+
220
+ function loadBaseline() {
221
+ try {
222
+ const data = JSON.parse(readFileSync(BASELINE_FILE, "utf8"));
223
+ if (data && data.version === BASELINE_VERSION) return data;
224
+ } catch {}
225
+ return null;
226
+ }
227
+
228
+ function createBaseline(results) {
229
+ const creepjs = results.pages.find((p) => p.id === "creepjs");
230
+ const sannysoft = results.pages.find((p) => p.id === "sannysoft");
231
+ return {
232
+ version: BASELINE_VERSION,
233
+ createdAt: new Date().toISOString(),
234
+ sannysoft: sannysoft?.summary
235
+ ? {
236
+ ok: sannysoft.summary.ok,
237
+ total: sannysoft.summary.total,
238
+ failed: sannysoft.summary.failed,
239
+ warning: sannysoft.summary.warning,
240
+ intoliAdditions: sannysoft.summary.intoliAdditions,
241
+ }
242
+ : null,
243
+ creepjs: creepjs?.summary?.scores ? { ...creepjs.summary.scores } : null,
244
+ };
245
+ }
246
+
247
+ function saveBaseline(results) {
248
+ try {
249
+ if (!existsSync(BASELINE_DIR)) mkdirSync(BASELINE_DIR, { recursive: true });
250
+ const baseline = createBaseline(results);
251
+ writeFileSync(BASELINE_FILE, JSON.stringify(baseline, null, 2), "utf8");
252
+ return baseline;
253
+ } catch (error) {
254
+ console.error("Warning: could not save baseline:", error.message);
255
+ return null;
256
+ }
257
+ }
258
+
259
+ function diffBaseline(baseline, results) {
260
+ const changes = [];
261
+ const creepjs = results.pages.find((p) => p.id === "creepjs");
262
+ const sannysoft = results.pages.find((p) => p.id === "sannysoft");
263
+ if (baseline.creepjs && creepjs?.summary?.scores) {
264
+ for (const key of ["likeHeadless", "headless", "stealth"]) {
265
+ const before = baseline.creepjs[key];
266
+ const after = creepjs.summary.scores[key];
267
+ if (before && after && before.percent !== after.percent) {
268
+ changes.push({
269
+ metric: `creepjs.${key}`,
270
+ before: before.percent,
271
+ after: after.percent,
272
+ improved: after.percent < before.percent,
273
+ });
274
+ }
275
+ }
276
+ }
277
+ if (baseline.sannysoft && sannysoft?.summary) {
278
+ for (const key of ["ok", "failed", "warning"]) {
279
+ const before = baseline.sannysoft[key];
280
+ const after = sannysoft.summary[key];
281
+ if (before !== after) {
282
+ changes.push({
283
+ metric: `sannysoft.${key}`,
284
+ before,
285
+ after,
286
+ improved: key === "ok" ? after > before : after < before,
287
+ });
288
+ }
289
+ }
290
+ }
291
+ return changes;
292
+ }
293
+
294
+ function checkStrict(results) {
295
+ const failures = [];
296
+ for (const page of results.pages) {
297
+ if (!page.ok) {
298
+ failures.push(`${page.name}: page load failed — ${page.error}`);
299
+ continue;
300
+ }
301
+ if (page.id === "sannysoft") {
302
+ const s = page.summary;
303
+ if (s.failed > 0 || s.warning > 0) {
304
+ failures.push(
305
+ `Sannysoft: ${s.failed} failed, ${s.warning} warning scanner tests`,
306
+ );
307
+ }
308
+ for (const [key, value] of Object.entries(s.intoliAdditions || {})) {
309
+ if (value && !/\bpassed\b/i.test(value)) {
310
+ failures.push(
311
+ `Sannysoft Intoli: ${key}=${value} (expected "passed")`,
312
+ );
313
+ }
314
+ }
315
+ }
316
+ if (page.id === "creepjs") {
317
+ const scores = page.summary?.scores;
318
+ if (!scores) {
319
+ failures.push("CreepJS: no scores extracted");
320
+ continue;
321
+ }
322
+ if (scores.likeHeadless && scores.likeHeadless.percent > 0) {
323
+ failures.push(
324
+ `CreepJS: likeHeadless=${scores.likeHeadless.percent}% (expected 0%)`,
325
+ );
326
+ }
327
+ if (scores.headless && scores.headless.percent > 0) {
328
+ failures.push(
329
+ `CreepJS: headless=${scores.headless.percent}% (expected 0%)`,
330
+ );
331
+ }
332
+ if (scores.stealth && scores.stealth.percent > 0) {
333
+ failures.push(
334
+ `CreepJS: stealth=${scores.stealth.percent}% (expected 0%)`,
335
+ );
336
+ }
337
+ }
338
+ }
339
+ return failures;
340
+ }
341
+
342
+ async function evalNoContext(cdp, tab, expression, timeoutMs = 20000) {
343
+ const raw = await cdp(
344
+ [
345
+ "evalraw",
346
+ tab,
347
+ "Runtime.evaluate",
348
+ JSON.stringify({ expression, returnByValue: true, awaitPromise: true }),
349
+ ],
350
+ timeoutMs,
351
+ );
352
+ let result;
353
+ try {
354
+ result = JSON.parse(raw);
355
+ } catch (error) {
356
+ throw new Error(`Runtime.evaluate returned invalid JSON: ${error.message}`);
357
+ }
358
+ if (result.exceptionDetails) {
359
+ throw new Error(
360
+ result.exceptionDetails.text ||
361
+ result.exceptionDetails.exception?.description ||
362
+ "Runtime.evaluate failed",
363
+ );
364
+ }
365
+ return String(result.result?.value ?? "");
366
+ }
367
+
368
+ async function waitForPageText(cdp, tab, predicate, timeoutMs = 12000) {
369
+ const deadline = Date.now() + timeoutMs;
370
+ let lastText = "";
371
+ while (Date.now() < deadline) {
372
+ lastText = await evalNoContext(
373
+ cdp,
374
+ tab,
375
+ "document.body ? document.body.innerText : ''",
376
+ 20000,
377
+ );
378
+ if (predicate(lastText)) return lastText;
379
+ await new Promise((resolve) => setTimeout(resolve, 750));
380
+ }
381
+ return lastText;
382
+ }
383
+
384
+ const DIRECT_PROBE = String.raw`
385
+ (async () => {
386
+ const webdriverProtoDesc = Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver');
387
+ const nativeString = webdriverProtoDesc?.get ? Function.prototype.toString.call(webdriverProtoDesc.get) : null;
388
+ const canvas = document.createElement('canvas');
389
+ canvas.width = 64; canvas.height = 16;
390
+ const ctx = canvas.getContext('2d');
391
+ if (ctx) {
392
+ ctx.textBaseline = 'top';
393
+ ctx.font = '14px Arial';
394
+ ctx.fillText('greedy-stealth-check', 1, 1);
395
+ }
396
+ let webglVendor = null;
397
+ let webglRenderer = null;
398
+ try {
399
+ const gl = document.createElement('canvas').getContext('webgl');
400
+ if (gl) {
401
+ const dbg = gl.getExtension('WEBGL_debug_renderer_info');
402
+ if (dbg) {
403
+ webglVendor = gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL);
404
+ webglRenderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL);
405
+ }
406
+ }
407
+ } catch (_) {}
408
+ let uaData = null;
409
+ try {
410
+ uaData = navigator.userAgentData ? {
411
+ brands: navigator.userAgentData.brands,
412
+ mobile: navigator.userAgentData.mobile,
413
+ platform: navigator.userAgentData.platform,
414
+ highEntropy: await navigator.userAgentData.getHighEntropyValues(['architecture','bitness','fullVersionList','platformVersion','uaFullVersion','wow64'])
415
+ } : null;
416
+ } catch (error) {
417
+ uaData = { error: String(error && error.message || error) };
418
+ }
419
+ return {
420
+ url: location.href,
421
+ userAgent: navigator.userAgent,
422
+ webdriverPresent: 'webdriver' in navigator,
423
+ webdriverOwnProperty: Object.prototype.hasOwnProperty.call(navigator, 'webdriver'),
424
+ webdriverPrototypeProperty: !!webdriverProtoDesc,
425
+ webdriverType: typeof navigator.webdriver,
426
+ webdriverValue: navigator.webdriver,
427
+ webdriverGetterNative: nativeString == null ? null : nativeString.includes('[native code]'),
428
+ platform: navigator.platform,
429
+ vendor: navigator.vendor,
430
+ languages: navigator.languages,
431
+ hardwareConcurrency: navigator.hardwareConcurrency,
432
+ deviceMemory: navigator.deviceMemory,
433
+ pluginsLength: navigator.plugins ? navigator.plugins.length : null,
434
+ mimeTypesLength: navigator.mimeTypes ? navigator.mimeTypes.length : null,
435
+ pdfViewerEnabled: navigator.pdfViewerEnabled,
436
+ hasChrome: !!window.chrome,
437
+ chromeKeys: window.chrome ? Object.keys(window.chrome).sort() : [],
438
+ outer: { width: window.outerWidth, height: window.outerHeight, innerWidth, innerHeight },
439
+ screen: { width: screen.width, height: screen.height, availWidth: screen.availWidth, availHeight: screen.availHeight, colorDepth: screen.colorDepth, pixelDepth: screen.pixelDepth },
440
+ webglVendor,
441
+ webglRenderer,
442
+ canvasHashSample: canvas.toDataURL().slice(0, 80),
443
+ uaData,
444
+ };
445
+ })()
446
+ `;
447
+
448
+ async function main() {
449
+ ensureChrome();
450
+
451
+ const { cdp, getOrOpenTab } = await import("../extractors/common.mjs");
452
+ const tab = await getOrOpenTab(null);
453
+ const results = {
454
+ mode: visible ? "visible" : "headless",
455
+ tab,
456
+ directProbe: null,
457
+ pages: [],
458
+ };
459
+
460
+ // New document navigation ensures Page.addScriptToEvaluateOnNewDocument stealth
461
+ // patches installed by getOrOpenTab() run before the probe executes.
462
+ await cdp(
463
+ [
464
+ "nav",
465
+ tab,
466
+ "data:text/html,<title>GreedySearch stealth check</title><body>probe</body>",
467
+ ],
468
+ 15000,
469
+ );
470
+ results.directProbe = tryJson(await cdp(["eval", tab, DIRECT_PROBE], 15000));
471
+
472
+ for (const test of TESTS) {
473
+ const page = {
474
+ id: test.id,
475
+ name: test.name,
476
+ url: test.url,
477
+ ok: false,
478
+ error: null,
479
+ summary: null,
480
+ };
481
+ try {
482
+ await cdp(["nav", tab, test.url], 45000);
483
+ await new Promise((resolve) => setTimeout(resolve, test.settleMs));
484
+ const text = await waitForPageText(
485
+ cdp,
486
+ tab,
487
+ (bodyText) => {
488
+ if (test.id === "sannysoft") return /\bPHANTOM_UA\b/i.test(bodyText);
489
+ if (test.id === "intoli") return /\bwebdriver\b/i.test(bodyText);
490
+ if (test.id === "creepjs")
491
+ return /headless|stealth|trust|fingerprint/i.test(bodyText);
492
+ return bodyText.length > 200;
493
+ },
494
+ test.id === "sannysoft" ? 30000 : 15000,
495
+ );
496
+ page.ok = true;
497
+ page.summary = summarizeGeneric(test.id, text);
498
+ } catch (error) {
499
+ page.error = error.message;
500
+ }
501
+ results.pages.push(page);
502
+ }
503
+
504
+ if (jsonOutput) {
505
+ console.log(JSON.stringify(results, null, 2));
506
+ if (strictMode) {
507
+ const sf = checkStrict(results);
508
+ if (sf.length) process.exit(1);
509
+ }
510
+ return;
511
+ }
512
+
513
+ printHuman(results);
514
+
515
+ // Baseline management
516
+ if (diffMode) {
517
+ const baseline = loadBaseline();
518
+ if (baseline) {
519
+ const changes = diffBaseline(baseline, results);
520
+ if (changes.length) {
521
+ console.log("\nBaseline comparison:");
522
+ for (const c of changes) {
523
+ const arrow = c.improved ? "\u2713" : "\u2717";
524
+ console.log(` ${arrow} ${c.metric}: ${c.before} \u2192 ${c.after}`);
525
+ }
526
+ } else {
527
+ console.log("\nNo baseline changes detected.");
528
+ }
529
+ } else {
530
+ console.log("\nNo baseline found. Run without --diff to create one.");
531
+ }
532
+ } else if (!strictMode) {
533
+ // Auto-save baseline on normal runs when none exists yet
534
+ const baseline = loadBaseline();
535
+ if (!baseline) {
536
+ const saved = saveBaseline(results);
537
+ if (saved) console.log(`\nBaseline saved to ${BASELINE_FILE}`);
538
+ }
539
+ }
540
+
541
+ // Strict gating (after baseline so record is written on failures too)
542
+ if (strictMode) {
543
+ const sf = checkStrict(results);
544
+ if (sf.length) {
545
+ console.log("\n❌ Strict mode failures:");
546
+ for (const f of sf) console.log(` - ${f}`);
547
+ process.exit(1);
548
+ }
549
+ console.log("\n✅ Strict mode: all checks passed");
550
+ }
551
+ }
552
+
553
+ function printHuman(results) {
554
+ console.log("\nGreedySearch stealth check (informational v1)");
555
+ console.log(`Mode: ${results.mode}`);
556
+ console.log(`Tab: ${results.tab}`);
557
+
558
+ console.log("\nDirect fingerprint probe");
559
+ const p = results.directProbe || {};
560
+ const rows = [
561
+ ["navigator.webdriver", `${p.webdriverType} / ${String(p.webdriverValue)}`],
562
+ [
563
+ "webdriver present",
564
+ `${String(p.webdriverPresent)} (own=${String(p.webdriverOwnProperty)}, proto=${String(p.webdriverPrototypeProperty)})`,
565
+ ],
566
+ [
567
+ "webdriver getter native",
568
+ p.webdriverGetterNative === null || p.webdriverGetterNative === undefined
569
+ ? "n/a"
570
+ : String(p.webdriverGetterNative),
571
+ ],
572
+ ["platform", p.platform],
573
+ ["vendor", p.vendor],
574
+ [
575
+ "languages",
576
+ Array.isArray(p.languages) ? p.languages.join(", ") : String(p.languages),
577
+ ],
578
+ ["plugins / mimeTypes", `${p.pluginsLength} / ${p.mimeTypesLength}`],
579
+ [
580
+ "hardware / memory",
581
+ `${p.hardwareConcurrency} cores / ${p.deviceMemory} GB`,
582
+ ],
583
+ [
584
+ "window outer",
585
+ `${p.outer?.width}x${p.outer?.height} (inner ${p.outer?.innerWidth}x${p.outer?.innerHeight})`,
586
+ ],
587
+ ["webgl", `${p.webglVendor || "?"} / ${p.webglRenderer || "?"}`],
588
+ ["UA-CH platform", p.uaData?.platform || p.uaData?.error || "n/a"],
589
+ ];
590
+ for (const [label, value] of rows) {
591
+ console.log(` ${label.padEnd(24)} ${value ?? "n/a"}`);
592
+ }
593
+
594
+ console.log("\nPublic test pages");
595
+ for (const page of results.pages) {
596
+ console.log(`\n- ${page.name}`);
597
+ console.log(` ${page.url}`);
598
+ if (!page.ok) {
599
+ console.log(` ERROR: ${page.error}`);
600
+ continue;
601
+ }
602
+ if (page.id === "sannysoft") {
603
+ const s = page.summary;
604
+ console.log(` Text length: ${s.textLength}`);
605
+ console.log(
606
+ ` Fingerprint scanner: ${s.ok}/${s.total} ok, ${s.failed} failed, ${s.warning} warning`,
607
+ );
608
+ if (!s.total && s.sample)
609
+ console.log(` Sample: ${s.sample.replace(/\s+/g, " ").slice(0, 180)}`);
610
+ if (s.intoliAdditions) {
611
+ console.log(
612
+ ` Intoli additions: webdriver=${s.intoliAdditions.webdriver || "n/a"}, ` +
613
+ `advanced=${s.intoliAdditions.webdriverAdvanced || "n/a"}, ` +
614
+ `chrome=${s.intoliAdditions.chrome || "n/a"}, ` +
615
+ `pluginsType=${s.intoliAdditions.pluginsType || "n/a"}`,
616
+ );
617
+ }
618
+ const failed = s.tests.filter((t) => t.status !== "ok");
619
+ if (failed.length)
620
+ console.log(
621
+ ` Non-ok scanner rows: ${failed.map((t) => `${t.name}:${t.status}`).join(", ")}`,
622
+ );
623
+ } else if (page.id === "intoli") {
624
+ const s = page.summary;
625
+ console.log(
626
+ ` Text signals: pass=${s.passCount}, fail=${s.failCount}, mentions webdriver=${s.mentionsWebdriver}`,
627
+ );
628
+ if (s.rows.length)
629
+ console.log(` Sample rows: ${s.rows.slice(0, 5).join(" | ")}`);
630
+ } else if (page.id === "creepjs") {
631
+ const s = page.summary;
632
+ console.log(` Text length: ${s.textLength}`);
633
+ if (s.scores) {
634
+ const parts = [];
635
+ if (s.scores.likeHeadless)
636
+ parts.push(`like headless: ${s.scores.likeHeadless.percent}%`);
637
+ if (s.scores.headless)
638
+ parts.push(`headless: ${s.scores.headless.percent}%`);
639
+ if (s.scores.stealth)
640
+ parts.push(`stealth: ${s.scores.stealth.percent}%`);
641
+ if (s.scores.trust !== null && s.scores.trust !== undefined)
642
+ parts.push(`trust: ${s.scores.trust}%`);
643
+ if (s.scores.bot !== null && s.scores.bot !== undefined)
644
+ parts.push(`bot: ${s.scores.bot}%`);
645
+ console.log(` Scores: ${parts.join(" | ")}`);
646
+ }
647
+ if (s.interesting.length)
648
+ console.log(` Raw lines: ${s.interesting.slice(0, 5).join(" | ")}`);
649
+ }
650
+ }
651
+
652
+ const notes = [];
653
+ if (strictMode)
654
+ notes.push("Strict mode enabled — exit code reflects pass/fail.");
655
+ if (diffMode) notes.push("Baseline comparison mode enabled.");
656
+ notes.push("Use --json for machine-readable output.");
657
+ console.log(`\nNote: ${notes.join(" ")}`);
658
+ }
659
+
660
+ main().catch((error) => {
661
+ console.error(`stealth-check failed: ${error.stack || error.message}`);
662
+ process.exit(1);
663
+ });
package/src/fetcher.mjs CHANGED
@@ -69,7 +69,16 @@ export function defaultFetchHeaders(overrides = {}) {
69
69
 
70
70
  export function isPrivateUrl(url) {
71
71
  try {
72
+ if (typeof url !== "string" || !url.trim()) {
73
+ return { blocked: true, reason: "URL must be a non-empty string" };
74
+ }
72
75
  const parsed = new URL(url);
76
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
77
+ return {
78
+ blocked: true,
79
+ reason: `Protocol not allowed: ${parsed.protocol}`,
80
+ };
81
+ }
73
82
  const hostname = parsed.hostname.toLowerCase();
74
83
 
75
84
  for (const pattern of PRIVATE_URL_PATTERNS) {
@@ -81,11 +90,6 @@ export function isPrivateUrl(url) {
81
90
  }
82
91
  }
83
92
 
84
- // Block file:// protocol
85
- if (parsed.protocol === "file:") {
86
- return { blocked: true, reason: "File protocol not allowed" };
87
- }
88
-
89
93
  return { blocked: false };
90
94
  } catch (error) {
91
95
  return { blocked: true, reason: `Invalid URL: ${error.message}` };
@@ -25,7 +25,7 @@ const CONFIG_FILE = join(CONFIG_DIR, "greedyconfig");
25
25
  // `semantic-scholar` are deliberately excluded — they belong in research
26
26
  // mode, not casual web search. Users who want them in normal `engine:all`
27
27
  // runs can add them via ~/.pi/greedyconfig (see ensureDefaultConfig()).
28
- export const DEFAULT_ENGINES = ["perplexity", "google", "chatgpt"];
28
+ export const DEFAULT_ENGINES = ["perplexity", "google", "chatgpt", "gemini"];
29
29
  export const DEFAULT_SYNTHESIZER = "gemini";
30
30
 
31
31
  function loadUserEngines() {