feat(core): json feed (#11494)

* feat(core): json feed

* docs: json feed description

* fix: remove extra comma if no elements exist after author

* test: json feed test

* feat: generate json feed without art-template

* test: fix coverage

* style: 4 spaces json

* docs: fix parameter
This commit is contained in:
Tony
2023-01-02 06:35:36 -07:00
committed by GitHub
parent 0b4c6581c3
commit d2ab8ab3c0
6 changed files with 148 additions and 25 deletions

View File

@@ -4,17 +4,17 @@
Parameters here are actually URI query and can be linked together with `&` to generate a complex feed.
Parameters here need to be placed after the route path. Some routes may have <span color=green>**custom route parameters**</span> and <span color=violet>**parameters here**</span> need to be placed after them.
Parameters here need to be placed after the route path. Some routes may have <span style="color: green">**custom route parameters**</span> and <span style="color: violet">**parameters here**</span> need to be placed after them.
E.g.
<a href="https://rsshub.app/twitter/user/durov/readable=1&includeRts=0?brief=100&limit=5">https://rsshub.app/twitter/user/durov/<span color=green><b>readable=1&includeRts=0</b></span>?<span color=violet><b>brief=100&limit=5</b></span></a>
<a href="https://rsshub.app/twitter/user/durov/readable=1&includeRts=0?brief=100&limit=5">https://rsshub.app/twitter/user/durov/<span style="green"><b>readable=1&includeRts=0</b></span>?<span style="color: violet"><b>brief=100&limit=5</b></span></a>
If a <span color=magenta>**output format**</span> (`.atom`, `.rss`, `.debug.json`) is set, it needs to be placed between the route path (including <span color=green>**custom route parameters**</span>) and <span color=violet>**other parameters**</span>.
If a <span style="color: magenta">**output format**</span> (`.atom`, `.rss`, `.json`, `.debug.json`) is set, it needs to be placed between the route path (including <span style="green">**custom route parameters**</span>) and <span style="color: violet">**other parameters**</span>.
E.g.
<a href="https://rsshub.app/twitter/user/durov/readable=1&includeRts=0.atom?brief=100&limit=5">https://rsshub.app/twitter/user/durov/<span color=green><b>readable=1&includeRts=0</b></span><span color=magenta><b>.atom</b></span>?<span color=violet><b>brief=100&limit=5</b></span></a>
<a href="https://rsshub.app/twitter/user/durov/readable=1&includeRts=0.atom?brief=100&limit=5">https://rsshub.app/twitter/user/durov/<span style="color: green"><b>readable=1&includeRts=0</b></span><span style="color: magenta"><b>.atom</b></span>?<span style="color: violet"><b>brief=100&limit=5</b></span></a>
:::
@@ -138,13 +138,14 @@ There are more details in the [FAQ](/en/faq.html).
## Output Formats
RSSHub conforms to RSS 2.0 and Atom Standard, simply append `.rss` `.atom` to the end of the feed address to obtain the feed in corresponding format. The default output format is RSS 2.0.
RSSHub conforms to RSS 2.0, Atom and JSON Feed Standard, simply append `.rss`, `.atom` or `.json` to the end of the feed address to obtain the feed in corresponding format. The default output format is RSS 2.0.
E.g.
- Default (RSS 2.0) - [https://rsshub.app/dribbble/popular](https://rsshub.app/dribbble/popular)
- RSS 2.0 - [https://rsshub.app/dribbble/popular.rss](https://rsshub.app/dribbble/popular.rss)
- Atom - [https://rsshub.app/dribbble/popular.atom](https://rsshub.app/dribbble/popular.atom)
- JSON Feed - [https://rsshub.app/twitter/user/DIYgod.json](https://rsshub.app/twitter/user/DIYgod.json)
- Apply filters or URL query - [https://rsshub.app/dribbble/popular.atom?filterout=Blue|Yellow|Black](https://rsshub.app/dribbble/popular.atom?filterout=Blue|Yellow|Black)
### Debug

View File

@@ -4,17 +4,17 @@
通用参数实际上是 URI 中的 query可以使用 `&` 连接组合使用,效果叠加。
通用参数需要置于路由路径的最后。有些路由在路由路径route path的最后引入了<span color=green>**自定义参数**</span><span color=violet>**通用参数**</span>也需要置于它们之后。
通用参数需要置于路由路径的最后。有些路由在路由路径route path的最后引入了<span style="color: green">**自定义参数**</span><span style="color: violet">**通用参数**</span>也需要置于它们之后。
举例:
<a href="https://rsshub.app/twitter/user/durov/readable=1&includeRts=0?brief=100&limit=5"><https://rsshub.app/twitter/user/durov/><span color=green><b>readable=1\&includeRts=0</b></span>?<span color=violet><b>brief=100\&limit=5</b></span></a>
<a href="https://rsshub.app/twitter/user/durov/readable=1&includeRts=0?brief=100&limit=5"><https://rsshub.app/twitter/user/durov/><span style="color: green"><b>readable=1\&includeRts=0</b></span>?<span style=violet><b>brief=100\&limit=5</b></span></a>
如果设置了<span color=magenta>**输出格式**</span>`.atom`, `.rss`, `.debug.json`),则需要置于路由路径(含<span color=green>**自定义参数**</span>)与<span color=violet>**其它通用参数**</span>之间。
如果设置了<span style="color: magenta">**输出格式**</span>`.atom`, `.rss`, `.json`, `.debug.json`),则需要置于路由路径(含<span style="color: green">**自定义参数**</span>)与<span style="color: violet">**其它通用参数**</span>之间。
举例:
<a href="https://rsshub.app/twitter/user/durov/readable=1&includeRts=0.atom?brief=100&limit=5"><https://rsshub.app/twitter/user/durov/><span color=green><b>readable=1\&includeRts=0</b></span><span color=magenta><b>.atom</b></span>?<span color=violet><b>brief=100\&limit=5</b></span></a>
<a href="https://rsshub.app/twitter/user/durov/readable=1&includeRts=0.atom?brief=100&limit=5"><https://rsshub.app/twitter/user/durov/><span style="color: green"><b>readable=1\&includeRts=0</b></span><span style="color: magenta"><b>.atom</b></span>?<span style="color: violet"><b>brief=100\&limit=5</b></span></a>
:::
@@ -137,13 +137,14 @@ Telegram 即时预览模式需要在官网制作页面处理模板,请前往[
## 输出格式
RSSHub 同时支持 RSS 2.0Atom 输出格式,在路由末尾添加 `.rss``.atom` 即可请求对应输出格式,缺省为 RSS 2.0
RSSHub 同时支持 RSS 2.0Atom 和 JSON Feed 输出格式,在路由末尾添加 `.rss``.atom``.json` 即可请求对应输出格式,缺省为 RSS 2.0
举例:
- 缺省 RSS 2.0 - <https://rsshub.app/jianshu/home>
- RSS 2.0 - <https://rsshub.app/jianshu/home.rss>
- Atom - <https://rsshub.app/jianshu/home.atom>
- JSON Feed - <https://rsshub.app/twitter/user/DIYgod.json>
- 和 filter 或其他 URL query 一起使用 - `https://rsshub.app/bilibili/user/coin/2267573.atom?filter=微小微|赤九玖|暴走大事件`
### debug

View File

@@ -1,7 +1,7 @@
const art = require('art-template');
const { art, json } = require('@/utils/render');
const path = require('path');
const config = require('@/config').value;
const typeRegex = /\.(atom|rss|debug\.json)$/;
const typeRegex = /\.(atom|rss|debug\.json|json)$/;
const { collapseWhitespace, convertDateToISO8601 } = require('@/utils/common-utils');
module.exports = async (ctx, next) => {
@@ -14,21 +14,25 @@ module.exports = async (ctx, next) => {
await next();
if (ctx.state.type[1] === 'debug.json' && config.debugInfo) {
const outputType = ctx.state.type[1] || 'rss';
if (outputType === 'debug.json' && config.debugInfo) {
ctx.set({
'Content-Type': 'application/json; charset=UTF-8',
});
if (ctx.state.json) {
ctx.body = JSON.stringify(ctx.state.json, null, 4);
} else {
ctx.body = JSON.stringify({ message: 'plugin does not set json' });
ctx.body = JSON.stringify({ message: 'plugin does not set debug json' });
}
}
if (outputType === 'json') {
ctx.set({ 'Content-Type': 'application/feed+json; charset=UTF-8' });
}
if (!ctx.body) {
let template;
const outputType = ctx.state.type[1];
switch (outputType) {
case 'atom':
template = path.resolve(__dirname, '../views/atom.art');
@@ -60,7 +64,16 @@ module.exports = async (ctx, next) => {
}
}
if (typeof item.author === 'string') {
item.author = collapseWhitespace(item.author);
} else if (typeof item.author === 'object') {
for (const a of item.author) {
a.name = collapseWhitespace(a.name);
}
if (outputType !== 'json') {
item.author = item.author.map((a) => a.name).join(', ');
}
}
if (item.itunes_duration && ((typeof item.itunes_duration === 'string' && item.itunes_duration.indexOf(':') === -1) || (typeof item.itunes_duration === 'number' && !isNaN(item.itunes_duration)))) {
item.itunes_duration = +item.itunes_duration;
@@ -68,7 +81,7 @@ module.exports = async (ctx, next) => {
Math.floor(item.itunes_duration / 3600) + ':' + (Math.floor((item.itunes_duration % 3600) / 60) / 100).toFixed(2).slice(-2) + ':' + (((item.itunes_duration % 3600) % 60) / 100).toFixed(2).slice(-2);
}
if (outputType === 'atom') {
if (outputType !== 'rss') {
item.pubDate = convertDateToISO8601(item.pubDate);
item.updated = convertDateToISO8601(item.updated);
}
@@ -86,10 +99,15 @@ module.exports = async (ctx, next) => {
};
if (config.isPackage) {
ctx.body = data;
} else {
if (template) {
return;
}
if (!template) {
return;
}
if (outputType !== 'json') {
ctx.body = art(template, data);
return;
}
}
ctx.body = json(data);
}
};

View File

@@ -2,6 +2,51 @@ const art = require('art-template');
// We may add more control over it later
/**
* This function should be used by RSSHub middleware only.
* @param {object} data ctx.state.data
* @returns `JSON.stringify`-ed [JSON Feed](https://www.jsonfeed.org/)
*/
const json = (data) => {
const jsonFeed = {
version: 'https://jsonfeed.org/version/1.1',
title: data.title || 'RSSHub',
home_page_url: data.link || 'https://docs.rsshub.app',
feed_url: data.feedLink,
description: `${data.description || data.title} - Made with love by RSSHub(https://github.com/DIYgod/RSSHub)`,
icon: data.image,
authors: typeof data.author === 'string' ? [{ name: data.author }] : data.author,
language: data.language || 'zh-cn',
items: data.item.map((item) => ({
id: item.guid || item.id || item.link,
url: item.link,
title: item.title,
content_html: (item.content && item.content.html) || item.description || item.title,
content_text: item.content && item.content.text,
image: item.image,
banner_image: item.banner,
date_published: item.pubDate,
date_modified: item.updated,
authors: typeof item.author === 'string' ? [{ name: item.author }] : item.author,
tags: typeof item.category === 'string' ? [item.category] : item.category,
language: item.language,
attachments: item.enclosure_url
? [
{
url: item.enclosure_url,
mime_type: item.enclosure_type,
title: item.enclosure_title,
size_in_bytes: item.enclosure_length,
duration_in_seconds: item.itunes_duration,
},
]
: undefined,
})),
};
return JSON.stringify(jsonFeed, null, 4);
};
module.exports = {
art,
json,
};

View File

@@ -176,6 +176,46 @@ module.exports = async (ctx) => {
pubDate: new Date(1546272000000).toUTCString(),
author: `DIYgod0`,
});
} else if (ctx.params.id === 'json') {
item.push(
{
title: 'Title0',
pubDate: new Date(`2019-3-1`).toUTCString(),
link: `https://github.com/DIYgod/RSSHub/issues/-3`,
},
{
title: 'Title1',
description: 'Description1',
pubDate: new Date(`2019-3-1`).toUTCString(),
link: `https://github.com/DIYgod/RSSHub/issues/-2`,
author: `DIYgod0 `,
category: 'Category0',
},
{
title: 'Title2 HTML in description',
description: '<a href="https://github.com/DIYgod/RSSHub">RSSHub</a>',
pubDate: new Date(`2019-3-1`).toUTCString(),
updated: new Date(`2019-3-2`).toUTCString(),
link: `https://github.com/DIYgod/RSSHub/issues/-1`,
author: [{ name: ' DIYgod1' }, { name: 'DIYgod2 ' }],
category: ['Category0', 'Category1'],
},
{
title: 'Title3 HTML in content',
content: {
html: '<a href="https://github.com/DIYgod/RSSHub">DIYgod/RSSHub</a>',
},
pubDate: new Date(`2019-3-1`).toUTCString(),
updated: new Date(`2019-3-2`).toUTCString(),
link: `https://github.com/DIYgod/RSSHub/issues/0`,
author: [{ name: ' DIYgod3' }, { name: 'DIYgod4 ' }, { name: 'DIYgod5 ' }],
category: ['Category1'],
enclosure_url: 'https://github.com/DIYgod/RSSHub/issues/0',
enclosure_type: 'image/jpeg',
enclosure_length: 3661,
itunes_duration: 36610,
}
);
}
for (let i = 1; i < 6; i++) {

View File

@@ -63,9 +63,27 @@ describe('template', () => {
});
it(`.json`, async () => {
const response = await request.get('/test/1.json');
const responseXML = await request.get('/test/1.rss');
expect(response.text.slice(0, 50)).toEqual(responseXML.text.slice(0, 50));
const jsonResponse = await request.get('/test/1.json');
const rssResponse = await request.get('/test/1.rss');
const jsonParsed = JSON.parse(jsonResponse.text);
const rssParsed = await parser.parseString(rssResponse.text);
expect(jsonResponse.headers['content-type']).toBe('application/feed+json; charset=UTF-8');
expect(jsonParsed.items[0].title).toEqual(rssParsed.items[0].title);
expect(jsonParsed.items[0].url).toEqual(rssParsed.items[0].link);
expect(jsonParsed.items[0].id).toEqual(rssParsed.items[0].guid);
expect(jsonParsed.items[0].date_published).toEqual(expectPubDate.toISOString());
expect(jsonParsed.items[0].content_html).toEqual(rssParsed.items[0].content);
expect(jsonParsed.items[0].authors[0].name).toEqual(rssParsed.items[0].author);
expect(jsonParsed.items.every((item) => item.authors.every((author) => author.name.includes(' ')))).toBe(false);
});
it('flatten author object', async () => {
const response = await request.get('/test/json');
const parsed = await parser.parseString(response.text);
expect(parsed.items[2].author).toBe(['DIYgod1', 'DIYgod2'].map((name) => name).join(', '));
expect(parsed.items[3].author).toBe(['DIYgod3', 'DIYgod4', 'DIYgod5'].map((name) => name).join(', '));
});
it(`long title`, async () => {