diff --git a/lib/middleware/template.js b/lib/middleware/template.js index 1538d9813..9865b5bda 100644 --- a/lib/middleware/template.js +++ b/lib/middleware/template.js @@ -1,7 +1,7 @@ -const { art, json } = require('@/utils/render'); +const { art, json, rss3Ums } = require('@/utils/render'); const path = require('path'); const config = require('@/config').value; -const typeRegex = /\.(atom|rss|debug\.json|json|\d+\.debug\.html)$/; +const typeRegex = /\.(atom|rss|ums|debug\.json|json|\d+\.debug\.html)$/; const { collapseWhitespace, convertDateToISO8601 } = require('@/utils/common-utils'); module.exports = async (ctx, next) => { @@ -16,111 +16,105 @@ module.exports = async (ctx, next) => { 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 debug json' }); + // only enable when debugInfo=true + if (config.debugInfo) { + if (outputType === 'debug.json') { + 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 debug json' }); + } } - } - if (outputType.endsWith('.debug.html') && config.debugInfo) { - ctx.set({ - 'Content-Type': 'text/html; charset=UTF-8', - }); - const index = parseInt(outputType.match(/(\d+)\.debug\.html$/)[1]); - if (!(ctx.state.data && ctx.state.data.item && ctx.state.data.item[index])) { - ctx.body = `ctx.state.data.item[${index}] not found`; - } else { - ctx.body = ctx.state.data.item[index].description; + if (outputType.endsWith('.debug.html')) { + ctx.set({ + 'Content-Type': 'text/html; charset=UTF-8', + }); + + const index = parseInt(outputType.match(/(\d+)\.debug\.html$/)[1]); + if (!(ctx.state.data && ctx.state.data.item && ctx.state.data.item[index])) { + ctx.body = `ctx.state.data.item[${index}] not found`; + } else { + ctx.body = ctx.state.data.item[index].description; + } } } - if (outputType === 'json') { - ctx.set({ 'Content-Type': 'application/feed+json; charset=UTF-8' }); - } - if (!ctx.body) { - let template; - - switch (outputType) { - case 'atom': - template = path.resolve(__dirname, '../views/atom.art'); - break; - case 'rss': - template = path.resolve(__dirname, '../views/rss.art'); - break; - default: - template = path.resolve(__dirname, '../views/rss.art'); - break; - } + const templateName = outputType === 'atom' ? 'atom.art' : 'rss.art'; + const template = path.resolve(__dirname, `../views/${templateName}`); if (ctx.state.data) { - ctx.state.data.title = collapseWhitespace(ctx.state.data.title); - ctx.state.data.subtitle = collapseWhitespace(ctx.state.data.subtitle); - ctx.state.data.author = collapseWhitespace(ctx.state.data.author); - - ctx.state.data.item && - ctx.state.data.item.forEach((item) => { - if (item.title) { - item.title = collapseWhitespace(item.title); - // trim title length - for (let length = 0, i = 0; i < item.title.length; i++) { - length += Buffer.from(item.title[i]).length !== 1 ? 2 : 1; - if (length > config.titleLengthLimit) { - item.title = `${item.title.slice(0, i)}...`; - break; - } - } - } - - if (typeof item.author === 'string') { - item.author = collapseWhitespace(item.author); - } else if (typeof item.author === 'object' && item.author !== null) { - 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; - item.itunes_duration = - 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 !== 'rss') { - item.pubDate = convertDateToISO8601(item.pubDate); - item.updated = convertDateToISO8601(item.updated); + const collapseWhitespaceForProperties = (properties, obj) => { + properties.forEach((prop) => { + if (obj[prop]) { + obj[prop] = collapseWhitespace(obj[prop]); } }); + }; + + collapseWhitespaceForProperties(['title', 'subtitle', 'author'], ctx.state.data); + + ctx.state.data.item?.forEach((item) => { + if (item.title) { + item.title = collapseWhitespace(item.title); + // trim title length + for (let length = 0, i = 0; i < item.title.length; i++) { + length += Buffer.from(item.title[i]).length !== 1 ? 2 : 1; + if (length > config.titleLengthLimit) { + item.title = `${item.title.slice(0, i)}...`; + break; + } + } + } + + if (typeof item.author === 'string') { + item.author = collapseWhitespace(item.author); + } else if (typeof item.author === 'object' && item.author !== null) { + item.author.forEach((a) => (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; + item.itunes_duration = + 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 !== 'rss') { + item.pubDate = convertDateToISO8601(item.pubDate); + item.updated = convertDateToISO8601(item.updated); + } + }); } - const routeTtl = (config.cache.routeExpire / 60) | 0; - + const currentDate = new Date(); const data = { - lastBuildDate: new Date().toUTCString(), - updated: new Date().toISOString(), - ttl: routeTtl, + lastBuildDate: currentDate.toUTCString(), + updated: currentDate.toISOString(), + ttl: (config.cache.routeExpire / 60) | 0, atomlink: ctx.request.href, ...ctx.state.data, }; + if (config.isPackage) { ctx.body = data; return; } - if (!template) { - return; - } - if (outputType !== 'json') { + + if (outputType === 'ums') { + ctx.set({ 'Content-Type': 'application/json; charset=UTF-8' }); + ctx.body = rss3Ums(data); + } else if (outputType === 'json') { + ctx.set({ 'Content-Type': 'application/feed+json; charset=UTF-8' }); + ctx.body = json(data); + } else { ctx.body = art(template, data); - return; } - ctx.body = json(data); } }; diff --git a/lib/utils/render.js b/lib/utils/render.js index 813f631c2..f45274303 100644 --- a/lib/utils/render.js +++ b/lib/utils/render.js @@ -1,9 +1,11 @@ const art = require('art-template'); const json = require('@/views/json'); +const rss3Ums = require('@/views/rss3-ums'); // We may add more control over it later module.exports = { art, json, // This should be used by RSSHub middleware only. + rss3Ums, }; diff --git a/lib/views/rss3-ums.js b/lib/views/rss3-ums.js new file mode 100644 index 000000000..bb31cb0af --- /dev/null +++ b/lib/views/rss3-ums.js @@ -0,0 +1,62 @@ +const dayjs = require('dayjs'); + +/** + * This function should be used by RSSHub middleware only. + * @param {object} data ctx.state.data + * @returns `JSON.stringify`-ed [UMS Result](https://docs.rss3.io/docs/unified-metadata-schemas) + */ + +const NETWORK = 'RSS'; +const TAG = 'RSS'; +const TYPE = 'feed'; + +const rss3Ums = (data) => { + const currentUnixTsp = dayjs().unix(); + const umsResult = { + data: data.item.map((item) => { + const owner = getOwnershipFieldFromURL(item); + return { + owner, + id: item.link, + network: NETWORK, + from: owner, + to: owner, + tag: TAG, + type: TYPE, + direction: 'out', + feeValue: '0', + actions: [ + { + tag: TAG, + type: TYPE, + platform: owner, + from: owner, + to: owner, + metadata: { + authors: typeof item.author === 'string' ? [{ name: item.author }] : item.author, + description: item.description, + pubDate: item.pubDate, + tags: typeof item.category === 'string' ? [item.category] : item.category, + title: item.title, + }, + related_urls: [item.link], + }, + ], + timestamp: dayjs(item.updated).unix() || currentUnixTsp, + }; + }), + }; + return JSON.stringify(umsResult, null, 4); +}; + +// we treat the domain as the owner of the content +function getOwnershipFieldFromURL(item) { + try { + const urlObj = new URL(item.link); + return urlObj.hostname; + } catch (e) { + return item.link; + } +} + +module.exports = rss3Ums; diff --git a/website/docs/parameter.md b/website/docs/parameter.md index 76288e95e..84e5372b1 100644 --- a/website/docs/parameter.md +++ b/website/docs/parameter.md @@ -136,7 +136,7 @@ There are more details in the [FAQ](/faq). ## Output Formats -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. +RSSHub conforms to RSS 2.0, Atom, JSON Feed and RSS3 UMS Standard, simply append `.rss`, `.atom`, `.json`, or `.ums` to the end of the feed address to obtain the feed in corresponding format. The default output format is RSS 2.0. E.g. @@ -144,6 +144,7 @@ E.g. - 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) +- RSS3 UMS - [https://rsshub.app/abc.ums](https://rsshub.app/abc.ums) - 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.json diff --git a/website/i18n/zh/docusaurus-plugin-content-docs/current/parameter.md b/website/i18n/zh/docusaurus-plugin-content-docs/current/parameter.md index ffedb7fa9..455eb1e64 100644 --- a/website/i18n/zh/docusaurus-plugin-content-docs/current/parameter.md +++ b/website/i18n/zh/docusaurus-plugin-content-docs/current/parameter.md @@ -158,7 +158,7 @@ Telegram 即时预览模式需要在官网制作页面处理模板,请前往[ ## 输出格式 -RSSHub 同时支持 RSS 2.0、Atom 和 JSON Feed 输出格式,在路由末尾添加 `.rss`、`.atom` 或 `.json` 即可请求对应输出格式,缺省为 RSS 2.0 +RSSHub 同时支持 RSS 2.0、Atom、JSON Feed 和 RSS3 UMS 输出格式,在路由末尾添加 `.rss`、`.atom`、`.json` 或 `.ums` 即可请求对应输出格式,缺省为 RSS 2.0 举例: @@ -166,6 +166,7 @@ RSSHub 同时支持 RSS 2.0、Atom 和 JSON Feed 输出格式,在路由末尾 - RSS 2.0 - [https://rsshub.app/jianshu/home.rss](https://rsshub.app/jianshu/home.rss) - Atom - [https://rsshub.app/jianshu/home.atom](https://rsshub.app/jianshu/home.atom) - JSON Feed - [https://rsshub.app/twitter/user/DIYgod.json](https://rsshub.app/twitter/user/DIYgod.json) +- RSS3 UMS - [https://rsshub.app/abc.ums](https://rsshub.app/abc.ums) - 和 filter 或其他 URL query 一起使用 - `https://rsshub.app/bilibili/user/coin/2267573.atom?filter=微小微|赤九玖|暴走大事件` ### debug.json