feedkeeper 0.35.5 → 0.35.7
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 +2 -1
- package/server/dist/feeds/content/noise/permalinkGlyphs.js +52 -0
- package/server/dist/feeds/contentNoise.js +3 -0
- package/server/dist/feeds/imageSources.js +6 -4
- package/server/dist/feeds/poller.js +3 -2
- package/server/dist/feeds/redirectLinks.js +22 -0
- package/server/dist/feeds/repository.js +13 -7
- package/server/dist/feeds/text.js +7 -2
- package/server/package.json +1 -1
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import { embedHeadings } from "./embeds/headings.js";
|
|
|
4
4
|
import { boilerplate } from "./noise/boilerplate.js";
|
|
5
5
|
import { loadingPlaceholders } from "./noise/loading.js";
|
|
6
6
|
import { trackingPixels } from "./noise/pixels.js";
|
|
7
|
+
import { permalinkGlyphs } from "./noise/permalinkGlyphs.js";
|
|
7
8
|
import { sidebars } from "./noise/sidebars.js";
|
|
8
9
|
import { teaserLinks } from "./noise/teaserLinks.js";
|
|
9
10
|
/**
|
|
@@ -11,7 +12,7 @@ import { teaserLinks } from "./noise/teaserLinks.js";
|
|
|
11
12
|
* tags that are removed next), then the clutter around the story. The tests check each rule against its fixtures in
|
|
12
13
|
* `test/fixtures/content/<rule name>` and that running it twice changes nothing. See `docs/content-rules.md`.
|
|
13
14
|
*/
|
|
14
|
-
export const PAGE_RULES = [lazyImages, ...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks];
|
|
15
|
+
export const PAGE_RULES = [lazyImages, ...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks, permalinkGlyphs];
|
|
15
16
|
export { resolveEmbeds };
|
|
16
17
|
/** What a fetched page goes through before its scripts, frames and `noscript` blocks are removed. */
|
|
17
18
|
export function cleanPage(root, stats = {}) {
|
|
@@ -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,7 @@ 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 { permalinkGlyphs } from "./content/noise/permalinkGlyphs.js";
|
|
10
11
|
import { teaserLinks } from "./content/noise/teaserLinks.js";
|
|
11
12
|
import { markVideoTeasers } from "./content/noise/videoTeasers.js";
|
|
12
13
|
import { flat, pruneEmpty } from "./content/shared.js";
|
|
@@ -95,6 +96,8 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
95
96
|
// A feed's own text ends with "[mehr]": the link to the article is no part of what is read.
|
|
96
97
|
if (teaserLinks.apply(root, stats))
|
|
97
98
|
changed = true;
|
|
99
|
+
if (permalinkGlyphs.apply(root, stats))
|
|
100
|
+
changed = true;
|
|
98
101
|
if (loadingPlaceholders.apply(root, stats))
|
|
99
102
|
changed = true;
|
|
100
103
|
if (trackingPixels.apply(root, stats))
|
|
@@ -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
|
|
@@ -3,6 +3,7 @@ import { fetchFeed } from "./fetcher.js";
|
|
|
3
3
|
import { discoverIconUrls, iconCheckDue } from "./icon.js";
|
|
4
4
|
import { refreshFeedIcon } from "./feedIcon.js";
|
|
5
5
|
import { decodeEntities, plainTitle } from "./text.js";
|
|
6
|
+
import { unwrapRedirectLink } from "./redirectLinks.js";
|
|
6
7
|
import { enqueueNewItemNotifications, startPushOutbox, stopPushOutbox } from "../pushOutbox.js";
|
|
7
8
|
import { hasUserCapability } from "../auth/capabilities.js";
|
|
8
9
|
import { findFeedById, listSubscriberIds, updateFeedAfterPoll, updateFeedIcon, upsertItems } from "./repository.js";
|
|
@@ -123,8 +124,8 @@ export function itemGuid(item) {
|
|
|
123
124
|
/** The article address: its link, or its guid when the feed gives only that and the guid is a web address. */
|
|
124
125
|
export function itemLink(item) {
|
|
125
126
|
if (item.link)
|
|
126
|
-
return item.link;
|
|
127
|
-
return item.guid && /^https?:\/\//i.test(item.guid) ? item.guid : undefined;
|
|
127
|
+
return unwrapRedirectLink(item.link);
|
|
128
|
+
return item.guid && /^https?:\/\//i.test(item.guid) ? unwrapRedirectLink(item.guid) : undefined;
|
|
128
129
|
}
|
|
129
130
|
/** One attempt; the queue owns retries, admission, single-flight and the total deadline. */
|
|
130
131
|
async function pollAttempt(feed, signal) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Some feeds wrap every article address in a redirect through the publisher's tracker. Google Alerts links through
|
|
3
|
+
* `https://www.google.com/url?...&url=<article>`, which is not the page to read and fails the full-text fetch. The article address
|
|
4
|
+
* inside the wrapper is what an item should point to.
|
|
5
|
+
*/
|
|
6
|
+
const GOOGLE_HOST = /^(?:www\.)?google\.[a-z]{2,3}(?:\.[a-z]{2})?$/i;
|
|
7
|
+
export function unwrapRedirectLink(link) {
|
|
8
|
+
if (!link)
|
|
9
|
+
return link;
|
|
10
|
+
let url;
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(link);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return link;
|
|
16
|
+
}
|
|
17
|
+
if (!GOOGLE_HOST.test(url.hostname) || url.pathname !== "/url")
|
|
18
|
+
return link;
|
|
19
|
+
const target = url.searchParams.get("url") ?? url.searchParams.get("q");
|
|
20
|
+
return target && /^https?:\/\//i.test(target) ? target : link;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=redirectLinks.js.map
|
|
@@ -3,7 +3,8 @@ import { articleText } from "./articleContent.js";
|
|
|
3
3
|
import { LEAD_VERSION } from "./contentNoise.js";
|
|
4
4
|
import { largerHeroImage } from "./heroImage.js";
|
|
5
5
|
import { refreshGalleryClassification, refreshLead } from "./lead.js";
|
|
6
|
-
import { decodeEntities } from "./text.js";
|
|
6
|
+
import { decodeEntities, plainTitle } from "./text.js";
|
|
7
|
+
import { unwrapRedirectLink } from "./redirectLinks.js";
|
|
7
8
|
export function findFeedByUrl(url) {
|
|
8
9
|
return db.prepare("SELECT * FROM feeds WHERE url = ?").get(url);
|
|
9
10
|
}
|
|
@@ -570,19 +571,24 @@ export function moveSubscription(userId, fromFeedId, toFeedId) {
|
|
|
570
571
|
export function updateFeedIcon(feedId, iconUrl) {
|
|
571
572
|
db.prepare("UPDATE feeds SET icon_url = ?, icon_checked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(iconUrl, feedId);
|
|
572
573
|
}
|
|
573
|
-
/**
|
|
574
|
+
/**
|
|
575
|
+
* Fix item titles, snippets, links, feed titles and labels stored before entity decoding, title markup removal and redirect unwrapping
|
|
576
|
+
* existed; cheap once nothing matches.
|
|
577
|
+
*/
|
|
574
578
|
export function repairEncodedText() {
|
|
575
579
|
const rows = db
|
|
576
|
-
.prepare(
|
|
580
|
+
.prepare(`SELECT id, title, content_snippet, link FROM items
|
|
581
|
+
WHERE title LIKE '%&%;%' OR title LIKE '%<%>%' OR content_snippet LIKE '%&%;%' OR link LIKE 'https://www.google.%/url?%'`)
|
|
577
582
|
.all();
|
|
578
|
-
const update = db.prepare("UPDATE items SET title = ?, content_snippet = ? WHERE id = ?");
|
|
583
|
+
const update = db.prepare("UPDATE items SET title = ?, content_snippet = ?, link = ? WHERE id = ?");
|
|
579
584
|
let fixed = 0;
|
|
580
585
|
db.transaction(() => {
|
|
581
586
|
for (const row of rows) {
|
|
582
|
-
const title =
|
|
587
|
+
const title = row.title === null ? null : (plainTitle(row.title) ?? row.title);
|
|
583
588
|
const snippet = decodeEntities(row.content_snippet);
|
|
584
|
-
|
|
585
|
-
|
|
589
|
+
const link = unwrapRedirectLink(row.link);
|
|
590
|
+
if (title !== row.title || snippet !== row.content_snippet || link !== row.link) {
|
|
591
|
+
update.run(title, snippet ?? null, link ?? null, row.id);
|
|
586
592
|
refreshLead(row.id);
|
|
587
593
|
fixed++;
|
|
588
594
|
}
|
|
@@ -15,9 +15,14 @@ function decodeOnce(text) {
|
|
|
15
15
|
return NAMED_ENTITIES[name] ?? match;
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Inline formatting that some feeds put into a title, such as the `<b>` Google Alerts wraps around the search term. Only these tags are
|
|
20
|
+
* removed, so a title that really contains an angle bracket stays as it is.
|
|
21
|
+
*/
|
|
22
|
+
const TITLE_MARKUP = /<\/?(?:b|i|u|em|strong|mark|span|small|sub|sup|font|a|br)\b[^>]*>/gi;
|
|
23
|
+
/** A title as one line of text: entities decoded, inline markup removed, line breaks and runs of spaces collapsed to one space, trimmed. */
|
|
19
24
|
export function plainTitle(text) {
|
|
20
|
-
const cleaned = decodeEntities(text)?.replace(/\s+/g, " ").trim();
|
|
25
|
+
const cleaned = decodeEntities(text)?.replace(TITLE_MARKUP, tag => (/^<br/i.test(tag) ? " " : "")).replace(/\s+/g, " ").trim();
|
|
21
26
|
return cleaned || undefined;
|
|
22
27
|
}
|
|
23
28
|
export function decodeEntities(text) {
|