From e7d62ebcd9da759f7f921d81f6f3187d00a206e4 Mon Sep 17 00:00:00 2001
From: notofoe <43095406+notofoe@users.noreply.github.com>
Date: Wed, 29 Jul 2020 21:27:23 +0800
Subject: [PATCH] feat: add mastodon user timeline (#5188)
---
docs/en/install/README.md | 5 ++
docs/en/social-media.md | 17 ++++--
docs/install/README.md | 5 ++
docs/social-media.md | 17 +++++-
lib/config.js | 5 ++
lib/router.js | 2 +
lib/routes/mastodon/account_id.js | 39 ++++++++++++++
lib/routes/mastodon/acct.js | 87 +++++++++++++++++++++++++++++++
lib/routes/mastodon/timeline.js | 35 +------------
lib/routes/mastodon/utils.js | 50 ++++++++++++++++++
10 files changed, 224 insertions(+), 38 deletions(-)
create mode 100644 lib/routes/mastodon/account_id.js
create mode 100644 lib/routes/mastodon/acct.js
create mode 100644 lib/routes/mastodon/utils.js
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,
+};