Skip to content

Commit

Permalink
feat(route): Add Meta's Threads (#12762)
Browse files Browse the repository at this point in the history
* feat(route) Add Meta's Threads

* Fixing deepscan issues

* Additional linting issues

* single quotes when possible

* woops

* Addressing PR comments

* Rename radar title

* Update lib/v2/threads/radar.js

* Update lib/v2/threads/radar.js

* docs: add pptr flag

---------
  • Loading branch information
ninboy committed Jul 8, 2023
1 parent 28a41d6 commit 4e41f3e
Show file tree
Hide file tree
Showing 5 changed files with 247 additions and 0 deletions.
25 changes: 25 additions & 0 deletions docs/en/social-media.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,31 @@ Due to Telegram restrictions, some channels involving pornography, copyright, an

<RouteEn author="fengkx" example="/telegram/blog" path="/telegram/blog" />

## Threads

### User timeline

<RouteEn author="ninboy" path="/threads/:user/:routeParams?" example="/threads/zuck" radar="1" rssbud="1" puppeteer="1">

Specify options (in the format of query string) in parameter `routeParams` to control some extra features for threads

| Key | Description | Accepts | Defaults to |
|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------| ---------------------- |-------------|
| `showAuthorInTitle` | Show author name in title | `0`/`1`/`true`/`false` | `true` |
| `showAuthorInDesc` | Show author name in description (RSS body) | `0`/`1`/`true`/`false` | `true` |
| `showQuotedAuthorAvatarInDesc` | Show avatar of quoted author in description (RSS body) (Not recommended if your RSS reader extracts images from description) | `0`/`1`/`true`/`false` | `false` |
| `showAuthorAvatarInDesc` | Show avatar of author in description (RSS body) (Not recommended if your RSS reader extracts images from description) | `0`/`1`/`true`/`false` | `falseP` |
| `showEmojiForQuotesAndReply` | Use "🔁" instead of "QT", "↩️" instead of "Re" | `0`/`1`/`true`/`false` | `true` |
| `showQuotedInTitle` | Show quoted tweet in title | `0`/`1`/`true`/`false` | `true` |
| `replies` | Show replies | `0`/`1`/`true`/`false` | `true` |

Specify different option values than default values to improve readability. The URL

```
https://rsshub.app/threads/zuck/showAuthorInTitle=1&showAuthorInDesc=1&showQuotedAuthorAvatarInDesc=1&showAuthorAvatarInDesc=1&showEmojiForQuotesAndReply=1&showQuotedInTitle=1
```
</RouteEn>

## TikTok

### User
Expand Down
203 changes: 203 additions & 0 deletions lib/v2/threads/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
const got = require('@/utils/got');
const cheerio = require('cheerio');
const { parseDate } = require('@/utils/parse-date');
const dayjs = require('dayjs');

const profileUrl = (user) => `https://www.threads.net/@${user}`;
const threadUrl = (code) => `https://www.threads.net/t/${code}`;
const apiUrl = 'https://www.threads.net/api/graphql';
const THREADS_QUERY = 6232751443445612;
const REPLIES_QUERY = 6307072669391286;
const USER_AGENT = 'Barcelona 289.0.0.77.109 Android';

const load = async (url) => {
// user id fetching needs puppeteer
const browser = await require('@/utils/puppeteer')();
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', (request) => {
request.resourceType() === 'document' ? request.continue() : request.abort();
});
await page.goto(url, {
waitUntil: 'domcontentloaded',
});
const response = await page.content();
browser.close();

return cheerio.load(response);
};

const extractTokens = async (user, ctx) => {
const $ = await load(profileUrl(user));
const lsd = $('script:contains("LSD"):first')
.html()
.match(/"LSD",\[],\{"token":"([a-zA-Z0-9@_-]+)"},/)?.[1];

// This needs puppeteer
const userId = $('script:contains("user_id"):first')
.html()
.match(/"user_id":"(\d+)"/)?.[1];

ctx.state.json = { lsd, userId };
return { lsd, userId };
};

const makeHeader = (user, lsd) => ({
Accept: 'application/json',
Host: 'www.threads.net',
Origin: 'https://www.threads.net',
Referer: `https://www.threads.net/@${user}`,
'User-Agent': USER_AGENT,
'X-FB-LSD': lsd,
'X-IG-App-ID': '238260118697367',
});

const hasMedia = (post) => post.image_versions2 || post.carousel_media || post.video_versions;
const buildMedia = (post) => {
let html = '';

if (!post.carousel_media) {
const mainImage = post.image_versions2?.candidates?.[0];
const mainVideo = post.video_versions?.[0];
if (mainImage) {
if (!mainVideo) {
html += `<img src="${mainImage.url}"/>`;
} else {
html += `<video controls autoplay loop poster="${mainImage.url}">`;
html += `<source src="${mainVideo.url}"/>`;
html += '</video>';
}
}
} else {
post.carousel_media.forEach((media) => {
const firstImage = media.image_versions2?.candidates[0];
const firstVideo = media.video_versions?.[0];
if (!firstVideo) {
html += `<img src="${firstImage.url}"/>`;
} else {
html += `<video controls autoplay loop poster="${firstImage.url}">`;
html += `<source src="${firstVideo.url}"/>`;
html += '</video>';
}
});
}

return html;
};

const buildContent = (item, options) => {
let title = '';
let description = '';
const quotedPost = item.post.text_post_app_info?.share_info?.quoted_post;
const repostedPost = item.post.text_post_app_info?.share_info?.reposted_post;
const isReply = item.post.text_post_app_info?.reply_to_author;
const embededPost = quotedPost ?? repostedPost;

if (options.showAuthorInTitle) {
title += `@${item.post.user?.username}: `;
}

if (options.showAuthorInDesc) {
description += '<p>';
if (options.showAuthorAvatarInDesc) {
description += `<img src="${item.post.user?.profile_pic_url}" width="48px" height="48px"> `;
}
description += `<strong>@${item.post.user?.username}</strong>`;
if (embededPost) {
description += options.showEmojiForQuotesAndReply ? ' 🔁' : ' quoted';
} else if (isReply) {
description += options.showEmojiForQuotesAndReply ? ' ↩️' : ' replied';
}
description += ':</p>';
}

if (item.post.caption?.text) {
title += item.post.caption?.text;
description += `<p>${item.post.caption?.text}</p>`;
}

if (hasMedia(item.post)) {
description += `<p>${buildMedia(item.post)}</p>`;
}

if (embededPost) {
if (options.showQuotedInTitle) {
title += options.showEmojiForQuotesAndReply ? ' 🔁 ' : ' QT: ';
title += `@${embededPost.user?.username}: `;
title += `"${embededPost.caption?.text}"`;
}
description += '<blockquote>';
description += `<p>${embededPost.caption?.text}</p>`;
if (hasMedia(embededPost)) {
description += `<p>${buildMedia(embededPost)}</p>`;
}
description += '— ';
if (options.showQuotedAuthorAvatarInDesc) {
description += `<img src="${embededPost.user?.profile_pic_url}" width="24px" height="24px"> `;
}
description += `@${embededPost.user?.username} — `;
description += `<a href="${threadUrl(embededPost.code)}">${dayjs(embededPost.taken_at, 'X').toString()}</a>`;
description += '</blockquote>';
}
return { title, description };
};

module.exports = async (ctx) => {
const { user, routeParams } = ctx.params;
const { lsd, userId } = await extractTokens(user, ctx);

const params = new URLSearchParams(routeParams);
ctx.state.json.params = routeParams;

const options = {
showAuthorInTitle: params.get('showAuthorInTitle') ?? true,
showAuthorInDesc: params.get('showAuthorInDesc') ?? true,
showAuthorAvatarInDesc: params.get('showAuthorAvatarInDesc') ?? false,
showQuotedInTitle: params.get('showQuotedInTitle') ?? true,
showQuotedAuthorAvatarInDesc: params.get('showQuotedAuthorAvatarInDesc') ?? false,
showEmojiForQuotesAndReply: params.get('showEmojiForQuotesAndReply') ?? true,
replies: params.get('replies') ?? false,
};

const headers = makeHeader(user, lsd);
const resp = await got.post(apiUrl, {
headers,
form: {
lsd,
variables: JSON.stringify({ userID: userId }),
doc_id: options.replies ? REPLIES_QUERY : THREADS_QUERY,
},
});

ctx.state.json.request = {
headers: resp.request.options.headers,
body: resp.request.options.body,
};

const responseBody = resp.data;
const threads = responseBody?.data?.mediaData?.threads || [];

const items = threads.flatMap((thread) =>
thread.thread_items
.filter((item) => user === item.post.user?.username)
.map((item) => {
const { title, description } = buildContent(item, options);
return {
author: user,
title,
description,
pubDate: parseDate(item.post.taken_at, 'X'),
link: threadUrl(item.post.code),
};
})
);

ctx.state.json.items = items;

ctx.state.data = {
title: user,
link: profileUrl(user),
description: user,
item: items,
};
};
3 changes: 3 additions & 0 deletions lib/v2/threads/maintainer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
'/:user/:routeParams?': ['ninboy'],
};
13 changes: 13 additions & 0 deletions lib/v2/threads/radar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module.exports = {
'threads.net': {
_name: 'Threads',
'.': [
{
title: 'User timeline',
docs: 'https://docs.rsshub.app/en/social-media.html#threads',
source: ['/:user'],
target: (params) => `/threads/${params.user.substring(1)}`,
},
],
},
};
3 changes: 3 additions & 0 deletions lib/v2/threads/router.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = (router) => {
router.get('/:user/:routeParams?', require('./index'));
};

0 comments on commit 4e41f3e

Please sign in to comment.