feedkeeper 0.35.6 → 0.35.8
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/index.js +3 -1
- package/server/dist/feeds/content/noise/pageChrome.js +38 -0
- package/server/dist/feeds/content/noise/permalinkGlyphs.js +52 -0
- package/server/dist/feeds/contentNoise.js +6 -0
- package/server/dist/feeds/imageSources.js +6 -4
- package/server/package.json +1 -1
package/package.json
CHANGED
|
@@ -3,7 +3,9 @@ import { EMBED_RULES, resolveEmbeds } from "./embeds/index.js";
|
|
|
3
3
|
import { embedHeadings } from "./embeds/headings.js";
|
|
4
4
|
import { boilerplate } from "./noise/boilerplate.js";
|
|
5
5
|
import { loadingPlaceholders } from "./noise/loading.js";
|
|
6
|
+
import { pageChrome } from "./noise/pageChrome.js";
|
|
6
7
|
import { trackingPixels } from "./noise/pixels.js";
|
|
8
|
+
import { permalinkGlyphs } from "./noise/permalinkGlyphs.js";
|
|
7
9
|
import { sidebars } from "./noise/sidebars.js";
|
|
8
10
|
import { teaserLinks } from "./noise/teaserLinks.js";
|
|
9
11
|
/**
|
|
@@ -11,7 +13,7 @@ import { teaserLinks } from "./noise/teaserLinks.js";
|
|
|
11
13
|
* tags that are removed next), then the clutter around the story. The tests check each rule against its fixtures in
|
|
12
14
|
* `test/fixtures/content/<rule name>` and that running it twice changes nothing. See `docs/content-rules.md`.
|
|
13
15
|
*/
|
|
14
|
-
export const PAGE_RULES = [lazyImages, ...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks];
|
|
16
|
+
export const PAGE_RULES = [lazyImages, ...EMBED_RULES, embedHeadings, pageChrome, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks, permalinkGlyphs];
|
|
15
17
|
export { resolveEmbeds };
|
|
16
18
|
/** What a fetched page goes through before its scripts, frames and `noscript` blocks are removed. */
|
|
17
19
|
export function cleanPage(root, stats = {}) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { count, flat, pruneEmpty } from "../shared.js";
|
|
2
|
+
/** A paragraph this long is prose, not a menu entry: an element that holds one is never page chrome, whatever it is called. */
|
|
3
|
+
const PROSE_CHARACTERS = 200;
|
|
4
|
+
/** What only a site's own navigation contains. */
|
|
5
|
+
const NAVIGATION = "nav, [role='navigation'], [role='menubar'], menu";
|
|
6
|
+
/** A page header holds the site's navigation, or even the page's main area; an article's own header holds a headline and a byline. */
|
|
7
|
+
const PAGE_HEADER_PARTS = `${NAVIGATION}, main`;
|
|
8
|
+
function holdsProse(element) {
|
|
9
|
+
return [...element.querySelectorAll("p")].some(paragraph => flat(paragraph.textContent).length >= PROSE_CHARACTERS);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Some publishers put their whole page header into the text of an article: a menu of magazines with empty entries
|
|
13
|
+
* (their icons are drawn by a stylesheet), service links, the logo and lists of the publisher's other pages. A reader
|
|
14
|
+
* shows all of it before the first line of the story. A header that contains navigation, and any navigation of its own, are
|
|
15
|
+
* the site and not the story: they go, unless prose sits inside, which means the markup is broken and wraps the article.
|
|
16
|
+
*/
|
|
17
|
+
export const pageChrome = {
|
|
18
|
+
name: "page-chrome",
|
|
19
|
+
description: "A page header that holds the site's navigation, and any navigation element, are removed from the text of an article.",
|
|
20
|
+
apply(root, stats) {
|
|
21
|
+
let changed = false;
|
|
22
|
+
const remove = (element) => {
|
|
23
|
+
if (!element.parentNode || holdsProse(element))
|
|
24
|
+
return;
|
|
25
|
+
const parent = element.parentNode;
|
|
26
|
+
element.remove();
|
|
27
|
+
pruneEmpty(parent);
|
|
28
|
+
count(stats, "page-chrome");
|
|
29
|
+
changed = true;
|
|
30
|
+
};
|
|
31
|
+
for (const header of [...root.querySelectorAll("header")].filter(candidate => candidate.querySelector(PAGE_HEADER_PARTS)))
|
|
32
|
+
remove(header);
|
|
33
|
+
for (const navigation of [...root.querySelectorAll(NAVIGATION)])
|
|
34
|
+
remove(navigation);
|
|
35
|
+
return changed;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=pageChrome.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { count, flat, pruneEmpty } from "../shared.js";
|
|
2
|
+
/** One to three characters that are neither letters nor digits: "★", "∞", "¶", "#", "↩". Non-breaking spaces around them count as blanks. */
|
|
3
|
+
const GLYPH_ONLY = /^[^\p{L}\p{N}]{1,3}$/u;
|
|
4
|
+
/** Whether any text follows the element in the document: only the tail of a story is looked at. */
|
|
5
|
+
function hasTextAfter(root, element) {
|
|
6
|
+
const document = root.nodeType === 9 ? root : root.ownerDocument;
|
|
7
|
+
if (!document)
|
|
8
|
+
return false;
|
|
9
|
+
const walker = document.createTreeWalker(root, 4);
|
|
10
|
+
let reached = false;
|
|
11
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
12
|
+
if (element.contains(node)) {
|
|
13
|
+
reached = true;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (reached && flat(node.textContent) !== "")
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Some blogs end every post with a permanent link that is only a symbol ("★", "∞", "¶"): a link to the post itself, of no use inside a reader.
|
|
23
|
+
* The block must hold nothing but that one link, come after the text of the story and have nothing after it. A symbol inside a sentence,
|
|
24
|
+
* a link with a picture and a block with more text stay.
|
|
25
|
+
*/
|
|
26
|
+
export const permalinkGlyphs = {
|
|
27
|
+
name: "permalink-glyphs",
|
|
28
|
+
description: "A block at the end of a story that holds only one link made of a single symbol such as \"★\" or \"¶\" is removed.",
|
|
29
|
+
apply(root, stats) {
|
|
30
|
+
let changed = false;
|
|
31
|
+
const total = flat(root.textContent).length;
|
|
32
|
+
for (const block of [...root.querySelectorAll("p, div")].reverse()) {
|
|
33
|
+
if (!root.contains(block) || block.querySelector("p, div, img, picture, video, iframe"))
|
|
34
|
+
continue;
|
|
35
|
+
const links = block.querySelectorAll("a[href]");
|
|
36
|
+
if (links.length !== 1)
|
|
37
|
+
continue;
|
|
38
|
+
const text = (block.textContent ?? "").replace(/[\s\u00a0]+/g, "");
|
|
39
|
+
if (!GLYPH_ONLY.test(text) || flat(links[0].textContent).replace(/[\s\u00a0]+/g, "") !== text)
|
|
40
|
+
continue;
|
|
41
|
+
if (total - text.length < 120 || hasTextAfter(root, block))
|
|
42
|
+
continue;
|
|
43
|
+
const parent = block.parentNode;
|
|
44
|
+
block.remove();
|
|
45
|
+
pruneEmpty(parent);
|
|
46
|
+
count(stats, "permalink-glyphs");
|
|
47
|
+
changed = true;
|
|
48
|
+
}
|
|
49
|
+
return changed;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=permalinkGlyphs.js.map
|
|
@@ -7,6 +7,8 @@ import { SIDEBAR_HEADINGS } from "./content/patterns/sidebars/index.js";
|
|
|
7
7
|
import { resolveEmbeds } from "./content/embeds/index.js";
|
|
8
8
|
import { loadingPlaceholders } from "./content/noise/loading.js";
|
|
9
9
|
import { trackingPixels } from "./content/noise/pixels.js";
|
|
10
|
+
import { pageChrome } from "./content/noise/pageChrome.js";
|
|
11
|
+
import { permalinkGlyphs } from "./content/noise/permalinkGlyphs.js";
|
|
10
12
|
import { teaserLinks } from "./content/noise/teaserLinks.js";
|
|
11
13
|
import { markVideoTeasers } from "./content/noise/videoTeasers.js";
|
|
12
14
|
import { flat, pruneEmpty } from "./content/shared.js";
|
|
@@ -95,10 +97,14 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
95
97
|
// A feed's own text ends with "[mehr]": the link to the article is no part of what is read.
|
|
96
98
|
if (teaserLinks.apply(root, stats))
|
|
97
99
|
changed = true;
|
|
100
|
+
if (permalinkGlyphs.apply(root, stats))
|
|
101
|
+
changed = true;
|
|
98
102
|
if (loadingPlaceholders.apply(root, stats))
|
|
99
103
|
changed = true;
|
|
100
104
|
if (trackingPixels.apply(root, stats))
|
|
101
105
|
changed = true;
|
|
106
|
+
if (pageChrome.apply(root, stats))
|
|
107
|
+
changed = true;
|
|
102
108
|
for (const selector of BREADCRUMB_MARKERS) {
|
|
103
109
|
try {
|
|
104
110
|
root.querySelectorAll(selector).forEach(element => drop(element, "breadcrumb"));
|
|
@@ -42,6 +42,8 @@ export function imageSource(attribute) {
|
|
|
42
42
|
}
|
|
43
43
|
return null;
|
|
44
44
|
}
|
|
45
|
+
/** The body of a tag up to its closing `>`: a `>` inside a quoted value, as in `media="(resolution >= 3x)"`, does not end it. */
|
|
46
|
+
const TAG_BODY = `(?:[^>"']|"[^"]*"|'[^']*')*`;
|
|
45
47
|
function attribute(tag, name) {
|
|
46
48
|
const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
|
|
47
49
|
return match ? match[1] ?? match[2] ?? match[3] : null;
|
|
@@ -56,14 +58,14 @@ export function normalizeImageSources(html) {
|
|
|
56
58
|
const withoutSources = tag.replace(/\s+(?:(?:data-(?:lazy-)?)?src(?:set)?|sizes|data-sizes|data-original|data-actualsrc)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+)/gi, "");
|
|
57
59
|
return withoutSources.replace(/\s*\/?>$/, (end) => ` src="${escaped}"${end}`);
|
|
58
60
|
};
|
|
59
|
-
const pictures = html.replace(
|
|
61
|
+
const pictures = html.replace(new RegExp(`<picture\\b${TAG_BODY}>[\\s\\S]*?<\\/picture\\s*>`, "gi"), (picture) => {
|
|
60
62
|
let fallback = null;
|
|
61
|
-
const withoutSources = picture.replace(
|
|
63
|
+
const withoutSources = picture.replace(new RegExp(`<source\\b${TAG_BODY}>`, "gi"), (tag) => {
|
|
62
64
|
fallback ??= firstSrcsetSource(attribute(tag, "data-lazy-srcset") ?? attribute(tag, "data-srcset") ?? attribute(tag, "srcset"));
|
|
63
65
|
return "";
|
|
64
66
|
});
|
|
65
|
-
return withoutSources.replace(
|
|
67
|
+
return withoutSources.replace(new RegExp(`<img\\b${TAG_BODY}>`, "gi"), (tag) => normalize(tag, fallback));
|
|
66
68
|
});
|
|
67
|
-
return pictures.replace(
|
|
69
|
+
return pictures.replace(new RegExp(`<img\\b${TAG_BODY}>`, "gi"), (tag) => normalize(tag));
|
|
68
70
|
}
|
|
69
71
|
//# sourceMappingURL=imageSources.js.map
|