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.
- 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/boilerplate/index.js +3 -2
- package/server/dist/feeds/content/patterns/boilerplate/ja.js +19 -0
- package/server/dist/feeds/content/patterns/consent/index.js +2 -1
- package/server/dist/feeds/content/patterns/consent/ja.js +14 -0
- package/server/dist/feeds/content/patterns/loading/index.js +2 -1
- package/server/dist/feeds/content/patterns/loading/ja.js +8 -0
- package/server/dist/feeds/content/patterns/play-labels/index.js +2 -1
- package/server/dist/feeds/content/patterns/play-labels/ja.js +3 -0
- package/server/dist/feeds/content/patterns/sidebars/index.js +2 -1
- package/server/dist/feeds/content/patterns/sidebars/ja.js +7 -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 +68 -9
- 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
|
|
@@ -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
|
|
@@ -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 [/^(?: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,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
|
-
//
|
|
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
|
|
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 =
|
|
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");
|
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"),
|