From ebb62b53d920c7213c6309f8b8ab0fb68928d44b Mon Sep 17 00:00:00 2001 From: Tony Date: Thu, 3 Jul 2025 07:05:56 +0800 Subject: [PATCH] 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> --------- --- lib/routes/fantube/creator.ts | 69 +++++++ lib/routes/fantube/namespace.ts | 7 + lib/routes/fantube/templates/post.art | 17 ++ lib/routes/fantube/types.ts | 100 ++++++++++ lib/routes/fantube/utils.ts | 268 ++++++++++++++++++++++++++ 5 files changed, 461 insertions(+) create mode 100644 lib/routes/fantube/creator.ts create mode 100644 lib/routes/fantube/namespace.ts create mode 100644 lib/routes/fantube/templates/post.art create mode 100644 lib/routes/fantube/types.ts create mode 100644 lib/routes/fantube/utils.ts 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[]; + });