diff --git a/migrations-pg/0068_idea_feed_links.sql b/migrations-pg/0068_idea_feed_links.sql new file mode 100644 index 0000000..9df826f --- /dev/null +++ b/migrations-pg/0068_idea_feed_links.sql @@ -0,0 +1,5 @@ +-- 0068_idea_feed_links.sql (Postgres). See migrations/0068_idea_feed_links.sql for the reasoning. +UPDATE idea_asks + SET url = 'https://rssamplifier.com/' || sub || '/read?p=' || substr(post_id, length('feed:' || sub || ':') + 1) + WHERE url NOT LIKE 'http%' + AND post_id LIKE 'feed:%'; diff --git a/migrations/0068_idea_feed_links.sql b/migrations/0068_idea_feed_links.sql new file mode 100644 index 0000000..08617ef --- /dev/null +++ b/migrations/0068_idea_feed_links.sql @@ -0,0 +1,12 @@ +-- 0068_idea_feed_links.sql: idea evidence links that pointed at our own origin. +-- +-- Podcast feeds in the RSS Amplifier directory (Starter Story above all) carry +-- no link per episode, only a guid such as a bare UUID. The reader fell back to +-- that guid as the url, the ideas board rendered it as an href, and the browser +-- resolved it against outreachgraph.com: a 404 on every Starter Story idea. The +-- reader now sends those to the directory's reader page; this rewrites the rows +-- it already filed. post_id is 'feed::', so the guid is recoverable. +UPDATE idea_asks + SET url = 'https://rssamplifier.com/' || sub || '/read?p=' || substr(post_id, length('feed:' || sub || ':') + 1) + WHERE url NOT LIKE 'http%' + AND post_id LIKE 'feed:%'; diff --git a/packages/ideas/src/feeds.test.ts b/packages/ideas/src/feeds.test.ts index 4fb6c85..34eacb6 100644 --- a/packages/ideas/src/feeds.test.ts +++ b/packages/ideas/src/feeds.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from 'bun:test'; import { worthScore, rivalsFor, specificLabel } from './demand'; -import { classifySignal, cleanFeeds, feedPostId, readFeed, DEFAULT_FEEDS, PAYS } from './feeds'; +import { + classifySignal, + cleanFeeds, + feedPostId, + postUrl, + readFeed, + DEFAULT_FEEDS, + PAYS, +} from './feeds'; describe('classifySignal', () => { test('a case study with revenue is a paid signal naming the product', () => { @@ -157,4 +165,29 @@ describe('specificLabel', () => { ]) expect(specificLabel(label)).toBe(true); }); + + test('a podcast episode with no link opens in the directory reader, not on our origin', async () => { + const got = await readFeed( + { slug: 'starter-story-2', role: 'signals', name: 'Starter Story' }, + async () => ({ + items: [ + { + guid: '3e12d0da-b5a2-11f0-98c9-e7999e01f92c', + url: null, + title: 'Bro simply rebuilt Skype and now makes $14,000 per month', + }, + ], + }), + 'https://rssamplifier.com', + ); + expect(got.posts[0]?.url).toBe( + 'https://rssamplifier.com/starter-story-2/read?p=3e12d0da-b5a2-11f0-98c9-e7999e01f92c', + ); + }); + + test('postUrl keeps a real link and falls back to an http guid', () => { + expect(postUrl('https://b.dev/1', 'g1', 'b', 'https://x.com')).toBe('https://b.dev/1'); + expect(postUrl(null, 'https://b.dev/2', 'b', 'https://x.com')).toBe('https://b.dev/2'); + expect(postUrl('', 'g 1', 'b', 'https://x.com')).toBe('https://x.com/b/read?p=g%201'); + }); }); diff --git a/packages/ideas/src/feeds.ts b/packages/ideas/src/feeds.ts index e8b85d9..23af57d 100644 --- a/packages/ideas/src/feeds.ts +++ b/packages/ideas/src/feeds.ts @@ -98,6 +98,18 @@ export function feedPostId(slug: string, guid: string): string { return `feed:${slug}:${guid}`.slice(0, 400); } +/** + * Where a post can be opened. Podcast feeds (Starter Story) often carry no link, + * only a guid like a bare UUID, which an href resolves against our own origin + * and 404s. Those go to the directory's reader page, which plays the episode. + */ +export function postUrl(url: unknown, guid: string, slug: string, base = FEED_BASE): string { + for (const candidate of [url, guid]) { + if (typeof candidate === 'string' && /^https?:\/\//i.test(candidate)) return candidate; + } + return `${base}/${encodeURIComponent(slug)}/read?p=${encodeURIComponent(guid)}`; +} + /** One feed's recent posts from the RSS Amplifier directory. */ export async function readFeed( feed: IdeaFeed, @@ -109,7 +121,7 @@ export async function readFeed( freshness?: string; items?: Array<{ guid?: string; - url?: string; + url?: string | null; title?: string; summary?: string; author?: string; @@ -125,7 +137,7 @@ export async function readFeed( sub: feed.slug, title: decode(String(item.title ?? '')), text: decode(String(item.summary ?? '')), - url: String(item.url ?? guid), + url: postUrl(item.url, guid, feed.slug, base), // A blog's posts are one voice: the feed, not a crowd, so it counts once. author: String(item.author ?? '').trim() || `feed:${feed.slug}`, postedAt: item.publishedAt ?? new Date(0).toISOString(),