Initial commit

This commit is contained in:
2026-04-18 09:03:18 +02:00
commit e03d7ea55c
16 changed files with 8174 additions and 0 deletions

3
.env.test.example Normal file
View 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
View File

@@ -0,0 +1,3 @@
dist/
node_modules/
.env.test

183
README.md Normal file
View 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** (11000).
## 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 (11000) |
| 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 (11000) |
| 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

56
package.json Normal file
View 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
View 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);
});

View 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,
},
];
}

View 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 (11000)',
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,
};
}

View 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');
});
});

View 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');
});
});

View 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

View 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
View 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"]
}