feedkeeper 0.28.0 → 0.28.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -121,8 +121,14 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
121
121
|
if (ancestor.querySelector("a[href]") && PRICE.test(text))
|
|
122
122
|
card = ancestor;
|
|
123
123
|
}
|
|
124
|
-
if (card)
|
|
124
|
+
if (card) {
|
|
125
|
+
const parent = card.parentElement;
|
|
125
126
|
drop(card, "product-card");
|
|
127
|
+
// A long product name can put the enclosing header just beyond the card's size limit.
|
|
128
|
+
if (parent && PRODUCT_LABEL.test(flat(parent.textContent)) && !parent.querySelector("img, picture, iframe, video, a[href]")) {
|
|
129
|
+
drop(parent, "empty-product-label");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
126
132
|
}
|
|
127
133
|
// Structured data is for machines. Frameworks render it as an element of any name (`<component type="application/ld+json">`),
|
|
128
134
|
// and a stray block may hold nothing else: neither is text for a reader.
|
|
@@ -137,6 +143,32 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
137
143
|
const headline = normalized(title ?? "");
|
|
138
144
|
const seen = new Set();
|
|
139
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
|
+
}
|
|
140
172
|
let leading = 0;
|
|
141
173
|
let reachedText = false;
|
|
142
174
|
let pastStart = false;
|
|
@@ -177,14 +209,9 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
177
209
|
continue;
|
|
178
210
|
}
|
|
179
211
|
// When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
|
|
180
|
-
|
|
181
|
-
|
|
212
|
+
cleanByline(block, text);
|
|
213
|
+
if (!root.contains(block))
|
|
182
214
|
continue;
|
|
183
|
-
}
|
|
184
|
-
if (BYLINE.test(text) && stripBylineDate(block)) {
|
|
185
|
-
stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
|
|
186
|
-
changed = true;
|
|
187
|
-
}
|
|
188
215
|
}
|
|
189
216
|
}
|
|
190
217
|
if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
|
|
@@ -248,6 +275,27 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
248
275
|
drop(block, "comment-rules");
|
|
249
276
|
break;
|
|
250
277
|
}
|
|
278
|
+
// Empty nested slots are structural evidence: the next story paragraph is outside this ad container.
|
|
279
|
+
// Do not infer an empty ad from a standalone heading followed by text (which may itself be sponsored).
|
|
280
|
+
for (const container of [...root.querySelectorAll("div, aside, section, p")]) {
|
|
281
|
+
if (!root.contains(container))
|
|
282
|
+
continue;
|
|
283
|
+
const text = flat(container.textContent);
|
|
284
|
+
const product = PRODUCT_LABEL.test(text);
|
|
285
|
+
if (!product && !AD_LABEL.test(text))
|
|
286
|
+
continue;
|
|
287
|
+
if (container.querySelector("img, picture, iframe, video, audio, a[href], object, embed"))
|
|
288
|
+
continue;
|
|
289
|
+
const emptySlot = container.querySelector("div:empty, ins:empty");
|
|
290
|
+
// Readability may remove the empty slot, leaving only its enclosing block and label paragraph.
|
|
291
|
+
const isolated = container.matches("div, aside, section") && container.querySelector("p") &&
|
|
292
|
+
(product || AD_SLOT_LABEL.test(text));
|
|
293
|
+
const next = container.nextElementSibling;
|
|
294
|
+
const nextHasMedia = next && (next.matches("img, picture, iframe, video, audio, a[href]") ||
|
|
295
|
+
next.querySelector("img, picture, iframe, video, audio, a[href]"));
|
|
296
|
+
if (emptySlot || (isolated && !nextHasMedia))
|
|
297
|
+
drop(container, product ? "empty-product-label" : "empty-ad-label");
|
|
298
|
+
}
|
|
251
299
|
// An ad label whose ad is not there (a script fills it): sponsored content itself stays, the label above nothing goes.
|
|
252
300
|
const labelled = leafBlocks(root);
|
|
253
301
|
labelled.forEach((block, index) => {
|
|
@@ -362,13 +410,14 @@ export function cleanReaderHtml(html, title, stats = {}, author) {
|
|
|
362
410
|
return html;
|
|
363
411
|
const root = JSDOM.fragment(html);
|
|
364
412
|
const images = resolveCustomImages(root, stats);
|
|
413
|
+
const duplicates = collapseAdjacentImages(root, stats);
|
|
365
414
|
const variants = collapseImageVariants(root, stats);
|
|
366
415
|
const videos = resolveVideoPlayers(root, stats);
|
|
367
416
|
const noise = removeReaderNoise(root, title, stats, author);
|
|
368
417
|
const typography = removeTypography(root, stats);
|
|
369
418
|
videos.links.forEach(link => { if (root.contains(link))
|
|
370
419
|
link.setAttribute("data-media", "video"); });
|
|
371
|
-
return images || variants || videos.changed || noise || typography ? serialize(root) : html;
|
|
420
|
+
return images || duplicates || variants || videos.changed || noise || typography ? serialize(root) : html;
|
|
372
421
|
}
|
|
373
422
|
const PLAY_LABEL = /^(video starten|video abspielen|abspielen|wiedergabe( starten)?|play( video)?|watch( video)?|start video)$/i;
|
|
374
423
|
const PLAY_SOURCE = /(?:^|[/_.-])play[-_]?(?:icon|button|btn)?\.(?:svg|png|webp|gif)(?:[?#]|$)/i;
|
|
@@ -539,6 +588,46 @@ function collapseImageVariants(root, stats) {
|
|
|
539
588
|
}
|
|
540
589
|
return changed;
|
|
541
590
|
}
|
|
591
|
+
/** Responsive copies may be separate images whose CSS normally hides one. Only exact adjacent copies go;
|
|
592
|
+
* text, other media, different links, captions and responsive sources keep images distinct. */
|
|
593
|
+
function collapseAdjacentImages(root, stats) {
|
|
594
|
+
const walker = root.ownerDocument.createTreeWalker(root, 1 | 4);
|
|
595
|
+
let previous = null;
|
|
596
|
+
const duplicates = [];
|
|
597
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
598
|
+
if (node.nodeType === 3) {
|
|
599
|
+
if (flat(node.textContent))
|
|
600
|
+
previous = null;
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const element = node;
|
|
604
|
+
if (element.matches("video, audio, iframe, object, embed, hr"))
|
|
605
|
+
previous = null;
|
|
606
|
+
if (element.tagName !== "IMG")
|
|
607
|
+
continue;
|
|
608
|
+
const src = flat(element.getAttribute("data-src") || element.getAttribute("data-lazy-src") ||
|
|
609
|
+
element.getAttribute("data-original") || element.getAttribute("src"));
|
|
610
|
+
if (!src || /^(data|blob):/i.test(src)) {
|
|
611
|
+
previous = null;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const picture = element.closest("picture");
|
|
615
|
+
const key = JSON.stringify([src, element.getAttribute("srcset"), element.getAttribute("data-srcset"),
|
|
616
|
+
element.getAttribute("alt"), element.getAttribute("title"), element.closest("a")?.getAttribute("href"),
|
|
617
|
+
[...(picture?.querySelectorAll("source") ?? [])].map(source => [source.getAttribute("srcset"), source.getAttribute("media"), source.getAttribute("type")])]);
|
|
618
|
+
if (previous === key)
|
|
619
|
+
duplicates.push(picture?.querySelectorAll("img").length === 1 ? picture : element);
|
|
620
|
+
else
|
|
621
|
+
previous = key;
|
|
622
|
+
}
|
|
623
|
+
for (const duplicate of duplicates) {
|
|
624
|
+
const parent = duplicate.parentNode;
|
|
625
|
+
duplicate.remove();
|
|
626
|
+
pruneEmpty(parent);
|
|
627
|
+
stats["duplicate-image"] = (stats["duplicate-image"] ?? 0) + 1;
|
|
628
|
+
}
|
|
629
|
+
return duplicates.length > 0;
|
|
630
|
+
}
|
|
542
631
|
const CREDIT_PATTERNS = [
|
|
543
632
|
/^(©|\(c\)\s|copyright\b)/i,
|
|
544
633
|
/^[([]?(Bild|Foto|Fotos|Bildquelle|Bildrechte|Bildnachweis|Titelbild|Beitragsbild|Photo|Image|Credits?|Grafik|Illustration)[)\]]?\s*[:/|]/i,
|
|
@@ -589,7 +678,9 @@ const COMMENT_PATTERNS = [
|
|
|
589
678
|
/\b(let us know|tell us) .{0,40}in the comments\b/i,
|
|
590
679
|
/\bNur angemeldete Benutzer können kommentieren\b/i,
|
|
591
680
|
];
|
|
592
|
-
const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|Sponsored( content| post)?|Paid (content|post)|Gesponsert|Promoted)\s
|
|
681
|
+
const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|Sponsored( content| post)?|Paid (content|post)|Gesponsert|Promoted|広告|広告掲載|スポンサー(?:記事|コンテンツ)?|宣伝|PR)\s*[::]?$/i;
|
|
682
|
+
const AD_SLOT_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Ads?|広告|広告掲載|宣伝)\s*[::]?$/i;
|
|
683
|
+
const PRODUCT_LABEL = /^(Produkthinweis|Produktempfehlung|Product (notice|recommendation)|商品紹介|商品案内|製品紹介)\s*[::]?$/i;
|
|
593
684
|
const COMMENT_RULES_HEADING = /^(Regeln für Kommentare|Kommentarregeln|Kommentar-Richtlinien|Community[- ]Richtlinien|(Comment(ing)?|Community|Discussion|House) (rules|guidelines|policy|policies)|Rules for comments|Commenting rules)\s*:?$/i;
|
|
594
685
|
const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
|
|
595
686
|
const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
|
|
@@ -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,
|