feedkeeper 0.35.0 → 0.35.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.35.0",
3
+ "version": "0.35.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",
@@ -31,7 +31,11 @@ async function main() {
31
31
  process.umask(0o077);
32
32
  const { db } = await import("./db/index.js");
33
33
  try {
34
- await db.backup(filePath);
34
+ await db.backup(filePath, {
35
+ progress({ remainingPages }) {
36
+ return remainingPages;
37
+ },
38
+ });
35
39
  const saved = new Database(filePath, { readonly: true, fileMustExist: true });
36
40
  try {
37
41
  assertHealthy(saved);
@@ -72,7 +76,11 @@ async function main() {
72
76
  assertHealthy(source);
73
77
  mkdirSync(dirname(databasePath), { recursive: true, mode: 0o700 });
74
78
  process.umask(0o077);
75
- await source.backup(databasePath);
79
+ await source.backup(databasePath, {
80
+ progress({ remainingPages }) {
81
+ return remainingPages;
82
+ },
83
+ });
76
84
  }
77
85
  finally {
78
86
  source.close();
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { JSDOM } from "jsdom";
3
3
  import { decodeEntities } from "./text.js";
4
+ import { isVideoTeaser } from "./content/noise/videoTeasers.js";
4
5
  import { cleanReaderHtml, cleanSnippet, derivedText } from "./contentNoise.js";
5
6
  /**
6
7
  * Plain publisher content, shared by readers, search consumers and MCP. Deliberately not cleaned: length decisions
@@ -35,8 +36,10 @@ export function readerHtml(html, title, author) {
35
36
  }
36
37
  export function contentMetadata(item) {
37
38
  const source = item.full_content_html ? "reader" : item.content_html ? "feed" : "snippet";
38
- return { revision: item.content_revision, source, status: item.extraction_status,
39
- attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at, isEmptyGallery: Boolean(item.is_empty_gallery) };
39
+ // A video's page has no text to fetch: its card is the whole content, so no client says the article is missing.
40
+ const videoCard = !item.full_content_html && isVideoTeaser(item.content_html, item.title);
41
+ return { revision: item.content_revision, source, status: videoCard ? "feed_only" : item.extraction_status,
42
+ attemptedAt: item.extraction_attempted_at, retryAt: videoCard ? null : item.extraction_retry_at, isEmptyGallery: Boolean(item.is_empty_gallery), ...(videoCard ? { isVideo: true } : {}) };
40
43
  }
41
44
  export function sourceText(item) {
42
45
  return `${decodeEntities(item.title) ?? ""}\n${derivedText(item.full_content_html || item.content_html, item.title) || cleanSnippet(decodeEntities(item.content_snippet), item.title) || ""}`.trim();
@@ -0,0 +1,76 @@
1
+ import { JSDOM } from "jsdom";
2
+ import { flat, pruneEmpty } from "../shared.js";
3
+ /** The address of a page that is a video: `/video/…`, `/videos/…`, `/mediathek/…`, `/watch`, `video-1234.html`. */
4
+ const VIDEO_PAGE = /(^|\/)(videos?|mediathek|watch|clips?)(\/|$|[-.])|\/(?:[\w-]+-)?video[\w-]*\.html?$/i;
5
+ function isVideoAddress(href) {
6
+ if (!href)
7
+ return false;
8
+ try {
9
+ return VIDEO_PAGE.test(new URL(href, "https://example.org").pathname);
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ /** The posters in a text: a picture that is a link to a video page, and the block that holds it. */
16
+ function posters(root) {
17
+ const found = [];
18
+ for (const link of root.querySelectorAll("a[href]")) {
19
+ if (!link.querySelector("img") || !isVideoAddress(link.getAttribute("href")))
20
+ continue;
21
+ const block = link.closest("p, div, li") ?? link.parentElement;
22
+ if (block)
23
+ found.push({ link, block });
24
+ }
25
+ return found;
26
+ }
27
+ /** Whether what stands beside the poster says nothing but the title. */
28
+ function onlyTheTitle(block, title) {
29
+ const copy = block.cloneNode(true);
30
+ copy.querySelectorAll("a, img, picture, figure").forEach(node => node.remove());
31
+ const text = flat(copy.textContent).replace(/[[\]()\s]/g, "");
32
+ return text === "" || text.toLowerCase() === (title ?? "").replace(/[[\]()\s]/g, "").toLowerCase();
33
+ }
34
+ /**
35
+ * A feed entry for a video is often just the poster as a link to the video page, the title again and "[mehr]". A client shows that as a
36
+ * card for a video, like an embedded player, instead of an article with nothing to read: the poster stays, and under it a marked link to the
37
+ * video page follows (`data-media="video"`), which clients turn into the card.
38
+ */
39
+ export function markVideoTeasers(root, title, stats) {
40
+ let changed = false;
41
+ for (const { link, block } of posters(root)) {
42
+ if (!block.isConnected && !root.contains(block))
43
+ continue;
44
+ const href = link.getAttribute("href");
45
+ const next = block.nextElementSibling;
46
+ if (next?.querySelector(`a[data-media="video"][href="${href.replace(/"/g, """)}"]`))
47
+ continue;
48
+ if (!onlyTheTitle(block, title))
49
+ continue;
50
+ // The poster stays alone in its block; the words beside it move into the card.
51
+ [...block.childNodes].forEach(node => {
52
+ if (node.nodeType === 3 || node.nodeName === "BR")
53
+ node.parentNode?.removeChild(node);
54
+ });
55
+ const document = block.ownerDocument;
56
+ const paragraph = document.createElement("p");
57
+ const card = document.createElement("a");
58
+ card.setAttribute("href", href);
59
+ card.setAttribute("data-media", "video");
60
+ card.textContent = flat(title) || "Video";
61
+ paragraph.append(card);
62
+ block.after(paragraph);
63
+ pruneEmpty(block.parentNode);
64
+ stats["video-teasers"] = (stats["video-teasers"] ?? 0) + 1;
65
+ changed = true;
66
+ }
67
+ return changed;
68
+ }
69
+ /** Whether a feed entry's text is such a video teaser; cheap to ask, as most entries fail the first test. */
70
+ export function isVideoTeaser(html, title) {
71
+ if (!html || html.length > 2000 || !/<img\b/i.test(html) || !/video|mediathek|watch|clip/i.test(html))
72
+ return false;
73
+ const fragment = JSDOM.fragment(html);
74
+ return posters(fragment).some(({ block }) => onlyTheTitle(block, title));
75
+ }
76
+ //# sourceMappingURL=videoTeasers.js.map
@@ -2,6 +2,7 @@ import { JSDOM } from "jsdom";
2
2
  import { CONSENT_ANCHORS, CONSENT_COMPANIONS } from "./content/patterns/consent/index.js";
3
3
  import { resolveEmbeds } from "./content/embeds/index.js";
4
4
  import { teaserLinks } from "./content/noise/teaserLinks.js";
5
+ import { markVideoTeasers } from "./content/noise/videoTeasers.js";
5
6
  import { flat, pruneEmpty } from "./content/shared.js";
6
7
  /**
7
8
  * Publisher pages carry page chrome inside the article body: breadcrumbs, dateline repeats, player leftovers,
@@ -212,7 +213,8 @@ function removeReaderNoise(root, title, stats, author) {
212
213
  continue;
213
214
  }
214
215
  }
215
- if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
216
+ // A block that holds a picture is more than the repeated headline: the picture stays.
217
+ if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14)) && !block.querySelector("img, picture, video")) {
216
218
  drop(block, "title-repeat");
217
219
  continue;
218
220
  }
@@ -320,6 +322,9 @@ function removeReaderNoise(root, title, stats, author) {
320
322
  drop(next, "consent-placeholder");
321
323
  }
322
324
  });
325
+ // Last, so that nothing above takes the card for a repeated headline.
326
+ if (markVideoTeasers(root, title, stats))
327
+ changed = true;
323
328
  return changed;
324
329
  }
325
330
  const PRESENTATIONAL_TAGS = new Set(["FONT", "BIG", "SMALL", "CENTER", "TT", "U", "STRIKE", "S", "BASEFONT", "BLINK", "NOBR", "SPAN", "MARK", "PICTURE"]);
@@ -1,4 +1,5 @@
1
1
  import { canAccessItem } from "../feeds/repository.js";
2
+ import { isVideoTeaser } from "../feeds/content/noise/videoTeasers.js";
2
3
  import { db } from "../db/index.js";
3
4
  import { listFoldersForUser, listMutedKeywords, listSubscriptionsForUser, } from "../feeds/repository.js";
4
5
  import { getEdition, getEditionState } from "../native/editions.js";
@@ -63,20 +64,25 @@ function loadItemContents(userId, itemIds) {
63
64
  ELSE 'snippet' END AS source,
64
65
  i.extraction_status,
65
66
  i.extraction_attempted_at,
66
- i.extraction_retry_at
67
+ i.extraction_retry_at,
68
+ i.title,
69
+ CASE WHEN i.full_content_html IS NULL AND length(i.content_html) <= 2000 THEN i.content_html END AS short_html
67
70
  FROM items i
68
71
  WHERE i.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
69
72
  .all(JSON.stringify(itemIds));
70
73
  for (const row of rows) {
71
74
  if (!canAccessItem(userId, row.id))
72
75
  continue;
76
+ // A video's page has no text to fetch: its card is the whole content (see `contentMetadata`).
77
+ const isVideo = isVideoTeaser(row.short_html, row.title);
73
78
  result.set(row.id, {
74
79
  revision: row.content_revision,
75
80
  source: row.source,
76
- status: row.extraction_status,
81
+ status: isVideo ? "feed_only" : row.extraction_status,
77
82
  attemptedAt: row.extraction_attempted_at,
78
- retryAt: row.extraction_retry_at,
83
+ retryAt: isVideo ? null : row.extraction_retry_at,
79
84
  isEmptyGallery: Boolean(row.is_empty_gallery),
85
+ ...(isVideo ? { isVideo: true } : {}),
80
86
  });
81
87
  }
82
88
  return result;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }