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 +1 -1
- package/server/dist/backup-cli.js +10 -2
- package/server/dist/feeds/articleContent.js +5 -2
- package/server/dist/feeds/content/noise/videoTeasers.js +76 -0
- package/server/dist/feeds/contentNoise.js +6 -1
- package/server/dist/sync/changeLog.js +9 -3
- package/server/package.json +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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;
|