feedkeeper 0.35.5 → 0.35.7

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.35.5",
3
+ "version": "0.35.7",
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",
@@ -4,6 +4,7 @@ import { embedHeadings } from "./embeds/headings.js";
4
4
  import { boilerplate } from "./noise/boilerplate.js";
5
5
  import { loadingPlaceholders } from "./noise/loading.js";
6
6
  import { trackingPixels } from "./noise/pixels.js";
7
+ import { permalinkGlyphs } from "./noise/permalinkGlyphs.js";
7
8
  import { sidebars } from "./noise/sidebars.js";
8
9
  import { teaserLinks } from "./noise/teaserLinks.js";
9
10
  /**
@@ -11,7 +12,7 @@ import { teaserLinks } from "./noise/teaserLinks.js";
11
12
  * tags that are removed next), then the clutter around the story. The tests check each rule against its fixtures in
12
13
  * `test/fixtures/content/<rule name>` and that running it twice changes nothing. See `docs/content-rules.md`.
13
14
  */
14
- export const PAGE_RULES = [lazyImages, ...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks];
15
+ export const PAGE_RULES = [lazyImages, ...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks, permalinkGlyphs];
15
16
  export { resolveEmbeds };
16
17
  /** What a fetched page goes through before its scripts, frames and `noscript` blocks are removed. */
17
18
  export function cleanPage(root, stats = {}) {
@@ -0,0 +1,52 @@
1
+ import { count, flat, pruneEmpty } from "../shared.js";
2
+ /** One to three characters that are neither letters nor digits: "★", "∞", "¶", "#", "↩". Non-breaking spaces around them count as blanks. */
3
+ const GLYPH_ONLY = /^[^\p{L}\p{N}]{1,3}$/u;
4
+ /** Whether any text follows the element in the document: only the tail of a story is looked at. */
5
+ function hasTextAfter(root, element) {
6
+ const document = root.nodeType === 9 ? root : root.ownerDocument;
7
+ if (!document)
8
+ return false;
9
+ const walker = document.createTreeWalker(root, 4);
10
+ let reached = false;
11
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
12
+ if (element.contains(node)) {
13
+ reached = true;
14
+ continue;
15
+ }
16
+ if (reached && flat(node.textContent) !== "")
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ /**
22
+ * Some blogs end every post with a permanent link that is only a symbol ("★", "∞", "¶"): a link to the post itself, of no use inside a reader.
23
+ * The block must hold nothing but that one link, come after the text of the story and have nothing after it. A symbol inside a sentence,
24
+ * a link with a picture and a block with more text stay.
25
+ */
26
+ export const permalinkGlyphs = {
27
+ name: "permalink-glyphs",
28
+ description: "A block at the end of a story that holds only one link made of a single symbol such as \"★\" or \"¶\" is removed.",
29
+ apply(root, stats) {
30
+ let changed = false;
31
+ const total = flat(root.textContent).length;
32
+ for (const block of [...root.querySelectorAll("p, div")].reverse()) {
33
+ if (!root.contains(block) || block.querySelector("p, div, img, picture, video, iframe"))
34
+ continue;
35
+ const links = block.querySelectorAll("a[href]");
36
+ if (links.length !== 1)
37
+ continue;
38
+ const text = (block.textContent ?? "").replace(/[\s\u00a0]+/g, "");
39
+ if (!GLYPH_ONLY.test(text) || flat(links[0].textContent).replace(/[\s\u00a0]+/g, "") !== text)
40
+ continue;
41
+ if (total - text.length < 120 || hasTextAfter(root, block))
42
+ continue;
43
+ const parent = block.parentNode;
44
+ block.remove();
45
+ pruneEmpty(parent);
46
+ count(stats, "permalink-glyphs");
47
+ changed = true;
48
+ }
49
+ return changed;
50
+ },
51
+ };
52
+ //# sourceMappingURL=permalinkGlyphs.js.map
@@ -7,6 +7,7 @@ import { SIDEBAR_HEADINGS } from "./content/patterns/sidebars/index.js";
7
7
  import { resolveEmbeds } from "./content/embeds/index.js";
8
8
  import { loadingPlaceholders } from "./content/noise/loading.js";
9
9
  import { trackingPixels } from "./content/noise/pixels.js";
10
+ import { permalinkGlyphs } from "./content/noise/permalinkGlyphs.js";
10
11
  import { teaserLinks } from "./content/noise/teaserLinks.js";
11
12
  import { markVideoTeasers } from "./content/noise/videoTeasers.js";
12
13
  import { flat, pruneEmpty } from "./content/shared.js";
@@ -95,6 +96,8 @@ function removeReaderNoise(root, title, stats, author) {
95
96
  // A feed's own text ends with "[mehr]": the link to the article is no part of what is read.
96
97
  if (teaserLinks.apply(root, stats))
97
98
  changed = true;
99
+ if (permalinkGlyphs.apply(root, stats))
100
+ changed = true;
98
101
  if (loadingPlaceholders.apply(root, stats))
99
102
  changed = true;
100
103
  if (trackingPixels.apply(root, stats))
@@ -42,6 +42,8 @@ export function imageSource(attribute) {
42
42
  }
43
43
  return null;
44
44
  }
45
+ /** The body of a tag up to its closing `>`: a `>` inside a quoted value, as in `media="(resolution >= 3x)"`, does not end it. */
46
+ const TAG_BODY = `(?:[^>"']|"[^"]*"|'[^']*')*`;
45
47
  function attribute(tag, name) {
46
48
  const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
47
49
  return match ? match[1] ?? match[2] ?? match[3] : null;
@@ -56,14 +58,14 @@ export function normalizeImageSources(html) {
56
58
  const withoutSources = tag.replace(/\s+(?:(?:data-(?:lazy-)?)?src(?:set)?|sizes|data-sizes|data-original|data-actualsrc)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+)/gi, "");
57
59
  return withoutSources.replace(/\s*\/?>$/, (end) => ` src="${escaped}"${end}`);
58
60
  };
59
- const pictures = html.replace(/<picture\b[^>]*>[\s\S]*?<\/picture\s*>/gi, (picture) => {
61
+ const pictures = html.replace(new RegExp(`<picture\\b${TAG_BODY}>[\\s\\S]*?<\\/picture\\s*>`, "gi"), (picture) => {
60
62
  let fallback = null;
61
- const withoutSources = picture.replace(/<source\b[^>]*>/gi, (tag) => {
63
+ const withoutSources = picture.replace(new RegExp(`<source\\b${TAG_BODY}>`, "gi"), (tag) => {
62
64
  fallback ??= firstSrcsetSource(attribute(tag, "data-lazy-srcset") ?? attribute(tag, "data-srcset") ?? attribute(tag, "srcset"));
63
65
  return "";
64
66
  });
65
- return withoutSources.replace(/<img\b[^>]*>/gi, (tag) => normalize(tag, fallback));
67
+ return withoutSources.replace(new RegExp(`<img\\b${TAG_BODY}>`, "gi"), (tag) => normalize(tag, fallback));
66
68
  });
67
- return pictures.replace(/<img\b[^>]*>/gi, (tag) => normalize(tag));
69
+ return pictures.replace(new RegExp(`<img\\b${TAG_BODY}>`, "gi"), (tag) => normalize(tag));
68
70
  }
69
71
  //# sourceMappingURL=imageSources.js.map
@@ -3,6 +3,7 @@ import { fetchFeed } from "./fetcher.js";
3
3
  import { discoverIconUrls, iconCheckDue } from "./icon.js";
4
4
  import { refreshFeedIcon } from "./feedIcon.js";
5
5
  import { decodeEntities, plainTitle } from "./text.js";
6
+ import { unwrapRedirectLink } from "./redirectLinks.js";
6
7
  import { enqueueNewItemNotifications, startPushOutbox, stopPushOutbox } from "../pushOutbox.js";
7
8
  import { hasUserCapability } from "../auth/capabilities.js";
8
9
  import { findFeedById, listSubscriberIds, updateFeedAfterPoll, updateFeedIcon, upsertItems } from "./repository.js";
@@ -123,8 +124,8 @@ export function itemGuid(item) {
123
124
  /** The article address: its link, or its guid when the feed gives only that and the guid is a web address. */
124
125
  export function itemLink(item) {
125
126
  if (item.link)
126
- return item.link;
127
- return item.guid && /^https?:\/\//i.test(item.guid) ? item.guid : undefined;
127
+ return unwrapRedirectLink(item.link);
128
+ return item.guid && /^https?:\/\//i.test(item.guid) ? unwrapRedirectLink(item.guid) : undefined;
128
129
  }
129
130
  /** One attempt; the queue owns retries, admission, single-flight and the total deadline. */
130
131
  async function pollAttempt(feed, signal) {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Some feeds wrap every article address in a redirect through the publisher's tracker. Google Alerts links through
3
+ * `https://www.google.com/url?...&url=<article>`, which is not the page to read and fails the full-text fetch. The article address
4
+ * inside the wrapper is what an item should point to.
5
+ */
6
+ const GOOGLE_HOST = /^(?:www\.)?google\.[a-z]{2,3}(?:\.[a-z]{2})?$/i;
7
+ export function unwrapRedirectLink(link) {
8
+ if (!link)
9
+ return link;
10
+ let url;
11
+ try {
12
+ url = new URL(link);
13
+ }
14
+ catch {
15
+ return link;
16
+ }
17
+ if (!GOOGLE_HOST.test(url.hostname) || url.pathname !== "/url")
18
+ return link;
19
+ const target = url.searchParams.get("url") ?? url.searchParams.get("q");
20
+ return target && /^https?:\/\//i.test(target) ? target : link;
21
+ }
22
+ //# sourceMappingURL=redirectLinks.js.map
@@ -3,7 +3,8 @@ import { articleText } from "./articleContent.js";
3
3
  import { LEAD_VERSION } from "./contentNoise.js";
4
4
  import { largerHeroImage } from "./heroImage.js";
5
5
  import { refreshGalleryClassification, refreshLead } from "./lead.js";
6
- import { decodeEntities } from "./text.js";
6
+ import { decodeEntities, plainTitle } from "./text.js";
7
+ import { unwrapRedirectLink } from "./redirectLinks.js";
7
8
  export function findFeedByUrl(url) {
8
9
  return db.prepare("SELECT * FROM feeds WHERE url = ?").get(url);
9
10
  }
@@ -570,19 +571,24 @@ export function moveSubscription(userId, fromFeedId, toFeedId) {
570
571
  export function updateFeedIcon(feedId, iconUrl) {
571
572
  db.prepare("UPDATE feeds SET icon_url = ?, icon_checked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(iconUrl, feedId);
572
573
  }
573
- /** Fix item titles, snippets, feed titles and labels stored before entity decoding existed; cheap once nothing matches. */
574
+ /**
575
+ * Fix item titles, snippets, links, feed titles and labels stored before entity decoding, title markup removal and redirect unwrapping
576
+ * existed; cheap once nothing matches.
577
+ */
574
578
  export function repairEncodedText() {
575
579
  const rows = db
576
- .prepare("SELECT id, title, content_snippet FROM items WHERE title LIKE '%&%;%' OR content_snippet LIKE '%&%;%'")
580
+ .prepare(`SELECT id, title, content_snippet, link FROM items
581
+ WHERE title LIKE '%&%;%' OR title LIKE '%<%>%' OR content_snippet LIKE '%&%;%' OR link LIKE 'https://www.google.%/url?%'`)
577
582
  .all();
578
- const update = db.prepare("UPDATE items SET title = ?, content_snippet = ? WHERE id = ?");
583
+ const update = db.prepare("UPDATE items SET title = ?, content_snippet = ?, link = ? WHERE id = ?");
579
584
  let fixed = 0;
580
585
  db.transaction(() => {
581
586
  for (const row of rows) {
582
- const title = decodeEntities(row.title);
587
+ const title = row.title === null ? null : (plainTitle(row.title) ?? row.title);
583
588
  const snippet = decodeEntities(row.content_snippet);
584
- if (title !== row.title || snippet !== row.content_snippet) {
585
- update.run(title ?? null, snippet ?? null, row.id);
589
+ const link = unwrapRedirectLink(row.link);
590
+ if (title !== row.title || snippet !== row.content_snippet || link !== row.link) {
591
+ update.run(title, snippet ?? null, link ?? null, row.id);
586
592
  refreshLead(row.id);
587
593
  fixed++;
588
594
  }
@@ -15,9 +15,14 @@ function decodeOnce(text) {
15
15
  return NAMED_ENTITIES[name] ?? match;
16
16
  });
17
17
  }
18
- /** A title as one line of text: entities decoded, line breaks and runs of spaces collapsed to one space, trimmed. */
18
+ /**
19
+ * Inline formatting that some feeds put into a title, such as the `<b>` Google Alerts wraps around the search term. Only these tags are
20
+ * removed, so a title that really contains an angle bracket stays as it is.
21
+ */
22
+ const TITLE_MARKUP = /<\/?(?:b|i|u|em|strong|mark|span|small|sub|sup|font|a|br)\b[^>]*>/gi;
23
+ /** A title as one line of text: entities decoded, inline markup removed, line breaks and runs of spaces collapsed to one space, trimmed. */
19
24
  export function plainTitle(text) {
20
- const cleaned = decodeEntities(text)?.replace(/\s+/g, " ").trim();
25
+ const cleaned = decodeEntities(text)?.replace(TITLE_MARKUP, tag => (/^<br/i.test(tag) ? " " : "")).replace(/\s+/g, " ").trim();
21
26
  return cleaned || undefined;
22
27
  }
23
28
  export function decodeEntities(text) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.35.5",
3
+ "version": "0.35.7",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }