feedkeeper 0.27.2 → 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/v1/index.js +1 -0
- package/server/dist/db/migrations/0040_empty_gallery.sql +10 -0
- package/server/dist/feeds/articleContent.js +1 -1
- package/server/dist/feeds/fullTextQueue.js +2 -0
- package/server/dist/feeds/galleryContent.js +33 -0
- package/server/dist/feeds/lead.js +13 -5
- package/server/dist/feeds/repository.js +11 -2
- package/server/dist/native/editions.js +4 -3
- package/server/dist/push.js +2 -0
- package/server/dist/pushOutbox.js +1 -1
- package/server/dist/sync/changeLog.js +2 -0
- 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
|
@@ -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;
|
|
@@ -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();
|
|
@@ -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";
|
|
@@ -344,6 +345,7 @@ export function startFullTextScheduler() {
|
|
|
344
345
|
for (const item of cached)
|
|
345
346
|
if (isCachedConsentSnippet(item.full_content_html)) {
|
|
346
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);
|
|
347
349
|
}
|
|
348
350
|
})();
|
|
349
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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { db } from "../db/index.js";
|
|
3
3
|
import { LEAD_VERSION, leadText } from "./contentNoise.js";
|
|
4
|
+
import { isEmptyGallery } from "./galleryContent.js";
|
|
4
5
|
const BACKFILL_BATCH = 50;
|
|
5
6
|
const BACKFILL_PAUSE_MS = 100;
|
|
6
7
|
const COMPUTED_LIMIT = 300;
|
|
@@ -10,10 +11,17 @@ export function computeLead(item) {
|
|
|
10
11
|
}
|
|
11
12
|
/** Derive and store the lead of one item; call after its title, summary or content was written. */
|
|
12
13
|
export function refreshLead(itemId) {
|
|
13
|
-
const item = db.prepare("SELECT title, content_snippet, content_html, full_content_html FROM items WHERE id = ?").get(itemId);
|
|
14
|
+
const item = db.prepare("SELECT title, link, content_snippet, content_html, full_content_html FROM items WHERE id = ?").get(itemId);
|
|
14
15
|
if (!item)
|
|
15
16
|
return;
|
|
16
|
-
db.prepare("UPDATE items SET content_lead = ?, lead_version = ? WHERE id = ?")
|
|
17
|
+
db.prepare("UPDATE items SET content_lead = ?, lead_version = ?, is_empty_gallery = ? WHERE id = ?")
|
|
18
|
+
.run(computeLead(item), LEAD_VERSION, Number(isEmptyGallery(item)), itemId);
|
|
19
|
+
}
|
|
20
|
+
/** A changed feed summary/link can affect visibility while the lead still belongs to the retained full text. */
|
|
21
|
+
export function refreshGalleryClassification(itemId) {
|
|
22
|
+
const item = db.prepare("SELECT link, content_snippet, content_html, full_content_html FROM items WHERE id = ?").get(itemId);
|
|
23
|
+
if (item)
|
|
24
|
+
db.prepare("UPDATE items SET is_empty_gallery = ? WHERE id = ?").run(Number(isEmptyGallery(item)), itemId);
|
|
17
25
|
}
|
|
18
26
|
/**
|
|
19
27
|
* The lead to serve: the stored one while its rules are current, otherwise derived on the spot, so an article is
|
|
@@ -35,10 +43,10 @@ export function leadOf(item) {
|
|
|
35
43
|
}
|
|
36
44
|
/** Derive leads for items that have none or were derived by older rules; returns how many were handled. */
|
|
37
45
|
export function backfillLeads(limit = BACKFILL_BATCH) {
|
|
38
|
-
const rows = db.prepare("SELECT id, title, content_snippet, content_html, full_content_html FROM items WHERE lead_version IS NULL OR lead_version <> ? LIMIT ?").all(LEAD_VERSION, limit);
|
|
39
|
-
const update = db.prepare("UPDATE items SET content_lead = ?, lead_version = ? WHERE id = ?");
|
|
46
|
+
const rows = db.prepare("SELECT id, title, link, content_snippet, content_html, full_content_html FROM items WHERE lead_version IS NULL OR lead_version <> ? LIMIT ?").all(LEAD_VERSION, limit);
|
|
47
|
+
const update = db.prepare("UPDATE items SET content_lead = ?, lead_version = ?, is_empty_gallery = ? WHERE id = ?");
|
|
40
48
|
db.transaction(() => { for (const row of rows)
|
|
41
|
-
update.run(computeLead(row), LEAD_VERSION, row.id); })();
|
|
49
|
+
update.run(computeLead(row), LEAD_VERSION, Number(isEmptyGallery(row)), row.id); })();
|
|
42
50
|
return rows.length;
|
|
43
51
|
}
|
|
44
52
|
/** Works through the backlog in small steps so polling and requests are never held up for long. */
|
|
@@ -2,7 +2,7 @@ import { db } from "../db/index.js";
|
|
|
2
2
|
import { articleText } from "./articleContent.js";
|
|
3
3
|
import { LEAD_VERSION } from "./contentNoise.js";
|
|
4
4
|
import { largerHeroImage } from "./heroImage.js";
|
|
5
|
-
import { refreshLead } from "./lead.js";
|
|
5
|
+
import { refreshGalleryClassification, refreshLead } from "./lead.js";
|
|
6
6
|
import { decodeEntities } from "./text.js";
|
|
7
7
|
export function findFeedByUrl(url) {
|
|
8
8
|
return db.prepare("SELECT * FROM feeds WHERE url = ?").get(url);
|
|
@@ -191,10 +191,13 @@ export function upsertItems(feedId, items) {
|
|
|
191
191
|
if (changed) {
|
|
192
192
|
update.run(row.title ?? null, row.link ?? null, nextSnippet, retained, row.publishedAt ?? null, nextImage, row.author ?? null, feedId, row.guid);
|
|
193
193
|
const leadChanged = (retained !== null && retained !== previous.content_html) ||
|
|
194
|
+
(row.link !== undefined && row.link !== previous.link) ||
|
|
194
195
|
(row.title !== undefined && row.title !== previous.title) ||
|
|
195
196
|
(!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet);
|
|
196
197
|
if (previous.lead_version !== LEAD_VERSION || (leadChanged && !previous.full_content_html))
|
|
197
198
|
refreshLead(previous.id);
|
|
199
|
+
else if (leadChanged)
|
|
200
|
+
refreshGalleryClassification(previous.id);
|
|
198
201
|
}
|
|
199
202
|
else if (previous.lead_version !== LEAD_VERSION && !previous.full_content_html) {
|
|
200
203
|
refreshLead(previous.id);
|
|
@@ -216,6 +219,10 @@ export function listItemsForUser(userId, opts = {}) {
|
|
|
216
219
|
conditions.push("(s.id IS NOT NULL OR b.item_id IS NOT NULL)");
|
|
217
220
|
}
|
|
218
221
|
const params = [];
|
|
222
|
+
// Keep the stored item available to direct reads and sync; deliberate saves/notes override stream filtering.
|
|
223
|
+
conditions.push(`(i.is_empty_gallery = 0 OR b.item_id IS NOT NULL OR
|
|
224
|
+
EXISTS (SELECT 1 FROM item_notes gallery_note WHERE gallery_note.item_id = i.id AND gallery_note.user_id = ?))`);
|
|
225
|
+
params.push(userId);
|
|
219
226
|
if (opts.feedId) {
|
|
220
227
|
conditions.push("i.feed_id = ?");
|
|
221
228
|
params.push(opts.feedId);
|
|
@@ -564,6 +571,7 @@ export function repairEncodedText() {
|
|
|
564
571
|
const snippet = decodeEntities(row.content_snippet);
|
|
565
572
|
if (title !== row.title || snippet !== row.content_snippet) {
|
|
566
573
|
update.run(title ?? null, snippet ?? null, row.id);
|
|
574
|
+
refreshLead(row.id);
|
|
567
575
|
fixed++;
|
|
568
576
|
}
|
|
569
577
|
}
|
|
@@ -686,7 +694,8 @@ export function needsSubstringSearch(raw) {
|
|
|
686
694
|
}
|
|
687
695
|
export function searchItemsForUser(userId, opts) {
|
|
688
696
|
const fts = needsSubstringSearch(opts.query) ? "" : formatFtsQuery(opts.query);
|
|
689
|
-
const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)"
|
|
697
|
+
const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)",
|
|
698
|
+
"(i.is_empty_gallery = 0 OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)"];
|
|
690
699
|
const params = [];
|
|
691
700
|
if (opts.feedId) {
|
|
692
701
|
conditions.push("i.feed_id = ?");
|
|
@@ -108,11 +108,11 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
|
|
|
108
108
|
for (const id of data.itemIds) {
|
|
109
109
|
if (!canAccessItem(userId, id))
|
|
110
110
|
throw new NativeError("item_not_found", 404);
|
|
111
|
-
const item = db.prepare(`SELECT s.folder_id, s.id IS NOT NULL AS subscribed,
|
|
111
|
+
const item = db.prepare(`SELECT s.folder_id, s.id IS NOT NULL AS subscribed, i.is_empty_gallery,
|
|
112
112
|
EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
|
|
113
113
|
(instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0)) AS muted
|
|
114
114
|
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.muted || (item.subscribed && (item.folder_id === null ? !prefs.showUnfiled : prefs.hiddenFolderIds.includes(item.folder_id)))) {
|
|
115
|
+
if (item.is_empty_gallery || item.muted || (item.subscribed && (item.folder_id === null ? !prefs.showUnfiled : prefs.hiddenFolderIds.includes(item.folder_id)))) {
|
|
116
116
|
throw new NativeError("item_excluded");
|
|
117
117
|
}
|
|
118
118
|
}
|
|
@@ -224,7 +224,8 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
|
|
|
224
224
|
row_number() OVER (PARTITION BY i.feed_id ORDER BY coalesce(julianday(i.published_at), julianday(i.created_at)) DESC, i.id DESC) AS source_rank
|
|
225
225
|
FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
|
|
226
226
|
JOIN feeds f ON f.id = i.feed_id LEFT JOIN folders ON folders.id = s.folder_id
|
|
227
|
-
WHERE
|
|
227
|
+
WHERE i.is_empty_gallery = 0
|
|
228
|
+
AND NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?)
|
|
228
229
|
AND NOT EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
|
|
229
230
|
(instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0))
|
|
230
231
|
AND coalesce(julianday(i.published_at), julianday(i.created_at)) BETWEEN julianday(?) AND julianday(?)
|
package/server/dist/push.js
CHANGED
|
@@ -112,6 +112,8 @@ export function newItemsPushPayload(feed, userId, label, latest) {
|
|
|
112
112
|
const muted = db.prepare("SELECT keyword FROM user_muted_keywords WHERE user_id = ?")
|
|
113
113
|
.all(userId).map(row => row.keyword.toLowerCase());
|
|
114
114
|
const fresh = latest.filter(item => {
|
|
115
|
+
if (item.is_empty_gallery)
|
|
116
|
+
return false;
|
|
115
117
|
const text = `${item.title ?? ""} ${item.content_snippet ?? ""}`.toLowerCase();
|
|
116
118
|
return !muted.some(keyword => text.includes(keyword));
|
|
117
119
|
});
|
|
@@ -111,7 +111,7 @@ export class PushOutbox {
|
|
|
111
111
|
if (!feed)
|
|
112
112
|
return; // Feed deletion cascades the event.
|
|
113
113
|
const latest = db.prepare(`
|
|
114
|
-
SELECT id,title,content_snippet FROM items WHERE feed_id = ? AND id <= ? ORDER BY id DESC LIMIT ?`)
|
|
114
|
+
SELECT id,title,content_snippet,is_empty_gallery FROM items WHERE feed_id = ? AND id <= ? ORDER BY id DESC LIMIT ?`)
|
|
115
115
|
.all(event.feed_id, event.max_item_id, event.item_count);
|
|
116
116
|
const payload = newItemsPushPayload(feed, subscription.user_id, subscription.label, latest);
|
|
117
117
|
if (!payload) {
|
|
@@ -57,6 +57,7 @@ function loadItemContents(userId, itemIds) {
|
|
|
57
57
|
const rows = db
|
|
58
58
|
.prepare(`SELECT i.id,
|
|
59
59
|
i.content_revision,
|
|
60
|
+
i.is_empty_gallery,
|
|
60
61
|
CASE WHEN i.full_content_html IS NOT NULL AND i.full_content_html != '' THEN 'reader'
|
|
61
62
|
WHEN i.content_html IS NOT NULL AND i.content_html != '' THEN 'feed'
|
|
62
63
|
ELSE 'snippet' END AS source,
|
|
@@ -75,6 +76,7 @@ function loadItemContents(userId, itemIds) {
|
|
|
75
76
|
status: row.extraction_status,
|
|
76
77
|
attemptedAt: row.extraction_attempted_at,
|
|
77
78
|
retryAt: row.extraction_retry_at,
|
|
79
|
+
isEmptyGallery: Boolean(row.is_empty_gallery),
|
|
78
80
|
});
|
|
79
81
|
}
|
|
80
82
|
return result;
|