diff --git a/lib/router.js b/lib/router.js
index ff1fd4405..36c0aa9fb 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 40dfff634..000000000
--- 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 = `
-
- `;
- 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 55eb6b99b..000000000
--- 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 dd4219e8b..000000000
--- 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 000000000..b8e84e319
--- /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 000000000..0244a7afd
--- /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 000000000..ebf43dd4f
--- /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 000000000..a3198e9a6
--- /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 000000000..c6fbf8361
--- /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;
+}