feedkeeper 0.26.5 → 0.27.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 +2 -2
- package/server/dist/api/items.js +3 -3
- package/server/dist/api/v1/index.js +1 -0
- package/server/dist/api/v1/serialize.js +2 -1
- package/server/dist/db/migrations/0037_item_author.sql +2 -0
- package/server/dist/feeds/articleContent.js +3 -3
- package/server/dist/feeds/author.js +52 -0
- package/server/dist/feeds/contentNoise.js +262 -4
- package/server/dist/feeds/heroImage.js +81 -0
- package/server/dist/feeds/poller.js +2 -0
- package/server/dist/feeds/repository.js +16 -5
- package/server/dist/mcp/items.js +1 -1
- package/server/package.json +1 -1
- package/web/dist/assets/index-Ci8_fWis.js +32 -0
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/index-CqwqZaF0.js +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedkeeper",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
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",
|
package/server/dist/api/items.js
CHANGED
|
@@ -49,8 +49,8 @@ itemsRouter.get("/", (req, res) => {
|
|
|
49
49
|
res.json(items.map(({ content_lead: _lead, lead_version: _leadVersion, ...item }) => ({
|
|
50
50
|
...item,
|
|
51
51
|
content_snippet: cleanSnippet(item.content_snippet, item.title),
|
|
52
|
-
content_html: readerHtml(item.content_html, item.title),
|
|
53
|
-
full_content_html: readerHtml(item.full_content_html, item.title),
|
|
52
|
+
content_html: readerHtml(item.content_html, item.title, item.author),
|
|
53
|
+
full_content_html: readerHtml(item.full_content_html, item.title, item.author),
|
|
54
54
|
read: Boolean(item.read),
|
|
55
55
|
bookmarked: Boolean(item.bookmarked),
|
|
56
56
|
})));
|
|
@@ -126,7 +126,7 @@ itemsRouter.post("/:itemId/extract-content", async (req, res) => {
|
|
|
126
126
|
const [served] = withArchivedImages([{ id: result.id, link: findItemById(result.id)?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
|
|
127
127
|
res.json({
|
|
128
128
|
id: result.id,
|
|
129
|
-
full_content_html: readerHtml(served.full_content_html, result.title ?? findItemById(result.id)?.title),
|
|
129
|
+
full_content_html: readerHtml(served.full_content_html, result.title ?? findItemById(result.id)?.title, findItemById(result.id)?.author),
|
|
130
130
|
...(result.cached ? {} : { title: result.title, byline: result.byline }),
|
|
131
131
|
cached: result.cached,
|
|
132
132
|
});
|
|
@@ -29,6 +29,7 @@ export function serializeItem(row, progress, withContent, hasNote = false) {
|
|
|
29
29
|
subscriptionId: row.feed_id,
|
|
30
30
|
subscriptionTitle: row.feed_title,
|
|
31
31
|
title: row.title,
|
|
32
|
+
author: row.author ?? null,
|
|
32
33
|
url: row.link,
|
|
33
34
|
publishedAt: row.published_at,
|
|
34
35
|
addedAt: row.created_at,
|
|
@@ -37,7 +38,7 @@ export function serializeItem(row, progress, withContent, hasNote = false) {
|
|
|
37
38
|
imageUrl: row.image_url,
|
|
38
39
|
hasFullText: Boolean(row.full_content_html),
|
|
39
40
|
content: contentMetadata(row),
|
|
40
|
-
...(withContent ? { contentHtml: readerHtml(row.content_html, row.title), fullTextHtml: readerHtml(row.full_content_html, row.title) } : {}),
|
|
41
|
+
...(withContent ? { contentHtml: readerHtml(row.content_html, row.title, row.author), fullTextHtml: readerHtml(row.full_content_html, row.title, row.author) } : {}),
|
|
41
42
|
state: {
|
|
42
43
|
read: Boolean(row.read),
|
|
43
44
|
saved: Boolean(row.bookmarked),
|
|
@@ -20,14 +20,14 @@ const readerCache = new Map();
|
|
|
20
20
|
* Article HTML as a reader shows it, without breadcrumbs, repeated headline and similar page chrome. Computed when
|
|
21
21
|
* served and never stored, so the publisher's original stays intact and rule changes apply to old articles too.
|
|
22
22
|
*/
|
|
23
|
-
export function readerHtml(html, title) {
|
|
23
|
+
export function readerHtml(html, title, author) {
|
|
24
24
|
if (!html)
|
|
25
25
|
return html ?? null;
|
|
26
|
-
const key = createHash("sha1").update(title ?? "").update("\0").update(html).digest("base64");
|
|
26
|
+
const key = createHash("sha1").update(title ?? "").update("\0").update(author ?? "").update("\0").update(html).digest("base64");
|
|
27
27
|
const cached = readerCache.get(key);
|
|
28
28
|
if (cached !== undefined)
|
|
29
29
|
return cached;
|
|
30
|
-
const cleaned = cleanReaderHtml(html, title);
|
|
30
|
+
const cleaned = cleanReaderHtml(html, title, {}, author);
|
|
31
31
|
if (readerCache.size >= READER_CACHE_LIMIT)
|
|
32
32
|
readerCache.delete(readerCache.keys().next().value);
|
|
33
33
|
readerCache.set(key, cleaned);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { decodeEntities } from "./text.js";
|
|
2
|
+
const GENERIC_NAMES = /^(admin(istrator)?|redaktion(steam)?|redaktionsteam|editors?|editorial( team| staff)?|staff|team|webmaster|redakteur(in)?|autor|author|unknown|unbekannt|n\/?a|null|undefined|anonymous|anonym|gast|guest|user|news|newsdesk|desk)$/i;
|
|
3
|
+
const MAX_AUTHORS = 6;
|
|
4
|
+
const MAX_LENGTH = 160;
|
|
5
|
+
function key(value) {
|
|
6
|
+
return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
7
|
+
}
|
|
8
|
+
/** One name of a feed's author field. `staff@site.test (Anna Muster)` is the name in brackets; a bare address is no name. */
|
|
9
|
+
function nameOf(raw) {
|
|
10
|
+
let text = decodeEntities(raw).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
11
|
+
const bracketed = text.match(/^\S+@\S+\s*\(([^)]+)\)$/);
|
|
12
|
+
if (bracketed)
|
|
13
|
+
text = bracketed[1].trim();
|
|
14
|
+
else if (/^<?\S+@\S+>?$/.test(text))
|
|
15
|
+
return null;
|
|
16
|
+
const withAddress = text.match(/^(.+?)\s*<\S+@\S+>$/);
|
|
17
|
+
if (withAddress)
|
|
18
|
+
text = withAddress[1].trim();
|
|
19
|
+
text = text.replace(/^(von|by|text|autor(in)?|author)\s*:?\s+/i, "").trim();
|
|
20
|
+
if (!text || /^https?:\/\//i.test(text) || GENERIC_NAMES.test(text))
|
|
21
|
+
return null;
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The author line of an item: every distinct name the feed gives, in order, joined by commas. Nothing is made up:
|
|
26
|
+
* addresses without a name, generic names ("admin", "Redaktion") and the publication's own name are no authors.
|
|
27
|
+
*/
|
|
28
|
+
export function authorLine(raw, publication = {}) {
|
|
29
|
+
const values = (Array.isArray(raw) ? raw : [raw]).filter((value) => typeof value === "string");
|
|
30
|
+
const own = new Set();
|
|
31
|
+
if (publication.title)
|
|
32
|
+
own.add(key(publication.title));
|
|
33
|
+
try {
|
|
34
|
+
if (publication.url)
|
|
35
|
+
own.add(key(new URL(publication.url).hostname.replace(/^www\./, "")));
|
|
36
|
+
}
|
|
37
|
+
catch { /* an unparsable feed address is no reason to lose the author */ }
|
|
38
|
+
// "Engadget" is the publication of "Engadget - Technology News" and of engadget.com alike.
|
|
39
|
+
const isOwn = (name) => name.length >= 4 && [...own].some(known => known === name || known.startsWith(name));
|
|
40
|
+
const names = [];
|
|
41
|
+
for (const value of values) {
|
|
42
|
+
for (const part of (decodeEntities(value) ?? "").split(/\s*(?:;|\s\/\s|\s&\s|\s+und\s+|\s+and\s)\s*/i)) {
|
|
43
|
+
const name = nameOf(part);
|
|
44
|
+
if (!name || isOwn(key(name)) || names.some(known => key(known) === key(name)))
|
|
45
|
+
continue;
|
|
46
|
+
names.push(name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const line = names.slice(0, MAX_AUTHORS).join(", ");
|
|
50
|
+
return line && line.length <= MAX_LENGTH ? line : line ? line.slice(0, MAX_LENGTH).replace(/[,\s]+\S*$/, "") || null : null;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=author.js.map
|
|
@@ -45,7 +45,42 @@ 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
|
-
|
|
48
|
+
const PRODUCT_CARD_CHARACTERS = 220;
|
|
49
|
+
/** An amount of money: `88,89 EUR`, `$199`, `19.99 €`. */
|
|
50
|
+
const PRICE = /(?:\d[\d.,]*\s?(?:€|EUR|\$|USD|£|GBP|CHF)|(?:€|\$|£)\s?\d)/;
|
|
51
|
+
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\\.";
|
|
52
|
+
const TIME_OF_DAY = "(?:[,\\s•·|-]+\\d{1,2}[:.]\\d{2}(?::\\d{2})?(?:\\s?(?:Uhr|[ap]\\.?m\\.?))?)?";
|
|
53
|
+
/** A whole line that is a date, with or without a time: `15. Oktober 2026, 10:51 Uhr`, `Oct 15, 2026`, `15.10.2026`. */
|
|
54
|
+
const DATE_PART = `(?:\\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})`;
|
|
55
|
+
const DATE_ONLY = new RegExp(`^${DATE_PART}${TIME_OF_DAY}$`, "iu");
|
|
56
|
+
/** The date at the end of a byline (`von Michael Bartl I 15.10.2026 - 11:41`): the author stays, the date is in the reader's head. */
|
|
57
|
+
const BYLINE = /^(von|by|text|autor(in)?|author|written by)\b/i;
|
|
58
|
+
const BYLINE_DATE_TAIL = new RegExp(`(?:^|\\s)[|•·–—I-]\\s*${DATE_PART}${TIME_OF_DAY}\\s*$`, "iu");
|
|
59
|
+
function stripBylineDate(block) {
|
|
60
|
+
const walker = block.ownerDocument.createTreeWalker(block, 4);
|
|
61
|
+
let last = null;
|
|
62
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode())
|
|
63
|
+
if (flat(node.textContent))
|
|
64
|
+
last = node;
|
|
65
|
+
if (!last || !BYLINE_DATE_TAIL.test(last.data))
|
|
66
|
+
return false;
|
|
67
|
+
last.data = last.data.replace(BYLINE_DATE_TAIL, "");
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
/** A whole line that is a reading time: `2 min`, `5 Min. Lesezeit`, `Lesezeit: 3 Minuten`, `4 min read`. */
|
|
71
|
+
const READING_TIME_ONLY = /^(?:Lesezeit:?\s*)?\d{1,3}\s*(?:min\.?|minuten?|minutes?)(?:\s*(?:Lesezeit|read|lesen))?$/i;
|
|
72
|
+
/** JSON-LD and similar: an object with a schema context or type, standing as text. */
|
|
73
|
+
function isStructuredData(text) {
|
|
74
|
+
if (text.length < 20 || !text.startsWith("{") || !/"@(context|type)"/.test(text))
|
|
75
|
+
return false;
|
|
76
|
+
try {
|
|
77
|
+
return typeof JSON.parse(text) === "object";
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function removeReaderNoise(root, title, stats, author) {
|
|
49
84
|
let changed = false;
|
|
50
85
|
const drop = (element, rule) => {
|
|
51
86
|
if (!element.parentNode)
|
|
@@ -73,8 +108,35 @@ function removeReaderNoise(root, title, stats) {
|
|
|
73
108
|
if (onlyLinks)
|
|
74
109
|
drop(list, "breadcrumb");
|
|
75
110
|
}
|
|
111
|
+
// A product card of a shop link: its picture is a CSS background, which no reader draws, and its price is a snapshot
|
|
112
|
+
// that is out of date when the article is read later. What is left would be a label, a link and a number.
|
|
113
|
+
for (const shell of [...root.querySelectorAll("[style*='url(' i]")]) {
|
|
114
|
+
if (!shell.parentNode || !/background(-image)?\s*:[^;]*url\(/i.test(shell.getAttribute("style") ?? "") || flat(shell.textContent) !== "")
|
|
115
|
+
continue;
|
|
116
|
+
let card = null;
|
|
117
|
+
for (let ancestor = shell.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
|
118
|
+
const text = flat(ancestor.textContent);
|
|
119
|
+
if (text.length > PRODUCT_CARD_CHARACTERS)
|
|
120
|
+
break;
|
|
121
|
+
if (ancestor.querySelector("a[href]") && PRICE.test(text))
|
|
122
|
+
card = ancestor;
|
|
123
|
+
}
|
|
124
|
+
if (card)
|
|
125
|
+
drop(card, "product-card");
|
|
126
|
+
}
|
|
127
|
+
// Structured data is for machines. Frameworks render it as an element of any name (`<component type="application/ld+json">`),
|
|
128
|
+
// and a stray block may hold nothing else: neither is text for a reader.
|
|
129
|
+
for (const element of [...root.querySelectorAll("[type]")]) {
|
|
130
|
+
if (/^application\/([\w.+-]*\+)?json(;.*)?$/i.test(element.getAttribute("type") ?? ""))
|
|
131
|
+
drop(element, "structured-data");
|
|
132
|
+
}
|
|
133
|
+
for (const block of leafBlocks(root)) {
|
|
134
|
+
if (block.parentNode && isStructuredData(flat(block.textContent)))
|
|
135
|
+
drop(block, "structured-data");
|
|
136
|
+
}
|
|
76
137
|
const headline = normalized(title ?? "");
|
|
77
138
|
const seen = new Set();
|
|
139
|
+
const authorKeys = (author ?? "").split(",").map(name => normalized(name)).filter(Boolean);
|
|
78
140
|
let leading = 0;
|
|
79
141
|
let reachedText = false;
|
|
80
142
|
for (const block of leafBlocks(root)) {
|
|
@@ -98,6 +160,26 @@ function removeReaderNoise(root, title, stats) {
|
|
|
98
160
|
continue;
|
|
99
161
|
}
|
|
100
162
|
}
|
|
163
|
+
// Near the start, a line that is only a date or a reading time repeats what the reader shows in its own head.
|
|
164
|
+
if (text.length <= 60 && charsBefore(root, block) <= 800) {
|
|
165
|
+
if (DATE_ONLY.test(text)) {
|
|
166
|
+
drop(block, "dateline");
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (READING_TIME_ONLY.test(text)) {
|
|
170
|
+
drop(block, "reading-time");
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
|
|
174
|
+
if (BYLINE.test(text) && authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
|
|
175
|
+
drop(block, "byline");
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (BYLINE.test(text) && stripBylineDate(block)) {
|
|
179
|
+
stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
|
|
180
|
+
changed = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
101
183
|
if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
|
|
102
184
|
drop(block, "title-repeat");
|
|
103
185
|
continue;
|
|
@@ -143,6 +225,34 @@ function removeReaderNoise(root, title, stats) {
|
|
|
143
225
|
seen.add(key);
|
|
144
226
|
}
|
|
145
227
|
}
|
|
228
|
+
// A comment section's house rules at the end of the text: no reader has comments, so heading and what follows go.
|
|
229
|
+
const total = (root.textContent ?? "").length;
|
|
230
|
+
for (const block of leafBlocks(root)) {
|
|
231
|
+
if (!block.parentNode || !COMMENT_RULES_HEADING.test(flat(block.textContent)))
|
|
232
|
+
continue;
|
|
233
|
+
if (total - charsBefore(root, block) > TAIL_CHARACTERS)
|
|
234
|
+
continue;
|
|
235
|
+
let node = block;
|
|
236
|
+
while (node.parentNode && node !== root) {
|
|
237
|
+
while (node.nextSibling)
|
|
238
|
+
node.nextSibling.remove();
|
|
239
|
+
node = node.parentNode;
|
|
240
|
+
}
|
|
241
|
+
drop(block, "comment-rules");
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
// An ad label whose ad is not there (a script fills it): sponsored content itself stays, the label above nothing goes.
|
|
245
|
+
const labelled = leafBlocks(root);
|
|
246
|
+
labelled.forEach((block, index) => {
|
|
247
|
+
if (!block.parentNode || !AD_LABEL.test(flat(block.textContent)) || block.querySelector("img, iframe, a"))
|
|
248
|
+
return;
|
|
249
|
+
const sibling = block.nextElementSibling;
|
|
250
|
+
if (sibling && (sibling.matches("img, iframe, video, picture, figure, a") || sibling.querySelector("img, iframe, video, picture, a")))
|
|
251
|
+
return;
|
|
252
|
+
const next = labelled.slice(index + 1).find(other => other.parentNode && flat(other.textContent));
|
|
253
|
+
if (!next || /^H[1-6]$/.test(next.tagName) || AD_LABEL.test(flat(next.textContent)))
|
|
254
|
+
drop(block, "empty-ad-label");
|
|
255
|
+
});
|
|
146
256
|
// Consent placeholders of embedded third-party content: only the neighbours of a known anchor phrase go.
|
|
147
257
|
const blocks = leafBlocks(root);
|
|
148
258
|
blocks.forEach((block, index) => {
|
|
@@ -240,14 +350,135 @@ function serialize(root) {
|
|
|
240
350
|
return holder.innerHTML;
|
|
241
351
|
}
|
|
242
352
|
/** The article HTML as a reader should show it. Unchanged input is returned as is. */
|
|
243
|
-
export function cleanReaderHtml(html, title, stats = {}) {
|
|
353
|
+
export function cleanReaderHtml(html, title, stats = {}, author) {
|
|
244
354
|
if (!html)
|
|
245
355
|
return html;
|
|
246
356
|
const root = JSDOM.fragment(html);
|
|
247
357
|
const images = resolveCustomImages(root, stats);
|
|
248
|
-
const
|
|
358
|
+
const variants = collapseImageVariants(root, stats);
|
|
359
|
+
const videos = resolveVideoPlayers(root, stats);
|
|
360
|
+
const noise = removeReaderNoise(root, title, stats, author);
|
|
249
361
|
const typography = removeTypography(root, stats);
|
|
250
|
-
|
|
362
|
+
videos.links.forEach(link => { if (root.contains(link))
|
|
363
|
+
link.setAttribute("data-media", "video"); });
|
|
364
|
+
return images || variants || videos.changed || noise || typography ? serialize(root) : html;
|
|
365
|
+
}
|
|
366
|
+
const PLAY_LABEL = /^(video starten|video abspielen|abspielen|wiedergabe( starten)?|play( video)?|watch( video)?|start video)$/i;
|
|
367
|
+
const PLAY_SOURCE = /(?:^|[/_.-])play[-_]?(?:icon|button|btn)?\.(?:svg|png|webp|gif)(?:[?#]|$)/i;
|
|
368
|
+
const DURATION = /^\d{1,2}:\d{2}(:\d{2})?$/;
|
|
369
|
+
const PLAYER_BLOCK_LIMIT = 120;
|
|
370
|
+
function isPlayOverlay(image) {
|
|
371
|
+
return PLAY_LABEL.test(flat(image.getAttribute("alt"))) || PLAY_SOURCE.test((image.getAttribute("src") ?? "").trim());
|
|
372
|
+
}
|
|
373
|
+
/** Takes away what is left empty once a player is gone, up to (not including) the fragment itself. */
|
|
374
|
+
function pruneEmpty(node) {
|
|
375
|
+
while (node && node.parentNode && node.nodeType === 1) {
|
|
376
|
+
const element = node;
|
|
377
|
+
if (flat(element.textContent) !== "" || element.querySelector("img, picture, video, iframe, a[href]"))
|
|
378
|
+
return;
|
|
379
|
+
const parent = element.parentNode;
|
|
380
|
+
element.remove();
|
|
381
|
+
node = parent;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Direct video URL of the embeds that are common enough to be worth a link. Anything else is not turned into a link. */
|
|
385
|
+
function embedTarget(source) {
|
|
386
|
+
let address;
|
|
387
|
+
try {
|
|
388
|
+
address = new URL(source.startsWith("//") ? `https:${source}` : source);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
const host = address.hostname.replace(/^www\./, "");
|
|
394
|
+
const segments = address.pathname.split("/").filter(Boolean);
|
|
395
|
+
if (/^youtube(-nocookie)?\.com$/.test(host) && segments[0] === "embed" && /^[\w-]{6,}$/.test(segments[1] ?? "")) {
|
|
396
|
+
return { url: `https://www.youtube.com/watch?v=${segments[1]}`, name: "YouTube" };
|
|
397
|
+
}
|
|
398
|
+
if (host === "player.vimeo.com" && segments[0] === "video" && /^\d+$/.test(segments[1] ?? "")) {
|
|
399
|
+
return { url: `https://vimeo.com/${segments[1]}`, name: "Vimeo" };
|
|
400
|
+
}
|
|
401
|
+
if (host === "dailymotion.com" && segments[0] === "embed" && segments[1] === "video" && /^\w+$/.test(segments[2] ?? "")) {
|
|
402
|
+
return { url: `https://www.dailymotion.com/video/${segments[2]}`, name: "Dailymotion" };
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
function videoLink(document, url, text) {
|
|
407
|
+
const paragraph = document.createElement("p");
|
|
408
|
+
const link = document.createElement("a");
|
|
409
|
+
link.setAttribute("href", url);
|
|
410
|
+
link.textContent = text;
|
|
411
|
+
paragraph.append(link);
|
|
412
|
+
return paragraph;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Players need the publisher's script: a reader without it shows their play-button picture as a broken image and
|
|
416
|
+
* leaves a dead block. What can be reached stays as one plain link to the video (`data-media="video"` tells a client
|
|
417
|
+
* that the target is a video page); what cannot, because the player is a script-driven button without an address,
|
|
418
|
+
* is left out. The video itself is never embedded here: that is for the client to decide.
|
|
419
|
+
*/
|
|
420
|
+
function resolveVideoPlayers(root, stats) {
|
|
421
|
+
const links = [];
|
|
422
|
+
let changed = false;
|
|
423
|
+
const count = () => { stats["video"] = (stats["video"] ?? 0) + 1; changed = true; };
|
|
424
|
+
for (const image of [...root.querySelectorAll("img")].filter(isPlayOverlay)) {
|
|
425
|
+
if (!root.contains(image))
|
|
426
|
+
continue;
|
|
427
|
+
const anchor = image.closest("a[href]");
|
|
428
|
+
if (anchor) {
|
|
429
|
+
const parent = anchor.parentNode;
|
|
430
|
+
image.remove();
|
|
431
|
+
const href = anchor.getAttribute("href");
|
|
432
|
+
const scope = anchor.closest("p, div, figure, li") ?? anchor.parentElement;
|
|
433
|
+
const twin = scope ? [...scope.querySelectorAll("a[href]")].find(other => other !== anchor && other.getAttribute("href") === href) : undefined;
|
|
434
|
+
const label = flat(anchor.textContent);
|
|
435
|
+
if (twin) {
|
|
436
|
+
anchor.remove();
|
|
437
|
+
pruneEmpty(parent);
|
|
438
|
+
links.push(twin);
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
if (label === "" || DURATION.test(label))
|
|
442
|
+
anchor.textContent = flat(anchor.getAttribute("title")) || "Video";
|
|
443
|
+
links.push(anchor);
|
|
444
|
+
}
|
|
445
|
+
count();
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
// The player block is the widest wrapper that holds nothing but the label, the picture and no link to follow.
|
|
449
|
+
let block = image;
|
|
450
|
+
while (block.parentElement) {
|
|
451
|
+
const parent = block.parentElement;
|
|
452
|
+
if (flat(parent.textContent).length > PLAYER_BLOCK_LIMIT || parent.querySelector("a[href]") || parent.querySelectorAll("img").length > 1)
|
|
453
|
+
break;
|
|
454
|
+
block = parent;
|
|
455
|
+
}
|
|
456
|
+
const parent = block.parentNode;
|
|
457
|
+
block.remove();
|
|
458
|
+
pruneEmpty(parent);
|
|
459
|
+
count();
|
|
460
|
+
}
|
|
461
|
+
for (const frame of [...root.querySelectorAll("iframe")]) {
|
|
462
|
+
const target = embedTarget(frame.getAttribute("src") ?? frame.getAttribute("data-src") ?? "");
|
|
463
|
+
if (!target)
|
|
464
|
+
continue;
|
|
465
|
+
const parent = frame.parentNode;
|
|
466
|
+
const replacement = videoLink(frame.ownerDocument, target.url, flat(frame.getAttribute("title")) || target.name);
|
|
467
|
+
frame.replaceWith(replacement);
|
|
468
|
+
pruneEmpty(parent);
|
|
469
|
+
links.push(replacement.querySelector("a"));
|
|
470
|
+
count();
|
|
471
|
+
}
|
|
472
|
+
for (const video of [...root.querySelectorAll("video")]) {
|
|
473
|
+
const source = (video.getAttribute("src") ?? video.querySelector("source[src]")?.getAttribute("src") ?? "").trim();
|
|
474
|
+
if (!/^https?:\/\//i.test(source))
|
|
475
|
+
continue;
|
|
476
|
+
const replacement = videoLink(video.ownerDocument, source, flat(video.getAttribute("title")) || "Video");
|
|
477
|
+
video.replaceWith(replacement);
|
|
478
|
+
links.push(replacement.querySelector("a"));
|
|
479
|
+
count();
|
|
480
|
+
}
|
|
481
|
+
return { changed, links };
|
|
251
482
|
}
|
|
252
483
|
/**
|
|
253
484
|
* Some publishers wrap a picture in their own element and let a script fill the image: `<a-img src="/imgs/…">`
|
|
@@ -276,6 +507,31 @@ function resolveCustomImages(root, stats) {
|
|
|
276
507
|
stats["custom-image"] = (stats["custom-image"] ?? 0) + 1;
|
|
277
508
|
return changed;
|
|
278
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* Publishers put one badge in two colour schemes into a link (`<a><img alt="X"><img alt="X"></a>`) and let their
|
|
512
|
+
* stylesheet show one of them. A reader has no such stylesheet, so both stand there at full size. Images that carry
|
|
513
|
+
* the same label inside one link and no other text are variants of one thing: the link keeps its label as text, or,
|
|
514
|
+
* without a label, its first image. The call to action itself stays.
|
|
515
|
+
*/
|
|
516
|
+
function collapseImageVariants(root, stats) {
|
|
517
|
+
let changed = false;
|
|
518
|
+
for (const anchor of [...root.querySelectorAll("a")]) {
|
|
519
|
+
const images = [...anchor.querySelectorAll("img")];
|
|
520
|
+
if (images.length < 2 || flat(anchor.textContent) !== "")
|
|
521
|
+
continue;
|
|
522
|
+
const labels = new Set(images.map(image => flat(image.getAttribute("alt"))));
|
|
523
|
+
if (labels.size !== 1)
|
|
524
|
+
continue;
|
|
525
|
+
const [label] = labels;
|
|
526
|
+
if (label)
|
|
527
|
+
anchor.replaceChildren(anchor.ownerDocument.createTextNode(label));
|
|
528
|
+
else
|
|
529
|
+
images.slice(1).forEach(image => image.remove());
|
|
530
|
+
stats["image-variants"] = (stats["image-variants"] ?? 0) + 1;
|
|
531
|
+
changed = true;
|
|
532
|
+
}
|
|
533
|
+
return changed;
|
|
534
|
+
}
|
|
279
535
|
const CREDIT_PATTERNS = [
|
|
280
536
|
/^(©|\(c\)\s|copyright\b)/i,
|
|
281
537
|
/^[([]?(Bild|Foto|Fotos|Bildquelle|Bildrechte|Bildnachweis|Titelbild|Beitragsbild|Photo|Image|Credits?|Grafik|Illustration)[)\]]?\s*[:/|]/i,
|
|
@@ -326,6 +582,8 @@ const COMMENT_PATTERNS = [
|
|
|
326
582
|
/\b(let us know|tell us) .{0,40}in the comments\b/i,
|
|
327
583
|
/\bNur angemeldete Benutzer können kommentieren\b/i,
|
|
328
584
|
];
|
|
585
|
+
const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertorial|Sponsored|Gesponsert|Promoted)\s*:?$/i;
|
|
586
|
+
const COMMENT_RULES_HEADING = /^(Regeln für Kommentare|Kommentarregeln|Kommentar-Richtlinien|Comment(ing)? (rules|guidelines|policy)|Community[- ]Richtlinien)\s*:?$/i;
|
|
329
587
|
const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
|
|
330
588
|
const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
|
|
331
589
|
const SECTION_LABEL = /^(Auch interessant|Das könnte (dich|Sie|euch) (auch )?interessieren|Mehr zum Thema|Lesen Sie auch|Lies auch|Lest auch|Aktuelle News|Weitere (Artikel|Meldungen|News)|Popular Stories|Related (Stories|Articles|Posts|Reading)|More (Stories|from)|Top Stories|Meistgelesen|Topaktuell|Regeln für Kommentare|Kommentare|Comments?)\b\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
|
|
@@ -10,6 +10,7 @@ import { db } from "../db/index.js";
|
|
|
10
10
|
import { configurePollHostCooldowns, PollQueue } from "./pollQueue.js";
|
|
11
11
|
import { feedRequests, PollDeferredError, sourceHost } from "./requestGate.js";
|
|
12
12
|
import { normalizePublishedAt } from "./dates.js";
|
|
13
|
+
import { authorLine } from "./author.js";
|
|
13
14
|
configurePollHostCooldowns();
|
|
14
15
|
function mediaAttribute(value, key) {
|
|
15
16
|
if (typeof value !== "object" || value === null)
|
|
@@ -111,6 +112,7 @@ async function pollAttempt(feed, signal) {
|
|
|
111
112
|
contentSnippet: decodeEntities(item.contentSnippet ?? item.content) ?? undefined,
|
|
112
113
|
contentHtml: item.contentEncoded ?? item["content:encoded"] ?? item.content ?? null,
|
|
113
114
|
publishedAt: normalizePublishedAt(item.isoDate ?? item.pubDate) ?? undefined, imageUrl: extractImageUrl(item),
|
|
115
|
+
author: authorLine([item.creator, item.author], { title: current.title, url: current.site_url ?? current.url }),
|
|
114
116
|
}));
|
|
115
117
|
// No await in this transaction. Items, health/validators and job completion become visible together.
|
|
116
118
|
const newItems = db.transaction(() => {
|
|
@@ -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) {
|
|
@@ -135,8 +136,8 @@ export function updateFeedAfterPoll(feedId, data) {
|
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
export function upsertItems(feedId, items) {
|
|
138
|
-
const insert = db.prepare(`INSERT INTO items (feed_id, guid, title, link, content_snippet, content_html, published_at, image_url)
|
|
139
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
139
|
+
const insert = db.prepare(`INSERT INTO items (feed_id, guid, title, link, content_snippet, content_html, published_at, image_url, author)
|
|
140
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
140
141
|
ON CONFLICT (feed_id, guid) DO NOTHING`);
|
|
141
142
|
const update = db.prepare(`UPDATE items SET
|
|
142
143
|
title = COALESCE(?, title),
|
|
@@ -144,7 +145,8 @@ export function upsertItems(feedId, items) {
|
|
|
144
145
|
content_snippet = COALESCE(?, content_snippet),
|
|
145
146
|
content_html = COALESCE(?, content_html),
|
|
146
147
|
published_at = COALESCE(?, published_at),
|
|
147
|
-
image_url = COALESCE(?, image_url)
|
|
148
|
+
image_url = COALESCE(?, image_url),
|
|
149
|
+
author = COALESCE(?, author)
|
|
148
150
|
WHERE feed_id = ? AND guid = ?`);
|
|
149
151
|
const insertMany = db.transaction((rows) => {
|
|
150
152
|
let inserted = 0;
|
|
@@ -157,7 +159,8 @@ export function upsertItems(feedId, items) {
|
|
|
157
159
|
row.contentSnippet ?? null,
|
|
158
160
|
row.contentHtml ?? null,
|
|
159
161
|
row.publishedAt ?? null,
|
|
160
|
-
row.imageUrl ?? null,
|
|
162
|
+
largerHeroImage(row.imageUrl, row.contentHtml, row.link ?? null) ?? row.imageUrl ?? null,
|
|
163
|
+
row.author ?? null,
|
|
161
164
|
];
|
|
162
165
|
const result = insert.run(...values);
|
|
163
166
|
if (result.changes > 0) {
|
|
@@ -173,7 +176,7 @@ export function upsertItems(feedId, items) {
|
|
|
173
176
|
// The summary belongs to the body it was sent with: keeping the longer body keeps its summary too, or
|
|
174
177
|
// a feed that alternates between two variants would change the content on every poll.
|
|
175
178
|
const keptPrevious = retained !== null && next !== retained;
|
|
176
|
-
update.run(row.title ?? null, row.link ?? null, keptPrevious ? null : row.contentSnippet ?? null, retained, row.publishedAt ?? null, row.imageUrl ?? null, feedId, row.guid);
|
|
179
|
+
update.run(row.title ?? null, row.link ?? null, keptPrevious ? null : row.contentSnippet ?? null, retained, row.publishedAt ?? null, row.imageUrl ?? null, row.author ?? null, feedId, row.guid);
|
|
177
180
|
// Polling rewrites every item; only a change of what the lead is made from needs it derived again.
|
|
178
181
|
if (previous) {
|
|
179
182
|
const changed = (retained !== null && retained !== previous.content_html) ||
|
|
@@ -425,6 +428,14 @@ export function findItemById(id) {
|
|
|
425
428
|
export function updateItemFullContent(id, fullContentHtml) {
|
|
426
429
|
db.prepare("UPDATE items SET full_content_html = ? WHERE id = ?").run(fullContentHtml, id);
|
|
427
430
|
refreshLead(id);
|
|
431
|
+
upgradeHeroImage(id, fullContentHtml);
|
|
432
|
+
}
|
|
433
|
+
/** The feed's picture is often a thumbnail of the one the article shows: use the larger size once the text is known. */
|
|
434
|
+
function upgradeHeroImage(id, html) {
|
|
435
|
+
const item = db.prepare("SELECT image_url, link FROM items WHERE id = ?").get(id);
|
|
436
|
+
const larger = item ? largerHeroImage(item.image_url, html, item.link) : null;
|
|
437
|
+
if (larger)
|
|
438
|
+
db.prepare("UPDATE items SET image_url = ? WHERE id = ?").run(larger, id);
|
|
428
439
|
}
|
|
429
440
|
/** Apply a per-item change to many items atomically; returns how many rows changed. */
|
|
430
441
|
export function applyToItems(itemIds, change) {
|
package/server/dist/mcp/items.js
CHANGED
|
@@ -51,7 +51,7 @@ export function getItemForMcp(userId, itemId, maxChars = MAX_CONTENT_LENGTH, off
|
|
|
51
51
|
return null;
|
|
52
52
|
if (revision !== undefined && revision !== item.content_revision)
|
|
53
53
|
throw new Error("content_revision_conflict");
|
|
54
|
-
const content = readerHtml(item.full_content_html ?? item.content_html, item.title);
|
|
54
|
+
const content = readerHtml(item.full_content_html ?? item.content_html, item.title, item.author);
|
|
55
55
|
const body = format === "text" ? sourceText(item) : content ?? "";
|
|
56
56
|
let end = Math.min(offset + Math.min(maxChars, MAX_CONTENT_LENGTH), body.length);
|
|
57
57
|
if (end < body.length && /[\uD800-\uDBFF]/.test(body[end - 1]))
|