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:
parent
f7fd8074f4
commit
ebb62b53d9
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { Namespace } from '@/types';
|
||||
|
||||
export const namespace: Namespace = {
|
||||
name: 'FANTUBE',
|
||||
url: 'www.fantube.tokyo',
|
||||
lang: 'ja',
|
||||
};
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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[];
|
||||
});
|
||||
Loading…
Reference in New Issue