feedkeeper 0.35.2 → 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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/server/dist/api/items.js +3 -3
  3. package/server/dist/api/v1/serialize.js +1 -1
  4. package/server/dist/feeds/articleContent.js +3 -3
  5. package/server/dist/feeds/content/index.js +2 -1
  6. package/server/dist/feeds/content/noise/lazyImages.js +22 -0
  7. package/server/dist/feeds/content/noise/videoPages.js +67 -0
  8. package/server/dist/feeds/content/patterns/boilerplate/index.js +3 -2
  9. package/server/dist/feeds/content/patterns/boilerplate/ja.js +19 -0
  10. package/server/dist/feeds/content/patterns/consent/index.js +2 -1
  11. package/server/dist/feeds/content/patterns/consent/ja.js +14 -0
  12. package/server/dist/feeds/content/patterns/loading/index.js +2 -1
  13. package/server/dist/feeds/content/patterns/loading/ja.js +8 -0
  14. package/server/dist/feeds/content/patterns/play-labels/index.js +2 -1
  15. package/server/dist/feeds/content/patterns/play-labels/ja.js +3 -0
  16. package/server/dist/feeds/content/patterns/sidebars/index.js +2 -1
  17. package/server/dist/feeds/content/patterns/sidebars/ja.js +7 -0
  18. package/server/dist/feeds/content/patterns/snippet-metadata/de.js +3 -0
  19. package/server/dist/feeds/content/patterns/snippet-metadata/en.js +3 -0
  20. package/server/dist/feeds/content/patterns/snippet-metadata/index.js +10 -0
  21. package/server/dist/feeds/content/patterns/snippet-metadata/ja.js +3 -0
  22. package/server/dist/feeds/content/patterns/video-pages/de.js +3 -0
  23. package/server/dist/feeds/content/patterns/video-pages/en.js +3 -0
  24. package/server/dist/feeds/content/patterns/video-pages/ja.js +3 -0
  25. package/server/dist/feeds/contentNoise.js +68 -9
  26. package/server/dist/feeds/extractor.js +2 -1
  27. package/server/dist/mcp/items.js +1 -1
  28. package/server/dist/mcp/server.js +7 -3
  29. package/server/package.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.35.2",
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
@@ -1,6 +1,7 @@
1
1
  import * as de from "./de.js";
2
2
  import * as en from "./en.js";
3
+ import * as ja from "./ja.js";
3
4
  /** One file per language, searched all at once. To add a language, add a file next to these and list it here. */
4
- export const BOILERPLATE_ANYWHERE = [de.ANYWHERE, en.ANYWHERE].flat();
5
- export const BOILERPLATE_TRAILING = [de.TRAILING, en.TRAILING].flat();
5
+ export const BOILERPLATE_ANYWHERE = [de.ANYWHERE, en.ANYWHERE, ja.ANYWHERE].flat();
6
+ export const BOILERPLATE_TRAILING = [de.TRAILING, en.TRAILING, ja.TRAILING].flat();
6
7
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Blocks, in Japanese, that publishers put into their text without being part of the story. A block counts only when its whole text is one
3
+ * of these lines (see `noise/boilerplate.ts`), so a sentence of the story that contains the words stays.
4
+ */
5
+ export const ANYWHERE = [
6
+ // Calls to follow the site on Google or elsewhere.
7
+ /^(?:Google(?: ニュース)?でフォロー(?:する)?|Google ニュースで[^.!?]{2,40}をフォロー)$/i,
8
+ // The text of download buttons whose picture is gone.
9
+ /^(?:App Storeからダウンロード|Google Playで手に入れよう|Google Playで入手|App Storeでダウンロード)$/i,
10
+ // A picture's filename shown as text.
11
+ /^[\w\-. ]{3,120}\.(?:jpe?g|png|webp|gif|avif)$/i,
12
+ ];
13
+ /** Footers at the end of the text; they count only in the second half. */
14
+ export const TRAILING = [
15
+ /^(?:この記事|本記事|本稿).{3,120}(?:で初出|に初出|で公開|に掲載|転載).*$/i,
16
+ /^(?:この記事について(?:話し合う|語る|コメント)|コメントする)\b.{0,40}$/i,
17
+ /^(?:関連記事|トピック|タグ|関連タグ):.{0,200}$/i,
18
+ ];
19
+ //# sourceMappingURL=ja.js.map
@@ -1,10 +1,11 @@
1
1
  import de from "./de.js";
2
2
  import en from "./en.js";
3
+ import ja from "./ja.js";
3
4
  /**
4
5
  * One file per language. A notice is looked for in every language at once, because an article in one language can
5
6
  * sit on a site in another. To add a language, add a file next to these and list it here.
6
7
  */
7
- const LANGUAGES = [de, en];
8
+ const LANGUAGES = [de, en, ja];
8
9
  export const CONSENT_ANCHORS = LANGUAGES.flatMap(language => language.anchors);
9
10
  export const CONSENT_COMPANIONS = LANGUAGES.flatMap(language => language.companions);
10
11
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,14 @@
1
+ /** Consent notices that sit where an embed would be, in Japanese. See `de.ts` for what the two lists mean. */
2
+ export default {
3
+ anchors: [
4
+ /^(?:外部コンテンツ|おすすめの外部コンテンツ|埋め込みコンテンツ)$/i,
5
+ /^(?:YouTube|Vimeo|Instagram|X|Twitter|TikTok|Spotify|Facebook)のコンテンツ$/i,
6
+ /^(?:外部コンテンツを表示|コンテンツを表示|外部コンテンツを読み込む)$/i,
7
+ ],
8
+ companions: [
9
+ /^(?:外部コンテンツの読み込みに同意する|同意して表示|コンテンツを表示する)$/i,
10
+ /個人データが(?:外部|サードパーティ)/i,
11
+ /プライバシーポリシー/i,
12
+ ],
13
+ };
14
+ //# sourceMappingURL=ja.js.map
@@ -1,5 +1,6 @@
1
1
  import de from "./de.js";
2
2
  import en from "./en.js";
3
+ import ja from "./ja.js";
3
4
  /** One file per language, searched all at once. To add a language, add a file next to these and list it here. */
4
- export const LOADING_LABELS = [de, en].flat();
5
+ export const LOADING_LABELS = [de, en, ja].flat();
5
6
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Whole-line placeholders, in Japanese, that a script replaces with content. A reader that does not run the script
3
+ * keeps them as text. Only a block that holds nothing else counts.
4
+ */
5
+ export default [
6
+ /^(?:読み込み中|ロード中|少々お待ちください)(?:\s*(?:\.{3}|…))?$/i,
7
+ ];
8
+ //# sourceMappingURL=ja.js.map
@@ -1,5 +1,6 @@
1
1
  import de from "./de.js";
2
2
  import en from "./en.js";
3
+ import ja from "./ja.js";
3
4
  /** One file per language, searched all at once. To add a language, add a file next to these and list it here. */
4
- export const PLAY_LABEL = new RegExp(`^(?:${[de, en].flat().map(label => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})$`, "i");
5
+ export const PLAY_LABEL = new RegExp(`^(?:${[de, en, ja].flat().map(label => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})$`, "i");
5
6
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ /** Labels, in Japanese, that a player's play-button picture carries as its alt text. See `en.ts`. */
2
+ export default ["動画を再生", "動画を再生する", "動画を見る", "再生", "再生する", "動画再生"];
3
+ //# sourceMappingURL=ja.js.map
@@ -1,6 +1,7 @@
1
1
  import de from "./de.js";
2
2
  import en from "./en.js";
3
+ import ja from "./ja.js";
3
4
  /** One file per language, searched all at once. To add a language, add a file next to these and list it here. */
4
- export const SIDEBAR_HEADINGS = [de, en].flat();
5
+ export const SIDEBAR_HEADINGS = [de, en, ja].flat();
5
6
  export { SIDEBAR_SELECTORS } from "./selectors.js";
6
7
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ /** Headings, in Japanese, that start a list of other articles. See `en.ts` for how they are used. */
2
+ export default [
3
+ /^(?:関連記事|おすすめ記事|オススメ記事|あわせて読みたい|人気の記事|人気記事|アクセスランキング|注目の記事|こちらもおすすめ|最新記事|新着記事|関連リンク)$/i,
4
+ // A site's welcome with logo and links at the end of the text.
5
+ /^.+へようこそ!?$/i,
6
+ ];
7
+ //# sourceMappingURL=ja.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,6 +1,12 @@
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";
6
+ import { SIDEBAR_HEADINGS } from "./content/patterns/sidebars/index.js";
3
7
  import { resolveEmbeds } from "./content/embeds/index.js";
8
+ import { loadingPlaceholders } from "./content/noise/loading.js";
9
+ import { trackingPixels } from "./content/noise/pixels.js";
4
10
  import { teaserLinks } from "./content/noise/teaserLinks.js";
5
11
  import { markVideoTeasers } from "./content/noise/videoTeasers.js";
6
12
  import { flat, pruneEmpty } from "./content/shared.js";
@@ -76,6 +82,7 @@ function isStructuredData(text) {
76
82
  }
77
83
  function removeReaderNoise(root, title, stats, author) {
78
84
  let changed = false;
85
+ const total = (root.textContent ?? "").length;
79
86
  const drop = (element, rule) => {
80
87
  if (!element.parentNode)
81
88
  return;
@@ -88,16 +95,25 @@ function removeReaderNoise(root, title, stats, author) {
88
95
  // A feed's own text ends with "[mehr]": the link to the article is no part of what is read.
89
96
  if (teaserLinks.apply(root, stats))
90
97
  changed = true;
98
+ if (loadingPlaceholders.apply(root, stats))
99
+ changed = true;
100
+ if (trackingPixels.apply(root, stats))
101
+ changed = true;
91
102
  for (const selector of BREADCRUMB_MARKERS) {
92
103
  try {
93
104
  root.querySelectorAll(selector).forEach(element => drop(element, "breadcrumb"));
94
105
  }
95
106
  catch { /* selector unsupported by the DOM */ }
96
107
  }
97
- // Breadcrumbs without markup hints: a short list near the start whose items are single short links.
108
+ // Short list of single links near the start (breadcrumbs) or in the tail (topic/tag chips).
98
109
  for (const list of root.querySelectorAll("ol, ul")) {
99
110
  const items = [...list.children].filter(child => child.tagName === "LI");
100
- if (items.length < 2 || items.length > 8 || charsBefore(root, list) > 300)
111
+ if (items.length < 2 || items.length > 8)
112
+ continue;
113
+ const before = charsBefore(root, list);
114
+ const isHead = before <= 300;
115
+ const isTail = (total - before) <= TAIL_CHARACTERS;
116
+ if (!isHead && !isTail)
101
117
  continue;
102
118
  const onlyLinks = items.every(item => {
103
119
  const links = item.querySelectorAll("a");
@@ -105,7 +121,7 @@ function removeReaderNoise(root, title, stats, author) {
105
121
  return links.length === 1 && label.length > 0 && label.length <= 30 && flat(links[0].textContent) === label;
106
122
  });
107
123
  if (onlyLinks)
108
- drop(list, "breadcrumb");
124
+ drop(list, isHead ? "breadcrumb" : "topic-list");
109
125
  }
110
126
  // A product card of a shop link: its picture is a CSS background, which no reader draws, and its price is a snapshot
111
127
  // that is out of date when the article is read later. What is left would be a label, a link and a number.
@@ -173,7 +189,7 @@ function removeReaderNoise(root, title, stats, author) {
173
189
  let pastStart = false;
174
190
  for (const block of leafBlocks(root)) {
175
191
  const text = flat(block.textContent);
176
- if (!text)
192
+ if (!text || block.querySelector("a[data-media]"))
177
193
  continue;
178
194
  const position = leading++;
179
195
  const heading = /^H[1-3]$/.test(block.tagName);
@@ -232,7 +248,7 @@ function removeReaderNoise(root, title, stats, author) {
232
248
  }
233
249
  if (!pastStart && text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
234
250
  const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
235
- 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)) {
236
252
  drop(block, "dateline");
237
253
  continue;
238
254
  }
@@ -260,7 +276,6 @@ function removeReaderNoise(root, title, stats, author) {
260
276
  }
261
277
  }
262
278
  // A comment section's house rules at the end of the text: no reader has comments, so heading and what follows go.
263
- const total = (root.textContent ?? "").length;
264
279
  for (const block of leafBlocks(root)) {
265
280
  if (!block.parentNode || !COMMENT_RULES_HEADING.test(flat(block.textContent)))
266
281
  continue;
@@ -275,6 +290,36 @@ function removeReaderNoise(root, title, stats, author) {
275
290
  drop(block, "comment-rules");
276
291
  break;
277
292
  }
293
+ // A consent wall, tracking choice or publisher welcome at the end: heading and everything below go.
294
+ for (const block of leafBlocks(root)) {
295
+ if (!block.parentNode || !CONSENT_WALL_HEADING.test(flat(block.textContent)))
296
+ continue;
297
+ if (total - charsBefore(root, block) > TAIL_CHARACTERS)
298
+ continue;
299
+ let node = block;
300
+ while (node.parentNode && node !== root) {
301
+ while (node.nextSibling)
302
+ node.nextSibling.remove();
303
+ node = node.parentNode;
304
+ }
305
+ drop(block, "consent-wall");
306
+ break;
307
+ }
308
+ // An orphan recommendation heading left without subsequent links or content.
309
+ for (const block of leafBlocks(root)) {
310
+ if (!block.parentNode)
311
+ continue;
312
+ const text = flat(block.textContent);
313
+ if (!SIDEBAR_HEADINGS.some(pattern => pattern.test(text)))
314
+ continue;
315
+ const parent = block.parentElement;
316
+ if (parent && !parent.querySelector("a[href], img, picture, video, iframe") && flat(parent.textContent) === text) {
317
+ drop(parent, "empty-section-label");
318
+ }
319
+ else if (!block.querySelector("a[href], img, picture, video, iframe") && block.nextElementSibling === null) {
320
+ drop(block, "empty-section-label");
321
+ }
322
+ }
278
323
  // Empty nested slots are structural evidence: the next story paragraph is outside this ad container.
279
324
  // Do not infer an empty ad from a standalone heading followed by text (which may itself be sponsored).
280
325
  for (const container of [...root.querySelectorAll("div, aside, section, p")]) {
@@ -410,17 +455,19 @@ function serialize(root) {
410
455
  return holder.innerHTML;
411
456
  }
412
457
  /** The article HTML as a reader should show it. Unchanged input is returned as is. */
413
- export function cleanReaderHtml(html, title, stats = {}, author) {
458
+ export function cleanReaderHtml(html, title, stats = {}, author, url) {
414
459
  if (!html)
415
460
  return html;
416
461
  const root = JSDOM.fragment(html);
462
+ const videoPage = cleanVideoPage(root, url, title, stats);
463
+ const lazy = lazyImages.apply(root, stats);
417
464
  const images = resolveCustomImages(root, stats);
418
465
  const duplicates = collapseAdjacentImages(root, stats);
419
466
  const variants = collapseImageVariants(root, stats);
420
467
  const embeds = resolveEmbeds(root, stats);
421
468
  const noise = removeReaderNoise(root, title, stats, author);
422
469
  const typography = removeTypography(root, stats);
423
- return images || duplicates || variants || embeds || noise || typography ? serialize(root) : html;
470
+ return videoPage || lazy || images || duplicates || variants || embeds || noise || typography ? serialize(root) : html;
424
471
  }
425
472
  /**
426
473
  * Some publishers wrap a picture in their own element and let a script fill the image: `<a-img src="/imgs/…">`
@@ -591,6 +638,7 @@ const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|S
591
638
  const AD_SLOT_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Ads?|広告|広告掲載|宣伝)\s*[::]?$/i;
592
639
  const PRODUCT_LABEL = /^(Produkthinweis|Produktempfehlung|Product (notice|recommendation)|商品紹介|商品案内|製品紹介)\s*[::]?$/i;
593
640
  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;
641
+ const CONSENT_WALL_HEADING = /^(?:Weiter mit Werbung|Mit Werbung weitersurfen|Weiterlesen mit Werbung|Zustimmen und weiter(?:lesen)?|Zustimmung erforderlich|Willkommen bei [^.!?]{2,40}!?|Einwilligung zu (?:Cookies|Tracking|Datenverarbeitung)|Cookie[- ]Einstellungen|Datenschutzoptionen|Continue with (?:ads|advertising|personalized advertising)|(?:Accept|Agree) and continue|Welcome to [^.!?]{2,40}!?|Cookie (?:and|&) (?:privacy|tracking) settings|Privacy (?:and|&) cookie policy|広告(?:付き|に同意して)で?続ける|同意して(?:続ける|進む)|.+へようこそ!?|Cookie(?:と|および)プライバシーの?設定)\s*:?$/i;
594
642
  const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
595
643
  const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
596
644
  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;
@@ -737,12 +785,23 @@ export function cleanSnippet(snippet, title) {
737
785
  for (const pattern of SNIPPET_INLINE_PATTERNS)
738
786
  text = text.replace(pattern, "");
739
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");
740
799
  const lines = text.split("\n").map(line => line.trim()).filter((line, index) => line && !SNIPPET_LINE_PATTERNS.some(pattern => pattern.test(line)) && !isSocialList(line) &&
741
800
  !(index === 0 && headline && normalized(line) === headline));
742
801
  return lines.join("\n").trim();
743
802
  }
744
803
  /** Raised whenever the rules above change what they remove, so stored leads are derived again. */
745
- export const LEAD_VERSION = 2;
804
+ export const LEAD_VERSION = 3;
746
805
  const LEAD_LENGTH = 600;
747
806
  function shorten(text, max) {
748
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.2",
3
+ "version": "0.35.4",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }