@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.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@nurkamol/seo-audit",
3
+ "version": "1.31.0",
4
+ "description": "Crawl a site's sitemap and check every page for SEO, metadata and structured-data problems that single-page graders miss. Zero dependencies.",
5
+ "type": "module",
6
+ "bin": {
7
+ "seo-audit": "bin/seo-audit.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/audit.mjs",
11
+ "./causes": "./src/causes.mjs",
12
+ "./report": "./src/report.mjs",
13
+ "./sitemap": "./src/sitemap.mjs",
14
+ "./areas": "./src/areas.mjs",
15
+ "./agents": "./src/agents.mjs",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src",
21
+ "action.yml",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "pretest": "node scripts/link-engine.mjs",
30
+ "test": "node --test test/*.test.mjs",
31
+ "audit": "node bin/seo-audit.mjs"
32
+ },
33
+ "keywords": [
34
+ "seo",
35
+ "audit",
36
+ "crawler",
37
+ "sitemap",
38
+ "metadata",
39
+ "open-graph",
40
+ "hreflang",
41
+ "cli"
42
+ ],
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/nurkamol/seo-audit.git"
47
+ },
48
+ "homepage": "https://nurkamol.github.io/seo-audit/",
49
+ "bugs": {
50
+ "url": "https://github.com/nurkamol/seo-audit/issues"
51
+ }
52
+ }
package/src/agents.mjs ADDED
@@ -0,0 +1,122 @@
1
+ // Presenting as something other than this tool.
2
+ //
3
+ // Three reasons a real audit needs it, and none of them is dressing up:
4
+ //
5
+ // - A site that answers a browser and blocks everything else is common, and
6
+ // the report from a blocked crawl is a report about the block.
7
+ // - Some sites serve different HTML to a crawler than to a person. Fetching
8
+ // as Googlebot is the only way to see what Google is given.
9
+ // - Google indexes what its smartphone crawler sees. On a site that serves a
10
+ // different page to phones, that is the page that matters.
11
+ //
12
+ // The strings below are a snapshot and will age; browser versions move every
13
+ // few weeks and nothing here can know that. They are close enough for a server
14
+ // deciding whether to answer, and `--user-agent` still takes a literal string
15
+ // for anything that has to be exact.
16
+
17
+ // Googlebot's two user agents, quoted from Google's own crawler documentation.
18
+ // Google publishes the Chrome version as the placeholder `W.X.Y.Z`; the real
19
+ // crawler sends a concrete one, so a plausible version is substituted here.
20
+ const GOOGLEBOT_CHROME = '131.0.6778.264';
21
+
22
+ const BOTS = {
23
+ googlebot:
24
+ `Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) ` +
25
+ `Chrome/${GOOGLEBOT_CHROME} Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)`,
26
+ 'googlebot-desktop':
27
+ `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; ` +
28
+ `+http://www.google.com/bot.html) Chrome/${GOOGLEBOT_CHROME} Safari/537.36`,
29
+ bingbot: 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)',
30
+ };
31
+
32
+ // What each system calls itself in a user agent. Chrome and Firefox both froze
33
+ // these years ago — the macOS one says 10_15_7 on every Mac made since, and
34
+ // that is correct rather than stale.
35
+ const SYSTEM = {
36
+ macos: { chromium: 'Macintosh; Intel Mac OS X 10_15_7', gecko: 'Macintosh; Intel Mac OS X 10.15' },
37
+ windows: { chromium: 'Windows NT 10.0; Win64; x64', gecko: 'Windows NT 10.0; Win64; x64' },
38
+ linux: { chromium: 'X11; Linux x86_64', gecko: 'X11; Linux x86_64' },
39
+ android: { chromium: 'Linux; Android 10; K', gecko: 'Android 15; Mobile' },
40
+ ios: { chromium: 'iPhone; CPU iPhone OS 18_5 like Mac OS X', gecko: 'iPhone; CPU iPhone OS 18_5 like Mac OS X' },
41
+ };
42
+
43
+ const CHROME = '141.0.0.0';
44
+ const FIREFOX = '143.0';
45
+ const SAFARI = '18.6';
46
+ const EDGE = '141.0.0.0';
47
+
48
+ /** Every browser that can run on a system, and what it says when it does.
49
+ *
50
+ * Absent combinations are absent on purpose: Safari does not run on Windows or
51
+ * Linux, and inventing a user agent for one would be describing a machine that
52
+ * does not exist. */
53
+ const BROWSERS = {
54
+ chrome: {
55
+ macos: (s) => `Mozilla/5.0 (${s}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME} Safari/537.36`,
56
+ windows: null, // filled below — the string is identical bar the system
57
+ linux: null,
58
+ android: (s) => `Mozilla/5.0 (${s}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME} Mobile Safari/537.36`,
59
+ ios: (s) =>
60
+ `Mozilla/5.0 (${s}) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${CHROME} Mobile/15E148 Safari/604.1`,
61
+ },
62
+ firefox: {
63
+ macos: (s) => `Mozilla/5.0 (${s}; rv:${FIREFOX}) Gecko/20100101 Firefox/${FIREFOX}`,
64
+ windows: null,
65
+ linux: null,
66
+ android: (s) => `Mozilla/5.0 (${s}; rv:${FIREFOX}) Gecko/${FIREFOX} Firefox/${FIREFOX}`,
67
+ ios: (s) => `Mozilla/5.0 (${s}) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/${FIREFOX} Mobile/15E148 Safari/605.1.15`,
68
+ },
69
+ safari: {
70
+ macos: (s) => `Mozilla/5.0 (${s}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/${SAFARI} Safari/605.1.15`,
71
+ ios: (s) => `Mozilla/5.0 (${s}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/${SAFARI} Mobile/15E148 Safari/604.1`,
72
+ },
73
+ edge: {
74
+ macos: (s) =>
75
+ `Mozilla/5.0 (${s}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME} Safari/537.36 Edg/${EDGE}`,
76
+ windows: null,
77
+ linux: null,
78
+ },
79
+ };
80
+ for (const browser of ['chrome', 'firefox', 'edge']) {
81
+ for (const os of ['windows', 'linux']) {
82
+ if (BROWSERS[browser][os] === null) BROWSERS[browser][os] = BROWSERS[browser].macos;
83
+ }
84
+ }
85
+
86
+ export const BROWSER_NAMES = [...Object.keys(BROWSERS), ...Object.keys(BOTS)];
87
+ export const OS_NAMES = Object.keys(SYSTEM);
88
+
89
+ /** The system this is running on, so `--browser chrome` alone means "as a
90
+ * browser on this machine" rather than making everyone name their own OS. */
91
+ export function thisPlatform(platform = process.platform) {
92
+ return { darwin: 'macos', win32: 'windows' }[platform] ?? 'linux';
93
+ }
94
+
95
+ /** A user agent for a browser and a system, or an explanation of why not.
96
+ *
97
+ * Returns `{ ua }` or `{ error }`. Never guesses: a combination that does not
98
+ * exist in the world is refused rather than approximated, because the whole
99
+ * point of the flag is to be believed by a server. */
100
+ export function userAgentFor(browser, os = thisPlatform()) {
101
+ const name = String(browser ?? '').toLowerCase();
102
+ const system = String(os ?? '').toLowerCase();
103
+
104
+ if (BOTS[name]) {
105
+ // A crawler's user agent says nothing about a machine, so an --os alongside
106
+ // it is a question with no answer rather than a conflict worth refusing.
107
+ return { ua: BOTS[name], ignoredOs: Boolean(os) && os !== thisPlatform() };
108
+ }
109
+ if (!BROWSERS[name]) {
110
+ return { error: `Unknown browser "${browser}". Try one of: ${BROWSER_NAMES.join(', ')}.` };
111
+ }
112
+ if (!SYSTEM[system]) {
113
+ return { error: `Unknown system "${os}". Try one of: ${OS_NAMES.join(', ')}.` };
114
+ }
115
+ const build = BROWSERS[name][system];
116
+ if (!build) {
117
+ const runs = Object.keys(BROWSERS[name]).join(', ');
118
+ return { error: `${browser} does not run on ${os}. It runs on: ${runs}.` };
119
+ }
120
+ const engine = name === 'firefox' ? 'gecko' : 'chromium';
121
+ return { ua: build(SYSTEM[system][engine]) };
122
+ }
package/src/areas.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // Which area of the site a check belongs to.
2
+ //
3
+ // Its own module because two things need it and neither can import the other:
4
+ // `report.mjs` groups the HTML report by area, and `causes.mjs` puts the area
5
+ // into the JSON payload so a native client can group its own report the same
6
+ // way without carrying a second copy of this table in another language.
7
+
8
+ // --- Categories -------------------------------------------------------------
9
+ // Severity says how loudly to complain; a category says who fixes it. Thirty
10
+ // findings sorted only by severity is a list you read once. The same thirty
11
+ // under "Images" and "Multilingual" is a list somebody can divide up.
12
+ //
13
+ // Ordered by what a crawler hits first: whether the page can be indexed at all,
14
+ // then what it says, then what it links to, then everything else.
15
+ export const CATEGORIES = [
16
+ 'Indexability',
17
+ 'Content',
18
+ 'Links',
19
+ 'Redirects',
20
+ 'Images',
21
+ 'Social',
22
+ 'Structured data',
23
+ 'Multilingual',
24
+ 'Sitemap & robots',
25
+ 'Site & security',
26
+ 'Performance',
27
+ ];
28
+
29
+ const CATEGORY_OF = {
30
+ // Indexability
31
+ noindex: 'Indexability', 'x-robots-noindex': 'Indexability', 'nofollow-page': 'Indexability',
32
+ 'page-status': 'Indexability', unreachable: 'Indexability', 'nothing-crawlable': 'Indexability',
33
+ 'crawl-rate-limited': 'Indexability',
34
+ 'rate-limited': 'Indexability',
35
+ 'canonical-missing': 'Indexability', 'canonical-multiple': 'Indexability',
36
+ 'canonical-other': 'Indexability', 'canonical-dead': 'Indexability',
37
+ 'canonical-redirects': 'Indexability', 'soft-404': 'Indexability',
38
+ 'canonical-chain': 'Indexability', 'robots-conflict': 'Indexability',
39
+ 'canonical-noindex': 'Indexability', 'canonical-paginated': 'Indexability',
40
+
41
+ // Content
42
+ 'title-missing': 'Content', 'title-long': 'Content', 'title-short': 'Content',
43
+ 'desc-missing': 'Content', 'desc-long': 'Content', 'desc-short': 'Content',
44
+ 'h1-missing': 'Content', 'h1-multiple': 'Content', 'heading-skip': 'Content',
45
+ 'thin-content': 'Content', 'duplicate-title': 'Content', 'duplicate-description': 'Content',
46
+ 'duplicate-content': 'Content', 'duplicate-content-not-checked': 'Content',
47
+ 'lang-missing': 'Content', 'charset-missing': 'Content', 'viewport-missing': 'Content',
48
+ 'viewport-locked': 'Content', 'viewport-fixed-width': 'Content',
49
+
50
+ // Links
51
+ 'broken-link': 'Links', 'orphan-page': 'Links', 'no-editorial-links': 'Links',
52
+ 'deep-page': 'Links', 'deep-page-more': 'Links', 'no-path-from-home': 'Links',
53
+ 'click-depth-skipped': 'Links', 'orphan-check-skipped': 'Links', 'link-no-text': 'Links', 'link-no-text-more': 'Links',
54
+ 'anchor-generic': 'Links', 'anchor-generic-more': 'Links',
55
+ 'anchor-ambiguous': 'Links', 'anchor-ambiguous-more': 'Links',
56
+ 'link-redirects': 'Links', 'link-sweep-capped': 'Links', 'internal-nofollow': 'Links',
57
+ 'missing-from-sitemap': 'Links', 'missing-from-sitemap-more': 'Links',
58
+ 'external-broken': 'Links', 'external-redirects': 'Links', 'external-sweep-capped': 'Links',
59
+
60
+ // Redirects
61
+ 'sitemap-redirect': 'Redirects', 'redirect-chain': 'Redirects', 'host-variant-dead': 'Redirects',
62
+ 'host-variant-not-checked': 'Redirects',
63
+ 'origin-redirected': 'Redirects',
64
+ 'serves-differently': 'Indexability', 'compare-sampled': 'Indexability',
65
+ 'search-console': 'Site & security', 'search-console-unconfigured': 'Site & security',
66
+ 'search-console-failed': 'Site & security',
67
+ 'trailing-slash': 'Redirects', 'meta-refresh': 'Redirects',
68
+ 'redirect-dead': 'Redirects', 'redirect-broken': 'Redirects',
69
+ 'redirect-not-applied': 'Redirects', 'redirect-hops': 'Redirects',
70
+ 'redirect-elsewhere': 'Redirects', 'redirect-temporary': 'Redirects',
71
+ 'redirect-pattern-skipped': 'Redirects', 'redirect-map-capped': 'Redirects',
72
+
73
+ // Images
74
+ 'img-alt': 'Images', 'img-alt-filename': 'Images', 'img-alt-placeholder': 'Images',
75
+ 'img-alt-duplicate': 'Images', 'img-alt-long': 'Images', 'img-dimensions': 'Images',
76
+ 'img-srcset': 'Images', 'broken-image': 'Images', 'image-sweep-capped': 'Images',
77
+ 'img-title-duplicates-alt': 'Images', 'img-title-on-decorative': 'Images',
78
+ 'img-lazy-priority': 'Images',
79
+
80
+ // Social
81
+ 'og-missing': 'Social', 'og-webp': 'Social', 'og-no-dimensions': 'Social',
82
+ 'og-image-relative': 'Social', 'og-image-broken': 'Social', 'og-image-heavy': 'Social',
83
+
84
+ // Structured data
85
+ 'jsonld-invalid': 'Structured data', 'jsonld-no-type': 'Structured data',
86
+ 'schema-expected': 'Structured data', 'schema-incomplete': 'Structured data',
87
+ 'schema-date-order': 'Structured data', 'schema-date-future': 'Structured data',
88
+ 'schema-image-broken': 'Structured data',
89
+
90
+ // Multilingual
91
+ 'hreflang-one-way': 'Multilingual', 'hreflang-no-self': 'Multilingual',
92
+ 'hreflang-lang-mismatch': 'Multilingual', 'hreflang-invalid': 'Multilingual',
93
+ 'hreflang-no-x-default': 'Multilingual', 'hreflang-dead': 'Multilingual',
94
+ 'content-language-mismatch': 'Multilingual',
95
+
96
+ // Sitemap & robots
97
+ 'no-sitemap': 'Sitemap & robots', truncated: 'Sitemap & robots',
98
+ // What the run was told to leave out. Facts about the crawl rather than
99
+ // about the site, and they sit beside `truncated` for that reason.
100
+ since: 'Sitemap & robots', 'since-not-usable': 'Sitemap & robots',
101
+ excluded: 'Sitemap & robots',
102
+ 'sitemap-not-checked': 'Sitemap & robots',
103
+ 'rate-limit-slowed': 'Sitemap & robots',
104
+ 'robots-missing': 'Sitemap & robots', 'robots-blocks-all': 'Sitemap & robots',
105
+ 'robots-no-sitemap': 'Sitemap & robots', 'robots-blocks-sitemap-url': 'Sitemap & robots',
106
+ 'sitemap-lastmod-missing': 'Sitemap & robots', 'sitemap-lastmod-identical': 'Sitemap & robots',
107
+ 'sitemap-lastmod-future': 'Sitemap & robots', 'llms-missing': 'Sitemap & robots',
108
+ 'sitemap-duplicate-url': 'Sitemap & robots',
109
+ 'sitemap-not-indexable': 'Sitemap & robots', 'sitemap-too-many-urls': 'Sitemap & robots',
110
+ 'sitemap-too-large': 'Sitemap & robots',
111
+
112
+ // Site & security
113
+ 'favicon-broken': 'Site & security', 'favicon-missing': 'Site & security',
114
+ 'mixed-content': 'Site & security', 'tls-not-checked': 'Site & security',
115
+ // The note the hosted Worker leaves where the certificate checks would
116
+ // have been. It belongs beside them, not in Other.
117
+ 'tls-expiring': 'Site & security',
118
+ 'tls-expired': 'Site & security', 'url-uppercase': 'Site & security',
119
+ 'url-underscore': 'Site & security', 'url-space': 'Site & security',
120
+ 'header-strict-transport-security': 'Site & security',
121
+ 'header-x-content-type-options': 'Site & security',
122
+ 'header-referrer-policy': 'Site & security',
123
+ 'header-content-security-policy': 'Site & security',
124
+
125
+ // Performance
126
+ slow: 'Performance', uncompressed: 'Performance', 'huge-html': 'Performance', 'psi-score': 'Performance', 'psi-lcp': 'Performance',
127
+ 'psi-cls': 'Performance', 'psi-inp': 'Performance', 'psi-opportunity': 'Performance',
128
+ 'psi-failed': 'Performance', 'psi-no-field-data': 'Performance',
129
+ 'psi-field-lcp': 'Performance', 'psi-field-cls': 'Performance', 'psi-field-inp': 'Performance',
130
+ 'psi-sampled': 'Performance', 'psi-no-match': 'Performance',
131
+ };
132
+
133
+ /** The category a check belongs to. Unknown ids fall to "Other" rather than
134
+ * disappearing — and a test asserts nothing in src/ ever lands there. */
135
+ export const categoryOf = (id) => CATEGORY_OF[id] ?? 'Other';