Initial commit
This commit is contained in:
310
scripts/freshrss-run.ts
Normal file
310
scripts/freshrss-run.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user