@nurkamol/seo-audit 1.31.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.
@@ -0,0 +1,100 @@
1
+ // The same page, asked for twice by two different readers.
2
+ //
3
+ // A site that serves one thing to Googlebot and another to a browser is either
4
+ // cloaking or misconfiguring its bot protection, and both are invisible to an
5
+ // audit that fetches once. This asks a sample of pages again as somebody else
6
+ // and reports what changed.
7
+ //
8
+ // Deliberately not a byte comparison. A nonce, a timestamp, a cart count and a
9
+ // session id all differ between two fetches of the same page by the same
10
+ // client, and reporting those would drown the one case that matters. What is
11
+ // compared is what a search engine reads: the status, the title, the canonical,
12
+ // the indexing directives, and how much content and how many links there are.
13
+ import { parseHtml } from './parse.mjs';
14
+ import { Fetcher, mapLimit } from './http.mjs';
15
+
16
+ const f = (level, id, title, detail, url) => ({ level, id, title, detail, url });
17
+
18
+ /** Roughly, since a byte count would fire on a timestamp. Two pages within a
19
+ * tenth of each other are the same page as far as this is concerned. */
20
+ const materiallyDifferent = (a, b) => Math.abs(a - b) > Math.max(a, b) * 0.1;
21
+
22
+ /** What one reader was given that the other was not. */
23
+ function differences(mine, theirs) {
24
+ const out = [];
25
+ if (mine.status !== theirs.status) out.push(`HTTP ${mine.status} became HTTP ${theirs.status}`);
26
+ if (!mine.doc || !theirs.doc) return out;
27
+
28
+ if ((mine.doc.title ?? '') !== (theirs.doc.title ?? '')) {
29
+ out.push(`the title changed from "${mine.doc.title ?? '(none)'}" to "${theirs.doc.title ?? '(none)'}"`);
30
+ }
31
+ const canonical = (d) => d.canonical?.[0] ?? '(none)';
32
+ if (canonical(mine.doc) !== canonical(theirs.doc)) {
33
+ out.push(`the canonical changed from ${canonical(mine.doc)} to ${canonical(theirs.doc)}`);
34
+ }
35
+ const robots = (d) => (d.robots ?? '(none)').toLowerCase();
36
+ if (robots(mine.doc) !== robots(theirs.doc)) {
37
+ out.push(`the robots meta changed from "${robots(mine.doc)}" to "${robots(theirs.doc)}"`);
38
+ }
39
+ if (materiallyDifferent(mine.doc.words, theirs.doc.words)) {
40
+ out.push(`the page went from ${mine.doc.words} words to ${theirs.doc.words}`);
41
+ }
42
+ const links = (d) => d.links.internal.length;
43
+ if (materiallyDifferent(links(mine.doc), links(theirs.doc))) {
44
+ out.push(`the internal links went from ${links(mine.doc)} to ${links(theirs.doc)}`);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /** Re-fetch a sample of crawled pages as somebody else.
50
+ *
51
+ * A sample rather than the whole crawl, because this doubles the request cost
52
+ * of every page it looks at, and because a site that cloaks does it to the
53
+ * template rather than to page 400 alone. The report says how many were
54
+ * compared so a sample never reads as a clean bill of health for the site. */
55
+ export async function compareAgents(pages, { agent, label, sample = 10, onProgress } = {}) {
56
+ const live = pages.filter((p) => p.doc && p.res.ok);
57
+ if (!live.length || !agent) return [];
58
+
59
+ // Spread across the crawl rather than taking the first ten, which on a
60
+ // sitemap ordered by date would be ten pages of one month.
61
+ const step = Math.max(1, Math.floor(live.length / sample));
62
+ const chosen = live.filter((_, i) => i % step === 0).slice(0, sample);
63
+
64
+ const other = new Fetcher({ concurrency: 3, userAgent: agent });
65
+ const results = await mapLimit(chosen, 3, async (page) => {
66
+ const res = await other.get(page.url);
67
+ onProgress?.({ phase: 'compare', status: res.status, ms: res.ms, url: page.url });
68
+ const isHtml = /text\/html/i.test(res.headers.get('content-type') ?? '');
69
+ return {
70
+ page,
71
+ theirs: { status: res.status, doc: res.ok && isHtml ? parseHtml(res.body, page.url) : null },
72
+ };
73
+ });
74
+
75
+ const out = [];
76
+ const changed = [];
77
+ for (const { page, theirs } of results) {
78
+ const diffs = differences({ status: page.res.status, doc: page.doc }, theirs);
79
+ if (diffs.length) changed.push({ url: page.url, diffs });
80
+ }
81
+
82
+ for (const { url, diffs } of changed.slice(0, 5)) {
83
+ out.push(f('warn', 'serves-differently', `This page is different when ${label} asks for it`,
84
+ `${diffs.join('; ')}. A page that changes with the reader is either cloaking or bot protection ` +
85
+ 'misfiring, and Google indexes the version it was given, not the one a person sees.', url));
86
+ }
87
+
88
+ out.push(f('info', 'compare-sampled',
89
+ changed.length
90
+ ? `${changed.length} of ${chosen.length} sampled pages differ when ${label} asks`
91
+ : `${chosen.length} pages were identical when ${label} asked`,
92
+ changed.length
93
+ ? `Compared ${chosen.length} pages spread across the crawl, not all ${live.length}, because this ` +
94
+ 'doubles the cost of every page it looks at. The rest were not compared.'
95
+ : `Compared ${chosen.length} pages spread across the crawl. Status, title, canonical, robots meta, ` +
96
+ 'word count and link count all matched, which is what a site that is not cloaking looks like.',
97
+ live[0].url));
98
+
99
+ return out;
100
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,156 @@
1
+ // Configuration: what this site has decided to live with.
2
+ //
3
+ // Every real site has findings that are true and deliberate — a contact page
4
+ // is meant to be short, a privacy policy has no business carrying editorial
5
+ // links. Without a way to say so, the report fills with noise nobody reads,
6
+ // and the one new finding that matters is lost in it.
7
+ //
8
+ // Looked for in the working directory, or passed with --config:
9
+ //
10
+ // seo-audit.config.json
11
+ // {
12
+ // "limit": 200,
13
+ // "failOn": "error",
14
+ // "ignore": [
15
+ // "img-srcset", // everywhere
16
+ // { "id": "thin-content", "urls": ["/contact/", "/*/legal/**"] },
17
+ // { "id": "no-editorial-links", "urls": ["**/privacy-policy/"] }
18
+ // ],
19
+ // "expect": [
20
+ // { "urls": ["/journal/*/"], "types": ["BlogPosting"] },
21
+ // { "urls": ["/"], "types": ["LocalBusiness"] }
22
+ // ]
23
+ // }
24
+ import { readFileSync, existsSync } from 'node:fs';
25
+
26
+ const FILENAMES = ['seo-audit.config.json', '.seo-audit.json'];
27
+
28
+ /** Glob over URL paths: `*` stops at a slash, `**` does not. */
29
+ export function matchGlob(pattern, path) {
30
+ const rx = pattern
31
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
32
+ // A placeholder while `*` is translated, written as an escape rather than
33
+ // as the byte itself: a raw NUL in the source makes this file binary to
34
+ // git and invisible to grep, which is how it went unnoticed that the glob
35
+ // matcher lived here at all.
36
+ .replace(/\*\*/g, '\u0000')
37
+ .replace(/\*/g, '[^/]*')
38
+ .replace(/\u0000/g, '.*');
39
+ return new RegExp(`^${rx}$`).test(path);
40
+ }
41
+
42
+ /** A rule matches a finding when the id matches and, if the rule names URLs,
43
+ * one of them matches the finding's path. */
44
+ function ruleMatches(rule, finding) {
45
+ const id = typeof rule === 'string' ? rule : rule.id;
46
+ if (id !== finding.id) return false;
47
+ const urls = typeof rule === 'string' ? null : rule.urls;
48
+ if (!urls?.length) return true;
49
+ if (!finding.url) return false;
50
+ let path;
51
+ try {
52
+ path = new URL(finding.url).pathname;
53
+ } catch {
54
+ path = finding.url;
55
+ }
56
+ return urls.some((u) => matchGlob(u, path));
57
+ }
58
+
59
+ export function loadConfig(explicitPath) {
60
+ const path = explicitPath ?? FILENAMES.find((f) => existsSync(f));
61
+ if (!path) return { source: null };
62
+ if (!existsSync(path)) throw new Error(`Config not found: ${path}`);
63
+ try {
64
+ return { ...JSON.parse(readFileSync(path, 'utf8')), source: path };
65
+ } catch (err) {
66
+ throw new Error(`Config is not valid JSON (${path}): ${err.message}`);
67
+ }
68
+ }
69
+
70
+ /** The sites to audit, each with whatever it overrides.
71
+ *
72
+ * A portfolio is not a list of interchangeable sites: one has a deliberately
73
+ * short contact page, another has no journal to expect BlogPosting on. So a
74
+ * `sites` entry may be a bare URL or an object carrying its own settings,
75
+ * which are merged over the shared config rather than replacing it.
76
+ *
77
+ * "sites": [
78
+ * "https://one.example",
79
+ * { "url": "https://two.example", "ignore": ["thin-content"], "limit": 50 }
80
+ * ]
81
+ *
82
+ * URLs given on the command line win over the config file entirely, because
83
+ * naming sites explicitly is how you audit a subset of a portfolio. */
84
+ export function resolveSites(positional = [], file = {}) {
85
+ const list = positional.length ? positional : (file.sites ?? []);
86
+ return list
87
+ .map((entry) => (typeof entry === 'string' ? { url: entry } : { ...entry }))
88
+ .filter((entry) => entry.url)
89
+ .map(({ url, ...overrides }) => ({
90
+ url: /^https?:\/\//i.test(url) ? url : `https://${url}`,
91
+ overrides,
92
+ }));
93
+ }
94
+
95
+ /** Shared options with one site's overrides on top. `ignore` accumulates,
96
+ * since a portfolio rule and a site rule are both meant to apply. */
97
+ export function optionsForSite(shared, overrides = {}) {
98
+ return {
99
+ ...shared,
100
+ ...overrides,
101
+ ignore: [...(shared.ignore ?? []), ...(overrides.ignore ?? [])],
102
+ };
103
+ }
104
+
105
+ /** Drop findings the site has accepted. Returns [kept, ignoredCount]. */
106
+ export function applyIgnores(findings, ignore = []) {
107
+ if (!ignore.length) return [findings, 0];
108
+ const kept = findings.filter((f) => !ignore.some((rule) => ruleMatches(rule, f)));
109
+ return [kept, findings.length - kept.length];
110
+ }
111
+
112
+ /** Structured-data expectations, as findings. `expect` says which schema types
113
+ * a group of pages must carry — the difference between "the JSON parses" and
114
+ * "this article is actually marked up as an article". */
115
+ export function expectationChecks(pages, expect = []) {
116
+ const out = [];
117
+ if (!expect.length) return out;
118
+
119
+ for (const page of pages) {
120
+ if (!page.doc) continue;
121
+ let path;
122
+ try {
123
+ path = new URL(page.url).pathname;
124
+ } catch {
125
+ continue;
126
+ }
127
+ const rules = expect.filter((rule) => rule.urls.some((u) => matchGlob(u, path)));
128
+ if (!rules.length) continue;
129
+
130
+ // Every @type on the page, including those nested in a @graph.
131
+ const types = new Set();
132
+ const collect = (node) => {
133
+ if (!node || typeof node !== 'object') return;
134
+ if (Array.isArray(node)) return node.forEach(collect);
135
+ for (const t of [node['@type']].flat().filter(Boolean)) types.add(t);
136
+ if (node['@graph']) collect(node['@graph']);
137
+ };
138
+ for (const block of page.doc.jsonld) if (block.ok) collect(block.data);
139
+
140
+ for (const rule of rules) {
141
+ const missing = rule.types.filter((t) => !types.has(t));
142
+ if (missing.length) {
143
+ out.push({
144
+ level: 'error',
145
+ id: 'schema-expected',
146
+ title: `Missing expected structured data: ${missing.join(', ')}`,
147
+ detail: types.size
148
+ ? `Page declares ${[...types].join(', ')}.`
149
+ : 'Page has no structured data at all.',
150
+ url: page.url,
151
+ });
152
+ }
153
+ }
154
+ }
155
+ return out;
156
+ }
@@ -0,0 +1,146 @@
1
+ // Search Console: what the pages in this crawl actually do in Google.
2
+ //
3
+ // Every ordering in this report so far is derived from the site's own markup —
4
+ // how many links point at a page, how far it is from the homepage. Those are
5
+ // good proxies. Impressions are not a proxy: a broken canonical on a page with
6
+ // four thousand impressions a month is a different sentence from the same
7
+ // canonical on a page nobody has ever been shown.
8
+ //
9
+ // Opt-in, and the only thing in this tool that needs an account. Credentials
10
+ // are read the way the PageSpeed key is — the environment first, then
11
+ // ~/.config/seo-audit/.env — and never from the repository.
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
17
+ const API = 'https://searchconsole.googleapis.com/webmasters/v3/sites';
18
+
19
+ const f = (level, id, title, detail, url) => ({ level, id, title, detail, url });
20
+
21
+ /** One credential, environment first. */
22
+ export function findCredential(name, env = process.env, read = readFileSync) {
23
+ if (env[name]) return env[name];
24
+ const dotfile = join(homedir(), '.config', 'seo-audit', '.env');
25
+ if (!existsSync(dotfile)) return null;
26
+ const match = read(dotfile, 'utf8').match(new RegExp(`^\\\\s*${name}\\\\s*=\\\\s*(\\\\S+)`, 'm'));
27
+ return match?.[1] ?? null;
28
+ }
29
+
30
+ /** All three, or a sentence saying which is missing. */
31
+ export function credentials(env = process.env) {
32
+ const found = {
33
+ clientId: findCredential('GSC_CLIENT_ID', env),
34
+ clientSecret: findCredential('GSC_CLIENT_SECRET', env),
35
+ refreshToken: findCredential('GSC_REFRESH_TOKEN', env),
36
+ };
37
+ const missing = Object.entries({
38
+ GSC_CLIENT_ID: found.clientId,
39
+ GSC_CLIENT_SECRET: found.clientSecret,
40
+ GSC_REFRESH_TOKEN: found.refreshToken,
41
+ })
42
+ .filter(([, value]) => !value)
43
+ .map(([name]) => name);
44
+ return missing.length ? { missing } : found;
45
+ }
46
+
47
+ /** A refresh token is the long-lived half; this trades it for the short one. */
48
+ async function accessToken({ clientId, clientSecret, refreshToken }, fetcher = fetch) {
49
+ const res = await fetcher(TOKEN_URL, {
50
+ method: 'POST',
51
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
52
+ body: new URLSearchParams({
53
+ client_id: clientId,
54
+ client_secret: clientSecret,
55
+ refresh_token: refreshToken,
56
+ grant_type: 'refresh_token',
57
+ }),
58
+ });
59
+ const data = await res.json();
60
+ if (!res.ok || !data.access_token) {
61
+ throw new Error(data.error_description ?? data.error ?? `HTTP ${res.status} from Google's token endpoint`);
62
+ }
63
+ return data.access_token;
64
+ }
65
+
66
+ const isoDaysAgo = (days, now) => new Date(now - days * 86400000).toISOString().slice(0, 10);
67
+
68
+ /** Impressions and clicks per page for the last 28 days.
69
+ *
70
+ * Search Console reports the last three days incompletely, so the window ends
71
+ * three days back: a page that looks like it lost all its impressions
72
+ * yesterday has usually just not been counted yet. */
73
+ export async function pageTraffic(siteUrl, creds, { fetcher = fetch, now = Date.now(), rowLimit = 25000 } = {}) {
74
+ const token = await accessToken(creds, fetcher);
75
+ const res = await fetcher(`${API}/${encodeURIComponent(siteUrl)}/searchAnalytics/query`, {
76
+ method: 'POST',
77
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
78
+ body: JSON.stringify({
79
+ startDate: isoDaysAgo(31, now),
80
+ endDate: isoDaysAgo(3, now),
81
+ dimensions: ['page'],
82
+ rowLimit,
83
+ }),
84
+ });
85
+ const data = await res.json();
86
+ if (!res.ok) {
87
+ throw new Error(data.error?.message ?? `HTTP ${res.status} from Search Console`);
88
+ }
89
+ const traffic = new Map();
90
+ for (const row of data.rows ?? []) {
91
+ const page = row.keys?.[0];
92
+ if (!page) continue;
93
+ traffic.set(page.replace(/\/$/, ''), {
94
+ impressions: Math.round(row.impressions ?? 0),
95
+ clicks: Math.round(row.clicks ?? 0),
96
+ });
97
+ }
98
+ return traffic;
99
+ }
100
+
101
+ /** Attach traffic to findings, and say what was found.
102
+ *
103
+ * A property Search Console does not have, or credentials it will not accept,
104
+ * is reported as a note rather than thrown: the rest of the audit is still
105
+ * worth having, and an audit that dies because an optional integration failed
106
+ * is worse than one that says so. */
107
+ export async function searchConsole(origin, findings, opts = {}) {
108
+ const creds = opts.credentials ?? credentials();
109
+ if (creds.missing) {
110
+ return [
111
+ f('info', 'search-console-unconfigured', 'Search Console was asked for but not configured',
112
+ `Missing ${creds.missing.join(', ')}. Set them in the environment or in ` +
113
+ '~/.config/seo-audit/.env — never in the repository. Without them the report is ordered by ' +
114
+ 'how much of the site links to a page, which is a proxy for the same thing.', origin),
115
+ ];
116
+ }
117
+
118
+ let traffic;
119
+ try {
120
+ traffic = await pageTraffic(opts.siteUrl ?? `${origin}/`, creds, opts);
121
+ } catch (err) {
122
+ return [
123
+ f('info', 'search-console-failed', 'Search Console did not answer',
124
+ `${err.message}. The property has to be one this account can read, and a domain property is ` +
125
+ 'named "sc-domain:example.com" rather than by its URL. Everything else in this report is ' +
126
+ 'unaffected.', origin),
127
+ ];
128
+ }
129
+
130
+ let matched = 0;
131
+ for (const finding of findings) {
132
+ if (!finding.url) continue;
133
+ const seen = traffic.get(finding.url.replace(/\/$/, ''));
134
+ if (!seen) continue;
135
+ finding.traffic = seen;
136
+ matched++;
137
+ }
138
+
139
+ const shown = [...traffic.values()].reduce((n, t) => n + t.impressions, 0);
140
+ return [
141
+ f('info', 'search-console', `Search Console has ${traffic.size.toLocaleString()} pages for this site`,
142
+ `${matched.toLocaleString()} of this crawl's findings are on pages Google has shown, ` +
143
+ `${shown.toLocaleString()} times between them over 28 days. Findings are ordered by that where it ` +
144
+ 'is known, and by how much of the site links to a page where it is not.', origin),
145
+ ];
146
+ }
package/src/dupes.mjs ADDED
@@ -0,0 +1,164 @@
1
+ // Pages whose content is the same page again.
2
+ //
3
+ // Titles and descriptions have been compared since early on; bodies never were,
4
+ // and that is the axis that matters most — a hundred product pages that differ
5
+ // by one word are a hundred pages competing with each other for one result,
6
+ // and they burn the crawl budget that would have gone to the pages that differ.
7
+ //
8
+ // It costs no requests. The text was read, measured for `words`, and thrown
9
+ // away; what is kept now is a sketch of it, a few hundred bytes a page.
10
+ //
11
+ // **MinHash**, rather than comparing text to text. Comparing every page with
12
+ // every other page is quadratic, and at five thousand pages that is twelve
13
+ // million comparisons of documents. A sketch of 64 numbers estimates how much
14
+ // two pages overlap by how many of those numbers agree, and banding the sketch
15
+ // puts likely pairs in the same bucket so most pairs are never compared at all.
16
+ // The estimate is unbiased and its error at 64 samples is about 6% — which is
17
+ // why the threshold below is nowhere near the edge.
18
+
19
+ /** Words, lowercased, with everything that is not a letter or a number gone.
20
+ * Punctuation and case differences are not content differences. */
21
+ function normalise(text) {
22
+ return text
23
+ .toLowerCase()
24
+ .replace(/[^\p{L}\p{N}\s]+/gu, ' ')
25
+ .split(/\s+/)
26
+ .filter(Boolean);
27
+ }
28
+
29
+ /** FNV-1a, 32-bit. Small, fast, well spread, and four lines. */
30
+ function hash(text) {
31
+ let value = 0x811c9dc5;
32
+ for (let i = 0; i < text.length; i++) {
33
+ value ^= text.charCodeAt(i);
34
+ value = Math.imul(value, 0x01000193) >>> 0;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ const SKETCH = 64;
40
+ const SHINGLE = 5; // Five-word runs: long enough that a shared sentence is
41
+ // meaningful, short enough that reordering does not hide a copy.
42
+
43
+ /** The 64 permutations, fixed so two runs of this tool agree. Odd multipliers
44
+ * so each is a bijection on 32 bits and no sketch position collapses. */
45
+ const PERMUTATIONS = Array.from({ length: SKETCH }, (_, i) => ({
46
+ a: (2654435761 * (i + 1) * 2 + 1) >>> 0,
47
+ b: (40503 * (i + 7)) >>> 0,
48
+ }));
49
+
50
+ /** A sketch of a page's content, or `null` when there is not enough of it to
51
+ * compare honestly.
52
+ *
53
+ * `null` is returned rather than a weak sketch on purpose. A page with fifty
54
+ * words shares most of them with any other page of fifty words, and a check
55
+ * that reports those as duplicates would be reporting the length, not the
56
+ * content. */
57
+ export function fingerprint(text, { minimumWords = 100 } = {}) {
58
+ const words = normalise(text);
59
+ if (words.length < minimumWords) return null;
60
+
61
+ const sketch = new Array(SKETCH).fill(0xffffffff);
62
+ let shingles = 0;
63
+ for (let i = 0; i + SHINGLE <= words.length; i++) {
64
+ const value = hash(words.slice(i, i + SHINGLE).join(' '));
65
+ shingles++;
66
+ for (let k = 0; k < SKETCH; k++) {
67
+ const permuted = (Math.imul(value, PERMUTATIONS[k].a) + PERMUTATIONS[k].b) >>> 0;
68
+ if (permuted < sketch[k]) sketch[k] = permuted;
69
+ }
70
+ }
71
+ return shingles ? { sketch, words: words.length } : null;
72
+ }
73
+
74
+ /** How much two pages overlap, 0 to 1. The share of sketch positions that
75
+ * agree estimates the Jaccard similarity of their five-word runs. */
76
+ export function similarity(a, b) {
77
+ if (!a || !b) return 0;
78
+ let same = 0;
79
+ for (let i = 0; i < SKETCH; i++) if (a.sketch[i] === b.sketch[i]) same++;
80
+ return same / SKETCH;
81
+ }
82
+
83
+ const BAND = 4;
84
+
85
+ /** Groups of pages whose content is near-identical.
86
+ *
87
+ * `pages` is `[{ url, fingerprint, ...anything }]`. Anything without a
88
+ * fingerprint is ignored rather than grouped with everything else.
89
+ *
90
+ * Banding first: pages whose sketches agree across a whole band of four are
91
+ * candidates, and only candidates are scored. Two pages at the threshold below
92
+ * share a band with probability ~99%, so almost nothing real is missed, and
93
+ * the pairs that are never compared are the ones that were never close. */
94
+ export function cluster(pages, { threshold = 0.9 } = {}) {
95
+ const usable = pages.filter((p) => p.fingerprint);
96
+ if (usable.length < 2) return [];
97
+
98
+ const buckets = new Map();
99
+ for (const page of usable) {
100
+ for (let band = 0; band * BAND < SKETCH; band++) {
101
+ const key = band + ':' + page.fingerprint.sketch.slice(band * BAND, band * BAND + BAND).join(',');
102
+ buckets.set(key, [...(buckets.get(key) ?? []), page]);
103
+ }
104
+ }
105
+
106
+ // Union–find over the candidate pairs, so three pages that are each near the
107
+ // other two come out as one group of three rather than three pairs.
108
+ const parent = new Map(usable.map((p) => [p.url, p.url]));
109
+ const find = (url) => {
110
+ let root = url;
111
+ while (parent.get(root) !== root) root = parent.get(root);
112
+ // Point everything along the way straight at the root, so the next lookup
113
+ // is one step.
114
+ let walk = url;
115
+ while (parent.get(walk) !== root) {
116
+ const next = parent.get(walk);
117
+ parent.set(walk, root);
118
+ walk = next;
119
+ }
120
+ return root;
121
+ };
122
+ const union = (a, b) => {
123
+ const [ra, rb] = [find(a), find(b)];
124
+ if (ra !== rb) parent.set(ra, rb);
125
+ };
126
+
127
+ const scored = new Map();
128
+ for (const candidates of buckets.values()) {
129
+ if (candidates.length < 2 || candidates.length > 200) continue; // A bucket of
130
+ // everything is a bucket of nothing: it means the sketches are degenerate,
131
+ // not that the site is one page repeated.
132
+ for (let i = 0; i < candidates.length; i++) {
133
+ for (let j = i + 1; j < candidates.length; j++) {
134
+ const pair = [candidates[i].url, candidates[j].url].sort().join('\u0000');
135
+ if (scored.has(pair)) continue;
136
+ const score = similarity(candidates[i].fingerprint, candidates[j].fingerprint);
137
+ scored.set(pair, score);
138
+ if (score >= threshold) union(candidates[i].url, candidates[j].url);
139
+ }
140
+ }
141
+ }
142
+
143
+ const groups = new Map();
144
+ for (const page of usable) {
145
+ const root = find(page.url);
146
+ groups.set(root, [...(groups.get(root) ?? []), page]);
147
+ }
148
+
149
+ return [...groups.values()]
150
+ .filter((group) => group.length > 1)
151
+ .map((group) => {
152
+ const urls = group.map((p) => p.url).sort();
153
+ // The lowest score inside the group, so the number reported is the one
154
+ // that is true of every pair in it rather than of the closest pair.
155
+ let lowest = 1;
156
+ for (let i = 0; i < group.length; i++) {
157
+ for (let j = i + 1; j < group.length; j++) {
158
+ lowest = Math.min(lowest, similarity(group[i].fingerprint, group[j].fingerprint));
159
+ }
160
+ }
161
+ return { urls, pages: group, similarity: lowest };
162
+ })
163
+ .sort((a, b) => b.pages.length - a.pages.length || a.urls[0].localeCompare(b.urls[0]));
164
+ }
package/src/graph.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // The site's internal link graph, built once and read by everything that needs
2
+ // it: the orphan check, click depth, and the ordering of the report.
3
+ //
4
+ // It was computed inside crossPageChecks and thrown away, which meant the two
5
+ // checks that used it could disagree with each other and nothing else could use
6
+ // it at all. A finding's *reach* — how many links point at the pages it affects,
7
+ // and how far those pages are from the homepage — is the difference between a
8
+ // list of problems and a list of work worth doing, and it was already in memory.
9
+
10
+ /** How a URL is compared with another. Fragments are not pages and a trailing
11
+ * slash is not a difference. */
12
+ export const key = (url) => (url ?? '').split('#')[0].replace(/\/$/, '');
13
+
14
+ const isHome = (url) => {
15
+ try {
16
+ return new URL(url).pathname.replace(/\/$/, '') === '';
17
+ } catch {
18
+ return false;
19
+ }
20
+ };
21
+
22
+ /** Build the graph from crawled pages.
23
+ *
24
+ * `home` is the page to measure distance from, and may be one the crawl never
25
+ * fetched — a sitemap is under no obligation to list it. Returns:
26
+ *
27
+ * depth key → clicks from the homepage, absent when nothing reaches it
28
+ * inlinks key → how many pages link to it, self-links not counted
29
+ * from key → the page it was first reached through, for printing a route
30
+ * stranded pages with no path from the homepage at all
31
+ *
32
+ * Everything here is a count of links that were actually read. Nothing is
33
+ * weighted, scored, or guessed. */
34
+ export function linkGraph(live, home = null) {
35
+ const pages = new Map(live.map((p) => [key(p.url), p]));
36
+ const root = live.find((p) => isHome(p.url)) ?? (home?.doc ? home : null);
37
+ if (root && !pages.has(key(root.url))) pages.set(key(root.url), root);
38
+
39
+ const inlinks = new Map();
40
+ for (const page of pages.values()) {
41
+ const seen = new Set();
42
+ for (const href of page.doc?.links?.internal ?? []) {
43
+ const target = key(href);
44
+ // A page linking to itself says nothing about how well linked it is, and
45
+ // a logo in the header does it on every page of the site.
46
+ if (target === key(page.url) || seen.has(target)) continue;
47
+ seen.add(target);
48
+ inlinks.set(target, (inlinks.get(target) ?? 0) + 1);
49
+ }
50
+ }
51
+
52
+ const depth = new Map();
53
+ const from = new Map();
54
+ if (root) {
55
+ depth.set(key(root.url), 0);
56
+ // Breadth first, so the first time a page is reached is by its shortest
57
+ // path — which is the number worth reporting and the route worth printing.
58
+ for (let frontier = [key(root.url)]; frontier.length; ) {
59
+ const next = [];
60
+ for (const at of frontier) {
61
+ for (const href of pages.get(at)?.doc?.links?.internal ?? []) {
62
+ const to = key(href);
63
+ if (!pages.has(to) || depth.has(to)) continue;
64
+ depth.set(to, depth.get(at) + 1);
65
+ from.set(to, at);
66
+ next.push(to);
67
+ }
68
+ }
69
+ frontier = next;
70
+ }
71
+ }
72
+
73
+ return {
74
+ root,
75
+ depth,
76
+ from,
77
+ inlinks,
78
+ stranded: live.filter((p) => !depth.has(key(p.url))),
79
+ /** What a finding on this URL can reach: how many pages link to it, and how
80
+ * far it is from the homepage. Absent values stay absent rather than
81
+ * becoming zero, because "nothing links here" and "this was never
82
+ * measured" are different answers. */
83
+ reachOf(url) {
84
+ const at = key(url);
85
+ if (!pages.has(at)) return null;
86
+ return { inlinks: inlinks.get(at) ?? 0, depth: depth.get(at) ?? null };
87
+ },
88
+ };
89
+ }