feat(route): fanbox (#15418)

* feat(route): fanbox

* fix

* Update lib/routes/fanbox/index.ts

Co-authored-by: Tony <TonyRL@users.noreply.github.com>

* replace `got` with `ofetch`

* Update lib/routes/fanbox/utils.ts

Co-authored-by: Tony <TonyRL@users.noreply.github.com>

---------
This commit is contained in:
karasu 2024-05-01 11:15:47 +08:00 committed by GitHub
parent 4aebe20ed2
commit b3f0276fc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 502 additions and 288 deletions

View File

@ -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'));

View File

@ -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 `<div style="border-style:solid;border-width:0.5px;padding:0.5em;">This tweet may not exist</div>`;
}
}
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 = `
<div style="width:640;padding:0.5em;border-style:solid;border-width:0.5px">
<img width=300 src="${post.imageForShare}" />
<br/>
<a href="${web_url}" style="font-weight:bold">${post.title}</a>
<br/>
<br/>
<a href="${home_url}" style="margin-right:1.6em">${post.user.name}</a>
<span style="margin-right:1.6em">Modify: ${datetime}</span>
<span>${post.feeRequired} JPY</span>
</div>
`;
return { url: web_url, html: box_html };
} catch {
return { url: null, html: `<div style="border-style:solid;border-width:0.5px;padding:0.5em;">fanbox post (${p}) may not exist</div>` };
}
}
// 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 = `<iframe type="text/html" width="640" height="360" src="${url}" frameborder="0" allowfullscreen></iframe>`;
break;
case 'vimeo':
url = `https://player.vimeo.com/video/${id}`;
ret = `<iframe type="text/html" width="640" height="360" src="${url}" frameborder="0" allowfullscreen></iframe>`;
break;
case 'soundcloud':
url = `https://soundcloud.com/${id}`;
ret = `<iframe width="640" height="166" frameborder="0" allowfullscreen src="https://w.soundcloud.com/player/?url=${url}"></iframe>`;
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 = `<iframe src="${url}" width=640 height=800 frameborder="0" allowfullscreen></iframe>`;
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 = `<iframe frameborder="0" width="640" height="480" srcdoc="<script src=${url}.js></script>"></iframe>`;
break;
}
if (url) {
ret += `<br/><a href="${url}" style="font-size:0.6em;">Click here if embedded content is not loaded.</a>`;
}
} catch (error) {
error;
}
return ret;
}
// render <p/> 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] = `<b>` + seg[s.offset];
seg[s.offset + s.length - 1] += `</b>`;
break;
}
return s;
});
}
if (p.links) {
p.links.map((l) => {
seg[l.offset] = `<a href="${l.url}">` + seg[l.offset];
seg[l.offset + l.length - 1] += `</a>`;
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 += `<hr><img src="${i.originalUrl}">`));
return ret;
}
function file_t(body) {
let ret = body.text || '';
body.files.map((f) => (ret += `<br><a href="${f.url}" download="${f.name}.${f.extension}">${f.name}.${f.extension}</a>`));
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('<p>');
switch (b.type) {
case 'p':
ret.push(passage_conv(b));
break;
case 'header':
ret.push(`<h2>${b.text}</h2>`);
break;
case 'image': {
const i = body.imageMap[b.imageId];
ret.push(`<img src="${i.originalUrl}">`);
break;
}
case 'file': {
const f = body.fileMap[b.fileId];
ret.push(`<a href="${f.url}" download="${f.name}.${f.extension}">${f.name}.${f.extension}</a>`);
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}<hr>`;
}
if (i.feeRequired !== 0) {
ret += `Fee Required: <b>${i.feeRequired} JPY/month</b><hr>`;
}
if (i.coverImageUrl) {
ret += `<img src="${i.coverImageUrl}"><hr>`;
}
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 += '<b>Unsupported content (RSSHub)</b>';
}
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,
});

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: 'fanbox',
url: 'https://www.fanbox.cc',
};

View File

@ -0,0 +1,7 @@
<a href={{postUrl}}>
<h1>{{title}}</h1>
<span style="margin-right:1.6em">{{user.name}}</span>
<br />
<br />
<span>{{excerpt}}</span>
</a>

239
lib/routes/fanbox/types.ts Normal file
View File

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

188
lib/routes/fanbox/utils.ts Normal file
View File

@ -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] = `<b>` + seg[s.offset];
seg[s.offset + s.length - 1] += `</b>`;
break;
default:
}
return s;
});
}
if (p.links) {
p.links.map((l) => {
seg[l.offset] = `<a href="${l.url}">` + seg[l.offset];
seg[l.offset + l.length - 1] += `</a>`;
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 += `<hr><img src="${i.originalUrl}">`;
}
return ret;
}
function parseFile(body: FilePost['body']) {
let ret = body.text || '';
for (const f of body.files) {
ret += `<br><a href="${f.url}" download="${f.name}.${f.extension}">${f.name}.${f.extension}</a>`;
}
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 += `<iframe src="https://www.youtube-nocookie.com/embed/${body.video.videoId}" frameborder="0"></iframe>`;
break;
case 'vimeo':
ret += `<iframe src="https://player.vimeo.com/video/${body.video.videoId}" frameborder="0"></iframe>`;
break;
default:
}
ret += `<br>${body.text}`;
return ret;
}
async function parseArtile(body: ArticlePost['body']) {
let ret: Array<string> = [];
for (let x = 0; x < body.blocks.length; ++x) {
const b = body.blocks[x];
ret.push('<p>');
switch (b.type) {
case 'p':
ret.push(passageConv(b));
break;
case 'header':
ret.push(`<h2>${b.text}</h2>`);
break;
case 'image': {
const i = body.imageMap[b.imageId];
ret.push(`<img src="${i.originalUrl}">`);
break;
}
case 'file': {
const file = body.fileMap[b.fileId];
ret.push(`<a href="${file.url}" download="${file.name}.${file.extension}">${file.name}.${file.extension}</a>`);
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: <b>${i.feeRequired} JPY/month</b><hr>`;
}
if (i.coverImageUrl) {
ret += `<img src="${i.coverImageUrl}"><hr>`;
}
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 += '<b>Unsupported content (RSSHub)</b>';
}
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<DataItem>;
}
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;
}