feedkeeper 0.28.1 → 0.28.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -143,6 +143,32 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
143
143
|
const headline = normalized(title ?? "");
|
|
144
144
|
const seen = new Set();
|
|
145
145
|
const authorKeys = (author ?? "").split(",").map(name => normalized(name)).filter(Boolean);
|
|
146
|
+
const cleanByline = (block, text) => {
|
|
147
|
+
if (!BYLINE.test(text))
|
|
148
|
+
return;
|
|
149
|
+
if (authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
|
|
150
|
+
drop(block, "byline");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (stripBylineDate(block)) {
|
|
154
|
+
stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
|
|
155
|
+
changed = true;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
// Some publishers use an inline wrapper as a standalone byline. Never treat spans inside prose as metadata.
|
|
159
|
+
for (const span of root.querySelectorAll("span")) {
|
|
160
|
+
if (!root.contains(span) || span.closest(BLOCKS) || span.querySelector(`${BLOCKS}, div`))
|
|
161
|
+
continue;
|
|
162
|
+
const text = flat(span.textContent);
|
|
163
|
+
if (text.length > 60 || !BYLINE.test(text) || charsBefore(root, span) > 800)
|
|
164
|
+
continue;
|
|
165
|
+
if ([...(span.parentNode?.childNodes ?? [])].some(node => node !== span && node.nodeType === 3 && flat(node.textContent)))
|
|
166
|
+
continue;
|
|
167
|
+
if ([...(span.parentElement?.children ?? [])].some(sibling => sibling !== span &&
|
|
168
|
+
!sibling.matches(`${BLOCKS}, div, article, section, header, footer, figure, ul, ol, table, aside`) && flat(sibling.textContent)))
|
|
169
|
+
continue;
|
|
170
|
+
cleanByline(span, text);
|
|
171
|
+
}
|
|
146
172
|
let leading = 0;
|
|
147
173
|
let reachedText = false;
|
|
148
174
|
let pastStart = false;
|
|
@@ -183,14 +209,9 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
183
209
|
continue;
|
|
184
210
|
}
|
|
185
211
|
// When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
|
|
186
|
-
|
|
187
|
-
|
|
212
|
+
cleanByline(block, text);
|
|
213
|
+
if (!root.contains(block))
|
|
188
214
|
continue;
|
|
189
|
-
}
|
|
190
|
-
if (BYLINE.test(text) && stripBylineDate(block)) {
|
|
191
|
-
stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
|
|
192
|
-
changed = true;
|
|
193
|
-
}
|
|
194
215
|
}
|
|
195
216
|
}
|
|
196
217
|
if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { JSDOM } from "jsdom";
|
|
2
|
+
import { decodeEntities } from "../feeds/text.js";
|
|
3
|
+
const PROMPTS = new Set([
|
|
4
|
+
"mehr", "weiterlesen", "mehr lesen", "read more", "more", "continue reading",
|
|
5
|
+
"video", "zum video", "video ansehen", "video abspielen", "watch video", "watch the video", "play video",
|
|
6
|
+
"audio", "zum audio", "audio anhören", "listen", "listen now", "listen to audio",
|
|
7
|
+
"続きを読む", "もっと読む", "動画を見る", "動画を再生", "音声を聞く",
|
|
8
|
+
]);
|
|
9
|
+
function normalize(text) {
|
|
10
|
+
return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
11
|
+
}
|
|
12
|
+
/** No length threshold: a short news sentence is useful, a media player or read-more link alone is not. */
|
|
13
|
+
export function hasEditionText(item) {
|
|
14
|
+
const title = normalize(decodeEntities(item.title) ?? "");
|
|
15
|
+
return [item.content_snippet, item.content_html, item.full_content_html].some(source => {
|
|
16
|
+
if (!source)
|
|
17
|
+
return false;
|
|
18
|
+
const fragment = JSDOM.fragment(source);
|
|
19
|
+
fragment.querySelectorAll("script, style, noscript, nav, footer, video, audio, iframe, object, embed").forEach(node => node.remove());
|
|
20
|
+
fragment.querySelectorAll("a, p, span, h1, h2, h3, h4").forEach(node => {
|
|
21
|
+
const text = normalize(node.textContent ?? "");
|
|
22
|
+
if (text === title || PROMPTS.has(text))
|
|
23
|
+
node.remove();
|
|
24
|
+
});
|
|
25
|
+
fragment.querySelectorAll("p, div, br, li, h1, h2, h3, h4, blockquote, figcaption").forEach(node => node.append("\n"));
|
|
26
|
+
return (fragment.textContent ?? "").split(/\n/).some(line => {
|
|
27
|
+
const text = normalize(line);
|
|
28
|
+
return text !== "" && text !== title && !PROMPTS.has(text);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=editionContent.js.map
|
|
@@ -7,6 +7,7 @@ import { editionPeriod, selectEdition } from "./editionSelection.js";
|
|
|
7
7
|
import { getNativePreferences } from "./preferences.js";
|
|
8
8
|
import { checkEditionLayout, editionLayoutSchema } from "./editionLayout.js";
|
|
9
9
|
import { NativeError, nativeRequest } from "./requests.js";
|
|
10
|
+
import { hasEditionText } from "./editionContent.js";
|
|
10
11
|
export const overviewSchema = z.array(z.object({
|
|
11
12
|
heading: z.string().trim().min(1).max(160),
|
|
12
13
|
segments: z.array(z.object({
|
|
@@ -109,10 +110,11 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
|
|
|
109
110
|
if (!canAccessItem(userId, id))
|
|
110
111
|
throw new NativeError("item_not_found", 404);
|
|
111
112
|
const item = db.prepare(`SELECT s.folder_id, s.id IS NOT NULL AS subscribed, i.is_empty_gallery,
|
|
113
|
+
i.title, i.content_snippet, i.content_html, i.full_content_html,
|
|
112
114
|
EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
|
|
113
115
|
(instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0)) AS muted
|
|
114
116
|
FROM items i LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ? WHERE i.id = ?`).get(userId, userId, id);
|
|
115
|
-
if (item.is_empty_gallery || item.muted || (item.subscribed && (item.folder_id === null ? !prefs.showUnfiled : prefs.hiddenFolderIds.includes(item.folder_id)))) {
|
|
117
|
+
if (item.is_empty_gallery || !hasEditionText(item) || item.muted || (item.subscribed && (item.folder_id === null ? !prefs.showUnfiled : prefs.hiddenFolderIds.includes(item.folder_id)))) {
|
|
116
118
|
throw new NativeError("item_excluded");
|
|
117
119
|
}
|
|
118
120
|
}
|
|
@@ -215,7 +217,7 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
|
|
|
215
217
|
const previous = new Set(previousItemIds);
|
|
216
218
|
// Fetch bounded candidates per source, so a busy feed cannot hide the others.
|
|
217
219
|
const rows = db.prepare(`SELECT r.id, r.feed_id, r.folder_id, r.title, r.link, r.content_snippet, r.image_url,
|
|
218
|
-
r.source_name, r.folder_name, r.published, r.source_rank,
|
|
220
|
+
r.source_name, r.folder_name, r.published, r.source_rank, full_item.content_html, full_item.full_content_html,
|
|
219
221
|
length(coalesce(full_item.full_content_html, full_item.content_html, r.content_snippet, '')) AS content_chars
|
|
220
222
|
FROM (
|
|
221
223
|
SELECT i.id, i.feed_id, s.folder_id, i.title, i.link, i.content_snippet, i.image_url,
|
|
@@ -233,7 +235,7 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
|
|
|
233
235
|
JOIN items full_item ON full_item.id = r.id
|
|
234
236
|
WHERE r.source_rank <= 40 ORDER BY r.source_rank, r.published DESC, r.id DESC LIMIT 4000`)
|
|
235
237
|
.all(userId, userId, userId, new Date(now.getTime() - 7 * 86_400_000).toISOString(), now.toISOString());
|
|
236
|
-
const scored = rows.filter((row) => row.folder_id === null ? prefs.showUnfiled : !prefs.hiddenFolderIds.includes(row.folder_id))
|
|
238
|
+
const scored = rows.filter((row) => (row.folder_id === null ? prefs.showUnfiled : !prefs.hiddenFolderIds.includes(row.folder_id)) && hasEditionText(row))
|
|
237
239
|
.map((row) => ({
|
|
238
240
|
id: row.id, sourceName: row.source_name, folderName: row.folder_name, subscriptionId: row.feed_id, folderId: row.folder_id,
|
|
239
241
|
title: row.title, url: row.link, publishedAt: row.published, snippet: decodeEntities(row.content_snippet) ?? null, imageUrl: row.image_url,
|