feedkeeper 0.28.1 → 0.28.3
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))) {
|
|
@@ -11,7 +11,7 @@ const FEED_MIME_TYPES = [
|
|
|
11
11
|
"application/xml",
|
|
12
12
|
];
|
|
13
13
|
const MAX_PROBES = 15;
|
|
14
|
-
const COMMON_PATHS = ["/feed", "/rss.xml", "/atom.xml", "/feed.xml", "/index.xml", "/rss", "/rss.php?feed=RSS2.0", "/rss.php?feed=ATOM1.0"];
|
|
14
|
+
const COMMON_PATHS = ["/feed/", "/feed", "/rss.xml", "/atom.xml", "/feed.xml", "/index.xml", "/rss/", "/rss", "/rss.php?feed=RSS2.0", "/rss.php?feed=ATOM1.0"];
|
|
15
15
|
// Feed-looking URLs anywhere in a page, including JSON embedded in attributes.
|
|
16
16
|
const FEED_URL_IN_TEXT = /https?:\/\/[^\s"'<>\\]+?(?:\.rss|\.atom|\/feed\/?|\/(?:rss|atom|feed)\.xml)(?=["'\s<>\\]|$)/gi;
|
|
17
17
|
function sameUrl(a, b) {
|
|
@@ -28,18 +28,23 @@ function sameUrl(a, b) {
|
|
|
28
28
|
return normalize(a) === normalize(b);
|
|
29
29
|
}
|
|
30
30
|
async function parseFeedAt(url) {
|
|
31
|
-
const fetched = await fetchFeed(url);
|
|
32
|
-
const finalUrl = fetched.finalUrl || url;
|
|
33
31
|
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
const fetched = await fetchFeed(url);
|
|
33
|
+
const finalUrl = fetched.finalUrl || url;
|
|
34
|
+
try {
|
|
35
|
+
const parsed = await rssParser.parseString(fetched.body);
|
|
36
|
+
if (parsed.items && (parsed.title || parsed.description || parsed.items.length > 0)) {
|
|
37
|
+
return { feed: { url: finalUrl, title: plainTitle(parsed.title) ?? null, type: "application/rss+xml" }, html: null, finalUrl };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Not a feed; the caller may look for feed links in the HTML instead.
|
|
37
42
|
}
|
|
43
|
+
return { feed: null, html: fetched.body, finalUrl };
|
|
38
44
|
}
|
|
39
45
|
catch {
|
|
40
|
-
|
|
46
|
+
return { feed: null, html: null, finalUrl: url };
|
|
41
47
|
}
|
|
42
|
-
return { feed: null, html: fetched.body, finalUrl };
|
|
43
48
|
}
|
|
44
49
|
/**
|
|
45
50
|
* Keep only candidates that really are feeds. Sites sometimes announce their own
|
|
@@ -75,22 +80,29 @@ export async function discoverFeeds(inputUrl) {
|
|
|
75
80
|
const page = await parseFeedAt(targetUrl);
|
|
76
81
|
if (page.feed)
|
|
77
82
|
return [page.feed];
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
// 1b. If the URL did not end in a slash, try with a trailing slash (e.g. /feed -> /feed/)
|
|
84
|
+
if (!targetUrl.endsWith("/")) {
|
|
85
|
+
const slashCandidate = await parseFeedAt(targetUrl + "/");
|
|
86
|
+
if (slashCandidate.feed)
|
|
87
|
+
return [slashCandidate.feed];
|
|
88
|
+
}
|
|
80
89
|
// 2. Feeds announced or linked on the page.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
if (page.html) {
|
|
91
|
+
const fromPage = await keepRealFeeds(extractFeedsFromHtml(page.html, page.finalUrl), page.finalUrl);
|
|
92
|
+
if (fromPage.length > 0)
|
|
93
|
+
return fromPage;
|
|
94
|
+
}
|
|
84
95
|
// 3. Common feed paths; some lead to an overview page that lists the site's feeds.
|
|
96
|
+
const probeBase = page.finalUrl || targetUrl;
|
|
85
97
|
for (const path of COMMON_PATHS) {
|
|
86
98
|
try {
|
|
87
|
-
const candidateUrl = new URL(path,
|
|
99
|
+
const candidateUrl = new URL(path, probeBase).toString();
|
|
88
100
|
if (sameUrl(candidateUrl, targetUrl))
|
|
89
101
|
continue;
|
|
90
102
|
const candidate = await parseFeedAt(candidateUrl);
|
|
91
103
|
if (candidate.feed)
|
|
92
104
|
return [candidate.feed];
|
|
93
|
-
if (candidate.html && !sameUrl(candidate.finalUrl,
|
|
105
|
+
if (candidate.html && !sameUrl(candidate.finalUrl, probeBase)) {
|
|
94
106
|
const listed = await keepRealFeeds(extractFeedsFromHtml(candidate.html, candidate.finalUrl), candidate.finalUrl);
|
|
95
107
|
if (listed.length > 0)
|
|
96
108
|
return listed;
|
|
@@ -64,7 +64,10 @@ export async function subscribeToFeed(userId, inputUrl, label = null, folderId =
|
|
|
64
64
|
// (e.g. a homepage that announces itself as RSS); don't keep the subscription.
|
|
65
65
|
if (pollResult.error && !pollResult.deferred && !findFeedById(feed.id)?.last_success_at) {
|
|
66
66
|
unsubscribeRepo(userId, feed.id);
|
|
67
|
-
|
|
67
|
+
if (pollResult.error === "feed_invalid_or_unavailable") {
|
|
68
|
+
throw new NoFeedsFoundError();
|
|
69
|
+
}
|
|
70
|
+
throw new FeedError(pollResult.error);
|
|
68
71
|
}
|
|
69
72
|
const subscription = findSubscriptionForUser(userId, feed.id);
|
|
70
73
|
if (!subscription)
|
|
@@ -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,
|