feedkeeper 0.28.0 → 0.28.1
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.
|
|
@@ -248,6 +254,27 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
248
254
|
drop(block, "comment-rules");
|
|
249
255
|
break;
|
|
250
256
|
}
|
|
257
|
+
// Empty nested slots are structural evidence: the next story paragraph is outside this ad container.
|
|
258
|
+
// Do not infer an empty ad from a standalone heading followed by text (which may itself be sponsored).
|
|
259
|
+
for (const container of [...root.querySelectorAll("div, aside, section, p")]) {
|
|
260
|
+
if (!root.contains(container))
|
|
261
|
+
continue;
|
|
262
|
+
const text = flat(container.textContent);
|
|
263
|
+
const product = PRODUCT_LABEL.test(text);
|
|
264
|
+
if (!product && !AD_LABEL.test(text))
|
|
265
|
+
continue;
|
|
266
|
+
if (container.querySelector("img, picture, iframe, video, audio, a[href], object, embed"))
|
|
267
|
+
continue;
|
|
268
|
+
const emptySlot = container.querySelector("div:empty, ins:empty");
|
|
269
|
+
// Readability may remove the empty slot, leaving only its enclosing block and label paragraph.
|
|
270
|
+
const isolated = container.matches("div, aside, section") && container.querySelector("p") &&
|
|
271
|
+
(product || AD_SLOT_LABEL.test(text));
|
|
272
|
+
const next = container.nextElementSibling;
|
|
273
|
+
const nextHasMedia = next && (next.matches("img, picture, iframe, video, audio, a[href]") ||
|
|
274
|
+
next.querySelector("img, picture, iframe, video, audio, a[href]"));
|
|
275
|
+
if (emptySlot || (isolated && !nextHasMedia))
|
|
276
|
+
drop(container, product ? "empty-product-label" : "empty-ad-label");
|
|
277
|
+
}
|
|
251
278
|
// An ad label whose ad is not there (a script fills it): sponsored content itself stays, the label above nothing goes.
|
|
252
279
|
const labelled = leafBlocks(root);
|
|
253
280
|
labelled.forEach((block, index) => {
|
|
@@ -362,13 +389,14 @@ export function cleanReaderHtml(html, title, stats = {}, author) {
|
|
|
362
389
|
return html;
|
|
363
390
|
const root = JSDOM.fragment(html);
|
|
364
391
|
const images = resolveCustomImages(root, stats);
|
|
392
|
+
const duplicates = collapseAdjacentImages(root, stats);
|
|
365
393
|
const variants = collapseImageVariants(root, stats);
|
|
366
394
|
const videos = resolveVideoPlayers(root, stats);
|
|
367
395
|
const noise = removeReaderNoise(root, title, stats, author);
|
|
368
396
|
const typography = removeTypography(root, stats);
|
|
369
397
|
videos.links.forEach(link => { if (root.contains(link))
|
|
370
398
|
link.setAttribute("data-media", "video"); });
|
|
371
|
-
return images || variants || videos.changed || noise || typography ? serialize(root) : html;
|
|
399
|
+
return images || duplicates || variants || videos.changed || noise || typography ? serialize(root) : html;
|
|
372
400
|
}
|
|
373
401
|
const PLAY_LABEL = /^(video starten|video abspielen|abspielen|wiedergabe( starten)?|play( video)?|watch( video)?|start video)$/i;
|
|
374
402
|
const PLAY_SOURCE = /(?:^|[/_.-])play[-_]?(?:icon|button|btn)?\.(?:svg|png|webp|gif)(?:[?#]|$)/i;
|
|
@@ -539,6 +567,46 @@ function collapseImageVariants(root, stats) {
|
|
|
539
567
|
}
|
|
540
568
|
return changed;
|
|
541
569
|
}
|
|
570
|
+
/** Responsive copies may be separate images whose CSS normally hides one. Only exact adjacent copies go;
|
|
571
|
+
* text, other media, different links, captions and responsive sources keep images distinct. */
|
|
572
|
+
function collapseAdjacentImages(root, stats) {
|
|
573
|
+
const walker = root.ownerDocument.createTreeWalker(root, 1 | 4);
|
|
574
|
+
let previous = null;
|
|
575
|
+
const duplicates = [];
|
|
576
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
577
|
+
if (node.nodeType === 3) {
|
|
578
|
+
if (flat(node.textContent))
|
|
579
|
+
previous = null;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const element = node;
|
|
583
|
+
if (element.matches("video, audio, iframe, object, embed, hr"))
|
|
584
|
+
previous = null;
|
|
585
|
+
if (element.tagName !== "IMG")
|
|
586
|
+
continue;
|
|
587
|
+
const src = flat(element.getAttribute("data-src") || element.getAttribute("data-lazy-src") ||
|
|
588
|
+
element.getAttribute("data-original") || element.getAttribute("src"));
|
|
589
|
+
if (!src || /^(data|blob):/i.test(src)) {
|
|
590
|
+
previous = null;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const picture = element.closest("picture");
|
|
594
|
+
const key = JSON.stringify([src, element.getAttribute("srcset"), element.getAttribute("data-srcset"),
|
|
595
|
+
element.getAttribute("alt"), element.getAttribute("title"), element.closest("a")?.getAttribute("href"),
|
|
596
|
+
[...(picture?.querySelectorAll("source") ?? [])].map(source => [source.getAttribute("srcset"), source.getAttribute("media"), source.getAttribute("type")])]);
|
|
597
|
+
if (previous === key)
|
|
598
|
+
duplicates.push(picture?.querySelectorAll("img").length === 1 ? picture : element);
|
|
599
|
+
else
|
|
600
|
+
previous = key;
|
|
601
|
+
}
|
|
602
|
+
for (const duplicate of duplicates) {
|
|
603
|
+
const parent = duplicate.parentNode;
|
|
604
|
+
duplicate.remove();
|
|
605
|
+
pruneEmpty(parent);
|
|
606
|
+
stats["duplicate-image"] = (stats["duplicate-image"] ?? 0) + 1;
|
|
607
|
+
}
|
|
608
|
+
return duplicates.length > 0;
|
|
609
|
+
}
|
|
542
610
|
const CREDIT_PATTERNS = [
|
|
543
611
|
/^(©|\(c\)\s|copyright\b)/i,
|
|
544
612
|
/^[([]?(Bild|Foto|Fotos|Bildquelle|Bildrechte|Bildnachweis|Titelbild|Beitragsbild|Photo|Image|Credits?|Grafik|Illustration)[)\]]?\s*[:/|]/i,
|
|
@@ -589,7 +657,9 @@ const COMMENT_PATTERNS = [
|
|
|
589
657
|
/\b(let us know|tell us) .{0,40}in the comments\b/i,
|
|
590
658
|
/\bNur angemeldete Benutzer können kommentieren\b/i,
|
|
591
659
|
];
|
|
592
|
-
const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|Sponsored( content| post)?|Paid (content|post)|Gesponsert|Promoted)\s
|
|
660
|
+
const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|Sponsored( content| post)?|Paid (content|post)|Gesponsert|Promoted|広告|広告掲載|スポンサー(?:記事|コンテンツ)?|宣伝|PR)\s*[::]?$/i;
|
|
661
|
+
const AD_SLOT_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Ads?|広告|広告掲載|宣伝)\s*[::]?$/i;
|
|
662
|
+
const PRODUCT_LABEL = /^(Produkthinweis|Produktempfehlung|Product (notice|recommendation)|商品紹介|商品案内|製品紹介)\s*[::]?$/i;
|
|
593
663
|
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
664
|
const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
|
|
595
665
|
const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
|