diff --git a/lib/routes/fantube/creator.ts b/lib/routes/fantube/creator.ts
new file mode 100644
index 000000000..9b457de26
--- /dev/null
+++ b/lib/routes/fantube/creator.ts
@@ -0,0 +1,69 @@
+import { Route } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+import { getCreatorFragment, getCreatorPostReelList, baseUrl } from './utils';
+import path from 'node:path';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/r18/creator/:identifier',
+ categories: ['multimedia'],
+ example: '/fantube/r18/creator/miyuu',
+ parameters: { identifier: 'User handle' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.fantube.tokyo/r18/creator/:identifier'],
+ },
+ ],
+ name: 'User Posts',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+const render = ({ description, thumbnailUrl, sampleVideoId, imageUrls }) =>
+ art(path.join(__dirname, 'templates/post.art'), {
+ description,
+ thumbnailUrl,
+ sampleVideoId,
+ imageUrls,
+ });
+
+async function handler(ctx) {
+ const { identifier } = ctx.req.param();
+ const limit = Number.parseInt(ctx.req.query('limit') || 18, 10);
+
+ const creatorInfo = await getCreatorFragment(identifier);
+ const posts = await getCreatorPostReelList(identifier, limit);
+
+ const items = posts.map((p) => ({
+ title: p.title.replaceAll('\n', ' ').trim(),
+ description: render({
+ description: p.description,
+ thumbnailUrl: p.thumbnailUrl,
+ sampleVideoId: p.sampleVideoId,
+ imageUrls: p.contentData?.imageUrls || [],
+ }),
+ link: `${baseUrl}/r18/post/${p.id}?creator=${identifier}`,
+ author: p.creator.displayName,
+ pubDate: parseDate(p.publishStartAt),
+ image: p.thumbnailUrl,
+ }));
+
+ return {
+ title: `${creatorInfo.displayName}のプロフィール|クリエイターページ|FANTUBE(ファンチューブ)`,
+ link: `${baseUrl}/r18/creator/${identifier}`,
+ description: creatorInfo.description,
+ image: creatorInfo.avatarImageUrl,
+ icon: creatorInfo.avatarImageUrl,
+ logo: creatorInfo.avatarImageUrl,
+ language: 'ja',
+ item: items,
+ };
+}
diff --git a/lib/routes/fantube/namespace.ts b/lib/routes/fantube/namespace.ts
new file mode 100644
index 000000000..b214b184e
--- /dev/null
+++ b/lib/routes/fantube/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FANTUBE',
+ url: 'www.fantube.tokyo',
+ lang: 'ja',
+};
diff --git a/lib/routes/fantube/templates/post.art b/lib/routes/fantube/templates/post.art
new file mode 100644
index 000000000..4d1d38589
--- /dev/null
+++ b/lib/routes/fantube/templates/post.art
@@ -0,0 +1,17 @@
+{{ if thumbnailUrl }}
+ 
+{{ /if }}
+
+{{ if imageUrls }}
+ {{ each imageUrls img }}
+ 
+ {{ /each }}
+{{ /if }}
+
+{{ if sampleVideoId }}
+
+{{ /if }}
+
+{{ if description }}
+ {{@ description.replaceAll('\n', '
') }}
+{{ /if }}
diff --git a/lib/routes/fantube/types.ts b/lib/routes/fantube/types.ts
new file mode 100644
index 000000000..9908eab70
--- /dev/null
+++ b/lib/routes/fantube/types.ts
@@ -0,0 +1,100 @@
+interface PlanPost {
+ id: string;
+ thumbnailUrl: string;
+ title: string;
+}
+
+interface Plan {
+ id: string;
+ title: string;
+ price: number;
+ description: string;
+ isArchive: boolean;
+ isRecommended: boolean;
+ deleteRequestAt: null;
+ subscriptionCloseAt: null;
+ capacity: null;
+ isSubscribing: boolean;
+ subscribersCount: number;
+ planPosts: {
+ totalCount: number;
+ nodes: {
+ post: PlanPost;
+ }[];
+ };
+}
+
+interface Followers {
+ totalCount: number;
+}
+
+interface CreatorUnitPurchaseTotalCount {
+ totalCount: number;
+}
+
+interface CreatorPostsTotalCount {
+ totalCount: number;
+}
+
+interface AllPosts {
+ totalCount: number;
+}
+
+export interface CreatorFragment {
+ displayName: string;
+ id: string;
+ messageReceive: boolean;
+ coverImageUrl: string;
+ avatarImageUrl: string;
+ identifier: string;
+ description: string;
+ snsLinks: string[];
+ isSelf: boolean;
+ following: boolean;
+ followers: Followers;
+ creatorUnitPurchaseTotalCount: CreatorUnitPurchaseTotalCount;
+ creatorPostsTotalCount: CreatorPostsTotalCount;
+ allPosts: AllPosts;
+ plans: {
+ totalCount: number;
+ nodes: Plan[];
+ };
+}
+
+interface Comments {
+ totalCount: number;
+}
+
+export interface PostReelNode {
+ id: string;
+ title: string;
+ type: 'VIDEO' | 'IMAGE';
+ price: number;
+ sampleVideoId: string | null;
+ thumbnailUrl: string;
+ description: string;
+ publishStartAt: string;
+ pinnedAt: string | null;
+ isBuyEnabled: boolean;
+ isFavorite: boolean;
+ isMine: boolean;
+ canComment: boolean;
+ creator: CreatorFragment;
+ comments: Comments;
+ planPosts: {
+ nodes: {
+ plan: Plan;
+ }[];
+ };
+ favoritesCount: number;
+ contentData: {
+ __typename: 'PostVideoType' | 'PostImageType';
+ videoUrl: string;
+ isSample: boolean;
+ noSample: boolean;
+ durationSeconds: number;
+ encrypted: boolean;
+ imageUrls: string[];
+ count: number;
+ };
+}
diff --git a/lib/routes/fantube/utils.ts b/lib/routes/fantube/utils.ts
new file mode 100644
index 000000000..5223609c2
--- /dev/null
+++ b/lib/routes/fantube/utils.ts
@@ -0,0 +1,268 @@
+import ofetch from '@/utils/ofetch';
+import { load } from 'cheerio';
+import cache from '@/utils/cache';
+import { CreatorFragment, PostReelNode } from './types';
+
+export const baseUrl = 'https://www.fantube.tokyo';
+
+export const getCreatorFragment = (username: string) =>
+ cache.tryGet(`fantube:creator:${username}`, async () => {
+ const response = await ofetch(`${baseUrl}/r18/creator/${username}`, {
+ headers: {
+ cookie: 'fantube-ageVerified=1;',
+ },
+ });
+ const $ = load(response);
+
+ const selfPushString = JSON.parse(
+ $('script:contains("creatorFragment")')
+ .text()
+ .match(/^self\.__next_f\.push\((.+?)\)$/)?.[1] || '{}'
+ );
+ const selfPushData = JSON.parse(selfPushString[1].slice(2));
+ // const creatorFragment = selfPushData[3].children.find((c) => c[1] === 'div')[3].children[3].creatorFragment;
+ const creatorFragment = selfPushData
+ .find((d) => d?.hasOwnProperty('children'))
+ .children.find((child) => Object.values(child).includes('div'))
+ .find((c) => c?.hasOwnProperty('children'))
+ .children.find((c) => c?.hasOwnProperty('creatorFragment')).creatorFragment;
+
+ return creatorFragment as CreatorFragment;
+ });
+
+export const getCreatorPostReelList = (identifier: string, limit: number): Promise =>
+ cache.tryGet(`fantube:creatorPostReelList:${identifier}:${limit}`, async () => {
+ const response = await ofetch('https://api.prd.fantube.tokyo/graphql', {
+ headers: {
+ Referer: baseUrl,
+ },
+ body: JSON.stringify({
+ query: `query CreatorPostReelList($identifier: String!, $first: Int, $after: String, $last: Int, $before: String) {
+ posts(
+ where: {status: {equals: PUBLISHED}, creator: {is: {identifier: {equals: $identifier}}}}
+ orderBy: [{pinnedAt: {nulls: last, sort: desc}}, {order: asc}, {createdAt: desc}, {id: desc}]
+ first: $first
+ after: $after
+ last: $last
+ before: $before
+ ) {
+ nodes {
+ ...PostSwiper_Post
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ hasPreviousPage
+ startCursor
+ }
+ }
+}
+
+fragment PostSwiper_Post on Post {
+ id
+ title
+ isFavorite
+ favoritesCount
+ ...PostSwiperSlide_Post
+}
+
+fragment PostSwiperSlide_Post on Post {
+ id
+ type
+ title
+ price
+ creator {
+ displayName
+ }
+ ...PostVideoElement_Post
+ ...PostImageElement_Post
+}
+
+fragment PostVideoElement_Post on Post {
+ id
+ title
+ contentData {
+ ... on PostVideoType {
+ __typename
+ videoUrl
+ isSample
+ noSample
+ durationSeconds
+ }
+ }
+ isFavorite
+ sampleVideoId
+ thumbnailUrl
+ creator {
+ displayName
+ }
+ ...PostInfo_Post
+ ...VideoControlIcons_Post
+ ...PurchaseWrapper_Post
+}
+
+fragment PostInfo_Post on Post {
+ title
+ description
+ publishStartAt
+ price
+ isBuyEnabled
+ ...Profile_Post
+}
+
+fragment Profile_Post on Post {
+ id
+ creator {
+ id
+ isSelf
+ identifier
+ displayName
+ avatarImageUrl
+ following
+ }
+}
+
+fragment VideoControlIcons_Post on Post {
+ id
+ isMine
+ pinnedAt
+ favoritesCount
+ ...PostComment_Post
+}
+
+fragment PostComment_Post on Post {
+ id
+ isMine
+ canComment
+ comments(
+ where: {OR: [{parentPostComment: {is: {isDeleted: {equals: false}}}}, {parentPostCommentId: {equals: null}}], isDeleted: {equals: false}}
+ ) {
+ totalCount
+ }
+ ...PostCommentReplyDrawer_Post
+}
+
+fragment PostCommentReplyDrawer_Post on Post {
+ id
+ isMine
+ canComment
+}
+
+fragment PurchaseWrapper_Post on Post {
+ id
+ title
+ price
+ creator {
+ displayName
+ }
+ ...PostPurchaseDialog_Post
+ ...PostPurchaseSingleDialog_Post
+}
+
+fragment PostPurchaseDialog_Post on Post {
+ id
+ isBuyEnabled
+ price
+ thumbnailUrl
+ title
+ planPosts(
+ orderBy: [{plan: {deleteRequestAt: {sort: desc, nulls: first}}}, {plan: {isRecommended: desc}}, {plan: {price: asc}}]
+ ) {
+ nodes {
+ plan {
+ id
+ title
+ price
+ ...PlanSwiper_Plan
+ }
+ }
+ }
+ creator {
+ displayName
+ }
+ ...PostPurchaseSingleDialog_Post
+}
+
+fragment PlanSwiper_Plan on Plan {
+ id
+ ...PlanSwiperItem_Plan
+}
+
+fragment PlanSwiperItem_Plan on Plan {
+ id
+ title
+ price
+ isArchive
+ isRecommended
+ deleteRequestAt
+ isSubscribing
+ subscriptionCloseAt
+ capacity
+ subscribersCount
+ planPosts(
+ where: {post: {is: {status: {equals: PUBLISHED}}}}
+ first: 7
+ orderBy: [{createdAt: desc}]
+ ) {
+ nodes {
+ post {
+ id
+ thumbnailUrl
+ title
+ }
+ }
+ totalCount
+ }
+ ...PlanUnavailableNote_Plan
+}
+
+fragment PlanUnavailableNote_Plan on Plan {
+ capacity
+ subscribersCount
+ subscriptionCloseAt
+ deleteRequestAt
+}
+
+fragment PostPurchaseSingleDialog_Post on Post {
+ id
+ price
+ thumbnailUrl
+ title
+ isBuyEnabled
+}
+
+fragment PostImageElement_Post on Post {
+ id
+ title
+ contentData {
+ __typename
+ ... on PostImageType {
+ encrypted
+ imageUrls
+ count
+ }
+ }
+ isFavorite
+ creator {
+ displayName
+ }
+ ...PostInfo_Post
+ ...ImageControlIcons_Post
+ ...PurchaseWrapper_Post
+}
+
+fragment ImageControlIcons_Post on Post {
+ id
+ isMine
+ pinnedAt
+ favoritesCount
+ ...PostComment_Post
+}`,
+ variables: { identifier, first: limit, after: '' },
+ operationName: 'CreatorPostReelList',
+ }),
+ method: 'POST',
+ });
+
+ return response.data.posts.nodes as PostReelNode[];
+ });