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 +1 -1
- package/server/dist/api/items.js +3 -3
- package/server/dist/api/v1/serialize.js +1 -1
- package/server/dist/feeds/articleContent.js +3 -3
- package/server/dist/feeds/content/index.js +2 -1
- package/server/dist/feeds/content/noise/lazyImages.js +22 -0
- package/server/dist/feeds/content/noise/videoPages.js +67 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/de.js +3 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/en.js +3 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/index.js +10 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/ja.js +3 -0
- package/server/dist/feeds/content/patterns/video-pages/de.js +3 -0
- package/server/dist/feeds/content/patterns/video-pages/en.js +3 -0
- package/server/dist/feeds/content/patterns/video-pages/ja.js +3 -0
- package/server/dist/feeds/contentNoise.js +21 -5
- package/server/dist/feeds/extractor.js +2 -1
- package/server/dist/mcp/items.js +1 -1
- package/server/dist/mcp/server.js +7 -3
- package/server/package.json +1 -1
package/package.json
CHANGED
package/server/dist/api/items.js
CHANGED
|
@@ -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 [/^(?: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
|
|
@@ -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 =
|
|
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");
|
package/server/dist/mcp/items.js
CHANGED
|
@@ -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
|
|
120
|
-
description: "Saves a web article URL, raw HTML, text clipping, or
|
|
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"),
|