diff --git a/docs/en/install/README.md b/docs/en/install/README.md index a9b7947ed..258a4db88 100644 --- a/docs/en/install/README.md +++ b/docs/en/install/README.md @@ -427,6 +427,11 @@ See the relation between access key/code and white/blacklisting. - `DISCUZ_COOKIE_{cid}`: Cookie of a forum powered by discuz, cid can be anything from 00 to 99. When visiting route discuz, using cid to specify this cookie. +- Mastodon user timeline: apply api here `https://mastodon.example/settings/applications`, please check scope `read:search` + - `MASTODON_API_HOST`: api instance domain + - `MASTODON_API_ACCESS_TOKEN`: user access token + - `MASTODON_API_ACCT_DOMAIN`: acct domain for particular instance + - Sci-hub for scientific journal routes: - `SCIHUB_HOST`: The Sci-hub mirror address that is accssible from your location, default to `https://sci-hub.tw`. diff --git a/docs/en/social-media.md b/docs/en/social-media.md index 40a711348..2496a9b7e 100644 --- a/docs/en/social-media.md +++ b/docs/en/social-media.md @@ -30,15 +30,26 @@ pageClass: routes ::: tip -Offical user RSS: https://**:instance**/users/**:username**.atom or https://**:instance**/users/**:username**.rss +Official user RSS: + - RSS: `https://**:instance**/users/**:username**.rss` ([Example](https://pawoo.net/users/pawoo_support.rss)) + - Atom: ~~`https://**:instance**/users/**:username**.atom`~~ (Only for pawoo.net, [example](https://pawoo.net/users/pawoo_support.atom)) -For example, https://pawoo.net/users/pawoo_support.atom or https://pawoo.net/users/pawoo_support.rss +These feed do not include boosts (a.k.a. reblogs). RSSHub provides a feed for user timeline based on the Mastodon API, but to use that, you will need to create application on a Mastodon instance, and configure your RSSHub instance. Check the [Deploy Guide](/en/install/#route-specific-configurations) for route-specific configurations. ::: +### User timeline + + + ### Timeline - + + +### User timeline (backup) + + + ## piapro diff --git a/docs/install/README.md b/docs/install/README.md index 14728072d..f7d42534d 100644 --- a/docs/install/README.md +++ b/docs/install/README.md @@ -463,6 +463,11 @@ RSSHub 支持使用访问密钥 / 码,白名单和黑名单三种方式进行 - `WEIBO_APP_SECRET`: 微博 App Secret - `WEIBO_REDIRECT_URL`: 微博登录授权回调地址,默认为 `RSSHub 地址/weibo/timeline/0`,自定义回调地址请确保最后可以转跳到 `RSSHub 地址/weibo/timeline/0?code=xxx` +- Mastodon 用户时间线路由:访问 `https://mastodon.example/settings/applications` 申请(替换掉 `mastodon.example`)。需要 `read:search` 权限 + - `MASTODON_API_HOST`: API 请求的实例 + - `MASTODON_API_ACCESS_TOKEN`: 用户 access token, 申请应用后,在应用配置页可以看到申请者的 access token + - `MASTODON_API_ACCT_DOMAIN`: 该实例本地用户 acct 标识的域名 + - 饭否 全部路由:[申请地址](https://github.com/FanfouAPI/FanFouAPIDoc/wiki/Oauth) - `FANFOU_CONSUMER_KEY`: 饭否 Consumer Key diff --git a/docs/social-media.md b/docs/social-media.md index 5201c3cba..4473c521f 100644 --- a/docs/social-media.md +++ b/docs/social-media.md @@ -322,16 +322,29 @@ Tiny Tiny RSS 会给所有 iframe 元素添加 `sandbox="allow-scripts"` 属性 ::: tip 提示 -官方提供了用户时间线 RSS: `https://**:instance**/users/**:username**.atom` 或 `https://**:instance**/users/**:username**.rss` +通常来说,各实例提供用户时间线的订阅源,如下: -例如: +- RSS: `https://**:instance**/users/**:username**.rss` +- Atom: ~~`https://**:instance**/users/**:username**.atom`~~ (仅 pawoo.net) + +例如: + +上述订阅源的内容不包括用户的转嘟。RSSHub 提供基于 Mastodon API 的订阅源,但需要您在某个 Mastodon 实例申请 API,并对 RSSHub 实例进行配置。详情见部署页面的配置模块。 ::: +### 用户公共时间线 + + + ### 实例公共时间线 +### 用户公共时间线(备用) + + + ## piapro ### 用户最新作品 diff --git a/lib/config.js b/lib/config.js index 5abefba7d..969e94138 100644 --- a/lib/config.js +++ b/lib/config.js @@ -144,6 +144,11 @@ const calculateValue = () => { host: envs.BTBYR_HOST || 'https://bt.byr.cn/', cookies: envs.BTBYR_COOKIE, }, + mastodon: { + apiHost: envs.MASTODON_API_HOST, + accessToken: envs.MASTODON_API_ACCESS_TOKEN, + acctDomain: envs.MASTODON_API_ACCT_DOMAIN, + }, xiaoyuzhou: { device_id: envs.XIAOYUZHOU_ID, refresh_token: envs.XIAOYUZHOU_TOKEN, diff --git a/lib/router.js b/lib/router.js index 78d3de990..5f897a645 100644 --- a/lib/router.js +++ b/lib/router.js @@ -2285,6 +2285,8 @@ router.get('/ikea/uk/offer', require('./routes/ikea/uk/offer')); // Mastodon router.get('/mastodon/timeline/:site/:only_media?', require('./routes/mastodon/timeline')); +router.get('/mastodon/account_id/:site/:account_id/statuses/:only_media?', require('./routes/mastodon/account_id')); +router.get('/mastodon/acct/:acct/statuses/:only_media?', require('./routes/mastodon/acct')); // Kernel Aliyun router.get('/aliyun-kernel/index', require('./routes/aliyun-kernel/index')); diff --git a/lib/routes/mastodon/account_id.js b/lib/routes/mastodon/account_id.js new file mode 100644 index 000000000..32d1bbaa5 --- /dev/null +++ b/lib/routes/mastodon/account_id.js @@ -0,0 +1,39 @@ +const got = require('@/utils/got'); +const utils = require('./utils'); + +module.exports = async (ctx) => { + const site = ctx.params.site; + const account_id = ctx.params.account_id; + const only_media = ctx.params.only_media ? true : false; + + const statuses_url = `http://${site}/api/v1/accounts/${account_id}/statuses`; + const statuses_response = await got({ + method: 'get', + url: statuses_url, + }); + let data = statuses_response.data; + + let account_data; + if (data.length !== 0 && data[0].account !== null) { + account_data = data[0].account; + } else { + const account_url = `http://${site}/api/v1/accounts/${account_id}`; + const account_response = await got({ + method: 'get', + url: account_url, + }); + account_data = account_response.data; + } + + if (only_media === true) { + data = data.filter((item) => item.media_attachments.length !== 0); + } + + ctx.state.data = { + title: `${account_data.display_name} (@${account_data.acct})`, + link: `${account_data.url}`, + description: `${account_data.note}`, + item: utils.ProcessFeed(data), + allowEmpty: true, + }; +}; diff --git a/lib/routes/mastodon/acct.js b/lib/routes/mastodon/acct.js new file mode 100644 index 000000000..7290e3eb6 --- /dev/null +++ b/lib/routes/mastodon/acct.js @@ -0,0 +1,87 @@ +const got = require('@/utils/got'); +const utils = require('./utils'); +const config = require('@/config').value; +const mastodonConfig = config.mastodon; + +module.exports = async (ctx) => { + const getAccountIdByAcct = async (acct) => { + if (!(mastodonConfig.apiHost && mastodonConfig.accessToken && mastodonConfig.acctDomain)) { + throw 'this route require api configuration, please check documentation'; + } + + const site = mastodonConfig.apiHost; + + const search_url = `http://${site}/api/v2/search`; + const cacheUid = `mastodon_acct_id/${site}/${acct}`; + + return await ctx.cache.tryGet(cacheUid, async () => { + const search_response = await got({ + method: 'get', + url: search_url, + headers: { + Authorization: `Bearer ${mastodonConfig.accessToken}`, + }, + searchParams: { + q: acct, + type: 'accounts', + }, + }); + const [acctUser, acctHost] = acct.split('@').filter((e) => e); + let acctOnServer; + + if (acctHost) { + if (acctHost === mastodonConfig.acctDomain) { + acctOnServer = acctUser; + } else { + acctOnServer = acctUser + '@' + acctHost; + } + } else { + acctOnServer = acctUser; + } + + const accountData = search_response.data.accounts.filter((item) => item.acct === acctOnServer); + + if (accountData.length === 0) { + throw `acct ${acct} not found`; + } + return accountData[0].id; + }); + }; + + const acct = ctx.params.acct; + const only_media = ctx.params.only_media ? true : false; + + const site = mastodonConfig.apiHost; + const account_id = await getAccountIdByAcct(acct); + + const statuses_url = `http://${site}/api/v1/accounts/${account_id}/statuses`; + const statuses_response = await got({ + method: 'get', + url: statuses_url, + }); + let data = statuses_response.data; + + let account_data; + if (data.length !== 0 && data[0].account !== null) { + account_data = data[0].account; + } else { + const account_url = `http://${site}/api/v1/accounts/${account_id}`; + const account_response = await got({ + method: 'get', + url: account_url, + }); + account_data = account_response.data; + } + + if (only_media === true) { + data = data.filter((item) => item.media_attachments.length !== 0); + } + + ctx.state.data = { + title: `${account_data.display_name} (@${account_data.acct})`, + link: `${account_data.url}`, + description: `${account_data.note}`, + item: utils.ProcessFeed(data), + allowEmpty: true, + }; +}; diff --git a/lib/routes/mastodon/timeline.js b/lib/routes/mastodon/timeline.js index f9fcc3c08..75c42c799 100644 --- a/lib/routes/mastodon/timeline.js +++ b/lib/routes/mastodon/timeline.js @@ -1,4 +1,5 @@ const got = require('@/utils/got'); +const utils = require('./utils'); module.exports = async (ctx) => { const site = ctx.params.site; @@ -9,41 +10,9 @@ module.exports = async (ctx) => { const response = await got.get(url); const list = response.data; - const ProcessFeed = (data) => - data.map((item) => { - const title = item.content - .replace(/|<\/span.*?>/gm, '') - .replace(/<(?:.|\n)*?>/gm, '\n') - .split('\n') - .filter((s) => s !== '')[0]; - - const media = item.media_attachments - .map((item) => { - switch (item.type) { - case 'gifv': - return `
`; - case 'video': - return `
`; - case 'image': - return `
`; - default: - return ''; - } - }) - .join(''); - - return { - title: title, - author: item.account.display_name, - description: item.content + media, - pubDate: new Date(item.created_at).toUTCString(), - link: item.uri, - }; - }); - ctx.state.data = { title: `Local Public${ctx.params.only_media ? ' Media' : ''} Timeline on ${site}`, link: `http://${site}`, - item: ProcessFeed(list), + item: utils.ProcessFeed(list), }; }; diff --git a/lib/routes/mastodon/utils.js b/lib/routes/mastodon/utils.js new file mode 100644 index 000000000..9fb46ddc5 --- /dev/null +++ b/lib/routes/mastodon/utils.js @@ -0,0 +1,50 @@ +const ProcessFeed = (data) => + data.map((item) => { + const content = item.content + ? item.content + .replace(/|<\/span.*?>/gm, '') + .replace(/<(?:.|\n)*?>/gm, '\n') + .split('\n') + .filter((s) => s !== '')[0] + : ''; + + const media = item.media_attachments + .map((item) => { + switch (item.type) { + case 'gifv': + return `
`; + case 'video': + return `
`; + case 'image': + return `
`; + default: + return ''; + } + }) + .join(''); + + let author, link, titleAuthor; + if (item.reblog !== null) { + author = `${item.reblog.account.display_name} (@${item.reblog.account.acct})`; + link = item.reblog.url; + titleAuthor = `Re @${item.reblog.account.username}`; + } else { + author = `${item.account.display_name} (@${item.account.acct})`; + link = item.url; + titleAuthor = `@${item.account.username}`; + } + const titleText = item.sentitive === true ? `(CW) ${item.spoiler_text}` : content; + + return { + title: `${titleAuthor}: "${titleText}"`, + author: author, + description: content + media, + pubDate: new Date(item.created_at).toUTCString(), + link: link, + guid: item.uri, + }; + }); + +module.exports = { + ProcessFeed: ProcessFeed, +};