mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-18 14:29:01 +08:00
b4ae78140d
Merge current master while retaining the completed route review and retired service removals. Keep upstream source migrations and new feed functionality, including the ACEA API, Xiaomi data helpers, Yahoo Finance topic helper, Bilibili list URLs, Douban IP locations, and Douyin live-stream handling. Resolve overlapping changes without losing existing limit validation, bounded detail requests, stable item metadata, or route configuration corrections. Keep RSS image MIME inference and explicit media precedence alongside upstream attachment extraction, emitting a single enclosure per item.
803 lines
35 KiB
TypeScript
803 lines
35 KiB
TypeScript
import { Context } from 'hono';
|
||
import Parser from 'rss-parser';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
process.env.OPENAI_API_KEY = 'sk-1234567890';
|
||
process.env.OPENAI_API_ENDPOINT = 'https://api.openai.mock/v1';
|
||
|
||
const { config } = await import('@/config');
|
||
const { default: app } = await import('@/app');
|
||
const { default: parameter } = await import('@/middleware/parameter');
|
||
|
||
const parser = new Parser();
|
||
|
||
const runMiddleware = async (data: any, query: Record<string, string | undefined>) => {
|
||
const searchParams = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(query)) {
|
||
if (value !== undefined) {
|
||
searchParams.set(key, value);
|
||
}
|
||
}
|
||
const ctx = new Context(new Request(`http://localhost/test?${searchParams.toString()}`));
|
||
ctx.set('data', data);
|
||
await parameter(ctx, async () => {});
|
||
return ctx.get('data');
|
||
};
|
||
|
||
describe('filter', () => {
|
||
it('filter', async () => {
|
||
const response = await app.request('/test/1?filter=Description4|Title5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(2);
|
||
expect(parsed.items[0].title).toBe('Title4');
|
||
expect(parsed.items[1].title).toBe('Title5');
|
||
});
|
||
|
||
it('filter filter_case_sensitive default', async () => {
|
||
const response = await app.request('/test/1?filter=description4|title5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(0);
|
||
});
|
||
|
||
it('filter filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/1?filter=description4|title5&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(2);
|
||
expect(parsed.items[0].title).toBe('Title4');
|
||
expect(parsed.items[1].title).toBe('Title5');
|
||
});
|
||
|
||
it('filter_title', async () => {
|
||
const response = await app.request('/test/1?filter_title=Description4|Title5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(1);
|
||
expect(parsed.items[0].title).toBe('Title5');
|
||
});
|
||
|
||
it('filter_title filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/1?filter_title=description4|title5&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(1);
|
||
expect(parsed.items[0].title).toBe('Title5');
|
||
});
|
||
|
||
it('filter_description', async () => {
|
||
const response = await app.request('/test/1?filter_description=Description4|Title5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(1);
|
||
expect(parsed.items[0].title).toBe('Title4');
|
||
});
|
||
|
||
it('filter_description filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/1?filter_description=description4|title5&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(1);
|
||
expect(parsed.items[0].title).toBe('Title4');
|
||
});
|
||
|
||
it('filter_author', async () => {
|
||
const response = await app.request('/test/1?filter_author=DIYgod4|DIYgod5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(2);
|
||
expect(parsed.items[0].title).toBe('Title4');
|
||
expect(parsed.items[1].title).toBe('Title5');
|
||
});
|
||
|
||
it('filter_author filter_case_sensitive default', async () => {
|
||
const response = await app.request('/test/1?filter_author=diygod4|diygod5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(0);
|
||
});
|
||
|
||
it('filter_author filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/1?filter_author=diygod4|diygod5&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(2);
|
||
expect(parsed.items[0].title).toBe('Title4');
|
||
expect(parsed.items[1].title).toBe('Title5');
|
||
});
|
||
|
||
it('filter_category', async () => {
|
||
const response = await app.request('/test/filter?filter_category=Category0|Category1');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(2);
|
||
expect(parsed.items[0].title).toBe('Filter Title1');
|
||
expect(parsed.items[1].title).toBe('Filter Title2');
|
||
});
|
||
|
||
it('filter_category filter_case_sensitive default', async () => {
|
||
const response = await app.request('/test/filter?filter_category=category0|category1');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(0);
|
||
});
|
||
|
||
it('filter_category filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/filter?filter_category=category0|category1&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(2);
|
||
expect(parsed.items[0].title).toBe('Filter Title1');
|
||
expect(parsed.items[1].title).toBe('Filter Title2');
|
||
});
|
||
|
||
it('filter_category filter_case_sensitive=false category string', async () => {
|
||
const response = await app.request('/test/filter?filter_category=category3&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(1);
|
||
expect(parsed.items[0].title).toBe('Filter Title3');
|
||
});
|
||
|
||
it('filter_category illegal_category', async () => {
|
||
const response = await app.request('/test/filter-illegal-category?filter_category=CategoryIllegal');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(1);
|
||
expect(parsed.items[0].categories?.length).toBe(1);
|
||
expect(parsed.items[0].categories?.[0]).toBe('CategoryIllegal');
|
||
});
|
||
|
||
it('filter_time', async () => {
|
||
const response = await app.request('/test/current_time?filter_time=25');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(2);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
});
|
||
|
||
it('filterout', async () => {
|
||
const response = await app.request('/test/1?filterout=Description4|Title5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(3);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
});
|
||
|
||
it('filterout filter_case_sensitive default', async () => {
|
||
const response = await app.request('/test/1?filterout=description4|title5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(5);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
});
|
||
|
||
it('filterout filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/1?filterout=description4|title5&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(3);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
});
|
||
|
||
it('filterout_title', async () => {
|
||
const response = await app.request('/test/1?filterout_title=Description4|Title5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(4);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
expect(parsed.items[3].title).toBe('Title4');
|
||
});
|
||
|
||
it('filterout_title filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/1?filterout_title=description4|title5&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(4);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
expect(parsed.items[3].title).toBe('Title4');
|
||
});
|
||
|
||
it('filterout_description', async () => {
|
||
const response = await app.request('/test/1?filterout_description=Description4|Title5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(4);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
expect(parsed.items[3].title).toBe('Title5');
|
||
});
|
||
|
||
it('filterout_description filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/1?filterout_description=description4|title5&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(4);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
expect(parsed.items[3].title).toBe('Title5');
|
||
});
|
||
|
||
it('filterout_author', async () => {
|
||
const response = await app.request('/test/1?filterout_author=DIYgod4|DIYgod5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(3);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
});
|
||
|
||
it('filterout_author filter_case_sensitive default', async () => {
|
||
const response = await app.request('/test/1?filterout_author=diygod4|diygod5');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(5);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
});
|
||
|
||
it('filterout_author filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/1?filterout_author=diygod4|diygod5&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(3);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
});
|
||
|
||
it('filterout_category', async () => {
|
||
const response = await app.request('/test/filter?filterout_category=Category0|Category1');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(6);
|
||
expect(parsed.items[0].title).toBe('Filter Title3');
|
||
expect(parsed.items[1].title).toBe('Title1');
|
||
expect(parsed.items[2].title).toBe('Title2');
|
||
});
|
||
|
||
it('filterout_category filter_case_sensitive default', async () => {
|
||
const response = await app.request('/test/filter?filterout_category=category0|category1');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(8);
|
||
expect(parsed.items[0].title).toBe('Filter Title1');
|
||
expect(parsed.items[1].title).toBe('Filter Title2');
|
||
expect(parsed.items[2].title).toBe('Filter Title3');
|
||
expect(parsed.items[3].title).toBe('Title1');
|
||
});
|
||
|
||
it('filterout_category filter_case_sensitive=false', async () => {
|
||
const response = await app.request('/test/filter?filterout_category=category0|category1&filter_case_sensitive=false');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(6);
|
||
expect(parsed.items[0].title).toBe('Filter Title3');
|
||
expect(parsed.items[1].title).toBe('Title1');
|
||
expect(parsed.items[2].title).toBe('Title2');
|
||
});
|
||
|
||
it('filter combination', async () => {
|
||
const response = await app.request('/test/filter?filter_title=Filter&filter_description=Description1');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(1);
|
||
expect(parsed.items[0].title).toBe('Filter Title1');
|
||
});
|
||
|
||
it('filterout combination', async () => {
|
||
const response = await app.request('/test/filter?filterout_title=Filter&filterout_description=Description1');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(4);
|
||
expect(parsed.items[0].title).toBe('Title2');
|
||
});
|
||
});
|
||
|
||
describe('limit', () => {
|
||
it.each([undefined, '', 'invalid', '-2', 'NaN', 'Infinity'])('preserves entries for an invalid or absent limit: %s', async (limit) => {
|
||
const items = Array.from({ length: 5 }, (_, index) => ({ title: `Entry ${index}`, link: `https://rsshub.example/${index}` }));
|
||
const result = await runMiddleware({ title: 'Limit validation', item: items }, { limit });
|
||
expect(result.item.map((item) => item.title)).toEqual(items.map((item) => item.title));
|
||
expect(result.item.map((item) => item.link)).toEqual(items.map((item) => item.link));
|
||
});
|
||
|
||
it.each([
|
||
{ limit: '0', count: 0 },
|
||
{ limit: '1', count: 1 },
|
||
{ limit: '3', count: 3 },
|
||
{ limit: '2.5', count: 2 },
|
||
{ limit: '20', count: 5 },
|
||
])('retains the existing nonnegative limit $limit', async ({ limit, count }) => {
|
||
const items = Array.from({ length: 5 }, (_, index) => ({ title: `Entry ${index}`, link: `https://rsshub.example/${index}` }));
|
||
const result = await runMiddleware({ title: 'Limit validation', item: items }, { limit });
|
||
expect(result.item.map((item) => item.title)).toEqual(items.slice(0, count).map((item) => item.title));
|
||
});
|
||
|
||
it('limit', async () => {
|
||
const response = await app.request('/test/1?limit=3');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(3);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title2');
|
||
expect(parsed.items[2].title).toBe('Title3');
|
||
});
|
||
});
|
||
|
||
describe('sorted', () => {
|
||
it.each([
|
||
{ preference: undefined, sorted: undefined, order: ['Recent', 'Ranked'] },
|
||
{ preference: false, sorted: undefined, order: ['Ranked', 'Recent'] },
|
||
{ preference: false, sorted: 'true', order: ['Recent', 'Ranked'] },
|
||
{ preference: false, sorted: 'yes', order: ['Recent', 'Ranked'] },
|
||
{ preference: true, sorted: 'false', order: ['Ranked', 'Recent'] },
|
||
])('respects route preference $preference with common sorted=$sorted', async ({ preference, sorted, order }) => {
|
||
const result = await runMiddleware(
|
||
{
|
||
title: 'Ranking',
|
||
sortItems: preference,
|
||
item: [
|
||
{ title: 'Ranked', pubDate: '2026-10-07T12:00:00Z' },
|
||
{ title: 'Recent', pubDate: '2026-10-09T12:00:00Z' },
|
||
],
|
||
},
|
||
{ sorted }
|
||
);
|
||
expect(result.item.map((item) => item.title)).toEqual(order);
|
||
expect(result.item.every((item) => !Number.isNaN(Date.parse(item.pubDate)))).toBe(true);
|
||
});
|
||
|
||
it('filters publication times while preserving a source ranking', async () => {
|
||
const now = Date.now();
|
||
const result = await runMiddleware(
|
||
{
|
||
title: 'Ranking',
|
||
sortItems: false,
|
||
item: [
|
||
{ title: 'Ranked', pubDate: new Date(now - 20000) },
|
||
{ title: 'Expired', pubDate: new Date(now - 120000) },
|
||
{ title: 'Recent', pubDate: new Date(now - 10000) },
|
||
],
|
||
},
|
||
{ filter_time: '60' }
|
||
);
|
||
expect(result.item.map((item) => item.title)).toEqual(['Ranked', 'Recent']);
|
||
});
|
||
|
||
it('sorted', async () => {
|
||
const response = await app.request('/test/sort?sorted=false');
|
||
expect(response.status).toBe(200);
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items[0].title).toBe('Sort Title 0');
|
||
expect(parsed.items[1].title).toBe('Sort Title 1');
|
||
expect(parsed.items[2].title).toBe('Sort Title 2');
|
||
expect(parsed.items[3].title).toBe('Sort Title 3');
|
||
});
|
||
});
|
||
|
||
describe('tgiv', () => {
|
||
it('tgiv', async () => {
|
||
const response = await app.request('/test/1?tgiv=test');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items[0].link).toBe('https://t.me/iv?url=https%3A%2F%2Fgithub.com%2FDIYgod%2FRSSHub%2Fissues%2F1&rhash=test');
|
||
expect(parsed.items[1].link).toBe('https://t.me/iv?url=https%3A%2F%2Fgithub.com%2FDIYgod%2FRSSHub%2Fissues%2F2&rhash=test');
|
||
});
|
||
});
|
||
|
||
describe('empty', () => {
|
||
it('empty', async () => {
|
||
const response1 = await app.request('/test/empty');
|
||
expect(response1.status).toBe(503);
|
||
expect(await response1.text()).toMatch(/Error: this route is empty/);
|
||
|
||
const response2 = await app.request('/test/1?limit=0');
|
||
expect(response2.status).toBe(200);
|
||
const parsed = await parser.parseString(await response2.text());
|
||
expect(parsed.items.length).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('allow_empty', () => {
|
||
it('allow_empty', async () => {
|
||
const response = await app.request('/test/allow_empty');
|
||
expect(response.status).toBe(200);
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('wrong_path', () => {
|
||
it('wrong_path', async () => {
|
||
const response = await app.request('/wrong');
|
||
expect(response.status).toBe(404);
|
||
expect(response.headers.get('cache-control')).toBe(`public, max-age=${config.cache.routeExpire}`);
|
||
expect(await response.text()).toMatch('The route does not exist or has been deleted.');
|
||
});
|
||
});
|
||
|
||
describe('fulltext_mode', () => {
|
||
it('fulltext', async () => {
|
||
const response = await app.request('/test/1?mode=fulltext');
|
||
expect(response.status).toBe(200);
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items[0].content).not.toBe(undefined);
|
||
}, 60000);
|
||
});
|
||
|
||
describe('complicated_description', () => {
|
||
it('complicated_description', async () => {
|
||
const response = await app.request('/test/complicated');
|
||
expect(response.status).toBe(200);
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items[0].content).toBe(`<a href="https://mock.com/DIYgod/RSSHub"></a>
|
||
<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
|
||
|
||
<a href="http://mock.com/DIYgod/RSSHub"></a>
|
||
<img src="https://mock.com/DIYgod/RSSHub.jpg" data-src="/DIYgod/RSSHub0.jpg" referrerpolicy="no-referrer">
|
||
<img data-src="/DIYgod/RSSHub.jpg" src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
|
||
<img data-mock="/DIYgod/RSSHub.png" src="https://mock.com/DIYgod/RSSHub.png" referrerpolicy="no-referrer">
|
||
<img mock="/DIYgod/RSSHub.gif" src="https://mock.com/DIYgod/RSSHub.gif" referrerpolicy="no-referrer">
|
||
<img src="http://mock.com/DIYgod/DIYgod/RSSHub" referrerpolicy="no-referrer">
|
||
<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
|
||
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">`);
|
||
expect(parsed.items[1].content).toBe(`<a href="https://mock.com/DIYgod/RSSHub"></a>
|
||
<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">`);
|
||
});
|
||
});
|
||
|
||
describe('multimedia_description', () => {
|
||
it('multimedia_description', async () => {
|
||
const response = await app.request('/test/multimedia');
|
||
expect(response.status).toBe(200);
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items[0].content).toBe(`<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
|
||
<video src="https://mock.com/DIYgod/RSSHub.mp4"></video>
|
||
<video poster="https://mock.com/DIYgod/RSSHub.jpg">
|
||
<source src="https://mock.com/DIYgod/RSSHub.mp4" type="video/mp4">
|
||
<source src="https://mock.com/DIYgod/RSSHub.webm" type="video/webm">
|
||
</video>
|
||
<audio src="https://mock.com/DIYgod/RSSHub.mp3"></audio>
|
||
<iframe src="https://mock.com/DIYgod/RSSHub.html" referrerpolicy="no-referrer"></iframe>`);
|
||
});
|
||
});
|
||
|
||
describe('sort', () => {
|
||
it('sort', async () => {
|
||
const response = await app.request('/test/sort');
|
||
expect(response.status).toBe(200);
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items[0].title).toBe('Sort Title 3');
|
||
expect(parsed.items.at(-3)?.title).toBe('Sort Title 2');
|
||
expect(parsed.items.at(-2)?.title).toBe('Sort Title 0');
|
||
expect(parsed.items.at(-1)?.title).toBe('Sort Title 1');
|
||
});
|
||
});
|
||
|
||
describe('mess parameter', () => {
|
||
it('date', async () => {
|
||
const response = await app.request('/test/mess');
|
||
expect(response.status).toBe(200);
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items[0].pubDate).toBe('Mon, 31 Dec 2018 16:00:00 GMT');
|
||
expect(parsed.items[0].link).toBe('https://github.com/DIYgod/RSSHub/issues/0');
|
||
});
|
||
});
|
||
|
||
describe('opencc', () => {
|
||
it('opencc', async () => {
|
||
const response = await app.request('/test/opencc?opencc=t2s');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items[0].title).toBe('小可爱');
|
||
expect(parsed.items[0].content).toBe('宇宙无敌');
|
||
});
|
||
});
|
||
|
||
describe('brief', () => {
|
||
it('brief', async () => {
|
||
const response = await app.request('/test/brief?brief=100');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items[0].title).toBe('小可愛');
|
||
expect(parsed.items[0].content).toBe(
|
||
'<p>宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵宇宙無敵…</p>'
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('multi parameter', () => {
|
||
it('filter before limit', async () => {
|
||
const response = await app.request('/test/filter-limit?filterout_title=2&limit=2');
|
||
const parsed = await parser.parseString(await response.text());
|
||
expect(parsed.items.length).toBe(2);
|
||
expect(parsed.items[0].title).toBe('Title1');
|
||
expect(parsed.items[1].title).toBe('Title3');
|
||
});
|
||
});
|
||
|
||
describe('openai', () => {
|
||
it('processes both title and description', async () => {
|
||
config.openai.inputOption = 'both';
|
||
const responseWithGpt = await app.request('/test/gpt?chatgpt=true');
|
||
const responseNormal = await app.request('/test/gpt');
|
||
|
||
expect(responseWithGpt.status).toBe(200);
|
||
expect(responseNormal.status).toBe(200);
|
||
|
||
const parsedGpt = await parser.parseString(await responseWithGpt.text());
|
||
const parsedNormal = await parser.parseString(await responseNormal.text());
|
||
|
||
expect(parsedGpt.items[0].title).not.toBe(parsedNormal.items[0].title);
|
||
expect(parsedGpt.items[0].title).toContain('AI processed content.');
|
||
expect(parsedGpt.items[0].content).not.toBe(parsedNormal.items[0].content);
|
||
expect(parsedGpt.items[0].content).toContain('AI processed content.');
|
||
});
|
||
|
||
it('processes title or description', async () => {
|
||
// test title
|
||
config.openai.inputOption = 'title';
|
||
const responseTitleOnly = await app.request('/test/gpt?chatgpt=true');
|
||
const parsedTitleOnly = await parser.parseString(await responseTitleOnly.text());
|
||
expect(parsedTitleOnly.items[0].title).toContain('AI processed content.');
|
||
expect(parsedTitleOnly.items[0].content).not.toContain('AI processed content.');
|
||
|
||
// test description
|
||
config.openai.inputOption = 'description';
|
||
const responseDescriptionOnly = await app.request('/test/gpt?chatgpt=true');
|
||
const parsedDescriptionOnly = await parser.parseString(await responseDescriptionOnly.text());
|
||
expect(parsedDescriptionOnly.items[0].title).not.toContain('AI processed content.');
|
||
expect(parsedDescriptionOnly.items[0].content).toContain('AI processed content.');
|
||
});
|
||
});
|
||
|
||
describe('parameter middleware branches', () => {
|
||
it('normalizes base urls and updates quote links', async () => {
|
||
const data = {
|
||
link: 'example.com/base',
|
||
item: [
|
||
{
|
||
title: 'Item 1',
|
||
link: '/relative',
|
||
description: '<div class="rsshub-quote">Quote</div><a href="/foo">Foo</a>',
|
||
_extra: {
|
||
links: [{ href: 'https://example.com' }],
|
||
},
|
||
},
|
||
{
|
||
title: 'Item 2',
|
||
description: '<img src="/img.png" />',
|
||
},
|
||
],
|
||
allowEmpty: true,
|
||
};
|
||
|
||
const result = await runMiddleware(data, {});
|
||
expect(result.item[0].link).toBe('http://example.com/relative');
|
||
expect(result.item[1].description).toContain('http://example.com/img.png');
|
||
expect(result.item[0]._extra.links[0].content_html).toContain('rsshub-quote');
|
||
});
|
||
|
||
it('keeps items without link in tgiv mode', async () => {
|
||
const data = {
|
||
link: 'https://example.com',
|
||
item: [{ title: 'NoLink' }],
|
||
};
|
||
|
||
const result = await runMiddleware(data, { tgiv: 'hash' });
|
||
expect(result.item[0].link).toBeUndefined();
|
||
});
|
||
|
||
it('rewrites links for scihub', async () => {
|
||
const data = {
|
||
link: 'https://example.com',
|
||
item: [
|
||
{ title: 'With DOI', doi: '10.1000/xyz' },
|
||
{ title: 'With link', link: 'https://example.com/paper' },
|
||
],
|
||
};
|
||
|
||
const result = await runMiddleware(data, { scihub: '1' });
|
||
expect(result.item[0].link).toBe(`${config.scihub.host}10.1000/xyz`);
|
||
expect(result.item[1].link).toBe(`${config.scihub.host}https://example.com/paper`);
|
||
});
|
||
|
||
it('throws on invalid brief value', async () => {
|
||
const data = {
|
||
link: 'https://example.com',
|
||
item: [{ title: 'Item', description: 'Desc' }],
|
||
};
|
||
|
||
await expect(runMiddleware(data, { brief: '10' })).rejects.toThrow('Invalid parameter brief');
|
||
});
|
||
|
||
it('processes openai description and title', async () => {
|
||
const originalInput = config.openai.inputOption;
|
||
|
||
const descriptionData = {
|
||
link: 'https://example.com',
|
||
item: [
|
||
{
|
||
title: 'Title',
|
||
description: 'Description',
|
||
link: `https://example.com/${Date.now()}/desc`,
|
||
},
|
||
],
|
||
};
|
||
config.openai.inputOption = 'description';
|
||
const descriptionResult = await runMiddleware(descriptionData, { chatgpt: 'true' });
|
||
expect(descriptionResult.item[0].description).toContain('AI processed content.');
|
||
|
||
const titleData = {
|
||
link: 'https://example.com',
|
||
item: [
|
||
{
|
||
title: 'Title',
|
||
description: 'Description',
|
||
link: `https://example.com/${Date.now()}/title`,
|
||
},
|
||
],
|
||
};
|
||
config.openai.inputOption = 'title';
|
||
const titleResult = await runMiddleware(titleData, { chatgpt: 'true' });
|
||
expect(titleResult.item[0].title).toContain('AI processed content.');
|
||
|
||
config.openai.inputOption = originalInput;
|
||
});
|
||
});
|
||
|
||
describe.each([{ engine: 'regexp' as const }, { engine: 're2' as const }])('parameter middleware filtering with $engine engine', ({ engine }) => {
|
||
it('filters items', async () => {
|
||
const originalEngine = config.feature.filter_regex_engine;
|
||
config.feature.filter_regex_engine = engine;
|
||
|
||
const data = {
|
||
link: 'https://example.com',
|
||
item: [
|
||
{ title: 'Keep', description: 'A' },
|
||
{ title: 'Drop', description: 'B' },
|
||
],
|
||
};
|
||
|
||
const result = await runMiddleware(data, { filter: 'Keep' });
|
||
expect(result.item).toHaveLength(1);
|
||
expect(result.item[0].title).toBe('Keep');
|
||
|
||
config.feature.filter_regex_engine = originalEngine;
|
||
});
|
||
|
||
it('matches categories when other fields do not match', async () => {
|
||
const originalEngine = config.feature.filter_regex_engine;
|
||
config.feature.filter_regex_engine = engine;
|
||
|
||
const data = {
|
||
link: 'https://example.com',
|
||
item: [
|
||
{
|
||
title: 'Nope',
|
||
description: 'Also nope',
|
||
author: 'Still nope',
|
||
category: ['Match'],
|
||
},
|
||
],
|
||
};
|
||
|
||
const result = await runMiddleware(data, { filter: 'Match' });
|
||
expect(result.item).toHaveLength(1);
|
||
expect(result.item[0].category).toContain('Match');
|
||
|
||
config.feature.filter_regex_engine = originalEngine;
|
||
});
|
||
});
|
||
|
||
describe('filter-engine', () => {
|
||
afterEach(() => {
|
||
delete process.env.FILTER_REGEX_ENGINE;
|
||
vi.resetModules();
|
||
});
|
||
|
||
it('filter RE2 engine ReDoS attack', async () => {
|
||
const freshApp = (await import('@/app')).default;
|
||
|
||
const response = await freshApp.request('/test/1?filter=abc(%3F%3Ddef)');
|
||
expect(response.status).toBe(503);
|
||
expect(await response.text()).toMatch(/RE2JSSyntaxException/);
|
||
});
|
||
|
||
it('filter Regexp engine backward compatibility', async () => {
|
||
process.env.FILTER_REGEX_ENGINE = 'regexp';
|
||
|
||
const freshApp = (await import('@/app')).default;
|
||
|
||
const response = await freshApp.request('/test/1?filter=abc(%3F%3Ddef)');
|
||
expect(response.status).toBe(200);
|
||
});
|
||
|
||
it('filter Regexp engine test config', async () => {
|
||
process.env.FILTER_REGEX_ENGINE = 'somethingelse';
|
||
|
||
const freshApp = (await import('@/app')).default;
|
||
|
||
const response = await freshApp.request('/test/1?filter=abc(%3F%3Ddef)');
|
||
expect(response.status).toBe(503);
|
||
expect(await response.text()).toMatch(/somethingelse/);
|
||
});
|
||
});
|
||
|
||
describe('entities', () => {
|
||
it('decodes HTML entities', async () => {
|
||
const data = await runMiddleware(
|
||
{
|
||
title: 'Feed Title & More',
|
||
description: 'Feed…',
|
||
item: [{ title: 'Item’s ’ ¬ify ?a=1<=2' }],
|
||
},
|
||
{}
|
||
);
|
||
expect(data.title).toBe('Feed\u{A0}Title & More');
|
||
expect(data.description).toBe('Feed…');
|
||
expect(data.item[0].title).toBe('Item’s ’ ¬ify ?a=1<=2');
|
||
});
|
||
});
|
||
|
||
const makeImageData = () => ({
|
||
image: 'https://example.com/logo.png',
|
||
item: [
|
||
{
|
||
title: 'Post',
|
||
link: 'https://example.com/post',
|
||
description:
|
||
'<p>Text</p><picture><source srcset="https://example.com/a.webp"><img src="https://example.com/a.png"></picture><video src="https://example.com/a.mp4" poster="https://example.com/a.jpg"></video><audio src="https://example.com/a.mp3"></audio>',
|
||
image: 'https://example.com/a.png',
|
||
banner: 'https://example.com/banner.png',
|
||
itunes_item_image: 'https://example.com/art.png',
|
||
enclosure_url: 'https://example.com/a.png',
|
||
enclosure_type: 'image/png',
|
||
enclosure_length: 10,
|
||
attachments: [
|
||
{ url: 'https://example.com/a.png', mime_type: 'image/png' },
|
||
{ url: 'https://example.com/a.mp3', mime_type: 'audio/mpeg' },
|
||
],
|
||
media: { thumbnail: { url: 'https://example.com/a.png' } },
|
||
},
|
||
],
|
||
});
|
||
|
||
describe('show_image', () => {
|
||
it('removes pictures and artwork while retaining text, video and audio', async () => {
|
||
const data = await runMiddleware(makeImageData(), { show_image: 'false' });
|
||
expect(data.image).toBeUndefined();
|
||
expect(data.item[0].description).not.toMatch(/<img|<picture|poster=/);
|
||
expect(data.item[0].description).toContain('<p>Text</p>');
|
||
expect(data.item[0].description).toContain('a.mp4');
|
||
expect(data.item[0].image).toBeUndefined();
|
||
expect(data.item[0].itunes_item_image).toBeUndefined();
|
||
expect(data.item[0].enclosure_url).toBeUndefined();
|
||
expect(data.item[0].attachments).toHaveLength(1);
|
||
expect(data.item[0].attachments[0].mime_type).toBe('audio/mpeg');
|
||
expect(data.item[0].media.thumbnail).toBeUndefined();
|
||
});
|
||
it('keeps images by default', async () => {
|
||
const data = await runMiddleware(makeImageData(), {});
|
||
expect(data.item[0].description).toContain('<img');
|
||
expect(data.item[0].image).toBe('https://example.com/a.png');
|
||
});
|
||
});
|
||
|
||
describe('enclosure', () => {
|
||
it('extracts direct media and downloads, preserving metadata and rejecting unsafe or untyped links', async () => {
|
||
const data = await runMiddleware(
|
||
{
|
||
item: [
|
||
{
|
||
title: 'Media',
|
||
link: 'https://example.com/posts/one',
|
||
enclosure_url: 'https://example.com/audio.mp3',
|
||
enclosure_type: 'audio/mpeg',
|
||
enclosure_length: 123,
|
||
description:
|
||
'<img src="/cover.png"><video><source src="/stream?id=1" type="video/mp4"></video><audio src="/audio.mp3"></audio><a href="/report.pdf">Report</a><img data-src="/cover.png"><a href="/article">Article</a><video src="javascript:alert(1)" type="video/mp4"></video><img src="data:image/png;base64,abc"><a href="/constructor">Unknown</a>',
|
||
},
|
||
],
|
||
},
|
||
{ enclosure: 'true' }
|
||
);
|
||
expect(data.item[0].attachments).toEqual([
|
||
expect.objectContaining({ url: 'https://example.com/audio.mp3', mime_type: 'audio/mpeg', size_in_bytes: 123 }),
|
||
{ url: 'https://example.com/stream?id=1', mime_type: 'video/mp4' },
|
||
{ url: 'https://example.com/cover.png', mime_type: 'image/png' },
|
||
{ url: 'https://example.com/report.pdf', mime_type: 'application/pdf' },
|
||
]);
|
||
expect(data.item[0].description).toContain('<video>');
|
||
});
|
||
|
||
it('keeps automatic extraction opt-in and respects hidden images', async () => {
|
||
const original = await runMiddleware(makeImageData(), {});
|
||
expect(original.item[0].attachments).toHaveLength(2);
|
||
const extracted = await runMiddleware(makeImageData(), { enclosure: 'true', show_image: 'false' });
|
||
expect(extracted.item[0].attachments.map((attachment) => attachment.mime_type)).toEqual(['audio/mpeg', 'video/mp4']);
|
||
});
|
||
});
|