spiderlint 0.0.0 → 0.143.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 +235 -0
- package/README.md +244 -0
- package/bin/spiderlint.js +11 -0
- package/dist/agent.js +46 -0
- package/dist/cache/extractors.js +37 -0
- package/dist/cache/http.js +99 -0
- package/dist/cache/index.js +101 -0
- package/dist/cache/purge.js +63 -0
- package/dist/cache/status.js +52 -0
- package/dist/cli.js +602 -0
- package/dist/color.js +10 -0
- package/dist/config/environment.js +88 -0
- package/dist/config/index.js +104 -0
- package/dist/config/policy.js +170 -0
- package/dist/config/schema.js +127 -0
- package/dist/crawl/body.js +92 -0
- package/dist/crawl/browser.js +498 -0
- package/dist/crawl/crawl.js +58 -0
- package/dist/crawl/diversity.js +153 -0
- package/dist/crawl/dns.js +194 -0
- package/dist/crawl/fetch.js +48 -0
- package/dist/crawl/frontier.js +370 -0
- package/dist/crawl/guard.js +69 -0
- package/dist/crawl/http.js +171 -0
- package/dist/crawl/links.js +89 -0
- package/dist/crawl/log.js +27 -0
- package/dist/crawl/network.js +88 -0
- package/dist/crawl/probe.js +107 -0
- package/dist/crawl/profile.js +33 -0
- package/dist/crawl/rdap.js +86 -0
- package/dist/crawl/resolve.js +132 -0
- package/dist/crawl/resources.js +233 -0
- package/dist/crawl/robots.js +100 -0
- package/dist/crawl/route.js +92 -0
- package/dist/crawl/scope.js +32 -0
- package/dist/crawl/scratch.js +15 -0
- package/dist/crawl/sitemap.js +187 -0
- package/dist/crawl/svcb.js +59 -0
- package/dist/crawl/tls-hello.js +297 -0
- package/dist/crawl/tls-probe.js +71 -0
- package/dist/facts/browser.js +138 -0
- package/dist/facts/byline.js +17 -0
- package/dist/facts/co2.js +27 -0
- package/dist/facts/csp.js +71 -0
- package/dist/facts/export.js +79 -0
- package/dist/facts/flatten.js +33 -0
- package/dist/facts/graph.js +73 -0
- package/dist/facts/headers.js +330 -0
- package/dist/facts/html.js +230 -0
- package/dist/facts/labels.js +89 -0
- package/dist/facts/language.js +32 -0
- package/dist/facts/parity.js +38 -0
- package/dist/facts/read-note.js +11 -0
- package/dist/facts/resources.js +46 -0
- package/dist/facts/robots.js +13 -0
- package/dist/facts/sites.js +140 -0
- package/dist/facts/transport.js +200 -0
- package/dist/facts/types.js +4 -0
- package/dist/facts/vendors.js +76 -0
- package/dist/fold/index.js +94 -0
- package/dist/groups/assign.js +45 -0
- package/dist/groups/sample.js +60 -0
- package/dist/i18n.js +92 -0
- package/dist/index.js +607 -0
- package/dist/interrupt.js +37 -0
- package/dist/logger.js +95 -0
- package/dist/plugins/axe.js +104 -0
- package/dist/plugins/carbon-txt.js +96 -0
- package/dist/plugins/cookies-registry.js +25 -0
- package/dist/plugins/cookies.js +168 -0
- package/dist/plugins/css.js +328 -0
- package/dist/plugins/dns.js +864 -0
- package/dist/plugins/feed-content.js +132 -0
- package/dist/plugins/feed-model.js +282 -0
- package/dist/plugins/feeds.js +836 -0
- package/dist/plugins/html-validate.js +191 -0
- package/dist/plugins/htmlhint.js +95 -0
- package/dist/plugins/icons.js +302 -0
- package/dist/plugins/images.js +269 -0
- package/dist/plugins/index.js +286 -0
- package/dist/plugins/keyboard.js +194 -0
- package/dist/plugins/lighthouse.js +110 -0
- package/dist/plugins/link-text.js +73 -0
- package/dist/plugins/list.js +22 -0
- package/dist/plugins/live.js +254 -0
- package/dist/plugins/mail.js +985 -0
- package/dist/plugins/manifest.js +97 -0
- package/dist/plugins/markup.js +98 -0
- package/dist/plugins/messages.js +25 -0
- package/dist/plugins/network.js +192 -0
- package/dist/plugins/origin.js +488 -0
- package/dist/plugins/profile-links.js +102 -0
- package/dist/plugins/schema-registry.js +101 -0
- package/dist/plugins/sshfp.js +159 -0
- package/dist/plugins/stack.js +161 -0
- package/dist/plugins/structured-data.js +358 -0
- package/dist/plugins/tls-probe.js +298 -0
- package/dist/plugins/tls-registry.js +418 -0
- package/dist/plugins/tls-scan.js +280 -0
- package/dist/plugins/trackers-registry.js +27 -0
- package/dist/plugins/trackers.js +56 -0
- package/dist/plugins/types.js +7 -0
- package/dist/plugins/visit.js +50 -0
- package/dist/plugins/well-known-registry.js +111 -0
- package/dist/plugins/well-known.js +732 -0
- package/dist/progress.js +162 -0
- package/dist/report/agent.js +122 -0
- package/dist/report/checkstyle.js +22 -0
- package/dist/report/csv.js +18 -0
- package/dist/report/html.js +266 -0
- package/dist/report/human.js +220 -0
- package/dist/report/index.js +16 -0
- package/dist/report/json.js +8 -0
- package/dist/report/printable.js +29 -0
- package/dist/report/rating.js +26 -0
- package/dist/report/sarif.js +100 -0
- package/dist/report/stats.js +46 -0
- package/dist/rules/builtin.js +465 -0
- package/dist/rules/caching.js +103 -0
- package/dist/rules/catalog.js +139 -0
- package/dist/rules/clock.js +48 -0
- package/dist/rules/declarative.js +234 -0
- package/dist/rules/deprecated.js +45 -0
- package/dist/rules/disclosure.js +99 -0
- package/dist/rules/fix.js +21 -0
- package/dist/rules/i18n.js +131 -0
- package/dist/rules/insights.js +100 -0
- package/dist/rules/length.js +52 -0
- package/dist/rules/message.js +48 -0
- package/dist/rules/relations.js +88 -0
- package/dist/rules/robots.js +75 -0
- package/dist/rules/rulesets.js +134 -0
- package/dist/rules/run.js +75 -0
- package/dist/rules/score.js +61 -0
- package/dist/rules/types.js +7 -0
- package/dist/rules/url.js +90 -0
- package/dist/server/analytics.js +49 -0
- package/dist/server/api.js +134 -0
- package/dist/server/clients.js +54 -0
- package/dist/server/clock.js +33 -0
- package/dist/server/compress.js +58 -0
- package/dist/server/health.js +14 -0
- package/dist/server/jobs.js +82 -0
- package/dist/server/main.js +83 -0
- package/dist/server/page.js +161 -0
- package/dist/server/policy.js +145 -0
- package/dist/server/providers.js +60 -0
- package/dist/server/queue.js +50 -0
- package/dist/server/scan.js +39 -0
- package/dist/server/settings.js +197 -0
- package/dist/server/web.js +353 -0
- package/dist/server/worker.js +129 -0
- package/dist/store/disk.js +215 -0
- package/dist/store/memory.js +18 -0
- package/dist/telemetry-environment.js +9 -0
- package/dist/telemetry.js +104 -0
- package/locales/es/LC_MESSAGES/messages.po +1864 -0
- package/locales/uk/LC_MESSAGES/messages.po +1864 -0
- package/package.json +134 -2
- package/presets/browser.yaml +26 -0
- package/presets/graph.yaml +35 -0
- package/presets/i18n.yaml +18 -0
- package/presets/insights.yaml +12 -0
- package/presets/links.yaml +22 -0
- package/presets/parity.yaml +59 -0
- package/presets/performance.yaml +271 -0
- package/presets/recommended.yaml +50 -0
- package/presets/redirects.yaml +23 -0
- package/presets/resources.yaml +29 -0
- package/presets/robots.yaml +13 -0
- package/presets/security-headers.yaml +257 -0
- package/presets/seo.yaml +380 -0
- package/presets/sitemap.yaml +34 -0
- package/presets/suggestions.yaml +97 -0
- package/presets/sustainability.yaml +16 -0
- package/presets/tls.yaml +72 -0
- package/presets/vendor.yaml +14 -0
- package/presets/web-comprehensive.yaml +17 -0
- package/presets/web-quick.yaml +15 -0
- package/src/server/apple-touch-icon.png +0 -0
- package/src/server/logo-36.png +0 -0
- package/src/server/logo-72.png +0 -0
- package/src/server/logo.png +0 -0
- package/vendors/dns.yaml +21 -0
- package/vendors/paths.yaml +55 -0
- package/vendors/stack.yaml +57 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
import { reason } from "../crawl/fetch.js";
|
|
7
|
+
import { RobotsDisallowed } from "../crawl/probe.js";
|
|
8
|
+
import { log } from "../logger.js";
|
|
9
|
+
import { header, linkTargets, pageRule, resolve } from "../rules/builtin.js";
|
|
10
|
+
import { said } from "../rules/message.js";
|
|
11
|
+
import { judgeContent, judgeTitle, wordsOf } from "./feed-content.js";
|
|
12
|
+
import { NS, readJson, readXml } from "./feed-model.js";
|
|
13
|
+
import { definePlugin } from "./types.js";
|
|
14
|
+
const ID = "feed";
|
|
15
|
+
const FEED_TYPES = new Set(["application/rss+xml", "application/atom+xml", "application/rdf+xml", "application/feed+json"]);
|
|
16
|
+
const WRONG_TYPES = new Set(["text/html", "text/plain", "application/octet-stream", ""]);
|
|
17
|
+
const XML_ROOT = /^\s*(?:<\?xml[^>]*>\s*)?(?:<\?xml-stylesheet[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<\?xml-stylesheet[^>]*>\s*)?<(?:rss|feed|rdf:RDF)[\s>]/;
|
|
18
|
+
const TRACKING = /^(?:utm_[a-z]+|fbclid|gclid|dclid|msclkid|mc_cid|mc_eid|_hsenc|_hsmi|mkt_tok)$/i;
|
|
19
|
+
const PROBLEM_CAP = 50;
|
|
20
|
+
const DAY_MS = 86_400_000;
|
|
21
|
+
const PODCAST_NAMESPACE = "ead4c236-bf58-58c6-a2c6-a6b28d128cb6";
|
|
22
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
23
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
24
|
+
const RFC822 = /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*)?(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{4})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s+([+-]\d{4}|UT|GMT|[ECMP][SD]T|Z)$/;
|
|
25
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
|
|
26
|
+
const ZONES = { UT: "+0000", GMT: "+0000", Z: "+0000", EST: "-0500", EDT: "-0400", CST: "-0600", CDT: "-0500", MST: "-0700", MDT: "-0600", PST: "-0800", PDT: "-0700" };
|
|
27
|
+
const DURATION = /^(?:\d+|\d{1,2}:\d{2}(?::\d{2})?)$/;
|
|
28
|
+
const XSL_TYPES = new Set(["text/xsl", "application/xslt+xml", "application/xml", "text/xml"]);
|
|
29
|
+
const FORMAT_TYPES = { "application/rss+xml": "rss", "application/rdf+xml": "rss", "application/atom+xml": "atom", "application/feed+json": "json" };
|
|
30
|
+
// A URL with its tracking query parameters removed; anything unparsable stays as written.
|
|
31
|
+
export function untracked(raw, base) {
|
|
32
|
+
const href = resolve(raw, base);
|
|
33
|
+
if (!URL.canParse(href))
|
|
34
|
+
return href;
|
|
35
|
+
const url = new URL(href);
|
|
36
|
+
const tracking = url.searchParams
|
|
37
|
+
.keys()
|
|
38
|
+
.filter((name) => TRACKING.test(name))
|
|
39
|
+
.toArray();
|
|
40
|
+
for (const name of tracking)
|
|
41
|
+
url.searchParams.delete(name);
|
|
42
|
+
return url.href;
|
|
43
|
+
}
|
|
44
|
+
// ISO form of an RFC 822 date, undefined when it breaks the grammar or names the wrong weekday.
|
|
45
|
+
function rfc822(raw) {
|
|
46
|
+
const match = RFC822.exec(raw.trim());
|
|
47
|
+
if (!match)
|
|
48
|
+
return undefined;
|
|
49
|
+
const [, day, date, month, year, hours, minutes, seconds = "00", zone = ""] = match;
|
|
50
|
+
const offset = ZONES[zone] ?? zone;
|
|
51
|
+
const calendar = new Date(Date.UTC(Number(year), MONTHS.indexOf(month), Number(date)));
|
|
52
|
+
if (calendar.getUTCDate() !== Number(date) || (day && DAYS[calendar.getUTCDay()] !== day))
|
|
53
|
+
return undefined;
|
|
54
|
+
const time = Date.parse(`${calendar.toISOString().slice(0, 10)}T${hours}:${minutes}:${seconds}${offset.slice(0, 3)}:${offset.slice(3)}`);
|
|
55
|
+
return Number.isNaN(time) ? undefined : new Date(time).toISOString();
|
|
56
|
+
}
|
|
57
|
+
// ISO form of a feed date in its format’s grammar: RFC 822 in RSS, RFC 3339 in Atom and JSON Feed.
|
|
58
|
+
function isoOf(format, date) {
|
|
59
|
+
if (format === "rss" && date.field === "dc:date")
|
|
60
|
+
return RFC3339.test(date.raw) || /^\d{4}-\d{2}-\d{2}$/.test(date.raw) ? new Date(date.raw).toISOString() : undefined;
|
|
61
|
+
if (format === "rss")
|
|
62
|
+
return rfc822(date.raw);
|
|
63
|
+
return RFC3339.test(date.raw) && !Number.isNaN(Date.parse(date.raw)) ? new Date(date.raw).toISOString() : undefined;
|
|
64
|
+
}
|
|
65
|
+
// Whether a value is a well-formed BCP 47 tag; `Intl` alone accepts bare names like `English`.
|
|
66
|
+
function isLanguage(tag) {
|
|
67
|
+
if (!/^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})*$/i.test(tag))
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
return Intl.getCanonicalLocales(tag).length === 1;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// The UUIDv5 Podcasting 2.0 derives from a feed URL without its scheme and trailing slashes.
|
|
77
|
+
export function podcastGuid(feedUrl) {
|
|
78
|
+
const name = feedUrl.replace(/^[a-z]+:\/\//i, "").replace(/\/+$/, "");
|
|
79
|
+
const hash = createHash("sha1")
|
|
80
|
+
.update(Buffer.from(PODCAST_NAMESPACE.replaceAll("-", ""), "hex"))
|
|
81
|
+
.update(name)
|
|
82
|
+
.digest();
|
|
83
|
+
hash[6] = (hash[6] & 0x0f) | 0x50;
|
|
84
|
+
hash[8] = (hash[8] & 0x3f) | 0x80;
|
|
85
|
+
const hex = hash.subarray(0, 16).toString("hex");
|
|
86
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
87
|
+
}
|
|
88
|
+
// Problem lists filled one location at a time, each capped.
|
|
89
|
+
class Problems {
|
|
90
|
+
lists = {};
|
|
91
|
+
add(rule, location) {
|
|
92
|
+
const list = (this.lists[rule] ??= []);
|
|
93
|
+
if (list.length < PROBLEM_CAP && !list.includes(location))
|
|
94
|
+
list.push(location);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// The feed format a page’s type or root element announces.
|
|
98
|
+
function formatOf(page, body) {
|
|
99
|
+
const type = page.http["content-type"];
|
|
100
|
+
if (type === "application/feed+json" || ((type === "application/json" || WRONG_TYPES.has(type)) && /"version"\s*:\s*"https:\/\/jsonfeed\.org\/version\//.test(body.slice(0, 4096))))
|
|
101
|
+
return "json";
|
|
102
|
+
if (!FEED_TYPES.has(type) && !((/[/+]xml$/.test(type) || WRONG_TYPES.has(type)) && XML_ROOT.test(body)))
|
|
103
|
+
return undefined;
|
|
104
|
+
return type === "application/atom+xml" || /<feed[\s>]/.test(body.slice(0, 4096)) ? "atom" : "rss";
|
|
105
|
+
}
|
|
106
|
+
// Spec conformance of the channel and every item.
|
|
107
|
+
function judgeSpec(model, problems, base) {
|
|
108
|
+
const where = model.format === "rss" ? "channel" : "feed";
|
|
109
|
+
for (const name of model.missing)
|
|
110
|
+
problems.add("required", `${where} lacks ${name}`);
|
|
111
|
+
const ids = new Map();
|
|
112
|
+
for (const item of model.items) {
|
|
113
|
+
const at = `item ${item.position}`;
|
|
114
|
+
for (const name of item.missing)
|
|
115
|
+
problems.add("required", `${at} lacks ${name}`);
|
|
116
|
+
if (model.format !== "rss" && !model.hasAuthor && !item.hasAuthor)
|
|
117
|
+
problems.add("required", `${at} names no author and the feed names none`);
|
|
118
|
+
for (const enclosure of item.enclosures)
|
|
119
|
+
for (const [name, value] of Object.entries(enclosure))
|
|
120
|
+
if (!value && model.format === "rss")
|
|
121
|
+
problems.add("required", `${at} enclosure lacks ${name}`);
|
|
122
|
+
for (const date of [item.published, item.updated])
|
|
123
|
+
if (date && !isoOf(model.format, date))
|
|
124
|
+
problems.add("date-format", `${at} ${date.field} “${date.raw}”`);
|
|
125
|
+
if (item.id !== undefined) {
|
|
126
|
+
const first = ids.get(item.id);
|
|
127
|
+
if (first === undefined) {
|
|
128
|
+
ids.set(item.id, item.position);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
problems.add("duplicate-id", `${at} repeats the id of item ${first}: ${item.id}`);
|
|
132
|
+
}
|
|
133
|
+
if (URL.canParse(item.id) && new URL(item.id).searchParams.keys().some((name) => TRACKING.test(name)))
|
|
134
|
+
problems.add("id-tracking", `${at}: ${item.id}`);
|
|
135
|
+
}
|
|
136
|
+
if (item.isPermalink && item.id !== undefined && !/^https?:\/\//i.test(item.id))
|
|
137
|
+
problems.add("permalink", `${at} guid “${item.id}”`);
|
|
138
|
+
const urls = [["link", item.link], ...item.enclosures.map((enclosure) => ["enclosure", enclosure.url])];
|
|
139
|
+
if (model.format !== "atom")
|
|
140
|
+
for (const [field, url] of urls)
|
|
141
|
+
if (url && !/^[a-z][a-z\d+.-]*:/i.test(url))
|
|
142
|
+
problems.add("absolute-url", `${at} ${field} “${url}”`);
|
|
143
|
+
for (const name of item.unknown)
|
|
144
|
+
problems.add(model.format === "json" ? "json-version" : "unknown-element", model.format === "json" ? `${at} uses author, which 1.1 replaced with authors` : `${at} <${name}>`);
|
|
145
|
+
for (const email of item.emails)
|
|
146
|
+
if (!/^\S+@\S+\.\S+(?:\s+\(.+\))?$/.test(email.raw))
|
|
147
|
+
problems.add("email", `${at} ${email.field} “${email.raw}”`);
|
|
148
|
+
if (item.title !== undefined)
|
|
149
|
+
for (const problem of judgeTitle(item.title, item.titleType))
|
|
150
|
+
problems.add("title-markup", `${at}: ${problem}`);
|
|
151
|
+
for (const content of item.contents) {
|
|
152
|
+
const found = judgeContent(content);
|
|
153
|
+
for (const [rule, samples] of Object.entries(found))
|
|
154
|
+
for (const sample of samples)
|
|
155
|
+
problems.add(rule === "mojibake" ? "charset" : rule, `${at} ${content.field}: ${sample}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (model.updated && !isoOf(model.format, model.updated))
|
|
159
|
+
problems.add("date-format", `${where} ${model.updated.field} “${model.updated.raw}”`);
|
|
160
|
+
if (model.link && model.format !== "atom" && !/^[a-z][a-z\d+.-]*:/i.test(model.link))
|
|
161
|
+
problems.add("absolute-url", `${where} link “${model.link}”`);
|
|
162
|
+
if (model.language && !isLanguage(model.language))
|
|
163
|
+
problems.add("language", `${where} language “${model.language}”`);
|
|
164
|
+
for (const name of model.unknown)
|
|
165
|
+
problems.add(model.format === "json" ? "json-version" : "unknown-element", model.format === "json" ? "feed uses author, which 1.1 replaced with authors" : `${where} <${name}>`);
|
|
166
|
+
for (const email of model.emails)
|
|
167
|
+
if (!/^\S+@\S+\.\S+(?:\s+\(.+\))?$/.test(email.raw))
|
|
168
|
+
problems.add("email", `${where} ${email.field} “${email.raw}”`);
|
|
169
|
+
if (model.format === "json" && model.version !== "https://jsonfeed.org/version/1.1")
|
|
170
|
+
problems.add("json-version", `version ${model.version}`);
|
|
171
|
+
if (model.ttl !== undefined && !/^\d+$/.test(model.ttl))
|
|
172
|
+
problems.add("required", `${where} ttl “${model.ttl}” is not a whole number of minutes`);
|
|
173
|
+
if (model.isComplete && (model.archives["prev-archive"] || model.archives["next-archive"]))
|
|
174
|
+
problems.add("archive", `${where} is complete and still names archive pages`);
|
|
175
|
+
log.debug({ url: base, format: model.format, items: model.items.length, rules: Object.keys(problems.lists) }, "feed judged");
|
|
176
|
+
}
|
|
177
|
+
// What a podcast directory requires of the channel and its episodes.
|
|
178
|
+
function judgePodcast(model, problems, self) {
|
|
179
|
+
const declared = new Set(Object.entries(model.itunes).flatMap(([name, value]) => (value ? [name] : [])));
|
|
180
|
+
for (const name of ["image", "category", "explicit", "author"])
|
|
181
|
+
if (!declared.has(name))
|
|
182
|
+
problems.add("itunes-required", `channel lacks itunes:${name}`);
|
|
183
|
+
if (!model.language)
|
|
184
|
+
problems.add("itunes-required", "channel lacks language");
|
|
185
|
+
for (const item of model.items)
|
|
186
|
+
for (const duration of item.durations)
|
|
187
|
+
if (!DURATION.test(duration))
|
|
188
|
+
problems.add("itunes-required", `item ${item.position} itunes:duration “${duration}”`);
|
|
189
|
+
const expected = podcastGuid(self);
|
|
190
|
+
if (!model.podcastGuid)
|
|
191
|
+
problems.add("podcast-guid", `channel lacks podcast:guid; ${expected} derives from ${self}`);
|
|
192
|
+
else if (model.podcastGuid.toLowerCase() !== expected)
|
|
193
|
+
problems.add("podcast-guid", `podcast:guid ${model.podcastGuid} is not ${expected}, the UUIDv5 of ${self}`);
|
|
194
|
+
log.debug({ url: self, itunes: Object.keys(model.itunes), guid: model.podcastGuid, expected }, "podcast judged");
|
|
195
|
+
}
|
|
196
|
+
// How the page was served: its type and the charset its header and declaration agree on.
|
|
197
|
+
function judgeServed(page, body, format, problems) {
|
|
198
|
+
const type = page.http["content-type"];
|
|
199
|
+
if (WRONG_TYPES.has(type))
|
|
200
|
+
problems.add("media-type", `served as ${type || "no type"}`);
|
|
201
|
+
const declared = /^\s*<\?xml[^>]*\bencoding\s*=\s*["']([^"']+)["']/i.exec(body)?.[1]?.toLowerCase();
|
|
202
|
+
const sent = /;\s*charset\s*=\s*"?([^";\s]+)/i.exec(header(page, "content-type"))?.[1]?.toLowerCase();
|
|
203
|
+
if (format !== "json" && sent && declared && sent.replace("-", "") !== declared.replace("-", ""))
|
|
204
|
+
problems.add("charset", `Content-Type says ${sent}, the XML declaration ${declared}`);
|
|
205
|
+
if (format === "json" && sent && sent.replace("-", "") !== "utf8")
|
|
206
|
+
problems.add("charset", `JSON Feed served as ${sent}, not UTF-8`);
|
|
207
|
+
}
|
|
208
|
+
// Feed facts of an RSS, Atom or JSON Feed page, the `Link` header adding a self or hub the body lacks.
|
|
209
|
+
async function extract(page, body) {
|
|
210
|
+
const format = formatOf(page, body);
|
|
211
|
+
if (!format || page.http.size.truncated) {
|
|
212
|
+
log.debug({ url: page.url.href, contentType: page.http["content-type"], format, truncated: page.http.size.truncated === true }, "feed skipped");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
let model;
|
|
216
|
+
try {
|
|
217
|
+
model = format === "json" ? readJson(body, page.url.href) : readXml(body, page.url.href);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
221
|
+
log.debug({ url: page.url.href, format, error: message }, "feed unparsable");
|
|
222
|
+
return { format, error: message, hubs: [], items: 0, unidentified: [] };
|
|
223
|
+
}
|
|
224
|
+
if (!model)
|
|
225
|
+
return;
|
|
226
|
+
const link = header(page, "link");
|
|
227
|
+
const self = model.self ?? linkTargets(link, "self", page.url.href)[0];
|
|
228
|
+
const hubs = model.hubs.length > 0 ? model.hubs : linkTargets(link, "hub", page.url.href);
|
|
229
|
+
const problems = new Problems();
|
|
230
|
+
judgeSpec(model, problems, page.url.href);
|
|
231
|
+
judgeServed(page, body, model.format, problems);
|
|
232
|
+
const isPodcast = model.namespaces.has(NS.itunes) || model.namespaces.has(NS.podcast) || model.items.some((item) => item.enclosures.some((enclosure) => /^(?:audio|video)\//.test(enclosure.type ?? "")));
|
|
233
|
+
if (isPodcast)
|
|
234
|
+
judgePodcast(model, problems, self ?? page.url.href);
|
|
235
|
+
const entries = model.items.map((item) => {
|
|
236
|
+
const published = (item.published ?? item.updated) && isoOf(model.format, (item.published ?? item.updated));
|
|
237
|
+
const words = item.contents.reduce((total, content) => total + wordsOf(content), 0);
|
|
238
|
+
return { position: item.position, ...(item.id && { id: item.id }), ...(item.link && { link: untracked(item.link, page.url.href) }), ...(item.title && { title: item.title }), ...(published && { published }), ...(words > 0 && { words }) };
|
|
239
|
+
});
|
|
240
|
+
const updated = model.updated && isoOf(model.format, model.updated);
|
|
241
|
+
const enclosures = model.items.flatMap((item) => item.enclosures.flatMap((enclosure) => {
|
|
242
|
+
if (!enclosure.url || !URL.canParse(enclosure.url, page.url.href))
|
|
243
|
+
return [];
|
|
244
|
+
const url = new URL(enclosure.url, page.url.href);
|
|
245
|
+
url.hash = "";
|
|
246
|
+
return /^https?:$/.test(url.protocol) ? [{ url: url.href, ...(enclosure.length !== undefined && { length: enclosure.length }), ...(enclosure.type !== undefined && { type: enclosure.type }), position: item.position }] : [];
|
|
247
|
+
}));
|
|
248
|
+
const image = model.itunes.image ? resolve(model.itunes.image, page.url.href) : undefined;
|
|
249
|
+
const itunesImage = image && URL.canParse(image) && /^https?:$/.test(new URL(image).protocol) ? new URL(image).href : undefined;
|
|
250
|
+
log.debug({ url: page.url.href, format, self, hubs: hubs.length, items: model.items.length, isPodcast, problems: Object.keys(problems.lists).length }, "feed read");
|
|
251
|
+
const seen = new Set((page.resources ?? []).map((resource) => `${resource.kind} ${resource.url}`));
|
|
252
|
+
for (const enclosure of enclosures) {
|
|
253
|
+
const key = `enclosure ${enclosure.url}`;
|
|
254
|
+
if (seen.has(key))
|
|
255
|
+
continue;
|
|
256
|
+
seen.add(key);
|
|
257
|
+
const url = new URL(enclosure.url);
|
|
258
|
+
(page.resources ??= []).push({ url: enclosure.url, kind: "enclosure", origin: url.origin === page.url.origin ? "same" : "cross" });
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
format: model.format,
|
|
262
|
+
...(self && { self }),
|
|
263
|
+
hubs,
|
|
264
|
+
items: model.items.length,
|
|
265
|
+
unidentified: model.items.filter((item) => !item.id?.trim()).map((item) => item.position),
|
|
266
|
+
...(model.language && { language: model.language }),
|
|
267
|
+
...(updated && { updated }),
|
|
268
|
+
...(model.ttl && /^\d+$/.test(model.ttl) && { ttl: Number(model.ttl) }),
|
|
269
|
+
...(model.stylesheet && { stylesheet: model.stylesheet }),
|
|
270
|
+
...(Object.keys(model.archives).length > 0 && { archives: model.archives }),
|
|
271
|
+
...(isPodcast && { podcast: true }),
|
|
272
|
+
entries,
|
|
273
|
+
...(enclosures.length > 0 && { enclosures }),
|
|
274
|
+
...(itunesImage && { itunesImage }),
|
|
275
|
+
...(model.locked !== undefined && { locked: model.locked }),
|
|
276
|
+
problems: problems.lists,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const feedOf = (page) => page[ID];
|
|
280
|
+
// A feed that parsed, else undefined so its rule skips.
|
|
281
|
+
const parsed = (page) => (feedOf(page)?.error === undefined ? feedOf(page) : undefined);
|
|
282
|
+
const DOCS = {
|
|
283
|
+
rss: "https://www.rssboard.org/rss-profile",
|
|
284
|
+
rss2: "https://www.rssboard.org/rss-specification",
|
|
285
|
+
atom: "https://www.rfc-editor.org/rfc/rfc4287",
|
|
286
|
+
json: "https://www.jsonfeed.org/version/1.1/",
|
|
287
|
+
websub: "https://www.w3.org/TR/websub/#discovery",
|
|
288
|
+
archive: "https://www.rfc-editor.org/rfc/rfc5005",
|
|
289
|
+
xslt: "https://chromestatus.com/feature/4709671889534976",
|
|
290
|
+
itunes: "https://podcasters.apple.com/support/823-podcast-requirements",
|
|
291
|
+
podcast: "https://podcasting2.org/docs/podcast-namespace/tags/guid",
|
|
292
|
+
locked: "https://podcasting2.org/docs/podcast-namespace/tags/locked",
|
|
293
|
+
discovery: "https://www.rssboard.org/rss-autodiscovery",
|
|
294
|
+
};
|
|
295
|
+
// The subject every problem sentence opens with.
|
|
296
|
+
const FEED = "{format} feed";
|
|
297
|
+
const wellFormed = pageRule("feeds/well-formed", [`${ID}.error`], (page) => {
|
|
298
|
+
const feed = feedOf(page);
|
|
299
|
+
return feed && (feed.error ? [{ ...said("{format} feed does not parse", { format: feed.format }), data: { [page.url.href]: { error: feed.error } }, value: feed.error }] : []);
|
|
300
|
+
}, { docs: "https://www.w3.org/TR/xml/#sec-well-formed", fix: "Serve the feed from an XML or JSON serialiser instead of a text template, so every value is escaped." });
|
|
301
|
+
const self = pageRule("feeds/self", [`${ID}.self`], (page) => {
|
|
302
|
+
const feed = parsed(page);
|
|
303
|
+
if (!feed)
|
|
304
|
+
return;
|
|
305
|
+
if (!feed.self)
|
|
306
|
+
return [said("{format} feed names no self URL", { format: feed.format })];
|
|
307
|
+
const here = [page.url.href, page.url.twin].flatMap((href) => (href ? [resolve(href, href)] : []));
|
|
308
|
+
return here.includes(feed.self) ? [] : [{ ...said("{format} feed names another URL as its self URL, not this one", { format: feed.format }), data: { [page.url.href]: { self: feed.self } }, value: feed.self }];
|
|
309
|
+
}, { docs: DOCS.rss, fix: 'Add `<atom:link rel="self">` (RSS), `<link rel="self">` (Atom) or `feed_url` (JSON Feed) naming the feed’s own URL.' });
|
|
310
|
+
const itemId = pageRule("feeds/item-id", [`${ID}.unidentified`], (page) => {
|
|
311
|
+
const feed = parsed(page);
|
|
312
|
+
if (!feed)
|
|
313
|
+
return;
|
|
314
|
+
const count = feed.unidentified.length;
|
|
315
|
+
return count === 0
|
|
316
|
+
? []
|
|
317
|
+
: [{ ...said("{format} feed items carry no stable identifier, so readers show them again as new", { format: feed.format }), data: { [page.url.href]: { unidentified: count, items: feed.items } }, value: feed.unidentified, locations: feed.unidentified.map((position) => `item ${position}`) }];
|
|
318
|
+
}, { docs: DOCS.rss, fix: "Give every item a `guid` (RSS), `id` (Atom or JSON Feed) that never changes once published." });
|
|
319
|
+
const websub = pageRule("feeds/websub", [`${ID}.hubs`, `${ID}.self`], (page) => {
|
|
320
|
+
const feed = parsed(page);
|
|
321
|
+
if (!feed)
|
|
322
|
+
return;
|
|
323
|
+
return feed.hubs.length === 0 || feed.self ? [] : [{ ...said("{format} feed names a WebSub hub but no self URL to subscribe to", { format: feed.format }), data: { [page.url.href]: { hubs: feed.hubs.join(", ") } }, value: feed.hubs }];
|
|
324
|
+
}, { docs: DOCS.websub, fix: 'Declare `rel="self"` beside `rel="hub"`, in the feed or its `Link` header.' });
|
|
325
|
+
// The first value of a header that may repeat.
|
|
326
|
+
const firstHeader = (value) => (Array.isArray(value) ? value[0] : value);
|
|
327
|
+
// An enclosure HEAD answer judged against its declaration: reachability, byte and type agreement, range support.
|
|
328
|
+
const enclosure = pageRule("feeds/enclosure", [`${ID}.enclosures`, "resources"], (page) => {
|
|
329
|
+
const feed = parsed(page);
|
|
330
|
+
if (!feed?.enclosures || !feed.podcast)
|
|
331
|
+
return;
|
|
332
|
+
const answers = new Map((page.resources ?? []).filter((resource) => resource.kind === "enclosure").map((resource) => [resource.url, resource.http]));
|
|
333
|
+
const locations = feed.enclosures.flatMap(({ url, length: declaredLength, type: declaredType, position }) => {
|
|
334
|
+
const answer = answers.get(url);
|
|
335
|
+
if (!answer)
|
|
336
|
+
return [];
|
|
337
|
+
if (answer.status < 200 || answer.status > 299)
|
|
338
|
+
return [`item ${position} ${url} answers ${answer.status}`];
|
|
339
|
+
const faults = [];
|
|
340
|
+
const length = firstHeader(answer.headers["content-length"]);
|
|
341
|
+
if (length !== undefined && declaredLength !== undefined && length !== declaredLength)
|
|
342
|
+
faults.push(`serves ${length} bytes, the feed declares ${declaredLength}`);
|
|
343
|
+
const type = firstHeader(answer.headers["content-type"])?.split(";", 1)[0]?.trim().toLowerCase();
|
|
344
|
+
if (type !== undefined && declaredType !== undefined && type !== declaredType.toLowerCase())
|
|
345
|
+
faults.push(`serves ${type}, the feed declares ${declaredType}`);
|
|
346
|
+
const ranges = [answer.headers["accept-ranges"] ?? []].flat().join(",").toLowerCase();
|
|
347
|
+
if (!ranges
|
|
348
|
+
.split(",")
|
|
349
|
+
.map((token) => token.trim())
|
|
350
|
+
.includes("bytes"))
|
|
351
|
+
faults.push("sends no Accept-Ranges: bytes");
|
|
352
|
+
return faults.map((fault) => `item ${position} ${url} ${fault}`);
|
|
353
|
+
});
|
|
354
|
+
log.debug({ rule: "feeds/enclosure", url: page.url.href, enclosures: feed.enclosures.length, locations: locations.length }, "enclosures judged");
|
|
355
|
+
return locations.length === 0 ? [] : [{ ...said("{format} feed enclosures disagree with their host", { format: feed.format }), data: { [page.url.href]: { enclosures: locations.length } }, value: locations.length, locations }];
|
|
356
|
+
}, { docs: DOCS.itunes, fix: "Serve every enclosure with its declared Content-Type and Content-Length over a host answering HEAD and byte ranges." });
|
|
357
|
+
// A podcast feed without `<podcast:locked>`, which leaves the feed importable anywhere.
|
|
358
|
+
const podcastLocked = pageRule("feeds/podcast-locked", [`${ID}.locked`], (page) => {
|
|
359
|
+
const feed = parsed(page);
|
|
360
|
+
if (!feed?.podcast)
|
|
361
|
+
return;
|
|
362
|
+
if (feed.locked === undefined)
|
|
363
|
+
return [said("{format} podcast feed sets no podcast:locked, so any platform may import it", { format: feed.format })];
|
|
364
|
+
return feed.locked === "yes" || feed.locked === "no" ? [] : [{ ...said("{format} podcast feed locks with a value that is neither yes nor no", { format: feed.format }), data: { [page.url.href]: { locked: feed.locked } }, value: feed.locked }];
|
|
365
|
+
}, { docs: DOCS.locked, fix: "Add `<podcast:locked>yes</podcast:locked>` to keep the feed where it is, or `no` to let platforms import it." });
|
|
366
|
+
// A rule reporting one problem list the extractor filled, one finding per feed with each problem as a location; `podcast` rules skip other feeds.
|
|
367
|
+
function problemRule(name, label, reference, fix, isPodcast = false) {
|
|
368
|
+
const id = `feeds/${name}`;
|
|
369
|
+
return [
|
|
370
|
+
id,
|
|
371
|
+
pageRule(id, [`${ID}.problems.${name}`], (page) => {
|
|
372
|
+
const feed = parsed(page);
|
|
373
|
+
if (!feed?.problems || (isPodcast && !feed.podcast))
|
|
374
|
+
return;
|
|
375
|
+
const list = feed.problems[name] ?? [];
|
|
376
|
+
return list.length === 0 ? [] : [{ ...said(`${FEED} ${label}`, { format: feed.format }), data: { [page.url.href]: { problems: list.length } }, value: list, locations: list }];
|
|
377
|
+
}, { docs: reference, fix }),
|
|
378
|
+
];
|
|
379
|
+
}
|
|
380
|
+
const PROBLEM_RULES = Object.fromEntries([
|
|
381
|
+
problemRule("required", "lacks what its format requires", DOCS.rss2, "Add the element the location names: RSS needs a channel title, link and description and a title or description per item; Atom an id, title and updated on the feed and every entry, and an author; JSON Feed a title and an id per item."),
|
|
382
|
+
problemRule("date-format", "carries a date readers cannot parse", DOCS.rss, "Write RSS dates as RFC 822 with a four-digit year and a zone, `Wed, 02 Oct 2026 14:00:00 +0000`, and Atom and JSON Feed dates as RFC 3339, `2026-10-02T14:00:00Z`."),
|
|
383
|
+
problemRule("duplicate-id", "repeats an item identifier", DOCS.rss, "Give every item its own `guid` or `id`, so readers neither merge two posts nor drop one."),
|
|
384
|
+
problemRule("id-tracking", "puts a tracking parameter into an item identifier", DOCS.rss, "Keep `utm_*` and other campaign parameters out of `guid` and `id`; put them on the item link only, so the identifier never changes."),
|
|
385
|
+
problemRule("permalink", "names a guid that is not a URL while claiming it is a permalink", DOCS.rss2, 'Add `isPermaLink="false"` to a `guid` that is not the item’s URL.'),
|
|
386
|
+
problemRule("absolute-url", "carries a relative URL", DOCS.rss, "Write every channel, item and enclosure URL absolute, `https://example.org/posts/1`."),
|
|
387
|
+
problemRule("language", "declares a language that is not a BCP 47 tag", "https://www.rfc-editor.org/info/bcp47", "Declare the language as a BCP 47 tag, `en` or `pt-BR`."),
|
|
388
|
+
problemRule("unknown-element", "carries elements RSS 2.0 does not define", DOCS.rss2, "Move an extension element into its own namespace, `<dc:creator>`, or remove it."),
|
|
389
|
+
problemRule("email", "names an email field that is not `address (Name)`", DOCS.rss2, "Write `managingEditor`, `webMaster` and `author` as `editor@example.org (Ana Silva)`."),
|
|
390
|
+
problemRule("json-version", "uses an outdated JSON Feed form", DOCS.json, 'Declare `"version": "https://jsonfeed.org/version/1.1"` and list `authors` instead of `author`.'),
|
|
391
|
+
problemRule("raw-markup", "delivers unrendered Markdown or MDX to readers", DOCS.rss, "Render each item’s Markdown or MDX to HTML before it goes into the feed, as the page itself does."),
|
|
392
|
+
problemRule("template-leak", "delivers template placeholders or missing values to readers", DOCS.rss, "Fill every template value before the feed is written, and leave out a field that has no value."),
|
|
393
|
+
problemRule("relative-url", "links images or pages by a relative URL, which breaks in every reader", DOCS.rss, "Make every `href` and `src` inside item content absolute, or set `xml:base` on the Atom content."),
|
|
394
|
+
problemRule("unsafe-html", "carries markup readers strip", DOCS.rss, "Leave scripts, frames, forms, event handlers and positioned styles out of item content; readers remove them."),
|
|
395
|
+
problemRule("double-escaped", "escapes item content twice, so readers show tags and entities as text", DOCS.rss, "Escape item HTML once: either wrap it in CDATA or escape it, not both."),
|
|
396
|
+
problemRule("content-type", "holds HTML in a plain-text field", DOCS.atom, 'Declare Atom `type="html"` on content holding tags, or put HTML into JSON Feed `content_html`.'),
|
|
397
|
+
problemRule("title-markup", "carries markup or placeholders in an item title", DOCS.rss, "Write item titles as plain text, with no tags, entities or template values."),
|
|
398
|
+
problemRule("media-type", "is served with a type readers do not treat as a feed", DOCS.rss, "Serve RSS as `application/rss+xml`, Atom as `application/atom+xml` and JSON Feed as `application/feed+json`."),
|
|
399
|
+
problemRule("charset", "declares or decodes its encoding inconsistently", "https://www.w3.org/TR/xml/#charencoding", "Serve the feed as UTF-8 and declare the same encoding in `Content-Type` and the XML declaration."),
|
|
400
|
+
problemRule("itunes-required", "lacks what podcast directories require", DOCS.itunes, "Add `itunes:image`, `itunes:category`, `itunes:explicit`, `itunes:author` and `language` to the channel, and write each `itunes:duration` as seconds or `HH:MM:SS`.", true),
|
|
401
|
+
problemRule("podcast-guid", "has no stable Podcasting 2.0 GUID", DOCS.podcast, "Add `<podcast:guid>` with the UUIDv5 of the feed URL the location names, and keep it when the feed moves.", true),
|
|
402
|
+
]);
|
|
403
|
+
// An item dated after the response that served it.
|
|
404
|
+
const dateFuture = pageRule("feeds/date-future", [`${ID}.entries`], (page) => {
|
|
405
|
+
const feed = parsed(page);
|
|
406
|
+
if (!feed?.entries)
|
|
407
|
+
return;
|
|
408
|
+
const now = Date.parse(header(page, "date")) || Date.now();
|
|
409
|
+
const future = feed.entries.filter((entry) => entry.published && Date.parse(entry.published) > now + DAY_MS / 24);
|
|
410
|
+
log.debug({ rule: "feeds/date-future", url: page.url.href, now: new Date(now).toISOString(), future: future.length }, "feed dates compared");
|
|
411
|
+
return future.length === 0
|
|
412
|
+
? []
|
|
413
|
+
: [{ ...said("{format} feed items are dated after the response, so readers sort them above everything else", { format: feed.format }), data: { [page.url.href]: { future: future.length, items: feed.items } }, locations: future.map((entry) => `item ${entry.position}: ${entry.published}`) }];
|
|
414
|
+
}, { docs: DOCS.rss, fix: "Date each item when it was published; hold a scheduled post out of the feed until then." });
|
|
415
|
+
// A feed whose newest item, or whose build date, is old.
|
|
416
|
+
const stale = (severity, settings) => pageRule("feeds/stale", [`${ID}.entries`, `${ID}.updated`], (page) => {
|
|
417
|
+
const feed = parsed(page);
|
|
418
|
+
if (!feed?.entries || feed.entries.length === 0)
|
|
419
|
+
return;
|
|
420
|
+
const days = settings?.["stale-days"] ?? 365;
|
|
421
|
+
const now = Date.parse(header(page, "date")) || Date.now();
|
|
422
|
+
const newest = Math.max(...feed.entries.map((entry) => (entry.published ? Date.parse(entry.published) : 0)));
|
|
423
|
+
const locations = [...(newest > 0 && now - newest > days * DAY_MS ? [`newest item ${new Date(newest).toISOString()} is over ${days} days old`] : []), ...(feed.updated && newest > 0 && Date.parse(feed.updated) + DAY_MS < newest ? [`build date ${feed.updated} is before the newest item`] : [])];
|
|
424
|
+
log.debug({ rule: "feeds/stale", url: page.url.href, newest, days, locations: locations.length }, "feed age judged");
|
|
425
|
+
return locations.length === 0 ? [] : [{ ...said("{format} feed looks stale", { format: feed.format }), locations }];
|
|
426
|
+
}, { docs: DOCS.rss, fix: "Publish the feed with every new post, and set `lastBuildDate` or `updated` when it is rebuilt." })(severity);
|
|
427
|
+
// A feed readers must download in full on every poll.
|
|
428
|
+
const conditionalGet = pageRule("feeds/conditional-get", [`${ID}.format`, "http.headers.etag", "http.headers.last-modified", "http.unmodified"], (page) => {
|
|
429
|
+
const feed = parsed(page);
|
|
430
|
+
if (!feed)
|
|
431
|
+
return;
|
|
432
|
+
if (page.http.unmodified)
|
|
433
|
+
return [said("{format} feed answers a conditional request with 200 and an unchanged body instead of 304, so every reader downloads it in full on every poll", { format: feed.format })];
|
|
434
|
+
const isValidated = header(page, "etag") !== "" || header(page, "last-modified") !== "";
|
|
435
|
+
return isValidated ? [] : [said("{format} feed sends neither ETag nor Last-Modified, so every reader downloads it in full on every poll", { format: feed.format })];
|
|
436
|
+
}, { docs: "https://www.rfc-editor.org/rfc/rfc9110#section-13.1", fix: "Send `ETag` or `Last-Modified` with the feed and answer conditional requests with 304." });
|
|
437
|
+
// Caching that makes every poll a full fetch, or contradicts the feed’s own refresh hint.
|
|
438
|
+
const cache = pageRule("feeds/cache", [`${ID}.ttl`, "http.headers.cache-control"], (page) => {
|
|
439
|
+
const feed = parsed(page);
|
|
440
|
+
if (!feed)
|
|
441
|
+
return;
|
|
442
|
+
const control = header(page, "cache-control").toLowerCase();
|
|
443
|
+
const maxAge = /(?:^|,)\s*max-age\s*=\s*(\d+)/.exec(control)?.[1];
|
|
444
|
+
const locations = [...(/(?:^|,)\s*no-store\b/.test(control) ? ["Cache-Control: no-store"] : []), ...(maxAge && feed.ttl && (Number(maxAge) > feed.ttl * 600 || Number(maxAge) * 10 < feed.ttl * 60) ? [`ttl ${feed.ttl} min against max-age ${maxAge} s`] : [])];
|
|
445
|
+
return locations.length === 0 ? [] : [{ ...said("{format} feed caching works against its readers", { format: feed.format }), locations }];
|
|
446
|
+
}, { docs: "https://www.rfc-editor.org/rfc/rfc9111#section-5.2", fix: "Let readers cache the feed for as long as `ttl` says, `Cache-Control: max-age=3600` for a one-hour `ttl`, and never `no-store`." });
|
|
447
|
+
// A feed body too large, or large and uncompressed.
|
|
448
|
+
const size = (severity, settings) => pageRule("feeds/size", [`${ID}.format`, "http.size", "http.headers.content-encoding"], (page) => {
|
|
449
|
+
const feed = parsed(page);
|
|
450
|
+
if (!feed)
|
|
451
|
+
return;
|
|
452
|
+
const limit = settings?.["max-bytes"] ?? 1_048_576;
|
|
453
|
+
const decoded = page.http.size.decoded;
|
|
454
|
+
const locations = [...(decoded > limit ? [`${decoded} bytes, over ${limit}`] : []), ...(decoded > 10_240 && !header(page, "content-encoding") ? [`${decoded} bytes served uncompressed`] : [])];
|
|
455
|
+
return locations.length === 0 ? [] : [{ ...said("{format} feed costs every poll more bytes than it should", { format: feed.format }), data: { [page.url.href]: { size: { fact: "http.size.decoded", value: decoded } } }, value: decoded, locations }];
|
|
456
|
+
}, { docs: DOCS.rss, fix: "Keep the newest items only, and serve the feed compressed with `br` or `gzip`." })(severity);
|
|
457
|
+
// A feed styled through XSLT, which Chrome stops applying.
|
|
458
|
+
const xslt = pageRule("feeds/xslt", [`${ID}.stylesheet`], (page) => {
|
|
459
|
+
const feed = parsed(page);
|
|
460
|
+
if (!feed)
|
|
461
|
+
return;
|
|
462
|
+
return feed.stylesheet ? [{ ...said("{format} feed is styled by an XSLT stylesheet; Chrome 158 ({date}) stops applying XSLT, so visitors there see raw XML", { format: feed.format, date: { at: "2026-11-17" } }), data: { [page.url.href]: { stylesheet: feed.stylesheet } }, value: feed.stylesheet }] : [];
|
|
463
|
+
}, { docs: DOCS.xslt, fix: "Link a human-readable page from the feed instead of relying on an XSL stylesheet." });
|
|
464
|
+
// Crawled pages by URL and by the URL that was requested.
|
|
465
|
+
function indexOf(pages) {
|
|
466
|
+
const index = new Map();
|
|
467
|
+
for (const page of pages) {
|
|
468
|
+
index.set(page.url.href, page);
|
|
469
|
+
if (page.crawl.requested)
|
|
470
|
+
index.set(resolve(page.crawl.requested, page.url.href), page);
|
|
471
|
+
}
|
|
472
|
+
return index;
|
|
473
|
+
}
|
|
474
|
+
// A site rule: one finding per parsed feed, each location a problem `judge` finds against the crawl.
|
|
475
|
+
function siteRule(id, facts, label, judge, guide) {
|
|
476
|
+
return (severity) => ({
|
|
477
|
+
meta: { id, severity, scope: "site", facts, ...guide },
|
|
478
|
+
check(pages, _group, site) {
|
|
479
|
+
const index = indexOf(pages);
|
|
480
|
+
const findings = [];
|
|
481
|
+
for (const page of pages) {
|
|
482
|
+
const feed = parsed(page);
|
|
483
|
+
if (!feed?.entries)
|
|
484
|
+
continue;
|
|
485
|
+
const locations = judge(feed, page, index, site);
|
|
486
|
+
log.debug({ rule: id, url: page.url.href, locations: locations.length }, "feed joined to the crawl");
|
|
487
|
+
if (locations.length > 0)
|
|
488
|
+
findings.push({ rule: id, severity, scope: "site", url: page.url.href, ...said(`${FEED} ${label}`, { format: feed.format }), data: { [page.url.href]: { problems: locations.length } }, value: locations.length, locations });
|
|
489
|
+
}
|
|
490
|
+
return findings;
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
const isOk = (page) => page.http.status >= 200 && page.http.status <= 299;
|
|
495
|
+
const normal = (text) => text.toLowerCase().replaceAll(/\s+/g, " ").trim();
|
|
496
|
+
// The crawled page an item links, its landing URL when the crawl followed a redirect.
|
|
497
|
+
function target(entry, index, site) {
|
|
498
|
+
if (!entry.link)
|
|
499
|
+
return {};
|
|
500
|
+
const landing = site?.redirects?.[entry.link];
|
|
501
|
+
return { page: index.get(entry.link) ?? (landing ? index.get(landing) : undefined), ...(landing && { landing }) };
|
|
502
|
+
}
|
|
503
|
+
const itemStatus = siteRule("feeds/item-status", [`${ID}.entries`, "http.status", "site.redirects"], "links items to pages that fail or redirect", (feed, _page, index, site) => (feed.entries ?? []).flatMap((entry) => {
|
|
504
|
+
const { page, landing } = target(entry, index, site);
|
|
505
|
+
if (landing)
|
|
506
|
+
return [`item ${entry.position} ${entry.link} redirects to ${landing}`];
|
|
507
|
+
return page && !isOk(page) ? [`item ${entry.position} ${entry.link} answers ${page.http.status}`] : [];
|
|
508
|
+
}), { docs: DOCS.rss, fix: "Point each item link at the final URL of a page that answers 200." });
|
|
509
|
+
const itemCanonical = siteRule("feeds/item-canonical", [`${ID}.entries`, "html.canonical"], "links items away from their canonical URL", (feed, _page, index, site) => (feed.entries ?? []).flatMap((entry) => {
|
|
510
|
+
const { page, landing } = target(entry, index, site);
|
|
511
|
+
if (landing || !page?.html?.canonical || !entry.link || !isOk(page))
|
|
512
|
+
return [];
|
|
513
|
+
const canonical = resolve(page.html.canonical, page.url.href);
|
|
514
|
+
const here = [page.url.href, page.url.twin, entry.link].flatMap((href) => (href ? [resolve(href, href)] : []));
|
|
515
|
+
return here.includes(canonical) ? [] : [`item ${entry.position} links ${entry.link}, its canonical is ${canonical}`];
|
|
516
|
+
}), { docs: DOCS.rss, fix: "Link each item to its page’s canonical URL." });
|
|
517
|
+
const itemTitle = siteRule("feeds/item-title", [`${ID}.entries`, "html.title", "html.property"], "titles items unlike the pages they link", (feed, _page, index, site) => (feed.entries ?? []).flatMap((entry) => {
|
|
518
|
+
const { page, landing } = target(entry, index, site);
|
|
519
|
+
if (landing || !page?.html || !entry.title || !isOk(page))
|
|
520
|
+
return [];
|
|
521
|
+
const titles = [page.html.title, page.html.property["og:title"]].flatMap((title) => (title ? [normal(title)] : []));
|
|
522
|
+
// A `Post: ` style label a mixed feed puts before the title is not part of the page’s title.
|
|
523
|
+
const wanted = [normal(entry.title), normal(entry.title.replace(/^[^:]{1,30}:\s+/, ""))];
|
|
524
|
+
return titles.length === 0 || titles.some((title) => wanted.some((text) => title.includes(text))) ? [] : [`item ${entry.position} “${entry.title}” against “${page.html.title ?? page.html.property["og:title"]}”`];
|
|
525
|
+
}), { docs: DOCS.rss, fix: "Use the page’s own title as the item title." });
|
|
526
|
+
const itemDate = siteRule("feeds/item-date", [`${ID}.entries`, "html.published"], "dates items unlike the pages they link", (feed, _page, index, site) => (feed.entries ?? []).flatMap((entry) => {
|
|
527
|
+
const { page, landing } = target(entry, index, site);
|
|
528
|
+
const published = landing ? undefined : page?.html?.published;
|
|
529
|
+
if (!published || !entry.published || Number.isNaN(Date.parse(published)))
|
|
530
|
+
return [];
|
|
531
|
+
return Math.abs(Date.parse(published) - Date.parse(entry.published)) > DAY_MS ? [`item ${entry.position} ${entry.published}, the page says ${published}`] : [];
|
|
532
|
+
}), { docs: DOCS.rss, fix: "Take the item date from the same field the page’s `article:published_time` or `datePublished` comes from." });
|
|
533
|
+
// A feed carrying summaries where readers expect full posts: every linked page far longer than its item.
|
|
534
|
+
const summaryOnly = siteRule("feeds/summary-only", [`${ID}.entries`, "html.text"], "carries summaries while the linked pages say far more", (feed, _page, index, site) => {
|
|
535
|
+
const judged = (feed.entries ?? []).flatMap((entry) => {
|
|
536
|
+
const { page, landing } = target(entry, index, site);
|
|
537
|
+
const pageWords = page?.html?.text;
|
|
538
|
+
return landing || !page || !pageWords || entry.words === undefined || !isOk(page) || pageWords < 150 ? [] : [{ entry, words: entry.words, pageWords }];
|
|
539
|
+
});
|
|
540
|
+
const short = judged.filter(({ words, pageWords }) => words < pageWords * 0.3);
|
|
541
|
+
return short.length === judged.length && judged.length > 0 ? short.map(({ entry, words, pageWords }) => `item ${entry.position}: ${words} words against ${pageWords} on the page`) : [];
|
|
542
|
+
}, { docs: DOCS.rss, fix: "Put the full post text into each item’s `content:encoded`, `content` or `content_html`, not a summary." });
|
|
543
|
+
// Pages advertising a feed through a head link, by the feed’s URL.
|
|
544
|
+
function advertisers(pages) {
|
|
545
|
+
const byFeed = new Map();
|
|
546
|
+
for (const page of pages) {
|
|
547
|
+
const links = page.html?.head.links ?? [];
|
|
548
|
+
for (const link of links) {
|
|
549
|
+
const type = link.type?.toLowerCase() ?? "";
|
|
550
|
+
if (!/\balternate\b/i.test(link.rel ?? "") || !FEED_TYPES.has(type) || !link.href)
|
|
551
|
+
continue;
|
|
552
|
+
const href = resolve(link.href, page.url.href);
|
|
553
|
+
byFeed.set(href, [...(byFeed.get(href) ?? []), { page, type }]);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return byFeed;
|
|
557
|
+
}
|
|
558
|
+
const primary = (tag) => tag.toLowerCase().split(/[-_]/, 1)[0];
|
|
559
|
+
const pageLanguage = siteRule("feeds/page-language", [`${ID}.language`, "html.lang", "html.head.links"], "declares a language other than the pages advertising it", (feed, page, index) => {
|
|
560
|
+
if (!feed.language)
|
|
561
|
+
return [];
|
|
562
|
+
const byFeed = advertisers([...new Set(index.values())]);
|
|
563
|
+
const pages = [...(byFeed.get(page.url.href) ?? []), ...(page.crawl.requested ? (byFeed.get(resolve(page.crawl.requested, page.url.href)) ?? []) : [])];
|
|
564
|
+
return pages.filter(({ page: advertiser }) => advertiser.html?.lang && primary(advertiser.html.lang) !== primary(feed.language)).map(({ page: advertiser }) => `${advertiser.url.href} is ${advertiser.html?.lang}, the feed ${feed.language}`);
|
|
565
|
+
}, { docs: DOCS.discovery, fix: "Link each locale’s own feed from its pages, and declare that locale as the feed’s language." });
|
|
566
|
+
const stylesheet = siteRule("feeds/stylesheet", [`${ID}.stylesheet`, "http.status", "http.content-type"], "names a stylesheet browsers will not apply", (feed, page, index) => {
|
|
567
|
+
if (!feed.stylesheet)
|
|
568
|
+
return [];
|
|
569
|
+
if (new URL(feed.stylesheet).origin !== page.url.origin)
|
|
570
|
+
return [`${feed.stylesheet} is on another origin, which browsers refuse for XSLT`];
|
|
571
|
+
const sheet = index.get(feed.stylesheet);
|
|
572
|
+
if (!sheet)
|
|
573
|
+
return [];
|
|
574
|
+
if (!isOk(sheet))
|
|
575
|
+
return [`${feed.stylesheet} answers ${sheet.http.status}`];
|
|
576
|
+
return XSL_TYPES.has(sheet.http["content-type"]) ? [] : [`${feed.stylesheet} is served as ${sheet.http["content-type"] || "no type"}`];
|
|
577
|
+
}, { docs: "https://www.w3.org/TR/xml-stylesheet/", fix: "Serve the XSL stylesheet from the feed’s own origin as `text/xsl`." });
|
|
578
|
+
const archive = siteRule("feeds/archive", [`${ID}.archives`, `${ID}.problems.archive`, "http.status"], "names archive pages readers cannot follow", (feed, _page, index) => [
|
|
579
|
+
...(feed.problems?.archive ?? []),
|
|
580
|
+
...Object.entries(feed.archives ?? {}).flatMap(([relation, href]) => {
|
|
581
|
+
const linked = index.get(href);
|
|
582
|
+
if (!linked)
|
|
583
|
+
return [];
|
|
584
|
+
if (!isOk(linked))
|
|
585
|
+
return [`${relation} ${href} answers ${linked.http.status}`];
|
|
586
|
+
return parsed(linked) ? [] : [`${relation} ${href} is not a feed`];
|
|
587
|
+
}),
|
|
588
|
+
], { docs: DOCS.archive, fix: "Point `prev-archive`, `next-archive` and `current` at feed documents that answer 200, and drop them from a complete feed." });
|
|
589
|
+
const ARTWORK_MIN = 1400;
|
|
590
|
+
const ARTWORK_MAX = 3000;
|
|
591
|
+
const ARTWORK_TYPES = new Map([
|
|
592
|
+
["jpeg", "JPEG"],
|
|
593
|
+
["png", "PNG"],
|
|
594
|
+
]);
|
|
595
|
+
// The pixel sizes of an image body; an unrecognised body has no format.
|
|
596
|
+
async function measureImage(url, bytes) {
|
|
597
|
+
try {
|
|
598
|
+
const metadata = await sharp(bytes, { limitInputPixels: 50_000_000 }).metadata();
|
|
599
|
+
return { format: metadata.format, width: metadata.width, height: metadata.height };
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
log.debug({ url, error: reason(error) }, "podcast artwork is no image");
|
|
603
|
+
return {};
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// Every channel artwork the origin’s podcast feeds name, fetched once, measured and judged.
|
|
607
|
+
const feedImages = {
|
|
608
|
+
id: "feed-images",
|
|
609
|
+
per: "origin",
|
|
610
|
+
crawled: true,
|
|
611
|
+
async extract(origin, context) {
|
|
612
|
+
const declared = [
|
|
613
|
+
...new Set(context.pages.flatMap((page) => {
|
|
614
|
+
const feed = page.feed;
|
|
615
|
+
return feed?.podcast && feed.itunesImage ? [feed.itunesImage] : [];
|
|
616
|
+
})),
|
|
617
|
+
];
|
|
618
|
+
if (declared.length === 0)
|
|
619
|
+
return;
|
|
620
|
+
const fetchOne = async (url) => {
|
|
621
|
+
try {
|
|
622
|
+
const answer = await (new URL(url).origin === origin ? context.fetch : context.delegated)(url, { redirect: "follow", binary: true });
|
|
623
|
+
const isOk = answer.status >= 200 && answer.status <= 299;
|
|
624
|
+
const measured = isOk && answer.bytes ? await measureImage(url, answer.bytes) : {};
|
|
625
|
+
log.debug({ url, status: answer.status, ...measured }, "podcast artwork fetched");
|
|
626
|
+
const type = answer.headers["content-type"];
|
|
627
|
+
return { status: answer.status, ...(typeof type === "string" && { type }), ...measured };
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
log.debug({ url, error: reason(error) }, "podcast artwork unreachable");
|
|
631
|
+
return { status: 0, error: error instanceof RobotsDisallowed ? "robots.txt disallows it" : reason(error) };
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
const files = Object.fromEntries(await Promise.all(declared.map(async (url) => [url, await fetchOne(url)])));
|
|
635
|
+
const problems = declared.flatMap((url) => {
|
|
636
|
+
const file = files[url];
|
|
637
|
+
if (file.error)
|
|
638
|
+
return [`${url} does not answer (${file.error})`];
|
|
639
|
+
if (file.status < 200 || file.status > 299)
|
|
640
|
+
return [`${url} answers ${file.status}`];
|
|
641
|
+
if (!file.format)
|
|
642
|
+
return [`${url} is not an image (${file.type || "no type"})`];
|
|
643
|
+
if (!ARTWORK_TYPES.has(file.format))
|
|
644
|
+
return [`${url} is ${file.format}, not JPEG or PNG`];
|
|
645
|
+
if (file.width !== file.height)
|
|
646
|
+
return [`${url} is ${file.width}x${file.height}, not square`];
|
|
647
|
+
const width = file.width ?? 0;
|
|
648
|
+
return width < ARTWORK_MIN || width > ARTWORK_MAX ? [`${url} is ${file.width} px wide, Apple wants ${ARTWORK_MIN}–${ARTWORK_MAX}`] : [];
|
|
649
|
+
});
|
|
650
|
+
log.debug({ origin, declared: declared.length, problems: problems.length }, "podcast artwork measured");
|
|
651
|
+
return { declared, files, "itunes-image": problems };
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
// Every WebSub hub the origin’s feeds declare, asked once whether it answers a discovery request.
|
|
655
|
+
const feedHubs = {
|
|
656
|
+
id: "feed-hubs",
|
|
657
|
+
per: "origin",
|
|
658
|
+
crawled: true,
|
|
659
|
+
async extract(origin, context) {
|
|
660
|
+
const hubs = [
|
|
661
|
+
...new Set(context.pages.flatMap((page) => {
|
|
662
|
+
const feed = page.feed;
|
|
663
|
+
return feed && !feed.error ? feed.hubs : [];
|
|
664
|
+
})),
|
|
665
|
+
];
|
|
666
|
+
if (hubs.length === 0)
|
|
667
|
+
return;
|
|
668
|
+
if (!context.settings?.websub) {
|
|
669
|
+
log.info({ origin, hubs: hubs.length }, "feeds/websub-hub skipped: opt in with org.spiderlint.feeds.websub");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const problems = [];
|
|
673
|
+
for (const hub of hubs) {
|
|
674
|
+
try {
|
|
675
|
+
const answer = await (new URL(hub).origin === origin ? context.fetch : context.delegated)(hub, { redirect: "follow" });
|
|
676
|
+
log.debug({ hub, status: answer.status }, "websub hub probed");
|
|
677
|
+
if (answer.status < 200 || answer.status > 299)
|
|
678
|
+
problems.push(`${hub} answers ${answer.status}`);
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
log.debug({ hub, error: reason(error) }, "websub hub unreachable");
|
|
682
|
+
if (!(error instanceof RobotsDisallowed))
|
|
683
|
+
problems.push(`${hub} does not answer (${reason(error)})`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
log.debug({ origin, hubs: hubs.length, problems: problems.length }, "websub hubs probed");
|
|
687
|
+
return { hubs, problems };
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
// Head links announcing a feed type the target is not, or a target that is no feed at all; one finding per target.
|
|
691
|
+
const discoveryType = (severity) => ({
|
|
692
|
+
meta: { id: "feeds/discovery-type", severity, scope: "site", facts: ["html.head.links", ID], docs: DOCS.discovery, fix: "Give each `rel=alternate` the type the feed is served as, and point it at the feed itself." },
|
|
693
|
+
check(pages) {
|
|
694
|
+
const index = indexOf(pages);
|
|
695
|
+
const findings = [];
|
|
696
|
+
for (const [href, links] of advertisers(pages)) {
|
|
697
|
+
const linked = index.get(href);
|
|
698
|
+
if (!linked || !isOk(linked))
|
|
699
|
+
continue;
|
|
700
|
+
const feed = feedOf(linked);
|
|
701
|
+
if (!feed && (linked.http.size.truncated || /[/+](?:xml|json)$/.test(linked.http["content-type"]))) {
|
|
702
|
+
log.debug({ rule: "feeds/discovery-type", url: href, type: linked.http["content-type"], truncated: linked.http.size.truncated === true }, "feed discovery skipped, the target may be a feed whose facts were never extracted");
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const wrong = links.filter(({ type }) => !feed || FORMAT_TYPES[type] !== feed.format);
|
|
706
|
+
log.debug({ rule: "feeds/discovery-type", url: href, format: feed?.format, links: links.length, wrong: wrong.length }, "feed discovery judged");
|
|
707
|
+
if (wrong.length === 0)
|
|
708
|
+
continue;
|
|
709
|
+
const urls = [...new Set(wrong.map(({ page }) => page.url.href))];
|
|
710
|
+
const sentence = feed
|
|
711
|
+
? said("head links announce this URL as {announced}, it is {format}; linked from these pages", { announced: wrong[0]?.type ?? "", format: feed.format })
|
|
712
|
+
: said("head links announce this URL as a feed, it is {type}; linked from these pages", { type: linked.http["content-type"] || "no type" });
|
|
713
|
+
findings.push({ rule: "feeds/discovery-type", severity, scope: "site", url: href, ...sentence, urls });
|
|
714
|
+
}
|
|
715
|
+
return findings;
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
// A group where some HTML pages advertise a feed and others do not.
|
|
719
|
+
const discovery = (severity) => ({
|
|
720
|
+
meta: { id: "feeds/discovery", severity, scope: "group", facts: ["html.head.links"], docs: DOCS.discovery, fix: "Link the feed with `<link rel=alternate type=application/atom+xml>` from every page of the template, not from some." },
|
|
721
|
+
check(pages, group) {
|
|
722
|
+
const html = pages.filter((page) => page.html !== undefined && isOk(page) && !feedOf(page));
|
|
723
|
+
const without = html.filter((page) => (page.html?.head.links ?? []).every((link) => !(/\balternate\b/i.test(link.rel ?? "") && FEED_TYPES.has(link.type?.toLowerCase() ?? "")))).map((page) => page.url.href);
|
|
724
|
+
log.debug({ rule: "feeds/discovery", group, pages: html.length, without: without.length }, "feed links judged");
|
|
725
|
+
if (without.length === html.length)
|
|
726
|
+
return;
|
|
727
|
+
return without.length === 0 ? [] : [{ rule: "feeds/discovery", severity, scope: "group", url: without[0], ...(group !== undefined && { group }), ...said("{count} of {total} pages advertise no feed while the rest do", { count: without.length, total: html.length }), urls: without }];
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
const RULES = {
|
|
731
|
+
"feeds/well-formed": wellFormed,
|
|
732
|
+
"feeds/self": self,
|
|
733
|
+
"feeds/item-id": itemId,
|
|
734
|
+
"feeds/websub": websub,
|
|
735
|
+
...PROBLEM_RULES,
|
|
736
|
+
"feeds/date-future": dateFuture,
|
|
737
|
+
"feeds/stale": stale,
|
|
738
|
+
"feeds/conditional-get": conditionalGet,
|
|
739
|
+
"feeds/cache": cache,
|
|
740
|
+
"feeds/size": size,
|
|
741
|
+
"feeds/xslt": xslt,
|
|
742
|
+
"feeds/item-status": itemStatus,
|
|
743
|
+
"feeds/item-canonical": itemCanonical,
|
|
744
|
+
"feeds/item-title": itemTitle,
|
|
745
|
+
"feeds/item-date": itemDate,
|
|
746
|
+
"feeds/summary-only": summaryOnly,
|
|
747
|
+
"feeds/page-language": pageLanguage,
|
|
748
|
+
"feeds/stylesheet": stylesheet,
|
|
749
|
+
"feeds/archive": archive,
|
|
750
|
+
"feeds/discovery-type": discoveryType,
|
|
751
|
+
"feeds/discovery": discovery,
|
|
752
|
+
"feeds/enclosure": enclosure,
|
|
753
|
+
"feeds/podcast-locked": podcastLocked,
|
|
754
|
+
};
|
|
755
|
+
export default definePlugin({
|
|
756
|
+
name: "feeds",
|
|
757
|
+
settings: {
|
|
758
|
+
type: "object",
|
|
759
|
+
additionalProperties: false,
|
|
760
|
+
properties: { "stale-days": { type: "integer", minimum: 1, default: 365 }, "max-bytes": { type: "integer", minimum: 1, default: 1_048_576 }, websub: { type: "boolean", default: false } },
|
|
761
|
+
},
|
|
762
|
+
extractors: [{ id: ID, inputs: ["headers.link"], extract }],
|
|
763
|
+
rules: RULES,
|
|
764
|
+
sites: [feedImages, feedHubs],
|
|
765
|
+
presets: {
|
|
766
|
+
feeds: {
|
|
767
|
+
description: "RSS, Atom and JSON Feed as readers need them: valid to their specification, content rendered, served for polling, and in step with the pages they describe",
|
|
768
|
+
rules: {
|
|
769
|
+
"feeds/well-formed": { severity: "error", score: 9 },
|
|
770
|
+
"feeds/required": { severity: "error", score: 8.6 },
|
|
771
|
+
"feeds/date-format": { severity: "error", score: 7.2 },
|
|
772
|
+
"feeds/duplicate-id": { severity: "error", score: 7.6 },
|
|
773
|
+
"feeds/raw-markup": { severity: "error", score: 8 },
|
|
774
|
+
"feeds/template-leak": { severity: "error", score: 8.4 },
|
|
775
|
+
"feeds/self": { severity: "warning", score: 6 },
|
|
776
|
+
"feeds/item-id": { severity: "warning", score: 4.6 },
|
|
777
|
+
"feeds/websub": { severity: "warning", score: 4 },
|
|
778
|
+
"feeds/id-tracking": { severity: "warning", score: 4.8 },
|
|
779
|
+
"feeds/permalink": { severity: "warning", score: 5.8 },
|
|
780
|
+
"feeds/absolute-url": { severity: "warning", score: 5.7 },
|
|
781
|
+
"feeds/language": { severity: "warning", score: 3.8 },
|
|
782
|
+
"feeds/unknown-element": { severity: "warning", score: 3.9 },
|
|
783
|
+
"feeds/email": { severity: "warning", score: 4.2 },
|
|
784
|
+
"feeds/json-version": { severity: "warning", score: 3.7 },
|
|
785
|
+
"feeds/relative-url": { severity: "warning", score: 4.5 },
|
|
786
|
+
"feeds/unsafe-html": { severity: "warning", score: 6.3 },
|
|
787
|
+
"feeds/double-escaped": { severity: "warning", score: 4.4 },
|
|
788
|
+
"feeds/content-type": { severity: "warning", score: 5.6 },
|
|
789
|
+
"feeds/title-markup": { severity: "warning", score: 4.3 },
|
|
790
|
+
"feeds/media-type": { severity: "warning", score: 4.9 },
|
|
791
|
+
"feeds/charset": { severity: "warning", score: 5.5 },
|
|
792
|
+
"feeds/date-future": { severity: "warning", score: 3.6 },
|
|
793
|
+
"feeds/conditional-get": { severity: "warning", score: 5.2 },
|
|
794
|
+
"feeds/cache": { severity: "warning", score: 5.1 },
|
|
795
|
+
"feeds/size": { severity: "warning", score: 5 },
|
|
796
|
+
"feeds/stylesheet": { severity: "warning", score: 4.7 },
|
|
797
|
+
"feeds/archive": { severity: "warning", score: 3.5 },
|
|
798
|
+
"feeds/item-status": { severity: "warning", score: 6.1 },
|
|
799
|
+
"feeds/item-canonical": { severity: "warning", score: 5.9 },
|
|
800
|
+
"feeds/item-date": { severity: "warning", score: 5.4 },
|
|
801
|
+
"feeds/page-language": { severity: "warning", score: 5.3 },
|
|
802
|
+
"feeds/discovery-type": { severity: "warning", score: 3.4 },
|
|
803
|
+
"feeds/discovery": { severity: "warning", score: 4.1 },
|
|
804
|
+
"feeds/item-title": { severity: "info", score: 2.8 },
|
|
805
|
+
"feeds/summary-only": { severity: "info", score: 2.6 },
|
|
806
|
+
"feeds/stale": { severity: "info", score: 2.2 },
|
|
807
|
+
"feeds/xslt": { severity: "info", score: 1.8 },
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
podcasts: {
|
|
811
|
+
description: "Podcast feeds as directories require them: iTunes channel and episode tags, a stable Podcasting 2.0 GUID, reachable enclosures and directory-ready artwork",
|
|
812
|
+
rules: {
|
|
813
|
+
"feeds/itunes-required": { severity: "warning", score: 5.8 },
|
|
814
|
+
"feeds/podcast-guid": { severity: "warning", score: 5.4 },
|
|
815
|
+
"feeds/enclosure": { severity: "warning", score: 6.2 },
|
|
816
|
+
"feeds/itunes-image": {
|
|
817
|
+
score: 5,
|
|
818
|
+
fact: "site.origins.*.feed-images.itunes-image",
|
|
819
|
+
expect: { maxItems: 0 },
|
|
820
|
+
message: "podcast artwork is missing or unfit: {got}",
|
|
821
|
+
severity: "warning",
|
|
822
|
+
docs: DOCS.itunes,
|
|
823
|
+
fix: "Serve the channel itunes:image as a square JPEG or PNG between 1400 and 3000 px on each side.",
|
|
824
|
+
},
|
|
825
|
+
"feeds/podcast-locked": { severity: "info", score: 2.4 },
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
websub: {
|
|
829
|
+
description: "WebSub as subscribers need it: every feed naming its self URL beside its hub, each hub probed once; probing opts in with org.spiderlint.feeds.websub",
|
|
830
|
+
rules: {
|
|
831
|
+
"feeds/websub": { severity: "warning", score: 4 },
|
|
832
|
+
"feeds/websub-hub": { score: 5.2, fact: "site.origins.*.feed-hubs.problems", expect: { maxItems: 0 }, message: "declared WebSub hub does not answer: {got}", severity: "warning", docs: DOCS.websub, fix: "Point each rel=hub link at a hub that answers 200, or remove it." },
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
});
|