Skip to content
SlashClone

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.

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

Cookies that keep you signed in and remember these choices are always on. Everything else is off until you turn it on.