feat(route/fantube): add user posts (#19529)

* feat(route/fantube): add user posts

* Update utils.ts

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------
This commit is contained in:
Tony 2025-07-03 07:05:56 +08:00 committed by GitHub
parent f7fd8074f4
commit ebb62b53d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 461 additions and 0 deletions

View File

@ -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,
};
}

View File

@ -0,0 +1,7 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: 'FANTUBE',
url: 'www.fantube.tokyo',
lang: 'ja',
};

View File

@ -0,0 +1,17 @@
{{ if thumbnailUrl }}
<img src="{{ thumbnailUrl }}"><br>
{{ /if }}
{{ if imageUrls }}
{{ each imageUrls img }}
<img src="{{ img }}"><br>
{{ /each }}
{{ /if }}
{{ if sampleVideoId }}
<div style="position: relative; padding-top: 56.25%"><iframe src="https://customer-7d4xajfg7g3ps2lm.cloudflarestream.com/{{ sampleVideoId }}/iframe" style="border: none; position: absolute; top: 0; height: 100%; width: 100%" allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;" allowfullscreen="true"></iframe></div><br>
{{ /if }}
{{ if description }}
{{@ description.replaceAll('\n', '<br>') }}
{{ /if }}

100
lib/routes/fantube/types.ts Normal file
View File

@ -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;
};
}

268
lib/routes/fantube/utils.ts Normal file
View File

@ -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<PostReelNode[]> =>
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[];
});