#!/usr/bin/env ts-node /** * Standalone FreshRSS CLI runner — no n8n needed. * * Usage: * npx ts-node scripts/freshrss-run.ts [operation] [--category ] [--max ] * * Operations: * categories — list all categories (default) * unread — fetch unread articles * unread-by-category — fetch unread articles for a specific category * * Credentials (from .env.test or environment): * FRESHRSS_URL base URL, e.g. https://rss.example.com * FRESHRSS_USERNAME * FRESHRSS_API_PASSWORD * * Examples: * npx ts-node scripts/freshrss-run.ts categories * npx ts-node scripts/freshrss-run.ts unread --max 10 * npx ts-node scripts/freshrss-run.ts unread-by-category --category Tech --max 5 */ import * as https from 'https'; import * as http from 'http'; import * as querystring from 'querystring'; import * as path from 'path'; import * as fs from 'fs'; // ── Load .env.test if present ───────────────────────────────────────────────── const envFile = path.resolve(__dirname, '..', '.env.test'); if (fs.existsSync(envFile)) { // Simple dotenv-style loader (avoids requiring the dotenv package at runtime) const lines = fs.readFileSync(envFile, 'utf-8').split('\n'); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eq = trimmed.indexOf('='); if (eq === -1) continue; const key = trimmed.slice(0, eq).trim(); const value = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, ''); if (!process.env[key]) process.env[key] = value; } } // ── Parse CLI args ──────────────────────────────────────────────────────────── const args = process.argv.slice(2); const operation = args[0] ?? 'categories'; function getArg(flag: string): string | undefined { const idx = args.indexOf(flag); return idx !== -1 ? args[idx + 1] : undefined; } const categoryName = getArg('--category') ?? getArg('-c') ?? ''; const maxResults = parseInt(getArg('--max') ?? getArg('-n') ?? '20', 10); const continuation = getArg('--continuation') ?? ''; const outputFormat = (getArg('--format') ?? 'pretty') as 'pretty' | 'json' | 'table'; // ── Credentials ─────────────────────────────────────────────────────────────── const baseUrl = (process.env.FRESHRSS_URL ?? '').replace(/\/$/, ''); const username = process.env.FRESHRSS_USERNAME ?? ''; const apiPassword = process.env.FRESHRSS_API_PASSWORD ?? ''; if (!baseUrl || !username || !apiPassword) { console.error('Missing credentials. Set FRESHRSS_URL, FRESHRSS_USERNAME, FRESHRSS_API_PASSWORD'); console.error(' in .env.test or as environment variables.'); process.exit(1); } // ── Minimal HTTP client ─────────────────────────────────────────────────────── interface RequestOptions { method: 'GET' | 'POST'; url: string; headers?: Record; form?: Record; qs?: Record; json?: boolean; } function request(opts: RequestOptions): Promise { return new Promise((resolve, reject) => { let urlStr = opts.url; if (opts.qs && Object.keys(opts.qs).length > 0) { const sep = urlStr.includes('?') ? '&' : '?'; urlStr += sep + querystring.stringify(opts.qs); } const parsed = new URL(urlStr); const isHttps = parsed.protocol === 'https:'; const transport = isHttps ? https : http; const body = opts.form ? querystring.stringify(opts.form) : undefined; const reqOptions: http.RequestOptions = { hostname: parsed.hostname, port: parsed.port || (isHttps ? 443 : 80), path: parsed.pathname + parsed.search, method: opts.method, headers: { ...(opts.headers ?? {}), ...(body ? { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body).toString(), } : {}), }, }; const req = transport.request(reqOptions, (res) => { const chunks: Buffer[] = []; res.on('data', (c: Buffer) => chunks.push(c)); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf-8'); if (res.statusCode && res.statusCode >= 400) { reject(Object.assign(new Error(`HTTP ${res.statusCode}`), { statusCode: res.statusCode, body: text })); } else { resolve(text); } }); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } // ── FreshRSS API ────────────────────────────────────────────────────────────── async function authenticate(): Promise { const response = await request({ method: 'POST', url: `${baseUrl}/api/greader.php/accounts/ClientLogin`, form: { Email: username, Passwd: apiPassword }, }); const authLine = response.split('\n').find((l) => l.startsWith('Auth=')); if (!authLine) { throw new Error('Auth token not found — check your API password in FreshRSS user settings.'); } return authLine.replace('Auth=', '').trim(); } async function apiGet(authToken: string, endpoint: string): Promise { const sep = endpoint.includes('?') ? '&' : '?'; const url = `${baseUrl}/api/greader.php${endpoint}${endpoint.includes('output=json') ? '' : sep + 'output=json'}`; const text = await request({ method: 'GET', url, headers: { Authorization: `GoogleLogin auth=${authToken}` }, }); return JSON.parse(text); } function buildStreamUrl(streamId: string, n: number, cont: string): string { const xt = encodeURIComponent('user/-/state/com.google/read'); let url = `/reader/api/0/stream/contents/${encodeURIComponent(streamId)}?output=json&n=${n}&xt=${xt}`; if (cont) url += `&c=${encodeURIComponent(cont)}`; return url; } // ── Output formatters ───────────────────────────────────────────────────────── function printCategories(categories: Array<{ id: string; label: string; sortId: string }>): void { if (outputFormat === 'json') { console.log(JSON.stringify(categories, null, 2)); return; } console.log(`\nCategories (${categories.length}):`); console.log('─'.repeat(50)); for (const c of categories) { console.log(` ${c.label.padEnd(30)} ${c.sortId ?? ''}`); } } interface Article { id: string; title: string; author: string; published: string | null; url: string | null; feedTitle: string | null; content: string | null; isRead: boolean; isStarred: boolean; } function printArticles(articles: Article[]): void { if (outputFormat === 'json') { console.log(JSON.stringify(articles, null, 2)); return; } console.log(`\nArticles (${articles.length}):`); console.log('─'.repeat(80)); for (const a of articles) { const star = a.isStarred ? ' ★' : ''; const read = a.isRead ? ' [read]' : ''; console.log(` [${a.feedTitle ?? 'unknown feed'}]${star}${read}`); console.log(` ${a.title}`); if (a.published) console.log(` Published: ${a.published}`); if (a.url) console.log(` URL: ${a.url}`); console.log(); } } // ── Normalize raw article ───────────────────────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any function normalizeArticle(raw: any): Article { const origin = raw.origin ?? {}; const summary = raw.summary ?? {}; const content = raw.content ?? summary; const canonical = raw.canonical ?? []; const categories: string[] = raw.categories ?? []; return { id: raw.id, title: raw.title, author: raw.author, published: raw.published ? new Date(raw.published * 1000).toISOString() : null, url: canonical[0]?.href ?? null, content: content.content ?? null, feedTitle: origin.title ?? null, isRead: categories.includes('user/-/state/com.google/read'), isStarred: categories.includes('user/-/state/com.google/starred'), }; } // ── Main ────────────────────────────────────────────────────────────────────── async function main(): Promise { console.log(`Connecting to: ${baseUrl}`); console.log('Authenticating…'); const authToken = await authenticate(); console.log('Authenticated.\n'); switch (operation) { case 'categories': { // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = (await apiGet(authToken, '/reader/api/0/tag/list')) as any; const categories = (data.tags ?? []) .filter( // eslint-disable-next-line @typescript-eslint/no-explicit-any (t: any) => (t.id as string).includes('/label/') && !(t.id as string).includes('com.google'), ) // eslint-disable-next-line @typescript-eslint/no-explicit-any .map((t: any) => { const match = (t.id as string).match(/\/label\/(.+)$/); return { id: t.id, label: match ? match[1] : t.id, sortId: t.sortid }; }); printCategories(categories); break; } case 'unread': { const streamId = 'user/-/state/com.google/reading-list'; const url = buildStreamUrl(streamId, maxResults, continuation); // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = (await apiGet(authToken, url)) as any; const articles = (data.items ?? []).map(normalizeArticle); printArticles(articles); if (data.continuation) { console.log(`Next page token: ${data.continuation}`); console.log( ` Run with: --continuation ${data.continuation}`, ); } break; } case 'unread-by-category': { if (!categoryName) { console.error('--category is required for unread-by-category'); process.exit(1); } const streamId = `user/-/label/${categoryName}`; const url = buildStreamUrl(streamId, maxResults, continuation); // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = (await apiGet(authToken, url)) as any; const articles = (data.items ?? []).map(normalizeArticle); printArticles(articles); if (data.continuation) { console.log(`Next page token: ${data.continuation}`); console.log(` Run with: --continuation ${data.continuation}`); } break; } default: console.error(`Unknown operation: "${operation}"`); console.error('Available: categories | unread | unread-by-category'); process.exit(1); } } main().catch((err) => { console.error('\nError:', err.message ?? err); process.exit(1); });