From c13ae21755735d55665ee48e5dd127e5cdd44e30 Mon Sep 17 00:00:00 2001 From: Tony Date: Thu, 15 Feb 2024 02:51:21 +0800 Subject: [PATCH] feat(route): fansly (#14458) --- lib/v2/fansly/maintainer.js | 4 + lib/v2/fansly/post.js | 29 +++++ lib/v2/fansly/radar.js | 19 +++ lib/v2/fansly/router.js | 4 + lib/v2/fansly/tag.js | 30 +++++ lib/v2/fansly/templates/media.art | 8 ++ lib/v2/fansly/templates/poll.art | 4 + lib/v2/fansly/templates/tip-goal.art | 2 + lib/v2/fansly/utils.js | 176 +++++++++++++++++++++++++++ website/docs/routes/social-media.mdx | 10 ++ 10 files changed, 286 insertions(+) create mode 100644 lib/v2/fansly/maintainer.js create mode 100644 lib/v2/fansly/post.js create mode 100644 lib/v2/fansly/radar.js create mode 100644 lib/v2/fansly/router.js create mode 100644 lib/v2/fansly/tag.js create mode 100644 lib/v2/fansly/templates/media.art create mode 100644 lib/v2/fansly/templates/poll.art create mode 100644 lib/v2/fansly/templates/tip-goal.art create mode 100644 lib/v2/fansly/utils.js diff --git a/lib/v2/fansly/maintainer.js b/lib/v2/fansly/maintainer.js new file mode 100644 index 000000000..8b8a59de6 --- /dev/null +++ b/lib/v2/fansly/maintainer.js @@ -0,0 +1,4 @@ +module.exports = { + '/tag/:tag': ['TonyRL'], + '/user/:username': ['TonyRL'], +}; diff --git a/lib/v2/fansly/post.js b/lib/v2/fansly/post.js new file mode 100644 index 000000000..8b4937c5c --- /dev/null +++ b/lib/v2/fansly/post.js @@ -0,0 +1,29 @@ +const { parseDate } = require('@/utils/parse-date'); +const { getAccountByUsername, getTimelineByAccountId, parseDescription, baseUrl } = require('./utils'); + +module.exports = async (ctx) => { + const { username } = ctx.params; + + const account = await getAccountByUsername(username, ctx.cache.tryGet); + const timeline = await getTimelineByAccountId(account.id); + + const items = timeline.posts.map((post) => ({ + title: post.content.split('\n')[0], + description: parseDescription(post, timeline), + pubDate: parseDate(post.createdAt, 'X'), + link: `${baseUrl}/post/${post.id}`, + author: `${account.displayName ?? account.username} (@${account.username})`, + })); + + ctx.state.data = { + title: `${account.displayName ?? account.username} (@${account.username}) - Fansly`, + link: `${baseUrl}/${account.username}`, + description: account.about.replaceAll('\n', ' '), + image: account.banner.locations[0].location, + icon: account.avatar.locations[0].location, + logo: account.avatar.locations[0].location, + language: 'en', + allowEmpty: true, + item: items, + }; +}; diff --git a/lib/v2/fansly/radar.js b/lib/v2/fansly/radar.js new file mode 100644 index 000000000..b819fecef --- /dev/null +++ b/lib/v2/fansly/radar.js @@ -0,0 +1,19 @@ +module.exports = { + 'fansly.com': { + _name: 'Fansly', + '.': [ + { + title: 'User Timeline', + docs: 'https://docs.rsshub.app/routes/social-media.html#fansly', + source: ['/:username/posts', '/:username/media'], + target: '/fansly/user/:username', + }, + { + title: 'Hashtag', + docs: 'https://docs.rsshub.app/routes/social-media.html#fansly', + source: ['/explore/tag/:tag'], + target: '/fansly/tag/:tag', + }, + ], + }, +}; diff --git a/lib/v2/fansly/router.js b/lib/v2/fansly/router.js new file mode 100644 index 000000000..8760fdf7b --- /dev/null +++ b/lib/v2/fansly/router.js @@ -0,0 +1,4 @@ +module.exports = (router) => { + router.get('/tag/:tag', require('./tag')); + router.get('/user/:username', require('./post')); +}; diff --git a/lib/v2/fansly/tag.js b/lib/v2/fansly/tag.js new file mode 100644 index 000000000..779a92f41 --- /dev/null +++ b/lib/v2/fansly/tag.js @@ -0,0 +1,30 @@ +const { parseDate } = require('@/utils/parse-date'); +const { getTagId, getTagSuggestion, findAccountById, parseDescription, baseUrl, icon } = require('./utils'); + +module.exports = async (ctx) => { + const { tag } = ctx.params; + + const tagId = await getTagId(tag, ctx.cache.tryGet); + const suggestion = await getTagSuggestion(tagId); + + const items = suggestion.aggregationData?.posts.map((post) => { + const account = findAccountById(post.accountId, suggestion.aggregationData.accounts); + return { + title: post.content.split('\n')[0], + description: parseDescription(post, suggestion.aggregationData), + pubDate: parseDate(post.createdAt, 'X'), + link: `${baseUrl}/post/${post.id}`, + author: `${account.displayName ?? account.username} (@${account.username})`, + }; + }); + + ctx.state.data = { + title: `#${tag} - Fansly`, + link: `${baseUrl}/explore/tag/${tag}`, + image: icon, + icon, + logo: icon, + language: 'en', + item: items, + }; +}; diff --git a/lib/v2/fansly/templates/media.art b/lib/v2/fansly/templates/media.art new file mode 100644 index 000000000..46bfcd0bf --- /dev/null +++ b/lib/v2/fansly/templates/media.art @@ -0,0 +1,8 @@ +{{ if poster && src }} + +{{ else if src }} + +{{ /if }} +
diff --git a/lib/v2/fansly/templates/poll.art b/lib/v2/fansly/templates/poll.art new file mode 100644 index 000000000..26c2e99d9 --- /dev/null +++ b/lib/v2/fansly/templates/poll.art @@ -0,0 +1,4 @@ +{{ title }}
+{{ each options option }} + {{ option.voteCount }}/{{ version }} {{ option.title }}
+{{ /each }} diff --git a/lib/v2/fansly/templates/tip-goal.art b/lib/v2/fansly/templates/tip-goal.art new file mode 100644 index 000000000..ac932742b --- /dev/null +++ b/lib/v2/fansly/templates/tip-goal.art @@ -0,0 +1,2 @@ +{{ label }}
+{{ currentPercentage }}% ${{ currentAmount / 1000 }} / ${{ goalAmount / 1000 }} diff --git a/lib/v2/fansly/utils.js b/lib/v2/fansly/utils.js new file mode 100644 index 000000000..84e2fe52c --- /dev/null +++ b/lib/v2/fansly/utils.js @@ -0,0 +1,176 @@ +const got = require('@/utils/got'); +const path = require('path'); +const { art } = require('@/utils/render'); + +const apiBaseUrl = 'https://apiv3.fansly.com'; +const baseUrl = 'https://fansly.com'; +const icon = `${baseUrl}/assets/images/icons/apple-touch-icon.png`; + +const findAccountById = (accountId, accounts) => { + const account = accounts.find((account) => account.id === accountId); + return { + displayName: account.displayName, + username: account.username, + }; +}; + +const getAccountByUsername = (username, tryGet) => + tryGet(`fansly:account:${username.toLowerCase()}`, async () => { + const { data: accountResponse } = await got(`${apiBaseUrl}/api/v1/account`, { + searchParams: { + usernames: username, + 'ngsw-bypass': true, + }, + }); + + if (!accountResponse.response.length) { + throw new Error('This profile or page does not exist.'); + } + + return accountResponse.response[0]; + }); + +const getTimelineByAccountId = async (accountId) => { + const { data: timeline } = await got(`${apiBaseUrl}/api/v1/timelinenew/${accountId}`, { + searchParams: { + before: 0, + after: 0, + wallId: '', + contentSearch: '', + 'ngsw-bypass': true, + }, + }); + + return timeline.response; +}; + +const getTagId = (tag, tryGet) => + tryGet(`fansly:tag:${tag.toLowerCase()}`, async () => { + const { data: tagResponse } = await got(`${apiBaseUrl}/api/v1/contentdiscovery/media/tag`, { + searchParams: { + tag, + 'ngsw-bypass': true, + }, + }); + + if (!tagResponse.response.mediaOfferSuggestionTag) { + throw new Error("Couldn't find this hashtag."); + } + + return tagResponse.response.mediaOfferSuggestionTag.id; + }); + +const getTagSuggestion = async (tagId) => { + const { data: suggestionResponse } = await got(`${apiBaseUrl}/api/v1/contentdiscovery/media/suggestionsnew`, { + searchParams: { + before: 0, + after: 0, + tagIds: tagId, + limit: 25, + offset: 0, + 'ngsw-bypass': true, + }, + }); + + return suggestionResponse.response; +}; + +const parseDescription = (post, aggregationData) => post.content.replaceAll('\n', '
') + '
' + parseAttachments(post.attachments, aggregationData); + +const parseAttachments = (attachments, aggregationData) => + attachments + .map((attachment) => { + switch (attachment.contentType) { + case 1: + // single media + return parseMedia(attachment.contentId, aggregationData.accountMedia); + case 2: { + // media bundle + let attachments = ''; + const bundle = aggregationData.accountMediaBundles.find((bundle) => bundle.id === attachment.contentId); + for (const mediaId of bundle.accountMediaIds) { + attachments += parseMedia(mediaId, aggregationData.accountMedia); + } + return attachments; + } + case 8: { + // aggregated post (repost) + let attachments = '

'; + const repost = aggregationData.aggregatedPosts.find((post) => post.id === attachment.contentId) || aggregationData.posts.find((post) => post.id === attachment.contentId); + attachments += parseDescription(repost, aggregationData); + + return attachments; + } + + case 7100: + return renderTipGoal(attachment.contentId, aggregationData.tipGoals); + case 32001: + // unknown + return ''; + case 42001: + return renderPoll(attachment.contentId, aggregationData.polls); + + default: + throw new Error(`Unhandled attachment type: ${attachment.contentType} for post ${attachment.postId}`); + } + }) + .join(''); + +const parseMedia = (contentId, accountMedia) => { + const media = accountMedia.find((media) => media.id === contentId); + if (!media) { + return ''; + } + return renderMedia(media.preview ?? media.media); +}; + +const renderMedia = (media) => { + switch (media.mimetype) { + case 'image/gif': + case 'image/jpeg': + case 'image/png': + case 'video/mp4': + case 'audio/mp4': + return art(path.join(__dirname, 'templates/media.art'), { + poster: media.mimetype === 'video/mp4' ? media.variants[0].locations[0] : null, + src: media.locations[0], + }); + default: + throw new Error(`Unhandled media type: ${media.mimetype}`); + } +}; + +const renderPoll = (pollId, polls) => { + const poll = polls.find((poll) => poll.id === pollId); + return art(path.join(__dirname, 'templates/poll.art'), { + title: poll.question, + options: poll.options, + version: poll.version, + }); +}; +const renderTipGoal = (tipGoalId, tipGoals) => { + const goal = tipGoals.find((goal) => goal.id === tipGoalId); + return art(path.join(__dirname, 'templates/tip-goal.art'), { + label: goal.label, + description: goal.description, + currentAmount: goal.currentAmount, + goalAmount: goal.goalAmount, + currentPercentage: goal.currentPercentage, + }); +}; + +module.exports = { + findAccountById, + baseUrl, + icon, + getAccountByUsername, + getTimelineByAccountId, + getTagId, + getTagSuggestion, + parseAttachments, + parseDescription, + parseMedia, + renderMedia, + renderPoll, + renderTipGoal, +}; diff --git a/website/docs/routes/social-media.mdx b/website/docs/routes/social-media.mdx index f9f45c3c2..3428ec166 100644 --- a/website/docs/routes/social-media.mdx +++ b/website/docs/routes/social-media.mdx @@ -413,6 +413,16 @@ +## Fansly {#fansly} + +### User Timeline {#fansly-user-timeline} + + + +### Hashtag {#fansly-hashtag} + + + ## Fur Affinity {#fur-affinity} ### Home {#fur-affinity-home}