feedkeeper 0.26.5 → 0.26.6
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedkeeper",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.6",
|
|
4
4
|
"description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rss",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"express-rate-limit": "^8.7.1",
|
|
48
48
|
"fast-xml-parser": "^5.11.2",
|
|
49
49
|
"helmet": "^8.3.0",
|
|
50
|
-
"ipaddr.js": "^
|
|
50
|
+
"ipaddr.js": "^2.5.0",
|
|
51
51
|
"jsdom": "^30.1.2",
|
|
52
52
|
"node-cron": "^4.6.0",
|
|
53
53
|
"otpauth": "^9.5.2",
|
|
@@ -45,6 +45,23 @@ const CONSENT_COMPANIONS = [
|
|
|
45
45
|
/\bInhalte? (von \S+ )?erlauben\b/i, /-Inhalte erlauben\b/i, /^Ich bin damit einverstanden, dass mir .+ angezeigt werden/i,
|
|
46
46
|
/Personenbezogene Daten können an Drittplattformen/i, /^Link zum .+-Inhalt$/i, /^Mehr dazu in unserer Datenschutzerklärung/i,
|
|
47
47
|
];
|
|
48
|
+
const MONTHS = "Januar|Februar|März|Maerz|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember|January|February|March|June|July|October|December|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec|Jan\\.|Feb\\.";
|
|
49
|
+
const TIME_OF_DAY = "(?:[,\\s•·|-]+\\d{1,2}[:.]\\d{2}(?::\\d{2})?(?:\\s?(?:Uhr|[ap]\\.?m\\.?))?)?";
|
|
50
|
+
/** A whole line that is a date, with or without a time: `15. Oktober 2026, 10:51 Uhr`, `Oct 15, 2026`, `15.10.2026`. */
|
|
51
|
+
const DATE_ONLY = new RegExp(`^(?:\\d{1,2}\\.\\s?\\d{1,2}\\.\\s?\\d{2,4}|\\d{1,2}\\.?\\s(?:${MONTHS})\\.?\\s\\d{4}|(?:${MONTHS})\\.?\\s\\d{1,2},?\\s\\d{4})${TIME_OF_DAY}$`, "iu");
|
|
52
|
+
/** A whole line that is a reading time: `2 min`, `5 Min. Lesezeit`, `Lesezeit: 3 Minuten`, `4 min read`. */
|
|
53
|
+
const READING_TIME_ONLY = /^(?:Lesezeit:?\s*)?\d{1,3}\s*(?:min\.?|minuten?|minutes?)(?:\s*(?:Lesezeit|read|lesen))?$/i;
|
|
54
|
+
/** JSON-LD and similar: an object with a schema context or type, standing as text. */
|
|
55
|
+
function isStructuredData(text) {
|
|
56
|
+
if (text.length < 20 || !text.startsWith("{") || !/"@(context|type)"/.test(text))
|
|
57
|
+
return false;
|
|
58
|
+
try {
|
|
59
|
+
return typeof JSON.parse(text) === "object";
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
48
65
|
function removeReaderNoise(root, title, stats) {
|
|
49
66
|
let changed = false;
|
|
50
67
|
const drop = (element, rule) => {
|
|
@@ -73,6 +90,16 @@ function removeReaderNoise(root, title, stats) {
|
|
|
73
90
|
if (onlyLinks)
|
|
74
91
|
drop(list, "breadcrumb");
|
|
75
92
|
}
|
|
93
|
+
// Structured data is for machines. Frameworks render it as an element of any name (`<component type="application/ld+json">`),
|
|
94
|
+
// and a stray block may hold nothing else: neither is text for a reader.
|
|
95
|
+
for (const element of [...root.querySelectorAll("[type]")]) {
|
|
96
|
+
if (/^application\/([\w.+-]*\+)?json(;.*)?$/i.test(element.getAttribute("type") ?? ""))
|
|
97
|
+
drop(element, "structured-data");
|
|
98
|
+
}
|
|
99
|
+
for (const block of leafBlocks(root)) {
|
|
100
|
+
if (block.parentNode && isStructuredData(flat(block.textContent)))
|
|
101
|
+
drop(block, "structured-data");
|
|
102
|
+
}
|
|
76
103
|
const headline = normalized(title ?? "");
|
|
77
104
|
const seen = new Set();
|
|
78
105
|
let leading = 0;
|
|
@@ -98,6 +125,17 @@ function removeReaderNoise(root, title, stats) {
|
|
|
98
125
|
continue;
|
|
99
126
|
}
|
|
100
127
|
}
|
|
128
|
+
// Near the start, a line that is only a date or a reading time repeats what the reader shows in its own head.
|
|
129
|
+
if (text.length <= 60 && charsBefore(root, block) <= 800) {
|
|
130
|
+
if (DATE_ONLY.test(text)) {
|
|
131
|
+
drop(block, "dateline");
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (READING_TIME_ONLY.test(text)) {
|
|
135
|
+
drop(block, "reading-time");
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
101
139
|
if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
|
|
102
140
|
drop(block, "title-repeat");
|
|
103
141
|
continue;
|
|
@@ -245,9 +283,10 @@ export function cleanReaderHtml(html, title, stats = {}) {
|
|
|
245
283
|
return html;
|
|
246
284
|
const root = JSDOM.fragment(html);
|
|
247
285
|
const images = resolveCustomImages(root, stats);
|
|
286
|
+
const variants = collapseImageVariants(root, stats);
|
|
248
287
|
const noise = removeReaderNoise(root, title, stats);
|
|
249
288
|
const typography = removeTypography(root, stats);
|
|
250
|
-
return images || noise || typography ? serialize(root) : html;
|
|
289
|
+
return images || variants || noise || typography ? serialize(root) : html;
|
|
251
290
|
}
|
|
252
291
|
/**
|
|
253
292
|
* Some publishers wrap a picture in their own element and let a script fill the image: `<a-img src="/imgs/…">`
|
|
@@ -276,6 +315,31 @@ function resolveCustomImages(root, stats) {
|
|
|
276
315
|
stats["custom-image"] = (stats["custom-image"] ?? 0) + 1;
|
|
277
316
|
return changed;
|
|
278
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Publishers put one badge in two colour schemes into a link (`<a><img alt="X"><img alt="X"></a>`) and let their
|
|
320
|
+
* stylesheet show one of them. A reader has no such stylesheet, so both stand there at full size. Images that carry
|
|
321
|
+
* the same label inside one link and no other text are variants of one thing: the link keeps its label as text, or,
|
|
322
|
+
* without a label, its first image. The call to action itself stays.
|
|
323
|
+
*/
|
|
324
|
+
function collapseImageVariants(root, stats) {
|
|
325
|
+
let changed = false;
|
|
326
|
+
for (const anchor of [...root.querySelectorAll("a")]) {
|
|
327
|
+
const images = [...anchor.querySelectorAll("img")];
|
|
328
|
+
if (images.length < 2 || flat(anchor.textContent) !== "")
|
|
329
|
+
continue;
|
|
330
|
+
const labels = new Set(images.map(image => flat(image.getAttribute("alt"))));
|
|
331
|
+
if (labels.size !== 1)
|
|
332
|
+
continue;
|
|
333
|
+
const [label] = labels;
|
|
334
|
+
if (label)
|
|
335
|
+
anchor.replaceChildren(anchor.ownerDocument.createTextNode(label));
|
|
336
|
+
else
|
|
337
|
+
images.slice(1).forEach(image => image.remove());
|
|
338
|
+
stats["image-variants"] = (stats["image-variants"] ?? 0) + 1;
|
|
339
|
+
changed = true;
|
|
340
|
+
}
|
|
341
|
+
return changed;
|
|
342
|
+
}
|
|
279
343
|
const CREDIT_PATTERNS = [
|
|
280
344
|
/^(©|\(c\)\s|copyright\b)/i,
|
|
281
345
|
/^[([]?(Bild|Foto|Fotos|Bildquelle|Bildrechte|Bildnachweis|Titelbild|Beitragsbild|Photo|Image|Credits?|Grafik|Illustration)[)\]]?\s*[:/|]/i,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { decodeEntities } from "./text.js";
|
|
2
|
+
/** The widest picture a hero needs; a still larger variant is not worth the bytes. */
|
|
3
|
+
const MAX_HERO_WIDTH = 2000;
|
|
4
|
+
/** A larger variant must be clearly larger than the feed's picture, so a small difference does not change anything. */
|
|
5
|
+
const MIN_GAIN = 1.5;
|
|
6
|
+
/**
|
|
7
|
+
* Splits an image address into what identifies the picture and the width of this size. Publishers name sizes in the
|
|
8
|
+
* file name (`…__wide__148`, `-300x200.jpg`, `_300x200.jpg`) or in the query (`?w=300`); two addresses with the same
|
|
9
|
+
* key are the same picture in different sizes. Addresses without a recognised size have no variant.
|
|
10
|
+
*/
|
|
11
|
+
export function imageVariant(raw) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(raw);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const path = url.pathname;
|
|
20
|
+
const named = /^(.*__[a-z]+__)(\d{2,4})$/i.exec(path) ?? /^(.*[-_])(\d{2,4})x\d{2,4}(\.[a-z0-9]{2,5})?$/i.exec(path);
|
|
21
|
+
if (named) {
|
|
22
|
+
// The query of such addresses is a cache version (`?v=…`), not part of the picture.
|
|
23
|
+
return { key: `${url.origin}${named[1]}#${named[3] ?? ""}`, width: Number(named[2]) };
|
|
24
|
+
}
|
|
25
|
+
const widthParam = ["w", "width"].find((name) => /^\d{2,4}$/.test(url.searchParams.get(name) ?? ""));
|
|
26
|
+
if (widthParam) {
|
|
27
|
+
const rest = new URLSearchParams(url.searchParams);
|
|
28
|
+
for (const name of ["w", "width", "h", "height", "q", "quality", "fit", "dpr"])
|
|
29
|
+
rest.delete(name);
|
|
30
|
+
return { key: `${url.origin}${path}?${rest.toString()}`, width: Number(url.searchParams.get(widthParam)) };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/** Every image address in the HTML: `src` of images and each entry of a `srcset`. */
|
|
35
|
+
function imageAddresses(html, baseUrl) {
|
|
36
|
+
const found = new Set();
|
|
37
|
+
const add = (raw) => {
|
|
38
|
+
const value = decodeEntities(raw.trim());
|
|
39
|
+
if (!value || value.startsWith("data:"))
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(value, baseUrl ?? undefined);
|
|
43
|
+
if (url.protocol === "https:" || url.protocol === "http:")
|
|
44
|
+
found.add(url.toString());
|
|
45
|
+
}
|
|
46
|
+
catch { /* A broken address is not a candidate. */ }
|
|
47
|
+
};
|
|
48
|
+
for (const [tag] of html.matchAll(/<(?:img|source)\b[^>]*>/gi)) {
|
|
49
|
+
const src = /\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag);
|
|
50
|
+
if (src)
|
|
51
|
+
add(src[1] ?? src[2] ?? "");
|
|
52
|
+
const srcset = /\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag);
|
|
53
|
+
if (srcset)
|
|
54
|
+
for (const entry of (srcset[1] ?? srcset[2] ?? "").split(/,\s+/))
|
|
55
|
+
add(entry.trim().split(/\s+/)[0] ?? "");
|
|
56
|
+
}
|
|
57
|
+
return [...found];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A feed often offers a thumbnail (`…__wide__148`) of the picture that its article shows in full size. When the
|
|
61
|
+
* article carries the same picture in a clearly larger size, that size is the better hero; otherwise there is none.
|
|
62
|
+
*/
|
|
63
|
+
export function largerHeroImage(imageUrl, html, baseUrl) {
|
|
64
|
+
if (!imageUrl || !html || !html.includes("<"))
|
|
65
|
+
return null;
|
|
66
|
+
const current = imageVariant(imageUrl);
|
|
67
|
+
if (!current)
|
|
68
|
+
return null;
|
|
69
|
+
let best = null;
|
|
70
|
+
for (const address of imageAddresses(html, baseUrl)) {
|
|
71
|
+
const variant = imageVariant(address);
|
|
72
|
+
if (!variant || variant.key !== current.key)
|
|
73
|
+
continue;
|
|
74
|
+
if (variant.width < current.width * MIN_GAIN || variant.width > MAX_HERO_WIDTH)
|
|
75
|
+
continue;
|
|
76
|
+
if (!best || variant.width > best.width)
|
|
77
|
+
best = { url: address, width: variant.width };
|
|
78
|
+
}
|
|
79
|
+
return best?.url ?? null;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=heroImage.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { db } from "../db/index.js";
|
|
2
2
|
import { articleText } from "./articleContent.js";
|
|
3
3
|
import { LEAD_VERSION } from "./contentNoise.js";
|
|
4
|
+
import { largerHeroImage } from "./heroImage.js";
|
|
4
5
|
import { refreshLead } from "./lead.js";
|
|
5
6
|
import { decodeEntities } from "./text.js";
|
|
6
7
|
export function findFeedByUrl(url) {
|
|
@@ -157,7 +158,7 @@ export function upsertItems(feedId, items) {
|
|
|
157
158
|
row.contentSnippet ?? null,
|
|
158
159
|
row.contentHtml ?? null,
|
|
159
160
|
row.publishedAt ?? null,
|
|
160
|
-
row.imageUrl ?? null,
|
|
161
|
+
largerHeroImage(row.imageUrl, row.contentHtml, row.link ?? null) ?? row.imageUrl ?? null,
|
|
161
162
|
];
|
|
162
163
|
const result = insert.run(...values);
|
|
163
164
|
if (result.changes > 0) {
|
|
@@ -425,6 +426,14 @@ export function findItemById(id) {
|
|
|
425
426
|
export function updateItemFullContent(id, fullContentHtml) {
|
|
426
427
|
db.prepare("UPDATE items SET full_content_html = ? WHERE id = ?").run(fullContentHtml, id);
|
|
427
428
|
refreshLead(id);
|
|
429
|
+
upgradeHeroImage(id, fullContentHtml);
|
|
430
|
+
}
|
|
431
|
+
/** The feed's picture is often a thumbnail of the one the article shows: use the larger size once the text is known. */
|
|
432
|
+
function upgradeHeroImage(id, html) {
|
|
433
|
+
const item = db.prepare("SELECT image_url, link FROM items WHERE id = ?").get(id);
|
|
434
|
+
const larger = item ? largerHeroImage(item.image_url, html, item.link) : null;
|
|
435
|
+
if (larger)
|
|
436
|
+
db.prepare("UPDATE items SET image_url = ? WHERE id = ?").run(larger, id);
|
|
428
437
|
}
|
|
429
438
|
/** Apply a per-item change to many items atomically; returns how many rows changed. */
|
|
430
439
|
export function applyToItems(itemIds, change) {
|