Docs/Skills and code/Top-level code
test-google-ai-mode.js
What the file says about itself
Google AI Mode feasibility test (Playwright, no API). Run: node test-google-ai-mode.js ["custom question"] [--headless] Opens https://www.google.com/search?udm=50&q=...
#!/usr/bin/env node
/**
* Google AI Mode feasibility test (Playwright, no API).
*
* Run: node test-google-ai-mode.js ["custom question"] [--headless]
*
* Opens https://www.google.com/search?udm=50&q=... in real Chrome with a persistent profile
* (data/ai-mode-poc/profile, so a CAPTCHA/consent you solve once is remembered), waits for the
* streamed answer to stop changing, then saves under data/ai-mode-poc/<timestamp>/:
* answer.txt, links.json (all outbound links, YouTube ones flagged), page.html, screenshot.png, result.json
*
* The point is to learn what comes back and whether it is scrapeable: is there an answer, does it
* cite YouTube videos, do the cited links carry titles/channels, or do we hit a block page.
*/
import fs from "node:fs";
import path from "node:path";
import { chromium } from "playwright-core";
const DEFAULT_QUESTION = "why did moser baer die. What are the most outlier amazing yt videos that deal with it";
const args = process.argv.slice(2);
const headless = args.includes("--headless");
const question = args.find(a => !a.startsWith("--")) ?? DEFAULT_QUESTION;
const ROOT = path.resolve("data", "ai-mode-poc");
const runDir = path.join(ROOT, new Date().toISOString().replace(/[:.]/g, "-"));
fs.mkdirSync(runDir, { recursive: true });
const url = `https://www.google.com/search?udm=50&hl=en&q=${encodeURIComponent(question)}`;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const log = (...a) => console.log(...a);
const BLOCK_PATTERNS = [/unusual traffic/i, /not a robot/i, /\/sorry\//, /captcha/i, /Before you continue/i];
const youtubeId = href => {
try {
const u = new URL(href);
if (u.hostname.endsWith("youtu.be")) return u.pathname.slice(1) || null;
if (u.hostname.endsWith("youtube.com")) return u.searchParams.get("v") ?? u.pathname.match(/\/(?:shorts|embed)\/([\w-]{11})/)?.[1] ?? null;
} catch {}
return null;
};
const context = await chromium.launchPersistentContext(path.join(ROOT, "profile"), {
channel: "chrome",
headless,
viewport: { width: 1280, height: 1000 },
locale: "en-US",
args: ["--disable-blink-features=AutomationControlled"]
});
const page = context.pages()[0] ?? await context.newPage();
const result = { question, url, headless, startedAt: new Date().toISOString() };
try {
log(`Question: ${question}\nOpening ${url}`);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
// Blocked / consent pages: give a human up to 2 minutes when running headed.
const blocked = async () => {
const body = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
return BLOCK_PATTERNS.some(p => p.test(page.url()) || p.test(body.slice(0, 2000)));
};
if (await blocked()) {
result.hitBlockPage = true;
log(headless ? "Block/consent page hit (headless). Re-run headed to solve it once." : "Block/consent page: solve it in the browser window (waiting up to 2 min)...");
if (!headless) for (let i = 0; i < 60 && await blocked(); i++) await sleep(2000);
}
// Wait for the answer to start, then until the text stops changing for ~6s (streamed answer).
const readText = () => page.evaluate(() => (document.querySelector("main, [role=main]") ?? document.body).innerText).catch(() => "");
let last = "", stableSince = Date.now();
const deadline = Date.now() + 90000;
while (Date.now() < deadline) {
await sleep(1500);
const now = await readText();
if (now !== last) { last = now; stableSince = Date.now(); }
else if (last.length > 400 && Date.now() - stableSince > 6000) break;
}
result.waitedMs = Date.now() - Date.parse(result.startedAt);
// Expand "show more" style controls so citations are in the DOM.
for (const label of [/show more/i, /show all/i, /sources/i]) {
const btn = page.getByRole("button", { name: label }).first();
if (await btn.count().catch(() => 0)) { await btn.click({ timeout: 2000 }).catch(() => {}); await sleep(1000); }
}
const text = await readText();
// Citations are wrapped as relative /goto?url=<opaque token> links; resolve each through Google's
// redirect (no auto-follow) to read the real target from the Location header.
const rawLinks = await page.evaluate(() =>
[...document.querySelectorAll("a[href]")].map(a => ({
href: a.href,
raw: a.getAttribute("href"),
text: (a.innerText || "").trim().slice(0, 200),
label: a.getAttribute("aria-label") || ""
}))
);
const isGoogle = h => /^https?:\/\/([^/]*\.)?(google|gstatic)\.[a-z.]+\//.test(h);
const candidates = rawLinks.filter(l => /^https?:/.test(l.href) && (!isGoogle(l.href) || l.raw.startsWith("/goto")));
const seen = new Set();
const byTarget = new Map();
for (const l of candidates) {
if (seen.has(l.href + l.text + l.label)) continue;
seen.add(l.href + l.text + l.label);
let target = l.href;
if (l.raw.startsWith("/goto")) {
const res = await context.request.get(l.href, { maxRedirects: 0, failOnStatusCode: false }).catch(() => null);
target = res?.headers()?.location ?? null;
}
// The same card appears several times (thumbnail, title, channel chip): merge by target.
const key = target ?? `unresolved:${l.href}`;
const prev = byTarget.get(key);
if (!prev) byTarget.set(key, { target, wrapped: l.raw.startsWith("/goto"), text: l.text, label: l.label, youtubeId: target ? youtubeId(target) : null });
else { prev.text ||= l.text; prev.label ||= l.label; }
}
const merged = [...byTarget.values()];
fs.writeFileSync(path.join(runDir, "answer.txt"), text);
fs.writeFileSync(path.join(runDir, "links.json"), JSON.stringify(merged, null, 2));
fs.writeFileSync(path.join(runDir, "page.html"), await page.content());
await page.screenshot({ path: path.join(runDir, "screenshot.png"), fullPage: true }).catch(() => {});
Object.assign(result, {
finalUrl: page.url(),
blockedAtEnd: await blocked(),
answerChars: text.length,
outboundLinks: merged.length,
youtubeLinks: merged.filter(l => l.youtubeId).length,
youtubeVideos: merged.filter(l => l.youtubeId).map(l => ({ id: l.youtubeId, title: l.text || l.label, url: l.target }))
});
} catch (error) {
result.error = error.message;
} finally {
result.dir = runDir;
fs.writeFileSync(path.join(runDir, "result.json"), JSON.stringify(result, null, 2));
await context.close();
}
log("\n=== RESULT ===");
log(JSON.stringify(result, null, 2));
if (!result.error && result.answerChars) {
log("\n--- answer preview (first 1500 chars) ---");
log(fs.readFileSync(path.join(runDir, "answer.txt"), "utf8").slice(0, 1500));
}
process.exit(result.error || result.blockedAtEnd || !result.answerChars ? 1 : 0);
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.