@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,183 @@
1
+ // The sitemap this site should have had.
2
+ //
3
+ // Every other output here describes a problem. This one is the fix: the crawl
4
+ // already knows every URL it read, what each answered, whether it says
5
+ // noindex, and where its canonical points, which is exactly what decides
6
+ // whether a URL belongs in a sitemap.
7
+ //
8
+ // The refusals below matter more than the file. A sitemap that quietly drops
9
+ // real pages is worse than one full of dead ones — the dead ones are a warning
10
+ // in Search Console, and the missing ones are pages that stop being crawled.
11
+ // So this refuses to write anything from a run that did not see the whole site,
12
+ // and says which run would.
13
+
14
+ /** The five characters XML cannot carry raw. `&` first, or the others get
15
+ * their own ampersands escaped a second time. */
16
+ const escape = (text) =>
17
+ text
18
+ .replace(/&/g, '&')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;')
22
+ .replace(/'/g, '&apos;');
23
+
24
+ const same = (a, b) => a.replace(/\/$/, '') === b.replace(/\/$/, '');
25
+
26
+ /** Why a URL the crawl saw is not in the file. Each is a decision somebody
27
+ * could disagree with, so each is counted and named rather than summarised. */
28
+ const REASONS = {
29
+ status: 'did not answer 200',
30
+ 'not-html': 'is not an HTML page',
31
+ noindex: 'says noindex',
32
+ 'canonical-elsewhere': 'has a canonical pointing at another page',
33
+ 'robots-disallowed': 'is disallowed by robots.txt',
34
+ };
35
+
36
+ /**
37
+ * @param pages what the crawl read: `[{ url, res, doc }]`
38
+ * @param findings the run's findings, read for pages that are linked but were
39
+ * missing from the sitemap — those are confirmed 200 HTML by
40
+ * the link sweep, and belong in the file
41
+ * @param context `{ entries, truncated, rateLimited, allowed }`
42
+ */
43
+ export function rebuild(pages, findings = [], context = {}) {
44
+ const { entries = [], truncated = 0, rateLimited = 0, allowed = () => true } = context;
45
+
46
+ // --- when not to write anything ----------------------------------------
47
+ if (truncated > 0) {
48
+ return refuse(
49
+ `The crawl stopped at its limit with ${truncated} URL(s) unread, so this file would leave ` +
50
+ `them out of the sitemap entirely. Run again with --limit ${pages.length + truncated}.`,
51
+ );
52
+ }
53
+ if (rateLimited > 0) {
54
+ return refuse(
55
+ `${rateLimited} page(s) were never read because the server was rate limiting, so whether they ` +
56
+ 'belong in a sitemap is not known. Run again with a lower --concurrency.',
57
+ );
58
+ }
59
+ // More linked-but-missing pages than the report enumerates. It says how many
60
+ // there are but not which, and a file built without them is incomplete.
61
+ if (findings.some((f) => f.id === 'missing-from-sitemap-more')) {
62
+ return refuse(
63
+ 'More pages are linked but missing from the sitemap than the report lists individually, so ' +
64
+ 'this file could not include all of them. Raise the link sweep cap and run again.',
65
+ );
66
+ }
67
+
68
+ // --- what goes in -------------------------------------------------------
69
+ const lastmod = new Map(entries.filter((e) => e.lastmod).map((e) => [e.loc, e.lastmod]));
70
+ const excluded = {};
71
+ const drop = (reason) => {
72
+ excluded[reason] = (excluded[reason] ?? 0) + 1;
73
+ };
74
+
75
+ const keep = [];
76
+ for (const page of pages) {
77
+ if (!page.res?.ok) { drop('status'); continue; }
78
+ if (!page.doc) { drop('not-html'); continue; }
79
+ if (/noindex/i.test(page.doc.robots ?? '')) { drop('noindex'); continue; }
80
+ const canonical = page.doc.canonical?.[0];
81
+ if (canonical && !same(canonical, page.url)) { drop('canonical-elsewhere'); continue; }
82
+ if (!allowed(page.url)) { drop('robots-disallowed'); continue; }
83
+ keep.push(page.url);
84
+ }
85
+
86
+ // Linked, answered 200, HTML, and absent from the sitemap — the check that
87
+ // reports them has already established every one of those.
88
+ const added = findings
89
+ .filter((f) => f.id === 'missing-from-sitemap' && f.url)
90
+ .map((f) => f.url)
91
+ .filter((url) => allowed(url) && !keep.some((k) => same(k, url)));
92
+
93
+ const urls = [...new Set([...keep, ...added])].sort();
94
+ if (urls.length === 0) {
95
+ return refuse('No page in this crawl belongs in a sitemap, so there is nothing to write.');
96
+ }
97
+
98
+ const body = urls
99
+ .map((url) => {
100
+ const when = lastmod.get(url) ?? lastmod.get(url.replace(/\/$/, ''));
101
+ return ` <url>\n <loc>${escape(url)}</loc>` + (when ? `\n <lastmod>${escape(when)}</lastmod>` : '') + '\n </url>';
102
+ })
103
+ .join('\n');
104
+
105
+ return {
106
+ xml: '<?xml version="1.0" encoding="UTF-8"?>\n'
107
+ + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
108
+ + `${body}\n</urlset>\n`,
109
+ urls,
110
+ added,
111
+ excluded,
112
+ refused: null,
113
+ };
114
+ }
115
+
116
+ function refuse(why) {
117
+ return { xml: null, urls: [], added: [], excluded: {}, refused: why };
118
+ }
119
+
120
+ /** The summary line for a terminal, and for the note the CLI prints. */
121
+ export function describe(result, path) {
122
+ if (result.refused) return ` Did not write ${path}: ${result.refused}\n`;
123
+ const out = [` wrote ${path} — ${result.urls.length} URLs`];
124
+ if (result.added.length) {
125
+ out.push(` added ${result.added.length} (linked, and were missing from the sitemap)`);
126
+ }
127
+ for (const [reason, count] of Object.entries(result.excluded).sort((a, b) => b[1] - a[1])) {
128
+ out.push(` dropped ${String(count).padEnd(3)} (${REASONS[reason] ?? reason})`);
129
+ }
130
+ return out.join('\n') + '\n';
131
+ }
132
+
133
+ /** Which sitemap URLs changed since a date, and whether that can be answered.
134
+ *
135
+ * A five-thousand-page site audited every week does not need five thousand
136
+ * requests: the sitemap already says which pages moved. The saving is real
137
+ * enough to be worth the two refusals below, both of which come from the same
138
+ * place — a `lastmod` nobody maintains is worse than none, because it looks
139
+ * like an answer.
140
+ *
141
+ * A URL with no `lastmod` is **kept**. Not knowing when a page changed is not
142
+ * evidence that it did not, and the whole value of this is that the pages it
143
+ * skips are ones the site said are unchanged.
144
+ */
145
+ export function changedSince(entries, since) {
146
+ const cutoff = Date.parse(since);
147
+ if (!Number.isFinite(cutoff)) {
148
+ return { refused: `"${since}" is not a date. Pass one like 2026-08-17.` };
149
+ }
150
+
151
+ const dated = entries.filter((e) => e.lastmod && Number.isFinite(Date.parse(e.lastmod)));
152
+ if (dated.length === 0) {
153
+ return {
154
+ refused:
155
+ 'No URL in this sitemap carries a lastmod, so there is nothing to compare a date against. ' +
156
+ 'Run without --since.',
157
+ };
158
+ }
159
+
160
+ // One date on every URL is a build stamp, which is the thing crawlers learn
161
+ // to ignore — and here it would mean checking everything or nothing
162
+ // depending on which side of the stamp the date fell.
163
+ const days = new Set(dated.map((e) => new Date(Date.parse(e.lastmod)).toISOString().slice(0, 10)));
164
+ if (days.size === 1 && dated.length > 1) {
165
+ return {
166
+ refused:
167
+ `Every dated URL in this sitemap carries ${[...days][0]}, which is a build stamp rather than ` +
168
+ 'a record of when each page changed. --since would check all of them or none of them.',
169
+ };
170
+ }
171
+
172
+ const changed = [];
173
+ const unknown = [];
174
+ const skipped = [];
175
+ for (const entry of entries) {
176
+ const at = entry.lastmod ? Date.parse(entry.lastmod) : NaN;
177
+ if (!Number.isFinite(at)) unknown.push(entry.loc);
178
+ else if (at >= cutoff) changed.push(entry.loc);
179
+ else skipped.push(entry.loc);
180
+ }
181
+
182
+ return { urls: [...changed, ...unknown], changed, unknown, skipped, refused: null };
183
+ }