sitetiller 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +24 -2
- package/bin/sitetiller.mjs +296 -0
- package/lib/agent.mjs +63 -0
- package/lib/backlinks.mjs +82 -0
- package/lib/citability.mjs +54 -0
- package/lib/crawl.mjs +154 -0
- package/lib/csv.mjs +37 -0
- package/lib/engines.mjs +100 -0
- package/lib/extras.mjs +101 -0
- package/lib/ghost.mjs +72 -0
- package/lib/github-publish.mjs +67 -0
- package/lib/google-auth.mjs +38 -0
- package/lib/gsc-analysis.mjs +75 -0
- package/lib/http.mjs +65 -0
- package/lib/images.mjs +68 -0
- package/lib/lighthouse.mjs +41 -0
- package/lib/links.mjs +76 -0
- package/lib/llms.mjs +43 -0
- package/lib/logs.mjs +115 -0
- package/lib/measure.mjs +38 -0
- package/lib/nap.mjs +61 -0
- package/lib/page.mjs +96 -0
- package/lib/perf.mjs +57 -0
- package/lib/providers.mjs +36 -0
- package/lib/quality.mjs +82 -0
- package/lib/robots.mjs +100 -0
- package/lib/schema-gen.mjs +45 -0
- package/lib/schema-lint.mjs +47 -0
- package/lib/sitemap.mjs +83 -0
- package/lib/tracker.mjs +89 -0
- package/lib/versions.mjs +93 -0
- package/lib/wordpress.mjs +57 -0
- package/package.json +36 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SiteTiller
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# sitetiller
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AI visibility and SEO checks from the command line. Zero dependencies, Node 18.17+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx sitetiller agent example.com # can AI assistants and agents read your page
|
|
7
|
+
npx sitetiller crawlers example.com # which search and AI crawlers robots.txt allows
|
|
8
|
+
npx sitetiller cite https://example.com/guide # how quotable each section is
|
|
9
|
+
npx sitetiller crawl example.com # polite site crawl with findings and a score
|
|
10
|
+
npx sitetiller logs access.log # real AI bot visits, verified against vendor IP lists
|
|
11
|
+
npx sitetiller help # all 41 commands
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Add `--json` to any command for machine-readable output.
|
|
15
|
+
|
|
16
|
+
This CLI powers the 44 open-source agent skills for Claude Code, Codex and Cursor at
|
|
17
|
+
[github.com/sitetiller/ai-visibility-skills](https://github.com/sitetiller/ai-visibility-skills), where every command,
|
|
18
|
+
its sources and real example outputs are documented.
|
|
19
|
+
|
|
20
|
+
Read-only by default: checks fetch public pages one at a time and respect robots.txt. Publishing commands (`wp`, `ghost`,
|
|
21
|
+
`github`) only ever edit pages this tool created.
|
|
22
|
+
|
|
23
|
+
Want it run every week, with new pages built and measured for you? See [SiteTiller](https://sitetiller.com/?utm_source=npm&utm_medium=readme).
|
|
24
|
+
|
|
25
|
+
MIT licensed.
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SiteTiller CLI: SEO and AI visibility checks. Read-only except `version snapshot` (writes ./.sitetiller/).
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { robotsReport, parseRobots, decide, BOTS } from "../lib/robots.mjs";
|
|
7
|
+
import { pageReport } from "../lib/page.mjs";
|
|
8
|
+
import { sitemapReport } from "../lib/sitemap.mjs";
|
|
9
|
+
import { llmsReport, buildLlms } from "../lib/llms.mjs";
|
|
10
|
+
import { linkReport, pagesReport } from "../lib/links.mjs";
|
|
11
|
+
import { gscRows, parseCsv, toCsv } from "../lib/csv.mjs";
|
|
12
|
+
import { ctrCurve, lowCtr, brandSplit, cannibalization, decay } from "../lib/gsc-analysis.mjs";
|
|
13
|
+
import { didTotals, didDaily } from "../lib/measure.mjs";
|
|
14
|
+
import { analyzeLogs } from "../lib/logs.mjs";
|
|
15
|
+
import { snapshot, list as listVersions, diff, drift, rollbackPlan } from "../lib/versions.mjs";
|
|
16
|
+
import { gscQuery, gscInspect, bingQueries, bingPages, compareEngines, ga4AiSessions } from "../lib/engines.mjs";
|
|
17
|
+
import { indexNow, cwv, strikingDistance, pseoGate } from "../lib/extras.mjs";
|
|
18
|
+
import { lintSchema } from "../lib/schema-lint.mjs";
|
|
19
|
+
import { citability } from "../lib/citability.mjs";
|
|
20
|
+
import { track } from "../lib/tracker.mjs";
|
|
21
|
+
import { wpCreate, wpUpdate, wpUnpublish, readLedger } from "../lib/wordpress.mjs";
|
|
22
|
+
import { get, target } from "../lib/http.mjs";
|
|
23
|
+
import { crawlSite } from "../lib/crawl.mjs";
|
|
24
|
+
import { ghostCreate, ghostUpdate, ghostUnpublish } from "../lib/ghost.mjs";
|
|
25
|
+
import { githubPut, githubDelete } from "../lib/github-publish.mjs";
|
|
26
|
+
import { imageAudit, imageSizes } from "../lib/images.mjs";
|
|
27
|
+
import { contentQuality } from "../lib/quality.mjs";
|
|
28
|
+
import { generateSchema } from "../lib/schema-gen.mjs";
|
|
29
|
+
import { cruxHistory } from "../lib/perf.mjs";
|
|
30
|
+
import { gscLinksCsv, anchorMix, concentration, bingLinkCounts, bingUrlLinks } from "../lib/backlinks.mjs";
|
|
31
|
+
import { lighthouseBatch } from "../lib/lighthouse.mjs";
|
|
32
|
+
import { napReport } from "../lib/nap.mjs";
|
|
33
|
+
import { providerStatus, dataforseoSummary, ahrefsDomainRating } from "../lib/providers.mjs";
|
|
34
|
+
import { agentReport } from "../lib/agent.mjs";
|
|
35
|
+
import { readdir } from "node:fs/promises";
|
|
36
|
+
import { join, extname } from "node:path";
|
|
37
|
+
|
|
38
|
+
const VERSION = "1.0.0";
|
|
39
|
+
const HELP = `sitetiller ${VERSION}: SEO and AI visibility checks
|
|
40
|
+
|
|
41
|
+
Site checks (public pages only):
|
|
42
|
+
crawlers <url> [--agents A,B] Which search/AI crawlers robots.txt allows, by purpose
|
|
43
|
+
robots-test <robots.txt> <path...> [--agents A,B] Test a local robots.txt draft
|
|
44
|
+
page <url> What a crawler sees: status chain, title, meta, robots, canonical, schema
|
|
45
|
+
sitemap <url> [--sample 20] Find and validate the sitemap, test a spread of listed URLs
|
|
46
|
+
llms <url> Validate /llms.txt against llmstxt.org and test its links
|
|
47
|
+
pages <url> [--pages 20] Titles, descriptions, H1, schema, noindex across sitemap pages
|
|
48
|
+
links <url> [--pages 50] Orphan and weakly linked pages (sitemap vs internal links)
|
|
49
|
+
crawl <url> [--max 50] [--delay 1000] Polite same-site crawl: findings per page, site score with the arithmetic
|
|
50
|
+
images <url> [--sizes 20] Image alt, dimensions, lazy LCP image, formats, srcset, file names (+ byte sizes)
|
|
51
|
+
quality <url> Readability, author/date/sources/first-hand signals, filler phrases (checklist)
|
|
52
|
+
schema-gen <type> [--name ... --url ...] JSON-LD for organization|website|article|product|software|localbusiness|breadcrumb|event|person, linted
|
|
53
|
+
agent <url> Agent readiness: crawler access, Content-Signal, server text, landmarks, accessible names, llms.txt
|
|
54
|
+
lighthouse <url...> [--file urls.txt] [--strategy mobile|desktop] Lab scores for many pages via PageSpeed Insights, one at a time
|
|
55
|
+
nap <url...> --name X --phone Y [--address Z] Name/address/phone consistency across your pages and listings you name
|
|
56
|
+
|
|
57
|
+
Search Console / Bing / GA4 (your credentials, read-only):
|
|
58
|
+
gsc <site> --start YYYY-MM-DD --end YYYY-MM-DD [--dims query,page] [--out file.csv]
|
|
59
|
+
inspect <site> <url> URL Inspection API result
|
|
60
|
+
bing <siteUrl> [--pages] [--start --end]
|
|
61
|
+
engines <site> <bingSiteUrl> --start --end Google vs Bing by query: only-in-one, position gaps
|
|
62
|
+
ga4-ai <propertyId> --start --end Sessions from AI assistants by landing page
|
|
63
|
+
|
|
64
|
+
cite <url> Section-by-section quotability signals (answer first, numbers, tables, sources)
|
|
65
|
+
schema <url> JSON-LD lint: Google-required properties, removed features, duplicates
|
|
66
|
+
cwv <url> [--origin] [--form PHONE|DESKTOP] Core Web Vitals p75 from CrUX (CRUX_API_KEY)
|
|
67
|
+
crux-history <url> [--origin] [--form PHONE] [--periods 25] Weekly CWV trend + LCP subparts (CrUX History API)
|
|
68
|
+
backlinks <links.csv> [--brand a,b] | backlinks --bing <siteUrl> [--page <url>] Search Console links export or Bing links
|
|
69
|
+
indexnow <url...> [--file urls.txt] Submit changed URLs to IndexNow (INDEXNOW_KEY, key file on site)
|
|
70
|
+
gate <folder> pSEO quality gate on .html/.md drafts: thin, duplicates, sources, AI tells
|
|
71
|
+
|
|
72
|
+
AI answers (your own API keys; each call is billed by the provider):
|
|
73
|
+
track --prompts prompts.txt --brand "Acme,acme.com" --domain acme.com [--competitors "X,Y"] [--engines openai,anthropic,perplexity] [--runs 1] [--yes]
|
|
74
|
+
Ask assistants with web search; log mentions, citations, cited domains (.sitetiller/track/)
|
|
75
|
+
|
|
76
|
+
Optional paid providers (your own keys; each call is billed by the provider):
|
|
77
|
+
provider status | provider dataforseo <domain> --yes | provider ahrefs <domain> --yes
|
|
78
|
+
|
|
79
|
+
Exports and logs (local files):
|
|
80
|
+
ctr <Queries.csv|Pages.csv> Your own CTR curve and under-clicked rows
|
|
81
|
+
striking <Queries.csv> [--min-imp 100] Positions 4-20 with real impressions, ranked
|
|
82
|
+
brand <Queries.csv> --terms a,b Branded vs non-branded split + Search Console regex
|
|
83
|
+
cannibal <query-page.csv> Queries split across several of your URLs
|
|
84
|
+
decay <before.csv> <after.csv> Pages losing clicks, with likely reason
|
|
85
|
+
logs <access.log...> [--no-verify] AI and search bot hits, verified against published IP ranges
|
|
86
|
+
measure --changed-before N --changed-after N [--control-before N --control-after N]
|
|
87
|
+
measure --daily file.csv (columns: group,period,clicks with group=changed|control, period=before|after)
|
|
88
|
+
|
|
89
|
+
Publish pages you own (ledger in ./.sitetiller/owned.json; existing pages are never edited):
|
|
90
|
+
wp create|update|unpublish|owned <https://site> [--type posts|pages] [--title --content-file --slug --status --id] (WP_USER, WP_APP_PASSWORD)
|
|
91
|
+
ghost create|update|unpublish <https://admin-site> [--type posts|pages] [--title --html-file --slug --meta-title --meta-description --canonical --id] (GHOST_ADMIN_API_KEY)
|
|
92
|
+
github put|delete <owner/repo> --dir content/sitetiller --file x.md [--content-file local.md] [--branch main] (GITHUB_TOKEN)
|
|
93
|
+
|
|
94
|
+
Versions of pages you own (stored in ./.sitetiller/):
|
|
95
|
+
version snapshot <url> [--label x] | list <url> | diff <url> [a b] | drift <url> | rollback <url> [--to n]
|
|
96
|
+
|
|
97
|
+
Other:
|
|
98
|
+
llms-build --name X --summary Y --pages pages.csv (columns: section,url,title,note)
|
|
99
|
+
|
|
100
|
+
Add --json to any command for machine-readable output. Docs: https://github.com/sitetiller/ai-visibility-skills`;
|
|
101
|
+
|
|
102
|
+
function args(argv) {
|
|
103
|
+
const pos = []; const opt = {};
|
|
104
|
+
for (let i = 0; i < argv.length; i++) {
|
|
105
|
+
const a = argv[i];
|
|
106
|
+
if (a.startsWith("--")) { const k = a.slice(2); const n = argv[i + 1]; if (n === undefined || n.startsWith("--")) opt[k] = true; else { opt[k] = n; i++; } }
|
|
107
|
+
else pos.push(a);
|
|
108
|
+
}
|
|
109
|
+
return { pos, opt };
|
|
110
|
+
}
|
|
111
|
+
const list = (v) => (typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined);
|
|
112
|
+
const pct = (x, d = 1) => (x == null ? "-" : `${(x * 100).toFixed(d)}%`);
|
|
113
|
+
const n0 = (x) => (x == null ? "-" : Math.round(x).toLocaleString("en-US"));
|
|
114
|
+
function table(head, rows) {
|
|
115
|
+
const w = head.map((h, i) => Math.max(String(h).length, ...rows.map((r) => String(r[i] ?? "").length)));
|
|
116
|
+
const line = (r) => "| " + r.map((c, i) => String(c ?? "").padEnd(w[i])).join(" | ") + " |";
|
|
117
|
+
return [line(head), "|" + w.map((x) => "-".repeat(x + 2)).join("|") + "|", ...rows.map(line)].join("\n");
|
|
118
|
+
}
|
|
119
|
+
async function fetchPage(raw) { const t = target(raw); return get(`${t.origin}${t.path}`, { ms: 10000, maxBytes: 3_000_000 }); }
|
|
120
|
+
async function readLines(files) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const f of files) { const rl = createInterface({ input: createReadStream(f), crlfDelay: Infinity }); for await (const l of rl) out.push(l); }
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const PRINT = {
|
|
127
|
+
crawlers: (r) => [`robots.txt: ${r.url} (HTTP ${r.status}) path: ${r.path}`, "",
|
|
128
|
+
table(["Crawler", "Owner", "Purpose", "Result", "Rule"], r.bots.map((b) => [b.agent, b.owner, b.purpose, b.allowed ? "allowed" : "BLOCKED", b.rule ? `${b.rule} (line ${b.line}, group ${b.group})` : "no matching rule"])),
|
|
129
|
+
...(r.contentSignals?.length ? ["", "Content-Signal: " + r.contentSignals.map((c) => `${Object.entries(c.signals).map(([k, v]) => `${k}=${v}`).join(", ")} (line ${c.line}, ${c.agents.join(", ")})`).join("; ")] : []),
|
|
130
|
+
...(r.notes.length ? ["", ...r.notes] : []), "", "Note: firewall/CDN bot rules are not visible here; check them separately."].join("\n"),
|
|
131
|
+
page: (r) => [`${r.requested} -> ${r.finalUrl} (HTTP ${r.status})`, r.chain.length > 1 ? "Redirects: " + r.chain.map((c) => `${c.status} ${c.url}`).join(" -> ") : "",
|
|
132
|
+
table(["Field", "Value"], [["Title", `${r.title} (${(r.title || "").length})`], ["Description", `${r.description} (${(r.description || "").length})`], ["H1", (r.h1 || []).join(" | ")], ["Canonical", r.canonical], ["Robots meta", (r.robotsMeta || []).join(", ") || "-"], ["X-Robots-Tag", r.xRobots || "-"], ["Noindex", r.noindex ? "YES" : "no"], ["robots.txt (Googlebot)", r.robotsTxtGooglebot ? (r.robotsTxtGooglebot.allowed ? "allowed" : `BLOCKED by ${r.robotsTxtGooglebot.rule}`) : "-"], ["Schema types", (r.schemaTypes || []).join(", ") || "-"], ["Words / tables / question headings", `${r.words} / ${r.tables} / ${r.questionHeadings}`]])].filter(Boolean).join("\n"),
|
|
133
|
+
sitemap: (r) => r.found ? [`Sitemap: ${r.sitemapUrl} (${r.kind}, ${n0(r.urlCount)} URLs, in robots.txt: ${r.inRobots ? "yes" : "no"})`, "", ...(r.issues.length ? ["Issues:", ...r.issues.map((i) => "- " + i), ""] : ["No issues found.", ""]),
|
|
134
|
+
table(["Sample URL", "Status", "Noindex", "Redirects to"], r.sample.map((s) => [s.url, s.status, s.noindex ? "YES" : "", s.redirectedTo]))].join("\n") : ["No sitemap found. Tried:", ...r.tried.map((t) => `- ${t.url} (${t.status}, ${t.kind})`)].join("\n"),
|
|
135
|
+
llms: (r) => r.found ? [`${r.url} (${r.contentType}, ${r.bytes} bytes)`, "", table(["Check", "Result"], r.checks.map((c) => [c.label, c.ok ? "pass" : "FIX"])), "", table(["Link", "Status"], r.links.map((l) => [l.url, l.status || "no answer"]))].join("\n") : `${r.url}: ${r.note}`,
|
|
136
|
+
logs: (r) => [`${r.parsed} of ${r.lines} lines parsed.`, "", table(["Bot", "Owner", "Purpose", "Hits", "Verified", "Spoofed", "Unverifiable", "IPs", "Top path"], r.bots.map((b) => [b.name, b.owner, b.purpose, b.hits, b.verified, b.spoofed, b.unverifiable, b.ips, b.paths[0]?.path || ""])),
|
|
137
|
+
"", "Visits referred by AI assistants:", r.aiReferrals.length ? table(["Source", "Visits", "Top landing page"], r.aiReferrals.map((x) => [x.source, x.visits, x.paths[0]?.path || ""])) : "(none)",
|
|
138
|
+
"", "Unverifiable = the owner publishes no IP list (Anthropic, Apple, Amazon, Meta and others); the user agent alone can be faked."].join("\n"),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const TIP_CMDS = new Set(["crawl", "crawlers", "page", "agent", "cite", "quality", "images", "schema", "sitemap", "llms", "pages", "links"]);
|
|
142
|
+
|
|
143
|
+
async function main() {
|
|
144
|
+
const { pos, opt } = args(process.argv.slice(2));
|
|
145
|
+
const cmd = pos.shift();
|
|
146
|
+
if (!cmd || cmd === "help" || opt.help) { console.log(HELP); return; }
|
|
147
|
+
if (cmd === "--version" || opt.version) { console.log(VERSION); return; }
|
|
148
|
+
let out;
|
|
149
|
+
switch (cmd) {
|
|
150
|
+
case "crawlers": out = await robotsReport(pos[0], { agents: list(opt.agents) }); break;
|
|
151
|
+
case "robots-test": {
|
|
152
|
+
const txt = await readFile(pos[0], "utf8"); const g = parseRobots(txt).groups;
|
|
153
|
+
const agents = list(opt.agents) || BOTS.map((b) => b.agent);
|
|
154
|
+
out = { tests: pos.slice(1).flatMap((p) => agents.map((a) => ({ path: p.replace(/^https?:\/\/[^/]+/i, "") || "/", agent: a, ...decide(g, a, p.replace(/^https?:\/\/[^/]+/i, "") || "/") }))) };
|
|
155
|
+
if (!opt.json) { console.log(table(["Path", "Agent", "Result", "Rule"], out.tests.map((t) => [t.path, t.agent, t.allowed ? "allowed" : "BLOCKED", t.rule ? `${t.rule} (line ${t.line})` : "no matching rule"]))); return; }
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "page": out = await pageReport(pos[0]); if (opt.json) delete out.text; break;
|
|
159
|
+
case "sitemap": out = await sitemapReport(pos[0], { sample: Number(opt.sample) || 20 }); break;
|
|
160
|
+
case "llms": out = await llmsReport(pos[0]); break;
|
|
161
|
+
case "pages": out = await pagesReport(pos[0], { pages: Number(opt.pages) || 20 }); break;
|
|
162
|
+
case "links": out = await linkReport(pos[0], { pages: Number(opt.pages) || 50 }); break;
|
|
163
|
+
case "gsc": {
|
|
164
|
+
out = await gscQuery(pos[0], { start: opt.start, end: opt.end, dimensions: list(opt.dims) || ["query"], keyFile: opt.key });
|
|
165
|
+
if (opt.out) { await writeFile(opt.out, toCsv(out)); console.error(`Wrote ${out.length} rows to ${opt.out}`); return; }
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "inspect": out = await gscInspect(pos[0], pos[1], { keyFile: opt.key }); break;
|
|
169
|
+
case "bing": out = opt.pages ? await bingPages(pos[0], { start: opt.start, end: opt.end }) : await bingQueries(pos[0], { start: opt.start, end: opt.end }); break;
|
|
170
|
+
case "engines": {
|
|
171
|
+
const g = await gscQuery(pos[0], { start: opt.start, end: opt.end, dimensions: ["query"], keyFile: opt.key });
|
|
172
|
+
const b = await bingQueries(pos[1], { start: opt.start, end: opt.end });
|
|
173
|
+
out = compareEngines(g, b); if (!opt.json) { const t = out.totals; console.log(`Google: ${n0(t.googleClicks)} clicks / ${n0(t.googleImpressions)} impressions. Bing: ${n0(t.bingClicks)} / ${n0(t.bingImpressions)}.\n\nOnly in Bing (top 15):\n` + table(["Query", "Bing impr.", "Bing pos."], out.onlyBing.slice(0, 15).map((r) => [r.key, r.bingImpressions, r.bingPosition?.toFixed(1)])) + "\n\nOnly in Google (top 15):\n" + table(["Query", "Google impr.", "Google pos."], out.onlyGoogle.slice(0, 15).map((r) => [r.key, r.googleImpressions, r.googlePosition?.toFixed(1)]))); return; }
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "cite": { const r = await fetchPage(pos[0]); out = { url: r.url, status: r.status, ...citability(r.text) }; break; }
|
|
177
|
+
case "schema": { const r = await fetchPage(pos[0]); out = { url: r.url, status: r.status, ...lintSchema(r.text) }; break; }
|
|
178
|
+
case "crawl": out = await crawlSite(pos[0], { max: Number(opt.max) || 50, delayMs: opt.delay != null ? Number(opt.delay) : 1000 }); break;
|
|
179
|
+
case "images": { const r = await fetchPage(pos[0]); out = { url: r.url, status: r.status, ...imageAudit(r.text, { url: r.url }) }; if (opt.sizes) out.sizes = await imageSizes(out.list, { n: Number(opt.sizes) || 20 }); break; }
|
|
180
|
+
case "quality": { const r = await fetchPage(pos[0]); out = { status: r.status, ...contentQuality(r.text, { url: r.url }) }; break; }
|
|
181
|
+
case "schema-gen": { const { json, ...data } = opt; out = generateSchema(pos[0], data); if (!opt.json) { console.log(out.html + "\n\n" + (out.lint.findings.length ? table(["Level", "Type", "Message"], out.lint.findings.map((f) => [f.level, f.type, f.message])) : "Lint: no findings.") + (out.notes.length ? "\n\n" + out.notes.join("\n") : "")); return; } break; }
|
|
182
|
+
case "agent": out = await agentReport(pos[0]); break;
|
|
183
|
+
case "lighthouse": { const urls = opt.file ? (await readFile(opt.file, "utf8")).split(/\r?\n/).map((x) => x.trim()).filter(Boolean) : pos; out = await lighthouseBatch(urls, { strategy: opt.strategy === "desktop" ? "desktop" : "mobile", delayMs: opt.delay != null ? Number(opt.delay) : 2000 }); break; }
|
|
184
|
+
case "nap": out = await napReport(pos, { name: opt.name, address: opt.address, phone: opt.phone }); break;
|
|
185
|
+
case "provider": {
|
|
186
|
+
const sub = pos.shift();
|
|
187
|
+
if (sub === "status" || !sub) { out = providerStatus(); break; }
|
|
188
|
+
if (!opt.yes) { console.log(`This calls ${sub}'s paid API once with your key and is billed by them. Re-run with --yes to proceed.`); return; }
|
|
189
|
+
if (sub === "dataforseo") out = await dataforseoSummary(pos[0]);
|
|
190
|
+
else if (sub === "ahrefs") out = await ahrefsDomainRating(pos[0], { date: opt.date });
|
|
191
|
+
else throw new Error("provider needs: status | dataforseo | ahrefs");
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case "crux-history": out = await cruxHistory(pos[0], { origin: !!opt.origin, formFactor: (opt.form || "PHONE").toUpperCase(), periods: opt.periods }); break;
|
|
195
|
+
case "backlinks": {
|
|
196
|
+
if (opt.bing) { out = opt.page ? await bingUrlLinks(opt.bing, opt.page) : { pages: await bingLinkCounts(opt.bing) }; if (opt.page) out.anchors = anchorMix(out.links.map((l) => l.anchor), { brand: list(opt.brand) || [] }); break; }
|
|
197
|
+
const c = gscLinksCsv(await readFile(pos[0], "utf8"));
|
|
198
|
+
out = c.kind === "linking-sites" ? { kind: c.kind, ...concentration(c.rows) } : c.kind === "anchors" ? { kind: c.kind, ...anchorMix(c.rows.map((r) => r.anchor), { brand: list(opt.brand) || [] }) } : { kind: c.kind, pages: c.rows.sort((a, b) => b.links - a.links).slice(0, 100) };
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case "cwv": out = await cwv(pos[0], { origin: !!opt.origin, formFactor: (opt.form || "PHONE").toUpperCase() }); break;
|
|
202
|
+
case "indexnow": { const urls = opt.file ? (await readFile(opt.file, "utf8")).split(/\r?\n/).map((x) => x.trim()).filter(Boolean) : pos; out = await indexNow(urls); break; }
|
|
203
|
+
case "striking": out = strikingDistance(gscRows(await readFile(pos[0], "utf8")), { minImp: Number(opt["min-imp"]) || 100 }).slice(0, 100); break;
|
|
204
|
+
case "gate": {
|
|
205
|
+
const files = (await readdir(pos[0])).filter((f) => [".html", ".md"].includes(extname(f)));
|
|
206
|
+
const pages = [];
|
|
207
|
+
for (const f of files) {
|
|
208
|
+
const body = await readFile(join(pos[0], f), "utf8");
|
|
209
|
+
const title = (body.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || body.match(/^#\s+(.+)$/m) || [])[1]?.trim();
|
|
210
|
+
const description = (body.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) || body.match(/^description:\s*"?(.+?)"?\s*$/m) || [])[1];
|
|
211
|
+
const sources = new Set([...body.matchAll(/https?:\/\/[^\s"')<>\]]+/g)].map((m) => m[0])).size;
|
|
212
|
+
pages.push({ id: f, title, description, body, sources });
|
|
213
|
+
}
|
|
214
|
+
out = pseoGate(pages, { minWords: Number(opt["min-words"]) || 600 });
|
|
215
|
+
if (!opt.json) { console.log(out.passed ? `Gate passed for ${out.pages} page(s).` : table(["Page", "Rule", "Detail"], out.errors.map((e) => [e.id, e.rule, e.detail]))); if (!out.passed) process.exitCode = 1; return; }
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case "track": {
|
|
219
|
+
const prompts = (await readFile(opt.prompts, "utf8")).split(/\r?\n/).map((x) => x.trim()).filter((x) => x && !x.startsWith("#"));
|
|
220
|
+
const engines = list(opt.engines) || ["openai", "anthropic", "perplexity"];
|
|
221
|
+
const runs = Number(opt.runs) || 1;
|
|
222
|
+
const calls = prompts.length * engines.length * runs;
|
|
223
|
+
if (!opt.yes) { console.log(`This will make up to ${calls} API calls (${prompts.length} prompts x ${engines.length} engines x ${runs} runs), billed to your API accounts. Re-run with --yes to proceed.`); return; }
|
|
224
|
+
out = await track(prompts, { engines, runs, brand: list(opt.brand) || [], domain: opt.domain || "", competitors: list(opt.competitors) || [] });
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "wp": {
|
|
228
|
+
const sub = pos.shift(); const site = pos[0]; const type = opt.type || "posts";
|
|
229
|
+
const content = opt["content-file"] ? await readFile(opt["content-file"], "utf8") : undefined;
|
|
230
|
+
if (sub === "create") out = await wpCreate(site, { type, title: opt.title, content, excerpt: opt.excerpt, slug: opt.slug, status: opt.status || "draft" });
|
|
231
|
+
else if (sub === "update") out = await wpUpdate(site, { type, id: opt.id, title: opt.title, content, excerpt: opt.excerpt, slug: opt.slug, status: opt.status });
|
|
232
|
+
else if (sub === "unpublish") out = await wpUnpublish(site, { type, id: opt.id });
|
|
233
|
+
else if (sub === "owned") out = await readLedger();
|
|
234
|
+
else throw new Error("wp needs: create | update | unpublish | owned");
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case "ghost": {
|
|
238
|
+
const sub = pos.shift(); const site = pos[0]; const type = opt.type || "posts";
|
|
239
|
+
const fields = { title: opt.title, html: opt["html-file"] ? await readFile(opt["html-file"], "utf8") : undefined, slug: opt.slug, status: opt.status, custom_excerpt: opt.excerpt, meta_title: opt["meta-title"], meta_description: opt["meta-description"], canonical_url: opt.canonical, og_image: opt["og-image"], codeinjection_head: opt["head-file"] ? await readFile(opt["head-file"], "utf8") : undefined };
|
|
240
|
+
if (sub === "create") out = await ghostCreate(site, { type, ...fields });
|
|
241
|
+
else if (sub === "update") out = await ghostUpdate(site, { type, id: opt.id, ...fields });
|
|
242
|
+
else if (sub === "unpublish") out = await ghostUnpublish(site, { type, id: opt.id });
|
|
243
|
+
else throw new Error("ghost needs: create | update | unpublish");
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case "github": {
|
|
247
|
+
const sub = pos.shift();
|
|
248
|
+
if (sub === "put") out = await githubPut(pos[0], { dir: opt.dir, file: opt.file, content: await readFile(opt["content-file"], "utf8"), message: opt.message }, { branch: opt.branch });
|
|
249
|
+
else if (sub === "delete") out = await githubDelete(pos[0], { dir: opt.dir, file: opt.file, message: opt.message }, { branch: opt.branch });
|
|
250
|
+
else throw new Error("github needs: put | delete");
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case "ga4-ai": out = await ga4AiSessions(pos[0], { start: opt.start, end: opt.end, keyFile: opt.key }); break;
|
|
254
|
+
case "ctr": {
|
|
255
|
+
const rows = gscRows(await readFile(pos[0], "utf8")); const curve = ctrCurve(rows);
|
|
256
|
+
out = { curve, lowCtr: lowCtr(rows, { curve, minImp: Number(opt["min-imp"]) || 100 }).slice(0, 50) };
|
|
257
|
+
if (!opt.json) { console.log("Your CTR by position (median):\n" + table(["Position", "CTR", "Rows"], Object.entries(curve).map(([p, c]) => [p, pct(c.ctr, 2), c.rows])) + "\n\nUnder-clicked:\n" + table(["Row", "Pos", "Impr.", "CTR", "Your median", "Missed clicks"], out.lowCtr.slice(0, 25).map((r) => [r.key, r.position?.toFixed(1), n0(r.impressions), pct(r.ctr, 2), pct(r.expectedCtr, 2), "+" + n0(r.missedClicks)]))); return; }
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case "brand": out = brandSplit(gscRows(await readFile(pos[0], "utf8")), list(opt.terms) || []); break;
|
|
261
|
+
case "cannibal": out = cannibalization(gscRows(await readFile(pos[0], "utf8")), { minImp: Number(opt["min-imp"]) || 50 }); break;
|
|
262
|
+
case "decay": out = decay(gscRows(await readFile(pos[0], "utf8")), gscRows(await readFile(pos[1], "utf8")), { minClicks: Number(opt["min-clicks"]) || 20, drop: (Number(opt.drop) || 30) / 100 }); break;
|
|
263
|
+
case "logs": out = await analyzeLogs(await readLines(pos), { verify: !opt["no-verify"] }); break;
|
|
264
|
+
case "measure": {
|
|
265
|
+
if (opt.daily) {
|
|
266
|
+
const rows = parseCsv(await readFile(opt.daily, "utf8")); const hd = rows[0].map((h) => h.toLowerCase());
|
|
267
|
+
const g = {}; for (const r of rows.slice(1)) { const k = `${r[hd.indexOf("group")]}-${r[hd.indexOf("period")]}`; (g[k] = g[k] || []).push(Number(r[hd.indexOf("clicks")])); }
|
|
268
|
+
out = didDaily({ changedBefore: g["changed-before"], changedAfter: g["changed-after"], controlBefore: g["control-before"], controlAfter: g["control-after"] });
|
|
269
|
+
} else out = didTotals({ changedBefore: Number(opt["changed-before"]), changedAfter: Number(opt["changed-after"]), controlBefore: Number(opt["control-before"]), controlAfter: Number(opt["control-after"]) });
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "version": {
|
|
273
|
+
const sub = pos.shift(); const url = pos[0];
|
|
274
|
+
if (sub === "snapshot") out = await snapshot(url, { label: opt.label || "" });
|
|
275
|
+
else if (sub === "list") out = (await listVersions(url)).map((v) => ({ n: v.n, at: v.at, label: v.label, title: v.fingerprint.title, words: v.fingerprint.words }));
|
|
276
|
+
else if (sub === "diff") { const v = await listVersions(url); const a = v.find((x) => x.n === Number(pos[1])) || v[v.length - 2]; const b = v.find((x) => x.n === Number(pos[2])) || v[v.length - 1]; out = a && b ? { from: a.n, to: b.n, changes: diff(a.fingerprint, b.fingerprint) } : { note: "Need two versions." }; }
|
|
277
|
+
else if (sub === "drift") out = await drift(url);
|
|
278
|
+
else if (sub === "rollback") { out = await rollbackPlan(url, { to: opt.to }); if (opt.json) delete out.text; }
|
|
279
|
+
else throw new Error("version needs: snapshot | list | diff | drift | rollback");
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case "llms-build": {
|
|
283
|
+
const rows = parseCsv(await readFile(opt.pages, "utf8")); const hd = rows[0].map((h) => h.toLowerCase().trim());
|
|
284
|
+
const pages = rows.slice(1).map((r) => Object.fromEntries(hd.map((h, i) => [h, r[i]])));
|
|
285
|
+
process.stdout.write(buildLlms({ name: opt.name, summary: opt.summary || "", pages })); return;
|
|
286
|
+
}
|
|
287
|
+
default: console.error(`Unknown command: ${cmd}\n`); console.log(HELP); process.exitCode = 2; return;
|
|
288
|
+
}
|
|
289
|
+
if (!opt.json && PRINT[cmd]) console.log(PRINT[cmd](out));
|
|
290
|
+
else console.log(JSON.stringify(out, null, 2));
|
|
291
|
+
// One line on stderr after human-readable output of site checks; never with --json, never in pipes' stdout.
|
|
292
|
+
if (!opt.json && TIP_CMDS.has(cmd) && !process.env.SITETILLER_NO_TIPS) console.error(`
|
|
293
|
+
Tip: SiteTiller runs these checks every week, builds the pages that fix them and measures each change: https://sitetiller.com/?utm_source=cli&utm_medium=${cmd} (hide: SITETILLER_NO_TIPS=1)`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
main().catch((e) => { console.error(`Error: ${e.message}`); process.exitCode = 1; });
|
package/lib/agent.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Agent readiness: can AI assistants and browsing agents fetch, read and act on a page without running JavaScript?
|
|
2
|
+
// Combines crawler access (robots.txt, Content-Signal), server-rendered text, semantic landmarks, accessible names
|
|
3
|
+
// (what an accessibility tree, which agents use, would expose), structured data and llms.txt. Deterministic checks.
|
|
4
|
+
import { get, target } from "./http.mjs";
|
|
5
|
+
import { analyzeHtml } from "./page.mjs";
|
|
6
|
+
import { robotsReport } from "./robots.mjs";
|
|
7
|
+
import { imageAudit } from "./images.mjs";
|
|
8
|
+
|
|
9
|
+
const attr = (tag, n) => { const m = tag.match(new RegExp(`\\s${n}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i")); return m ? (m[1] ?? m[2] ?? "") : null; };
|
|
10
|
+
const strip = (s) => s.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
|
11
|
+
|
|
12
|
+
/** Pure: page-level checks from server HTML. */
|
|
13
|
+
export function agentChecks(html, { url = "" } = {}) {
|
|
14
|
+
const h = String(html);
|
|
15
|
+
const body = (h.match(/<body[\s\S]*$/i) || [h])[0];
|
|
16
|
+
const f = analyzeHtml(h, { url, host: (() => { try { return new URL(url).hostname; } catch { return ""; } })() });
|
|
17
|
+
const scripts = (h.match(/<script\b(?![^>]*application\/ld\+json)[^>]*>/gi) || []).length;
|
|
18
|
+
const appShell = /<div[^>]+id\s*=\s*["'](root|app|__next|__nuxt|svelte)["'][^>]*>\s*<\/div>/i.test(body);
|
|
19
|
+
// Skip hidden, button-like and honeypot fields (tabindex=-1 keeps them out of keyboard and assistive navigation).
|
|
20
|
+
const inputs = [...body.matchAll(/<(input|select|textarea)\b[^>]*>/gi)].map((m) => m[0]).filter((t) => !/type\s*=\s*["']?(hidden|submit|button|image|reset)/i.test(t) && attr(t, "tabindex") !== "-1");
|
|
21
|
+
const wrapped = [...body.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/gi)].map((m) => m[1]).join("\n");
|
|
22
|
+
const labelled = inputs.filter((t) => { const id = attr(t, "id"); if (wrapped.includes(t)) return true; return attr(t, "aria-label") || attr(t, "aria-labelledby") || attr(t, "title") || (id && new RegExp(`<label[^>]+for\\s*=\\s*["']${id.replace(/[^\w-]/g, "")}["']`, "i").test(body)); }).length;
|
|
23
|
+
const buttons = [...body.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/gi)];
|
|
24
|
+
const namedButtons = buttons.filter((m) => strip(m[2]) || /aria-label\s*=|title\s*=/.test(m[1])).length;
|
|
25
|
+
const links = [...body.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)];
|
|
26
|
+
const emptyLinks = links.filter((m) => !strip(m[2]) && !/aria-label\s*=|title\s*=/.test(m[1]) && !/<img[^>]+alt\s*=\s*["'][^"']+/i.test(m[2])).length;
|
|
27
|
+
const fakeLinks = (body.match(/<(div|span)\b[^>]*onclick\s*=/gi) || []).length;
|
|
28
|
+
const img = imageAudit(h, { url });
|
|
29
|
+
const checks = [
|
|
30
|
+
["Main text is in the server HTML (150+ words)", f.words >= 150, `${f.words} words without running JavaScript${appShell ? "; empty app-shell root found" : ""}`],
|
|
31
|
+
["One H1 and a heading outline", f.h1.length === 1 && f.headings.length >= 2, `${f.h1.length} H1, ${f.headings.length} H2-H4`],
|
|
32
|
+
["Landmarks: <main> and <nav>", /<main\b/i.test(body) && /<nav\b/i.test(body), ""],
|
|
33
|
+
["<html lang> set", !!f.lang, f.lang || "missing"],
|
|
34
|
+
["Form fields have labels", inputs.length === labelled, `${labelled}/${inputs.length}`],
|
|
35
|
+
["Buttons have accessible names", buttons.length === namedButtons, `${namedButtons}/${buttons.length}`],
|
|
36
|
+
["No empty links", emptyLinks === 0, `${emptyLinks} empty`],
|
|
37
|
+
["Clickable things are links or buttons (no onclick divs)", fakeLinks === 0, `${fakeLinks} onclick div/span`],
|
|
38
|
+
["Images have alt attributes", !(img.issueCounts["alt-missing"] > 0), `${img.issueCounts["alt-missing"] || 0} missing`],
|
|
39
|
+
["Structured data present and valid", f.schemaTypes.length > 0 && f.schemaBlocks.every((b) => b.valid), f.schemaTypes.join(", ") || "none"],
|
|
40
|
+
["Not noindex", !f.noindex, ""],
|
|
41
|
+
].map(([check, pass, detail]) => ({ check, pass: !!pass, detail }));
|
|
42
|
+
return { words: f.words, scripts, appShell, checks, passed: checks.filter((c) => c.pass).length, of: checks.length };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function agentReport(raw) {
|
|
46
|
+
const t = target(raw);
|
|
47
|
+
const url = `${t.origin}${t.path}`;
|
|
48
|
+
const r = await get(url, { ms: 10000, maxBytes: 3_000_000 });
|
|
49
|
+
const page = agentChecks(r.text, { url: r.url });
|
|
50
|
+
const robots = await robotsReport(raw);
|
|
51
|
+
const userBots = robots.bots.filter((b) => b.purpose === "search" || b.purpose === "user");
|
|
52
|
+
let llms = { status: 0 };
|
|
53
|
+
try { const l = await get(`${t.origin}/llms.txt`, { ms: 6000, maxBytes: 300_000 }); llms = { status: l.status, found: l.status === 200 && /^#\s/m.test(l.text) }; } catch { /* none */ }
|
|
54
|
+
const access = [
|
|
55
|
+
{ check: "AI search and user-triggered agents allowed by robots.txt", pass: userBots.every((b) => b.allowed), detail: userBots.filter((b) => !b.allowed).map((b) => b.agent).join(", ") || "all allowed" },
|
|
56
|
+
{ check: "Content-Signal preferences stated (optional)", pass: robots.contentSignals.length > 0, detail: robots.contentSignals.map((c) => Object.entries(c.signals).map(([k, v]) => `${k}=${v}`).join(", ")).join(" | ") || "none", optional: true },
|
|
57
|
+
{ check: "/llms.txt present (optional)", pass: !!llms.found, detail: `HTTP ${llms.status}`, optional: true },
|
|
58
|
+
];
|
|
59
|
+
const all = [...access, ...page.checks];
|
|
60
|
+
const required = all.filter((c) => !c.optional);
|
|
61
|
+
return { url: r.url, status: r.status, ...page, access, passed: required.filter((c) => c.pass).length, of: required.length,
|
|
62
|
+
note: "Checks are proxies: semantic HTML and accessible names help any client that reads the page without a screen. Firewall or CDN bot rules can still block agents and are not visible here." };
|
|
63
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Backlinks from first-party data only: Search Console "Links" exports (CSV) and the Bing Webmaster API
|
|
2
|
+
// (GetLinkCounts, GetUrlLinks). No third-party index is needed; paid indexes (Ahrefs, Moz, DataForSEO) are optional
|
|
3
|
+
// and out of scope for this file. Docs (checked 2026-11-13):
|
|
4
|
+
// https://learn.microsoft.com/en-us/dotnet/api/microsoft.bing.webmaster.api.interfaces.iwebmasterapi.getlinkcounts
|
|
5
|
+
// https://learn.microsoft.com/en-us/dotnet/api/microsoft.bing.webmaster.api.interfaces.iwebmasterapi.geturllinks
|
|
6
|
+
import { num, parseCsv } from "./csv.mjs";
|
|
7
|
+
import { bingCall } from "./engines.mjs";
|
|
8
|
+
import { sleep } from "./http.mjs";
|
|
9
|
+
|
|
10
|
+
/** Detect which Search Console links export a CSV is and return normalised rows. */
|
|
11
|
+
export function gscLinksCsv(text) {
|
|
12
|
+
const rows = parseCsv(text);
|
|
13
|
+
if (rows.length < 2) return { kind: "empty", rows: [] };
|
|
14
|
+
const hd = rows[0].map((h) => h.toLowerCase().trim());
|
|
15
|
+
const has = (re) => hd.findIndex((h) => re.test(h));
|
|
16
|
+
const body = rows.slice(1);
|
|
17
|
+
if (has(/^(site|linking site|top linking sites?)$/) >= 0 || (hd[0] === "site" && has(/linking pages/) >= 0)) {
|
|
18
|
+
const iP = has(/linking pages/), iT = has(/target pages/);
|
|
19
|
+
return { kind: "linking-sites", rows: body.map((r) => ({ site: r[0], linkingPages: num(r[iP]), targetPages: iT >= 0 ? num(r[iT]) : null })) };
|
|
20
|
+
}
|
|
21
|
+
if (has(/^(target page|top linked pages?|page)$/) >= 0 && has(/incoming links|links/) >= 0) {
|
|
22
|
+
const iL = has(/incoming links|^links$/), iS = has(/linking sites/);
|
|
23
|
+
return { kind: "linked-pages", rows: body.map((r) => ({ page: r[0], links: num(r[iL]), linkingSites: iS >= 0 ? num(r[iS]) : null })) };
|
|
24
|
+
}
|
|
25
|
+
if (has(/linking text|anchor/) >= 0) return { kind: "anchors", rows: body.map((r) => ({ anchor: r[0] })) };
|
|
26
|
+
throw new Error("Unrecognised CSV. Export one of Search Console > Links: Top linking sites, Top linked pages or Top linking text.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SPAM = /\b(casino|bet(ting)?|poker|slots?|viagra|cialis|porn|xxx|escort|payday|loans?|replica|essay writing|crypto (signals|pump))\b/i;
|
|
30
|
+
|
|
31
|
+
/** Anchor mix: brand, URL, generic, other; plus anchors that warrant a manual look. */
|
|
32
|
+
export function anchorMix(anchors, { brand = [] } = {}) {
|
|
33
|
+
const b = brand.map((x) => x.toLowerCase()).filter(Boolean);
|
|
34
|
+
const kind = (a) => {
|
|
35
|
+
const s = a.toLowerCase().trim();
|
|
36
|
+
if (!s) return "empty";
|
|
37
|
+
if (/^(https?:\/\/|www\.)|\.[a-z]{2,}(\/|$)/.test(s)) return "url";
|
|
38
|
+
if (b.some((x) => s.includes(x))) return "brand";
|
|
39
|
+
if (/^(click here|here|this|link|read more|learn more|website|visit|source|more|this site|this article|view|see more)$/.test(s)) return "generic";
|
|
40
|
+
return "other";
|
|
41
|
+
};
|
|
42
|
+
const counts = { brand: 0, url: 0, generic: 0, other: 0, empty: 0 };
|
|
43
|
+
for (const a of anchors) counts[kind(a)]++;
|
|
44
|
+
const n = anchors.length || 1;
|
|
45
|
+
const shares = Object.fromEntries(Object.entries(counts).map(([k, v]) => [k, Math.round((v / n) * 100) / 100]));
|
|
46
|
+
return { total: anchors.length, counts, shares, review: anchors.filter((a) => SPAM.test(a)).slice(0, 50) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Concentration of linking sites: share of all linking pages that the top site and top 10 sites hold. */
|
|
50
|
+
export function concentration(sites) {
|
|
51
|
+
const s = [...sites].sort((a, b) => b.linkingPages - a.linkingPages);
|
|
52
|
+
const total = s.reduce((n, x) => n + x.linkingPages, 0) || 1;
|
|
53
|
+
const top10 = s.slice(0, 10).reduce((n, x) => n + x.linkingPages, 0);
|
|
54
|
+
return { linkingSites: s.length, linkingPages: total, topSite: s[0] || null, topSiteShare: Math.round(((s[0]?.linkingPages || 0) / total) * 100) / 100,
|
|
55
|
+
top10Share: Math.round((top10 / total) * 100) / 100, review: s.filter((x) => SPAM.test(x.site)).slice(0, 50) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Bing: pages on your site with inbound links and their counts (all result pages, capped). */
|
|
59
|
+
export async function bingLinkCounts(siteUrl, { apiKey, maxPages = 20, delayMs = 500, call = bingCall } = {}) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let page = 0; page < maxPages; page++) {
|
|
62
|
+
const d = await call("GetLinkCounts", { siteUrl, page: String(page) }, { apiKey });
|
|
63
|
+
for (const l of d?.Links || []) out.push({ url: l.Url, count: Number(l.Count) || 0 });
|
|
64
|
+
if (page + 1 >= Number(d?.TotalPages || 0)) break;
|
|
65
|
+
if (delayMs) await sleep(delayMs);
|
|
66
|
+
}
|
|
67
|
+
return out.sort((a, b) => b.count - a.count);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Bing: linking URLs and anchor texts for one page of your site. */
|
|
71
|
+
export async function bingUrlLinks(siteUrl, link, { apiKey, maxPages = 10, delayMs = 500, call = bingCall } = {}) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (let page = 0; page < maxPages; page++) {
|
|
74
|
+
const d = await call("GetUrlLinks", { siteUrl, link, page: String(page) }, { apiKey });
|
|
75
|
+
for (const l of d?.Details || []) out.push({ from: l.Url, anchor: l.AnchorText || "" });
|
|
76
|
+
if (page + 1 >= Number(d?.TotalPages || 0)) break;
|
|
77
|
+
if (delayMs) await sleep(delayMs);
|
|
78
|
+
}
|
|
79
|
+
const domains = {};
|
|
80
|
+
for (const l of out) { try { const h = new URL(l.from).hostname.replace(/^www\./, ""); domains[h] = (domains[h] || 0) + 1; } catch { /* skip */ } }
|
|
81
|
+
return { link, links: out, linkingDomains: Object.entries(domains).sort((a, b) => b[1] - a[1]).map(([domain, links]) => ({ domain, links })) };
|
|
82
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Section-by-section signals for how easy a page is to quote in an answer. Deterministic: counts what is on the page.
|
|
2
|
+
// It does not predict citations. The agent (skill) adds judgement about whether answers are correct and specific.
|
|
3
|
+
// Signals follow published guidance: Microsoft's AI Performance post asks for clear headings, tables, FAQ sections,
|
|
4
|
+
// claims supported with examples, data and sources; Google's helpful-content guidance asks for original information.
|
|
5
|
+
|
|
6
|
+
// A sentence ends at . ! or ? followed by whitespace and a capital letter, digit or quote, or at the end of text.
|
|
7
|
+
// Dots inside tokens (llms.txt, e.g., 3.5, example.com) do not end a sentence.
|
|
8
|
+
const sentences = (t) => String(t).split(/(?<=[.!?])\s+(?=["'(\[]?[A-Z0-9])/).map((s) => s.trim()).filter(Boolean);
|
|
9
|
+
|
|
10
|
+
/** html -> [{heading, level, words, firstSentence, firstSentenceWords, numbers, hasList, hasTable, links, question}] */
|
|
11
|
+
export function sections(html) {
|
|
12
|
+
const body = String((html.match(/<body[\s\S]*$/i) || [html])[0])
|
|
13
|
+
.replace(/<(script|style|noscript|svg|nav|footer|header|aside|form)\b[\s\S]*?<\/\1>/gi, " ");
|
|
14
|
+
const parts = body.split(/(?=<h[1-4]\b)/i);
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const p of parts) {
|
|
17
|
+
const hm = p.match(/^<h([1-4])\b[^>]*>([\s\S]*?)<\/h\1>/i);
|
|
18
|
+
const heading = hm ? hm[2].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() : "(intro)";
|
|
19
|
+
const rest = hm ? p.slice(hm[0].length) : p;
|
|
20
|
+
const text = rest.replace(/<[^>]+>/g, " ").replace(/ | /g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim();
|
|
21
|
+
if (!text) continue;
|
|
22
|
+
const s = sentences(text);
|
|
23
|
+
out.push({
|
|
24
|
+
heading, level: hm ? Number(hm[1]) : 0,
|
|
25
|
+
words: text.split(" ").length,
|
|
26
|
+
firstSentence: s[0]?.slice(0, 220) || "",
|
|
27
|
+
firstSentenceWords: (s[0] || "").split(/\s+/).filter(Boolean).length,
|
|
28
|
+
numbers: (text.match(/\b\d[\d,.]*\s?(%|percent|\$|€|£|ms|s|days?|hours?|months?|years?|x)?/gi) || []).length,
|
|
29
|
+
hasList: /<(ul|ol)\b/i.test(rest), hasTable: /<table\b/i.test(rest),
|
|
30
|
+
links: (rest.match(/<a\b[^>]*href\s*=\s*["']https?:/gi) || []).length,
|
|
31
|
+
question: /\?\s*$/.test(heading),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Page-level summary with the share of sections that answer up front and carry specifics. */
|
|
38
|
+
export function citability(html) {
|
|
39
|
+
const secs = sections(html).filter((s) => s.words >= 25);
|
|
40
|
+
const n = secs.length || 1;
|
|
41
|
+
const share = (f) => secs.filter(f).length / n;
|
|
42
|
+
return {
|
|
43
|
+
sections: secs,
|
|
44
|
+
summary: {
|
|
45
|
+
sectionCount: secs.length,
|
|
46
|
+
questionHeadings: secs.filter((s) => s.question).length,
|
|
47
|
+
answerFirstShare: share((s) => s.firstSentenceWords > 0 && s.firstSentenceWords <= 30),
|
|
48
|
+
specificShare: share((s) => s.numbers > 0),
|
|
49
|
+
structuredShare: share((s) => s.hasList || s.hasTable),
|
|
50
|
+
sourcedShare: share((s) => s.links > 0),
|
|
51
|
+
longSections: secs.filter((s) => s.words > 400).map((s) => s.heading),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|