feedkeeper 0.25.1 → 0.26.1
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 +6 -1
- package/server/dist/api/v1/index.js +2 -0
- package/server/dist/api/v1/serialize.js +6 -3
- package/server/dist/db/migrations/0034_edition_layout.sql +2 -0
- package/server/dist/db/migrations/0035_item_lead.sql +4 -0
- package/server/dist/feeds/articleContent.js +26 -2
- package/server/dist/feeds/contentNoise.js +419 -0
- package/server/dist/feeds/fullText.js +2 -0
- package/server/dist/feeds/inbox.js +2 -0
- package/server/dist/feeds/lead.js +57 -0
- package/server/dist/feeds/poller.js +7 -1
- package/server/dist/feeds/repository.js +17 -2
- package/server/dist/mcp/items.js +2 -2
- package/server/dist/mcp/server.js +1 -1
- package/server/dist/native/editionLayout.js +66 -0
- package/server/dist/native/editions.js +15 -7
- package/server/dist/server.js +2 -0
- package/server/package.json +1 -1
package/package.json
CHANGED
package/server/dist/api/items.js
CHANGED
|
@@ -5,6 +5,8 @@ import { z } from "zod";
|
|
|
5
5
|
import { requireSession } from "../auth/middleware.js";
|
|
6
6
|
import { listItemsForUser, markAllRead, markItemRead, markItemUnread, bookmarkItem, unbookmarkItem, findItemById, } from "../feeds/repository.js";
|
|
7
7
|
import { loadFullText } from "../feeds/fullText.js";
|
|
8
|
+
import { readerHtml } from "../feeds/articleContent.js";
|
|
9
|
+
import { cleanSnippet } from "../feeds/contentNoise.js";
|
|
8
10
|
import { fetchItemImage, pruneArchive, scheduleArchive, withArchivedImages, withProxiedImages } from "../feeds/archive.js";
|
|
9
11
|
import { ImageCacheBusyError } from "../feeds/imageCache.js";
|
|
10
12
|
export const FULL_TEXT_STATUS = {
|
|
@@ -46,6 +48,9 @@ itemsRouter.get("/", (req, res) => {
|
|
|
46
48
|
const items = parsed.data.offline ? withProxiedImages(archived, origin) : archived;
|
|
47
49
|
res.json(items.map((item) => ({
|
|
48
50
|
...item,
|
|
51
|
+
content_snippet: cleanSnippet(item.content_snippet, item.title),
|
|
52
|
+
content_html: readerHtml(item.content_html, item.title),
|
|
53
|
+
full_content_html: readerHtml(item.full_content_html, item.title),
|
|
49
54
|
read: Boolean(item.read),
|
|
50
55
|
bookmarked: Boolean(item.bookmarked),
|
|
51
56
|
})));
|
|
@@ -121,7 +126,7 @@ itemsRouter.post("/:itemId/extract-content", async (req, res) => {
|
|
|
121
126
|
const [served] = withArchivedImages([{ id: result.id, link: findItemById(result.id)?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
|
|
122
127
|
res.json({
|
|
123
128
|
id: result.id,
|
|
124
|
-
full_content_html: served.full_content_html,
|
|
129
|
+
full_content_html: readerHtml(served.full_content_html, result.title ?? findItemById(result.id)?.title),
|
|
125
130
|
...(result.cached ? {} : { title: result.title, byline: result.byline }),
|
|
126
131
|
cached: result.cached,
|
|
127
132
|
});
|
|
@@ -25,6 +25,7 @@ const FEATURES = [
|
|
|
25
25
|
"fulltext",
|
|
26
26
|
"content.revisions",
|
|
27
27
|
"content.prepare",
|
|
28
|
+
"item.lead",
|
|
28
29
|
"images",
|
|
29
30
|
"muted-keywords",
|
|
30
31
|
"opml",
|
|
@@ -38,6 +39,7 @@ const FEATURES = [
|
|
|
38
39
|
"native-preferences",
|
|
39
40
|
"native-preferences.edition-size",
|
|
40
41
|
"edition.overview",
|
|
42
|
+
"edition.layout",
|
|
41
43
|
"search.fts",
|
|
42
44
|
"account-capabilities",
|
|
43
45
|
"mcp",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { contentMetadata } from "../../feeds/articleContent.js";
|
|
1
|
+
import { contentMetadata, readerHtml } from "../../feeds/articleContent.js";
|
|
2
|
+
import { cleanSnippet } from "../../feeds/contentNoise.js";
|
|
3
|
+
import { leadOf } from "../../feeds/lead.js";
|
|
2
4
|
import { db } from "../../db/index.js";
|
|
3
5
|
import { serializeSubscription } from "../../sync/changeLog.js";
|
|
4
6
|
/** A subscription as the list shows it: the synced fields plus counts and polling health. */
|
|
@@ -30,11 +32,12 @@ export function serializeItem(row, progress, withContent, hasNote = false) {
|
|
|
30
32
|
url: row.link,
|
|
31
33
|
publishedAt: row.published_at,
|
|
32
34
|
addedAt: row.created_at,
|
|
33
|
-
snippet: row.content_snippet,
|
|
35
|
+
snippet: cleanSnippet(row.content_snippet, row.title),
|
|
36
|
+
lead: leadOf(row),
|
|
34
37
|
imageUrl: row.image_url,
|
|
35
38
|
hasFullText: Boolean(row.full_content_html),
|
|
36
39
|
content: contentMetadata(row),
|
|
37
|
-
...(withContent ? { contentHtml: row.content_html, fullTextHtml: row.full_content_html } : {}),
|
|
40
|
+
...(withContent ? { contentHtml: readerHtml(row.content_html, row.title), fullTextHtml: readerHtml(row.full_content_html, row.title) } : {}),
|
|
38
41
|
state: {
|
|
39
42
|
read: Boolean(row.read),
|
|
40
43
|
saved: Boolean(row.bookmarked),
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- Plain-text beginning of an article without page chrome, derived whenever its content changes. The version says
|
|
2
|
+
-- which cleaning rules produced it, so a rule change is picked up by the background backfill.
|
|
3
|
+
ALTER TABLE items ADD COLUMN content_lead TEXT;
|
|
4
|
+
ALTER TABLE items ADD COLUMN lead_version INTEGER;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { JSDOM } from "jsdom";
|
|
2
3
|
import { decodeEntities } from "./text.js";
|
|
3
|
-
|
|
4
|
+
import { cleanReaderHtml, cleanSnippet, derivedText } from "./contentNoise.js";
|
|
5
|
+
/**
|
|
6
|
+
* Plain publisher content, shared by readers, search consumers and MCP. Deliberately not cleaned: length decisions
|
|
7
|
+
* (which text is the better copy, what retention keeps) must not depend on which phrases the rules know.
|
|
8
|
+
*/
|
|
4
9
|
export function articleText(html) {
|
|
5
10
|
if (!html)
|
|
6
11
|
return "";
|
|
@@ -9,13 +14,32 @@ export function articleText(html) {
|
|
|
9
14
|
fragment.querySelectorAll("p, div, br, li, h1, h2, h3, h4, blockquote").forEach(node => node.append(" "));
|
|
10
15
|
return (fragment.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
11
16
|
}
|
|
17
|
+
const READER_CACHE_LIMIT = 300;
|
|
18
|
+
const readerCache = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* Article HTML as a reader shows it, without breadcrumbs, repeated headline and similar page chrome. Computed when
|
|
21
|
+
* served and never stored, so the publisher's original stays intact and rule changes apply to old articles too.
|
|
22
|
+
*/
|
|
23
|
+
export function readerHtml(html, title) {
|
|
24
|
+
if (!html)
|
|
25
|
+
return html ?? null;
|
|
26
|
+
const key = createHash("sha1").update(title ?? "").update("\0").update(html).digest("base64");
|
|
27
|
+
const cached = readerCache.get(key);
|
|
28
|
+
if (cached !== undefined)
|
|
29
|
+
return cached;
|
|
30
|
+
const cleaned = cleanReaderHtml(html, title);
|
|
31
|
+
if (readerCache.size >= READER_CACHE_LIMIT)
|
|
32
|
+
readerCache.delete(readerCache.keys().next().value);
|
|
33
|
+
readerCache.set(key, cleaned);
|
|
34
|
+
return cleaned;
|
|
35
|
+
}
|
|
12
36
|
export function contentMetadata(item) {
|
|
13
37
|
const source = item.full_content_html ? "reader" : item.content_html ? "feed" : "snippet";
|
|
14
38
|
return { revision: item.content_revision, source, status: item.extraction_status,
|
|
15
39
|
attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at };
|
|
16
40
|
}
|
|
17
41
|
export function sourceText(item) {
|
|
18
|
-
return `${decodeEntities(item.title) ?? ""}\n${
|
|
42
|
+
return `${decodeEntities(item.title) ?? ""}\n${derivedText(item.full_content_html || item.content_html, item.title) || cleanSnippet(decodeEntities(item.content_snippet), item.title) || ""}`.trim();
|
|
19
43
|
}
|
|
20
44
|
/** Prefer existing substantive publisher text over a shorter extracted teaser. */
|
|
21
45
|
export function improvesContent(item, extracted) {
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { JSDOM } from "jsdom";
|
|
2
|
+
/**
|
|
3
|
+
* Publisher pages carry page chrome inside the article body: breadcrumbs, dateline repeats, player leftovers,
|
|
4
|
+
* recommendation lists, follow prompts. This module removes it in two layers that follow one principle:
|
|
5
|
+
*
|
|
6
|
+
* - `cleanReaderHtml` is what a reader shows. It removes only what cannot work in a reader or merely repeats what
|
|
7
|
+
* the reader already shows (breadcrumbs, in-page jump links, a date line, the headline, consent placeholders,
|
|
8
|
+
* player labels). Attribution, sponsored and affiliate content, disclosures and a publisher's own calls to action
|
|
9
|
+
* stay: a source's revenue model is not ours to remove.
|
|
10
|
+
* - `derivedText` and `cleanSnippet` feed text that is not read as a page (snippets, search, teasers, curation).
|
|
11
|
+
* There noise only costs relevance, so credits, ads, calls to action and recommendation lists go as well.
|
|
12
|
+
*
|
|
13
|
+
* Rules are structural or generic idioms. A phrase belongs here only when more than one publisher uses it.
|
|
14
|
+
*/
|
|
15
|
+
const BLOCKS = "p, li, h1, h2, h3, h4, h5, h6, blockquote, figcaption, dt, dd, td, th, pre";
|
|
16
|
+
function flat(value) {
|
|
17
|
+
return (value ?? "").replace(/\s+/g, " ").trim();
|
|
18
|
+
}
|
|
19
|
+
function normalized(value) {
|
|
20
|
+
return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
21
|
+
}
|
|
22
|
+
function leafBlocks(root) {
|
|
23
|
+
return [...root.querySelectorAll(`${BLOCKS}, div`)].filter(element => !element.querySelector(BLOCKS) && (element.tagName !== "DIV" || !element.querySelector("div")));
|
|
24
|
+
}
|
|
25
|
+
function charsBefore(root, element) {
|
|
26
|
+
const range = root.ownerDocument.createRange();
|
|
27
|
+
range.setStart(root, 0);
|
|
28
|
+
range.setEndBefore(element);
|
|
29
|
+
return range.toString().length;
|
|
30
|
+
}
|
|
31
|
+
const BREADCRUMB_MARKERS = [
|
|
32
|
+
"[aria-label*='breadcrumb' i]",
|
|
33
|
+
"[itemtype*='BreadcrumbList' i]",
|
|
34
|
+
"[class~='breadcrumb']",
|
|
35
|
+
"[class~='breadcrumbs']",
|
|
36
|
+
];
|
|
37
|
+
const LANGUAGE_NAMES = ["English", "Deutsch", "Français", "Español", "Italiano", "Português", "Nederlands", "Polski", "Türkçe",
|
|
38
|
+
"Русский", "日本語", "한국어", "简体字", "繁體字", "简体中文", "繁體中文", "中文", "العربية"];
|
|
39
|
+
const CONSENT_ANCHORS = [
|
|
40
|
+
/^Empfohlener redaktioneller Inhalt\b/i,
|
|
41
|
+
/An dieser Stelle findest du einen externen Inhalt/i,
|
|
42
|
+
/Du kannst ihn dir mit einem Klick anzeigen lassen/i,
|
|
43
|
+
];
|
|
44
|
+
const CONSENT_COMPANIONS = [
|
|
45
|
+
/\bInhalte? (von \S+ )?erlauben\b/i, /-Inhalte erlauben\b/i, /^Ich bin damit einverstanden, dass mir .+ angezeigt werden/i,
|
|
46
|
+
/Personenbezogene Daten können an Drittplattformen/i, /^Link zum .+-Inhalt$/i, /^Mehr dazu in unserer Datenschutzerklärung/i,
|
|
47
|
+
];
|
|
48
|
+
function removeReaderNoise(root, title, stats) {
|
|
49
|
+
let changed = false;
|
|
50
|
+
const drop = (element, rule) => {
|
|
51
|
+
if (!element.parentNode)
|
|
52
|
+
return;
|
|
53
|
+
element.remove();
|
|
54
|
+
stats[rule] = (stats[rule] ?? 0) + 1;
|
|
55
|
+
changed = true;
|
|
56
|
+
};
|
|
57
|
+
for (const selector of BREADCRUMB_MARKERS) {
|
|
58
|
+
try {
|
|
59
|
+
root.querySelectorAll(selector).forEach(element => drop(element, "breadcrumb"));
|
|
60
|
+
}
|
|
61
|
+
catch { /* selector unsupported by the DOM */ }
|
|
62
|
+
}
|
|
63
|
+
// Breadcrumbs without markup hints: a short list near the start whose items are single short links.
|
|
64
|
+
for (const list of root.querySelectorAll("ol, ul")) {
|
|
65
|
+
const items = [...list.children].filter(child => child.tagName === "LI");
|
|
66
|
+
if (items.length < 2 || items.length > 8 || charsBefore(root, list) > 300)
|
|
67
|
+
continue;
|
|
68
|
+
const onlyLinks = items.every(item => {
|
|
69
|
+
const links = item.querySelectorAll("a");
|
|
70
|
+
const label = flat(item.textContent);
|
|
71
|
+
return links.length === 1 && label.length > 0 && label.length <= 30 && flat(links[0].textContent) === label;
|
|
72
|
+
});
|
|
73
|
+
if (onlyLinks)
|
|
74
|
+
drop(list, "breadcrumb");
|
|
75
|
+
}
|
|
76
|
+
const headline = normalized(title ?? "");
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
let leading = 0;
|
|
79
|
+
for (const block of leafBlocks(root)) {
|
|
80
|
+
const text = flat(block.textContent);
|
|
81
|
+
if (!text)
|
|
82
|
+
continue;
|
|
83
|
+
const position = leading++;
|
|
84
|
+
const heading = /^H[1-3]$/.test(block.tagName);
|
|
85
|
+
if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
|
|
86
|
+
drop(block, "title-repeat");
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (position < 14 && /^(Audiobeitrag|Videobeitrag)\s*>/.test(text) && headline && normalized(text).endsWith(headline)) {
|
|
90
|
+
drop(block, "title-repeat");
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (/^[>›»|/·•–—-]+$/.test(text)) {
|
|
94
|
+
drop(block, "separator");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (/^(Audiobeitrag|Videobeitrag|Autoplay)$/i.test(text) || (position < 8 && /^Bild$/.test(text))) {
|
|
98
|
+
drop(block, "player");
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
|
|
102
|
+
const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
|
|
103
|
+
if (/^(stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
|
|
104
|
+
drop(block, "dateline");
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const anchors = block.querySelectorAll("a");
|
|
109
|
+
if (text.length <= 60 && anchors.length === 1 && anchors[0].getAttribute("href")?.startsWith("#") && flat(anchors[0].textContent) === text) {
|
|
110
|
+
drop(block, "jump-link");
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (text.length <= 200 && /^\d{1,2}:\d{2}\b/.test(text) && /\bAutoplay\b/i.test(text)) {
|
|
114
|
+
drop(block, "player");
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (text.length <= 160 && LANGUAGE_NAMES.filter(name => text.includes(name)).length >= 4) {
|
|
118
|
+
drop(block, "language-switch");
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (position < 10 && text.length >= 30) {
|
|
122
|
+
const key = normalized(text);
|
|
123
|
+
if (seen.has(key)) {
|
|
124
|
+
drop(block, "repeat");
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
seen.add(key);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Consent placeholders of embedded third-party content: only the neighbours of a known anchor phrase go.
|
|
131
|
+
const blocks = leafBlocks(root);
|
|
132
|
+
blocks.forEach((block, index) => {
|
|
133
|
+
if (!block.parentNode || flat(block.textContent).length > 400 || !CONSENT_ANCHORS.some(pattern => pattern.test(flat(block.textContent))))
|
|
134
|
+
return;
|
|
135
|
+
drop(block, "consent-placeholder");
|
|
136
|
+
for (const next of blocks.slice(index + 1, index + 7)) {
|
|
137
|
+
const text = flat(next.textContent);
|
|
138
|
+
if (!next.parentNode || text.length > 220)
|
|
139
|
+
break;
|
|
140
|
+
if (CONSENT_ANCHORS.some(pattern => pattern.test(text)) || CONSENT_COMPANIONS.some(pattern => pattern.test(text)))
|
|
141
|
+
drop(next, "consent-placeholder");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return changed;
|
|
145
|
+
}
|
|
146
|
+
const PRESENTATIONAL_TAGS = new Set(["FONT", "BIG", "SMALL", "CENTER", "TT", "U", "STRIKE", "S", "BASEFONT", "BLINK", "NOBR", "SPAN", "MARK"]);
|
|
147
|
+
const PRESENTATIONAL_ATTRIBUTES = ["style", "class", "color", "face", "size", "bgcolor", "align", "valign", "border", "cellpadding",
|
|
148
|
+
"cellspacing", "background", "hspace", "vspace", "nowrap"];
|
|
149
|
+
const SIZED_TAGS = new Set(["IMG", "VIDEO", "CANVAS", "IFRAME", "EMBED", "OBJECT"]);
|
|
150
|
+
const EMPHASIS_EXEMPT = "h1, h2, h3, h4, h5, h6, th, strong, b";
|
|
151
|
+
function wrapChildren(element, tag) {
|
|
152
|
+
const wrapper = element.ownerDocument.createElement(tag);
|
|
153
|
+
while (element.firstChild)
|
|
154
|
+
wrapper.append(element.firstChild);
|
|
155
|
+
element.append(wrapper);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Typography belongs to the client: fonts, sizes, colours, alignment and classes are removed so that only
|
|
159
|
+
* structure (headings, paragraphs, lists, quotes, tables, links, images) and emphasis (bold, italic) are left.
|
|
160
|
+
* Emphasis that was only a style (`font-weight: bold` on a span) is kept as strong or em.
|
|
161
|
+
*/
|
|
162
|
+
function removeTypography(root, stats) {
|
|
163
|
+
let changed = false;
|
|
164
|
+
root.querySelectorAll("style, link[rel~='stylesheet']").forEach(node => { node.remove(); changed = true; });
|
|
165
|
+
const targets = new Set([...root.querySelectorAll("a[href^='#']")].map(anchor => (anchor.getAttribute("href") ?? "").slice(1)));
|
|
166
|
+
for (const element of [...root.querySelectorAll("*")]) {
|
|
167
|
+
const style = element.getAttribute("style") ?? "";
|
|
168
|
+
const bold = /font-weight\s*:\s*(bold|bolder|[6-9]00)\b/i.test(style);
|
|
169
|
+
const italic = /font-style\s*:\s*(italic|oblique)\b/i.test(style);
|
|
170
|
+
const plain = !element.matches(EMPHASIS_EXEMPT) && !element.closest(EMPHASIS_EXEMPT) && flat(element.textContent) !== "";
|
|
171
|
+
for (const name of PRESENTATIONAL_ATTRIBUTES) {
|
|
172
|
+
if (element.hasAttribute(name)) {
|
|
173
|
+
element.removeAttribute(name);
|
|
174
|
+
changed = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!SIZED_TAGS.has(element.tagName)) {
|
|
178
|
+
for (const name of ["width", "height"]) {
|
|
179
|
+
if (element.hasAttribute(name)) {
|
|
180
|
+
element.removeAttribute(name);
|
|
181
|
+
changed = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (plain && bold)
|
|
186
|
+
wrapChildren(element, "strong");
|
|
187
|
+
if (plain && italic && !element.closest("em, i"))
|
|
188
|
+
wrapChildren(element, "em");
|
|
189
|
+
const anchorTarget = element.id && targets.has(element.id);
|
|
190
|
+
if (PRESENTATIONAL_TAGS.has(element.tagName) && !anchorTarget) {
|
|
191
|
+
element.replaceWith(...element.childNodes);
|
|
192
|
+
changed = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (changed)
|
|
196
|
+
stats["typography"] = (stats["typography"] ?? 0) + 1;
|
|
197
|
+
return changed;
|
|
198
|
+
}
|
|
199
|
+
function serialize(root) {
|
|
200
|
+
const holder = root.ownerDocument.createElement("div");
|
|
201
|
+
holder.append(root);
|
|
202
|
+
return holder.innerHTML;
|
|
203
|
+
}
|
|
204
|
+
/** The article HTML as a reader should show it. Unchanged input is returned as is. */
|
|
205
|
+
export function cleanReaderHtml(html, title, stats = {}) {
|
|
206
|
+
if (!html)
|
|
207
|
+
return html;
|
|
208
|
+
const root = JSDOM.fragment(html);
|
|
209
|
+
const noise = removeReaderNoise(root, title, stats);
|
|
210
|
+
const typography = removeTypography(root, stats);
|
|
211
|
+
return noise || typography ? serialize(root) : html;
|
|
212
|
+
}
|
|
213
|
+
const CREDIT_PATTERNS = [
|
|
214
|
+
/^(©|\(c\)\s|copyright\b)/i,
|
|
215
|
+
/^[([]?(Bild|Foto|Fotos|Bildquelle|Bildrechte|Bildnachweis|Titelbild|Beitragsbild|Photo|Image|Credits?|Grafik|Illustration)[)\]]?\s*[:/|]/i,
|
|
216
|
+
/\|\s*(Image|Photo|Foto|Bild|Credit)s?\s*:/i,
|
|
217
|
+
/\(\s*(Bildquelle|Bildrechte|Foto|Bild|Photo|Image|Credit|Quelle)\s*:[^)]{1,60}\)\s*\.?$/i,
|
|
218
|
+
/\bBildrechte:\s*(Bildrechte:\s*)?[^.]{1,60}$/i,
|
|
219
|
+
/©[^\n]{1,40}$/,
|
|
220
|
+
/\/\s*(Getty Images|Reuters|AP|AFP|dpa|Imago)\s*$/i,
|
|
221
|
+
/\bvia (REUTERS|Reuters|AP|AFP|dpa|Getty Images|Imagen)\s*$/,
|
|
222
|
+
/^Quelle:?\s*\S{0,40}$/i,
|
|
223
|
+
];
|
|
224
|
+
const HEAD_META_PATTERNS = [
|
|
225
|
+
/^(Stand|Aktualisiert|Zuletzt aktualisiert|Ver(ö|oe)ffentlicht|Updated|Published|Posted)\b.{0,40}\d/i,
|
|
226
|
+
/^von\s+.{2,40}\s[I|]\s+\d{1,2}\.\d{1,2}\.\d{2,4}/i,
|
|
227
|
+
/^(\d{1,2}\.\s?\d{1,2}\.\s?\d{2,4}|[A-Z][a-z]{2,8}\.? \d{1,2}, \d{4})([,\s•·|-]+\d{1,2}[:.]\d{2}( Uhr)?)?$/,
|
|
228
|
+
/^\p{L}[\p{L}\p{N} .&-]{0,30}\s(\d{1,2}\.\s?\d{1,2}\.\s?\d{2,4}|[A-Z][a-z]{2,8}\.? \d{1,2}, \d{4})([,\s•·|-]+\d{1,2}[:.]\d{2}( Uhr)?)?$/u,
|
|
229
|
+
/^(Kommentare|Kommentieren|Comments|Teilen|Share|Drucken|Print|Merken|Vorlesen)$/i,
|
|
230
|
+
/^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen)\s*:?\s+[^.!?]{0,160}$/i,
|
|
231
|
+
];
|
|
232
|
+
const AD_PATTERNS = [
|
|
233
|
+
/^(Anzeige|Werbung|Advertisement|Advertorial|Sponsored|Gesponsert|Promoted)\s*:?$/i,
|
|
234
|
+
/\b(affiliate|partner)[- ]?links?\b/i,
|
|
235
|
+
/\bwe (may|might) (receive|earn) a commission\b/i,
|
|
236
|
+
/^FTC:/i,
|
|
237
|
+
/\bClick Here to Buy\b/i,
|
|
238
|
+
/^Hurry,/i,
|
|
239
|
+
/^Worth checking out on Amazon/i,
|
|
240
|
+
/\bProvision\b.{0,60}\b(Links?|Einkäufe|Käufe)\b/i,
|
|
241
|
+
];
|
|
242
|
+
const SOCIAL_NETWORKS = ["Facebook", "Instagram", "Reddit", "Threads", "Twitter", "Mastodon", "YouTube", "TikTok", "Google News", "Flipboard", "WhatsApp", "LinkedIn"];
|
|
243
|
+
const CALL_TO_ACTION_PATTERNS = [
|
|
244
|
+
/\b(bevorzugte[nr]?\s+(Google-)?Quelle|bei Google bevorzugen|preferred source)\b/i,
|
|
245
|
+
/\bWo du uns folgen kannst\b/i,
|
|
246
|
+
/^Folge[nt]? uns\b/i,
|
|
247
|
+
/\bFolge uns auch auf\b/i,
|
|
248
|
+
/\bUns auf Google folgen\b/i,
|
|
249
|
+
/\bfollow us\b/i,
|
|
250
|
+
/\bFügt uns doch bei Google\b/i,
|
|
251
|
+
/\bAuf dem Laufenden bleiben\?/i,
|
|
252
|
+
/\bNewsletter\b.{0,240}\b(Anmeldung|anmelden|abonnier\w*|subscribe)\b/i,
|
|
253
|
+
/\b(subscribe|abonnier\w+)\b.{0,60}\b(newsletter|YouTube|channel|Kanal)\b/i,
|
|
254
|
+
/\bBy subscribing\b/i,
|
|
255
|
+
/^SUBSCRIBE NOW$/i,
|
|
256
|
+
/^Hier geht'?s zur Anmeldung!?$/i,
|
|
257
|
+
/^★.+★$/,
|
|
258
|
+
];
|
|
259
|
+
const COMMENT_PATTERNS = [
|
|
260
|
+
/\b(let us know|tell us) .{0,40}in the comments\b/i,
|
|
261
|
+
/\bNur angemeldete Benutzer können kommentieren\b/i,
|
|
262
|
+
];
|
|
263
|
+
const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
|
|
264
|
+
const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
|
|
265
|
+
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;
|
|
266
|
+
const MIN_KEPT_CHARACTERS = 150;
|
|
267
|
+
const HEAD_BLOCKS = 6;
|
|
268
|
+
const TAIL_CHARACTERS = 1500;
|
|
269
|
+
const TAIL_CUT_FROM = 0.6;
|
|
270
|
+
function matchesAny(text, patterns) {
|
|
271
|
+
return patterns.some(pattern => pattern.test(text));
|
|
272
|
+
}
|
|
273
|
+
function isSocialList(text) {
|
|
274
|
+
return text.length <= 200 && SOCIAL_NETWORKS.filter(name => text.includes(name)).length >= 3;
|
|
275
|
+
}
|
|
276
|
+
function isLinkOnly(block, text) {
|
|
277
|
+
const anchors = block.querySelectorAll("a");
|
|
278
|
+
return anchors.length === 1 && flat(anchors[0].textContent) === text;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Plain, single-line publisher text without page chrome: for snippets, search, teasers and curation, never for
|
|
282
|
+
* display as a page. Falls back to the plain flattening when cleaning would leave almost nothing.
|
|
283
|
+
*/
|
|
284
|
+
export function derivedText(html, title, stats = {}) {
|
|
285
|
+
if (!html)
|
|
286
|
+
return "";
|
|
287
|
+
const root = JSDOM.fragment(html);
|
|
288
|
+
root.querySelectorAll("script, style, noscript, nav, footer").forEach(node => node.remove());
|
|
289
|
+
const plain = () => {
|
|
290
|
+
root.querySelectorAll("p, div, br, li, h1, h2, h3, h4, blockquote").forEach(node => node.append(" "));
|
|
291
|
+
return flat(root.textContent);
|
|
292
|
+
};
|
|
293
|
+
removeReaderNoise(root, title, stats);
|
|
294
|
+
root.querySelectorAll("figcaption").forEach(node => node.remove());
|
|
295
|
+
const entries = leafBlocks(root).map(element => ({ element, text: flat(element.textContent) })).filter(entry => entry.text);
|
|
296
|
+
const total = entries.reduce((sum, entry) => sum + entry.text.length, 0);
|
|
297
|
+
if (entries.length === 0 || total < 2 * MIN_KEPT_CHARACTERS)
|
|
298
|
+
return plain();
|
|
299
|
+
const dropped = new Set();
|
|
300
|
+
const mark = (index, rule) => {
|
|
301
|
+
if (dropped.has(index))
|
|
302
|
+
return;
|
|
303
|
+
dropped.add(index);
|
|
304
|
+
stats[rule] = (stats[rule] ?? 0) + 1;
|
|
305
|
+
};
|
|
306
|
+
let before = 0;
|
|
307
|
+
let cutAt = -1;
|
|
308
|
+
entries.forEach((entry, index) => {
|
|
309
|
+
const { text } = entry;
|
|
310
|
+
const early = index < HEAD_BLOCKS || before <= 600;
|
|
311
|
+
const late = total - before - text.length <= TAIL_CHARACTERS || index >= entries.length - 8;
|
|
312
|
+
const position = before / total;
|
|
313
|
+
before += text.length;
|
|
314
|
+
if (cutAt >= 0)
|
|
315
|
+
return;
|
|
316
|
+
if (SECTION_LABEL.test(text) && text.length <= 400) {
|
|
317
|
+
if (position >= TAIL_CUT_FROM && before - text.length >= MIN_KEPT_CHARACTERS) {
|
|
318
|
+
cutAt = index;
|
|
319
|
+
stats["recommendations"] = (stats["recommendations"] ?? 0) + 1;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
mark(index, "recommendations");
|
|
323
|
+
for (let next = index + 1; next < entries.length && isLinkOnly(entries[next].element, entries[next].text); next++)
|
|
324
|
+
mark(next, "recommendations");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (TAG_LABEL.test(text)) {
|
|
328
|
+
mark(index, "head-meta");
|
|
329
|
+
for (let next = index + 1; next < entries.length && isLinkOnly(entries[next].element, entries[next].text); next++)
|
|
330
|
+
mark(next, "head-meta");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (text.length > 400)
|
|
334
|
+
return;
|
|
335
|
+
if (text.length <= 160 && VIDEO_CARD.test(text) && entry.element.querySelector("a"))
|
|
336
|
+
mark(index, "player");
|
|
337
|
+
else if (matchesAny(text, AD_PATTERNS))
|
|
338
|
+
mark(index, "advertising");
|
|
339
|
+
else if (text.length <= 300 && (early || late) && matchesAny(text, CALL_TO_ACTION_PATTERNS))
|
|
340
|
+
mark(index, "call-to-action");
|
|
341
|
+
else if (text.length <= 300 && late && matchesAny(text, COMMENT_PATTERNS))
|
|
342
|
+
mark(index, "comments");
|
|
343
|
+
else if (isSocialList(text))
|
|
344
|
+
mark(index, "call-to-action");
|
|
345
|
+
else if (early && text.length <= 160 && matchesAny(text, HEAD_META_PATTERNS))
|
|
346
|
+
mark(index, "head-meta");
|
|
347
|
+
else if ((early || late) && text.length <= 300 && matchesAny(text, CREDIT_PATTERNS)) {
|
|
348
|
+
mark(index, "credit");
|
|
349
|
+
// In the head a credit line follows the caption it belongs to.
|
|
350
|
+
const caption = entries[index - 1];
|
|
351
|
+
if (early && caption && index - 1 < HEAD_BLOCKS && caption.text.length <= 200 && /^(©|\(c\)|copyright|Bildrechte|Bildquelle|Foto|Photo|Image|Credit)/i.test(text))
|
|
352
|
+
mark(index - 1, "credit");
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
const kept = entries.filter((_, index) => !dropped.has(index) && (cutAt < 0 || index < cutAt)).map(entry => entry.text);
|
|
356
|
+
const text = kept.join(" ");
|
|
357
|
+
if (text.length < Math.min(MIN_KEPT_CHARACTERS, total) && text.length < total * 0.7)
|
|
358
|
+
return plain();
|
|
359
|
+
return text;
|
|
360
|
+
}
|
|
361
|
+
const SNIPPET_LINE_PATTERNS = [
|
|
362
|
+
/^(Der|Die|Das) Beitrag .+ (erschien|wurde) zuerst\b.*$/i,
|
|
363
|
+
/^The post .+ appeared first on\b.*$/i,
|
|
364
|
+
/^This article, ".+" first appeared on\b.*$/i,
|
|
365
|
+
/^Discuss this article in our forums\.?$/i,
|
|
366
|
+
/^Read (the )?full story( here| at .+)?\.?$/i,
|
|
367
|
+
/^Zum Beitrag:.*$/i,
|
|
368
|
+
/^(more…|more\.\.\.|Mehr|Weiterlesen|Read more)$/i,
|
|
369
|
+
/^(Related Roundup|Related Forum|Tags?|Buyer's Guide):.*$/i,
|
|
370
|
+
/^★.+★$/,
|
|
371
|
+
/^Wo du uns folgen kannst:.*$/i,
|
|
372
|
+
/^Auf dem Laufenden bleiben\?$/i,
|
|
373
|
+
/^Fügt uns doch bei Google.*$/i,
|
|
374
|
+
];
|
|
375
|
+
const SNIPPET_INLINE_PATTERNS = [
|
|
376
|
+
/\s*(Der|Die|Das) Beitrag [^\n]+? (erschien|wurde) zuerst (auf|bei|beim) [^\n]*?\.(?=\s|$)(\s*(Folge uns auch auf|Über unseren Newsletter)[^\n]*?\.(?=\s|$))*/gi,
|
|
377
|
+
/\s*Über unseren Newsletter [^\n]*?\.(?=\s|$)/gi,
|
|
378
|
+
/\s*The post [^\n]+? appeared first on [^\n]*?\.(?=\s|$)/gi,
|
|
379
|
+
];
|
|
380
|
+
/**
|
|
381
|
+
* A feed's summary without the footers feed plugins append ("The post … appeared first on …", "Read the full
|
|
382
|
+
* story at …"), without a repeated headline and with whitespace collapsed to paragraphs.
|
|
383
|
+
*/
|
|
384
|
+
export function cleanSnippet(snippet, title) {
|
|
385
|
+
if (snippet === null || snippet === undefined)
|
|
386
|
+
return null;
|
|
387
|
+
let text = snippet.replace(/\r\n?/g, "\n").replace(/[ \t ]+/g, " ");
|
|
388
|
+
for (const pattern of SNIPPET_INLINE_PATTERNS)
|
|
389
|
+
text = text.replace(pattern, "");
|
|
390
|
+
const headline = normalized(title ?? "");
|
|
391
|
+
const lines = text.split("\n").map(line => line.trim()).filter((line, index) => line && !SNIPPET_LINE_PATTERNS.some(pattern => pattern.test(line)) && !isSocialList(line) &&
|
|
392
|
+
!(index === 0 && headline && normalized(line) === headline));
|
|
393
|
+
return lines.join("\n").trim();
|
|
394
|
+
}
|
|
395
|
+
/** Raised whenever the rules above change what they remove, so stored leads are derived again. */
|
|
396
|
+
export const LEAD_VERSION = 1;
|
|
397
|
+
const LEAD_LENGTH = 600;
|
|
398
|
+
function shorten(text, max) {
|
|
399
|
+
if (text.length <= max)
|
|
400
|
+
return text;
|
|
401
|
+
let slice = text.slice(0, max);
|
|
402
|
+
if (/[\uD800-\uDBFF]$/.test(slice))
|
|
403
|
+
slice = slice.slice(0, -1);
|
|
404
|
+
const sentence = Math.max(slice.lastIndexOf(". "), slice.lastIndexOf("! "), slice.lastIndexOf("? "));
|
|
405
|
+
if (sentence >= max * 0.5)
|
|
406
|
+
return slice.slice(0, sentence + 1);
|
|
407
|
+
const space = slice.lastIndexOf(" ");
|
|
408
|
+
return (space >= max * 0.5 ? slice.slice(0, space) : slice).replace(/[\s,;:–-]+$/, "") + "…";
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The beginning of an article as one plain line, for teasers: the cleaned body, or the cleaned feed summary when
|
|
412
|
+
* that says more (a failed download leaves only the summary).
|
|
413
|
+
*/
|
|
414
|
+
export function leadText(parts, max = LEAD_LENGTH) {
|
|
415
|
+
const body = derivedText(parts.html, parts.title);
|
|
416
|
+
const summary = flat(cleanSnippet(parts.snippet, parts.title));
|
|
417
|
+
return shorten(body.length >= summary.length ? body : summary, max);
|
|
418
|
+
}
|
|
419
|
+
//# sourceMappingURL=contentNoise.js.map
|
|
@@ -5,6 +5,7 @@ import { articleWorkPool, ArticleWorkBusyError, ArticleAccessDeniedError, Articl
|
|
|
5
5
|
import { requestFullText, authorizedFullTextRequester } from "./fullTextQueue.js";
|
|
6
6
|
import { SsrfBlockedError } from "./ssrfGuard.js";
|
|
7
7
|
import { improvesContent } from "./articleContent.js";
|
|
8
|
+
import { refreshLead } from "./lead.js";
|
|
8
9
|
import { canAccessItem, findItemById, findSubscriptionFullTextMode, updateItemFullContent } from "./repository.js";
|
|
9
10
|
// Kept for integrations importing the old constant; retries now belong to individual articles.
|
|
10
11
|
export const FULL_TEXT_BLOCK_THRESHOLD = 2;
|
|
@@ -24,6 +25,7 @@ export function cachedFullText(userId, itemId, force = false) {
|
|
|
24
25
|
if (item.full_content_html && isCachedConsentSnippet(item.full_content_html)) {
|
|
25
26
|
// Repair cached walls before any caller can mistake them for reader content again.
|
|
26
27
|
db.prepare("UPDATE items SET full_content_html = NULL, extraction_status = 'pending', extraction_retry_at = NULL WHERE id = ?").run(itemId);
|
|
28
|
+
refreshLead(itemId);
|
|
27
29
|
item.full_content_html = null;
|
|
28
30
|
}
|
|
29
31
|
if (!force && item.full_content_html && !isCachedConsentSnippet(item.full_content_html)) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { refreshLead } from "./lead.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { db } from "../db/index.js";
|
|
3
4
|
import { hasUserCapability } from "../auth/capabilities.js";
|
|
@@ -102,6 +103,7 @@ export async function saveToInbox(userId, input) {
|
|
|
102
103
|
RETURNING id`)
|
|
103
104
|
.get(inbox.id, guid, title, targetUrl, snippet, contentHtml, extractedPartial ? null : contentHtml, imageUrl, now, now, contentHtml && !extractedPartial ? "ready" : extractionStatus, extractedPartial ? new Date(Date.now() + 86400_000).toISOString() : null);
|
|
104
105
|
const itemId = row.id;
|
|
106
|
+
refreshLead(itemId);
|
|
105
107
|
bookmarkItem(userId, itemId);
|
|
106
108
|
let hasNote = false;
|
|
107
109
|
if (input.note?.trim()) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { db } from "../db/index.js";
|
|
3
|
+
import { LEAD_VERSION, leadText } from "./contentNoise.js";
|
|
4
|
+
const BACKFILL_BATCH = 50;
|
|
5
|
+
const BACKFILL_PAUSE_MS = 100;
|
|
6
|
+
const COMPUTED_LIMIT = 300;
|
|
7
|
+
const computed = new Map();
|
|
8
|
+
export function computeLead(item) {
|
|
9
|
+
return leadText({ html: item.full_content_html || item.content_html, snippet: item.content_snippet, title: item.title });
|
|
10
|
+
}
|
|
11
|
+
/** Derive and store the lead of one item; call after its title, summary or content was written. */
|
|
12
|
+
export function refreshLead(itemId) {
|
|
13
|
+
const item = db.prepare("SELECT title, content_snippet, content_html, full_content_html FROM items WHERE id = ?").get(itemId);
|
|
14
|
+
if (!item)
|
|
15
|
+
return;
|
|
16
|
+
db.prepare("UPDATE items SET content_lead = ?, lead_version = ? WHERE id = ?").run(computeLead(item), LEAD_VERSION, itemId);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The lead to serve: the stored one while its rules are current, otherwise derived on the spot, so an article is
|
|
20
|
+
* never served without one just because the backfill has not reached it.
|
|
21
|
+
*/
|
|
22
|
+
export function leadOf(item) {
|
|
23
|
+
if (item.lead_version === LEAD_VERSION)
|
|
24
|
+
return item.content_lead || null;
|
|
25
|
+
const key = createHash("sha1").update(item.title ?? "").update("\0").update(item.content_snippet ?? "").update("\0")
|
|
26
|
+
.update(item.full_content_html || item.content_html || "").digest("base64");
|
|
27
|
+
let lead = computed.get(key);
|
|
28
|
+
if (lead === undefined) {
|
|
29
|
+
lead = computeLead(item);
|
|
30
|
+
if (computed.size >= COMPUTED_LIMIT)
|
|
31
|
+
computed.delete(computed.keys().next().value);
|
|
32
|
+
computed.set(key, lead);
|
|
33
|
+
}
|
|
34
|
+
return lead || null;
|
|
35
|
+
}
|
|
36
|
+
/** Derive leads for items that have none or were derived by older rules; returns how many were handled. */
|
|
37
|
+
export function backfillLeads(limit = BACKFILL_BATCH) {
|
|
38
|
+
const rows = db.prepare("SELECT id, title, content_snippet, content_html, full_content_html FROM items WHERE lead_version IS NULL OR lead_version <> ? LIMIT ?").all(LEAD_VERSION, limit);
|
|
39
|
+
const update = db.prepare("UPDATE items SET content_lead = ?, lead_version = ? WHERE id = ?");
|
|
40
|
+
db.transaction(() => { for (const row of rows)
|
|
41
|
+
update.run(computeLead(row), LEAD_VERSION, row.id); })();
|
|
42
|
+
return rows.length;
|
|
43
|
+
}
|
|
44
|
+
/** Works through the backlog in small steps so polling and requests are never held up for long. */
|
|
45
|
+
export function startLeadBackfill() {
|
|
46
|
+
let total = 0;
|
|
47
|
+
const step = () => {
|
|
48
|
+
const handled = backfillLeads();
|
|
49
|
+
total += handled;
|
|
50
|
+
if (handled === BACKFILL_BATCH)
|
|
51
|
+
setTimeout(step, BACKFILL_PAUSE_MS).unref();
|
|
52
|
+
else if (total > 0)
|
|
53
|
+
console.log(`[db] derived the lead of ${total} stored items`);
|
|
54
|
+
};
|
|
55
|
+
setTimeout(step, 1000).unref();
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=lead.js.map
|
|
@@ -88,6 +88,12 @@ function extractImageUrl(item) {
|
|
|
88
88
|
}
|
|
89
89
|
return null;
|
|
90
90
|
}
|
|
91
|
+
/** The article address: its link, or its guid when the feed gives only that and the guid is a web address. */
|
|
92
|
+
export function itemLink(item) {
|
|
93
|
+
if (item.link)
|
|
94
|
+
return item.link;
|
|
95
|
+
return item.guid && /^https?:\/\//i.test(item.guid) ? item.guid : undefined;
|
|
96
|
+
}
|
|
91
97
|
/** One attempt; the queue owns retries, admission, single-flight and the total deadline. */
|
|
92
98
|
async function pollAttempt(feed, signal) {
|
|
93
99
|
const fetched = await fetchFeed(feed.url, { etag: feed.etag ?? undefined, lastModified: feed.last_modified ?? undefined, signal });
|
|
@@ -101,7 +107,7 @@ async function pollAttempt(feed, signal) {
|
|
|
101
107
|
return { newItems: 0, error: "feed_poll_not_allowed" };
|
|
102
108
|
const items = parsed?.items.map(item => ({
|
|
103
109
|
guid: item.guid ?? item.link ?? item.title ?? crypto.randomUUID(),
|
|
104
|
-
title: plainTitle(item.title), link: item
|
|
110
|
+
title: plainTitle(item.title), link: itemLink(item),
|
|
105
111
|
contentSnippet: decodeEntities(item.contentSnippet ?? item.content) ?? undefined,
|
|
106
112
|
contentHtml: item.contentEncoded ?? item["content:encoded"] ?? item.content ?? null,
|
|
107
113
|
publishedAt: normalizePublishedAt(item.isoDate ?? item.pubDate) ?? undefined, imageUrl: extractImageUrl(item),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { db } from "../db/index.js";
|
|
2
2
|
import { articleText } from "./articleContent.js";
|
|
3
|
+
import { LEAD_VERSION } from "./contentNoise.js";
|
|
4
|
+
import { refreshLead } from "./lead.js";
|
|
3
5
|
import { decodeEntities } from "./text.js";
|
|
4
6
|
export function findFeedByUrl(url) {
|
|
5
7
|
return db.prepare("SELECT * FROM feeds WHERE url = ?").get(url);
|
|
@@ -160,14 +162,26 @@ export function upsertItems(feedId, items) {
|
|
|
160
162
|
const result = insert.run(...values);
|
|
161
163
|
if (result.changes > 0) {
|
|
162
164
|
inserted++;
|
|
165
|
+
refreshLead(Number(result.lastInsertRowid));
|
|
163
166
|
}
|
|
164
167
|
else {
|
|
165
|
-
const previous = db.prepare("SELECT content_html FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
|
|
168
|
+
const previous = db.prepare("SELECT id, title, content_snippet, content_html, full_content_html, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
|
|
166
169
|
const next = row.contentHtml;
|
|
167
170
|
// Some publishers replace complete feed bodies with short teasers on a later poll.
|
|
168
171
|
const retained = previous?.content_html && next && next.length < previous.content_html.length * 0.9 &&
|
|
169
172
|
articleText(next).length < articleText(previous.content_html).length * 0.9 ? previous.content_html : next ?? null;
|
|
170
|
-
|
|
173
|
+
// The summary belongs to the body it was sent with: keeping the longer body keeps its summary too, or
|
|
174
|
+
// a feed that alternates between two variants would change the content on every poll.
|
|
175
|
+
const keptPrevious = retained !== null && next !== retained;
|
|
176
|
+
update.run(row.title ?? null, row.link ?? null, keptPrevious ? null : row.contentSnippet ?? null, retained, row.publishedAt ?? null, row.imageUrl ?? null, feedId, row.guid);
|
|
177
|
+
// Polling rewrites every item; only a change of what the lead is made from needs it derived again.
|
|
178
|
+
if (previous) {
|
|
179
|
+
const changed = (retained !== null && retained !== previous.content_html) ||
|
|
180
|
+
(row.title !== undefined && row.title !== previous.title) ||
|
|
181
|
+
(!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet);
|
|
182
|
+
if (previous.lead_version !== LEAD_VERSION || (changed && !previous.full_content_html))
|
|
183
|
+
refreshLead(previous.id);
|
|
184
|
+
}
|
|
171
185
|
}
|
|
172
186
|
}
|
|
173
187
|
return inserted;
|
|
@@ -410,6 +424,7 @@ export function findItemById(id) {
|
|
|
410
424
|
}
|
|
411
425
|
export function updateItemFullContent(id, fullContentHtml) {
|
|
412
426
|
db.prepare("UPDATE items SET full_content_html = ? WHERE id = ?").run(fullContentHtml, id);
|
|
427
|
+
refreshLead(id);
|
|
413
428
|
}
|
|
414
429
|
/** Apply a per-item change to many items atomically; returns how many rows changed. */
|
|
415
430
|
export function applyToItems(itemIds, change) {
|
package/server/dist/mcp/items.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { contentMetadata, sourceText } from "../feeds/articleContent.js";
|
|
1
|
+
import { contentMetadata, readerHtml, sourceText } from "../feeds/articleContent.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { findItemForUser, listItemsForUser } from "../feeds/repository.js";
|
|
4
4
|
const cursorSchema = z.object({
|
|
@@ -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 = item.full_content_html ?? item.content_html;
|
|
54
|
+
const content = readerHtml(item.full_content_html ?? item.content_html, item.title);
|
|
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]))
|
|
@@ -500,7 +500,7 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
500
500
|
if (scope === "write")
|
|
501
501
|
server.registerTool("publish_edition", {
|
|
502
502
|
title: "Publish curated edition",
|
|
503
|
-
description: "Publishes an ordered list of 1 to 24 accessible article IDs. Curated editions override automatic ones until expiry (default 24 hours, maximum seven days). App-only hidden sections and muted keywords are respected. Supply the revision from get_edition as expectedRevision and a UUID requestId for conflict detection and safe retries. Optional title and summary are editorial text; original articles stay unchanged.",
|
|
503
|
+
description: "Publishes an ordered list of 1 to 24 accessible article IDs. Curated editions override automatic ones until expiry (default 24 hours, maximum seven days). App-only hidden sections and muted keywords are respected. Supply the revision from get_edition as expectedRevision and a UUID requestId for conflict detection and safe retries. Optional title and summary are editorial text; original articles stay unchanged. Optional layout (version 1) says how the issue is set: a character (dominant, calm or briefing) and blocks (lead, dossier, feature, standard, briefs, photo, quote) that reference the issue's article IDs at most once each; it never changes the articles.",
|
|
504
504
|
inputSchema: publishEditionFields,
|
|
505
505
|
}, async (input) => capabilityAction("editions", () => publishCuratedEdition(userId, input)));
|
|
506
506
|
if (scope === "write")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { NativeError } from "./requests.js";
|
|
3
|
+
/**
|
|
4
|
+
* A presentation plan for an edition. It says what the issue's writer considers important and what belongs together,
|
|
5
|
+
* never how a screen draws it: clients map the roles onto their own columns, sizes and type.
|
|
6
|
+
*
|
|
7
|
+
* Versioned and strict. A client that meets a block type it does not know sets that block's stories as plain ones.
|
|
8
|
+
*/
|
|
9
|
+
const itemId = z.number().int().positive();
|
|
10
|
+
/** A short label above a headline: a classification, not a statement. */
|
|
11
|
+
const kicker = z.string().trim().min(1).max(40)
|
|
12
|
+
.refine((value) => !/[\r\n]/.test(value) && !/[.!?:;,]$/.test(value), "A kicker is one short phrase without closing punctuation");
|
|
13
|
+
const story = { itemId, kicker: kicker.optional() };
|
|
14
|
+
const block = z.discriminatedUnion("type", [
|
|
15
|
+
/** The story of the day; only once and only first. */
|
|
16
|
+
z.object({ type: z.literal("lead"), ...story }).strict(),
|
|
17
|
+
/** One overview topic (1-based position in `overview`) with the stories it is about. */
|
|
18
|
+
z.object({ type: z.literal("dossier"), topic: z.number().int().min(1).max(5), itemIds: z.array(itemId).min(1).max(4) }).strict(),
|
|
19
|
+
z.object({ type: z.literal("feature"), ...story }).strict(),
|
|
20
|
+
z.object({ type: z.literal("standard"), stories: z.array(z.object(story).strict()).min(1).max(3) }).strict(),
|
|
21
|
+
z.object({ type: z.literal("briefs"), itemIds: z.array(itemId).min(2).max(8), title: z.string().trim().min(1).max(60).optional() }).strict(),
|
|
22
|
+
z.object({ type: z.literal("photo"), itemId }).strict(),
|
|
23
|
+
/** A statement of an article set large. The issuer vouches that the text is quoted from the article. */
|
|
24
|
+
z.object({ type: z.literal("quote"), itemId, text: z.string().trim().min(1).max(160) }).strict(),
|
|
25
|
+
]);
|
|
26
|
+
export const editionLayoutSchema = z.object({
|
|
27
|
+
version: z.literal(1),
|
|
28
|
+
character: z.enum(["dominant", "calm", "briefing"]),
|
|
29
|
+
blocks: z.array(block).min(1).max(24),
|
|
30
|
+
}).strict();
|
|
31
|
+
function blockItemIds(block) {
|
|
32
|
+
switch (block.type) {
|
|
33
|
+
case "standard": return block.stories.map((entry) => entry.itemId);
|
|
34
|
+
case "dossier":
|
|
35
|
+
case "briefs": return block.itemIds;
|
|
36
|
+
default: return [block.itemId];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Checks a well-formed plan against the issue it is published with: every story is in the issue and appears once,
|
|
41
|
+
* the lead stands first, and every topic exists and has one dossier at most.
|
|
42
|
+
*/
|
|
43
|
+
export function checkEditionLayout(layout, itemIds, topicCount) {
|
|
44
|
+
const allowed = new Set(itemIds);
|
|
45
|
+
const used = new Set();
|
|
46
|
+
const topics = new Set();
|
|
47
|
+
layout.blocks.forEach((block, index) => {
|
|
48
|
+
if (block.type === "lead" && index !== 0)
|
|
49
|
+
throw new NativeError("invalid_layout");
|
|
50
|
+
if (block.type === "dossier") {
|
|
51
|
+
if (block.topic > topicCount)
|
|
52
|
+
throw new NativeError("invalid_layout_reference");
|
|
53
|
+
if (topics.has(block.topic))
|
|
54
|
+
throw new NativeError("invalid_layout");
|
|
55
|
+
topics.add(block.topic);
|
|
56
|
+
}
|
|
57
|
+
for (const id of blockItemIds(block)) {
|
|
58
|
+
if (!allowed.has(id))
|
|
59
|
+
throw new NativeError("invalid_layout_reference");
|
|
60
|
+
if (used.has(id))
|
|
61
|
+
throw new NativeError("invalid_layout");
|
|
62
|
+
used.add(id);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=editionLayout.js.map
|
|
@@ -7,6 +7,7 @@ import { canAccessItem } from "../feeds/repository.js";
|
|
|
7
7
|
import { decodeEntities } from "../feeds/text.js";
|
|
8
8
|
import { editionPeriod, nextEditionBoundary, selectEdition } from "./editionSelection.js";
|
|
9
9
|
import { getNativePreferences } from "./preferences.js";
|
|
10
|
+
import { checkEditionLayout, editionLayoutSchema } from "./editionLayout.js";
|
|
10
11
|
import { NativeError, nativeRequest } from "./requests.js";
|
|
11
12
|
export const overviewSchema = z.array(z.object({
|
|
12
13
|
heading: z.string().trim().min(1).max(160),
|
|
@@ -24,7 +25,8 @@ function serialize(row) {
|
|
|
24
25
|
id: row.edition_id, revision: row.revision, source: row.source,
|
|
25
26
|
createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at,
|
|
26
27
|
period: row.period?.split(":")[1] ?? editionPeriod(new Date(row.created_at), prefs.timeZone).period,
|
|
27
|
-
title: row.title, summary: row.summary, overview: row.overview ? JSON.parse(row.overview) : null,
|
|
28
|
+
title: row.title, summary: row.summary, overview: row.overview ? JSON.parse(row.overview) : null, layout: row.layout ? JSON.parse(row.layout) : null,
|
|
29
|
+
preferencesRevision: row.preferences_revision,
|
|
28
30
|
itemIds: JSON.parse(row.item_ids),
|
|
29
31
|
};
|
|
30
32
|
}
|
|
@@ -43,18 +45,19 @@ function checkRevision(userId, expectedRevision, now) {
|
|
|
43
45
|
if (expectedRevision !== undefined && current.revision !== expectedRevision)
|
|
44
46
|
throw new NativeError("revision_conflict", 409, current);
|
|
45
47
|
}
|
|
46
|
-
function writeEdition(userId, itemIds, source, expiry, now, title = null, summary = null, overview = null) {
|
|
48
|
+
function writeEdition(userId, itemIds, source, expiry, now, title = null, summary = null, overview = null, layout = null) {
|
|
47
49
|
const prefs = getNativePreferences(userId);
|
|
48
50
|
const id = randomUUID();
|
|
49
51
|
const revision = (storedEdition(userId)?.revision ?? 0) + 1;
|
|
50
52
|
const created = now.toISOString();
|
|
51
|
-
db.prepare(`INSERT INTO editions (user_id, edition_id, item_ids, revision, source, status, created_at, updated_at, expires_at, period, title, summary, preferences_revision, overview)
|
|
52
|
-
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?)
|
|
53
|
+
db.prepare(`INSERT INTO editions (user_id, edition_id, item_ids, revision, source, status, created_at, updated_at, expires_at, period, title, summary, preferences_revision, overview, layout)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
53
55
|
ON CONFLICT(user_id) DO UPDATE SET edition_id = excluded.edition_id, item_ids = excluded.item_ids,
|
|
54
56
|
revision = excluded.revision, source = excluded.source, status = 'active', created_at = excluded.created_at,
|
|
55
57
|
updated_at = excluded.updated_at, expires_at = excluded.expires_at, period = excluded.period,
|
|
56
|
-
title = excluded.title, summary = excluded.summary, preferences_revision = excluded.preferences_revision, overview = excluded.overview
|
|
57
|
-
|
|
58
|
+
title = excluded.title, summary = excluded.summary, preferences_revision = excluded.preferences_revision, overview = excluded.overview,
|
|
59
|
+
layout = excluded.layout`)
|
|
60
|
+
.run(userId, id, JSON.stringify(itemIds), revision, source, created, created, expiry.toISOString(), editionPeriod(now, prefs.timeZone).key, title, summary, prefs.revision, overview ? JSON.stringify(overview.map((block, index) => ({ id: `topic-${index + 1}`, ...block }))) : null, layout ? JSON.stringify(layout) : null);
|
|
58
61
|
db.prepare("INSERT INTO edition_history (user_id, edition_id, item_ids, revision, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
59
62
|
.run(userId, id, JSON.stringify(itemIds), revision, created);
|
|
60
63
|
db.prepare(`DELETE FROM edition_history WHERE user_id = ? AND edition_id NOT IN
|
|
@@ -71,11 +74,14 @@ export const publishEditionFields = {
|
|
|
71
74
|
title: z.string().trim().min(1).max(200).optional(),
|
|
72
75
|
summary: z.string().trim().min(1).max(2000).optional(),
|
|
73
76
|
overview: overviewSchema.optional(),
|
|
77
|
+
layout: editionLayoutSchema.optional(),
|
|
74
78
|
};
|
|
75
79
|
const publishSchema = z.object(publishEditionFields).strict().refine((input) => !(input.expiresAt && input.durationHours), "Use expiresAt or durationHours, not both");
|
|
76
80
|
/** Both automatic and MCP editions obey the app's hard exclusions. Read/saved articles
|
|
77
81
|
* may be selected deliberately by an agent, but foreign and muted items cannot be published. */
|
|
78
82
|
export function publishCuratedEdition(userId, input, now = new Date()) {
|
|
83
|
+
if (input.layout !== undefined && !editionLayoutSchema.safeParse(input.layout).success)
|
|
84
|
+
throw new NativeError("invalid_layout");
|
|
79
85
|
const parsed = publishSchema.safeParse(input);
|
|
80
86
|
if (!parsed.success)
|
|
81
87
|
throw new NativeError("invalid_input");
|
|
@@ -88,6 +94,8 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
|
|
|
88
94
|
block.segments.some((segment) => segment.itemId !== undefined && !data.itemIds.includes(segment.itemId)))) {
|
|
89
95
|
throw new NativeError("invalid_overview_reference");
|
|
90
96
|
}
|
|
97
|
+
if (data.layout)
|
|
98
|
+
checkEditionLayout(data.layout, data.itemIds, data.overview?.length ?? 0);
|
|
91
99
|
const prefs = getNativePreferences(userId);
|
|
92
100
|
for (const id of data.itemIds) {
|
|
93
101
|
if (!canAccessItem(userId, id))
|
|
@@ -103,7 +111,7 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
|
|
|
103
111
|
const expiry = data.expiresAt ? new Date(data.expiresAt) : new Date(now.getTime() + (data.durationHours ?? 24) * 3_600_000);
|
|
104
112
|
if (expiry.getTime() <= now.getTime() || expiry.getTime() > now.getTime() + 168 * 3_600_000)
|
|
105
113
|
throw new NativeError("invalid_expiry");
|
|
106
|
-
return writeEdition(userId, data.itemIds, "curated", expiry, now, data.title ?? null, data.summary ?? (data.overview?.map((block) => `${block.heading}: ${block.segments.map((segment) => segment.text).join("")}`).join("\n\n").slice(0, 2000).replace(/[\uD800-\uDBFF]$/, "") ?? null), data.overview ?? null);
|
|
114
|
+
return writeEdition(userId, data.itemIds, "curated", expiry, now, data.title ?? null, data.summary ?? (data.overview?.map((block) => `${block.heading}: ${block.segments.map((segment) => segment.text).join("")}`).join("\n\n").slice(0, 2000).replace(/[\uD800-\uDBFF]$/, "") ?? null), data.overview ?? null, data.layout ?? null);
|
|
107
115
|
}, now);
|
|
108
116
|
}
|
|
109
117
|
/** Compatibility entry point for existing internal callers. New agents supply a
|
package/server/dist/server.js
CHANGED
|
@@ -19,12 +19,14 @@ import { startCleanupScheduler } from "./feeds/cleanup.js";
|
|
|
19
19
|
import { repairEncodedText } from "./feeds/repository.js";
|
|
20
20
|
import { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
|
|
21
21
|
import { startEditionScheduler } from "./native/editions.js";
|
|
22
|
+
import { startLeadBackfill } from "./feeds/lead.js";
|
|
22
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
24
|
const WEB_DIST = join(__dirname, "../../web/dist");
|
|
24
25
|
runMigrations();
|
|
25
26
|
const repairedItems = repairEncodedText();
|
|
26
27
|
if (repairedItems > 0)
|
|
27
28
|
console.log(`[db] decoded HTML entities in ${repairedItems} stored items`);
|
|
29
|
+
startLeadBackfill();
|
|
28
30
|
pruneArchive();
|
|
29
31
|
const pendingArchives = scheduleMissingArchives();
|
|
30
32
|
if (pendingArchives > 0)
|