feedkeeper 0.26.0 → 0.26.2

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.2",
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 = {
@@ -44,8 +46,11 @@ itemsRouter.get("/", (req, res) => {
44
46
  const archived = withArchivedImages(listItemsForUser(req.user.id, parsed.data), origin);
45
47
  // Offline copies also get the images of articles that are not archived, through this server.
46
48
  const items = parsed.data.offline ? withProxiedImages(archived, origin) : archived;
47
- res.json(items.map((item) => ({
49
+ res.json(items.map(({ content_lead: _lead, lead_version: _leadVersion, ...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",
@@ -33,7 +34,6 @@ const FEATURES = [
33
34
  "folder-icons",
34
35
  "notes",
35
36
  "edition",
36
- "edition.automatic",
37
37
  "edition.revisions",
38
38
  "native-preferences",
39
39
  "native-preferences.edition-size",
@@ -18,7 +18,7 @@ import { SsrfBlockedError, normalizeUrlCandidate } from "../../feeds/ssrfGuard.j
18
18
  import { buildOverview } from "../../mcp/digest.js";
19
19
  import { decodeCursor, encodeCursor } from "../../mcp/items.js";
20
20
  import { readingPositions, serializeItem, serializeSubscriptionDetail } from "./serialize.js";
21
- import { dismissEdition, editionCandidates, generateEdition, generateEditionSchema, getEdition, getEditionState } from "../../native/editions.js";
21
+ import { dismissEdition, editionCandidates, ensureEditionSchema, getEdition, getEditionState } from "../../native/editions.js";
22
22
  import { getNativePreferences, patchNativePreferences } from "../../native/preferences.js";
23
23
  import { NativeError } from "../../native/requests.js";
24
24
  import { capabilityDenial, hasUserCapability, requireCapability } from "../../auth/capabilities.js";
@@ -520,7 +520,7 @@ function editionResponse(req, edition) {
520
520
  .filter((row) => Boolean(row));
521
521
  const progress = readingPositions(req.user.id, rows.map((r) => r.id));
522
522
  const hasNotes = itemsWithNotes(req.user.id, rows.map((r) => r.id));
523
- const prepared = prepare(rows, req, "original");
523
+ const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original");
524
524
  return {
525
525
  ...edition,
526
526
  items: prepared.map((row) => serializeItem(row, progress, false, hasNotes.has(row.id))),
@@ -563,14 +563,19 @@ resourcesRouter.get("/edition/candidates", requireCapability("editions"), (req,
563
563
  res.setHeader("Cache-Control", "private, no-store");
564
564
  res.json({ preferences: getNativePreferences(req.user.id), current: getEditionState(req.user.id), candidates: editionCandidates(req.user.id, new Date(), parsed.data.limit) });
565
565
  });
566
+ // Older clients may still ensure an edition before reading. Keep that request read-only.
566
567
  resourcesRouter.post("/edition/generate", requireCapability("editions"), (req, res) => {
567
- const parsed = generateEditionSchema.safeParse(req.body);
568
+ const parsed = ensureEditionSchema.safeParse(req.body);
568
569
  if (!parsed.success)
569
570
  return invalid(res, parsed.error.flatten());
570
571
  nativeAction(res, () => {
571
- const edition = generateEdition(req.user.id, parsed.data);
572
+ const current = getEditionState(req.user.id);
573
+ if (parsed.data.expectedRevision !== undefined && parsed.data.expectedRevision !== current.revision) {
574
+ throw new NativeError("revision_conflict", 409, current);
575
+ }
576
+ const edition = getEdition(req.user.id);
572
577
  if (!edition)
573
- return void res.status(404).json({ error: "no_edition", current: getEditionState(req.user.id) });
578
+ return void res.status(404).json({ error: "no_edition", current });
574
579
  res.json(editionResponse(req, edition));
575
580
  });
576
581
  });
@@ -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;
@@ -0,0 +1,5 @@
1
+ -- Retire legacy automatic slots without dismissing the current curation period.
2
+ -- Keep the revision watermark so a late publication cannot silently overwrite newer state.
3
+ UPDATE editions SET status = 'expired', revision = revision + 1,
4
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
5
+ WHERE source = 'automatic' AND status = 'active';
@@ -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) {
@@ -92,7 +92,7 @@ export function runCleanup(custom) {
92
92
  AND i.id NOT IN (SELECT item_id FROM item_notes)
93
93
  AND i.feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
94
94
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
95
- WHERE selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
95
+ WHERE selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed')
96
96
  AND i.id IN (SELECT item_id FROM item_reads)
97
97
  AND NOT EXISTS (
98
98
  SELECT 1 FROM subscriptions s
@@ -110,7 +110,7 @@ export function runCleanup(custom) {
110
110
  AND id NOT IN (SELECT item_id FROM item_notes)
111
111
  AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
112
112
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
113
- WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
113
+ WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')
114
114
  AND COALESCE(julianday(published_at), julianday(created_at)) <= julianday('now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
115
115
  deletedOldItems = res.changes;
116
116
  }
@@ -121,7 +121,7 @@ export function runCleanup(custom) {
121
121
  AND id NOT IN (SELECT item_id FROM item_notes)
122
122
  AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
123
123
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
124
- WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
124
+ WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')
125
125
  AND id IN (
126
126
  SELECT id FROM (
127
127
  SELECT id, ROW_NUMBER() OVER (
@@ -0,0 +1,441 @@
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
+ // Tracking and scripting hooks no client uses. Lazy-load sources stay: they may be an image's only address.
151
+ const LAZY_SOURCES = new Set(["data-src", "data-srcset", "data-lazy-src", "data-original"]);
152
+ const INERT_ATTRIBUTES = new Set(["role", "tabindex", "itemprop", "itemscope", "itemtype", "itemid", "draggable", "contenteditable"]);
153
+ function isInertAttribute(name) {
154
+ if (LAZY_SOURCES.has(name))
155
+ return false;
156
+ return name.startsWith("data-") || name.startsWith("aria-") || name.startsWith("on") || INERT_ATTRIBUTES.has(name);
157
+ }
158
+ const EMPHASIS_EXEMPT = "h1, h2, h3, h4, h5, h6, th, strong, b";
159
+ function wrapChildren(element, tag) {
160
+ const wrapper = element.ownerDocument.createElement(tag);
161
+ while (element.firstChild)
162
+ wrapper.append(element.firstChild);
163
+ element.append(wrapper);
164
+ }
165
+ /**
166
+ * Typography belongs to the client: fonts, sizes, colours, alignment and classes are removed so that only
167
+ * structure (headings, paragraphs, lists, quotes, tables, links, images) and emphasis (bold, italic) are left.
168
+ * Emphasis that was only a style (`font-weight: bold` on a span) is kept as strong or em.
169
+ */
170
+ function removeTypography(root, stats) {
171
+ let changed = false;
172
+ root.querySelectorAll("style, link[rel~='stylesheet']").forEach(node => { node.remove(); changed = true; });
173
+ const targets = new Set([...root.querySelectorAll("a[href^='#']")].map(anchor => (anchor.getAttribute("href") ?? "").slice(1)));
174
+ for (const element of [...root.querySelectorAll("*")]) {
175
+ const style = element.getAttribute("style") ?? "";
176
+ const bold = /font-weight\s*:\s*(bold|bolder|[6-9]00)\b/i.test(style);
177
+ const italic = /font-style\s*:\s*(italic|oblique)\b/i.test(style);
178
+ const plain = !element.matches(EMPHASIS_EXEMPT) && !element.closest(EMPHASIS_EXEMPT) && flat(element.textContent) !== "";
179
+ for (const name of PRESENTATIONAL_ATTRIBUTES) {
180
+ if (element.hasAttribute(name)) {
181
+ element.removeAttribute(name);
182
+ changed = true;
183
+ }
184
+ }
185
+ for (const name of element.getAttributeNames()) {
186
+ if (isInertAttribute(name)) {
187
+ element.removeAttribute(name);
188
+ changed = true;
189
+ }
190
+ }
191
+ if (!SIZED_TAGS.has(element.tagName)) {
192
+ for (const name of ["width", "height"]) {
193
+ if (element.hasAttribute(name)) {
194
+ element.removeAttribute(name);
195
+ changed = true;
196
+ }
197
+ }
198
+ }
199
+ if (plain && bold)
200
+ wrapChildren(element, "strong");
201
+ if (plain && italic && !element.closest("em, i"))
202
+ wrapChildren(element, "em");
203
+ const anchorTarget = element.id && targets.has(element.id);
204
+ if (element.id && !anchorTarget) {
205
+ element.removeAttribute("id");
206
+ changed = true;
207
+ }
208
+ if (element.tagName === "A" && element.hasAttribute("name") && !targets.has(element.getAttribute("name") ?? "")) {
209
+ element.removeAttribute("name");
210
+ changed = true;
211
+ }
212
+ if (PRESENTATIONAL_TAGS.has(element.tagName) && !anchorTarget) {
213
+ element.replaceWith(...element.childNodes);
214
+ changed = true;
215
+ }
216
+ }
217
+ if (changed)
218
+ stats["typography"] = (stats["typography"] ?? 0) + 1;
219
+ return changed;
220
+ }
221
+ function serialize(root) {
222
+ const holder = root.ownerDocument.createElement("div");
223
+ holder.append(root);
224
+ return holder.innerHTML;
225
+ }
226
+ /** The article HTML as a reader should show it. Unchanged input is returned as is. */
227
+ export function cleanReaderHtml(html, title, stats = {}) {
228
+ if (!html)
229
+ return html;
230
+ const root = JSDOM.fragment(html);
231
+ const noise = removeReaderNoise(root, title, stats);
232
+ const typography = removeTypography(root, stats);
233
+ return noise || typography ? serialize(root) : html;
234
+ }
235
+ const CREDIT_PATTERNS = [
236
+ /^(©|\(c\)\s|copyright\b)/i,
237
+ /^[([]?(Bild|Foto|Fotos|Bildquelle|Bildrechte|Bildnachweis|Titelbild|Beitragsbild|Photo|Image|Credits?|Grafik|Illustration)[)\]]?\s*[:/|]/i,
238
+ /\|\s*(Image|Photo|Foto|Bild|Credit)s?\s*:/i,
239
+ /\(\s*(Bildquelle|Bildrechte|Foto|Bild|Photo|Image|Credit|Quelle)\s*:[^)]{1,60}\)\s*\.?$/i,
240
+ /\bBildrechte:\s*(Bildrechte:\s*)?[^.]{1,60}$/i,
241
+ /©[^\n]{1,40}$/,
242
+ /\/\s*(Getty Images|Reuters|AP|AFP|dpa|Imago)\s*$/i,
243
+ /\bvia (REUTERS|Reuters|AP|AFP|dpa|Getty Images|Imagen)\s*$/,
244
+ /^Quelle:?\s*\S{0,40}$/i,
245
+ ];
246
+ const HEAD_META_PATTERNS = [
247
+ /^(Stand|Aktualisiert|Zuletzt aktualisiert|Ver(ö|oe)ffentlicht|Updated|Published|Posted)\b.{0,40}\d/i,
248
+ /^von\s+.{2,40}\s[I|]\s+\d{1,2}\.\d{1,2}\.\d{2,4}/i,
249
+ /^(\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)?)?$/,
250
+ /^\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,
251
+ /^(Kommentare|Kommentieren|Comments|Teilen|Share|Drucken|Print|Merken|Vorlesen)$/i,
252
+ /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen)\s*:?\s+[^.!?]{0,160}$/i,
253
+ ];
254
+ const AD_PATTERNS = [
255
+ /^(Anzeige|Werbung|Advertisement|Advertorial|Sponsored|Gesponsert|Promoted)\s*:?$/i,
256
+ /\b(affiliate|partner)[- ]?links?\b/i,
257
+ /\bwe (may|might) (receive|earn) a commission\b/i,
258
+ /^FTC:/i,
259
+ /\bClick Here to Buy\b/i,
260
+ /^Hurry,/i,
261
+ /^Worth checking out on Amazon/i,
262
+ /\bProvision\b.{0,60}\b(Links?|Einkäufe|Käufe)\b/i,
263
+ ];
264
+ const SOCIAL_NETWORKS = ["Facebook", "Instagram", "Reddit", "Threads", "Twitter", "Mastodon", "YouTube", "TikTok", "Google News", "Flipboard", "WhatsApp", "LinkedIn"];
265
+ const CALL_TO_ACTION_PATTERNS = [
266
+ /\b(bevorzugte[nr]?\s+(Google-)?Quelle|bei Google bevorzugen|preferred source)\b/i,
267
+ /\bWo du uns folgen kannst\b/i,
268
+ /^Folge[nt]? uns\b/i,
269
+ /\bFolge uns auch auf\b/i,
270
+ /\bUns auf Google folgen\b/i,
271
+ /\bfollow us\b/i,
272
+ /\bFügt uns doch bei Google\b/i,
273
+ /\bAuf dem Laufenden bleiben\?/i,
274
+ /\bNewsletter\b.{0,240}\b(Anmeldung|anmelden|abonnier\w*|subscribe)\b/i,
275
+ /\b(subscribe|abonnier\w+)\b.{0,60}\b(newsletter|YouTube|channel|Kanal)\b/i,
276
+ /\bBy subscribing\b/i,
277
+ /^SUBSCRIBE NOW$/i,
278
+ /^Hier geht'?s zur Anmeldung!?$/i,
279
+ /^★.+★$/,
280
+ ];
281
+ const COMMENT_PATTERNS = [
282
+ /\b(let us know|tell us) .{0,40}in the comments\b/i,
283
+ /\bNur angemeldete Benutzer können kommentieren\b/i,
284
+ ];
285
+ const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
286
+ const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
287
+ 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;
288
+ const MIN_KEPT_CHARACTERS = 150;
289
+ const HEAD_BLOCKS = 6;
290
+ const TAIL_CHARACTERS = 1500;
291
+ const TAIL_CUT_FROM = 0.6;
292
+ function matchesAny(text, patterns) {
293
+ return patterns.some(pattern => pattern.test(text));
294
+ }
295
+ function isSocialList(text) {
296
+ return text.length <= 200 && SOCIAL_NETWORKS.filter(name => text.includes(name)).length >= 3;
297
+ }
298
+ function isLinkOnly(block, text) {
299
+ const anchors = block.querySelectorAll("a");
300
+ return anchors.length === 1 && flat(anchors[0].textContent) === text;
301
+ }
302
+ /**
303
+ * Plain, single-line publisher text without page chrome: for snippets, search, teasers and curation, never for
304
+ * display as a page. Falls back to the plain flattening when cleaning would leave almost nothing.
305
+ */
306
+ export function derivedText(html, title, stats = {}) {
307
+ if (!html)
308
+ return "";
309
+ const root = JSDOM.fragment(html);
310
+ root.querySelectorAll("script, style, noscript, nav, footer").forEach(node => node.remove());
311
+ const plain = () => {
312
+ root.querySelectorAll("p, div, br, li, h1, h2, h3, h4, blockquote").forEach(node => node.append(" "));
313
+ return flat(root.textContent);
314
+ };
315
+ removeReaderNoise(root, title, stats);
316
+ root.querySelectorAll("figcaption").forEach(node => node.remove());
317
+ const entries = leafBlocks(root).map(element => ({ element, text: flat(element.textContent) })).filter(entry => entry.text);
318
+ const total = entries.reduce((sum, entry) => sum + entry.text.length, 0);
319
+ if (entries.length === 0 || total < 2 * MIN_KEPT_CHARACTERS)
320
+ return plain();
321
+ const dropped = new Set();
322
+ const mark = (index, rule) => {
323
+ if (dropped.has(index))
324
+ return;
325
+ dropped.add(index);
326
+ stats[rule] = (stats[rule] ?? 0) + 1;
327
+ };
328
+ let before = 0;
329
+ let cutAt = -1;
330
+ entries.forEach((entry, index) => {
331
+ const { text } = entry;
332
+ const early = index < HEAD_BLOCKS || before <= 600;
333
+ const late = total - before - text.length <= TAIL_CHARACTERS || index >= entries.length - 8;
334
+ const position = before / total;
335
+ before += text.length;
336
+ if (cutAt >= 0)
337
+ return;
338
+ if (SECTION_LABEL.test(text) && text.length <= 400) {
339
+ if (position >= TAIL_CUT_FROM && before - text.length >= MIN_KEPT_CHARACTERS) {
340
+ cutAt = index;
341
+ stats["recommendations"] = (stats["recommendations"] ?? 0) + 1;
342
+ return;
343
+ }
344
+ mark(index, "recommendations");
345
+ for (let next = index + 1; next < entries.length && isLinkOnly(entries[next].element, entries[next].text); next++)
346
+ mark(next, "recommendations");
347
+ return;
348
+ }
349
+ if (TAG_LABEL.test(text)) {
350
+ mark(index, "head-meta");
351
+ for (let next = index + 1; next < entries.length && isLinkOnly(entries[next].element, entries[next].text); next++)
352
+ mark(next, "head-meta");
353
+ return;
354
+ }
355
+ if (text.length > 400)
356
+ return;
357
+ if (text.length <= 160 && VIDEO_CARD.test(text) && entry.element.querySelector("a"))
358
+ mark(index, "player");
359
+ else if (matchesAny(text, AD_PATTERNS))
360
+ mark(index, "advertising");
361
+ else if (text.length <= 300 && (early || late) && matchesAny(text, CALL_TO_ACTION_PATTERNS))
362
+ mark(index, "call-to-action");
363
+ else if (text.length <= 300 && late && matchesAny(text, COMMENT_PATTERNS))
364
+ mark(index, "comments");
365
+ else if (isSocialList(text))
366
+ mark(index, "call-to-action");
367
+ else if (early && text.length <= 160 && matchesAny(text, HEAD_META_PATTERNS))
368
+ mark(index, "head-meta");
369
+ else if ((early || late) && text.length <= 300 && matchesAny(text, CREDIT_PATTERNS)) {
370
+ mark(index, "credit");
371
+ // In the head a credit line follows the caption it belongs to.
372
+ const caption = entries[index - 1];
373
+ if (early && caption && index - 1 < HEAD_BLOCKS && caption.text.length <= 200 && /^(©|\(c\)|copyright|Bildrechte|Bildquelle|Foto|Photo|Image|Credit)/i.test(text))
374
+ mark(index - 1, "credit");
375
+ }
376
+ });
377
+ const kept = entries.filter((_, index) => !dropped.has(index) && (cutAt < 0 || index < cutAt)).map(entry => entry.text);
378
+ const text = kept.join(" ");
379
+ if (text.length < Math.min(MIN_KEPT_CHARACTERS, total) && text.length < total * 0.7)
380
+ return plain();
381
+ return text;
382
+ }
383
+ const SNIPPET_LINE_PATTERNS = [
384
+ /^(Der|Die|Das) Beitrag .+ (erschien|wurde) zuerst\b.*$/i,
385
+ /^The post .+ appeared first on\b.*$/i,
386
+ /^This article, ".+" first appeared on\b.*$/i,
387
+ /^Discuss this article in our forums\.?$/i,
388
+ /^Read (the )?full story( here| at .+)?\.?$/i,
389
+ /^Zum Beitrag:.*$/i,
390
+ /^(more…|more\.\.\.|Mehr|Weiterlesen|Read more)$/i,
391
+ /^(Related Roundup|Related Forum|Tags?|Buyer's Guide):.*$/i,
392
+ /^★.+★$/,
393
+ /^Wo du uns folgen kannst:.*$/i,
394
+ /^Auf dem Laufenden bleiben\?$/i,
395
+ /^Fügt uns doch bei Google.*$/i,
396
+ ];
397
+ const SNIPPET_INLINE_PATTERNS = [
398
+ /\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,
399
+ /\s*Über unseren Newsletter [^\n]*?\.(?=\s|$)/gi,
400
+ /\s*The post [^\n]+? appeared first on [^\n]*?\.(?=\s|$)/gi,
401
+ ];
402
+ /**
403
+ * A feed's summary without the footers feed plugins append ("The post … appeared first on …", "Read the full
404
+ * story at …"), without a repeated headline and with whitespace collapsed to paragraphs.
405
+ */
406
+ export function cleanSnippet(snippet, title) {
407
+ if (snippet === null || snippet === undefined)
408
+ return null;
409
+ let text = snippet.replace(/\r\n?/g, "\n").replace(/[ \t\u00a0]+/g, " ");
410
+ for (const pattern of SNIPPET_INLINE_PATTERNS)
411
+ text = text.replace(pattern, "");
412
+ const headline = normalized(title ?? "");
413
+ const lines = text.split("\n").map(line => line.trim()).filter((line, index) => line && !SNIPPET_LINE_PATTERNS.some(pattern => pattern.test(line)) && !isSocialList(line) &&
414
+ !(index === 0 && headline && normalized(line) === headline));
415
+ return lines.join("\n").trim();
416
+ }
417
+ /** Raised whenever the rules above change what they remove, so stored leads are derived again. */
418
+ export const LEAD_VERSION = 1;
419
+ const LEAD_LENGTH = 600;
420
+ function shorten(text, max) {
421
+ if (text.length <= max)
422
+ return text;
423
+ let slice = text.slice(0, max);
424
+ if (/[\uD800-\uDBFF]$/.test(slice))
425
+ slice = slice.slice(0, -1);
426
+ const sentence = Math.max(slice.lastIndexOf(". "), slice.lastIndexOf("! "), slice.lastIndexOf("? "));
427
+ if (sentence >= max * 0.5)
428
+ return slice.slice(0, sentence + 1);
429
+ const space = slice.lastIndexOf(" ");
430
+ return (space >= max * 0.5 ? slice.slice(0, space) : slice).replace(/[\s,;:–-]+$/, "") + "…";
431
+ }
432
+ /**
433
+ * The beginning of an article as one plain line, for teasers: the cleaned body, or the cleaned feed summary when
434
+ * that says more (a failed download leaves only the summary).
435
+ */
436
+ export function leadText(parts, max = LEAD_LENGTH) {
437
+ const body = derivedText(parts.html, parts.title);
438
+ const summary = flat(cleanSnippet(parts.snippet, parts.title));
439
+ return shorten(body.length >= summary.length ? body : summary, max);
440
+ }
441
+ //# 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);
@@ -41,7 +43,7 @@ export function removeFeedIfUnused(feedId) {
41
43
  return;
42
44
  db.prepare(`DELETE FROM items WHERE feed_id = ? AND id NOT IN (SELECT item_id FROM item_bookmarks) AND id NOT IN (SELECT item_id FROM item_notes)
43
45
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
44
- WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))`).run(feedId);
46
+ WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')`).run(feedId);
45
47
  db.prepare("DELETE FROM feeds WHERE id = ? AND is_system_inbox = 0 AND NOT EXISTS (SELECT 1 FROM items WHERE feed_id = ?)").run(feedId, feedId);
46
48
  }
47
49
  /** Feeds kept only for saved articles or annotated articles whose last bookmark and note are gone. */
@@ -52,7 +54,7 @@ export function removeUnusedFeeds() {
52
54
  AND NOT EXISTS (SELECT 1 FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.feed_id = feeds.id)
53
55
  AND NOT EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = feeds.id)
54
56
  AND NOT EXISTS (SELECT 1 FROM items i, editions e, json_each(e.item_ids) selected
55
- WHERE i.feed_id = feeds.id AND selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))`).run().changes;
57
+ WHERE i.feed_id = feeds.id AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed')`).run().changes;
56
58
  }
57
59
  /** Subscribers can open every item of their feeds; anyone can open what they saved or added a note to. */
58
60
  export function canAccessItem(userId, itemId) {
@@ -62,7 +64,7 @@ export function canAccessItem(userId, itemId) {
62
64
  OR EXISTS (SELECT 1 FROM item_bookmarks b WHERE b.item_id = i.id AND b.user_id = ?)
63
65
  OR EXISTS (SELECT 1 FROM item_notes n WHERE n.item_id = i.id AND n.user_id = ?)
64
66
  OR EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
65
- AND selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now')))`).get(itemId, userId, userId, userId, userId));
67
+ AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).get(itemId, userId, userId, userId, userId));
66
68
  }
67
69
  export function listSubscriptionsForUser(userId) {
68
70
  return db
@@ -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;
@@ -278,7 +292,7 @@ export function findItemForUser(userId, itemId) {
278
292
  LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
279
293
  WHERE i.id = ? AND (s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.item_id IS NOT NULL OR
280
294
  EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
281
- AND selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now')))`).get(userId, userId, userId, userId, itemId, userId);
295
+ AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).get(userId, userId, userId, userId, itemId, userId);
282
296
  }
283
297
  export function updateSubscriptionLabel(userId, feedId, label) {
284
298
  db.prepare("UPDATE subscriptions SET label = ? WHERE user_id = ? AND feed_id = ?").run(label?.trim() || null, userId, feedId);
@@ -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({
@@ -37,7 +37,7 @@ export function listItemsPage(userId, options) {
37
37
  });
38
38
  const hasMore = rows.length > limit;
39
39
  const page = rows.slice(0, limit);
40
- const items = page.map(({ content_html: _contentHtml, full_content_html: _fullContentHtml, ...item }) => item);
40
+ const items = page.map(({ content_html: _contentHtml, full_content_html: _fullContentHtml, content_lead: _lead, lead_version: _leadVersion, ...item }) => item);
41
41
  return {
42
42
  items,
43
43
  newestCursor: page[0] ? encodeCursor(page[0]) : null,
@@ -51,15 +51,16 @@ 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]))
58
58
  end--;
59
59
  if (end === offset && offset < body.length)
60
60
  end = Math.min(offset + 2, body.length);
61
+ const { content_lead: _lead, lead_version: _leadVersion, ...visible } = item;
61
62
  return {
62
- ...item,
63
+ ...visible,
63
64
  content_html: format === "html" ? body.slice(offset, end) : undefined,
64
65
  content_truncated: end < body.length,
65
66
  full_content_html: undefined,
@@ -29,7 +29,7 @@ export function registerPromptsAndResources(server, userId, scope) {
29
29
  server.registerResource("feeds", "feedkeeper://feeds", { title: "Subscribed feeds", description: "All subscriptions with unread counts and health.", mimeType: "application/json" }, (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(listSubscriptionsForUser(userId), null, 2) }] }));
30
30
  server.registerResource("opml", "feedkeeper://opml", { title: "Subscriptions as OPML", description: "The subscription list in OPML 2.0, for backup or migration.", mimeType: "text/x-opml" }, (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/x-opml", text: generateOpml(userId) }] }));
31
31
  server.registerResource("saved", "feedkeeper://saved", { title: "Saved articles", description: "The newest 100 saved articles, without their content.", mimeType: "application/json" }, (uri) => {
32
- const items = listItemsForUser(userId, { bookmarkedOnly: true, limit: 100 }).map(({ content_html: _html, full_content_html: _full, ...item }) => item);
32
+ const items = listItemsForUser(userId, { bookmarkedOnly: true, limit: 100 }).map(({ content_html: _html, full_content_html: _full, content_lead: _lead, lead_version: _leadVersion, ...item }) => item);
33
33
  return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(items, null, 2) }] };
34
34
  });
35
35
  server.registerResource("digest", "feedkeeper://digest", { title: "Unread digest", description: "Unread articles of the last 24 hours, grouped by feed.", mimeType: "application/json" }, (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(buildDigest(userId, { hours: 24 }), null, 2) }] }));
@@ -15,7 +15,7 @@ import { discoverFeeds } from "../feeds/discovery.js";
15
15
  import { findUserById } from "../auth/users.js";
16
16
  import { getDatabaseStats, runCleanup } from "../feeds/cleanup.js";
17
17
  import { APP_VERSION } from "../version.js";
18
- import { dismissEdition, editionCandidates, generateEdition, generateEditionSchema, getEditionState, publishCuratedEdition, publishEditionFields } from "../native/editions.js";
18
+ import { dismissEdition, editionCandidates, ensureEditionSchema, getEdition, getEditionState, publishCuratedEdition, publishEditionFields } from "../native/editions.js";
19
19
  import { getNativePreferences, patchNativePreferences, preferencePatchSchema } from "../native/preferences.js";
20
20
  import { NativeError } from "../native/requests.js";
21
21
  import { capabilityDenial, hasUserCapability } from "../auth/capabilities.js";
@@ -254,7 +254,7 @@ export function createMcpServerForUser(userId, scope) {
254
254
  },
255
255
  }, async ({ feedId, folderId, search, bookmarkedOnly, since, until, limit, includeContent, snippetChars }) => textResult(listItemsForUser(userId, {
256
256
  feedId, folderId, search, bookmarkedOnly, limit, unreadOnly: true, publishedSince: since, publishedUntil: until,
257
- }).map(({ content_html, full_content_html, ...item }) => ({
257
+ }).map(({ content_html, full_content_html, content_lead: _lead, lead_version: _leadVersion, ...item }) => ({
258
258
  ...item,
259
259
  ...(includeContent === false ? {} : { content_html, full_content_html }),
260
260
  content_snippet: snippetChars ? truncate(item.content_snippet, snippetChars) : item.content_snippet,
@@ -500,19 +500,23 @@ export function createMcpServerForUser(userId, scope) {
500
500
  if (scope === "write")
501
501
  server.registerTool("publish_edition", {
502
502
  title: "Publish curated edition",
503
- description: "Publishes an ordered list of 1 to 24 accessible article IDs. Curated editions override automatic ones until expiry (default 24 hours, maximum seven days). App-only hidden sections and muted keywords are respected. Supply the revision from get_edition as expectedRevision and a UUID requestId for conflict detection and safe retries. Optional title and summary are editorial text; original articles stay unchanged. Optional layout (version 1) says how the issue is set: a character (dominant, calm or briefing) and blocks (lead, dossier, feature, standard, briefs, photo, quote) that reference the issue's article IDs at most once each; it never changes the articles.",
503
+ description: "Publishes an ordered list of 1 to 24 accessible article IDs. The latest curated edition stays readable until another is published; expiry (default 24 hours, maximum seven days) controls when a new publication is due. App-only hidden sections and muted keywords are respected. Supply the revision from get_edition as expectedRevision and a UUID requestId for conflict detection and safe retries. Optional title and summary are editorial text; original articles stay unchanged. Optional layout (version 1) says how the issue is set: a character (dominant, calm or briefing) and blocks (lead, dossier, feature, standard, briefs, photo, quote) that reference the issue's article IDs at most once each; it never changes the articles.",
504
504
  inputSchema: publishEditionFields,
505
505
  }, async (input) => capabilityAction("editions", () => publishCuratedEdition(userId, input)));
506
506
  if (scope === "write")
507
507
  server.registerTool("generate_edition", {
508
- title: "Generate an automatic edition",
509
- description: "Ensures an automatic edition exists without disturbing a valid current issue. force=true requests a new automatic selection and requires expectedRevision. A valid curated edition must be explicitly dismissed first. Use a stable UUID requestId for retries.",
510
- inputSchema: generateEditionSchema.shape,
508
+ title: "Get the latest curated edition",
509
+ description: "Compatibility alias for reading the latest curated publication. It never selects articles or publishes an edition. force is accepted for older clients but does not create a replacement; use publish_edition to publish a new curated selection.",
510
+ inputSchema: ensureEditionSchema.shape,
511
511
  }, async (input) => capabilityAction("editions", () => {
512
- const parsed = generateEditionSchema.safeParse(input);
512
+ const parsed = ensureEditionSchema.safeParse(input);
513
513
  if (!parsed.success)
514
514
  throw new NativeError("invalid_input");
515
- return { edition: generateEdition(userId, parsed.data), current: getEditionState(userId) };
515
+ const current = getEditionState(userId);
516
+ if (parsed.data.expectedRevision !== undefined && parsed.data.expectedRevision !== current.revision) {
517
+ throw new NativeError("revision_conflict", 409, current);
518
+ }
519
+ return { edition: getEdition(userId), current };
516
520
  }));
517
521
  if (scope === "write")
518
522
  server.registerTool("dismiss_edition", {
@@ -1,11 +1,9 @@
1
- import { hasUserCapability } from "../auth/capabilities.js";
2
1
  import { randomUUID } from "node:crypto";
3
- import cron from "node-cron";
4
2
  import { z } from "zod";
5
3
  import { db } from "../db/index.js";
6
4
  import { canAccessItem } from "../feeds/repository.js";
7
5
  import { decodeEntities } from "../feeds/text.js";
8
- import { editionPeriod, nextEditionBoundary, selectEdition } from "./editionSelection.js";
6
+ import { editionPeriod, selectEdition } from "./editionSelection.js";
9
7
  import { getNativePreferences } from "./preferences.js";
10
8
  import { checkEditionLayout, editionLayoutSchema } from "./editionLayout.js";
11
9
  import { NativeError, nativeRequest } from "./requests.js";
@@ -22,9 +20,9 @@ function storedEdition(userId) {
22
20
  function serialize(row) {
23
21
  const prefs = getNativePreferences(row.user_id);
24
22
  return {
25
- id: row.edition_id, revision: row.revision, source: row.source,
23
+ id: row.edition_id, revision: row.revision, source: "curated",
26
24
  createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at,
27
- period: row.period?.split(":")[1] ?? editionPeriod(new Date(row.created_at), prefs.timeZone).period,
25
+ period: row.period?.split(":").pop() ?? editionPeriod(new Date(row.created_at), prefs.timeZone).period,
28
26
  title: row.title, summary: row.summary, overview: row.overview ? JSON.parse(row.overview) : null, layout: row.layout ? JSON.parse(row.layout) : null,
29
27
  preferencesRevision: row.preferences_revision,
30
28
  itemIds: JSON.parse(row.item_ids),
@@ -34,11 +32,15 @@ export function getEditionState(userId, now = new Date()) {
34
32
  const row = storedEdition(userId);
35
33
  if (!row)
36
34
  return { revision: 0, status: "none", edition: null };
35
+ if (row.source !== "curated")
36
+ return { revision: row.revision, status: "expired", edition: null };
37
37
  const status = row.status === "active" && Date.parse(row.expires_at) <= now.getTime() ? "expired" : row.status;
38
38
  return { revision: row.revision, status, edition: status === "active" ? serialize(row) : null };
39
39
  }
40
- export function getEdition(userId, now = new Date()) {
41
- return getEditionState(userId, now).edition;
40
+ /** Expiry schedules the next publication; it does not take the last issue away from readers. */
41
+ export function getEdition(userId, _now = new Date()) {
42
+ const row = storedEdition(userId);
43
+ return row?.source === "curated" && row.status !== "dismissed" ? serialize(row) : null;
42
44
  }
43
45
  function checkRevision(userId, expectedRevision, now) {
44
46
  const current = getEditionState(userId, now);
@@ -77,7 +79,13 @@ export const publishEditionFields = {
77
79
  layout: editionLayoutSchema.optional(),
78
80
  };
79
81
  const publishSchema = z.object(publishEditionFields).strict().refine((input) => !(input.expiresAt && input.durationHours), "Use expiresAt or durationHours, not both");
80
- /** Both automatic and MCP editions obey the app's hard exclusions. Read/saved articles
82
+ /** Legacy ensure requests read the latest publication; they never select or publish articles. */
83
+ export const ensureEditionSchema = z.object({
84
+ requestId: z.uuid(),
85
+ expectedRevision: z.number().int().min(0).optional(),
86
+ force: z.boolean().default(false),
87
+ }).strict().refine((input) => !input.force || input.expectedRevision !== undefined, "force requires expectedRevision");
88
+ /** Curated publications obey the app's hard exclusions. Read/saved articles
81
89
  * may be selected deliberately by an agent, but foreign and muted items cannot be published. */
82
90
  export function publishCuratedEdition(userId, input, now = new Date()) {
83
91
  if (input.layout !== undefined && !editionLayoutSchema.safeParse(input.layout).success)
@@ -135,7 +143,7 @@ export function deleteEdition(userId) {
135
143
  dismissEdition(userId);
136
144
  return existed;
137
145
  }
138
- /** The automatic edition looks at every candidate; smaller limits are shortlists for an agent or the curator. */
146
+ /** Bound candidate gathering before building a shortlist for an agent or the curator. */
139
147
  const AUTOMATIC_EDITION_LIMIT = 4000;
140
148
  /** How often each feed appeared in the editions of the last 72 hours, so a feed that was shown a lot waits its turn. */
141
149
  function recentSourceUse(userId, itemIds) {
@@ -237,58 +245,4 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
237
245
  }
238
246
  return scored.sort((a, b) => b.score - a.score || b.id - a.id);
239
247
  }
240
- export const generateEditionSchema = z.object({
241
- requestId: z.uuid(),
242
- expectedRevision: z.number().int().min(0).optional(),
243
- force: z.boolean().default(false),
244
- }).strict().refine((input) => !input.force || input.expectedRevision !== undefined, "force requires expectedRevision");
245
- export function generateEdition(userId, input = {}, now = new Date()) {
246
- return nativeRequest(userId, input.requestId, "edition.generate", { expectedRevision: input.expectedRevision, force: input.force ?? false }, () => {
247
- checkRevision(userId, input.expectedRevision, now);
248
- const prefs = getNativePreferences(userId);
249
- const period = editionPeriod(now, prefs.timeZone).key;
250
- const state = getEditionState(userId, now);
251
- if (state.edition) {
252
- if (state.edition.source === "curated") {
253
- if (input.force)
254
- throw new NativeError("curated_edition_active", 409, state);
255
- return state.edition;
256
- }
257
- if (!input.force)
258
- return state.edition;
259
- }
260
- const row = storedEdition(userId);
261
- if (!input.force && row?.status === "dismissed" && row.period === period)
262
- return null;
263
- if (row?.status === "active" && !state.edition) {
264
- db.prepare("UPDATE editions SET status = 'expired', revision = revision + 1, updated_at = ? WHERE user_id = ?").run(now.toISOString(), userId);
265
- }
266
- const chosen = selectEdition(editionCandidates(userId, now, AUTOMATIC_EDITION_LIMIT), prefs);
267
- if (!chosen.length)
268
- return state.edition;
269
- if (state.edition && JSON.stringify(state.edition.itemIds) === JSON.stringify(chosen.map((item) => item.id)))
270
- return state.edition;
271
- return writeEdition(userId, chosen.map((item) => item.id), "automatic", nextEditionBoundary(now, prefs.timeZone), now);
272
- }, now);
273
- }
274
- /** Only users who opted in by storing preferences or creating an edition are scheduled. */
275
- export function maintainEditions(now = new Date()) {
276
- const users = db.prepare("SELECT user_id FROM native_preferences UNION SELECT user_id FROM editions").all();
277
- for (const user of users) {
278
- if (!hasUserCapability(user.user_id, "editions"))
279
- continue;
280
- try {
281
- generateEdition(user.user_id, {}, now);
282
- }
283
- catch (error) {
284
- console.error("[edition] could not refresh an edition", error instanceof Error ? error.message : "unknown error");
285
- }
286
- }
287
- // Receipts remain valid for at least 35 days, beyond the native offline window.
288
- db.prepare("DELETE FROM native_requests WHERE created_at < ?").run(new Date(now.getTime() - 35 * 86_400_000).toISOString());
289
- }
290
- export function startEditionScheduler() {
291
- maintainEditions();
292
- cron.schedule("* * * * *", () => maintainEditions());
293
- }
294
248
  //# sourceMappingURL=editions.js.map
@@ -1,3 +1,4 @@
1
+ import cron from "node-cron";
1
2
  import { createHash } from "node:crypto";
2
3
  import { db } from "../db/index.js";
3
4
  export class NativeError extends Error {
@@ -32,4 +33,11 @@ export function nativeRequest(userId, requestId, operation, input, action, now =
32
33
  return result;
33
34
  })();
34
35
  }
36
+ /** Receipts outlive the native offline window; publication metadata stays in the edition slot. */
37
+ export function startNativeRequestCleanup() {
38
+ cron.schedule("0 * * * *", () => {
39
+ db.prepare("DELETE FROM native_requests WHERE created_at < ?")
40
+ .run(new Date(Date.now() - 35 * 86_400_000).toISOString());
41
+ });
42
+ }
35
43
  //# sourceMappingURL=requests.js.map
@@ -18,13 +18,15 @@ import { installPollingShutdown, startPollingScheduler } from "./feeds/poller.js
18
18
  import { startCleanupScheduler } from "./feeds/cleanup.js";
19
19
  import { repairEncodedText } from "./feeds/repository.js";
20
20
  import { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
21
- import { startEditionScheduler } from "./native/editions.js";
21
+ import { startNativeRequestCleanup } from "./native/requests.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)
@@ -32,7 +34,7 @@ if (pendingArchives > 0)
32
34
  startPollingScheduler();
33
35
  startFullTextScheduler();
34
36
  startCleanupScheduler();
35
- startEditionScheduler();
37
+ startNativeRequestCleanup();
36
38
  const app = express();
37
39
  // Express validates the addresses here, so a mistyped TRUST_PROXY list stops the server at startup.
38
40
  if (config.trustProxy !== false) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }