feedkeeper 0.28.12 → 0.29.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 +1 -1
- package/server/dist/feeds/content/embeds/consent.js +88 -0
- package/server/dist/feeds/content/embeds/index.js +16 -0
- package/server/dist/feeds/content/embeds/social.js +103 -0
- package/server/dist/feeds/content/embeds/video.js +113 -0
- package/server/dist/feeds/content/index.js +14 -0
- package/server/dist/feeds/content/noise/sidebars.js +67 -0
- package/server/dist/feeds/content/patterns/consent/de.js +27 -0
- package/server/dist/feeds/content/patterns/consent/en.js +15 -0
- package/server/dist/feeds/content/patterns/consent/index.js +10 -0
- package/server/dist/feeds/content/patterns/consent/wrappers.js +4 -0
- package/server/dist/feeds/content/patterns/play-labels/de.js +3 -0
- package/server/dist/feeds/content/patterns/play-labels/en.js +6 -0
- package/server/dist/feeds/content/patterns/play-labels/index.js +5 -0
- package/server/dist/feeds/content/patterns/sidebars/de.js +5 -0
- package/server/dist/feeds/content/patterns/sidebars/en.js +8 -0
- package/server/dist/feeds/content/patterns/sidebars/index.js +6 -0
- package/server/dist/feeds/content/patterns/sidebars/selectors.js +15 -0
- package/server/dist/feeds/content/patterns/social-platforms.js +20 -0
- package/server/dist/feeds/content/shared.js +35 -0
- package/server/dist/feeds/contentNoise.js +8 -134
- package/server/dist/feeds/extractor.js +17 -11
- package/server/package.json +1 -1
- package/web/dist/assets/{index-DIFm1rN-.css → index-C7Q6CgHZ.css} +1 -1
- package/web/dist/assets/{index-CLbCb67B.js → index-CQfoCXb-.js} +2 -2
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { CONSENT_WRAPPER } from "../patterns/consent/wrappers.js";
|
|
2
|
+
import { count, flat, linkParagraph, pruneEmpty } from "../shared.js";
|
|
3
|
+
import { embedTarget } from "./video.js";
|
|
4
|
+
import { socialFrameTarget } from "./social.js";
|
|
5
|
+
function decodeEmbedText(text) {
|
|
6
|
+
let decoded = text.replace(/\\+\//g, "/");
|
|
7
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
8
|
+
decoded = decoded.replace(/"|�?34;/g, '"').replace(/�?39;|'|'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
9
|
+
}
|
|
10
|
+
return decoded;
|
|
11
|
+
}
|
|
12
|
+
/** The text of a wrapper as far as the reader can look into it: attributes, scripts and the contents consent plugins keep encoded. */
|
|
13
|
+
function embedSource(wrapper) {
|
|
14
|
+
let source = wrapper.outerHTML;
|
|
15
|
+
for (const element of [wrapper, ...wrapper.querySelectorAll("*")]) {
|
|
16
|
+
for (const attribute of [...element.attributes]) {
|
|
17
|
+
if (!/^[A-Za-z0-9+/=\s]{80,}$/.test(attribute.value))
|
|
18
|
+
continue;
|
|
19
|
+
// Plugins keep the embed as base64 in an attribute of their own choosing; only what decodes to markup or an address counts.
|
|
20
|
+
const decoded = Buffer.from(attribute.value, "base64").toString("utf8");
|
|
21
|
+
if (/<[a-z]|https?:\/\//i.test(decoded))
|
|
22
|
+
source += ` ${decoded}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return decodeEmbedText(source);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Consent plugins (Embed Privacy, Borlabs, Complianz, Real Cookie Banner, …) replace an embed with a notice and a
|
|
29
|
+
* switch, and keep the embed itself in a script or an attribute until the reader agrees. A reader has nobody to ask:
|
|
30
|
+
* the notice and its legal text are noise, while the video behind them is the content. The address is looked up
|
|
31
|
+
* wherever it is kept; what is found stays as one marked link, a wrapper with nothing behind it goes. The same
|
|
32
|
+
* wrapper around a plain WordPress embed block (`wp-block-embed`) only matters when an address can be found.
|
|
33
|
+
*/
|
|
34
|
+
function resolveConsentEmbeds(root, stats) {
|
|
35
|
+
const links = [];
|
|
36
|
+
let changed = false;
|
|
37
|
+
for (const wrapper of [...root.querySelectorAll(`${CONSENT_WRAPPER}, figure.wp-block-embed`)]) {
|
|
38
|
+
if (!root.contains(wrapper))
|
|
39
|
+
continue;
|
|
40
|
+
const isConsent = wrapper.matches(CONSENT_WRAPPER);
|
|
41
|
+
const source = embedSource(wrapper);
|
|
42
|
+
const addresses = source.match(/(?:https?:)?\/\/[^\s"'<>\\)]+/g) ?? [];
|
|
43
|
+
let found = null;
|
|
44
|
+
for (const address of addresses) {
|
|
45
|
+
const video = embedTarget(address);
|
|
46
|
+
if (video) {
|
|
47
|
+
found = { ...video, kind: "video" };
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const post = socialFrameTarget(address);
|
|
51
|
+
if (post) {
|
|
52
|
+
found = { url: post.url, name: post.platform.name, kind: post.platform.kind };
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!found && !isConsent)
|
|
57
|
+
continue;
|
|
58
|
+
// The title of the embed's own iframe names the video; the plugin's notice would only say "external content".
|
|
59
|
+
const title = flat(/\btitle="([^"]{1,200})"/.exec(source.slice(Math.max(0, source.indexOf("<iframe"))))?.[1] ?? "");
|
|
60
|
+
const parent = wrapper.parentNode;
|
|
61
|
+
if (found) {
|
|
62
|
+
const replacement = linkParagraph(wrapper.ownerDocument, found.url, (source.includes("<iframe") ? title : "") || found.name);
|
|
63
|
+
wrapper.replaceWith(replacement);
|
|
64
|
+
links.push({ link: replacement.querySelector("a"), kind: found.kind, source: found.name });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
wrapper.remove();
|
|
68
|
+
}
|
|
69
|
+
pruneEmpty(parent);
|
|
70
|
+
count(stats, "consent-embed");
|
|
71
|
+
changed = true;
|
|
72
|
+
}
|
|
73
|
+
return { changed, links };
|
|
74
|
+
}
|
|
75
|
+
/** Embeds held back by a consent plugin become a link to the embed, or go. */
|
|
76
|
+
export const consentEmbeds = {
|
|
77
|
+
name: "consent-embeds",
|
|
78
|
+
description: "Consent plugins' notices around videos and posts are replaced by one marked link, or removed without an address.",
|
|
79
|
+
apply(root, stats) {
|
|
80
|
+
const { changed, links } = resolveConsentEmbeds(root, stats);
|
|
81
|
+
links.forEach(({ link, kind, source }) => {
|
|
82
|
+
link.setAttribute("data-media", kind);
|
|
83
|
+
link.setAttribute("data-source", source);
|
|
84
|
+
});
|
|
85
|
+
return changed;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
//# sourceMappingURL=consent.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { consentEmbeds } from "./consent.js";
|
|
2
|
+
import { socialEmbeds } from "./social.js";
|
|
3
|
+
import { videoPlayers } from "./video.js";
|
|
4
|
+
/**
|
|
5
|
+
* Embeds, in the order they must run: consent plugins first, because the address of the embed sits inside their
|
|
6
|
+
* notice, then the video players and iframes, then the social posts.
|
|
7
|
+
*/
|
|
8
|
+
export const EMBED_RULES = [consentEmbeds, videoPlayers, socialEmbeds];
|
|
9
|
+
/**
|
|
10
|
+
* Turns every embed of a page into the marked link a reader shows as a card. A fetched page must go through this
|
|
11
|
+
* before its scripts, frames and `noscript` blocks are removed: that is where consent plugins and players keep the address.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveEmbeds(root, stats = {}) {
|
|
14
|
+
return EMBED_RULES.map(rule => rule.apply(root, stats)).some(Boolean);
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { MASTODON_EMBED_PATH, SOCIAL_PLATFORMS, SOCIAL_QUOTE } from "../patterns/social-platforms.js";
|
|
2
|
+
import { flat, linkParagraph, parseAddress, pruneEmpty } from "../shared.js";
|
|
3
|
+
/** The platform and the address of the single post an address points at, without tracking parameters. */
|
|
4
|
+
export function socialPost(source) {
|
|
5
|
+
const address = parseAddress(source.trim());
|
|
6
|
+
if (!address || !/^https?:$/.test(address.protocol))
|
|
7
|
+
return null;
|
|
8
|
+
const host = address.hostname.replace(/^(?:www|m|mobile)\./, "");
|
|
9
|
+
const platform = SOCIAL_PLATFORMS.find(candidate => candidate.host.test(host));
|
|
10
|
+
if (!platform || !platform.post.test(`${address.pathname}${address.search}`))
|
|
11
|
+
return null;
|
|
12
|
+
const keepsQuery = platform.name === "Facebook";
|
|
13
|
+
return { platform, url: `${address.origin}${address.pathname.replace(/\/embed\/?$/, "/")}${keepsQuery ? address.search : ""}` };
|
|
14
|
+
}
|
|
15
|
+
/** The post behind an embed iframe, which usually names it in the path or in a parameter. */
|
|
16
|
+
export function socialFrameTarget(source) {
|
|
17
|
+
const address = parseAddress(source.trim());
|
|
18
|
+
if (!address)
|
|
19
|
+
return null;
|
|
20
|
+
const host = address.hostname.replace(/^www\./, "");
|
|
21
|
+
const find = (name) => SOCIAL_PLATFORMS.find(platform => platform.name === name);
|
|
22
|
+
if (host === "platform.twitter.com" && address.searchParams.get("id")) {
|
|
23
|
+
return { platform: find("X"), url: `https://x.com/i/status/${address.searchParams.get("id")}` };
|
|
24
|
+
}
|
|
25
|
+
if (host === "tiktok.com" && /^\/embed\/(?:v\d\/)?(\d+)/.test(address.pathname)) {
|
|
26
|
+
return { platform: find("TikTok"), url: `https://www.tiktok.com/video/${/(\d+)/.exec(address.pathname)?.[1]}` };
|
|
27
|
+
}
|
|
28
|
+
if (host === "open.spotify.com")
|
|
29
|
+
return socialPost(source.replace("/embed/", "/"));
|
|
30
|
+
if (host === "w.soundcloud.com") {
|
|
31
|
+
const inner = address.searchParams.get("url");
|
|
32
|
+
return inner ? socialPost(inner) : null;
|
|
33
|
+
}
|
|
34
|
+
if (host === "facebook.com" && /\/plugins\/(?:post|video)\.php$/.test(address.pathname)) {
|
|
35
|
+
const inner = address.searchParams.get("href");
|
|
36
|
+
return inner ? socialPost(inner) : null;
|
|
37
|
+
}
|
|
38
|
+
if (MASTODON_EMBED_PATH.test(address.pathname) && /^https?:$/.test(address.protocol)) {
|
|
39
|
+
return { platform: { name: "Mastodon", host: /.*/, post: MASTODON_EMBED_PATH, kind: "social" }, url: `${address.origin}${address.pathname.replace(/\/embed\/?$/, "")}` };
|
|
40
|
+
}
|
|
41
|
+
return socialPost(source);
|
|
42
|
+
}
|
|
43
|
+
function excerpt(element) {
|
|
44
|
+
const text = flat(element.querySelector("p")?.textContent ?? "");
|
|
45
|
+
return text.length > 160 ? `${text.slice(0, 157).trimEnd()}…` : text;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Posts of social platforms arrive as a placeholder (`<blockquote class="instagram-media">`, `twitter-tweet`, …) that the
|
|
49
|
+
* platform's script fills in, or as an iframe. Without the script the placeholder shows its scaffolding: the platform's
|
|
50
|
+
* logo as a large picture, empty frames and the bar of a quotation. What can be reached stays as one plain link to the
|
|
51
|
+
* post (`data-media="social"`, or `"audio"` for music and podcast players, with `data-source` naming the platform) and
|
|
52
|
+
* what cannot is left out. The post itself is never embedded here: that is for the client to decide.
|
|
53
|
+
*/
|
|
54
|
+
function resolveSocialEmbeds(root, stats) {
|
|
55
|
+
const links = [];
|
|
56
|
+
let changed = false;
|
|
57
|
+
const count = () => { stats["social"] = (stats["social"] ?? 0) + 1; changed = true; };
|
|
58
|
+
for (const quote of [...root.querySelectorAll(SOCIAL_QUOTE)]) {
|
|
59
|
+
if (!root.contains(quote))
|
|
60
|
+
continue;
|
|
61
|
+
const candidates = [quote.getAttribute("cite") ?? "", ...[...quote.querySelectorAll("a[href]")].map(anchor => anchor.getAttribute("href") ?? "")];
|
|
62
|
+
// A tweet names its author's profile before the post: the last address that is a post wins.
|
|
63
|
+
const target = candidates.map(socialPost).filter((found) => found !== null).pop();
|
|
64
|
+
const parent = quote.parentNode;
|
|
65
|
+
if (!target) {
|
|
66
|
+
quote.remove();
|
|
67
|
+
pruneEmpty(parent);
|
|
68
|
+
count();
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const replacement = linkParagraph(quote.ownerDocument, target.url, excerpt(quote) || target.platform.name);
|
|
72
|
+
quote.replaceWith(replacement);
|
|
73
|
+
pruneEmpty(parent);
|
|
74
|
+
links.push({ link: replacement.querySelector("a"), kind: target.platform.kind, source: target.platform.name });
|
|
75
|
+
count();
|
|
76
|
+
}
|
|
77
|
+
for (const frame of [...root.querySelectorAll("iframe")]) {
|
|
78
|
+
const target = socialFrameTarget(frame.getAttribute("src") ?? frame.getAttribute("data-src") ?? "");
|
|
79
|
+
if (!target)
|
|
80
|
+
continue;
|
|
81
|
+
const parent = frame.parentNode;
|
|
82
|
+
const replacement = linkParagraph(frame.ownerDocument, target.url, flat(frame.getAttribute("title")) || target.platform.name);
|
|
83
|
+
frame.replaceWith(replacement);
|
|
84
|
+
pruneEmpty(parent);
|
|
85
|
+
links.push({ link: replacement.querySelector("a"), kind: target.platform.kind, source: target.platform.name });
|
|
86
|
+
count();
|
|
87
|
+
}
|
|
88
|
+
return { changed, links };
|
|
89
|
+
}
|
|
90
|
+
/** Posts of social platforms and audio players become one link to the post. */
|
|
91
|
+
export const socialEmbeds = {
|
|
92
|
+
name: "social-embeds",
|
|
93
|
+
description: "Instagram, X, TikTok, Bluesky, Threads, Reddit, Facebook posts and Spotify/SoundCloud players become one marked link.",
|
|
94
|
+
apply(root, stats) {
|
|
95
|
+
const { changed, links } = resolveSocialEmbeds(root, stats);
|
|
96
|
+
links.forEach(({ link, kind, source }) => {
|
|
97
|
+
link.setAttribute("data-media", kind);
|
|
98
|
+
link.setAttribute("data-source", source);
|
|
99
|
+
});
|
|
100
|
+
return changed;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
//# sourceMappingURL=social.js.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { PLAY_LABEL } from "../patterns/play-labels/index.js";
|
|
2
|
+
import { flat, linkParagraph, parseAddress, pruneEmpty } from "../shared.js";
|
|
3
|
+
const PLAY_SOURCE = /(?:^|[/_.-])play[-_]?(?:icon|button|btn)?\.(?:svg|png|webp|gif)(?:[?#]|$)/i;
|
|
4
|
+
const DURATION = /^\d{1,2}:\d{2}(:\d{2})?$/;
|
|
5
|
+
const PLAYER_BLOCK_LIMIT = 120;
|
|
6
|
+
function isPlayOverlay(image) {
|
|
7
|
+
return PLAY_LABEL.test(flat(image.getAttribute("alt"))) || PLAY_SOURCE.test((image.getAttribute("src") ?? "").trim());
|
|
8
|
+
}
|
|
9
|
+
/** Direct video URL of the embeds that are common enough to be worth a link. Anything else is not turned into a link. */
|
|
10
|
+
export function embedTarget(source) {
|
|
11
|
+
const parsed = parseAddress(source);
|
|
12
|
+
if (!parsed)
|
|
13
|
+
return null;
|
|
14
|
+
const address = parsed;
|
|
15
|
+
const host = address.hostname.replace(/^www\./, "");
|
|
16
|
+
const segments = address.pathname.split("/").filter(Boolean);
|
|
17
|
+
if (/^youtube(-nocookie)?\.com$/.test(host) && segments[0] === "embed" && /^[\w-]{6,}$/.test(segments[1] ?? "")) {
|
|
18
|
+
return { url: `https://www.youtube.com/watch?v=${segments[1]}`, name: "YouTube" };
|
|
19
|
+
}
|
|
20
|
+
if (/^(?:m\.)?youtube\.com$/.test(host) && segments[0] === "watch" && /^[\w-]{6,}$/.test(address.searchParams.get("v") ?? "")) {
|
|
21
|
+
return { url: `https://www.youtube.com/watch?v=${address.searchParams.get("v")}`, name: "YouTube" };
|
|
22
|
+
}
|
|
23
|
+
if (host === "youtu.be" && /^[\w-]{6,}$/.test(segments[0] ?? "")) {
|
|
24
|
+
return { url: `https://www.youtube.com/watch?v=${segments[0]}`, name: "YouTube" };
|
|
25
|
+
}
|
|
26
|
+
if (host === "player.vimeo.com" && segments[0] === "video" && /^\d+$/.test(segments[1] ?? "")) {
|
|
27
|
+
return { url: `https://vimeo.com/${segments[1]}`, name: "Vimeo" };
|
|
28
|
+
}
|
|
29
|
+
if (host === "dailymotion.com" && segments[0] === "embed" && segments[1] === "video" && /^\w+$/.test(segments[2] ?? "")) {
|
|
30
|
+
return { url: `https://www.dailymotion.com/video/${segments[2]}`, name: "Dailymotion" };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Players need the publisher's script: a reader without it shows their play-button picture as a broken image and
|
|
36
|
+
* leaves a dead block. What can be reached stays as one plain link to the video (`data-media="video"` tells a client
|
|
37
|
+
* that the target is a video page); what cannot, because the player is a script-driven button without an address,
|
|
38
|
+
* is left out. The video itself is never embedded here: that is for the client to decide.
|
|
39
|
+
*/
|
|
40
|
+
function resolveVideoPlayers(root, stats) {
|
|
41
|
+
const links = [];
|
|
42
|
+
let changed = false;
|
|
43
|
+
const count = () => { stats["video"] = (stats["video"] ?? 0) + 1; changed = true; };
|
|
44
|
+
for (const image of [...root.querySelectorAll("img")].filter(isPlayOverlay)) {
|
|
45
|
+
if (!root.contains(image))
|
|
46
|
+
continue;
|
|
47
|
+
const anchor = image.closest("a[href]");
|
|
48
|
+
if (anchor) {
|
|
49
|
+
const parent = anchor.parentNode;
|
|
50
|
+
image.remove();
|
|
51
|
+
const href = anchor.getAttribute("href");
|
|
52
|
+
const scope = anchor.closest("p, div, figure, li") ?? anchor.parentElement;
|
|
53
|
+
const twin = scope ? [...scope.querySelectorAll("a[href]")].find(other => other !== anchor && other.getAttribute("href") === href) : undefined;
|
|
54
|
+
const label = flat(anchor.textContent);
|
|
55
|
+
if (twin) {
|
|
56
|
+
anchor.remove();
|
|
57
|
+
pruneEmpty(parent);
|
|
58
|
+
links.push(twin);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
if (label === "" || DURATION.test(label))
|
|
62
|
+
anchor.textContent = flat(anchor.getAttribute("title")) || "Video";
|
|
63
|
+
links.push(anchor);
|
|
64
|
+
}
|
|
65
|
+
count();
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// The player block is the widest wrapper that holds nothing but the label, the picture and no link to follow.
|
|
69
|
+
let block = image;
|
|
70
|
+
while (block.parentElement) {
|
|
71
|
+
const parent = block.parentElement;
|
|
72
|
+
if (flat(parent.textContent).length > PLAYER_BLOCK_LIMIT || parent.querySelector("a[href]") || parent.querySelectorAll("img").length > 1)
|
|
73
|
+
break;
|
|
74
|
+
block = parent;
|
|
75
|
+
}
|
|
76
|
+
const parent = block.parentNode;
|
|
77
|
+
block.remove();
|
|
78
|
+
pruneEmpty(parent);
|
|
79
|
+
count();
|
|
80
|
+
}
|
|
81
|
+
for (const frame of [...root.querySelectorAll("iframe")]) {
|
|
82
|
+
const target = embedTarget(frame.getAttribute("src") ?? frame.getAttribute("data-src") ?? "");
|
|
83
|
+
if (!target)
|
|
84
|
+
continue;
|
|
85
|
+
const parent = frame.parentNode;
|
|
86
|
+
const replacement = linkParagraph(frame.ownerDocument, target.url, flat(frame.getAttribute("title")) || target.name);
|
|
87
|
+
frame.replaceWith(replacement);
|
|
88
|
+
pruneEmpty(parent);
|
|
89
|
+
links.push(replacement.querySelector("a"));
|
|
90
|
+
count();
|
|
91
|
+
}
|
|
92
|
+
for (const video of [...root.querySelectorAll("video")]) {
|
|
93
|
+
const source = (video.getAttribute("src") ?? video.querySelector("source[src]")?.getAttribute("src") ?? "").trim();
|
|
94
|
+
if (!/^https?:\/\//i.test(source))
|
|
95
|
+
continue;
|
|
96
|
+
const replacement = linkParagraph(video.ownerDocument, source, flat(video.getAttribute("title")) || "Video");
|
|
97
|
+
video.replaceWith(replacement);
|
|
98
|
+
links.push(replacement.querySelector("a"));
|
|
99
|
+
count();
|
|
100
|
+
}
|
|
101
|
+
return { changed, links };
|
|
102
|
+
}
|
|
103
|
+
/** Players and video embeds of the common video sites become one link to the video. */
|
|
104
|
+
export const videoPlayers = {
|
|
105
|
+
name: "video-players",
|
|
106
|
+
description: "Play-button pictures, players and video iframes become one marked link to the video page.",
|
|
107
|
+
apply(root, stats) {
|
|
108
|
+
const { changed, links } = resolveVideoPlayers(root, stats);
|
|
109
|
+
links.forEach(link => link.setAttribute("data-media", "video"));
|
|
110
|
+
return changed;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
//# sourceMappingURL=video.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { EMBED_RULES, resolveEmbeds } from "./embeds/index.js";
|
|
2
|
+
import { sidebars } from "./noise/sidebars.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every rule of the content pipeline, in the order they run on a fetched page: embeds first (their address sits in
|
|
5
|
+
* tags that are removed next), then the clutter around the story. The tests check each rule against its fixtures in
|
|
6
|
+
* `test/fixtures/content/<rule name>` and that running it twice changes nothing. See `docs/content-rules.md`.
|
|
7
|
+
*/
|
|
8
|
+
export const PAGE_RULES = [...EMBED_RULES, sidebars];
|
|
9
|
+
export { resolveEmbeds };
|
|
10
|
+
/** What a fetched page goes through before its scripts, frames and `noscript` blocks are removed. */
|
|
11
|
+
export function cleanPage(root, stats = {}) {
|
|
12
|
+
return PAGE_RULES.map(rule => rule.apply(root, stats)).some(Boolean);
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { SIDEBAR_HEADINGS, SIDEBAR_SELECTORS } from "../patterns/sidebars/index.js";
|
|
2
|
+
import { count, flat, pruneEmpty } from "../shared.js";
|
|
3
|
+
const HEADINGS = "h2, h3, h4";
|
|
4
|
+
/** Only the second half of the text can hold a list of other articles: before it, the same title is part of the story. */
|
|
5
|
+
const AFTER_SHARE = 0.5;
|
|
6
|
+
const MIN_LINKS = 2;
|
|
7
|
+
function level(heading) {
|
|
8
|
+
return Number(heading.tagName.slice(1));
|
|
9
|
+
}
|
|
10
|
+
function textBefore(root, node) {
|
|
11
|
+
const range = root.ownerDocument?.createRange() ?? root.createRange();
|
|
12
|
+
range.setStart(root, 0);
|
|
13
|
+
range.setEndBefore(node);
|
|
14
|
+
return flat(range.toString()).length;
|
|
15
|
+
}
|
|
16
|
+
/** The elements that follow a heading up to the next heading of the same or a higher level. */
|
|
17
|
+
function sectionOf(heading) {
|
|
18
|
+
const section = [];
|
|
19
|
+
for (let sibling = heading.nextElementSibling; sibling; sibling = sibling.nextElementSibling) {
|
|
20
|
+
if (/^H[1-6]$/.test(sibling.tagName) && level(sibling) <= level(heading))
|
|
21
|
+
break;
|
|
22
|
+
section.push(sibling);
|
|
23
|
+
}
|
|
24
|
+
return section;
|
|
25
|
+
}
|
|
26
|
+
function removeBlock(element, stats) {
|
|
27
|
+
const parent = element.parentNode;
|
|
28
|
+
element.remove();
|
|
29
|
+
pruneEmpty(parent);
|
|
30
|
+
count(stats, "sidebar");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Lists of other articles ("Popular Stories", "Related articles", "Mehr zum Thema") sit inside the article's container
|
|
34
|
+
* on many sites, so a reader that picks the container takes them along as if they were the story. They go by their
|
|
35
|
+
* markup, or by a heading that names them, when it stands in the second half of the text and is followed by links.
|
|
36
|
+
*/
|
|
37
|
+
export const sidebars = {
|
|
38
|
+
name: "sidebars",
|
|
39
|
+
description: "Lists of recommended, related or popular articles inside the article's container are removed.",
|
|
40
|
+
apply(root, stats) {
|
|
41
|
+
let changed = false;
|
|
42
|
+
for (const selector of SIDEBAR_SELECTORS) {
|
|
43
|
+
for (const element of [...root.querySelectorAll(selector)]) {
|
|
44
|
+
if (!root.contains(element))
|
|
45
|
+
continue;
|
|
46
|
+
removeBlock(element, stats);
|
|
47
|
+
changed = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const total = flat(root.textContent).length;
|
|
51
|
+
if (total === 0)
|
|
52
|
+
return changed;
|
|
53
|
+
for (const heading of [...root.querySelectorAll(HEADINGS)]) {
|
|
54
|
+
if (!root.contains(heading) || !SIDEBAR_HEADINGS.some(pattern => pattern.test(flat(heading.textContent))))
|
|
55
|
+
continue;
|
|
56
|
+
const section = sectionOf(heading);
|
|
57
|
+
const links = section.reduce((sum, element) => sum + element.querySelectorAll("a[href]").length + (element.matches("a[href]") ? 1 : 0), 0);
|
|
58
|
+
if (links < MIN_LINKS || textBefore(root, heading) / total < AFTER_SHARE)
|
|
59
|
+
continue;
|
|
60
|
+
section.forEach(element => element.remove());
|
|
61
|
+
removeBlock(heading, stats);
|
|
62
|
+
changed = true;
|
|
63
|
+
}
|
|
64
|
+
return changed;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=sidebars.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent notices that sit where an embed would be, in German. `anchors` start a notice (a block matching one is
|
|
3
|
+
* removed), `companions` are the lines of the same notice that follow and go with it. A pattern belongs here only
|
|
4
|
+
* when more than one site uses the wording; a single word like "Datenschutz" would also hit articles about it.
|
|
5
|
+
*/
|
|
6
|
+
export default {
|
|
7
|
+
anchors: [
|
|
8
|
+
/^Empfohlene[rn]? redaktionelle[rn]? Inhalte?\b/i,
|
|
9
|
+
/^Ein Klick für den Datenschutz$/i,
|
|
10
|
+
/An dieser Stelle (?:finden Sie|befindet sich) einen? .{0,60}externen? Inhalt/i,
|
|
11
|
+
/Hier findest du externe Inhalte von/i,
|
|
12
|
+
/An dieser Stelle findest du einen externen Inhalt/i,
|
|
13
|
+
/Du kannst ihn dir mit einem Klick anzeigen lassen/i,
|
|
14
|
+
],
|
|
15
|
+
companions: [
|
|
16
|
+
/\bInhalte? (von \S+ )?erlauben\b/i,
|
|
17
|
+
/-Inhalte erlauben\b/i,
|
|
18
|
+
/^Ich bin damit einverstanden, dass mir .+ angezeigt (?:wird|werden)/i,
|
|
19
|
+
/Erst wenn Sie (?:hier )?klicken, werden .{0,60}nachgeladen/i,
|
|
20
|
+
/^Externen? Inhalt(?:e)? anzeigen$/i,
|
|
21
|
+
/^Inhalte? (?:ansehen|anzeigen)$/i,
|
|
22
|
+
/Personenbezogene Daten können an Drittplattformen/i,
|
|
23
|
+
/^Link zum .+-Inhalt$/i,
|
|
24
|
+
/^Mehr dazu in unserer Datenschutzerklärung/i,
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=de.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Consent notices that sit where an embed would be, in English. See `de.ts` for what the two lists mean. */
|
|
2
|
+
export default {
|
|
3
|
+
anchors: [
|
|
4
|
+
/^(?:Recommended|Featured) editorial content$/i,
|
|
5
|
+
/^External content$/i,
|
|
6
|
+
/^Content from (?:YouTube|Vimeo|Instagram|X|Twitter|TikTok|Spotify|Facebook)$/i,
|
|
7
|
+
/^(?:Show|Load|Allow) external content$/i,
|
|
8
|
+
],
|
|
9
|
+
companions: [
|
|
10
|
+
/^I agree (?:to|that) .{0,80}(?:external|third[- ]party) content/i,
|
|
11
|
+
/^Personal data (?:may|can) be (?:transmitted|transferred|shared) to third[- ]party/i,
|
|
12
|
+
/^(?:Show|Load|Allow|Display) content$/i,
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=en.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import de from "./de.js";
|
|
2
|
+
import en from "./en.js";
|
|
3
|
+
/**
|
|
4
|
+
* One file per language. A notice is looked for in every language at once, because an article in one language can
|
|
5
|
+
* sit on a site in another. To add a language, add a file next to these and list it here.
|
|
6
|
+
*/
|
|
7
|
+
const LANGUAGES = [de, en];
|
|
8
|
+
export const CONSENT_ANCHORS = LANGUAGES.flatMap(language => language.anchors);
|
|
9
|
+
export const CONSENT_COMPANIONS = LANGUAGES.flatMap(language => language.companions);
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Wrappers of the consent plugins that hold back an embed until the reader agrees. The embed itself sits inside, escaped. */
|
|
2
|
+
export const CONSENT_WRAPPER = ".embed-privacy-container, [data-embed-provider], .cmplz-blocked-content-container, .borlabs-cookie, " +
|
|
3
|
+
"[data-borlabs-cookie-content], .rcb-content-blocker, [data-rcb-blocker-id]";
|
|
4
|
+
//# sourceMappingURL=wrappers.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alt texts of play-button pictures. A picture with one of them is the button of a player, not an illustration, and goes
|
|
3
|
+
* with the player. Whole texts only: a sentence that merely contains "play" is never a label.
|
|
4
|
+
*/
|
|
5
|
+
export default ["play", "play video", "watch", "watch video", "start video"];
|
|
6
|
+
//# sourceMappingURL=en.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import de from "./de.js";
|
|
2
|
+
import en from "./en.js";
|
|
3
|
+
/** One file per language, searched all at once. To add a language, add a file next to these and list it here. */
|
|
4
|
+
export const PLAY_LABEL = new RegExp(`^(?:${[de, en].flat().map(label => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})$`, "i");
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Headings, in German, that start a list of other articles. See `en.ts` for how they are used. */
|
|
2
|
+
export default [
|
|
3
|
+
/^(?:Mehr zum Thema|Weitere Artikel|Weitere Meldungen|Ähnliche Artikel|Verwandte Artikel|Auch interessant|Das könnte (?:dich|Sie|euch) (?:auch )?interessieren|Meistgelesen(?:e Artikel)?|Beliebte Artikel|Lesen Sie auch|Lies auch|Empfehlungen)$/i,
|
|
4
|
+
];
|
|
5
|
+
//# sourceMappingURL=de.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headings that start a list of other articles. A heading counts only when it is followed by links and stands in the
|
|
3
|
+
* second half of the text (see `noise/sidebars.ts`), so a section of the story with the same title stays.
|
|
4
|
+
*/
|
|
5
|
+
export default [
|
|
6
|
+
/^(?:Popular (?:Stories|Articles|Posts)|Most (?:Read|Popular)|Trending(?: Now| Stories)?|Related (?:Articles|Stories|Posts|Reading|Content)|You (?:might|may) also like|More (?:from|stories|articles)\b.*|Read (?:more|next)|Further reading|Top Stories|Recommended(?: for you)?|Latest (?:Stories|News))$/i,
|
|
7
|
+
];
|
|
8
|
+
//# sourceMappingURL=en.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import de from "./de.js";
|
|
2
|
+
import en from "./en.js";
|
|
3
|
+
/** One file per language, searched all at once. To add a language, add a file next to these and list it here. */
|
|
4
|
+
export const SIDEBAR_HEADINGS = [de, en].flat();
|
|
5
|
+
export { SIDEBAR_SELECTORS } from "./selectors.js";
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocks that recommend other articles, named by the markup of well-known plugins and sites. They are never part of
|
|
3
|
+
* the story. Add a selector only when a plugin or theme is widespread or a site's block is specific enough that it
|
|
4
|
+
* cannot hit an article; a fixture in `test/fixtures/content/sidebars` shows the expected result.
|
|
5
|
+
*/
|
|
6
|
+
export const SIDEBAR_SELECTORS = [
|
|
7
|
+
// WordPress plugins and themes for related and popular posts.
|
|
8
|
+
".yarpp-related", "#jp-relatedposts", ".jp-relatedposts", ".related-posts", ".related_posts", ".relatedposts",
|
|
9
|
+
".wp-block-latest-posts", ".post-navigation", ".crp_related",
|
|
10
|
+
// Sites that mark the block with an analytics label.
|
|
11
|
+
"[data-track='popular-stories']", "[data-track='related-stories']", "[data-track='related-articles']",
|
|
12
|
+
// Broadcasters' "this topic in our programme" teasers.
|
|
13
|
+
".relatedbroadcast",
|
|
14
|
+
];
|
|
15
|
+
//# sourceMappingURL=selectors.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const SOCIAL_PLATFORMS = [
|
|
2
|
+
{ name: "Instagram", host: /^instagram\.com$/, post: /^\/(?:[\w.]+\/)?(?:p|reel|reels|tv)\/[\w-]+/, kind: "social" },
|
|
3
|
+
{ name: "X", host: /^(?:twitter|x)\.com$/, post: /^\/(?:i\/|[\w]+\/)(?:web\/)?status\/\d+/, kind: "social" },
|
|
4
|
+
{ name: "TikTok", host: /^tiktok\.com$/, post: /\/(?:video|photo)\/\d+/, kind: "social" },
|
|
5
|
+
{ name: "Bluesky", host: /^bsky\.app$/, post: /^\/profile\/[^/]+\/post\/[\w]+/, kind: "social" },
|
|
6
|
+
{ name: "Threads", host: /^threads\.(?:net|com)$/, post: /^\/@[\w.]+\/post\/[\w-]+/, kind: "social" },
|
|
7
|
+
{ name: "Reddit", host: /^reddit\.com$/, post: /^\/r\/\w+\/comments\//, kind: "social" },
|
|
8
|
+
{ name: "Facebook", host: /^facebook\.com$/, post: /\/(?:posts|videos|photos|permalink|watch|reel|share)\b|[?&](?:story_fbid|v)=/, kind: "social" },
|
|
9
|
+
{ name: "Spotify", host: /^open\.spotify\.com$/, post: /^\/(?:track|album|playlist|episode|show|artist)\/\w+/, kind: "audio" },
|
|
10
|
+
{ name: "SoundCloud", host: /^soundcloud\.com$/, post: /^\/[^/]+\/[^/]+/, kind: "audio" },
|
|
11
|
+
];
|
|
12
|
+
/** Classes of the blockquote placeholders that the platforms' scripts turn into the real post. */
|
|
13
|
+
export const SOCIAL_QUOTE = "blockquote.instagram-media, blockquote.twitter-tweet, blockquote.tiktok-embed, blockquote.bluesky-embed, " +
|
|
14
|
+
"blockquote.reddit-embed-bq, blockquote.text-post-media, blockquote.fb-xfbml-parse-ignore";
|
|
15
|
+
/**
|
|
16
|
+
* Mastodon has no fixed host: any instance embeds a post as an iframe whose address ends in `/@user/<id>/embed`. The
|
|
17
|
+
* path alone identifies it, so it is matched on every host, for frames only.
|
|
18
|
+
*/
|
|
19
|
+
export const MASTODON_EMBED_PATH = /^\/@[\w.]+\/\d{5,}\/embed\/?$/;
|
|
20
|
+
//# sourceMappingURL=social-platforms.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function flat(value) {
|
|
2
|
+
return (value ?? "").replace(/\s+/g, " ").trim();
|
|
3
|
+
}
|
|
4
|
+
/** Takes away what is left empty once an element is gone, up to (not including) the fragment itself. */
|
|
5
|
+
export function pruneEmpty(node) {
|
|
6
|
+
while (node && node.parentNode && node.nodeType === 1) {
|
|
7
|
+
const element = node;
|
|
8
|
+
if (flat(element.textContent) !== "" || element.querySelector("img, picture, video, iframe, a[href]"))
|
|
9
|
+
return;
|
|
10
|
+
const parent = element.parentNode;
|
|
11
|
+
element.remove();
|
|
12
|
+
node = parent;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** A paragraph holding one plain link: what an embed becomes. Clients turn marked links into cards. */
|
|
16
|
+
export function linkParagraph(document, url, text) {
|
|
17
|
+
const paragraph = document.createElement("p");
|
|
18
|
+
const link = document.createElement("a");
|
|
19
|
+
link.setAttribute("href", url);
|
|
20
|
+
link.textContent = text;
|
|
21
|
+
paragraph.append(link);
|
|
22
|
+
return paragraph;
|
|
23
|
+
}
|
|
24
|
+
export function parseAddress(source) {
|
|
25
|
+
try {
|
|
26
|
+
return new URL(source.startsWith("//") ? `https:${source}` : source);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function count(stats, name) {
|
|
33
|
+
stats[name] = (stats[name] ?? 0) + 1;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=shared.js.map
|