feedkeeper 0.28.1 → 0.28.3

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.3",
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))) {
@@ -11,7 +11,7 @@ const FEED_MIME_TYPES = [
11
11
  "application/xml",
12
12
  ];
13
13
  const MAX_PROBES = 15;
14
- const COMMON_PATHS = ["/feed", "/rss.xml", "/atom.xml", "/feed.xml", "/index.xml", "/rss", "/rss.php?feed=RSS2.0", "/rss.php?feed=ATOM1.0"];
14
+ const COMMON_PATHS = ["/feed/", "/feed", "/rss.xml", "/atom.xml", "/feed.xml", "/index.xml", "/rss/", "/rss", "/rss.php?feed=RSS2.0", "/rss.php?feed=ATOM1.0"];
15
15
  // Feed-looking URLs anywhere in a page, including JSON embedded in attributes.
16
16
  const FEED_URL_IN_TEXT = /https?:\/\/[^\s"'<>\\]+?(?:\.rss|\.atom|\/feed\/?|\/(?:rss|atom|feed)\.xml)(?=["'\s<>\\]|$)/gi;
17
17
  function sameUrl(a, b) {
@@ -28,18 +28,23 @@ function sameUrl(a, b) {
28
28
  return normalize(a) === normalize(b);
29
29
  }
30
30
  async function parseFeedAt(url) {
31
- const fetched = await fetchFeed(url);
32
- const finalUrl = fetched.finalUrl || url;
33
31
  try {
34
- const parsed = await rssParser.parseString(fetched.body);
35
- if (parsed.items && (parsed.title || parsed.description || parsed.items.length > 0)) {
36
- return { feed: { url: finalUrl, title: plainTitle(parsed.title) ?? null, type: "application/rss+xml" }, html: null, finalUrl };
32
+ const fetched = await fetchFeed(url);
33
+ const finalUrl = fetched.finalUrl || url;
34
+ try {
35
+ const parsed = await rssParser.parseString(fetched.body);
36
+ if (parsed.items && (parsed.title || parsed.description || parsed.items.length > 0)) {
37
+ return { feed: { url: finalUrl, title: plainTitle(parsed.title) ?? null, type: "application/rss+xml" }, html: null, finalUrl };
38
+ }
39
+ }
40
+ catch {
41
+ // Not a feed; the caller may look for feed links in the HTML instead.
37
42
  }
43
+ return { feed: null, html: fetched.body, finalUrl };
38
44
  }
39
45
  catch {
40
- // Not a feed; the caller may look for feed links in the HTML instead.
46
+ return { feed: null, html: null, finalUrl: url };
41
47
  }
42
- return { feed: null, html: fetched.body, finalUrl };
43
48
  }
44
49
  /**
45
50
  * Keep only candidates that really are feeds. Sites sometimes announce their own
@@ -75,22 +80,29 @@ export async function discoverFeeds(inputUrl) {
75
80
  const page = await parseFeedAt(targetUrl);
76
81
  if (page.feed)
77
82
  return [page.feed];
78
- if (!page.html)
79
- return [];
83
+ // 1b. If the URL did not end in a slash, try with a trailing slash (e.g. /feed -> /feed/)
84
+ if (!targetUrl.endsWith("/")) {
85
+ const slashCandidate = await parseFeedAt(targetUrl + "/");
86
+ if (slashCandidate.feed)
87
+ return [slashCandidate.feed];
88
+ }
80
89
  // 2. Feeds announced or linked on the page.
81
- const fromPage = await keepRealFeeds(extractFeedsFromHtml(page.html, page.finalUrl), page.finalUrl);
82
- if (fromPage.length > 0)
83
- return fromPage;
90
+ if (page.html) {
91
+ const fromPage = await keepRealFeeds(extractFeedsFromHtml(page.html, page.finalUrl), page.finalUrl);
92
+ if (fromPage.length > 0)
93
+ return fromPage;
94
+ }
84
95
  // 3. Common feed paths; some lead to an overview page that lists the site's feeds.
96
+ const probeBase = page.finalUrl || targetUrl;
85
97
  for (const path of COMMON_PATHS) {
86
98
  try {
87
- const candidateUrl = new URL(path, page.finalUrl).toString();
99
+ const candidateUrl = new URL(path, probeBase).toString();
88
100
  if (sameUrl(candidateUrl, targetUrl))
89
101
  continue;
90
102
  const candidate = await parseFeedAt(candidateUrl);
91
103
  if (candidate.feed)
92
104
  return [candidate.feed];
93
- if (candidate.html && !sameUrl(candidate.finalUrl, page.finalUrl)) {
105
+ if (candidate.html && !sameUrl(candidate.finalUrl, probeBase)) {
94
106
  const listed = await keepRealFeeds(extractFeedsFromHtml(candidate.html, candidate.finalUrl), candidate.finalUrl);
95
107
  if (listed.length > 0)
96
108
  return listed;
@@ -64,7 +64,10 @@ export async function subscribeToFeed(userId, inputUrl, label = null, folderId =
64
64
  // (e.g. a homepage that announces itself as RSS); don't keep the subscription.
65
65
  if (pollResult.error && !pollResult.deferred && !findFeedById(feed.id)?.last_success_at) {
66
66
  unsubscribeRepo(userId, feed.id);
67
- throw new NoFeedsFoundError();
67
+ if (pollResult.error === "feed_invalid_or_unavailable") {
68
+ throw new NoFeedsFoundError();
69
+ }
70
+ throw new FeedError(pollResult.error);
68
71
  }
69
72
  const subscription = findSubscriptionForUser(userId, feed.id);
70
73
  if (!subscription)
@@ -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.3",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }