feedkeeper 0.28.1 → 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.1",
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",
@@ -143,6 +143,32 @@ function removeReaderNoise(root, title, stats, author) {
143
143
  const headline = normalized(title ?? "");
144
144
  const seen = new Set();
145
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
+ }
146
172
  let leading = 0;
147
173
  let reachedText = false;
148
174
  let pastStart = false;
@@ -183,14 +209,9 @@ function removeReaderNoise(root, title, stats, author) {
183
209
  continue;
184
210
  }
185
211
  // When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
186
- if (BYLINE.test(text) && authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
187
- drop(block, "byline");
212
+ cleanByline(block, text);
213
+ if (!root.contains(block))
188
214
  continue;
189
- }
190
- if (BYLINE.test(text) && stripBylineDate(block)) {
191
- stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
192
- changed = true;
193
- }
194
215
  }
195
216
  }
196
217
  if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
@@ -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.1",
3
+ "version": "0.28.2",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }