feedkeeper 0.27.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/dist/api/items.js +3 -2
- package/server/dist/api/v1/index.js +1 -0
- package/server/dist/api/v1/resources.js +13 -11
- package/server/dist/auth/tokens.js +10 -1
- package/server/dist/db/index.js +4 -0
- package/server/dist/db/migrations/0039_performance_indexes.sql +96 -0
- package/server/dist/db/migrations/0040_empty_gallery.sql +10 -0
- package/server/dist/feeds/archive.js +26 -14
- package/server/dist/feeds/articleContent.js +1 -1
- package/server/dist/feeds/cleanup.js +11 -8
- package/server/dist/feeds/contentNoise.js +24 -17
- package/server/dist/feeds/extractor.js +2 -4
- package/server/dist/feeds/fullTextQueue.js +9 -3
- package/server/dist/feeds/galleryContent.js +33 -0
- package/server/dist/feeds/imageCache.js +10 -0
- package/server/dist/feeds/inbox.js +5 -4
- package/server/dist/feeds/lead.js +13 -5
- package/server/dist/feeds/pollQueue.js +11 -2
- package/server/dist/feeds/poller.js +1 -1
- package/server/dist/feeds/repository.js +112 -19
- package/server/dist/feeds/service.js +4 -4
- package/server/dist/native/editions.js +20 -15
- package/server/dist/oauth/store.js +9 -1
- package/server/dist/push.js +2 -0
- package/server/dist/pushOutbox.js +1 -1
- package/server/dist/sync/changeLog.js +114 -33
- package/server/package.json +1 -1
- package/web/dist/assets/index-CTTNHCfi.js +32 -0
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +62 -13
- package/web/dist/assets/index-Ci8_fWis.js +0 -32
package/package.json
CHANGED
package/server/dist/api/items.js
CHANGED
|
@@ -123,10 +123,11 @@ itemsRouter.post("/:itemId/extract-content", async (req, res) => {
|
|
|
123
123
|
res.status(FULL_TEXT_STATUS[result.error]).json({ error: result.error, ...(result.message ? { message: result.message } : {}) });
|
|
124
124
|
return;
|
|
125
125
|
}
|
|
126
|
-
const
|
|
126
|
+
const currentItem = findItemById(result.id);
|
|
127
|
+
const [served] = withArchivedImages([{ id: result.id, link: currentItem?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
|
|
127
128
|
res.json({
|
|
128
129
|
id: result.id,
|
|
129
|
-
full_content_html: readerHtml(served.full_content_html, result.title ??
|
|
130
|
+
full_content_html: readerHtml(served.full_content_html, result.title ?? currentItem?.title, currentItem?.author),
|
|
130
131
|
...(result.cached ? {} : { title: result.title, byline: result.byline }),
|
|
131
132
|
cached: result.cached,
|
|
132
133
|
});
|
|
@@ -11,7 +11,7 @@ import { loadFullText } from "../../feeds/fullText.js";
|
|
|
11
11
|
import { generateOpml, importOpmlFeeds } from "../../feeds/opml.js";
|
|
12
12
|
import { pollFeed, pollFeeds } from "../../feeds/poller.js";
|
|
13
13
|
import { canAccessFeed, loadFeedIcon } from "../../feeds/feedIcon.js";
|
|
14
|
-
import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemNote, findFeedById, findFolderById, findItemForUser, findItemNote, noteConflictState, isUserSubscribed, itemsWithNotes, listFoldersForUser, listItemsForUser, listMutedKeywords, listNotesForUser, listSubscriptionsForUser, markAllRead, removeMutedKeyword, reorderSubscriptions, searchItemsForUser, setItemNote, updateFolder, } from "../../feeds/repository.js";
|
|
14
|
+
import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemNote, findFeedById, findFolderById, findItemForUser, findItemsForUser, findSubscriptionForUser, findItemNote, noteConflictState, isUserSubscribed, itemsWithNotes, listFoldersForUser, listItemsForUser, listMutedKeywords, listNotesForUser, listSubscriptionsForUser, markAllRead, removeMutedKeyword, reorderSubscriptions, searchItemsForUser, setItemNote, updateFolder, } from "../../feeds/repository.js";
|
|
15
15
|
import { saveToInbox, deleteInboxItem } from "../../feeds/inbox.js";
|
|
16
16
|
import { changeFeedUrl, FeedError, MultipleFeedsFoundError, subscribeToFeed, unsubscribeFromFeed, updateFeedSettings } from "../../feeds/service.js";
|
|
17
17
|
import { SsrfBlockedError, normalizeUrlCandidate } from "../../feeds/ssrfGuard.js";
|
|
@@ -29,7 +29,7 @@ const imageLimiter = rateLimit({ windowMs: 60 * 1000, limit: 600, standardHeader
|
|
|
29
29
|
const urlField = z.preprocess((val) => (typeof val === "string" ? normalizeUrlCandidate(val) : val), z.string().url());
|
|
30
30
|
const invalid = (res, details) => void res.status(400).json({ error: "invalid_input", ...(details ? { details } : {}) });
|
|
31
31
|
function subscriptionOf(userId, id) {
|
|
32
|
-
const subscription =
|
|
32
|
+
const subscription = findSubscriptionForUser(userId, id);
|
|
33
33
|
return subscription ? serializeSubscriptionDetail(subscription) : null;
|
|
34
34
|
}
|
|
35
35
|
// ---- Account -------------------------------------------------------------
|
|
@@ -302,9 +302,9 @@ const itemsQuery = z.object({
|
|
|
302
302
|
images: z.enum(["original", "proxy"]).default("original"),
|
|
303
303
|
});
|
|
304
304
|
/** Rows ready for a native client: images served by this server where it keeps copies. */
|
|
305
|
-
function prepare(rows, req, images) {
|
|
306
|
-
const archived = withArchivedImages(rows, origin(req), API_BASE);
|
|
307
|
-
return images === "proxy" ? withProxiedImages(archived, origin(req), API_BASE) : archived;
|
|
305
|
+
function prepare(rows, req, images, withContent = true) {
|
|
306
|
+
const archived = withArchivedImages(rows, origin(req), API_BASE, withContent);
|
|
307
|
+
return images === "proxy" ? withProxiedImages(archived, origin(req), API_BASE, withContent) : archived;
|
|
308
308
|
}
|
|
309
309
|
const searchQuery = z.object({
|
|
310
310
|
q: z.string().trim().min(1).max(200),
|
|
@@ -340,7 +340,7 @@ resourcesRouter.get("/search", requireCapability("search.fts"), (req, res) => {
|
|
|
340
340
|
res.json({
|
|
341
341
|
query: query.q,
|
|
342
342
|
total,
|
|
343
|
-
items: prepare(rows, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
|
|
343
|
+
items: prepare(rows, req, query.images, withContent).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
|
|
344
344
|
});
|
|
345
345
|
});
|
|
346
346
|
resourcesRouter.get("/items", (req, res) => {
|
|
@@ -374,7 +374,7 @@ resourcesRouter.get("/items", (req, res) => {
|
|
|
374
374
|
const progress = readingPositions(req.user.id, page.map((row) => row.id));
|
|
375
375
|
const hasNotes = itemsWithNotes(req.user.id, page.map((row) => row.id));
|
|
376
376
|
res.json({
|
|
377
|
-
items: prepare(page, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
|
|
377
|
+
items: prepare(page, req, query.images, withContent).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
|
|
378
378
|
newestCursor: page[0] ? encodeCursor(page[0]) : null,
|
|
379
379
|
nextCursor: hasMore && page.length ? encodeCursor(page[page.length - 1]) : null,
|
|
380
380
|
});
|
|
@@ -385,10 +385,11 @@ resourcesRouter.get("/items/bundle", (req, res) => {
|
|
|
385
385
|
if (!parsed.success)
|
|
386
386
|
return invalid(res, "ids: up to 50 comma separated article ids");
|
|
387
387
|
const ids = [...new Set(parsed.data.ids.split(",").map(Number))];
|
|
388
|
-
const
|
|
388
|
+
const byId = new Map(findItemsForUser(req.user.id, ids).map((row) => [row.id, row]));
|
|
389
|
+
const rows = ids.map((id) => byId.get(id)).filter((row) => Boolean(row));
|
|
389
390
|
const progress = readingPositions(req.user.id, rows.map((row) => row.id));
|
|
390
391
|
const hasNotes = itemsWithNotes(req.user.id, rows.map((row) => row.id));
|
|
391
|
-
res.json({ items: prepare(rows, req, parsed.data.images).map((row) => serializeItem(row, progress, true, hasNotes.has(row.id))) });
|
|
392
|
+
res.json({ items: prepare(rows, req, parsed.data.images, true).map((row) => serializeItem(row, progress, true, hasNotes.has(row.id))) });
|
|
392
393
|
});
|
|
393
394
|
// Explicit extraction side effect; read-only devices continue to consume the shared cache.
|
|
394
395
|
resourcesRouter.post("/items/prepare", requireCapability("fulltext"), async (req, res) => {
|
|
@@ -515,12 +516,13 @@ resourcesRouter.get("/notes/export", (req, res) => {
|
|
|
515
516
|
});
|
|
516
517
|
// ---- Curated Edition ("Deine Zeitung") ------------------------------------
|
|
517
518
|
function editionResponse(req, edition) {
|
|
519
|
+
const byId = new Map(findItemsForUser(req.user.id, edition.itemIds).map((row) => [row.id, row]));
|
|
518
520
|
const rows = edition.itemIds
|
|
519
|
-
.map((id) =>
|
|
521
|
+
.map((id) => byId.get(id))
|
|
520
522
|
.filter((row) => Boolean(row));
|
|
521
523
|
const progress = readingPositions(req.user.id, rows.map((r) => r.id));
|
|
522
524
|
const hasNotes = itemsWithNotes(req.user.id, rows.map((r) => r.id));
|
|
523
|
-
const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original");
|
|
525
|
+
const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original", false);
|
|
524
526
|
return {
|
|
525
527
|
...edition,
|
|
526
528
|
items: prepared.map((row) => serializeItem(row, progress, false, hasNotes.has(row.id))),
|
|
@@ -38,9 +38,16 @@ export function resolveToken(token) {
|
|
|
38
38
|
if (stored.length !== provided.length || !timingSafeEqual(stored, provided)) {
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const lastTouch = tokenLastTouch.get(row.id) ?? 0;
|
|
43
|
+
if (!row.last_used_at || now - lastTouch >= TOKEN_TOUCH_INTERVAL_MS) {
|
|
44
|
+
tokenLastTouch.set(row.id, now);
|
|
45
|
+
db.prepare("UPDATE personal_access_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(row.id);
|
|
46
|
+
}
|
|
42
47
|
return { userId: row.user_id, scope: row.scope, kind: row.kind, tokenId: row.id };
|
|
43
48
|
}
|
|
49
|
+
const tokenLastTouch = new Map();
|
|
50
|
+
const TOKEN_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
|
|
44
51
|
/** Hand-made tokens only; paired devices are listed separately. */
|
|
45
52
|
export function listTokensForUser(userId) {
|
|
46
53
|
return db
|
|
@@ -49,6 +56,7 @@ export function listTokensForUser(userId) {
|
|
|
49
56
|
.map(({ token_hash, ...rest }) => rest);
|
|
50
57
|
}
|
|
51
58
|
export function deleteToken(userId, tokenId) {
|
|
59
|
+
tokenLastTouch.delete(tokenId);
|
|
52
60
|
db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'api'").run(tokenId, userId);
|
|
53
61
|
}
|
|
54
62
|
export function listDevicesForUser(userId) {
|
|
@@ -59,6 +67,7 @@ export function listDevicesForUser(userId) {
|
|
|
59
67
|
}
|
|
60
68
|
/** Revokes a device; its token stops working immediately. Returns whether one was removed. */
|
|
61
69
|
export function deleteDevice(userId, deviceId) {
|
|
70
|
+
tokenLastTouch.delete(deviceId);
|
|
62
71
|
return db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'device'").run(deviceId, userId).changes > 0;
|
|
63
72
|
}
|
|
64
73
|
//# sourceMappingURL=tokens.js.map
|
package/server/dist/db/index.js
CHANGED
|
@@ -8,7 +8,11 @@ const MIGRATIONS_DIR = join(__dirname, "migrations");
|
|
|
8
8
|
mkdirSync(dirname(config.databasePath), { recursive: true });
|
|
9
9
|
export const db = new Database(config.databasePath);
|
|
10
10
|
db.pragma("journal_mode = WAL");
|
|
11
|
+
db.pragma("synchronous = NORMAL");
|
|
11
12
|
db.pragma("foreign_keys = ON");
|
|
13
|
+
db.pragma("busy_timeout = 5000");
|
|
14
|
+
db.pragma("temp_store = MEMORY");
|
|
15
|
+
db.pragma("cache_size = -65536");
|
|
12
16
|
function ensureMigrationsTable() {
|
|
13
17
|
db.exec(`CREATE TABLE IF NOT EXISTS _migrations (
|
|
14
18
|
name TEXT PRIMARY KEY,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
-- Foreign key and cascade delete indexes
|
|
2
|
+
CREATE INDEX IF NOT EXISTS idx_item_reads_item ON item_reads(item_id);
|
|
3
|
+
CREATE INDEX IF NOT EXISTS idx_item_progress_item ON item_progress(item_id);
|
|
4
|
+
CREATE INDEX IF NOT EXISTS idx_item_note_revisions_item ON item_note_revisions(item_id);
|
|
5
|
+
CREATE INDEX IF NOT EXISTS idx_full_text_jobs_user ON full_text_jobs(user_id);
|
|
6
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_codes_user ON oauth_codes(user_id);
|
|
7
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_codes(client_id);
|
|
8
|
+
CREATE INDEX IF NOT EXISTS idx_security_challenges_user ON security_challenges(user_id);
|
|
9
|
+
|
|
10
|
+
-- Housekeeping and retention cleanup indexes
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_native_requests_created ON native_requests(created_at);
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_applied_mutations_applied ON applied_mutations(applied_at);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_items_feed_eff_pub ON items(
|
|
14
|
+
feed_id,
|
|
15
|
+
COALESCE(julianday(published_at), julianday(created_at)) DESC,
|
|
16
|
+
id DESC
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
-- Item listing, cursor pagination, lead backfill, and full-text candidate indexes
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_items_feed_created ON items(feed_id, created_at DESC, id DESC);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_bookmarks_user_created ON item_bookmarks(user_id, created_at DESC, item_id DESC);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_items_lead_version ON items(lead_version);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_items_fulltext_candidates ON items(feed_id, created_at DESC)
|
|
24
|
+
WHERE link IS NOT NULL AND full_content_html IS NULL AND extraction_status != 'feed_only';
|
|
25
|
+
|
|
26
|
+
-- Avoid rebuilding FTS5 entries when non-indexed item columns change during polling or extraction
|
|
27
|
+
DROP TRIGGER IF EXISTS trg_items_fts_update;
|
|
28
|
+
CREATE TRIGGER trg_items_fts_update AFTER UPDATE ON items
|
|
29
|
+
WHEN OLD.title IS NOT NEW.title
|
|
30
|
+
OR OLD.content_snippet IS NOT NEW.content_snippet
|
|
31
|
+
OR OLD.content_html IS NOT NEW.content_html
|
|
32
|
+
OR OLD.full_content_html IS NOT NEW.full_content_html
|
|
33
|
+
BEGIN
|
|
34
|
+
INSERT INTO items_fts(items_fts, rowid, title, content_snippet, content_html, full_content_html)
|
|
35
|
+
VALUES ('delete', old.id, old.title, old.content_snippet, old.content_html, old.full_content_html);
|
|
36
|
+
INSERT INTO items_fts(rowid, title, content_snippet, content_html, full_content_html)
|
|
37
|
+
VALUES (new.id, new.title, new.content_snippet, new.content_html, new.full_content_html);
|
|
38
|
+
END;
|
|
39
|
+
|
|
40
|
+
-- Monotonic per-user sync sequence high-water mark across change log pruning
|
|
41
|
+
ALTER TABLE sync_state ADD COLUMN last_seq INTEGER NOT NULL DEFAULT 0;
|
|
42
|
+
UPDATE sync_state
|
|
43
|
+
SET last_seq = MAX(
|
|
44
|
+
pruned_through,
|
|
45
|
+
COALESCE((SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id), 0)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
DROP TRIGGER IF EXISTS trg_changes_seq_insert;
|
|
49
|
+
CREATE TRIGGER trg_changes_seq_insert
|
|
50
|
+
AFTER INSERT ON changes
|
|
51
|
+
WHEN NEW.seq <= COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
|
|
52
|
+
BEGIN
|
|
53
|
+
UPDATE changes
|
|
54
|
+
SET seq = MAX(
|
|
55
|
+
COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = NEW.user_id), 0),
|
|
56
|
+
COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
|
|
57
|
+
) + 1
|
|
58
|
+
WHERE user_id = NEW.user_id AND entity = NEW.entity AND entity_id = NEW.entity_id;
|
|
59
|
+
END;
|
|
60
|
+
|
|
61
|
+
DROP TRIGGER IF EXISTS trg_changes_seq_update;
|
|
62
|
+
CREATE TRIGGER trg_changes_seq_update
|
|
63
|
+
AFTER UPDATE OF seq ON changes
|
|
64
|
+
WHEN NEW.seq <= COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
|
|
65
|
+
BEGIN
|
|
66
|
+
UPDATE changes
|
|
67
|
+
SET seq = MAX(
|
|
68
|
+
COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = NEW.user_id), 0),
|
|
69
|
+
COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
|
|
70
|
+
) + 1
|
|
71
|
+
WHERE user_id = NEW.user_id AND entity = NEW.entity AND entity_id = NEW.entity_id;
|
|
72
|
+
END;
|
|
73
|
+
|
|
74
|
+
-- Record old feed deletion when a subscription moves to a different feed
|
|
75
|
+
DROP TRIGGER IF EXISTS trg_changes_subscription_move;
|
|
76
|
+
CREATE TRIGGER trg_changes_subscription_move
|
|
77
|
+
AFTER UPDATE OF feed_id ON subscriptions
|
|
78
|
+
WHEN OLD.feed_id IS NOT NEW.feed_id
|
|
79
|
+
BEGIN
|
|
80
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
81
|
+
VALUES (OLD.user_id, 'subscription', OLD.feed_id, 'delete', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
82
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
83
|
+
END;
|
|
84
|
+
|
|
85
|
+
-- Notify subscribers when a feed's URL or poll interval is updated in place
|
|
86
|
+
DROP TRIGGER IF EXISTS trg_changes_feed_update;
|
|
87
|
+
CREATE TRIGGER trg_changes_feed_update
|
|
88
|
+
AFTER UPDATE OF url, poll_interval_minutes ON feeds
|
|
89
|
+
WHEN OLD.url IS NOT NEW.url OR OLD.poll_interval_minutes IS NOT NEW.poll_interval_minutes
|
|
90
|
+
BEGIN
|
|
91
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
92
|
+
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')
|
|
93
|
+
FROM subscriptions s
|
|
94
|
+
WHERE s.feed_id = NEW.id
|
|
95
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
96
|
+
END;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Classification is shared publisher metadata, not a deletion or a per-user read state.
|
|
2
|
+
ALTER TABLE items ADD COLUMN is_empty_gallery INTEGER NOT NULL DEFAULT 0 CHECK (is_empty_gallery IN (0, 1));
|
|
3
|
+
-- Reuse the bounded metadata backfill; existing content and reading history stay intact.
|
|
4
|
+
UPDATE items SET lead_version = NULL;
|
|
5
|
+
|
|
6
|
+
CREATE TRIGGER trg_gallery_revision AFTER UPDATE OF is_empty_gallery ON items
|
|
7
|
+
WHEN OLD.is_empty_gallery IS NOT NEW.is_empty_gallery
|
|
8
|
+
BEGIN
|
|
9
|
+
UPDATE items SET content_revision = content_revision + 1 WHERE id = NEW.id;
|
|
10
|
+
END;
|
|
@@ -22,6 +22,7 @@ const archiveWrites = new Set();
|
|
|
22
22
|
async function storeArchivedImage(destination, image) {
|
|
23
23
|
const temporary = `${destination}.tmp`;
|
|
24
24
|
archiveWrites.add(temporary);
|
|
25
|
+
archiveWrites.add(destination);
|
|
25
26
|
try {
|
|
26
27
|
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
|
|
27
28
|
throw error; });
|
|
@@ -164,13 +165,18 @@ export async function archiveItem(itemId) {
|
|
|
164
165
|
continue;
|
|
165
166
|
const fileName = `${itemId}-${createHash("sha256").update(url).digest("hex").slice(0, 16)}.${type.ext}`;
|
|
166
167
|
const destination = join(archiveDir(), fileName);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
try {
|
|
169
|
+
await storeArchivedImage(destination, image);
|
|
170
|
+
// The last bookmark or article may have disappeared while the image was loading.
|
|
171
|
+
if (!hasBookmark(itemId) || !findItemById(itemId)) {
|
|
172
|
+
unlinkSync(destination);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
insert.run(itemId, url, fileName, type.mime, buffer.length);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
archiveWrites.delete(destination);
|
|
172
179
|
}
|
|
173
|
-
insert.run(itemId, url, fileName, type.mime, buffer.length);
|
|
174
180
|
}
|
|
175
181
|
catch (error) {
|
|
176
182
|
// Leave the durable bookmark pending so startup can finish it later.
|
|
@@ -276,7 +282,7 @@ export function findArchivedImage(userId, imageId) {
|
|
|
276
282
|
return { path: join(archiveDir(), row.file_name), mime: row.mime_type };
|
|
277
283
|
}
|
|
278
284
|
/** Swap image URLs of archived articles for their local copies before sending them to the browser. */
|
|
279
|
-
export function withArchivedImages(items, origin, apiBase = "/api") {
|
|
285
|
+
export function withArchivedImages(items, origin, apiBase = "/api", withContent = true) {
|
|
280
286
|
const archived = archivedImagesFor(items.map((item) => item.id));
|
|
281
287
|
return items.map((item) => {
|
|
282
288
|
const images = archived.get(item.id);
|
|
@@ -286,8 +292,8 @@ export function withArchivedImages(items, origin, apiBase = "/api") {
|
|
|
286
292
|
return {
|
|
287
293
|
...item,
|
|
288
294
|
image_url: hero && images.has(hero) ? archivedImageUrl(origin, images.get(hero), apiBase) : item.image_url,
|
|
289
|
-
content_html: item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
|
|
290
|
-
full_content_html: item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
|
|
295
|
+
content_html: withContent && item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
|
|
296
|
+
full_content_html: withContent && item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
|
|
291
297
|
};
|
|
292
298
|
});
|
|
293
299
|
}
|
|
@@ -303,7 +309,7 @@ function proxiedImageUrl(origin, itemId, imageUrl, apiBase = "/api") {
|
|
|
303
309
|
* For offline copies: point the first images of articles that are not archived at an endpoint
|
|
304
310
|
* of this server, so the browser can store them without cross-origin requests.
|
|
305
311
|
*/
|
|
306
|
-
export function withProxiedImages(items, origin, apiBase = "/api") {
|
|
312
|
+
export function withProxiedImages(items, origin, apiBase = "/api", withContent = true) {
|
|
307
313
|
const own = `${origin.replace(/\/$/, "")}/api/`;
|
|
308
314
|
return items.map((item) => {
|
|
309
315
|
const urls = new Set(itemImageUrls(item).filter((url) => !url.startsWith(own)));
|
|
@@ -314,20 +320,26 @@ export function withProxiedImages(items, origin, apiBase = "/api") {
|
|
|
314
320
|
return {
|
|
315
321
|
...item,
|
|
316
322
|
image_url: hero && urls.has(hero) ? proxiedImageUrl(origin, item.id, hero, apiBase) : item.image_url,
|
|
317
|
-
content_html: item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
|
|
318
|
-
full_content_html: item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
|
|
323
|
+
content_html: withContent && item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
|
|
324
|
+
full_content_html: withContent && item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
|
|
319
325
|
};
|
|
320
326
|
});
|
|
321
327
|
}
|
|
322
328
|
/** Download one of an article's own images for the offline copy, or null when it is not one of them. */
|
|
323
329
|
export async function fetchItemImage(userId, itemId, imageUrl) {
|
|
324
330
|
const item = findItemForUser(userId, itemId);
|
|
325
|
-
if (!item
|
|
331
|
+
if (!item)
|
|
332
|
+
return "forbidden";
|
|
333
|
+
const isMatch = (it) => {
|
|
334
|
+
const hero = it.image_url ? resolveImageUrl(it.image_url, it.link) : null;
|
|
335
|
+
return hero === imageUrl || itemImageUrls(it).includes(imageUrl);
|
|
336
|
+
};
|
|
337
|
+
if (!isMatch(item))
|
|
326
338
|
return "forbidden";
|
|
327
339
|
const image = await sharedImage(item, imageUrl);
|
|
328
340
|
// Access or the article's image list can change while a shared download is running.
|
|
329
341
|
const current = findItemForUser(userId, itemId);
|
|
330
|
-
if (!current || !
|
|
342
|
+
if (!current || !isMatch(current))
|
|
331
343
|
return "forbidden";
|
|
332
344
|
return image;
|
|
333
345
|
}
|
|
@@ -36,7 +36,7 @@ export function readerHtml(html, title, author) {
|
|
|
36
36
|
export function contentMetadata(item) {
|
|
37
37
|
const source = item.full_content_html ? "reader" : item.content_html ? "feed" : "snippet";
|
|
38
38
|
return { revision: item.content_revision, source, status: item.extraction_status,
|
|
39
|
-
attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at };
|
|
39
|
+
attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at, isEmptyGallery: Boolean(item.is_empty_gallery) };
|
|
40
40
|
}
|
|
41
41
|
export function sourceText(item) {
|
|
42
42
|
return `${decodeEntities(item.title) ?? ""}\n${derivedText(item.full_content_html || item.content_html, item.title) || cleanSnippet(decodeEntities(item.content_snippet), item.title) || ""}`.trim();
|
|
@@ -91,8 +91,8 @@ export function runCleanup(custom) {
|
|
|
91
91
|
WHERE i.id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
92
92
|
AND i.id NOT IN (SELECT item_id FROM item_notes)
|
|
93
93
|
AND i.feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
94
|
-
AND NOT
|
|
95
|
-
WHERE
|
|
94
|
+
AND i.id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
|
|
95
|
+
WHERE e.source = 'curated' AND e.status <> 'dismissed')
|
|
96
96
|
AND i.id IN (SELECT item_id FROM item_reads)
|
|
97
97
|
AND NOT EXISTS (
|
|
98
98
|
SELECT 1 FROM subscriptions s
|
|
@@ -109,8 +109,8 @@ export function runCleanup(custom) {
|
|
|
109
109
|
WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
110
110
|
AND id NOT IN (SELECT item_id FROM item_notes)
|
|
111
111
|
AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
112
|
-
AND NOT
|
|
113
|
-
WHERE
|
|
112
|
+
AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
|
|
113
|
+
WHERE e.source = 'curated' AND e.status <> 'dismissed')
|
|
114
114
|
AND COALESCE(julianday(published_at), julianday(created_at)) <= julianday('now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
|
|
115
115
|
deletedOldItems = res.changes;
|
|
116
116
|
}
|
|
@@ -120,8 +120,8 @@ export function runCleanup(custom) {
|
|
|
120
120
|
WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
121
121
|
AND id NOT IN (SELECT item_id FROM item_notes)
|
|
122
122
|
AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
123
|
-
AND NOT
|
|
124
|
-
WHERE
|
|
123
|
+
AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
|
|
124
|
+
WHERE e.source = 'curated' AND e.status <> 'dismissed')
|
|
125
125
|
AND id IN (
|
|
126
126
|
SELECT id FROM (
|
|
127
127
|
SELECT id, ROW_NUMBER() OVER (
|
|
@@ -139,11 +139,14 @@ export function runCleanup(custom) {
|
|
|
139
139
|
pruneArchive();
|
|
140
140
|
pruneChangeLog();
|
|
141
141
|
const totalDeleted = deletedReadItems + deletedOldItems + deletedPerFeedExcess;
|
|
142
|
-
// Optimize query planning; rebuild storage only when this run freed
|
|
142
|
+
// Optimize query planning; rebuild storage only when this run freed substantial pages.
|
|
143
143
|
db.exec("PRAGMA optimize;");
|
|
144
144
|
if (totalDeleted > 0) {
|
|
145
145
|
try {
|
|
146
|
-
db.
|
|
146
|
+
const freelist = db.pragma("freelist_count", { simple: true }) ?? 0;
|
|
147
|
+
if (freelist > 500) {
|
|
148
|
+
db.exec("VACUUM;");
|
|
149
|
+
}
|
|
147
150
|
}
|
|
148
151
|
catch (err) {
|
|
149
152
|
console.warn("[cleanup] VACUUM skipped or could not run:", err);
|
|
@@ -139,6 +139,7 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
139
139
|
const authorKeys = (author ?? "").split(",").map(name => normalized(name)).filter(Boolean);
|
|
140
140
|
let leading = 0;
|
|
141
141
|
let reachedText = false;
|
|
142
|
+
let pastStart = false;
|
|
142
143
|
for (const block of leafBlocks(root)) {
|
|
143
144
|
const text = flat(block.textContent);
|
|
144
145
|
if (!text)
|
|
@@ -161,23 +162,29 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
161
162
|
}
|
|
162
163
|
}
|
|
163
164
|
// Near the start, a line that is only a date or a reading time repeats what the reader shows in its own head.
|
|
164
|
-
if (text.length <= 60
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
if (READING_TIME_ONLY.test(text)) {
|
|
170
|
-
drop(block, "reading-time");
|
|
171
|
-
continue;
|
|
165
|
+
if (!pastStart && text.length <= 60) {
|
|
166
|
+
const before = charsBefore(root, block);
|
|
167
|
+
if (before > 800) {
|
|
168
|
+
pastStart = true;
|
|
172
169
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
170
|
+
else {
|
|
171
|
+
if (DATE_ONLY.test(text)) {
|
|
172
|
+
drop(block, "dateline");
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (READING_TIME_ONLY.test(text)) {
|
|
176
|
+
drop(block, "reading-time");
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
|
|
180
|
+
if (BYLINE.test(text) && authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
|
|
181
|
+
drop(block, "byline");
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (BYLINE.test(text) && stripBylineDate(block)) {
|
|
185
|
+
stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
|
|
186
|
+
changed = true;
|
|
187
|
+
}
|
|
181
188
|
}
|
|
182
189
|
}
|
|
183
190
|
if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
|
|
@@ -196,7 +203,7 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
196
203
|
drop(block, "player");
|
|
197
204
|
continue;
|
|
198
205
|
}
|
|
199
|
-
if (text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
|
|
206
|
+
if (!pastStart && text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
|
|
200
207
|
const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
|
|
201
208
|
if (/^(stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
|
|
202
209
|
drop(block, "dateline");
|
|
@@ -151,7 +151,7 @@ async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl, ownerSign
|
|
|
151
151
|
}
|
|
152
152
|
chunks.push(value);
|
|
153
153
|
}
|
|
154
|
-
const html = new TextDecoder().decode(Buffer.concat(chunks
|
|
154
|
+
const html = new TextDecoder().decode(Buffer.concat(chunks, total));
|
|
155
155
|
return { html, url: safeUrl.toString() };
|
|
156
156
|
}
|
|
157
157
|
return null;
|
|
@@ -169,14 +169,12 @@ export async function extractArticle(rawUrl, fetchImpl = defaultFetch, priority
|
|
|
169
169
|
}, priority);
|
|
170
170
|
}
|
|
171
171
|
async function extractArticleContent(rawUrl, fetchImpl, signal) {
|
|
172
|
-
signal.throwIfAborted();
|
|
173
|
-
const safeUrl = await assertPublicHttpUrl(rawUrl);
|
|
174
172
|
signal.throwIfAborted();
|
|
175
173
|
const dispatcher = createPublicDispatcher();
|
|
176
174
|
const controller = new AbortController();
|
|
177
175
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
178
176
|
try {
|
|
179
|
-
const fetched = await fetchArticleHtml(
|
|
177
|
+
const fetched = await fetchArticleHtml(rawUrl, AbortSignal.any([signal, controller.signal]), dispatcher, fetchImpl, signal);
|
|
180
178
|
if (!fetched)
|
|
181
179
|
return { status: "failed" };
|
|
182
180
|
const cleanedHtml = stripHeavyTags(fetched.html);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isCachedConsentSnippet } from "./extractor.js";
|
|
2
|
+
import { refreshLead } from "./lead.js";
|
|
2
3
|
import { db } from "../db/index.js";
|
|
3
4
|
import { hasUserCapability } from "../auth/capabilities.js";
|
|
4
5
|
import { loadFullText, executeFullText } from "./fullText.js";
|
|
@@ -229,6 +230,8 @@ function pump() {
|
|
|
229
230
|
if (timer)
|
|
230
231
|
clearTimeout(timer);
|
|
231
232
|
const pool = configureFullTextPool();
|
|
233
|
+
if (active.size >= pool.limits.concurrency)
|
|
234
|
+
return;
|
|
232
235
|
if (seedTimer && db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get().n < pool.limits.concurrency * 2)
|
|
233
236
|
scheduleFullTexts();
|
|
234
237
|
const jobs = db.prepare(`SELECT j.*, i.link FROM full_text_jobs j JOIN items i ON i.id=j.item_id
|
|
@@ -258,8 +261,10 @@ function pump() {
|
|
|
258
261
|
const work = execute(job, host).finally(() => { active.delete(job.item_id); wake(); });
|
|
259
262
|
active.set(job.item_id, work);
|
|
260
263
|
}
|
|
261
|
-
|
|
262
|
-
|
|
264
|
+
const nextJob = db.prepare("SELECT MIN(available_at) AS next_at FROM full_text_jobs").get();
|
|
265
|
+
if (nextJob?.next_at) {
|
|
266
|
+
const delay = Math.max(250, Math.min(30_000, Date.parse(nextJob.next_at) - Date.now()));
|
|
267
|
+
timer = setTimeout(wake, delay);
|
|
263
268
|
timer.unref();
|
|
264
269
|
}
|
|
265
270
|
}
|
|
@@ -272,7 +277,7 @@ async function execute(job, host) {
|
|
|
272
277
|
attemptErrors++;
|
|
273
278
|
errorCounts[result.error] = (errorCounts[result.error] ?? 0) + 1;
|
|
274
279
|
}
|
|
275
|
-
if (!result.ok && result.error === "source_busy") {
|
|
280
|
+
if (!result.ok && result.error === "source_busy" && !result.deferred) {
|
|
276
281
|
throttled++;
|
|
277
282
|
deferFullTextHost(result.host ?? host, result.retryAt);
|
|
278
283
|
}
|
|
@@ -340,6 +345,7 @@ export function startFullTextScheduler() {
|
|
|
340
345
|
for (const item of cached)
|
|
341
346
|
if (isCachedConsentSnippet(item.full_content_html)) {
|
|
342
347
|
db.prepare("UPDATE items SET full_content_html=NULL,extraction_status='pending',extraction_retry_at=NULL WHERE id=?").run(item.id);
|
|
348
|
+
refreshLead(item.id);
|
|
343
349
|
}
|
|
344
350
|
})();
|
|
345
351
|
tick();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { JSDOM } from "jsdom";
|
|
2
|
+
const GALLERY_COMPONENTS = new Set([
|
|
3
|
+
"bilder", "bildergalerie", "bildergalerien", "fotogalerie", "fotogalerien", "fotostrecke", "fotostrecken",
|
|
4
|
+
"galerie", "galerien", "gallery", "galleries", "photogallery", "photogalleries", "slideshow", "slideshows",
|
|
5
|
+
"photos", "pictures", "images",
|
|
6
|
+
]);
|
|
7
|
+
const PLACEHOLDERS = new Set([
|
|
8
|
+
"", "mehr", "weiterlesen", "mehr lesen", "read more", "more", "continue reading",
|
|
9
|
+
"zur galerie", "zur bildergalerie", "view gallery", "続きを読む", "もっと読む",
|
|
10
|
+
]);
|
|
11
|
+
function isPlaceholder(source) {
|
|
12
|
+
const fragment = JSDOM.fragment(source ?? "");
|
|
13
|
+
fragment.querySelectorAll("script, style, noscript").forEach(node => node.remove());
|
|
14
|
+
fragment.querySelectorAll("p, div, br, li, h1, h2, h3, blockquote, figcaption").forEach(node => node.append(" "));
|
|
15
|
+
const normalized = (fragment.textContent ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
16
|
+
return PLACEHOLDERS.has(normalized);
|
|
17
|
+
}
|
|
18
|
+
/** A gallery path alone is not enough: even one editorial sentence or caption keeps the article visible. */
|
|
19
|
+
export function isEmptyGallery(item) {
|
|
20
|
+
if (!item.link)
|
|
21
|
+
return false;
|
|
22
|
+
let path;
|
|
23
|
+
try {
|
|
24
|
+
path = decodeURIComponent(new URL(item.link).pathname).toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (!path.split(/[^\p{L}\p{N}]+/u).some(component => GALLERY_COMPONENTS.has(component)))
|
|
30
|
+
return false;
|
|
31
|
+
return isPlaceholder(item.content_snippet) && isPlaceholder(item.full_content_html || item.content_html);
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=galleryContent.js.map
|
|
@@ -36,6 +36,16 @@ export class ImageCache {
|
|
|
36
36
|
if (this.stopping)
|
|
37
37
|
throw new ImageCacheBusyError();
|
|
38
38
|
const key = createHash("sha256").update(`${scope}\0${url}`).digest("hex");
|
|
39
|
+
const missing = this.negative.get(key);
|
|
40
|
+
if (missing) {
|
|
41
|
+
if (missing.expires > this.now()) {
|
|
42
|
+
this.counters.negativeHits++;
|
|
43
|
+
if (missing.result === "unsupported")
|
|
44
|
+
return "unsupported";
|
|
45
|
+
throw new FeedHttpError(missing.result, undefined, "");
|
|
46
|
+
}
|
|
47
|
+
this.negative.delete(key);
|
|
48
|
+
}
|
|
39
49
|
const shared = this.running.get(key);
|
|
40
50
|
if (shared) {
|
|
41
51
|
this.counters.shared++;
|