Files
freshrss/scripts/freshrss-run.ts
2026-04-18 09:03:18 +02:00

311 lines
11 KiB
TypeScript

#!/usr/bin/env ts-node
/**
* Standalone FreshRSS CLI runner — no n8n needed.
*
* Usage:
* npx ts-node scripts/freshrss-run.ts [operation] [--category <name>] [--max <n>]
*
* 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<string, string>;
form?: Record<string, string>;
qs?: Record<string, string>;
json?: boolean;
}
function request(opts: RequestOptions): Promise<string> {
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<string> {
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<unknown> {
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<void> {
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 <name> 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);
});