feedkeeper 0.26.0 → 0.26.1

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.0",
3
+ "version": "0.26.1",
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",
@@ -5,6 +5,8 @@ import { z } from "zod";
5
5
  import { requireSession } from "../auth/middleware.js";
6
6
  import { listItemsForUser, markAllRead, markItemRead, markItemUnread, bookmarkItem, unbookmarkItem, findItemById, } from "../feeds/repository.js";
7
7
  import { loadFullText } from "../feeds/fullText.js";
8
+ import { readerHtml } from "../feeds/articleContent.js";
9
+ import { cleanSnippet } from "../feeds/contentNoise.js";
8
10
  import { fetchItemImage, pruneArchive, scheduleArchive, withArchivedImages, withProxiedImages } from "../feeds/archive.js";
9
11
  import { ImageCacheBusyError } from "../feeds/imageCache.js";
10
12
  export const FULL_TEXT_STATUS = {
@@ -46,6 +48,9 @@ itemsRouter.get("/", (req, res) => {
46
48
  const items = parsed.data.offline ? withProxiedImages(archived, origin) : archived;
47
49
  res.json(items.map((item) => ({
48
50
  ...item,
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),
49
54
  read: Boolean(item.read),
50
55
  bookmarked: Boolean(item.bookmarked),
51
56
  })));
@@ -121,7 +126,7 @@ itemsRouter.post("/:itemId/extract-content", async (req, res) => {
121
126
  const [served] = withArchivedImages([{ id: result.id, link: findItemById(result.id)?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
122
127
  res.json({
123
128
  id: result.id,
124
- full_content_html: served.full_content_html,
129
+ full_content_html: readerHtml(served.full_content_html, result.title ?? findItemById(result.id)?.title),
125
130
  ...(result.cached ? {} : { title: result.title, byline: result.byline }),
126
131
  cached: result.cached,
127
132
  });
@@ -25,6 +25,7 @@ const FEATURES = [
25
25
  "fulltext",
26
26
  "content.revisions",
27
27
  "content.prepare",
28
+ "item.lead",
28
29
  "images",
29
30
  "muted-keywords",
30
31
  "opml",
@@ -1,4 +1,6 @@
1
- import { contentMetadata } from "../../feeds/articleContent.js";
1
+ import { contentMetadata, readerHtml } from "../../feeds/articleContent.js";
2
+ import { cleanSnippet } from "../../feeds/contentNoise.js";
3
+ import { leadOf } from "../../feeds/lead.js";
2
4
  import { db } from "../../db/index.js";
3
5
  import { serializeSubscription } from "../../sync/changeLog.js";
4
6
  /** A subscription as the list shows it: the synced fields plus counts and polling health. */
@@ -30,11 +32,12 @@ export function serializeItem(row, progress, withContent, hasNote = false) {
30
32
  url: row.link,
31
33
  publishedAt: row.published_at,
32
34
  addedAt: row.created_at,
33
- snippet: row.content_snippet,
35
+ snippet: cleanSnippet(row.content_snippet, row.title),
36
+ lead: leadOf(row),
34
37
  imageUrl: row.image_url,
35
38
  hasFullText: Boolean(row.full_content_html),
36
39
  content: contentMetadata(row),
37
- ...(withContent ? { contentHtml: row.content_html, fullTextHtml: row.full_content_html } : {}),
40
+ ...(withContent ? { contentHtml: readerHtml(row.content_html, row.title), fullTextHtml: readerHtml(row.full_content_html, row.title) } : {}),
38
41
  state: {
39
42
  read: Boolean(row.read),
40
43
  saved: Boolean(row.bookmarked),
@@ -0,0 +1,4 @@
1
+ -- Plain-text beginning of an article without page chrome, derived whenever its content changes. The version says
2
+ -- which cleaning rules produced it, so a rule change is picked up by the background backfill.
3
+ ALTER TABLE items ADD COLUMN content_lead TEXT;
4
+ ALTER TABLE items ADD COLUMN lead_version INTEGER;
@@ -1,6 +1,11 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { JSDOM } from "jsdom";
2
3
  import { decodeEntities } from "./text.js";
3
- /** Plain publisher content, shared by readers, search consumers and MCP. */
4
+ import { cleanReaderHtml, cleanSnippet, derivedText } from "./contentNoise.js";
5
+ /**
6
+ * Plain publisher content, shared by readers, search consumers and MCP. Deliberately not cleaned: length decisions
7
+ * (which text is the better copy, what retention keeps) must not depend on which phrases the rules know.
8
+ */
4
9
  export function articleText(html) {
5
10
  if (!html)
6
11
  return "";
@@ -9,13 +14,32 @@ export function articleText(html) {
9
14
  fragment.querySelectorAll("p, div, br, li, h1, h2, h3, h4, blockquote").forEach(node => node.append(" "));
10
15
  return (fragment.textContent ?? "").replace(/\s+/g, " ").trim();
11
16
  }
17
+ const READER_CACHE_LIMIT = 300;
18
+ const readerCache = new Map();
19
+ /**
20
+ * Article HTML as a reader shows it, without breadcrumbs, repeated headline and similar page chrome. Computed when
21
+ * served and never stored, so the publisher's original stays intact and rule changes apply to old articles too.
22
+ */
23
+ export function readerHtml(html, title) {
24
+ if (!html)
25
+ return html ?? null;
26
+ const key = createHash("sha1").update(title ?? "").update("\0").update(html).digest("base64");
27
+ const cached = readerCache.get(key);
28
+ if (cached !== undefined)
29
+ return cached;
30
+ const cleaned = cleanReaderHtml(html, title);
31
+ if (readerCache.size >= READER_CACHE_LIMIT)
32
+ readerCache.delete(readerCache.keys().next().value);
33
+ readerCache.set(key, cleaned);
34
+ return cleaned;
35
+ }
12
36
  export function contentMetadata(item) {
13
37
  const source = item.full_content_html ? "reader" : item.content_html ? "feed" : "snippet";
14
38
  return { revision: item.content_revision, source, status: item.extraction_status,
15
39
  attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at };
16
40
  }
17
41
  export function sourceText(item) {
18
- return `${decodeEntities(item.title) ?? ""}\n${articleText(item.full_content_html || item.content_html) || decodeEntities(item.content_snippet) || ""}`.trim();
42
+ return `${decodeEntities(item.title) ?? ""}\n${derivedText(item.full_content_html || item.content_html, item.title) || cleanSnippet(decodeEntities(item.content_snippet), item.title) || ""}`.trim();
19
43
  }
20
44
  /** Prefer existing substantive publisher text over a shorter extracted teaser. */
21
45
  export function improvesContent(item, extracted) {
@@ -0,0 +1,419 @@
1
+ import { JSDOM } from "jsdom";
2
+ /**
3
+ * Publisher pages carry page chrome inside the article body: breadcrumbs, dateline repeats, player leftovers,
4
+ * recommendation lists, follow prompts. This module removes it in two layers that follow one principle:
5
+ *
6
+ * - `cleanReaderHtml` is what a reader shows. It removes only what cannot work in a reader or merely repeats what
7
+ * the reader already shows (breadcrumbs, in-page jump links, a date line, the headline, consent placeholders,
8
+ * player labels). Attribution, sponsored and affiliate content, disclosures and a publisher's own calls to action
9
+ * stay: a source's revenue model is not ours to remove.
10
+ * - `derivedText` and `cleanSnippet` feed text that is not read as a page (snippets, search, teasers, curation).
11
+ * There noise only costs relevance, so credits, ads, calls to action and recommendation lists go as well.
12
+ *
13
+ * Rules are structural or generic idioms. A phrase belongs here only when more than one publisher uses it.
14
+ */
15
+ const BLOCKS = "p, li, h1, h2, h3, h4, h5, h6, blockquote, figcaption, dt, dd, td, th, pre";
16
+ function flat(value) {
17
+ return (value ?? "").replace(/\s+/g, " ").trim();
18
+ }
19
+ function normalized(value) {
20
+ return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
21
+ }
22
+ function leafBlocks(root) {
23
+ return [...root.querySelectorAll(`${BLOCKS}, div`)].filter(element => !element.querySelector(BLOCKS) && (element.tagName !== "DIV" || !element.querySelector("div")));
24
+ }
25
+ function charsBefore(root, element) {
26
+ const range = root.ownerDocument.createRange();
27
+ range.setStart(root, 0);
28
+ range.setEndBefore(element);
29
+ return range.toString().length;
30
+ }
31
+ const BREADCRUMB_MARKERS = [
32
+ "[aria-label*='breadcrumb' i]",
33
+ "[itemtype*='BreadcrumbList' i]",
34
+ "[class~='breadcrumb']",
35
+ "[class~='breadcrumbs']",
36
+ ];
37
+ const LANGUAGE_NAMES = ["English", "Deutsch", "Français", "Español", "Italiano", "Português", "Nederlands", "Polski", "Türkçe",
38
+ "Русский", "日本語", "한국어", "简体字", "繁體字", "简体中文", "繁體中文", "中文", "العربية"];
39
+ const CONSENT_ANCHORS = [
40
+ /^Empfohlener redaktioneller Inhalt\b/i,
41
+ /An dieser Stelle findest du einen externen Inhalt/i,
42
+ /Du kannst ihn dir mit einem Klick anzeigen lassen/i,
43
+ ];
44
+ const CONSENT_COMPANIONS = [
45
+ /\bInhalte? (von \S+ )?erlauben\b/i, /-Inhalte erlauben\b/i, /^Ich bin damit einverstanden, dass mir .+ angezeigt werden/i,
46
+ /Personenbezogene Daten können an Drittplattformen/i, /^Link zum .+-Inhalt$/i, /^Mehr dazu in unserer Datenschutzerklärung/i,
47
+ ];
48
+ function removeReaderNoise(root, title, stats) {
49
+ let changed = false;
50
+ const drop = (element, rule) => {
51
+ if (!element.parentNode)
52
+ return;
53
+ element.remove();
54
+ stats[rule] = (stats[rule] ?? 0) + 1;
55
+ changed = true;
56
+ };
57
+ for (const selector of BREADCRUMB_MARKERS) {
58
+ try {
59
+ root.querySelectorAll(selector).forEach(element => drop(element, "breadcrumb"));
60
+ }
61
+ catch { /* selector unsupported by the DOM */ }
62
+ }
63
+ // Breadcrumbs without markup hints: a short list near the start whose items are single short links.
64
+ for (const list of root.querySelectorAll("ol, ul")) {
65
+ const items = [...list.children].filter(child => child.tagName === "LI");
66
+ if (items.length < 2 || items.length > 8 || charsBefore(root, list) > 300)
67
+ continue;
68
+ const onlyLinks = items.every(item => {
69
+ const links = item.querySelectorAll("a");
70
+ const label = flat(item.textContent);
71
+ return links.length === 1 && label.length > 0 && label.length <= 30 && flat(links[0].textContent) === label;
72
+ });
73
+ if (onlyLinks)
74
+ drop(list, "breadcrumb");
75
+ }
76
+ const headline = normalized(title ?? "");
77
+ const seen = new Set();
78
+ let leading = 0;
79
+ for (const block of leafBlocks(root)) {
80
+ const text = flat(block.textContent);
81
+ if (!text)
82
+ continue;
83
+ const position = leading++;
84
+ const heading = /^H[1-3]$/.test(block.tagName);
85
+ if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
86
+ drop(block, "title-repeat");
87
+ continue;
88
+ }
89
+ if (position < 14 && /^(Audiobeitrag|Videobeitrag)\s*>/.test(text) && headline && normalized(text).endsWith(headline)) {
90
+ drop(block, "title-repeat");
91
+ continue;
92
+ }
93
+ if (/^[>›»|/·•–—-]+$/.test(text)) {
94
+ drop(block, "separator");
95
+ continue;
96
+ }
97
+ if (/^(Audiobeitrag|Videobeitrag|Autoplay)$/i.test(text) || (position < 8 && /^Bild$/.test(text))) {
98
+ drop(block, "player");
99
+ continue;
100
+ }
101
+ if (text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
102
+ const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
103
+ if (/^(stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
104
+ drop(block, "dateline");
105
+ continue;
106
+ }
107
+ }
108
+ const anchors = block.querySelectorAll("a");
109
+ if (text.length <= 60 && anchors.length === 1 && anchors[0].getAttribute("href")?.startsWith("#") && flat(anchors[0].textContent) === text) {
110
+ drop(block, "jump-link");
111
+ continue;
112
+ }
113
+ if (text.length <= 200 && /^\d{1,2}:\d{2}\b/.test(text) && /\bAutoplay\b/i.test(text)) {
114
+ drop(block, "player");
115
+ continue;
116
+ }
117
+ if (text.length <= 160 && LANGUAGE_NAMES.filter(name => text.includes(name)).length >= 4) {
118
+ drop(block, "language-switch");
119
+ continue;
120
+ }
121
+ if (position < 10 && text.length >= 30) {
122
+ const key = normalized(text);
123
+ if (seen.has(key)) {
124
+ drop(block, "repeat");
125
+ continue;
126
+ }
127
+ seen.add(key);
128
+ }
129
+ }
130
+ // Consent placeholders of embedded third-party content: only the neighbours of a known anchor phrase go.
131
+ const blocks = leafBlocks(root);
132
+ blocks.forEach((block, index) => {
133
+ if (!block.parentNode || flat(block.textContent).length > 400 || !CONSENT_ANCHORS.some(pattern => pattern.test(flat(block.textContent))))
134
+ return;
135
+ drop(block, "consent-placeholder");
136
+ for (const next of blocks.slice(index + 1, index + 7)) {
137
+ const text = flat(next.textContent);
138
+ if (!next.parentNode || text.length > 220)
139
+ break;
140
+ if (CONSENT_ANCHORS.some(pattern => pattern.test(text)) || CONSENT_COMPANIONS.some(pattern => pattern.test(text)))
141
+ drop(next, "consent-placeholder");
142
+ }
143
+ });
144
+ return changed;
145
+ }
146
+ const PRESENTATIONAL_TAGS = new Set(["FONT", "BIG", "SMALL", "CENTER", "TT", "U", "STRIKE", "S", "BASEFONT", "BLINK", "NOBR", "SPAN", "MARK"]);
147
+ const PRESENTATIONAL_ATTRIBUTES = ["style", "class", "color", "face", "size", "bgcolor", "align", "valign", "border", "cellpadding",
148
+ "cellspacing", "background", "hspace", "vspace", "nowrap"];
149
+ const SIZED_TAGS = new Set(["IMG", "VIDEO", "CANVAS", "IFRAME", "EMBED", "OBJECT"]);
150
+ const EMPHASIS_EXEMPT = "h1, h2, h3, h4, h5, h6, th, strong, b";
151
+ function wrapChildren(element, tag) {
152
+ const wrapper = element.ownerDocument.createElement(tag);
153
+ while (element.firstChild)
154
+ wrapper.append(element.firstChild);
155
+ element.append(wrapper);
156
+ }
157
+ /**
158
+ * Typography belongs to the client: fonts, sizes, colours, alignment and classes are removed so that only
159
+ * structure (headings, paragraphs, lists, quotes, tables, links, images) and emphasis (bold, italic) are left.
160
+ * Emphasis that was only a style (`font-weight: bold` on a span) is kept as strong or em.
161
+ */
162
+ function removeTypography(root, stats) {
163
+ let changed = false;
164
+ root.querySelectorAll("style, link[rel~='stylesheet']").forEach(node => { node.remove(); changed = true; });
165
+ const targets = new Set([...root.querySelectorAll("a[href^='#']")].map(anchor => (anchor.getAttribute("href") ?? "").slice(1)));
166
+ for (const element of [...root.querySelectorAll("*")]) {
167
+ const style = element.getAttribute("style") ?? "";
168
+ const bold = /font-weight\s*:\s*(bold|bolder|[6-9]00)\b/i.test(style);
169
+ const italic = /font-style\s*:\s*(italic|oblique)\b/i.test(style);
170
+ const plain = !element.matches(EMPHASIS_EXEMPT) && !element.closest(EMPHASIS_EXEMPT) && flat(element.textContent) !== "";
171
+ for (const name of PRESENTATIONAL_ATTRIBUTES) {
172
+ if (element.hasAttribute(name)) {
173
+ element.removeAttribute(name);
174
+ changed = true;
175
+ }
176
+ }
177
+ if (!SIZED_TAGS.has(element.tagName)) {
178
+ for (const name of ["width", "height"]) {
179
+ if (element.hasAttribute(name)) {
180
+ element.removeAttribute(name);
181
+ changed = true;
182
+ }
183
+ }
184
+ }
185
+ if (plain && bold)
186
+ wrapChildren(element, "strong");
187
+ if (plain && italic && !element.closest("em, i"))
188
+ wrapChildren(element, "em");
189
+ const anchorTarget = element.id && targets.has(element.id);
190
+ if (PRESENTATIONAL_TAGS.has(element.tagName) && !anchorTarget) {
191
+ element.replaceWith(...element.childNodes);
192
+ changed = true;
193
+ }
194
+ }
195
+ if (changed)
196
+ stats["typography"] = (stats["typography"] ?? 0) + 1;
197
+ return changed;
198
+ }
199
+ function serialize(root) {
200
+ const holder = root.ownerDocument.createElement("div");
201
+ holder.append(root);
202
+ return holder.innerHTML;
203
+ }
204
+ /** The article HTML as a reader should show it. Unchanged input is returned as is. */
205
+ export function cleanReaderHtml(html, title, stats = {}) {
206
+ if (!html)
207
+ return html;
208
+ const root = JSDOM.fragment(html);
209
+ const noise = removeReaderNoise(root, title, stats);
210
+ const typography = removeTypography(root, stats);
211
+ return noise || typography ? serialize(root) : html;
212
+ }
213
+ const CREDIT_PATTERNS = [
214
+ /^(©|\(c\)\s|copyright\b)/i,
215
+ /^[([]?(Bild|Foto|Fotos|Bildquelle|Bildrechte|Bildnachweis|Titelbild|Beitragsbild|Photo|Image|Credits?|Grafik|Illustration)[)\]]?\s*[:/|]/i,
216
+ /\|\s*(Image|Photo|Foto|Bild|Credit)s?\s*:/i,
217
+ /\(\s*(Bildquelle|Bildrechte|Foto|Bild|Photo|Image|Credit|Quelle)\s*:[^)]{1,60}\)\s*\.?$/i,
218
+ /\bBildrechte:\s*(Bildrechte:\s*)?[^.]{1,60}$/i,
219
+ /©[^\n]{1,40}$/,
220
+ /\/\s*(Getty Images|Reuters|AP|AFP|dpa|Imago)\s*$/i,
221
+ /\bvia (REUTERS|Reuters|AP|AFP|dpa|Getty Images|Imagen)\s*$/,
222
+ /^Quelle:?\s*\S{0,40}$/i,
223
+ ];
224
+ const HEAD_META_PATTERNS = [
225
+ /^(Stand|Aktualisiert|Zuletzt aktualisiert|Ver(ö|oe)ffentlicht|Updated|Published|Posted)\b.{0,40}\d/i,
226
+ /^von\s+.{2,40}\s[I|]\s+\d{1,2}\.\d{1,2}\.\d{2,4}/i,
227
+ /^(\d{1,2}\.\s?\d{1,2}\.\s?\d{2,4}|[A-Z][a-z]{2,8}\.? \d{1,2}, \d{4})([,\s•·|-]+\d{1,2}[:.]\d{2}( Uhr)?)?$/,
228
+ /^\p{L}[\p{L}\p{N} .&-]{0,30}\s(\d{1,2}\.\s?\d{1,2}\.\s?\d{2,4}|[A-Z][a-z]{2,8}\.? \d{1,2}, \d{4})([,\s•·|-]+\d{1,2}[:.]\d{2}( Uhr)?)?$/u,
229
+ /^(Kommentare|Kommentieren|Comments|Teilen|Share|Drucken|Print|Merken|Vorlesen)$/i,
230
+ /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen)\s*:?\s+[^.!?]{0,160}$/i,
231
+ ];
232
+ const AD_PATTERNS = [
233
+ /^(Anzeige|Werbung|Advertisement|Advertorial|Sponsored|Gesponsert|Promoted)\s*:?$/i,
234
+ /\b(affiliate|partner)[- ]?links?\b/i,
235
+ /\bwe (may|might) (receive|earn) a commission\b/i,
236
+ /^FTC:/i,
237
+ /\bClick Here to Buy\b/i,
238
+ /^Hurry,/i,
239
+ /^Worth checking out on Amazon/i,
240
+ /\bProvision\b.{0,60}\b(Links?|Einkäufe|Käufe)\b/i,
241
+ ];
242
+ const SOCIAL_NETWORKS = ["Facebook", "Instagram", "Reddit", "Threads", "Twitter", "Mastodon", "YouTube", "TikTok", "Google News", "Flipboard", "WhatsApp", "LinkedIn"];
243
+ const CALL_TO_ACTION_PATTERNS = [
244
+ /\b(bevorzugte[nr]?\s+(Google-)?Quelle|bei Google bevorzugen|preferred source)\b/i,
245
+ /\bWo du uns folgen kannst\b/i,
246
+ /^Folge[nt]? uns\b/i,
247
+ /\bFolge uns auch auf\b/i,
248
+ /\bUns auf Google folgen\b/i,
249
+ /\bfollow us\b/i,
250
+ /\bFügt uns doch bei Google\b/i,
251
+ /\bAuf dem Laufenden bleiben\?/i,
252
+ /\bNewsletter\b.{0,240}\b(Anmeldung|anmelden|abonnier\w*|subscribe)\b/i,
253
+ /\b(subscribe|abonnier\w+)\b.{0,60}\b(newsletter|YouTube|channel|Kanal)\b/i,
254
+ /\bBy subscribing\b/i,
255
+ /^SUBSCRIBE NOW$/i,
256
+ /^Hier geht'?s zur Anmeldung!?$/i,
257
+ /^★.+★$/,
258
+ ];
259
+ const COMMENT_PATTERNS = [
260
+ /\b(let us know|tell us) .{0,40}in the comments\b/i,
261
+ /\bNur angemeldete Benutzer können kommentieren\b/i,
262
+ ];
263
+ const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
264
+ const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
265
+ 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;
266
+ const MIN_KEPT_CHARACTERS = 150;
267
+ const HEAD_BLOCKS = 6;
268
+ const TAIL_CHARACTERS = 1500;
269
+ const TAIL_CUT_FROM = 0.6;
270
+ function matchesAny(text, patterns) {
271
+ return patterns.some(pattern => pattern.test(text));
272
+ }
273
+ function isSocialList(text) {
274
+ return text.length <= 200 && SOCIAL_NETWORKS.filter(name => text.includes(name)).length >= 3;
275
+ }
276
+ function isLinkOnly(block, text) {
277
+ const anchors = block.querySelectorAll("a");
278
+ return anchors.length === 1 && flat(anchors[0].textContent) === text;
279
+ }
280
+ /**
281
+ * Plain, single-line publisher text without page chrome: for snippets, search, teasers and curation, never for
282
+ * display as a page. Falls back to the plain flattening when cleaning would leave almost nothing.
283
+ */
284
+ export function derivedText(html, title, stats = {}) {
285
+ if (!html)
286
+ return "";
287
+ const root = JSDOM.fragment(html);
288
+ root.querySelectorAll("script, style, noscript, nav, footer").forEach(node => node.remove());
289
+ const plain = () => {
290
+ root.querySelectorAll("p, div, br, li, h1, h2, h3, h4, blockquote").forEach(node => node.append(" "));
291
+ return flat(root.textContent);
292
+ };
293
+ removeReaderNoise(root, title, stats);
294
+ root.querySelectorAll("figcaption").forEach(node => node.remove());
295
+ const entries = leafBlocks(root).map(element => ({ element, text: flat(element.textContent) })).filter(entry => entry.text);
296
+ const total = entries.reduce((sum, entry) => sum + entry.text.length, 0);
297
+ if (entries.length === 0 || total < 2 * MIN_KEPT_CHARACTERS)
298
+ return plain();
299
+ const dropped = new Set();
300
+ const mark = (index, rule) => {
301
+ if (dropped.has(index))
302
+ return;
303
+ dropped.add(index);
304
+ stats[rule] = (stats[rule] ?? 0) + 1;
305
+ };
306
+ let before = 0;
307
+ let cutAt = -1;
308
+ entries.forEach((entry, index) => {
309
+ const { text } = entry;
310
+ const early = index < HEAD_BLOCKS || before <= 600;
311
+ const late = total - before - text.length <= TAIL_CHARACTERS || index >= entries.length - 8;
312
+ const position = before / total;
313
+ before += text.length;
314
+ if (cutAt >= 0)
315
+ return;
316
+ if (SECTION_LABEL.test(text) && text.length <= 400) {
317
+ if (position >= TAIL_CUT_FROM && before - text.length >= MIN_KEPT_CHARACTERS) {
318
+ cutAt = index;
319
+ stats["recommendations"] = (stats["recommendations"] ?? 0) + 1;
320
+ return;
321
+ }
322
+ mark(index, "recommendations");
323
+ for (let next = index + 1; next < entries.length && isLinkOnly(entries[next].element, entries[next].text); next++)
324
+ mark(next, "recommendations");
325
+ return;
326
+ }
327
+ if (TAG_LABEL.test(text)) {
328
+ mark(index, "head-meta");
329
+ for (let next = index + 1; next < entries.length && isLinkOnly(entries[next].element, entries[next].text); next++)
330
+ mark(next, "head-meta");
331
+ return;
332
+ }
333
+ if (text.length > 400)
334
+ return;
335
+ if (text.length <= 160 && VIDEO_CARD.test(text) && entry.element.querySelector("a"))
336
+ mark(index, "player");
337
+ else if (matchesAny(text, AD_PATTERNS))
338
+ mark(index, "advertising");
339
+ else if (text.length <= 300 && (early || late) && matchesAny(text, CALL_TO_ACTION_PATTERNS))
340
+ mark(index, "call-to-action");
341
+ else if (text.length <= 300 && late && matchesAny(text, COMMENT_PATTERNS))
342
+ mark(index, "comments");
343
+ else if (isSocialList(text))
344
+ mark(index, "call-to-action");
345
+ else if (early && text.length <= 160 && matchesAny(text, HEAD_META_PATTERNS))
346
+ mark(index, "head-meta");
347
+ else if ((early || late) && text.length <= 300 && matchesAny(text, CREDIT_PATTERNS)) {
348
+ mark(index, "credit");
349
+ // In the head a credit line follows the caption it belongs to.
350
+ const caption = entries[index - 1];
351
+ if (early && caption && index - 1 < HEAD_BLOCKS && caption.text.length <= 200 && /^(©|\(c\)|copyright|Bildrechte|Bildquelle|Foto|Photo|Image|Credit)/i.test(text))
352
+ mark(index - 1, "credit");
353
+ }
354
+ });
355
+ const kept = entries.filter((_, index) => !dropped.has(index) && (cutAt < 0 || index < cutAt)).map(entry => entry.text);
356
+ const text = kept.join(" ");
357
+ if (text.length < Math.min(MIN_KEPT_CHARACTERS, total) && text.length < total * 0.7)
358
+ return plain();
359
+ return text;
360
+ }
361
+ const SNIPPET_LINE_PATTERNS = [
362
+ /^(Der|Die|Das) Beitrag .+ (erschien|wurde) zuerst\b.*$/i,
363
+ /^The post .+ appeared first on\b.*$/i,
364
+ /^This article, ".+" first appeared on\b.*$/i,
365
+ /^Discuss this article in our forums\.?$/i,
366
+ /^Read (the )?full story( here| at .+)?\.?$/i,
367
+ /^Zum Beitrag:.*$/i,
368
+ /^(more…|more\.\.\.|Mehr|Weiterlesen|Read more)$/i,
369
+ /^(Related Roundup|Related Forum|Tags?|Buyer's Guide):.*$/i,
370
+ /^★.+★$/,
371
+ /^Wo du uns folgen kannst:.*$/i,
372
+ /^Auf dem Laufenden bleiben\?$/i,
373
+ /^Fügt uns doch bei Google.*$/i,
374
+ ];
375
+ const SNIPPET_INLINE_PATTERNS = [
376
+ /\s*(Der|Die|Das) Beitrag [^\n]+? (erschien|wurde) zuerst (auf|bei|beim) [^\n]*?\.(?=\s|$)(\s*(Folge uns auch auf|Über unseren Newsletter)[^\n]*?\.(?=\s|$))*/gi,
377
+ /\s*Über unseren Newsletter [^\n]*?\.(?=\s|$)/gi,
378
+ /\s*The post [^\n]+? appeared first on [^\n]*?\.(?=\s|$)/gi,
379
+ ];
380
+ /**
381
+ * A feed's summary without the footers feed plugins append ("The post … appeared first on …", "Read the full
382
+ * story at …"), without a repeated headline and with whitespace collapsed to paragraphs.
383
+ */
384
+ export function cleanSnippet(snippet, title) {
385
+ if (snippet === null || snippet === undefined)
386
+ return null;
387
+ let text = snippet.replace(/\r\n?/g, "\n").replace(/[ \t ]+/g, " ");
388
+ for (const pattern of SNIPPET_INLINE_PATTERNS)
389
+ text = text.replace(pattern, "");
390
+ const headline = normalized(title ?? "");
391
+ const lines = text.split("\n").map(line => line.trim()).filter((line, index) => line && !SNIPPET_LINE_PATTERNS.some(pattern => pattern.test(line)) && !isSocialList(line) &&
392
+ !(index === 0 && headline && normalized(line) === headline));
393
+ return lines.join("\n").trim();
394
+ }
395
+ /** Raised whenever the rules above change what they remove, so stored leads are derived again. */
396
+ export const LEAD_VERSION = 1;
397
+ const LEAD_LENGTH = 600;
398
+ function shorten(text, max) {
399
+ if (text.length <= max)
400
+ return text;
401
+ let slice = text.slice(0, max);
402
+ if (/[\uD800-\uDBFF]$/.test(slice))
403
+ slice = slice.slice(0, -1);
404
+ const sentence = Math.max(slice.lastIndexOf(". "), slice.lastIndexOf("! "), slice.lastIndexOf("? "));
405
+ if (sentence >= max * 0.5)
406
+ return slice.slice(0, sentence + 1);
407
+ const space = slice.lastIndexOf(" ");
408
+ return (space >= max * 0.5 ? slice.slice(0, space) : slice).replace(/[\s,;:–-]+$/, "") + "…";
409
+ }
410
+ /**
411
+ * The beginning of an article as one plain line, for teasers: the cleaned body, or the cleaned feed summary when
412
+ * that says more (a failed download leaves only the summary).
413
+ */
414
+ export function leadText(parts, max = LEAD_LENGTH) {
415
+ const body = derivedText(parts.html, parts.title);
416
+ const summary = flat(cleanSnippet(parts.snippet, parts.title));
417
+ return shorten(body.length >= summary.length ? body : summary, max);
418
+ }
419
+ //# sourceMappingURL=contentNoise.js.map
@@ -5,6 +5,7 @@ import { articleWorkPool, ArticleWorkBusyError, ArticleAccessDeniedError, Articl
5
5
  import { requestFullText, authorizedFullTextRequester } from "./fullTextQueue.js";
6
6
  import { SsrfBlockedError } from "./ssrfGuard.js";
7
7
  import { improvesContent } from "./articleContent.js";
8
+ import { refreshLead } from "./lead.js";
8
9
  import { canAccessItem, findItemById, findSubscriptionFullTextMode, updateItemFullContent } from "./repository.js";
9
10
  // Kept for integrations importing the old constant; retries now belong to individual articles.
10
11
  export const FULL_TEXT_BLOCK_THRESHOLD = 2;
@@ -24,6 +25,7 @@ export function cachedFullText(userId, itemId, force = false) {
24
25
  if (item.full_content_html && isCachedConsentSnippet(item.full_content_html)) {
25
26
  // Repair cached walls before any caller can mistake them for reader content again.
26
27
  db.prepare("UPDATE items SET full_content_html = NULL, extraction_status = 'pending', extraction_retry_at = NULL WHERE id = ?").run(itemId);
28
+ refreshLead(itemId);
27
29
  item.full_content_html = null;
28
30
  }
29
31
  if (!force && item.full_content_html && !isCachedConsentSnippet(item.full_content_html)) {
@@ -1,3 +1,4 @@
1
+ import { refreshLead } from "./lead.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { db } from "../db/index.js";
3
4
  import { hasUserCapability } from "../auth/capabilities.js";
@@ -102,6 +103,7 @@ export async function saveToInbox(userId, input) {
102
103
  RETURNING id`)
103
104
  .get(inbox.id, guid, title, targetUrl, snippet, contentHtml, extractedPartial ? null : contentHtml, imageUrl, now, now, contentHtml && !extractedPartial ? "ready" : extractionStatus, extractedPartial ? new Date(Date.now() + 86400_000).toISOString() : null);
104
105
  const itemId = row.id;
106
+ refreshLead(itemId);
105
107
  bookmarkItem(userId, itemId);
106
108
  let hasNote = false;
107
109
  if (input.note?.trim()) {
@@ -0,0 +1,57 @@
1
+ import { createHash } from "node:crypto";
2
+ import { db } from "../db/index.js";
3
+ import { LEAD_VERSION, leadText } from "./contentNoise.js";
4
+ const BACKFILL_BATCH = 50;
5
+ const BACKFILL_PAUSE_MS = 100;
6
+ const COMPUTED_LIMIT = 300;
7
+ const computed = new Map();
8
+ export function computeLead(item) {
9
+ return leadText({ html: item.full_content_html || item.content_html, snippet: item.content_snippet, title: item.title });
10
+ }
11
+ /** Derive and store the lead of one item; call after its title, summary or content was written. */
12
+ export function refreshLead(itemId) {
13
+ const item = db.prepare("SELECT title, content_snippet, content_html, full_content_html FROM items WHERE id = ?").get(itemId);
14
+ if (!item)
15
+ return;
16
+ db.prepare("UPDATE items SET content_lead = ?, lead_version = ? WHERE id = ?").run(computeLead(item), LEAD_VERSION, itemId);
17
+ }
18
+ /**
19
+ * The lead to serve: the stored one while its rules are current, otherwise derived on the spot, so an article is
20
+ * never served without one just because the backfill has not reached it.
21
+ */
22
+ export function leadOf(item) {
23
+ if (item.lead_version === LEAD_VERSION)
24
+ return item.content_lead || null;
25
+ const key = createHash("sha1").update(item.title ?? "").update("\0").update(item.content_snippet ?? "").update("\0")
26
+ .update(item.full_content_html || item.content_html || "").digest("base64");
27
+ let lead = computed.get(key);
28
+ if (lead === undefined) {
29
+ lead = computeLead(item);
30
+ if (computed.size >= COMPUTED_LIMIT)
31
+ computed.delete(computed.keys().next().value);
32
+ computed.set(key, lead);
33
+ }
34
+ return lead || null;
35
+ }
36
+ /** Derive leads for items that have none or were derived by older rules; returns how many were handled. */
37
+ export function backfillLeads(limit = BACKFILL_BATCH) {
38
+ const rows = db.prepare("SELECT id, title, content_snippet, content_html, full_content_html FROM items WHERE lead_version IS NULL OR lead_version <> ? LIMIT ?").all(LEAD_VERSION, limit);
39
+ const update = db.prepare("UPDATE items SET content_lead = ?, lead_version = ? WHERE id = ?");
40
+ db.transaction(() => { for (const row of rows)
41
+ update.run(computeLead(row), LEAD_VERSION, row.id); })();
42
+ return rows.length;
43
+ }
44
+ /** Works through the backlog in small steps so polling and requests are never held up for long. */
45
+ export function startLeadBackfill() {
46
+ let total = 0;
47
+ const step = () => {
48
+ const handled = backfillLeads();
49
+ total += handled;
50
+ if (handled === BACKFILL_BATCH)
51
+ setTimeout(step, BACKFILL_PAUSE_MS).unref();
52
+ else if (total > 0)
53
+ console.log(`[db] derived the lead of ${total} stored items`);
54
+ };
55
+ setTimeout(step, 1000).unref();
56
+ }
57
+ //# sourceMappingURL=lead.js.map
@@ -88,6 +88,12 @@ function extractImageUrl(item) {
88
88
  }
89
89
  return null;
90
90
  }
91
+ /** The article address: its link, or its guid when the feed gives only that and the guid is a web address. */
92
+ export function itemLink(item) {
93
+ if (item.link)
94
+ return item.link;
95
+ return item.guid && /^https?:\/\//i.test(item.guid) ? item.guid : undefined;
96
+ }
91
97
  /** One attempt; the queue owns retries, admission, single-flight and the total deadline. */
92
98
  async function pollAttempt(feed, signal) {
93
99
  const fetched = await fetchFeed(feed.url, { etag: feed.etag ?? undefined, lastModified: feed.last_modified ?? undefined, signal });
@@ -101,7 +107,7 @@ async function pollAttempt(feed, signal) {
101
107
  return { newItems: 0, error: "feed_poll_not_allowed" };
102
108
  const items = parsed?.items.map(item => ({
103
109
  guid: item.guid ?? item.link ?? item.title ?? crypto.randomUUID(),
104
- title: plainTitle(item.title), link: item.link,
110
+ title: plainTitle(item.title), link: itemLink(item),
105
111
  contentSnippet: decodeEntities(item.contentSnippet ?? item.content) ?? undefined,
106
112
  contentHtml: item.contentEncoded ?? item["content:encoded"] ?? item.content ?? null,
107
113
  publishedAt: normalizePublishedAt(item.isoDate ?? item.pubDate) ?? undefined, imageUrl: extractImageUrl(item),
@@ -1,5 +1,7 @@
1
1
  import { db } from "../db/index.js";
2
2
  import { articleText } from "./articleContent.js";
3
+ import { LEAD_VERSION } from "./contentNoise.js";
4
+ import { refreshLead } from "./lead.js";
3
5
  import { decodeEntities } from "./text.js";
4
6
  export function findFeedByUrl(url) {
5
7
  return db.prepare("SELECT * FROM feeds WHERE url = ?").get(url);
@@ -160,14 +162,26 @@ export function upsertItems(feedId, items) {
160
162
  const result = insert.run(...values);
161
163
  if (result.changes > 0) {
162
164
  inserted++;
165
+ refreshLead(Number(result.lastInsertRowid));
163
166
  }
164
167
  else {
165
- const previous = db.prepare("SELECT content_html FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
168
+ const previous = db.prepare("SELECT id, title, content_snippet, content_html, full_content_html, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
166
169
  const next = row.contentHtml;
167
170
  // Some publishers replace complete feed bodies with short teasers on a later poll.
168
171
  const retained = previous?.content_html && next && next.length < previous.content_html.length * 0.9 &&
169
172
  articleText(next).length < articleText(previous.content_html).length * 0.9 ? previous.content_html : next ?? null;
170
- update.run(row.title ?? null, row.link ?? null, row.contentSnippet ?? null, retained, row.publishedAt ?? null, row.imageUrl ?? null, feedId, row.guid);
173
+ // The summary belongs to the body it was sent with: keeping the longer body keeps its summary too, or
174
+ // a feed that alternates between two variants would change the content on every poll.
175
+ 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);
177
+ // Polling rewrites every item; only a change of what the lead is made from needs it derived again.
178
+ if (previous) {
179
+ const changed = (retained !== null && retained !== previous.content_html) ||
180
+ (row.title !== undefined && row.title !== previous.title) ||
181
+ (!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet);
182
+ if (previous.lead_version !== LEAD_VERSION || (changed && !previous.full_content_html))
183
+ refreshLead(previous.id);
184
+ }
171
185
  }
172
186
  }
173
187
  return inserted;
@@ -410,6 +424,7 @@ export function findItemById(id) {
410
424
  }
411
425
  export function updateItemFullContent(id, fullContentHtml) {
412
426
  db.prepare("UPDATE items SET full_content_html = ? WHERE id = ?").run(fullContentHtml, id);
427
+ refreshLead(id);
413
428
  }
414
429
  /** Apply a per-item change to many items atomically; returns how many rows changed. */
415
430
  export function applyToItems(itemIds, change) {
@@ -1,4 +1,4 @@
1
- import { contentMetadata, sourceText } from "../feeds/articleContent.js";
1
+ import { contentMetadata, readerHtml, sourceText } from "../feeds/articleContent.js";
2
2
  import { z } from "zod";
3
3
  import { findItemForUser, listItemsForUser } from "../feeds/repository.js";
4
4
  const cursorSchema = z.object({
@@ -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 = item.full_content_html ?? item.content_html;
54
+ const content = readerHtml(item.full_content_html ?? item.content_html, item.title);
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]))
@@ -19,12 +19,14 @@ import { startCleanupScheduler } from "./feeds/cleanup.js";
19
19
  import { repairEncodedText } from "./feeds/repository.js";
20
20
  import { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
21
21
  import { startEditionScheduler } from "./native/editions.js";
22
+ import { startLeadBackfill } from "./feeds/lead.js";
22
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
24
  const WEB_DIST = join(__dirname, "../../web/dist");
24
25
  runMigrations();
25
26
  const repairedItems = repairEncodedText();
26
27
  if (repairedItems > 0)
27
28
  console.log(`[db] decoded HTML entities in ${repairedItems} stored items`);
29
+ startLeadBackfill();
28
30
  pruneArchive();
29
31
  const pendingArchives = scheduleMissingArchives();
30
32
  if (pendingArchives > 0)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }