Docs/Skills and code/Top-level code

test-google-ai-mode.js

Code145 lines6.6 KBTop-level code

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.