feedkeeper 0.35.5 → 0.35.6

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.6",
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",
@@ -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.6",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }