feedkeeper 0.35.3 → 0.35.4

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.35.3",
3
+ "version": "0.35.4",
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",
@@ -49,8 +49,8 @@ itemsRouter.get("/", (req, res) => {
49
49
  res.json(items.map(({ content_lead: _lead, lead_version: _leadVersion, ...item }) => ({
50
50
  ...item,
51
51
  content_snippet: cleanSnippet(item.content_snippet, item.title),
52
- content_html: readerHtml(item.content_html, item.title, item.author),
53
- full_content_html: readerHtml(item.full_content_html, item.title, item.author),
52
+ content_html: readerHtml(item.content_html, item.title, item.author, item.link),
53
+ full_content_html: readerHtml(item.full_content_html, item.title, item.author, item.link),
54
54
  read: Boolean(item.read),
55
55
  bookmarked: Boolean(item.bookmarked),
56
56
  })));
@@ -127,7 +127,7 @@ itemsRouter.post("/:itemId/extract-content", async (req, res) => {
127
127
  const [served] = withArchivedImages([{ id: result.id, link: currentItem?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
128
128
  res.json({
129
129
  id: result.id,
130
- full_content_html: readerHtml(served.full_content_html, result.title ?? currentItem?.title, currentItem?.author),
130
+ full_content_html: readerHtml(served.full_content_html, result.title ?? currentItem?.title, currentItem?.author, currentItem?.link),
131
131
  ...(result.cached ? {} : { title: result.title, byline: result.byline }),
132
132
  cached: result.cached,
133
133
  });
@@ -38,7 +38,7 @@ export function serializeItem(row, progress, withContent, hasNote = false) {
38
38
  imageUrl: row.image_url,
39
39
  hasFullText: Boolean(row.full_content_html),
40
40
  content: contentMetadata(row),
41
- ...(withContent ? { contentHtml: readerHtml(row.content_html, row.title, row.author), fullTextHtml: readerHtml(row.full_content_html, row.title, row.author) } : {}),
41
+ ...(withContent ? { contentHtml: readerHtml(row.content_html, row.title, row.author, row.link), fullTextHtml: readerHtml(row.full_content_html, row.title, row.author, row.link) } : {}),
42
42
  state: {
43
43
  read: Boolean(row.read),
44
44
  saved: Boolean(row.bookmarked),
@@ -21,14 +21,14 @@ const readerCache = new Map();
21
21
  * Article HTML as a reader shows it, without breadcrumbs, repeated headline and similar page chrome. Computed when
22
22
  * served and never stored, so the publisher's original stays intact and rule changes apply to old articles too.
23
23
  */
24
- export function readerHtml(html, title, author) {
24
+ export function readerHtml(html, title, author, url) {
25
25
  if (!html)
26
26
  return html ?? null;
27
- const key = createHash("sha1").update(title ?? "").update("\0").update(author ?? "").update("\0").update(html).digest("base64");
27
+ const key = createHash("sha1").update(title ?? "").update("\0").update(author ?? "").update("\0").update(url ?? "").update("\0").update(html).digest("base64");
28
28
  const cached = readerCache.get(key);
29
29
  if (cached !== undefined)
30
30
  return cached;
31
- const cleaned = cleanReaderHtml(html, title, {}, author);
31
+ const cleaned = cleanReaderHtml(html, title, {}, author, url);
32
32
  if (readerCache.size >= READER_CACHE_LIMIT)
33
33
  readerCache.delete(readerCache.keys().next().value);
34
34
  readerCache.set(key, cleaned);
@@ -1,3 +1,4 @@
1
+ import { lazyImages } from "./noise/lazyImages.js";
1
2
  import { EMBED_RULES, resolveEmbeds } from "./embeds/index.js";
2
3
  import { embedHeadings } from "./embeds/headings.js";
3
4
  import { boilerplate } from "./noise/boilerplate.js";
@@ -10,7 +11,7 @@ import { teaserLinks } from "./noise/teaserLinks.js";
10
11
  * tags that are removed next), then the clutter around the story. The tests check each rule against its fixtures in
11
12
  * `test/fixtures/content/<rule name>` and that running it twice changes nothing. See `docs/content-rules.md`.
12
13
  */
13
- export const PAGE_RULES = [...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks];
14
+ export const PAGE_RULES = [lazyImages, ...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks];
14
15
  export { resolveEmbeds };
15
16
  /** What a fetched page goes through before its scripts, frames and `noscript` blocks are removed. */
16
17
  export function cleanPage(root, stats = {}) {
@@ -0,0 +1,22 @@
1
+ /** A script accidentally parsed as a URL is never an image, even after it was made absolute. */
2
+ export const SCRIPT_IMAGE_SOURCE = /\bthis\.src\s*=/i;
3
+ export const lazyImages = {
4
+ name: "lazy-images",
5
+ description: "Lazy image addresses replace missing, data placeholders or script fragments parsed as image URLs.",
6
+ apply(root, stats) {
7
+ let changed = false;
8
+ for (const image of root.querySelectorAll("img")) {
9
+ const current = image.getAttribute("src") ?? "";
10
+ if (current && !/^data:/i.test(current) && !SCRIPT_IMAGE_SOURCE.test(current))
11
+ continue;
12
+ const source = image.getAttribute("data-lazy-src") || image.getAttribute("data-src") || image.getAttribute("data-original");
13
+ if (!source || !/^(?:https?:\/\/|\/)/i.test(source) || SCRIPT_IMAGE_SOURCE.test(source))
14
+ continue;
15
+ image.setAttribute("src", source);
16
+ stats["lazy-images"] = (stats["lazy-images"] ?? 0) + 1;
17
+ changed = true;
18
+ }
19
+ return changed;
20
+ },
21
+ };
22
+ //# sourceMappingURL=lazyImages.js.map
@@ -0,0 +1,67 @@
1
+ import { flat, linkParagraph } from "../shared.js";
2
+ import de from "../patterns/video-pages/de.js";
3
+ import en from "../patterns/video-pages/en.js";
4
+ import ja from "../patterns/video-pages/ja.js";
5
+ const labels = new Set([...de, ...en, ...ja].map(value => value.toLowerCase()));
6
+ const date = /\b\d{1,2}\.\d{1,2}\.\d{4}\b|\b[A-Za-z]{3,9} \d{1,2},? \d{4}\b|\d{4}年\d{1,2}月\d{1,2}日/;
7
+ /** A video landing page with a poster and metadata, but no editorial paragraphs. */
8
+ export function cleanVideoPage(root, url, title, stats) {
9
+ if (!url)
10
+ return false;
11
+ try {
12
+ const address = new URL(url);
13
+ if (!/^https?:$/.test(address.protocol) || !/(^|\/)(videos?|mediathek|watch|clips?)(\/|$|[-.])/i.test(address.pathname))
14
+ return false;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ const mains = root.querySelectorAll("main");
20
+ if (mains.length !== 1)
21
+ return false;
22
+ const main = mains[0];
23
+ const images = main.querySelectorAll("img");
24
+ if (images.length !== 1 || main.querySelector("article, blockquote, table, a[data-media]"))
25
+ return false;
26
+ for (const link of main.querySelectorAll("a[href]")) {
27
+ if (link.closest("nav, ul, ol") || link.querySelector("img"))
28
+ continue;
29
+ // A home link is navigation; a transcript or another editorial link is content and must survive.
30
+ try {
31
+ const target = new URL(link.getAttribute("href"), url);
32
+ if (target.origin !== new URL(url).origin || target.pathname !== "/" || target.search || target.hash)
33
+ return false;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ const copy = main.cloneNode(true);
40
+ copy.querySelectorAll("nav, ul, ol, form, img, picture, a, h1").forEach(element => element.remove());
41
+ const blocks = [...copy.querySelectorAll("p")].filter(element => flat(element.textContent));
42
+ if (!blocks.some(element => labels.has(flat(element.textContent).toLowerCase())))
43
+ return false;
44
+ // All remaining words must be short metadata lines containing an explicit date.
45
+ for (const block of blocks) {
46
+ const text = flat(block.textContent);
47
+ if (!labels.has(text.toLowerCase()) && (text.length > 160 || !date.test(text)))
48
+ return false;
49
+ block.remove();
50
+ }
51
+ if (flat(copy.textContent))
52
+ return false;
53
+ const document = main.ownerDocument;
54
+ const poster = document.createElement("p");
55
+ poster.append(images[0].cloneNode(true));
56
+ const card = linkParagraph(document, url, flat(title) || "Video");
57
+ card.querySelector("a").setAttribute("data-media", "video");
58
+ // Preserve attribution/broadcast metadata; omit the repeated publication timestamp and kind label.
59
+ const credits = [...main.querySelectorAll("p")].filter(element => {
60
+ const text = flat(element.textContent);
61
+ return date.test(text) && /[,、]/.test(text) && text.length <= 160 && !element.querySelector("a, img");
62
+ }).map(element => element.cloneNode(true));
63
+ root.replaceChildren(poster, card, ...credits);
64
+ stats["video-page"] = (stats["video-page"] ?? 0) + 1;
65
+ return true;
66
+ }
67
+ //# sourceMappingURL=videoPages.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole bylines with an explicit timestamp, only ahead of the first editorial line. */
2
+ export default [/^von\s+[^.!?\n]{2,60}\s+[I|·–—-]\s+\d{1,2}\.\d{1,2}\.\d{2,4}\s*[-–|·]\s*\d{1,2}:\d{2}$/iu];
3
+ //# sourceMappingURL=de.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole bylines with an explicit timestamp, only ahead of the first editorial line. */
2
+ export default [/^(?:by|written by)\s+[^.!?\n]{2,60}\s+[I|·–—-]\s+(?:[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}|\d{4}-\d{2}-\d{2})\s*[-–|·]\s*\d{1,2}:\d{2}(?:\s*[ap]m)?$/iu];
3
+ //# sourceMappingURL=en.js.map
@@ -0,0 +1,10 @@
1
+ import de from "./de.js";
2
+ import en from "./en.js";
3
+ import ja from "./ja.js";
4
+ export const SNIPPET_BYLINES = [...de, ...en, ...ja];
5
+ /** File names and copyright notices are metadata only when they stand alone before the text. */
6
+ export const SNIPPET_IMAGE_METADATA = [
7
+ /^[\p{L}\p{N}_. -]{3,120}\.(?:jpe?g|png|webp|gif|avif)$/iu,
8
+ /^(?:©|\(c\)|copyright(?:\s*©)?)\s*[^.!?\n]{1,100}$/iu,
9
+ ];
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole bylines with an explicit timestamp, only ahead of the first editorial line. */
2
+ export default [/^(?:著者|執筆|文)[::]\s*[^。!?\n]{1,40}\s+[I|·–—-]\s*\d{4}年\d{1,2}月\d{1,2}日\s*[-–|·]\s*\d{1,2}:\d{2}$/u];
3
+ //# sourceMappingURL=ja.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole metadata labels of video landing pages, never words inside prose. */
2
+ export default ["Video", "Videobeitrag"];
3
+ //# sourceMappingURL=de.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole metadata labels of video landing pages, never words inside prose. */
2
+ export default ["Video", "Video report"];
3
+ //# sourceMappingURL=en.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole metadata labels of video landing pages, never words inside prose. */
2
+ export default ["動画", "映像", "動画ニュース"];
3
+ //# sourceMappingURL=ja.js.map
@@ -1,4 +1,7 @@
1
+ import { cleanVideoPage } from "./content/noise/videoPages.js";
2
+ import { lazyImages } from "./content/noise/lazyImages.js";
1
3
  import { JSDOM } from "jsdom";
4
+ import { SNIPPET_BYLINES, SNIPPET_IMAGE_METADATA } from "./content/patterns/snippet-metadata/index.js";
2
5
  import { CONSENT_ANCHORS, CONSENT_COMPANIONS } from "./content/patterns/consent/index.js";
3
6
  import { SIDEBAR_HEADINGS } from "./content/patterns/sidebars/index.js";
4
7
  import { resolveEmbeds } from "./content/embeds/index.js";
@@ -186,7 +189,7 @@ function removeReaderNoise(root, title, stats, author) {
186
189
  let pastStart = false;
187
190
  for (const block of leafBlocks(root)) {
188
191
  const text = flat(block.textContent);
189
- if (!text)
192
+ if (!text || block.querySelector("a[data-media]"))
190
193
  continue;
191
194
  const position = leading++;
192
195
  const heading = /^H[1-3]$/.test(block.tagName);
@@ -245,7 +248,7 @@ function removeReaderNoise(root, title, stats, author) {
245
248
  }
246
249
  if (!pastStart && text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
247
250
  const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
248
- if (/^(stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
251
+ if (/^(news|ニュース|nachrichten|stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
249
252
  drop(block, "dateline");
250
253
  continue;
251
254
  }
@@ -452,17 +455,19 @@ function serialize(root) {
452
455
  return holder.innerHTML;
453
456
  }
454
457
  /** The article HTML as a reader should show it. Unchanged input is returned as is. */
455
- export function cleanReaderHtml(html, title, stats = {}, author) {
458
+ export function cleanReaderHtml(html, title, stats = {}, author, url) {
456
459
  if (!html)
457
460
  return html;
458
461
  const root = JSDOM.fragment(html);
462
+ const videoPage = cleanVideoPage(root, url, title, stats);
463
+ const lazy = lazyImages.apply(root, stats);
459
464
  const images = resolveCustomImages(root, stats);
460
465
  const duplicates = collapseAdjacentImages(root, stats);
461
466
  const variants = collapseImageVariants(root, stats);
462
467
  const embeds = resolveEmbeds(root, stats);
463
468
  const noise = removeReaderNoise(root, title, stats, author);
464
469
  const typography = removeTypography(root, stats);
465
- return images || duplicates || variants || embeds || noise || typography ? serialize(root) : html;
470
+ return videoPage || lazy || images || duplicates || variants || embeds || noise || typography ? serialize(root) : html;
466
471
  }
467
472
  /**
468
473
  * Some publishers wrap a picture in their own element and let a script fill the image: `<a-img src="/imgs/…">`
@@ -780,12 +785,23 @@ export function cleanSnippet(snippet, title) {
780
785
  for (const pattern of SNIPPET_INLINE_PATTERNS)
781
786
  text = text.replace(pattern, "");
782
787
  const headline = normalized(title ?? "");
788
+ let inMetadata = true;
789
+ text = text.split("\n").filter(line => {
790
+ const value = line.trim();
791
+ if (!value)
792
+ return false;
793
+ if (inMetadata && (matchesAny(value, SNIPPET_BYLINES) || matchesAny(value, SNIPPET_IMAGE_METADATA) ||
794
+ (headline && normalized(value) === headline)))
795
+ return false;
796
+ inMetadata = false;
797
+ return true;
798
+ }).join("\n");
783
799
  const lines = text.split("\n").map(line => line.trim()).filter((line, index) => line && !SNIPPET_LINE_PATTERNS.some(pattern => pattern.test(line)) && !isSocialList(line) &&
784
800
  !(index === 0 && headline && normalized(line) === headline));
785
801
  return lines.join("\n").trim();
786
802
  }
787
803
  /** Raised whenever the rules above change what they remove, so stored leads are derived again. */
788
- export const LEAD_VERSION = 2;
804
+ export const LEAD_VERSION = 3;
789
805
  const LEAD_LENGTH = 600;
790
806
  function shorten(text, max) {
791
807
  if (text.length <= max)
@@ -1,3 +1,4 @@
1
+ import { SCRIPT_IMAGE_SOURCE } from "./content/noise/lazyImages.js";
1
2
  import { Readability } from "@mozilla/readability";
2
3
  import { JSDOM } from "jsdom";
3
4
  import { fetch as undiciFetch } from "undici";
@@ -44,7 +45,7 @@ function resolveLazyImages(doc) {
44
45
  img.getAttribute("data-original") ||
45
46
  img.getAttribute("data-actualsrc");
46
47
  const src = img.getAttribute("src");
47
- if (lazySrc && (!src || src.startsWith("data:") || src.length < 10)) {
48
+ if (lazySrc && (!src || src.startsWith("data:") || src.length < 10 || SCRIPT_IMAGE_SOURCE.test(src))) {
48
49
  img.setAttribute("src", lazySrc);
49
50
  }
50
51
  const lazySrcset = img.getAttribute("data-lazy-srcset") || img.getAttribute("data-srcset");
@@ -51,7 +51,7 @@ export function getItemForMcp(userId, itemId, maxChars = MAX_CONTENT_LENGTH, off
51
51
  return null;
52
52
  if (revision !== undefined && revision !== item.content_revision)
53
53
  throw new Error("content_revision_conflict");
54
- const content = readerHtml(item.full_content_html ?? item.content_html, item.title, item.author);
54
+ const content = readerHtml(item.full_content_html ?? item.content_html, item.title, item.author, item.link);
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]))
@@ -42,7 +42,11 @@ function selectedItemIds({ itemId, itemIds }) {
42
42
  // instance is created per request (see mcp/http.ts) so tool handlers can
43
43
  // safely close over `userId` without leaking data between users.
44
44
  export function createMcpServerForUser(userId, scope) {
45
- const server = new McpServer({ name: "feedkeeper", version: APP_VERSION });
45
+ const server = new McpServer({ name: "feedkeeper", version: APP_VERSION }, {
46
+ instructions: "FeedKeeper is a personal RSS reader, article archiver, and news curation service. " +
47
+ "Use it to read, search, and triage RSS/Atom feeds, fetch full article text, manage reading lists, " +
48
+ "and read or publish curated newspaper editions.",
49
+ });
46
50
  server.registerTool("list_feeds", {
47
51
  title: "List subscribed feeds",
48
52
  description: "Lists all RSS/Atom feeds the current user is subscribed to, including unread counts and polling health.",
@@ -116,8 +120,8 @@ export function createMcpServerForUser(userId, scope) {
116
120
  });
117
121
  if (scope === "write")
118
122
  server.registerTool("save_to_inbox", {
119
- title: "Save article or note to Inbox",
120
- description: "Saves a web article URL, raw HTML, text clipping, or markdown note directly into the user's Universal Inbox.",
123
+ title: "Save article or clipping to Inbox",
124
+ description: "Saves a web article URL, raw HTML, text clipping, or note directly into the user's Universal Inbox.",
121
125
  inputSchema: {
122
126
  url: mcpUrlSchema.optional().describe("Web page URL to fetch, extract, and save"),
123
127
  title: z.string().max(500).optional().describe("Optional custom title"),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.35.3",
3
+ "version": "0.35.4",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }