diff --git a/lib/v2/fansly/maintainer.js b/lib/v2/fansly/maintainer.js
new file mode 100644
index 0000000000..8b8a59de6f
--- /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 0000000000..8b4937c5c6
--- /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 0000000000..b819fecef2
--- /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 0000000000..8760fdf7bc
--- /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 0000000000..779a92f41d
--- /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 0000000000..46bfcd0bf9
--- /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 0000000000..26c2e99d9b
--- /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 0000000000..ac932742b1
--- /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 0000000000..84e2fe52cc
--- /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 f9f45c3c20..3428ec166f 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}