From b3f0276fc0394063ae1caeae6ac89fc23fd5f821 Mon Sep 17 00:00:00 2001 From: karasu Date: Wed, 1 May 2024 11:15:47 +0800 Subject: [PATCH] feat(route): fanbox (#15418) * feat(route): fanbox * fix * Update lib/routes/fanbox/index.ts Co-authored-by: Tony * replace `got` with `ofetch` * Update lib/routes/fanbox/utils.ts Co-authored-by: Tony --------- --- lib/router.js | 3 - lib/routes-deprecated/fanbox/conv.js | 227 ------------------- lib/routes-deprecated/fanbox/header.js | 13 -- lib/routes-deprecated/fanbox/main.js | 45 ---- lib/routes/fanbox/index.ts | 62 +++++ lib/routes/fanbox/namespace.ts | 6 + lib/routes/fanbox/templates/fanbox-post.art | 7 + lib/routes/fanbox/types.ts | 239 ++++++++++++++++++++ lib/routes/fanbox/utils.ts | 188 +++++++++++++++ 9 files changed, 502 insertions(+), 288 deletions(-) delete mode 100644 lib/routes-deprecated/fanbox/conv.js delete mode 100644 lib/routes-deprecated/fanbox/header.js delete mode 100644 lib/routes-deprecated/fanbox/main.js create mode 100644 lib/routes/fanbox/index.ts create mode 100644 lib/routes/fanbox/namespace.ts create mode 100644 lib/routes/fanbox/templates/fanbox-post.art create mode 100644 lib/routes/fanbox/types.ts create mode 100644 lib/routes/fanbox/utils.ts diff --git a/lib/router.js b/lib/router.js index ff1fd44059a7a0..36c0aa9fbe5954 100644 --- a/lib/router.js +++ b/lib/router.js @@ -25,9 +25,6 @@ router.get('/benedictevans', lazyloadRouteHandler('./routes/benedictevans/recent // router.get('/jianshu/collection/:id', lazyloadRouteHandler('./routes/jianshu/collection')); // router.get('/jianshu/user/:id', lazyloadRouteHandler('./routes/jianshu/user')); -// pixiv-fanbox -router.get('/fanbox/:user?', lazyloadRouteHandler('./routes/fanbox/main')); - // Disqus router.get('/disqus/posts/:forum', lazyloadRouteHandler('./routes/disqus/posts')); diff --git a/lib/routes-deprecated/fanbox/conv.js b/lib/routes-deprecated/fanbox/conv.js deleted file mode 100644 index 40dfff634b74f6..00000000000000 --- a/lib/routes-deprecated/fanbox/conv.js +++ /dev/null @@ -1,227 +0,0 @@ -const got = require('@/utils/got'); - -const get_header = require('./header'); - -async function get_twitter(t) { - try { - const resp = await got(`https://publish.twitter.com/oembed?url=${t}`); - return resp.data.html; - } catch { - return `
This tweet may not exist
`; - } -} - -async function get_fanbox(p) { - try { - const m = p.match(/creator\/(\d+)\/post\/(\d+)/); - const post_id = m[2]; - const api_url = `https://api.fanbox.cc/post.info?postId=${post_id}`; - const resp = await got(api_url, { headers: get_header() }); - const post = resp.data.body; - - const home_url = `https://${post.creatorId}.fanbox.cc`; - const web_url = `${home_url}/posts/${post.id}`; - const datetime = new Date(post.updatedDatetime).toLocaleString('ja'); - - const box_html = ` -
- -
- ${post.title} -
-
- ${post.user.name} - Modify: ${datetime} - ${post.feeRequired} JPY -
- `; - return { url: web_url, html: box_html }; - } catch { - return { url: null, html: `
fanbox post (${p}) may not exist
` }; - } -} - -// embedded items -async function embed_map(e) { - const id = e.contentId || e.videoId; - const sp = e.serviceProvider; - - let ret = `Unknown host: ${sp}, with ID: ${id}`; - let url = null; - - try { - switch (sp) { - case 'youtube': - url = `https://www.youtube.com/embed/${id}`; - ret = ``; - break; - case 'vimeo': - url = `https://player.vimeo.com/video/${id}`; - ret = ``; - break; - case 'soundcloud': - url = `https://soundcloud.com/${id}`; - ret = ``; - break; - case 'twitter': - url = `https://twitter.com/i/status/${id}`; - ret = await get_twitter(url); - break; - case 'google_forms': - url = `https://docs.google.com/forms/d/e/${id}/viewform?embedded=true`; - ret = ``; - break; - case 'fanbox': { - const info = await get_fanbox(id); - url = info.url; - ret = info.html; - break; - } - case 'gist': - url = `https://gist.github.com/${id}`; - ret = ``; - break; - } - if (url) { - ret += `
Click here if embedded content is not loaded.`; - } - } catch (error) { - error; - } - - return ret; -} - -// render

blocks -function passage_conv(p) { - const seg = [...p.text]; - // seg.push(''); - if (p.styles) { - p.styles.map((s) => { - switch (s.type) { - case 'bold': - seg[s.offset] = `` + seg[s.offset]; - seg[s.offset + s.length - 1] += ``; - break; - } - return s; - }); - } - if (p.links) { - p.links.map((l) => { - seg[l.offset] = `` + seg[l.offset]; - seg[l.offset + l.length - 1] += ``; - return l; - }); - } - const ret = seg.join(''); - // console.log(ret) - return ret; -} - -// article types -function text_t(body) { - return body.text || ''; -} - -function image_t(body) { - let ret = body.text || ''; - body.images.map((i) => (ret += `


`)); - return ret; -} - -function file_t(body) { - let ret = body.text || ''; - body.files.map((f) => (ret += `
${f.name}.${f.extension}`)); - return ret; -} - -async function video_t(body) { - let ret = body.text || ''; - ret += (await embed_map(body.video)) || ''; - return ret; -} - -async function blog_t(body) { - let ret = []; - for (let x = 0; x < body.blocks.length; ++x) { - const b = body.blocks[x]; - ret.push('

'); - - switch (b.type) { - case 'p': - ret.push(passage_conv(b)); - break; - case 'header': - ret.push(`

${b.text}

`); - break; - case 'image': { - const i = body.imageMap[b.imageId]; - ret.push(``); - break; - } - case 'file': { - const f = body.fileMap[b.fileId]; - ret.push(`${f.name}.${f.extension}`); - break; - } - case 'embed': - ret.push(embed_map(body.embedMap[b.embedId])); // Promise object - break; - } - } - ret = await Promise.all(ret); // get real data - return ret.join(''); -} - -// parse by type -async function conv_article(i) { - let ret = ''; - if (i.title) { - ret += `[${i.type}] ${i.title}
`; - } - if (i.feeRequired !== 0) { - ret += `Fee Required: ${i.feeRequired} JPY/month
`; - } - if (i.coverImageUrl) { - ret += `
`; - } - - if (!i.body) { - ret += i.excerpt; - return ret; - } - - // console.log(i); - // skip paywall - - switch (i.type) { - case 'text': - ret += text_t(i.body); - break; - case 'file': - ret += file_t(i.body); - break; - case 'image': - ret += image_t(i.body); - break; - case 'video': - ret += await video_t(i.body); - break; - case 'article': - ret += await blog_t(i.body); - break; - default: - ret += 'Unsupported content (RSSHub)'; - } - return ret; -} - -// render wrapper -module.exports = async (i) => ({ - title: i.title || `No title`, - description: await conv_article(i), - pubDate: new Date(i.publishedDatetime).toUTCString(), - link: `https://${i.creatorId}.fanbox.cc/posts/${i.id}`, - category: i.tags, -}); diff --git a/lib/routes-deprecated/fanbox/header.js b/lib/routes-deprecated/fanbox/header.js deleted file mode 100644 index 55eb6b99bb0e8f..00000000000000 --- a/lib/routes-deprecated/fanbox/header.js +++ /dev/null @@ -1,13 +0,0 @@ -const config = require('@/config').value; - -// unlock contents paid by user -module.exports = () => { - const sessid = config.fanbox.session; - let cookie = ''; - if (sessid) { - cookie += `FANBOXSESSID=${sessid}`; - } - const headers = { origin: 'https://fanbox.cc', cookie }; - - return headers; -}; diff --git a/lib/routes-deprecated/fanbox/main.js b/lib/routes-deprecated/fanbox/main.js deleted file mode 100644 index dd4219e8be188d..00000000000000 --- a/lib/routes-deprecated/fanbox/main.js +++ /dev/null @@ -1,45 +0,0 @@ -// pixiv fanbox, maybe blocked by upstream - -// params: -// user?: fanbox domain name - -const got = require('@/utils/got'); -const { isValidHost } = require('@/utils/valid-host'); -const conv_item = require('./conv'); -const get_header = require('./header'); - -module.exports = async (ctx) => { - const user = ctx.params.user || 'official'; // if no user specified, just go to official page - if (!isValidHost(user)) { - throw new Error('Invalid user'); - } - const box_url = `https://${user}.fanbox.cc`; - - // get user info - let title = `${user}'s fanbox`; - let descr = title; - - try { - const user_api = `https://api.fanbox.cc/creator.get?creatorId=${user}`; - const resp_u = await got(user_api, { headers: get_header() }); - title = `${resp_u.data.body.user.name}'s fanbox`; - descr = resp_u.data.description; - } catch (error) { - error; - } - - // get user posts - const posts_api = `https://api.fanbox.cc/post.listCreator?creatorId=${user}&limit=20`; - const response = await got(posts_api, { headers: get_header() }); - - // render posts - const items = await Promise.all(response.data.body.items.map((i) => conv_item(i))); - - // return rss feed - ctx.state.data = { - title, - link: box_url, - description: descr, - item: items, - }; -}; diff --git a/lib/routes/fanbox/index.ts b/lib/routes/fanbox/index.ts new file mode 100644 index 00000000000000..b8e84e31901a92 --- /dev/null +++ b/lib/routes/fanbox/index.ts @@ -0,0 +1,62 @@ +import InvalidParameterError from '@/errors/types/invalid-parameter'; +import type { Data, Route } from '@/types'; +import { isValidHost } from '@/utils/valid-host'; +import type { Context } from 'hono'; +import { getHeaders, parseItem } from './utils'; +import type { PostListResponse, UserInfoResponse } from './types'; +import ofetch from '@/utils/ofetch'; + +export const route: Route = { + path: '/:creator', + categories: ['social-media'], + example: '/fanbox/official', + parameters: { creator: 'fanbox user name' }, + maintainers: ['KarasuShin'], + name: 'Creator', + handler, + features: { + requireConfig: [ + { + name: 'FANBOX_SESSION_ID', + description: 'Required for private posts. Can be found in browser DevTools -> Application -> Cookies -> https://www.fanbox.cc -> FANBOXSESSID', + optional: true, + }, + ], + }, +}; + +async function handler(ctx: Context): Promise { + const creator = ctx.req.param('creator'); + if (!isValidHost(creator)) { + throw new InvalidParameterError('Invalid user name'); + } + + let title = `Fanbox - ${creator}`; + + let description: string | undefined; + + let image: string | undefined; + + try { + const userApi = `https://api.fanbox.cc/creator.get?creatorId=${creator}`; + const userInfoResponse = (await ofetch(userApi, { + headers: getHeaders(), + })) as UserInfoResponse; + title = `Fanbox - ${userInfoResponse.body.user.name}`; + description = userInfoResponse.body.description; + image = userInfoResponse.body.user.iconUrl; + } catch { + // ignore + } + + const postListResponse = (await ofetch(`https://api.fanbox.cc/post.listCreator?creatorId=${creator}&limit=20`, { headers: getHeaders() })) as PostListResponse; + const items = await Promise.all(postListResponse.body.items.map((i) => parseItem(i))); + + return { + title, + link: `https://${creator}.fanbox.cc`, + description, + image, + item: items, + }; +} diff --git a/lib/routes/fanbox/namespace.ts b/lib/routes/fanbox/namespace.ts new file mode 100644 index 00000000000000..0244a7afd5fd78 --- /dev/null +++ b/lib/routes/fanbox/namespace.ts @@ -0,0 +1,6 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'fanbox', + url: 'https://www.fanbox.cc', +}; diff --git a/lib/routes/fanbox/templates/fanbox-post.art b/lib/routes/fanbox/templates/fanbox-post.art new file mode 100644 index 00000000000000..ebf43dd4f1aaa0 --- /dev/null +++ b/lib/routes/fanbox/templates/fanbox-post.art @@ -0,0 +1,7 @@ + +

{{title}}

+ {{user.name}} +
+
+ {{excerpt}} +
diff --git a/lib/routes/fanbox/types.ts b/lib/routes/fanbox/types.ts new file mode 100644 index 00000000000000..a3198e9a6276cc --- /dev/null +++ b/lib/routes/fanbox/types.ts @@ -0,0 +1,239 @@ +export interface UserInfoResponse { + body: { + user: { + userId: string; + name: string; + iconUrl: string; + }; + creatorId: string; + description: string; + hasAdultContent: boolean; + coverImageUrl: string; + profileLinks: string[]; + profileItems: { + id: string; + type: string; + serviceProvider: string; + videoId: string; + }[]; + isFollowed: boolean; + isSupported: boolean; + isStopped: boolean; + isAcceptingRequest: boolean; + hasBoothShop: boolean; + }; +} + +export interface PostListResponse { + body: { + items: PostItem[]; + nextUrl: string | null; + }; +} + +export interface PostDetailResponse { + body: PostDetail; +} + +export interface PostItem { + commentCount: number; + cover: { + type: string; + url: string; + }; + creatorId: string; + excerpt: string; + feeRequired: number; + hasAdultContent: boolean; + id: string; + isLiked: boolean; + isRestricted: boolean; + likeCount: number; + publishedDatetime: string; + tags: string[]; + title: string; + updatedDatetime: string; + user: { + iconUrl: string; + name: string; + userId: string; + }; +} + +interface BasicPost { + commentCount: number; + commentList: { + items: { + body: string; + createdDatetime: string; + id: string; + isLiked: boolean; + isOwn: boolean; + likeCount: number; + parentCommentId: string; + replies: { + body: string; + createdDatetime: string; + id: string; + isLiked: boolean; + isOwn: boolean; + likeCount: number; + parentCommentId: string; + rootCommentId: string; + }[]; + rootCommentId: string; + user: { + iconUrl: string; + name: string; + userId: string; + }; + }[]; + nextUrl: string | null; + }; + coverImageUrl: string | null; + creatorId: string; + excerpt: string; + feeRequired: number; + hasAdultContent: boolean; + id: string; + imageForShare: string; + isLiked: boolean; + isRestricted: boolean; + likeCount: number; + nextPost: { + id: string; + title: string; + publishedDatetime: string; + }; + publishedDatetime: string; + tags: string[]; + title: string; + updatedDatetime: string; +} + +export interface ArticlePost extends BasicPost { + type: 'article'; + body: { + blocks: Block[]; + embedMap: { + [key: string]: unknown; + }; + fileMap: { + [key: string]: { + id: string; + extension: string; + name: string; + size: number; + url: string; + }; + }; + imageMap: { + [key: string]: { + id: string; + originalUrl: string; + thumbnailUrl: string; + width: number; + height: number; + extension: string; + }; + }; + urlEmbedMap: { + [key: string]: + | { + type: 'html'; + html: string; + id: string; + } + | { + type: 'fanbox.post'; + id: string; + postInfo: PostItem; + }; + }; + }; +} + +export interface FilePost extends BasicPost { + type: 'file'; + body: { + files: { + extension: string; + id: string; + name: string; + size: number; + url: string; + }[]; + text: string; + }; +} + +export interface VideoPost extends BasicPost { + type: 'video'; + body: { + text: string; + video: { + serviceProvider: 'youtube' | 'vimeo' | 'soundcloud'; + videoId: 'string'; + }; + }; +} + +export interface ImagePost extends BasicPost { + type: 'image'; + body: { + images: { + id: string; + originalUrl: string; + thumbnailUrl: string; + width: number; + height: number; + extension: string; + }[]; + text: string; + }; +} + +export interface TextPost extends BasicPost { + type: 'text'; + body: { + text: string; + }; +} + +export interface PostDetailResponse { + body: PostDetail; +} + +interface TextBlock { + type: 'p'; + text: string; + styles?: { + length: number; + offset: number; + type: 'bold'; + }[]; +} + +interface HeaderBlock { + type: 'header'; + text: string; +} + +interface ImageBlock { + type: 'image'; + imageId: string; +} + +interface FileBlock { + type: 'file'; + fileId: string; +} + +interface EmbedBlock { + type: 'url_embed'; + urlEmbedId: string; +} + +type PostDetail = ArticlePost | FilePost | ImagePost | VideoPost | TextPost; + +type Block = TextBlock | HeaderBlock | ImageBlock | FileBlock | EmbedBlock; diff --git a/lib/routes/fanbox/utils.ts b/lib/routes/fanbox/utils.ts new file mode 100644 index 00000000000000..c6fbf83613c00c --- /dev/null +++ b/lib/routes/fanbox/utils.ts @@ -0,0 +1,188 @@ +import { config } from '@/config'; +import type { DataItem } from '@/types'; +import ofetch from '@/utils/ofetch'; +import type { ArticlePost, FilePost, ImagePost, PostDetailResponse, PostItem, TextPost, VideoPost } from './types'; +import { parseDate } from '@/utils/parse-date'; +import cache from '@/utils/cache'; +import { art } from '@/utils/render'; +import path from 'node:path'; +import { getCurrentPath } from '@/utils/helpers'; + +const __dirname = getCurrentPath(import.meta.url); + +export function getHeaders() { + const sessionid = config.fanbox.session; + const cookie = sessionid ? `FANBOXSESSID=${sessionid}` : ''; + return { + origin: 'https://fanbox.cc', + cookie, + }; +} + +function embedUrlMap(urlEmbed: ArticlePost['body']['urlEmbedMap'][string]) { + switch (urlEmbed.type) { + case 'html': + return urlEmbed.html; + case 'fanbox.post': + return art(path.join(__dirname, 'templates/fanbox-post.art'), { + postUrl: `https://${urlEmbed.postInfo.creatorId}.fanbox.cc/posts/${urlEmbed.postInfo.id}`, + title: urlEmbed.postInfo.title, + user: urlEmbed.postInfo.user, + excerpt: urlEmbed.postInfo.excerpt, + }); + default: + return ''; + } +} + +function passageConv(p) { + const seg = [...p.text]; + if (p.styles) { + p.styles.map((s) => { + switch (s.type) { + case 'bold': + seg[s.offset] = `` + seg[s.offset]; + seg[s.offset + s.length - 1] += ``; + break; + default: + } + return s; + }); + } + if (p.links) { + p.links.map((l) => { + seg[l.offset] = `` + seg[l.offset]; + seg[l.offset + l.length - 1] += ``; + return l; + }); + } + const ret = seg.join(''); + return ret; +} + +function parseText(body: TextPost['body']) { + return body.text || ''; +} + +function parseImage(body: ImagePost['body']) { + let ret = body.text || ''; + for (const i of body.images) { + ret += `
`; + } + return ret; +} + +function parseFile(body: FilePost['body']) { + let ret = body.text || ''; + for (const f of body.files) { + ret += `
${f.name}.${f.extension}`; + } + return ret; +} + +async function parseVideo(body: VideoPost['body']) { + let ret = ''; + switch (body.video.serviceProvider) { + case 'soundcloud': + ret += await getSoundCloudEmbedUrl(body.video.videoId); + break; + case 'youtube': + ret += ``; + break; + case 'vimeo': + ret += ``; + break; + default: + } + ret += `
${body.text}`; + return ret; +} + +async function parseArtile(body: ArticlePost['body']) { + let ret: Array = []; + for (let x = 0; x < body.blocks.length; ++x) { + const b = body.blocks[x]; + ret.push('

'); + + switch (b.type) { + case 'p': + ret.push(passageConv(b)); + break; + case 'header': + ret.push(`

${b.text}

`); + break; + case 'image': { + const i = body.imageMap[b.imageId]; + ret.push(``); + break; + } + case 'file': { + const file = body.fileMap[b.fileId]; + ret.push(`${file.name}.${file.extension}`); + break; + } + case 'url_embed': + ret.push(embedUrlMap(body.urlEmbedMap[b.urlEmbedId])); + break; + default: + } + } + ret = await Promise.all(ret); + return ret.join(''); +} + +async function parseDetail(i: PostDetailResponse['body']) { + let ret = ''; + if (i.feeRequired !== 0) { + ret += `Fee Required: ${i.feeRequired} JPY/month
`; + } + if (i.coverImageUrl) { + ret += `
`; + } + + if (!i.body) { + ret += i.excerpt; + return ret; + } + + switch (i.type) { + case 'text': + ret += parseText(i.body); + break; + case 'file': + ret += parseFile(i.body); + break; + case 'image': + ret += parseImage(i.body); + break; + case 'video': + ret += await parseVideo(i.body); + break; + case 'article': + ret += await parseArtile(i.body); + break; + default: + ret += 'Unsupported content (RSSHub)'; + } + return ret; +} + +export function parseItem(item: PostItem) { + return cache.tryGet(`fanbox-${item.id}-${item.updatedDatetime}`, async () => { + const postDetail = (await ofetch(`https://api.fanbox.cc/post.info?postId=${item.id}`, { headers: getHeaders() })) as PostDetailResponse; + return { + title: item.title || `No title`, + description: await parseDetail(postDetail.body), + pubDate: parseDate(item.updatedDatetime), + link: `https://${item.creatorId}.fanbox.cc/posts/${item.id}`, + category: item.tags, + }; + }) as Promise; +} + +async function getSoundCloudEmbedUrl(videoId: string) { + const videoUrl = `https://soundcloud.com/${videoId}`; + const apiUrl = `https://soundcloud.com/oembed?url=${encodeURIComponent(videoUrl)}&format=json&maxheight=400&format=json`; + const resp = await ofetch(apiUrl); + return resp.html; +}