feedkeeper 0.28.0 → 0.28.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.28.0",
3
+ "version": "0.28.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",
@@ -121,8 +121,14 @@ function removeReaderNoise(root, title, stats, author) {
121
121
  if (ancestor.querySelector("a[href]") && PRICE.test(text))
122
122
  card = ancestor;
123
123
  }
124
- if (card)
124
+ if (card) {
125
+ const parent = card.parentElement;
125
126
  drop(card, "product-card");
127
+ // A long product name can put the enclosing header just beyond the card's size limit.
128
+ if (parent && PRODUCT_LABEL.test(flat(parent.textContent)) && !parent.querySelector("img, picture, iframe, video, a[href]")) {
129
+ drop(parent, "empty-product-label");
130
+ }
131
+ }
126
132
  }
127
133
  // Structured data is for machines. Frameworks render it as an element of any name (`<component type="application/ld+json">`),
128
134
  // and a stray block may hold nothing else: neither is text for a reader.
@@ -137,6 +143,32 @@ function removeReaderNoise(root, title, stats, author) {
137
143
  const headline = normalized(title ?? "");
138
144
  const seen = new Set();
139
145
  const authorKeys = (author ?? "").split(",").map(name => normalized(name)).filter(Boolean);
146
+ const cleanByline = (block, text) => {
147
+ if (!BYLINE.test(text))
148
+ return;
149
+ if (authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
150
+ drop(block, "byline");
151
+ return;
152
+ }
153
+ if (stripBylineDate(block)) {
154
+ stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
155
+ changed = true;
156
+ }
157
+ };
158
+ // Some publishers use an inline wrapper as a standalone byline. Never treat spans inside prose as metadata.
159
+ for (const span of root.querySelectorAll("span")) {
160
+ if (!root.contains(span) || span.closest(BLOCKS) || span.querySelector(`${BLOCKS}, div`))
161
+ continue;
162
+ const text = flat(span.textContent);
163
+ if (text.length > 60 || !BYLINE.test(text) || charsBefore(root, span) > 800)
164
+ continue;
165
+ if ([...(span.parentNode?.childNodes ?? [])].some(node => node !== span && node.nodeType === 3 && flat(node.textContent)))
166
+ continue;
167
+ if ([...(span.parentElement?.children ?? [])].some(sibling => sibling !== span &&
168
+ !sibling.matches(`${BLOCKS}, div, article, section, header, footer, figure, ul, ol, table, aside`) && flat(sibling.textContent)))
169
+ continue;
170
+ cleanByline(span, text);
171
+ }
140
172
  let leading = 0;
141
173
  let reachedText = false;
142
174
  let pastStart = false;
@@ -177,14 +209,9 @@ function removeReaderNoise(root, title, stats, author) {
177
209
  continue;
178
210
  }
179
211
  // When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
180
- if (BYLINE.test(text) && authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
181
- drop(block, "byline");
212
+ cleanByline(block, text);
213
+ if (!root.contains(block))
182
214
  continue;
183
- }
184
- if (BYLINE.test(text) && stripBylineDate(block)) {
185
- stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
186
- changed = true;
187
- }
188
215
  }
189
216
  }
190
217
  if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
@@ -248,6 +275,27 @@ function removeReaderNoise(root, title, stats, author) {
248
275
  drop(block, "comment-rules");
249
276
  break;
250
277
  }
278
+ // Empty nested slots are structural evidence: the next story paragraph is outside this ad container.
279
+ // Do not infer an empty ad from a standalone heading followed by text (which may itself be sponsored).
280
+ for (const container of [...root.querySelectorAll("div, aside, section, p")]) {
281
+ if (!root.contains(container))
282
+ continue;
283
+ const text = flat(container.textContent);
284
+ const product = PRODUCT_LABEL.test(text);
285
+ if (!product && !AD_LABEL.test(text))
286
+ continue;
287
+ if (container.querySelector("img, picture, iframe, video, audio, a[href], object, embed"))
288
+ continue;
289
+ const emptySlot = container.querySelector("div:empty, ins:empty");
290
+ // Readability may remove the empty slot, leaving only its enclosing block and label paragraph.
291
+ const isolated = container.matches("div, aside, section") && container.querySelector("p") &&
292
+ (product || AD_SLOT_LABEL.test(text));
293
+ const next = container.nextElementSibling;
294
+ const nextHasMedia = next && (next.matches("img, picture, iframe, video, audio, a[href]") ||
295
+ next.querySelector("img, picture, iframe, video, audio, a[href]"));
296
+ if (emptySlot || (isolated && !nextHasMedia))
297
+ drop(container, product ? "empty-product-label" : "empty-ad-label");
298
+ }
251
299
  // An ad label whose ad is not there (a script fills it): sponsored content itself stays, the label above nothing goes.
252
300
  const labelled = leafBlocks(root);
253
301
  labelled.forEach((block, index) => {
@@ -362,13 +410,14 @@ export function cleanReaderHtml(html, title, stats = {}, author) {
362
410
  return html;
363
411
  const root = JSDOM.fragment(html);
364
412
  const images = resolveCustomImages(root, stats);
413
+ const duplicates = collapseAdjacentImages(root, stats);
365
414
  const variants = collapseImageVariants(root, stats);
366
415
  const videos = resolveVideoPlayers(root, stats);
367
416
  const noise = removeReaderNoise(root, title, stats, author);
368
417
  const typography = removeTypography(root, stats);
369
418
  videos.links.forEach(link => { if (root.contains(link))
370
419
  link.setAttribute("data-media", "video"); });
371
- return images || variants || videos.changed || noise || typography ? serialize(root) : html;
420
+ return images || duplicates || variants || videos.changed || noise || typography ? serialize(root) : html;
372
421
  }
373
422
  const PLAY_LABEL = /^(video starten|video abspielen|abspielen|wiedergabe( starten)?|play( video)?|watch( video)?|start video)$/i;
374
423
  const PLAY_SOURCE = /(?:^|[/_.-])play[-_]?(?:icon|button|btn)?\.(?:svg|png|webp|gif)(?:[?#]|$)/i;
@@ -539,6 +588,46 @@ function collapseImageVariants(root, stats) {
539
588
  }
540
589
  return changed;
541
590
  }
591
+ /** Responsive copies may be separate images whose CSS normally hides one. Only exact adjacent copies go;
592
+ * text, other media, different links, captions and responsive sources keep images distinct. */
593
+ function collapseAdjacentImages(root, stats) {
594
+ const walker = root.ownerDocument.createTreeWalker(root, 1 | 4);
595
+ let previous = null;
596
+ const duplicates = [];
597
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
598
+ if (node.nodeType === 3) {
599
+ if (flat(node.textContent))
600
+ previous = null;
601
+ continue;
602
+ }
603
+ const element = node;
604
+ if (element.matches("video, audio, iframe, object, embed, hr"))
605
+ previous = null;
606
+ if (element.tagName !== "IMG")
607
+ continue;
608
+ const src = flat(element.getAttribute("data-src") || element.getAttribute("data-lazy-src") ||
609
+ element.getAttribute("data-original") || element.getAttribute("src"));
610
+ if (!src || /^(data|blob):/i.test(src)) {
611
+ previous = null;
612
+ continue;
613
+ }
614
+ const picture = element.closest("picture");
615
+ const key = JSON.stringify([src, element.getAttribute("srcset"), element.getAttribute("data-srcset"),
616
+ element.getAttribute("alt"), element.getAttribute("title"), element.closest("a")?.getAttribute("href"),
617
+ [...(picture?.querySelectorAll("source") ?? [])].map(source => [source.getAttribute("srcset"), source.getAttribute("media"), source.getAttribute("type")])]);
618
+ if (previous === key)
619
+ duplicates.push(picture?.querySelectorAll("img").length === 1 ? picture : element);
620
+ else
621
+ previous = key;
622
+ }
623
+ for (const duplicate of duplicates) {
624
+ const parent = duplicate.parentNode;
625
+ duplicate.remove();
626
+ pruneEmpty(parent);
627
+ stats["duplicate-image"] = (stats["duplicate-image"] ?? 0) + 1;
628
+ }
629
+ return duplicates.length > 0;
630
+ }
542
631
  const CREDIT_PATTERNS = [
543
632
  /^(©|\(c\)\s|copyright\b)/i,
544
633
  /^[([]?(Bild|Foto|Fotos|Bildquelle|Bildrechte|Bildnachweis|Titelbild|Beitragsbild|Photo|Image|Credits?|Grafik|Illustration)[)\]]?\s*[:/|]/i,
@@ -589,7 +678,9 @@ const COMMENT_PATTERNS = [
589
678
  /\b(let us know|tell us) .{0,40}in the comments\b/i,
590
679
  /\bNur angemeldete Benutzer können kommentieren\b/i,
591
680
  ];
592
- const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|Sponsored( content| post)?|Paid (content|post)|Gesponsert|Promoted)\s*:?$/i;
681
+ const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|Sponsored( content| post)?|Paid (content|post)|Gesponsert|Promoted|広告|広告掲載|スポンサー(?:記事|コンテンツ)?|宣伝|PR)\s*[::]?$/i;
682
+ const AD_SLOT_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Ads?|広告|広告掲載|宣伝)\s*[::]?$/i;
683
+ const PRODUCT_LABEL = /^(Produkthinweis|Produktempfehlung|Product (notice|recommendation)|商品紹介|商品案内|製品紹介)\s*[::]?$/i;
593
684
  const COMMENT_RULES_HEADING = /^(Regeln für Kommentare|Kommentarregeln|Kommentar-Richtlinien|Community[- ]Richtlinien|(Comment(ing)?|Community|Discussion|House) (rules|guidelines|policy|policies)|Rules for comments|Commenting rules)\s*:?$/i;
594
685
  const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
595
686
  const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
@@ -0,0 +1,32 @@
1
+ import { JSDOM } from "jsdom";
2
+ import { decodeEntities } from "../feeds/text.js";
3
+ const PROMPTS = new Set([
4
+ "mehr", "weiterlesen", "mehr lesen", "read more", "more", "continue reading",
5
+ "video", "zum video", "video ansehen", "video abspielen", "watch video", "watch the video", "play video",
6
+ "audio", "zum audio", "audio anhören", "listen", "listen now", "listen to audio",
7
+ "続きを読む", "もっと読む", "動画を見る", "動画を再生", "音声を聞く",
8
+ ]);
9
+ function normalize(text) {
10
+ return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
11
+ }
12
+ /** No length threshold: a short news sentence is useful, a media player or read-more link alone is not. */
13
+ export function hasEditionText(item) {
14
+ const title = normalize(decodeEntities(item.title) ?? "");
15
+ return [item.content_snippet, item.content_html, item.full_content_html].some(source => {
16
+ if (!source)
17
+ return false;
18
+ const fragment = JSDOM.fragment(source);
19
+ fragment.querySelectorAll("script, style, noscript, nav, footer, video, audio, iframe, object, embed").forEach(node => node.remove());
20
+ fragment.querySelectorAll("a, p, span, h1, h2, h3, h4").forEach(node => {
21
+ const text = normalize(node.textContent ?? "");
22
+ if (text === title || PROMPTS.has(text))
23
+ node.remove();
24
+ });
25
+ fragment.querySelectorAll("p, div, br, li, h1, h2, h3, h4, blockquote, figcaption").forEach(node => node.append("\n"));
26
+ return (fragment.textContent ?? "").split(/\n/).some(line => {
27
+ const text = normalize(line);
28
+ return text !== "" && text !== title && !PROMPTS.has(text);
29
+ });
30
+ });
31
+ }
32
+ //# sourceMappingURL=editionContent.js.map
@@ -7,6 +7,7 @@ import { editionPeriod, selectEdition } from "./editionSelection.js";
7
7
  import { getNativePreferences } from "./preferences.js";
8
8
  import { checkEditionLayout, editionLayoutSchema } from "./editionLayout.js";
9
9
  import { NativeError, nativeRequest } from "./requests.js";
10
+ import { hasEditionText } from "./editionContent.js";
10
11
  export const overviewSchema = z.array(z.object({
11
12
  heading: z.string().trim().min(1).max(160),
12
13
  segments: z.array(z.object({
@@ -109,10 +110,11 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
109
110
  if (!canAccessItem(userId, id))
110
111
  throw new NativeError("item_not_found", 404);
111
112
  const item = db.prepare(`SELECT s.folder_id, s.id IS NOT NULL AS subscribed, i.is_empty_gallery,
113
+ i.title, i.content_snippet, i.content_html, i.full_content_html,
112
114
  EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
113
115
  (instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0)) AS muted
114
116
  FROM items i LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ? WHERE i.id = ?`).get(userId, userId, id);
115
- if (item.is_empty_gallery || item.muted || (item.subscribed && (item.folder_id === null ? !prefs.showUnfiled : prefs.hiddenFolderIds.includes(item.folder_id)))) {
117
+ if (item.is_empty_gallery || !hasEditionText(item) || item.muted || (item.subscribed && (item.folder_id === null ? !prefs.showUnfiled : prefs.hiddenFolderIds.includes(item.folder_id)))) {
116
118
  throw new NativeError("item_excluded");
117
119
  }
118
120
  }
@@ -215,7 +217,7 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
215
217
  const previous = new Set(previousItemIds);
216
218
  // Fetch bounded candidates per source, so a busy feed cannot hide the others.
217
219
  const rows = db.prepare(`SELECT r.id, r.feed_id, r.folder_id, r.title, r.link, r.content_snippet, r.image_url,
218
- r.source_name, r.folder_name, r.published, r.source_rank,
220
+ r.source_name, r.folder_name, r.published, r.source_rank, full_item.content_html, full_item.full_content_html,
219
221
  length(coalesce(full_item.full_content_html, full_item.content_html, r.content_snippet, '')) AS content_chars
220
222
  FROM (
221
223
  SELECT i.id, i.feed_id, s.folder_id, i.title, i.link, i.content_snippet, i.image_url,
@@ -233,7 +235,7 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
233
235
  JOIN items full_item ON full_item.id = r.id
234
236
  WHERE r.source_rank <= 40 ORDER BY r.source_rank, r.published DESC, r.id DESC LIMIT 4000`)
235
237
  .all(userId, userId, userId, new Date(now.getTime() - 7 * 86_400_000).toISOString(), now.toISOString());
236
- const scored = rows.filter((row) => row.folder_id === null ? prefs.showUnfiled : !prefs.hiddenFolderIds.includes(row.folder_id))
238
+ const scored = rows.filter((row) => (row.folder_id === null ? prefs.showUnfiled : !prefs.hiddenFolderIds.includes(row.folder_id)) && hasEditionText(row))
237
239
  .map((row) => ({
238
240
  id: row.id, sourceName: row.source_name, folderName: row.folder_name, subscriptionId: row.feed_id, folderId: row.folder_id,
239
241
  title: row.title, url: row.link, publishedAt: row.published, snippet: decodeEntities(row.content_snippet) ?? null, imageUrl: row.image_url,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.28.0",
3
+ "version": "0.28.2",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }