// Live parser-drift probe for awwwards-mcp.
//
// Catches awwwards.com markup drift the moment it breaks *us*: every probe is
// one anchor (split/indexOf/regex-literal) that src/parsers.ts depends on,
// counted against the fetched page. Raw-HTML diffing would false-alarm on
// every new site card; probing the anchors fires only when the parsers
// actually break.
//
// Coverage: the /websites/ listing + /sites/ detail pages — 3 pinned slugs
// (stable targets, strict verdicts) + up to 3 slugs per-day-sampled from the
// local index DB (rotating coverage; a sampled page can be removed/renamed
// any day, so its fetch failure is inconclusive, never drift).
//
// Usage:
// node scripts/parser-drift-probe.mjs # live probe at 1 req/s
// # writes .drift/status.json
// node scripts/parser-drift-probe.mjs --fixture # probe committed fixtures
// # (offline) + sampler determinism check
//
// `npm run drift` = the live probe.
// Probe politeness: only pages whose URL shapes the indexer already crawls
// (a /websites/ listing + /sites/ detail pages), same UA, 1 req/s between
// ALL fetches, no retries. Exit 0 ok / 1 drift / 2 fetch-fail (3 = fixture
// self-check failure).
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const LIST_URL = "https://www.awwwards.com/websites/";
const SITE_DETAIL_URL = (slug) => `https://www.awwwards.com/sites/${slug}`;
// Stable detail targets — strict verdicts (a missing anchor on a healthy
// fetch is drift). Fixture mode probes the committed detail fixtures instead
// (see DETAIL_FIXTURES; gionatan-nese-26 has no committed fixture).
const PINNED_DETAIL_SLUGS = ["gionatan-nese-26", "emergence-magazine", "l-i-s-a"];
// Committed detail-page fixtures; og:url identifies the live page each was
// saved from (detail.html = l-i-s-a, detail-lxl.html = lxl-creative,
// detail-emergence.html = emergence-magazine).
const DETAIL_FIXTURES = [
"test/fixtures/detail.html",
"test/fixtures/detail-lxl.html",
"test/fixtures/detail-emergence.html",
];
// --- parser anchors (kept in sync with src/parsers.ts) ---
// Each probe names the parser + what the anchor locates, so a failure says
// exactly which upstream markup changed.
const LISTING_PROBES = [
{ name: "parseListing[card JSON blob]", parser: "parseListing (site cards)", anchor: 'data-collectable-model-value="' },
{ name: "parseListing[rollover live-url]", parser: "parseListing (live-site URL)", anchor: 'class="figure-rollover__bt"' },
{ name: "parseListing[detail href]", parser: "parseListing (detail link)", anchor: 'href="/sites/' },
{ name: "parseListing[award tag]", parser: "parseListing (award labels)", anchor: "budget-tag--" },
{ name: "parseCategories[rollover links]", parser: "parseCategories (taxonomy page)", anchor: "figure-rollover" },
];
const detailProbesFor = (url) => [
{ name: "parseDetail[tech section]", parser: "parseDetail (tech stack)", anchor: "Technologies & Tools" },
{ name: "parseElements[section start]", parser: "parseDetail + parseElements", anchor: ">Elements" },
{ name: "parseElements[section end]", parser: "parseDetail + parseElements", anchor: ">Color Palette" },
{ name: "parseDetail[description marker]", parser: "parseDetail (description)", anchor: ">Description" },
{ name: "parseDetail[description h3]", parser: "parseDetail (description text)", anchor: '
' },
{ name: "parseDetail[og:image]", parser: "parseDetail (screenshot)", anchor: 'property="og:image" content="' },
{ name: "parseDetail[og:title]", parser: "parseScore + parseElements", anchor: 'property="og:title" content="' },
];
const STATUS_FILE = resolve(root, ".drift/status.json");
function countAll(html, anchor) {
let n = 0;
let i = html.indexOf(anchor);
while (i >= 0) {
n++;
i = html.indexOf(anchor, i + anchor.length);
}
return n;
}
function countMatches(html, regex) {
const re = regex.global ? regex : new RegExp(regex.source, regex.flags + "g");
const matches = html.match(re);
return matches ? matches.length : 0;
}
// The Elements grid slice of a detail page — null when the page has no
// Elements section (legitimate for some sites; never drift by itself).
function elementsSection(html) {
const start = html.indexOf(">Elements");
if (start < 0) return null;
const end = html.indexOf(">Color Palette", start);
return end > start ? html.slice(start, end) : html.slice(start);
}
// The emergence-magazine incident signal: section present, zero blobs.
function sectionBlobCount(html) {
const section = elementsSection(html);
if (section === null) return -1; // no section on this site = legitimate, skip
return countAll(section, 'data-collectable-model-value="');
}
// element|external media paths inside the Elements section. Detail HTML
// embeds collectable JSON entity-escaped (") with JSON-escaped slashes
// (element\/…), so the pattern pins that exact byte shape — a prefix scheme
// change (element/ → something else) zeroes this count while blob counts
// stay healthy.
const MEDIA_PATH_RE = new RegExp('"collectableImage":"(element|external)\\\\/', "g");
// Deterministic per-day sample of detail slugs from the local index.
// seed = YYYYMMDD → same picks all day, different slice daily.
function sampleDetailSlugs(slugs, count = 3, seed = Number(new Date().toISOString().slice(0, 10).replaceAll("-", ""))) {
const sorted = [...new Set(slugs)].sort();
if (sorted.length === 0) return [];
const start = seed % sorted.length;
const picked = new Set();
// k < sorted.length: the +97 stride visits every index when it copes with
// the list length; the cap only stops a pathological multiple-of-97 list
// from looping forever (it would then just sample fewer than `count`).
for (let k = 0; picked.size < Math.min(count, sorted.length) && k < sorted.length; k++) {
picked.add(sorted[(start + k * 97) % sorted.length]);
}
return [...picked];
}
// Index slugs live in the cache DB; read via a throwaway subprocess so a
// missing/locked DB can never crash or block the probe (empty list → the
// caller falls back to pinned-only and logs it).
function loadIndexSlugs() {
try {
const out = execFileSync(
process.execPath,
["--input-type=module", "-e", `
import { DatabaseSync } from "node:sqlite";
import { homedir } from "node:os";
import { join } from "node:path";
const db = new DatabaseSync(join(process.env.AWWWARDS_CACHE_DIR ?? join(homedir(), ".awwwards-mcp"), "cache.db"), { readOnly: true });
const rows = db.prepare("SELECT DISTINCT slug FROM sites ORDER BY slug").all();
console.log(JSON.stringify(rows.map((r) => r.slug)));
db.close();
`],
{ encoding: "utf8", timeout: 10000 },
);
return JSON.parse(out.trim());
} catch {
return []; // no index → caller falls back to pinned-only, logged
}
}
// Behavioral verdicts over a detail page's Elements section. Sampled pages
// are inconclusive on fetch failure instead of drift (they can disappear any
// day); pinned pages and fixtures keep strict verdicts.
function sectionVerdictRows(url, html, pageOk, kind) {
const section = elementsSection(html);
const blobs = section === null ? -1 : countAll(section, 'data-collectable-model-value="');
const paths = section === null ? 0 : countMatches(section, MEDIA_PATH_RE);
const inconclusive = !pageOk
? kind === "sampled"
? "sampled page inconclusive (fetch/removed)"
: "page fetch blocked/challenge — inconclusive"
: null;
const blobRow = { name: "parseElements[section blobs]", parser: "parseDetail + parseElements", url, count: blobs, drift: false };
if (inconclusive) blobRow.note = inconclusive;
else if (blobs === -1) blobRow.note = "no section";
else if (blobs === 0) {
blobRow.drift = true;
blobRow.note = "Elements section present but zero collectable blobs — section markup drift";
}
const pathRow = { name: "parseElements[media path scheme]", parser: "parseElements", url, count: paths, drift: false };
if (inconclusive) pathRow.note = inconclusive;
else if (section === null) pathRow.note = "no section";
else if (blobs === 0) pathRow.note = "no blobs";
else if (paths === 0) {
pathRow.drift = true;
pathRow.note = `section has ${blobs} blobs but zero element/external media paths — media path scheme changed`;
}
return { rows: [blobRow, pathRow], drifts: (blobRow.drift ? 1 : 0) + (pathRow.drift ? 1 : 0) };
}
function anchorProbeRows(group, html, pageOk, mode) {
const rows = [];
let drifts = 0;
for (const probe of group.probes) {
const n = countAll(html, probe.anchor);
const r = { name: probe.name, parser: probe.parser, url: group.url, drift: n === 0, count: n };
if (group.kind === "sampled") {
// Sampled pages are arbitrary sites: sections like Technologies /
// Color Palette / Description are optional per-site (verified on
// healthy pages), so a count of 0 there is an observation, not drift.
// Strict anchor verdicts stay on the listing + pinned pages.
if (n === 0) {
r.drift = false;
r.note = pageOk
? "anchor absent on sampled page (optional per-site — observed, not drift)"
: "sampled page inconclusive (fetch/removed)";
}
} else if (n === 0) {
r.note = !pageOk
? "page fetch blocked/challenge — inconclusive"
: mode === "fixture"
? "fixture lacks anchor — fixture stale vs parsers, or probe anchor wrong"
: "anchor gone from live page — parser drift";
if (pageOk) drifts++;
}
rows.push(r);
}
return { rows, drifts };
}
function probePage(group, html, pageOk, mode) {
const a = anchorProbeRows(group, html, pageOk, mode);
let rows = a.rows;
let drifts = a.drifts;
if (group.kind !== "listing") {
const s = sectionVerdictRows(group.url, html, pageOk, group.kind);
rows = rows.concat(s.rows);
drifts += s.drifts;
}
return { rows, drifts };
}
const listingGroup = () => ({ url: LIST_URL, kind: "listing", probes: LISTING_PROBES });
const detailGroup = (url, kind) => ({ url, kind, probes: detailProbesFor(url) });
function determinismSelfCheck() {
const slugs = Array.from({ length: 30 }, (_, i) => `probe-seed-slug-${String(i + 1).padStart(2, "0")}`);
const a1 = sampleDetailSlugs(slugs, 3, 20260918);
const a2 = sampleDetailSlugs(slugs, 3, 20260918);
const b = sampleDetailSlugs(slugs, 3, 20260919);
const ok =
a1.length === 3 &&
a1.every((s) => slugs.includes(s)) &&
JSON.stringify(a1) === JSON.stringify(a2) &&
JSON.stringify(a1) !== JSON.stringify(b);
if (ok) {
console.log(`determinism: ok — seed 20260918 → ${a1.join(", ")} (stable across calls); seed 20260919 → ${b.join(", ")} (different slice)`);
} else {
console.error(`determinism: FAILED — a1=${JSON.stringify(a1)} a2=${JSON.stringify(a2)} seed20260919=${JSON.stringify(b)}`);
}
return ok;
}
function probeFixtures() {
const groups = [
{ url: "test/fixtures/listing.html", kind: "listing", probes: LISTING_PROBES },
...DETAIL_FIXTURES.map((fx) => detailGroup(fx, "pinned")),
];
if (!determinismSelfCheck()) process.exit(3);
const probes = [];
const pages = [];
let driftCount = 0;
for (const group of groups) {
const p = resolve(root, group.url);
if (!existsSync(p)) {
console.error(`fixture missing: ${group.url} — skipped`);
continue;
}
const html = readFileSync(p, "utf8");
pages.push({ url: group.url, status: 200, ok: true });
const { rows, drifts } = probePage(group, html, true, "fixture");
probes.push(...rows);
driftCount += drifts;
}
const status = { at: new Date().toISOString(), mode: "fixture", pages, probes, driftCount, verdict: driftCount > 0 ? "drift" : "ok" };
render(status, groups);
process.exit(driftCount > 0 ? 1 : 0);
}
async function probeLive() {
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 awwwards-mcp/1.1 parser-probe";
const groups = [listingGroup(), ...PINNED_DETAIL_SLUGS.map((slug) => detailGroup(SITE_DETAIL_URL(slug), "pinned"))];
const indexSlugs = loadIndexSlugs();
const sampled = sampleDetailSlugs(indexSlugs.filter((s) => !PINNED_DETAIL_SLUGS.includes(s)));
if (indexSlugs.length === 0) {
console.error("local index empty/missing — pinned-only run (no sampled detail pages)");
} else {
console.error(`local index: ${indexSlugs.length} slugs — sampled detail pages: ${sampled.join(", ")}`);
}
for (const slug of sampled) groups.push(detailGroup(SITE_DETAIL_URL(slug), "sampled"));
// Every page fetched sequentially, ≥1s between ALL fetches (politeness,
// same as the client). No retries, ever.
const pages = {};
let fetchFailed = false;
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (i > 0) await new Promise((r) => setTimeout(r, 1000));
let page;
try {
const res = await fetch(group.url, { headers: { "user-agent": UA } });
const html = await res.text();
const blocked = res.status !== 200 || html.includes("Your Humanity") || html.includes("cf-chl");
page = { html, status: res.status, ok: !blocked };
} catch (err) {
// Network rejection (DNS, ECONNRESET, …): record as failed page, not a
// crash — an unhandled rejection would exit 1 and the workflow would
// misreport it as parser drift.
console.error(`fetch failed: ${group.url} — ${(err && err.cause && err.cause.code) || err.message}`);
page = { html: "", status: 0, ok: false };
}
pages[group.url] = page;
// Sampled fetch failures are inconclusive (rows note it); only listing +
// pinned failures push the run to fetch-fail.
if (!pages[group.url].ok && group.kind !== "sampled") fetchFailed = true;
}
const probes = [];
let driftCount = 0;
for (const group of groups) {
const page = pages[group.url];
const { rows, drifts } = probePage(group, page.html, page.ok, "live");
probes.push(...rows);
driftCount += drifts;
}
// per-anchor diff against the previous run's verdicts (cosmetic context)
let prev = null;
if (existsSync(STATUS_FILE)) {
try {
prev = JSON.parse(readFileSync(STATUS_FILE, "utf8"));
} catch {
prev = null;
}
}
for (const p of probes) {
const was = prev?.probes?.find((x) => x.name === p.name && x.url === p.url);
if (was && was.drift !== p.drift) {
p.note = `${p.note ? p.note + "; " : ""}since ${prev.at}: ${was.drift ? "was DRIFT" : "was ok"}`;
}
}
const pagesArr = groups.map((g) => {
const { status, ok } = pages[g.url];
return { url: g.url, status, ok };
});
const verdict = driftCount > 0 ? "drift" : fetchFailed ? "fetch-fail" : "ok";
const status = { at: new Date().toISOString(), mode: "live", pages: pagesArr, probes, driftCount, verdict };
mkdirSync(dirname(STATUS_FILE), { recursive: true });
writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2));
render(status, groups);
process.exit(verdict === "drift" ? 1 : verdict === "fetch-fail" ? 2 : 0);
}
function render(status, groups) {
const lines = [
`Parser-drift probe — ${status.at} (${status.mode})`,
`verdict: ${status.verdict.toUpperCase()}, drifted anchors: ${status.driftCount}/${status.probes.length}`,
"",
];
for (const p of status.pages) lines.push(` [${p.status}] ${p.url}${p.ok ? "" : " (blocked/challenge)"}`);
lines.push("");
for (const group of groups) {
const rows = status.probes.filter((p) => p.url === group.url);
if (!rows.length) continue;
const isDetail = group.kind !== "listing";
lines.push(isDetail && status.mode === "live" ? `${group.url} [${group.kind}]:` : `${group.url}:`);
const verdicts = rows.filter((r) => r.name === "parseElements[section blobs]" || r.name === "parseElements[media path scheme]");
const anchors = rows.filter((r) => !verdicts.includes(r));
if (anchors.some((r) => r.drift)) {
// something broke — show every anchor row for this page
for (const p of anchors) {
lines.push(` ${p.drift ? "✗ DRIFT" : "✓ ok "} ${p.name} (count ${p.count})${p.note ? ` — ${p.note}` : ""}`);
}
} else if (anchors.length > 0 && anchors.every((r) => r.note?.includes("inconclusive"))) {
lines.push(` ~ hold anchors ${anchors.length}/${anchors.length} — ${anchors[0].note}`);
} else {
const absent = anchors.filter((r) => r.count === 0).length;
lines.push(
absent > 0
? ` ✓ ok anchors ${anchors.length - absent}/${anchors.length} present, ${absent} absent (optional per-site)`
: ` ✓ ok anchors ${anchors.length}/${anchors.length} (min count ${Math.min(...anchors.map((r) => r.count))})`
);
}
for (const p of verdicts) {
const what = p.name === "parseElements[section blobs]" ? "blobs" : "element|external paths";
lines.push(` ${p.drift ? "✗ DRIFT" : "✓ ok "} ${p.name} (${what} ${p.count})${p.note ? ` — ${p.note}` : ""}`);
}
lines.push("");
}
console.log(lines.join("\n"));
}
if (process.argv.includes("--fixture")) probeFixtures();
else probeLive();