#!/usr/bin/env node // txtfirst: does this website answer a program? // // Zero dependencies, one file, Node 18+. It makes at most seven GET requests to one // origin, identifies itself, and honours robots.txt. It never writes anything. // // npx txtfirst check example.com // npx txtfirst check example.com --json // npx txtfirst check example.com --path /blog/some-post // // Two questions, in order. First: does the site answer a client that is not pretending // to be a browser at all. Most do not, and nothing else matters until that is fixed. // Second: does it meet txtfirst 0.1, which is R1 to R4 at https://txtfirst.com/spec "use strict"; const UA = "txtfirst-cli/0.1 (+https://txtfirst.com/probe; conformance check, run by a site owner)"; const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"; const TIMEOUT = 12000; const C = process.stdout.isTTY && !process.env.NO_COLOR ? { g: "\x1b[32m", r: "\x1b[31m", y: "\x1b[33m", d: "\x1b[2m", b: "\x1b[1m", x: "\x1b[0m" } : { g: "", r: "", y: "", d: "", b: "", x: "" }; async function get(url, { ua = UA, accept = "*/*" } = {}) { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), TIMEOUT); try { const res = await fetch(url, { headers: { "user-agent": ua, accept }, redirect: "follow", signal: ctl.signal, }); const body = await res.text(); return { ok: true, status: res.status, body, bytes: Buffer.byteLength(body), type: (res.headers.get("content-type") || "").toLowerCase(), link: res.headers.get("link") || "", vary: (res.headers.get("vary") || "").toLowerCase(), url: res.url, }; } catch (e) { return { ok: false, status: e.name === "AbortError" ? "timeout" : "error", body: "", bytes: 0, type: "", link: "", vary: "", error: String(e.message || e) }; } finally { clearTimeout(t); } } // Text left once the markup is gone. The same stripping the study uses, so the numbers // this prints can be compared with the ones published at txtfirst.com/measure. function textOf(html) { return html .replace(/<(script|style|noscript|svg|template|iframe)[^>]*>[\s\S]*?<\/\1>/gi, " ") .replace(//g, " ") .replace(/<[^>]+>/g, " ") .replace(/ /g, " ") .replace(/\s+/g, " ") .trim(); } // Enough of RFC 9309 to honour a Disallow aimed at this client. Not a full parser. function robotsAllows(txt, agent, path) { const groups = []; let cur = null; for (let line of txt.split(/\r?\n/)) { line = line.replace(/#.*/, "").trim(); const m = line.match(/^([a-z-]+)\s*:\s*(.*)$/i); if (!m) continue; const k = m[1].toLowerCase(), v = m[2].trim(); if (k === "user-agent") { if (!cur || cur.rules.length) { cur = { agents: [], rules: [] }; groups.push(cur); } cur.agents.push(v.toLowerCase()); } else if (cur && (k === "allow" || k === "disallow")) cur.rules.push([k, v]); } const pick = groups.find(g => g.agents.some(a => a !== "*" && agent.includes(a))) || groups.find(g => g.agents.includes("*")); if (!pick) return true; let best = null; for (const [k, v] of pick.rules) { if (v === "") { if (k === "disallow" && best === null) best = ["allow", ""]; continue; } if (path.startsWith(v) && (best === null || v.length > best[1].length)) best = [k, v]; } return best === null || best[0] === "allow"; } // What robots.txt lets us do, as one decision with a name, so it can be tested without // having to find a website willing to refuse us. function robotsVerdict(status, body, path) { if (status === 401 || status === 403) return "blocked"; if (status === 200 && !robotsAllows(body, "txtfirst-cli", path)) return "robots-disallow"; return "proceed"; } function normalise(input) { let s = String(input || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, ""); const slash = s.indexOf("/"); const host = (slash === -1 ? s : s.slice(0, slash)).toLowerCase(); const path = slash === -1 ? "" : s.slice(slash); if (!/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(host)) return null; return { host, path }; } async function run(target, opts) { const t = normalise(target); if (!t) { console.error("Give a public hostname, like example.com or example.com/blog/post"); process.exit(2); } const origin = "https://" + t.host; const path = opts.path || t.path || "/"; const out = { tool: "txtfirst-cli/0.1", spec: "0.1", host: t.host, path, checked: new Date().toISOString(), checks: [], notes: [] }; const add = (id, level, pass, title, detail) => out.checks.push({ id, level, pass, title, detail }); // Nothing is requested before robots.txt says it may be. const robots = await get(origin + "/robots.txt"); // A 403 on robots.txt is not the owner declining, it is the origin refusing to speak // to a program at all. Both mean stop, and reporting them as the same thing tells a // site owner they made a choice they did not make. They are separated here. const gate = robotsVerdict(robots.status, robots.body, path); if (gate === "blocked") { out.verdict = "blocked"; out.notes.push(`The request for /robots.txt was refused with ${robots.status}, so nothing else was requested. This is not a robots.txt preference: the origin will not answer a program at all. On Cloudflare it is usually Browser Integrity Check, which is on by default.`); return out; } if (gate === "robots-disallow") { out.verdict = "robots-disallow"; out.notes.push("robots.txt disallows this client, so nothing was requested and nothing was measured. This is a choice the site made and it is respected."); return out; } // Layer 1, access. A site that refuses a plain client fails before any format matters. const plain = await get(origin + path); const asBrowser = await get(origin + path, { ua: BROWSER_UA, accept: "text/html,*/*" }); const text = textOf(plain.body); const readable = plain.status === 200 && text.length >= 1500; add("A1", "access", plain.status === 200, "answers a plainly identified client", plain.status === 200 ? "HTTP 200" : `HTTP ${plain.status}` + (asBrowser.status === 200 ? ", while the same URL answers 200 to a browser string" : "")); add("A2", "access", readable, "the answer carries readable text", plain.status === 200 ? `${text.length} bytes of text inside ${plain.bytes} bytes` + (text.length ? `, ${(plain.bytes / text.length).toFixed(1)} to 1` : "") : "not measured, the request was refused"); if (plain.status !== 200 && asBrowser.status === 200) out.notes.push("This origin serves a browser string and refuses an honest one. On Cloudflare this is usually Browser Integrity Check, which is on by default."); // R2, the same URL in two shapes, from one source. const neg = await get(origin + path, { accept: "text/markdown" }); const negIsMd = neg.type.includes("text/markdown"); add("R2a", "spec", negIsMd, "Accept: text/markdown returns markdown", neg.status === 200 ? `HTTP 200, content-type ${neg.type || "(none)"}` : `HTTP ${neg.status}`); const mdPath = path === "/" ? "/index.md" : path.replace(/\/$/, "") + ".md"; const suffix = await get(origin + mdPath); const suffixIsMd = suffix.status === 200 && suffix.type.includes("text/markdown"); add("R2b", "spec", suffixIsMd, "the .md suffix returns the same source", `${mdPath} -> HTTP ${suffix.status}` + (suffix.type ? `, ${suffix.type}` : "")); const identical = negIsMd && suffixIsMd && neg.body === suffix.body && neg.body.length > 0; add("R2c", "spec", identical, "both routes return byte-identical bytes", negIsMd && suffixIsMd ? (identical ? `${neg.bytes} bytes both ways` : `${neg.bytes} vs ${suffix.bytes} bytes`) : "not comparable, one of the two routes did not return markdown"); const linkOk = /rel=("|')?alternate\1?/i.test(asBrowser.link) && /text\/markdown/i.test(asBrowser.link); add("R2d", "spec", linkOk, "a Link header points at the markdown", asBrowser.link ? asBrowser.link.slice(0, 120) : "no Link header"); add("R2e", "spec", asBrowser.vary.includes("accept"), "Vary: Accept, so caches keep the shapes apart", asBrowser.vary || "no Vary header"); const tagOk = /]+rel=["']?alternate["']?[^>]*type=["']text\/markdown/i.test(asBrowser.body) || /]+type=["']text\/markdown[^>]*rel=["']?alternate/i.test(asBrowser.body); add("R2f", "spec", tagOk, "the HTML declares its markdown alternate", tagOk ? " present" : "no alternate link element in the HTML"); // R3, discovery. A machine that has never seen this site must be able to find the map. const wk = await get(origin + "/.well-known/txtfirst.json"); let wkOk = false; try { wkOk = wk.status === 200 && !!JSON.parse(wk.body).txtfirst; } catch { wkOk = false; } add("R3a", "spec", wkOk, "/.well-known/txtfirst.json", wkOk ? "present and declares a version" : `HTTP ${wk.status}`); const llms = await get(origin + "/llms.txt"); add("R3b", "spec", llms.status === 200 && llms.bytes > 50, "/llms.txt", `HTTP ${llms.status}, ${llms.bytes} bytes`); const spec = out.checks.filter(c => c.level === "spec"); const access = out.checks.filter(c => c.level === "access"); out.score = { spec_passed: spec.filter(c => c.pass).length, spec_total: spec.length, access_passed: access.filter(c => c.pass).length, access_total: access.length }; out.verdict = !readable ? (plain.status === 200 ? "no content" : "blocked") : out.score.spec_passed === out.score.spec_total ? "conformant" : out.score.spec_passed === 0 ? "readable, not txtfirst" : "partial"; return out; } function report(r) { const mark = (p) => p ? `${C.g}pass${C.x}` : `${C.r}fail${C.x}`; console.log(`\n${C.b}${r.host}${r.path === "/" ? "" : r.path}${C.x} ${C.d}txtfirst 0.1 conformance${C.x}\n`); if (r.verdict === "robots-disallow" || (r.verdict === "blocked" && !r.checks.length)) { for (const n of r.notes) console.log(` ${C.y}${n}${C.x}`); console.log(`\n verdict: ${C.y}${r.verdict}${C.x}\n`); return r.verdict === "robots-disallow" ? 3 : 1; } console.log(` ${C.d}access, before any format matters${C.x}`); for (const c of r.checks.filter(c => c.level === "access")) console.log(` ${mark(c.pass)} ${c.title}\n ${C.d}${c.detail}${C.x}`); console.log(`\n ${C.d}txtfirst 0.1${C.x}`); for (const c of r.checks.filter(c => c.level === "spec")) console.log(` ${mark(c.pass)} ${c.id} ${c.title}\n ${C.d}${c.detail}${C.x}`); for (const n of r.notes) console.log(`\n ${C.y}note${C.x} ${n}`); const v = r.verdict === "conformant" ? `${C.g}${r.verdict}${C.x}` : `${C.y}${r.verdict}${C.x}`; console.log(`\n verdict: ${v} spec ${r.score.spec_passed}/${r.score.spec_total} access ${r.score.access_passed}/${r.score.access_total}`); console.log(` ${C.d}what each check means: https://txtfirst.com/spec${C.x}`); console.log(` ${C.d}how to implement it: https://txtfirst.com/implement${C.x}\n`); return r.verdict === "conformant" ? 0 : 1; } const HELP = `txtfirst 0.1 does this website answer a program? curl -s https://txtfirst.com/cli/index.js > txtfirst.js node txtfirst.js check check the home page node txtfirst.js check --path /p check one page node txtfirst.js check --json machine readable, for CI At most seven GET requests to one origin, identified as txtfirst-cli, after asking robots.txt. Nothing is written and nothing is sent anywhere. Exit codes 0 conformant 1 not conformant 2 bad input 3 not measured, because robots.txt disallows this client The spec is public domain: https://txtfirst.com/spec`; async function main() { const argv = process.argv.slice(2); const cmd = argv[0]; if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") { console.log(HELP); return 0; } if (cmd !== "check") { console.error(`Unknown command "${cmd}".\n\n${HELP}`); return 2; } const target = argv[1]; if (!target) { console.error(`Give a hostname.\n\n${HELP}`); return 2; } const opts = { json: argv.includes("--json") }; const pi = argv.indexOf("--path"); if (pi !== -1) opts.path = argv[pi + 1]; const r = await run(target, opts); // One table of exit codes for both modes. A site that refuses the checker must never // leave a deploy gate green: not measured is its own answer, not a pass. // 3 means not measured. A site that blocks the checker is measured and it failed, // so it is 1: only a robots.txt saying no leaves the question genuinely unanswered. const code = r.verdict === "robots-disallow" ? 3 : r.verdict === "conformant" ? 0 : 1; if (opts.json) { console.log(JSON.stringify({ ...r, exit_code: code }, null, 2)); return code; } report(r); return code; } main().then(c => process.exit(c)).catch(e => { console.error(String(e && e.message || e)); process.exit(2); });