feat(route): yamibo (#15537)

* feat(route): yamibo

* fix

* fix
This commit is contained in:
karasu
2024-05-14 11:50:09 +08:00
committed by GitHub
parent 8dbe8c9fd2
commit a62625428c
5 changed files with 328 additions and 0 deletions
+8
View File
@@ -286,6 +286,10 @@ export type Config = {
xueqiu: {
cookies?: string;
};
yamibo: {
salt?: string;
auth?: string;
};
youtube: {
key?: string;
clientId?: string;
@@ -642,6 +646,10 @@ const calculateValue = () => {
xueqiu: {
cookies: envs.XUEQIU_COOKIES,
},
yamibo: {
salt: envs.YAMIBO_SALT,
auth: envs.YAMIBO_AUTH,
},
youtube: {
key: envs.YOUTUBE_KEY,
clientId: envs.YOUTUBE_CLIENT_ID,
+116
View File
@@ -0,0 +1,116 @@
import type { Data, DataItem, Route } from '@/types';
import type { Context } from 'hono';
import { config } from '@/config';
import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
import { asyncPoolAll, fetchThread, generateDescription, getDate, bbsOrigin } from '../utils';
import cache from '@/utils/cache';
export const route: Route = {
name: 'BBS - 板块',
categories: ['bbs'],
path: '/bbs/forum/:fid/:type?',
example: '/yamibo/bbs/forum/5/404',
parameters: {
fid: '板块 id,可从URL中提取。https://bbs.yamibo.com/forum-aa-b.html中的aa部分即为fid值',
type: '板块子分类,网页中选中板块分类后URL中的typeid值',
},
maintainers: ['KarasuShin'],
handler,
features: {
antiCrawler: true,
requireConfig: [
{
optional: true,
name: 'YAMIBO_SALT',
description:
'百合会BBS登录后的认证信息,获取方式:1. 登录百合会BBS网页版 2. 打开浏览器开发者工具,切换到 Application 面板\n3. 点击侧边栏中的Storage -> Cookies -> https://bbs.yamibo.com 4. 复制 Cookie 中的 EeqY_2132_saltkey 值',
},
{
optional: true,
name: 'YAMIBO_AUTH',
description:
'百合会BBS登录后的认证信息,获取方式:1. 登录百合会BBS网页版 2. 打开浏览器开发者工具,切换到 Application 面板\n3. 点击侧边栏中的Storage -> Cookies -> https://bbs.yamibo.com 4. 复制 Cookie 中的 EeqY_2132_auth 值',
},
],
},
description: `:::warning
百合会BBS访问部分板块需要用户登录认证,请参考配置说明
:::`,
};
async function handler(ctx: Context): Promise<Data> {
const fid = ctx.req.param('fid');
const type = ctx.req.param('type');
const { auth, salt } = config.yamibo;
const params = new URLSearchParams();
params.set('mod', 'forumdisplay');
params.set('fid', fid);
params.set('orderby', 'dateline');
if (type) {
params.set('filter', 'typeid');
params.set('typeid', type);
}
const headers: HeadersInit = {};
if (auth && salt) {
headers.cookie = `EeqY_2132_saltkey=${salt}; EeqY_2132_auth=${auth}`;
}
const link = `${bbsOrigin}/forum.php?${params.toString()}`;
const $ = load(await ofetch<string>(link, { headers }));
const title = $('title').text().replace(' - 百合会 - Powered by Discuz!', '');
let items: DataItem[] = $('tbody[id^="normalthread_"]')
.toArray()
.map((item) => {
const $item = $(item);
const id = $item.attr('id')!.match(/\d+/)![0];
const title = $item.find('th em').text() + $item.find('th .s.xst').text();
const link = `${bbsOrigin}/thread-${id}-1-1.html`;
const pubDate = getDate($item.find('td.by').first().find('em').text());
return {
id,
title,
link,
pubDate,
};
});
items = await asyncPoolAll(
5,
items,
async (item) =>
(await cache.tryGet(item.link!, async () => {
let description: string | undefined;
const { data } = await fetchThread(item.id!);
if (data && !data.startsWith('<script type="text/javascript">')) {
const $ = load(data);
if ($('#postlist>div[id^="post_"]').length) {
const op = $('#postlist>div[id^="post_"]').first();
const postId = op.attr('id')?.match(/\d+/)?.[0];
if (postId) {
description = generateDescription(op, postId);
}
}
}
return {
title: item.title,
link: item.link,
description,
pubDate: item.pubDate,
};
})) as DataItem
);
return {
title,
link,
item: items,
};
}
+84
View File
@@ -0,0 +1,84 @@
import type { Data, DataItem, Route } from '@/types';
import type { Context } from 'hono';
import { load } from 'cheerio';
import { fetchThread, generateDescription, getDate, bbsOrigin } from '../utils';
export const route: Route = {
name: 'BBS - 讨论串',
categories: ['bbs'],
path: '/bbs/thread/:tid',
example: '/yamibo/bbs/thread/541914',
parameters: {
tid: '讨论串 id,可从URL中提取。https://bbs.yamibo.com/forum.php?mod=viewthread&tid=xxxx中的xxx或https://bbs.yamibo.com/thread-aaa-b-c.html中的aaa部分即为tid值',
},
maintainers: ['KarasuShin'],
handler,
features: {
antiCrawler: true,
requireConfig: [
{
optional: true,
name: 'YAMIBO_SALT',
description:
'百合会BBS登录后的认证信息,获取方式:1. 登录百合会BBS网页版 2. 打开浏览器开发者工具,切换到 Application 面板\n3. 点击侧边栏中的Storage -> Cookies -> https://bbs.yamibo.com 4. 复制 Cookie 中的 EeqY_2132_saltkey 值',
},
{
optional: true,
name: 'YAMIBO_AUTH',
description:
'百合会BBS登录后的认证信息,获取方式:1. 登录百合会BBS网页版 2. 打开浏览器开发者工具,切换到 Application 面板\n3. 点击侧边栏中的Storage -> Cookies -> https://bbs.yamibo.com 4. 复制 Cookie 中的 EeqY_2132_auth 值',
},
],
},
description: `:::warning
百合会BBS访问部分讨论串需要用户登录认证,请参考配置说明
:::`,
};
async function handler(ctx: Context): Promise<Data> {
const tid = ctx.req.param('tid');
const { data, link } = await fetchThread(tid, { ordertype: '1' });
if (!data) {
return {
title: '讨论串不存在',
link,
item: [],
};
}
const $ = load(data);
const title = $('title').text().replace(' - 百合会 - Powered by Discuz!', '');
const items: DataItem[] = $('#postlist>div[id^="post_"]')
.toArray()
.map((item) => {
const $item = $(item);
const isOP = !!$item.has('#fj').length;
const postId = $item.attr('id')!.match(/\d+/)![0];
const $tr = $item.find('table').find('tr').first();
const profileBlock = $tr.find(`#favatar${postId}`);
const nickName = profileBlock.find('.authi').text();
const floor = isOP ? '主楼' : $tr.find(`#postnum${postId} em`).text();
const link = isOP ? `${bbsOrigin}/forum.php?mod=viewthread&tid=${tid}` : `${bbsOrigin}/forum.php?mod=redirect&goto=findpost&ptid=${tid}&pid=${postId}`;
const description = generateDescription($item, postId);
const createTime = $tr
.find(`#authorposton${postId}`)
.text()
.match(/\d{4}(?:-\d{1,2}){2} \d{2}:\d{2}/)![0];
return {
title: `${floor} - ${nickName}`,
link,
description,
pubDate: getDate(createTime),
};
});
return {
title,
link,
item: items,
};
}
+6
View File
@@ -0,0 +1,6 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: '百合会',
url: 'yamibo.com',
};
+114
View File
@@ -0,0 +1,114 @@
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
import ofetch from '@/utils/ofetch';
import { config } from '@/config';
import asyncPool from 'tiny-async-pool';
import { JSDOM } from 'jsdom';
import type { Cheerio, Element } from 'cheerio';
export const bbsOrigin = 'https://bbs.yamibo.com';
export function getDate(date: string): Date {
return timezone(parseDate(date), 8);
}
export async function fetchThread(
tid: string,
options?: {
ordertype?: string;
_dsign?: string;
},
retry = 0
): Promise<{
link: string;
data?: string;
}> {
const { auth, salt } = config.yamibo;
const params = new URLSearchParams();
params.set('mod', 'viewthread');
params.set('tid', tid);
if (options?.ordertype) {
params.set('ordertype', options.ordertype);
}
if (options?._dsign) {
params.set('_dsign', options._dsign);
}
const link = `https://bbs.yamibo.com/forum.php?${params.toString()}`;
const headers: HeadersInit = {};
if (auth && salt) {
headers.cookie = `EeqY_2132_saltkey=${salt}; EeqY_2132_auth=${auth}`;
}
const data = await ofetch<string>(link, { headers });
// sometimes may trigger anti-crawling measures
if (data.startsWith('<script type="text/javascript">') && retry <= 3) {
let script = data.match(/<script type="text\/javascript">([\S\s]*?)<\/script>/)![1];
script = script.replace(/= location;|=location;/, '=fakeLocation;');
script = script.replace('location.replace', 'foo');
script = script.replace('location.assign', 'foo');
script = script.replace(/location\[[^\]]*]\(/, 'foo(');
script = script.replace(/location\[[^\]]*]=/, 'window.locationValue=');
script = script.replace('location.href=', 'window.locationValue=');
script = script.replace('location=', 'window.locationValue=');
const dom = new JSDOM(
`<script>
function foo(value) { window.locationValue = value; };
fakeLocation = { href: '', replace: foo, assign: foo };
Object.defineProperty(fakeLocation, 'href', {
set: function (value) {
window.locationValue = value;
}
});
${script}
</script>`,
{
runScripts: 'dangerously',
}
);
const locationValue = dom.window.locationValue;
if (locationValue) {
const searchParams = new URLSearchParams(locationValue);
const _dsign = searchParams.get('_dsign');
if (_dsign) {
options = {
...options,
_dsign,
};
}
}
return await fetchThread(tid, options, ++retry);
}
return {
link,
data,
};
}
export function generateDescription($item: Cheerio<Element>, postId: string) {
const content = $item.find(`#postmessage_${postId}`).parent();
content.find('img').each((_, img) => {
const src = img.attribs.zoomfile ?? img.attribs.src;
img.attribs.src = `${bbsOrigin}/${src}`;
});
let description = content.html() ?? '';
const images = $item.find('.pattl img').toArray();
for (const img of images) {
const src = img.attribs.zoomfile ?? img.attribs.src;
description += `<img src="${bbsOrigin}/${src}" />`;
}
return description;
}
export async function asyncPoolAll<IN, OUT>(poolLimit: number, array: readonly IN[], iteratorFn: (generator: IN) => Promise<OUT>) {
const results: Awaited<OUT[]> = [];
for await (const result of asyncPool(poolLimit, array, iteratorFn)) {
results.push(result);
}
return results;
}