feedkeeper 0.35.3 → 0.35.5
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 +3 -3
- package/server/dist/api/auth.js +1 -1
- package/server/dist/api/feeds.js +1 -1
- package/server/dist/api/folders.js +11 -8
- package/server/dist/api/items.js +3 -3
- package/server/dist/api/onboarding.js +2 -1
- package/server/dist/api/v1/resources.js +14 -19
- package/server/dist/api/v1/serialize.js +1 -1
- package/server/dist/auth/middleware.js +2 -2
- package/server/dist/db/migrations/0041_sync_feed_metadata.sql +16 -0
- package/server/dist/feeds/archive.js +3 -2
- 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 +23 -0
- package/server/dist/feeds/content/noise/videoPages.js +67 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/de.js +3 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/en.js +3 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/index.js +10 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/ja.js +3 -0
- package/server/dist/feeds/content/patterns/video-pages/de.js +3 -0
- package/server/dist/feeds/content/patterns/video-pages/en.js +3 -0
- package/server/dist/feeds/content/patterns/video-pages/ja.js +3 -0
- package/server/dist/feeds/contentNoise.js +21 -5
- package/server/dist/feeds/extractor.js +2 -1
- package/server/dist/feeds/feedIcon.js +4 -2
- package/server/dist/feeds/imageSources.js +69 -0
- package/server/dist/feeds/inbox.js +4 -9
- package/server/dist/feeds/opml.js +122 -27
- package/server/dist/feeds/repository.js +19 -7
- package/server/dist/mcp/items.js +1 -1
- package/server/dist/mcp/server.js +17 -3
- package/server/dist/server.js +1 -1
- package/server/dist/sync/changeLog.js +1 -0
- package/server/package.json +1 -1
- package/web/dist/assets/index-CilgZLF5.js +32 -0
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/index-CYi9R_Ds.js +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedkeeper",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.5",
|
|
4
4
|
"description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rss",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"cors": "^2.8.6",
|
|
45
45
|
"dotenv": "^18.0.8",
|
|
46
46
|
"express": "^5.2.1",
|
|
47
|
-
"express-rate-limit": "^8.7.
|
|
47
|
+
"express-rate-limit": "^8.7.2",
|
|
48
48
|
"fast-xml-parser": "^5.11.2",
|
|
49
49
|
"helmet": "^8.3.0",
|
|
50
50
|
"ipaddr.js": "^2.5.0",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"node-cron": "^4.6.0",
|
|
53
53
|
"otpauth": "^9.5.2",
|
|
54
54
|
"rss-parser": "^3.13.0",
|
|
55
|
-
"undici": "^8.12.
|
|
55
|
+
"undici": "^8.12.3",
|
|
56
56
|
"web-push": "^3.6.7",
|
|
57
57
|
"zod": "^4.6.5"
|
|
58
58
|
}
|
package/server/dist/api/auth.js
CHANGED
|
@@ -161,7 +161,7 @@ authRouter.post("/signup", (req, res) => {
|
|
|
161
161
|
return;
|
|
162
162
|
}
|
|
163
163
|
const user = createUser({ ...parsed.data, role: "user" });
|
|
164
|
-
req.session
|
|
164
|
+
req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
|
|
165
165
|
res.status(201).json(toPublicUser(user));
|
|
166
166
|
});
|
|
167
167
|
//# sourceMappingURL=auth.js.map
|
package/server/dist/api/feeds.js
CHANGED
|
@@ -54,7 +54,7 @@ feedsRouter.get("/:feedId/icon", iconLimiter, async (req, res) => {
|
|
|
54
54
|
});
|
|
55
55
|
feedsRouter.get("/opml", (req, res) => {
|
|
56
56
|
const opml = generateOpml(req.user.id);
|
|
57
|
-
res.setHeader("Content-Type", "
|
|
57
|
+
res.setHeader("Content-Type", "text/x-opml; charset=utf-8");
|
|
58
58
|
res.setHeader("Content-Disposition", 'attachment; filename="feedkeeper-subscriptions.opml"');
|
|
59
59
|
res.send(opml);
|
|
60
60
|
});
|
|
@@ -19,11 +19,7 @@ foldersRouter.post("/", requireCapability("sync"), (req, res) => {
|
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
21
|
const folder = createFolder(req.user.id, parsed.data.name);
|
|
22
|
-
res.status(201).json(
|
|
23
|
-
...folder,
|
|
24
|
-
feed_count: 0,
|
|
25
|
-
unread_count: 0,
|
|
26
|
-
});
|
|
22
|
+
res.status(201).json(listFoldersForUser(req.user.id).find((entry) => entry.id === folder.id));
|
|
27
23
|
});
|
|
28
24
|
foldersRouter.patch("/:folderId", (req, res) => {
|
|
29
25
|
const folderId = Number(req.params.folderId);
|
|
@@ -37,15 +33,22 @@ foldersRouter.patch("/:folderId", (req, res) => {
|
|
|
37
33
|
res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
|
|
38
34
|
return;
|
|
39
35
|
}
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
try {
|
|
37
|
+
const updated = updateFolder(req.user.id, folderId, parsed.data.name);
|
|
38
|
+
res.json(updated);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (!(error instanceof Error && "code" in error && error.code === "SQLITE_CONSTRAINT_UNIQUE"))
|
|
42
|
+
throw error;
|
|
43
|
+
res.status(409).json({ error: "folder_name_taken" });
|
|
44
|
+
}
|
|
42
45
|
});
|
|
43
46
|
foldersRouter.delete("/:folderId", (req, res) => {
|
|
44
47
|
const folderId = Number(req.params.folderId);
|
|
45
48
|
deleteFolder(req.user.id, folderId);
|
|
46
49
|
res.json({ success: true });
|
|
47
50
|
});
|
|
48
|
-
foldersRouter.post("/:folderId/read", (req, res) => {
|
|
51
|
+
foldersRouter.post("/:folderId/read", requireCapability("sync"), (req, res) => {
|
|
49
52
|
const folderId = Number(req.params.folderId);
|
|
50
53
|
const updatedCount = markAllRead(req.user.id, { folderId });
|
|
51
54
|
res.json({ read: updatedCount });
|
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
|
});
|
|
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { hasAnyUser } from "../db/index.js";
|
|
4
4
|
import { createUser } from "../auth/users.js";
|
|
5
|
+
import { securityVersion } from "../auth/security.js";
|
|
5
6
|
export const onboardingRouter = Router();
|
|
6
7
|
onboardingRouter.get("/status", (_req, res) => {
|
|
7
8
|
res.json({ needsOnboarding: !hasAnyUser() });
|
|
@@ -24,7 +25,7 @@ onboardingRouter.post("/", (req, res) => {
|
|
|
24
25
|
return;
|
|
25
26
|
}
|
|
26
27
|
const user = createUser({ ...parsed.data, role: "admin" });
|
|
27
|
-
req.session
|
|
28
|
+
req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
|
|
28
29
|
res.status(201).json({ id: user.id, email: user.email, displayName: user.display_name, role: user.role });
|
|
29
30
|
});
|
|
30
31
|
//# sourceMappingURL=onboarding.js.map
|
|
@@ -53,7 +53,7 @@ const subscribeSchema = z.object({
|
|
|
53
53
|
label: z.string().max(200).nullable().optional(),
|
|
54
54
|
folderId: z.number().int().positive().nullable().optional(),
|
|
55
55
|
});
|
|
56
|
-
resourcesRouter.post("/subscriptions", async (req, res) => {
|
|
56
|
+
resourcesRouter.post("/subscriptions", requireCapability("sync"), async (req, res) => {
|
|
57
57
|
const parsed = subscribeSchema.safeParse(req.body);
|
|
58
58
|
if (!parsed.success)
|
|
59
59
|
return invalid(res, parsed.error.flatten());
|
|
@@ -97,6 +97,9 @@ resourcesRouter.patch("/subscriptions/:id", async (req, res) => {
|
|
|
97
97
|
if (!parsed.success)
|
|
98
98
|
return invalid(res, parsed.error.flatten());
|
|
99
99
|
const { url, ...settings } = parsed.data;
|
|
100
|
+
if (url && !hasUserCapability(req.user.id, "sync")) {
|
|
101
|
+
return void res.status(403).json(capabilityDenial(req.user.id, "sync"));
|
|
102
|
+
}
|
|
100
103
|
try {
|
|
101
104
|
let targetId = id;
|
|
102
105
|
if (url)
|
|
@@ -132,13 +135,13 @@ async function refresh(userId, id) {
|
|
|
132
135
|
const result = await pollFeed(feed);
|
|
133
136
|
return { subscription: subscriptionOf(userId, id), ...result };
|
|
134
137
|
}
|
|
135
|
-
resourcesRouter.post("/subscriptions/:id/refresh", async (req, res) => {
|
|
138
|
+
resourcesRouter.post("/subscriptions/:id/refresh", requireCapability("sync"), async (req, res) => {
|
|
136
139
|
const result = await refresh(req.user.id, Number(req.params.id));
|
|
137
140
|
if (!result)
|
|
138
141
|
return void res.status(404).json({ error: "not_found" });
|
|
139
142
|
res.json(result);
|
|
140
143
|
});
|
|
141
|
-
resourcesRouter.post("/refresh", async (req, res) => {
|
|
144
|
+
resourcesRouter.post("/refresh", requireCapability("sync"), async (req, res) => {
|
|
142
145
|
const subscriptions = listSubscriptionsForUser(req.user.id);
|
|
143
146
|
const { newItems, errors, deferred } = await pollFeeds(subscriptions);
|
|
144
147
|
res.json({ refreshed: subscriptions.length, newItems, errors, deferred });
|
|
@@ -166,7 +169,7 @@ resourcesRouter.get("/subscriptions/:id/icon", imageLimiter, async (req, res) =>
|
|
|
166
169
|
res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
|
167
170
|
res.type(icon.mime).send(icon.buffer);
|
|
168
171
|
});
|
|
169
|
-
resourcesRouter.post("/discover", async (req, res) => {
|
|
172
|
+
resourcesRouter.post("/discover", requireCapability("sync"), async (req, res) => {
|
|
170
173
|
const parsed = z.object({ url: urlField }).safeParse(req.body);
|
|
171
174
|
if (!parsed.success)
|
|
172
175
|
return invalid(res, parsed.error.flatten());
|
|
@@ -184,7 +187,7 @@ resourcesRouter.get("/opml", (req, res) => {
|
|
|
184
187
|
res.setHeader("Content-Disposition", 'attachment; filename="feedkeeper-subscriptions.opml"');
|
|
185
188
|
res.send(generateOpml(req.user.id));
|
|
186
189
|
});
|
|
187
|
-
resourcesRouter.post("/opml", async (req, res) => {
|
|
190
|
+
resourcesRouter.post("/opml", requireCapability("sync"), async (req, res) => {
|
|
188
191
|
const xml = typeof req.body === "string" ? req.body : typeof req.body?.opml === "string" ? req.body.opml : "";
|
|
189
192
|
if (!xml.trim())
|
|
190
193
|
return void res.status(400).json({ error: "missing_opml_content" });
|
|
@@ -233,19 +236,9 @@ resourcesRouter.post("/folders", (req, res) => {
|
|
|
233
236
|
if (parsed.data.iconSymbol !== undefined && !hasUserCapability(req.user.id, "sync")) {
|
|
234
237
|
return void res.status(403).json(capabilityDenial(req.user.id, "sync"));
|
|
235
238
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
id: folder.id,
|
|
240
|
-
name: folder.name,
|
|
241
|
-
iconSymbol: folder.icon_symbol ?? null,
|
|
242
|
-
subscriptionCount: 0,
|
|
243
|
-
unreadCount: 0,
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
res.status(409).json({ error: "folder_name_taken" });
|
|
248
|
-
}
|
|
239
|
+
const folder = createFolder(req.user.id, parsed.data.name, parsed.data.iconSymbol);
|
|
240
|
+
const detail = listFoldersForUser(req.user.id).find((entry) => entry.id === folder.id);
|
|
241
|
+
res.status(201).json(serializeFolder(detail));
|
|
249
242
|
});
|
|
250
243
|
resourcesRouter.patch("/folders/:id", (req, res) => {
|
|
251
244
|
const id = Number(req.params.id);
|
|
@@ -260,7 +253,9 @@ resourcesRouter.patch("/folders/:id", (req, res) => {
|
|
|
260
253
|
try {
|
|
261
254
|
updateFolder(req.user.id, id, parsed.data);
|
|
262
255
|
}
|
|
263
|
-
catch {
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (!(error instanceof Error && "code" in error && error.code === "SQLITE_CONSTRAINT_UNIQUE"))
|
|
258
|
+
throw error;
|
|
264
259
|
return void res.status(409).json({ error: "folder_name_taken" });
|
|
265
260
|
}
|
|
266
261
|
const folder = listFoldersForUser(req.user.id).find((item) => item.id === id);
|
|
@@ -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),
|
|
@@ -19,7 +19,7 @@ export function requireSession(req, res, next) {
|
|
|
19
19
|
}
|
|
20
20
|
function bearerToken(req) {
|
|
21
21
|
const header = req.headers.authorization;
|
|
22
|
-
return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;
|
|
22
|
+
return header?.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : undefined;
|
|
23
23
|
}
|
|
24
24
|
// MCP auth: Bearer personal access token or OAuth access token (not a paired device), scopes the request to its owner.
|
|
25
25
|
export function requireBearerToken(req, res, next) {
|
|
@@ -27,7 +27,7 @@ export function requireBearerToken(req, res, next) {
|
|
|
27
27
|
}
|
|
28
28
|
// Native app API: a paired device token or a hand-made token; the web UI may call it with its session.
|
|
29
29
|
export function requireSessionOrDevice(req, res, next) {
|
|
30
|
-
if (
|
|
30
|
+
if (req.headers.authorization !== undefined)
|
|
31
31
|
authenticateBearer(["api", "device"])(req, res, next);
|
|
32
32
|
else
|
|
33
33
|
requireSession(req, res, next);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Notify subscribers when a feed's URL, metadata, poll interval, or error state is updated
|
|
2
|
+
DROP TRIGGER IF EXISTS trg_changes_feed_update;
|
|
3
|
+
CREATE TRIGGER trg_changes_feed_update
|
|
4
|
+
AFTER UPDATE OF url, title, site_url, poll_interval_minutes, last_error ON feeds
|
|
5
|
+
WHEN OLD.url IS NOT NEW.url
|
|
6
|
+
OR OLD.title IS NOT NEW.title
|
|
7
|
+
OR OLD.site_url IS NOT NEW.site_url
|
|
8
|
+
OR OLD.poll_interval_minutes IS NOT NEW.poll_interval_minutes
|
|
9
|
+
OR OLD.last_error IS NOT NEW.last_error
|
|
10
|
+
BEGIN
|
|
11
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
12
|
+
SELECT s.user_id, 'subscription', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = s.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
13
|
+
FROM subscriptions s
|
|
14
|
+
WHERE s.feed_id = NEW.id
|
|
15
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
16
|
+
END;
|
|
@@ -12,6 +12,7 @@ import { ImageCache, ImageCacheBusyError } from "./imageCache.js";
|
|
|
12
12
|
import { detectImageType, isVectorImageUrl } from "./imageTypes.js";
|
|
13
13
|
export { detectImageType } from "./imageTypes.js";
|
|
14
14
|
import { comparableImageKey } from "./heroImage.js";
|
|
15
|
+
import { normalizeImageSources } from "./imageSources.js";
|
|
15
16
|
const MAX_IMAGES_PER_ARTICLE = 30;
|
|
16
17
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
17
18
|
const FILE_NAME = /^\d+-[0-9a-f]{16}\.(jpg|png|webp|gif|avif)$/;
|
|
@@ -100,7 +101,7 @@ function resolveImageUrl(raw, baseUrl) {
|
|
|
100
101
|
/** Image URLs an article shows, in order, without tracking pixels. */
|
|
101
102
|
export function articleImageUrls(html, baseUrl) {
|
|
102
103
|
const urls = new Set();
|
|
103
|
-
for (const [tag] of html.matchAll(/<img\b[^>]*>/gi)) {
|
|
104
|
+
for (const [tag] of normalizeImageSources(html).matchAll(/<img\b[^>]*>/gi)) {
|
|
104
105
|
if (/\swidth\s*=\s*["']?1["'\s>]/i.test(tag) && /\sheight\s*=\s*["']?1["'\s>]/i.test(tag))
|
|
105
106
|
continue;
|
|
106
107
|
const src = srcAttribute(tag);
|
|
@@ -112,7 +113,7 @@ export function articleImageUrls(html, baseUrl) {
|
|
|
112
113
|
}
|
|
113
114
|
/** Replace the `src` of every image for which `replacement` knows a new address. */
|
|
114
115
|
function rewriteImageSources(html, baseUrl, replacement) {
|
|
115
|
-
return html.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
116
|
+
return normalizeImageSources(html).replace(/<img\b[^>]*>/gi, (tag) => {
|
|
116
117
|
const src = srcAttribute(tag);
|
|
117
118
|
const url = src && resolveImageUrl(src.value, baseUrl);
|
|
118
119
|
const next = url ? replacement(url) : undefined;
|
|
@@ -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,23 @@
|
|
|
1
|
+
import { firstSrcsetSource, imageSource, usableImageSource } from "../../imageSources.js";
|
|
2
|
+
export const lazyImages = {
|
|
3
|
+
name: "lazy-images",
|
|
4
|
+
description: "Lazy image addresses replace missing, data placeholders or script fragments parsed as image URLs.",
|
|
5
|
+
apply(root, stats) {
|
|
6
|
+
let changed = false;
|
|
7
|
+
for (const image of root.querySelectorAll("img")) {
|
|
8
|
+
if (usableImageSource(image.getAttribute("src")))
|
|
9
|
+
continue;
|
|
10
|
+
let source = imageSource((name) => image.getAttribute(name));
|
|
11
|
+
for (const candidate of image.closest("picture")?.querySelectorAll("source") ?? []) {
|
|
12
|
+
source ??= firstSrcsetSource(candidate.getAttribute("data-lazy-srcset") ?? candidate.getAttribute("data-srcset") ?? candidate.getAttribute("srcset"));
|
|
13
|
+
}
|
|
14
|
+
if (!source)
|
|
15
|
+
continue;
|
|
16
|
+
image.setAttribute("src", source);
|
|
17
|
+
stats["lazy-images"] = (stats["lazy-images"] ?? 0) + 1;
|
|
18
|
+
changed = true;
|
|
19
|
+
}
|
|
20
|
+
return changed;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=lazyImages.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { flat, linkParagraph } from "../shared.js";
|
|
2
|
+
import de from "../patterns/video-pages/de.js";
|
|
3
|
+
import en from "../patterns/video-pages/en.js";
|
|
4
|
+
import ja from "../patterns/video-pages/ja.js";
|
|
5
|
+
const labels = new Set([...de, ...en, ...ja].map(value => value.toLowerCase()));
|
|
6
|
+
const date = /\b\d{1,2}\.\d{1,2}\.\d{4}\b|\b[A-Za-z]{3,9} \d{1,2},? \d{4}\b|\d{4}年\d{1,2}月\d{1,2}日/;
|
|
7
|
+
/** A video landing page with a poster and metadata, but no editorial paragraphs. */
|
|
8
|
+
export function cleanVideoPage(root, url, title, stats) {
|
|
9
|
+
if (!url)
|
|
10
|
+
return false;
|
|
11
|
+
try {
|
|
12
|
+
const address = new URL(url);
|
|
13
|
+
if (!/^https?:$/.test(address.protocol) || !/(^|\/)(videos?|mediathek|watch|clips?)(\/|$|[-.])/i.test(address.pathname))
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const mains = root.querySelectorAll("main");
|
|
20
|
+
if (mains.length !== 1)
|
|
21
|
+
return false;
|
|
22
|
+
const main = mains[0];
|
|
23
|
+
const images = main.querySelectorAll("img");
|
|
24
|
+
if (images.length !== 1 || main.querySelector("article, blockquote, table, a[data-media]"))
|
|
25
|
+
return false;
|
|
26
|
+
for (const link of main.querySelectorAll("a[href]")) {
|
|
27
|
+
if (link.closest("nav, ul, ol") || link.querySelector("img"))
|
|
28
|
+
continue;
|
|
29
|
+
// A home link is navigation; a transcript or another editorial link is content and must survive.
|
|
30
|
+
try {
|
|
31
|
+
const target = new URL(link.getAttribute("href"), url);
|
|
32
|
+
if (target.origin !== new URL(url).origin || target.pathname !== "/" || target.search || target.hash)
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const copy = main.cloneNode(true);
|
|
40
|
+
copy.querySelectorAll("nav, ul, ol, form, img, picture, a, h1").forEach(element => element.remove());
|
|
41
|
+
const blocks = [...copy.querySelectorAll("p")].filter(element => flat(element.textContent));
|
|
42
|
+
if (!blocks.some(element => labels.has(flat(element.textContent).toLowerCase())))
|
|
43
|
+
return false;
|
|
44
|
+
// All remaining words must be short metadata lines containing an explicit date.
|
|
45
|
+
for (const block of blocks) {
|
|
46
|
+
const text = flat(block.textContent);
|
|
47
|
+
if (!labels.has(text.toLowerCase()) && (text.length > 160 || !date.test(text)))
|
|
48
|
+
return false;
|
|
49
|
+
block.remove();
|
|
50
|
+
}
|
|
51
|
+
if (flat(copy.textContent))
|
|
52
|
+
return false;
|
|
53
|
+
const document = main.ownerDocument;
|
|
54
|
+
const poster = document.createElement("p");
|
|
55
|
+
poster.append(images[0].cloneNode(true));
|
|
56
|
+
const card = linkParagraph(document, url, flat(title) || "Video");
|
|
57
|
+
card.querySelector("a").setAttribute("data-media", "video");
|
|
58
|
+
// Preserve attribution/broadcast metadata; omit the repeated publication timestamp and kind label.
|
|
59
|
+
const credits = [...main.querySelectorAll("p")].filter(element => {
|
|
60
|
+
const text = flat(element.textContent);
|
|
61
|
+
return date.test(text) && /[,、]/.test(text) && text.length <= 160 && !element.querySelector("a, img");
|
|
62
|
+
}).map(element => element.cloneNode(true));
|
|
63
|
+
root.replaceChildren(poster, card, ...credits);
|
|
64
|
+
stats["video-page"] = (stats["video-page"] ?? 0) + 1;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=videoPages.js.map
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Whole bylines with an explicit timestamp, only ahead of the first editorial line. */
|
|
2
|
+
export default [/^(?:by|written by)\s+[^.!?\n]{2,60}\s+[I|·–—-]\s+(?:[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}|\d{4}-\d{2}-\d{2})\s*[-–|·]\s*\d{1,2}:\d{2}(?:\s*[ap]m)?$/iu];
|
|
3
|
+
//# sourceMappingURL=en.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import de from "./de.js";
|
|
2
|
+
import en from "./en.js";
|
|
3
|
+
import ja from "./ja.js";
|
|
4
|
+
export const SNIPPET_BYLINES = [...de, ...en, ...ja];
|
|
5
|
+
/** File names and copyright notices are metadata only when they stand alone before the text. */
|
|
6
|
+
export const SNIPPET_IMAGE_METADATA = [
|
|
7
|
+
/^[\p{L}\p{N}_. -]{3,120}\.(?:jpe?g|png|webp|gif|avif)$/iu,
|
|
8
|
+
/^(?:©|\(c\)|copyright(?:\s*©)?)\s*[^.!?\n]{1,100}$/iu,
|
|
9
|
+
];
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { cleanVideoPage } from "./content/noise/videoPages.js";
|
|
2
|
+
import { lazyImages } from "./content/noise/lazyImages.js";
|
|
1
3
|
import { JSDOM } from "jsdom";
|
|
4
|
+
import { SNIPPET_BYLINES, SNIPPET_IMAGE_METADATA } from "./content/patterns/snippet-metadata/index.js";
|
|
2
5
|
import { CONSENT_ANCHORS, CONSENT_COMPANIONS } from "./content/patterns/consent/index.js";
|
|
3
6
|
import { SIDEBAR_HEADINGS } from "./content/patterns/sidebars/index.js";
|
|
4
7
|
import { resolveEmbeds } from "./content/embeds/index.js";
|
|
@@ -186,7 +189,7 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
186
189
|
let pastStart = false;
|
|
187
190
|
for (const block of leafBlocks(root)) {
|
|
188
191
|
const text = flat(block.textContent);
|
|
189
|
-
if (!text)
|
|
192
|
+
if (!text || block.querySelector("a[data-media]"))
|
|
190
193
|
continue;
|
|
191
194
|
const position = leading++;
|
|
192
195
|
const heading = /^H[1-3]$/.test(block.tagName);
|
|
@@ -245,7 +248,7 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
245
248
|
}
|
|
246
249
|
if (!pastStart && text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
|
|
247
250
|
const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
|
|
248
|
-
if (/^(stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
|
|
251
|
+
if (/^(news|ニュース|nachrichten|stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
|
|
249
252
|
drop(block, "dateline");
|
|
250
253
|
continue;
|
|
251
254
|
}
|
|
@@ -452,17 +455,19 @@ function serialize(root) {
|
|
|
452
455
|
return holder.innerHTML;
|
|
453
456
|
}
|
|
454
457
|
/** The article HTML as a reader should show it. Unchanged input is returned as is. */
|
|
455
|
-
export function cleanReaderHtml(html, title, stats = {}, author) {
|
|
458
|
+
export function cleanReaderHtml(html, title, stats = {}, author, url) {
|
|
456
459
|
if (!html)
|
|
457
460
|
return html;
|
|
458
461
|
const root = JSDOM.fragment(html);
|
|
462
|
+
const videoPage = cleanVideoPage(root, url, title, stats);
|
|
463
|
+
const lazy = lazyImages.apply(root, stats);
|
|
459
464
|
const images = resolveCustomImages(root, stats);
|
|
460
465
|
const duplicates = collapseAdjacentImages(root, stats);
|
|
461
466
|
const variants = collapseImageVariants(root, stats);
|
|
462
467
|
const embeds = resolveEmbeds(root, stats);
|
|
463
468
|
const noise = removeReaderNoise(root, title, stats, author);
|
|
464
469
|
const typography = removeTypography(root, stats);
|
|
465
|
-
return images || duplicates || variants || embeds || noise || typography ? serialize(root) : html;
|
|
470
|
+
return videoPage || lazy || images || duplicates || variants || embeds || noise || typography ? serialize(root) : html;
|
|
466
471
|
}
|
|
467
472
|
/**
|
|
468
473
|
* Some publishers wrap a picture in their own element and let a script fill the image: `<a-img src="/imgs/…">`
|
|
@@ -780,12 +785,23 @@ export function cleanSnippet(snippet, title) {
|
|
|
780
785
|
for (const pattern of SNIPPET_INLINE_PATTERNS)
|
|
781
786
|
text = text.replace(pattern, "");
|
|
782
787
|
const headline = normalized(title ?? "");
|
|
788
|
+
let inMetadata = true;
|
|
789
|
+
text = text.split("\n").filter(line => {
|
|
790
|
+
const value = line.trim();
|
|
791
|
+
if (!value)
|
|
792
|
+
return false;
|
|
793
|
+
if (inMetadata && (matchesAny(value, SNIPPET_BYLINES) || matchesAny(value, SNIPPET_IMAGE_METADATA) ||
|
|
794
|
+
(headline && normalized(value) === headline)))
|
|
795
|
+
return false;
|
|
796
|
+
inMetadata = false;
|
|
797
|
+
return true;
|
|
798
|
+
}).join("\n");
|
|
783
799
|
const lines = text.split("\n").map(line => line.trim()).filter((line, index) => line && !SNIPPET_LINE_PATTERNS.some(pattern => pattern.test(line)) && !isSocialList(line) &&
|
|
784
800
|
!(index === 0 && headline && normalized(line) === headline));
|
|
785
801
|
return lines.join("\n").trim();
|
|
786
802
|
}
|
|
787
803
|
/** Raised whenever the rules above change what they remove, so stored leads are derived again. */
|
|
788
|
-
export const LEAD_VERSION =
|
|
804
|
+
export const LEAD_VERSION = 3;
|
|
789
805
|
const LEAD_LENGTH = 600;
|
|
790
806
|
function shorten(text, max) {
|
|
791
807
|
if (text.length <= max)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SCRIPT_IMAGE_SOURCE } from "./imageSources.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");
|
|
@@ -24,12 +24,14 @@ export function detectIconType(buffer) {
|
|
|
24
24
|
const head = Buffer.from(buffer.subarray(0, 512)).toString("utf8").trimStart();
|
|
25
25
|
return /^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)?<svg[\s>]/i.test(head) ? "image/svg+xml" : null;
|
|
26
26
|
}
|
|
27
|
-
/** Whether the user subscribes to the feed or has saved or
|
|
27
|
+
/** Whether the user subscribes to the feed or has saved, annotated, or curated one of its articles. */
|
|
28
28
|
export function canAccessFeed(userId, feedId) {
|
|
29
29
|
return Boolean(db.prepare(`SELECT 1 FROM feeds f WHERE f.id = ?
|
|
30
30
|
AND (EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = f.id AND s.user_id = ?)
|
|
31
31
|
OR EXISTS (SELECT 1 FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.feed_id = f.id AND b.user_id = ?)
|
|
32
|
-
OR EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = f.id AND n.user_id = ?)
|
|
32
|
+
OR EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = f.id AND n.user_id = ?)
|
|
33
|
+
OR EXISTS (SELECT 1 FROM items i, editions e, json_each(e.item_ids) selected WHERE i.feed_id = f.id AND e.user_id = ?
|
|
34
|
+
AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).get(feedId, userId, userId, userId, userId));
|
|
33
35
|
}
|
|
34
36
|
/**
|
|
35
37
|
* Declared icons first, then the stored one, then the well-known files of the site, and last the feed's own logo (`<image>` of an RSS channel):
|
|
@@ -0,0 +1,69 @@
|
|
|
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 function usableImageSource(raw) {
|
|
4
|
+
const source = raw?.trim();
|
|
5
|
+
if (!source || SCRIPT_IMAGE_SOURCE.test(source))
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(source, "https://image-source.invalid/");
|
|
9
|
+
return url.protocol === "https:" || url.protocol === "http:" ? source : null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** URLs may contain commas (notably data placeholders); descriptors end at the next comma. */
|
|
16
|
+
export function firstSrcsetSource(srcset) {
|
|
17
|
+
let rest = srcset?.trim() ?? "";
|
|
18
|
+
while (rest) {
|
|
19
|
+
const match = /^([^\s]+)\s*/.exec(rest);
|
|
20
|
+
const source = match[1].replace(/,+$/, "");
|
|
21
|
+
rest = rest.slice(match[0].length);
|
|
22
|
+
if (!match[1].endsWith(",")) {
|
|
23
|
+
const comma = rest.indexOf(",");
|
|
24
|
+
rest = comma < 0 ? "" : rest.slice(comma + 1).trim();
|
|
25
|
+
}
|
|
26
|
+
const usable = usableImageSource(source);
|
|
27
|
+
if (usable)
|
|
28
|
+
return usable;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function imageSource(attribute) {
|
|
33
|
+
for (const name of ["src", "data-lazy-src", "data-src", "data-original", "data-actualsrc"]) {
|
|
34
|
+
const source = usableImageSource(attribute(name));
|
|
35
|
+
if (source)
|
|
36
|
+
return source;
|
|
37
|
+
}
|
|
38
|
+
for (const name of ["data-lazy-srcset", "data-srcset", "srcset"]) {
|
|
39
|
+
const source = firstSrcsetSource(attribute(name));
|
|
40
|
+
if (source)
|
|
41
|
+
return source;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function attribute(tag, name) {
|
|
46
|
+
const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
|
|
47
|
+
return match ? match[1] ?? match[2] ?? match[3] : null;
|
|
48
|
+
}
|
|
49
|
+
/** Keep one fallback image before archive/proxy rewriting, including raw HTML stored by older versions. */
|
|
50
|
+
export function normalizeImageSources(html) {
|
|
51
|
+
const normalize = (tag, fallback = null) => {
|
|
52
|
+
const source = imageSource((name) => attribute(tag, name)) ?? fallback;
|
|
53
|
+
if (!source)
|
|
54
|
+
return tag;
|
|
55
|
+
const escaped = source.replace(/"/g, """);
|
|
56
|
+
const withoutSources = tag.replace(/\s+(?:(?:data-(?:lazy-)?)?src(?:set)?|sizes|data-sizes|data-original|data-actualsrc)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+)/gi, "");
|
|
57
|
+
return withoutSources.replace(/\s*\/?>$/, (end) => ` src="${escaped}"${end}`);
|
|
58
|
+
};
|
|
59
|
+
const pictures = html.replace(/<picture\b[^>]*>[\s\S]*?<\/picture\s*>/gi, (picture) => {
|
|
60
|
+
let fallback = null;
|
|
61
|
+
const withoutSources = picture.replace(/<source\b[^>]*>/gi, (tag) => {
|
|
62
|
+
fallback ??= firstSrcsetSource(attribute(tag, "data-lazy-srcset") ?? attribute(tag, "data-srcset") ?? attribute(tag, "srcset"));
|
|
63
|
+
return "";
|
|
64
|
+
});
|
|
65
|
+
return withoutSources.replace(/<img\b[^>]*>/gi, (tag) => normalize(tag, fallback));
|
|
66
|
+
});
|
|
67
|
+
return pictures.replace(/<img\b[^>]*>/gi, (tag) => normalize(tag));
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=imageSources.js.map
|