Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,67 @@ rssamp schedule auto-post --platform bluesky --interval daily
- `rssamp feeds list` - List all feeds
- `rssamp feeds refresh` - Manually refresh feeds

### Feed Catalogue

For catalogues rather than a handful of hand-picked feeds. These commands use a
SQLite store (`~/.config/rss-amplifier/feeds.db`) instead of `feeds.json`,
because the JSON store loads every feed into memory and rewrites the whole file
on each change — fine for fifty feeds, fatal for fifty thousand.

- `rssamp feeds import-opml --file feeds.opml` - Stream an OPML catalogue in.
Tested at 47,000 feeds in under two seconds.
- `rssamp feeds harvest-podcasts [--all]` - Build a podcast catalogue from the
iTunes Search API (free, no key). `--all` sweeps ten markets instead of one.
Paced under Apple's rate limit; Ctrl-C is safe and keeps what it found.
- `rssamp feeds stats` - Catalogue size, what is due, what is failing.
- `rssamp feeds recent [--kind podcast]` - Newest articles collected.

#### Re-syncing from brisk.news

brisk.news holds the same feeds in Supabase (`rss_feed_sources`), split into
`smallweb` (the Kagi catalogue) and `opml` (feeds collected by hand over the
years — the ones worth keeping). Export them as OPML server-side so tens of
thousands of rows never travel through an agent's context:

```sql
select '<?xml version="1.0" encoding="UTF-8"?><opml version="2.0">'
|| '<head><title>brisk.news feeds</title></head><body>'
|| string_agg(
'<outline type="rss" title="' || replace(coalesce(title, feed_url), '"', '&quot;')
|| '" xmlUrl="' || replace(feed_url, '"', '&quot;') || '"/>', '')
|| '</body></opml>'
from rss_feed_sources
where source_origin = 'opml'; -- or drop the filter for all ~33k
```

Then `rssamp feeds import-opml --file <that file> --origin brisk`. Re-running is
safe: feed URLs are unique and normalised, so an existing feed is skipped rather
than duplicated.

### The Poller Daemon

```bash
rssamp daemon start # 8 feeds at a time, 2s between batches
rssamp daemon start --batch 16 --pause 5
rssamp daemon status # same as `feeds stats`
```

Keeping tens of thousands of feeds current is only affordable because almost
every poll costs nothing:

- **Conditional GET.** Each feed stores its `ETag`/`Last-Modified`, so an
unchanged feed answers `304` — no body, no parsing. Servers that ignore
those get the same cheap path via a content hash.
- **Adaptive intervals.** A feed that publishes is checked sooner; one that
never changes backs off, up to a day.
- **Backoff and eviction.** Failures back off exponentially, and a feed that
fails repeatedly is deactivated rather than retried forever.
- **Bounded everything.** One feed per host per batch, a request timeout, and
a download size cap.

The database is the queue — there is no Redis and no job server. The daemon can
be killed at any moment and resumes exactly where it left off.

### Snippet Management
- `rssamp snippets generate [options]` - Generate AI snippets
- `rssamp snippets list` - List all snippets
Expand Down
101 changes: 98 additions & 3 deletions bin/rss-amplifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ dotenv.config();
// Import core modules
import { loadConfig, getConfigPath, getPlatformDisplayName, getAIConfig, isAIReady } from '../src/config-manager.js';
import { SetupWizard } from '../src/setup-wizard.js';
import {
importOpmlCommand,
harvestPodcastsCommand,
feedStatsCommand,
recentArticlesCommand,
daemonCommand,
} from '../src/feed-commands.js';

/**
* Handle setup command
Expand Down Expand Up @@ -164,8 +171,41 @@ async function handleImportCommand(argv) {
* Handle feeds command (placeholder)
*/
async function handleFeedsCommand(argv) {
console.log(colors.yellow('📰 Feed management coming soon...'));
console.log(colors.cyan('This feature will be implemented in the next development phase.'));
switch (argv.action) {
case 'import-opml': {
// `--file` or the trailing positional, so both spellings work.
const file = argv.file ?? argv._?.[1];
if (!file) {
console.error(colors.red('Which OPML file? Pass --file <path>'));
process.exit(1);
}
await importOpmlCommand({ ...argv, file });
return;
}
case 'harvest-podcasts':
await harvestPodcastsCommand(argv);
return;
case 'stats':
await feedStatsCommand(argv);
return;
case 'recent':
await recentArticlesCommand(argv);
return;
default:
console.log(colors.yellow('📰 That feed action is not implemented yet.'));
console.log(colors.cyan('Available now: import-opml, harvest-podcasts, stats, recent'));
}
}

/**
* Handle daemon command
*/
async function handleDaemonCommand(argv) {
if (argv.action === 'status') {
await feedStatsCommand(argv);
return;
}
await daemonCommand(argv);
}

/**
Expand Down Expand Up @@ -230,7 +270,59 @@ function configureCommandLine() {
.positional('action', {
describe: 'Action to perform',
type: 'string',
choices: ['list', 'refresh', 'add', 'remove']
choices: ['list', 'refresh', 'add', 'remove', 'import-opml', 'harvest-podcasts', 'stats', 'recent']
})
.option('file', {
describe: 'OPML file to import (import-opml)',
type: 'string'
})
.option('db', {
describe: 'Path to the feed database',
type: 'string'
})
.option('origin', {
describe: 'Label recorded as the source of imported feeds',
type: 'string'
})
.option('all', {
describe: 'Sweep every market rather than just the US (harvest-podcasts)',
type: 'boolean',
default: false
})
.option('delay', {
describe: 'Milliseconds between search requests (harvest-podcasts)',
type: 'number'
})
.option('kind', {
describe: 'Filter by feed kind',
type: 'string',
choices: ['blog', 'podcast', 'unknown']
})
.option('limit', {
describe: 'How many rows to show',
type: 'number'
});
})
.command('daemon <action>', 'Run the feed polling daemon', (yargs) => {
return yargs
.positional('action', {
describe: 'Action to perform',
type: 'string',
choices: ['start', 'status']
})
.option('db', {
describe: 'Path to the feed database',
type: 'string'
})
.option('batch', {
describe: 'Feeds fetched concurrently per batch',
type: 'number',
default: 8
})
.option('pause', {
describe: 'Seconds to wait between batches',
type: 'number',
default: 2
});
})
.command('snippets <action>', 'Manage snippets', (yargs) => {
Expand Down Expand Up @@ -320,6 +412,9 @@ async function main() {
case 'feeds':
await handleFeedsCommand(argv);
break;
case 'daemon':
await handleDaemonCommand(argv);
break;
case 'snippets':
await handleSnippetsCommand(argv);
break;
Expand Down
Loading
Loading