Get found in search and see where sign-ups come from
@slashclone/website-traffic
Get your web app found in Google and AI search, and see which channels bring sign-ups. Sets up sitemap, robots, canonical URLs, share images, and structured data with a page checker, then consent-aware first-touch tracking from UTM tags and referral links, saved at sign-up. Includes a launch channel checklist. Engineering guidance, not legal advice.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Checks saved HTML pages (and an optional sitemap.xml and robots.txt next to them)
- 3// for the discoverability basics: titles, canonical URLs, noindex, share images,
- 4// and JSON-LD structured data. Node standard library only. No network.
- 5//
- 6// Usage:
- 7// node scripts/page-check.mjs --pages examples/site --out out/page-check.json [--report-only]
- 8//
- 9// Exit code: 0 when there are no problems (or with --report-only), 1 otherwise.
- 11import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
- 12import { join, relative, dirname } from "node:path";
- 14function args(argv) {
- 15 const out = { pages: "", out: "out/page-check.json", reportOnly: false };
- 16 for (let i = 0; i < argv.length; i++) {
- 17 const a = argv[i];
- 18 if (a === "--pages") out.pages = argv[++i] ?? "";
- 19 else if (a === "--out") out.out = argv[++i] ?? out.out;
- 20 else if (a === "--report-only") out.reportOnly = true;
- 21 else throw new Error(`Unknown argument: ${a}`);
- 22 }
- 23 if (!out.pages) throw new Error("Pass --pages <folder of saved .html files>");
- 24 return out;
- 25}
- 27function htmlFiles(dir) {
- 28 const found = [];
- 29 for (const name of readdirSync(dir)) {
- 30 const full = join(dir, name);
- 31 if (statSync(full).isDirectory()) found.push(...htmlFiles(full));
- 32 else if (name.endsWith(".html")) found.push(full);
- 33 }
- 34 return found.sort();
- 35}
- 37const decode = (s) =>
- 38 s
- 39 .replace(/</g, "<")
- 40 .replace(/>/g, ">")
- 41 .replace(/"/g, '"')
- 42 .replace(/'/g, "'")
- 43 .replace(/&/g, "&");
- 45function attr(tag, name) {
- 46 const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i"));
- 47 return m ? decode(m[2] ?? m[3] ?? "") : undefined;
- 48}
- 50function tags(html, tagName) {
- 51 return html.match(new RegExp(`<${tagName}\\b[^>]*>`, "gi")) ?? [];
- 52}
- 54function readPage(html) {
- 55 const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
- 56 const title = titleMatch ? decode(titleMatch[1]).trim() : "";
- 57 const canonical = tags(html, "link")
- 58 .filter((t) => (attr(t, "rel") ?? "").toLowerCase().split(/\s+/).includes("canonical"))
- 59 .map((t) => attr(t, "href"))[0];
- 60 const robots = tags(html, "meta")
- 61 .filter((t) => (attr(t, "name") ?? "").toLowerCase() === "robots")
- 62 .map((t) => (attr(t, "content") ?? "").toLowerCase())
- 63 .join(",");
- 64 const ogImage = tags(html, "meta")
- 65 .filter((t) => (attr(t, "property") ?? "").toLowerCase() === "og:image")
- 66 .map((t) => attr(t, "content"))[0];
- 67 const ldBlocks = [
- 68 ...html.matchAll(
- 69 /<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
- 70 ),
- 71 ].map((m) => m[1]);
- 72 return { title, canonical, noindex: /\bnoindex\b/.test(robots), ogImage, ldBlocks };
- 73}
- 75// Flatten JSON-LD: arrays, @graph, and nested objects that carry an @type.
- 76function nodes(value, acc = []) {
- 77 if (Array.isArray(value)) value.forEach((v) => nodes(v, acc));
- 78 else if (value && typeof value === "object") {
- 79 if (value["@type"]) acc.push(value);
- 80 for (const [k, v] of Object.entries(value)) if (k !== "@context") nodes(v, acc);
- 81 }
- 82 return acc;
- 83}
- 85const typesOf = (node) => [].concat(node["@type"]).map(String);
- 86const hasMarkup = (s) => /<[a-z!/][^>]*>/i.test(s);
- 87const isAbsoluteHttps = (u) => {
- 88 try {
- 89 return new URL(u).protocol === "https:";
- 90 } catch {
- 91 return false;
- 92 }
- 93};
- 95function checkLd(node, problem) {
- 96 const types = typesOf(node);
- 97 if (types.includes("FAQPage")) {
- 98 const qs = [].concat(node.mainEntity ?? []);
- 99 if (qs.length === 0) problem("FAQPage has no questions");
- 100 qs.forEach((q, i) => {
- 101 const text = q?.acceptedAnswer?.text;
- 102 if (!q?.name) problem(`FAQPage question ${i + 1} has no name`);
- 103 if (typeof text !== "string" || !text.trim())
- 104 problem(`FAQPage question ${i + 1} has no answer text`);
- 105 else if (hasMarkup(text)) problem(`FAQPage answer ${i + 1} contains markup; use plain text`);
- 106 });
- 107 }
- 108 if (types.some((t) => t === "Product" || t === "SoftwareApplication")) {
- 109 const offers = [].concat(node.offers ?? []);
- 110 if (offers.length === 0)
- 111 problem(`${types.join("/")} has no offers; state the real price (0 for free)`);
- 112 offers.forEach((o, i) => {
- 113 if (o?.price === undefined || o?.price === "" || Number.isNaN(Number(o.price)))
- 114 problem(`${types.join("/")} offer ${i + 1} has no numeric price`);
- 115 if (!o?.priceCurrency) problem(`${types.join("/")} offer ${i + 1} has no priceCurrency`);
- 116 });
- 117 }
- 118 if (types.includes("ItemList")) {
- 119 const items = [].concat(node.itemListElement ?? []);
- 120 if (items.length === 0) problem("ItemList is empty");
- 121 items.forEach((it, i) => {
- 122 if (typeof it?.position !== "number")
- 123 problem(`ItemList item ${i + 1} has no numeric position`);
- 124 if (!it?.url && !it?.item) problem(`ItemList item ${i + 1} has no url or item`);
- 125 });
- 126 }
- 127}
- 129function sitemapUrls(dir) {
- 130 const file = join(dir, "sitemap.xml");
- 131 if (!existsSync(file)) return null;
- 132 const xml = readFileSync(file, "utf8");
- 133 return [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => decode(m[1]));
- 134}
- 136function main() {
- 137 const opts = args(process.argv.slice(2));
- 138 const problems = [];
- 139 const pages = [];
- 140 const sitemap = sitemapUrls(opts.pages);
- 141 const robotsFile = join(opts.pages, "robots.txt");
- 142 const robots = existsSync(robotsFile) ? readFileSync(robotsFile, "utf8") : null;
- 144 if (robots !== null && !/^\s*sitemap:\s*https:\/\//im.test(robots))
- 145 problems.push({ file: "robots.txt", problem: "robots.txt has no absolute https Sitemap line" });
- 146 if (sitemap) {
- 147 for (const u of sitemap)
- 148 if (!isAbsoluteHttps(u))
- 149 problems.push({ file: "sitemap.xml", problem: `not an absolute https URL: ${u}` });
- 150 }
- 152 const titles = new Map();
- 153 for (const file of htmlFiles(opts.pages)) {
- 154 const rel = relative(opts.pages, file);
- 155 const page = readPage(readFileSync(file, "utf8"));
- 156 const problem = (p) => problems.push({ file: rel, problem: p });
- 157 const ldTypes = [];
- 159 if (!page.title) problem("no <title>");
- 160 if (!page.noindex) {
- 161 if (page.title) titles.set(page.title, [...(titles.get(page.title) ?? []), rel]);
- 162 if (!page.canonical) problem("indexable page has no canonical link");
- 163 else if (!isAbsoluteHttps(page.canonical)) problem("canonical is not an absolute https URL");
- 164 if (!page.ogImage) problem("indexable page has no og:image share image");
- 165 if (sitemap && page.canonical && !sitemap.includes(page.canonical))
- 166 problem("indexable page is missing from sitemap.xml");
- 167 } else if (sitemap && page.canonical && sitemap.includes(page.canonical)) {
- 168 problem("noindex page is listed in sitemap.xml");
- 169 }
- 171 for (const block of page.ldBlocks) {
- 172 let data;
- 173 try {
- 174 data = JSON.parse(block);
- 175 } catch {
- 176 problem("JSON-LD block is not valid JSON");
- 177 continue;
- 178 }
- 179 for (const node of nodes(data)) {
- 180 ldTypes.push(...typesOf(node));
- 181 checkLd(node, problem);
- 182 }
- 183 }
- 184 pages.push({
- 185 file: rel,
- 186 title: page.title,
- 187 indexable: !page.noindex,
- 188 canonical: page.canonical ?? null,
- 189 jsonLd: ldTypes,
- 190 });
- 191 }
- 193 for (const [title, files] of titles)
- 194 if (files.length > 1)
- 195 problems.push({ file: files.join(", "), problem: `duplicate title: ${title}` });
- 197 const report = {
- 198 problems,
- 199 pages,
- 200 sitemap: sitemap ? { urls: sitemap.length } : null,
- 201 robots: robots !== null,
- 202 };
- 203 mkdirSync(dirname(opts.out), { recursive: true });
- 204 writeFileSync(opts.out, JSON.stringify(report, null, 2) + "\n");
- 205 for (const p of problems) console.log(`problem: ${p.file}: ${p.problem}`);
- 206 console.log(`${pages.length} pages checked, ${problems.length} problems. Report: ${opts.out}`);
- 207 if (problems.length && !opts.reportOnly) process.exit(1);
- 208}
- 210try {
- 211 main();
- 212} catch (err) {
- 213 console.error(err instanceof Error ? err.message : String(err));
- 214 process.exit(2);
- 215}
