feat(route): 添加電音部新闻及组合新闻相关路由 (#20770)

* feat: denonbu

* chore: UPPER_SNAKE_CASE for constants

* fix: timezone

* fix: extract a title if none is given

* fix: typo

* fix: handle special media fields

testcase: /denonbu/news/daiba

* fix: naming

* feat: cache token

* chore: remove manual `...` (RSSHub will add it)

* chore: simplify checks

shall be safe to guess the site won't have sid === 0

* Apply suggestion from TonyRL

---------
This commit is contained in:
Outvi V 2025-12-28 23:17:36 +08:00 committed by GitHub
parent 976b47b1a7
commit af886e3f43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 206 additions and 0 deletions

View File

@ -0,0 +1,6 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: '電音部',
url: 'denonbu.jp',
};

200
lib/routes/denonbu/news.ts Normal file
View File

@ -0,0 +1,200 @@
import type { Context } from 'hono';
import type { Data, DataItem, Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
export const route: Route = {
url: 'denonbu.jp',
path: '/news/:area?',
categories: ['anime'],
example: '/denonbu/news/azabu',
parameters: {
area: 'The id of the area or category; values are as follows.',
},
description: `**Area**
| ID | Group name/Area name |
| ------------- | ------------------------------------------------ |
| akiba | |
| harajuku | |
| azabu | |
| shibuya | |
| kabuki | 宿GR学園 |
| deep-okubo | BellemuleDJ |
| deep-okubo-k | DJ |
| shinsaibashi | OKINIPARTY'S |
| ikebukuro | |
| neotokyo | |
| neonakano | |
| shimokitazawa | Ma'Scar'Piece |
**Category**
Working category IDs include \`news\` (the default), \`event\`, \`goods\`, \`comic\`, \`movie\`, \`music\` or \`livearchives\`.
`,
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
radar: [
{
source: ['denonbu.jp/news'],
target: '/news',
},
{
source: ['denonbu.jp/event'],
target: '/news/event',
},
{
source: ['denonbu.jp/goods'],
target: '/news/goods',
},
{
source: ['denonbu.jp/comic'],
target: '/news/comic',
},
{
source: ['denonbu.jp/movie'],
target: '/news/movie',
},
{
source: ['denonbu.jp/music'],
target: '/news/music',
},
{
source: ['denonbu.jp/livearchives'],
target: '/news/livearchives',
},
{
source: ['denonbu.jp/area/:area'],
target: '/news/:area',
},
],
name: '新闻',
maintainers: ['outloudvi'],
handler,
};
const AREA_NAMES = {
akiba: '外神田文芸高校',
harajuku: '神宮前参道學園',
azabu: '港白金女学院',
shibuya: '帝音国際学院',
kabuki: '真新宿GR学園',
'deep-okubo': 'Bellemule深大久保DJダンスアカデミー',
'deep-okubo-k': '輝きスタルジア深大久保DJダンスアカデミー',
shinsaibashi: "OKINI☆PARTY'S心斎橋演芸高校",
ikebukuro: '池袋電音部(池袋空乗院高校)',
neotokyo: '東京電脳(東京電脳学園)',
neonakano: '中野電脳(中野電脳学園)',
shimokitazawa: "Ma'Scar'Piece北沢音箱高校",
news: '新闻',
event: '活动',
goods: '商品',
comic: '漫画',
movie: '影片',
music: '音乐',
livearchives: 'Live留档',
};
const BASE_URL = 'https://denonbu.jp/backend-api/v1.0.0/';
const COMMON_HEADERS = {
'X-API-KEY': 'FVpHcMLqyf7v2EubqiLxznC9gVMqBDFFMt4zvkS2',
};
const PRIMARY_CATEGORIES = new Set(['news', 'event', 'goods', 'comic', 'movie', 'music', 'livearchives']);
const CACHE_TOKEN_KEY = 'denonbu-news';
async function getToken(): Promise<string> {
const cacheToken = await cache.get(CACHE_TOKEN_KEY, false);
if (cacheToken) {
return cacheToken;
}
const payload = await ofetch(String(new URL('auths/token/get', BASE_URL)), {
headers: COMMON_HEADERS,
}).then((x) => x.payload);
const { token, expires } = payload;
if (!token) {
throw new Error('Failed to get token');
}
cache.set(CACHE_TOKEN_KEY, token, expires ? expires - Number(Date.now()) / 1000 - 1 : 3600);
return token;
}
function buildLink(body: any): string | null {
switch (body.source_type) {
case 'main':
case 'deep-okubo':
case 'shinsaibashi':
case 'neotokyo': {
const { sid, uid } = body;
if (sid && uid) {
return `https://denonbu.jp/detail/${sid}/${uid}`;
}
return null;
}
case 'tw': {
const {
account: { account_id },
uid,
} = body;
if (account_id && uid) {
return `https://twitter.com/${account_id}/status/${uid}`;
}
return null;
}
default:
return null;
}
}
async function handler(ctx: Context): Promise<Data> {
const { area: _area } = ctx.req.param();
const area = _area ?? 'news';
const token = await getToken();
const data = await ofetch(String(new URL(`contents/search/${area}?limit=20&offset=0`, BASE_URL)), {
headers: {
...COMMON_HEADERS,
Authorization: `Bearer ${token}`,
},
}).then((x) => x.payload.items);
const items = data.map((item) => {
const { title, body, post_date, category, media } = item;
const link = buildLink(item);
const result: DataItem = {
title: title ?? body.split('\n')[0],
description: body,
pubDate: timezone(parseDate(post_date), +9),
category: category.map((x) => x.name),
};
if (media?.[0]) {
const firstMedia = media[0];
const imageUrl = typeof firstMedia === 'string' ? firstMedia : firstMedia?.url;
if (typeof imageUrl === 'string') {
result.image = imageUrl;
}
}
if (link) {
result.link = link;
}
return result;
});
return {
title: `電音部新闻 - ${AREA_NAMES[area] ?? area}`,
link: PRIMARY_CATEGORIES.has(area) ? `https://denonbu.jp/${area}` : `https://denonbu.jp/area/${area}`,
item: items,
language: 'ja',
};
}