Initial commit
This commit is contained in:
3
.env.test.example
Normal file
3
.env.test.example
Normal file
@@ -0,0 +1,3 @@
|
||||
FRESHRSS_URL=https://rss.example.com
|
||||
FRESHRSS_USERNAME=your_username
|
||||
FRESHRSS_API_PASSWORD=your_api_password
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.env.test
|
||||
183
README.md
Normal file
183
README.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# n8n-nodes-freshrss
|
||||
|
||||
Node społecznościowy dla [n8n](https://n8n.io) integrujący [FreshRSS](https://freshrss.github.io/FreshRSS/) przez Google Reader API (GReader).
|
||||
|
||||
## Funkcjonalności
|
||||
|
||||
| Zasób | Operacja | Opis |
|
||||
|---|---|---|
|
||||
| **Kategoria** | Pobierz wszystkie | Lista wszystkich kategorii (etykiet) z instancji FreshRSS |
|
||||
| **Artykuł** | Pobierz nieprzeczytane | Pobiera wszystkie nieprzeczytane artykuły ze wszystkich kanałów |
|
||||
| **Artykuł** | Pobierz nieprzeczytane wg kategorii | Pobiera nieprzeczytane artykuły z wybranej kategorii |
|
||||
|
||||
Obie operacje artykułów obsługują **paginację** przez token kontynuacji oraz konfigurowalną **liczbę wyników** (1–1000).
|
||||
|
||||
## Wymagania
|
||||
|
||||
- Instancja FreshRSS z włączonym **Google Reader API**
|
||||
- Ustawienia → Uwierzytelnianie → Zezwól na dostęp przez API (zaznacz checkbox)
|
||||
- **Hasło API** ustawione dla użytkownika
|
||||
- Ustawienia użytkownika → Uwierzytelnianie → Hasło API
|
||||
|
||||
## Instalacja
|
||||
|
||||
W instancji n8n:
|
||||
|
||||
```
|
||||
Settings → Community Nodes → Install → n8n-nodes-freshrss
|
||||
```
|
||||
|
||||
Lub przez npm w instalacji self-hosted:
|
||||
|
||||
```bash
|
||||
npm install n8n-nodes-freshrss
|
||||
```
|
||||
|
||||
## Dane uwierzytelniające
|
||||
|
||||
| Pole | Opis |
|
||||
|---|---|
|
||||
| Base URL | URL twojej instancji FreshRSS, np. `https://rss.example.com` |
|
||||
| Username | Login do FreshRSS |
|
||||
| API Password | Hasło API przypisane do użytkownika (Ustawienia → Uwierzytelnianie → Hasło API) — **różne** od hasła logowania |
|
||||
|
||||
## Parametry node'a
|
||||
|
||||
### Kategoria → Pobierz wszystkie
|
||||
|
||||
Zwraca wszystkie kategorie zdefiniowane przez użytkownika. Tagi systemowe (`com.google/*`) są automatycznie odfiltrowywane.
|
||||
|
||||
### Artykuł → Pobierz nieprzeczytane
|
||||
|
||||
Pobiera nieprzeczytane artykuły ze wszystkich kanałów.
|
||||
|
||||
| Parametr | Domyślnie | Opis |
|
||||
|---|---|---|
|
||||
| Maks. wyników | 50 | Maksymalna liczba artykułów (1–1000) |
|
||||
| Token kontynuacji | — | Token paginacji zwrócony przez poprzednie wywołanie |
|
||||
|
||||
### Artykuł → Pobierz nieprzeczytane wg kategorii
|
||||
|
||||
Jak wyżej, ale ograniczone do jednej kategorii.
|
||||
|
||||
| Parametr | Domyślnie | Opis |
|
||||
|---|---|---|
|
||||
| Nazwa kategorii | — | Dokładna nazwa kategorii tak jak widnieje w FreshRSS |
|
||||
| Maks. wyników | 50 | Maksymalna liczba artykułów (1–1000) |
|
||||
| Token kontynuacji | — | Token paginacji zwrócony przez poprzednie wywołanie |
|
||||
|
||||
### Pola wyjściowe artykułu
|
||||
|
||||
```
|
||||
id, title, author, published, updated, url,
|
||||
content, feedTitle, feedUrl, categories,
|
||||
isRead, isStarred
|
||||
```
|
||||
|
||||
## Rozwój
|
||||
|
||||
### Konfiguracja środowiska
|
||||
|
||||
```bash
|
||||
git clone https://github.com/paramah/n8n-nodes-freshrss
|
||||
cd n8n-nodes-freshrss
|
||||
npm install
|
||||
```
|
||||
|
||||
### Budowanie
|
||||
|
||||
```bash
|
||||
npm run build # kompilacja TypeScript → dist/
|
||||
npm run dev # tryb obserwowania zmian
|
||||
```
|
||||
|
||||
### Linting i formatowanie
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
|
||||
### Testy
|
||||
|
||||
Testy jednostkowe używają **Jest + ts-jest**. Nie wymagają instancji n8n — wszystkie wywołania HTTP są mockowane.
|
||||
|
||||
```bash
|
||||
npm test # uruchom wszystkie testy
|
||||
npm run test:watch # tryb obserwowania zmian
|
||||
npm run test:coverage # z raportem pokrycia kodu
|
||||
```
|
||||
|
||||
Zakres testów:
|
||||
|
||||
- `buildStreamUrl` — budowanie i enkodowanie URL
|
||||
- `getAuthToken` — przepływ uwierzytelniania, obsługa błędów
|
||||
- `freshrssApiRequest` — adapter HTTP, błędy 401/403
|
||||
- `FreshRss.node` — wszystkie operacje, normalizacja artykułów, `continueOnFail`
|
||||
|
||||
### Ręczny runner CLI (bez n8n)
|
||||
|
||||
Testuj swoją instancję FreshRSS bezpośrednio z terminala, aby sprawdzić rzeczywisty output API.
|
||||
|
||||
**1. Skopiuj i uzupełnij dane uwierzytelniające:**
|
||||
|
||||
```bash
|
||||
cp .env.test.example .env.test
|
||||
# edytuj .env.test
|
||||
```
|
||||
|
||||
`.env.test`:
|
||||
```
|
||||
FRESHRSS_URL=https://rss.example.com
|
||||
FRESHRSS_USERNAME=twoj_login
|
||||
FRESHRSS_API_PASSWORD=twoje_haslo_api
|
||||
```
|
||||
|
||||
**2. Uruchom:**
|
||||
|
||||
```bash
|
||||
npm run freshrss categories
|
||||
npm run freshrss unread
|
||||
npm run freshrss unread -- --max 10
|
||||
npm run freshrss unread-by-category -- --category Tech
|
||||
npm run freshrss unread-by-category -- --category Tech --max 5
|
||||
npm run freshrss unread -- --continuation <token>
|
||||
npm run freshrss unread -- --format json
|
||||
```
|
||||
|
||||
Lub przez Task:
|
||||
|
||||
```bash
|
||||
task categories
|
||||
task unread
|
||||
task unread-by-category CAT=Tech
|
||||
task unread-by-category CAT=Tech MAX=5
|
||||
```
|
||||
|
||||
Wynik można przekazać do `jq` z flagą `--format json`:
|
||||
|
||||
```bash
|
||||
npm run freshrss unread -- --format json | jq '.[].title'
|
||||
```
|
||||
|
||||
## Struktura projektu
|
||||
|
||||
```
|
||||
src/
|
||||
credentials/
|
||||
FreshRssApi.credentials.ts # definicja danych uwierzytelniających dla n8n
|
||||
nodes/
|
||||
FreshRss/
|
||||
FreshRss.node.ts # główny node (zasoby, operacje, execute)
|
||||
helpers.ts # getAuthToken, freshrssApiRequest, buildStreamUrl
|
||||
freshrss.svg # ikona node'a
|
||||
__tests__/
|
||||
helpers.test.ts # testy jednostkowe helperów
|
||||
FreshRss.node.test.ts # testy jednostkowe node'a
|
||||
scripts/
|
||||
freshrss-run.ts # standalone runner CLI
|
||||
```
|
||||
|
||||
## Licencja
|
||||
|
||||
MIT
|
||||
104
Taskfile.yml
Normal file
104
Taskfile.yml
Normal file
@@ -0,0 +1,104 @@
|
||||
version: '3'
|
||||
|
||||
dotenv: ['.env.test']
|
||||
|
||||
vars:
|
||||
MAX: '20'
|
||||
CAT: ''
|
||||
FORMAT: 'pretty'
|
||||
CONTINUATION: ''
|
||||
|
||||
tasks:
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
build:
|
||||
desc: Compile TypeScript to dist/
|
||||
cmds:
|
||||
- npm run build
|
||||
|
||||
dev:
|
||||
desc: Watch mode — recompile on change
|
||||
cmds:
|
||||
- npm run dev
|
||||
|
||||
# ── Code quality ───────────────────────────────────────────────────────────
|
||||
|
||||
lint:
|
||||
desc: Run ESLint
|
||||
cmds:
|
||||
- npm run lint
|
||||
|
||||
format:
|
||||
desc: Format source with Prettier
|
||||
cmds:
|
||||
- npm run format
|
||||
|
||||
# ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test:
|
||||
desc: Run all unit tests
|
||||
cmds:
|
||||
- npm test
|
||||
|
||||
test:watch:
|
||||
desc: Run tests in watch mode
|
||||
cmds:
|
||||
- npm run test:watch
|
||||
|
||||
test:coverage:
|
||||
desc: Run tests with coverage report
|
||||
cmds:
|
||||
- npm run test:coverage
|
||||
|
||||
# ── FreshRSS CLI runner ────────────────────────────────────────────────────
|
||||
|
||||
categories:
|
||||
desc: List all FreshRSS categories
|
||||
cmds:
|
||||
- npx ts-node scripts/freshrss-run.ts categories --format {{.FORMAT}}
|
||||
|
||||
unread:
|
||||
desc: "Fetch unread articles [MAX=20] [FORMAT=pretty|json] [CONTINUATION=<token>]"
|
||||
cmds:
|
||||
- >
|
||||
npx ts-node scripts/freshrss-run.ts unread
|
||||
--max {{.MAX}}
|
||||
--format {{.FORMAT}}
|
||||
{{if .CONTINUATION}}--continuation {{.CONTINUATION}}{{end}}
|
||||
|
||||
unread-by-category:
|
||||
desc: "Fetch unread articles by category CAT=<name> [MAX=20] [FORMAT=pretty|json]"
|
||||
cmds:
|
||||
- >
|
||||
npx ts-node scripts/freshrss-run.ts unread-by-category
|
||||
--category "{{.CAT}}"
|
||||
--max {{.MAX}}
|
||||
--format {{.FORMAT}}
|
||||
{{if .CONTINUATION}}--continuation {{.CONTINUATION}}{{end}}
|
||||
|
||||
unread-json:
|
||||
desc: "Fetch unread articles as raw JSON (pipe to jq) [MAX=20]"
|
||||
cmds:
|
||||
- >
|
||||
npx ts-node scripts/freshrss-run.ts unread
|
||||
--max {{.MAX}}
|
||||
--format json
|
||||
|
||||
# ── Composite ─────────────────────────────────────────────────────────────
|
||||
|
||||
check:
|
||||
desc: Lint + test (pre-push safety check)
|
||||
cmds:
|
||||
- task: lint
|
||||
- task: test
|
||||
|
||||
setup:
|
||||
desc: Install dependencies and copy .env.test.example if .env.test is missing
|
||||
cmds:
|
||||
- npm install
|
||||
- |
|
||||
if [ ! -f .env.test ]; then
|
||||
cp .env.test.example .env.test
|
||||
echo ".env.test created — fill in your credentials"
|
||||
fi
|
||||
2
index.ts
Normal file
2
index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { FreshRss } from './src/nodes/FreshRss/FreshRss.node';
|
||||
export { FreshRssApi } from './src/credentials/FreshRssApi.credentials';
|
||||
12
jest.config.js
Normal file
12
jest.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
};
|
||||
6563
package-lock.json
generated
Normal file
6563
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
package.json
Normal file
56
package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "n8n-nodes-freshrss",
|
||||
"version": "0.1.0",
|
||||
"description": "n8n community node for FreshRSS - fetch categories and unread articles",
|
||||
"keywords": [
|
||||
"n8n-community-node-package",
|
||||
"freshrss",
|
||||
"rss",
|
||||
"feed"
|
||||
],
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paramah/n8n-nodes-freshrss",
|
||||
"author": {
|
||||
"name": "paramah"
|
||||
},
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "tsc && npm run copy-icons",
|
||||
"copy-icons": "copyfiles -u 2 'src/nodes/**/*.svg' dist/nodes/",
|
||||
"dev": "tsc --watch",
|
||||
"format": "prettier src --write",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"prepublishOnly": "npm run build && npm run lint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"freshrss": "ts-node scripts/freshrss-run.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"n8n": {
|
||||
"n8nNodesApiVersion": 1,
|
||||
"credentials": [
|
||||
"dist/credentials/FreshRssApi.credentials.js"
|
||||
],
|
||||
"nodes": [
|
||||
"dist/nodes/FreshRss/FreshRss.node.js"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"copyfiles": "^2.4.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"jest": "^29.7.0",
|
||||
"n8n-workflow": "*",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"n8n-workflow": "*"
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
38
src/credentials/FreshRssApi.credentials.ts
Normal file
38
src/credentials/FreshRssApi.credentials.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class FreshRssApi implements ICredentialType {
|
||||
name = 'freshRssApi';
|
||||
displayName = 'FreshRSS API';
|
||||
documentationUrl =
|
||||
'https://freshrss.github.io/FreshRSS/en/admins/06_GoogleReaderAPI.html';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'baseUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'https://freshrss.example.com',
|
||||
description:
|
||||
'The base URL of your FreshRSS instance (without trailing slash)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Username',
|
||||
name: 'username',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'API Password',
|
||||
name: 'apiPassword',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
description:
|
||||
'The API password configured in FreshRSS → User Settings → Authentication. Different from your login password.',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
284
src/nodes/FreshRss/FreshRss.node.ts
Normal file
284
src/nodes/FreshRss/FreshRss.node.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
NodeOperationError,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { freshrssApiRequest, getAuthToken, buildStreamUrl } from './helpers';
|
||||
|
||||
export class FreshRss implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'FreshRSS',
|
||||
name: 'freshRss',
|
||||
icon: 'file:freshrss.svg',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Fetch categories and unread articles from FreshRSS',
|
||||
defaults: {
|
||||
name: 'FreshRSS',
|
||||
},
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'freshRssApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
// Resource selector
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Category',
|
||||
value: 'category',
|
||||
},
|
||||
{
|
||||
name: 'Article',
|
||||
value: 'article',
|
||||
},
|
||||
],
|
||||
default: 'article',
|
||||
},
|
||||
|
||||
// ── Category operations ──────────────────────────────────────────
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: { resource: ['category'] },
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Get All',
|
||||
value: 'getAll',
|
||||
description: 'Retrieve all categories (labels)',
|
||||
action: 'Get all categories',
|
||||
},
|
||||
],
|
||||
default: 'getAll',
|
||||
},
|
||||
|
||||
// ── Article operations ───────────────────────────────────────────
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: { resource: ['article'] },
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Get Unread',
|
||||
value: 'getUnread',
|
||||
description: 'Retrieve all unread articles',
|
||||
action: 'Get all unread articles',
|
||||
},
|
||||
{
|
||||
name: 'Get Unread by Category',
|
||||
value: 'getUnreadByCategory',
|
||||
description: 'Retrieve unread articles from a specific category',
|
||||
action: 'Get unread articles by category',
|
||||
},
|
||||
],
|
||||
default: 'getUnread',
|
||||
},
|
||||
|
||||
// ── Category selector (for getUnreadByCategory) ──────────────────
|
||||
{
|
||||
displayName: 'Category Name',
|
||||
name: 'categoryName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'Exact name of the category as it appears in FreshRSS',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['article'],
|
||||
operation: ['getUnreadByCategory'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Shared options ───────────────────────────────────────────────
|
||||
{
|
||||
displayName: 'Max Results',
|
||||
name: 'maxResults',
|
||||
type: 'number',
|
||||
default: 50,
|
||||
description: 'Maximum number of items to return (1–1000)',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 1000,
|
||||
},
|
||||
displayOptions: {
|
||||
show: { resource: ['article'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Continuation Token',
|
||||
name: 'continuation',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Pagination token returned by a previous call. Leave empty for the first page.',
|
||||
displayOptions: {
|
||||
show: { resource: ['article'] },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const credentials = await this.getCredentials('freshRssApi');
|
||||
const baseUrl = (credentials.baseUrl as string).replace(/\/$/, '');
|
||||
const username = credentials.username as string;
|
||||
const apiPassword = credentials.apiPassword as string;
|
||||
|
||||
// Obtain GReader auth token once for all items
|
||||
const authToken = await getAuthToken(this, baseUrl, username, apiPassword);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const resource = this.getNodeParameter('resource', i) as string;
|
||||
const operation = this.getNodeParameter('operation', i) as string;
|
||||
|
||||
try {
|
||||
if (resource === 'category') {
|
||||
if (operation === 'getAll') {
|
||||
const tags = await freshrssApiRequest.call(
|
||||
this,
|
||||
baseUrl,
|
||||
authToken,
|
||||
'GET',
|
||||
'/reader/api/0/tag/list',
|
||||
);
|
||||
|
||||
const categories = ((tags.tags as IDataObject[]) ?? []).filter(
|
||||
(t) =>
|
||||
(t.id as string).includes('/label/') &&
|
||||
!(t.id as string).includes('com.google'),
|
||||
);
|
||||
|
||||
for (const cat of categories) {
|
||||
returnData.push({
|
||||
json: {
|
||||
id: cat.id,
|
||||
label: extractLabel(cat.id as string),
|
||||
sortId: cat.sortid,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (resource === 'article') {
|
||||
const maxResults = this.getNodeParameter('maxResults', i, 50) as number;
|
||||
const continuation = this.getNodeParameter('continuation', i, '') as string;
|
||||
|
||||
let streamId: string;
|
||||
|
||||
if (operation === 'getUnread') {
|
||||
streamId = 'user/-/state/com.google/reading-list';
|
||||
} else if (operation === 'getUnreadByCategory') {
|
||||
const categoryName = this.getNodeParameter(
|
||||
'categoryName',
|
||||
i,
|
||||
) as string;
|
||||
if (!categoryName) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Category Name is required',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
streamId = `user/-/label/${categoryName}`;
|
||||
} else {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Unknown operation: ${operation}`,
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
|
||||
const url = buildStreamUrl(streamId, maxResults, continuation);
|
||||
const response = await freshrssApiRequest.call(
|
||||
this,
|
||||
baseUrl,
|
||||
authToken,
|
||||
'GET',
|
||||
url,
|
||||
);
|
||||
|
||||
const articles = (response.items as IDataObject[]) ?? [];
|
||||
for (const article of articles) {
|
||||
returnData.push({
|
||||
json: normalizeArticle(article),
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({
|
||||
json: { error: (error as Error).message },
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function extractLabel(id: string): string {
|
||||
const match = id.match(/\/label\/(.+)$/);
|
||||
return match ? match[1] : id;
|
||||
}
|
||||
|
||||
function normalizeArticle(raw: IDataObject): IDataObject {
|
||||
const origin = (raw.origin as IDataObject) ?? {};
|
||||
const summary = (raw.summary as IDataObject) ?? {};
|
||||
const content = (raw.content as IDataObject) ?? summary;
|
||||
const canonical = (raw.canonical as IDataObject[]) ?? [];
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
author: raw.author,
|
||||
published: raw.published
|
||||
? new Date((raw.published as number) * 1000).toISOString()
|
||||
: null,
|
||||
updated: raw.updated
|
||||
? new Date((raw.updated as number) * 1000).toISOString()
|
||||
: null,
|
||||
url: canonical[0]?.href ?? null,
|
||||
content: (content.content as string) ?? null,
|
||||
feedTitle: origin.title ?? null,
|
||||
feedUrl: origin.streamId ?? null,
|
||||
categories: raw.categories ?? [],
|
||||
isRead: Array.isArray(raw.categories)
|
||||
? (raw.categories as string[]).includes('user/-/state/com.google/read')
|
||||
: false,
|
||||
isStarred: Array.isArray(raw.categories)
|
||||
? (raw.categories as string[]).includes('user/-/state/com.google/starred')
|
||||
: false,
|
||||
};
|
||||
}
|
||||
301
src/nodes/FreshRss/__tests__/FreshRss.node.test.ts
Normal file
301
src/nodes/FreshRss/__tests__/FreshRss.node.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { FreshRss } from '../FreshRss.node';
|
||||
import * as helpers from '../helpers';
|
||||
import { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
jest.mock('../helpers');
|
||||
|
||||
const mockedGetAuthToken = helpers.getAuthToken as jest.MockedFunction<typeof helpers.getAuthToken>;
|
||||
const mockedApiRequest = helpers.freshrssApiRequest as jest.MockedFunction<
|
||||
typeof helpers.freshrssApiRequest
|
||||
>;
|
||||
const mockedBuildStreamUrl = helpers.buildStreamUrl as jest.MockedFunction<
|
||||
typeof helpers.buildStreamUrl
|
||||
>;
|
||||
|
||||
// ── Fake IExecuteFunctions context ───────────────────────────────────────────
|
||||
|
||||
function makeContext(overrides: {
|
||||
resource: string;
|
||||
operation: string;
|
||||
categoryName?: string;
|
||||
maxResults?: number;
|
||||
continuation?: string;
|
||||
continueOnFail?: boolean;
|
||||
}): IExecuteFunctions {
|
||||
const params: Record<string, unknown> = {
|
||||
resource: overrides.resource,
|
||||
operation: overrides.operation,
|
||||
categoryName: overrides.categoryName ?? '',
|
||||
maxResults: overrides.maxResults ?? 50,
|
||||
continuation: overrides.continuation ?? '',
|
||||
};
|
||||
|
||||
return {
|
||||
getInputData: () => [{ json: {} }],
|
||||
getCredentials: async () => ({
|
||||
baseUrl: 'https://freshrss.example.com/',
|
||||
username: 'testuser',
|
||||
apiPassword: 'testpass',
|
||||
}),
|
||||
getNodeParameter: (name: string, _i: number, fallback?: unknown) =>
|
||||
params[name] ?? fallback,
|
||||
getNode: () => ({ name: 'FreshRSS', type: 'freshRss' }),
|
||||
continueOnFail: () => overrides.continueOnFail ?? false,
|
||||
helpers: { request: jest.fn() },
|
||||
} as unknown as IExecuteFunctions;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockedGetAuthToken.mockResolvedValue('auth-token');
|
||||
mockedBuildStreamUrl.mockReturnValue('/reader/api/0/stream/contents/...?output=json&n=50');
|
||||
});
|
||||
|
||||
describe('FreshRss node – category.getAll', () => {
|
||||
it('returns filtered categories with label and sortId', async () => {
|
||||
mockedApiRequest.mockResolvedValue({
|
||||
tags: [
|
||||
{ id: 'user/1/label/Tech', sortid: 'a' },
|
||||
{ id: 'user/1/label/News', sortid: 'b' },
|
||||
{ id: 'user/1/state/com.google/reading-list', sortid: 'z' }, // should be filtered
|
||||
],
|
||||
});
|
||||
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'category', operation: 'getAll' });
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][0].json).toEqual({
|
||||
id: 'user/1/label/Tech',
|
||||
label: 'Tech',
|
||||
sortId: 'a',
|
||||
});
|
||||
expect(result[0][1].json).toEqual({
|
||||
id: 'user/1/label/News',
|
||||
label: 'News',
|
||||
sortId: 'b',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips trailing slash from baseUrl before passing to auth', async () => {
|
||||
mockedApiRequest.mockResolvedValue({ tags: [] });
|
||||
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'category', operation: 'getAll' });
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(mockedGetAuthToken).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
'https://freshrss.example.com',
|
||||
'testuser',
|
||||
'testpass',
|
||||
);
|
||||
});
|
||||
|
||||
it('filters out com.google system tags', async () => {
|
||||
mockedApiRequest.mockResolvedValue({
|
||||
tags: [
|
||||
{ id: 'user/1/label/Tech', sortid: 'a' },
|
||||
{ id: 'user/1/state/com.google/starred', sortid: 'x' },
|
||||
{ id: 'user/1/label/com.google/something', sortid: 'y' }, // has both /label/ and com.google
|
||||
],
|
||||
});
|
||||
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'category', operation: 'getAll' });
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
// only 'Tech' should pass — the com.google one is filtered out
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json.label).toBe('Tech');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FreshRss node – article.getUnread', () => {
|
||||
const sampleArticle = {
|
||||
id: 'article-1',
|
||||
title: 'Hello World',
|
||||
author: 'Alice',
|
||||
published: 1700000000,
|
||||
updated: 1700000100,
|
||||
origin: { title: 'My Feed', streamId: 'feed/http://example.com/feed' },
|
||||
summary: { content: '<p>summary</p>' },
|
||||
canonical: [{ href: 'https://example.com/post/1' }],
|
||||
categories: ['user/-/state/com.google/reading-list'],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApiRequest.mockResolvedValue({ items: [sampleArticle] });
|
||||
});
|
||||
|
||||
it('returns normalized articles', async () => {
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'article', operation: 'getUnread' });
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0]).toHaveLength(1);
|
||||
const article = result[0][0].json;
|
||||
expect(article.id).toBe('article-1');
|
||||
expect(article.title).toBe('Hello World');
|
||||
expect(article.url).toBe('https://example.com/post/1');
|
||||
expect(article.feedTitle).toBe('My Feed');
|
||||
expect(article.isRead).toBe(false);
|
||||
expect(article.isStarred).toBe(false);
|
||||
expect(article.published).toBe(new Date(1700000000 * 1000).toISOString());
|
||||
});
|
||||
|
||||
it('uses reading-list stream ID for getUnread', async () => {
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'article', operation: 'getUnread' });
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(mockedBuildStreamUrl).toHaveBeenCalledWith(
|
||||
'user/-/state/com.google/reading-list',
|
||||
50,
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks article as read when category present', async () => {
|
||||
mockedApiRequest.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...sampleArticle,
|
||||
categories: [
|
||||
'user/-/state/com.google/reading-list',
|
||||
'user/-/state/com.google/read',
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'article', operation: 'getUnread' });
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0][0].json.isRead).toBe(true);
|
||||
});
|
||||
|
||||
it('marks article as starred when category present', async () => {
|
||||
mockedApiRequest.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...sampleArticle,
|
||||
categories: ['user/-/state/com.google/starred'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'article', operation: 'getUnread' });
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0][0].json.isStarred).toBe(true);
|
||||
});
|
||||
|
||||
it('handles null published/updated gracefully', async () => {
|
||||
mockedApiRequest.mockResolvedValue({
|
||||
items: [{ ...sampleArticle, published: undefined, updated: undefined }],
|
||||
});
|
||||
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'article', operation: 'getUnread' });
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0][0].json.published).toBeNull();
|
||||
expect(result[0][0].json.updated).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to summary when content is absent', async () => {
|
||||
mockedApiRequest.mockResolvedValue({
|
||||
items: [{ ...sampleArticle, content: undefined, summary: { content: '<p>sum</p>' } }],
|
||||
});
|
||||
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'article', operation: 'getUnread' });
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0][0].json.content).toBe('<p>sum</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FreshRss node – article.getUnreadByCategory', () => {
|
||||
beforeEach(() => {
|
||||
mockedApiRequest.mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
it('builds stream URL with user label', async () => {
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({
|
||||
resource: 'article',
|
||||
operation: 'getUnreadByCategory',
|
||||
categoryName: 'Tech',
|
||||
});
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(mockedBuildStreamUrl).toHaveBeenCalledWith('user/-/label/Tech', 50, '');
|
||||
});
|
||||
|
||||
it('throws NodeOperationError when categoryName is empty', async () => {
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({
|
||||
resource: 'article',
|
||||
operation: 'getUnreadByCategory',
|
||||
categoryName: '',
|
||||
});
|
||||
|
||||
await expect(node.execute.call(ctx)).rejects.toThrow('Category Name is required');
|
||||
});
|
||||
|
||||
it('passes maxResults and continuation to buildStreamUrl', async () => {
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({
|
||||
resource: 'article',
|
||||
operation: 'getUnreadByCategory',
|
||||
categoryName: 'News',
|
||||
maxResults: 100,
|
||||
continuation: 'pagetoken',
|
||||
});
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(mockedBuildStreamUrl).toHaveBeenCalledWith('user/-/label/News', 100, 'pagetoken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FreshRss node – error handling', () => {
|
||||
it('returns error item when continueOnFail is true', async () => {
|
||||
mockedApiRequest.mockRejectedValue(new Error('API down'));
|
||||
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({
|
||||
resource: 'category',
|
||||
operation: 'getAll',
|
||||
continueOnFail: true,
|
||||
});
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json.error).toBe('API down');
|
||||
});
|
||||
|
||||
it('re-throws when continueOnFail is false', async () => {
|
||||
mockedApiRequest.mockRejectedValue(new Error('API down'));
|
||||
|
||||
const node = new FreshRss();
|
||||
const ctx = makeContext({ resource: 'category', operation: 'getAll', continueOnFail: false });
|
||||
|
||||
await expect(node.execute.call(ctx)).rejects.toThrow('API down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FreshRss node description', () => {
|
||||
it('has correct metadata', () => {
|
||||
const node = new FreshRss();
|
||||
expect(node.description.name).toBe('freshRss');
|
||||
expect(node.description.credentials).toHaveLength(1);
|
||||
expect(node.description.credentials![0].name).toBe('freshRssApi');
|
||||
});
|
||||
});
|
||||
166
src/nodes/FreshRss/__tests__/helpers.test.ts
Normal file
166
src/nodes/FreshRss/__tests__/helpers.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { buildStreamUrl, getAuthToken, freshrssApiRequest } from '../helpers';
|
||||
import { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
// ── buildStreamUrl ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildStreamUrl', () => {
|
||||
const streamId = 'user/-/state/com.google/reading-list';
|
||||
const xt = encodeURIComponent('user/-/state/com.google/read');
|
||||
|
||||
it('builds URL without continuation token', () => {
|
||||
const url = buildStreamUrl(streamId, 20, '');
|
||||
expect(url).toBe(
|
||||
`/reader/api/0/stream/contents/${encodeURIComponent(streamId)}?output=json&n=20&xt=${xt}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('appends continuation token when provided', () => {
|
||||
const url = buildStreamUrl(streamId, 50, 'tok123');
|
||||
expect(url).toContain('&c=tok123');
|
||||
});
|
||||
|
||||
it('URL-encodes the stream ID', () => {
|
||||
const labelId = 'user/-/label/Tech & Dev';
|
||||
const url = buildStreamUrl(labelId, 10, '');
|
||||
expect(url).toContain(encodeURIComponent(labelId));
|
||||
});
|
||||
|
||||
it('URL-encodes the continuation token', () => {
|
||||
const url = buildStreamUrl(streamId, 10, 'a b/c');
|
||||
expect(url).toContain(`&c=${encodeURIComponent('a b/c')}`);
|
||||
});
|
||||
|
||||
it('always includes output=json', () => {
|
||||
const url = buildStreamUrl(streamId, 5, '');
|
||||
expect(url).toContain('output=json');
|
||||
});
|
||||
|
||||
it('always excludes read items via xt parameter', () => {
|
||||
const url = buildStreamUrl(streamId, 5, '');
|
||||
expect(url).toContain(`xt=${xt}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAuthToken ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makeContext(requestFn: jest.Mock): IExecuteFunctions {
|
||||
return {
|
||||
helpers: { request: requestFn },
|
||||
getNode: () => ({ name: 'FreshRSS', type: 'freshRss' }),
|
||||
} as unknown as IExecuteFunctions;
|
||||
}
|
||||
|
||||
describe('getAuthToken', () => {
|
||||
it('returns the auth token on success', async () => {
|
||||
const request = jest.fn().mockResolvedValue('SID=sid\nLSID=lsid\nAuth=mytoken\n');
|
||||
const ctx = makeContext(request);
|
||||
|
||||
const token = await getAuthToken(ctx, 'https://example.com', 'user', 'pass');
|
||||
|
||||
expect(token).toBe('mytoken');
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
method: 'POST',
|
||||
url: 'https://example.com/api/greader.php/accounts/ClientLogin',
|
||||
form: { Email: 'user', Passwd: 'pass' },
|
||||
resolveWithFullResponse: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NodeOperationError when Auth line is missing', async () => {
|
||||
const request = jest.fn().mockResolvedValue('SID=sid\nLSID=lsid\n');
|
||||
const ctx = makeContext(request);
|
||||
|
||||
await expect(getAuthToken(ctx, 'https://example.com', 'user', 'pass')).rejects.toThrow(
|
||||
'FreshRSS authentication failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('trims whitespace from the token', async () => {
|
||||
const request = jest.fn().mockResolvedValue('Auth= spacy \n');
|
||||
const ctx = makeContext(request);
|
||||
|
||||
const token = await getAuthToken(ctx, 'https://example.com', 'u', 'p');
|
||||
expect(token).toBe('spacy');
|
||||
});
|
||||
});
|
||||
|
||||
// ── freshrssApiRequest ────────────────────────────────────────────────────────
|
||||
|
||||
function makeThis(requestFn: jest.Mock): IExecuteFunctions {
|
||||
return {
|
||||
helpers: { request: requestFn },
|
||||
getNode: () => ({ name: 'FreshRSS', type: 'freshRss' }),
|
||||
} as unknown as IExecuteFunctions;
|
||||
}
|
||||
|
||||
describe('freshrssApiRequest', () => {
|
||||
it('performs a GET with Authorization header and output=json', async () => {
|
||||
const request = jest.fn().mockResolvedValue({ tags: [] });
|
||||
const ctx = makeThis(request);
|
||||
|
||||
const result = await freshrssApiRequest.call(
|
||||
ctx,
|
||||
'https://example.com',
|
||||
'mytoken',
|
||||
'GET',
|
||||
'/reader/api/0/tag/list',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ tags: [] });
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
url: 'https://example.com/api/greader.php/reader/api/0/tag/list',
|
||||
headers: { Authorization: 'GoogleLogin auth=mytoken' },
|
||||
qs: { output: 'json' },
|
||||
json: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add output=json to qs when already in endpoint', async () => {
|
||||
const request = jest.fn().mockResolvedValue({});
|
||||
const ctx = makeThis(request);
|
||||
|
||||
await freshrssApiRequest.call(
|
||||
ctx,
|
||||
'https://example.com',
|
||||
'tok',
|
||||
'GET',
|
||||
'/reader/api/0/stream/contents/foo?output=json&n=10',
|
||||
);
|
||||
|
||||
const callArgs = request.mock.calls[0][0];
|
||||
expect(callArgs.qs).toEqual({});
|
||||
});
|
||||
|
||||
it('throws NodeOperationError on 401', async () => {
|
||||
const error = Object.assign(new Error('Unauthorized'), { statusCode: 401 });
|
||||
const request = jest.fn().mockRejectedValue(error);
|
||||
const ctx = makeThis(request);
|
||||
|
||||
await expect(
|
||||
freshrssApiRequest.call(ctx, 'https://example.com', 'tok', 'GET', '/some/endpoint'),
|
||||
).rejects.toThrow('401 Unauthorized');
|
||||
});
|
||||
|
||||
it('throws NodeOperationError on 403', async () => {
|
||||
const error = Object.assign(new Error('Forbidden'), { statusCode: 403 });
|
||||
const request = jest.fn().mockRejectedValue(error);
|
||||
const ctx = makeThis(request);
|
||||
|
||||
await expect(
|
||||
freshrssApiRequest.call(ctx, 'https://example.com', 'tok', 'GET', '/some/endpoint'),
|
||||
).rejects.toThrow('403 Forbidden');
|
||||
});
|
||||
|
||||
it('re-throws unknown errors as-is', async () => {
|
||||
const error = new Error('Network timeout');
|
||||
const request = jest.fn().mockRejectedValue(error);
|
||||
const ctx = makeThis(request);
|
||||
|
||||
await expect(
|
||||
freshrssApiRequest.call(ctx, 'https://example.com', 'tok', 'GET', '/some/endpoint'),
|
||||
).rejects.toThrow('Network timeout');
|
||||
});
|
||||
});
|
||||
13
src/nodes/FreshRss/freshrss.svg
Normal file
13
src/nodes/FreshRss/freshrss.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="32" fill="#2ea44f"/>
|
||||
<g fill="white">
|
||||
<!-- RSS dot -->
|
||||
<circle cx="16" cy="48" r="5"/>
|
||||
<!-- RSS arc 1 -->
|
||||
<path d="M16 36 a16 16 0 0 1 16 16 h-5 a11 11 0 0 0-11-11 z"/>
|
||||
<!-- RSS arc 2 -->
|
||||
<path d="M16 24 a28 28 0 0 1 28 28 h-5 a23 23 0 0 0-23-23 z"/>
|
||||
<!-- RSS arc 3 -->
|
||||
<path d="M16 13 a39 39 0 0 1 39 39 h-5 a34 34 0 0 0-34-34 z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
117
src/nodes/FreshRss/helpers.ts
Normal file
117
src/nodes/FreshRss/helpers.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
NodeOperationError,
|
||||
JsonObject,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Authenticate with FreshRSS GReader API and return the auth token.
|
||||
* FreshRSS uses a dedicated API password (not the login password).
|
||||
*/
|
||||
export async function getAuthToken(
|
||||
context: IExecuteFunctions,
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
apiPassword: string,
|
||||
): Promise<string> {
|
||||
const response = await context.helpers.request({
|
||||
method: 'POST',
|
||||
url: `${baseUrl}/api/greader.php/accounts/ClientLogin`,
|
||||
form: {
|
||||
Email: username,
|
||||
Passwd: apiPassword,
|
||||
},
|
||||
resolveWithFullResponse: false,
|
||||
});
|
||||
|
||||
// Response is plain text: "SID=...\nLSID=...\nAuth=..."
|
||||
const authLine = (response as string)
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('Auth='));
|
||||
|
||||
if (!authLine) {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
'FreshRSS authentication failed: Auth token not found in response. Check your API password in FreshRSS user settings.',
|
||||
);
|
||||
}
|
||||
|
||||
return authLine.replace('Auth=', '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to the FreshRSS GReader API.
|
||||
*/
|
||||
export async function freshrssApiRequest(
|
||||
this: IExecuteFunctions,
|
||||
baseUrl: string,
|
||||
authToken: string,
|
||||
method: 'GET' | 'POST',
|
||||
endpoint: string,
|
||||
body?: IDataObject,
|
||||
): Promise<IDataObject> {
|
||||
const options = {
|
||||
method,
|
||||
url: `${baseUrl}/api/greader.php${endpoint}`,
|
||||
headers: {
|
||||
Authorization: `GoogleLogin auth=${authToken}`,
|
||||
},
|
||||
qs: {} as IDataObject,
|
||||
json: true,
|
||||
};
|
||||
|
||||
// output=json is required for JSON responses on endpoints that don't have it in the path
|
||||
if (method === 'GET' && !endpoint.includes('output=json')) {
|
||||
options.qs['output'] = 'json';
|
||||
}
|
||||
|
||||
if (body) {
|
||||
Object.assign(options, { body });
|
||||
}
|
||||
|
||||
try {
|
||||
return (await this.helpers.request(options)) as IDataObject;
|
||||
} catch (error) {
|
||||
const err = error as JsonObject;
|
||||
const statusCode = (err.statusCode as number) ?? 0;
|
||||
|
||||
if (statusCode === 401) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'FreshRSS API returned 401 Unauthorized. Verify your credentials.',
|
||||
);
|
||||
}
|
||||
if (statusCode === 403) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'FreshRSS API returned 403 Forbidden. Make sure the Google Reader API is enabled in your FreshRSS instance.',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stream contents URL for fetching articles.
|
||||
*
|
||||
* @param streamId e.g. "user/-/state/com.google/reading-list" or "user/-/label/Tech"
|
||||
* @param n max results
|
||||
* @param continuation pagination token (empty string = first page)
|
||||
*/
|
||||
export function buildStreamUrl(
|
||||
streamId: string,
|
||||
n: number,
|
||||
continuation: string,
|
||||
): string {
|
||||
// Exclude already-read items
|
||||
const xt = 'user/-/state/com.google/read';
|
||||
let url = `/reader/api/0/stream/contents/${encodeURIComponent(streamId)}?output=json&n=${n}&xt=${encodeURIComponent(xt)}`;
|
||||
|
||||
if (continuation) {
|
||||
url += `&c=${encodeURIComponent(continuation)}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2019",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2019"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user