;
diff --git a/lib/routes/51read/namespace.ts b/lib/routes/51read/namespace.ts
new file mode 100644
index 00000000000000..26b64b3c717e78
--- /dev/null
+++ b/lib/routes/51read/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '51Read',
+ url: 'm.51read.org',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/52hrtt/index.ts b/lib/routes/52hrtt/index.ts
new file mode 100644
index 00000000000000..69860c718a123f
--- /dev/null
+++ b/lib/routes/52hrtt/index.ts
@@ -0,0 +1,80 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:area?/:type?',
+ categories: ['new-media'],
+ example: '/52hrtt/global',
+ parameters: { area: '地区,默认为全球', type: '分类,默认为新闻' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '新闻',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `地区和分类皆可在浏览器地址栏中找到,下面是一个例子。
+
+ 访问华人头条全球站的国际分类,会跳转到 \`https://www.52hrtt.com/global/n/w?infoTypeId=A1459145516533\`。其中 \`global\` 即为 **全球** 对应的地区代码,\`A1459145516533\` 即为 **国际** 对应的分类代码。`,
+};
+
+async function handler(ctx) {
+ const area = ctx.req.param('area') ?? 'global';
+ const type = ctx.req.param('type') ?? '';
+
+ const rootUrl = 'https://www.52hrtt.com';
+ const currentUrl = `${rootUrl}/${area}/n/w${type ? `?infoTypeId=${type}` : ''}`;
+ const apiUrl = `${rootUrl}/s/webapi/${area}/n/w${type ? `?infoTypeId=${type}` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const titleResponse = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(titleResponse.data);
+
+ const list = response.data.data.infosMap.infoList
+ .filter((item) => item.infoTitle)
+ .map((item) => ({
+ title: item.infoTitle,
+ author: item.quoteFrom,
+ pubDate: timezone(parseDate(item.infoStartTime), +8),
+ link: `${rootUrl}/${area}/n/w/info/${item.infoCentreId}`,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const content = load(detailResponse.data);
+
+ item.description = content('.info-content').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${response.data.data.area.areaName} - ${$('.router-link-active').eq(0).text()} - 华人头条`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/52hrtt/namespace.ts b/lib/routes/52hrtt/namespace.ts
new file mode 100644
index 00000000000000..f7c5f126cde083
--- /dev/null
+++ b/lib/routes/52hrtt/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '52hrtt 华人头条',
+ url: '52hrtt.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/52hrtt/symposium.ts b/lib/routes/52hrtt/symposium.ts
new file mode 100644
index 00000000000000..b01ea7dbada14a
--- /dev/null
+++ b/lib/routes/52hrtt/symposium.ts
@@ -0,0 +1,90 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/symposium/:id?/:classId?',
+ categories: ['new-media'],
+ example: '/52hrtt/symposium/F1626082387819',
+ parameters: { id: '专题 id', classId: '子分类 id' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['52hrtt.com/global/n/w/symposium/:id'],
+ target: '/symposium/:id',
+ },
+ ],
+ name: '专题',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `专题 id 和 子分类 id 皆可在浏览器地址栏中找到,下面是一个例子。
+
+ 访问 “邱毅看平潭” 专题,会跳转到 \`https://www.52hrtt.com/global/n/w/symposium/F1626082387819\`。其中 \`F1626082387819\` 即为 **专题 id** 对应的地区代码。
+
+::: tip
+ 更多的专题可以点击 [这里](https://www.52hrtt.com/global/n/w/symposium)
+:::`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '';
+ const classId = ctx.req.param('classId') ?? '';
+
+ const rootUrl = 'https://www.52hrtt.com';
+ const currentUrl = `${rootUrl}/global/n/w/symposium/${id}`;
+ const apiUrl = `${rootUrl}/s/webapi/global/symposium/getInfoList?symposiumId=${id}${classId ? `&symposiumclassId=${classId}` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const titleResponse = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(titleResponse.data);
+
+ const list = response.data.data
+ .filter((item) => item.infoTitle)
+ .map((item) => ({
+ title: item.infoTitle,
+ author: item.quoteFrom,
+ pubDate: timezone(parseDate(item.infoStartTime), +8),
+ link: `${rootUrl}/global/n/w/info/${item.infoCentreId}`,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const content = load(detailResponse.data);
+
+ item.description = content('.info-content').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/56kog/class.ts b/lib/routes/56kog/class.ts
new file mode 100644
index 00000000000000..8d7fe4ecc37008
--- /dev/null
+++ b/lib/routes/56kog/class.ts
@@ -0,0 +1,38 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+
+import { fetchItems, rootUrl } from './util';
+
+export const route: Route = {
+ path: '/class/:category?',
+ categories: ['reading'],
+ example: '/56kog/class/1_1',
+ parameters: { category: '分类,见下表,默认为玄幻魔法' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| [玄幻魔法](https://www.56kog.com/class/1_1.html) | [武侠修真](https://www.56kog.com/class/2_1.html) | [历史军事](https://www.56kog.com/class/4_1.html) | [侦探推理](https://www.56kog.com/class/5_1.html) | [网游动漫](https://www.56kog.com/class/6_1.html) |
+| ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ |
+| 1\_1 | 2\_1 | 4\_1 | 5\_1 | 6\_1 |
+
+| [恐怖灵异](https://www.56kog.com/class/8_1.html) | [都市言情](https://www.56kog.com/class/3_1.html) | [科幻](https://www.56kog.com/class/7_1.html) | [女生小说](https://www.56kog.com/class/9_1.html) | [其他](https://www.56kog.com/class/10_1.html) |
+| ------------------------------------------------ | ------------------------------------------------ | -------------------------------------------- | ------------------------------------------------ | --------------------------------------------- |
+| 8\_1 | 3\_1 | 7\_1 | 9\_1 | 10\_1 |`,
+};
+
+async function handler(ctx) {
+ const { category = '1_1' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const currentUrl = new URL(`class/${category}.html`, rootUrl).href;
+
+ return await fetchItems(limit, currentUrl, cache.tryGet);
+}
diff --git a/lib/routes/56kog/namespace.ts b/lib/routes/56kog/namespace.ts
new file mode 100644
index 00000000000000..a3e00cb9d6e4dc
--- /dev/null
+++ b/lib/routes/56kog/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '明月中文网',
+ url: '56kog.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/56kog/templates/description.art b/lib/routes/56kog/templates/description.art
new file mode 100644
index 00000000000000..dccde741aefb60
--- /dev/null
+++ b/lib/routes/56kog/templates/description.art
@@ -0,0 +1,32 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if details }}
+
+
+ {{ each details detail }}
+
+ {{ detail.label }}
+
+ {{ if detail.value?.href && detail.value?.text }}
+ {{ detail.value.text }}
+ {{ else }}
+ {{ detail.value }}
+ {{ /if }}
+
+
+ {{ /each }}
+
+
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/56kog/top.ts b/lib/routes/56kog/top.ts
new file mode 100644
index 00000000000000..f9176fd65542cf
--- /dev/null
+++ b/lib/routes/56kog/top.ts
@@ -0,0 +1,34 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+
+import { fetchItems, rootUrl } from './util';
+
+export const route: Route = {
+ path: '/top/:category?',
+ categories: ['reading'],
+ example: '/56kog/top/weekvisit',
+ parameters: { category: '分类,见下表,默认为周点击榜' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '榜单',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| [周点击榜](https://www.56kog.com/top/weekvisit.html) | [总收藏榜](https://www.56kog.com/top/goodnum.html) | [最新 入库](https://www.56kog.com/top/postdate.html) |
+| ---------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- |
+| weekvisit | goodnum | postdate |`,
+};
+
+async function handler(ctx) {
+ const { category = 'weekvisit' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const currentUrl = new URL(`top/${category.split(/_/)[0]}_1.html`, rootUrl).href;
+
+ return await fetchItems(limit, currentUrl, cache.tryGet);
+}
diff --git a/lib/routes/56kog/util.ts b/lib/routes/56kog/util.ts
new file mode 100644
index 00000000000000..eea4914fa4c0b8
--- /dev/null
+++ b/lib/routes/56kog/util.ts
@@ -0,0 +1,110 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const rootUrl = 'https://www.56kog.com';
+
+const fetchItems = async (limit, currentUrl, tryGet) => {
+ const { data: response } = await got(currentUrl, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(iconv.decode(response, 'gbk'));
+
+ let items = $('p.line')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a');
+
+ return {
+ title: a.text(),
+ link: new URL(a.prop('href'), rootUrl).href,
+ author: item.find('span').last().text(),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ tryGet(item.link, async () => {
+ try {
+ const { data: detailResponse } = await got(item.link, {
+ responseType: 'buffer',
+ });
+
+ const content = load(iconv.decode(detailResponse, 'gbk'));
+
+ const details = content('div.mohe-content p')
+ .toArray()
+ .map((detail) => {
+ detail = content(detail);
+ const as = detail.find('a');
+
+ return {
+ label: detail.find('span.c-l-depths').text().split(/:/)[0],
+ value:
+ as.length === 0
+ ? content(
+ detail
+ .contents()
+ .toArray()
+ .find((c) => c.nodeType === 3)
+ )
+ .text()
+ .trim()
+ : {
+ href: new URL(as.first().prop('href'), rootUrl).href,
+ text: as.first().text().trim(),
+ },
+ };
+ });
+
+ const pubDate = details.find((detail) => detail.label === '更新').value;
+
+ item.title = content('h1').contents().first().text();
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ images: [
+ {
+ src: new URL(content('a.mohe-imgs img').prop('src'), rootUrl).href,
+ alt: item.title,
+ },
+ ],
+ details,
+ });
+ item.author = details.find((detail) => detail.label === '作者').value;
+ item.category = [details.find((detail) => detail.label === '状态').value, details.find((detail) => detail.label === '类型').value.text].filter(Boolean);
+ item.guid = `56kog-${item.link.match(/\/(\d+)\.html$/)[1]}#${pubDate}`;
+ item.pubDate = timezone(parseDate(pubDate), +8);
+ } catch {
+ // no-empty
+ }
+
+ return item;
+ })
+ )
+ );
+
+ const icon = new URL('favicon.ico', rootUrl).href;
+
+ return {
+ item: items.filter((item) => item.description).slice(0, limit),
+ title: $('title').text(),
+ link: currentUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: $('html').prop('lang'),
+ icon,
+ logo: icon,
+ subtitle: $('meta[name="keywords"]').prop('content'),
+ author: $('div.uni_footer a').text(),
+ allowEmpty: true,
+ };
+};
+
+export { fetchItems, rootUrl };
diff --git a/lib/routes/591/list.ts b/lib/routes/591/list.ts
new file mode 100644
index 00000000000000..fcff9079e65ae9
--- /dev/null
+++ b/lib/routes/591/list.ts
@@ -0,0 +1,167 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import { CookieJar } from 'tough-cookie';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+import { isValidHost } from '@/utils/valid-host';
+
+const cookieJar = new CookieJar();
+
+const client = got.extend({
+ cookieJar,
+});
+
+function appendRentalAPIParams(urlString) {
+ const searchParams = new URLSearchParams(urlString);
+
+ searchParams.set('is_format_data', '1');
+ searchParams.set('is_new_list', '1');
+ searchParams.set('type', '1');
+
+ return searchParams.toString();
+}
+
+async function getToken() {
+ const html = await client('https://rent.591.com.tw').text();
+
+ const $ = load(html);
+ const csrfToken = $('meta[name="csrf-token"]').attr('content');
+
+ if (!csrfToken) {
+ throw new Error('CSRF token not found');
+ }
+
+ return csrfToken;
+}
+
+async function getHouseList(houseListURL) {
+ const csrfToken = await getToken();
+
+ const data = await client({
+ url: houseListURL,
+ headers: {
+ 'X-CSRF-TOKEN': csrfToken,
+ },
+ }).json();
+
+ const {
+ data: { data: houseList },
+ } = data;
+
+ return houseList;
+}
+
+/**
+
+@typedef {object} House
+@property {string} title - The title of the house.
+@property {string} type - The type of the house.
+@property {number} post_id - The post id of the house.
+@property {string} kind_name - The name of the kind of the house.
+@property {string} room_str - A string representation of the number of rooms in the house.
+@property {string} floor_str - A string representation of the floor of the house.
+@property {string} community - The community the house is located in.
+@property {string} price - The price of the house.
+@property {string} price_unit - The unit of the price of the house.
+@property {string[]} photo_list - A list of photos of the house.
+@property {string} section_name - The name of the section where the house is located.
+@property {string} street_name - The name of the street where the house is located.
+@property {string} location - The location of the house.
+@property {RentTagItem[]} rent_tag - An array of rent tags for the house.
+@property {string} area - The area of the house.
+@property {string} role_name - The name of the role of the house.
+@property {string} contact - The contact information for the house.
+@property {string} refresh_time - The time the information about the house was last refreshed.
+@property {number} yesterday_hit - The number of hits the house received yesterday.
+@property {number} is_vip - A flag indicating whether the house is VIP or not.
+@property {number} is_combine - A flag indicating whether the house is combined or not.
+@property {number} hurry - A flag indicating whether there is a hurry for the house.
+@property {number} is_socail - A flag indicating whether the house is social or not.
+@property {Surrounding} surrounding - The surrounding area of the house.
+@property {string} discount_price_str - A string representation of the discounted price of the house.
+@property {number} cases_id - The id of the cases for the house.
+@property {number} is_video - A flag indicating whether there is a video for the house.
+@property {number} preferred - A flag indicating whether the house is preferred or not.
+@property {number} cid - The id of the house.
+*/
+
+/**
+
+@typedef {object} RentTagItem
+@property {string} id - The id of the rent tag item.
+@property {string} name - The name of the rent tag item.
+*/
+/**
+
+@typedef {object} Surrounding
+@property {string} type - The type of the surrounding.
+@property {string} desc - The description of the surrounding.
+@property {string} distance - The distance to the surrounding.
+*/
+
+const renderHouse = (house) => art(path.join(__dirname, 'templates/house.art'), { house });
+
+export const route: Route = {
+ path: '/:country/rent/:query?',
+ categories: ['other'],
+ example: '/591/tw/rent/order=posttime&orderType=desc',
+ parameters: { country: 'Country code. Only tw is supported now', query: 'Query Parameters' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Rental house',
+ maintainers: ['Yukaii'],
+ handler,
+ description: `::: tip
+ Copy the URL of the 591 filter housing page and remove the front part \`https://rent.591.com.tw/?\`, you will get the query parameters.
+:::`,
+};
+
+async function handler(ctx) {
+ const query = ctx.req.param('query') ?? '';
+ const country = ctx.req.param('country') ?? 'tw';
+
+ if (!isValidHost(country) && country !== 'tw') {
+ throw new InvalidParameterError('Invalid country codes. Only "tw" is supported now.');
+ }
+
+ /** @type {House[]} */
+ const houses = await getHouseList(`https://rent.591.com.tw/home/search/rsList?${appendRentalAPIParams(query)}`);
+
+ const queryUrl = `https://rent.591.com.tw/?${query}`;
+
+ const items = houses.map((house) => {
+ const { title, post_id, price, price_unit } = house;
+
+ const itemUrl = `https://rent.591.com.tw/home/${post_id}`;
+ const itemTitle = `${title} - ${price} ${price_unit}`;
+
+ const description = renderHouse(house);
+
+ return {
+ title: itemTitle,
+ description,
+ link: itemUrl,
+ };
+ });
+
+ ctx.set('json', {
+ houses,
+ });
+
+ return {
+ title: '591 租屋 - 自訂查詢',
+ link: queryUrl,
+ description: `591 租屋 - 自訂查詢, query: ${query}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/591/namespace.ts b/lib/routes/591/namespace.ts
new file mode 100644
index 00000000000000..7e5055c3a51985
--- /dev/null
+++ b/lib/routes/591/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '591 Rental house',
+ url: 'rent.591.com.tw',
+ lang: 'zh-TW',
+};
diff --git a/lib/v2/591/templates/house.art b/lib/routes/591/templates/house.art
similarity index 100%
rename from lib/v2/591/templates/house.art
rename to lib/routes/591/templates/house.art
diff --git a/lib/routes/5eplay/index.ts b/lib/routes/5eplay/index.ts
new file mode 100644
index 00000000000000..14de35b05f2370
--- /dev/null
+++ b/lib/routes/5eplay/index.ts
@@ -0,0 +1,77 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/article',
+ categories: ['game'],
+ example: '/5eplay/article',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['csgo.5eplay.com/', 'csgo.5eplay.com/article'],
+ },
+ ],
+ name: '新闻列表',
+ maintainers: ['Dlouxgit'],
+ handler,
+ url: 'csgo.5eplay.com/',
+};
+
+async function handler() {
+ const rootUrl = 'https://csgo.5eplay.com/';
+ const apiUrl = `${rootUrl}api/article?page=1&type_id=0&time=0&order_by=0`;
+
+ const { data: response } = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = await Promise.all(
+ response.data.list.map((item) =>
+ cache.tryGet(item.jump_link, async () => {
+ const { data: detailResponse } = await got({
+ method: 'get',
+ url: item.jump_link,
+ });
+ const $ = load(detailResponse);
+
+ const content = $('.article-text');
+ const res: string[] = [];
+
+ content.find('> p, > blockquote').each((i, e) => {
+ res.push($(e).text());
+ const src = $(e).find('img').first().attr('src');
+ if (src && !src.includes('data:image/png;base64')) {
+ // drop base64 img
+ res.push(` `);
+ }
+ });
+
+ return {
+ title: item.title,
+ description: res.join(' '),
+ pubDate: parseDate(item.dateline * 1000),
+ link: item.jump_link,
+ };
+ })
+ )
+ );
+
+ return {
+ title: '5EPLAY',
+ link: 'https://csgo.5eplay.com/article',
+ item: items,
+ };
+}
diff --git a/lib/routes/5eplay/namespace.ts b/lib/routes/5eplay/namespace.ts
new file mode 100644
index 00000000000000..dca7393b9a2bfa
--- /dev/null
+++ b/lib/routes/5eplay/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '5EPLAY',
+ url: 'csgo.5eplay.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/5eplay/utils.ts b/lib/routes/5eplay/utils.ts
new file mode 100644
index 00000000000000..1e50b25fd159f1
--- /dev/null
+++ b/lib/routes/5eplay/utils.ts
@@ -0,0 +1,34 @@
+const getAcwScV2ByArg1 = (arg1) => {
+ const pwd = '3000176000856006061501533003690027800375';
+ const hexXor = function (box, pwd) {
+ let res = '';
+ for (let i = 0x0; i < box.length && i < pwd.length; i += 2) {
+ const tmp1 = Number.parseInt(box.slice(i, i + 2), 16);
+ const tmp2 = Number.parseInt(pwd.slice(i, i + 2), 16);
+ let tmp = (tmp1 ^ tmp2).toString(16);
+ if (tmp.length === 1) {
+ tmp = '0' + tmp;
+ }
+ res += tmp;
+ }
+ return res;
+ };
+ const unsbox = function (str: string) {
+ const code = [15, 35, 29, 24, 33, 16, 1, 38, 10, 9, 19, 31, 40, 27, 22, 23, 25, 13, 6, 11, 39, 18, 20, 8, 14, 21, 32, 26, 2, 30, 7, 4, 17, 5, 3, 28, 34, 37, 12, 36];
+ const res: string[] = [];
+ // eslint-disable-next-line unicorn/no-for-loop
+ for (let i = 0; i < str.length; i++) {
+ const cur = str[i];
+ for (const [j, element] of code.entries()) {
+ if (element === i + 1) {
+ res[j] = cur;
+ }
+ }
+ }
+ return res.join('');
+ };
+ const box = unsbox(arg1);
+ return hexXor(box, pwd);
+};
+
+export { getAcwScV2ByArg1 };
diff --git a/lib/routes/5music/index.ts b/lib/routes/5music/index.ts
new file mode 100644
index 00000000000000..8b250948986d8a
--- /dev/null
+++ b/lib/routes/5music/index.ts
@@ -0,0 +1,88 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/new-releases/:category?',
+ categories: ['shopping'],
+ example: '/5music/new-releases',
+ parameters: { category: 'Category, see below, defaults to all' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.5music.com.tw/New_releases.asp', 'www.5music.com.tw/'],
+ target: '/new-releases',
+ },
+ ],
+ name: '新貨上架',
+ maintainers: ['gideonsenku'],
+ handler,
+ description: `Categories:
+| 華語 | 西洋 | 東洋 | 韓語 | 古典 |
+| ---- | ---- | ---- | ---- | ---- |
+| A | B | F | M | D |`,
+ url: 'www.5music.com.tw/New_releases.asp',
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'A';
+ const url = `https://www.5music.com.tw/New_releases.asp?mut=${category}`;
+
+ const { data } = await got(url);
+ const $ = load(data);
+
+ const items = $('.releases-list .tbody > .box')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const cells = $item.find('.td');
+
+ // 解析艺人名称 (可能包含中英文名)
+ const artistCell = $(cells[0]);
+ const artist = artistCell
+ .find('a')
+ .toArray()
+ .map((el) => $(el).text().trim())
+ .join(' / ');
+
+ // 解析专辑信息
+ const albumCell = $(cells[1]);
+ const album = albumCell.find('a').first().text().trim();
+ const albumLink = albumCell.find('a').first().attr('href');
+
+ const releaseDate = $(cells[2]).text().trim();
+ const company = $(cells[3]).text().trim();
+ const format = $(cells[4]).text().trim();
+
+ return {
+ title: `${artist} - ${album}`,
+ description: `
+ 艺人: ${artist}
+ 专辑: ${album}
+ 发行公司: ${company}
+ 格式: ${format}
+ 发行日期: ${releaseDate}
+ `,
+ link: albumLink ? `https://www.5music.com.tw/${albumLink}` : url,
+ pubDate: parseDate(releaseDate),
+ category: format,
+ author: artist,
+ };
+ });
+
+ return {
+ title: '五大唱片 - 新货上架',
+ link: url,
+ item: items,
+ language: 'zh-tw',
+ };
+}
diff --git a/lib/routes/5music/namespace.ts b/lib/routes/5music/namespace.ts
new file mode 100644
index 00000000000000..a553165dcc82df
--- /dev/null
+++ b/lib/routes/5music/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '五大唱片',
+ url: '5music.com.tw',
+ lang: 'zh-TW',
+ categories: ['shopping'],
+ description: '五大唱片是台湾五大唱片股份有限公司的简称,成立于1990年,是台湾最大的唱片公司之一。',
+};
diff --git a/lib/routes/60s-science/transcript.js b/lib/routes/60s-science/transcript.js
deleted file mode 100644
index a609b9b7028940..00000000000000
--- a/lib/routes/60s-science/transcript.js
+++ /dev/null
@@ -1,60 +0,0 @@
-const cheerio = require('cheerio');
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- const url = `https://www.scientificamerican.com/podcast/60-second-science/`;
- const cover = `https://static.scientificamerican.com/sciam/cache/file/484FA146-8892-477F-B38D18775563CC9E_small.jpg`;
-
- const res = await got.get(url);
- const $ = cheerio.load(res.data);
- const list = $('.underlined_text.t_small').get();
-
- const out = await Promise.all(
- list.map(async (item) => {
- const $ = cheerio.load(item);
- const fullTitle = $(item).attr('aria-label');
- const title = fullTitle.substring(0, fullTitle.length - 11);
- const address = $(item).attr('href');
- const cache = await ctx.cache.get(address);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
- const res = await got.get(address);
- const capture = cheerio.load(res.data);
-
- const tStr = '.article-header__divider > ul > li > span:nth-child(2)';
- const time = capture(tStr).text();
- const aStr = '.article-header__divider > ul > li > span:nth-child(1) > span';
- const author = capture(aStr).text();
-
- capture('.transcript__close').remove();
- const intro = capture('.article-media__object').html() + ' ';
- const contents =
- intro +
- capture('.transcript__inner')
- .html()
- .replace(/\[?\(?The above text.*this podcast.*?em.*?p>/g, '');
- const track = capture('.podcasts__media > div > a').attr('href');
- const single = {
- title,
- description: contents,
- enclosure_url: track,
- enclosure_type: 'audio/mpeg',
- link: address,
- guid: address,
- author,
- pubDate: new Date(time).toUTCString(),
- };
- ctx.cache.set(address, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
- ctx.state.data = {
- itunes_author: 'Scientific American',
- itunes_category: 'Science',
- image: cover,
- title: '60-Second Science',
- link: url,
- item: out,
- };
-};
diff --git a/lib/routes/69shu/article.ts b/lib/routes/69shu/article.ts
new file mode 100644
index 00000000000000..329038e67adbae
--- /dev/null
+++ b/lib/routes/69shu/article.ts
@@ -0,0 +1,97 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/article/:id',
+ name: '章节',
+ url: 'www.69shuba.cx',
+ maintainers: ['eternasuno'],
+ example: '/69shu/article/47117',
+ parameters: { id: '小说 id, 可在对应小说页 URL 中找到' },
+ categories: ['reading'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.69shuba.cx/book/:id.htm'],
+ target: '/article/:id',
+ },
+ ],
+ handler: async (ctx) => {
+ const { id } = ctx.req.param();
+ const link = `https://www.69shuba.cx/book/${id}.htm`;
+ const $ = load(await get(link));
+
+ const item = await Promise.all(
+ $('.qustime li>a')
+ .toArray()
+ .map((chapter) => createItem(chapter.attribs.href))
+ );
+
+ return {
+ title: $('h1>a').text(),
+ description: $('.navtxt>p:first-of-type').text(),
+ link,
+ item,
+ image: $('.bookimg2>img').attr('src'),
+ author: $('.booknav2>p:first-of-type>a').text(),
+ language: 'zh-cn',
+ };
+ },
+};
+
+const createItem = (url: string) =>
+ cache.tryGet(url, async () => {
+ const $ = load(await get(url));
+ const { articleid, chapterid, chaptername } = parseObject(/bookinfo\s?=\s?{[\S\s]+?}/, $('head>script:not([src])').text());
+ const decryptionMap = parseObject(/_\d+\s?=\s?{[\S\s]+?}/, $('.txtnav+script').text());
+
+ return {
+ title: chaptername,
+ description: decrypt($('.txtnav').html() || '', articleid, chapterid, decryptionMap),
+ link: url,
+ };
+ }) as Promise;
+
+const get = async (url: string, encoding = 'gbk') => new TextDecoder(encoding).decode(await ofetch(url, { responseType: 'arrayBuffer' }));
+
+const parseObject = (reg: RegExp, str: string): Record => {
+ const obj = {};
+ const match = reg.exec(str);
+ if (match) {
+ for (const line of match[0].matchAll(/(\w+):\s?["']?([\S\s]+?)["']?[\n,}]/g)) {
+ obj[line[1]] = line[2];
+ }
+ }
+
+ return obj;
+};
+
+const decrypt = (txt: string, articleid: string, chapterid: string, decryptionMap: Record) => {
+ if (!txt || txt.length < 10) {
+ return txt;
+ }
+
+ const lineMap = {};
+ const articleKey = Number(articleid) + 3_061_711;
+ const chapterKey = Number(chapterid) + 3_421_001;
+ for (const key of Object.keys(decryptionMap)) {
+ lineMap[(Number(key) ^ chapterKey) - articleKey] = (Number(decryptionMap[key]) ^ chapterKey) - articleKey;
+ }
+
+ return txt
+ .replaceAll(/\u2003|\n/g, '')
+ .split(' ')
+ .flatMap((line, index, array) => (lineMap[index] ? array[lineMap[index]] : line).split(' '))
+ .slice(1, -2)
+ .join(' ');
+};
diff --git a/lib/routes/69shu/namespace.ts b/lib/routes/69shu/namespace.ts
new file mode 100644
index 00000000000000..6c19ebe51938bd
--- /dev/null
+++ b/lib/routes/69shu/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '69书吧',
+ url: '69shuba.cx',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/6park/index.ts b/lib/routes/6park/index.ts
new file mode 100644
index 00000000000000..42009e9e9ac5bb
--- /dev/null
+++ b/lib/routes/6park/index.ts
@@ -0,0 +1,86 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/index/:id?/:type?/:keyword?',
+ name: '首页',
+ maintainers: ['nczitzk', 'cscnk52'],
+ handler,
+ example: '/6park/index',
+ parameters: { id: '分站,见下表,默认为史海钩沉', type: '类型,可选值为 gold、type,默认为空', keyword: '关键词,可选,默认为空' },
+ radar: [
+ {
+ source: ['club.6parkbbs.com/:id/index.php', 'club.6parkbbs.com/'],
+ target: '/:id?',
+ },
+ ],
+ description: `| 婚姻家庭 | 魅力时尚 | 女性频道 | 生活百态 | 美食厨房 | 非常影音 | 车迷沙龙 | 游戏天地 | 卡通漫画 | 体坛纵横 | 运动健身 | 电脑前线 | 数码家电 | 旅游风向 | 摄影部落 | 奇珍异宝 | 笑口常开 | 娱乐八卦 | 吃喝玩乐 | 文化长廊 | 军事纵横 | 百家论坛 | 科技频道 | 爱子情怀 | 健康人生 | 博论天下 | 史海钩沉 | 网际谈兵 | 经济观察 | 谈股论金 | 杂论闲侃 | 唯美乐园 | 学习园地 | 命理玄机 | 宠物情缘 | 网络歌坛 | 音乐殿堂 | 情感世界 |
+|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|
+| life9 | life1 | chan10 | life2 | life6 | fr | enter7 | enter3 | enter6 | enter5 | sport | know1 | chan6 | life7 | chan8 | page | enter1 | enter8 | netstar | life10 | nz | other | chan2 | chan5 | life5 | bolun | chan1 | military | finance | chan4 | pk | gz1 | gz2 | gz3 | life8 | chan7 | enter4 | life3 |`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? 'chan1';
+ const type = ctx.req.param('type') ?? '';
+ const keyword = ctx.req.param('keyword') ?? '';
+
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
+
+ const rootUrl = 'https://club.6parkbbs.com';
+ const indexUrl = `${rootUrl}/${id}/index.php`;
+ const currentUrl = `${indexUrl}${type === '' || keyword === '' ? '' : type === 'gold' ? '?app=forum&act=gold' : `?action=search&act=threadsearch&app=forum&${type}=${keyword}&submit=${type === 'type' ? '查询' : '栏目搜索'}`}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('#d_list ul li, #thread_list li, .t_l .t_subject')
+ .toArray()
+ .slice(0, limit)
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a').first();
+
+ return {
+ link: `${rootUrl}/${id}/${a.attr('href')}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.title = content('title').text().replace(' -6park.com', '');
+ item.author = detailResponse.data.match(/送交者: .*>(.*)<.*\[/)[1];
+ item.pubDate = timezone(parseDate(detailResponse.data.match(/于 (.*) 已读/)[1], 'YYYY-MM-DD h:m'), +8);
+ item.description = content('pre')
+ .html()
+ .replaceAll('
', '')
+ .replaceAll(/6park.com<\/font>/g, '');
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/6park/namespace.ts b/lib/routes/6park/namespace.ts
new file mode 100644
index 00000000000000..5664e409c7aead
--- /dev/null
+++ b/lib/routes/6park/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '留园网',
+ url: 'club.6parkbbs.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/6park/news.ts b/lib/routes/6park/news.ts
new file mode 100644
index 00000000000000..2050185e5d8ed2
--- /dev/null
+++ b/lib/routes/6park/news.ts
@@ -0,0 +1,99 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/news/:site?/:id?/:keyword?',
+ radar: [
+ {
+ source: ['club.6parkbbs.com/:id/index.php', 'club.6parkbbs.com/'],
+ target: '/:id?',
+ },
+ ],
+ name: '新闻栏目',
+ maintainers: ['nczitzk', 'cscnk52'],
+ parameters: {
+ site: '分站,可选newspark、local,默认为 newspark',
+ id: '栏目 id,可选,默认为空',
+ keyword: '关键词,可选,默认为空',
+ },
+ description: `::: tip 提示
+若订阅 [时政](https://www.6parknews.com/newspark/index.php?type=1),其网址为 ,其中 \`newspark\` 为分站,\`1\` 为栏目 id。
+若订阅 [美国](https://local.6parknews.com/index.php?type_id=1),其网址为 ,其中 \`local\` 为分站,\`1\` 为栏目 id。
+:::`,
+ handler,
+};
+
+async function handler(ctx) {
+ const site = ctx.req.param('site') ?? 'newspark';
+ const id = ctx.req.param('id') ?? '';
+ const keyword = ctx.req.param('keyword') ?? '';
+
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
+
+ const isLocal = site === 'local';
+
+ const rootUrl = `https://${isLocal ? site : 'www'}.6parknews.com`;
+ const indexUrl = `${rootUrl}${isLocal ? '' : '/newspark'}/index.php`;
+ const currentUrl = `${indexUrl}${keyword ? `?act=newssearch&app=news&keywords=${keyword}&submit=查询` : id ? (Number.isNaN(id) ? `?act=${id}` : isLocal ? `?type_id=${id}` : `?type=${id}`) : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('#d_list ul li, #thread_list li, .t_l .t_subject')
+ .toArray()
+ .slice(0, limit)
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a').first();
+ const link = a.attr('href');
+
+ return {
+ title: a.text(),
+ link: link.startsWith('http') ? link : `${rootUrl}/${link.startsWith('view') ? `newspark/${link}` : link}`,
+ };
+ });
+
+ items = await Promise.all(
+ items
+ .filter((item) => /6parknews\.com/.test(item.link))
+ .map((item) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ const matches = detailResponse.data.match(/新闻来源:(.*?)于.*(\d{4}(?:-\d{2}){2} (?:\d{1,2}:){2}\d{1,2})/);
+
+ item.title = content('h2').text();
+ item.author = matches[1].trim();
+ item.pubDate = timezone(parseDate(matches[2], 'YYYY-MM-DD h:m'), +8);
+ item.description = content('#shownewsc').html().replaceAll('
', '');
+ } catch {
+ // no-empty
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/6v123/index.ts b/lib/routes/6v123/index.ts
new file mode 100644
index 00000000000000..12c02661d087c6
--- /dev/null
+++ b/lib/routes/6v123/index.ts
@@ -0,0 +1,488 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+import iconv from 'iconv-lite';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'dy' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '25', 10);
+
+ const encoding: string = 'gb2312';
+
+ const baseUrl: string = 'https://www.hao6v.me';
+ const targetUrl: string = new URL(category.startsWith('gvod') ? `${category}.html` : category, baseUrl).href;
+
+ const response = await ofetch(targetUrl, {
+ responseType: 'arrayBuffer',
+ });
+ const $: CheerioAPI = load(iconv.decode(Buffer.from(response), encoding));
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = $('ul.list li')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('a').text();
+ const pubDateStr: string | undefined = $el
+ .find('span')
+ .text()
+ .replaceAll(/(\[|\])/g, '');
+ const linkUrl: string | undefined = $el.find('a').attr('href');
+ const guid: string = `${linkUrl}#${title}`;
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? parseDate(pubDateStr, ['MM-DD', 'YYYY-MM-DD']) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ guid,
+ id: guid,
+ updated: upDatedStr ? parseDate(upDatedStr, ['MM-DD', 'YYYY-MM-DD']) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link, {
+ responseType: 'arrayBuffer',
+ });
+ const $$: CheerioAPI = load(iconv.decode(Buffer.from(detailResponse), encoding));
+
+ $$('div#endText div.fl').remove();
+ $$('div#endText div.fr').remove();
+ $$('div#endText div.cr').remove();
+
+ $$('div#endText div.tps').remove();
+ $$('div#endText div.downtps').remove();
+
+ const title: string = $$('h1').text();
+ const description: string | undefined = $$('div#endText').html() ?? undefined;
+ const pubDateStr: string | undefined = item.link?.match(/\/(\d{4}-\d{2}-\d{2})\/\d+\.html/)?.[1];
+ const categoryEls: Element[] = $$('div#endText p a').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).text()?.trim()).filter(Boolean))];
+ const image: string | undefined = $$('div#endText p img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ const $enclosureEl: Cheerio = $$('td a[href^="magnet"]').last();
+ const enclosureUrl: string | undefined = $enclosureEl.attr('href');
+
+ if (enclosureUrl) {
+ const enclosureType: string = 'application/x-bittorrent';
+ const enclosureTitle: string = $enclosureEl.text();
+
+ processedItem = {
+ ...processedItem,
+ enclosure_url: enclosureUrl,
+ enclosure_type: enclosureType,
+ enclosure_title: enclosureTitle || title,
+ };
+ }
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ return {
+ title: `${$('title').text().split(/,/).pop()} - ${$('div.t a').last().text()}`,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: new URL('images/logo.gif', baseUrl).href,
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: '分类',
+ url: 'www.hao6v.me',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/6v123/dy',
+ parameters: {
+ category: {
+ description: '分类,默认为 `dy`,即最新电影,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '最新电影',
+ value: 'dy',
+ },
+ {
+ label: '国语配音电影',
+ value: 'gydy',
+ },
+ {
+ label: '动漫新番',
+ value: 'zydy',
+ },
+ {
+ label: '经典高清',
+ value: 'gq',
+ },
+ {
+ label: '动画电影',
+ value: 'jddy',
+ },
+ {
+ label: '3D 电影',
+ value: '3D',
+ },
+ {
+ label: '真人秀',
+ value: 'shoujidianyingmp4',
+ },
+ {
+ label: '国剧',
+ value: 'dlz',
+ },
+ {
+ label: '日韩剧',
+ value: 'rj',
+ },
+ {
+ label: '欧美剧',
+ value: 'mj',
+ },
+ {
+ label: '综艺节目',
+ value: 'zy',
+ },
+ {
+ label: '港台电影',
+ value: 's/gangtaidianying',
+ },
+ {
+ label: '日韩电影',
+ value: 's/jingdiandianying',
+ },
+ {
+ label: '喜剧',
+ value: 's/xiju',
+ },
+ {
+ label: '动作',
+ value: 's/dongzuo',
+ },
+ {
+ label: '爱情',
+ value: 's/aiqing',
+ },
+ {
+ label: '科幻',
+ value: 's/kehuan',
+ },
+ {
+ label: '奇幻',
+ value: 's/qihuan',
+ },
+ {
+ label: '神秘',
+ value: 's/shenmi',
+ },
+ {
+ label: '幻想',
+ value: 's/huanxiang',
+ },
+ {
+ label: '恐怖',
+ value: 's/kongbu',
+ },
+ {
+ label: '战争',
+ value: 's/zhanzheng',
+ },
+ {
+ label: '冒险',
+ value: 's/maoxian',
+ },
+ {
+ label: '惊悚',
+ value: 's/jingsong',
+ },
+ {
+ label: '剧情',
+ value: 's/juqingpian',
+ },
+ {
+ label: '传记',
+ value: 's/zhuanji',
+ },
+ {
+ label: '历史',
+ value: 's/lishi',
+ },
+ {
+ label: '纪录',
+ value: 's/jilu',
+ },
+ {
+ label: '印度电影',
+ value: 's/yindudianying',
+ },
+ {
+ label: '国产电影',
+ value: 's/guochandianying',
+ },
+ {
+ label: '欧洲电影',
+ value: 's/xijudianying',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+订阅 [最新电影](https://www.hao6v.me/dy/),其源网址为 \`https://www.hao6v.me/dy/\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/6v123/dy\`](https://rsshub.app/6v123/dy)。
+:::
+
+
+ 更多分类
+
+| 分类 | ID |
+| ---------------------------------------------------- | ----------------------------------------------------------------- |
+| [最新电影](https://www.hao6v.me/dy/) | [dy](https://rsshub.app/6v123/dy) |
+| [国语配音电影](https://www.hao6v.me/gydy/) | [gydy](https://rsshub.app/6v123/gydy) |
+| [动漫新番](https://www.hao6v.me/zydy/) | [zydy](https://rsshub.app/6v123/zydy) |
+| [经典高清](https://www.hao6v.me/gq/) | [gq](https://rsshub.app/6v123/gq) |
+| [动画电影](https://www.hao6v.me/jddy/) | [jddy](https://rsshub.app/6v123/jddy) |
+| [3D 电影](https://www.hao6v.me/3D/) | [3D](https://rsshub.app/6v123/3D) |
+| [真人秀](https://www.hao6v.me/shoujidianyingmp4/) | [shoujidianyingmp4](https://rsshub.app/6v123/shoujidianyingmp4) |
+| [国剧](https://www.hao6v.me/dlz/) | [dlz](https://rsshub.app/6v123/dlz) |
+| [日韩剧](https://www.hao6v.me/rj/) | [rj](https://rsshub.app/6v123/rj) |
+| [欧美剧](https://www.hao6v.me/mj/) | [mj](https://rsshub.app/6v123/mj) |
+| [综艺节目](https://www.hao6v.me/zy/) | [zy](https://rsshub.app/6v123/zy) |
+| [港台电影](https://www.hao6v.me/s/gangtaidianying/) | [s/gangtaidianying](https://rsshub.app/6v123/s/gangtaidianying) |
+| [日韩电影](https://www.hao6v.me/s/jingdiandianying/) | [s/jingdiandianying](https://rsshub.app/6v123/s/jingdiandianying) |
+| [喜剧](https://www.hao6v.me/s/xiju/) | [s/xiju](https://rsshub.app/6v123/s/xiju) |
+| [动作](https://www.hao6v.me/s/dongzuo/) | [s/dongzuo](https://rsshub.app/6v123/s/dongzuo) |
+| [爱情](https://www.hao6v.me/s/aiqing/) | [s/aiqing](https://rsshub.app/6v123/s/aiqing) |
+| [科幻](https://www.hao6v.me/s/kehuan/) | [s/kehuan](https://rsshub.app/6v123/s/kehuan) |
+| [奇幻](https://www.hao6v.me/s/qihuan/) | [s/qihuan](https://rsshub.app/6v123/s/qihuan) |
+| [神秘](https://www.hao6v.me/s/shenmi/) | [s/shenmi](https://rsshub.app/6v123/s/shenmi) |
+| [幻想](https://www.hao6v.me/s/huanxiang/) | [s/huanxiang](https://rsshub.app/6v123/s/huanxiang) |
+| [恐怖](https://www.hao6v.me/s/kongbu/) | [s/kongbu](https://rsshub.app/6v123/s/kongbu) |
+| [战争](https://www.hao6v.me/s/zhanzheng/) | [s/zhanzheng](https://rsshub.app/6v123/s/zhanzheng) |
+| [冒险](https://www.hao6v.me/s/maoxian/) | [s/maoxian](https://rsshub.app/6v123/s/maoxian) |
+| [惊悚](https://www.hao6v.me/s/jingsong/) | [s/jingsong](https://rsshub.app/6v123/s/jingsong) |
+| [剧情](https://www.hao6v.me/s/juqingpian/) | [s/juqingpian](https://rsshub.app/6v123/s/juqingpian) |
+| [传记](https://www.hao6v.me/s/zhuanji/) | [s/zhuanji](https://rsshub.app/6v123/s/zhuanji) |
+| [历史](https://www.hao6v.me/s/lishi/) | [s/lishi](https://rsshub.app/6v123/s/lishi) |
+| [纪录](https://www.hao6v.me/s/jilu/) | [s/jilu](https://rsshub.app/6v123/s/jilu) |
+| [印度电影](https://www.hao6v.me/s/yindudianying/) | [s/yindudianying](https://rsshub.app/6v123/s/yindudianying) |
+| [国产电影](https://www.hao6v.me/s/guochandianying/) | [s/guochandianying](https://rsshub.app/6v123/s/guochandianying) |
+| [欧洲电影](https://www.hao6v.me/s/xijudianying/) | [s/xijudianying](https://rsshub.app/6v123/s/xijudianying) |
+
+
+`,
+ categories: ['multimedia'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: true,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.hao6v.me/:category'],
+ target: '/:category',
+ },
+ {
+ title: '最新电影',
+ source: ['www.hao6v.me/dy/'],
+ target: '/dy',
+ },
+ {
+ title: '国语配音电影',
+ source: ['www.hao6v.me/gydy/'],
+ target: '/gydy',
+ },
+ {
+ title: '动漫新番',
+ source: ['www.hao6v.me/zydy/'],
+ target: '/zydy',
+ },
+ {
+ title: '经典高清',
+ source: ['www.hao6v.me/gq/'],
+ target: '/gq',
+ },
+ {
+ title: '动画电影',
+ source: ['www.hao6v.me/jddy/'],
+ target: '/jddy',
+ },
+ {
+ title: '3D电影',
+ source: ['www.hao6v.me/3D/'],
+ target: '/3D',
+ },
+ {
+ title: '真人秀',
+ source: ['www.hao6v.me/shoujidianyingmp4/'],
+ target: '/shoujidianyingmp4',
+ },
+ {
+ title: '国剧',
+ source: ['www.hao6v.me/dlz/'],
+ target: '/dlz',
+ },
+ {
+ title: '日韩剧',
+ source: ['www.hao6v.me/rj/'],
+ target: '/rj',
+ },
+ {
+ title: '欧美剧',
+ source: ['www.hao6v.me/mj/'],
+ target: '/mj',
+ },
+ {
+ title: '综艺节目',
+ source: ['www.hao6v.me/zy/'],
+ target: '/zy',
+ },
+ {
+ title: '港台电影',
+ source: ['www.hao6v.me/s/gangtaidianying/'],
+ target: '/s/gangtaidianying',
+ },
+ {
+ title: '日韩电影',
+ source: ['www.hao6v.me/s/jingdiandianying/'],
+ target: '/s/jingdiandianying',
+ },
+ {
+ title: '喜剧',
+ source: ['www.hao6v.me/s/xiju/'],
+ target: '/s/xiju',
+ },
+ {
+ title: '动作',
+ source: ['www.hao6v.me/s/dongzuo/'],
+ target: '/s/dongzuo',
+ },
+ {
+ title: '爱情',
+ source: ['www.hao6v.me/s/aiqing/'],
+ target: '/s/aiqing',
+ },
+ {
+ title: '科幻',
+ source: ['www.hao6v.me/s/kehuan/'],
+ target: '/s/kehuan',
+ },
+ {
+ title: '奇幻',
+ source: ['www.hao6v.me/s/qihuan/'],
+ target: '/s/qihuan',
+ },
+ {
+ title: '神秘',
+ source: ['www.hao6v.me/s/shenmi/'],
+ target: '/s/shenmi',
+ },
+ {
+ title: '幻想',
+ source: ['www.hao6v.me/s/huanxiang/'],
+ target: '/s/huanxiang',
+ },
+ {
+ title: '恐怖',
+ source: ['www.hao6v.me/s/kongbu/'],
+ target: '/s/kongbu',
+ },
+ {
+ title: '战争',
+ source: ['www.hao6v.me/s/zhanzheng/'],
+ target: '/s/zhanzheng',
+ },
+ {
+ title: '冒险',
+ source: ['www.hao6v.me/s/maoxian/'],
+ target: '/s/maoxian',
+ },
+ {
+ title: '惊悚',
+ source: ['www.hao6v.me/s/jingsong/'],
+ target: '/s/jingsong',
+ },
+ {
+ title: '剧情',
+ source: ['www.hao6v.me/s/juqingpian/'],
+ target: '/s/juqingpian',
+ },
+ {
+ title: '传记',
+ source: ['www.hao6v.me/s/zhuanji/'],
+ target: '/s/zhuanji',
+ },
+ {
+ title: '历史',
+ source: ['www.hao6v.me/s/lishi/'],
+ target: '/s/lishi',
+ },
+ {
+ title: '纪录',
+ source: ['www.hao6v.me/s/jilu/'],
+ target: '/s/jilu',
+ },
+ {
+ title: '印度电影',
+ source: ['www.hao6v.me/s/yindudianying/'],
+ target: '/s/yindudianying',
+ },
+ {
+ title: '国产电影',
+ source: ['www.hao6v.me/s/guochandianying/'],
+ target: '/s/guochandianying',
+ },
+ {
+ title: '欧洲电影',
+ source: ['www.hao6v.me/s/xijudianying/'],
+ target: '/s/xijudianying',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/6v123/latest-movies.ts b/lib/routes/6v123/latest-movies.ts
new file mode 100644
index 00000000000000..0a1a8a48a06d45
--- /dev/null
+++ b/lib/routes/6v123/latest-movies.ts
@@ -0,0 +1,40 @@
+import type { Route } from '@/types';
+
+import { processItems } from './utils';
+
+const baseURL = 'https://www.hao6v.cc/gvod/zx.html';
+
+export const route: Route = {
+ path: '/latestMovies',
+ categories: ['multimedia'],
+ example: '/6v123/latestMovies',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: true,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['hao6v.com/', 'hao6v.com/gvod/zx.html'],
+ },
+ ],
+ name: '最新电影',
+ maintainers: ['tc9011'],
+ handler,
+ url: 'hao6v.com/',
+};
+
+async function handler(ctx) {
+ const item = await processItems(ctx, baseURL, [/第*集/, /第*季/, /(ep)\d+/i, /(s)\d+/i, /更新/]);
+
+ return {
+ title: '6v电影-最新电影',
+ link: baseURL,
+ description: '6v最新电影RSS',
+ item,
+ };
+}
diff --git a/lib/routes/6v123/latest-tvseries.ts b/lib/routes/6v123/latest-tvseries.ts
new file mode 100644
index 00000000000000..e19fd5941a905e
--- /dev/null
+++ b/lib/routes/6v123/latest-tvseries.ts
@@ -0,0 +1,40 @@
+import type { Route } from '@/types';
+
+import { processItems } from './utils';
+
+const baseURL = 'https://www.hao6v.tv/gvod/dsj.html';
+
+export const route: Route = {
+ path: '/latestTVSeries',
+ categories: ['multimedia'],
+ example: '/6v123/latestTVSeries',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: true,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['hao6v.com/', 'hao6v.com/gvod/dsj.html'],
+ },
+ ],
+ name: '最新电视剧',
+ maintainers: ['tc9011'],
+ handler,
+ url: 'hao6v.com/',
+};
+
+async function handler(ctx) {
+ const item = await processItems(ctx, baseURL, []);
+
+ return {
+ title: '6v电影-最新电影',
+ link: baseURL,
+ description: '6v最新电影RSS',
+ item,
+ };
+}
diff --git a/lib/routes/6v123/namespace.ts b/lib/routes/6v123/namespace.ts
new file mode 100644
index 00000000000000..80d157fef6ded8
--- /dev/null
+++ b/lib/routes/6v123/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '6v 电影',
+ url: 'hao6v.cc',
+ categories: ['multimedia'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/6v123/utils.ts b/lib/routes/6v123/utils.ts
new file mode 100644
index 00000000000000..f4e8bab9096bad
--- /dev/null
+++ b/lib/routes/6v123/utils.ts
@@ -0,0 +1,87 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export async function loadDetailPage(link) {
+ const response = await got.get(link, {
+ responseType: 'buffer',
+ });
+ response.data = iconv.decode(response.data, 'gb2312');
+
+ const $ = load(response.data);
+
+ return {
+ title: $('title')
+ .text()
+ .replaceAll(/,免费下载,迅雷下载|,6v电影/g, ''),
+ description: $('meta[name="description"]').attr('content'),
+ enclosure_urls: $('table td')
+ .toArray()
+ .map((e) => ({
+ title: $(e).text().replace('磁力:', ''),
+ magnet: $(e).find('a').attr('href'),
+ }))
+ .filter((item) => item.magnet?.includes('magnet')),
+ };
+}
+
+export async function processItems(ctx, baseURL, exclude) {
+ const response = await got.get(baseURL, {
+ responseType: 'buffer',
+ });
+ response.data = iconv.decode(response.data, 'gb2312');
+
+ const $ = load(response.data);
+ const list = $('ul.list')[0].children;
+
+ const process = await Promise.all(
+ list.map((item) => {
+ const link = $(item).find('a');
+ const href = link.attr('href');
+ const pubDate = timezone(parseDate($(item).find('span').text().replaceAll(/[[\]]/g, ''), 'MM-DD'), +8);
+ const text = link.text();
+
+ if (href === undefined) {
+ return;
+ }
+
+ if (exclude && exclude.some((e) => e.test(text))) {
+ // 过滤掉满足正则的标题条目
+ return;
+ }
+
+ const itemUrl = 'https://www.hao6v.cc' + link.attr('href');
+
+ return cache.tryGet(itemUrl, async () => {
+ const detailInfo = await loadDetailPage(itemUrl);
+
+ if (detailInfo.enclosure_urls.length > 1) {
+ return detailInfo.enclosure_urls.map((url) => ({
+ enclosure_url: url.magnet,
+ enclosure_type: 'application/x-bittorrent',
+ title: `${link.text()} ( ${url.title} )`,
+ description: detailInfo.description,
+ pubDate,
+ link: itemUrl,
+ guid: `${itemUrl}#${url.title}`,
+ }));
+ }
+
+ return {
+ enclosure_url: detailInfo.enclosure_urls.length === 0 ? '' : detailInfo.enclosure_urls[0].magnet,
+ enclosure_type: 'application/x-bittorrent',
+ title: link.text(),
+ description: detailInfo.description,
+ pubDate,
+ link: itemUrl,
+ };
+ });
+ })
+ );
+
+ return process.filter((item) => item !== undefined).flat();
+}
diff --git a/lib/routes/755/user.js b/lib/routes/755/user.js
deleted file mode 100644
index c544e64c8f4988..00000000000000
--- a/lib/routes/755/user.js
+++ /dev/null
@@ -1,71 +0,0 @@
-const got = require('@/utils/got');
-
-const postTypes = {
- 1: '文字',
- 2: '表情',
- 3: '纯图片',
- 4: '评论',
- 5: '转发',
- 6: '纯视频',
- 7: '图片文字',
- 8: '视频文字',
-};
-
-module.exports = async (ctx) => {
- const username = ctx.params.username;
-
- const url = `https://api.7gogo.jp/web/v2/talks/${username}/posts?talkId=${username}&direction=PREV`;
-
- const response = await got.get(url);
- const list = response.data.data;
-
- const user_display_name = list[0].user.name;
- const user_description = list[0].user.description;
- const user_image = list[0].user.coverImageUrl;
-
- const GetContent = (post) =>
- post.body &&
- post.body
- .map((item) => {
- switch (item.bodyType) {
- case 1:
- return `${item.text.replace(/\n/gm, ' ')}
`;
- case 2:
- return ` `;
- case 3:
- return ` `;
- case 4:
- return `${item.comment.user.name}: ${item.comment.comment.body.replace(/\n/gm, ' ')} `;
- case 7:
- return `${item.talk.name}: ${GetContent(item.post)} `;
- case 8:
- return `Video `;
- default:
- return '';
- }
- })
- .join('');
-
- const GetTitle = (post) => {
- const texts = post.body.filter((s) => s.bodyType === 1);
- const type_name = postTypes[post.postType];
- return texts.length !== 0 ? texts[0].text.split('\n')[0] : type_name;
- };
-
- const ProcessFeed = (data) =>
- data.slice(0, 20).map((item) => ({
- title: GetTitle(item.post),
- author: user_display_name,
- description: GetContent(item.post),
- pubDate: new Date(item.post.time * 1000).toUTCString(),
- link: `https://7gogo.jp/${username}/${item.post.postId}`,
- }));
-
- ctx.state.data = {
- title: `${user_display_name} - 755`,
- image: user_image,
- description: user_description,
- link: `https://7gogo.jp/${username}`,
- item: ProcessFeed(list),
- };
-};
diff --git a/lib/routes/78dm/index.ts b/lib/routes/78dm/index.ts
new file mode 100644
index 00000000000000..d0ea3ca0f57420
--- /dev/null
+++ b/lib/routes/78dm/index.ts
@@ -0,0 +1,472 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx) => {
+ const { category = 'news' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 10;
+
+ const rootUrl = 'https://www.78dm.net';
+ const currentUrl = new URL(category.includes('/') ? `${category}.html` : category, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('section.box-content div.card a.card-title')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item).parent();
+
+ const title = item.find('a.card-title').text();
+
+ const src = item.find('a.card-image img').prop('data-src');
+ const image = src?.startsWith('//') ? `https:${src}` : src;
+
+ const description = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ });
+ const pubDate = item.find('div.card-info span.item').last().text();
+
+ const href = item.find('a.card-title').prop('href');
+
+ return {
+ title,
+ description,
+ pubDate: pubDate && /\d{4}(?:\.\d{2}){2}\s\d{2}:\d{2}/.test(pubDate) ? timezone(parseDate(pubDate, 'YYYY.MM.DD HH:mm'), +8) : undefined,
+ link: href?.startsWith('//') ? `https:${href}` : href,
+ category: [
+ ...new Set([
+ ...item
+ .find('span.tag-title')
+ .toArray()
+ .map((c) => $(c).text()),
+ item.find('div.card-info span.item').first().text(),
+ ]),
+ ].filter(Boolean),
+ image,
+ banner: image,
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ $$('i.p-status').remove();
+
+ $$('div.image-text-content p img.lazy').each((_, el) => {
+ el = $$(el);
+
+ const src = el.prop('data-src');
+ const image = src?.startsWith('//') ? `https:${src}` : src;
+
+ el.parent().replaceWith(
+ art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: el.prop('title') ?? '',
+ },
+ ]
+ : undefined,
+ })
+ );
+ });
+
+ const title = $$('h2.title').text();
+ const description =
+ item.description +
+ art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div.image-text-content').first().html(),
+ });
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = timezone(parseDate($$('p.push-time').text().split(/:/).pop()), +8);
+ item.author = $$('a.push-username').contents().first().text();
+ item.content = {
+ html: description,
+ text: $$('div.image-text-content').first().text(),
+ };
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const title = $('title').text();
+ const image = new URL($('a.logo img').prop('src'), rootUrl).href;
+
+ return {
+ title: `${title} | ${$('div.actived').text()}`,
+ description: $('meta[name="description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('meta[property="og:site_name"]').prop('content'),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: '分类',
+ url: '78dm.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/78dm/news',
+ parameters: { category: '分类,默认为 `news`,即新品速递,可在对应分类页 URL 中找到' },
+ description: `::: tip
+ 若订阅 [新品速递](https://www.78dm.net/news),网址为 \`https://www.78dm.net/news\`。截取 \`https://www.78dm.net/\` 到末尾的部分 \`news\` 作为参数填入,此时路由为 [\`/78dm/news\`](https://rsshub.app/78dm/news)。
+
+ 若订阅 [精彩评测 - 变形金刚](https://www.78dm.net/eval_list/109/0/0/1.html),网址为 \`https://www.78dm.net/eval_list/109/0/0/1.html\`。截取 \`https://www.78dm.net/\` 到末尾 \`.html\` 的部分 \`eval_list/109/0/0/1\` 作为参数填入,此时路由为 [\`/78dm/eval_list/109/0/0/1\`](https://rsshub.app/78dm/eval_list/109/0/0/1)。
+:::
+
+
+更多分类
+
+#### [新品速递](https://www.78dm.net/news)
+
+| 分类 | ID |
+| -------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| [全部](https://www.78dm.net/news/0/0/0/0/0/0/0/1.html) | [news/0/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/0/0/0/0/0/0/1) |
+| [变形金刚](https://www.78dm.net/news/3/0/0/0/0/0/0/1.html) | [news/3/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/3/0/0/0/0/0/0/1) |
+| [高达](https://www.78dm.net/news/4/0/0/0/0/0/0/1.html) | [news/4/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/4/0/0/0/0/0/0/1) |
+| [圣斗士](https://www.78dm.net/news/2/0/0/0/0/0/0/1.html) | [news/2/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/2/0/0/0/0/0/0/1) |
+| [海贼王](https://www.78dm.net/news/8/0/0/0/0/0/0/1.html) | [news/8/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/8/0/0/0/0/0/0/1) |
+| [PVC 手办](https://www.78dm.net/news/0/5/0/0/0/0/0/1.html) | [news/0/5/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/5/0/0/0/0/0/1) |
+| [拼装模型](https://www.78dm.net/news/0/1/0/0/0/0/0/1.html) | [news/0/1/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/1/0/0/0/0/0/1) |
+| [机甲成品](https://www.78dm.net/news/0/2/0/0/0/0/0/1.html) | [news/0/2/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/2/0/0/0/0/0/1) |
+| [特摄](https://www.78dm.net/news/0/3/0/0/0/0/0/1.html) | [news/0/3/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/3/0/0/0/0/0/1) |
+| [美系](https://www.78dm.net/news/0/4/0/0/0/0/0/1.html) | [news/0/4/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/4/0/0/0/0/0/1) |
+| [GK](https://www.78dm.net/news/0/6/0/0/0/0/0/1.html) | [news/0/6/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/6/0/0/0/0/0/1) |
+| [扭蛋盒蛋食玩](https://www.78dm.net/news/0/7/0/0/0/0/0/1.html) | [news/0/7/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/7/0/0/0/0/0/1) |
+| [其他](https://www.78dm.net/news/0/8/0/0/0/0/0/1.html) | [news/0/8/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/8/0/0/0/0/0/1) |
+| [综合](https://www.78dm.net/news/0/9/0/0/0/0/0/1.html) | [news/0/9/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/9/0/0/0/0/0/1) |
+| [军模](https://www.78dm.net/news/0/10/0/0/0/0/0/1.html) | [news/0/10/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/10/0/0/0/0/0/1) |
+| [民用](https://www.78dm.net/news/0/11/0/0/0/0/0/1.html) | [news/0/11/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/11/0/0/0/0/0/1) |
+| [配件](https://www.78dm.net/news/0/12/0/0/0/0/0/1.html) | [news/0/12/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/12/0/0/0/0/0/1) |
+| [工具](https://www.78dm.net/news/0/13/0/0/0/0/0/1.html) | [news/0/13/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/13/0/0/0/0/0/1) |
+
+#### [精彩评测](https://www.78dm.net/eval_list)
+
+| 分类 | ID |
+| --------------------------------------------------------- | ------------------------------------------------------------------ |
+| [全部](https://www.78dm.net/eval_list/0/0/0/1.html) | [eval_list/0/0/0/1](https://rsshub.app/78dm/eval_list/0/0/0/1) |
+| [变形金刚](https://www.78dm.net/eval_list/109/0/0/1.html) | [eval_list/109/0/0/1](https://rsshub.app/78dm/eval_list/109/0/0/1) |
+| [高达](https://www.78dm.net/eval_list/110/0/0/1.html) | [eval_list/110/0/0/1](https://rsshub.app/78dm/eval_list/110/0/0/1) |
+| [圣斗士](https://www.78dm.net/eval_list/111/0/0/1.html) | [eval_list/111/0/0/1](https://rsshub.app/78dm/eval_list/111/0/0/1) |
+| [海贼王](https://www.78dm.net/eval_list/112/0/0/1.html) | [eval_list/112/0/0/1](https://rsshub.app/78dm/eval_list/112/0/0/1) |
+| [PVC 手办](https://www.78dm.net/eval_list/115/0/0/1.html) | [eval_list/115/0/0/1](https://rsshub.app/78dm/eval_list/115/0/0/1) |
+| [拼装模型](https://www.78dm.net/eval_list/113/0/0/1.html) | [eval_list/113/0/0/1](https://rsshub.app/78dm/eval_list/113/0/0/1) |
+| [机甲成品](https://www.78dm.net/eval_list/114/0/0/1.html) | [eval_list/114/0/0/1](https://rsshub.app/78dm/eval_list/114/0/0/1) |
+| [特摄](https://www.78dm.net/eval_list/116/0/0/1.html) | [eval_list/116/0/0/1](https://rsshub.app/78dm/eval_list/116/0/0/1) |
+| [美系](https://www.78dm.net/eval_list/117/0/0/1.html) | [eval_list/117/0/0/1](https://rsshub.app/78dm/eval_list/117/0/0/1) |
+| [GK](https://www.78dm.net/eval_list/118/0/0/1.html) | [eval_list/118/0/0/1](https://rsshub.app/78dm/eval_list/118/0/0/1) |
+| [综合](https://www.78dm.net/eval_list/120/0/0/1.html) | [eval_list/120/0/0/1](https://rsshub.app/78dm/eval_list/120/0/0/1) |
+
+#### [好贴推荐](https://www.78dm.net/ht_list)
+
+| 分类 | ID |
+| ------------------------------------------------------- | -------------------------------------------------------------- |
+| [全部](https://www.78dm.net/ht_list/0/0/0/1.html) | [ht_list/0/0/0/1](https://rsshub.app/78dm/ht_list/0/0/0/1) |
+| [变形金刚](https://www.78dm.net/ht_list/95/0/0/1.html) | [ht_list/95/0/0/1](https://rsshub.app/78dm/ht_list/95/0/0/1) |
+| [高达](https://www.78dm.net/ht_list/96/0/0/1.html) | [ht_list/96/0/0/1](https://rsshub.app/78dm/ht_list/96/0/0/1) |
+| [圣斗士](https://www.78dm.net/ht_list/98/0/0/1.html) | [ht_list/98/0/0/1](https://rsshub.app/78dm/ht_list/98/0/0/1) |
+| [海贼王](https://www.78dm.net/ht_list/99/0/0/1.html) | [ht_list/99/0/0/1](https://rsshub.app/78dm/ht_list/99/0/0/1) |
+| [PVC 手办](https://www.78dm.net/ht_list/100/0/0/1.html) | [ht_list/100/0/0/1](https://rsshub.app/78dm/ht_list/100/0/0/1) |
+| [拼装模型](https://www.78dm.net/ht_list/101/0/0/1.html) | [ht_list/101/0/0/1](https://rsshub.app/78dm/ht_list/101/0/0/1) |
+| [机甲成品](https://www.78dm.net/ht_list/102/0/0/1.html) | [ht_list/102/0/0/1](https://rsshub.app/78dm/ht_list/102/0/0/1) |
+| [特摄](https://www.78dm.net/ht_list/103/0/0/1.html) | [ht_list/103/0/0/1](https://rsshub.app/78dm/ht_list/103/0/0/1) |
+| [美系](https://www.78dm.net/ht_list/104/0/0/1.html) | [ht_list/104/0/0/1](https://rsshub.app/78dm/ht_list/104/0/0/1) |
+| [GK](https://www.78dm.net/ht_list/105/0/0/1.html) | [ht_list/105/0/0/1](https://rsshub.app/78dm/ht_list/105/0/0/1) |
+| [综合](https://www.78dm.net/ht_list/107/0/0/1.html) | [ht_list/107/0/0/1](https://rsshub.app/78dm/ht_list/107/0/0/1) |
+| [装甲战车](https://www.78dm.net/ht_list/131/0/0/1.html) | [ht_list/131/0/0/1](https://rsshub.app/78dm/ht_list/131/0/0/1) |
+| [舰船模型](https://www.78dm.net/ht_list/132/0/0/1.html) | [ht_list/132/0/0/1](https://rsshub.app/78dm/ht_list/132/0/0/1) |
+| [飞机模型](https://www.78dm.net/ht_list/133/0/0/1.html) | [ht_list/133/0/0/1](https://rsshub.app/78dm/ht_list/133/0/0/1) |
+| [民用模型](https://www.78dm.net/ht_list/134/0/0/1.html) | [ht_list/134/0/0/1](https://rsshub.app/78dm/ht_list/134/0/0/1) |
+| [兵人模型](https://www.78dm.net/ht_list/135/0/0/1.html) | [ht_list/135/0/0/1](https://rsshub.app/78dm/ht_list/135/0/0/1) |
+
+ `,
+ categories: ['new-media'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.78dm.net/:category?'],
+ target: (params) => {
+ const category = params.category?.replace(/\.html$/, '');
+
+ return `/78dm${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '新品速递 - 全部',
+ source: ['www.78dm.net/news/0/0/0/0/0/0/0/1.html'],
+ target: '/news/0/0/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 变形金刚',
+ source: ['www.78dm.net/news/3/0/0/0/0/0/0/1.html'],
+ target: '/news/3/0/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 高达',
+ source: ['www.78dm.net/news/4/0/0/0/0/0/0/1.html'],
+ target: '/news/4/0/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 圣斗士',
+ source: ['www.78dm.net/news/2/0/0/0/0/0/0/1.html'],
+ target: '/news/2/0/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 海贼王',
+ source: ['www.78dm.net/news/8/0/0/0/0/0/0/1.html'],
+ target: '/news/8/0/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - PVC手办',
+ source: ['www.78dm.net/news/0/5/0/0/0/0/0/1.html'],
+ target: '/news/0/5/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 拼装模型',
+ source: ['www.78dm.net/news/0/1/0/0/0/0/0/1.html'],
+ target: '/news/0/1/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 机甲成品',
+ source: ['www.78dm.net/news/0/2/0/0/0/0/0/1.html'],
+ target: '/news/0/2/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 特摄',
+ source: ['www.78dm.net/news/0/3/0/0/0/0/0/1.html'],
+ target: '/news/0/3/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 美系',
+ source: ['www.78dm.net/news/0/4/0/0/0/0/0/1.html'],
+ target: '/news/0/4/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - GK',
+ source: ['www.78dm.net/news/0/6/0/0/0/0/0/1.html'],
+ target: '/news/0/6/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 扭蛋盒蛋食玩',
+ source: ['www.78dm.net/news/0/7/0/0/0/0/0/1.html'],
+ target: '/news/0/7/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 其他',
+ source: ['www.78dm.net/news/0/8/0/0/0/0/0/1.html'],
+ target: '/news/0/8/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 综合',
+ source: ['www.78dm.net/news/0/9/0/0/0/0/0/1.html'],
+ target: '/news/0/9/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 军模',
+ source: ['www.78dm.net/news/0/10/0/0/0/0/0/1.html'],
+ target: '/news/0/10/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 民用',
+ source: ['www.78dm.net/news/0/11/0/0/0/0/0/1.html'],
+ target: '/news/0/11/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 配件',
+ source: ['www.78dm.net/news/0/12/0/0/0/0/0/1.html'],
+ target: '/news/0/12/0/0/0/0/0/1',
+ },
+ {
+ title: '新品速递 - 工具',
+ source: ['www.78dm.net/news/0/13/0/0/0/0/0/1.html'],
+ target: '/news/0/13/0/0/0/0/0/1',
+ },
+ {
+ title: '精彩评测 - 全部',
+ source: ['www.78dm.net/eval_list/0/0/0/1.html'],
+ target: '/eval_list/0/0/0/1',
+ },
+ {
+ title: '精彩评测 - 变形金刚',
+ source: ['www.78dm.net/eval_list/109/0/0/1.html'],
+ target: '/eval_list/109/0/0/1',
+ },
+ {
+ title: '精彩评测 - 高达',
+ source: ['www.78dm.net/eval_list/110/0/0/1.html'],
+ target: '/eval_list/110/0/0/1',
+ },
+ {
+ title: '精彩评测 - 圣斗士',
+ source: ['www.78dm.net/eval_list/111/0/0/1.html'],
+ target: '/eval_list/111/0/0/1',
+ },
+ {
+ title: '精彩评测 - 海贼王',
+ source: ['www.78dm.net/eval_list/112/0/0/1.html'],
+ target: '/eval_list/112/0/0/1',
+ },
+ {
+ title: '精彩评测 - PVC手办',
+ source: ['www.78dm.net/eval_list/115/0/0/1.html'],
+ target: '/eval_list/115/0/0/1',
+ },
+ {
+ title: '精彩评测 - 拼装模型',
+ source: ['www.78dm.net/eval_list/113/0/0/1.html'],
+ target: '/eval_list/113/0/0/1',
+ },
+ {
+ title: '精彩评测 - 机甲成品',
+ source: ['www.78dm.net/eval_list/114/0/0/1.html'],
+ target: '/eval_list/114/0/0/1',
+ },
+ {
+ title: '精彩评测 - 特摄',
+ source: ['www.78dm.net/eval_list/116/0/0/1.html'],
+ target: '/eval_list/116/0/0/1',
+ },
+ {
+ title: '精彩评测 - 美系',
+ source: ['www.78dm.net/eval_list/117/0/0/1.html'],
+ target: '/eval_list/117/0/0/1',
+ },
+ {
+ title: '精彩评测 - GK',
+ source: ['www.78dm.net/eval_list/118/0/0/1.html'],
+ target: '/eval_list/118/0/0/1',
+ },
+ {
+ title: '精彩评测 - 综合',
+ source: ['www.78dm.net/eval_list/120/0/0/1.html'],
+ target: '/eval_list/120/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 全部',
+ source: ['www.78dm.net/ht_list/0/0/0/1.html'],
+ target: '/ht_list/0/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 变形金刚',
+ source: ['www.78dm.net/ht_list/95/0/0/1.html'],
+ target: '/ht_list/95/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 高达',
+ source: ['www.78dm.net/ht_list/96/0/0/1.html'],
+ target: '/ht_list/96/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 圣斗士',
+ source: ['www.78dm.net/ht_list/98/0/0/1.html'],
+ target: '/ht_list/98/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 海贼王',
+ source: ['www.78dm.net/ht_list/99/0/0/1.html'],
+ target: '/ht_list/99/0/0/1',
+ },
+ {
+ title: '好贴推荐 - PVC手办',
+ source: ['www.78dm.net/ht_list/100/0/0/1.html'],
+ target: '/ht_list/100/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 拼装模型',
+ source: ['www.78dm.net/ht_list/101/0/0/1.html'],
+ target: '/ht_list/101/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 机甲成品',
+ source: ['www.78dm.net/ht_list/102/0/0/1.html'],
+ target: '/ht_list/102/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 特摄',
+ source: ['www.78dm.net/ht_list/103/0/0/1.html'],
+ target: '/ht_list/103/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 美系',
+ source: ['www.78dm.net/ht_list/104/0/0/1.html'],
+ target: '/ht_list/104/0/0/1',
+ },
+ {
+ title: '好贴推荐 - GK',
+ source: ['www.78dm.net/ht_list/105/0/0/1.html'],
+ target: '/ht_list/105/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 综合',
+ source: ['www.78dm.net/ht_list/107/0/0/1.html'],
+ target: '/ht_list/107/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 装甲战车',
+ source: ['www.78dm.net/ht_list/131/0/0/1.html'],
+ target: '/ht_list/131/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 舰船模型',
+ source: ['www.78dm.net/ht_list/132/0/0/1.html'],
+ target: '/ht_list/132/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 飞机模型',
+ source: ['www.78dm.net/ht_list/133/0/0/1.html'],
+ target: '/ht_list/133/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 民用模型',
+ source: ['www.78dm.net/ht_list/134/0/0/1.html'],
+ target: '/ht_list/134/0/0/1',
+ },
+ {
+ title: '好贴推荐 - 兵人模型',
+ source: ['www.78dm.net/ht_list/135/0/0/1.html'],
+ target: '/ht_list/135/0/0/1',
+ },
+ ],
+};
diff --git a/lib/routes/78dm/namespace.ts b/lib/routes/78dm/namespace.ts
new file mode 100644
index 00000000000000..2fa777e3cefd30
--- /dev/null
+++ b/lib/routes/78dm/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '78 动漫',
+ url: '78dm.net',
+ categories: ['anime'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/78dm/templates/description.art b/lib/routes/78dm/templates/description.art
new file mode 100644
index 00000000000000..dfab19230c1108
--- /dev/null
+++ b/lib/routes/78dm/templates/description.art
@@ -0,0 +1,17 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/7mmtv/index.ts b/lib/routes/7mmtv/index.ts
new file mode 100644
index 00000000000000..c77db04fa7c4a0
--- /dev/null
+++ b/lib/routes/7mmtv/index.ts
@@ -0,0 +1,122 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/:language?/:category?/:type?',
+ categories: ['multimedia'],
+ example: '/7mmtv/zh/censored_list/all',
+ parameters: { language: 'Language, see below, `en` as English by default', category: 'Category, see below, `censored_list` as Censored by default', type: 'Server, see below, all server by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ name: 'Category',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `**Language**
+
+| English | 日本語 | 한국의 | 中文 |
+| ------- | ------ | ------ | ---- |
+| en | ja | ko | zh |
+
+ **Category**
+
+| Chinese subtitles AV | Censored | Amateur | Uncensored | Asian self-timer | H comics |
+| -------------------- | -------------- | ---------------- | ---------------- | ---------------- | ------------ |
+| chinese\_list | censored\_list | amateurjav\_list | uncensored\_list | amateur\_list | hcomic\_list |
+
+| Chinese subtitles AV random | Censored random | Amateur random | Uncensored random | Asian self-timer random | H comics random |
+| --------------------------- | ---------------- | ------------------ | ------------------ | ----------------------- | --------------- |
+| chinese\_random | censored\_random | amateurjav\_random | uncensored\_random | amateur\_random | hcomic\_random |
+
+ **Server**
+
+| All Server | fembed(Full DL) | streamsb(Full DL) | doodstream | streamtape(Full DL) | avgle | embedgram | videovard(Full DL) |
+| ---------- | --------------- | ----------------- | ---------- | ------------------- | ----- | --------- | ------------------ |
+| all | 21 | 30 | 28 | 29 | 17 | 34 | 33 |`,
+};
+
+async function handler(ctx) {
+ const language = ctx.req.param('language') ?? 'en';
+ const category = ctx.req.param('category') ?? 'censored_list';
+ const type = ctx.req.param('type') ?? 'all';
+
+ const rootUrl = 'https://7mmtv.sx';
+ const currentUrl = `${rootUrl}/${language}/${category}/${type}/1.html`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.video')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.find('.video-title a');
+ return {
+ title: title.text(),
+ author: item.find('.video-channel').text(),
+ pubDate: parseDate(item.find('.small').text()),
+ link: title.attr('href'),
+ poster: item.find('img').attr('data-src'),
+ video: item.find('video').attr('data-src'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ cover: content('.content_main_cover img').attr('src'),
+ images: content('.owl-lazy')
+ .toArray()
+ .map((i) => content(i).attr('data-src')),
+ description: content('.video-introduction-images-text').html(),
+ poster: item.poster,
+ video: item.video,
+ });
+
+ item.category = content('.categories a')
+ .toArray()
+ .map((a) => content(a).text());
+
+ delete item.poster;
+ delete item.video;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title')
+ .text()
+ .replace(/ - Watch JAV Online/, ''),
+ link: currentUrl,
+ item: items,
+ description: $('meta[name="description"]').attr('content'),
+ };
+}
diff --git a/lib/routes/7mmtv/namespace.ts b/lib/routes/7mmtv/namespace.ts
new file mode 100644
index 00000000000000..30daaeef3d2835
--- /dev/null
+++ b/lib/routes/7mmtv/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '7mmtv',
+ url: '7mmtv.tv',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/7mmtv/templates/description.art b/lib/routes/7mmtv/templates/description.art
similarity index 100%
rename from lib/v2/7mmtv/templates/description.art
rename to lib/routes/7mmtv/templates/description.art
diff --git a/lib/routes/81/81rc/index.ts b/lib/routes/81/81rc/index.ts
new file mode 100644
index 00000000000000..ca8b8767292a04
--- /dev/null
+++ b/lib/routes/81/81rc/index.ts
@@ -0,0 +1,108 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx) => {
+ const { category = 'sy/gzdt_210283' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'https://81rc.81.cn';
+ const currentUrl = new URL(category?.endsWith('/') ? `${category}/` : category, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('div.left-news ul li')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.find('a').text(),
+ pubDate: timezone(parseDate(item.find('span').text()), +8),
+ link: item.find('a').prop('href'),
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ const description = $$('div.txt').html();
+
+ item.title = $$('h2').text();
+ item.description = description;
+ item.pubDate = timezone(parseDate($$('div.time span').last().text()), +8);
+ item.author = $$('div.time span').first().text();
+ item.content = {
+ html: description,
+ text: $$('div.txt').text(),
+ };
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const title = $('title').text();
+ const image = new URL('template/tenant207/t582/new.jpg', rootUrl).href;
+
+ return {
+ title,
+ description: $('div.time').contents().first().text(),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: title.split(/-/).pop()?.trim(),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/81rc/:category{.+}?',
+ name: '中国人民解放军专业技术人才网',
+ url: '81rc.81.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/81/81rc/sy/gzdt_210283',
+ parameters: { category: '分类,默认为 `sy/gzdt_210283`,即工作动态,可在对应分类页 URL 中找到' },
+ description: `::: tip
+ 若订阅 [工作动态](https://81rc.81.cn/sy/gzdt_210283),网址为 \`https://81rc.81.cn/sy/gzdt_210283\`。截取 \`https://81rc.81.cn/\` 到末尾的部分 \`sy/gzdt_210283\` 作为参数填入,此时路由为 [\`/81/81rc/sy/gzdt_210283\`](https://rsshub.app/81/81rc/sy/gzdt_210283)。
+:::
+ `,
+ categories: ['government'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['81rc.81.cn/:category'],
+ target: (params) => {
+ const category = params.category;
+
+ return `/81/81rc/${category ? `/${category}` : ''}`;
+ },
+ },
+ ],
+};
diff --git a/lib/routes/81/namespace.ts b/lib/routes/81/namespace.ts
new file mode 100644
index 00000000000000..3019d5fbba3f6f
--- /dev/null
+++ b/lib/routes/81/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国军网',
+ url: '81.cn',
+ categories: ['government'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/8264/list.ts b/lib/routes/8264/list.ts
new file mode 100644
index 00000000000000..3e498aea0c07c7
--- /dev/null
+++ b/lib/routes/8264/list.ts
@@ -0,0 +1,171 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/list/:id?',
+ categories: ['bbs'],
+ example: '/8264/list/751',
+ parameters: { id: '列表 id,见下表,默认为 751,即热门推荐' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '列表',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 热门推荐 | 户外知识 | 户外装备 |
+| -------- | -------- | -------- |
+| 751 | 238 | 204 |
+
+
+更多列表
+
+#### 热门推荐
+
+| 业界 | 国际 | 专访 | 图说 | 户外 | 登山 | 攀岩 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| 489 | 733 | 746 | 902 | 914 | 934 | 935 |
+
+#### 户外知识
+
+| 徒步 | 露营 | 安全急救 | 领队 | 登雪山 |
+| ---- | ---- | -------- | ---- | ------ |
+| 242 | 950 | 931 | 920 | 915 |
+
+| 攀岩 | 骑行 | 跑步 | 滑雪 | 水上运动 |
+| ---- | ---- | ---- | ---- | -------- |
+| 916 | 917 | 918 | 919 | 921 |
+
+| 钓鱼 | 潜水 | 攀冰 | 冲浪 | 网球 |
+| ---- | ---- | ---- | ---- | ---- |
+| 951 | 952 | 953 | 966 | 967 |
+
+| 绳索知识 | 高尔夫 | 马术 | 户外摄影 | 羽毛球 |
+| -------- | ------ | ---- | -------- | ------ |
+| 968 | 969 | 970 | 973 | 971 |
+
+| 游泳 | 溯溪 | 健身 | 瑜伽 |
+| ---- | ---- | ---- | ---- |
+| 974 | 975 | 976 | 977 |
+
+#### 户外装备
+
+| 服装 | 冲锋衣 | 抓绒衣 | 皮肤衣 | 速干衣 |
+| ---- | ------ | ------ | ------ | ------ |
+| 209 | 923 | 924 | 925 | 926 |
+
+| 羽绒服 | 软壳 | 户外鞋 | 登山鞋 | 徒步鞋 |
+| ------ | ---- | ------ | ------ | ------ |
+| 927 | 929 | 211 | 928 | 930 |
+
+| 越野跑鞋 | 溯溪鞋 | 登山杖 | 帐篷 | 睡袋 |
+| -------- | ------ | ------ | ---- | ---- |
+| 933 | 932 | 220 | 208 | 212 |
+
+| 炉具 | 灯具 | 水具 | 面料 | 背包 |
+| ---- | ---- | ---- | ---- | ---- |
+| 792 | 218 | 219 | 222 | 207 |
+
+| 防潮垫 | 电子导航 | 冰岩绳索 | 综合装备 |
+| ------ | -------- | -------- | -------- |
+| 214 | 216 | 215 | 223 |
+ `,
+};
+
+async function handler(ctx) {
+ const { id = '751' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'https://www.8264.com';
+ const currentUrl = new URL(`list/${id}`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(iconv.decode(response, 'gbk'));
+
+ $('div.newslist_info').remove();
+
+ let items = $('div.newlist_r, div.newslist_r, div.bbslistone_name, dt')
+ .find('a')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const link = item.prop('href');
+
+ return {
+ title: item.text(),
+ link: link.startsWith('http') ? link : new URL(link, rootUrl).href,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link, {
+ responseType: 'buffer',
+ });
+
+ const content = load(iconv.decode(detailResponse, 'gbk'));
+
+ content('a.syq, a.xlsj, a.titleoverflow200, #fjump').remove();
+ content('i.pstatus').remove();
+ content('div.crly').remove();
+
+ const pubDate = content('span.pub-time').text() || content('span.fby span').first().prop('title') || content('span.fby').first().text().split('发表于').pop().trim();
+
+ content('img').each(function () {
+ content(this).replaceWith(
+ art(path.join(__dirname, 'templates/description.art'), {
+ image: {
+ src: content(this).prop('file'),
+ alt: content(this).prop('alt'),
+ },
+ })
+ );
+ });
+
+ item.title = content('h1').first().text();
+ item.description = content('div.art-content, td.t_f').first().html();
+ item.author = content('a.user-name, #author').first().text();
+ item.category = content('div.fl_dh a, div.site a')
+ .toArray()
+ .map((c) => content(c).text().trim());
+ item.pubDate = timezone(parseDate(pubDate, ['YYYY-MM-DD HH:mm', 'YYYY-M-D HH:mm']), +8);
+
+ return item;
+ })
+ )
+ );
+
+ const description = $('meta[name="description"]').prop('content').trim();
+ const icon = new URL('favicon', rootUrl).href;
+
+ return {
+ item: items,
+ title: `${$('span.country, h2').text()} - ${description.split(',').pop()}`,
+ link: currentUrl,
+ description,
+ language: 'zh-cn',
+ icon,
+ logo: icon,
+ subtitle: $('meta[name="keywords"]').prop('content').trim(),
+ author: $('meta[name="author"]').prop('content'),
+ };
+}
diff --git a/lib/routes/8264/namespace.ts b/lib/routes/8264/namespace.ts
new file mode 100644
index 00000000000000..48522cf12fe0a4
--- /dev/null
+++ b/lib/routes/8264/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '8264',
+ url: '8264.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/8264/templates/description.art b/lib/routes/8264/templates/description.art
similarity index 100%
rename from lib/v2/8264/templates/description.art
rename to lib/routes/8264/templates/description.art
diff --git a/lib/routes/8kcos/article.ts b/lib/routes/8kcos/article.ts
new file mode 100644
index 00000000000000..f6c2bdb1165c0b
--- /dev/null
+++ b/lib/routes/8kcos/article.ts
@@ -0,0 +1,25 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+async function loadArticle(link) {
+ const resp = await got(link);
+ const article = load(resp.body);
+ const entryChildren = article('div.entry-content').children();
+ const imgs = entryChildren
+ .find('noscript')
+ .toArray()
+ .map((e) => e.children[0].data);
+ const txt = entryChildren
+ .slice(2)
+ .toArray()
+ .map((e) => load(e).html());
+ return {
+ title: article('.entry-title').text(),
+ description: [...imgs, ...txt].join(''),
+ pubDate: parseDate(article('time')[0].attribs.datetime),
+ link,
+ };
+}
+export default loadArticle;
diff --git a/lib/routes/8kcos/cat.ts b/lib/routes/8kcos/cat.ts
new file mode 100644
index 00000000000000..816c3525e89220
--- /dev/null
+++ b/lib/routes/8kcos/cat.ts
@@ -0,0 +1,44 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
+export const route: Route = {
+ path: '/cat/:cat{.+}?',
+ radar: [
+ {
+ source: ['8kcosplay.com/'],
+ target: '',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+ url: '8kcosplay.com/',
+ features: {
+ nsfw: true,
+ },
+};
+
+async function handler(ctx) {
+ const limit = Number.parseInt(ctx.req.query('limit'));
+ const { cat = '8kasianidol' } = ctx.req.param();
+ const url = `${SUB_URL}category/${cat}/`;
+ const resp = await got(url);
+ const $ = load(resp.body);
+ const itemRaw = $('li.item').toArray();
+ return {
+ title: `${SUB_NAME_PREFIX}-${$('span[property=name]:not(.hide)').text()}`,
+ link: url,
+ item: await Promise.all(
+ (limit ? itemRaw.slice(0, limit) : itemRaw).map((e) => {
+ const { href } = load(e)('h2 > a')[0].attribs;
+ return cache.tryGet(href, () => loadArticle(href));
+ })
+ ),
+ };
+}
diff --git a/lib/routes/8kcos/const.ts b/lib/routes/8kcos/const.ts
new file mode 100644
index 00000000000000..b3d599140873d8
--- /dev/null
+++ b/lib/routes/8kcos/const.ts
@@ -0,0 +1,4 @@
+const SUB_NAME_PREFIX = '8KCosplay';
+const SUB_URL = 'https://www.8kcosplay.com/';
+
+export { SUB_NAME_PREFIX, SUB_URL };
diff --git a/lib/routes/8kcos/latest.ts b/lib/routes/8kcos/latest.ts
new file mode 100644
index 00000000000000..8a9860e8d957f4
--- /dev/null
+++ b/lib/routes/8kcos/latest.ts
@@ -0,0 +1,54 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
+const url = SUB_URL;
+
+export const route: Route = {
+ path: '/',
+ categories: ['picture'],
+ example: '/8kcos/',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['8kcosplay.com/'],
+ target: '',
+ },
+ ],
+ name: '最新',
+ maintainers: ['KotoriK'],
+ handler,
+ url: '8kcosplay.com/',
+};
+
+async function handler(ctx) {
+ const limit = Number.parseInt(ctx.req.query('limit'));
+ const response = await got(url);
+ const itemRaw = load(response.body)('ul.post-loop li.item').toArray();
+ return {
+ title: `${SUB_NAME_PREFIX}-最新`,
+ link: url,
+ item:
+ response.body &&
+ (await Promise.all(
+ (limit ? itemRaw.slice(0, limit) : itemRaw).map((e) => {
+ const { href } = load(e)('h2 > a')[0].attribs;
+ return cache.tryGet(href, () => loadArticle(href));
+ })
+ )),
+ };
+}
diff --git a/lib/routes/8kcos/namespace.ts b/lib/routes/8kcos/namespace.ts
new file mode 100644
index 00000000000000..0fcb25f00a58c0
--- /dev/null
+++ b/lib/routes/8kcos/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '8KCosplay',
+ url: '8kcosplay.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/8kcos/tag.ts b/lib/routes/8kcos/tag.ts
new file mode 100644
index 00000000000000..c86040dfa91199
--- /dev/null
+++ b/lib/routes/8kcos/tag.ts
@@ -0,0 +1,53 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
+export const route: Route = {
+ path: '/tag/:tag',
+ categories: ['picture'],
+ example: '/8kcos/tag/cosplay',
+ parameters: { tag: '标签名' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['8kcosplay.com/tag/:tag'],
+ },
+ ],
+ name: '标签',
+ maintainers: ['KotoriK'],
+ handler,
+ url: '8kcosplay.com/',
+};
+
+async function handler(ctx) {
+ const limit = Number.parseInt(ctx.req.query('limit'));
+ const tag = ctx.req.param('tag');
+ const url = `${SUB_URL}tag/${tag}/`;
+ const resp = await got(url);
+ const $ = load(resp.body);
+ const itemRaw = $('li.item').toArray();
+
+ return {
+ title: `${SUB_NAME_PREFIX}-${$('span[property=name]:not(.hide)').text()}`,
+ link: url,
+ item: await Promise.all(
+ (limit ? itemRaw.slice(0, limit) : itemRaw).map((e) => {
+ const { href } = load(e)('h2 > a')[0].attribs;
+ return cache.tryGet(href, () => loadArticle(href));
+ })
+ ),
+ };
+}
diff --git a/lib/routes/8world/index.ts b/lib/routes/8world/index.ts
new file mode 100644
index 00000000000000..d64bbbfa1e39bb
--- /dev/null
+++ b/lib/routes/8world/index.ts
@@ -0,0 +1,68 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '*',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const path = getSubPath(ctx) === '/' ? '/realtime' : getSubPath(ctx);
+
+ const rootUrl = 'https://www.8world.com';
+ const currentUrl = `${rootUrl}${path}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('div[data-column="Two-Third"] .article-title .article-link')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text(),
+ link: `${rootUrl}${item.attr('href')}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.description = content('.text-long').html();
+ item.title = content('meta[name="cXenseParse:mdc-title"]').attr('content');
+ item.author = content('meta[name="cXenseParse:author"]').attr('content');
+ item.pubDate = parseDate(content('meta[name="cXenseParse:recs:publishtime"]').attr('content'));
+ item.category = content('meta[name="cXenseParse:mdc-keywords"]')
+ .toArray()
+ .map((keyword) => content(keyword).attr('content'));
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/8world/namespace.ts b/lib/routes/8world/namespace.ts
new file mode 100644
index 00000000000000..5cd25e46eb8e3f
--- /dev/null
+++ b/lib/routes/8world/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '8 视界',
+ url: '8world.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/91porn/author.ts b/lib/routes/91porn/author.ts
new file mode 100644
index 00000000000000..3fdfa94387d0de
--- /dev/null
+++ b/lib/routes/91porn/author.ts
@@ -0,0 +1,91 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { domainValidation } from './utils';
+
+export const route: Route = {
+ path: '/author/:uid/:lang?',
+ categories: ['multimedia'],
+ example: '/91porn/author/2d6d2iWm4vVCwqujAZbSrKt2QJCbbaObv9HQ21Zo8wGJWudWBg',
+ parameters: { uid: 'Author ID, can be found in URL', lang: 'Language, see above, `en_US` by default ' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['91porn.com/index.php'],
+ target: '',
+ },
+ ],
+ name: 'Author',
+ maintainers: ['TonyRL'],
+ handler,
+ url: '91porn.com/index.php',
+};
+
+async function handler(ctx) {
+ const { domain = '91porn.com' } = ctx.req.query();
+ const { uid, lang = 'en_US' } = ctx.req.param();
+ const siteUrl = `https://${domain}/uvideos.php?UID=${uid}&type=public`;
+ domainValidation(domain);
+
+ const response = await got.post(siteUrl, {
+ form: {
+ session_language: lang,
+ },
+ headers: {
+ referer: siteUrl,
+ },
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.row .well')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('.video-title').text(),
+ link: item.find('a').attr('href'),
+ poster: item.find('.img-responsive').attr('src'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(`91porn:${lang}:${new URL(item.link).searchParams.get('viewkey')}`, async () => {
+ const { data } = await got(item.link);
+ const $ = load(data);
+
+ item.pubDate = parseDate($('.title-yakov').eq(0).text(), 'YYYY-MM-DD');
+ item.description = art(path.join(__dirname, 'templates/index.art'), {
+ link: item.link,
+ poster: item.poster,
+ });
+ item.author = $('.title-yakov a span').text();
+ delete item.poster;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('.login_register_header').text()} - 91porn`,
+ link: siteUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/91porn/index.ts b/lib/routes/91porn/index.ts
new file mode 100644
index 00000000000000..2a026e16afec9d
--- /dev/null
+++ b/lib/routes/91porn/index.ts
@@ -0,0 +1,94 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { domainValidation } from './utils';
+
+export const route: Route = {
+ path: '/:lang?',
+ categories: ['multimedia'],
+ example: '/91porn',
+ parameters: { lang: 'Language, see below, `en_US` by default ' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['91porn.com/index.php'],
+ target: '',
+ },
+ ],
+ name: 'Hot Video Today',
+ maintainers: ['TonyRL'],
+ handler,
+ url: '91porn.com/index.php',
+ description: `| English | 简体中文 | 繁體中文 |
+| ------- | -------- | -------- |
+| en\_US | cn\_CN | zh\_ZH |`,
+};
+
+async function handler(ctx) {
+ const { domain = '91porn.com' } = ctx.req.query();
+ const siteUrl = `https://${domain}/index.php`;
+ const { lang = 'en_US' } = ctx.req.param();
+ domainValidation(domain);
+
+ const response = await got.post(siteUrl, {
+ form: {
+ session_language: lang,
+ },
+ headers: {
+ referer: siteUrl,
+ },
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.row .well')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('.video-title').text(),
+ link: item.find('a').attr('href'),
+ poster: item.find('.img-responsive').attr('src'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(`91porn:${lang}:${new URL(item.link).searchParams.get('viewkey')}`, async () => {
+ const { data } = await got(item.link);
+ const $ = load(data);
+
+ item.pubDate = parseDate($('.title-yakov').eq(0).text(), 'YYYY-MM-DD');
+ item.description = art(path.join(__dirname, 'templates/index.art'), {
+ link: item.link,
+ poster: item.poster,
+ });
+ item.author = $('.title-yakov a span').text();
+ delete item.poster;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('.login_register_header').text()} - 91porn`,
+ link: siteUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/91porn/namespace.ts b/lib/routes/91porn/namespace.ts
new file mode 100644
index 00000000000000..3b2ca7baca4edc
--- /dev/null
+++ b/lib/routes/91porn/namespace.ts
@@ -0,0 +1,10 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '91porn',
+ url: '91porn.com',
+ description: `::: tip
+91porn has multiple backup domains, routes use the permanent domain \`https://91porn.com\` by default. If the domain is not accessible, you can add \`?domain=\` to specify the domain to be used. If you want to specify the backup domain to \`https://0122.91p30.com\`, you can add \`?domain=0122.91p30.com\` to the end of all 91porn routes, then the route will become [\`/91porn?domain=0122.91p30.com\`](https://rsshub.app/91porn?domain=0122.91p30.com)
+:::`,
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/91porn/templates/index.art b/lib/routes/91porn/templates/index.art
similarity index 100%
rename from lib/v2/91porn/templates/index.art
rename to lib/routes/91porn/templates/index.art
diff --git a/lib/routes/91porn/utils.ts b/lib/routes/91porn/utils.ts
new file mode 100644
index 00000000000000..4f858554a0ca1f
--- /dev/null
+++ b/lib/routes/91porn/utils.ts
@@ -0,0 +1,12 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+
+const allowDomain = new Set(['91porn.com', 'www.91porn.com', '0122.91p30.com', 'www.91zuixindizhi.com', 'w1218.91p46.com']);
+
+const domainValidation = (domain) => {
+ if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.has(domain)) {
+ throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
+ }
+};
+
+export { domainValidation };
diff --git a/lib/routes/95mm/category.ts b/lib/routes/95mm/category.ts
new file mode 100644
index 00000000000000..fdca8c7c76e107
--- /dev/null
+++ b/lib/routes/95mm/category.ts
@@ -0,0 +1,50 @@
+import type { Route } from '@/types';
+
+import { ProcessItems, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/category/:category',
+ categories: ['picture'],
+ example: '/95mm/category/1',
+ parameters: { category: '集合,见下表' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['95mm.org/'],
+ },
+ ],
+ name: '集合',
+ maintainers: ['nczitzk'],
+ handler,
+ url: '95mm.org/',
+ description: `| 清纯唯美 | 摄影私房 | 明星写真 | 三次元 | 异域美景 | 性感妖姬 | 游戏主题 | 美女壁纸 |
+| -------- | -------- | -------- | ------ | -------- | -------- | -------- | -------- |
+| 1 | 2 | 4 | 5 | 6 | 7 | 9 | 11 |`,
+};
+
+async function handler(ctx) {
+ const categories = {
+ 1: '清纯唯美',
+ 2: '摄影私房',
+ 4: '明星写真',
+ 5: '三次元',
+ 6: '异域美景',
+ 7: '性感妖姬',
+ 9: '游戏主题',
+ 11: '美女壁纸',
+ };
+
+ const category = ctx.req.param('category');
+
+ const currentUrl = `${rootUrl}/category-${category}/list-1/index.html?page=1`;
+
+ return await ProcessItems(ctx, categories[category], currentUrl);
+}
diff --git a/lib/routes/95mm/namespace.ts b/lib/routes/95mm/namespace.ts
new file mode 100644
index 00000000000000..ff0cd85269cfd9
--- /dev/null
+++ b/lib/routes/95mm/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'MM 范',
+ url: '95mm.org',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/95mm/tab.ts b/lib/routes/95mm/tab.ts
new file mode 100644
index 00000000000000..e1780bc5f57ca8
--- /dev/null
+++ b/lib/routes/95mm/tab.ts
@@ -0,0 +1,38 @@
+import type { Route } from '@/types';
+
+import { ProcessItems, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/tab/:tab?',
+ categories: ['picture'],
+ example: '/95mm/tab/热门',
+ parameters: { tab: '分类,见下表,默认为最新' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['95mm.org/'],
+ },
+ ],
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ url: '95mm.org/',
+ description: `| 最新 | 热门 | 校花 | 森系 | 清纯 | 童颜 | 嫩模 | 少女 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |`,
+};
+
+async function handler(ctx) {
+ const tab = ctx.req.param('tab') ?? '最新';
+
+ const currentUrl = `${rootUrl}/home-ajax/index.html?tabcid=${tab}&page=1`;
+
+ return await ProcessItems(ctx, tab, currentUrl);
+}
diff --git a/lib/routes/95mm/tag.ts b/lib/routes/95mm/tag.ts
new file mode 100644
index 00000000000000..d0736ebd922e4e
--- /dev/null
+++ b/lib/routes/95mm/tag.ts
@@ -0,0 +1,36 @@
+import type { Route } from '@/types';
+
+import { ProcessItems, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/tag/:tag',
+ categories: ['picture'],
+ example: '/95mm/tag/黑丝',
+ parameters: { tag: '标签,可在对应标签页中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['95mm.org/'],
+ },
+ ],
+ name: '标签',
+ maintainers: ['nczitzk'],
+ handler,
+ url: '95mm.org/',
+};
+
+async function handler(ctx) {
+ const tag = ctx.req.param('tag');
+
+ const currentUrl = `${rootUrl}/tag-${tag}/page-1/index.html`;
+
+ return await ProcessItems(ctx, tag, currentUrl);
+}
diff --git a/lib/v2/95mm/templates/description.art b/lib/routes/95mm/templates/description.art
similarity index 100%
rename from lib/v2/95mm/templates/description.art
rename to lib/routes/95mm/templates/description.art
diff --git a/lib/routes/95mm/utils.ts b/lib/routes/95mm/utils.ts
new file mode 100644
index 00000000000000..33695a68cbf236
--- /dev/null
+++ b/lib/routes/95mm/utils.ts
@@ -0,0 +1,62 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const rootUrl = 'https://www.95mm.vip';
+
+const ProcessItems = async (ctx, title, currentUrl) => {
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ headers: {
+ Referer: rootUrl,
+ },
+ });
+
+ const $ = load(response.data);
+
+ let items = $('div.list-body')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a');
+
+ return {
+ title: a.text(),
+ link: a.attr('href'),
+ guid: a.attr('href').replace('95mm.vip', '95mm.org'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const images = detailResponse.data.match(/src": '(.*?)',"width/g);
+
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ images: images.map((i) => i.split("'")[1].replaceAll(String.raw`\/`, '/')),
+ });
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${title} - MM范`,
+ link: currentUrl,
+ item: items,
+ };
+};
+
+export { ProcessItems, rootUrl };
diff --git a/lib/routes/99percentinvisible/transcript.js b/lib/routes/99percentinvisible/transcript.js
deleted file mode 100644
index dd8274a4fa3724..00000000000000
--- a/lib/routes/99percentinvisible/transcript.js
+++ /dev/null
@@ -1,46 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const url = require('url');
-
-const host = 'https://99percentinvisible.org/';
-
-module.exports = async (ctx) => {
- const link = url.resolve(host, 'archives/');
- const response = await got.get(link);
-
- const $ = cheerio.load(response.data);
-
- const list = $('.content .post-blocks a.post-image')
- .map((i, e) => $(e).attr('href'))
- .get();
-
- const out = await Promise.all(
- list
- .filter((e) => e.startsWith(url.resolve(host, 'episode/')))
- .map(async (itemUrl) => {
- const cache = await ctx.cache.get(itemUrl);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const response = await got.get(itemUrl);
- const $ = cheerio.load(response.data);
-
- const single = {
- title: $('article header h1').text().trim(),
- link: itemUrl,
- author: '99% Invisible',
- description: $('article .page-content').html(),
- pubDate: new Date($('article header .entry-meta time').text()).toUTCString(),
- };
- ctx.cache.set(itemUrl, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
-
- ctx.state.data = {
- title: '99% Invisible Transcript',
- link,
- item: out,
- };
-};
diff --git a/lib/routes/9to5/namespace.ts b/lib/routes/9to5/namespace.ts
new file mode 100644
index 00000000000000..ec50d03f614ac8
--- /dev/null
+++ b/lib/routes/9to5/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '9To5',
+ url: '9to5toys.com',
+ lang: 'en',
+};
diff --git a/lib/routes/9to5/subsite.ts b/lib/routes/9to5/subsite.ts
new file mode 100644
index 00000000000000..07b65cc641a7ff
--- /dev/null
+++ b/lib/routes/9to5/subsite.ts
@@ -0,0 +1,80 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import parser from '@/utils/rss-parser';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/:subsite/:tag?',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ let title = '9To5',
+ link,
+ description;
+
+ switch (ctx.req.param('subsite')) {
+ case 'mac':
+ link = 'https://9to5mac.com';
+ title += 'Mac';
+ description = 'Apple News & Mac Rumors Breaking All Day';
+ break;
+
+ case 'google':
+ link = 'https://9to5google.com';
+ title += 'Google';
+ description = 'Google, Pixel news, Android, Home, Chrome OS, apps, more';
+ break;
+
+ case 'toys':
+ link = 'https://9to5toys.com';
+ title += 'Toys';
+ description = 'New Gear, reviews and deals';
+ break;
+
+ default:
+ break;
+ }
+
+ if (ctx.req.param('tag')) {
+ link = `${link}/guides/${ctx.req.param('tag')}/feed/`;
+ title = `${ctx.req.param('tag')} | ${title}`;
+ } else {
+ link = `${link}/feed/`;
+ }
+
+ const feed = await parser.parseURL(link);
+
+ const items = await Promise.all(
+ feed.items.splice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 10).map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const description = utils.ProcessFeed(response.data);
+
+ const single = {
+ title: item.title,
+ description,
+ pubDate: item.pubDate,
+ link: item.link,
+ author: item['dc:creator'],
+ };
+
+ return single;
+ })
+ )
+ );
+
+ return {
+ title,
+ link,
+ description,
+ item: items,
+ };
+}
diff --git a/lib/routes/9to5/utils.ts b/lib/routes/9to5/utils.ts
new file mode 100644
index 00000000000000..3cbb329b1fc716
--- /dev/null
+++ b/lib/routes/9to5/utils.ts
@@ -0,0 +1,34 @@
+import { load } from 'cheerio';
+
+const ProcessFeed = (data) => {
+ const $ = load(data);
+ const content = $('div.post-content');
+
+ const cover = $('meta[property="og:image"]');
+ if (cover.length > 0) {
+ $(` `).insertBefore(content[0].firstChild);
+ }
+
+ // remove useless DOMs
+ content.find('hr').nextAll().remove();
+
+ content.find('hr, ins.adsbygoogle, script').each((i, e) => {
+ $(e).remove();
+ });
+
+ // remove ad
+ content.find('div.ad-disclaimer-container').remove();
+
+ content.find('div').each((i, e) => {
+ if ($(e)[0].attribs.class) {
+ const classes = $(e)[0].attribs.class;
+ if (/\w{10}\s\w{10}/g.test(classes)) {
+ $(e).remove();
+ }
+ }
+ });
+
+ return content.html();
+};
+
+export default { ProcessFeed };
diff --git a/lib/routes/a9vg/a9vg.js b/lib/routes/a9vg/a9vg.js
deleted file mode 100644
index 29e07ee4c128fa..00000000000000
--- a/lib/routes/a9vg/a9vg.js
+++ /dev/null
@@ -1,36 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const response = await got({
- method: 'get',
- url: 'http://www.a9vg.com/list/news',
- headers: {
- Referer: 'http://www.a9vg.com/list/news',
- },
- });
-
- const data = response.data;
-
- const $ = cheerio.load(data);
- const list = $('.a9-rich-card-list li');
-
- ctx.state.data = {
- title: 'A9VG电玩部落',
- link: 'http://www.a9vg.com/list/news/',
- description: '电玩资讯_电玩动态- A9VG电玩部落',
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- return {
- title: item.find('.a9-rich-card-list_label').text(),
- description: item.find('.a9-rich-card-list_summary').text() + ' ',
- pubDate: new Date(item.find('.a9-rich-card-list_infos').text()).toUTCString(),
- link: 'http://www.a9vg.com' + item.find('.a9-rich-card-list_item').attr('href'),
- };
- })
- .get(),
- };
-};
diff --git a/lib/routes/a9vg/index.ts b/lib/routes/a9vg/index.ts
new file mode 100644
index 00000000000000..dfbc158b94f09c
--- /dev/null
+++ b/lib/routes/a9vg/index.ts
@@ -0,0 +1,213 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx) => {
+ const { category = 'news/All' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 15;
+
+ const rootUrl = 'http://www.a9vg.com';
+ const currentUrl = new URL(`list/${category}`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('a.a9-rich-card-list_item')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const image = item.find('img.a9-rich-card-list_image');
+ const title = item.find('div.a9-rich-card-list_label').text();
+
+ return {
+ title,
+ link: new URL(item.prop('href'), rootUrl).href,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image.prop('src'),
+ alt: title,
+ },
+ ]
+ : undefined,
+ }),
+ pubDate: timezone(parseDate(item.find('div.a9-rich-card-list_infos').text()), +8),
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ $$('ignore_js_op img, p img').each((_, el) => {
+ el = $$(el);
+
+ el.parent().replaceWith(
+ art(path.join(__dirname, 'templates/description.art'), {
+ images: el.prop('file')
+ ? [
+ {
+ src: el.prop('file'),
+ alt: el.next().find('div.xs0 p').first().text(),
+ },
+ ]
+ : undefined,
+ })
+ );
+ });
+
+ item.title = $$('h1.ts, div.c-article-main_content-title').first().text();
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('td.t_f, div.c-article-main_contentraw').first().html(),
+ });
+ item.author =
+ $$('b a.blue').first().text() ||
+ $$(
+ $$('span.c-article-main_content-intro-item')
+ .toArray()
+ .findLast((i) => $$(i).text().startsWith('作者'))
+ )
+ .text()
+ .split(/:/)
+ .pop();
+ item.pubDate = timezone(
+ parseDate(
+ $$('div.authi em')
+ .first()
+ .text()
+ .trim()
+ .match(/发表于 (\d+-\d+-\d+ \d+:\d+)/)?.[1] ?? $$('span.c-article-main_content-intro-item').first().text(),
+ ['YYYY-M-D HH:mm', 'YYYY-MM-DD HH:mm']
+ ),
+ +8
+ );
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const title = $('title').text();
+ const image = new URL('images/logo.1cee7c0f.svg', rootUrl).href;
+
+ return {
+ title,
+ description: $('meta[name="description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: title.split(/-/).pop(),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: '新闻',
+ url: 'a9vg.com',
+ maintainers: ['monnerHenster', 'nczitzk'],
+ handler,
+ example: '/a9vg/news',
+ parameters: { category: '分类,默认为 ,可在对应分类页 URL 中找到, Category, by default' },
+ description: `::: tip
+ 若订阅 [PS4](http://www.a9vg.com/list/news/PS4),网址为 \`http://www.a9vg.com/list/news/PS4\`。截取 \`http://www.a9vg.com/list\` 到末尾的部分 \`news/PS4\` 作为参数填入,此时路由为 [\`/a9vg/news/PS4\`](https://rsshub.app/a9vg/news/PS4)。
+:::
+
+| 分类 | ID |
+| -------------------------------------------------- | ------------------------------------------------------ |
+| [All](https://www.a9vg.com/list/news/All) | [news/All](https://rsshub.app/a9vg/news/All) |
+| [PS4](https://www.a9vg.com/list/news/PS4) | [news/PS4](https://rsshub.app/a9vg/news/PS4) |
+| [PS5](https://www.a9vg.com/list/news/PS5) | [news/PS5](https://rsshub.app/a9vg/news/PS5) |
+| [Switch](https://www.a9vg.com/list/news/Switch) | [news/Switch](https://rsshub.app/a9vg/news/Switch) |
+| [Xbox One](https://www.a9vg.com/list/news/XboxOne) | [news/XboxOne](https://rsshub.app/a9vg/news/XboxOne) |
+| [XSX](https://www.a9vg.com/list/news/XSX) | [news/XSX](https://rsshub.app/a9vg/news/XSX) |
+| [PC](https://www.a9vg.com/list/news/PC) | [news/PC](https://rsshub.app/a9vg/news/PC) |
+| [业界](https://www.a9vg.com/list/news/Industry) | [news/Industry](https://rsshub.app/a9vg/news/Industry) |
+| [厂商](https://www.a9vg.com/list/news/Factory) | [news/Factory](https://rsshub.app/a9vg/news/Factory) |
+ `,
+ categories: ['game'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.a9vg.com/list/:category'],
+ target: (params) => {
+ const category = params.category;
+
+ return category ? `/${category}` : '';
+ },
+ },
+ {
+ title: 'All',
+ source: ['www.a9vg.com/list/news/All'],
+ target: '/news/All',
+ },
+ {
+ title: 'PS4',
+ source: ['www.a9vg.com/list/news/PS4'],
+ target: '/news/PS4',
+ },
+ {
+ title: 'PS5',
+ source: ['www.a9vg.com/list/news/PS5'],
+ target: '/news/PS5',
+ },
+ {
+ title: 'Switch',
+ source: ['www.a9vg.com/list/news/Switch'],
+ target: '/news/Switch',
+ },
+ {
+ title: 'Xbox One',
+ source: ['www.a9vg.com/list/news/XboxOne'],
+ target: '/news/XboxOne',
+ },
+ {
+ title: 'XSX',
+ source: ['www.a9vg.com/list/news/XSX'],
+ target: '/news/XSX',
+ },
+ {
+ title: 'PC',
+ source: ['www.a9vg.com/list/news/PC'],
+ target: '/news/PC',
+ },
+ {
+ title: '业界',
+ source: ['www.a9vg.com/list/news/Industry'],
+ target: '/news/Industry',
+ },
+ {
+ title: '厂商',
+ source: ['www.a9vg.com/list/news/Factory'],
+ target: '/news/Factory',
+ },
+ ],
+};
diff --git a/lib/routes/a9vg/namespace.ts b/lib/routes/a9vg/namespace.ts
new file mode 100644
index 00000000000000..dc0110181863d4
--- /dev/null
+++ b/lib/routes/a9vg/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'A9VG 电玩部落',
+ url: 'a9vg.com',
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/a9vg/templates/description.art b/lib/routes/a9vg/templates/description.art
new file mode 100644
index 00000000000000..dfab19230c1108
--- /dev/null
+++ b/lib/routes/a9vg/templates/description.art
@@ -0,0 +1,17 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/aa1/60s.ts b/lib/routes/aa1/60s.ts
new file mode 100644
index 00000000000000..af7e98264dcd43
--- /dev/null
+++ b/lib/routes/aa1/60s.ts
@@ -0,0 +1,190 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '100', 10);
+
+ const apiSlug = 'wp-json/wp/v2';
+ const baseUrl: string = 'https://60s.aa1.cn';
+
+ const apiUrl = new URL(`${apiSlug}/posts`, baseUrl).href;
+ const apiSearchUrl = new URL(`${apiSlug}/categories`, baseUrl).href;
+
+ const searchResponse = await ofetch(apiSearchUrl, {
+ query: {
+ search: category,
+ },
+ });
+
+ const categoryObj = searchResponse.find((c) => c.slug === category || c.name === category);
+ const categoryId: number | undefined = categoryObj?.id ?? undefined;
+ const categorySlug: string | undefined = categoryObj?.slug ?? undefined;
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ _embed: 'true',
+ per_page: limit,
+ categories: categoryId,
+ },
+ });
+
+ const targetUrl: string = new URL(categorySlug ? `category/${categorySlug}` : '', baseUrl).href;
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = response.slice(0, limit).map((item): DataItem => {
+ const title: string = item.title?.rendered ?? item.title;
+ const description: string | undefined = item.content.rendered;
+ const pubDate: number | string = item.date_gmt;
+ const linkUrl: string | undefined = item.link;
+
+ const terminologies = item._embedded?.['wp:term'];
+
+ const categories: string[] = terminologies?.flat().map((c) => c.name) ?? [];
+ const authors: DataItem['author'] =
+ item._embedded?.author.map((author) => ({
+ name: author.name,
+ url: author.link,
+ avatar: author.avatar_urls?.['96'] ?? author.avatar_urls?.['48'] ?? author.avatar_urls?.['24'] ?? undefined,
+ })) ?? [];
+ const guid: string = item.guid?.rendered ?? item.guid;
+ const image: string | undefined = item._embedded?.['wp:featuredmedia']?.[0].source_url ?? undefined;
+ const updated: number | string = item.modified_gmt ?? pubDate;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDate ? parseDate(pubDate) : undefined,
+ link: linkUrl ?? guid,
+ category: categories,
+ author: authors,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: updated ? parseDate(updated) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('header#header-div img').attr('src'),
+ author: title.split(/-/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/60s/:category?',
+ name: '每日新闻',
+ url: '60s.aa1.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/aa1/60s/news',
+ parameters: {
+ category: {
+ description: '分类,默认为全部,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '全部',
+ value: '',
+ },
+ {
+ label: '新闻词文章数据',
+ value: 'freenewsdata',
+ },
+ {
+ label: '最新',
+ value: 'new',
+ },
+ {
+ label: '本平台同款自动发文章插件',
+ value: '1',
+ },
+ {
+ label: '每天60秒读懂世界',
+ value: 'news',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+订阅 [每天60秒读懂世界](https://60s.aa1.cn/category/news),其源网址为 \`https://60s.aa1.cn/category/news\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/aa1/60s/news\`](https://rsshub.app/aa1/60s/news) 或 [\`/aa1/60s/每天60秒读懂世界\`](https://rsshub.app/aa1/60s/每天60秒读懂世界)。
+:::
+
+| 分类 | ID |
+| ---------------------------------------------------------- | ------------------------------------------------------- |
+| [全部](https://60s.aa1.cn) | [<空>](https://rsshub.app/aa1/60s) |
+| [新闻词文章数据](https://60s.aa1.cn/category/freenewsdata) | [freenewsdata](https://rsshub.app/aa1/60s/freenewsdata) |
+| [最新](https://60s.aa1.cn/category/new) | [new](https://rsshub.app/aa1/60s/new) |
+| [本平台同款自动发文章插件](https://60s.aa1.cn/category/1) | [1](https://rsshub.app/aa1/60s/1) |
+| [每天 60 秒读懂世界](https://60s.aa1.cn/category/news) | [news](https://rsshub.app/aa1/60s/news) |
+`,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['60s.aa1.cn', '60s.aa1.cn/category/:category'],
+ target: '/60s/:category',
+ },
+ {
+ title: '全部',
+ source: ['60s.aa1.cn'],
+ target: '/60s',
+ },
+ {
+ title: '新闻词文章数据',
+ source: ['60s.aa1.cn/category/freenewsdata'],
+ target: '/60s/freenewsdata',
+ },
+ {
+ title: '最新',
+ source: ['60s.aa1.cn/category/new'],
+ target: '/60s/new',
+ },
+ {
+ title: '本平台同款自动发文章插件',
+ source: ['60s.aa1.cn/category/1'],
+ target: '/60s/1',
+ },
+ {
+ title: '每天60秒读懂世界',
+ source: ['60s.aa1.cn/category/news'],
+ target: '/60s/news',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/aa1/namespace.ts b/lib/routes/aa1/namespace.ts
new file mode 100644
index 00000000000000..e033ccb8f06324
--- /dev/null
+++ b/lib/routes/aa1/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '夏柔',
+ url: 'aa1.cn',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aamacau/index.ts b/lib/routes/aamacau/index.ts
new file mode 100644
index 00000000000000..0dd98c78af1f20
--- /dev/null
+++ b/lib/routes/aamacau/index.ts
@@ -0,0 +1,94 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:category?/:id?',
+ categories: ['new-media'],
+ example: '/aamacau',
+ parameters: { category: '分类,见下表,默认为即時報道', id: 'id,可在对应页面 URL 中找到,默认为空' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['aamacau.com/'],
+ },
+ ],
+ name: '话题',
+ maintainers: [],
+ handler,
+ url: 'aamacau.com/',
+ description: `| 即時報道 | 每週專題 | 藝文爛鬼樓 | 論盡紙本 | 新聞事件 | 特別企劃 |
+| ------------ | ----------- | ---------- | -------- | -------- | -------- |
+| breakingnews | weeklytopic | culture | press | case | special |
+
+::: tip
+ 除了直接订阅分类全部文章(如 [每週專題](https://aamacau.com/topics/weeklytopic) 的对应路由为 [/aamacau/weeklytopic](https://rsshub.app/aamacau/weeklytopic)),你也可以订阅特定的专题,如 [【9-12】2021 澳門立法會選舉](https://aamacau.com/topics/【9-12】2021澳門立法會選舉) 的对应路由为 [/【9-12】2021 澳門立法會選舉](https://rsshub.app/aamacau/【9-12】2021澳門立法會選舉)。
+
+ 分类中的专题也可以单独订阅,如 [新聞事件](https://aamacau.com/topics/case) 中的 [「武漢肺炎」新聞檔案](https://aamacau.com/topics/case/「武漢肺炎」新聞檔案) 对应路由为 [/case/「武漢肺炎」新聞檔案](https://rsshub.app/aamacau/case/「武漢肺炎」新聞檔案)。
+
+ 同理,其他分类同上例子也可以订阅特定的单独专题。
+:::`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'topics';
+ const id = ctx.req.param('id') ?? '';
+
+ const rootUrl = 'https://aamacau.com';
+ const currentUrl = `${rootUrl}/${category === 'topics' ? 'topics/breakingnews' : `topics/${category}${id ? `/${id}` : ''}`}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const list = $('post-title a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text(),
+ link: item.attr('href'),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ content('.cat, .author, .date').remove();
+
+ item.description = content('#contentleft').html();
+ item.author = content('meta[itemprop="author"]').attr('content');
+ item.pubDate = parseDate(content('meta[property="article:published_time"]').attr('content'));
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/aamacau/namespace.ts b/lib/routes/aamacau/namespace.ts
new file mode 100644
index 00000000000000..d0eb36ae27115c
--- /dev/null
+++ b/lib/routes/aamacau/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '論盡媒體 AllAboutMacau Media',
+ url: 'aamacau.com',
+ lang: 'zh-HK',
+};
diff --git a/lib/routes/abc/index.ts b/lib/routes/abc/index.ts
new file mode 100644
index 00000000000000..51d60073cf3320
--- /dev/null
+++ b/lib/routes/abc/index.ts
@@ -0,0 +1,191 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ example: '/wa',
+ radar: [
+ {
+ source: ['abc.net.au/:category*'],
+ target: '/:category',
+ },
+ ],
+ parameters: {
+ category: 'Category, can be found in the URL, can also be filled in with the `documentId` in the source code of the page, `news/justin` as **Just In** by default',
+ },
+ name: 'Channel & Topic',
+ categories: ['traditional-media'],
+ description: `
+::: tip
+ All Topics in [Topic Library](https://abc.net.au/news/topics) are supported, you can fill in the field after \`topic\` in its URL, or fill in the \`documentId\`.
+
+ For example, the URL for [Computer Science](https://www.abc.net.au/news/topic/computer-science) is \`https://www.abc.net.au/news/topic/computer-science\`, the \`category\` is \`news/topic/computer-science\`, and the \`documentId\` of the Topic is \`2302\`, so the route is [/abc/news/topic/computer-science](https://rsshub.app/abc/news/topic/computer-science) and [/abc/2302](https://rsshub.app/abc/2302).
+
+ The supported channels are all listed in the table below. For other channels, please find the \`documentId\` in the source code of the channel page and fill it in as above.
+:::`,
+ maintainers: ['nczitzk', 'pseudoyu'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { category = 'news/justin' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'https://www.abc.net.au';
+ const apiUrl = new URL('news-web/api/loader/channelrefetch', rootUrl).href;
+
+ let currentUrl = '';
+ let documentId;
+
+ if (Number.isNaN(category)) {
+ currentUrl = new URL(category, rootUrl).href;
+ } else {
+ documentId = category;
+ const feedUrl = new URL(`news/feed/${documentId}/rss.xml`, rootUrl).href;
+
+ const feedResponse = await ofetch(feedUrl);
+ currentUrl = feedResponse.match(/ ([\w-./:?]+)<\/link>/)[1];
+ }
+
+ const currentResponse = await ofetch(currentUrl);
+
+ const $ = load(currentResponse);
+
+ documentId = documentId ?? $('div[data-uri^="coremedia://collection/"]').first().prop('data-uri').split(/\//).pop();
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ name: 'PaginationArticles',
+ documentId,
+ size: limit,
+ },
+ });
+
+ let items = response.collection.slice(0, limit).map((i) => {
+ const item = {
+ title: i.title.children ?? i.title,
+ link: i.link.startsWith('https://') ? i.link : new URL(i.link, rootUrl).href,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ image: i.image
+ ? {
+ src: i.image.imgSrc.split(/\?/)[0],
+ alt: i.image.alt,
+ }
+ : undefined,
+ }),
+ author: i.newsBylineProps?.authors?.map((a) => a.name).join('/') ?? undefined,
+ guid: `abc-${i.id}`,
+ pubDate: parseDate(i.dates.firstPublished),
+ updated: i.dates.lastUpdated ? parseDate(i.dates.lastUpdated) : undefined,
+ };
+
+ if (i.mediaIndicator) {
+ item.enclosure_type = 'audio/mpeg';
+ item.itunes_item_image = i.image?.imgSrc.split(/\?/)[0] ?? undefined;
+ item.itunes_duration = i.mediaIndicator.duration;
+ }
+
+ return item;
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ const detailResponse = await ofetch(item.link);
+
+ const content = load(detailResponse);
+
+ content('aside, header, [data-print="inline-media"], [data-component="EmbedBlock"]').remove();
+
+ content('#body *, div[data-component="FeatureMedia"]')
+ .children()
+ .each(function () {
+ const element = content(this);
+ if (element.prop('tagName').toLowerCase() === 'figure') {
+ element.replaceWith(
+ art(path.join(__dirname, 'templates/description.art'), {
+ image: {
+ src: element.find('img').prop('src').split(/\?/)[0],
+ alt: element.find('figcaption').text().trim(),
+ },
+ })
+ );
+ } else {
+ element.removeAttr('id class role data-component data-uri');
+ }
+ });
+
+ item.title = content('meta[property="og:title"]').prop('content');
+ item.description = '';
+
+ const enclosurePattern = String.raw`"(?:MIME|content)?Type":"([\w]+/[\w]+)".*?"(?:fileS|s)?ize":(\d+),.*?"url":"([\w-.:/?]+)"`;
+
+ const enclosureMatches = detailResponse.match(new RegExp(enclosurePattern, 'g'));
+
+ if (enclosureMatches) {
+ const enclosureMatch = enclosureMatches
+ .map((e) => e.match(new RegExp(enclosurePattern)))
+ .toSorted((a, b) => Number.parseInt(a[2], 10) - Number.parseInt(b[2], 10))
+ .pop();
+
+ item.enclosure_url = enclosureMatch[3];
+ item.enclosure_length = enclosureMatch[2];
+ item.enclosure_type = enclosureMatch[1];
+
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ enclosure: {
+ src: item.enclosure_url,
+ type: item.enclosure_type,
+ },
+ });
+ }
+
+ item.description =
+ art(path.join(__dirname, 'templates/description.art'), {
+ description: (content('div[data-component="FeatureMedia"]').html() || '') + (content('#body div[data-component="LayoutContainer"] div').first().html() || ''),
+ }) + item.description;
+
+ item.category = content('meta[property="article:tag"]')
+ .toArray()
+ .flatMap((c) =>
+ content(c)
+ .prop('content')
+ .split(/,/)
+ .map((c) => c.trim())
+ );
+ item.guid = `abc-${content('meta[name="ContentId"]').prop('content')}`;
+ item.pubDate = parseDate(content('meta[property="article:published_time"]').prop('content'));
+ item.updated = parseDate(content('meta[property="article:modified_time"]').prop('content'));
+ } catch {
+ //
+ }
+
+ return item;
+ })
+ )
+ );
+
+ const icon = new URL($('link[rel="apple-touch-icon"]').prop('href') || '', rootUrl).href;
+
+ return {
+ item: items,
+ title: $('title').first().text(),
+ link: currentUrl,
+ description: $('meta[property="og:description"]').prop('content'),
+ language: $('html').prop('lang'),
+ image: $('meta[property="og:image"]').prop('content').split('?')[0],
+ icon,
+ logo: icon,
+ subtitle: $('meta[property="og:title"]').prop('content'),
+ author: $('meta[name="generator"]').prop('content'),
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/abc/namespace.ts b/lib/routes/abc/namespace.ts
new file mode 100644
index 00000000000000..4d7b42b2d8342e
--- /dev/null
+++ b/lib/routes/abc/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ABC News (Australian Broadcasting Corporation)',
+ url: 'abc.net.au',
+ lang: 'en',
+};
diff --git a/lib/v2/abc/templates/description.art b/lib/routes/abc/templates/description.art
similarity index 100%
rename from lib/v2/abc/templates/description.art
rename to lib/routes/abc/templates/description.art
diff --git a/lib/routes/abmedia/category.ts b/lib/routes/abmedia/category.ts
new file mode 100644
index 00000000000000..e12f635b6fc985
--- /dev/null
+++ b/lib/routes/abmedia/category.ts
@@ -0,0 +1,58 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const rootUrl = 'https://www.abmedia.io';
+const cateAPIUrl = `${rootUrl}/wp-json/wp/v2/categories`;
+const postsAPIUrl = `${rootUrl}/wp-json/wp/v2/posts`;
+
+const getCategoryId = (category) => got.get(`${cateAPIUrl}?slug=${category}`).then((res) => res.data[0].id);
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/abmedia/technology-development',
+ parameters: { category: '类别,默认为产品技术' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.abmedia.io/category/:catehory'],
+ target: '/:category',
+ },
+ ],
+ name: '类别',
+ maintainers: [],
+ handler,
+ description: `参数可以从链接中拿到,如:
+
+ \`https://www.abmedia.io/category/technology-development\` 对应 \`/abmedia/technology-development\``,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'technology-development';
+ const limit = ctx.req.param('limit') ?? 25;
+ const categoryId = await getCategoryId(category);
+
+ const response = await got.get(`${postsAPIUrl}?categories=${categoryId}&page=1&per_page=${limit}`);
+ const data = response.data;
+
+ const items = data.map((item) => ({
+ title: item.title.rendered,
+ link: item.link,
+ description: item.content.rendered,
+ pubDate: parseDate(item.date),
+ }));
+
+ return {
+ title: `abmedia - ${category}`,
+ link: `${rootUrl}/category/${category}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/abmedia/index.ts b/lib/routes/abmedia/index.ts
new file mode 100644
index 00000000000000..7513d30b668bba
--- /dev/null
+++ b/lib/routes/abmedia/index.ts
@@ -0,0 +1,51 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const rootUrl = 'https://www.abmedia.io';
+const postsAPIUrl = `${rootUrl}/wp-json/wp/v2/posts`;
+
+export const route: Route = {
+ path: '/index',
+ categories: ['new-media'],
+ example: '/abmedia/index',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.abmedia.io/'],
+ },
+ ],
+ name: '首页最新新闻',
+ maintainers: [],
+ handler,
+ url: 'www.abmedia.io/',
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.param('limit') ?? 10;
+ const url = `${postsAPIUrl}?per_page=${limit}`;
+
+ const response = await got.get(url);
+ const data = response.data;
+
+ const items = data.map((item) => ({
+ title: item.title.rendered,
+ link: item.link,
+ description: item.content.rendered,
+ pubDate: parseDate(item.date),
+ }));
+
+ return {
+ title: 'ABMedia - 最新消息',
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/abmedia/namespace.ts b/lib/routes/abmedia/namespace.ts
new file mode 100644
index 00000000000000..e20c52b77a9dc5
--- /dev/null
+++ b/lib/routes/abmedia/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '链新闻 ABMedia',
+ url: 'www.abmedia.io',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/abskoop/index.ts b/lib/routes/abskoop/index.ts
new file mode 100644
index 00000000000000..7252758e93ce95
--- /dev/null
+++ b/lib/routes/abskoop/index.ts
@@ -0,0 +1,67 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/',
+ radar: [
+ {
+ source: ['ahhhhfs.com/'],
+ target: '',
+ },
+ ],
+ name: '存档列表',
+ maintainers: ['zhenhappy'],
+ handler,
+ url: 'ahhhhfs.com/',
+ features: {
+ nsfw: true,
+ },
+};
+
+async function handler(ctx) {
+ const response = await got({
+ method: 'get',
+ url: 'https://www.ahhhhfs.com/wp-json/wp/v2/posts',
+ searchParams: {
+ per_page: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 10,
+ _embed: '',
+ },
+ });
+ const list = response.data.map((item) => ({
+ title: item.title.rendered,
+ link: item.link,
+ pubDate: parseDate(item.date_gmt),
+ updated: parseDate(item.modified_gmt),
+ author: item._embedded.author[0].name,
+ category: [...new Set(item._embedded['wp:term'].flatMap((i) => i.map((j) => j.name)))],
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({ method: 'get', url: item.link });
+ const $detail = load(detailResponse.data);
+ $detail('article.post-content').find('.lwptoc').remove();
+ $detail('article.post-content').find('#related_posts').remove();
+ $detail('article.post-content').find('.entry-copyright').remove();
+ $detail('article.post-content img').each(function () {
+ $detail(this).replaceWith(` `);
+ });
+ item.description = $detail('article.post-content').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: 'ahhhhfs-A姐分享',
+ link: 'https://www.ahhhhfs.com',
+ description:
+ 'A姐分享,分享各种网络云盘资源、BT种子、高清电影电视剧和羊毛福利,收集各种有趣实用的软件和APP的下载、安装、使用方法,发现一些稀奇古怪的的网站,折腾一些有趣实用的教程,关注谷歌苹果等互联网最新的资讯动态,探索新领域,发现新美好,分享小快乐。',
+ item: items,
+ };
+}
diff --git a/lib/routes/abskoop/namespace.ts b/lib/routes/abskoop/namespace.ts
new file mode 100644
index 00000000000000..ee0227a29655da
--- /dev/null
+++ b/lib/routes/abskoop/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'A 姐分享',
+ url: 'nsfw.abskoop.com',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/abskoop/nsfw.ts b/lib/routes/abskoop/nsfw.ts
new file mode 100644
index 00000000000000..4cc07f67c16824
--- /dev/null
+++ b/lib/routes/abskoop/nsfw.ts
@@ -0,0 +1,49 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/nsfw',
+ radar: [
+ {
+ source: ['ahhhhfs.com/'],
+ target: '',
+ },
+ ],
+ name: '存档列表 - NSFW',
+ maintainers: ['zhenhappy'],
+ handler,
+ url: 'ahhhhfs.com/',
+ features: {
+ nsfw: true,
+ },
+};
+
+async function handler(ctx) {
+ const response = await got({
+ method: 'get',
+ url: 'https://nsfw.abskoop.com/wp-json/wp/v2/posts',
+ searchParams: {
+ per_page: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 10,
+ _embed: '',
+ },
+ });
+
+ const list = response.data.map((item) => ({
+ title: item.title.rendered,
+ description: item.content.rendered,
+ link: item.link,
+ pubDate: parseDate(item.date_gmt),
+ updated: parseDate(item.modified_gmt),
+ author: item._embedded.author[0].name,
+ category: [...new Set(item._embedded['wp:term'].flatMap((i) => i.map((j) => j.name)))],
+ }));
+
+ return {
+ title: 'ahhhhfs-A姐分享NSFW',
+ link: 'https://nsfw.ahhhhfs.com/articles-archive',
+ description:
+ 'A姐分享NSFW,分享各种网络云盘资源、BT种子、磁力链接、高清电影电视剧和羊毛福利,收集各种有趣实用的软件和APP的下载、安装、使用方法,发现一些稀奇古怪的的网站,折腾一些有趣实用的教程,关注谷歌苹果等互联网最新的资讯动态,探索新领域,发现新美好,分享小快乐。',
+ item: list,
+ };
+}
diff --git a/lib/routes/academia/namespace.ts b/lib/routes/academia/namespace.ts
new file mode 100644
index 00000000000000..c1610b575efaa3
--- /dev/null
+++ b/lib/routes/academia/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Academia',
+ url: 'www.academia.edu',
+ lang: 'en',
+};
diff --git a/lib/routes/academia/topics.ts b/lib/routes/academia/topics.ts
new file mode 100644
index 00000000000000..c9dd6430972658
--- /dev/null
+++ b/lib/routes/academia/topics.ts
@@ -0,0 +1,40 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/topic/:interest',
+ example: '/academia/topic/Urban_History',
+ parameters: { interest: 'interest' },
+ radar: [
+ {
+ source: ['academia.edu/Documents/in/:interest'],
+ target: '/topic/:interest',
+ },
+ ],
+ name: 'interest',
+ maintainers: ['K33k0', 'cscnk52'],
+ categories: ['journal'],
+ handler,
+ url: 'academia.edu',
+};
+
+async function handler(ctx) {
+ const interest = ctx.req.param('interest');
+ const response = await ofetch(`https://www.academia.edu/Documents/in/${interest}`);
+ const $ = load(response);
+ const list = $('.works > .div')
+ .toArray()
+ .map((item) => ({
+ title: $(item).find('.title').text(),
+ link: $(item).find('.title > a').attr('href'),
+ author: $(item).find('.authors').text().replace('by', '').trim(),
+ description: $(item).find('.summarized').text(),
+ }));
+ return {
+ title: `academia.edu | ${interest} documents`,
+ link: `https://academia.edu/Documents/in/${interest}`,
+ item: list,
+ };
+}
diff --git a/lib/routes/accessbriefing/index.ts b/lib/routes/accessbriefing/index.ts
new file mode 100644
index 00000000000000..b613f516d9053f
--- /dev/null
+++ b/lib/routes/accessbriefing/index.ts
@@ -0,0 +1,216 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx) => {
+ const { category = 'latest/news' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'https://www.accessbriefing.com';
+ const currentUrl = new URL(category, rootUrl).href;
+ const apiUrl = new URL('Ajax/GetPagedArticles', rootUrl).href;
+
+ const { data: currentResponse } = await got(currentUrl);
+
+ const brandId = currentResponse.match(/'BrandID':\s(\d+)/)?.[1] ?? '32';
+ const moreID = currentResponse.match(/'MoreID':\s(\d+)/)?.[1] ?? '9282';
+
+ const { data: response } = await got(apiUrl, {
+ searchParams: {
+ navcontentid: moreID,
+ brandid: brandId,
+ page: 0,
+ lastpage: 0,
+ pagesize: limit,
+ },
+ });
+
+ const $ = load(currentResponse);
+
+ const language = $('html').prop('lang');
+
+ let items = response.slice(0, limit).map((item) => {
+ const title = item.Article_Headline;
+ const image = new URL(item.Image, rootUrl).href;
+ const description = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ intro: item.Article_Intro_Plaintext,
+ });
+ const guid = `accessbriefing-${item.Article_ID}`;
+
+ return {
+ title,
+ description,
+ pubDate: parseDate(item.Article_PublishedDate),
+ link: new URL(item.URL, rootUrl).href,
+ author: item.Authors.join('/'),
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: item.Article_Intro_Plaintext,
+ },
+ image,
+ banner: image,
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ const title = $$('h1.khl-article-page-title').text();
+ const description =
+ item.description +
+ art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div.khl-article-page-storybody').html(),
+ });
+
+ item.title = title;
+ item.description = description;
+ item.category = $$('a.badge[data-id]')
+ .toArray()
+ .map((c) => $$(c).text());
+ item.author = $$('div.authorDetails a span b').text();
+ item.content = {
+ html: description,
+ text: $$('div.khl-article-page-storybody').text(),
+ };
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const image = new URL($('a.navbar-brand img').prop('src'), rootUrl).href;
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('meta[property="og:site_name"]').prop('content'),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: 'Articles',
+ url: 'accessbriefing.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/accessbriefing/latest/news',
+ parameters: { category: 'Category, Latest News by default' },
+ description: `::: tip
+ If you subscribe to [Latest News](https://www.accessbriefing.com/latest/news),where the URL is \`https://www.accessbriefing.com/latest/news\`, extract the part \`https://www.accessbriefing.com/\` to the end, and use it as the parameter to fill in. Therefore, the route will be [\`/accessbriefing/latest/news\`](https://rsshub.app/accessbriefing/latest/news).
+:::
+
+#### Latest
+
+| Category | ID |
+| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| [News](https://www.accessbriefing.com/latest/news) | [latest/news](https://rsshub.app/target/site/latest/news) |
+| [Products & Technology](https://www.accessbriefing.com/latest/products-and-technology) | [latest/products-and-technology](https://rsshub.app/target/site/latest/products-and-technology) |
+| [Rental News](https://www.accessbriefing.com/latest/rental-news) | [latest/rental-news](https://rsshub.app/target/site/latest/rental-news) |
+| [People](https://www.accessbriefing.com/latest/people) | [latest/people](https://rsshub.app/target/site/latest/people) |
+| [Regualtions & Safety](https://www.accessbriefing.com/latest/regualtions-safety) | [latest/regualtions-safety](https://rsshub.app/target/site/latest/regualtions-safety) |
+| [Finance](https://www.accessbriefing.com/latest/finance) | [latest/finance](https://rsshub.app/target/site/latest/finance) |
+| [Sustainability](https://www.accessbriefing.com/latest/sustainability) | [latest/sustainability](https://rsshub.app/target/site/latest/sustainability) |
+
+#### Insight
+
+| Category | ID |
+| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
+| [Interviews](https://www.accessbriefing.com/insight/interviews) | [insight/interviews](https://rsshub.app/target/site/insight/interviews) |
+| [Longer reads](https://www.accessbriefing.com/insight/longer-reads) | [insight/longer-reads](https://rsshub.app/target/site/insight/longer-reads) |
+| [Videos and podcasts](https://www.accessbriefing.com/insight/videos-and-podcasts) | [insight/videos-and-podcasts](https://rsshub.app/target/site/insight/videos-and-podcasts) |
+ `,
+ categories: ['new-media'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['accessbriefing.com/:category*'],
+ target: '/:category',
+ },
+ {
+ title: 'Latest - News',
+ source: ['accessbriefing.com/latest/news'],
+ target: '/latest/news',
+ },
+ {
+ title: 'Latest - Products & Technology',
+ source: ['accessbriefing.com/latest/products-and-technology'],
+ target: '/latest/products-and-technology',
+ },
+ {
+ title: 'Latest - Rental News',
+ source: ['accessbriefing.com/latest/rental-news'],
+ target: '/latest/rental-news',
+ },
+ {
+ title: 'Latest - People',
+ source: ['accessbriefing.com/latest/people'],
+ target: '/latest/people',
+ },
+ {
+ title: 'Latest - Regualtions & Safety',
+ source: ['accessbriefing.com/latest/regualtions-safety'],
+ target: '/latest/regualtions-safety',
+ },
+ {
+ title: 'Latest - Finance',
+ source: ['accessbriefing.com/latest/finance'],
+ target: '/latest/finance',
+ },
+ {
+ title: 'Latest - Sustainability',
+ source: ['accessbriefing.com/latest/sustainability'],
+ target: '/latest/sustainability',
+ },
+ {
+ title: 'Insight - Interviews',
+ source: ['accessbriefing.com/insight/interviews'],
+ target: '/insight/interviews',
+ },
+ {
+ title: 'Insight - Longer reads',
+ source: ['accessbriefing.com/insight/longer-reads'],
+ target: '/insight/longer-reads',
+ },
+ {
+ title: 'Insight - Videos and podcasts',
+ source: ['accessbriefing.com/insight/videos-and-podcasts'],
+ target: '/insight/videos-and-podcasts',
+ },
+ ],
+};
diff --git a/lib/routes/accessbriefing/namespace.ts b/lib/routes/accessbriefing/namespace.ts
new file mode 100644
index 00000000000000..a2a058d3df2fdc
--- /dev/null
+++ b/lib/routes/accessbriefing/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Access Briefing',
+ url: 'accessbriefing.com',
+ categories: ['new-media'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/accessbriefing/templates/description.art b/lib/routes/accessbriefing/templates/description.art
new file mode 100644
index 00000000000000..cd725d1f54a204
--- /dev/null
+++ b/lib/routes/accessbriefing/templates/description.art
@@ -0,0 +1,27 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if intro }}
+ {{ intro }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/acfun/article.ts b/lib/routes/acfun/article.ts
new file mode 100644
index 00000000000000..b28cbb68ac39cc
--- /dev/null
+++ b/lib/routes/acfun/article.ts
@@ -0,0 +1,160 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'https://www.acfun.cn';
+const categoryMap = {
+ 184: {
+ title: '二次元画师',
+ realmId: 'realmId=18' + '&realmId=14' + '&realmId=51',
+ },
+ 110: {
+ title: '综合',
+ realmId: 'realmId=5' + '&realmId=22' + '&realmId=28' + '&realmId=3' + '&realmId=4',
+ },
+ 73: {
+ title: '生活情感',
+ realmId: 'realmId=50' + '&realmId=25' + '&realmId=34' + '&realmId=7' + '&realmId=6' + '&realmId=17' + '&realmId=1' + '&realmId=2' + '&realmId=49',
+ },
+ 164: {
+ title: '游戏',
+ realmId: 'realmId=8' + '&realmId=53' + '&realmId=52' + '&realmId=11' + '&realmId=43' + '&realmId=44' + '&realmId=45' + '&realmId=46' + '&realmId=47',
+ },
+ 74: {
+ title: '动漫文化',
+ realmId: 'realmId=13' + '&realmId=31' + '&realmId=48',
+ },
+ 75: {
+ title: '漫画文学',
+ realmId: 'realmId=15' + '&realmId=23' + '&realmId=16',
+ },
+};
+const sortTypeEnum = new Set(['createTime', 'lastCommentTime', 'hotScore']);
+const timeRangeEnum = new Set(['all', 'oneDay', 'threeDay', 'oneWeek', 'oneMonth']);
+
+export const route: Route = {
+ path: '/article/:categoryId/:sortType?/:timeRange?',
+ categories: ['anime'],
+ view: ViewType.Articles,
+ example: '/acfun/article/110',
+ parameters: {
+ categoryId: {
+ description: '分区 ID',
+ options: Object.keys(categoryMap).map((id) => ({ value: id, label: categoryMap[id].title })),
+ },
+ sortType: {
+ description: '排序',
+ options: [
+ { value: 'createTime', label: '最新发表' },
+ { value: 'lastCommentTime', label: '最新动态' },
+ { value: 'hotScore', label: '最热文章' },
+ ],
+ default: 'createTime',
+ },
+ timeRange: {
+ description: '时间范围,仅在排序是 `hotScore` 有效',
+ options: [
+ { value: 'all', label: '时间不限' },
+ { value: 'oneDay', label: '24 小时' },
+ { value: 'threeDay', label: '三天' },
+ { value: 'oneWeek', label: '一周' },
+ { value: 'oneMonth', label: '一个月' },
+ ],
+ default: 'all',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '文章',
+ maintainers: ['TonyRL'],
+ handler,
+ description: `| 二次元画师 | 综合 | 生活情感 | 游戏 | 动漫文化 | 漫画文学 |
+| ---------- | ---- | -------- | ---- | -------- | -------- |
+| 184 | 110 | 73 | 164 | 74 | 75 |
+
+| 最新发表 | 最新动态 | 最热文章 |
+| ---------- | --------------- | -------- |
+| createTime | lastCommentTime | hotScore |
+
+| 时间不限 | 24 小时 | 三天 | 一周 | 一个月 |
+| -------- | ------- | -------- | ------- | -------- |
+| all | oneDay | threeDay | oneWeek | oneMonth |`,
+};
+
+async function handler(ctx) {
+ const { categoryId, sortType = 'createTime', timeRange = 'all' } = ctx.req.param();
+ if (!categoryMap[categoryId]) {
+ throw new InvalidParameterError(`Invalid category Id: ${categoryId}`);
+ }
+ if (!sortTypeEnum.has(sortType)) {
+ throw new InvalidParameterError(`Invalid sort type: ${sortType}`);
+ }
+ if (!timeRangeEnum.has(timeRange)) {
+ throw new InvalidParameterError(`Invalid time range: ${timeRange}`);
+ }
+
+ const url = `${baseUrl}/v/list${categoryId}/index.htm`;
+ const response = await got.post(
+ `${baseUrl}/rest/pc-direct/article/feed` +
+ '?cursor=first_page' +
+ '&onlyOriginal=false' +
+ '&limit=10' +
+ `&sortType=${sortType}` +
+ `&timeRange=${sortType === 'hotScore' ? timeRange : 'all'}` +
+ `&${categoryMap[categoryId].realmId}`,
+ {
+ headers: {
+ referer: url,
+ },
+ }
+ );
+
+ const list = response.data.data.map((item) => ({
+ title: item.title,
+ link: `${baseUrl}/a/ac${item.articleId}`,
+ author: item.userName,
+ pubDate: parseDate(item.createTime, 'x'),
+ category: item.realmName,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link, {
+ headers: {
+ referer: url,
+ },
+ });
+ const $ = load(response.data);
+ const articleInfo = $('.main script')
+ .text()
+ .match(/window.articleInfo = (.*);\n\s*window.likeDomain/)[1];
+ const data = JSON.parse(articleInfo);
+
+ item.description = data.parts[0].content;
+ if (data.tagList) {
+ item.category = [item.category, ...data.tagList.map((tag) => tag.name)];
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: categoryMap[categoryId].title,
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/acfun/bangumi.ts b/lib/routes/acfun/bangumi.ts
new file mode 100644
index 00000000000000..b83c04319c1ea5
--- /dev/null
+++ b/lib/routes/acfun/bangumi.ts
@@ -0,0 +1,53 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/bangumi/:id',
+ categories: ['anime'],
+ view: ViewType.Videos,
+ example: '/acfun/bangumi/5022158',
+ parameters: { id: '番剧 id' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '番剧',
+ maintainers: ['xyqfer'],
+ handler,
+ description: `::: tip
+番剧 id 不包含开头的 aa。
+例如:\`https://www.acfun.cn/bangumi/aa5022158\` 的番剧 id 是 5022158,不包括开头的 aa。
+:::`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const url = `https://www.acfun.cn/bangumi/aa${id}`;
+
+ const bangumiPage = await got(url, {
+ headers: {
+ Referer: 'https://www.acfun.cn',
+ },
+ });
+ const bangumiData = JSON.parse(bangumiPage.data.match(/window.bangumiData = (.*?);\n/)[1]);
+ const bangumiList = JSON.parse(bangumiPage.data.match(/window.bangumiList = (.*?);\n/)[1]);
+
+ return {
+ title: bangumiData.bangumiTitle,
+ link: url,
+ description: bangumiData.bangumiIntro,
+ image: bangumiData.belongResource.coverImageV,
+ item: bangumiList.items.map((item) => ({
+ title: `${item.episodeName} - ${item.title}`,
+ description: ` `,
+ link: `http://www.acfun.cn/bangumi/aa${id}_36188_${item.itemId}`,
+ pubDate: parseDate(item.updateTime, 'x'),
+ })),
+ };
+}
diff --git a/lib/routes/acfun/namespace.ts b/lib/routes/acfun/namespace.ts
new file mode 100644
index 00000000000000..c69cd5feb257c8
--- /dev/null
+++ b/lib/routes/acfun/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AcFun',
+ url: 'www.acfun.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/acfun/video.ts b/lib/routes/acfun/video.ts
new file mode 100644
index 00000000000000..1c105702a3f0f5
--- /dev/null
+++ b/lib/routes/acfun/video.ts
@@ -0,0 +1,67 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/user/video/:uid',
+ radar: [
+ {
+ source: ['www.acfun.cn/u/:id'],
+ target: '/user/video/:id',
+ },
+ ],
+ name: '用户投稿',
+ parameters: {
+ uid: '用户 UID',
+ },
+ categories: ['anime'],
+ example: '/acfun/user/video/6102',
+ view: ViewType.Videos,
+ maintainers: ['wdssmq'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const url = `https://www.acfun.cn/u/${uid}`;
+ const host = 'https://www.acfun.cn';
+ const response = await got(url, {
+ headers: {
+ Referer: host,
+ },
+ });
+ const data = response.data;
+
+ const $ = load(data);
+ const title = $('title').text();
+ const description = $('.signature .complete').text();
+ const list = $('#ac-space-video-list a').toArray();
+ const image = $('head style')
+ .text()
+ .match(/.user-photo{\n\s*background:url\((.*)\) 0% 0% \/ 100% no-repeat;/)[1];
+
+ return {
+ title,
+ link: url,
+ description,
+ image,
+ item: list.map((item) => {
+ item = $(item);
+
+ const itemTitle = item.find('p.title').text();
+ const itemImg = item.find('figure img').attr('src');
+ const itemUrl = item.attr('href');
+ const itemDate = item.find('.date').text();
+
+ return {
+ title: itemTitle,
+ description: ` `,
+ link: host + itemUrl,
+ pubDate: parseDate(itemDate, 'YYYY/MM/DD'),
+ };
+ }),
+ };
+}
diff --git a/lib/routes/acg17/namespace.ts b/lib/routes/acg17/namespace.ts
new file mode 100644
index 00000000000000..57c7ec67535401
--- /dev/null
+++ b/lib/routes/acg17/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ACG17',
+ url: 'acg17.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/acg17/post.ts b/lib/routes/acg17/post.ts
new file mode 100644
index 00000000000000..c3bd077cd64e06
--- /dev/null
+++ b/lib/routes/acg17/post.ts
@@ -0,0 +1,45 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const host = 'http://acg17.com';
+
+export const route: Route = {
+ path: '/post/all',
+ categories: ['anime'],
+ example: '/acg17/post/all',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['acg17.com/post'],
+ },
+ ],
+ name: '全部文章',
+ maintainers: ['SunBK201'],
+ handler,
+ url: 'acg17.com/post',
+};
+
+async function handler() {
+ const response = await got(`${host}/wp-json/wp/v2/posts?per_page=30`);
+ const list = response.data;
+ return {
+ title: `ACG17 - 全部文章`,
+ link: `${host}/blog`,
+ description: 'ACG17 - 全部文章',
+ item: list.map((item) => ({
+ title: item.title.rendered,
+ link: item.link,
+ pubDate: parseDate(item.date_gmt),
+ description: item.content.rendered,
+ })),
+ };
+}
diff --git a/lib/routes/acgvinyl/namespace.ts b/lib/routes/acgvinyl/namespace.ts
new file mode 100644
index 00000000000000..5bbc34996c16c4
--- /dev/null
+++ b/lib/routes/acgvinyl/namespace.ts
@@ -0,0 +1,6 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ACG Vinyl - 黑胶',
+ url: 'www.acgvinyl.com',
+};
diff --git a/lib/routes/acgvinyl/news.ts b/lib/routes/acgvinyl/news.ts
new file mode 100644
index 00000000000000..3a8f618f0843ab
--- /dev/null
+++ b/lib/routes/acgvinyl/news.ts
@@ -0,0 +1,87 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['anime'],
+ example: '/news',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.acgvinyl.com'],
+ target: '/news',
+ },
+ ],
+ name: 'News',
+ maintainers: ['williamgateszhao'],
+ handler,
+ url: 'www.acgvinyl.com/col.jsp?id=103',
+ zh: {
+ name: '黑胶新闻',
+ },
+};
+
+async function handler(ctx) {
+ const rootUrl = 'http://www.acgvinyl.com';
+
+ const newsIndexResponse = await ofetch(`${rootUrl}/col.jsp?id=103`);
+ const $ = load(newsIndexResponse);
+ const newsIndexJsonText = $('script:contains("window.__INITIAL_STATE__")').text().replaceAll('window.__INITIAL_STATE__=', '');
+ const newsIndexJson = JSON.parse(newsIndexJsonText);
+
+ const newsListResponse = await ofetch(`${rootUrl}/rajax/news_h.jsp?cmd=getWafNotCk_getList`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ page: '1',
+ pageSize: String(ctx.req.query('limit') ?? 20),
+ fromMid: newsIndexJson.modules.module366.id,
+ idList: `[${newsIndexJson.modules.module366.prop3}]`,
+ sortKey: newsIndexJson.modules.module366.blob0.sortKey,
+ sortType: newsIndexJson.modules.module366.blob0.sortType,
+ }).toString(),
+ });
+ const list = JSON.parse(newsListResponse);
+
+ if (!list?.success || !Array.isArray(list?.list)) {
+ return null;
+ }
+
+ const items = await Promise.all(
+ list.list.map((item) =>
+ cache.tryGet(item.url, async () => {
+ const detailResponse = await ofetch(`${rootUrl}${item.url}`);
+ const $ = load(detailResponse);
+ const detailJsonText = $('script:contains("window.__INITIAL_STATE__")').text().replaceAll('window.__INITIAL_STATE__=', '');
+ const detailJson = JSON.parse(detailJsonText);
+ const detail = load(detailJson.modules.module2.newsInfo.content);
+ detail('[style]').removeAttr('style');
+ return {
+ title: item.title,
+ link: `${rootUrl}${item.url}`,
+ pubDate: parseDate(item.date),
+ description: detail.html(),
+ };
+ })
+ )
+ );
+
+ return {
+ title: 'ACG Vinyl - 黑胶 - 黑胶新闻',
+ link: 'http://www.acgvinyl.com/col.jsp?id=103',
+ item: items,
+ };
+}
diff --git a/lib/routes/acpaa/index.ts b/lib/routes/acpaa/index.ts
new file mode 100644
index 00000000000000..60707c51945849
--- /dev/null
+++ b/lib/routes/acpaa/index.ts
@@ -0,0 +1,78 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:id?/:name?',
+ categories: ['other'],
+ example: '/acpaa',
+ parameters: { id: '标签 id,默认为 1,可在对应标签页 URL 中找到', name: '标签名称,默认为重要通知,可在对应标签页 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '标签',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { id = '1', name = '重要通知' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'http://www.acpaa.cn';
+ const currentUrl = new URL(`article/taglist.jhtml?tagIds=${id}&tagname=${name}`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ let items = $('div.text01 ul li a[title]')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.prop('title'),
+ link: new URL(item.prop('href'), rootUrl).href,
+ pubDate: timezone(parseDate(item.find('span[title]').prop('title')), +8),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const content = load(detailResponse);
+
+ item.title = content('div.xhjj_head01').text();
+ item.description = content('div.text01').html();
+
+ return item;
+ })
+ )
+ );
+
+ const author = $('title').text().replaceAll('-', '');
+ const subtitle = $('span.myTitle').text().trim();
+
+ return {
+ item: items,
+ title: `${author} - ${subtitle}`,
+ link: currentUrl,
+ description: $('meta[property="og:description"]').prop('content'),
+ language: 'zh',
+ subtitle,
+ author,
+ };
+}
diff --git a/lib/routes/acpaa/namespace.ts b/lib/routes/acpaa/namespace.ts
new file mode 100644
index 00000000000000..a72a98eeaf1df0
--- /dev/null
+++ b/lib/routes/acpaa/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中华全国专利代理师协会',
+ url: 'acpaa.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/acs/journal.ts b/lib/routes/acs/journal.ts
new file mode 100644
index 00000000000000..d04f778de88987
--- /dev/null
+++ b/lib/routes/acs/journal.ts
@@ -0,0 +1,90 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { parseDate } from '@/utils/parse-date';
+import puppeteer from '@/utils/puppeteer';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/journal/:id',
+ radar: [
+ {
+ source: ['pubs.acs.org/journal/:id', 'pubs.acs.org/'],
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '';
+
+ const rootUrl = 'https://pubs.acs.org';
+ const currentUrl = `${rootUrl}/toc/${id}/0/0`;
+
+ let title = '';
+
+ const browser = await puppeteer();
+ const items = await cache.tryGet(
+ currentUrl,
+ async () => {
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' || request.resourceType() === 'script' ? request.continue() : request.abort();
+ });
+ await page.goto(currentUrl, {
+ waitUntil: 'domcontentloaded',
+ });
+ await page.waitForSelector('.toc');
+
+ const html = await page.evaluate(() => document.documentElement.innerHTML);
+ await page.close();
+
+ const $ = load(html);
+
+ title = $('meta[property="og:title"]').attr('content');
+
+ return $('.issue-item')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('.issue-item_title a');
+ const doi = item.find('input[name="doi"]').attr('value');
+
+ return {
+ doi,
+ guid: doi,
+ title: a.text(),
+ link: `${rootUrl}${a.attr('href')}`,
+ pubDate: parseDate(item.find('.pub-date-value').text(), 'MMMM D, YYYY'),
+ author: item
+ .find('.issue-item_loa li')
+ .toArray()
+ .map((a) => $(a).text())
+ .join(', '),
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ image: item.find('.issue-item_img').html(),
+ description: item.find('.hlFld-Abstract').html(),
+ }),
+ };
+ });
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+ await browser.close();
+
+ return {
+ title,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/acs/namespace.ts b/lib/routes/acs/namespace.ts
new file mode 100644
index 00000000000000..91fafa4667cb26
--- /dev/null
+++ b/lib/routes/acs/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ACS Publications',
+ url: 'pubs.acs.org',
+ lang: 'en',
+};
diff --git a/lib/v2/acs/templates/description.art b/lib/routes/acs/templates/description.art
similarity index 100%
rename from lib/v2/acs/templates/description.art
rename to lib/routes/acs/templates/description.art
diff --git a/lib/routes/acwifi/index.js b/lib/routes/acwifi/index.js
deleted file mode 100644
index 26cb2305029724..00000000000000
--- a/lib/routes/acwifi/index.js
+++ /dev/null
@@ -1,48 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const baseUrl = 'https://www.acwifi.net/';
- const response = await got({
- method: 'get',
- url: baseUrl,
- });
- const $ = cheerio.load(response.data);
- const list = $('div.widget.widget_recent_entries li').get();
-
- const ProcessFeed = (data) => {
- const $ = cheerio.load(data);
- return {
- desc: $('article.article-content').html(),
- };
- };
-
- const out = await Promise.all(
- list.map(async (item) => {
- const $ = cheerio.load(item);
- const $a = $('a');
- const link = $a.attr('href');
-
- const cache = await ctx.cache.get(link);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const response = await got({
- method: 'get',
- url: link,
- });
- const feed = ProcessFeed(response.data);
- const description = feed.desc;
-
- const single = {
- title: $a.text(),
- description,
- link,
- };
- ctx.cache.set(link, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
- ctx.state.data = { title: '路由器交流', link: baseUrl, item: out };
-};
diff --git a/lib/routes/adquan/case-library.ts b/lib/routes/adquan/case-library.ts
new file mode 100644
index 00000000000000..6746cbdfa43fd7
--- /dev/null
+++ b/lib/routes/adquan/case-library.ts
@@ -0,0 +1,146 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '24', 10);
+
+ const baseUrl: string = 'https://www.adquan.com';
+ const targetUrl: string = new URL('case_library/index', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('div.article_1')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('p.article_2_p').text();
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ intro: $el.find('div.article_1_fu p').first().text(),
+ });
+ const pubDateStr: string | undefined = $el.find('div.article_1_fu p').last().text();
+ const linkUrl: string | undefined = $el.find('a.article_2_href').attr('href');
+ const authors: DataItem['author'] = $el.find('div.article_4').text();
+ const image: string | undefined = $el.find('img.article_1_img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('p.infoTitle_left').text();
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div.articleContent').html(),
+ });
+ const pubDateStr: string | undefined = $$('p.time').text().split(/:/).pop();
+ const categoryEls: Element[] = $$('span.article_5').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).text()).filter(Boolean))];
+ const authors: DataItem['author'] = $$('div.infoTitle_right span').text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.navi_logo').attr('src'),
+ author: $('meta[name="author"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/case_library',
+ name: '案例库',
+ url: 'www.adquan.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/adquan/case_library',
+ parameters: undefined,
+ description: undefined,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.adquan.com/case_library/index'],
+ target: '/case_library',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/adquan/index.js b/lib/routes/adquan/index.js
deleted file mode 100644
index abe707aedc3bfe..00000000000000
--- a/lib/routes/adquan/index.js
+++ /dev/null
@@ -1,72 +0,0 @@
-const url = require('url');
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-const config = {
- index: {
- link: 'https://www.adquan.com/',
- title: '首页',
- },
- info: {
- link: 'https://www.adquan.com/info',
- title: '行业观察',
- },
- creative: {
- link: 'https://creative.adquan.com/',
- title: '案例库',
- },
-};
-
-module.exports = async (ctx) => {
- let cfg;
- if (ctx.params.type) {
- cfg = config[ctx.params.type];
- } else {
- cfg = config.index;
- }
-
- const response = await got({
- method: 'get',
- url: cfg.link,
- });
- const $ = cheerio.load(response.data);
-
- let where;
- switch (cfg.title) {
- case '首页':
- where = $('div.w_l_inner h2.wrok_l_title a');
- break;
- case '行业观察':
- where = $('div.article_rel div.rel_write a p').parent();
- break;
- case '案例库':
- where = $('#showcontent li h2 a');
- break;
- }
-
- const list = where
- .map((_, item) => {
- item = $(item);
- return {
- title: item.text(),
- link: url.resolve(cfg.link, item.attr('href')),
- };
- })
- .get();
-
- ctx.state.data = {
- title: `广告网 - ${cfg.title}`,
- link: cfg.link,
- item: await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({ method: 'get', url: item.link });
- const content = cheerio.load(detailResponse.data);
- item.pubDate = new Date(content('p.text_time2 span').eq(0).text()).toUTCString();
- item.description = content('div.con_Text').html();
- return item;
- })
- )
- ),
- };
-};
diff --git a/lib/routes/adquan/index.ts b/lib/routes/adquan/index.ts
new file mode 100644
index 00000000000000..1fd823337926b0
--- /dev/null
+++ b/lib/routes/adquan/index.ts
@@ -0,0 +1,146 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'https://www.adquan.com';
+ const targetUrl: string = baseUrl;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('div.article_1')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('p.article_2_p').text();
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ intro: $el.find('div.article_1_fu p').first().text(),
+ });
+ const pubDateStr: string | undefined = $el.find('div.article_1_fu p').last().text();
+ const linkUrl: string | undefined = $el.find('a.article_2_href').attr('href');
+ const authors: DataItem['author'] = $el.find('div.article_4').text();
+ const image: string | undefined = $el.find('img.article_1_img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('p.infoTitle_left').text();
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div.articleContent').html(),
+ });
+ const pubDateStr: string | undefined = $$('p.time').text().split(/:/).pop();
+ const categoryEls: Element[] = $$('span.article_5').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).text()).filter(Boolean))];
+ const authors: DataItem['author'] = $$('div.infoTitle_right span').text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.navi_logo').attr('src'),
+ author: $('meta[name="author"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/',
+ name: '最新文章',
+ url: 'www.adquan.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/adquan',
+ parameters: undefined,
+ description: undefined,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.adquan.com'],
+ target: '/',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/adquan/namespace.ts b/lib/routes/adquan/namespace.ts
new file mode 100644
index 00000000000000..68736d1fe4e7e9
--- /dev/null
+++ b/lib/routes/adquan/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '广告门',
+ url: 'adquan.com',
+ categories: ['new-media'],
+ description: '一个行业的跌宕起伏',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/adquan/templates/description.art b/lib/routes/adquan/templates/description.art
new file mode 100644
index 00000000000000..57498ab45a9d86
--- /dev/null
+++ b/lib/routes/adquan/templates/description.art
@@ -0,0 +1,7 @@
+{{ if intro }}
+ {{ intro }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/aeaweb/index.ts b/lib/routes/aeaweb/index.ts
new file mode 100644
index 00000000000000..0efe029f4a1a65
--- /dev/null
+++ b/lib/routes/aeaweb/index.ts
@@ -0,0 +1,114 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/:id',
+ categories: ['journal'],
+ example: '/aeaweb/aer',
+ parameters: { id: 'Journal id, can be found in URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: true,
+ },
+ radar: [
+ {
+ source: ['aeaweb.org/journals/:id', 'aeaweb.org/'],
+ },
+ ],
+ name: 'Journal',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `The URL of the journal [American Economic Review](https://www.aeaweb.org/journals/aer) is \`https://www.aeaweb.org/journals/aer\`, where \`aer\` is the id of the journal, so the route for this journal is \`/aeaweb/aer\`.
+
+::: tip
+ More jounals can be found in [AEA Journals](https://www.aeaweb.org/journals).
+:::`,
+};
+
+async function handler(ctx) {
+ let id = ctx.req.param('id');
+
+ const rootUrl = 'https://www.aeaweb.org';
+ const currentUrl = `${rootUrl}/journals/${id}`;
+
+ let response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ let $ = load(response.data);
+
+ $('.read-more').remove();
+
+ id = $('input[name="journal"]').attr('value');
+
+ const title = $('.page-title').text();
+ const description = $('.intro-copy').text();
+ const searchUrl = `${rootUrl}/journals/search-results?journal=${id}&ArticleSearch[current]=1`;
+
+ response = await got({
+ method: 'get',
+ url: searchUrl,
+ });
+
+ $ = load(response.data);
+
+ let items = $('h4.title a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ link: `${rootUrl}${item.attr('href').split('&')[0]}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.doi = content('meta[name="citation_doi"]').attr('content');
+
+ item.guid = item.doi;
+ item.title = content('meta[name="citation_title"]').attr('content');
+ item.author = content('.author')
+ .toArray()
+ .map((a) => content(a).text().trim())
+ .join(', ');
+ item.pubDate = parseDate(content('meta[name="citation_publication_date"]').attr('content'), 'YYYY/MM');
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ description: content('meta[name="twitter:description"]')
+ .attr('content')
+ .replace(/\(\w+ \d+\)( - )?/, ''),
+ });
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title,
+ description,
+ link: currentUrl,
+ item: items,
+ language: $('html').attr('lang'),
+ };
+}
diff --git a/lib/routes/aeaweb/namespace.ts b/lib/routes/aeaweb/namespace.ts
new file mode 100644
index 00000000000000..b727a055f2b476
--- /dev/null
+++ b/lib/routes/aeaweb/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'American Economic Association',
+ url: 'aeaweb.org',
+ lang: 'en',
+};
diff --git a/lib/v2/aeaweb/templates/description.art b/lib/routes/aeaweb/templates/description.art
similarity index 100%
rename from lib/v2/aeaweb/templates/description.art
rename to lib/routes/aeaweb/templates/description.art
diff --git a/lib/routes/aeon/category.ts b/lib/routes/aeon/category.ts
new file mode 100644
index 00000000000000..bc87a7e54ca300
--- /dev/null
+++ b/lib/routes/aeon/category.ts
@@ -0,0 +1,69 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { getBuildId, getData } from './utils';
+
+export const route: Route = {
+ path: '/category/:category',
+ categories: ['new-media'],
+ example: '/aeon/category/philosophy',
+ parameters: {
+ category: {
+ description: 'Category',
+ options: [
+ { value: 'philosophy', label: 'Philosophy' },
+ { value: 'science', label: 'Science' },
+ { value: 'psychology', label: 'Psychology' },
+ { value: 'society', label: 'Society' },
+ { value: 'culture', label: 'Culture' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['aeon.co/:category'],
+ },
+ ],
+ name: 'Categories',
+ maintainers: ['emdoe'],
+ handler,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category').toLowerCase();
+ const url = `https://aeon.co/category/${category}`;
+ const buildId = await getBuildId();
+ const response = await ofetch(`https://aeon.co/_next/data/${buildId}/${category}.json`);
+
+ const section = response.pageProps.section;
+
+ const list = section.articles.edges.map(({ node }) => ({
+ title: node.title,
+ description: node.standfirstLong,
+ author: node.authors.map((author) => author.displayName).join(', '),
+ link: `https://aeon.co/${node.type}s/${node.slug}`,
+ pubDate: parseDate(node.createdAt),
+ category: [node.section.title, ...node.topics.map((topic) => topic.title)],
+ image: node.image.url,
+ type: node.type,
+ slug: node.slug,
+ }));
+
+ const items = await getData(list);
+
+ return {
+ title: `AEON | ${section.title}`,
+ link: url,
+ description: section.metaDescription,
+ item: items,
+ };
+}
diff --git a/lib/routes/aeon/namespace.ts b/lib/routes/aeon/namespace.ts
new file mode 100644
index 00000000000000..16fc79f2faa96b
--- /dev/null
+++ b/lib/routes/aeon/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AEON',
+ url: 'aeon.co',
+ lang: 'en',
+};
diff --git a/lib/routes/aeon/templates/essay.art b/lib/routes/aeon/templates/essay.art
new file mode 100644
index 00000000000000..8fed51cad667f4
--- /dev/null
+++ b/lib/routes/aeon/templates/essay.art
@@ -0,0 +1,9 @@
+{{ if banner.url }}
+
+
+ {{ if banner.caption }}
+ {{ banner.caption }}
+ {{ /if }}
+{{ /if }}
+{{@ authorsBio }}
+{{@ content }}
diff --git a/lib/routes/aeon/templates/video.art b/lib/routes/aeon/templates/video.art
new file mode 100644
index 00000000000000..b16cf33d5dc185
--- /dev/null
+++ b/lib/routes/aeon/templates/video.art
@@ -0,0 +1,10 @@
+{{ set video = article.hosterId }}
+{{ if article.hoster === 'vimeo' }}
+ {{ set video = "https://player.vimeo.com/video/" + video + "?dnt=1" }}
+{{ else if article.hoster === 'youtube' }}
+ {{ set video = "https://www.youtube-nocookie.com/embed/" + video }}
+{{ /if }}
+
+
+{{@ article.credits }}
+{{@ article.description }}
diff --git a/lib/routes/aeon/type.ts b/lib/routes/aeon/type.ts
new file mode 100644
index 00000000000000..581c25fa8cf9c0
--- /dev/null
+++ b/lib/routes/aeon/type.ts
@@ -0,0 +1,69 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { getBuildId, getData } from './utils';
+
+export const route: Route = {
+ path: '/:type',
+ categories: ['new-media'],
+ example: '/aeon/essays',
+ parameters: {
+ type: {
+ description: 'Type',
+ options: [
+ { value: 'essays', label: 'Essays' },
+ { value: 'videos', label: 'Videos' },
+ { value: 'audio', label: 'Audio' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['aeon.co/:type'],
+ },
+ ],
+ name: 'Types',
+ maintainers: ['emdoe'],
+ handler,
+ description: `Supported types: Essays, Videos, and Audio.
+
+ Compared to the official one, the RSS feed generated by RSSHub not only has more fine-grained options, but also eliminates pull quotes, which can't be easily distinguished from other paragraphs by any RSS reader, but only disrupt the reading flow. This feed also provides users with a bio of the author at the top.`,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type');
+ const capitalizedType = type.charAt(0).toUpperCase() + type.slice(1);
+
+ const buildId = await getBuildId();
+ const url = `https://aeon.co/${type}`;
+ const response = await ofetch(`https://aeon.co/_next/data/${buildId}/${type}.json`);
+
+ const list = response.pageProps.articles.map((node) => ({
+ title: node.title,
+ description: node.standfirstLong,
+ author: node.authors.map((author) => author.displayName).join(', '),
+ link: `https://aeon.co/${node.type}s/${node.slug}`,
+ pubDate: parseDate(node.createdAt),
+ category: [node.section.title, ...node.topics.map((topic) => topic.title)],
+ image: node.image.url,
+ type: node.type,
+ slug: node.slug,
+ }));
+
+ const items = await getData(list);
+
+ return {
+ title: `AEON | ${capitalizedType}`,
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/aeon/utils.ts b/lib/routes/aeon/utils.ts
new file mode 100644
index 00000000000000..2e1d05fe833c14
--- /dev/null
+++ b/lib/routes/aeon/utils.ts
@@ -0,0 +1,84 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const getBuildId = () =>
+ cache.tryGet(
+ 'aeon:buildId',
+ async () => {
+ const response = await ofetch('https://aeon.co');
+ const $ = load(response);
+ const nextData = JSON.parse($('script#__NEXT_DATA__').text());
+ return nextData.buildId;
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+const getData = async (list) => {
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const buildId = await getBuildId();
+ const response = await ofetch(`https://aeon.co/_next/data/${buildId}/${item.type}s/${item.slug}.json?id=${item.slug}`);
+
+ const data = response.pageProps.article;
+ const type = data.type.toLowerCase();
+
+ item.pubDate = parseDate(data.publishedAt);
+
+ if (type === 'video') {
+ item.description = art(path.join(__dirname, 'templates/video.art'), { article: data });
+ } else {
+ if (data.audio?.id) {
+ const response = await ofetch('https://api.aeonmedia.co/graphql', {
+ method: 'POST',
+ body: {
+ query: `query getAudio($audioId: ID!) {
+ audio(id: $audioId) {
+ id
+ streamUrl
+ }
+ }`,
+ variables: {
+ audioId: data.audio.id,
+ },
+ operationName: 'getAudio',
+ },
+ });
+
+ delete item.image;
+ item.enclosure_url = response.data.audio.streamUrl;
+ item.enclosure_type = 'audio/mpeg';
+ }
+
+ // Besides, it seems that the method based on __NEXT_DATA__
+ // does not include the information of the two-column
+ // images in the article body,
+ // e.g. https://aeon.co/essays/how-to-mourn-a-forest-a-lesson-from-west-papua .
+ // But that's very rare.
+
+ const capture = load(data.body, null, false);
+ const banner = data.image;
+ capture('p.pullquote').remove();
+
+ const authorsBio = data.authors.map((author) => '' + author.name + author.authorBio.replaceAll(/^
/g, ' ')).join('');
+
+ item.description = art(path.join(__dirname, 'templates/essay.art'), { banner, authorsBio, content: capture.html() });
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return items;
+};
+
+export { getData };
diff --git a/lib/routes/afdian/dynamic.js b/lib/routes/afdian/dynamic.js
deleted file mode 100644
index b25a3d605b0b7e..00000000000000
--- a/lib/routes/afdian/dynamic.js
+++ /dev/null
@@ -1,36 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- const url_slug = ctx.params.uid.replace('@', '');
- const baseUrl = 'https://afdian.net';
- const userInfoRes = await got(`${baseUrl}/api/user/get-profile-by-slug`, {
- searchParams: {
- url_slug,
- },
- });
- const userInfo = userInfoRes.data.data.user;
- const { user_id, name, avatar } = userInfo;
-
- const dynamicRes = await got(`${baseUrl}/api/post/get-list`, {
- searchParams: {
- type: 'old',
- user_id,
- },
- });
- const list = dynamicRes.data.data.list.map((item) => {
- const { publish_time, title, content, pics = [], post_id } = item;
- return {
- title,
- description: [content, pics.map((url) => ` `).join('')].filter((str) => !!str).join(' '),
- link: `${baseUrl}/p/${post_id}`,
- pubDate: new Date(Number(publish_time) * 1000).toUTCString(),
- };
- });
- ctx.state.data = {
- title: `${name}的爱发电动态`,
- description: `${name}的爱发电动态`,
- image: avatar,
- link: `${baseUrl}/@${url_slug}`,
- item: list,
- };
-};
diff --git a/lib/routes/afdian/dynamic.ts b/lib/routes/afdian/dynamic.ts
new file mode 100644
index 00000000000000..1b00bf34f2e17a
--- /dev/null
+++ b/lib/routes/afdian/dynamic.ts
@@ -0,0 +1,47 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/dynamic/:uid?',
+ categories: ['other'],
+ example: '/afdian/dynamic/@afdian',
+ parameters: { uid: '用户id,用户动态页面url里可找到' },
+ name: '用户动态',
+ maintainers: ['sanmmm'],
+ handler,
+};
+
+async function handler(ctx) {
+ const url_slug = ctx.req.param('uid').replace('@', '');
+ const baseUrl = 'https://afdian.com';
+ const userInfoRes = await got(`${baseUrl}/api/user/get-profile-by-slug`, {
+ searchParams: {
+ url_slug,
+ },
+ });
+ const userInfo = userInfoRes.data.data.user;
+ const { user_id, name, avatar } = userInfo;
+
+ const dynamicRes = await got(`${baseUrl}/api/post/get-list`, {
+ searchParams: {
+ type: 'old',
+ user_id,
+ },
+ });
+ const list = dynamicRes.data.data.list.map((item) => {
+ const { publish_time, title, content, pics = [], post_id } = item;
+ return {
+ title,
+ description: [content, pics.map((url) => ` `).join('')].filter((str) => !!str).join(' '),
+ link: `${baseUrl}/p/${post_id}`,
+ pubDate: new Date(Number(publish_time) * 1000).toUTCString(),
+ };
+ });
+ return {
+ title: `${name}的爱发电动态`,
+ description: `${name}的爱发电动态`,
+ image: avatar,
+ link: `${baseUrl}/@${url_slug}`,
+ item: list,
+ };
+}
diff --git a/lib/routes/afdian/explore.js b/lib/routes/afdian/explore.js
deleted file mode 100644
index bccd77236aec61..00000000000000
--- a/lib/routes/afdian/explore.js
+++ /dev/null
@@ -1,57 +0,0 @@
-const got = require('@/utils/got');
-
-const categoryMap = {
- 所有: '',
- 绘画: '1d2c1ac230dd11e88a2052540025c377',
- 视频: '68cf9fc630dd11e8aca852540025c377',
- 写作: '9db3776230dd11e89c6c52540025c377',
- 游戏: 'ed45455e30dc11e89fd452540025c377',
- 音乐: 'f89c99b22e6f11e8940c52540025c377',
- 播客: '5378451a30dd11e8bd4f52540025c377',
- 摄影: 'ffa47c662e6f11e8b26952540025c377',
- 技术: 'f62d3e58c39211e88abd52540025c377',
- Vtuber: 'e4f952e865cc11e98fb252540025c377',
- 舞蹈: '98a30fda836b11e9bbf152540025c377',
- 体育: 'f2212a3c836c11e9842d52540025c377',
- 旅游: '3935643c836e11e98cc552540025c377',
- 美食: 'c8d2eaae837011e9bfb652540025c377',
- 时尚: '05e960f6837311e9984952540025c377',
- 数码: 'd6163d8c837611e98ac352540025c377',
- 动画: '67498b10837711e99f0652540025c377',
- 其他: 'b1643af4328011e8b5b152540025c377',
-};
-
-const typeToLabel = {
- rec: '推荐',
- hot: '人气',
-};
-
-module.exports = async (ctx) => {
- const { type = 'rec', category = '所有' } = ctx.params;
- const baseUrl = 'https://afdian.net';
- const link = `${baseUrl}/api/creator/list`;
- const res = await got(link, {
- searchParams: {
- type,
- category_id: categoryMap[category],
- },
- });
- const list = res.data.data.list.map((item) => {
- const { doing, monthly_fans, detail } = item.creator;
- return {
- title: item.name,
- description: `正在创作 ${doing}
- ${monthly_fans}发电人次/月
- 详情:
- ${detail}
- `,
- link: `${baseUrl}/@${item.url_slug}`,
- };
- });
- ctx.state.data = {
- title: `爱发电-创作者 (按 ${category}/${typeToLabel[type]})`,
- description: `爱发电-发现创作者 (按 ${category}/${typeToLabel[type]})`,
- link: `${baseUrl}/explore`,
- item: list,
- };
-};
diff --git a/lib/routes/afdian/explore.ts b/lib/routes/afdian/explore.ts
new file mode 100644
index 00000000000000..e212ca4066c320
--- /dev/null
+++ b/lib/routes/afdian/explore.ts
@@ -0,0 +1,78 @@
+import got from '@/utils/got';
+
+const categoryMap = {
+ 所有: '',
+ 绘画: '1d2c1ac230dd11e88a2052540025c377',
+ 视频: '68cf9fc630dd11e8aca852540025c377',
+ 写作: '9db3776230dd11e89c6c52540025c377',
+ 游戏: 'ed45455e30dc11e89fd452540025c377',
+ 音乐: 'f89c99b22e6f11e8940c52540025c377',
+ 播客: '5378451a30dd11e8bd4f52540025c377',
+ 摄影: 'ffa47c662e6f11e8b26952540025c377',
+ 技术: 'f62d3e58c39211e88abd52540025c377',
+ Vtuber: 'e4f952e865cc11e98fb252540025c377',
+ 舞蹈: '98a30fda836b11e9bbf152540025c377',
+ 体育: 'f2212a3c836c11e9842d52540025c377',
+ 旅游: '3935643c836e11e98cc552540025c377',
+ 美食: 'c8d2eaae837011e9bfb652540025c377',
+ 时尚: '05e960f6837311e9984952540025c377',
+ 数码: 'd6163d8c837611e98ac352540025c377',
+ 动画: '67498b10837711e99f0652540025c377',
+ 其他: 'b1643af4328011e8b5b152540025c377',
+};
+
+const typeToLabel = {
+ rec: '推荐',
+ hot: '人气',
+};
+
+export const route: Route = {
+ path: '/explore/:type/:category?',
+ categories: ['other'],
+ example: '/afdian/explore/hot/所有',
+ parameters: { type: '分类', category: '目录类型,默认为 `所有`' },
+ name: '发现用户',
+ maintainers: ['sanmmm'],
+ description: `分类
+
+| 推荐 | 最热 |
+| ---- | ---- |
+| rec | hot |
+
+ 目录类型
+
+| 所有 | 绘画 | 视频 | 写作 | 游戏 | 音乐 | 播客 | 摄影 | 技术 | Vtuber | 舞蹈 | 体育 | 旅游 | 美食 | 时尚 | 数码 | 动画 | 其他 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ------ | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| 所有 | 绘画 | 视频 | 写作 | 游戏 | 音乐 | 播客 | 摄影 | 技术 | Vtuber | 舞蹈 | 体育 | 旅游 | 美食 | 时尚 | 数码 | 动画 | 其他 |`,
+ handler,
+};
+
+async function handler(ctx) {
+ const { type = 'rec', category = '所有' } = ctx.req.param();
+ const baseUrl = 'https://afdian.com';
+ const link = `${baseUrl}/api/creator/list`;
+ const res = await got(link, {
+ searchParams: {
+ type,
+ category_id: categoryMap[category],
+ },
+ });
+ const list = res.data.data.list.map((item) => {
+ const { doing, monthly_fans, detail } = item.creator;
+ return {
+ title: item.name,
+ description: `正在创作 ${doing}
+ ${monthly_fans}发电人次/月
+ 详情:
+ ${detail}
+ `,
+ link: `${baseUrl}/@${item.url_slug}`,
+ };
+ });
+ return {
+ title: `爱发电-创作者 (按 ${category}/${typeToLabel[type]})`,
+ description: `爱发电-发现创作者 (按 ${category}/${typeToLabel[type]})`,
+ link: `${baseUrl}/explore`,
+ item: list,
+ };
+}
diff --git a/lib/routes/afdian/namespace.ts b/lib/routes/afdian/namespace.ts
new file mode 100644
index 00000000000000..43af5fb4c42cd4
--- /dev/null
+++ b/lib/routes/afdian/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '爱发电',
+ url: 'afdian.net',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aflcio/blog.ts b/lib/routes/aflcio/blog.ts
new file mode 100644
index 00000000000000..1e824792a38491
--- /dev/null
+++ b/lib/routes/aflcio/blog.ts
@@ -0,0 +1,159 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '5', 10);
+
+ const baseUrl: string = 'https://aflcio.org';
+ const targetUrl: string = new URL('blog', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+
+ let items: DataItem[] = [];
+
+ items = $('article.article')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('header.container h1 a').first();
+
+ const title: string = $aEl.text();
+ const description: string | undefined = $el.find('div.section').html() ?? '';
+ const pubDateStr: string | undefined = $el.find('div.date-timeline time').attr('datetime');
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const authorEls: Element[] = $el.find('div.date-timeline a.user').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $authorEl: Cheerio = $(authorEl);
+
+ return {
+ name: $authorEl.text(),
+ url: $authorEl.attr('href') ? new URL($authorEl.attr('href') as string, baseUrl).href : undefined,
+ avatar: undefined,
+ };
+ });
+ const image: string | undefined = $el.find('div.section img').first().attr('src') ? new URL($el.find('div.section img').first().attr('src') as string, baseUrl).href : undefined;
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('header.article-header h1').text();
+ const description: string | undefined = $$('div.section-article-body').html() ?? '';
+ const pubDateStr: string | undefined = $$('time').attr('datetime');
+ const authorEls: Element[] = $$('div.byline a[property="schema:name"]').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl);
+
+ return {
+ name: $$authorEl.text(),
+ url: $$authorEl.attr('href') ? new URL($$authorEl.attr('href') as string, baseUrl).href : undefined,
+ avatar: undefined,
+ };
+ });
+ const image: string | undefined = $$('meta[property="og:image"]').attr('content');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: title,
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.main-logo').attr('src') ? new URL($('img.main-logo').attr('src') as string, baseUrl).href : undefined,
+ author: title.split(/\|/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/blog',
+ name: 'Blog',
+ url: 'aflcio.org',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/aflcio/blog',
+ parameters: undefined,
+ description: undefined,
+ categories: ['other'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['aflcio.org/blog'],
+ target: '/blog',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/aflcio/namespace.ts b/lib/routes/aflcio/namespace.ts
new file mode 100644
index 00000000000000..0ac32675699b66
--- /dev/null
+++ b/lib/routes/aflcio/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AFL-CIO',
+ url: 'aflcio.org',
+ categories: ['other'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/afr/latest.ts b/lib/routes/afr/latest.ts
new file mode 100644
index 00000000000000..e70f92a596c7a7
--- /dev/null
+++ b/lib/routes/afr/latest.ts
@@ -0,0 +1,70 @@
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { assetsConnectionByCriteriaQuery } from './query';
+import { getItem } from './utils';
+
+export const route: Route = {
+ path: '/latest',
+ categories: ['traditional-media'],
+ example: '/afr/latest',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.afr.com/latest', 'www.afr.com/'],
+ },
+ ],
+ name: 'Latest',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'www.afr.com/latest',
+};
+
+async function handler(ctx: Context) {
+ const limit = Number.parseInt(ctx.req.query('limit') ?? '10');
+ const response = await ofetch('https://api.afr.com/graphql', {
+ query: {
+ query: assetsConnectionByCriteriaQuery,
+ operationName: 'assetsConnectionByCriteria',
+ variables: {
+ brand: 'afr',
+ first: limit,
+ render: 'web',
+ types: ['article', 'bespoke', 'featureArticle', 'liveArticle', 'video'],
+ after: '',
+ },
+ },
+ });
+
+ const list = response.data.assetsConnectionByCriteria.edges.map(({ node }) => ({
+ title: node.asset.headlines.headline,
+ description: node.asset.about,
+ link: `https://www.afr.com${node.urls.published.afr.path}`,
+ pubDate: parseDate(node.dates.firstPublished),
+ updated: parseDate(node.dates.modified),
+ author: node.asset.byline,
+ category: [node.tags.primary.displayName, ...node.tags.secondary.map((tag) => tag.displayName)],
+ image: node.featuredImages && `https://static.ffx.io/images/${node.featuredImages.landscape16x9.data.id}`,
+ }));
+
+ const items = await Promise.all(list.map((item) => cache.tryGet(item.link, () => getItem(item))));
+
+ return {
+ title: 'Latest | The Australian Financial Review | AFR',
+ description: 'The latest news, events, analysis and opinion from The Australian Financial Review',
+ image: 'https://www.afr.com/apple-touch-icon-1024x1024.png',
+ link: 'https://www.afr.com/latest',
+ item: items,
+ };
+}
diff --git a/lib/routes/afr/namespace.ts b/lib/routes/afr/namespace.ts
new file mode 100644
index 00000000000000..d6fd9b647e2165
--- /dev/null
+++ b/lib/routes/afr/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'The Australian Financial Review',
+ url: 'afr.com',
+ lang: 'en',
+};
diff --git a/lib/routes/afr/navigation.ts b/lib/routes/afr/navigation.ts
new file mode 100644
index 00000000000000..d39e74c72accd6
--- /dev/null
+++ b/lib/routes/afr/navigation.ts
@@ -0,0 +1,76 @@
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { pageByNavigationPathQuery } from './query';
+import { getItem } from './utils';
+
+export const route: Route = {
+ path: '/navigation/:path{.+}',
+ categories: ['traditional-media'],
+ example: '/afr/navigation/markets',
+ parameters: {
+ path: 'Navigation path, can be found in the URL of the page',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.afr.com/path*'],
+ },
+ ],
+ name: 'Navigation',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'www.afr.com',
+};
+
+async function handler(ctx: Context) {
+ const { path } = ctx.req.param();
+ const limit = Number.parseInt(ctx.req.query('limit') ?? '10');
+
+ const response = await ofetch('https://api.afr.com/api/content-audience/afr/graphql', {
+ query: {
+ query: pageByNavigationPathQuery,
+ operationName: 'pageByNavigationPath',
+ variables: {
+ input: { brandKey: 'afr', navigationPath: `/${path}`, renderName: 'web' },
+ firstStories: limit,
+ afterStories: '',
+ },
+ },
+ });
+
+ const list = response.data.pageByNavigationPath.page.latestStoriesConnection.edges.map(({ node }) => ({
+ title: node.headlines.headline,
+ description: node.overview.about,
+ link: `https://www.afr.com${node.urls.canonical.path}`,
+ pubDate: parseDate(node.dates.firstPublished),
+ updated: parseDate(node.dates.modified),
+ author: node.byline
+ .filter((byline) => byline.type === 'AUTHOR')
+ .map((byline) => byline.author.name)
+ .join(', '),
+ category: [node.tags.primary.displayName, ...node.tags.secondary.map((tag) => tag.displayName)],
+ image: node.images && `https://static.ffx.io/images/${node.images.landscape16x9.mediaId}`,
+ }));
+
+ const items = await Promise.all(list.map((item) => cache.tryGet(item.link, () => getItem(item))));
+
+ return {
+ title: response.data.pageByNavigationPath.page.seo.title,
+ description: response.data.pageByNavigationPath.page.seo.description,
+ image: 'https://www.afr.com/apple-touch-icon-1024x1024.png',
+ link: `https://www.afr.com/${path}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/afr/query.ts b/lib/routes/afr/query.ts
new file mode 100644
index 00000000000000..a596f8fc4f70dd
--- /dev/null
+++ b/lib/routes/afr/query.ts
@@ -0,0 +1,349 @@
+export const pageByNavigationPathQuery = `query pageByNavigationPath(
+ $input: PageByNavigationPathInput!
+ $firstStories: Int
+ $afterStories: Cursor
+ ) {
+ pageByNavigationPath(input: $input) {
+ error {
+ message
+ type {
+ class
+ ... on ErrorTypeInvalidRequest {
+ fields {
+ field
+ message
+ }
+ }
+ }
+ }
+ page {
+ ads {
+ suppress
+ }
+ description
+ id
+ latestStoriesConnection(first: $firstStories, after: $afterStories) {
+ edges {
+ node {
+ byline {
+ ...AssetBylineFragment
+ }
+ headlines {
+ headline
+ }
+ ads {
+ sponsor {
+ name
+ }
+ }
+ overview {
+ about
+ label
+ }
+ type
+ dates {
+ firstPublished
+ published
+ }
+ id
+ publicId
+ images {
+ ...AssetImagesFragmentAudience
+ }
+ tags {
+ primary {
+ ...TagFragmentAudience
+ }
+ secondary {
+ ...TagFragmentAudience
+ }
+ }
+ urls {
+ ...AssetUrlsAudienceFragment
+ }
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
+ }
+ }
+ name
+ seo {
+ canonical {
+ brand {
+ key
+ }
+ }
+ description
+ title
+ }
+ social {
+ image {
+ height
+ url
+ width
+ }
+ }
+ }
+ redirect
+ }
+ }
+ fragment AssetBylineFragment on AssetByline {
+ type
+ ... on AssetBylineAuthor {
+ author {
+ name
+ publicId
+ profile {
+ avatar
+ bio
+ body
+ canonical {
+ brand {
+ key
+ }
+ }
+ email
+ socials {
+ facebook {
+ publicId
+ }
+ twitter {
+ publicId
+ }
+ }
+ title
+ }
+ }
+ }
+ ... on AssetBylineName {
+ name
+ }
+ }
+ fragment AssetImagesFragmentAudience on ImageRenditions {
+ landscape16x9 {
+ ...ImageFragmentAudience
+ }
+ landscape3x2 {
+ ...ImageFragmentAudience
+ }
+ portrait2x3 {
+ ...ImageFragmentAudience
+ }
+ square1x1 {
+ ...ImageFragmentAudience
+ }
+ }
+ fragment ImageFragmentAudience on ImageRendition {
+ altText
+ animated
+ caption
+ credit
+ crop {
+ offsetX
+ offsetY
+ width
+ zoom
+ }
+ mediaId
+ mimeType
+ source
+ type
+ }
+ fragment AssetUrlsAudienceFragment on AssetURLs {
+ canonical {
+ brand {
+ key
+ }
+ path
+ }
+ external {
+ url
+ }
+ published {
+ brand {
+ key
+ }
+ path
+ }
+ }
+ fragment TagFragmentAudience on Tag {
+ company {
+ exchangeCode
+ stockCode
+ }
+ context {
+ name
+ }
+ description
+ displayName
+ externalEntities {
+ google {
+ placeId
+ }
+ wikipedia {
+ publicId
+ url
+ }
+ }
+ id
+ location {
+ latitude
+ longitude
+ postalCode
+ state
+ }
+ name
+ publicId
+ seo {
+ description
+ title
+ }
+ urls {
+ canonical {
+ brand {
+ key
+ }
+ path
+ }
+ published {
+ brand {
+ key
+ }
+ path
+ }
+ }
+ }`;
+
+export const assetsConnectionByCriteriaQuery = `query assetsConnectionByCriteria(
+ $after: ID
+ $brand: Brand!
+ $categories: [Int!]
+ $first: Int!
+ $render: Render!
+ $types: [AssetType!]!
+ ) {
+ assetsConnectionByCriteria(
+ after: $after
+ brand: $brand
+ categories: $categories
+ first: $first
+ render: $render
+ types: $types
+ ) {
+ edges {
+ cursor
+ node {
+ ...AssetFragment
+ sponsor {
+ name
+ }
+ }
+ }
+ error {
+ message
+ type {
+ class
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
+ }
+ }
+ }
+ fragment AssetFragment on Asset {
+ asset {
+ about
+ byline
+ duration
+ headlines {
+ headline
+ }
+ live
+ }
+ assetType
+ dates {
+ firstPublished
+ modified
+ published
+ }
+ id
+ featuredImages {
+ landscape16x9 {
+ ...ImageFragment
+ }
+ landscape3x2 {
+ ...ImageFragment
+ }
+ portrait2x3 {
+ ...ImageFragment
+ }
+ square1x1 {
+ ...ImageFragment
+ }
+ }
+ label
+ tags {
+ primary: primaryTag {
+ ...AssetTag
+ }
+ secondary {
+ ...AssetTag
+ }
+ }
+ urls {
+ ...AssetURLs
+ }
+ }
+ fragment AssetTag on AssetTagDetails {
+ ...AssetTagAudience
+ shortID
+ slug
+ }
+ fragment AssetTagAudience on AssetTagDetails {
+ company {
+ exchangeCode
+ stockCode
+ }
+ context
+ displayName
+ id
+ name
+ urls {
+ canonical {
+ brand
+ path
+ }
+ published {
+ afr {
+ path
+ }
+ }
+ }
+ }
+ fragment AssetURLs on AssetURLs {
+ canonical {
+ brand
+ path
+ }
+ published {
+ afr {
+ path
+ }
+ }
+ }
+ fragment ImageFragment on Image {
+ data {
+ altText
+ aspect
+ autocrop
+ caption
+ cropWidth
+ id
+ offsetX
+ offsetY
+ zoom
+ }
+ }`;
diff --git a/lib/routes/afr/utils.ts b/lib/routes/afr/utils.ts
new file mode 100644
index 00000000000000..f56d36248a418b
--- /dev/null
+++ b/lib/routes/afr/utils.ts
@@ -0,0 +1,81 @@
+import * as cheerio from 'cheerio';
+
+import ofetch from '@/utils/ofetch';
+
+export const getItem = async (item) => {
+ const response = await ofetch(item.link);
+ const $ = cheerio.load(response);
+
+ const reduxState = JSON.parse($('script#__REDUX_STATE__').text().replaceAll(':undefined', ':null').match('__REDUX_STATE__=(.*);')?.[1] || '{}');
+
+ const content = reduxState.page.content;
+ const asset = content.asset;
+
+ switch (content.assetType) {
+ case 'liveArticle':
+ item.description = asset.posts.map((post) => `${post.asset.headlines.headline} ${post.asset.body}`).join('');
+ break;
+
+ case 'article':
+ case 'featureArticle':
+ item.description = renderArticle(asset, item.link);
+ break;
+
+ default:
+ throw new Error(`Unknown asset type: ${content.assetType} in ${item.link}`);
+ }
+
+ return item;
+};
+
+const renderArticle = (asset, link: string) => {
+ const $ = cheerio.load(asset.body, null, false);
+ $('x-placeholder').each((_, el) => {
+ const $el = $(el);
+ const id = $el.attr('id');
+ if (!id) {
+ $el.replaceWith('');
+ }
+
+ const placeholder = asset.bodyPlaceholders[id!];
+ switch (placeholder?.type) {
+ case 'callout':
+ case 'relatedStory':
+ $el.replaceWith('');
+ break;
+
+ case 'iframe':
+ $el.replaceWith(``);
+ break;
+
+ case 'image':
+ $el.replaceWith(` `);
+ break;
+
+ case 'linkArticle':
+ $el.replaceWith(placeholder.data.text);
+ break;
+
+ case 'linkExternal':
+ $el.replaceWith(`${placeholder.data.text} `);
+ break;
+
+ case 'quote':
+ $el.replaceWith(placeholder.data.markup);
+ break;
+
+ case 'scribd':
+ $el.replaceWith(`View on Scribd `);
+ break;
+
+ case 'twitter':
+ $el.replaceWith(`${placeholder.data.url} `);
+ break;
+
+ default:
+ throw new Error(`Unknown placeholder type: ${placeholder?.type} in ${link}`);
+ }
+ });
+
+ return $.html();
+};
diff --git a/lib/routes/agefans/detail.ts b/lib/routes/agefans/detail.ts
new file mode 100644
index 00000000000000..1cd53e2c2b6e3a
--- /dev/null
+++ b/lib/routes/agefans/detail.ts
@@ -0,0 +1,57 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import { rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/detail/:id',
+ categories: ['anime'],
+ example: '/agefans/detail/20200035',
+ parameters: { id: '番剧 id,对应详情 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['agemys.org/detail/:id'],
+ },
+ ],
+ name: '番剧详情',
+ maintainers: ['s2marine'],
+ handler,
+};
+
+async function handler(ctx) {
+ const response = await got(`${rootUrl}/detail/${ctx.req.param('id')}`);
+ const $ = load(response.data);
+
+ const ldJson = JSON.parse($('script[type="application/ld+json"]').text());
+
+ const items = $('.video_detail_episode')
+ .first()
+ .find('li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a');
+ return {
+ title: a.text(),
+ link: a.attr('href').replace('http://', 'https://'),
+ };
+ })
+ .toReversed();
+
+ return {
+ title: `AGE动漫 - ${ldJson.name}`,
+ link: `${rootUrl}/detail/${ctx.req.param('id')}`,
+ description: ldJson.description,
+ item: items,
+ };
+}
diff --git a/lib/routes/agefans/namespace.ts b/lib/routes/agefans/namespace.ts
new file mode 100644
index 00000000000000..1cbb5dbc53fb0b
--- /dev/null
+++ b/lib/routes/agefans/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AGE 动漫',
+ url: 'agemys.cc',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/agefans/update.ts b/lib/routes/agefans/update.ts
new file mode 100644
index 00000000000000..c7dbf3dd33bca6
--- /dev/null
+++ b/lib/routes/agefans/update.ts
@@ -0,0 +1,79 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/update',
+ categories: ['anime'],
+ example: '/agefans/update',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['agemys.org/update', 'agemys.org/'],
+ },
+ ],
+ name: '最近更新',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'agemys.org/update',
+};
+
+async function handler() {
+ const currentUrl = `${rootUrl}/update`;
+ const response = await got(currentUrl);
+
+ const $ = load(response.data);
+
+ const list = $('.video_item')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const link = item.find('a').attr('href').replace('http://', 'https://');
+ return {
+ title: item.text(),
+ link,
+ guid: `${link}#${item.find('.video_item--info').text()}`,
+ };
+ });
+
+ const items: DataItem[] = await pMap(
+ list,
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link);
+ const content = load(detailResponse.data);
+
+ content('img').each((_, ele) => {
+ if (ele.attribs['data-original']) {
+ ele.attribs.src = ele.attribs['data-original'];
+ delete ele.attribs['data-original'];
+ }
+ });
+ content('.video_detail_collect').remove();
+
+ item.description = content('.video_detail_left').html();
+
+ return item;
+ }),
+ { concurrency: 3 }
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/agefans/utils.ts b/lib/routes/agefans/utils.ts
new file mode 100644
index 00000000000000..c3eb91f7ff24e8
--- /dev/null
+++ b/lib/routes/agefans/utils.ts
@@ -0,0 +1,3 @@
+const rootUrl = 'https://www.agemys.org';
+
+export { rootUrl };
diff --git a/lib/routes/agirls/namespace.ts b/lib/routes/agirls/namespace.ts
new file mode 100644
index 00000000000000..6a660dc1e741b8
--- /dev/null
+++ b/lib/routes/agirls/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '电獭少女',
+ url: 'agirls.aotter.net',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/agirls/topic-list.ts b/lib/routes/agirls/topic-list.ts
new file mode 100644
index 00000000000000..b7f1f0fff6ff1d
--- /dev/null
+++ b/lib/routes/agirls/topic-list.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import { baseUrl } from './utils';
+
+export const route: Route = {
+ path: '/topic_list',
+ categories: ['new-media'],
+ example: '/agirls/topic_list',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['agirls.aotter.net/', 'agirls.aotter.net/topic'],
+ },
+ ],
+ name: '当前精选主题列表',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'agirls.aotter.net/',
+};
+
+async function handler() {
+ const category = 'topic';
+ const link = `${baseUrl}/${category}`;
+
+ const response = await got(`${baseUrl}/${category}`);
+
+ const $ = load(response.data);
+
+ const items = $('.ag-topic')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('.ag-topic__link').text().trim(),
+ description: item.find('.ag-topic__summery').text().trim(),
+ link: `${baseUrl}${item.find('.ag-topic__link').attr('href')}`,
+ };
+ });
+
+ return {
+ title: $('head title').text().trim(),
+ link,
+ description: $('head meta[name=description]').attr('content'),
+ item: items,
+ language: $('html').attr('lang'),
+ };
+}
diff --git a/lib/routes/agirls/topic.ts b/lib/routes/agirls/topic.ts
new file mode 100644
index 00000000000000..5ea7dc8a2bc6b7
--- /dev/null
+++ b/lib/routes/agirls/topic.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { baseUrl, parseArticle } from './utils';
+
+export const route: Route = {
+ path: '/topic/:topic',
+ categories: ['new-media'],
+ example: '/agirls/topic/AppleWatch',
+ parameters: { topic: '精选主题,可通过下方精选主题列表获得' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['agirls.aotter.net/topic/:topic'],
+ },
+ ],
+ name: '精选主题',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const topic = ctx.req.param('topic');
+ const link = `${baseUrl}/topic/${topic}`;
+ const response = await got(link);
+
+ const $ = load(response.data);
+ const ldJson = JSON.parse($('script[type="application/ld+json"]').text());
+ const list = $('.ag-post-item__link')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.text().trim(),
+ link: `${baseUrl}${item.attr('href')}`,
+ };
+ });
+
+ const items = await Promise.all(list.map((item) => cache.tryGet(item.link, () => parseArticle(item))));
+
+ return {
+ title: $('head title').text().trim(),
+ link,
+ description: ldJson['@graph'][0].description,
+ item: items,
+ language: $('html').attr('lang'),
+ };
+}
diff --git a/lib/routes/agirls/utils.ts b/lib/routes/agirls/utils.ts
new file mode 100644
index 00000000000000..13059a6978bdf0
--- /dev/null
+++ b/lib/routes/agirls/utils.ts
@@ -0,0 +1,29 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'https://agirls.aotter.net';
+
+const parseArticle = async (item) => {
+ const detailResponse = await got(item.link);
+ const content = load(detailResponse.data);
+
+ item.category = [
+ ...new Set(
+ content('.ag-article__tag')
+ .toArray()
+ .map((e) => content(e).text().trim().replace('#', ''))
+ ),
+ ];
+ const ldJson = JSON.parse(content('script[type="application/ld+json"]').text());
+
+ item.description = content('.ag-article__content').html();
+ item.pubDate = parseDate(ldJson['@graph'][0].datePublished); // 2023-07-05T12:11:36+08:00
+ item.updated = parseDate(ldJson['@graph'][0].dateModified); // 2023-07-05T12:11:36+08:00
+ item.author = ldJson['@graph'][0].author.map((a) => a.name).join(', ');
+
+ return item;
+};
+
+export { baseUrl, parseArticle };
diff --git a/lib/routes/agirls/z-index.ts b/lib/routes/agirls/z-index.ts
new file mode 100644
index 00000000000000..689668196f27eb
--- /dev/null
+++ b/lib/routes/agirls/z-index.ts
@@ -0,0 +1,62 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { baseUrl, parseArticle } from './utils';
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/agirls/app',
+ parameters: { category: '分类,默认为最新文章,可在对应主题页的 URL 中找到,下表仅列出部分' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['agirls.aotter.net/posts/:category'],
+ target: '/:category',
+ },
+ ],
+ name: '分类',
+ maintainers: ['TonyRL'],
+ handler,
+ description: `| App 评测 | 手机开箱 | 笔电开箱 | 3C 周边 | 教学小技巧 | 科技情报 |
+| -------- | -------- | -------- | ----------- | ---------- | -------- |
+| app | phone | computer | accessories | tutorial | techlife |`,
+};
+
+async function handler(ctx) {
+ const { category = '' } = ctx.req.param();
+ const link = `${baseUrl}/posts${category ? `/${category}` : ''}`;
+ const response = await got(link);
+
+ const $ = load(response.data);
+
+ const list = $('.ag-post-item__link')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.text().trim(),
+ link: `${baseUrl}${item.attr('href')}`,
+ };
+ });
+
+ const items = await Promise.all(list.map((item) => cache.tryGet(item.link, () => parseArticle(item))));
+
+ return {
+ title: $('head title').text().trim(),
+ link,
+ description: $('head meta[name=description]').attr('content'),
+ item: items,
+ language: $('html').attr('lang'),
+ };
+}
diff --git a/lib/routes/agora0/index.ts b/lib/routes/agora0/index.ts
new file mode 100644
index 00000000000000..ed6f17a6765d30
--- /dev/null
+++ b/lib/routes/agora0/index.ts
@@ -0,0 +1,84 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/agora0/initium',
+ parameters: { category: '分类,见下表,默认为 initium,即端传媒' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['agora0.gitlab.io/blog/:category', 'agora0.gitlab.io/'],
+ target: '/:category',
+ },
+ ],
+ name: '零博客',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| muitinⒾ | aidemnⒾ | srettaⓂ | qⓅ | sucoⓋ |
+| ------- | ------- | -------- | -- | ----- |
+| initium | inmedia | matters | pq | vocus |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'initium';
+
+ const rootUrl = 'https://agora0.gitlab.io';
+ const currentUrl = `${rootUrl}/blog/${category}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.card span:not(.comments) a')
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text(),
+ link: item.attr('href'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.author = content('meta[name="author"]').attr('content');
+ item.pubDate = parseDate(content('meta[property="article:published_time"]').attr('content'));
+ item.description = content('.post-content').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/agora0/namespace.ts b/lib/routes/agora0/namespace.ts
new file mode 100644
index 00000000000000..e970eba75d9a3e
--- /dev/null
+++ b/lib/routes/agora0/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AG⓪RA',
+ url: 'agorahub.github.io',
+ lang: 'en',
+};
diff --git a/lib/routes/agora0/pen0.ts b/lib/routes/agora0/pen0.ts
new file mode 100644
index 00000000000000..664e4aaeb2c1ad
--- /dev/null
+++ b/lib/routes/agora0/pen0.ts
@@ -0,0 +1,70 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/pen0',
+ categories: ['new-media'],
+ example: '/agora0/pen0',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['agorahub.github.io/pen0'],
+ },
+ ],
+ name: '共和報',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'agorahub.github.io/pen0',
+};
+
+async function handler() {
+ const baseUrl = 'https://agorahub.github.io';
+ const response = await got(`${baseUrl}/pen0/`);
+ const $ = load(response.data);
+
+ const list = $('div article')
+ .toArray()
+ .slice(0, -1) // last one is a dummy
+ .map((item) => {
+ item = $(item);
+ const meta = item.find('h5').first().text();
+ return {
+ title: item.find('h3').text(),
+ link: item.find('h3 a').attr('href'),
+ author: meta.split('|')[0].trim(),
+ pubDate: parseDate(meta.split('|')[1].trim()),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+ $('h1').remove();
+ item.description = $('article').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('head title').text(),
+ description: $('head meta[name="description"]').attr('content'),
+ link: response.url,
+ image: $('link[rel="apple-touch-icon"]').attr('href'),
+ item: items,
+ };
+}
diff --git a/lib/routes/agri/index.ts b/lib/routes/agri/index.ts
new file mode 100644
index 00000000000000..07cf69db90b873
--- /dev/null
+++ b/lib/routes/agri/index.ts
@@ -0,0 +1,306 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx) => {
+ const { category = 'zx/zxfb/' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 10;
+
+ const rootUrl = 'http://www.agri.cn';
+ const currentUrl = new URL(category.endsWith('/') ? category : `${category}/`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('div.list_li_con, div.nxw_video_com')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a').first();
+
+ const title = a.text();
+ const image = item.find('img').first().prop('src') ? new URL(item.find('img').first().prop('src'), rootUrl).href : undefined;
+ const description = art(path.join(__dirname, 'templates/description.art'), {
+ intro: item.find('p.con_text').text() || undefined,
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ });
+
+ return {
+ title,
+ description,
+ pubDate: parseDate(item.find('span.con_date_span').text() || `${item.find('div.com_time_p2').text().trim()}${item.find('div.com_time_p1').text()}`, ['YYYY-MM-DD', 'YYYY.MM.DD']),
+ link: new URL(a.prop('href'), currentUrl).href,
+ content: {
+ html: description,
+ text: item.find('p.con_text').text() || undefined,
+ },
+ image,
+ banner: image,
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ const title = $$('div.detailCon_info_tit').text().trim();
+ const description = art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div.content_body_box').html(),
+ });
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = timezone(parseDate($$('meta[name="publishdate"]').prop('content')), +8);
+ item.author = $$('meta[name="author"]').prop('content') || $$('meta[name="source"]').prop('content');
+ item.content = {
+ html: description,
+ text: $$('div.content_body_box').text(),
+ };
+ item.language = language;
+
+ item.enclosure_url = $$('div.content_body_box video').prop('src') ?? undefined;
+ item.enclosure_type = item.enclosure_url ? 'video/mp4' : undefined;
+ item.enclosure_title = item.enclosure_url ? title : undefined;
+
+ return item;
+ })
+ )
+ );
+
+ const image = new URL($('div.logo img').prop('src'), rootUrl).href;
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: '分类',
+ url: 'www.agri.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/agri/zx/zxfb',
+ parameters: { category: '分类,默认为 `zx/zxfb`,即最新发布,可在对应分类页 URL 中找到' },
+ description: `::: tip
+ 若订阅 [最新发布](http://www.agri.cn/zx/zxfb/),网址为 \`http://www.agri.cn/zx/zxfb/\`。截取 \`https://www.agri.cn/\` 到末尾的部分 \`zx/zxfb\` 作为参数填入,此时路由为 [\`/agri/zx/zxfb\`](https://rsshub.app/agri/zx/zxfb)。
+:::
+
+#### [机构](http://www.agri.cn/jg/)
+
+| 分类 | ID |
+| --------------------------------------- | ------------------------------------------ |
+| [成果展示](http://www.agri.cn/jg/cgzs/) | [jg/cgzs](https://rsshub.app/agri/jg/cgzs) |
+
+#### [资讯](http://www.agri.cn/zx/)
+
+| 分类 | ID |
+| ------------------------------------------- | ------------------------------------------ |
+| [最新发布](http://www.agri.cn/zx/zxfb/) | [zx/zxfb](https://rsshub.app/agri/zx/zxfb) |
+| [农业要闻](http://www.agri.cn/zx/nyyw/) | [zx/nyyw](https://rsshub.app/agri/zx/nyyw) |
+| [中心动态](http://www.agri.cn/zx/zxdt/) | [zx/zxdt](https://rsshub.app/agri/zx/zxdt) |
+| [通知公告](http://www.agri.cn/zx/hxgg/) | [zx/hxgg](https://rsshub.app/agri/zx/hxgg) |
+| [全国信息联播](http://www.agri.cn/zx/xxlb/) | [zx/xxlb](https://rsshub.app/agri/zx/xxlb) |
+
+#### [生产](http://www.agri.cn/sc/)
+
+| 分类 | ID |
+| --------------------------------------- | ------------------------------------------ |
+| [生产动态](http://www.agri.cn/sc/scdt/) | [sc/scdt](https://rsshub.app/agri/sc/scdt) |
+| [农业品种](http://www.agri.cn/sc/nypz/) | [sc/nypz](https://rsshub.app/agri/sc/nypz) |
+| [农事指导](http://www.agri.cn/sc/nszd/) | [sc/nszd](https://rsshub.app/agri/sc/nszd) |
+| [农业气象](http://www.agri.cn/sc/nyqx/) | [sc/nyqx](https://rsshub.app/agri/sc/nyqx) |
+| [专项监测](http://www.agri.cn/sc/zxjc/) | [sc/zxjc](https://rsshub.app/agri/sc/zxjc) |
+
+#### [数据](http://www.agri.cn/sj/)
+
+| 分类 | ID |
+| --------------------------------------- | ------------------------------------------ |
+| [市场动态](http://www.agri.cn/sj/scdt/) | [sj/scdt](https://rsshub.app/agri/sj/scdt) |
+| [供需形势](http://www.agri.cn/sj/gxxs/) | [sj/gxxs](https://rsshub.app/agri/sj/gxxs) |
+| [监测预警](http://www.agri.cn/sj/jcyj/) | [sj/jcyj](https://rsshub.app/agri/sj/jcyj) |
+
+#### [信息化](http://www.agri.cn/xxh/)
+
+| 分类 | ID |
+| ---------------------------------------------- | ------------------------------------------------ |
+| [智慧农业](http://www.agri.cn/xxh/zhny/) | [xxh/zhny](https://rsshub.app/agri/xxh/zhny) |
+| [信息化标准](http://www.agri.cn/xxh/xxhbz/) | [xxh/xxhbz](https://rsshub.app/agri/xxh/xxhbz) |
+| [中国乡村资讯](http://www.agri.cn/xxh/zgxczx/) | [xxh/zgxczx](https://rsshub.app/agri/xxh/zgxczx) |
+
+#### [视频](http://www.agri.cn/video/)
+
+| 分类 | ID |
+| -------------------------------------------------- | ---------------------------------------------------------------- |
+| [新闻资讯](http://www.agri.cn/video/xwzx/nyxw/) | [video/xwzx/nyxw](https://rsshub.app/agri/video/xwzx/nyxw) |
+| [致富天地](http://www.agri.cn/video/zftd/) | [video/zftd](https://rsshub.app/agri/video/zftd) |
+| [地方农业](http://www.agri.cn/video/dfny/beijing/) | [video/dfny/beijing](https://rsshub.app/agri/video/dfny/beijing) |
+| [气象农业](http://www.agri.cn/video/qxny/) | [video/qxny](https://rsshub.app/agri/video/qxny) |
+| [讲座培训](http://www.agri.cn/video/jzpx/) | [video/jzpx](https://rsshub.app/agri/video/jzpx) |
+| [文化生活](http://www.agri.cn/video/whsh/) | [video/whsh](https://rsshub.app/agri/video/whsh) |
+ `,
+ categories: ['new-media'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.agri.cn/:category?'],
+ target: (params) => {
+ const category = params.category;
+
+ return category ? `/${category}` : '';
+ },
+ },
+ {
+ title: '机构 - 成果展示',
+ source: ['www.agri.cn/jg/cgzs/'],
+ target: '/jg/cgzs',
+ },
+ {
+ title: '资讯 - 最新发布',
+ source: ['www.agri.cn/zx/zxfb/'],
+ target: '/zx/zxfb',
+ },
+ {
+ title: '资讯 - 农业要闻',
+ source: ['www.agri.cn/zx/nyyw/'],
+ target: '/zx/nyyw',
+ },
+ {
+ title: '资讯 - 中心动态',
+ source: ['www.agri.cn/zx/zxdt/'],
+ target: '/zx/zxdt',
+ },
+ {
+ title: '资讯 - 通知公告',
+ source: ['www.agri.cn/zx/hxgg/'],
+ target: '/zx/hxgg',
+ },
+ {
+ title: '资讯 - 全国信息联播',
+ source: ['www.agri.cn/zx/xxlb/'],
+ target: '/zx/xxlb',
+ },
+ {
+ title: '生产 - 生产动态',
+ source: ['www.agri.cn/sc/scdt/'],
+ target: '/sc/scdt',
+ },
+ {
+ title: '生产 - 农业品种',
+ source: ['www.agri.cn/sc/nypz/'],
+ target: '/sc/nypz',
+ },
+ {
+ title: '生产 - 农事指导',
+ source: ['www.agri.cn/sc/nszd/'],
+ target: '/sc/nszd',
+ },
+ {
+ title: '生产 - 农业气象',
+ source: ['www.agri.cn/sc/nyqx/'],
+ target: '/sc/nyqx',
+ },
+ {
+ title: '生产 - 专项监测',
+ source: ['www.agri.cn/sc/zxjc/'],
+ target: '/sc/zxjc',
+ },
+ {
+ title: '数据 - 市场动态',
+ source: ['www.agri.cn/sj/scdt/'],
+ target: '/sj/scdt',
+ },
+ {
+ title: '数据 - 供需形势',
+ source: ['www.agri.cn/sj/gxxs/'],
+ target: '/sj/gxxs',
+ },
+ {
+ title: '数据 - 监测预警',
+ source: ['www.agri.cn/sj/jcyj/'],
+ target: '/sj/jcyj',
+ },
+ {
+ title: '信息化 - 智慧农业',
+ source: ['www.agri.cn/xxh/zhny/'],
+ target: '/xxh/zhny',
+ },
+ {
+ title: '信息化 - 信息化标准',
+ source: ['www.agri.cn/xxh/xxhbz/'],
+ target: '/xxh/xxhbz',
+ },
+ {
+ title: '信息化 - 中国乡村资讯',
+ source: ['www.agri.cn/xxh/zgxczx/'],
+ target: '/xxh/zgxczx',
+ },
+ {
+ title: '视频 - 新闻资讯',
+ source: ['www.agri.cn/video/xwzx/nyxw/'],
+ target: '/video/xwzx/nyxw',
+ },
+ {
+ title: '视频 - 致富天地',
+ source: ['www.agri.cn/video/zftd/'],
+ target: '/video/zftd',
+ },
+ {
+ title: '视频 - 地方农业',
+ source: ['www.agri.cn/video/dfny/beijing/'],
+ target: '/video/dfny/beijing',
+ },
+ {
+ title: '视频 - 气象农业',
+ source: ['www.agri.cn/video/qxny/'],
+ target: '/video/qxny',
+ },
+ {
+ title: '视频 - 讲座培训',
+ source: ['www.agri.cn/video/jzpx/'],
+ target: '/video/jzpx',
+ },
+ {
+ title: '视频 - 文化生活',
+ source: ['www.agri.cn/video/whsh/'],
+ target: '/video/whsh',
+ },
+ ],
+};
diff --git a/lib/routes/agri/namespace.ts b/lib/routes/agri/namespace.ts
new file mode 100644
index 00000000000000..f059dffc2eb84a
--- /dev/null
+++ b/lib/routes/agri/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国农业农村信息网',
+ url: 'agri.cn',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/agri/templates/description.art b/lib/routes/agri/templates/description.art
new file mode 100644
index 00000000000000..249654e7e618a4
--- /dev/null
+++ b/lib/routes/agri/templates/description.art
@@ -0,0 +1,21 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if intro }}
+ {{ intro }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/ahjzu/namespace.ts b/lib/routes/ahjzu/namespace.ts
new file mode 100644
index 00000000000000..98bf403952ec20
--- /dev/null
+++ b/lib/routes/ahjzu/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '安徽建筑大学',
+ url: 'news.ahjzu.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/ahjzu/news.ts b/lib/routes/ahjzu/news.ts
new file mode 100644
index 00000000000000..f3f66364570240
--- /dev/null
+++ b/lib/routes/ahjzu/news.ts
@@ -0,0 +1,84 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['university'],
+ example: '/ahjzu/news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['news.ahjzu.edu.cn/20/list.htm'],
+ },
+ ],
+ name: '通知公告',
+ maintainers: ['Yuk-0v0'],
+ handler,
+ url: 'news.ahjzu.edu.cn/20/list.htm',
+};
+
+async function handler() {
+ const rootUrl = 'https://www.ahjzu.edu.cn';
+ const currentUrl = 'https://www.ahjzu.edu.cn/20/list.htm';
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const list = $('#wp_news_w9')
+ .find('li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const date = item.find('.column-news-date').text();
+
+ // 置顶链接自带http前缀,其他不带,需要手动判断
+ const a = item.find('a').attr('href');
+ const link = a.slice(0, 4) === 'http' ? a : rootUrl + a;
+ return {
+ title: item.find('a').attr('title'),
+ link,
+ pubDate: timezone(parseDate(date), +8),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+ const post = content('.wp_articlecontent').html();
+
+ item.description = post;
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '安建大-通知公告',
+ description: '安徽建筑大学-通知公告',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/aiaa/journal.ts b/lib/routes/aiaa/journal.ts
new file mode 100644
index 00000000000000..f196199a6e658e
--- /dev/null
+++ b/lib/routes/aiaa/journal.ts
@@ -0,0 +1,66 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ name: 'ASR Articles',
+ maintainers: ['HappyZhu99'],
+ categories: ['journal'],
+ path: '/journal/:journalID',
+ parameters: {
+ journalID: 'journal ID, can be found in the URL',
+ },
+ example: '/aiaa/journal/aiaaj',
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('journalID');
+
+ const baseUrl = 'https://arc.aiaa.org';
+ const rssUrl = `${baseUrl}/action/showFeed?type=etoc&feed=rss&jc=${id}`;
+
+ const pageResponse = await ofetch(rssUrl);
+
+ const $ = load(pageResponse, {
+ xml: {
+ xmlMode: true,
+ },
+ });
+
+ const channelTitle = $('title').first().text().replace(': Table of Contents', '');
+
+ const imageUrl = $('image url').text();
+ const items: DataItem[] = $('item')
+ .toArray()
+ .map((element) => {
+ const $item = $(element);
+ const title = $item.find(String.raw`dc\:title`).text();
+ const link = $item.find('link').text() || '';
+ const description = $item.find('description').text() || '';
+ const pubDate = parseDate($item.find(String.raw`dc\:date`).text() || '');
+ const authors = $item
+ .find(String.raw`dc\:creator`)
+ .toArray()
+ .map((authorElement) => $(authorElement).text());
+ const author = authors.join(', ');
+
+ return {
+ title,
+ link,
+ description,
+ pubDate,
+ author,
+ } satisfies DataItem;
+ });
+
+ return {
+ title: `${channelTitle} | arc.aiaa.org`,
+ description: 'List of articles from both the latest and ahead of print issues.',
+ image: imageUrl,
+ link: `${baseUrl}/journal/${id}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/aiaa/namespace.ts b/lib/routes/aiaa/namespace.ts
new file mode 100644
index 00000000000000..f89d01ea91104a
--- /dev/null
+++ b/lib/routes/aiaa/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AIAA Aerospace Research Central',
+ url: 'arc.aiaa.org',
+ lang: 'en',
+};
diff --git a/lib/routes/aibase/daily.ts b/lib/routes/aibase/daily.ts
new file mode 100644
index 00000000000000..9d83c5b601954f
--- /dev/null
+++ b/lib/routes/aibase/daily.ts
@@ -0,0 +1,112 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { buildApiUrl, rootUrl } from './util';
+
+export const route: Route = {
+ path: '/daily',
+ name: 'AI日报',
+ url: 'www.aibase.com',
+ maintainers: ['3tuuu'],
+ handler: async (ctx) => {
+ // 每页数量限制
+ const limit = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+ // 用项目中已有的获取页面方法,获取页面以及 Token
+ const currentUrl = new URL('discover', rootUrl).href;
+ const currentHtml = await ofetch(currentUrl);
+ const $ = load(currentHtml);
+ const logoSrc = $('img.logo').prop('src');
+ const image = logoSrc ? new URL(logoSrc, rootUrl).href : '';
+ const author = 'AI Base';
+ const { aILogListUrl } = await buildApiUrl($);
+ const response: DailyData = await ofetch(aILogListUrl, {
+ headers: {
+ accept: 'application/json;charset=utf-8',
+ },
+ query: {
+ pagesize: limit,
+ page: 1,
+ type: 2,
+ isen: 0,
+ },
+ });
+ if (!response || !response.data) {
+ throw new Error('日报数据不存在或为空');
+ }
+ const items = await Promise.all(
+ response.data.slice(0, limit).map(async (item) => {
+ const articleUrl = `https://www.aibase.com/zh/news/${item.Id}`;
+ return await cache.tryGet(articleUrl, async () => {
+ const articleHtml = await ofetch(articleUrl);
+ const $ = load(articleHtml);
+ const description = $('.post-content').html();
+ if (!description) {
+ throw new Error(`Empty content: ${articleUrl}`);
+ }
+ return {
+ title: item.title,
+ link: articleUrl,
+ description,
+ pubDate: parseDate(item.addtime),
+ author: 'AI Base',
+ };
+ });
+ })
+ );
+
+ return {
+ title: 'AI日报',
+ description: '每天三分钟关注AI行业趋势',
+ language: 'zh-cn',
+ link: 'https://www.aibase.com/zh/daily',
+ item: items,
+ allowEmpty: true,
+ image,
+ author,
+ };
+ },
+ example: '/aibase/daily',
+ description: '获取 AI 日报',
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.aibase.com/zh/daily'],
+ target: '/daily',
+ },
+ ],
+};
+
+interface DailyData {
+ has_more: boolean;
+ message: string;
+ data: DailyItem[];
+}
+interface DailyItem {
+ /** 文章 ID */
+ Id: number;
+ /** 添加时间 */
+ addtime: string;
+ /** 文章标题 */
+ title: string;
+ /** 文章副标题 */
+ subtitle: string;
+ /** 文章简要描述 */
+ desc: string;
+ /** 文章主图 */
+ orgthumb: string;
+ playtime: number;
+ pv: string;
+}
diff --git a/lib/routes/aibase/discover.ts b/lib/routes/aibase/discover.ts
new file mode 100644
index 00000000000000..3e592b386f3e0b
--- /dev/null
+++ b/lib/routes/aibase/discover.ts
@@ -0,0 +1,388 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import { buildApiUrl, processItems, rootUrl } from './util';
+
+export const handler = async (ctx) => {
+ const { id } = ctx.req.param();
+
+ const [pid, sid] = id?.split(/-/) ?? [undefined, undefined];
+
+ const limit = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const currentUrl = new URL(`discover${id ? `/${id}` : ''}`, rootUrl).href;
+
+ const currentHtml = await ofetch(currentUrl);
+
+ const $ = load(currentHtml);
+
+ const { apiRecommListUrl, apiRecommProcUrl, apiTagProcUrl } = await buildApiUrl($);
+
+ let ptag, stag;
+ let isTag = !!(pid && sid);
+
+ if (isTag) {
+ const apiRecommList = await ofetch(apiRecommListUrl);
+
+ const recommList = apiRecommList?.data?.results ?? [];
+
+ const parentTag = recommList.find((t) => String(t.Id) === pid);
+ const subTag = parentTag ? parentTag.sublist.find((t) => String(t.Id) === sid) : undefined;
+
+ ptag = parentTag?.tag ?? parentTag?.alias ?? undefined;
+ stag = subTag?.tag ?? subTag?.alias ?? undefined;
+
+ isTag = !!(ptag && stag);
+ }
+
+ const query = {
+ page: 1,
+ pagesize: limit,
+ ticket: '',
+ };
+
+ const {
+ data: { results: apiProcs },
+ } = await (isTag
+ ? ofetch(apiRecommProcUrl, {
+ query: {
+ ...query,
+ ptag,
+ stag,
+ },
+ })
+ : ofetch(apiTagProcUrl, {
+ query: {
+ ...query,
+ f: 'id',
+ o: 'desc',
+ },
+ }));
+
+ const items = processItems(apiProcs?.slice(0, limit) ?? []);
+
+ const image = new URL($('img.logo').prop('src'), rootUrl).href;
+
+ const author = $('title').text().split(/_/).pop();
+
+ return {
+ title: `${author}${isTag ? ` | ${ptag} - ${stag}` : ''}`,
+ description: $('meta[property="og:description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author,
+ };
+};
+
+export const route: Route = {
+ path: '/discover/:id?',
+ name: '发现',
+ url: 'top.aibase.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/aibase/discover',
+ parameters: { id: '发现分类,默认为空,即全部产品,可在对应发现分类页 URL 中找到' },
+ description: `::: tip
+ 若订阅 [图片背景移除](https://top.aibase.com/discover/37-49),网址为 \`https://top.aibase.com/discover/37-49\`。截取 \`https://top.aibase.com/discover/\` 到末尾的部分 \`37-49\` 作为参数填入,此时路由为 [\`/aibase/discover/37-49\`](https://rsshub.app/aibase/discover/37-49)。
+:::
+
+
+更多分类
+
+#### 图像处理
+
+| 分类 | ID |
+| ----------------------------------------------------- | ------------------------------------------------- |
+| [图片背景移除](https://top.aibase.com/discover/37-49) | [37-49](https://rsshub.app/aibase/discover/37-49) |
+| [图片无损放大](https://top.aibase.com/discover/37-50) | [37-50](https://rsshub.app/aibase/discover/37-50) |
+| [图片AI修复](https://top.aibase.com/discover/37-51) | [37-51](https://rsshub.app/aibase/discover/37-51) |
+| [图像生成](https://top.aibase.com/discover/37-52) | [37-52](https://rsshub.app/aibase/discover/37-52) |
+| [Ai图片拓展](https://top.aibase.com/discover/37-53) | [37-53](https://rsshub.app/aibase/discover/37-53) |
+| [Ai漫画生成](https://top.aibase.com/discover/37-54) | [37-54](https://rsshub.app/aibase/discover/37-54) |
+| [Ai生成写真](https://top.aibase.com/discover/37-55) | [37-55](https://rsshub.app/aibase/discover/37-55) |
+| [电商图片制作](https://top.aibase.com/discover/37-83) | [37-83](https://rsshub.app/aibase/discover/37-83) |
+| [Ai图像转视频](https://top.aibase.com/discover/37-86) | [37-86](https://rsshub.app/aibase/discover/37-86) |
+
+#### 视频创作
+
+| 分类 | ID |
+| --------------------------------------------------- | ------------------------------------------------- |
+| [视频剪辑](https://top.aibase.com/discover/38-56) | [38-56](https://rsshub.app/aibase/discover/38-56) |
+| [生成视频](https://top.aibase.com/discover/38-57) | [38-57](https://rsshub.app/aibase/discover/38-57) |
+| [Ai动画制作](https://top.aibase.com/discover/38-58) | [38-58](https://rsshub.app/aibase/discover/38-58) |
+| [字幕生成](https://top.aibase.com/discover/38-84) | [38-84](https://rsshub.app/aibase/discover/38-84) |
+
+#### 效率助手
+
+| 分类 | ID |
+| --------------------------------------------------- | ------------------------------------------------- |
+| [AI文档工具](https://top.aibase.com/discover/39-59) | [39-59](https://rsshub.app/aibase/discover/39-59) |
+| [PPT](https://top.aibase.com/discover/39-60) | [39-60](https://rsshub.app/aibase/discover/39-60) |
+| [思维导图](https://top.aibase.com/discover/39-61) | [39-61](https://rsshub.app/aibase/discover/39-61) |
+| [表格处理](https://top.aibase.com/discover/39-62) | [39-62](https://rsshub.app/aibase/discover/39-62) |
+| [Ai办公助手](https://top.aibase.com/discover/39-63) | [39-63](https://rsshub.app/aibase/discover/39-63) |
+
+#### 写作灵感
+
+| 分类 | ID |
+| ------------------------------------------------- | ------------------------------------------------- |
+| [文案写作](https://top.aibase.com/discover/40-64) | [40-64](https://rsshub.app/aibase/discover/40-64) |
+| [论文写作](https://top.aibase.com/discover/40-88) | [40-88](https://rsshub.app/aibase/discover/40-88) |
+
+#### 艺术灵感
+
+| 分类 | ID |
+| --------------------------------------------------- | ------------------------------------------------- |
+| [音乐创作](https://top.aibase.com/discover/41-65) | [41-65](https://rsshub.app/aibase/discover/41-65) |
+| [设计创作](https://top.aibase.com/discover/41-66) | [41-66](https://rsshub.app/aibase/discover/41-66) |
+| [Ai图标生成](https://top.aibase.com/discover/41-67) | [41-67](https://rsshub.app/aibase/discover/41-67) |
+
+#### 趣味
+
+| 分类 | ID |
+| ----------------------------------------------------- | ------------------------------------------------- |
+| [Ai名字生成器](https://top.aibase.com/discover/42-68) | [42-68](https://rsshub.app/aibase/discover/42-68) |
+| [游戏娱乐](https://top.aibase.com/discover/42-71) | [42-71](https://rsshub.app/aibase/discover/42-71) |
+| [其他](https://top.aibase.com/discover/42-72) | [42-72](https://rsshub.app/aibase/discover/42-72) |
+
+#### 开发编程
+
+| 分类 | ID |
+| --------------------------------------------------- | ------------------------------------------------- |
+| [开发编程](https://top.aibase.com/discover/43-73) | [43-73](https://rsshub.app/aibase/discover/43-73) |
+| [Ai开放平台](https://top.aibase.com/discover/43-74) | [43-74](https://rsshub.app/aibase/discover/43-74) |
+| [Ai算力平台](https://top.aibase.com/discover/43-75) | [43-75](https://rsshub.app/aibase/discover/43-75) |
+
+#### 聊天机器人
+
+| 分类 | ID |
+| ------------------------------------------------- | ------------------------------------------------- |
+| [智能聊天](https://top.aibase.com/discover/44-76) | [44-76](https://rsshub.app/aibase/discover/44-76) |
+| [智能客服](https://top.aibase.com/discover/44-77) | [44-77](https://rsshub.app/aibase/discover/44-77) |
+
+#### 翻译
+
+| 分类 | ID |
+| --------------------------------------------- | ------------------------------------------------- |
+| [翻译](https://top.aibase.com/discover/46-79) | [46-79](https://rsshub.app/aibase/discover/46-79) |
+
+#### 教育学习
+
+| 分类 | ID |
+| ------------------------------------------------- | ------------------------------------------------- |
+| [教育学习](https://top.aibase.com/discover/47-80) | [47-80](https://rsshub.app/aibase/discover/47-80) |
+
+#### 智能营销
+
+| 分类 | ID |
+| ------------------------------------------------- | ------------------------------------------------- |
+| [智能营销](https://top.aibase.com/discover/48-81) | [48-81](https://rsshub.app/aibase/discover/48-81) |
+
+#### 法律
+
+| 分类 | ID |
+| ----------------------------------------------- | ----------------------------------------------------- |
+| [法律](https://top.aibase.com/discover/138-139) | [138-139](https://rsshub.app/aibase/discover/138-139) |
+
+ `,
+ categories: ['new-media'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['top.aibase.com/discover/:id'],
+ target: (params) => {
+ const id = params.id;
+
+ return `/discover${id ? `/${id}` : ''}`;
+ },
+ },
+ {
+ title: '图像处理 - 图片背景移除',
+ source: ['top.aibase.com/discover/37-49'],
+ target: '/discover/37-49',
+ },
+ {
+ title: '图像处理 - 图片无损放大',
+ source: ['top.aibase.com/discover/37-50'],
+ target: '/discover/37-50',
+ },
+ {
+ title: '图像处理 - 图片AI修复',
+ source: ['top.aibase.com/discover/37-51'],
+ target: '/discover/37-51',
+ },
+ {
+ title: '图像处理 - 图像生成',
+ source: ['top.aibase.com/discover/37-52'],
+ target: '/discover/37-52',
+ },
+ {
+ title: '图像处理 - Ai图片拓展',
+ source: ['top.aibase.com/discover/37-53'],
+ target: '/discover/37-53',
+ },
+ {
+ title: '图像处理 - Ai漫画生成',
+ source: ['top.aibase.com/discover/37-54'],
+ target: '/discover/37-54',
+ },
+ {
+ title: '图像处理 - Ai生成写真',
+ source: ['top.aibase.com/discover/37-55'],
+ target: '/discover/37-55',
+ },
+ {
+ title: '图像处理 - 电商图片制作',
+ source: ['top.aibase.com/discover/37-83'],
+ target: '/discover/37-83',
+ },
+ {
+ title: '图像处理 - Ai图像转视频',
+ source: ['top.aibase.com/discover/37-86'],
+ target: '/discover/37-86',
+ },
+ {
+ title: '视频创作 - 视频剪辑',
+ source: ['top.aibase.com/discover/38-56'],
+ target: '/discover/38-56',
+ },
+ {
+ title: '视频创作 - 生成视频',
+ source: ['top.aibase.com/discover/38-57'],
+ target: '/discover/38-57',
+ },
+ {
+ title: '视频创作 - Ai动画制作',
+ source: ['top.aibase.com/discover/38-58'],
+ target: '/discover/38-58',
+ },
+ {
+ title: '视频创作 - 字幕生成',
+ source: ['top.aibase.com/discover/38-84'],
+ target: '/discover/38-84',
+ },
+ {
+ title: '效率助手 - AI文档工具',
+ source: ['top.aibase.com/discover/39-59'],
+ target: '/discover/39-59',
+ },
+ {
+ title: '效率助手 - PPT',
+ source: ['top.aibase.com/discover/39-60'],
+ target: '/discover/39-60',
+ },
+ {
+ title: '效率助手 - 思维导图',
+ source: ['top.aibase.com/discover/39-61'],
+ target: '/discover/39-61',
+ },
+ {
+ title: '效率助手 - 表格处理',
+ source: ['top.aibase.com/discover/39-62'],
+ target: '/discover/39-62',
+ },
+ {
+ title: '效率助手 - Ai办公助手',
+ source: ['top.aibase.com/discover/39-63'],
+ target: '/discover/39-63',
+ },
+ {
+ title: '写作灵感 - 文案写作',
+ source: ['top.aibase.com/discover/40-64'],
+ target: '/discover/40-64',
+ },
+ {
+ title: '写作灵感 - 论文写作',
+ source: ['top.aibase.com/discover/40-88'],
+ target: '/discover/40-88',
+ },
+ {
+ title: '艺术灵感 - 音乐创作',
+ source: ['top.aibase.com/discover/41-65'],
+ target: '/discover/41-65',
+ },
+ {
+ title: '艺术灵感 - 设计创作',
+ source: ['top.aibase.com/discover/41-66'],
+ target: '/discover/41-66',
+ },
+ {
+ title: '艺术灵感 - Ai图标生成',
+ source: ['top.aibase.com/discover/41-67'],
+ target: '/discover/41-67',
+ },
+ {
+ title: '趣味 - Ai名字生成器',
+ source: ['top.aibase.com/discover/42-68'],
+ target: '/discover/42-68',
+ },
+ {
+ title: '趣味 - 游戏娱乐',
+ source: ['top.aibase.com/discover/42-71'],
+ target: '/discover/42-71',
+ },
+ {
+ title: '趣味 - 其他',
+ source: ['top.aibase.com/discover/42-72'],
+ target: '/discover/42-72',
+ },
+ {
+ title: '开发编程 - 开发编程',
+ source: ['top.aibase.com/discover/43-73'],
+ target: '/discover/43-73',
+ },
+ {
+ title: '开发编程 - Ai开放平台',
+ source: ['top.aibase.com/discover/43-74'],
+ target: '/discover/43-74',
+ },
+ {
+ title: '开发编程 - Ai算力平台',
+ source: ['top.aibase.com/discover/43-75'],
+ target: '/discover/43-75',
+ },
+ {
+ title: '聊天机器人 - 智能聊天',
+ source: ['top.aibase.com/discover/44-76'],
+ target: '/discover/44-76',
+ },
+ {
+ title: '聊天机器人 - 智能客服',
+ source: ['top.aibase.com/discover/44-77'],
+ target: '/discover/44-77',
+ },
+ {
+ title: '翻译 - 翻译',
+ source: ['top.aibase.com/discover/46-79'],
+ target: '/discover/46-79',
+ },
+ {
+ title: '教育学习 - 教育学习',
+ source: ['top.aibase.com/discover/47-80'],
+ target: '/discover/47-80',
+ },
+ {
+ title: '智能营销 - 智能营销',
+ source: ['top.aibase.com/discover/48-81'],
+ target: '/discover/48-81',
+ },
+ {
+ title: '法律 - 法律',
+ source: ['top.aibase.com/discover/138-139'],
+ target: '/discover/138-139',
+ },
+ ],
+};
diff --git a/lib/routes/aibase/namespace.ts b/lib/routes/aibase/namespace.ts
new file mode 100644
index 00000000000000..8969f5915e037e
--- /dev/null
+++ b/lib/routes/aibase/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AIbase',
+ url: 'aibase.com',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aibase/news.ts b/lib/routes/aibase/news.ts
new file mode 100644
index 00000000000000..83cd1ebf0b1a6f
--- /dev/null
+++ b/lib/routes/aibase/news.ts
@@ -0,0 +1,120 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { buildApiUrl, rootUrl } from './util';
+
+export const route: Route = {
+ path: '/news',
+ name: '资讯',
+ url: 'www.aibase.com',
+ maintainers: ['zreo0'],
+ handler: async (ctx) => {
+ // 每页数量限制
+ const limit = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+ // 用项目中已有的获取页面方法,获取页面以及 Token
+ const currentUrl = new URL('discover', rootUrl).href;
+ const currentHtml = await ofetch(currentUrl);
+ const $ = load(currentHtml);
+ const logoSrc = $('img.logo').prop('src');
+ const image = logoSrc ? new URL(logoSrc, rootUrl).href : '';
+ const author = $('title').text().split(/_/).pop();
+ const { apiInfoListUrl } = await buildApiUrl($);
+ // 获取资讯列表,解析数据
+ const data: NewsItem[] = await ofetch(apiInfoListUrl, {
+ headers: {
+ accept: 'application/json;charset=utf-8',
+ },
+ query: {
+ pagesize: limit,
+ page: 1,
+ type: 1,
+ isen: 0,
+ },
+ });
+ const items = data.map((item) => ({
+ // 文章标题
+ title: item.title,
+ // 文章链接
+ link: `https://www.aibase.com/zh/news/${item.Id}`,
+ // 文章正文
+ description: item.summary,
+ // 文章发布日期
+ pubDate: parseDate(item.addtime),
+ // 文章作者
+ author: item.author || 'AI Base',
+ }));
+
+ return {
+ title: 'AI新闻资讯',
+ description: 'AI新闻资讯 - 不错过全球AI革新的每一个时刻',
+ language: 'zh-cn',
+ link: 'https://www.aibase.com/zh/news',
+ item: items,
+ allowEmpty: true,
+ image,
+ author,
+ };
+ },
+ example: '/aibase/news',
+ description: '获取 AI 资讯列表',
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.aibase.com/zh/news'],
+ target: '/news',
+ },
+ ],
+};
+
+/** API 返回的资讯结构 */
+interface NewsItem {
+ /** 文章 ID */
+ Id: number;
+ /** 文章标题 */
+ title: string;
+ /** 文章副标题 */
+ subtitle: string;
+ /** 文章简要描述 */
+ description: string;
+ /** 文章主图 */
+ thumb: string;
+ classname: string;
+ /** 正文总结 */
+ summary: string;
+ /** 标签,字符串,样例:[\"人工智能\",\"Hingham高中\"] */
+ tags: string;
+ /** 可能是来源 */
+ sourcename: string;
+ /** 作者 */
+ author: string;
+ status: number;
+ url: string;
+ type: number;
+ added: number;
+ /** 添加时间 */
+ addtime: string;
+ /** 更新时间 */
+ upded: number;
+ updtime: string;
+ isshoulu: number;
+ vurl: string;
+ vsize: number;
+ weight: number;
+ isailog: number;
+ sites: string;
+ categrates: string;
+ /** 访问量 */
+ pv: number;
+}
diff --git a/lib/routes/aibase/templates/description.art b/lib/routes/aibase/templates/description.art
new file mode 100644
index 00000000000000..fae2782a3c7dfa
--- /dev/null
+++ b/lib/routes/aibase/templates/description.art
@@ -0,0 +1,100 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if item }}
+
+
+
+ 名称
+ {{ item.name }}
+
+
+ 标签
+
+ {{ each strToArray(item.tags) t }}
+ {{ t }}  
+ {{ /each }}
+
+
+
+ 类型
+
+ {{ if item.proctypename }}
+ {{ item.proctypename }}
+ {{ else }}
+ 无
+ {{ /if }}
+
+
+ 描述
+ {{ if item.desc }}
+ {{ item.desc }}
+ {{ else }}
+ 无
+ {{ /if }}
+
+
+ 需求人群
+
+ {{ set list = strToArray(item.use) }}
+ {{ if list.length === 1 }}
+ {{ list[0] }}
+ {{ else }}
+ {{ each list l }}
+ {{ l }}
+ {{ /each }}
+ {{ /if }}
+
+
+
+ 使用场景示例
+
+ {{ set list = strToArray(item.example) }}
+ {{ if list.length === 1 }}
+ {{ list[0] }}
+ {{ else }}
+ {{ each list l }}
+ {{ l }}
+ {{ /each }}
+ {{ /if }}
+
+
+
+ 产品特色
+
+ {{ set list = strToArray(item.functions) }}
+ {{ if list.length === 1 }}
+ {{ list[0] }}
+ {{ else }}
+ {{ each list l }}
+ {{ l }}
+ {{ /each }}
+ {{ /if }}
+
+
+
+ 站点
+
+ {{ if item.url }}
+
+ {{ item.url }}
+
+ {{ else }}
+ 无
+ {{ /if }}
+
+
+
+
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/aibase/topic.ts b/lib/routes/aibase/topic.ts
new file mode 100644
index 00000000000000..dff5ea8ca49e8e
--- /dev/null
+++ b/lib/routes/aibase/topic.ts
@@ -0,0 +1,614 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import { buildApiUrl, processItems, rootUrl } from './util';
+
+export const handler = async (ctx) => {
+ const { id, filter = 'id' } = ctx.req.param();
+
+ const limit = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const currentUrl = new URL(id ? `topic/${id}` : 'discover', rootUrl).href;
+
+ const currentHtml = await ofetch(currentUrl);
+
+ const $ = load(currentHtml);
+
+ const { apiTagProcUrl } = await buildApiUrl($);
+
+ const {
+ data: { results: apiTagProcs },
+ } = await ofetch(apiTagProcUrl, {
+ query: {
+ ...(id ? { tag: id } : {}),
+ page: 1,
+ pagesize: 20,
+ f: filter,
+ o: 'desc',
+ ticket: '',
+ },
+ });
+
+ const items = processItems(apiTagProcs?.slice(0, limit) ?? []);
+
+ const image = new URL($('img.logo').prop('src'), rootUrl).href;
+
+ const author = $('title').text().split(/_/).pop();
+
+ return {
+ title: `${author}${id ? ` | ${id}` : ''}`,
+ description: $('meta[property="og:description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author,
+ };
+};
+
+export const route: Route = {
+ path: '/topic/:id?/:filter?',
+ name: '标签',
+ url: 'top.aibase.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/aibase/topic',
+ parameters: { id: '标签,默认为空,即全部产品,可在对应标签页 URL 中找到', filter: '过滤器,默认为 `id` 即最新,可选 `pv` 即热门' },
+ description: `::: tip
+ 若订阅 [AI](https://top.aibase.com/topic/AI),网址为 \`https://top.aibase.com/topic/AI\`。截取 \`https://top.aibase.com/topic\` 到末尾的部分 \`AI\` 作为参数填入,此时路由为 [\`/aibase/topic/AI\`](https://rsshub.app/aibase/topic/AI)。
+:::
+
+::: tip
+ 此处查看 [全部标签](https://top.aibase.com/topic)
+:::
+
+
+更多标签
+
+| [AI](https://top.aibase.com/topic/AI) | [人工智能](https://top.aibase.com/topic/%E4%BA%BA%E5%B7%A5%E6%99%BA%E8%83%BD) | [图像生成](https://top.aibase.com/topic/%E5%9B%BE%E5%83%8F%E7%94%9F%E6%88%90) | [自动化](https://top.aibase.com/topic/%E8%87%AA%E5%8A%A8%E5%8C%96) | [AI 助手](https://top.aibase.com/topic/AI%E5%8A%A9%E6%89%8B) |
+| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
+| [聊天机器人](https://top.aibase.com/topic/%E8%81%8A%E5%A4%A9%E6%9C%BA%E5%99%A8%E4%BA%BA) | [个性化](https://top.aibase.com/topic/%E4%B8%AA%E6%80%A7%E5%8C%96) | [社交媒体](https://top.aibase.com/topic/%E7%A4%BE%E4%BA%A4%E5%AA%92%E4%BD%93) | [图像处理](https://top.aibase.com/topic/%E5%9B%BE%E5%83%8F%E5%A4%84%E7%90%86) | [数据分析](https://top.aibase.com/topic/%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90) |
+| [自然语言处理](https://top.aibase.com/topic/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E5%A4%84%E7%90%86) | [聊天](https://top.aibase.com/topic/%E8%81%8A%E5%A4%A9) | [机器学习](https://top.aibase.com/topic/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0) | [教育](https://top.aibase.com/topic/%E6%95%99%E8%82%B2) | [内容创作](https://top.aibase.com/topic/%E5%86%85%E5%AE%B9%E5%88%9B%E4%BD%9C) |
+| [生产力](https://top.aibase.com/topic/%E7%94%9F%E4%BA%A7%E5%8A%9B) | [设计](https://top.aibase.com/topic/%E8%AE%BE%E8%AE%A1) | [ChatGPT](https://top.aibase.com/topic/ChatGPT) | [创意](https://top.aibase.com/topic/%E5%88%9B%E6%84%8F) | [开源](https://top.aibase.com/topic/%E5%BC%80%E6%BA%90) |
+| [写作](https://top.aibase.com/topic/%E5%86%99%E4%BD%9C) | [效率助手](https://top.aibase.com/topic/%E6%95%88%E7%8E%87%E5%8A%A9%E6%89%8B) | [学习](https://top.aibase.com/topic/%E5%AD%A6%E4%B9%A0) | [插件](https://top.aibase.com/topic/%E6%8F%92%E4%BB%B6) | [翻译](https://top.aibase.com/topic/%E7%BF%BB%E8%AF%91) |
+| [团队协作](https://top.aibase.com/topic/%E5%9B%A2%E9%98%9F%E5%8D%8F%E4%BD%9C) | [SEO](https://top.aibase.com/topic/SEO) | [营销](https://top.aibase.com/topic/%E8%90%A5%E9%94%80) | [内容生成](https://top.aibase.com/topic/%E5%86%85%E5%AE%B9%E7%94%9F%E6%88%90) | [AI 技术](https://top.aibase.com/topic/AI%E6%8A%80%E6%9C%AF) |
+| [AI 工具](https://top.aibase.com/topic/AI%E5%B7%A5%E5%85%B7) | [智能助手](https://top.aibase.com/topic/%E6%99%BA%E8%83%BD%E5%8A%A9%E6%89%8B) | [深度学习](https://top.aibase.com/topic/%E6%B7%B1%E5%BA%A6%E5%AD%A6%E4%B9%A0) | [多语言支持](https://top.aibase.com/topic/%E5%A4%9A%E8%AF%AD%E8%A8%80%E6%94%AF%E6%8C%81) | [视频](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91) |
+| [艺术](https://top.aibase.com/topic/%E8%89%BA%E6%9C%AF) | [文本生成](https://top.aibase.com/topic/%E6%96%87%E6%9C%AC%E7%94%9F%E6%88%90) | [开发编程](https://top.aibase.com/topic/%E5%BC%80%E5%8F%91%E7%BC%96%E7%A8%8B) | [协作](https://top.aibase.com/topic/%E5%8D%8F%E4%BD%9C) | [语言模型](https://top.aibase.com/topic/%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8B) |
+| [工具](https://top.aibase.com/topic/%E5%B7%A5%E5%85%B7) | [销售](https://top.aibase.com/topic/%E9%94%80%E5%94%AE) | [生产力工具](https://top.aibase.com/topic/%E7%94%9F%E4%BA%A7%E5%8A%9B%E5%B7%A5%E5%85%B7) | [AI 写作](https://top.aibase.com/topic/AI%E5%86%99%E4%BD%9C) | [创作](https://top.aibase.com/topic/%E5%88%9B%E4%BD%9C) |
+| [工作效率](https://top.aibase.com/topic/%E5%B7%A5%E4%BD%9C%E6%95%88%E7%8E%87) | [无代码](https://top.aibase.com/topic/%E6%97%A0%E4%BB%A3%E7%A0%81) | [隐私保护](https://top.aibase.com/topic/%E9%9A%90%E7%A7%81%E4%BF%9D%E6%8A%A4) | [视频编辑](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91%E7%BC%96%E8%BE%91) | [摘要](https://top.aibase.com/topic/%E6%91%98%E8%A6%81) |
+| [多语言](https://top.aibase.com/topic/%E5%A4%9A%E8%AF%AD%E8%A8%80) | [求职](https://top.aibase.com/topic/%E6%B1%82%E8%81%8C) | [GPT](https://top.aibase.com/topic/GPT) | [音乐](https://top.aibase.com/topic/%E9%9F%B3%E4%B9%90) | [视频创作](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91%E5%88%9B%E4%BD%9C) |
+| [设计工具](https://top.aibase.com/topic/%E8%AE%BE%E8%AE%A1%E5%B7%A5%E5%85%B7) | [搜索](https://top.aibase.com/topic/%E6%90%9C%E7%B4%A2) | [写作工具](https://top.aibase.com/topic/%E5%86%99%E4%BD%9C%E5%B7%A5%E5%85%B7) | [视频生成](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91%E7%94%9F%E6%88%90) | [招聘](https://top.aibase.com/topic/%E6%8B%9B%E8%81%98) |
+| [代码生成](https://top.aibase.com/topic/%E4%BB%A3%E7%A0%81%E7%94%9F%E6%88%90) | [大型语言模型](https://top.aibase.com/topic/%E5%A4%A7%E5%9E%8B%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8B) | [语音识别](https://top.aibase.com/topic/%E8%AF%AD%E9%9F%B3%E8%AF%86%E5%88%AB) | [编程](https://top.aibase.com/topic/%E7%BC%96%E7%A8%8B) | [在线工具](https://top.aibase.com/topic/%E5%9C%A8%E7%BA%BF%E5%B7%A5%E5%85%B7) |
+| [API](https://top.aibase.com/topic/API) | [趣味](https://top.aibase.com/topic/%E8%B6%A3%E5%91%B3) | [客户支持](https://top.aibase.com/topic/%E5%AE%A2%E6%88%B7%E6%94%AF%E6%8C%81) | [语音合成](https://top.aibase.com/topic/%E8%AF%AD%E9%9F%B3%E5%90%88%E6%88%90) | [图像](https://top.aibase.com/topic/%E5%9B%BE%E5%83%8F) |
+| [电子商务](https://top.aibase.com/topic/%E7%94%B5%E5%AD%90%E5%95%86%E5%8A%A1) | [SEO 优化](https://top.aibase.com/topic/SEO%E4%BC%98%E5%8C%96) | [AI 辅助](https://top.aibase.com/topic/AI%E8%BE%85%E5%8A%A9) | [AI 生成](https://top.aibase.com/topic/AI%E7%94%9F%E6%88%90) | [创作工具](https://top.aibase.com/topic/%E5%88%9B%E4%BD%9C%E5%B7%A5%E5%85%B7) |
+| [免费](https://top.aibase.com/topic/%E5%85%8D%E8%B4%B9) | [LinkedIn](https://top.aibase.com/topic/LinkedIn) | [博客](https://top.aibase.com/topic/%E5%8D%9A%E5%AE%A2) | [写作助手](https://top.aibase.com/topic/%E5%86%99%E4%BD%9C%E5%8A%A9%E6%89%8B) | [助手](https://top.aibase.com/topic/%E5%8A%A9%E6%89%8B) |
+| [智能](https://top.aibase.com/topic/%E6%99%BA%E8%83%BD) | [健康](https://top.aibase.com/topic/%E5%81%A5%E5%BA%B7) | [多模态](https://top.aibase.com/topic/%E5%A4%9A%E6%A8%A1%E6%80%81) | [任务管理](https://top.aibase.com/topic/%E4%BB%BB%E5%8A%A1%E7%AE%A1%E7%90%86) | [电子邮件](https://top.aibase.com/topic/%E7%94%B5%E5%AD%90%E9%82%AE%E4%BB%B6) |
+| [笔记](https://top.aibase.com/topic/%E7%AC%94%E8%AE%B0) | [搜索引擎](https://top.aibase.com/topic/%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E) | [计算机视觉](https://top.aibase.com/topic/%E8%AE%A1%E7%AE%97%E6%9C%BA%E8%A7%86%E8%A7%89) | [社区](https://top.aibase.com/topic/%E7%A4%BE%E5%8C%BA) | [效率](https://top.aibase.com/topic/%E6%95%88%E7%8E%87) |
+| [知识管理](https://top.aibase.com/topic/%E7%9F%A5%E8%AF%86%E7%AE%A1%E7%90%86) | [LLM](https://top.aibase.com/topic/LLM) | [智能聊天](https://top.aibase.com/topic/%E6%99%BA%E8%83%BD%E8%81%8A%E5%A4%A9) | [社交](https://top.aibase.com/topic/%E7%A4%BE%E4%BA%A4) | [语言学习](https://top.aibase.com/topic/%E8%AF%AD%E8%A8%80%E5%AD%A6%E4%B9%A0) |
+| [娱乐](https://top.aibase.com/topic/%E5%A8%B1%E4%B9%90) | [简历](https://top.aibase.com/topic/%E7%AE%80%E5%8E%86) | [OpenAI](https://top.aibase.com/topic/OpenAI) | [客户服务](https://top.aibase.com/topic/%E5%AE%A2%E6%88%B7%E6%9C%8D%E5%8A%A1) | [室内设计](https://top.aibase.com/topic/%E5%AE%A4%E5%86%85%E8%AE%BE%E8%AE%A1) |
+
+ `,
+ categories: ['new-media'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['top.aibase.com/topic/:id'],
+ target: (params) => {
+ const id = params.id;
+
+ return `/topic${id ? `/${id}` : ''}`;
+ },
+ },
+ {
+ title: 'AI',
+ source: ['top.aibase.com/topic/AI'],
+ target: '/topic/AI',
+ },
+ {
+ title: '人工智能',
+ source: ['top.aibase.com/topic/%E4%BA%BA%E5%B7%A5%E6%99%BA%E8%83%BD'],
+ target: '/topic/人工智能',
+ },
+ {
+ title: '图像生成',
+ source: ['top.aibase.com/topic/%E5%9B%BE%E5%83%8F%E7%94%9F%E6%88%90'],
+ target: '/topic/图像生成',
+ },
+ {
+ title: '自动化',
+ source: ['top.aibase.com/topic/%E8%87%AA%E5%8A%A8%E5%8C%96'],
+ target: '/topic/自动化',
+ },
+ {
+ title: 'AI助手',
+ source: ['top.aibase.com/topic/AI%E5%8A%A9%E6%89%8B'],
+ target: '/topic/AI助手',
+ },
+ {
+ title: '聊天机器人',
+ source: ['top.aibase.com/topic/%E8%81%8A%E5%A4%A9%E6%9C%BA%E5%99%A8%E4%BA%BA'],
+ target: '/topic/聊天机器人',
+ },
+ {
+ title: '个性化',
+ source: ['top.aibase.com/topic/%E4%B8%AA%E6%80%A7%E5%8C%96'],
+ target: '/topic/个性化',
+ },
+ {
+ title: '社交媒体',
+ source: ['top.aibase.com/topic/%E7%A4%BE%E4%BA%A4%E5%AA%92%E4%BD%93'],
+ target: '/topic/社交媒体',
+ },
+ {
+ title: '图像处理',
+ source: ['top.aibase.com/topic/%E5%9B%BE%E5%83%8F%E5%A4%84%E7%90%86'],
+ target: '/topic/图像处理',
+ },
+ {
+ title: '数据分析',
+ source: ['top.aibase.com/topic/%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90'],
+ target: '/topic/数据分析',
+ },
+ {
+ title: '自然语言处理',
+ source: ['top.aibase.com/topic/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E5%A4%84%E7%90%86'],
+ target: '/topic/自然语言处理',
+ },
+ {
+ title: '聊天',
+ source: ['top.aibase.com/topic/%E8%81%8A%E5%A4%A9'],
+ target: '/topic/聊天',
+ },
+ {
+ title: '机器学习',
+ source: ['top.aibase.com/topic/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0'],
+ target: '/topic/机器学习',
+ },
+ {
+ title: '教育',
+ source: ['top.aibase.com/topic/%E6%95%99%E8%82%B2'],
+ target: '/topic/教育',
+ },
+ {
+ title: '内容创作',
+ source: ['top.aibase.com/topic/%E5%86%85%E5%AE%B9%E5%88%9B%E4%BD%9C'],
+ target: '/topic/内容创作',
+ },
+ {
+ title: '生产力',
+ source: ['top.aibase.com/topic/%E7%94%9F%E4%BA%A7%E5%8A%9B'],
+ target: '/topic/生产力',
+ },
+ {
+ title: '设计',
+ source: ['top.aibase.com/topic/%E8%AE%BE%E8%AE%A1'],
+ target: '/topic/设计',
+ },
+ {
+ title: 'ChatGPT',
+ source: ['top.aibase.com/topic/ChatGPT'],
+ target: '/topic/ChatGPT',
+ },
+ {
+ title: '创意',
+ source: ['top.aibase.com/topic/%E5%88%9B%E6%84%8F'],
+ target: '/topic/创意',
+ },
+ {
+ title: '开源',
+ source: ['top.aibase.com/topic/%E5%BC%80%E6%BA%90'],
+ target: '/topic/开源',
+ },
+ {
+ title: '写作',
+ source: ['top.aibase.com/topic/%E5%86%99%E4%BD%9C'],
+ target: '/topic/写作',
+ },
+ {
+ title: '效率助手',
+ source: ['top.aibase.com/topic/%E6%95%88%E7%8E%87%E5%8A%A9%E6%89%8B'],
+ target: '/topic/效率助手',
+ },
+ {
+ title: '学习',
+ source: ['top.aibase.com/topic/%E5%AD%A6%E4%B9%A0'],
+ target: '/topic/学习',
+ },
+ {
+ title: '插件',
+ source: ['top.aibase.com/topic/%E6%8F%92%E4%BB%B6'],
+ target: '/topic/插件',
+ },
+ {
+ title: '翻译',
+ source: ['top.aibase.com/topic/%E7%BF%BB%E8%AF%91'],
+ target: '/topic/翻译',
+ },
+ {
+ title: '团队协作',
+ source: ['top.aibase.com/topic/%E5%9B%A2%E9%98%9F%E5%8D%8F%E4%BD%9C'],
+ target: '/topic/团队协作',
+ },
+ {
+ title: 'SEO',
+ source: ['top.aibase.com/topic/SEO'],
+ target: '/topic/SEO',
+ },
+ {
+ title: '营销',
+ source: ['top.aibase.com/topic/%E8%90%A5%E9%94%80'],
+ target: '/topic/营销',
+ },
+ {
+ title: '内容生成',
+ source: ['top.aibase.com/topic/%E5%86%85%E5%AE%B9%E7%94%9F%E6%88%90'],
+ target: '/topic/内容生成',
+ },
+ {
+ title: 'AI技术',
+ source: ['top.aibase.com/topic/AI%E6%8A%80%E6%9C%AF'],
+ target: '/topic/AI技术',
+ },
+ {
+ title: 'AI工具',
+ source: ['top.aibase.com/topic/AI%E5%B7%A5%E5%85%B7'],
+ target: '/topic/AI工具',
+ },
+ {
+ title: '智能助手',
+ source: ['top.aibase.com/topic/%E6%99%BA%E8%83%BD%E5%8A%A9%E6%89%8B'],
+ target: '/topic/智能助手',
+ },
+ {
+ title: '深度学习',
+ source: ['top.aibase.com/topic/%E6%B7%B1%E5%BA%A6%E5%AD%A6%E4%B9%A0'],
+ target: '/topic/深度学习',
+ },
+ {
+ title: '多语言支持',
+ source: ['top.aibase.com/topic/%E5%A4%9A%E8%AF%AD%E8%A8%80%E6%94%AF%E6%8C%81'],
+ target: '/topic/多语言支持',
+ },
+ {
+ title: '视频',
+ source: ['top.aibase.com/topic/%E8%A7%86%E9%A2%91'],
+ target: '/topic/视频',
+ },
+ {
+ title: '艺术',
+ source: ['top.aibase.com/topic/%E8%89%BA%E6%9C%AF'],
+ target: '/topic/艺术',
+ },
+ {
+ title: '文本生成',
+ source: ['top.aibase.com/topic/%E6%96%87%E6%9C%AC%E7%94%9F%E6%88%90'],
+ target: '/topic/文本生成',
+ },
+ {
+ title: '开发编程',
+ source: ['top.aibase.com/topic/%E5%BC%80%E5%8F%91%E7%BC%96%E7%A8%8B'],
+ target: '/topic/开发编程',
+ },
+ {
+ title: '协作',
+ source: ['top.aibase.com/topic/%E5%8D%8F%E4%BD%9C'],
+ target: '/topic/协作',
+ },
+ {
+ title: '语言模型',
+ source: ['top.aibase.com/topic/%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8B'],
+ target: '/topic/语言模型',
+ },
+ {
+ title: '工具',
+ source: ['top.aibase.com/topic/%E5%B7%A5%E5%85%B7'],
+ target: '/topic/工具',
+ },
+ {
+ title: '销售',
+ source: ['top.aibase.com/topic/%E9%94%80%E5%94%AE'],
+ target: '/topic/销售',
+ },
+ {
+ title: '生产力工具',
+ source: ['top.aibase.com/topic/%E7%94%9F%E4%BA%A7%E5%8A%9B%E5%B7%A5%E5%85%B7'],
+ target: '/topic/生产力工具',
+ },
+ {
+ title: 'AI写作',
+ source: ['top.aibase.com/topic/AI%E5%86%99%E4%BD%9C'],
+ target: '/topic/AI写作',
+ },
+ {
+ title: '创作',
+ source: ['top.aibase.com/topic/%E5%88%9B%E4%BD%9C'],
+ target: '/topic/创作',
+ },
+ {
+ title: '工作效率',
+ source: ['top.aibase.com/topic/%E5%B7%A5%E4%BD%9C%E6%95%88%E7%8E%87'],
+ target: '/topic/工作效率',
+ },
+ {
+ title: '无代码',
+ source: ['top.aibase.com/topic/%E6%97%A0%E4%BB%A3%E7%A0%81'],
+ target: '/topic/无代码',
+ },
+ {
+ title: '隐私保护',
+ source: ['top.aibase.com/topic/%E9%9A%90%E7%A7%81%E4%BF%9D%E6%8A%A4'],
+ target: '/topic/隐私保护',
+ },
+ {
+ title: '视频编辑',
+ source: ['top.aibase.com/topic/%E8%A7%86%E9%A2%91%E7%BC%96%E8%BE%91'],
+ target: '/topic/视频编辑',
+ },
+ {
+ title: '摘要',
+ source: ['top.aibase.com/topic/%E6%91%98%E8%A6%81'],
+ target: '/topic/摘要',
+ },
+ {
+ title: '多语言',
+ source: ['top.aibase.com/topic/%E5%A4%9A%E8%AF%AD%E8%A8%80'],
+ target: '/topic/多语言',
+ },
+ {
+ title: '求职',
+ source: ['top.aibase.com/topic/%E6%B1%82%E8%81%8C'],
+ target: '/topic/求职',
+ },
+ {
+ title: 'GPT',
+ source: ['top.aibase.com/topic/GPT'],
+ target: '/topic/GPT',
+ },
+ {
+ title: '音乐',
+ source: ['top.aibase.com/topic/%E9%9F%B3%E4%B9%90'],
+ target: '/topic/音乐',
+ },
+ {
+ title: '视频创作',
+ source: ['top.aibase.com/topic/%E8%A7%86%E9%A2%91%E5%88%9B%E4%BD%9C'],
+ target: '/topic/视频创作',
+ },
+ {
+ title: '设计工具',
+ source: ['top.aibase.com/topic/%E8%AE%BE%E8%AE%A1%E5%B7%A5%E5%85%B7'],
+ target: '/topic/设计工具',
+ },
+ {
+ title: '搜索',
+ source: ['top.aibase.com/topic/%E6%90%9C%E7%B4%A2'],
+ target: '/topic/搜索',
+ },
+ {
+ title: '写作工具',
+ source: ['top.aibase.com/topic/%E5%86%99%E4%BD%9C%E5%B7%A5%E5%85%B7'],
+ target: '/topic/写作工具',
+ },
+ {
+ title: '视频生成',
+ source: ['top.aibase.com/topic/%E8%A7%86%E9%A2%91%E7%94%9F%E6%88%90'],
+ target: '/topic/视频生成',
+ },
+ {
+ title: '招聘',
+ source: ['top.aibase.com/topic/%E6%8B%9B%E8%81%98'],
+ target: '/topic/招聘',
+ },
+ {
+ title: '代码生成',
+ source: ['top.aibase.com/topic/%E4%BB%A3%E7%A0%81%E7%94%9F%E6%88%90'],
+ target: '/topic/代码生成',
+ },
+ {
+ title: '大型语言模型',
+ source: ['top.aibase.com/topic/%E5%A4%A7%E5%9E%8B%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8B'],
+ target: '/topic/大型语言模型',
+ },
+ {
+ title: '语音识别',
+ source: ['top.aibase.com/topic/%E8%AF%AD%E9%9F%B3%E8%AF%86%E5%88%AB'],
+ target: '/topic/语音识别',
+ },
+ {
+ title: '编程',
+ source: ['top.aibase.com/topic/%E7%BC%96%E7%A8%8B'],
+ target: '/topic/编程',
+ },
+ {
+ title: '在线工具',
+ source: ['top.aibase.com/topic/%E5%9C%A8%E7%BA%BF%E5%B7%A5%E5%85%B7'],
+ target: '/topic/在线工具',
+ },
+ {
+ title: 'API',
+ source: ['top.aibase.com/topic/API'],
+ target: '/topic/API',
+ },
+ {
+ title: '趣味',
+ source: ['top.aibase.com/topic/%E8%B6%A3%E5%91%B3'],
+ target: '/topic/趣味',
+ },
+ {
+ title: '客户支持',
+ source: ['top.aibase.com/topic/%E5%AE%A2%E6%88%B7%E6%94%AF%E6%8C%81'],
+ target: '/topic/客户支持',
+ },
+ {
+ title: '语音合成',
+ source: ['top.aibase.com/topic/%E8%AF%AD%E9%9F%B3%E5%90%88%E6%88%90'],
+ target: '/topic/语音合成',
+ },
+ {
+ title: '图像',
+ source: ['top.aibase.com/topic/%E5%9B%BE%E5%83%8F'],
+ target: '/topic/图像',
+ },
+ {
+ title: '电子商务',
+ source: ['top.aibase.com/topic/%E7%94%B5%E5%AD%90%E5%95%86%E5%8A%A1'],
+ target: '/topic/电子商务',
+ },
+ {
+ title: 'SEO优化',
+ source: ['top.aibase.com/topic/SEO%E4%BC%98%E5%8C%96'],
+ target: '/topic/SEO优化',
+ },
+ {
+ title: 'AI辅助',
+ source: ['top.aibase.com/topic/AI%E8%BE%85%E5%8A%A9'],
+ target: '/topic/AI辅助',
+ },
+ {
+ title: 'AI生成',
+ source: ['top.aibase.com/topic/AI%E7%94%9F%E6%88%90'],
+ target: '/topic/AI生成',
+ },
+ {
+ title: '创作工具',
+ source: ['top.aibase.com/topic/%E5%88%9B%E4%BD%9C%E5%B7%A5%E5%85%B7'],
+ target: '/topic/创作工具',
+ },
+ {
+ title: '免费',
+ source: ['top.aibase.com/topic/%E5%85%8D%E8%B4%B9'],
+ target: '/topic/免费',
+ },
+ {
+ title: 'LinkedIn',
+ source: ['top.aibase.com/topic/LinkedIn'],
+ target: '/topic/LinkedIn',
+ },
+ {
+ title: '博客',
+ source: ['top.aibase.com/topic/%E5%8D%9A%E5%AE%A2'],
+ target: '/topic/博客',
+ },
+ {
+ title: '写作助手',
+ source: ['top.aibase.com/topic/%E5%86%99%E4%BD%9C%E5%8A%A9%E6%89%8B'],
+ target: '/topic/写作助手',
+ },
+ {
+ title: '助手',
+ source: ['top.aibase.com/topic/%E5%8A%A9%E6%89%8B'],
+ target: '/topic/助手',
+ },
+ {
+ title: '智能',
+ source: ['top.aibase.com/topic/%E6%99%BA%E8%83%BD'],
+ target: '/topic/智能',
+ },
+ {
+ title: '健康',
+ source: ['top.aibase.com/topic/%E5%81%A5%E5%BA%B7'],
+ target: '/topic/健康',
+ },
+ {
+ title: '多模态',
+ source: ['top.aibase.com/topic/%E5%A4%9A%E6%A8%A1%E6%80%81'],
+ target: '/topic/多模态',
+ },
+ {
+ title: '任务管理',
+ source: ['top.aibase.com/topic/%E4%BB%BB%E5%8A%A1%E7%AE%A1%E7%90%86'],
+ target: '/topic/任务管理',
+ },
+ {
+ title: '电子邮件',
+ source: ['top.aibase.com/topic/%E7%94%B5%E5%AD%90%E9%82%AE%E4%BB%B6'],
+ target: '/topic/电子邮件',
+ },
+ {
+ title: '笔记',
+ source: ['top.aibase.com/topic/%E7%AC%94%E8%AE%B0'],
+ target: '/topic/笔记',
+ },
+ {
+ title: '搜索引擎',
+ source: ['top.aibase.com/topic/%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E'],
+ target: '/topic/搜索引擎',
+ },
+ {
+ title: '计算机视觉',
+ source: ['top.aibase.com/topic/%E8%AE%A1%E7%AE%97%E6%9C%BA%E8%A7%86%E8%A7%89'],
+ target: '/topic/计算机视觉',
+ },
+ {
+ title: '社区',
+ source: ['top.aibase.com/topic/%E7%A4%BE%E5%8C%BA'],
+ target: '/topic/社区',
+ },
+ {
+ title: '效率',
+ source: ['top.aibase.com/topic/%E6%95%88%E7%8E%87'],
+ target: '/topic/效率',
+ },
+ {
+ title: '知识管理',
+ source: ['top.aibase.com/topic/%E7%9F%A5%E8%AF%86%E7%AE%A1%E7%90%86'],
+ target: '/topic/知识管理',
+ },
+ {
+ title: 'LLM',
+ source: ['top.aibase.com/topic/LLM'],
+ target: '/topic/LLM',
+ },
+ {
+ title: '智能聊天',
+ source: ['top.aibase.com/topic/%E6%99%BA%E8%83%BD%E8%81%8A%E5%A4%A9'],
+ target: '/topic/智能聊天',
+ },
+ {
+ title: '社交',
+ source: ['top.aibase.com/topic/%E7%A4%BE%E4%BA%A4'],
+ target: '/topic/社交',
+ },
+ {
+ title: '语言学习',
+ source: ['top.aibase.com/topic/%E8%AF%AD%E8%A8%80%E5%AD%A6%E4%B9%A0'],
+ target: '/topic/语言学习',
+ },
+ {
+ title: '娱乐',
+ source: ['top.aibase.com/topic/%E5%A8%B1%E4%B9%90'],
+ target: '/topic/娱乐',
+ },
+ {
+ title: '简历',
+ source: ['top.aibase.com/topic/%E7%AE%80%E5%8E%86'],
+ target: '/topic/简历',
+ },
+ {
+ title: 'OpenAI',
+ source: ['top.aibase.com/topic/OpenAI'],
+ target: '/topic/OpenAI',
+ },
+ {
+ title: '客户服务',
+ source: ['top.aibase.com/topic/%E5%AE%A2%E6%88%B7%E6%9C%8D%E5%8A%A1'],
+ target: '/topic/客户服务',
+ },
+ {
+ title: '室内设计',
+ source: ['top.aibase.com/topic/%E5%AE%A4%E5%86%85%E8%AE%BE%E8%AE%A1'],
+ target: '/topic/室内设计',
+ },
+ ],
+};
diff --git a/lib/routes/aibase/util.ts b/lib/routes/aibase/util.ts
new file mode 100644
index 00000000000000..5926f304f8f427
--- /dev/null
+++ b/lib/routes/aibase/util.ts
@@ -0,0 +1,116 @@
+import path from 'node:path';
+
+import type { CheerioAPI } from 'cheerio';
+
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const defaultSrc = '_static/ee6af7e.js';
+const defaultToken = 'djflkdsoisknfoklsyhownfrlewfknoiaewf';
+
+const rootUrl = 'https://top.aibase.com';
+const apiRootUrl = 'https://app.chinaz.com';
+
+/**
+ * Converts a string to an array.
+ * If the string starts with '[', it is assumed to be a JSON array and is parsed accordingly.
+ * Otherwise, the string is wrapped in an array.
+ *
+ * @param str - The input string to convert to an array.
+ * @returns An array created from the input string.
+ */
+const strToArray = (str: string) => {
+ if (str.startsWith('[')) {
+ return JSON.parse(str);
+ }
+ return [str];
+};
+
+art.defaults.imports.strToArray = strToArray;
+
+/**
+ * Retrieve a token asynchronously using a CheerioAPI instance.
+ * @param $ - The CheerioAPI instance.
+ * @returns A Promise that resolves to a string representing the token.
+ */
+const getToken = async ($: CheerioAPI): Promise => {
+ const scriptUrl = new URL($('script[src]').last()?.prop('src') ?? defaultSrc, rootUrl).href;
+
+ const script = await ofetch(scriptUrl, {
+ responseType: 'text',
+ });
+
+ return script.match(/"\/(\w+)\/ai\/.*?\.aspx"/)?.[1] ?? defaultToken;
+};
+
+/**
+ * Build API URLs asynchronously using a CheerioAPI instance.
+ * @param $ - The CheerioAPI instance.
+ * @returns An object containing API URLs.
+ */
+const buildApiUrl = async ($: CheerioAPI) => {
+ const token = await getToken($);
+
+ const apiRecommListUrl = new URL(`${token}/ai/GetAIProcRecommList.aspx`, apiRootUrl).href;
+ const apiRecommProcUrl = new URL(`${token}/ai/GetAIProcListByRecomm.aspx`, apiRootUrl).href;
+ const apiTagProcUrl = new URL(`${token}/ai/GetAiProductOfTag.aspx`, apiRootUrl).href;
+ // AI 资讯列表
+ const apiInfoListUrl = new URL(`${token}/ai/GetAiInfoList.aspx`, apiRootUrl).href;
+ // AI 日报
+ const aILogListUrl = new URL(`${token}/ai/v2/GetAILogList.aspx`, apiRootUrl).href;
+
+ return {
+ apiRecommListUrl,
+ apiRecommProcUrl,
+ apiTagProcUrl,
+ apiInfoListUrl,
+ aILogListUrl,
+ };
+};
+
+/**
+ * Process an array of items to generate a new array of processed items for RSS.
+ * @param items - An array of items to process.
+ * @returns An array of processed items.
+ */
+const processItems = (items: any[]): any[] =>
+ items.map((item) => {
+ const title = item.name;
+ const image = item.imgurl;
+ const description = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ item,
+ });
+ const guid = `aibase-${item.zurl}`;
+
+ return {
+ title,
+ description,
+ pubDate: timezone(parseDate(item.addtime), +8),
+ link: new URL(`tool/${item.zurl}`, rootUrl).href,
+ category: [...new Set([...strToArray(item.categories), ...strToArray(item.tags), item.catname, item.procattrname, item.procformname, item.proctypename])].filter(Boolean),
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: item.desc,
+ },
+ image,
+ banner: image,
+ updated: parseDate(item.UpdTime),
+ enclosure_url: item.logo,
+ enclosure_type: item.logo ? `image/${item.logo.split(/\./).pop()}` : undefined,
+ enclosure_title: title,
+ };
+ });
+
+export { buildApiUrl, processItems, rootUrl };
diff --git a/lib/routes/aiblog-2xv/archives.ts b/lib/routes/aiblog-2xv/archives.ts
new file mode 100644
index 00000000000000..38528ecf7471b2
--- /dev/null
+++ b/lib/routes/aiblog-2xv/archives.ts
@@ -0,0 +1,93 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/archives',
+ categories: ['blog'],
+ example: '/aiblog-2xv/archives',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['aiblog-2xv.pages.dev/archives'],
+ target: '/archives',
+ },
+ ],
+ name: '归档-全部文章',
+ maintainers: ['Liao-Ke'],
+ handler,
+};
+
+async function handler() {
+ const baseUrl = 'https://aiblog-2xv.pages.dev';
+ const response = await ofetch(`${baseUrl}/archives`);
+ const $ = load(response);
+
+ // 遍历每个月份分组
+ const list = $('#top > main > div > div.archive-month')
+ .toArray()
+ .flatMap((monthItem) =>
+ $(monthItem)
+ .find('.archive-posts .archive-entry')
+ .toArray()
+ .map((postItem) => {
+ const $post = $(postItem);
+ const $link = $post.find('a').first();
+ const $title = $post.find('h3').first();
+ const $dateMeta = $post.find('.archive-meta span');
+
+ return {
+ title: $title.text().trim(), // 去除首尾空格
+ link: $link.attr('href') || '',
+ // 解析发布时间和更新时间(根据页面结构调整选择器,若存在则启用)
+ pubDate: parseDate($dateMeta.eq(0).attr('title') || ''),
+ author: $post.find('.archive-meta span').last().text().trim() || '',
+ description: '',
+ };
+ })
+ );
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+
+ const $main = $('main').first();
+ item.description = `
+
+
+
+ ${$main.find('figure').first().html()}
+
+
+
+ ${$main.find('.post-content').first().html()}
+
+ `;
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '归档-全部文章 | AI Blog', // 优化标题,增加站点标识
+ link: `${baseUrl}/archives`,
+ item: items.filter((item) => item.title && item.link), // 过滤无效数据
+ };
+}
diff --git a/lib/routes/aiblog-2xv/namespace.ts b/lib/routes/aiblog-2xv/namespace.ts
new file mode 100644
index 00000000000000..b331f65d73ebda
--- /dev/null
+++ b/lib/routes/aiblog-2xv/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AI 博客',
+ url: 'aiblog-2xv.pages.dev',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aicaijing/index.ts b/lib/routes/aicaijing/index.ts
new file mode 100644
index 00000000000000..c57ee1cffdfa08
--- /dev/null
+++ b/lib/routes/aicaijing/index.ts
@@ -0,0 +1,82 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/:category?/:id?',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'latest';
+ const id = ctx.req.param('id') ?? 14;
+
+ const titles = {
+ 14: '热点 - 最新',
+ 5: '热点 - 科技',
+ 9: '热点 - 消费',
+ 7: '热点 - 出行',
+ 13: '热点 - 文娱',
+ 10: '热点 - 教育',
+ 25: '热点 - 地产',
+ 11: '热点 - 更多',
+ 28: '深度 - 出行',
+ 29: '深度 - 科技',
+ 31: '深度 - 消费',
+ 33: '深度 - 教育',
+ 34: '深度 - 更多',
+ 8: '深度 - 地产',
+ 6: '深度 - 文娱',
+ };
+
+ const categories = {
+ latest: {
+ url: '',
+ title: '最新文章',
+ },
+ recommend: {
+ url: '&isRecommend=true',
+ title: '推荐资讯',
+ },
+ cover: {
+ url: '&position=1',
+ title: '封面文章',
+ },
+ information: {
+ url: `&categoryId=${id}`,
+ title: titles[id],
+ },
+ };
+
+ const rootUrl = 'https://www.aicaijing.com.cn';
+ const apiRootUrl = 'https://api.aicaijing.com.cn';
+ const apiUrl = `${apiRootUrl}/article/detail/list?size=${ctx.req.query('limit') ?? 50}&page=1${categories[category].url}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.data.items.map((item) => ({
+ title: item.title,
+ link: `${rootUrl}/article/${item.articleId}`,
+ author: item.userInfo.nickname,
+ pubDate: parseDate(item.createTime),
+ category: [item.category.name, ...item.tags.map((t) => t.name)],
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ image: item.cover,
+ description: item.content,
+ }),
+ }));
+
+ return {
+ title: `AI 财经社 - ${categories[category].title}`,
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/aicaijing/namespace.ts b/lib/routes/aicaijing/namespace.ts
new file mode 100644
index 00000000000000..d50e13febc390e
--- /dev/null
+++ b/lib/routes/aicaijing/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AI 财经社',
+ url: 'www.aicaijing.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/aicaijing/templates/description.art b/lib/routes/aicaijing/templates/description.art
similarity index 100%
rename from lib/v2/aicaijing/templates/description.art
rename to lib/routes/aicaijing/templates/description.art
diff --git a/lib/routes/aiea/index.ts b/lib/routes/aiea/index.ts
new file mode 100644
index 00000000000000..d7ad69f45ae836
--- /dev/null
+++ b/lib/routes/aiea/index.ts
@@ -0,0 +1,63 @@
+import type { Route } from '@/types';
+import buildData from '@/utils/common-config';
+
+export const route: Route = {
+ path: '/seminars/:period',
+ categories: ['study'],
+ example: '/aiea/seminars/upcoming',
+ parameters: { period: 'Time frame' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Seminar Series',
+ maintainers: ['zxx-457'],
+ handler,
+ description: `| Time frame |
+| ---------- |
+| upcoming |
+| past |
+| both |`,
+};
+
+async function handler(ctx) {
+ const link = 'http://www.aiea.org/0504';
+ const period = ctx.req.param('period') ?? '';
+
+ let nth_child = 'n';
+ switch (period) {
+ case 'upcoming':
+ nth_child = '1';
+ break;
+
+ case 'past':
+ nth_child = '2';
+ break;
+
+ case 'both':
+ nth_child = 'n';
+ break;
+
+ default:
+ break;
+ }
+
+ return await buildData({
+ link,
+ url: link,
+ title: `%title%`,
+ params: {
+ title: 'AIEA Seminars',
+ },
+ item: {
+ item: `.seminar-contents .seminar-partWrap:nth-child(${nth_child}) > .seminar-list`,
+ title: `$('.seminar-list-title > span').text()`,
+ link: `$('a[href^="/0504"]').attr('href')`,
+ description: `$('.seminar-list .txt > .title').text()`,
+ },
+ });
+}
diff --git a/lib/routes/aiea/namespace.ts b/lib/routes/aiea/namespace.ts
new file mode 100644
index 00000000000000..9b1dbcfd416ca6
--- /dev/null
+++ b/lib/routes/aiea/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Asian Innovation and Entrepreneurship Association',
+ url: 'www.aiea.org',
+ lang: 'en',
+};
diff --git a/lib/routes/aijishu/index.ts b/lib/routes/aijishu/index.ts
new file mode 100644
index 00000000000000..9371e56433f6c2
--- /dev/null
+++ b/lib/routes/aijishu/index.ts
@@ -0,0 +1,52 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/:type/:name?',
+ categories: ['programming'],
+ example: '/aijishu/channel/ai',
+ parameters: { type: '文章类型,可以取值如下', name: '名字,取自URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '频道、专栏、用户',
+ maintainers: [],
+ handler,
+ description: `| type | 说明 |
+| ------- | ---- |
+| channel | 频道 |
+| blog | 专栏 |
+| u | 用户 |`,
+};
+
+async function handler(ctx) {
+ const { type, name = 'newest' } = ctx.req.param();
+ const u = name === 'newest' ? `https://aijishu.com/` : `https://aijishu.com/${type}/${name}`;
+ const html = await got(u);
+
+ const $ = load(html.data);
+ const title = $('title').text();
+ const api_path = $('li[data-js-stream-load-more]').attr('data-api-url');
+
+ const channel_url = `https://aijishu.com${api_path}?page=1`;
+ const channel_url_resp = await got(channel_url);
+ const resp = channel_url_resp.data;
+ const list = resp.data.rows;
+
+ const items = await Promise.all(list.filter((item) => item?.url?.startsWith('/a/') || item?.object?.url.startsWith('/a/')).map((item) => utils.parseArticle(item)));
+
+ return {
+ title: title.split(' - ').slice(0, 2).join(' - '),
+ link: u,
+ item: items,
+ };
+}
diff --git a/lib/routes/aijishu/namespace.ts b/lib/routes/aijishu/namespace.ts
new file mode 100644
index 00000000000000..02ed7d23ce725e
--- /dev/null
+++ b/lib/routes/aijishu/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '极术社区',
+ url: 'www.aijishu',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aijishu/utils.ts b/lib/routes/aijishu/utils.ts
new file mode 100644
index 00000000000000..6d4e2346d13eea
--- /dev/null
+++ b/lib/routes/aijishu/utils.ts
@@ -0,0 +1,36 @@
+import { load } from 'cheerio';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate, parseRelativeDate } from '@/utils/parse-date';
+
+const parseArticle = (item) => {
+ const articleUrl = `https://aijishu.com${item.url || item.object.url}`;
+ return cache.tryGet(articleUrl, async () => {
+ const d1 = parseDate(item.createdDate, ['YYYY-MM-DD', 'M-DD']);
+ const d2 = parseRelativeDate(item.createdDate);
+
+ let resp, desc;
+ try {
+ resp = await got(articleUrl);
+ const $ = load(resp.data);
+ desc = $('article.fmt').html();
+ } catch (error) {
+ if (error.response.status === 403) {
+ // skip it
+ } else {
+ throw error;
+ }
+ }
+
+ const article_item = {
+ title: item.title || item.object.title,
+ link: articleUrl,
+ description: desc,
+ pubDate: d1.toString() === 'Invalid Date' ? d2 : d1,
+ };
+ return article_item;
+ });
+};
+
+export default { parseArticle };
diff --git a/lib/routes/ainvest/article.ts b/lib/routes/ainvest/article.ts
new file mode 100644
index 00000000000000..649e2bb327223e
--- /dev/null
+++ b/lib/routes/ainvest/article.ts
@@ -0,0 +1,68 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import { decryptAES, encryptAES, getHeaders, randomString } from './utils';
+
+export const route: Route = {
+ path: '/article',
+ categories: ['finance'],
+ example: '/ainvest/article',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['ainvest.com/news'],
+ },
+ ],
+ name: 'Latest Article',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'ainvest.com/news',
+};
+
+async function handler(ctx) {
+ const key = randomString(16);
+
+ const { data: response } = await got.post('https://api.ainvest.com/gw/socialcenter/v1/edu/article/listArticle', {
+ headers: getHeaders(key),
+ searchParams: {
+ timestamp: Date.now(),
+ },
+ data: encryptAES(
+ JSON.stringify({
+ batch: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30,
+ startId: null,
+ tags: {
+ in: ['markettrends', 'premarket', 'companyinsights', 'macro'],
+ and: ['web', 'creationplatform'],
+ },
+ }),
+ key
+ ),
+ });
+
+ const { data } = JSON.parse(decryptAES(response, key));
+
+ const items = data.map((item) => ({
+ title: item.title,
+ description: item.content,
+ link: item.sourceUrl,
+ pubDate: parseDate(item.postDate, 'x'),
+ category: [item.nickName, ...item.tags.map((tag) => tag.code)],
+ }));
+
+ return {
+ title: 'AInvest - Latest Articles',
+ link: 'https://www.ainvest.com/news',
+ language: 'en',
+ item: items,
+ };
+}
diff --git a/lib/routes/ainvest/namespace.ts b/lib/routes/ainvest/namespace.ts
new file mode 100644
index 00000000000000..e6985b1cfbabce
--- /dev/null
+++ b/lib/routes/ainvest/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AInvest',
+ url: 'ainvest.com',
+ lang: 'en',
+};
diff --git a/lib/routes/ainvest/news.ts b/lib/routes/ainvest/news.ts
new file mode 100644
index 00000000000000..ab639f9584167a
--- /dev/null
+++ b/lib/routes/ainvest/news.ts
@@ -0,0 +1,67 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import { decryptAES, getHeaders, randomString } from './utils';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['finance'],
+ example: '/ainvest/news',
+ parameters: {},
+ view: ViewType.Articles,
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['ainvest.com/news'],
+ },
+ ],
+ name: 'Latest News',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'ainvest.com/news',
+};
+
+async function handler(ctx) {
+ const key = randomString(16);
+
+ const { data: response } = await got('https://api.ainvest.com/gw/news_f10/v1/newsFlash/getNewsData', {
+ headers: getHeaders(key),
+ searchParams: {
+ terminal: 'web',
+ tab: 'all',
+ page: 1,
+ size: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50,
+ lastId: '',
+ timestamp: Date.now(),
+ },
+ });
+
+ const { data } = JSON.parse(decryptAES(response, key));
+
+ const items = data.content.map((item) => ({
+ title: item.title,
+ description: item.content,
+ link: item.sourceUrl,
+ pubDate: parseDate(item.publishTime, 'x'),
+ category: item.tagList.map((tag) => tag.nameEn),
+ author: item.userInfo.nickname,
+ upvotes: item.likeCount,
+ comments: item.commentCount,
+ }));
+
+ return {
+ title: 'AInvest - Latest News',
+ link: 'https://www.ainvest.com/news',
+ language: 'en',
+ item: items,
+ };
+}
diff --git a/lib/routes/ainvest/utils.ts b/lib/routes/ainvest/utils.ts
new file mode 100644
index 00000000000000..49d15799aa606e
--- /dev/null
+++ b/lib/routes/ainvest/utils.ts
@@ -0,0 +1,69 @@
+import crypto from 'node:crypto';
+
+import CryptoJS from 'crypto-js';
+import { hextob64, KEYUTIL, KJUR } from 'jsrsasign';
+
+const publicKey =
+ 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCARnxLlrhTK28bEV7s2IROjT73KLSjfqpKIvV8L+Yhe4BrF0Ut4oOH728HZlbSF0C3N0vXZjLAFesoS4v1pYOjVCPXl920Lh2seCv82m0cK78WMGuqZTfA44Nv7JsQMHC3+J6IZm8YD53ft2d8mYBFgKektduucjx8sObe7eRyoQIDAQAB';
+
+const randomString = (length: number) => {
+ if (length > 32) {
+ throw new Error('Max length is 32.');
+ }
+ return uuidv4().replaceAll('-', '').slice(0, length);
+};
+
+const uuidv4 = () => crypto.randomUUID();
+
+/**
+ * @param {string} str
+ * @returns {CryptoJS.lib.WordArray}
+ */
+const MD5 = (str) => CryptoJS.MD5(str);
+
+const encryptAES = (data, key) => {
+ if (typeof key === 'string') {
+ key = MD5(key);
+ }
+ return CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(data), key, {
+ mode: CryptoJS.mode.ECB,
+ padding: CryptoJS.pad.Pkcs7,
+ }).toString();
+};
+
+const decryptAES = (data, key) => {
+ if (typeof key === 'string') {
+ key = MD5(key);
+ }
+ return CryptoJS.AES.decrypt(data, key, {
+ mode: CryptoJS.mode.ECB,
+ padding: CryptoJS.pad.Pkcs7,
+ }).toString(CryptoJS.enc.Utf8);
+};
+
+const encryptRSA = (data) => {
+ // Original code:
+ // var n = new JSEncrypt();
+ // n.setPublicKey(pubKey);
+ // return n.encrypt(message);
+ // Note: Server will reject the public key if it's encrypted using crypto.publicEncrypt().
+ let pubKey = `-----BEGIN PUBLIC KEY-----${publicKey}-----END PUBLIC KEY-----`;
+ pubKey = KEYUTIL.getKey(pubKey);
+ return hextob64(KJUR.crypto.Cipher.encrypt(data, pubKey));
+};
+
+const getHeaders = (key) => {
+ const fingerPrint = uuidv4();
+
+ return {
+ 'content-type': 'application/json',
+ 'ovse-trace': uuidv4(),
+ callertype: 'USER',
+ fingerprint: encryptAES(fingerPrint, MD5(key)),
+ onetimeskey: encryptRSA(key),
+ timestamp: encryptAES(Date.now(), key),
+ referer: 'https://www.ainvest.com/',
+ };
+};
+
+export { decryptAES, encryptAES, getHeaders, randomString };
diff --git a/lib/routes/aip/journal-pupp.ts b/lib/routes/aip/journal-pupp.ts
new file mode 100644
index 00000000000000..a6eb8dcafdb25c
--- /dev/null
+++ b/lib/routes/aip/journal-pupp.ts
@@ -0,0 +1,64 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import cache from '@/utils/cache';
+import puppeteer from '@/utils/puppeteer';
+import { isValidHost } from '@/utils/valid-host';
+
+import { puppeteerGet, renderDesc } from './utils';
+
+const handler = async (ctx) => {
+ const pub = ctx.req.param('pub');
+ const jrn = ctx.req.param('jrn');
+ const host = `https://pubs.aip.org`;
+ const jrnlUrl = `${host}/${pub}/${jrn}/issue`;
+ if (!isValidHost(pub)) {
+ throw new InvalidParameterError('Invalid pub');
+ }
+
+ // use Puppeteer due to the obstacle by cloudflare challenge
+ const browser = await puppeteer();
+
+ const { jrnlName, list } = await cache.tryGet(
+ jrnlUrl,
+ async () => {
+ const response = await puppeteerGet(jrnlUrl, browser);
+ const $ = load(response);
+ const jrnlName = $('.header-journal-title').text();
+ const list = $('.card')
+ .toArray()
+ .map((item) => {
+ $(item).find('.access-text').remove();
+ const title = $(item).find('.hlFld-Title').text();
+ const authors = $(item).find('.entryAuthor.all').text();
+ const img = $(item).find('img').attr('src');
+ const link = $(item).find('.ref.nowrap').attr('href');
+ const doi = link.replace('/doi/full/', '');
+ const description = renderDesc(title, authors, doi, img);
+ return {
+ title,
+ link,
+ doi,
+ description,
+ };
+ });
+ return {
+ jrnlName,
+ list,
+ };
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+ await browser.close();
+
+ return {
+ title: jrnlName,
+ link: jrnlUrl,
+ item: list,
+ allowEmpty: true,
+ };
+};
+export default handler;
diff --git a/lib/routes/aip/journal.ts b/lib/routes/aip/journal.ts
new file mode 100644
index 00000000000000..f958c3a5b4006c
--- /dev/null
+++ b/lib/routes/aip/journal.ts
@@ -0,0 +1,82 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import { renderDesc } from './utils';
+
+export const route: Route = {
+ path: '/:pub/:jrn',
+ categories: ['journal'],
+ example: '/aip/aapt/ajp',
+ parameters: { pub: 'Publisher id', jrn: 'Journal id' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: true,
+ },
+ radar: [
+ {
+ source: ['pubs.aip.org/:pub/:jrn'],
+ },
+ ],
+ name: 'Journal',
+ maintainers: ['Derekmini', 'auto-bot-ty'],
+ handler,
+ description: `Refer to the URL format \`pubs.aip.org/:pub/:jrn\`
+
+::: tip
+ More jounals can be found in [AIP Publications](https://publishing.aip.org/publications/find-the-right-journal).
+:::`,
+};
+
+async function handler(ctx) {
+ const pub = ctx.req.param('pub');
+ const jrn = ctx.req.param('jrn');
+ const host = `https://pubs.aip.org`;
+ const jrnlUrl = `${host}/${pub}/${jrn}/issue`;
+
+ const { data: response } = await got.get(jrnlUrl);
+ const $ = load(response);
+ const jrnlName = $('meta[property="og:title"]')
+ .attr('content')
+ .match(/(?:[^=]*=)?\s*([^>]+)\s*/)[1];
+ const publication = $('.al-article-item-wrap.al-normal');
+
+ const list = publication.toArray().map((item) => {
+ const title = $(item).find('.item-title a:first').text();
+ const link = $(item).find('.item-title a:first').attr('href');
+ const doilink = $(item).find('.citation-label a').attr('href');
+ const doi = doilink && doilink.match(/10\.\d+\/\S+/)[0];
+ const id = $(item).find('h5[data-resource-id-access]').data('resource-id-access');
+ const authors = $(item)
+ .find('.al-authors-list')
+ .find('a')
+ .toArray()
+ .map((element) => $(element).text())
+ .join('; ');
+ const imgUrl = $(item).find('.issue-featured-image a img').attr('src');
+ const img = imgUrl ? imgUrl.replace(/\?.+$/, '') : '';
+ const description = renderDesc(title, authors, doi, img);
+ return {
+ title,
+ link,
+ doilink,
+ id,
+ authors,
+ img,
+ doi,
+ description,
+ };
+ });
+
+ return {
+ title: jrnlName,
+ link: jrnlUrl,
+ item: list,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/aip/namespace.ts b/lib/routes/aip/namespace.ts
new file mode 100644
index 00000000000000..cd7f9ca97013a1
--- /dev/null
+++ b/lib/routes/aip/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'American Institute of Physics',
+ url: 'pubs.aip.org',
+ lang: 'en',
+};
diff --git a/lib/v2/aip/templates/description.art b/lib/routes/aip/templates/description.art
similarity index 100%
rename from lib/v2/aip/templates/description.art
rename to lib/routes/aip/templates/description.art
diff --git a/lib/routes/aip/utils.ts b/lib/routes/aip/utils.ts
new file mode 100644
index 00000000000000..1590c8d022fb71
--- /dev/null
+++ b/lib/routes/aip/utils.ts
@@ -0,0 +1,27 @@
+import path from 'node:path';
+
+import { art } from '@/utils/render';
+
+const puppeteerGet = async (url, browser) => {
+ const page = await browser.newPage();
+ // await page.setExtraHTTPHeaders({ referer: host });
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' ? request.continue() : request.abort();
+ });
+ await page.goto(url, {
+ waitUntil: 'domcontentloaded',
+ });
+ const html = await page.evaluate(() => document.documentElement.innerHTML);
+ return html;
+};
+
+const renderDesc = (title, authors, doi, img) =>
+ art(path.join(__dirname, 'templates/description.art'), {
+ title,
+ authors,
+ doi,
+ img,
+ });
+
+export { puppeteerGet, renderDesc };
diff --git a/lib/routes/air-level/index.ts b/lib/routes/air-level/index.ts
new file mode 100644
index 00000000000000..d5b05efdc4ef10
--- /dev/null
+++ b/lib/routes/air-level/index.ts
@@ -0,0 +1,50 @@
+import { load } from 'cheerio'; // 类似 jQuery 的 API HTML 解析器
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch'; // 统一使用的请求库
+
+export const route: Route = {
+ path: '/air/:area',
+ radar: [
+ {
+ source: ['m.air-level.com/air/:area/'],
+ target: '/air/:area',
+ },
+ ],
+ parameters: {
+ area: '地区',
+ },
+ name: '空气质量',
+ maintainers: ['lifetraveler'],
+ example: '/air-level/air/xian',
+ handler,
+};
+
+async function handler(ctx) {
+ const area = ctx.req.param('area');
+ const currentUrl = `https://m.air-level.com/air/${area}`;
+ const response = await ofetch(currentUrl);
+ const $ = load(response);
+
+ const title = $('body > div.container > div.row.page > div:nth-child(1) > h2').text().replaceAll('[]', '');
+
+ const table = $('body > div.container > div.row.page > div:nth-child(1) > div:nth-child(3) > table');
+
+ const qt = $('body > div.container > div.row.page > div:nth-child(1) > div.aqi-dv > div > span.aqi-bg.aqi-level-2').text();
+ const pubtime = $('body > div.container > div.row.page > div:nth-child(1) > div.aqi-dv > div > span.label.label-info').text();
+
+ const items = [
+ {
+ title: title + ' ' + qt + ' ' + pubtime,
+ link: currentUrl,
+ description: ``,
+ guid: pubtime,
+ },
+ ];
+ return {
+ title,
+ item: items,
+ description: '订阅每个城市的天气质量',
+ link: currentUrl,
+ };
+}
diff --git a/lib/routes/air-level/levelrank.ts b/lib/routes/air-level/levelrank.ts
new file mode 100644
index 00000000000000..856466be7ba4af
--- /dev/null
+++ b/lib/routes/air-level/levelrank.ts
@@ -0,0 +1,66 @@
+import { load } from 'cheerio'; // 类似 jQuery 的 API HTML 解析器
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch'; // 统一使用的请求库
+
+export const route: Route = {
+ path: ['/rank/:status?'],
+ radar: [
+ {
+ source: ['m.air-level.com/rank/:status', 'm.air-level.com/rank'],
+ target: '/rank/:status',
+ },
+ ],
+ parameters: {
+ status: '地区',
+ },
+ name: '空气质量排行',
+ maintainers: ['lifetraveler'],
+ example: '/air-level/rank/best,/air-level/rank',
+ handler,
+};
+
+async function handler(ctx) {
+ const status = ctx.req.param('status');
+ const currentUrl = 'https://m.air-level.com/rank';
+ const response = await ofetch(currentUrl);
+ const $ = load(response);
+ let table = '';
+ let title = '';
+
+ const titleBest = $('body > div.container > div.row.page > div:nth-child(1) > div:nth-child(5) > h3').text().replaceAll('[]', '');
+ const tableBest = $('body > div.container > div.row.page > div:nth-child(1) > div:nth-child(5) > table').html();
+ const titleWorst = $('body > div.container > div.row.page > div:nth-child(1) > div:nth-child(3) > h3').text().replaceAll('[]', '');
+ const tableWorst = $('body > div.container > div.row.page > div:nth-child(1) > div:nth-child(3) > table').html();
+
+ if (status) {
+ if (status === 'best') {
+ title = titleBest;
+ table = ``;
+ }
+
+ if (status === 'worsest') {
+ title = titleWorst;
+ table = ``;
+ }
+ } else {
+ title = $('body > div.container > div.row.page > div:nth-child(1) > h2').text().replaceAll('[]', '');
+ table = `${titleBest}${titleWorst} ${tableWorst}
`;
+ }
+
+ const pubtime = $('body > div.container > div.row.page > div:nth-child(1) > h4').text();
+ const items = [
+ {
+ title,
+ link: currentUrl,
+ description: table,
+ guid: pubtime,
+ },
+ ];
+ return {
+ title,
+ item: items,
+ description: '空气质量排行',
+ link: currentUrl,
+ };
+}
diff --git a/lib/routes/air-level/namespace.ts b/lib/routes/air-level/namespace.ts
new file mode 100644
index 00000000000000..d85046bbd87472
--- /dev/null
+++ b/lib/routes/air-level/namespace.ts
@@ -0,0 +1,12 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Air-Level',
+ url: 'air-level.com',
+ description: `
+ - 可以订阅每个城市的空气质量,按照拼音订阅
+ - 支持订阅每天的实时排名
+ `,
+ categories: ['forecast'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/airchina/index.ts b/lib/routes/airchina/index.ts
new file mode 100644
index 00000000000000..dbe342e5821ffa
--- /dev/null
+++ b/lib/routes/airchina/index.ts
@@ -0,0 +1,66 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import buildData from '@/utils/common-config';
+import got from '@/utils/got';
+
+const baseUrl = 'https://www.airchina.com.cn';
+
+export const route: Route = {
+ path: '/announcement',
+ categories: ['travel'],
+ example: '/airchina/announcement',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.airchina.com.cn/'],
+ },
+ ],
+ name: '服务公告',
+ maintainers: ['LandonLi'],
+ handler,
+ url: 'www.airchina.com.cn/',
+};
+
+async function handler() {
+ const link = `${baseUrl}/cn/info/new-service/service_announcement.shtml`;
+ const data = await buildData({
+ link,
+ url: link,
+ title: `%title%`,
+ description: `%description%`,
+ params: {
+ title: '国航服务公告',
+ description: '中国国际航空公司服务公告',
+ },
+ item: {
+ item: '.serviceMsg li',
+ title: `$('a').text()`,
+ link: `$('a').attr('href')`,
+ pubDate: `parseDate($('span').text(), 'YYYY-MM-DD')`,
+ guid: Buffer.from(`$('a').attr('href')`).toString('base64'),
+ },
+ });
+
+ await Promise.all(
+ data.item.map(async (item) => {
+ const detailLink = baseUrl + item.link;
+ item.description = await cache.tryGet(detailLink, async () => {
+ const result = await got(detailLink);
+ const $ = load(result.data);
+ return $('.serviceMsg').html();
+ });
+ })
+ );
+
+ return data;
+}
diff --git a/lib/routes/airchina/namespace.ts b/lib/routes/airchina/namespace.ts
new file mode 100644
index 00000000000000..1941def61fce23
--- /dev/null
+++ b/lib/routes/airchina/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国国际航空公司',
+ url: 'www.airchina.com.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aisixiang/column.ts b/lib/routes/aisixiang/column.ts
new file mode 100644
index 00000000000000..ba0d9f5363ddcb
--- /dev/null
+++ b/lib/routes/aisixiang/column.ts
@@ -0,0 +1,66 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import { ossUrl, ProcessFeed, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/column/:id',
+ categories: ['reading'],
+ example: '/aisixiang/column/722',
+ parameters: { id: '栏目 ID, 可在对应栏目 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '栏目',
+ maintainers: ['HenryQW', 'nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const currentUrl = new URL(`/data/search?column=${id}`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const title = $('div.article-title a').first().text().replaceAll('[]', '');
+
+ const items = $('div.article-title')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a[title]');
+
+ return {
+ title: a.text(),
+ link: new URL(a.prop('href'), rootUrl).href,
+ author: a.text().split(':')[0],
+ pubDate: timezone(parseDate(item.find('span').text()), +8),
+ };
+ });
+
+ return {
+ item: await ProcessFeed(limit, cache.tryGet, items),
+ title: `爱思想 - ${title}`,
+ link: currentUrl,
+ description: $('div.tips').text(),
+ language: 'zh-cn',
+ image: new URL('images/logo.jpg', ossUrl).href,
+ subtitle: title,
+ };
+}
diff --git a/lib/routes/aisixiang/namespace.ts b/lib/routes/aisixiang/namespace.ts
new file mode 100644
index 00000000000000..aa1811ab78ec58
--- /dev/null
+++ b/lib/routes/aisixiang/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '爱思想',
+ url: 'aisixiang.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aisixiang/thinktank.ts b/lib/routes/aisixiang/thinktank.ts
new file mode 100644
index 00000000000000..d32d7079968507
--- /dev/null
+++ b/lib/routes/aisixiang/thinktank.ts
@@ -0,0 +1,73 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { ossUrl, ProcessFeed, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/thinktank/:id/:type?',
+ categories: ['reading'],
+ example: '/aisixiang/thinktank/WuQine/论文',
+ parameters: { id: '专栏 ID,一般为作者拼音,可在URL中找到', type: '栏目类型,参考下表,默认为全部' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '思想库(专栏)',
+ maintainers: ['hoilc', 'nczitzk'],
+ handler,
+ description: `| 论文 | 时评 | 随笔 | 演讲 | 访谈 | 著作 | 读书 | 史论 | 译作 | 诗歌 | 书信 | 科学 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |`,
+};
+
+async function handler(ctx) {
+ const { id, type = '' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const currentUrl = new URL(`thinktank/${id}.html`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const title = `${$('h2').first().text().trim()}${type}`;
+
+ let items = [];
+
+ const targetList = $('h3')
+ .toArray()
+ .filter((h) => (type ? $(h).text() === type : true));
+ if (!targetList) {
+ throw new InvalidParameterError(`Not found ${type} in ${id}: ${currentUrl}`);
+ }
+
+ for (const l of targetList) {
+ items = [...items, ...$(l).parent().find('ul li a').toArray()];
+ }
+
+ items = items.slice(0, limit).map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text().split(':').pop(),
+ link: new URL(item.prop('href'), rootUrl).href,
+ };
+ });
+
+ return {
+ item: await ProcessFeed(limit, cache.tryGet, items),
+ title: `爱思想 - ${title}`,
+ link: currentUrl,
+ description: $('div.thinktank-author-description-box p').text(),
+ language: 'zh-cn',
+ image: new URL('images/logo_thinktank.jpg', ossUrl).href,
+ subtitle: title,
+ };
+}
diff --git a/lib/routes/aisixiang/toplist.ts b/lib/routes/aisixiang/toplist.ts
new file mode 100644
index 00000000000000..3599fe035e94cb
--- /dev/null
+++ b/lib/routes/aisixiang/toplist.ts
@@ -0,0 +1,56 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import { ossUrl, ProcessFeed, rootUrl } from './utils';
+
+export const route: Route = {
+ path: ['/ranking/:id?/:period?', '/toplist/:id?/:period?'],
+ name: 'Unknown',
+ maintainers: ['HenryQW', 'nczitzk'],
+ handler,
+ description: `| 文章点击排行 | 最近更新文章 | 文章推荐排行 |
+| ------------ | ------------ | ------------ |
+| 1 | 10 | 11 |`,
+};
+
+async function handler(ctx) {
+ const { id = '1', period = '1' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const currentUrl = new URL(`toplist${id ? `?id=${id}${id === '1' ? `&period=${period}` : ''}` : ''}`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const title = `${$('a.hl').text() || ''}${$('title').text().split('_')[0]}`;
+
+ const items = $('div.tops_list')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('div.tips a');
+
+ return {
+ title: a.text(),
+ link: new URL(a.prop('href'), rootUrl).href,
+ author: item.find('div.name').text(),
+ pubDate: parseDate(item.find('div.times').text()),
+ };
+ });
+
+ return {
+ item: await ProcessFeed(limit, cache.tryGet, items),
+ title: `爱思想 - ${title}`,
+ link: currentUrl,
+ language: 'zh-cn',
+ image: new URL('images/logo_toplist.jpg', ossUrl).href,
+ subtitle: title,
+ };
+}
diff --git a/lib/routes/aisixiang/utils.ts b/lib/routes/aisixiang/utils.ts
new file mode 100644
index 00000000000000..94ba3e62ecfde4
--- /dev/null
+++ b/lib/routes/aisixiang/utils.ts
@@ -0,0 +1,40 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const ossUrl = 'https://oss.aisixiang.com';
+const rootUrl = 'https://www.aisixiang.com';
+
+const ProcessFeed = (limit, tryGet, items) =>
+ Promise.all(
+ items.slice(0, limit).map((item) =>
+ tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const content = load(detailResponse);
+
+ const commentMatches = content('h3.comment-header')
+ .text()
+ .match(/评论(\d+)/);
+
+ item.title = content('h3').first().text().split(':').pop();
+ item.description = content('div.article-content').html();
+ item.author = content('div.about strong').first().text();
+ item.category = content('u')
+ .first()
+ .parent()
+ .find('u')
+ .toArray()
+ .map((c) => content(c).text());
+ item.pubDate = timezone(parseDate(content('div.info').text().split('时间:').pop()), +8);
+ item.upvotes = content('span.like-num').text() ? Number.parseInt(content('span.like-num').text(), 10) : 0;
+ item.comments = commentMatches ? Number.parseInt(commentMatches[1], 10) : 0;
+
+ return item;
+ })
+ )
+ );
+
+export { ossUrl, ProcessFeed, rootUrl };
diff --git a/lib/routes/aisixiang/zhuanti.ts b/lib/routes/aisixiang/zhuanti.ts
new file mode 100644
index 00000000000000..212adbd89c5031
--- /dev/null
+++ b/lib/routes/aisixiang/zhuanti.ts
@@ -0,0 +1,69 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import { ossUrl, ProcessFeed, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/zhuanti/:id',
+ categories: ['reading'],
+ example: '/aisixiang/zhuanti/211',
+ parameters: { id: '专题 ID, 可在对应专题 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '专题',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `::: tip
+ 更多专题请见 [关键词](http://www.aisixiang.com/zhuanti/)
+:::`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const currentUrl = new URL(`zhuanti/${id}.html`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const title = $('div.tips h2').first().text();
+
+ const items = $('div.article-title')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a');
+
+ return {
+ title: a.text(),
+ link: new URL(a.prop('href'), rootUrl).href,
+ author: a.text().split(':')[0],
+ pubDate: timezone(parseDate(item.find('span').text()), +8),
+ };
+ });
+
+ return {
+ item: await ProcessFeed(limit, cache.tryGet, items),
+ title: `爱思想 - ${title}`,
+ link: currentUrl,
+ description: $('div.tips p').text(),
+ language: 'zh-cn',
+ image: new URL('images/logo_zhuanti.jpg', ossUrl).href,
+ subtitle: title,
+ };
+}
diff --git a/lib/routes/aiyanxishe/home.js b/lib/routes/aiyanxishe/home.js
deleted file mode 100644
index eeefc6b3e64ef0..00000000000000
--- a/lib/routes/aiyanxishe/home.js
+++ /dev/null
@@ -1,130 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- const id = ctx.params.id;
- const sort = ctx.params.sort || 'new';
-
- let url = 'https://api.yanxishe.com/api?page=1&size=30&parent_tag=';
-
- if (id === 'all') {
- url += '&tag=';
- } else {
- url += `&tag=${id}`;
- }
- if (sort === 'hot') {
- url += '&is_hot=1&is_recommend=0';
- } else if (sort === 'recommend') {
- url += '&is_hot=0&is_recommend=1';
- } else {
- url += '&is_hot=0&is_recommend=0';
- }
-
- const response = await got({
- method: 'GET',
- url,
- headers: {
- Referer: `https://www.yanxishe.com/`,
- },
- });
-
- const ProcessFeed = (type, data, id) => {
- let description = '';
- let author = '';
- let link;
- switch (type) {
- case 'blog': // 博客
- description = data.content;
- author = data.user.nickname;
- if (data.relation_special) {
- link = `https://www.yanxishe.com/columnDetail/${id}`;
- } else {
- link = `https://www.yanxishe.com/blogDetail/${id}`;
- }
- break;
- case 'question': // 问答
- description = data.content;
- author = data.user.nickname;
- link = `https://www.yanxishe.com/questionDetail/${id}`;
- break;
- case 'article': // 翻译
- description = ` ${data.title} ${data.zh_title} `;
- data.paragraphs.forEach((element) => {
- description += ` ${element.content} ${element.zh_content.content} `;
- });
- description += `
- `;
- author = data.user.nickname;
- link = `https://www.yanxishe.com/TextTranslation/${id}`;
- break;
- case 'paper': // 论文
- description = `标题 ${data.paper.title}
`;
- description += `作者 ${data.paper.author.toString()}
`;
- description += `下载地址 ${data.paper.url}
`;
- description += `发布时间 ${data.paper.publish_time}
`;
- description += `摘要 ${data.paper.description}
`;
- description += `推荐理由 ${data.paper.userInfo.nickname} : ${data.paper.recommend_reason}
`;
- author = data.paper.userInfo.nickname;
- link = `https://paper.yanxishe.com/review/${id}`;
- break;
- default:
- description = '暂不支持此类型,请到 https://github.com/DIYgod/RSSHub/issues 反馈';
- break;
- }
-
- // 提取内容
- return { author, description, link };
- };
-
- const items = await Promise.all(
- response.data.data.all.map(async (item) => {
- let itemUrl = `https://api.yanxishe.com/api/sthread/${item.id}?token=null`;
-
- if (item.type === 'blog') {
- itemUrl += '&type=null';
- } else if (item.type === 'article') {
- itemUrl = `https://api.yanxishe.com/api/stranslate/article/${item.id}?token=null&type=null`;
- } else if (item.type === 'paper') {
- itemUrl = `https://api.yanxishe.com/api/spaper/detail?token=null&id=${item.id}`;
- }
-
- const cache = await ctx.cache.get(itemUrl);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const response = await got({
- method: 'get',
- url: itemUrl,
- });
-
- const result = ProcessFeed(item.type, response.data.data, item.id);
-
- const single = {
- title: item.zh_title,
- description: result.description,
- pubDate: new Date(parseFloat(item.published_time + '000')).toUTCString(),
- link: result.link,
- author: result.author,
- };
- ctx.cache.set(itemUrl, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
-
- ctx.state.data = {
- title: `AI研习社`,
- link: `https://ai.yanxishe.com/`,
- description: '专注AI技术发展与AI工程师成长的求知平台',
- item: items,
- };
-};
diff --git a/lib/routes/ajcass/namespace.ts b/lib/routes/ajcass/namespace.ts
new file mode 100644
index 00000000000000..f471eed914ee46
--- /dev/null
+++ b/lib/routes/ajcass/namespace.ts
@@ -0,0 +1,11 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '社科期刊网',
+ url: 'ajcass.com',
+ description: '中国社会科学院学术期刊方阵',
+ lang: 'zh-CN',
+ zh: {
+ name: '社科期刊网',
+ },
+};
diff --git a/lib/routes/ajcass/shxyj.ts b/lib/routes/ajcass/shxyj.ts
new file mode 100644
index 00000000000000..7402ef221e5d62
--- /dev/null
+++ b/lib/routes/ajcass/shxyj.ts
@@ -0,0 +1,74 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/shxyj/:year?/:issue?',
+ categories: ['journal'],
+ example: '/ajcass/shxyj/2024/1',
+ parameters: { year: 'Year of the issue, `null` for the lastest', issue: 'Issue number, `null` for the lastest' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '社会学研究',
+ maintainers: ['CNYoki'],
+ handler,
+};
+
+async function handler(ctx) {
+ let { year, issue } = ctx.req.param();
+
+ if (!year) {
+ const response = await got('https://shxyj.ajcass.com/');
+ const $ = load(response.body);
+ const latestIssueText = $('p.hod.pop').first().text();
+
+ const match = latestIssueText.match(/(\d{4}) Vol\.(\d+):/);
+ if (match) {
+ year = match[1];
+ issue = match[2];
+ } else {
+ throw new Error('无法获取最新的 year 和 issue');
+ }
+ }
+
+ const url = `https://shxyj.ajcass.com/Magazine/?Year=${year}&Issue=${issue}`;
+ const response = await got(url);
+ const $ = load(response.body);
+
+ const items = $('#tab tr')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const articleTitle = $item.find('a').first().text().trim();
+ const articleLink = $item.find('a').first().attr('href');
+ const summary = $item.find('li').eq(1).text().replace('[摘要]', '').trim();
+ const authors = $item.find('li').eq(2).text().replace('作者:', '').trim();
+ const pubDate = parseDate(`${year}-${Number.parseInt(issue) * 2}`);
+
+ if (articleTitle && articleLink) {
+ return {
+ title: articleTitle,
+ link: `https://shxyj.ajcass.com${articleLink}`,
+ description: summary,
+ author: authors,
+ pubDate,
+ };
+ }
+ return null;
+ })
+ .filter((item) => item !== null);
+
+ return {
+ title: `社会学研究 ${year}年第${issue}期`,
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/ajmide/index.ts b/lib/routes/ajmide/index.ts
new file mode 100644
index 00000000000000..1305e792c85c69
--- /dev/null
+++ b/lib/routes/ajmide/index.ts
@@ -0,0 +1,50 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:id',
+ categories: ['multimedia'],
+ view: ViewType.Audios,
+ example: '/ajmide/10603594',
+ parameters: { id: '播客 id,可以从播客页面 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '播客',
+ maintainers: ['Fatpandac'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const limit = ctx.req.param('limit') ?? 25;
+ const playListAPI = `https://a.ajmide.com/v3/getBrandContentList.php?brandId=${id}&c=${limit}&i=0`;
+ const response = await got.get(playListAPI);
+ const data = response.data.data.filter((item) => !item.contentType);
+
+ const items = data.map((item) => ({
+ title: item.subject,
+ author: item.author_info.nick,
+ link: item.shareInfo.link,
+ pubDate: parseDate(item.postTime, 'YYYY-MM-DD HH:mm:ss'),
+ itunes_item_image: item.brandImgPath,
+ enclosure_url: item.audioAttach[0].liveUrl,
+ itunes_duration: item.audioAttach[0].audioTime,
+ enclosure_type: 'audio/x-m4a',
+ }));
+
+ return {
+ title: data[0].brandName,
+ link: `https://m.ajmide.com/m/brand?id=${id}`,
+ itunes_author: data[0].author_info.nick,
+ image: data[0].brandImgPath,
+ item: items,
+ };
+}
diff --git a/lib/routes/ajmide/namespace.ts b/lib/routes/ajmide/namespace.ts
new file mode 100644
index 00000000000000..bfb258b9ce7ed9
--- /dev/null
+++ b/lib/routes/ajmide/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '阿基米德 FM',
+ url: 'm.ajmide.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/ali213/namespace.ts b/lib/routes/ali213/namespace.ts
new file mode 100644
index 00000000000000..14cec63452d684
--- /dev/null
+++ b/lib/routes/ali213/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '游侠网',
+ url: 'ali213.net',
+ categories: ['game'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/ali213/news.ts b/lib/routes/ali213/news.ts
new file mode 100644
index 00000000000000..c54fa4c132e4e8
--- /dev/null
+++ b/lib/routes/ali213/news.ts
@@ -0,0 +1,268 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'new' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const rootUrl: string = 'https://www.ali213.net';
+ const targetUrl: string = new URL(`news/${category.endsWith('/') ? category : `${category}/`}`, rootUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language: string = $('html').prop('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = $('div.n_lone')
+ .slice(0, limit)
+ .toArray()
+ .map((item): DataItem => {
+ const $item: Cheerio = $(item);
+
+ const aEl: Cheerio = $item.find('h2.lone_t a');
+
+ const title: string = aEl.prop('title') || aEl.text();
+ const link: string | undefined = aEl.prop('href');
+
+ const imageEl: Cheerio = $item.find('img');
+ const imageSrc: string | undefined = imageEl?.prop('src');
+ const imageAlt: string | undefined = imageEl?.prop('alt');
+
+ const intro: string = $item.find('div.lone_f_r_t').text();
+
+ const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ images: imageEl
+ ? [
+ {
+ src: imageSrc,
+ alt: imageAlt,
+ },
+ ]
+ : undefined,
+ intro,
+ });
+
+ const author: DataItem['author'] = $item.find('div.lone_f_r_f span').last().text().split(/:/).pop();
+
+ return {
+ title,
+ description,
+ pubDate: parseDate($item.find('div.lone_f_r_f span').first().text()),
+ link,
+ author,
+ content: {
+ html: description,
+ text: $item.find('div.lone_f_r_t').text(),
+ },
+ image: imageSrc,
+ banner: imageSrc,
+ language,
+ };
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link && typeof item.link !== 'string') {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ try {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('h1.newstit').text();
+ const image: string | undefined = $$('div#Content img').first().prop('src');
+
+ const mediaContent: Cheerio = $$('div#Content p span img');
+ const media: Record> = {};
+
+ if (mediaContent.length) {
+ mediaContent.each((_, el) => {
+ const $$el: Cheerio = $$(el);
+
+ const pEl: Cheerio = $$el.closest('p');
+
+ const mediaUrl: string | undefined = $$el.prop('src');
+ const mediaType: string | undefined = mediaUrl?.split(/\./).pop();
+
+ if (mediaType && mediaUrl) {
+ media[mediaType] = { url: mediaUrl };
+
+ pEl.replaceWith(
+ art(path.join(__dirname, 'templates/description.art'), {
+ images: [
+ {
+ src: mediaUrl,
+ },
+ ],
+ })
+ );
+ }
+ });
+ }
+
+ const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div#Content').html() ?? '',
+ });
+
+ const extraLinks = $$('div.extend_read ul li a')
+ .toArray()
+ .map((el) => {
+ const $$el: Cheerio = $$(el);
+
+ return {
+ url: $$el.prop('href'),
+ type: 'related',
+ content_html: $$el.prop('title') || $$el.text(),
+ };
+ })
+ .filter((_): _ is { url: string; type: string; content_html: string } => true);
+
+ return {
+ ...item,
+ title,
+ description,
+ pubDate: timezone(parseDate($$('div.newstag_l').text().split(/\s/)[0]), +8),
+ content: {
+ html: description,
+ text: $$('div#Content').html() ?? '',
+ },
+ image,
+ banner: image,
+ language,
+ media: Object.keys(media).length > 0 ? media : undefined,
+ _extra: {
+ links: extraLinks.length > 0 ? extraLinks : undefined,
+ },
+ };
+ } catch {
+ return item;
+ }
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const author = '游侠网';
+ const title = $('div.news-list-title').text();
+ const feedImage = new URL('news/images/ali213_app_big.png', rootUrl).href;
+
+ return {
+ title: `${author} - ${title}`,
+ description: title,
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: feedImage,
+ author,
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/news/:category?',
+ name: '资讯',
+ url: 'www.ali213.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/ali213/news/new',
+ parameters: {
+ category: '分类,默认为 `new`,即最新资讯,可在对应分类页 URL 中找到',
+ },
+ description: `::: tip
+若订阅 [游戏资讯](https://www.ali213.net/news/game/),网址为 \`https://www.ali213.net/news/game/\`,请截取 \`https://www.ali213.net/news/\` 到末尾 \`/\` 的部分 \`game\` 作为 \`category\` 参数填入,此时目标路由为 [\`/ali213/news/game\`](https://rsshub.app/ali213/news/game)。
+:::
+
+| 分类名称 | 分类 ID |
+| -------- | ------- |
+| 最新资讯 | new |
+| 评测 | pingce |
+| 游戏 | game |
+| 动漫 | comic |
+| 影视 | movie |
+| 科技 | tech |
+| 电竞 | esports |
+| 娱乐 | amuse |
+| 手游 | mobile |
+`,
+ categories: ['game'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.ali213.net/news/:category'],
+ target: (params) => {
+ const category = params.category;
+
+ return `/news/${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '最新资讯',
+ source: ['www.ali213.net/news/new'],
+ target: '/news/new',
+ },
+ {
+ title: '评测',
+ source: ['www.ali213.net/news/pingce'],
+ target: '/news/pingce',
+ },
+ {
+ title: '游戏',
+ source: ['www.ali213.net/news/game'],
+ target: '/news/game',
+ },
+ {
+ title: '动漫',
+ source: ['www.ali213.net/news/comic'],
+ target: '/news/comic',
+ },
+ {
+ title: '影视',
+ source: ['www.ali213.net/news/movie'],
+ target: '/news/movie',
+ },
+ {
+ title: '科技',
+ source: ['www.ali213.net/news/tech'],
+ target: '/news/tech',
+ },
+ {
+ title: '电竞',
+ source: ['www.ali213.net/news/esports'],
+ target: '/news/esports',
+ },
+ {
+ title: '娱乐',
+ source: ['www.ali213.net/news/amuse'],
+ target: '/news/amuse',
+ },
+ {
+ title: '手游',
+ source: ['www.ali213.net/news/mobile'],
+ target: '/news/mobile',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/ali213/templates/description.art b/lib/routes/ali213/templates/description.art
new file mode 100644
index 00000000000000..249654e7e618a4
--- /dev/null
+++ b/lib/routes/ali213/templates/description.art
@@ -0,0 +1,21 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if intro }}
+ {{ intro }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/ali213/zl.ts b/lib/routes/ali213/zl.ts
new file mode 100644
index 00000000000000..6c405624ec534d
--- /dev/null
+++ b/lib/routes/ali213/zl.ts
@@ -0,0 +1,225 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '1', 10);
+
+ const rootUrl: string = 'https://www.ali213.net';
+ const apiRootUrl: string = 'https://mp.ali213.net';
+ const targetUrl: string = new URL(`/news/zl/${category ? (category.endsWith('/') ? category : `${category}/`) : ''}`, rootUrl).href;
+ const apiUrl: string = new URL('ajax/newslist', apiRootUrl).href;
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ type: 'new',
+ },
+ });
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language: string = $('html').prop('lang') ?? 'zh';
+
+ let items: DataItem[] = JSON.parse(response.replace(/^\((.*)\)$/, '$1'))
+ .data.slice(0, limit)
+ .map((item): DataItem => {
+ const title: string = item.Title;
+ const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ intro: item.GuideRead ?? '',
+ });
+ const guid: string = `ali213-zl-${item.ID}`;
+ const image: string | undefined = item.PicPath ? `https:${item.PicPath}` : undefined;
+
+ const author: DataItem['author'] = item.xiaobian;
+
+ return {
+ title,
+ description,
+ pubDate: parseDate(item.addtime * 1000),
+ link: item.url ? `https:${item.url}` : undefined,
+ author,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: item.GuideRead ?? '',
+ },
+ image,
+ banner: image,
+ language,
+ };
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link && typeof item.link !== 'string') {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('h1.newstit').text();
+
+ let description: string = $$('div#Content').html() ?? '';
+
+ const pageLinks: string[] = [];
+ $$('a.currpage')
+ .parent()
+ .find('a:not(.currpage)')
+ .each((_, el) => {
+ const href = $$(el).attr('href');
+ if (href) {
+ pageLinks.push(href);
+ }
+ });
+
+ const pageContents = await Promise.all(
+ pageLinks.map(async (link) => {
+ const response = await ofetch(new URL(link, item.link).href);
+ const $$$: CheerioAPI = load(response);
+
+ return $$$('div#Content').html() ?? '';
+ })
+ );
+
+ description += pageContents.join('');
+
+ description = art(path.join(__dirname, 'templates/description.art'), {
+ description,
+ });
+
+ const extraLinks = $$('div.extend_read a')
+ .toArray()
+ .map((el) => {
+ const $$el: Cheerio = $$(el);
+
+ return {
+ url: $$el.prop('href'),
+ type: 'related',
+ content_html: $$el.text(),
+ };
+ })
+ .filter((_): _ is { url: string; type: string; content_html: string } => true);
+
+ return {
+ title,
+ description,
+ pubDate: item.pubDate,
+ category: $$('.category')
+ .toArray()
+ .map((c) => $$(c).text()),
+ author: item.author,
+ doi: $$('meta[name="citation_doi"]').prop('content') || undefined,
+ guid: item.guid,
+ id: item.guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image: item.image,
+ banner: item.image,
+ language,
+ _extra: {
+ links: extraLinks.length > 0 ? extraLinks : undefined,
+ },
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const title: string = $('title').text();
+ const feedImage: string = new URL('news/images/dxhlogo.png', rootUrl).href;
+
+ return {
+ title,
+ description: $('meta[name="description"]').prop('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: feedImage,
+ author: title.split(/_/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/zl/:category?',
+ name: '大侠号',
+ url: 'www.ali213.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/ali213/zl',
+ parameters: {
+ category: '分类,默认为首页,可在对应分类页 URL 中找到',
+ },
+ description: `::: tip
+若订阅 [游戏](https://www.ali213.net/news/zl/game/),网址为 \`https://www.ali213.net/news/zl/game/\`,请截取 \`https://www.ali213.net/news/zl/\` 到末尾 \`/\` 的部分 \`game\` 作为 \`category\` 参数填入,此时目标路由为 [\`/ali213/zl/game\`](https://rsshub.app/ali213/zl/game)。
+:::
+
+| 首页 | 游戏 | 动漫 | 影视 | 娱乐 |
+| ---------------------------------------- | -------------------------------------------- | ---------------------------------------------- | ---------------------------------------------- | ---------------------------------------------- |
+| [index](https://www.ali213.net/news/zl/) | [game](https://www.ali213.net/news/zl/game/) | [comic](https://www.ali213.net/news/zl/comic/) | [movie](https://www.ali213.net/news/zl/movie/) | [amuse](https://www.ali213.net/news/zl/amuse/) |
+`,
+ categories: ['game'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.ali213.net/news/zl/:category'],
+ target: (params) => {
+ const category = params.category;
+
+ return `/ali213/zl${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '首页',
+ source: ['www.ali213.net/news/zl/'],
+ target: '/zl',
+ },
+ {
+ title: '游戏',
+ source: ['www.ali213.net/news/zl/game/'],
+ target: '/zl/game',
+ },
+ {
+ title: '动漫',
+ source: ['www.ali213.net/news/zl/comic/'],
+ target: '/zl/comic',
+ },
+ {
+ title: '影视',
+ source: ['www.ali213.net/news/zl/movie/'],
+ target: '/zl/movie',
+ },
+ {
+ title: '娱乐',
+ source: ['www.ali213.net/news/zl/amuse/'],
+ target: '/zl/amuse',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/alicesoft/infomation.ts b/lib/routes/alicesoft/infomation.ts
new file mode 100644
index 00000000000000..0cffbbdf455f21
--- /dev/null
+++ b/lib/routes/alicesoft/infomation.ts
@@ -0,0 +1,89 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const baseUrl = 'https://www.alicesoft.com';
+
+export const route: Route = {
+ url: 'www.alicesoft.com/information',
+ path: '/information/:category?/:game?',
+ categories: ['game'],
+ example: '/alicesoft/information/game/cat377',
+ parameters: {
+ category: 'Category in the URL, which can be accessed under カテゴリ一覧 on the website.',
+ game: 'Game-specific subcategory in the URL, which can be accessed under カテゴリ一覧 on the website. In this case, the category value should be `game`.',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.alicesoft.com/information', 'www.alicesoft.com/information/:category', 'www.alicesoft.com/information/:category/:game'],
+ target: '/information/:category/:game',
+ },
+ ],
+ name: 'ニュース',
+ maintainers: ['keocheung'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { category, game } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 10;
+
+ let url = `${baseUrl}/information`;
+ if (category) {
+ url += `/${category}`;
+ if (game) {
+ url += `/${game}`;
+ }
+ }
+
+ const response = await got(url);
+ const $ = load(response.data);
+
+ let items = $('div.cont-main li')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('p.txt').text(),
+ link: item.find('a').attr('href'),
+ pubDate: new Date(item.find('time').attr('datetime')),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link.startsWith(`${baseUrl}/information/`)) {
+ return item;
+ }
+ return cache.tryGet(item.link, async () => {
+ const contentResponse = await got(item.link);
+
+ const content = load(contentResponse.data);
+ content('iframe[src^="https://www.youtube.com/"]').removeAttr('height').removeAttr('width');
+ item.description = `${content('div.article-detail')
+ .html()
+ ?.replaceAll(/
(.+?)<\/p>/g, '
$1 ')
+ ?.replaceAll(/
(.+?)<\/p>/g, '
$1 ')}
`;
+ return item;
+ });
+ })
+ );
+
+ return {
+ title: 'ALICESOFT ' + $('article h2').clone().children().remove().end().text(),
+ link: url,
+ item: items,
+ language: 'ja',
+ };
+}
diff --git a/lib/routes/alicesoft/namespace.ts b/lib/routes/alicesoft/namespace.ts
new file mode 100644
index 00000000000000..ca9904aa76a3ad
--- /dev/null
+++ b/lib/routes/alicesoft/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ALICESOFT',
+ url: 'www.alicesoft.com',
+ lang: 'ja',
+};
diff --git a/lib/routes/alipan/files.ts b/lib/routes/alipan/files.ts
new file mode 100644
index 00000000000000..0c2b5d4587031e
--- /dev/null
+++ b/lib/routes/alipan/files.ts
@@ -0,0 +1,81 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import type { AnonymousShareInfo, ShareList, TokenResponse } from './types';
+
+export const route: Route = {
+ path: '/files/:share_id/:parent_file_id?',
+ example: '/alipan/files/jjtKEgXJAtC/64a957744876479ab17941b29d1289c6ebdd71ef',
+ parameters: { share_id: '分享 id,可以从分享页面 URL 中找到', parent_file_id: '文件夹 id,可以从文件夹页面 URL 中找到' },
+ radar: [
+ {
+ source: ['www.alipan.com/s/:share_id/folder/:parent_file_id', 'www.alipan.com/s/:share_id'],
+ },
+ ],
+ name: '文件列表',
+ maintainers: ['DIYgod'],
+ handler,
+ url: 'www.alipan.com/s',
+};
+
+async function handler(ctx) {
+ const { share_id, parent_file_id } = ctx.req.param();
+ const url = `https://www.aliyundrive.com/s/${share_id}${parent_file_id ? `/folder/${parent_file_id}` : ''}`;
+
+ const headers = {
+ referer: 'https://www.aliyundrive.com/',
+ origin: 'https://www.aliyundrive.com',
+ 'x-canary': 'client=web,app=share,version=v2.3.1',
+ };
+
+ const shareRes = await ofetch('https://api.aliyundrive.com/adrive/v3/share_link/get_share_by_anonymous', {
+ method: 'POST',
+ headers,
+ query: {
+ share_id,
+ },
+ body: {
+ share_id,
+ },
+ });
+
+ const tokenRes = await ofetch('https://api.aliyundrive.com/v2/share_link/get_share_token', {
+ method: 'POST',
+ headers,
+ body: {
+ share_id,
+ },
+ });
+ const shareToken = tokenRes.share_token;
+
+ const listRes = await ofetch('https://api.aliyundrive.com/adrive/v2/file/list_by_share', {
+ method: 'POST',
+ headers: {
+ ...headers,
+ 'x-share-token': shareToken,
+ },
+ body: {
+ limit: 100,
+ order_by: 'created_at',
+ order_direction: 'DESC',
+ parent_file_id: parent_file_id || 'root',
+ share_id,
+ },
+ });
+
+ const result = listRes.items.map((item) => ({
+ title: item.name,
+ description: item.name + (item.thumbnail ? ` ` : ''),
+ link: url,
+ pubDate: parseDate(item.created_at),
+ updated: parseDate(item.updated_at),
+ guid: item.file_id,
+ }));
+
+ return {
+ title: `${shareRes.display_name || `${share_id}${parent_file_id ? `-${parent_file_id}` : ''}`}-阿里云盘`,
+ link: url,
+ item: result,
+ };
+}
diff --git a/lib/routes/alipan/namespace.ts b/lib/routes/alipan/namespace.ts
new file mode 100644
index 00000000000000..91c32c55173649
--- /dev/null
+++ b/lib/routes/alipan/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '阿里云盘',
+ url: 'www.alipan.com',
+ categories: ['multimedia'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/alipan/types.ts b/lib/routes/alipan/types.ts
new file mode 100644
index 00000000000000..a784c58797426d
--- /dev/null
+++ b/lib/routes/alipan/types.ts
@@ -0,0 +1,62 @@
+interface FileInfo {
+ type: string;
+ file_id: string;
+ file_name: string;
+}
+
+interface SaveButton {
+ text: string;
+ select_all_text: string;
+}
+
+export interface AnonymousShareInfo {
+ file_count: number;
+ share_name: string;
+ file_infos: FileInfo[];
+ creator_phone: string;
+ avatar: string;
+ display_name: string;
+ save_button: SaveButton;
+ updated_at: string;
+ share_title: string;
+ has_pwd: boolean;
+ creator_id: string;
+ creator_name: string;
+ expiration: string;
+ vip: string;
+}
+
+export interface TokenResponse {
+ expire_time: string;
+ expires_in: number;
+ share_token: string;
+}
+
+interface ImageMediaMetadata {
+ exif: string;
+}
+
+interface FileDetail {
+ drive_id: string;
+ domain_id: string;
+ file_id: string;
+ share_id: string;
+ name: string;
+ type: string;
+ created_at: string;
+ updated_at: string;
+ file_extension: string;
+ mime_type: string;
+ mime_extension: string;
+ size: number;
+ parent_file_id: string;
+ thumbnail: string;
+ category: string;
+ image_media_metadata: ImageMediaMetadata;
+ punish_flag: number;
+}
+
+export interface ShareList {
+ items: FileDetail[];
+ next_marker: string;
+}
diff --git a/lib/routes/aliresearch/information.ts b/lib/routes/aliresearch/information.ts
new file mode 100644
index 00000000000000..2baf52a7c2d4e4
--- /dev/null
+++ b/lib/routes/aliresearch/information.ts
@@ -0,0 +1,86 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/information/:type?',
+ categories: ['new-media'],
+ example: '/aliresearch/information',
+ parameters: { type: '类型,见下表,默认为新闻' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['aliresearch.com/cn/information', 'aliresearch.com/'],
+ target: '/information',
+ },
+ ],
+ name: '资讯',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'aliresearch.com/cn/information',
+ description: `| 新闻 | 观点 | 案例 |
+| ---- | ---- | ---- |`,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type') ?? '新闻';
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
+
+ const rootUrl = 'http://www.aliresearch.com';
+ const currentUrl = `${rootUrl}/cn/information`;
+ const apiUrl = `${rootUrl}/ch/listArticle`;
+
+ const response = await got({
+ method: 'post',
+ url: apiUrl,
+ json: {
+ pageNo: 1,
+ pageSize: 10,
+ type,
+ },
+ });
+
+ let items = response.data.data.slice(0, limit).map((item) => ({
+ title: item.articleCode,
+ author: item.author,
+ pubDate: timezone(parseDate(item.gmtCreated), +8),
+ link: `${rootUrl}/ch/information/informationdetails?articleCode=${item.articleCode}`,
+ }));
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'post',
+ url: `${rootUrl}/ch/getArticle`,
+ json: {
+ articleCode: item.title,
+ },
+ });
+
+ const data = detailResponse.data.data;
+
+ item.title = data.title;
+ item.description = data.content;
+ item.category = data.special.split(',');
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `阿里研究院 - ${type}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/aliresearch/namespace.ts b/lib/routes/aliresearch/namespace.ts
new file mode 100644
index 00000000000000..249c83f7dda055
--- /dev/null
+++ b/lib/routes/aliresearch/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '阿里研究院',
+ url: 'aliresearch.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/alistapart/index.ts b/lib/routes/alistapart/index.ts
new file mode 100644
index 00000000000000..ded80ed7db76d5
--- /dev/null
+++ b/lib/routes/alistapart/index.ts
@@ -0,0 +1,37 @@
+import type { Route } from '@/types';
+
+import { getData, getList } from './utils';
+
+export const route: Route = {
+ path: '/',
+ categories: ['programming'],
+ radar: [
+ {
+ source: ['alistapart.com/articles/'],
+ target: '/',
+ },
+ ],
+ name: 'Home Feed',
+ maintainers: ['Rjnishant530'],
+ handler,
+ url: 'alistapart.com/articles/',
+ example: '/alistapart',
+};
+
+async function handler() {
+ const baseUrl = 'https://alistapart.com';
+ const route = '/wp-json/wp/v2/article?_embed';
+
+ const data = await getData(`${baseUrl}${route}`);
+ const items = await getList(data);
+
+ return {
+ title: 'A List Apart',
+ link: `${baseUrl}/articles`,
+ item: items,
+ description: 'Articles on aListApart.com',
+ logo: 'https://i0.wp.com/alistapart.com/wp-content/uploads/2019/03/cropped-icon_navigation-laurel-512.jpg?fit=192,192&ssl=1',
+ icon: 'https://i0.wp.com/alistapart.com/wp-content/uploads/2019/03/cropped-icon_navigation-laurel-512.jpg?fit=32,32&ssl=1',
+ language: 'en-us',
+ };
+}
diff --git a/lib/routes/alistapart/namespace.ts b/lib/routes/alistapart/namespace.ts
new file mode 100644
index 00000000000000..6d8f2730e318c4
--- /dev/null
+++ b/lib/routes/alistapart/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'A List Apart',
+ url: 'alistapart.com',
+ lang: 'en',
+};
diff --git a/lib/routes/alistapart/topic.ts b/lib/routes/alistapart/topic.ts
new file mode 100644
index 00000000000000..350a682532b9e3
--- /dev/null
+++ b/lib/routes/alistapart/topic.ts
@@ -0,0 +1,95 @@
+import type { Route } from '@/types';
+
+import { getData, getList } from './utils';
+
+export const route: Route = {
+ path: '/:topic',
+ categories: ['programming'],
+ example: '/alistapart/application-development',
+ parameters: { topic: 'Any Topic or from the table below. Defaults to All Articles' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['alistapart.com/blog/topic/:topic'],
+ target: '/:topic',
+ },
+ ],
+ name: 'Topics',
+ maintainers: ['Rjnishant530'],
+ handler,
+ url: 'alistapart.com/articles/',
+ description: `You have the option to utilize the main heading or use individual categories as topics for the path.
+
+| **Code** | *code* |
+| --------------------------- | ------------------------- |
+| **Application Development** | *application-development* |
+| **Browsers** | *browsers* |
+| **CSS** | *css* |
+| **HTML** | *html* |
+| **JavaScript** | *javascript* |
+| **The Server Side** | *the-server-side* |
+
+| **Content** | *content* |
+| -------------------- | ------------------ |
+| **Community** | *community* |
+| **Content Strategy** | *content-strategy* |
+| **Writing** | *writing* |
+
+| **Design** | *design* |
+| -------------------------- | ---------------------- |
+| **Brand Identity** | *brand-identity* |
+| **Graphic Design** | *graphic-design* |
+| **Layout & Grids** | *layout-grids* |
+| **Mobile/Multidevice** | *mobile-multidevice* |
+| **Responsive Design** | *responsive-design* |
+| **Typography & Web Fonts** | *typography-web-fonts* |
+
+| **Industry & Business** | *industry-business* |
+| ----------------------- | ------------------- |
+| **Business** | *business* |
+| **Career** | *career* |
+| **Industry** | *industry* |
+| **State of the Web** | *state-of-the-web* |
+
+| **Process** | *process* |
+| ---------------------- | -------------------- |
+| **Creativity** | *creativity* |
+| **Project Management** | *project-management* |
+| **Web Strategy** | *web-strategy* |
+| **Workflow & Tools** | *workflow-tools* |
+
+| **User Experience** | *user-experience* |
+| ---------------------------- | -------------------------- |
+| **Accessibility** | *accessibility* |
+| **Information Architecture** | *information-architecture* |
+| **Interaction Design** | *interaction-design* |
+| **Usability** | *usability* |
+| **User Research** | *user-research* |`,
+};
+
+async function handler(ctx) {
+ const baseUrl = 'https://alistapart.com';
+ const searchRoute = '/wp-json/wp/v2/categories?slug=';
+ const articleRoute = '/wp-json/wp/v2/article?categories=';
+ const topic = ctx.req.param('topic');
+ const id = (await getData(`${baseUrl}${searchRoute}${topic}`))[0]?.id;
+ const data = await getData(`${baseUrl}${articleRoute}${id}&_embed`);
+ const items = await getList(data);
+
+ return {
+ title: 'A List Apart',
+ link: `${baseUrl}/blog/topic/${topic}`,
+ item: items,
+ description: `${topic[0].toUpperCase() + topic.slice(1)} Articles on aListApart.com`,
+ logo: 'https://i0.wp.com/alistapart.com/wp-content/uploads/2019/03/cropped-icon_navigation-laurel-512.jpg?fit=192,192&ssl=1',
+ icon: 'https://i0.wp.com/alistapart.com/wp-content/uploads/2019/03/cropped-icon_navigation-laurel-512.jpg?fit=32,32&ssl=1',
+ language: 'en-us',
+ };
+}
diff --git a/lib/routes/alistapart/utils.ts b/lib/routes/alistapart/utils.ts
new file mode 100644
index 00000000000000..2d426e89b03ebf
--- /dev/null
+++ b/lib/routes/alistapart/utils.ts
@@ -0,0 +1,22 @@
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const getData = (url) => ofetch(url);
+
+const getList = (data) =>
+ data.map((value) => {
+ const { id, title, content, date_gmt, modified_gmt, link, _embedded } = value;
+ return {
+ id,
+ title: title.rendered,
+ description: content.rendered,
+ link,
+ category: _embedded['wp:term'][0].map((v) => v.name),
+ author: _embedded.author.map((v) => v.name).join(', '),
+ pubDate: timezone(parseDate(date_gmt), 0),
+ updated: timezone(parseDate(modified_gmt), 0),
+ };
+ });
+
+export { getData, getList };
diff --git a/lib/routes/aliyun/database-month.ts b/lib/routes/aliyun/database-month.ts
new file mode 100644
index 00000000000000..c005de1d56d0a4
--- /dev/null
+++ b/lib/routes/aliyun/database-month.ts
@@ -0,0 +1,67 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/database_month',
+ categories: ['programming'],
+ example: '/aliyun/database_month',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['mysql.taobao.org/monthly', 'mysql.taobao.org/'],
+ },
+ ],
+ name: '数据库内核月报',
+ maintainers: ['junbaor'],
+ handler,
+ url: 'mysql.taobao.org/monthly',
+};
+
+async function handler() {
+ const url = 'http://mysql.taobao.org/monthly/';
+ const response = await got({ method: 'get', url });
+ const $ = load(response.data);
+
+ const list = $("ul[class='posts'] > li")
+ .toArray()
+ .map((e) => {
+ const element = $(e);
+ const title = element.find('a').text().trim();
+ const link = `http://mysql.taobao.org${element.find('a').attr('href').trim()}/`;
+ return {
+ title,
+ description: '',
+ link,
+ };
+ });
+
+ const result = await Promise.all(
+ list.map((item) => {
+ const link = item.link;
+
+ return cache.tryGet(link, async () => {
+ const itemReponse = await got(link);
+ const itemElement = load(itemReponse.data);
+ item.description = itemElement('.content').html();
+ return item;
+ });
+ })
+ );
+
+ return {
+ title: $('title').text(),
+ link: url,
+ item: result.toReversed(),
+ };
+}
diff --git a/lib/routes/aliyun/developer/group.ts b/lib/routes/aliyun/developer/group.ts
new file mode 100644
index 00000000000000..60a01fbc9bdba3
--- /dev/null
+++ b/lib/routes/aliyun/developer/group.ts
@@ -0,0 +1,68 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/developer/group/:type',
+ categories: ['programming'],
+ example: '/aliyun/developer/group/alitech',
+ parameters: { type: '对应技术领域分类' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['developer.aliyun.com/group/:type'],
+ },
+ ],
+ name: '开发者社区 - 主题',
+ maintainers: ['umm233'],
+ handler,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type');
+ const link = `https://developer.aliyun.com/group/${type}`;
+
+ // 发起 HTTP GET 请求
+ const response = await got({
+ method: 'get',
+ url: link,
+ });
+
+ const data = response.data;
+
+ // 使用 cheerio 加载返回的 HTML
+ const $ = load(data);
+ const title = $('div[class="header-information-title"]')
+ .contents()
+ .filter((element) => element.nodeType === 3)
+ .text()
+ .trim();
+ const desc = $('div[class="header-information"]').find('span').last().text().trim();
+ const list = $('ul[class^="content-tab-list"] > li');
+
+ return {
+ title: `阿里云开发者社区-${title}`,
+ link,
+ description: desc,
+ item: list.toArray().map((item) => {
+ item = $(item);
+ const desc = item.find('.question-desc');
+ const description = item.find('.browse').text() + ' ' + desc.find('.answer').text();
+ return {
+ title: item.find('.question-title').text().trim() || item.find('a p').text().trim(),
+ link: item.find('a').attr('href'),
+ pubDate: parseDate(item.find('.time').text()),
+ description,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/aliyun/namespace.ts b/lib/routes/aliyun/namespace.ts
new file mode 100644
index 00000000000000..64771ea7a95dca
--- /dev/null
+++ b/lib/routes/aliyun/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '阿里云',
+ url: 'developer.aliyun.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aliyun/notice.ts b/lib/routes/aliyun/notice.ts
new file mode 100644
index 00000000000000..f86cb0b642eb9e
--- /dev/null
+++ b/lib/routes/aliyun/notice.ts
@@ -0,0 +1,84 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const typeMap = {
+ 0: '9004748',
+ 1: '9004749',
+ 2: '9213612',
+ 3: '8314815',
+ 4: '9222707',
+};
+
+/**
+ *
+ * @param ctx {import('koa').Context}
+ */
+export const route: Route = {
+ path: '/notice/:type?',
+ categories: ['programming'],
+ example: '/aliyun/notice',
+ parameters: { type: 'N' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '公告',
+ maintainers: ['muzea'],
+ handler,
+ description: `| 类型 | type |
+| -------- | ---- |
+| 全部 | |
+| 升级公告 | 1 |
+| 安全公告 | 2 |
+| 备案公告 | 3 |
+| 其他 | 4 |`,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type');
+ const url = `https://help.aliyun.com/noticelist/${typeMap[type] || typeMap[0]}.html`;
+ const response = await got({ method: 'get', url });
+ const $ = load(response.data);
+ const list = $('ul > li.notice-li')
+ .toArray()
+ .map((e) => {
+ const element = $(e);
+ const title = element.find('a').text().trim();
+ const link = 'https://help.aliyun.com' + element.find('a').attr('href').trim();
+ const date = element.find('.y-right').text();
+ const pubDate = timezone(parseDate(date), +8);
+ return {
+ title,
+ description: '',
+ link,
+ pubDate,
+ };
+ });
+
+ const result = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const itemReponse = await got(item.link);
+ const itemElement = load(itemReponse.data);
+ item.description = itemElement('#se-knowledge').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text().trim(),
+ link: url,
+ item: result,
+ };
+}
diff --git a/lib/routes/aljazeera/index.ts b/lib/routes/aljazeera/index.ts
new file mode 100644
index 00000000000000..40f93eb45b5938
--- /dev/null
+++ b/lib/routes/aljazeera/index.ts
@@ -0,0 +1,102 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
+import ofetch from '@/utils/ofetch';
+import { art } from '@/utils/render';
+
+const languages = {
+ arabic: {
+ rootUrl: 'https://www.aljazeera.net',
+ rssUrl: 'rss',
+ },
+ chinese: {
+ rootUrl: 'https://chinese.aljazeera.net',
+ rssUrl: undefined,
+ },
+ english: {
+ rootUrl: 'https://www.aljazeera.com',
+ rssUrl: 'xml/rss/all.xml',
+ },
+};
+
+export const route: Route = {
+ path: '*',
+ name: 'Unknown',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const params = getSubPath(ctx) === '/' ? ['arabic'] : getSubPath(ctx).replace(/^\//, '').split('/');
+
+ if (!Object.hasOwn(languages, params[0])) {
+ params.unshift('arabic');
+ }
+
+ const language = params.shift();
+ const isRSS = params.length === 1 && params.at(-1) === 'rss' && languages[language].rssUrl;
+
+ const rootUrl = languages[language].rootUrl;
+ const currentUrl = `${rootUrl}/${isRSS ? languages[language].rssUrl : params.join('/')}`;
+
+ const response = await ofetch(currentUrl);
+ const $ = load(response);
+
+ let items = isRSS
+ ? response.data.match(new RegExp(' ' + rootUrl + '/(.*?)', 'g')).map((item) => ({
+ link: item.match(/ (.*?)<\/link>/)[1],
+ }))
+ : $('.u-clickable-card__link')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ link: `${rootUrl}${item.attr('href')}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50).map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await ofetch(item.link);
+
+ const content = load(detailResponse);
+
+ content('.more-on').parent().remove();
+ content('.responsive-image img').removeAttr('srcset');
+ let pubDate;
+
+ const datePublished = detailResponse.match(/"datePublished": ?"(.*?)",/);
+ if (datePublished && datePublished.length > 1) {
+ pubDate = detailResponse.match(/"datePublished": ?"(.*?)",/)[1];
+ } else {
+ // uploadDate replaces datePublished for video articles
+ const uploadDate = detailResponse.match(/"uploadDate": ?"(.*?)",/)[1];
+
+ pubDate = uploadDate && uploadDate.length > 1 ? uploadDate : content('div.date-simple > span:nth-child(2)').text();
+ }
+
+ item.title = content('h1').first().text();
+ item.author = content('.author').text();
+ item.pubDate = pubDate;
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ image: content('.article-featured-image').html(),
+ description: content('.wysiwyg').html(),
+ });
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').first().text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/aljazeera/namespace.ts b/lib/routes/aljazeera/namespace.ts
new file mode 100644
index 00000000000000..097358395a4cae
--- /dev/null
+++ b/lib/routes/aljazeera/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Aljazeera',
+ url: 'aljazeera.com',
+ lang: 'en',
+};
diff --git a/lib/v2/aljazeera/templates/description.art b/lib/routes/aljazeera/templates/description.art
similarity index 100%
rename from lib/v2/aljazeera/templates/description.art
rename to lib/routes/aljazeera/templates/description.art
diff --git a/lib/routes/ally/namespace.ts b/lib/routes/ally/namespace.ts
new file mode 100644
index 00000000000000..888ca1c2611130
--- /dev/null
+++ b/lib/routes/ally/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '艾莱资讯',
+ url: 'rail.ally.net.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/ally/rail.ts b/lib/routes/ally/rail.ts
new file mode 100644
index 00000000000000..412d8bb9c4228d
--- /dev/null
+++ b/lib/routes/ally/rail.ts
@@ -0,0 +1,144 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/rail/:category?/:topic?',
+ categories: ['new-media'],
+ example: '/ally/rail/hyzix/chengguijiaotong',
+ parameters: { category: '分类,可在 URL 中找到;略去则抓取首页', topic: '话题,可在 URL 中找到;并非所有页面均有此字段' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['rail.ally.net.cn/', 'rail.ally.net.cn/html/:category?/:topic?'],
+ },
+ ],
+ name: '世界轨道交通资讯网',
+ maintainers: ['Rongronggg9'],
+ handler,
+ url: 'rail.ally.net.cn/',
+ description: `::: tip
+ 默认抓取前 20 条,可通过 \`?limit=\` 改变。
+:::`,
+};
+
+async function handler(ctx) {
+ // http://rail.ally.net.cn/sitemap.html
+ const { category, topic } = ctx.req.param();
+ const rootUrl = 'http://rail.ally.net.cn';
+ const pageUrl = category ? (topic ? `${rootUrl}/html/${category}/${topic}/` : `${rootUrl}/html/${category}/`) : rootUrl;
+
+ const response = await got.get(pageUrl);
+ const $ = load(response.data);
+ let title = '';
+ const titleLinks = $('.container .regsiter a').toArray().slice(1); // what a typo... drop "首页"
+ for (const link of titleLinks) {
+ const linkText = $(link).text();
+ title = title ? `${title} - ${linkText}` : linkText;
+ }
+ title = title || (category && topic ? `${category} - ${topic}` : category) || '首页';
+ let links = [
+ // list page: http://rail.ally.net.cn/html/lujuzixun/
+ $('.left .hynewsO h2 a').toArray(),
+ // multi-sub-topic page: http://rail.ally.net.cn/html/hyzix/
+ $('.left .list_content_c').find('.new_hy_focus_con_tit a, .new_hy_list_name a').toArray(),
+ // multi-sub-topic page 2: http://rail.ally.net.cn/html/foster/
+ $('.left').find('.nnewslistpic a, .nnewslistinfo dd a').toArray(),
+ // data list page: http://rail.ally.net.cn/html/tongjigongbao/
+ $('.left .list_con .datacountTit a').toArray(),
+ // home page: http://rail.ally.net.cn
+ $('.container_left').find('dd a, h1 a, ul.slideshow li a').toArray(),
+ ].flat();
+ if (!links.length) {
+ // try aggressively sniffing links, e.g. http://rail.ally.net.cn/html/InviteTen/
+ links = $('.left a, .container_left a').toArray();
+ }
+
+ let items = links
+ .map((link) => {
+ link = $(link);
+ const url = link.attr('href');
+ const urlMatch = url && url.match(/\/html\/(\d{4})\/\w+_(\d{4})\/\d+\.html/);
+ if (!urlMatch) {
+ return null;
+ }
+ const title = link.text();
+ return {
+ title,
+ link: url.startsWith('/') ? `${rootUrl}${url}` : url,
+ pubDate: timezone(parseDate(`${urlMatch[1]}${urlMatch[2]}`), 8),
+ };
+ })
+ .filter(Boolean);
+ const uniqueItems: DataItem[] = [];
+ for (const item of items) {
+ if (!uniqueItems.some((uniqueItem) => uniqueItem.link === item?.link)) {
+ uniqueItems.push(item!);
+ }
+ }
+ items = uniqueItems.toSorted((a, b) => b.pubDate - a.pubDate).slice(0, ctx.req.query('limit') || 20);
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+ // fix weird format
+ let description = '';
+ const content = $('div.content_all');
+ if (content.length) {
+ content
+ .eq(content.length - 1) // some pages have "summary"
+ .contents()
+ .each((_, child) => {
+ const $child = $(child);
+ let innerHtml;
+ if (child.name === 'div') {
+ innerHtml = $child.html();
+ innerHtml = innerHtml && innerHtml.trim();
+ description += !innerHtml || innerHtml === ' ' ? (description ? ' ' : '') : innerHtml;
+ } else {
+ // bare text node or something else
+ description += $child.toString().trim();
+ }
+ });
+ } else {
+ // http://rail.ally.net.cn/html/2022/InviteTen_0407/4686.html
+ description = $('div.content div').first().html();
+ }
+
+ description = description.replace(/\s* \s*$/, ''); // trim at the end
+ const info = $('.content > em span');
+ return {
+ title: $('.content > h2').text() || item.title,
+ description,
+ // pubDate: timezone(parseDate(info.eq(0).text()), 8),
+ pubDate: item.pubDate,
+ author: info
+ .eq(1)
+ .text()
+ .replace(/^来源:/, ''),
+ link: item.link,
+ };
+ })
+ )
+ );
+
+ return {
+ title: `世界轨道交通资讯网 - ${title}`,
+ link: pageUrl,
+ item: items,
+ description: $('head > meta[name="description"]').attr('content'),
+ };
+}
diff --git a/lib/routes/alpinelinux/namespace.ts b/lib/routes/alpinelinux/namespace.ts
new file mode 100644
index 00000000000000..be620e436b7586
--- /dev/null
+++ b/lib/routes/alpinelinux/namespace.ts
@@ -0,0 +1,12 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Alpine Linux',
+ url: 'alpinelinux.org',
+ description: 'Alpine Linux is a security-oriented, lightweight Linux distribution based on musl libc and busybox.',
+ zh: {
+ name: 'Alpine Linux',
+ description: 'Alpine Linux 是一个基于 musl libc 和 busybox 的面向安全的轻量级 Linux 发行版。',
+ },
+ lang: 'en',
+};
diff --git a/lib/routes/alpinelinux/pkgs.ts b/lib/routes/alpinelinux/pkgs.ts
new file mode 100644
index 00000000000000..bcaf1c4aa0a955
--- /dev/null
+++ b/lib/routes/alpinelinux/pkgs.ts
@@ -0,0 +1,105 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import { config } from '@/config';
+import type { Data, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ name: 'Packages',
+ categories: ['program-update'],
+ maintainers: ['CaoMeiYouRen'],
+ path: '/pkgs/:name/:routeParams?',
+ parameters: { name: 'Packages name', routeParams: 'Filters of packages type. E.g. branch=edge&repo=main&arch=armv7&maintainer=Jakub%20Jirutka' },
+ example: '/alpinelinux/pkgs/nodejs',
+ description: `Alpine Linux packages update`,
+ handler,
+ radar: [
+ {
+ source: ['https://pkgs.alpinelinux.org/packages'],
+ target: (params, url) => {
+ const searchParams = new URL(url).searchParams;
+ const name = searchParams.get('name');
+ searchParams.delete('name');
+ const routeParams = searchParams.toString();
+ return `/alpinelinux/pkgs/${name}/${routeParams}`;
+ },
+ },
+ ],
+ zh: {
+ name: '软件包',
+ description: 'Alpine Linux 软件包更新',
+ },
+};
+
+type RowData = {
+ package: string;
+ packageUrl?: string;
+ version: string;
+ description?: string;
+ project?: string;
+ license: string;
+ branch: string;
+ repository: string;
+ architecture: string;
+ maintainer: string;
+ buildDate: string;
+};
+
+function parseTableToJSON(tableHTML: string) {
+ const $ = load(tableHTML);
+ const data: RowData[] = $('tbody tr')
+ .toArray()
+ .map((row) => ({
+ package: $(row).find('.package a').text().trim(),
+ packageUrl: $(row).find('.package a').attr('href')?.trim(),
+ description: $(row).find('.package a').attr('aria-label')?.trim(),
+ version: $(row).find('.version').text().trim(),
+ project: $(row).find('.url a').attr('href')?.trim(),
+ license: $(row).find('.license').text().trim(),
+ branch: $(row).find('.branch').text().trim(),
+ repository: $(row).find('.repo a').text().trim(),
+ architecture: $(row).find('.arch a').text().trim(),
+ maintainer: $(row).find('.maintainer a').text().trim(),
+ buildDate: $(row).find('.bdate').text().trim(),
+ }));
+
+ return data;
+}
+
+async function handler(ctx: Context): Promise {
+ const { name, routeParams } = ctx.req.param();
+ const query = new URLSearchParams(routeParams);
+ query.append('name', name);
+ const link = `https://pkgs.alpinelinux.org/packages?${query.toString()}`;
+ const key = `alpinelinux:packages:${query.toString()}`;
+ const rowData = (await cache.tryGet(
+ key,
+ async () => {
+ const response = await got({
+ url: link,
+ });
+ const html = response.data;
+ return parseTableToJSON(html);
+ },
+ config.cache.routeExpire,
+ false
+ )) as RowData[];
+
+ const items = rowData.map((e) => ({
+ title: `${e.package}@${e.version}/${e.architecture}`,
+ description: `Version: ${e.version} Project: ${e.project} Description: ${e.description} License: ${e.license} Branch: ${e.branch} Repository: ${e.repository} Maintainer: ${e.maintainer} Build Date: ${e.buildDate}`,
+ link: `https://pkgs.alpinelinux.org${e.packageUrl}`,
+ guid: `https://pkgs.alpinelinux.org${e.packageUrl}#${e.version}`,
+ author: e.maintainer,
+ pubDate: parseDate(e.buildDate),
+ }));
+ return {
+ title: `${name} - Alpine Linux packages`,
+ link,
+ description: 'Alpine Linux packages update',
+ item: items,
+ };
+}
diff --git a/lib/routes/alternativeto/namespace.ts b/lib/routes/alternativeto/namespace.ts
new file mode 100644
index 00000000000000..6f24e15533beac
--- /dev/null
+++ b/lib/routes/alternativeto/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AlternativeTo',
+ url: 'www.alternativeto.net',
+ lang: 'en',
+};
diff --git a/lib/routes/alternativeto/platform.ts b/lib/routes/alternativeto/platform.ts
new file mode 100644
index 00000000000000..8d3e5d1dfb06df
--- /dev/null
+++ b/lib/routes/alternativeto/platform.ts
@@ -0,0 +1,61 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+
+import { baseURL, puppeteerGet } from './utils';
+
+export const route: Route = {
+ path: '/platform/:name/:routeParams?',
+ categories: ['programming'],
+ example: '/alternativeto/platform/firefox',
+ parameters: { name: 'Platform name', routeParams: 'Filters of software type' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: true,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.alternativeto.net/platform/:name'],
+ target: '/platform/:name',
+ },
+ ],
+ name: 'Platform Software',
+ maintainers: ['JimenezLi'],
+ handler,
+ description: `> routeParms can be copied from original site URL, example: \`/alternativeto/platform/firefox/license=free\``,
+};
+
+async function handler(ctx) {
+ const name = ctx.req.param('name');
+ const query = new URLSearchParams(ctx.req.param('routeParams'));
+ const link = `https://alternativeto.net/platform/${name}/?${query.toString()}`;
+
+ // use Puppeteer due to the obstacle by cloudflare challenge
+ const html = await puppeteerGet(link, cache);
+ const $ = load(html);
+
+ return {
+ title: $('.Heading_h1___Cf5Y').text().trim(),
+ description: $('.intro-text').text().trim(),
+ link,
+ item: $('.AppListItem_appInfo__h9cWP')
+ .toArray()
+ .map((element) => {
+ const item = $(element);
+ const title = item.find('.Heading_h2___LwQD').text().trim();
+ const link = `${baseURL}${item.find('.Heading_h2___LwQD a').attr('href')}`;
+ const description = item.find('.AppListItem_description__wtODK').text().trim();
+
+ return {
+ title,
+ link,
+ description,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/alternativeto/software.ts b/lib/routes/alternativeto/software.ts
new file mode 100644
index 00000000000000..8a23ff78b97f32
--- /dev/null
+++ b/lib/routes/alternativeto/software.ts
@@ -0,0 +1,61 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+
+import { baseURL, puppeteerGet } from './utils';
+
+export const route: Route = {
+ path: '/software/:name/:routeParams?',
+ categories: ['programming'],
+ example: '/alternativeto/software/cpp',
+ parameters: { name: 'Software name', routeParams: 'Filters of software type' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: true,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.alternativeto.net/software/:name'],
+ target: '/software/:name',
+ },
+ ],
+ name: 'Software Alternatives',
+ maintainers: ['JimenezLi'],
+ handler,
+ description: `> routeParms can be copied from original site URL, example: \`/alternativeto/software/cpp/license=opensource&platform=windows\``,
+};
+
+async function handler(ctx) {
+ const name = ctx.req.param('name');
+ const query = new URLSearchParams(ctx.req.param('routeParams'));
+ const link = `https://alternativeto.net/software/${name}/?${query.toString()}`;
+
+ // use Puppeteer due to the obstacle by cloudflare challenge
+ const html = await puppeteerGet(link, cache);
+ const $ = load(html);
+
+ return {
+ title: $('.Heading_h1___Cf5Y').text().trim(),
+ description: $('.intro-text').text().trim(),
+ link,
+ item: $('.AppListItem_appInfo__h9cWP')
+ .toArray()
+ .map((element) => {
+ const item = $(element);
+ const title = item.find('.Heading_h2___LwQD').text().trim();
+ const link = `${baseURL}${item.find('.Heading_h2___LwQD a').attr('href')}`;
+ const description = item.find('.AppListItem_description__wtODK').text().trim();
+
+ return {
+ title,
+ link,
+ description,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/alternativeto/utils.ts b/lib/routes/alternativeto/utils.ts
new file mode 100644
index 00000000000000..c53c34b8afe6f0
--- /dev/null
+++ b/lib/routes/alternativeto/utils.ts
@@ -0,0 +1,21 @@
+import puppeteer from '@/utils/puppeteer';
+
+const baseURL = 'https://alternativeto.net';
+
+const puppeteerGet = (url, cache) =>
+ cache.tryGet(url, async () => {
+ const browser = await puppeteer();
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' ? request.continue() : request.abort();
+ });
+ await page.goto(url, {
+ waitUntil: 'domcontentloaded',
+ });
+ const html = await page.evaluate(() => document.documentElement.innerHTML);
+ await browser.close();
+ return html;
+ });
+
+export { baseURL, puppeteerGet };
diff --git a/lib/routes/altotrain/namespace.ts b/lib/routes/altotrain/namespace.ts
new file mode 100644
index 00000000000000..a4d0888e217932
--- /dev/null
+++ b/lib/routes/altotrain/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Alto - Toronto-Québec City High-Speed Rail Network',
+ url: 'altotrain.ca',
+ lang: 'en',
+};
diff --git a/lib/routes/altotrain/news.ts b/lib/routes/altotrain/news.ts
new file mode 100644
index 00000000000000..f541b8ed2bb3f1
--- /dev/null
+++ b/lib/routes/altotrain/news.ts
@@ -0,0 +1,93 @@
+import 'dayjs/locale/fr.js';
+
+import type { Cheerio } from 'cheerio';
+import { load } from 'cheerio';
+import dayjs from 'dayjs';
+import localizedFormat from 'dayjs/plugin/localizedFormat.js';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+dayjs.extend(localizedFormat);
+
+export const route: Route = {
+ path: '/:language?',
+ categories: ['travel'],
+ example: '/altotrain/en',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['altotrain.ca/:language', 'altotrain.ca/:language/news', 'altotrain.ca/:language/nouvelles'],
+ target: '/:language',
+ },
+ ],
+ name: 'Alto News',
+ maintainers: ['elibroftw'],
+ handler: async (ctx: Context): Promise => {
+ const { language = 'en' } = ctx.req.param();
+ const link = language === 'fr' ? 'https://www.altotrain.ca/fr/nouvelles' : 'https://www.altotrain.ca/en/news';
+ const response = await ofetch(link);
+
+ const $ = load(response);
+
+ const featuredPost = $('body > div:first-of-type > main > div:nth-of-type(2) > div:nth-of-type(2) > div > div:first-of-type > div > a').first();
+ const featuredItems: DataItem[] = featuredPost.length
+ ? (() => {
+ const featuredItem = extractItem(featuredPost, language);
+ return [featuredItem];
+ })()
+ : [];
+
+ const posts = $('.tw-grid > div.tw-flex.tw-flex-col')
+ .toArray()
+ .map((el) => {
+ const a = $(el).find('a').first();
+ return extractItem(a, language);
+ });
+
+ return {
+ title: 'Alto News',
+ link,
+ item: [...featuredItems, ...posts],
+ };
+ },
+};
+
+function extractItem(a: Cheerio, language: string) {
+ const href = a.attr('href');
+
+ const titleEl = a.find('h2, h3').first();
+ const title = titleEl.text().trim();
+
+ const descEl = a.find('p').first();
+ const description = descEl.text().trim();
+
+ const dateMatch = language === 'fr' ? description.match(/(\d{1,2} [a-zéû]+[.]? \d{4})/i) : description.match(/([A-Z][a-z]+[.]? \d{1,2}, \d{4})/);
+
+ const pubDateStr = dateMatch ? dateMatch[1].trim() : '';
+ const pubDate = parseDate(pubDateStr);
+
+ const imgEl = a.find('img').first();
+ const src = imgEl.attr('src');
+ const image = src ? new URL(src, 'https://www.altotrain.ca').href : undefined;
+
+ return {
+ title,
+ link: href!,
+ pubDate,
+ author: 'Alto',
+ category: ['News'],
+ description,
+ id: href!,
+ image,
+ };
+}
diff --git a/lib/routes/amazfitwatchfaces/index.ts b/lib/routes/amazfitwatchfaces/index.ts
new file mode 100644
index 00000000000000..dd64d326b9741c
--- /dev/null
+++ b/lib/routes/amazfitwatchfaces/index.ts
@@ -0,0 +1,431 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const { device, sort, searchParams } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'https://amazfitwatchfaces.com';
+ const targetUrl: string = new URL(`${device}/${sort}${searchParams ? `?${searchParams}` : ''}`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+
+ let items: DataItem[] = [];
+
+ items = $('div.wf-panel')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.prop('title');
+ const image: string | undefined = $el.find('img.wf-img').attr('src');
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ });
+ const linkUrl: string | undefined = $el.find('a.wf-act').attr('href');
+ const categoryEls: Element[] = $el.find('div.wf-comp code').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $(el).text()).filter(Boolean))];
+ const authorEls: Element[] = $el.find('div.wf-user a').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $authorEl: Cheerio = $(authorEl);
+
+ return {
+ name: $authorEl.text(),
+ url: $authorEl.attr('href') ? new URL($authorEl.attr('href') as string, baseUrl).href : undefined,
+ avatar: undefined,
+ };
+ });
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.page-title h1').text();
+ const image: string | undefined = $$('img#watchface-preview').attr('src');
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: $$('div.unicodebidi').html(),
+ });
+ const pubDateStr: string | undefined = $$('i.fa-calendar').parent().find('span').text();
+ const linkUrl: string | undefined = $$('.title').attr('href');
+ const categoryEls: Element[] = $$('div.mdesc a.btn-sm').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).text()).filter(Boolean))];
+ const authorEls: Element[] = $$('div.wf-userinfo-name').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl).find('a.wf-author-h');
+
+ return {
+ name: $$authorEl.text(),
+ url: $$authorEl.attr('href') ? new URL($$authorEl.attr('href') as string, baseUrl).href : undefined,
+ avatar: $$authorEl.find('img.wf-userpic').attr('src'),
+ };
+ });
+ const upDatedStr: string | undefined = $$('i.fa-clock-o').parent().find('span').text();
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, 'DD.MM.YYYY HH:mm') : item.pubDate,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : item.link,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr, 'DD.MM.YYYY HH:mm') : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.mainlogolg').attr('src') ? new URL($('img.mainlogolg').attr('src') as string, baseUrl).href : undefined,
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:device/:sort/:searchParams?',
+ name: 'Watch Faces',
+ url: 'amazfitwatchfaces.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/amazfitwatchfaces/amazfit-x/fresh',
+ parameters: {
+ device: {
+ description: 'Device Id',
+ options: [
+ {
+ label: 'Amazfit X',
+ value: 'amazfit-x',
+ },
+ {
+ label: 'Amazfit Band',
+ value: 'amazfit-band',
+ },
+ {
+ label: 'Amazfit Bip',
+ value: 'bip',
+ },
+ {
+ label: 'Amazfit Active',
+ value: 'active',
+ },
+ {
+ label: 'Amazfit Balance',
+ value: 'balance',
+ },
+ {
+ label: 'Amazfit Cheetah',
+ value: 'cheetah',
+ },
+ {
+ label: 'Amazfit Falcon',
+ value: 'falcon',
+ },
+ {
+ label: 'Amazfit GTR',
+ value: 'gtr',
+ },
+ {
+ label: 'Amazfit GTS',
+ value: 'gts',
+ },
+ {
+ label: 'Amazfit T-Rex',
+ value: 't-rex',
+ },
+ {
+ label: 'Amazfit Stratos',
+ value: 'pace',
+ },
+ {
+ label: 'Amazfit Verge Lite',
+ value: 'verge-lite',
+ },
+ {
+ label: 'Haylou Watches',
+ value: 'haylou',
+ },
+ {
+ label: 'Huawei Watches',
+ value: 'huawei-watch-gt',
+ },
+ {
+ label: 'Xiaomi Mi Band 4',
+ value: 'mi-band-4',
+ },
+ {
+ label: 'Xiaomi Mi Band 5',
+ value: 'mi-band-5',
+ },
+ {
+ label: 'Xiaomi Mi Band 6',
+ value: 'mi-band-6',
+ },
+ {
+ label: 'Xiaomi Mi Band 7',
+ value: 'mi-band-7',
+ },
+ {
+ label: 'Xiaomi Smart Band 8',
+ value: 'mi-band',
+ },
+ {
+ label: 'Xiaomi Smart Band 9',
+ value: 'mi-band',
+ },
+ ],
+ },
+ sort: {
+ description: 'Sort By',
+ options: [
+ {
+ label: 'Fresh',
+ value: 'fresh',
+ },
+ {
+ label: 'Updated',
+ value: 'updated',
+ },
+ {
+ label: 'Random',
+ value: 'random',
+ },
+ {
+ label: 'Top',
+ value: 'top',
+ },
+ ],
+ },
+ searchParams: {
+ description: 'Search Params',
+ },
+ },
+ description: `::: tip
+If you subscribe to [Updated watch faces for Amazfit X](https://amazfitwatchfaces.com/amazfit-x/updated),where the URL is \`https://amazfitwatchfaces.com/amazfit-x/updated\`, extract the part \`https://amazfitwatchfaces.com/\` to the end, which is \`amazfit-x/updated\`, and use it as the parameter to fill in. Therefore, the route will be [\`/amazfitwatchfaces/amazfit-x/updated\`](https://rsshub.app/amazfitwatchfaces/amazfit-x/updated).
+
+If you subscribe to [TOP for the last 6 months (Only new) - Xiaomi Smart Band 9](https://amazfitwatchfaces.com/mi-band/top?compatible=Smart_Band_9&topof=6months),where the URL is \`https://amazfitwatchfaces.com/mi-band/top?compatible=Smart_Band_9&topof=6months\`, extract the part \`https://amazfitwatchfaces.com/\` to the end, which is \`mi-band/top\`, and use it as the parameter to fill in. Therefore, the route will be [\`/amazfitwatchfaces/mi-band/top/compatible=Smart_Band_9&topof=6months\`](https://rsshub.app/amazfitwatchfaces/mi-band/top/compatible=Smart_Band_9&topof=6months).
+:::
+
+
+ More devices
+
+| Device Name | Device Id |
+| ------------------------------------------------------------------------------------------ | --------------- |
+| [Amazfit X](https://amazfitwatchfaces.com/amazfit-x/fresh) | [amazfit-x](https://rsshub.app/amazfitwatchfaces/amazfit-x/fresh) |
+| [Amazfit Band](https://amazfitwatchfaces.com/amazfit-band/fresh) | [amazfit-band](https://rsshub.app/amazfitwatchfaces/amazfit-band/fresh) |
+| [Amazfit Bip](https://amazfitwatchfaces.com/bip/fresh) | [bip](https://rsshub.app/amazfitwatchfaces/bip/fresh) |
+| [Amazfit Active](https://amazfitwatchfaces.com/active/fresh) | [active](https://rsshub.app/amazfitwatchfaces/active/fresh) |
+| [Amazfit Balance](https://amazfitwatchfaces.com/balance/fresh) | [balance](https://rsshub.app/amazfitwatchfaces/balance/fresh) |
+| [Amazfit Cheetah](https://amazfitwatchfaces.com/cheetah/fresh) | [cheetah](https://rsshub.app/amazfitwatchfaces/cheetah/fresh) |
+| [Amazfit Falcon](https://amazfitwatchfaces.com/falcon/fresh) | [falcon](https://rsshub.app/amazfitwatchfaces/falcon/fresh) |
+| [Amazfit GTR](https://amazfitwatchfaces.com/gtr/fresh) | [gtr](https://rsshub.app/amazfitwatchfaces/gtr/fresh) |
+| [Amazfit GTS](https://amazfitwatchfaces.com/gts/fresh) | [gts](https://rsshub.app/amazfitwatchfaces/gts/fresh) |
+| [Amazfit T-Rex](https://amazfitwatchfaces.com/t-rex/fresh) | [t-rex](https://rsshub.app/amazfitwatchfaces/t-rex/fresh) |
+| [Amazfit Stratos](https://amazfitwatchfaces.com/pace/fresh) | [pace](https://rsshub.app/amazfitwatchfaces/pace/fresh) |
+| [Amazfit Verge Lite](https://amazfitwatchfaces.com/verge-lite/fresh) | [verge-lite](https://rsshub.app/amazfitwatchfaces/verge-lite/fresh) |
+| [Haylou Watches](https://amazfitwatchfaces.com/haylou/fresh) | [haylou](https://rsshub.app/amazfitwatchfaces/haylou/fresh) |
+| [Huawei Watches](https://amazfitwatchfaces.com/huawei-watch-gt/fresh) | [huawei-watch-gt](https://rsshub.app/amazfitwatchfaces/huawei-watch-gt/fresh) |
+| [Xiaomi Mi Band 4](https://amazfitwatchfaces.com/mi-band-4/fresh) | [mi-band-4](https://rsshub.app/amazfitwatchfaces/mi-band-4/fresh) |
+| [Xiaomi Mi Band 5](https://amazfitwatchfaces.com/mi-band-5/fresh) | [mi-band-5](https://rsshub.app/amazfitwatchfaces/mi-band-5/fresh) |
+| [Xiaomi Mi Band 6](https://amazfitwatchfaces.com/mi-band-6/fresh) | [mi-band-6](https://rsshub.app/amazfitwatchfaces/mi-band-6/fresh) |
+| [Xiaomi Mi Band 7](https://amazfitwatchfaces.com/mi-band-7/fresh) | [mi-band-7](https://rsshub.app/amazfitwatchfaces/mi-band-7/fresh) |
+| [Xiaomi Smart Band 8](https://amazfitwatchfaces.com/mi-band/fresh?compatible=Smart_Band_8) | [mi-band](https://rsshub.app/amazfitwatchfaces/mi-band/fresh/compatible=Smart_Band_8) |
+| [Xiaomi Smart Band 9](https://amazfitwatchfaces.com/mi-band/fresh?compatible=Smart_Band_9) | [mi-band](https://rsshub.app/amazfitwatchfaces/mi-band/fresh/compatible=Smart_Band_9) |
+
+
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['amazfitwatchfaces.com/:device/:sort'],
+ target: (params) => {
+ const device: string = params.device;
+ const sort: string = params.sort;
+
+ return `/amazfitwatchfaces${device ? `/${device}${sort ? `/${sort}` : ''}` : ''}`;
+ },
+ },
+ {
+ title: 'Fresh watch faces for Amazfit X',
+ source: ['amazfitwatchfaces.com/amazfit-x/fresh'],
+ target: '/amazfit-x/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Band',
+ source: ['amazfitwatchfaces.com/amazfit-band/fresh'],
+ target: '/amazfit-band/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Bip',
+ source: ['amazfitwatchfaces.com/bip/fresh'],
+ target: '/bip/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Active',
+ source: ['amazfitwatchfaces.com/active/fresh'],
+ target: '/active/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Balance',
+ source: ['amazfitwatchfaces.com/balance/fresh'],
+ target: '/balance/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Cheetah',
+ source: ['amazfitwatchfaces.com/cheetah/fresh'],
+ target: '/cheetah/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Falcon',
+ source: ['amazfitwatchfaces.com/falcon/fresh'],
+ target: '/falcon/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit GTR',
+ source: ['amazfitwatchfaces.com/gtr/fresh'],
+ target: '/gtr/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit GTS',
+ source: ['amazfitwatchfaces.com/gts/fresh'],
+ target: '/gts/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit T-Rex',
+ source: ['amazfitwatchfaces.com/t-rex/fresh'],
+ target: '/t-rex/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Stratos',
+ source: ['amazfitwatchfaces.com/pace/fresh'],
+ target: '/pace/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Verge Lite',
+ source: ['amazfitwatchfaces.com/verge-lite/fresh'],
+ target: '/verge-lite/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Haylou Watches',
+ source: ['amazfitwatchfaces.com/haylou/fresh'],
+ target: '/haylou/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Huawei Watches',
+ source: ['amazfitwatchfaces.com/huawei-watch-gt/fresh'],
+ target: '/huawei-watch-gt/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Mi Band 4',
+ source: ['amazfitwatchfaces.com/mi-band-4/fresh'],
+ target: '/mi-band-4/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Mi Band 5',
+ source: ['amazfitwatchfaces.com/mi-band-5/fresh'],
+ target: '/mi-band-5/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Mi Band 6',
+ source: ['amazfitwatchfaces.com/mi-band-6/fresh'],
+ target: '/mi-band-6/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Mi Band 7',
+ source: ['amazfitwatchfaces.com/mi-band-7/fresh'],
+ target: '/mi-band-7/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Smart Band 8',
+ source: ['amazfitwatchfaces.com/mi-band/fresh'],
+ target: '/mi-band/fresh/compatible=Smart_Band_8',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Smart Band 9',
+ source: ['amazfitwatchfaces.com/mi-band/fresh'],
+ target: '/mi-band/fresh/compatible=Smart_Band_9',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/amazfitwatchfaces/namespace.ts b/lib/routes/amazfitwatchfaces/namespace.ts
new file mode 100644
index 00000000000000..13600d63164b4f
--- /dev/null
+++ b/lib/routes/amazfitwatchfaces/namespace.ts
@@ -0,0 +1,10 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Amazfitwatchfaces',
+ url: 'amazfitwatchfaces.com',
+ categories: ['program-update'],
+ description:
+ "amazfitwatchfaces.com is the world's largest collection of watch faces for Amazfit, Zepp, Bip, Pace, Stratos, Cor, Verge, Verge Lite, GTR, GTS, T-Rex, watches. Here you can find everything you need to customize & personalize your device! The website also has catalogs of watch faces for Xiaomi, Haylou, Honor and Huawei watches.",
+ lang: 'en',
+};
diff --git a/lib/routes/amazfitwatchfaces/search.js b/lib/routes/amazfitwatchfaces/search.js
deleted file mode 100644
index a37ad328c20d15..00000000000000
--- a/lib/routes/amazfitwatchfaces/search.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const utils = require('./utils');
-
-module.exports = async (ctx) => {
- const currentUrl = `search/${ctx.params.model}/tags/${ctx.params.keyword ? ctx.params.keyword : ''}${ctx.params.sortBy ? '?sortby=' + ctx.params.sortBy : ''}`;
-
- ctx.state.data = await utils(ctx, currentUrl);
-};
diff --git a/lib/routes/amazfitwatchfaces/templates/description.art b/lib/routes/amazfitwatchfaces/templates/description.art
new file mode 100644
index 00000000000000..dfab19230c1108
--- /dev/null
+++ b/lib/routes/amazfitwatchfaces/templates/description.art
@@ -0,0 +1,17 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/amazon/awsblogs.ts b/lib/routes/amazon/awsblogs.ts
new file mode 100644
index 00000000000000..c9257551569fe6
--- /dev/null
+++ b/lib/routes/amazon/awsblogs.ts
@@ -0,0 +1,35 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/awsblogs/:locale?',
+ name: 'Unknown',
+ maintainers: ['HankChow'],
+ handler,
+};
+
+async function handler(ctx) {
+ const locale = ctx.req.param('locale') ?? 'zh_CN';
+
+ const response = await got({
+ url: `https://aws.amazon.com/api/dirs/items/search?item.directoryId=blog-posts&sort_by=item.additionalFields.createdDate&sort_order=desc&size=50&item.locale=${locale}`,
+ });
+
+ const items = response.data.items;
+
+ return {
+ title: 'AWS Blog',
+ link: 'https://aws.amazon.com/blogs/',
+ description: 'AWS Blog 更新',
+ item:
+ items &&
+ items.map((item) => ({
+ title: item.item.additionalFields.title,
+ description: item.item.additionalFields.postExcerpt,
+ pubDate: parseDate(item.item.dateCreated),
+ link: item.item.additionalFields.link,
+ author: item.item.additionalFields.contributors,
+ })),
+ };
+}
diff --git a/lib/routes/amazon/kindle-software-updates.ts b/lib/routes/amazon/kindle-software-updates.ts
new file mode 100644
index 00000000000000..4af10e1fb641e1
--- /dev/null
+++ b/lib/routes/amazon/kindle-software-updates.ts
@@ -0,0 +1,71 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const host = 'https://www.amazon.com';
+export const route: Route = {
+ path: '/kindle/software-updates',
+ categories: ['program-update'],
+ example: '/amazon/kindle/software-updates',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Kindle Software Updates',
+ maintainers: ['EthanWng97'],
+ handler,
+};
+
+async function handler() {
+ const url = host + '/gp/help/customer/display.html';
+ const nodeIdValue = 'GKMQC26VQQMM8XSW';
+ const response = await got({
+ method: 'get',
+ url,
+ searchParams: {
+ nodeId: nodeIdValue,
+ },
+ });
+ const data = response.data;
+
+ const $ = load(data);
+ const list = $('.a-row.cs-help-landing-section.help-display-cond')
+ .toArray()
+ .map((item) => {
+ const data = {
+ title: $(item).find('.sectiontitle').text(),
+ link: $(item).find('a').eq(0).attr('href'),
+ version: $(item).find('li').first().text(),
+ website: `${url}?nodeId=${nodeIdValue}`,
+ description: $(item)
+ .find('.a-column.a-span8')
+ .html()
+ .replaceAll(/[\t\n]/g, ''),
+ };
+ return data;
+ });
+ return {
+ title: 'Kindle E-Reader Software Updates',
+ link: `${url}?nodeId=${nodeIdValue}`,
+ description: 'Kindle E-Reader Software Updates',
+ item: list.map((item) => ({
+ title: item.title + ' - ' + item.version,
+ description:
+ item.description +
+ art(path.join(__dirname, 'templates/software-description.art'), {
+ item,
+ }),
+ guid: item.title + ' - ' + item.version,
+ link: item.link,
+ })),
+ };
+}
diff --git a/lib/routes/amazon/namespace.ts b/lib/routes/amazon/namespace.ts
new file mode 100644
index 00000000000000..7815db99cfa5bf
--- /dev/null
+++ b/lib/routes/amazon/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Amazon',
+ url: 'amazon.com',
+ lang: 'en',
+};
diff --git a/lib/v2/amazon/templates/software-description.art b/lib/routes/amazon/templates/software-description.art
similarity index 100%
rename from lib/v2/amazon/templates/software-description.art
rename to lib/routes/amazon/templates/software-description.art
diff --git a/lib/routes/amz123/kx.ts b/lib/routes/amz123/kx.ts
new file mode 100644
index 00000000000000..3dc1c155f1b07d
--- /dev/null
+++ b/lib/routes/amz123/kx.ts
@@ -0,0 +1,66 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/kx',
+ categories: ['new-media'],
+ example: '/amz123/kx',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['amz123.com/kx'],
+ target: '/kx',
+ },
+ ],
+ name: 'AMZ123 快讯',
+ maintainers: ['defp'],
+ handler,
+ url: 'amz123.com/kx',
+ view: ViewType.Articles,
+};
+
+async function handler() {
+ const limit = 12;
+ const apiRootUrl = 'https://api.amz123.com';
+ const rootUrl = 'https://www.amz123.com';
+
+ const { data: response } = await got.post(`${apiRootUrl}/ugc/v1/user_content/forum_list`, {
+ json: {
+ page: 1,
+ page_size: limit,
+ tag_id: 0,
+ fid: 4,
+ ban: 0,
+ is_new: 1,
+ },
+ headers: {
+ 'content-type': 'application/json',
+ },
+ });
+
+ const items = response.data.rows.map((item) => ({
+ title: item.title,
+ description: item.description,
+ pubDate: parseDate(item.published_at * 1000),
+ link: `${rootUrl}/kx/${item.id}`,
+ author: item.author?.username,
+ category: item.tags.map((tag) => tag.name),
+ guid: item.resource_id,
+ }));
+
+ return {
+ title: 'AMZ123 快讯',
+ link: `${rootUrl}/kx`,
+ item: items,
+ };
+}
diff --git a/lib/routes/amz123/namespace.ts b/lib/routes/amz123/namespace.ts
new file mode 100644
index 00000000000000..289242d2dd8894
--- /dev/null
+++ b/lib/routes/amz123/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Amz123',
+ url: 'www.amz123.com',
+ categories: ['new-media'],
+ description: '跨境电商平台',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/android/namespace.ts b/lib/routes/android/namespace.ts
new file mode 100644
index 00000000000000..ddcd05dcda2549
--- /dev/null
+++ b/lib/routes/android/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Android',
+ url: 'developer.android.com',
+ lang: 'en',
+};
diff --git a/lib/routes/android/platform-tools-releases.ts b/lib/routes/android/platform-tools-releases.ts
new file mode 100644
index 00000000000000..b8a7ac5794fff3
--- /dev/null
+++ b/lib/routes/android/platform-tools-releases.ts
@@ -0,0 +1,73 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/platform-tools-releases',
+ categories: ['program-update'],
+ example: '/android/platform-tools-releases',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['developer.android.com/studio/releases/platform-tools', 'developer.android.com/'],
+ },
+ ],
+ name: 'SDK Platform Tools release notes',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'developer.android.com/studio/releases/platform-tools',
+};
+
+async function handler() {
+ const rootUrl = 'https://developer.android.com';
+ const currentUrl = `${rootUrl}/studio/releases/platform-tools`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ headers: {
+ cookie: 'signin=autosignin',
+ },
+ });
+
+ const $ = load(response.data);
+
+ $('.hide-from-toc').remove();
+ $('.devsite-dialog, .devsite-badge-awarder, .devsite-hats-survey').remove();
+
+ const items = $('h4')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.attr('data-text');
+
+ let description = '';
+ item.nextUntil('h4').each(function () {
+ description += $(this).html();
+ });
+
+ return {
+ title,
+ description,
+ link: `${currentUrl}#${item.attr('id')}`,
+ pubDate: parseDate(title.match(/\((.*)\)/)[1], 'MMMM YYYY'),
+ };
+ });
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/android/security-bulletin.ts b/lib/routes/android/security-bulletin.ts
new file mode 100644
index 00000000000000..b81271a1295f1c
--- /dev/null
+++ b/lib/routes/android/security-bulletin.ts
@@ -0,0 +1,63 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/security-bulletin',
+ categories: ['program-update'],
+ example: '/android/security-bulletin',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['source.android.com/docs/security/bulletin', 'source.android.com/docs/security/bulletin/asb-overview', 'source.android.com/'],
+ },
+ ],
+ name: 'Security Bulletins',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'source.android.com/docs/security/bulletin/asb-overview',
+};
+
+async function handler() {
+ const baseUrl = 'https://source.android.com';
+ const link = `${baseUrl}/docs/security/bulletin/asb-overview`;
+
+ const response = await ofetch(link, {
+ headers: {
+ Cookie: 'signin=autosignin; cookies_accepted=true; django_language=en;',
+ },
+ });
+
+ const $ = load(response);
+
+ const items = $('table tr')
+ .slice(1)
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const a = $item.find('td:nth-child(1) a');
+ return {
+ title: `Bulletin ${a.text()}`,
+ description: $item.find('td:nth-child(2)').html(),
+ link: `${baseUrl}${a.attr('href')}`,
+ pubDate: parseDate($item.find('td:nth-child(3)').text()),
+ };
+ });
+
+ return {
+ title: $('title').text(),
+ link,
+ image: $('link[rel="apple-touch-icon"]').attr('href'),
+ item: items,
+ };
+}
diff --git a/lib/routes/anigamer/anime.js b/lib/routes/anigamer/anime.js
deleted file mode 100644
index a601f73657213e..00000000000000
--- a/lib/routes/anigamer/anime.js
+++ /dev/null
@@ -1,14 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- const { anime } = await got.get(`https://api.gamer.com.tw/mobile_app/anime/v1/video.php?sn=0&anime_sn=${ctx.params.sn}`).then((r) => r.data);
- ctx.state.data = {
- title: anime.title,
- link: `https://ani.gamer.com.tw/animeRef.php?sn=${anime.anime_sn}`,
- description: ` ` + anime.content.trim(),
- item: anime.volumes[0].map((item) => ({
- title: `${anime.title} 第 ${item.volume} 集`,
- link: `https://ani.gamer.com.tw/animeVideo.php?sn=${item.video_sn}`,
- })),
- };
-};
diff --git a/lib/routes/anigamer/new_anime.js b/lib/routes/anigamer/new_anime.js
deleted file mode 100644
index c974fda86a9abe..00000000000000
--- a/lib/routes/anigamer/new_anime.js
+++ /dev/null
@@ -1,22 +0,0 @@
-const got = require('@/utils/got');
-const { parseDate } = require('@/utils/parse-date');
-const timezone = require('@/utils/timezone');
-
-module.exports = async (ctx) => {
- const rootUrl = 'https://ani.gamer.com.tw';
- const response = await got.get('https://api.gamer.com.tw/mobile_app/anime/v3/index.php');
- const newAnime = response.data.data.newAnime;
- ctx.state.data = {
- title: '動畫瘋最後更新',
- link: `${rootUrl}/`,
- item: newAnime.date.map((item) => {
- const date = `${item.upTime} ${item.upTimeHours}`;
- return {
- title: `${item.title} ${item.volume}`,
- description: ` `,
- link: `${rootUrl}/animeVideo.php?sn=${item.videoSn}`,
- pubDate: timezone(parseDate(date, 'MM/DD HH:mm'), +8),
- };
- }),
- };
-};
diff --git a/lib/routes/anime1/anime.js b/lib/routes/anime1/anime.js
deleted file mode 100644
index 214f10ca2dd2ee..00000000000000
--- a/lib/routes/anime1/anime.js
+++ /dev/null
@@ -1,25 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const { time, name } = ctx.params;
- const $ = await got.get(`https://anime1.me/category/${encodeURIComponent(time)}/${encodeURIComponent(name)}`).then((r) => cheerio.load(r.data));
- const title = $('.page-title').text().trim();
- ctx.state.data = {
- title,
- link: `https://anime1.me/category/${time}/${name}`,
- description: title,
- item: $('article')
- .toArray()
- .map((art) => {
- const $el = $(art);
- const title = $el.find('.entry-title a').text();
- return {
- title: $el.find('.entry-title a').text(),
- link: $el.find('.entry-title a').attr('href'),
- description: title,
- pubDate: new Date($el.find('time').attr('datetime')).toUTCString(),
- };
- }),
- };
-};
diff --git a/lib/routes/anime1/anime.ts b/lib/routes/anime1/anime.ts
new file mode 100644
index 00000000000000..5537c1e9565859
--- /dev/null
+++ b/lib/routes/anime1/anime.ts
@@ -0,0 +1,64 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: 'anime/:category/:name',
+ name: 'Anime',
+ url: 'anime1.me',
+ maintainers: ['cxheng315'],
+ example: '/anime1/anime/2024年夏季/神之塔-第二季',
+ categories: ['anime'],
+ parameters: {
+ category: 'Anime1 Category',
+ name: 'Anime1 Name',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['anime1.me/category/:category/:name'],
+ target: '/anime/:category/:name',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { category, name } = ctx.req.param();
+
+ const response = await ofetch(`https://anime1.me/category/${category}/${name}`);
+
+ const $ = load(response);
+
+ const title = $('.page-title').text().trim();
+
+ const items = $('article')
+ .toArray()
+ .map((el) => {
+ const $el = $(el);
+ const title = $el.find('.entry-title a').text().trim();
+ return {
+ title,
+ link: $el.find('.entry-title a').attr('href'),
+ description: title,
+ pubDate: parseDate($el.find('time').attr('datetime') || ''),
+ itunes_item_image: $el.find('video').attr('poster'),
+ };
+ });
+
+ return {
+ title,
+ link: `https://anime1.me/category/${category}/${name}`,
+ description: title,
+ item: items,
+ };
+}
diff --git a/lib/routes/anime1/namespace.ts b/lib/routes/anime1/namespace.ts
new file mode 100644
index 00000000000000..7d5644fadc9230
--- /dev/null
+++ b/lib/routes/anime1/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Anime1',
+ url: 'anime1.me',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/anime1/search.js b/lib/routes/anime1/search.js
deleted file mode 100644
index 6b88f8b9db5429..00000000000000
--- a/lib/routes/anime1/search.js
+++ /dev/null
@@ -1,25 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const { keyword } = ctx.params;
- const $ = await got.get(`https://anime1.me/?s=${encodeURIComponent(keyword)}`).then((r) => cheerio.load(r.data));
- const title = $('.page-title').text().trim();
- ctx.state.data = {
- title,
- link: `https://anime1.me/?s=${keyword}`,
- description: title,
- item: $('article:has(.cat-links)')
- .toArray()
- .map((art) => {
- const $el = $(art);
- const title = $el.find('.entry-title a').text();
- return {
- title: $el.find('.entry-title a').text(),
- link: $el.find('.entry-title a').attr('href'),
- description: title,
- pubDate: new Date($el.find('time').attr('datetime')).toUTCString(),
- };
- }),
- };
-};
diff --git a/lib/routes/anime1/search.ts b/lib/routes/anime1/search.ts
new file mode 100644
index 00000000000000..773cd69ebd66b6
--- /dev/null
+++ b/lib/routes/anime1/search.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: 'search/:keyword',
+ name: 'Search',
+ url: 'anime1.me',
+ maintainers: ['cxheng315'],
+ example: '/anime1/search/神之塔',
+ categories: ['anime'],
+ parameters: {
+ keyword: 'Anime1 Search Keyword',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ handler,
+};
+
+async function handler(ctx) {
+ const { keyword } = ctx.req.param();
+
+ const response = await ofetch(`https://anime1.me/?s=${keyword}`);
+
+ const $ = load(response);
+
+ const title = $('page-title').text().trim();
+
+ const items = $('article.type-post')
+ .toArray()
+ .map((el) => {
+ const $el = $(el);
+ const title = $el.find('.entry-title a').text().trim();
+ return {
+ title,
+ link: $el.find('.entry-title a').attr('href'),
+ description: title,
+ pubDate: parseDate($el.find('time').attr('datetime') || ''),
+ };
+ });
+
+ return {
+ title,
+ link: `https://anime1.me/?s=${keyword}`,
+ description: title,
+ itunes_author: 'Anime1',
+ itunes_image: 'https://anime1.me/wp-content/uploads/2021/02/cropped-1-180x180.png',
+ item: items,
+ };
+}
diff --git a/lib/routes/annualreviews/index.ts b/lib/routes/annualreviews/index.ts
new file mode 100644
index 00000000000000..6329050781afcb
--- /dev/null
+++ b/lib/routes/annualreviews/index.ts
@@ -0,0 +1,100 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:id',
+ categories: ['journal'],
+ example: '/annualreviews/anchem',
+ parameters: { id: 'Journal id, can be found in URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: true,
+ },
+ radar: [
+ {
+ source: ['annualreviews.org/journal/:id', 'annualreviews.org/'],
+ },
+ ],
+ name: 'Journal',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `The URL of the journal [Annual Review of Analytical Chemistry](https://www.annualreviews.org/journal/anchem) is \`https://www.annualreviews.org/journal/anchem\`, where \`anchem\` is the id of the journal, so the route for this journal is \`/annualreviews/anchem\`.
+
+::: tip
+ More jounals can be found in [Browse Journals](https://www.annualreviews.org/action/showPublications).
+:::`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+
+ const rootUrl = 'https://www.annualreviews.org';
+ const apiRootUrl = `https://api.crossref.org`;
+ const feedUrl = `${rootUrl}/r/${id}_rss`;
+ const currentUrl = `${rootUrl}/toc/${id}/current`;
+
+ const response = await got({
+ method: 'get',
+ url: feedUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('entry')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const doi = item.find('id').text().split('doi=').pop();
+
+ return {
+ doi,
+ guid: doi,
+ title: item.find('title').text(),
+ link: item.find('link').attr('href').split('?')[0],
+ description: item.find('content').text(),
+ pubDate: parseDate(item.find('published').text()),
+ author: item
+ .find('author name')
+ .toArray()
+ .map((a) => $(a).text())
+ .join(', '),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.guid, async () => {
+ const apiUrl = `${apiRootUrl}/works/${item.doi}`;
+
+ const detailResponse = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ item.description = detailResponse.data.message.abstract.replaceAll('jats:p>', 'p>');
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title')
+ .first()
+ .text()
+ .replace(/: Table of Contents/, ''),
+ description: $('subtitle').first().text(),
+ link: currentUrl,
+ item: items,
+ language: $('html').attr('lang'),
+ };
+}
diff --git a/lib/routes/annualreviews/namespace.ts b/lib/routes/annualreviews/namespace.ts
new file mode 100644
index 00000000000000..9ce5576f8ea459
--- /dev/null
+++ b/lib/routes/annualreviews/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Annual Reviews',
+ url: 'annualreviews.org',
+ lang: 'en',
+};
diff --git a/lib/routes/anquanke/category.ts b/lib/routes/anquanke/category.ts
new file mode 100644
index 00000000000000..2da6a9349ee01b
--- /dev/null
+++ b/lib/routes/anquanke/category.ts
@@ -0,0 +1,63 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:category/:fulltext?',
+ categories: ['programming'],
+ example: '/anquanke/week',
+ parameters: { category: '分类订阅', fulltext: '是否获取全文,如需获取全文参数传入 `quanwen` 或 `fulltext`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '分类订阅',
+ maintainers: ['qwertyuiop6'],
+ handler,
+ description: `| 360 网络安全周报 | 活动 | 知识 | 资讯 | 招聘 | 工具 |
+| ---------------- | -------- | --------- | ---- | ---- | ---- |
+| week | activity | knowledge | news | job | tool |`,
+};
+
+async function handler(ctx) {
+ const api = 'https://api.anquanke.com/data/v1/posts?size=10&page=1&category=';
+ const type = ctx.req.param('category');
+ const fulltext = ctx.req.param('fulltext');
+ const host = 'https://www.anquanke.com';
+ const res = await got(`${api}${type}`);
+ const dataArray = res.data.data;
+
+ const items = await Promise.all(
+ dataArray.map(async (item) => {
+ const art_url = `${host}/${type === 'week' ? 'week' : 'post'}/id/${item.id}`;
+ return {
+ title: item.title,
+ description:
+ fulltext === 'fulltext' || fulltext === 'quanwen'
+ ? await cache.tryGet(art_url, async () => {
+ const { data: res } = await got(art_url);
+ const content = load(res);
+ return content('#js-article').html();
+ })
+ : item.desc,
+ pubDate: timezone(parseDate(item.date), +8),
+ link: art_url,
+ author: item.author.nickname,
+ };
+ })
+ );
+
+ return {
+ title: `安全客-${dataArray[0].category_name}`,
+ link: `https://www.anquanke.com/${type === 'week' ? 'week-list' : type}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/anquanke/namespace.ts b/lib/routes/anquanke/namespace.ts
new file mode 100644
index 00000000000000..e9ad9674f5165f
--- /dev/null
+++ b/lib/routes/anquanke/namespace.ts
@@ -0,0 +1,10 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '安全客',
+ url: 'anquanke.com',
+ description: `::: tip
+官方提供了混合的主页资讯 RSS: [https://api.anquanke.com/data/v1/rss](https://api.anquanke.com/data/v1/rss)
+:::`,
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/anquanke/vul.ts b/lib/routes/anquanke/vul.ts
new file mode 100644
index 00000000000000..1ecdf681abcc48
--- /dev/null
+++ b/lib/routes/anquanke/vul.ts
@@ -0,0 +1,37 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const handler = async () => {
+ const url = 'https://www.anquanke.com';
+
+ const response = await got(`${url}/vul`);
+ const $ = load(response.data);
+ const list = $('table>tbody>tr').toArray();
+
+ const items = list.map((i) => {
+ const item = $(i);
+
+ const title = item.find('td:first-child a').text();
+ const cve = item.find('td:nth-child(2)').text();
+ const pla = item.find('.vul-type-item').text().replaceAll(/\s+/g, '');
+ const date = parseDate(item.find('td:nth-last-child(2)').text().replaceAll(/\s+/g, ''));
+ const href = item.find('a').attr('href');
+
+ return {
+ title: `${title}【${cve}】${pla === '未知' ? '' : pla}`,
+ description: `编号:${cve} | 平台:${pla}`,
+ pubDate: date,
+ link: `${url}${href}`,
+ };
+ });
+
+ return {
+ title: '安全客-漏洞cve报告',
+ link: 'https://www.anquanke.com/vul',
+ item: items,
+ };
+};
+
+export default handler;
diff --git a/lib/routes/anthropic/engineering.ts b/lib/routes/anthropic/engineering.ts
new file mode 100644
index 00000000000000..a07974b14895b6
--- /dev/null
+++ b/lib/routes/anthropic/engineering.ts
@@ -0,0 +1,81 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/engineering',
+ categories: ['programming'],
+ example: '/anthropic/engineering',
+ parameters: {},
+ radar: [
+ {
+ source: ['www.anthropic.com/engineering', 'www.anthropic.com'],
+ },
+ ],
+ name: 'Engineering',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'www.anthropic.com/engineering',
+};
+
+async function handler(ctx) {
+ const baseUrl = 'https://www.anthropic.com';
+ const link = `${baseUrl}/engineering`;
+ const response = await ofetch(link);
+ const $ = load(response);
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+
+ const list: DataItem[] = $('a[class*="cardLink"]')
+ .toArray()
+ .map((element) => {
+ const $e = $(element);
+ const href = $e.attr('href') ?? '';
+ const fullLink = href.startsWith('http') ? href : `${baseUrl}${href}`;
+ const pubDate = $e.find('div[class*="date"]').text().trim();
+ return {
+ title: $e.find('h2, h3').text().trim(),
+ link: fullLink,
+ pubDate,
+ };
+ })
+ .filter((item) => item.title && item.link)
+ .slice(0, limit);
+
+ const items = await pMap(
+ list,
+ (item) =>
+ cache.tryGet(item.link!, async () => {
+ const response = await ofetch(item.link!);
+ const $ = load(response);
+
+ const content = $('article > div > div[class*="__body"]');
+
+ content.find('img').each((_, e) => {
+ const $e = $(e);
+ $e.removeAttr('style srcset');
+ const src = $e.attr('src');
+ const params = new URLSearchParams(src);
+ const newSrc = params.get('/_next/image?url');
+ if (newSrc) {
+ $e.attr('src', newSrc);
+ }
+ });
+
+ item.description = content.html() ?? undefined;
+
+ return item;
+ }),
+ { concurrency: 5 }
+ );
+
+ return {
+ title: 'Anthropic Engineering',
+ link,
+ description: 'Latest engineering posts from Anthropic',
+ image: `${baseUrl}/images/icons/apple-touch-icon.png`,
+ item: items,
+ };
+}
diff --git a/lib/routes/anthropic/namespace.ts b/lib/routes/anthropic/namespace.ts
new file mode 100644
index 00000000000000..f11fc642cd2092
--- /dev/null
+++ b/lib/routes/anthropic/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Anthropic',
+ url: 'anthropic.com',
+ lang: 'en',
+};
diff --git a/lib/routes/anthropic/news.ts b/lib/routes/anthropic/news.ts
new file mode 100644
index 00000000000000..e827f9c10d59ff
--- /dev/null
+++ b/lib/routes/anthropic/news.ts
@@ -0,0 +1,89 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['programming'],
+ example: '/anthropic/news',
+ parameters: {},
+ radar: [
+ {
+ source: ['www.anthropic.com/news', 'www.anthropic.com'],
+ },
+ ],
+ name: 'News',
+ maintainers: ['etShaw-zh', 'goestav'],
+ handler,
+ url: 'www.anthropic.com/news',
+};
+
+async function handler(ctx) {
+ const link = 'https://www.anthropic.com/news';
+ const response = await ofetch(link);
+ const $ = load(response);
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+
+ const list: DataItem[] = $('.contentFadeUp a')
+ .toArray()
+ .slice(0, limit)
+ .map((el) => {
+ const $el = $(el);
+ const title = $el.find('h3').text().trim();
+ const href = $el.attr('href') ?? '';
+ const pubDate = $el.find('p.detail-m.agate').text().trim() || $el.find('div[class^="PostList_post-date__"]').text().trim(); // legacy selector used roughly before Jan 2025
+ const fullLink = href.startsWith('http') ? href : `https://www.anthropic.com${href}`;
+ return {
+ title,
+ link: fullLink,
+ pubDate,
+ };
+ });
+
+ const out = await pMap(
+ list,
+ (item) =>
+ cache.tryGet(item.link!, async () => {
+ const response = await ofetch(item.link!);
+ const $ = load(response);
+
+ const content = $('#main-content');
+
+ // Remove meaningless information (heading, sidebar, quote carousel, footer and codeblock controls)
+ $(`
+ [class^="PostDetail_post-heading"],
+ [class^="ArticleDetail_sidebar-container"],
+ [class^="QuoteCarousel_carousel-controls"],
+ [class^="PostDetail_b-social-share"],
+ [class^="LandingPageSection_root"],
+ [class^="CodeBlock_controls"]
+ `).remove();
+
+ content.find('img').each((_, e) => {
+ const $e = $(e);
+ $e.removeAttr('style srcset');
+ const src = $e.attr('src');
+ const params = new URLSearchParams(src);
+ const newSrc = params.get('/_next/image?url');
+ if (newSrc) {
+ $e.attr('src', newSrc);
+ }
+ });
+
+ item.description = content.html() ?? undefined;
+
+ return item;
+ }),
+ { concurrency: 5 }
+ );
+
+ return {
+ title: 'Anthropic News',
+ link,
+ description: 'Latest news from Anthropic',
+ item: out,
+ };
+}
diff --git a/lib/routes/anthropic/research.ts b/lib/routes/anthropic/research.ts
new file mode 100644
index 00000000000000..55961d98a8d374
--- /dev/null
+++ b/lib/routes/anthropic/research.ts
@@ -0,0 +1,113 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/research',
+ categories: ['programming'],
+ example: '/anthropic/research',
+ parameters: {},
+ radar: [
+ {
+ source: ['www.anthropic.com/research', 'www.anthropic.com'],
+ },
+ ],
+ name: 'Research',
+ maintainers: ['ttttmr'],
+ handler,
+ url: 'www.anthropic.com/research',
+};
+
+async function handler() {
+ const link = 'https://www.anthropic.com/research';
+ const response = await ofetch(link);
+ const $ = load(response);
+
+ // self.__next_f.push
+ const regexp = /self\.__next_f\.push\((.+)\)/;
+ const textList: string[] = [];
+ for (const e of $('script').toArray()) {
+ const $e = $(e);
+ const text = $e.text();
+ const match = regexp.exec(text);
+ if (match) {
+ let data;
+ try {
+ data = JSON.parse(match[1]);
+ if (Array.isArray(data) && data.length === 2 && data[0] === 1) {
+ textList.push(data[1]);
+ }
+ } catch {
+ // ignore
+ }
+ }
+ }
+
+ const partRegex = /^([0-9a-zA-Z]+):([0-9a-zA-Z]+)?(\[.*)$/;
+ const fd = textList
+ .join('')
+ .split('\n')
+ .map((d) => {
+ const matchPart = partRegex.exec(d);
+ if (matchPart) {
+ return {
+ id: matchPart[1],
+ tag: matchPart[2],
+ data: JSON.parse(matchPart[3]),
+ };
+ }
+ return {
+ id: '',
+ tag: '',
+ data: d,
+ };
+ });
+
+ const sections = fd.flatMap((d) => (Array.isArray(d.data) ? d.data : [])).flatMap((item) => item?.page?.sections ?? []);
+ const tabPages = sections.flatMap((section) => section?.tabPages ?? []).filter((tabPage) => tabPage?.label === 'Overview');
+ const publicationSections = tabPages.flatMap((tabPage) => tabPage.sections).filter((section) => section?.title === 'Publications');
+ const posts = publicationSections
+ .flatMap((section) => section?.posts ?? [])
+ .map((post) => ({
+ title: post.title,
+ link: `https://www.anthropic.com/research/${post.slug.current}`,
+ pubDate: parseDate(post.publishedOn),
+ }));
+
+ const items = await pMap(
+ posts,
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+
+ const content = $('div[class*="PostDetail_post-detail__"]');
+ content.find('img').each((_, e) => {
+ const $e = $(e);
+ $e.removeAttr('style srcset');
+ const src = $e.attr('src');
+ const params = new URLSearchParams(src);
+ const newSrc = params.get('/_next/image?url');
+ if (newSrc) {
+ $e.attr('src', newSrc);
+ }
+ });
+
+ item.description = content.html();
+
+ return item;
+ }),
+ { concurrency: 5 }
+ );
+
+ return {
+ title: 'Anthropic Research',
+ link,
+ description: 'Latest research from Anthropic',
+ item: items,
+ };
+}
diff --git a/lib/routes/anytxt/namespace.ts b/lib/routes/anytxt/namespace.ts
new file mode 100644
index 00000000000000..294df43f9fedef
--- /dev/null
+++ b/lib/routes/anytxt/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Anytxt Searcher',
+ url: 'anytxt.net',
+ categories: ['program-update'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/anytxt/release-notes.ts b/lib/routes/anytxt/release-notes.ts
new file mode 100644
index 00000000000000..951d75d48a8777
--- /dev/null
+++ b/lib/routes/anytxt/release-notes.ts
@@ -0,0 +1,93 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'https://anytxt.net';
+ const targetUrl: string = new URL('download/', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en-US';
+
+ const image: string | undefined = $('meta[property="og:image"]').attr('content');
+
+ const items: DataItem[] = $('p.has-medium-font-size')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.text();
+ const description: string | undefined = $el.next().html() ?? '';
+ const pubDateStr: string | undefined = title.split(/\s/)[0];
+ const linkUrl: string | undefined = targetUrl;
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ })
+ .filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/release-notes',
+ name: 'Release Notes',
+ url: 'anytxt.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/anytxt/release-notes',
+ parameters: undefined,
+ description: undefined,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['anytxt.net'],
+ target: '/anytxt/release-notes',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/apache/apisix/blog.ts b/lib/routes/apache/apisix/blog.ts
new file mode 100644
index 00000000000000..8bf1d32742c1d0
--- /dev/null
+++ b/lib/routes/apache/apisix/blog.ts
@@ -0,0 +1,52 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+async function getArticles() {
+ const url = 'https://apisix.apache.org/zh/blog/';
+ const { data: res } = await got(url);
+ const $ = load(res);
+ const articles = $('section.sec_gjjg').eq(1).find('article');
+ return articles.toArray().map((elem) => {
+ const a = $(elem).find('header > a');
+ return {
+ title: a.find('h2').text(),
+ description: a.find('p').text(),
+ link: a.attr('href'),
+ pubDate: parseDate($(elem).find('footer').find('time').attr('datetime')),
+ category: $(elem)
+ .find('header div a')
+ .toArray()
+ .map((elem) => $(elem).text()),
+ };
+ });
+}
+
+export const route: Route = {
+ path: '/apisix/blog',
+ categories: ['blog'],
+ example: '/apache/apisix/blog',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'APISIX 博客',
+ maintainers: ['aneasystone'],
+ handler,
+};
+
+async function handler() {
+ const articles = await getArticles();
+ return {
+ title: 'Blog | Apache APISIX',
+ link: 'https://apisix.apache.org/zh/blog/',
+ item: articles,
+ };
+}
diff --git a/lib/routes/apache/namespace.ts b/lib/routes/apache/namespace.ts
new file mode 100644
index 00000000000000..d6b71e27ca2aca
--- /dev/null
+++ b/lib/routes/apache/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Apache',
+ url: 'apisix.apache.org',
+ lang: 'en',
+};
diff --git a/lib/routes/apiseven/blog.ts b/lib/routes/apiseven/blog.ts
new file mode 100644
index 00000000000000..eb53f4bcdaae02
--- /dev/null
+++ b/lib/routes/apiseven/blog.ts
@@ -0,0 +1,69 @@
+import { load } from 'cheerio';
+import MarkdownIt from 'markdown-it';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const md = MarkdownIt({
+ html: true,
+});
+
+async function getArticles() {
+ const url = 'https://www.apiseven.com/blog';
+ const { data: res } = await got(url);
+ const $ = load(res);
+ const json = JSON.parse($('#__NEXT_DATA__').text());
+ return json.props.pageProps.list.map((item) => ({
+ title: item.title,
+ link: 'https://www.apiseven.com' + item.slug,
+ pubDate: timezone(parseDate(item.published_at), +8),
+ category: item.tags,
+ }));
+}
+
+export const route: Route = {
+ path: '/blog',
+ categories: ['blog'],
+ example: '/apiseven/blog',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '博客',
+ maintainers: ['aneasystone'],
+ handler,
+};
+
+async function handler() {
+ const articles = await getArticles();
+ const items = await Promise.all(
+ articles.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: res } = await got(item.link);
+ const $ = load(res);
+ const json = JSON.parse($('#__NEXT_DATA__').text());
+ return {
+ title: item.title,
+ description: md.render(json.props.pageProps.post.content),
+ link: item.link,
+ pubDate: item.pubDate,
+ author: json.props.pageProps.post.author_name,
+ };
+ })
+ )
+ );
+
+ return {
+ title: '博客 | 支流科技',
+ link: 'https://www.apiseven.com/blog',
+ item: items,
+ };
+}
diff --git a/lib/routes/apiseven/namespace.ts b/lib/routes/apiseven/namespace.ts
new file mode 100644
index 00000000000000..d1482c49afda0c
--- /dev/null
+++ b/lib/routes/apiseven/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '支流科技',
+ url: 'apiseven.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/apkpure/namespace.ts b/lib/routes/apkpure/namespace.ts
new file mode 100644
index 00000000000000..6bacb019896ec1
--- /dev/null
+++ b/lib/routes/apkpure/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'APKPure',
+ url: 'apkpure.com',
+ lang: 'en',
+};
diff --git a/lib/routes/apkpure/versions.ts b/lib/routes/apkpure/versions.ts
new file mode 100644
index 00000000000000..19d288ede3e638
--- /dev/null
+++ b/lib/routes/apkpure/versions.ts
@@ -0,0 +1,69 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import logger from '@/utils/logger';
+import { parseDate } from '@/utils/parse-date';
+import puppeteer from '@/utils/puppeteer';
+
+export const route: Route = {
+ path: '/versions/:pkg/:region?',
+ categories: ['program-update'],
+ example: '/apkpure/versions/jp.co.craftegg.band/jp',
+ parameters: { pkg: 'Package name', region: 'Region code, `en` by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: true,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Versions',
+ maintainers: ['maple3142'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { pkg, region = 'en' } = ctx.req.param();
+ const baseUrl = 'https://apkpure.com';
+ const link = `${baseUrl}/${region}/${pkg}/versions`;
+
+ const browser = await puppeteer();
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' ? request.continue() : request.abort();
+ });
+ logger.http(`Requesting ${link}`);
+ await page.goto(link, {
+ waitUntil: 'domcontentloaded',
+ });
+
+ const r = await page.evaluate(() => document.documentElement.innerHTML);
+ await browser.close();
+
+ const $ = load(r);
+ const img = new URL($('.ver-top img').attr('src'));
+ img.searchParams.delete('w'); // get full resolution icon
+
+ const items = $('.ver li')
+ .toArray()
+ .map((ver) => {
+ ver = $(ver);
+ return {
+ title: ver.find('.ver-item-n').text(),
+ description: ver.html(),
+ link: `${baseUrl}${ver.find('a').attr('href')}`,
+ pubDate: parseDate(ver.find('.update-on').text().replaceAll(/年|月/g, '-').replace('日', '')),
+ };
+ });
+
+ return {
+ title: $('.ver-top-h1').text(),
+ description: $('.ver-top-title p').text(),
+ image: img.href,
+ language: region ?? 'en',
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/apnews/mobile-api.ts b/lib/routes/apnews/mobile-api.ts
new file mode 100644
index 00000000000000..3c894ca3a36ed3
--- /dev/null
+++ b/lib/routes/apnews/mobile-api.ts
@@ -0,0 +1,97 @@
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { fetchArticle } from './utils';
+
+export const route: Route = {
+ path: '/mobile/:path{.+}?',
+ categories: ['traditional-media'],
+ example: '/apnews/mobile/ap-top-news',
+ view: ViewType.Articles,
+ parameters: {
+ path: {
+ description: 'Corresponding path from AP News website',
+ default: 'ap-top-news',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['apnews.com/'],
+ },
+ ],
+ name: 'News (from mobile client API)',
+ maintainers: ['dzx-dzx'],
+ handler,
+};
+
+async function handler(ctx) {
+ const path = ctx.req.param('path') ? `/${ctx.req.param('path')}` : '/hub/ap-top-news';
+ const apiRootUrl = 'https://apnews.com/graphql/delivery/ap/v1';
+ const res = await ofetch(apiRootUrl, {
+ query: {
+ operationName: 'ContentPageQuery',
+ variables: { path },
+ extensions: { persistedQuery: { version: 1, sha256Hash: '3bc305abbf62e9e632403a74cc86dc1cba51156d2313f09b3779efec51fc3acb' } },
+ },
+ });
+
+ const screen = res.data.Screen;
+
+ const list = [...screen.main.filter((e) => e.__typename === 'ColumnContainer').flatMap((_) => _.columns), ...screen.main.filter((e) => e.__typename !== 'ColumnContainer')]
+ .filter((e) => e.__typename !== 'GoogleDfPAdModule')
+ .flatMap((e) => {
+ switch (e.__typename) {
+ case 'PageListModule':
+ return e.items;
+ case 'VideoPlaylistModule':
+ return e.playlist;
+ default:
+ return;
+ }
+ })
+ .filter(Boolean)
+ .map((e) => {
+ if (e.__typename === 'PagePromo') {
+ return {
+ title: e.title,
+ link: e.url,
+ pubDate: parseDate(e.publishDateStamp),
+ category: e.category,
+ description: e.description,
+ guid: e.id,
+ };
+ } else if (e.__typename === 'VideoPlaylistItem') {
+ return {
+ title: e.title,
+ link: e.url,
+ description: e.description,
+ guid: e.contentId,
+ };
+ } else {
+ return;
+ }
+ })
+ .filter(Boolean)
+ .toSorted((a, b) => b.pubDate - a.pubDate)
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20);
+
+ const items = ctx.req.query('fulltext') === 'true' ? await pMap(list, (item) => fetchArticle(item), { concurrency: 10 }) : list;
+
+ return {
+ title: screen.category ?? screen.title,
+ item: items,
+ link: 'https://apnews.com',
+ };
+}
diff --git a/lib/routes/apnews/namespace.ts b/lib/routes/apnews/namespace.ts
new file mode 100644
index 00000000000000..48e7aa1caf6e8c
--- /dev/null
+++ b/lib/routes/apnews/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AP News',
+ url: 'apnews.com',
+ lang: 'en',
+};
diff --git a/lib/routes/apnews/rss.ts b/lib/routes/apnews/rss.ts
new file mode 100644
index 00000000000000..8a2889422af7ad
--- /dev/null
+++ b/lib/routes/apnews/rss.ts
@@ -0,0 +1,50 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import parser from '@/utils/rss-parser';
+
+import { fetchArticle } from './utils';
+
+const HOME_PAGE = 'https://apnews.com';
+
+export const route: Route = {
+ path: '/rss/:category?',
+ categories: ['traditional-media'],
+ example: '/apnews/rss/business',
+ view: ViewType.Articles,
+ parameters: {
+ category: {
+ description: 'Category from the first segment of the corresponding site, or `index` for the front page.',
+ default: 'index',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['apnews.com/:rss'],
+ target: '/rss/:rss',
+ },
+ ],
+ name: 'News',
+ maintainers: ['zoenglinghou', 'mjysci', 'TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { rss = 'index' } = ctx.req.param();
+ const url = `${HOME_PAGE}/${rss}.rss`;
+ const res = await parser.parseURL(url);
+
+ const items = ctx.req.query('fulltext') === 'true' ? await Promise.all(res.items.map((item) => fetchArticle(item))) : res;
+
+ return {
+ ...res,
+ item: items,
+ };
+}
diff --git a/lib/routes/apnews/sitemap.ts b/lib/routes/apnews/sitemap.ts
new file mode 100644
index 00000000000000..1abf1fec0be8c4
--- /dev/null
+++ b/lib/routes/apnews/sitemap.ts
@@ -0,0 +1,96 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import { fetchArticle } from './utils';
+
+const HOME_PAGE = 'https://apnews.com';
+
+export const route: Route = {
+ path: '/sitemap/:route',
+ categories: ['traditional-media'],
+ example: '/apnews/sitemap/ap-sitemap-latest',
+ view: ViewType.Articles,
+ parameters: {
+ route: {
+ description: 'Route for sitemap, excluding the `.xml` extension',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['apnews.com/'],
+ },
+ ],
+ name: 'Sitemap',
+ maintainers: ['zoenglinghou', 'mjysci', 'TonyRL', 'dzx-dzx'],
+ handler,
+};
+
+async function handler(ctx) {
+ const route = ctx.req.param('route');
+ const url = `${HOME_PAGE}/${route}.xml`;
+ const response = await ofetch(url);
+ const $ = load(response);
+
+ const list = $('urlset url')
+ .toArray()
+ .map((e) => {
+ const LANGUAGE_MAP = new Map([
+ ['eng', 'en'],
+ ['spa', 'es'],
+ ]);
+
+ const title = $(e)
+ .find(String.raw`news\:title`)
+ .text();
+ const pubDate = parseDate(
+ $(e)
+ .find(String.raw`news\:publication_date`)
+ .text()
+ );
+ const lastmod = timezone(parseDate($(e).find(`lastmod`).text()), -4);
+ const language = LANGUAGE_MAP.get(
+ $(e)
+ .find(String.raw`news\:language`)
+ .text()
+ );
+ let res = { link: $(e).find('loc').text() };
+ if (title) {
+ res = Object.assign(res, { title });
+ }
+ if (pubDate.toString() !== 'Invalid Date') {
+ res = Object.assign(res, { pubDate });
+ }
+ if (language) {
+ res = Object.assign(res, { language });
+ }
+ if (lastmod.toString() !== 'Invalid Date') {
+ res = Object.assign(res, { lastmod });
+ }
+ return res;
+ })
+ .filter((e) => Boolean(e.link) && !new URL(e.link).pathname.split('/').includes('hub'))
+ .toSorted((a, b) => (a.pubDate && b.pubDate ? b.pubDate - a.pubDate : b.lastmod - a.lastmod))
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20);
+
+ const items = ctx.req.query('fulltext') === 'true' ? await pMap(list, (item) => fetchArticle(item), { concurrency: 20 }) : list;
+
+ return {
+ title: `AP News sitemap:${route}`,
+ item: items,
+ link: 'https://apnews.com',
+ };
+}
diff --git a/lib/routes/apnews/templates/description.art b/lib/routes/apnews/templates/description.art
new file mode 100644
index 00000000000000..aa54abacbfdd8a
--- /dev/null
+++ b/lib/routes/apnews/templates/description.art
@@ -0,0 +1,14 @@
+{{ if media }}
+ {{ each media }}
+ {{ if $value.type === 'Photo' }}
+
+
+ {{@ $value.caption }}
+
+ {{ else if $value.type === 'YouTube' }}
+
+ {{ if $value.caption }}{{@ $value.caption }}{{ /if }}
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+{{@ description }}
diff --git a/lib/routes/apnews/topics.ts b/lib/routes/apnews/topics.ts
new file mode 100644
index 00000000000000..7c955205ce5172
--- /dev/null
+++ b/lib/routes/apnews/topics.ts
@@ -0,0 +1,67 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+
+import { fetchArticle, removeDuplicateByKey } from './utils';
+
+const HOME_PAGE = 'https://apnews.com';
+
+export const route: Route = {
+ path: ['/topics/:topic?', '/nav/:nav{.*}?'],
+ categories: ['traditional-media'],
+ example: '/apnews/topics/apf-topnews',
+ view: ViewType.Articles,
+ parameters: {
+ topic: {
+ description: 'Topic name, can be found in URL. For example: the topic name of AP Top News [https://apnews.com/apf-topnews](https://apnews.com/apf-topnews) is `apf-topnews`',
+ default: 'trending-news',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['apnews.com/hub/:topic'],
+ target: '/topics/:topic',
+ },
+ ],
+ name: 'Topics',
+ maintainers: ['zoenglinghou', 'mjysci', 'TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { topic = 'trending-news', nav = '' } = ctx.req.param();
+ const useNav = ctx.req.routePath === '/apnews/nav/:nav{.*}?';
+ const url = useNav ? `${HOME_PAGE}/${nav}` : `${HOME_PAGE}/hub/${topic}`;
+ const response = await got(url);
+ const $ = load(response.data);
+
+ const list = $(':is(.PagePromo-content, .PageListStandardE-leadPromo-info) bsp-custom-headline')
+ .toArray()
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : Infinity)
+ .map((e) => ({
+ title: $(e).find('span.PagePromoContentIcons-text').text(),
+ link: $(e).find('a').attr('href'),
+ }))
+ .filter((e) => typeof e.link === 'string');
+
+ const items = ctx.req.query('fulltext') === 'true' ? await pMap(list, (item) => fetchArticle(item), { concurrency: 10 }) : list;
+
+ return {
+ title: $('title').text(),
+ description: $("meta[property='og:description']").text(),
+ link: url,
+ item: removeDuplicateByKey(items, 'link'),
+ language: $('html').attr('lang'),
+ };
+}
diff --git a/lib/routes/apnews/utils.ts b/lib/routes/apnews/utils.ts
new file mode 100644
index 00000000000000..083c19fb9aa7b2
--- /dev/null
+++ b/lib/routes/apnews/utils.ts
@@ -0,0 +1,67 @@
+import { load } from 'cheerio';
+
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export function removeDuplicateByKey(items, key: string) {
+ return [...new Map(items.map((x) => [x[key], x])).values()];
+}
+
+export function fetchArticle(item) {
+ return cache.tryGet(item.link, async () => {
+ const data = await ofetch(item.link);
+ const $ = load(data);
+ if ($('#link-ld-json').length === 0) {
+ const gtmRaw = $('meta[name="gtm-dataLayer"]').attr('content');
+ if (gtmRaw) {
+ const gtmParsed = JSON.parse(gtmRaw);
+ return {
+ title: gtmParsed.headline,
+ pubDate: parseDate(gtmParsed.publication_date),
+ description: $('div.RichTextStoryBody').html() || $(':is(.VideoLead, .VideoPage-pageSubHeading)').html(),
+ category: gtmParsed.tag_array.split(','),
+ guid: $("meta[name='brightspot.contentId']").attr('content'),
+ author: gtmParsed.author,
+ ...item,
+ };
+ } else {
+ return item;
+ }
+ }
+ const rawLdjson = JSON.parse($('#link-ld-json').text());
+ let ldjson;
+ if (rawLdjson['@type'] === 'NewsArticle' || (Array.isArray(rawLdjson) && rawLdjson.some((e) => e['@type'] === 'NewsArticle'))) {
+ // Regular(Articles, Videos)
+ ldjson = Array.isArray(rawLdjson) ? rawLdjson.find((e) => e['@type'] === 'NewsArticle') : rawLdjson;
+
+ $('div.Enhancement').remove();
+ const section = $("meta[property='article:section']").attr('content');
+ return {
+ ...item,
+ title: ldjson.headline,
+ pubDate: parseDate(ldjson.datePublished),
+ updated: parseDate(ldjson.dateModified),
+ description: $('div.RichTextStoryBody').html() || $(':is(.VideoLead, .VideoPage-pageSubHeading)').html(),
+ category: [...(section ? [section] : []), ...(ldjson.keywords ?? [])],
+ guid: $("meta[name='brightspot.contentId']").attr('content'),
+ author: ldjson.author?.map((e) => e.mainEntity),
+ };
+ } else {
+ // Live
+ ldjson = rawLdjson;
+
+ const url = new URL(item.link);
+ const description = url.hash ? $(url.hash).parent().find('.LiveBlogPost-body').html() : ldjson.description;
+ const pubDate = url.hash ? parseDate(Number.parseInt($(url.hash).parent().attr('data-posted-date-timestamp'), 10)) : parseDate(ldjson.coverageStartTime);
+
+ return {
+ ...item,
+ category: ldjson.keywords,
+ pubDate,
+ description,
+ guid: $("meta[name='brightspot.contentId']").attr('content'),
+ };
+ }
+ });
+}
diff --git a/lib/routes/apnic/index.ts b/lib/routes/apnic/index.ts
new file mode 100644
index 00000000000000..fcf98adb86c4b8
--- /dev/null
+++ b/lib/routes/apnic/index.ts
@@ -0,0 +1,61 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/blog',
+ categories: ['blog'],
+ example: '/apnic/blog',
+ url: 'blog.apnic.net',
+ name: 'Blog',
+ maintainers: ['p3psi-boo'],
+ handler,
+};
+
+async function handler() {
+ const baseUrl = 'https://blog.apnic.net';
+ const feedUrl = `${baseUrl}/feed/`;
+
+ const response = await got(feedUrl);
+ const $ = load(response.data, { xmlMode: true });
+
+ // 从 RSS XML 中直接提取文章信息
+ const list = $('item')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ return {
+ title: $item.find('title').text(),
+ link: $item.find('link').text(),
+ author: $item.find(String.raw`dc\:creator`).text(),
+ category:
+ $item
+ .find('category')
+ .text()
+ .match(/>([^<]+))?.[1] || '',
+ pubDate: parseDate($item.find('pubDate').text()),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: articleData } = await got(item.link);
+ const $article = load(articleData);
+
+ // 获取文章正文内容
+ item.description = $article('.entry-content').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: 'APNIC Blog',
+ link: baseUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/apnic/namespace.ts b/lib/routes/apnic/namespace.ts
new file mode 100644
index 00000000000000..e3e9914088b2aa
--- /dev/null
+++ b/lib/routes/apnic/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'APNIC',
+ url: 'blog.apnic.net',
+ description: 'Asia-Pacific Network Information Centre',
+ lang: 'en',
+};
diff --git a/lib/routes/app-center/namespace.ts b/lib/routes/app-center/namespace.ts
new file mode 100644
index 00000000000000..c3dfb838b4352e
--- /dev/null
+++ b/lib/routes/app-center/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'App Center',
+ url: 'install.appcenter.ms',
+ lang: 'en',
+};
diff --git a/lib/routes/app-center/release.ts b/lib/routes/app-center/release.ts
new file mode 100644
index 00000000000000..ebe6106ea4e0d2
--- /dev/null
+++ b/lib/routes/app-center/release.ts
@@ -0,0 +1,150 @@
+import path from 'node:path';
+
+import MarkdownIt from 'markdown-it';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/release/:user/:app/:distribution_group',
+ categories: ['program-update'],
+ example: '/app-center/release/cloudflare/1.1.1.1-windows/beta',
+ parameters: { user: 'User', app: 'App name', distribution_group: 'Distribution group' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['install.appcenter.ms/users/:user/apps/:app/distribution_groups/:distribution_group', 'install.appcenter.ms/orgs/:user/apps/:app/distribution_groups/:distribution_group'],
+ },
+ ],
+ name: 'Release',
+ maintainers: ['Rongronggg9'],
+ handler,
+ description: `::: tip
+ The parameters can be extracted from the Release page URL: \`https://install.appcenter.ms/users/:user/apps/:app/distribution_groups/:distribution_group\`
+:::`,
+};
+
+async function handler(ctx) {
+ const user = ctx.req.param('user');
+ const app = ctx.req.param('app');
+ const distribution_group = ctx.req.param('distribution_group');
+
+ const baseUrl = 'https://install.appcenter.ms/api/v0.1/apps';
+ const apiUrl = `${baseUrl}/${user}/${app}/distribution_groups/${distribution_group}`;
+ const releasesListUrl = `${apiUrl}/public_releases?scope=tester`;
+ // const releaseUrl = `${apiUrl}/releases/${release_id}?is_install_page=true`;
+ const link = `https://install.appcenter.ms/users/${user}/apps/${app}/distribution_groups/${distribution_group}`;
+
+ const response = await got(releasesListUrl);
+ let items = response.data.map((item) => ({
+ // item:
+ // {
+ // "id": 504,
+ // "short_version": "8.5.0.0",
+ // "version": "18558",
+ // "origin": "appcenter",
+ // "uploaded_at": "2022-02-02T11:36:06.044Z",
+ // "mandatory_update": false,
+ // "enabled": true,
+ // "is_external_build": false
+ // }
+
+ pubDate: parseDate(item.uploaded_at),
+ link: `${apiUrl}/releases/${item.id}?is_install_page=true`,
+ }));
+
+ // Release info examples:
+ // Android: https://install.appcenter.ms/api/v0.1/apps/rafalense-70ux/plus-release/distribution_groups/public/releases/42?is_install_page=true
+ // iOS: https://install.appcenter.ms/api/v0.1/apps/gameonline/baitomobile/distribution_groups/baito/releases/26?is_install_page=true
+ // Windows: https://install.appcenter.ms/api/v0.1/apps/remitano/remitano-windows/distribution_groups/beta/releases/5?is_install_page=true
+ // macOS: https://install.appcenter.ms/api/v0.1/apps/rdmacios-k2vy/microsoft-remote-desktop-for-mac/distribution_groups/all-users-of-microsoft-remote-desktop-for-mac/releases/635?is_install_page=true
+
+ const md = new MarkdownIt();
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const releaseResponse = await got(item.link);
+ const releaseInfo = releaseResponse.data;
+
+ const userName = releaseInfo.owner.display_name;
+ const appOS = releaseInfo.app_os;
+ const shortVersion = releaseInfo.short_version; // will be an empty string for Windows
+ const versionCode = releaseInfo.version;
+ const isExternalBuild = releaseInfo.is_external_build;
+ const isMandatoryUpdate = releaseInfo.mandatory_update;
+ // const isLatest = releaseInfo.is_latest; // this is not representing the latest release, but the latest version of a certain release
+ const sizeInMBytes = (releaseInfo.size / (1024 * 1024)).toFixed(2);
+ const releaseDate = releaseInfo.uploaded_at; // use original text here because it is already an ISO 8601 time
+ const fingerprint = releaseInfo.fingerprint;
+ const minOS = releaseInfo.min_os; // `null` for Windows
+ const androidMinApiLevel = releaseInfo.android_min_api_level; // only for Android
+ const deviceFamily = releaseInfo.device_family; // only for iOS, `null` for others
+ const bundleId = releaseInfo.bundle_identifier; // can be a hash or a package name
+ const releaseNotes = releaseInfo.release_notes; // markdown, can be an empty string
+ const downloadUrl = releaseInfo.download_url;
+ const installUrl = releaseInfo.install_url;
+ const fileExtension = releaseInfo.fileExtension;
+
+ // workaround: cache feed title and icon
+ const appName = releaseInfo.app_display_name;
+ const distributionGroupId = releaseInfo.distribution_group_id;
+ const distributionGroupName = releaseInfo.distribution_groups.find((group) => group.id === distributionGroupId).display_name;
+ item._feed_title = `${appName} (${distributionGroupName}) for ${appOS} by ${userName} - App Center Releases`;
+ item._feed_icon = releaseInfo.app_icon_url;
+
+ const version = shortVersion && versionCode ? `${shortVersion} (${versionCode})` : shortVersion || versionCode;
+
+ item.title =
+ `${appName}: ` +
+ (isMandatoryUpdate ? '[Mandatory]' : '') +
+ // + (isLatest ? "[Latest]" : "")
+ (isExternalBuild ? '[External Build]' : '') +
+ `Version ${version}`;
+ item.link = link; // replace the link with the release page
+ item.author = userName;
+ item.description = art(
+ path.join(__dirname, 'templates/description.art'),
+ {
+ releaseDate,
+ sizeInMBytes,
+ minOS,
+ deviceFamily,
+ androidMinApiLevel,
+ bundleId,
+ downloadUrl,
+ installUrl,
+ fingerprint,
+ appOS,
+ fileExtension,
+ releaseNotes: releaseNotes && md.render(releaseNotes),
+ },
+ { minimize: true }
+ );
+ item.guid = fingerprint;
+
+ return item;
+ })
+ )
+ );
+
+ const icon = items && items[0]._feed_icon; // if it is an empty feed, would not raise an error here
+ const title = items && items[0]._feed_title;
+
+ return {
+ title,
+ link,
+ description: title,
+ image: icon,
+ item: items,
+ };
+}
diff --git a/lib/v2/app-center/templates/description.art b/lib/routes/app-center/templates/description.art
similarity index 100%
rename from lib/v2/app-center/templates/description.art
rename to lib/routes/app-center/templates/description.art
diff --git a/lib/routes/app-sales/index.ts b/lib/routes/app-sales/index.ts
new file mode 100644
index 00000000000000..3b19daba01c725
--- /dev/null
+++ b/lib/routes/app-sales/index.ts
@@ -0,0 +1,201 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import { baseUrl, fetchItems } from './util';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'highlights', country = 'us' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '100', 10);
+
+ const targetUrl: string = new URL(category.endsWith('/') ? category : `${category}/`, baseUrl).href;
+
+ const response = await ofetch(targetUrl, {
+ headers: {
+ Cookie: `countryId=${country};`,
+ },
+ });
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+ const selector: string = 'div.card-panel';
+
+ const items: DataItem[] = await fetchItems($, selector, targetUrl, country, limit);
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('a.brand-logo img').attr('src') ? new URL($('a.brand-logo img').attr('src') as string, baseUrl).href : undefined,
+ author: title.split(/\|/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:category?/:country?',
+ name: 'Category',
+ url: 'app-sales.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/app-sales/highlights',
+ parameters: {
+ category: {
+ description: 'Category, `highlights` as Highlights by default',
+ options: [
+ {
+ label: 'Highlights',
+ value: 'highlights',
+ },
+ {
+ label: 'Active Sales',
+ value: 'activesales',
+ },
+ {
+ label: 'Now Free',
+ value: 'nowfree',
+ },
+ ],
+ },
+ country: {
+ description: 'Country ID, `us` as United States by default',
+ options: [
+ {
+ label: 'United States',
+ value: 'us',
+ },
+ {
+ label: 'Austria',
+ value: 'at',
+ },
+ {
+ label: 'Australia',
+ value: 'au',
+ },
+ {
+ label: 'Brazil',
+ value: 'br',
+ },
+ {
+ label: 'Canada',
+ value: 'ca',
+ },
+ {
+ label: 'France',
+ value: 'fr',
+ },
+ {
+ label: 'Germany',
+ value: 'de',
+ },
+ {
+ label: 'India',
+ value: 'in',
+ },
+ {
+ label: 'Italy',
+ value: 'it',
+ },
+ {
+ label: 'Netherlands',
+ value: 'nl',
+ },
+ {
+ label: 'Poland',
+ value: 'pl',
+ },
+ {
+ label: 'Russia',
+ value: 'ru',
+ },
+ {
+ label: 'Spain',
+ value: 'es',
+ },
+ {
+ label: 'Sweden',
+ value: 'se',
+ },
+ {
+ label: 'Great Britain',
+ value: 'gb',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+To subscribe to [Highlights](https://www.app-sales.net/highlights/), where the source URL is \`https://www.app-sales.net/highlights/\`, extract the certain parts from this URL to be used as parameters, resulting in the route as [\`/app-sales/highlights\`](https://rsshub.app/app-sales/highlights).
+:::
+
+| Highlights | Active Sales | Now Free |
+| ---------- | ------------ | -------- |
+| highlights | activesales | nowfree |
+
+
+ More countries
+
+| Currency | Country | ID |
+| -------- | ------------- | --- |
+| USD | United States | us |
+| EUR | Austria | at |
+| AUD | Australia | au |
+| BRL | Brazil | br |
+| CAD | Canada | ca |
+| EUR | France | fr |
+| EUR | Germany | de |
+| INR | India | in |
+| EUR | Italy | it |
+| EUR | Netherlands | nl |
+| PLN | Poland | pl |
+| RUB | Russia | ru |
+| EUR | Spain | es |
+| SEK | Sweden | se |
+| GBP | Great Britain | gb |
+
+
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['app-sales.net/:category'],
+ target: (params) => {
+ const category: string = params.category;
+
+ return `/app-sales${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: 'Highlights',
+ source: ['app-sales.net/highlights'],
+ target: '/highlights',
+ },
+ {
+ title: 'Active Sales',
+ source: ['app-sales.net/activesales'],
+ target: '/activesales',
+ },
+ {
+ title: 'Now Free',
+ source: ['app-sales.net/nowfree'],
+ target: '/nowfree',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/app-sales/mostwanted.ts b/lib/routes/app-sales/mostwanted.ts
new file mode 100644
index 00000000000000..31f16a1988b1ec
--- /dev/null
+++ b/lib/routes/app-sales/mostwanted.ts
@@ -0,0 +1,195 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import { baseUrl, fetchItems } from './util';
+
+export const handler = async (ctx: Context): Promise => {
+ const { time = '24h', country = 'us' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const targetUrl: string = new URL('mostwanted/', baseUrl).href;
+
+ const response = await ofetch(targetUrl, {
+ headers: {
+ Cookie: `countryId=${country};`,
+ },
+ });
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+ const selector: string = time ? `div[id$="-${time}"] div.card-panel` : 'div.card-panel';
+
+ const items: DataItem[] = await fetchItems($, selector, targetUrl, country, limit);
+
+ const title: string = $('title').text();
+ const tabTitle: string = $(`ul.tabs li.tab a[href$="-${time}"]`).text();
+
+ return {
+ title: `${title}${tabTitle ? ` - ${tabTitle}` : ''}`,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('a.brand-logo img').attr('src') ? new URL($('a.brand-logo img').attr('src') as string, baseUrl).href : undefined,
+ author: title.split(/\|/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/mostwanted/:time?/:country?',
+ name: 'Watchlist Charts',
+ url: 'app-sales.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/app-sales/mostwanted',
+ parameters: {
+ time: {
+ description: 'Time, `24h` as Last 24h by default',
+ options: [
+ {
+ label: 'Last 24h',
+ value: '24h',
+ },
+ {
+ label: 'Last Week',
+ value: 'week',
+ },
+ {
+ label: 'All Time',
+ value: 'alltime',
+ },
+ ],
+ },
+ country: {
+ description: 'Country ID, `us` as United States by default',
+ options: [
+ {
+ label: 'United States',
+ value: 'us',
+ },
+ {
+ label: 'Austria',
+ value: 'at',
+ },
+ {
+ label: 'Australia',
+ value: 'au',
+ },
+ {
+ label: 'Brazil',
+ value: 'br',
+ },
+ {
+ label: 'Canada',
+ value: 'ca',
+ },
+ {
+ label: 'France',
+ value: 'fr',
+ },
+ {
+ label: 'Germany',
+ value: 'de',
+ },
+ {
+ label: 'India',
+ value: 'in',
+ },
+ {
+ label: 'Italy',
+ value: 'it',
+ },
+ {
+ label: 'Netherlands',
+ value: 'nl',
+ },
+ {
+ label: 'Poland',
+ value: 'pl',
+ },
+ {
+ label: 'Russia',
+ value: 'ru',
+ },
+ {
+ label: 'Spain',
+ value: 'es',
+ },
+ {
+ label: 'Sweden',
+ value: 'se',
+ },
+ {
+ label: 'Great Britain',
+ value: 'gb',
+ },
+ ],
+ },
+ },
+ description: `
+| Last 24h | Last Week | All Time |
+| -------- | --------- | -------- |
+| 24h | week | alltime |
+
+
+ More countries
+
+| Currency | Country | ID |
+| -------- | ------------- | --- |
+| USD | United States | us |
+| EUR | Austria | at |
+| AUD | Australia | au |
+| BRL | Brazil | br |
+| CAD | Canada | ca |
+| EUR | France | fr |
+| EUR | Germany | de |
+| INR | India | in |
+| EUR | Italy | it |
+| EUR | Netherlands | nl |
+| PLN | Poland | pl |
+| RUB | Russia | ru |
+| EUR | Spain | es |
+| SEK | Sweden | se |
+| GBP | Great Britain | gb |
+
+
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['app-sales.net/mostwanted'],
+ target: '/mostwanted',
+ },
+ {
+ title: 'Watchlist Charts - Last 24h',
+ source: ['app-sales.net/mostwanted'],
+ target: '/mostwanted/24h',
+ },
+ {
+ title: 'Watchlist Charts - Last Week',
+ source: ['app-sales.net/mostwanted'],
+ target: '/mostwanted/week',
+ },
+ {
+ title: 'Watchlist Charts - All Time',
+ source: ['app-sales.net/mostwanted'],
+ target: '/mostwanted/alltime',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/app-sales/namespace.ts b/lib/routes/app-sales/namespace.ts
new file mode 100644
index 00000000000000..80591a88796db8
--- /dev/null
+++ b/lib/routes/app-sales/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AppSales',
+ url: 'app-sales.net',
+ categories: ['program-update'],
+ description: 'Most recent discounted and temporarily free Android apps and games on Google Play',
+ lang: 'en',
+};
diff --git a/lib/routes/app-sales/templates/description.art b/lib/routes/app-sales/templates/description.art
new file mode 100644
index 00000000000000..d57823de7f227b
--- /dev/null
+++ b/lib/routes/app-sales/templates/description.art
@@ -0,0 +1,120 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if appName }}
+
+
+
+
+ Name
+
+
+ {{ appName }}
+
+
+ {{ if appDev }}
+
+
+ Developer
+
+
+ {{ appDev }}
+
+
+ {{ /if }}
+ {{ if appNote }}
+
+
+ Note
+
+
+ {{ appNote }}
+
+
+ {{ /if }}
+ {{ if rating }}
+
+
+ Rating
+
+
+ {{ rating }}
+
+
+ {{ /if }}
+ {{ if downloads }}
+
+
+ Downloads
+
+
+ {{ downloads }}
+
+
+ {{ /if }}
+ {{ if bookmarks }}
+
+
+ Bookmarks
+
+
+ {{ bookmarks }}
+
+
+ {{ /if }}
+ {{ if priceNew }}
+
+
+ Price
+
+
+
+
+ {{ priceNew }}
+
+
+ {{ if priceOld }}
+
+
+
+ {{ priceOld }}
+
+
+
+ {{ /if }}
+ {{ if priceDisco }}
+
+
+ {{ priceDisco }}
+
+
+ {{ /if }}
+
+
+ {{ /if }}
+ {{ if linkUrl }}
+
+
+ Link
+
+
+
+ {{ linkUrl }}
+
+
+
+ {{ /if }}
+
+
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/app-sales/util.ts b/lib/routes/app-sales/util.ts
new file mode 100644
index 00000000000000..8e81daae7d7f53
--- /dev/null
+++ b/lib/routes/app-sales/util.ts
@@ -0,0 +1,162 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+
+import type { DataItem } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { art } from '@/utils/render';
+
+const baseUrl: string = 'https://www.app-sales.net';
+
+/**
+ * Formats price change information into a standardized tag
+ * @param priceOld - Old price
+ * @param priceNew - New price
+ * @param priceDisco - Discount
+ * @returns Formatted price change tag string. Returns empty string when no new price exists.
+ */
+const formatPriceChangeTag = (priceOld: string, priceNew: string, priceDisco: string): string => (priceNew?.trim() ? `[${[priceOld && `${priceOld}→`, priceNew, priceDisco && ` ${priceDisco}`].filter(Boolean).join('')}]` : '');
+
+/**
+ * Processes DOM elements into structured data items
+ * @param $ - CheerioAPI instance
+ * @param selector - CSS selector for target elements
+ * @returns Parsed data items array.
+ */
+const processItems = ($: CheerioAPI, selector: string): DataItem[] =>
+ $(selector)
+ .toArray()
+ .map((el) => {
+ const $el: Cheerio = $(el);
+
+ const appName: string = $el.find('p.app-name').text()?.trim();
+ const appDev: string = $el.find('p.app-dev').text()?.trim();
+ const appNote: string = $el.find('p.app-dev').next('p')?.text()?.trim() ?? '';
+ const rating: string = $el.find('p.rating').contents().last().text()?.trim();
+ const downloads: string = $el.find('p.downloads').contents().last().text()?.trim();
+ const bookmarks: string = $el.find('p.bookmarks').contents().last().text()?.trim();
+ const priceNew: string = $el.find('div.price-new').text()?.trim();
+ const priceOld: string = $el.find('div.price-old').text()?.trim();
+ const priceDisco: string = $el.find('div.price-disco').text()?.trim();
+
+ const isHot: boolean = $el.hasClass('sale-hot');
+ const isFree: boolean = priceNew?.toLocaleUpperCase() === 'FREE';
+
+ const title: string = `${appName} ${formatPriceChangeTag(priceOld, priceNew, priceDisco)}`;
+ const image: string | undefined = $el.find('div.app-icon img').attr('src');
+ const linkUrl: string | undefined = $el.find('div.sale-list-action a').attr('href');
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ alt: `${appName}-${appDev}`,
+ src: image,
+ },
+ ]
+ : undefined,
+ appName,
+ appDev,
+ appNote,
+ rating,
+ downloads,
+ bookmarks,
+ priceNew,
+ priceOld,
+ priceDisco,
+ linkUrl,
+ });
+ const categories: string[] = [isHot ? 'Hot' : undefined, isFree ? 'Free' : undefined].filter(Boolean) as string[];
+ const authors: DataItem['author'] = appDev;
+ const guid: string = [appName, appDev, rating, downloads, bookmarks, priceNew].filter(Boolean).join('-');
+
+ const processedItem: DataItem = {
+ title: title.trim(),
+ description,
+ link: linkUrl,
+ category: categories,
+ author: authors,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ };
+
+ return processedItem;
+ });
+
+/**
+ * Retrieves pagination URLs from page navigation
+ * @param $ - CheerioAPI instance
+ * @param targetUrl - Base URL for relative path resolution
+ * @returns Array of absolute pagination URLs.
+ */
+const getAvailablePageUrls = ($: CheerioAPI, targetUrl: string): string[] =>
+ $('ul.pagination li.waves-effect a')
+ .slice(0, -1)
+ .toArray()
+ .filter((el) => {
+ const $el: Cheerio = $(el);
+
+ return $el.attr('href');
+ })
+ .map((el) => {
+ const $el: Cheerio = $(el);
+
+ return new URL($el.attr('href') as string, targetUrl).href;
+ });
+
+/**
+ * Aggregates items across paginated pages
+ * @param $ - Initial page CheerioAPI instance
+ * @param selector - Target element CSS selector
+ * @param targetUrl - Base URL for pagination requests
+ * @param country - Country ID for request headers
+ * @param limit - Maximum number of items to return
+ * @returns Aggregated items array within specified limit.
+ */
+const fetchItems = async ($: CheerioAPI, selector: string, targetUrl: string, country: string, limit: number): Promise => {
+ const initialItems = processItems($, selector);
+ if (initialItems.length >= limit) {
+ return initialItems.slice(0, limit);
+ }
+
+ /**
+ * Recursive helper function to process paginated URLs
+ *
+ * @param remainingUrls - Array of URLs yet to be processed
+ * @param aggregated - Accumulator for collected items
+ *
+ * @returns Promise resolving to aggregated items
+ */
+ const processPage = async (remainingUrls: string[], aggregated: DataItem[]): Promise => {
+ if (aggregated.length >= limit || remainingUrls.length === 0) {
+ return aggregated.slice(0, limit);
+ }
+
+ const [currentUrl, ...restUrls] = remainingUrls;
+
+ try {
+ const response = await ofetch(currentUrl, {
+ headers: {
+ Cookie: `countryId=${country};`,
+ },
+ });
+ const pageItems = processItems(load(response), selector);
+ const newItems = [...aggregated, ...pageItems];
+
+ return newItems.length >= limit ? newItems.slice(0, limit) : processPage(restUrls, newItems);
+ } catch {
+ return processPage(restUrls, aggregated);
+ }
+ };
+
+ return await processPage(getAvailablePageUrls($, targetUrl), initialItems);
+};
+
+export { baseUrl, fetchItems };
diff --git a/lib/routes/apple/apps.ts b/lib/routes/apple/apps.ts
new file mode 100644
index 00000000000000..fc74125f9f7690
--- /dev/null
+++ b/lib/routes/apple/apps.ts
@@ -0,0 +1,174 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { appstoreBearerToken } from './utils';
+
+const platformIds = {
+ osx: 'macOS',
+ ios: 'iOS',
+ appletvos: 'tvOS',
+};
+
+const platforms = {
+ macos: 'osx',
+ ios: 'ios',
+ tvos: 'appletvos',
+};
+
+export const route: Route = {
+ path: '/apps/update/:country/:id/:platform?',
+ categories: ['program-update'],
+ view: ViewType.Notifications,
+ example: '/apple/apps/update/us/id408709785',
+ parameters: {
+ country: 'App Store Country, obtain from the app URL, see below',
+ id: 'App id, obtain from the app URL',
+ platform: {
+ description: 'App Platform, see below, all by default',
+ options: [
+ {
+ value: 'All',
+ label: 'all',
+ },
+ {
+ value: 'iOS',
+ label: 'iOS',
+ },
+ {
+ value: 'macOS',
+ label: 'macOS',
+ },
+ {
+ value: 'tvOS',
+ label: 'tvOS',
+ },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['apps.apple.com/:country/app/:appSlug/:id', 'apps.apple.com/:country/app/:id'],
+ target: '/apps/update/:country/:id',
+ },
+ ],
+ name: 'App Update',
+ maintainers: ['EkkoG', 'nczitzk'],
+ handler,
+ description: `
+::: tip
+ For example, the URL of [GarageBand](https://apps.apple.com/us/app/garageband/id408709785) in the App Store is \`https://apps.apple.com/us/app/garageband/id408709785\`. In this case, the \`App Store Country\` parameter for the route is \`us\`, and the \`App id\` parameter is \`id408709785\`. So the route should be [\`/apple/apps/update/us/id408709785\`](https://rsshub.app/apple/apps/update/us/id408709785).
+:::`,
+};
+
+async function handler(ctx) {
+ const { country, id } = ctx.req.param();
+ let { platform } = ctx.req.param();
+
+ let platformId;
+
+ if (platform && platform !== 'all') {
+ platform = platform.toLowerCase();
+ platformId = Object.hasOwn(platforms, platform) ? platforms[platform] : platform;
+ }
+ platform = undefined;
+
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 100;
+
+ const rootUrl = 'https://apps.apple.com';
+ const currentUrl = new URL(`${country}/app/${id}`, rootUrl).href;
+
+ const bearer = await appstoreBearerToken();
+
+ const response = await ofetch(`https://amp-api-edge.apps.apple.com/v1/catalog/${country}/apps/${id.replace('id', '')}`, {
+ headers: {
+ authorization: `Bearer ${bearer}`,
+ origin: 'https://apps.apple.com',
+ },
+ query: {
+ platform: 'iphone',
+ additionalPlatforms: 'appletv,ipad,iphone,mac,realityDevice,watch',
+ extend: 'accessibility,accessibilityDetails,ageRating,backgroundAssetsInfo,backgroundAssetsInfoWithOptional,customArtwork,customDeepLink,customIconArtwork,customPromotionalText,customScreenshotsByType,customVideoPreviewsByType,description,expectedReleaseDateDisplayFormat,fileSizeByDevice,gameDisplayName,iconArtwork,installSizeByDeviceInBytes,messagesScreenshots,miniGamesDeepLink,minimumOSVersion,privacy,privacyDetails,privacyPolicyUrl,remoteControllerRequirement,requirementsByDeviceFamily,supportURLForLanguage,supportedGameCenterFeatures,supportsFunCamera,supportsSharePlay,versionHistory,websiteUrl',
+ 'extend[app-events]': 'description,productArtwork,productVideo',
+ include: 'alternate-apps,app-bundles,customers-also-bought-apps,developer,developer-other-apps,merchandised-in-apps,related-editorial-items,reviews,top-in-apps',
+ 'include[apps]': 'app-events',
+ 'availableIn[app-events]': 'future',
+ 'sparseLimit[apps:customers-also-bought-apps]': 40,
+ 'sparseLimit[apps:developer-other-apps]': 40,
+ 'sparseLimit[apps:related-editorial-items]': 40,
+ 'limit[reviews]': 8,
+ l: 'en-US',
+ },
+ });
+
+ const attributes = response.data[0].attributes;
+
+ const appName = attributes.name;
+ const artistName = attributes.artistName;
+ const platformAttributes = attributes.platformAttributes;
+
+ let items = [];
+ let title = '';
+ let description = '';
+ let image = '';
+
+ if (platformId && Object.hasOwn(platformAttributes, platformId)) {
+ platform = Object.hasOwn(platformIds, platformId) ? platformIds[platformId] : platformId;
+
+ const platformAttribute = platformAttributes[platformId];
+
+ items = platformAttribute.versionHistory;
+ title = `${appName}${platform ? ` for ${platform} ` : ' '}`;
+ description = platformAttribute.description.standard;
+ image = platformAttribute.iconArtwork?.url?.replace('{w}x{h}{c}.{f}', '3000x3000bb.webp');
+ } else {
+ title = appName;
+ for (const pid of Object.keys(platformAttributes)) {
+ const platformAttribute = platformAttributes[pid];
+ items = [
+ ...items,
+ ...platformAttribute.versionHistory.map((v) => ({
+ ...v,
+ platformId: pid,
+ })),
+ ];
+ description = platformAttribute.description.standard;
+ image = platformAttribute.iconArtwork?.url?.replace('{w}x{h}{c}.{f}', '3000x3000bb.webp');
+ }
+ }
+
+ items = items.slice(0, limit).map((item) => {
+ const pid = item.platformId ?? platformId;
+ const p = platform ?? (Object.hasOwn(platformIds, pid) ? platformIds[pid] : pid);
+
+ return {
+ title: `${appName} ${item.versionDisplay} for ${p}`,
+ link: currentUrl,
+ description: item.releaseNotes?.replaceAll('\n', ' '),
+ category: [p],
+ guid: `apple/apps/${country}/${id}/${pid}#${item.versionDisplay}`,
+ pubDate: parseDate(item.releaseTimestamp),
+ };
+ });
+
+ return {
+ item: items,
+ title: `${title} - Apple App Store`,
+ link: currentUrl,
+ description: description?.replaceAll('\n', ' '),
+ image,
+ logo: image,
+ subtitle: appName,
+ author: artistName,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/apple/design.ts b/lib/routes/apple/design.ts
new file mode 100644
index 00000000000000..db5d47045323ae
--- /dev/null
+++ b/lib/routes/apple/design.ts
@@ -0,0 +1,55 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import md5 from '@/utils/md5';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ categories: ['design'],
+ example: '/apple/design',
+ handler,
+ maintainers: ['jean-jacket'],
+ name: 'Design updates',
+ path: '/design',
+ url: 'developer.apple.com/design/whats-new/',
+};
+
+async function handler() {
+ const LINK = 'https://developer.apple.com/design/whats-new/';
+
+ const response = await ofetch(LINK);
+ const $ = load(response);
+
+ const items = $('table')
+ .toArray()
+ .flatMap((item) => {
+ const table = $(item);
+ const date = table.find('.date').first().text();
+
+ return table
+ .find('.topic-item')
+ .toArray()
+ .map((row) => {
+ const update = $(row);
+ const titleTag = update.find('span.topic-title a');
+ const title = titleTag.text();
+ const link = `https://developer.apple.com${titleTag.attr('href')}`;
+ const description = update.find('span.description').text();
+
+ return {
+ description,
+ guid: md5(`${title}${description}${date}`),
+ link,
+ pubDate: parseDate(date),
+ title,
+ };
+ });
+ });
+
+ return {
+ item: items,
+ link: LINK,
+ title: 'Apple design updates',
+ };
+}
diff --git a/lib/routes/apple/exchange-repair.ts b/lib/routes/apple/exchange-repair.ts
new file mode 100644
index 00000000000000..1f879c470e4ca6
--- /dev/null
+++ b/lib/routes/apple/exchange-repair.ts
@@ -0,0 +1,75 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const host = 'https://support.apple.com/';
+
+export const route: Route = {
+ path: '/exchange_repair/:country?',
+ categories: ['other'],
+ example: '/apple/exchange_repair',
+ parameters: { country: 'country code in apple.com URL (exception: for `United States` please use `us`), default to China `cn`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['support.apple.com/:country/service-programs'],
+ target: '/exchange_repair/:country',
+ },
+ ],
+ name: 'Exchange and Repair Extension Programs',
+ maintainers: ['metowolf', 'HenryQW', 'kt286'],
+ handler,
+};
+
+async function handler(ctx) {
+ const country = ctx.req.param('country') ?? '';
+ const link = new URL(`${country}/service-programs`, host).href;
+
+ const response = await got(link);
+ const $ = load(response.data);
+ const list = $('section.as-container-column')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('.icon-chevronright').parent();
+ return {
+ title: a.text(),
+ link: new URL(a.attr('href'), host).href,
+ pubDate: parseDate(item.find('.note').text(), ['MMMM D, YYYY', 'D MMMM YYYY', 'YYYY 年 M 月 D 日']),
+ };
+ });
+
+ const out = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $$ = load(response.data);
+
+ // delete input and dropdown elements
+ $$('div.as-sn-lookup-wrapper').remove();
+ $$('div.as-dropdown-wrapper').remove();
+ item.description = $$('.main').html();
+
+ item.author = 'Apple Inc.';
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `Apple - ${$('h1.as-center').text()}`,
+ link,
+ item: out,
+ };
+}
diff --git a/lib/routes/apple/namespace.ts b/lib/routes/apple/namespace.ts
new file mode 100644
index 00000000000000..7539f7966b8bc6
--- /dev/null
+++ b/lib/routes/apple/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Apple',
+ url: 'apple.com',
+ lang: 'en',
+};
diff --git a/lib/routes/apple/podcast.ts b/lib/routes/apple/podcast.ts
new file mode 100644
index 00000000000000..d73d90fe341353
--- /dev/null
+++ b/lib/routes/apple/podcast.ts
@@ -0,0 +1,107 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/podcast/:id/:region?',
+ categories: ['multimedia'],
+ example: '/apple/podcast/id1559695855/cn',
+ parameters: {
+ id: '播客id,可以在 Apple 播客app 内分享的播客的 URL 中找到',
+ region: '地區代碼,例如 cn、us、jp,預設為 cn',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['podcasts.apple.com/:region/podcast/:showName/:id', 'podcasts.apple.com/:region/podcast/:id'],
+ },
+ ],
+ name: '播客',
+ maintainers: ['Acring'],
+ handler,
+ url: 'www.apple.com/apple-podcasts/',
+};
+
+async function handler(ctx) {
+ const { id, region } = ctx.req.param();
+ const numericId = id.match(/id(\d+)/)?.[1];
+ const baseUrl = 'https://podcasts.apple.com';
+ const link = `${baseUrl}/${region || `cn`}/podcast/${id}`;
+
+ const response = await ofetch(link);
+
+ const $ = load(response);
+
+ const serializedServerData = JSON.parse($('#serialized-server-data').text());
+ const header = serializedServerData[0].data.shelves.find((item) => item.contentType === 'showHeaderRegular').items[0];
+
+ const bearerToken = await cache.tryGet(
+ 'apple:podcast:bearer',
+ async () => {
+ const moduleAddress = new URL($('head script[type="module"]').attr('src'), baseUrl).href;
+ const modulesResponse = await ofetch(moduleAddress, {
+ parseResponse: (txt) => txt,
+ });
+ const bearerToken = modulesResponse.match(/="(eyJhbGci.*?)",/)[1];
+
+ return bearerToken as string;
+ },
+ config.cache.contentExpire,
+ false
+ );
+
+ const episodeReponse = await ofetch(`https://amp-api.podcasts.apple.com/v1/catalog/us/podcasts/${numericId}/episodes`, {
+ query: {
+ 'extend[podcast-channels]': 'editorialArtwork,subscriptionArtwork,subscriptionOffers',
+ include: 'channel',
+ limit: 25,
+ with: 'entitlements',
+ l: 'en-US',
+ },
+ headers: {
+ Authorization: `Bearer ${bearerToken}`,
+ Origin: baseUrl,
+ },
+ });
+
+ const episodes = episodeReponse.data.map(({ attributes: item }) => {
+ // Try to keep line breaks in the description
+ const offer = item.offers[0];
+
+ return {
+ title: item.name,
+ enclosure_url: item.assetUrl || offer.hlsUrl,
+ enclosure_type: item.assetUrl ? 'audio/mp4' : 'application/vnd.apple.mpegurl',
+ itunes_duration: (item.durationInMilliseconds || offer.durationInMilliseconds) / 1000,
+ link: item.url,
+ pubDate: parseDate(item.releaseDateTime),
+ description: item.description.standard.replaceAll('\n', ' '),
+ author: item.artistName,
+ itunes_item_image: item.artwork.url.replace(/\{w\}x\{h\}(?:\{c\}|bb)\.\{f\}/, '3000x3000bb.webp'),
+ category: item.genreNames,
+ };
+ });
+
+ const channel = episodeReponse.data.find((d) => d.type === 'podcast-episodes').relationships.channel.data.find((d) => d.type === 'podcast-channels')?.attributes;
+
+ return {
+ title: channel?.name ?? header.title,
+ link: channel?.url ?? header.contextAction.podcastOffer.storeUrl,
+ itunes_author: header.contextAction.podcastOffer.author,
+ item: episodes,
+ description: (header.description || channel?.description.standard)?.replaceAll('\n', ' '),
+ image: ((channel?.logoArtwork || channel?.subscriptionArtwork)?.url || header.contextAction.podcastOffer.artwork.template).replace(/\{w\}x\{h\}(?:\{c\}|bb)\.\{f\}/, '3000x3000bb.webp'),
+ itunes_category: header.metadata.find((d) => Object.hasOwn(d, 'category')).category?.title || header.metadata.find((d) => Object.hasOwn(d, 'category')).category,
+ };
+}
diff --git a/lib/routes/apple/security-releases.ts b/lib/routes/apple/security-releases.ts
new file mode 100644
index 00000000000000..723441955a425a
--- /dev/null
+++ b/lib/routes/apple/security-releases.ts
@@ -0,0 +1,181 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const { language = 'en-us' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'https://support.apple.com';
+ const targetUrl: string = new URL(`${language}/100100`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+
+ const $trEls: Cheerio = $('table.gb-table tbody tr');
+ const headers: string[] = $trEls
+ .find('th')
+ .toArray()
+ .map((el) => $(el).text());
+
+ let items: DataItem[] = [];
+
+ items = $trEls
+ .slice(1, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const titleEl: Cheerio = $el.find('td').first();
+ const title: string = titleEl.contents().first().text();
+ const description: string | undefined = art(path.join(__dirname, 'templates/security-releases.art'), {
+ headers,
+ infos: $el
+ .find('td')
+ .toArray()
+ .map((el) => $(el).html() ?? ''),
+ });
+ const pubDateStr: string | undefined = $el.find('td').last().text();
+ const linkUrl: string | undefined = titleEl.find('a.gb-anchor').attr('href');
+ const authors: DataItem['author'] = $el.find('meta[property="og:site_name"]').attr('content');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, ['DD MMM YYYY', 'YYYY 年 MM 月 DD 日']) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr, ['DD MMM YYYY', 'YYYY 年 MM 月 DD 日']) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = item.title ?? $$('h1.gb-header').text();
+
+ $$('h1.gb-header').remove();
+
+ const description: string | undefined =
+ item.description +
+ art(path.join(__dirname, 'templates/security-releases.art'), {
+ description: $$('div#sections').html(),
+ });
+ const pubDateStr: string | undefined = detailResponse.match(/publish_date:\s"(\d{8})",/, '')?.[1];
+ const authors: DataItem['author'] = $$('meta[property="og:site_name"]').attr('content');
+ const upDatedStr: string | undefined = $$('.time').text() || pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, 'MMDDYYYY') : item.pubDate,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr, 'MMDDYYYY') : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/security-releases/:language?',
+ name: 'Security releases',
+ url: 'support.apple.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/apple/security-releases',
+ parameters: {
+ language: {
+ description: 'Language, `en-us` by default',
+ },
+ },
+ description: `::: tip
+To subscribe to [Apple security releases](https://support.apple.com/en-us/100100), where the source URL is \`https://support.apple.com/en-us/100100\`, extract the certain parts from this URL to be used as parameters, resulting in the route as [\`/apple/security-releases/en-us\`](https://rsshub.app/apple/security-releases/en-us).
+:::
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['support.apple.com/:language/100100'],
+ target: (params) => {
+ const language: string = params.language;
+
+ return `/apple/security-releases${language ? `/${language}` : ''}`;
+ },
+ },
+ ],
+ view: ViewType.Articles,
+
+ zh: {
+ path: '/security-releases/:language?',
+ name: '安全性发布',
+ url: 'support.apple.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/apple/security-releases',
+ parameters: {
+ language: {
+ description: '语言,默认为 `en-us`,可在对应页 URL 中找到',
+ },
+ },
+ description: `::: tip
+若订阅 [Apple 安全性发布](https://support.apple.com/zh-cn/100100),网址为 \`https://support.apple.com/zh-cn/100100\`,请截取 \`https://support.apple.com/\` 到末尾 \`/100100\` 的部分 \`zh-cn\` 作为 \`language\` 参数填入,此时目标路由为 [\`/apple/security-releases/zh-cn\`](https://rsshub.app/apple/security-releases/zh-cn)。
+:::
+`,
+ },
+};
diff --git a/lib/routes/apple/templates/security-releases.art b/lib/routes/apple/templates/security-releases.art
new file mode 100644
index 00000000000000..9801d5ce572a1b
--- /dev/null
+++ b/lib/routes/apple/templates/security-releases.art
@@ -0,0 +1,28 @@
+{{ if headers && infos }}
+
+
+ {{ if headers.length > 0 }}
+
+ {{ each headers header }}
+
+ {{ header }}
+
+ {{ /each }}
+
+ {{ /if }}
+ {{ if infos.length > 0 }}
+
+ {{ each infos info }}
+
+ {{@ info }}
+
+ {{ /each }}
+
+ {{ /if }}
+
+
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/apple/utils.ts b/lib/routes/apple/utils.ts
new file mode 100644
index 00000000000000..04cbf4b86776c1
--- /dev/null
+++ b/lib/routes/apple/utils.ts
@@ -0,0 +1,26 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+// App Store and Podcast use different bearer tokens
+export const appstoreBearerToken = () =>
+ cache.tryGet(
+ 'apple:podcast:bearer',
+ async () => {
+ const baseUrl = 'https://apps.apple.com';
+ const response = await ofetch(`${baseUrl}/us/iphone/today`);
+ const $ = load(response);
+
+ const moduleAddress = new URL($('head script[type="module"]').attr('src'), baseUrl).href;
+ const modulesResponse = await ofetch(moduleAddress, {
+ parseResponse: (txt) => txt,
+ });
+ const bearerToken = modulesResponse.match(/="(eyJhbGci.*?)"/)[1];
+
+ return bearerToken as string;
+ },
+ config.cache.contentExpire,
+ false
+ );
diff --git a/lib/routes/appleinsider/index.ts b/lib/routes/appleinsider/index.ts
new file mode 100644
index 00000000000000..227b4c299d5488
--- /dev/null
+++ b/lib/routes/appleinsider/index.ts
@@ -0,0 +1,89 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/appleinsider',
+ parameters: { category: 'Category, see below, News by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['appleinsider.com/:category', 'appleinsider.com/'],
+ target: '/:category',
+ },
+ ],
+ name: 'Category',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| News | Reviews | How-tos |
+| ---- | ------- | ------- |
+| | reviews | how-to |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? '';
+
+ const rootUrl = 'https://appleinsider.com';
+ const currentUrl = `${rootUrl}${category ? `/${category}` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $(`${category === '' ? '#news-river ' : ''}.river`)
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 30)
+ .toArray()
+ .map((item) => {
+ item = $(item).find('a').first();
+
+ return {
+ title: item.text(),
+ link: item.attr('href'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ content('#article-social').next().remove();
+ content('#article-hero, #article-social').remove();
+ content('.deals-widget').remove();
+
+ item.title = content('.h1-adjust').text();
+ item.author = content('.avatar-link a').attr('title');
+ item.pubDate = parseDate(content('time').first().attr('datetime'));
+ item.description = content('header').next('.row').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/appleinsider/namespace.ts b/lib/routes/appleinsider/namespace.ts
new file mode 100644
index 00000000000000..b2712ee6b3a6aa
--- /dev/null
+++ b/lib/routes/appleinsider/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AppleInsider',
+ url: 'appleinsider.com',
+ lang: 'en',
+};
diff --git a/lib/routes/appsales/index.js b/lib/routes/appsales/index.js
deleted file mode 100644
index 403b1a85c380e0..00000000000000
--- a/lib/routes/appsales/index.js
+++ /dev/null
@@ -1,66 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- ctx.params.caty = ctx.params.caty || 'highlights';
- ctx.params.time = ctx.params.time || '24h';
-
- const times = ['24h', 'week', 'alltime'];
- const rootUrl = `https://www.app-sales.net/${ctx.params.caty}/#!`;
- const response = await got({
- method: 'get',
- url: rootUrl,
- });
-
- const $ = cheerio.load(response.data);
-
- if (ctx.params.caty === 'mostwanted') {
- times.forEach((i) => {
- if (i !== ctx.params.time) {
- $(`#charts-${i}`).remove();
- }
- });
- }
-
- $('i,img.play-icon-small').remove();
-
- const items = $('div.sale-list-item')
- .map((_, item) => {
- item = $(item);
-
- const icon = item.find('div.app-icon').html();
- const name = item.find('p.app-name').text();
- const developer = item.find('p.app-dev').text();
- const oldPrice = item.find('div.price-old').text();
- const newPrice = item.find('div.price-new').text();
- const discount = item.find('div.price-disco').text();
- const rating = item.find('p.rating').text();
- const downloads = item.find('p.downloads').text();
- const bookmarks = item.find('p.bookmarks').text();
-
- const description =
- `${icon}${name}
` +
- ` Developer ${developer} ` +
- ` Bookmarks ${bookmarks} ` +
- (rating ? ` Rating ${rating} ` : '') +
- (downloads ? ` Downloads ${downloads} ` : '') +
- (discount ? ` Discount ${discount} ` : '') +
- (oldPrice ? ` Old Price ${oldPrice} ` : '') +
- (newPrice ? ` New Price ${newPrice} ` : '') +
- '
';
-
- return {
- title: `${name} ${newPrice ? '[' + newPrice + ']' : ''}`,
- link: item.find('a.waves-effect').attr('href'),
- description,
- author: developer,
- };
- })
- .get();
-
- ctx.state.data = {
- title: $('title').text(),
- link: rootUrl,
- item: items,
- };
-};
diff --git a/lib/routes/appstare/comments.ts b/lib/routes/appstare/comments.ts
new file mode 100644
index 00000000000000..cc8d072cdbcdc2
--- /dev/null
+++ b/lib/routes/appstare/comments.ts
@@ -0,0 +1,57 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const handler = async (ctx) => {
+ const country = ctx.req.param('country');
+ const appid = ctx.req.param('appid');
+ const url = `https://monitor.appstare.net/spider/appComments?country=${country}&appId=${appid}`;
+ const data = await ofetch(url);
+
+ const items = data.map((item) => ({
+ title: item.title,
+ description: `
+ ${'⭐️'.repeat(Math.floor(item.rating))}
+ ${item.review}
+ `,
+ pubDate: new Date(item.date).toUTCString(),
+ }));
+
+ const link = `https://appstare.net/data/app/comment/${appid}/${country}`;
+
+ return {
+ title: 'App Comments',
+ appID: appid,
+ country,
+ item: items,
+ link,
+ allowEmpty: true,
+ };
+};
+
+export const route: Route = {
+ path: '/comments/:country/:appid',
+ name: 'Comments',
+ url: 'appstare.net/',
+ example: '/appstare/comments/cn/989673964',
+ maintainers: ['zhixideyu'],
+ handler,
+ parameters: {
+ country: 'App Store country code, e.g., US, CN',
+ appid: 'Unique App Store application identifier (app id)',
+ },
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['appstare.net/'],
+ },
+ ],
+ description: 'Retrieve only the comments of the app from the past 7 days.',
+};
diff --git a/lib/routes/appstare/namespace.ts b/lib/routes/appstare/namespace.ts
new file mode 100644
index 00000000000000..3d2809e68a9fe9
--- /dev/null
+++ b/lib/routes/appstare/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AppStare',
+ url: 'appstare.net',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/appstore/in-app-purchase.ts b/lib/routes/appstore/in-app-purchase.ts
new file mode 100644
index 00000000000000..94688010f0c4c4
--- /dev/null
+++ b/lib/routes/appstore/in-app-purchase.ts
@@ -0,0 +1,72 @@
+import { load } from 'cheerio';
+
+import { appstoreBearerToken } from '@/routes/apple/utils';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/iap/:country/:id',
+ categories: ['program-update'],
+ example: '/appstore/iap/us/id953286746',
+ parameters: {
+ country: 'App Store Country, obtain from the app URL https://apps.apple.com/us/app/id953286746, in this case, `us`',
+ id: 'App Store app id, obtain from the app URL https://apps.apple.com/us/app/id953286746, in this case, `id953286746`',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'In-App-Purchase Price Drop Alert',
+ maintainers: ['HenryQW'],
+ handler,
+};
+
+async function handler(ctx) {
+ const country = ctx.req.param('country');
+ const id = ctx.req.param('id');
+ const link = `https://apps.apple.com/${country}/app/${id}`;
+
+ const res = await ofetch(link);
+ const $ = load(res);
+ const lang = $('html').attr('lang');
+ const mediaToken = await appstoreBearerToken();
+
+ const apiResponse = await ofetch(`https://amp-api-edge.apps.apple.com/v1/catalog/${country}/apps/${id.replace('id', '')}`, {
+ headers: {
+ authorization: `Bearer ${mediaToken}`,
+ origin: 'https://apps.apple.com',
+ },
+ query: {
+ platform: 'web',
+ include: 'merchandised-in-apps,top-in-apps,eula',
+ l: lang,
+ },
+ });
+
+ const appData = apiResponse.data[0];
+ const attributes = appData.attributes;
+
+ const platform = attributes.deviceFamilies.includes('mac') ? 'macOS' : 'iOS';
+
+ let item = [];
+
+ const iap = appData.relationships['top-in-apps'].data;
+ if (iap) {
+ item = iap.map(({ attributes }) => ({
+ title: `${attributes.name} is now ${attributes.offers[0].priceFormatted}`,
+ link: attributes.url,
+ guid: `${attributes.url}:${attributes.offerName}:${attributes.offers[0].priceString}`,
+ description: attributes.artwork ? attributes.description.standard + ` ` : attributes.description.standard,
+ }));
+ }
+
+ return {
+ title: `${country.toLowerCase() === 'cn' ? '内购限免提醒' : 'IAP price watcher'}: ${attributes.name} for ${platform}`,
+ link,
+ item,
+ };
+}
diff --git a/lib/routes/appstore/namespace.ts b/lib/routes/appstore/namespace.ts
new file mode 100644
index 00000000000000..cc4f08444621c0
--- /dev/null
+++ b/lib/routes/appstore/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'App Store/Mac App Store',
+ url: 'apps.apple.com',
+ lang: 'en',
+};
diff --git a/lib/routes/appstore/price.ts b/lib/routes/appstore/price.ts
new file mode 100644
index 00000000000000..829df08ea337c9
--- /dev/null
+++ b/lib/routes/appstore/price.ts
@@ -0,0 +1,83 @@
+import currency from 'currency-symbol-map';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/price/:country/:type/:id',
+ categories: ['program-update'],
+ example: '/appstore/price/us/mac/id1152443474',
+ parameters: {
+ country: 'App Store Country, obtain from the app URL https://apps.apple.com/us/app/id1152443474, in this case, `us`',
+ type: 'App type,either `iOS` or `mac`',
+ id: 'App Store app id, obtain from the app URL https://apps.apple.com/us/app/id1152443474, in this case, `id1152443474`',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['apps.apple.com/'],
+ },
+ ],
+ name: 'Price Drop',
+ maintainers: ['HenryQW'],
+ handler,
+ url: 'apps.apple.com/',
+};
+
+async function handler(ctx) {
+ const country = ctx.req.param('country');
+ const type = ctx.req.param('type').toLowerCase() === 'mac' ? 'macapps' : 'apps';
+ const id = ctx.req.param('id').replace('id', '');
+
+ const url = `https://buster.cheapcharts.de/v1/DetailData.php?&store=itunes&country=${country}&itemType=${type}&idInStore=${id}`;
+
+ const res = await got({
+ method: 'get',
+ url,
+ headers: {
+ Referer: `http://www.cheapcharts.info/itunes/${country}/apps/detail-view/${id}`,
+ },
+ });
+
+ if (!res.data.results) {
+ const unsupported = '当前 app 未被收录. Price monitor is not available for this app.';
+ return {
+ title: unsupported,
+ item: [{ title: unsupported }],
+ };
+ }
+
+ let result = res.data.results.apps;
+ if (type === 'macapps') {
+ result = res.data.results.macapps;
+ }
+
+ const item = [];
+
+ const title = `${country === 'cn' ? '限免提醒' : 'Price watcher'}: ${result.title} for ${type === 'macapps' ? 'macOS' : 'iOS'}`;
+
+ const link = `https://apps.apple.com/${country}/app/id${id}`;
+
+ if (result.priceDropIndicator === -1) {
+ const single = {
+ title: `${result.title} is now ${currency(result.currency)}${result.price} `,
+ description: `Go to App Store `,
+ link,
+ guid: id + result.priceLastChangeDate,
+ };
+ item.push(single);
+ }
+ return {
+ title,
+ link,
+ item,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/appstore/xianmian.ts b/lib/routes/appstore/xianmian.ts
new file mode 100644
index 00000000000000..c4cabc5cd8908c
--- /dev/null
+++ b/lib/routes/appstore/xianmian.ts
@@ -0,0 +1,53 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/xianmian',
+ categories: ['program-update'],
+ example: '/appstore/xianmian',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['app.so/xianmian'],
+ },
+ ],
+ name: '每日精品限免 / 促销应用(鲜面连线 by AppSo)',
+ maintainers: ['Andiedie'],
+ handler,
+ url: 'app.so/xianmian',
+};
+
+async function handler() {
+ const {
+ data: { objects: data },
+ } = await got.get('https://app.so/api/v5/appso/discount/?platform=web&limit=10');
+
+ return {
+ title: '每日精品限免 / 促销应用',
+ link: 'http://app.so/xianmian/',
+ description: '鲜面连线 by AppSo:每日精品限免 / 促销应用',
+ item: data.map((item) => ({
+ title: `「${item.discount_info[0].discounted_price === '0.00' ? '免费' : '降价'}」${item.app.name}`,
+ description: `
+
+
+ 原价:¥${item.discount_info[0].original_price} -> 现价:¥${item.discount_info[0].discounted_price}
+
+ 平台:${item.app.download_link[0].device}
+
+ ${item.content}
+ `,
+ pubDate: parseDate(item.updated_at * 1000),
+ link: item.app.download_link[0].link,
+ })),
+ };
+}
diff --git a/lib/routes/appstorrent/namespace.ts b/lib/routes/appstorrent/namespace.ts
new file mode 100644
index 00000000000000..f95fc97cb516bc
--- /dev/null
+++ b/lib/routes/appstorrent/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AppsTorrent',
+ url: 'appstorrent.ru',
+ lang: 'ru',
+};
diff --git a/lib/routes/appstorrent/programs.ts b/lib/routes/appstorrent/programs.ts
new file mode 100644
index 00000000000000..3e273623bf3560
--- /dev/null
+++ b/lib/routes/appstorrent/programs.ts
@@ -0,0 +1,92 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import dayjs from 'dayjs';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import type { Options } from '@/utils/got';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/programs',
+ categories: ['program-update'],
+ example: '/appstorrent/programs',
+ name: 'Programs',
+ maintainers: ['xzzpig'],
+ handler,
+ url: 'appstorrent.ru/programs/',
+};
+
+async function handler(ctx?: Context): Promise {
+ const limit = ctx?.req.query('limit') ? Number.parseInt(ctx?.req.query('limit') ?? '20') : 20;
+ const baseUrl = 'https://appstorrent.ru';
+ const currentUrl = `${baseUrl}/programs/`;
+ const gotOptions: Options = {
+ http2: true,
+ };
+
+ const response = await got(currentUrl, gotOptions);
+ const $ = load(response.data as any);
+
+ const selector = 'article.soft-item:not(.locked)';
+ const list = $(selector)
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ return {
+ title: $item.find('.subtitle').text().trim(),
+ link: $item.find('.subtitle a').attr('href')!,
+ category: [$item.find('.info .category').text().trim()],
+ version: $item.find('.version').text(),
+ architecture: $item.find('.architecture').text().trim(),
+ size: $item.find('.size').text().trim(),
+ };
+ });
+
+ const items: DataItem[] = await Promise.all(
+ list.map(
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link, gotOptions);
+ const $ = load(response.data as any);
+
+ const pubDate = parseDate($('.tech-info .date-news a').attr('href')?.replace('https://appstorrent.ru/', '') ?? '');
+
+ return {
+ title: item.title,
+ link: item.link,
+ category: item.category,
+ pubDate,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ cover: baseUrl + $('.main-title img').attr('src')?.trim(),
+ title: item.title,
+ pubDate: dayjs(pubDate).format('YYYY-MM-DD'),
+ version: item.version,
+ architecture: item.architecture,
+ compatibility: $('div.right > div.info > div.right-container > div:nth-child(5) > div > span:nth-child(2) > a').text(),
+ size: item.size,
+ activation: $('div.right > div.info > div.right-container > div:nth-child(4) > div > span:nth-child(2) > a').text(),
+ description: $('.content .body-content').first().text(),
+ changelog: $('.content .body-content').last().text(),
+ screenshots: $('.screenshots img')
+ .toArray()
+ .map((img) => $(img).attr('src'))
+ .map((src) => baseUrl + src),
+ }),
+ } as DataItem;
+ }) as Promise
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl.toString(),
+ allowEmpty: true,
+ item: items,
+ };
+}
diff --git a/lib/routes/appstorrent/templates/description.art b/lib/routes/appstorrent/templates/description.art
new file mode 100644
index 00000000000000..2e86e168257444
--- /dev/null
+++ b/lib/routes/appstorrent/templates/description.art
@@ -0,0 +1,22 @@
+
+
+
{{ title }}
+Public Date : {{pubDate}}
+Version : {{version}}
+Architecture : {{architecture}}
+Compactibility : {{compatibility}}
+Size : {{size}}
+Activation : {{activation}}
+
+Description :
+
+{{ description }}
+
+Change Log :
+
+{{ changelog }}
+
+Screenshots
+{{each screenshots}}
+
+{{/each}}
\ No newline at end of file
diff --git a/lib/routes/aqara/community.ts b/lib/routes/aqara/community.ts
new file mode 100644
index 00000000000000..2d9a136db966f1
--- /dev/null
+++ b/lib/routes/aqara/community.ts
@@ -0,0 +1,51 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/community/:id?/:keyword?',
+ categories: ['other'],
+ example: '/aqara/community',
+ parameters: { id: '分类 id,可在对应分类页 URL 中找到,默认为全部', keyword: '关键字,默认为空' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '社区',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '';
+ const keyword = ctx.req.param('keyword') ?? '';
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 100;
+
+ const rootUrl = 'https://community.aqara.com';
+ const apiUrl = `${rootUrl}/api/v2/feeds?limit=${limit}&platedetail_id=${id}&keyword=${keyword}&all=1`;
+ const currentUrl = `${rootUrl}/pc/#/post${id ? `?id=${id}` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.map((item) => ({
+ title: item.feed_title,
+ link: `${rootUrl}/pc/#/post/postDetail/${item.id}`,
+ description: item.feed_content,
+ pubDate: parseDate(item.created_at),
+ author: item.user.nickname,
+ }));
+
+ return {
+ title: 'Aqara社区',
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/aqara/namespace.ts b/lib/routes/aqara/namespace.ts
new file mode 100644
index 00000000000000..d343eaea253e9f
--- /dev/null
+++ b/lib/routes/aqara/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Aqara',
+ url: 'aqara.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aqara/news.ts b/lib/routes/aqara/news.ts
new file mode 100644
index 00000000000000..898ea0dac62d1c
--- /dev/null
+++ b/lib/routes/aqara/news.ts
@@ -0,0 +1,63 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/cn/news',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 35;
+
+ const rootUrl = 'https://www.aqara.cn';
+ const currentUrl = new URL('news', rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ let items = response
+ .match(/(parm\.newsTitle[\S\s]*?arr\.push\(parm\))/g)
+ .slice(0, limit)
+ .map((item) => ({
+ title: item.match(/parm\.newsTitle = '(.*?)'/)[1],
+ link: new URL(item.match(/parm\.contentHerf = '(\d+)'/)[1], rootUrl).href,
+ pubDate: parseDate(item.match(/parm\.issueTime = '(.*?)'/)[1], 'YYYY 年 MM 月 DD 日'),
+ }));
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const content = load(detailResponse);
+
+ item.title = content('h4.fnt_56').last().text();
+ item.description = content('div.news_body').html();
+ item.pubDate = parseDate(content('div.news_date').first().text(), 'YYYY 年 MM 月 DD 日');
+
+ return item;
+ })
+ )
+ );
+
+ const icon = $('link[rel="shortcut icon"]').prop('href').split('?')[0];
+
+ return {
+ item: items,
+ title: $('title').text(),
+ link: currentUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: 'zh-cn',
+ image: $('meta[property="og:image"]').prop('content'),
+ icon,
+ logo: icon,
+ author: $('meta[name="author"]').prop('content'),
+ };
+}
diff --git a/lib/routes/aqara/post.ts b/lib/routes/aqara/post.ts
new file mode 100644
index 00000000000000..d64e1d96a48d46
--- /dev/null
+++ b/lib/routes/aqara/post.ts
@@ -0,0 +1,103 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '*',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50;
+
+ const rootUrl = 'https://aqara.com';
+ const apiSlug = 'wp-json/wp/v2';
+
+ let filterName;
+
+ let currentUrl = rootUrl;
+ let apiUrl = new URL(`${apiSlug}/posts?_embed=true&per_page=${limit}`, rootUrl).href;
+
+ const filterMatches = getSubPath(ctx).match(/^\/([^/]*)\/([^/]*)\/(.*)$/);
+
+ if (filterMatches) {
+ const filterRegion = filterMatches[1];
+ const filterType = filterMatches[2] === 'tag' ? 'tags' : filterMatches[2] === 'category' ? 'categories' : filterMatches[2];
+ const filterKeyword = decodeURI(filterMatches[3].split('/').pop());
+ const filterApiUrl = new URL(`${filterRegion}/${apiSlug}/${filterType}?search=${filterKeyword}`, rootUrl).href;
+
+ const { data: filterResponse } = await got(filterApiUrl);
+
+ const filter = filterResponse.pop();
+
+ if (filter?.id ?? undefined) {
+ filterName = filter.name ?? filterKeyword;
+ currentUrl = filter.link ?? currentUrl;
+ apiUrl = new URL(`${filterRegion}/${apiSlug}/posts?_embed=true&per_page=${limit}&${filterType}=${filter.id}`, rootUrl).href;
+ }
+ }
+
+ const { data: response } = await got(apiUrl);
+
+ const items = response.slice(0, limit).map((item) => {
+ const terminologies = item._embedded['wp:term'];
+
+ const content = load(item.content?.rendered ?? item.content);
+
+ // To handle lazy-loaded images.
+
+ content('figure').each(function () {
+ const image = content(this).find('img');
+ const src = (image.prop('data-actualsrc') ?? image.prop('data-original') ?? image.prop('src')).replace(/(-\d+x\d+)/, '');
+ const width = image.prop('data-rawwidth') ?? image.prop('width');
+ const height = image.prop('data-rawheight') ?? image.prop('height');
+
+ content(this).replaceWith(
+ art(path.join(__dirname, 'templates/figure.art'), {
+ src,
+ width,
+ height,
+ })
+ );
+ });
+
+ return {
+ title: item.title?.rendered ?? item.title,
+ link: item.link,
+ description: content.html(),
+ author: item._embedded.author.map((a) => a.name).join('/'),
+ category: terminologies.flat().map((c) => c.name),
+ guid: item.guid?.rendered ?? item.guid,
+ pubDate: parseDate(item.date_gmt),
+ updated: parseDate(item.modified_gmt),
+ };
+ });
+
+ const { data: currentResponse } = await got(currentUrl);
+
+ const $ = load(currentResponse);
+
+ const icon = $('link[rel="apple-touch-icon"]').first().prop('href');
+ const title = $('meta[property="og:site_name"]').prop('content') ?? 'Aqara';
+
+ return {
+ item: items,
+ title: `${title}${filterName ? ` - ${filterName}` : ''}`,
+ link: currentUrl,
+ description: $('meta[property="og:title"]').prop('content'),
+ language: $('meta[property="og:locale"]').prop('content'),
+ image: $('meta[name="msapplication-TileImage"]').prop('content'),
+ icon,
+ logo: icon,
+ subtitle: $('meta[property="og:type"]').prop('content'),
+ author: title,
+ };
+}
diff --git a/lib/routes/aqara/region.ts b/lib/routes/aqara/region.ts
new file mode 100644
index 00000000000000..b321cfeb5449fe
--- /dev/null
+++ b/lib/routes/aqara/region.ts
@@ -0,0 +1,19 @@
+import type { Route } from '@/types';
+
+export const route: Route = {
+ path: '/:region/:type?',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+function handler(ctx) {
+ const types = {
+ news: 'press-release',
+ blog: 'article',
+ };
+
+ const { region = 'en', type = 'news' } = ctx.req.param();
+ const redirectTo = `/aqara/${region}/category/${types[type]}`;
+ ctx.set('redirect', redirectTo);
+}
diff --git a/lib/v2/aqara/templates/figure.art b/lib/routes/aqara/templates/figure.art
similarity index 100%
rename from lib/v2/aqara/templates/figure.art
rename to lib/routes/aqara/templates/figure.art
diff --git a/lib/routes/aqicn/aqi.ts b/lib/routes/aqicn/aqi.ts
new file mode 100644
index 00000000000000..6f07c3e636c171
--- /dev/null
+++ b/lib/routes/aqicn/aqi.ts
@@ -0,0 +1,83 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:city/:pollution?',
+ categories: ['other'],
+ example: '/aqicn/beijing/pm25',
+ parameters: {
+ city: '城市拼音或地区 ID,详见[aqicn.org](http://aqicn.org/city/)',
+ pollution: '可选择显示更详细的空气污染成分',
+ },
+ radar: [
+ {
+ source: ['aqicn.org'],
+ },
+ ],
+ name: '实时 AQI',
+ maintainers: ['ladeng07'],
+ handler,
+ url: 'aqicn.org',
+ descriptions: `
+| 参数 | 污染成分 |
+| -------- | -------- |
+| pm25 | PM2.5 |
+| pm10 | PM10 |
+| o3 | O3 |
+| no2 | NO2 |
+| so2 | SO2 |
+| co | CO |
+
+举例: [https://rsshub.app/aqicn/beijing/pm25,pm10](https://rsshub.app/aqicn/beijing/pm25,pm10)
+
+1. 显示单个污染成分,例如「pm25」, [https://rsshub.app/aqicn/beijing/pm25](https://rsshub.app/aqicn/beijing/pm25)
+2. 逗号分隔显示多个污染成分,例如「pm25,pm10」,[https://rsshub.app/aqicn/beijing/pm25,pm10](https://rsshub.app/aqicn/beijing/pm25,pm10)
+3. 城市子站 ID 获取方法:右键显示网页源代码,搜索 "idx" (带双冒号),后面的 ID 就是子站的 ID,如你给的链接 ID 是 4258,RSS 地址就是 [https://rsshub.app/aqicn/4258](https://rsshub.app/aqicn/4258)
+`,
+};
+
+async function handler(ctx) {
+ const city = ctx.req.param('city');
+ const pollution = ctx.req.param('pollution') || [];
+ const pollutionType = {
+ so2: 'so2',
+ no2: 'no2',
+ co: 'co',
+ o3: 'O3',
+ pm25: 'PM2.5',
+ pm10: 'PM10',
+ };
+ const area = Number.isNaN(Number(city)) ? city : `@${city}`;
+
+ const response = await got({
+ method: 'get',
+ url: `http://aqicn.org/aqicn/json/android/${area}/json`,
+ });
+ const data = response.data;
+ const pollutionDetailed =
+ pollution.length === 0
+ ? ''
+ : pollution
+ .split(',')
+ .map((item) => {
+ const pollutionValue = typeof data.historic[pollutionType[item]] === 'object' ? data.historic[pollutionType[item]][Object.keys(data.historic[pollutionType[item]])[0]] : data.historic[pollutionType[item]][0];
+ return `${pollutionType[item].toUpperCase()}:${pollutionValue} `;
+ })
+ .join('');
+
+ return {
+ title: `${data.namena}AQI`,
+ link: `https://aqicn.org/city/${data.ids.path}`,
+ description: `${data.namena}AQI-aqicn.org`,
+ item: [
+ {
+ title: `${data.namena}实时空气质量(AQI)${data.utimecn}`,
+ description: `${data.infocn} 风力:${data.cwind[0]} 级 AQI:${data.aqi} ${pollutionDetailed} `,
+ pubDate: parseDate(data.time * 1000),
+ guid: `${data.time}-${city}-${pollution}`,
+ link: `https://aqicn.org/city/${data.ids.path}`,
+ },
+ ],
+ };
+}
diff --git a/lib/routes/aqicn/namespace.ts b/lib/routes/aqicn/namespace.ts
new file mode 100644
index 00000000000000..a0c29c99d6d400
--- /dev/null
+++ b/lib/routes/aqicn/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '空气质量',
+ url: 'aqicn.org',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/archdaily/home.js b/lib/routes/archdaily/home.js
deleted file mode 100644
index 0874e0d206132c..00000000000000
--- a/lib/routes/archdaily/home.js
+++ /dev/null
@@ -1,47 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const parser = require('@/utils/rss-parser');
-
-module.exports = async (ctx) => {
- const feed = await parser.parseURL('https://feeds.feedburner.com/ArchdailyCN');
-
- const ProcessFeed = async (link) => {
- const response = await got({
- method: 'get',
- url: link,
- });
-
- const $ = cheerio.load(response.data);
-
- // 提取内容
- return $('#single-content').html();
- };
-
- const items = await Promise.all(
- feed.items.map(async (item) => {
- const cache = await ctx.cache.get(item.link);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const description = await ProcessFeed(item.link);
-
- const single = {
- title: item.title,
- description,
- pubDate: item.pubDate,
- link: item.link,
- author: item.author,
- };
- ctx.cache.set(item.link, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
-
- ctx.state.data = {
- title: feed.title,
- link: feed.link,
- description: feed.description,
- item: items,
- };
-};
diff --git a/lib/routes/arcteryx/namespace.ts b/lib/routes/arcteryx/namespace.ts
new file mode 100644
index 00000000000000..cc97c54bf81889
--- /dev/null
+++ b/lib/routes/arcteryx/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Arcteryx',
+ url: 'arcteryx.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/arcteryx/new-arrivals.ts b/lib/routes/arcteryx/new-arrivals.ts
new file mode 100644
index 00000000000000..6885aab2c473a6
--- /dev/null
+++ b/lib/routes/arcteryx/new-arrivals.ts
@@ -0,0 +1,76 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+import { generateRssData } from './utils';
+
+export const route: Route = {
+ path: '/new-arrivals/:country/:gender',
+ categories: ['shopping'],
+ example: '/arcteryx/new-arrivals/us/mens',
+ parameters: { country: 'country', gender: 'gender' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['arcteryx.com/:country/en/c/:gender/new-arrivals'],
+ },
+ ],
+ name: 'New Arrivals',
+ maintainers: ['EthanWng97'],
+ handler,
+ description: `Country
+
+| United States | Canada | United Kingdom |
+| ------------- | ------ | -------------- |
+| us | ca | gb |
+
+ gender
+
+| male | female |
+| ---- | ------ |
+| mens | womens |
+
+::: tip
+ Parameter \`country\` can be found within the url of \`Arcteryx\` website.
+:::`,
+};
+
+async function handler(ctx) {
+ const { country, gender } = ctx.req.param();
+ const host = `https://arcteryx.com/${country}/en/`;
+ const url = `${host}api/fredhopper/query`;
+ const productUrl = `${host}shop/`;
+ const pageUrl = `${host}c/${gender}/new-arrivals`;
+ const response = await got({
+ method: 'get',
+ url,
+ searchParams: {
+ fh_location: `//catalog01/en_CA/gender>{${gender}}/intended_use>{newarrivals}`,
+ fh_country: country,
+ fh_view_size: 'all',
+ },
+ });
+ const items = response.data.universes.universe[1]['items-section'].items.item.map((item, index, arr) => generateRssData(item, index, arr, country));
+
+ return {
+ title: `Arcteryx - New Arrivals(${country.toUpperCase()}) - ${gender.toUpperCase()}`,
+ link: pageUrl,
+ description: `Arcteryx - New Arrivals(${country.toUpperCase()}) - ${gender.toUpperCase()}`,
+ item: items.map((item) => ({
+ title: item.name,
+ link: productUrl + item.slug,
+ description: art(path.join(__dirname, 'templates/product-description.art'), {
+ item,
+ }),
+ })),
+ };
+}
diff --git a/lib/routes/arcteryx/outlet.ts b/lib/routes/arcteryx/outlet.ts
new file mode 100644
index 00000000000000..71020df27cde4a
--- /dev/null
+++ b/lib/routes/arcteryx/outlet.ts
@@ -0,0 +1,78 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+import { generateRssData } from './utils';
+
+export const route: Route = {
+ path: '/outlet/:country/:gender',
+ categories: ['shopping'],
+ example: '/arcteryx/outlet/us/mens',
+ parameters: { country: 'country', gender: 'gender' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['outlet.arcteryx.com/:country/en/c/:gender'],
+ },
+ ],
+ name: 'Outlet',
+ maintainers: ['EthanWng97'],
+ handler,
+ description: `Country
+
+| United States | Canada | United Kingdom |
+| ------------- | ------ | -------------- |
+| us | ca | gb |
+
+ gender
+
+| male | female |
+| ---- | ------ |
+| mens | womens |
+
+::: tip
+ Parameter \`country\` can be found within the url of \`Arcteryx\` website.
+:::`,
+};
+
+async function handler(ctx) {
+ const { country, gender } = ctx.req.param();
+ const host = `https://outlet.arcteryx.com/${country}/en/`;
+ const url = `${host}api/fredhopper/query`;
+ const productUrl = `${host}shop/`;
+ const pageUrl = `${host}c/${gender}`;
+ const response = await got({
+ method: 'get',
+ url,
+ searchParams: {
+ fh_location: `//catalog01/en_CA/gender>{${gender}}`,
+ fh_country: country,
+ fh_review: 'lister',
+ fh_view_size: 'all',
+ fh_context_location: '//catalog01',
+ },
+ });
+ const items = response.data.universes.universe[1]['items-section'].items.item.map((item, index, arr) => generateRssData(item, index, arr, country));
+
+ return {
+ title: `Arcteryx - Outlet(${country.toUpperCase()}) - ${gender.toUpperCase()}`,
+ link: pageUrl,
+ description: `Arcteryx - Outlet(${country.toUpperCase()}) - ${gender.toUpperCase()}`,
+ item: items.map((item) => ({
+ title: item.name,
+ link: productUrl + item.slug,
+ description: art(path.join(__dirname, 'templates/product-description.art'), {
+ item,
+ }),
+ })),
+ };
+}
diff --git a/lib/routes/arcteryx/regear-new-arrivals.ts b/lib/routes/arcteryx/regear-new-arrivals.ts
new file mode 100644
index 00000000000000..a0dba42937172c
--- /dev/null
+++ b/lib/routes/arcteryx/regear-new-arrivals.ts
@@ -0,0 +1,78 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const host = 'https://www.regear.arcteryx.com';
+function getUSDPrice(number) {
+ return (number / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
+}
+export const route: Route = {
+ path: '/regear/new-arrivals',
+ categories: ['shopping'],
+ example: '/arcteryx/regear/new-arrivals',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['regear.arcteryx.com/shop/new-arrivals', 'regear.arcteryx.com/'],
+ },
+ ],
+ name: 'Regear New Arrivals',
+ maintainers: ['EthanWng97'],
+ handler,
+ url: 'regear.arcteryx.com/shop/new-arrivals',
+};
+
+async function handler() {
+ const url = `${host}/shop/new-arrivals`;
+ const response = await got({
+ method: 'get',
+ url,
+ });
+
+ const data = response.data;
+ const $ = load(data);
+ const contents = $('script:contains("window.__PRELOADED_STATE__")').text();
+ const regex = /{.*}/;
+ let items = JSON.parse(contents.match(regex)[0]).shop.items;
+ items = items.filter((item) => item.availableSizes.length !== 0);
+
+ const list = items.map((item) => {
+ const data = {
+ title: item.displayTitle,
+ link: item.pdpLink.url,
+ imgUrl: JSON.parse(item.imageUrls).front,
+ availableSizes: item.availableSizes,
+ color: item.color,
+ originalPrice: getUSDPrice(item.originalPrice),
+ regearPrice: item.priceRange[0] === item.priceRange[1] ? getUSDPrice(item.priceRange[0]) : `${getUSDPrice(item.priceRange[0])} - ${getUSDPrice(item.priceRange[1])}`,
+ description: '',
+ };
+ data.description = art(path.join(__dirname, 'templates/regear-product-description.art'), {
+ data,
+ });
+ return data;
+ });
+
+ return {
+ title: 'Arcteryx - Regear - New Arrivals',
+ link: url,
+ description: 'Arcteryx - Regear - New Arrivals',
+ item: list.map((item) => ({
+ title: item.title,
+ link: item.link,
+ description: item.description,
+ })),
+ };
+}
diff --git a/lib/v2/arcteryx/templates/product-description.art b/lib/routes/arcteryx/templates/product-description.art
similarity index 100%
rename from lib/v2/arcteryx/templates/product-description.art
rename to lib/routes/arcteryx/templates/product-description.art
diff --git a/lib/v2/arcteryx/templates/regear-product-description.art b/lib/routes/arcteryx/templates/regear-product-description.art
similarity index 100%
rename from lib/v2/arcteryx/templates/regear-product-description.art
rename to lib/routes/arcteryx/templates/regear-product-description.art
diff --git a/lib/routes/arcteryx/utils.ts b/lib/routes/arcteryx/utils.ts
new file mode 100644
index 00000000000000..13cbda22966eac
--- /dev/null
+++ b/lib/routes/arcteryx/utils.ts
@@ -0,0 +1,21 @@
+function generateRssData(item, index, arr, country) {
+ const attributeSet = new Set(['name', 'image', 'short_description', 'slug', `price_${country}`, `discount_price_${country}`]);
+ const attributes = item.attribute;
+ const data = {};
+
+ for (const attribute of attributes) {
+ const key = attribute.name;
+ const value = attribute.value[0].value;
+ if (attributeSet.has(key)) {
+ if (key === `price_${country}`) {
+ data.original_price = value;
+ } else if (key === `discount_price_${country}`) {
+ data.price = value;
+ } else {
+ data[key] = value;
+ }
+ }
+ }
+ return (arr[index] = data);
+}
+export { generateRssData };
diff --git a/lib/routes/artstation/namespace.ts b/lib/routes/artstation/namespace.ts
new file mode 100644
index 00000000000000..6967b625032b3b
--- /dev/null
+++ b/lib/routes/artstation/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ArtStation',
+ url: 'www.artstation.com',
+ lang: 'en',
+};
diff --git a/lib/v2/artstation/templates/description.art b/lib/routes/artstation/templates/description.art
similarity index 100%
rename from lib/v2/artstation/templates/description.art
rename to lib/routes/artstation/templates/description.art
diff --git a/lib/routes/artstation/user.ts b/lib/routes/artstation/user.ts
new file mode 100644
index 00000000000000..420b99aae63170
--- /dev/null
+++ b/lib/routes/artstation/user.ts
@@ -0,0 +1,126 @@
+import path from 'node:path';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/:handle',
+ categories: ['picture'],
+ example: '/artstation/wlop',
+ parameters: { handle: 'Artist handle, can be found in URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.artstation.com/:handle'],
+ },
+ ],
+ name: 'Artist Profolio',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const handle = ctx.req.param('handle');
+
+ const headers = {
+ accept: 'application/json, text/plain, */*',
+ 'accept-language': 'en-US,en;q=0.9',
+ 'content-type': 'application/json',
+ 'user-agent': config.trueUA,
+ };
+
+ const csrfToken = await cache.tryGet('artstation:csrfToken', async () => {
+ const tokenResponse = await ofetch.raw('https://www.artstation.com/api/v2/csrf_protection/token.json', {
+ method: 'POST',
+ headers,
+ });
+ return tokenResponse.headers.getSetCookie()[0].split(';')[0].split('=')[1];
+ });
+
+ const { data: userData } = await got(`https://www.artstation.com/users/${handle}/quick.json`, {
+ headers: {
+ ...headers,
+ cookie: `PRIVATE-CSRF-TOKEN=${csrfToken}`,
+ },
+ });
+ const { data: projects } = await got(`https://www.artstation.com/users/${handle}/projects.json`, {
+ headers: {
+ ...headers,
+ cookie: `PRIVATE-CSRF-TOKEN=${csrfToken}`,
+ },
+ searchParams: {
+ user_id: userData.id,
+ page: 1,
+ },
+ });
+
+ const resolveImageUrl = (url) => url.replace(/\/\d{14}\/small_square\//, '/large/');
+
+ const list = projects.data.map((item) => ({
+ title: item.title,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ description: item.description,
+ image: {
+ src: resolveImageUrl(item.cover.small_square_url),
+ title: item.title,
+ },
+ }),
+ pubDate: parseDate(item.published_at),
+ updated: parseDate(item.updated_at),
+ link: item.permalink,
+ author: userData.full_name,
+ assetsCount: item.assets_count,
+ hashId: item.hash_id,
+ icons: item.icons,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (item.assetsCount > 1 || !item.icons.image) {
+ const { data } = await got(`https://www.artstation.com/projects/${item.hashId}.json`, {
+ headers: {
+ ...headers,
+ cookie: `PRIVATE-CSRF-TOKEN=${csrfToken}`,
+ },
+ });
+
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ description: data.description,
+ assets: data.assets,
+ });
+
+ for (const a of data.assets) {
+ if (a.asset_type !== 'video' && a.asset_type !== 'image' && a.asset_type !== 'video_clip' && a.asset_type !== 'cover') {
+ throw new Error(`Unhandle asset type: ${a.asset_type}`); // model3d, marmoset, pano
+ }
+ }
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${userData.full_name} - ArtStation`,
+ description: userData.headline,
+ link: userData.permalink,
+ logo: userData.large_avatar_url,
+ icon: userData.large_avatar_url,
+ image: userData.default_cover_url,
+ item: items,
+ };
+}
diff --git a/lib/routes/asahi/index.js b/lib/routes/asahi/index.js
deleted file mode 100644
index da21e0d816322e..00000000000000
--- a/lib/routes/asahi/index.js
+++ /dev/null
@@ -1,94 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-const categories = {
- obituaries: '/obituaries',
- yoron: '/politics/yoron',
- baseball: '/sports/baseball',
- soccer: '/sports/soccer',
- sumo: '/sports/sumo',
- winter_figureskate: '/sports/winter/figureskate',
- golf: '/sports/golf',
- general: '/sports/general',
- olympics: '/olympics',
- paralympics: '/paralympics',
- eco: '/eco',
- igo: '/igo',
- shougi: '/shougi',
- eldercare: '/national/eldercare',
- hataraku: '/special/hataraku',
- food: '/culture/food',
- gassho: '/edu/gassho',
- suisogaku: '/edu/suisogaku',
- hagukumu: '/edu/hagukumu',
- msta: '/msta',
-};
-
-module.exports = async (ctx) => {
- const genre = ctx.params.genre || '';
- const category = ctx.params.category || '';
-
- const rootUrl = 'https://www.asahi.com';
- let currentUrl;
- if (genre) {
- if (category) {
- if (category in categories) {
- currentUrl = `${rootUrl}${categories[category]}`;
- } else {
- currentUrl = `${rootUrl}/${genre}/list/${category}.html`;
- }
- } else {
- currentUrl = `${rootUrl}/${genre}`;
- }
- } else {
- currentUrl = `${rootUrl}${'/news/history.html'}`;
- }
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = cheerio.load(response.data);
-
- $('.Time').remove();
-
- const list = $('#MainInner .Section .List li a')
- .slice(0, 6)
- .map((_, item) => {
- item = $(item);
- const link = item.attr('href');
-
- return {
- link: link.indexOf('//') < 0 ? `${rootUrl}${link}` : `https:${link}`,
- };
- })
- .get();
-
- const items = await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const content = cheerio.load(detailResponse.data);
-
- content('._30SFw, .-Oj2D, .notPrint').remove();
-
- item.description = content('._3YqJ1').html();
- item.title = content('meta[name="TITLE"]').attr('content');
- item.pubDate = Date.parse(content('meta[name="pubdate"]').attr('content'));
-
- return item;
- })
- )
- );
-
- ctx.state.data = {
- title: $('title').text(),
- link: currentUrl,
- item: items,
- description: '朝日新聞社のニュースサイト、朝日新聞デジタルの社会ニュースについてのページです',
- };
-};
diff --git a/lib/routes/asiafruitchina/categories.ts b/lib/routes/asiafruitchina/categories.ts
new file mode 100644
index 00000000000000..ec596ac55fd340
--- /dev/null
+++ b/lib/routes/asiafruitchina/categories.ts
@@ -0,0 +1,685 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'all' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '10', 10);
+
+ const baseUrl: string = 'https://asiafruitchina.net';
+ const targetUrl: string = new URL(`categories?gspx=${category}`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('div.listBlocks ul li')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('div.storyDetails h3 a');
+
+ const title: string = $aEl.text();
+ const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ images:
+ $el.find('a.image img').length > 0
+ ? $el
+ .find('a.image img')
+ .toArray()
+ .map((imgEl) => {
+ const $imgEl: Cheerio = $(imgEl);
+
+ return {
+ src: $imgEl.attr('src'),
+ alt: $imgEl.attr('alt'),
+ };
+ })
+ : undefined,
+ });
+ const pubDateStr: string | undefined = $el.find('span.date').text();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const image: string | undefined = $el.find('a.image img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.story_title h1').text();
+ const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div.storytext').html(),
+ });
+ const pubDateStr: string | undefined = $$('span.date').first().text().split(/:/).pop();
+ const categories: string[] =
+ $$('meta[name="keywords"]')
+ .attr('content')
+ ?.split(/,/)
+ .map((c) => c.trim()) ?? [];
+ const authors: DataItem['author'] = $$('span.author').first().text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ const extraLinkEls: Element[] = $$('div.extrasStory ul li').toArray();
+ const extraLinks = extraLinkEls
+ .map((extraLinkEl) => {
+ const $$extraLinkEl: Cheerio = $$(extraLinkEl);
+
+ return {
+ url: $$extraLinkEl.find('a').attr('href'),
+ type: 'related',
+ content_html: $$extraLinkEl.html(),
+ };
+ })
+ .filter((_): _ is { url: string; type: string; content_html: string } => true);
+
+ if (extraLinks) {
+ processedItem = {
+ ...processedItem,
+ _extra: {
+ links: extraLinks,
+ },
+ };
+ }
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const title: string = $('title').text().trim();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.logo').attr('src'),
+ author: title.split(/-/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/categories/:category?',
+ name: '果蔬品项',
+ url: 'asiafruitchina.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/asiafruitchina/categories/all',
+ parameters: {
+ category: {
+ description: '分类,默认为 `all`,即全部,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '全部',
+ value: 'all',
+ },
+ {
+ label: '橙',
+ value: 'chengzi',
+ },
+ {
+ label: '百香果',
+ value: 'baixiangguo',
+ },
+ {
+ label: '菠萝/凤梨',
+ value: 'boluo',
+ },
+ {
+ label: '菠萝蜜',
+ value: 'boluomi',
+ },
+ {
+ label: '草莓',
+ value: 'caomei',
+ },
+ {
+ label: '番荔枝/释迦',
+ value: 'fanlizhi',
+ },
+ {
+ label: '番茄',
+ value: 'fanqie',
+ },
+ {
+ label: '柑橘',
+ value: 'ganju',
+ },
+ {
+ label: '哈密瓜',
+ value: 'hamigua',
+ },
+ {
+ label: '核果',
+ value: 'heguo',
+ },
+ {
+ label: '红毛丹',
+ value: 'hongmaodan',
+ },
+ {
+ label: '火龙果',
+ value: 'huolongguo',
+ },
+ {
+ label: '浆果',
+ value: 'jiangguo',
+ },
+ {
+ label: '桔子',
+ value: 'juzi',
+ },
+ {
+ label: '蓝莓',
+ value: 'lanmei',
+ },
+ {
+ label: '梨',
+ value: 'li',
+ },
+ {
+ label: '荔枝',
+ value: 'lizhi',
+ },
+ {
+ label: '李子',
+ value: 'lizi',
+ },
+ {
+ label: '榴莲',
+ value: 'liulian',
+ },
+ {
+ label: '龙眼',
+ value: 'lognyan',
+ },
+ {
+ label: '芦笋',
+ value: 'lusun',
+ },
+ {
+ label: '蔓越莓',
+ value: 'manyuemei',
+ },
+ {
+ label: '芒果',
+ value: 'mangguo',
+ },
+ {
+ label: '猕猴桃/奇异果',
+ value: 'mihoutao',
+ },
+ {
+ label: '柠檬',
+ value: 'ningmeng',
+ },
+ {
+ label: '牛油果',
+ value: 'niuyouguo',
+ },
+ {
+ label: '苹果',
+ value: 'pingguo',
+ },
+ {
+ label: '葡萄/提子',
+ value: 'putao',
+ },
+ {
+ label: '其他',
+ value: 'qita',
+ },
+ {
+ label: '奇异莓',
+ value: 'qiyimei',
+ },
+ {
+ label: '热带水果',
+ value: 'redaishuiguo',
+ },
+ {
+ label: '山竹',
+ value: 'shanzhu',
+ },
+ {
+ label: '石榴',
+ value: 'shiliu',
+ },
+ {
+ label: '蔬菜',
+ value: 'shucai',
+ },
+ {
+ label: '树莓',
+ value: 'shumei',
+ },
+ {
+ label: '桃',
+ value: 'tao',
+ },
+ {
+ label: '甜瓜',
+ value: 'tiangua',
+ },
+ {
+ label: '甜椒',
+ value: 'tianjiao',
+ },
+ {
+ label: '甜柿',
+ value: 'tianshi',
+ },
+ {
+ label: '香蕉',
+ value: 'xiangjiao',
+ },
+ {
+ label: '西瓜',
+ value: 'xigua',
+ },
+ {
+ label: '西梅',
+ value: 'ximei',
+ },
+ {
+ label: '杏',
+ value: 'xing',
+ },
+ {
+ label: '椰子',
+ value: 'yezi',
+ },
+ {
+ label: '杨梅',
+ value: 'yangmei',
+ },
+ {
+ label: '樱桃',
+ value: 'yintao',
+ },
+ {
+ label: '油桃',
+ value: 'youtao',
+ },
+ {
+ label: '柚子',
+ value: 'youzi',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+若订阅 [橙](https://asiafruitchina.net/categories?gspx=chengzi),网址为 \`https://asiafruitchina.net/categories?gspx=chengzi\`,请截取 \`https://asiafruitchina.net/categories?gspx=\` 到末尾的部分 \`chengzi\` 作为 \`category\` 参数填入,此时目标路由为 [\`/asiafruitchina/categories/chengzi\`](https://rsshub.app/asiafruitchina/categories/chengzi)。
+:::
+
+
+ 更多分类
+
+ | [全部](https://asiafruitchina.net/categories?gspx=all) | [橙](https://asiafruitchina.net/categories?gspx=chengzi) | [百香果](https://asiafruitchina.net/categories?gspx=baixiangguo) | [菠萝/凤梨](https://asiafruitchina.net/categories?gspx=boluo) | [菠萝蜜](https://asiafruitchina.net/categories?gspx=boluomi) |
+ | ------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------- |
+ | [all](https://rsshub.app/asiafruitchina/categories/all) | [chengzi](https://rsshub.app/asiafruitchina/categories/chengzi) | [baixiangguo](https://rsshub.app/asiafruitchina/categories/baixiangguo) | [boluo](https://rsshub.app/asiafruitchina/categories/boluo) | [boluomi](https://rsshub.app/asiafruitchina/categories/boluomi) |
+
+ | [草莓](https://asiafruitchina.net/categories?gspx=caomei) | [番荔枝/释迦](https://asiafruitchina.net/categories?gspx=fanlizhi) | [番茄](https://asiafruitchina.net/categories?gspx=fanqie) | [柑橘](https://asiafruitchina.net/categories?gspx=ganju) | [哈密瓜](https://asiafruitchina.net/categories?gspx=hamigua) |
+ | ------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------- |
+ | [caomei](https://rsshub.app/asiafruitchina/categories/caomei) | [fanlizhi](https://rsshub.app/asiafruitchina/categories/fanlizhi) | [fanqie](https://rsshub.app/asiafruitchina/categories/fanqie) | [ganju](https://rsshub.app/asiafruitchina/categories/ganju) | [hamigua](https://rsshub.app/asiafruitchina/categories/hamigua) |
+
+ | [核果](https://asiafruitchina.net/categories?gspx=heguo) | [红毛丹](https://asiafruitchina.net/categories?gspx=hongmaodan) | [火龙果](https://asiafruitchina.net/categories?gspx=huolongguo) | [浆果](https://asiafruitchina.net/categories?gspx=jiangguo) | [桔子](https://asiafruitchina.net/categories?gspx=juzi) |
+ | ----------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- |
+ | [heguo](https://rsshub.app/asiafruitchina/categories/heguo) | [hongmaodan](https://rsshub.app/asiafruitchina/categories/hongmaodan) | [huolongguo](https://rsshub.app/asiafruitchina/categories/huolongguo) | [jiangguo](https://rsshub.app/asiafruitchina/categories/jiangguo) | [juzi](https://rsshub.app/asiafruitchina/categories/juzi) |
+
+ | [蓝莓](https://asiafruitchina.net/categories?gspx=lanmei) | [梨](https://asiafruitchina.net/categories?gspx=li) | [荔枝](https://asiafruitchina.net/categories?gspx=lizhi) | [李子](https://asiafruitchina.net/categories?gspx=lizi) | [榴莲](https://asiafruitchina.net/categories?gspx=liulian) |
+ | ------------------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------- |
+ | [lanmei](https://rsshub.app/asiafruitchina/categories/lanmei) | [li](https://rsshub.app/asiafruitchina/categories/li) | [lizhi](https://rsshub.app/asiafruitchina/categories/lizhi) | [lizi](https://rsshub.app/asiafruitchina/categories/lizi) | [liulian](https://rsshub.app/asiafruitchina/categories/liulian) |
+
+ | [龙眼](https://asiafruitchina.net/categories?gspx=lognyan) | [芦笋](https://asiafruitchina.net/categories?gspx=lusun) | [蔓越莓](https://asiafruitchina.net/categories?gspx=manyuemei) | [芒果](https://asiafruitchina.net/categories?gspx=mangguo) | [猕猴桃/奇异果](https://asiafruitchina.net/categories?gspx=mihoutao) |
+ | --------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------- |
+ | [lognyan](https://rsshub.app/asiafruitchina/categories/lognyan) | [lusun](https://rsshub.app/asiafruitchina/categories/lusun) | [manyuemei](https://rsshub.app/asiafruitchina/categories/manyuemei) | [mangguo](https://rsshub.app/asiafruitchina/categories/mangguo) | [mihoutao](https://rsshub.app/asiafruitchina/categories/mihoutao) |
+
+ | [柠檬](https://asiafruitchina.net/categories?gspx=ningmeng) | [牛油果](https://asiafruitchina.net/categories?gspx=niuyouguo) | [苹果](https://asiafruitchina.net/categories?gspx=pingguo) | [葡萄/提子](https://asiafruitchina.net/categories?gspx=putao) | [其他](https://asiafruitchina.net/categories?gspx=qita) |
+ | ----------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- |
+ | [ningmeng](https://rsshub.app/asiafruitchina/categories/ningmeng) | [niuyouguo](https://rsshub.app/asiafruitchina/categories/niuyouguo) | [pingguo](https://rsshub.app/asiafruitchina/categories/pingguo) | [putao](https://rsshub.app/asiafruitchina/categories/putao) | [qita](https://rsshub.app/asiafruitchina/categories/qita) |
+
+ | [奇异莓](https://asiafruitchina.net/categories?gspx=qiyimei) | [热带水果](https://asiafruitchina.net/categories?gspx=redaishuiguo) | [山竹](https://asiafruitchina.net/categories?gspx=shanzhu) | [石榴](https://asiafruitchina.net/categories?gspx=shiliu) | [蔬菜](https://asiafruitchina.net/categories?gspx=shucai) |
+ | --------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- |
+ | [qiyimei](https://rsshub.app/asiafruitchina/categories/qiyimei) | [redaishuiguo](https://rsshub.app/asiafruitchina/categories/redaishuiguo) | [shanzhu](https://rsshub.app/asiafruitchina/categories/shanzhu) | [shiliu](https://rsshub.app/asiafruitchina/categories/shiliu) | [shucai](https://rsshub.app/asiafruitchina/categories/shucai) |
+
+ | [树莓](https://asiafruitchina.net/categories?gspx=shumei) | [桃](https://asiafruitchina.net/categories?gspx=tao) | [甜瓜](https://asiafruitchina.net/categories?gspx=tiangua) | [甜椒](https://asiafruitchina.net/categories?gspx=tianjiao) | [甜柿](https://asiafruitchina.net/categories?gspx=tianshi) |
+ | ------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------- |
+ | [shumei](https://rsshub.app/asiafruitchina/categories/shumei) | [tao](https://rsshub.app/asiafruitchina/categories/tao) | [tiangua](https://rsshub.app/asiafruitchina/categories/tiangua) | [tianjiao](https://rsshub.app/asiafruitchina/categories/tianjiao) | [tianshi](https://rsshub.app/asiafruitchina/categories/tianshi) |
+
+ | [香蕉](https://asiafruitchina.net/categories?gspx=xiangjiao) | [西瓜](https://asiafruitchina.net/categories?gspx=xigua) | [西梅](https://asiafruitchina.net/categories?gspx=ximei) | [杏](https://asiafruitchina.net/categories?gspx=xing) | [椰子](https://asiafruitchina.net/categories?gspx=yezi) |
+ | ------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- |
+ | [xiangjiao](https://rsshub.app/asiafruitchina/categories/xiangjiao) | [xigua](https://rsshub.app/asiafruitchina/categories/xigua) | [ximei](https://rsshub.app/asiafruitchina/categories/ximei) | [xing](https://rsshub.app/asiafruitchina/categories/xing) | [yezi](https://rsshub.app/asiafruitchina/categories/yezi) |
+
+ | [杨梅](https://asiafruitchina.net/categories?gspx=yangmei) | [樱桃](https://asiafruitchina.net/categories?gspx=yintao) | [油桃](https://asiafruitchina.net/categories?gspx=youtao) | [柚子](https://asiafruitchina.net/categories?gspx=youzi) |
+ | --------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------- |
+ | [yangmei](https://rsshub.app/asiafruitchina/categories/yangmei) | [yintao](https://rsshub.app/asiafruitchina/categories/yintao) | [youtao](https://rsshub.app/asiafruitchina/categories/youtao) | [youzi](https://rsshub.app/asiafruitchina/categories/youzi) |
+
+
+`,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['asiafruitchina.net/categories'],
+ target: (_, url) => {
+ const urlObj: URL = new URL(url);
+ const category: string | undefined = urlObj.searchParams.get('id') ?? undefined;
+
+ return `/asiafruitchina/categories${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '全部',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/all',
+ },
+ {
+ title: '橙',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/chengzi',
+ },
+ {
+ title: '百香果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/baixiangguo',
+ },
+ {
+ title: '菠萝/凤梨',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/boluo',
+ },
+ {
+ title: '菠萝蜜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/boluomi',
+ },
+ {
+ title: '草莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/caomei',
+ },
+ {
+ title: '番荔枝/释迦',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/fanlizhi',
+ },
+ {
+ title: '番茄',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/fanqie',
+ },
+ {
+ title: '柑橘',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/ganju',
+ },
+ {
+ title: '哈密瓜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/hamigua',
+ },
+ {
+ title: '核果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/heguo',
+ },
+ {
+ title: '红毛丹',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/hongmaodan',
+ },
+ {
+ title: '火龙果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/huolongguo',
+ },
+ {
+ title: '浆果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/jiangguo',
+ },
+ {
+ title: '桔子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/juzi',
+ },
+ {
+ title: '蓝莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lanmei',
+ },
+ {
+ title: '梨',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/li',
+ },
+ {
+ title: '荔枝',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lizhi',
+ },
+ {
+ title: '李子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lizi',
+ },
+ {
+ title: '榴莲',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/liulian',
+ },
+ {
+ title: '龙眼',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lognyan',
+ },
+ {
+ title: '芦笋',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lusun',
+ },
+ {
+ title: '蔓越莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/manyuemei',
+ },
+ {
+ title: '芒果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/mangguo',
+ },
+ {
+ title: '猕猴桃/奇异果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/mihoutao',
+ },
+ {
+ title: '柠檬',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/ningmeng',
+ },
+ {
+ title: '牛油果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/niuyouguo',
+ },
+ {
+ title: '苹果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/pingguo',
+ },
+ {
+ title: '葡萄/提子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/putao',
+ },
+ {
+ title: '其他',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/qita',
+ },
+ {
+ title: '奇异莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/qiyimei',
+ },
+ {
+ title: '热带水果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/redaishuiguo',
+ },
+ {
+ title: '山竹',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/shanzhu',
+ },
+ {
+ title: '石榴',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/shiliu',
+ },
+ {
+ title: '蔬菜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/shucai',
+ },
+ {
+ title: '树莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/shumei',
+ },
+ {
+ title: '桃',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/tao',
+ },
+ {
+ title: '甜瓜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/tiangua',
+ },
+ {
+ title: '甜椒',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/tianjiao',
+ },
+ {
+ title: '甜柿',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/tianshi',
+ },
+ {
+ title: '香蕉',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/xiangjiao',
+ },
+ {
+ title: '西瓜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/xigua',
+ },
+ {
+ title: '西梅',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/ximei',
+ },
+ {
+ title: '杏',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/xing',
+ },
+ {
+ title: '椰子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/yezi',
+ },
+ {
+ title: '杨梅',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/yangmei',
+ },
+ {
+ title: '樱桃',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/yintao',
+ },
+ {
+ title: '油桃',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/youtao',
+ },
+ {
+ title: '柚子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/youzi',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/asiafruitchina/namespace.ts b/lib/routes/asiafruitchina/namespace.ts
new file mode 100644
index 00000000000000..48c11815ea05f9
--- /dev/null
+++ b/lib/routes/asiafruitchina/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '亚洲水果',
+ url: 'asiafruitchina.net',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/asiafruitchina/news.ts b/lib/routes/asiafruitchina/news.ts
new file mode 100644
index 00000000000000..facc192aa87bcd
--- /dev/null
+++ b/lib/routes/asiafruitchina/news.ts
@@ -0,0 +1,184 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'https://asiafruitchina.net';
+ const targetUrl: string = new URL('category/news', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('div.listBlocks ul li')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('div.storyDetails h3 a');
+
+ const title: string = $aEl.text();
+ const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ images:
+ $el.find('a.image img').length > 0
+ ? $el
+ .find('a.image img')
+ .toArray()
+ .map((imgEl) => {
+ const $imgEl: Cheerio = $(imgEl);
+
+ return {
+ src: $imgEl.attr('src'),
+ alt: $imgEl.attr('alt'),
+ };
+ })
+ : undefined,
+ });
+ const pubDateStr: string | undefined = $el.find('span.date').text();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const image: string | undefined = $el.find('a.image img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.story_title h1').text();
+ const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div.storytext').html(),
+ });
+ const pubDateStr: string | undefined = $$('span.date').first().text().split(/:/).pop();
+ const categories: string[] =
+ $$('meta[name="keywords"]')
+ .attr('content')
+ ?.split(/,/)
+ .map((c) => c.trim()) ?? [];
+ const authors: DataItem['author'] = $$('span.author').first().text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ const extraLinkEls: Element[] = $$('div.extrasStory ul li').toArray();
+ const extraLinks = extraLinkEls
+ .map((extraLinkEl) => {
+ const $$extraLinkEl: Cheerio = $$(extraLinkEl);
+
+ return {
+ url: $$extraLinkEl.find('a').attr('href'),
+ type: 'related',
+ content_html: $$extraLinkEl.html(),
+ };
+ })
+ .filter((_): _ is { url: string; type: string; content_html: string } => true);
+
+ if (extraLinks) {
+ processedItem = {
+ ...processedItem,
+ _extra: {
+ links: extraLinks,
+ },
+ };
+ }
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const title: string = $('title').text().trim();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.logo').attr('src'),
+ author: title.split(/-/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/news',
+ name: '行业资讯',
+ url: 'asiafruitchina.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/asiafruitchina/news',
+ parameters: undefined,
+ description: undefined,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['asiafruitchina.net/category/news'],
+ target: '/asiafruitchina/news',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/asiafruitchina/templates/description.art b/lib/routes/asiafruitchina/templates/description.art
new file mode 100644
index 00000000000000..dfab19230c1108
--- /dev/null
+++ b/lib/routes/asiafruitchina/templates/description.art
@@ -0,0 +1,17 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/asianfanfics/namespace.ts b/lib/routes/asianfanfics/namespace.ts
new file mode 100644
index 00000000000000..7903b878628da4
--- /dev/null
+++ b/lib/routes/asianfanfics/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Asianfanfics',
+ url: 'asianfanfics.com',
+ lang: 'en',
+};
diff --git a/lib/routes/asianfanfics/tag.ts b/lib/routes/asianfanfics/tag.ts
new file mode 100644
index 00000000000000..60e7eb8f35384a
--- /dev/null
+++ b/lib/routes/asianfanfics/tag.ts
@@ -0,0 +1,91 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+// test url http://localhost:1200/asianfanfics/tag/milklove/N
+
+export const route: Route = {
+ path: '/tag/:tag/:type',
+ categories: ['reading'],
+ example: '/asianfanfics/tag/milklove/N',
+ parameters: {
+ tag: '标签',
+ type: '排序类型',
+ },
+ name: '标签',
+ maintainers: ['KazooTTT'],
+ radar: [
+ {
+ source: ['www.asianfanfics.com/browse/tag/:tag/:type'],
+ target: '/tag/:tag/:type',
+ },
+ ],
+ description: `匹配asianfanfics标签,支持排序类型:
+- L: Latest 最近更新
+- N: Newest 最近发布
+- O: Oldest 最早发布
+- C: Completed 已完成
+- OS: One Shots 短篇
+`,
+ handler,
+};
+
+type Type = 'L' | 'N' | 'O' | 'C' | 'OS';
+
+const typeToText = {
+ L: '最近更新',
+ N: '最近发布',
+ O: '最早发布',
+ C: '已完成',
+ OS: '短篇',
+};
+
+async function handler(ctx) {
+ const tag = ctx.req.param('tag');
+ const type = ctx.req.param('type') as Type;
+
+ if (!type || !['L', 'N', 'O', 'C', 'OS'].includes(type)) {
+ throw new Error('无效的排序类型');
+ }
+ const link = `https://www.asianfanfics.com/browse/tag/${tag}/${type}`;
+
+ const response = await ofetch(link, {
+ headers: {
+ 'user-agent': config.trueUA,
+ Referer: 'https://www.asianfanfics.com/',
+ },
+ });
+ const $ = load(response);
+
+ const items: DataItem[] = $('.primary-container .excerpt')
+ .toArray()
+ .filter((element) => {
+ const $element = $(element);
+ return $element.find('.excerpt__title a').length > 0;
+ })
+ .map((element) => {
+ const $element = $(element);
+ const title = $element.find('.excerpt__title a').text();
+ const link = 'https://www.asianfanfics.com' + $element.find('.excerpt__title a').attr('href');
+ const author = $element.find('.excerpt__meta__name a').text().trim();
+ const pubDate = parseDate($element.find('time').attr('datetime') || '');
+ const description = $element.find('.excerpt__text').html();
+
+ return {
+ title,
+ link,
+ author,
+ pubDate,
+ description,
+ };
+ });
+
+ return {
+ title: `Asianfanfics - 标签:${tag} - ${typeToText[type]}`,
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/asianfanfics/text-search.ts b/lib/routes/asianfanfics/text-search.ts
new file mode 100644
index 00000000000000..35e915c1f4759d
--- /dev/null
+++ b/lib/routes/asianfanfics/text-search.ts
@@ -0,0 +1,71 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+// test url http://localhost:1200/asianfanfics/text-search/milklove
+
+export const route: Route = {
+ path: '/text-search/:keyword',
+ categories: ['reading'],
+ example: '/asianfanfics/text-search/milklove',
+ parameters: {
+ keyword: '关键词',
+ },
+ name: '关键词',
+ maintainers: ['KazooTTT'],
+ radar: [
+ {
+ source: ['www.asianfanfics.com/browse/text_search?q=:keyword'],
+ target: '/text-search/:keyword',
+ },
+ ],
+ description: '匹配asianfanfics搜索关键词',
+ handler,
+};
+
+async function handler(ctx) {
+ const keyword = ctx.req.param('keyword');
+ if (keyword.trim() === '') {
+ throw new Error('关键词不能为空');
+ }
+ const link = `https://www.asianfanfics.com/browse/text_search?q=${keyword}+`;
+
+ const response = await ofetch(link, {
+ headers: {
+ 'user-agent': config.trueUA,
+ },
+ });
+ const $ = load(response);
+
+ const items: DataItem[] = $('.primary-container .excerpt')
+ .toArray()
+ .filter((element) => {
+ const $element = $(element);
+ return $element.find('.excerpt__title a').length > 0;
+ })
+ .map((element) => {
+ const $element = $(element);
+ const title = $element.find('.excerpt__title a').text();
+ const link = 'https://www.asianfanfics.com' + $element.find('.excerpt__title a').attr('href');
+ const author = $element.find('.excerpt__meta__name a').text().trim();
+ const pubDate = parseDate($element.find('time').attr('datetime') || '');
+ const description = $element.find('.excerpt__text').html();
+
+ return {
+ title,
+ link,
+ author,
+ pubDate,
+ description,
+ };
+ });
+
+ return {
+ title: `Asianfanfics - 关键词:${keyword}`,
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/asiantolick/index.ts b/lib/routes/asiantolick/index.ts
new file mode 100644
index 00000000000000..6000d8151bcdf9
--- /dev/null
+++ b/lib/routes/asiantolick/index.ts
@@ -0,0 +1,134 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ radar: [
+ {
+ source: ['asiantolick.com/'],
+ target: '',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+ url: 'asiantolick.com/',
+ features: {
+ nsfw: true,
+ },
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category');
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 24;
+
+ const rootUrl = 'https://asiantolick.com';
+ const apiUrl = new URL('ajax/buscar_posts.php', rootUrl).href;
+ const currentUrl = new URL(category?.replace(/^(tag|category)?\/(\d+)/, '$1-$2') ?? '', rootUrl).href;
+
+ const searchParams = {};
+ const matches = category?.match(/^(tag|category|search|page)?[/-]?(\w+)/) ?? undefined;
+
+ if (matches) {
+ const key = matches[1] === 'category' ? 'cat' : matches[1];
+ const value = matches[2];
+ searchParams[key] = value;
+ } else if (category) {
+ searchParams.page = 'news';
+ }
+
+ const { data: response } = await got(apiUrl, {
+ searchParams,
+ });
+
+ let $ = load(response);
+
+ let items = $('a.miniatura')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const image = item.find('div.background_miniatura img');
+
+ return {
+ title: item.find('div.base_tt').text(),
+ link: item.prop('href'),
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image.prop('data-src').split(/\?/)[0],
+ alt: image.prop('alt'),
+ },
+ ]
+ : undefined,
+ }),
+ author: item.find('.author').text(),
+ category: item
+ .find('.category')
+ .toArray()
+ .map((c) => $(c).text()),
+ guid: image ? image.prop('post-id') : item.link.match(/\/(\d+)/)[1],
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const content = load(detailResponse);
+
+ item.title = content('h1').first().text();
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ description: content('#metadata_qrcode').html(),
+ images: content('div.miniatura')
+ .toArray()
+ .map((i) => ({
+ src: content(i).prop('data-src'),
+ alt: content(i).find('img').prop('alt'),
+ })),
+ });
+ item.author = content('.author').text();
+ item.category = content('#categoria_tags_post a')
+ .toArray()
+ .map((c) => content(c).text().trim().replace(/^#/, ''));
+ item.pubDate = parseDate(detailResponse.match(/"pubDate":\s"((?!http)[^"]*)"/)[1]);
+ item.updated = parseDate(detailResponse.match(/"upDate":\s"((?!http)[^"]*)"/)[1]);
+ item.enclosure_url = new URL(`ajax/download_post.php?ver=3&dir=/down/new_${item.guid}&post_id=${item.guid}&post_name=${detailResponse.match(/"title":\s"((?!http)[^"]*)"/)[1]}`, rootUrl).href;
+
+ item.guid = `asiantolick-${item.guid}`;
+
+ return item;
+ })
+ )
+ );
+
+ const { data: currentResponse } = await got(currentUrl);
+
+ $ = load(currentResponse);
+
+ const title = $('title').text().split(/-/)[0].trim();
+ const icon = $('link[rel="icon"]').first().prop('href');
+
+ return {
+ item: items,
+ title: title === 'Asian To Lick' ? title : `Asian To Lick - ${title}`,
+ link: currentUrl,
+ description: $('meta[property="og:description"]').prop('content'),
+ language: $('html').prop('lang'),
+ image: $('meta[name="msapplication-TileImage"]').prop('content'),
+ icon,
+ logo: icon,
+ subtitle: title,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/asiantolick/namespace.ts b/lib/routes/asiantolick/namespace.ts
new file mode 100644
index 00000000000000..21d7619ddb8cc9
--- /dev/null
+++ b/lib/routes/asiantolick/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Asian to lick',
+ url: 'asiantolick.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/asiantolick/templates/description.art b/lib/routes/asiantolick/templates/description.art
similarity index 100%
rename from lib/v2/asiantolick/templates/description.art
rename to lib/routes/asiantolick/templates/description.art
diff --git a/lib/routes/asmr-200/index.ts b/lib/routes/asmr-200/index.ts
new file mode 100644
index 00000000000000..293f22ffb989eb
--- /dev/null
+++ b/lib/routes/asmr-200/index.ts
@@ -0,0 +1,67 @@
+import path from 'node:path';
+
+import type { Result, Work } from '@/routes/asmr-200/type';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const render = (work: Work, link: string) => art(path.join(__dirname, 'templates/work.art'), { work, link });
+
+export const route: Route = {
+ path: '/works/:order?/:subtitle?/:sort?',
+ categories: ['multimedia'],
+ example: '/asmr-200/works',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ parameters: {
+ order: '排序字段,默认按照资源的收录日期来排序,详见下表',
+ sort: '排序方式,可选 `asc` 和 `desc` ,默认倒序',
+ subtitle: '筛选带字幕音频,可选 `0` 和 `1` ,默认关闭',
+ },
+ radar: [
+ {
+ source: ['asmr-200.com'],
+ target: 'asmr-200/works',
+ },
+ ],
+ name: '最新收录',
+ maintainers: ['hualiong'],
+ url: 'asmr-200.com',
+ description: `| 发售日期 | 收录日期 | 销量 | 价格 | 评价 | 随机 | RJ号 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| release | create_date | dl_count | price | rate_average_2dp | random | id |`,
+ handler: async (ctx) => {
+ const { order = 'create_date', sort = 'desc', subtitle = '0' } = ctx.req.param();
+ const res = await ofetch('https://api.asmr-200.com/api/works', { query: { order, sort, page: 1, subtitle } });
+
+ const items: DataItem[] = res.works.map((each) => {
+ const category = each.tags.map((tag) => tag.name);
+ each.category = category.join(',');
+ each.cv = each.vas.map((cv) => cv.name).join(',');
+ return {
+ title: each.title,
+ image: each.mainCoverUrl,
+ author: each.name,
+ link: `https://asmr-200.com/work/${each.source_id}`,
+ pubDate: timezone(parseDate(each.release, 'YYYY-MM-DD'), +8),
+ category,
+ description: render(each, `https://asmr-200.com/work/${each.source_id}`),
+ };
+ });
+
+ return {
+ title: '最新收录 - ASMR Online',
+ link: 'https://asmr-200.com/',
+ item: items,
+ };
+ },
+};
diff --git a/lib/routes/asmr-200/namespace.ts b/lib/routes/asmr-200/namespace.ts
new file mode 100644
index 00000000000000..b447efa8abc1d4
--- /dev/null
+++ b/lib/routes/asmr-200/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ASMR Online',
+ url: 'asmr-200.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/asmr-200/templates/work.art b/lib/routes/asmr-200/templates/work.art
new file mode 100644
index 00000000000000..759ad749f63a95
--- /dev/null
+++ b/lib/routes/asmr-200/templates/work.art
@@ -0,0 +1,7 @@
+
+{{ work.title }} {{ work.source_id }}
+发布者: {{ work.name }}
+评分: {{ work.rate_average_2dp }} | 评论数: {{ work.review_count }} | 总时长: {{ work.duration }} | 音频来源: {{ work.source_type }}
+价格: {{ work.price }} JPY | 销量: {{ work.dl_count }}
+分类: {{ work.category }}
+声优: {{ work.cv }}
\ No newline at end of file
diff --git a/lib/routes/asmr-200/type.ts b/lib/routes/asmr-200/type.ts
new file mode 100644
index 00000000000000..8036204afd1222
--- /dev/null
+++ b/lib/routes/asmr-200/type.ts
@@ -0,0 +1,96 @@
+export interface Result {
+ pagination: {
+ currentPage: number;
+ pageSize: number;
+ totalCount: number;
+ };
+ works: Work[];
+}
+
+export interface Work {
+ age_category_string: string;
+ circle: {
+ id: number;
+ name: string;
+ source_id: string;
+ source_type: string;
+ };
+ circle_id: number;
+ create_date: string;
+ dl_count: number;
+ duration: number;
+ has_subtitle: boolean;
+ id: number;
+ language_editions: {
+ display_order: number;
+ edition_id: number;
+ edition_type: string;
+ label: string;
+ lang: string;
+ workno: string;
+ }[];
+ mainCoverUrl: string;
+ name: string;
+ nsfw: boolean;
+ original_workno: null | string;
+ other_language_editions_in_db: {
+ id: number;
+ is_original: boolean;
+ lang: string;
+ source_id: string;
+ source_type: string;
+ title: string;
+ }[];
+ playlistStatus: any;
+ price: number;
+ rank:
+ | {
+ category: string;
+ rank: number;
+ rank_date: string;
+ term: string;
+ }[]
+ | null;
+ rate_average_2dp: number | number;
+ rate_count: number;
+ rate_count_detail: {
+ count: number;
+ ratio: number;
+ review_point: number;
+ }[];
+ release: string;
+ review_count: number;
+ samCoverUrl: string;
+ source_id: string;
+ source_type: string;
+ source_url: string;
+ tags: {
+ i18n: any;
+ id: number;
+ name: string;
+ }[];
+ category: string;
+ thumbnailCoverUrl: string;
+ title: string;
+ translation_info: {
+ child_worknos: string[];
+ is_child: boolean;
+ is_original: boolean;
+ is_parent: boolean;
+ is_translation_agree: boolean;
+ is_translation_bonus_child: boolean;
+ is_volunteer: boolean;
+ lang: null | string;
+ original_workno: null | string;
+ parent_workno: null | string;
+ production_trade_price_rate: number;
+ translation_bonus_langs: string[];
+ };
+ userRating: null;
+ vas: {
+ id: string;
+ name: string;
+ }[];
+ cv: string;
+ work_attributes: string;
+}
diff --git a/lib/routes/asus/bios.ts b/lib/routes/asus/bios.ts
new file mode 100644
index 00000000000000..ad34d46a8961eb
--- /dev/null
+++ b/lib/routes/asus/bios.ts
@@ -0,0 +1,129 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+const endPoints = {
+ zh: {
+ url: 'https://odinapi.asus.com.cn/',
+ lang: 'cn',
+ websiteCode: 'cn',
+ },
+ en: {
+ url: 'https://odinapi.asus.com/',
+ lang: 'en',
+ websiteCode: 'global',
+ },
+};
+
+const getProductInfo = (model, language) => {
+ const currentEndpoint = endPoints[language] ?? endPoints.zh;
+ const { url, lang, websiteCode } = currentEndpoint;
+
+ const searchAPI = `${url}recent-data/apiv2/SearchSuggestion?SystemCode=asus&WebsiteCode=${websiteCode}&SearchKey=${model}&SearchType=ProductsAll&RowLimit=4&sitelang=${lang}`;
+
+ return cache.tryGet(`asus:bios:${model}:${language}`, async () => {
+ const response = await ofetch(searchAPI);
+ const product = response.Result[0].Content[0];
+
+ return {
+ productID: product.DataId,
+ hashId: product.HashId,
+ url: product.Url,
+ title: product.Title,
+ image: product.ImageURL,
+ m1Id: product.M1Id,
+ productLine: product.ProductLine,
+ };
+ }) as Promise<{
+ productID: string;
+ hashId: string;
+ url: string;
+ title: string;
+ image: string;
+ m1Id: string;
+ productLine: string;
+ }>;
+};
+
+export const route: Route = {
+ path: '/bios/:model/:lang?',
+ categories: ['program-update'],
+ example: '/asus/bios/RT-AX88U/zh',
+ parameters: {
+ model: 'Model, can be found in product page',
+ lang: {
+ description: 'Language, provide access routes for other parts of the world',
+ options: [
+ {
+ label: 'Chinese',
+ value: 'zh',
+ },
+ {
+ label: 'Global',
+ value: 'en',
+ },
+ ],
+ default: 'en',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: [
+ 'www.asus.com/displays-desktops/:productLine/:series/:model',
+ 'www.asus.com/laptops/:productLine/:series/:model',
+ 'www.asus.com/motherboards-components/:productLine/:series/:model',
+ 'www.asus.com/networking-iot-servers/:productLine/:series/:model',
+ 'www.asus.com/:region/displays-desktops/:productLine/:series/:model',
+ 'www.asus.com/:region/laptops/:productLine/:series/:model',
+ 'www.asus.com/:region/motherboards-components/:productLine/:series/:model',
+ 'www.asus.com/:region/networking-iot-servers/:productLine/:series/:model',
+ ],
+ target: '/bios/:model',
+ },
+ ],
+ name: 'BIOS',
+ maintainers: ['Fatpandac'],
+ handler,
+ url: 'www.asus.com',
+};
+
+async function handler(ctx) {
+ const model = ctx.req.param('model');
+ const language = ctx.req.param('lang') ?? 'en';
+ const productInfo = await getProductInfo(model, language);
+ const biosAPI =
+ language === 'zh' ? `https://www.asus.com.cn/support/api/product.asmx/GetPDBIOS?website=cn&model=${model}&sitelang=cn` : `https://www.asus.com/support/api/product.asmx/GetPDBIOS?website=global&model=${model}&sitelang=en`;
+
+ const response = await ofetch(biosAPI);
+ const biosList = response.Result.Obj[0].Files;
+
+ const items = biosList.map((item) => ({
+ title: item.Title,
+ description: art(path.join(__dirname, 'templates/bios.art'), {
+ item,
+ language,
+ }),
+ guid: productInfo.url + item.Version,
+ pubDate: parseDate(item.ReleaseDate, 'YYYY/MM/DD'),
+ link: productInfo.url,
+ }));
+
+ return {
+ title: `${productInfo.title} BIOS`,
+ link: productInfo.url,
+ image: productInfo.image,
+ item: items,
+ };
+}
diff --git a/lib/routes/asus/gpu-tweak.ts b/lib/routes/asus/gpu-tweak.ts
new file mode 100644
index 00000000000000..75e87aad695584
--- /dev/null
+++ b/lib/routes/asus/gpu-tweak.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const pageUrl = 'https://www.asus.com/campaign/GPU-Tweak-III/tw/index.php';
+
+export const route: Route = {
+ path: '/gpu-tweak',
+ categories: ['program-update'],
+ example: '/asus/gpu-tweak',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['asus.com/campaign/GPU-Tweak-III/*', 'asus.com/'],
+ },
+ ],
+ name: 'GPU Tweak',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'asus.com/campaign/GPU-Tweak-III/*',
+};
+
+async function handler() {
+ const response = await got(pageUrl);
+ const $ = load(response.data);
+
+ const items = $('section div.inner div.item')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ item.find('.last').remove();
+ return {
+ title: item.find('.ver h6').text().trim(),
+ description: item.find('.btnbox a.open_patch_lightbox').attr('data-info'),
+ pubDate: parseDate(item.find('.ti').text()),
+ link: item.find('.btnbox a[download=]').attr('href'),
+ };
+ });
+
+ return {
+ title: $('head title').text(),
+ description: $('meta[name=description]').attr('content'),
+ image: new URL($('head link[rel="shortcut icon"]').attr('href'), pageUrl).href,
+ link: pageUrl,
+ item: items,
+ language: $('html').attr('lang'),
+ };
+}
diff --git a/lib/routes/asus/namespace.ts b/lib/routes/asus/namespace.ts
new file mode 100644
index 00000000000000..5bfdf756afa789
--- /dev/null
+++ b/lib/routes/asus/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ASUS',
+ url: 'asus.com.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/asus/templates/bios.art b/lib/routes/asus/templates/bios.art
new file mode 100644
index 00000000000000..559dcc7571a330
--- /dev/null
+++ b/lib/routes/asus/templates/bios.art
@@ -0,0 +1,13 @@
+{{ if language !== 'zh' }}
+ Changes:
+ {{@ item.Description}}
+ Version: {{item.Version}}
+ Size: {{item.FileSize}}
+ Download: {{ item.DownloadUrl.Global.split('/').pop().split('?')[0] }}
+{{ else }}
+ 更新信息:
+ {{@ item.Description}}
+ 版本: {{item.Version}}
+ 大小: {{item.FileSize}}
+ 下载链接: 中国下载 | 全球下载
+{{ /if }}
diff --git a/lib/routes/atcoder/contest.ts b/lib/routes/atcoder/contest.ts
new file mode 100644
index 00000000000000..270a9689a9e568
--- /dev/null
+++ b/lib/routes/atcoder/contest.ts
@@ -0,0 +1,104 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/contest/:language?/:rated?/:category?/:keyword?',
+ categories: ['programming'],
+ example: '/atcoder/contest',
+ parameters: { language: 'Language, `jp` as Japanese or `en` as English, English by default', rated: 'Rated Range, see below, all by default', category: 'Category, see below, all by default', keyword: 'Keyword' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Contests Archive',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `Rated Range
+
+| ABC Class (Rated for \~1999) | ARC Class (Rated for \~2799) | AGC Class (Rated for \~9999) |
+| ---------------------------- | ---------------------------- | ---------------------------- |
+| 1 | 2 | 3 |
+
+ Category
+
+| All | AtCoder Typical Contest | PAST Archive | Unofficial(unrated) |
+| --- | ----------------------- | ------------ | ------------------- |
+| 0 | 6 | 50 | 101 |
+
+| JOI Archive | Sponsored Tournament | Sponsored Parallel(rated) |
+| ----------- | -------------------- | ------------------------- |
+| 200 | 1000 | 1001 |
+
+| Sponsored Parallel(unrated) | Optimization Contest |
+| --------------------------- | -------------------- |
+| 1002 | 1200 |`,
+};
+
+async function handler(ctx) {
+ const status = ['action', 'upcoming', 'recent'];
+
+ const language = ctx.req.param('language') ?? 'en';
+
+ let rated = ctx.req.param('rated') ?? '0';
+ const category = ctx.req.param('category') ?? '0';
+ const keyword = ctx.req.param('keyword') ?? '';
+
+ rated = rated === 'active' ? 'action' : rated;
+ const isStatus = status.includes(rated);
+
+ const rootUrl = 'https://atcoder.jp';
+ const currentUrl = `${rootUrl}/contests${isStatus ? `?lang=${language}` : `/archive?lang=${language}&ratedType=${rated}&category=${category}${keyword ? `&keyword=${keyword}` : ''}`}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $(isStatus ? `#contest-table-${rated}` : '.row')
+ .find('tr')
+ .slice(1, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 20)
+ .toArray()
+ .map((item) => {
+ item = $(item).find('td a').eq(1);
+
+ return {
+ title: item.text(),
+ link: `${rootUrl}${item.attr('href')}?lang=${language}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.description = content(`.lang-${language}`).html();
+ item.pubDate = parseDate(content('.fixtime-full').first().text());
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: String(isStatus ? `${$(`#contest-table-${rated} h3`).text()} - AtCoder` : $('title').text()),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/atcoder/namespace.ts b/lib/routes/atcoder/namespace.ts
new file mode 100644
index 00000000000000..cb177cc58be66f
--- /dev/null
+++ b/lib/routes/atcoder/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AtCoder',
+ url: 'atcoder.jp',
+ lang: 'en',
+};
diff --git a/lib/routes/atcoder/post.ts b/lib/routes/atcoder/post.ts
new file mode 100644
index 00000000000000..55130f76876f57
--- /dev/null
+++ b/lib/routes/atcoder/post.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/post/:language?/:keyword?',
+ categories: ['programming'],
+ example: '/atcoder/post',
+ parameters: { language: 'Language, `jp` as Japanese or `en` as English, English by default', keyword: 'Keyword' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Posts',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const language = ctx.req.param('language') ?? 'en';
+ const keyword = ctx.req.param('keyword') ?? '';
+
+ const rootUrl = 'https://atcoder.jp';
+ const currentUrl = `${rootUrl}/posts?lang=${language}${keyword ? `&keyword=${keyword}` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const items = $('.panel')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.find('.panel-title').text(),
+ description: item.find('.panel-body').html(),
+ link: `${rootUrl}${item.find('.panel-title a').attr('href')}`,
+ pubDate: timezone(parseDate(item.find('.timeago').attr('datetime')), +9),
+ };
+ });
+
+ return {
+ title: `${keyword ? `[${keyword}] - ` : ''}${$('title').text()}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/atptour/namespace.ts b/lib/routes/atptour/namespace.ts
new file mode 100644
index 00000000000000..0916b893b623c5
--- /dev/null
+++ b/lib/routes/atptour/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ATP Tour',
+ url: 'www.atptour.com',
+ description: "News from the official site of men's professional tennis.",
+ lang: 'en',
+};
diff --git a/lib/routes/atptour/news.ts b/lib/routes/atptour/news.ts
new file mode 100644
index 00000000000000..0382bf2ecd156c
--- /dev/null
+++ b/lib/routes/atptour/news.ts
@@ -0,0 +1,52 @@
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news/:lang?',
+ categories: ['other'],
+ example: '/atptour/news/en',
+ parameters: { lang: 'en or es.' },
+ radar: [
+ {
+ source: ['atptour.com'],
+ },
+ ],
+ name: 'News',
+ maintainers: ['LM1207'],
+ handler,
+};
+
+async function handler(ctx) {
+ const baseUrl = 'https://www.atptour.com';
+ const favIcon = `${baseUrl}/assets/atptour/assets/favicon.ico`;
+ const { lang = 'en' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 15;
+
+ const link = `${baseUrl}/${lang}/-/tour/news/latest-filtered-results/0/${limit}`;
+ const { data } = await got(link, {
+ headers: {
+ 'user-agent': config.trueUA,
+ },
+ });
+
+ return {
+ title: lang === 'en' ? 'News' : 'Noticias',
+ link: `${baseUrl}/${lang}/news/news-filter-results`,
+ description: lang === 'en' ? "News from the official site of men's professional tennis." : 'Noticias del sitio oficial del tenis profesional masculino.',
+ language: lang,
+ icon: favIcon,
+ logo: favIcon,
+ author: 'ATP',
+ item: data.content.map((item) => ({
+ title: item.title,
+ link: baseUrl + item.url,
+ description: item.description,
+ author: item.byline,
+ category: item.category,
+ pubDate: parseDate(item.authoredDate),
+ image: baseUrl + item.image,
+ })),
+ };
+}
diff --git a/lib/routes/augmentcode/blog.ts b/lib/routes/augmentcode/blog.ts
new file mode 100644
index 00000000000000..9d93acd5674974
--- /dev/null
+++ b/lib/routes/augmentcode/blog.ts
@@ -0,0 +1,163 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '50', 10);
+
+ const baseUrl: string = 'https://augmentcode.com';
+ const targetUrl: string = new URL('blog', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+
+ let items: DataItem[] = [];
+
+ items = $('div[data-slot="card"]')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('div[data-slot="card-content"]').text();
+ const pubDateStr: string | undefined = $el.find('div[data-slot="card-footer"] p').last().text();
+ const linkUrl: string | undefined = $el.parent().attr('href');
+ const authorEls: Element[] = $el.find('div[data-slot="card-footer"] p').first().find('span').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $authorEl: Cheerio = $(authorEl);
+
+ return {
+ name: $authorEl.contents().first().text(),
+ url: undefined,
+ avatar: undefined,
+ };
+ });
+ const image: string | undefined = $el.find('div[data-slot="card-header"] img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ author: authors,
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('article h1').text();
+ const image: string | undefined = $$('meta[property="og:image"]').attr('content') ?? item.image;
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: $$('div.prose').html(),
+ });
+ const pubDateStr: string | undefined = $$('meta[property="article:published_time"]').attr('content');
+ const authorEls: Element[] = $$('meta[property="article:author"]').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl);
+
+ return {
+ name: $$authorEl.attr('content'),
+ url: undefined,
+ avatar: undefined,
+ };
+ });
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: title.split(/-/).pop()?.trim(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/blog',
+ name: 'Blog',
+ url: 'augmentcode.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/augmentcode/blog',
+ parameters: undefined,
+ description: undefined,
+ categories: ['programming'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['augmentcode.com/blog'],
+ target: '/blog',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/augmentcode/namespace.ts b/lib/routes/augmentcode/namespace.ts
new file mode 100644
index 00000000000000..f01991dea5feff
--- /dev/null
+++ b/lib/routes/augmentcode/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Augment Code',
+ url: 'augmentcode.com',
+ categories: ['programming'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/augmentcode/templates/description.art b/lib/routes/augmentcode/templates/description.art
new file mode 100644
index 00000000000000..dfab19230c1108
--- /dev/null
+++ b/lib/routes/augmentcode/templates/description.art
@@ -0,0 +1,17 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/auto-stats/index.ts b/lib/routes/auto-stats/index.ts
new file mode 100644
index 00000000000000..e5c1640cb77a3b
--- /dev/null
+++ b/lib/routes/auto-stats/index.ts
@@ -0,0 +1,89 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['other'],
+ example: '/auto-stats',
+ parameters: { category: '分类,见下表,默认为信息快递' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 信息快递 | 工作动态 | 专题分析 |
+| -------- | -------- | -------- |
+| xxkd | gzdt | ztfx |`,
+};
+
+async function handler(ctx) {
+ const { category = 'xxkd' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'http://www.auto-stats.org.cn';
+ const currentUrl = new URL(`${category}.asp`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(iconv.decode(response, 'gbk'));
+
+ let items = $('a.dnews font')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.text();
+ const pubDate = title.match(/(\d{4}(?:\/\d{1,2}){2}\s\d{1,2}(?::\d{2}){2})/)?.[1] ?? undefined;
+
+ return {
+ title: title.replace(/●/, '').split(/(\d+/)[0],
+ link: new URL(item.parent().prop('href'), rootUrl).href,
+ pubDate: timezone(parseDate(pubDate, 'YYYY/M/D H:mm:ss'), +8),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link, {
+ responseType: 'buffer',
+ });
+
+ const content = load(iconv.decode(detailResponse, 'gbk'));
+
+ item.description = content('table tbody tr td.dd').last().html();
+
+ return item;
+ })
+ )
+ );
+
+ const subtitle = $('title').text().split(/——/).pop();
+ const image = new URL('images/logo.jpg', rootUrl).href;
+
+ return {
+ item: items,
+ title: $('title').text(),
+ link: currentUrl,
+ description: subtitle,
+ language: 'zh',
+ image,
+ subtitle,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/auto-stats/namespace.ts b/lib/routes/auto-stats/namespace.ts
new file mode 100644
index 00000000000000..247b60e68d57c0
--- /dev/null
+++ b/lib/routes/auto-stats/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国汽车工业协会统计信息网',
+ url: 'auto-stats.org.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/autocentre/index.ts b/lib/routes/autocentre/index.ts
new file mode 100644
index 00000000000000..3ee5c013037ce3
--- /dev/null
+++ b/lib/routes/autocentre/index.ts
@@ -0,0 +1,29 @@
+import type { Data, Route } from '@/types';
+import parser from '@/utils/rss-parser';
+
+export const route: Route = {
+ path: '/',
+ name: 'Автомобільний сайт N1 в Україні',
+ categories: ['new-media'],
+ maintainers: ['driversti'],
+ example: '/autocentre',
+ handler,
+};
+
+const createItem = (item) => ({
+ title: item.title,
+ link: item.link,
+ description: item.contentSnippet,
+});
+
+async function handler(): Promise {
+ const feed = await parser.parseURL('https://www.autocentre.ua/rss');
+
+ return {
+ title: feed.title as string,
+ link: feed.link,
+ description: feed.description,
+ language: 'uk',
+ item: await Promise.all(feed.items.map((item) => createItem(item))),
+ };
+}
diff --git a/lib/routes/autocentre/namespace.ts b/lib/routes/autocentre/namespace.ts
new file mode 100644
index 00000000000000..b9db3ac3a9c2c3
--- /dev/null
+++ b/lib/routes/autocentre/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Автоцентр.ua',
+ url: 'autocentre.ua',
+ description: 'Автоцентр.ua: автоновини - Автомобільний сайт N1 в Україні',
+ lang: 'ru',
+};
diff --git a/lib/routes/autotrader/index.js b/lib/routes/autotrader/index.js
deleted file mode 100644
index dc35fcf8b53ff5..00000000000000
--- a/lib/routes/autotrader/index.js
+++ /dev/null
@@ -1,69 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const link = `https://www.autotrader.co.uk/results-car-search?${ctx.params.query}`;
- const response = await got.get(link);
-
- const $ = cheerio.load(response.data.html);
-
- const idList = $('li.search-page__result').slice(0, 10).get();
-
- const items = await Promise.all(
- idList.map(async (item) => {
- const link = `https://www.autotrader.co.uk/classified/advert/${item.attribs.id}`;
-
- const cache = await ctx.cache.get(link);
-
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const response = await got.get(link);
- const $ = cheerio.load(response.data);
-
- let keyFacts = '';
- $('.keyFacts__list .keyFacts__label').each((i, e) => {
- keyFacts += `${$(e).text()} `;
- if ((i + 1) % 4 === 0) {
- keyFacts += ' ';
- }
- });
- keyFacts += ' ';
-
- $('.fpaSpecifications__economy .fpaSpecifications__listItem').each((i, e) => {
- keyFacts += `${$(e).find('.fpaSpecifications__term').text()}: ${$(e).find('.fpaSpecifications__description').text()} `;
- if ((i + 1) % 4 === 0) {
- keyFacts += ' ';
- }
- });
-
- keyFacts += '
';
-
- let images = '';
-
- $('.fpa-image-overlay img').each((i, e) => {
- images += ` `;
- });
-
- const description = keyFacts + images + $('meta[name="og:description"]').attr('content');
-
- const title = `「${$('.fpaGallery__priceLabel').text()}」${$('meta[name="og:title"]').attr('content')}`;
-
- const single = {
- title,
- description,
- pubDate: new Date().toISOString(),
- link,
- };
- ctx.cache.set(link, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
-
- ctx.state.data = {
- title: 'Auto Trader',
- link,
- item: items,
- };
-};
diff --git a/lib/routes/axis-studios/work.js b/lib/routes/axis-studios/work.js
deleted file mode 100755
index 76b6e034e9fa0f..00000000000000
--- a/lib/routes/axis-studios/work.js
+++ /dev/null
@@ -1,82 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const { type, tag } = ctx.params || '';
- const response = await got({
- method: 'get',
- url: `https://axisstudiosgroup.com/${type}/${tag}`,
- });
- const data = response.data;
- const $ = cheerio.load(data); // 使用 cheerio 加载返回的 HTML
- const list = $('a.overlay-link').get().slice(0, 13);
- const articledata = await Promise.all(
- list.map(async (item) => {
- const link = `https://axisstudiosgroup.com${$(item)
- .attr('href')
- .replace(/https:/, '')}`;
-
- const cache = await ctx.cache.get(link);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const response2 = await got({
- method: 'get',
- url: link,
- });
-
- const articleHtml = response2.data;
- const $2 = cheerio.load(articleHtml);
-
- $2('.slider-nav').remove(); // 图片导航
- $2('.social-wrapper').remove(); // 社交按钮
- $2('source').remove(); // 莫名其妙的图片
- $2('video').remove(); // 视频标签
- $2('button').remove(); // 按钮
- $2('div.modal.fade').remove(); // 莫名奇妙的图片
- $2('i').remove();
-
- const youtube = $2('.mcdr.playlist-control a').attr('data-video-type') === 'video/youtube' ? $2('.mcdr.playlist-control a').attr('data-video-url').replace('https://www.youtube.com/watch?v=', '') : '';
- const mp4 = $2('.mcdr.playlist-control a').attr('data-video-type') === 'video/mp4' ? $2('.mcdr.playlist-control a').attr('data-video-url') : '';
-
- const content = $2('.container-fluid>div:nth-child(2)')
- .html()
- .replace(//g, '')
- .replace(//g, '')
- .replace(/<.?div>/g, '')
- .replace(//g, '');
- const single = {
- describe: content,
- title: $2('.overlay-content').find('h1').text(),
- link,
- mp4,
- youtube,
- };
- ctx.cache.set(link, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
-
- ctx.state.data = {
- title: `Axis Studios | ${type} ${tag ? tag : ''}`,
- link: 'http://axisstudiosgroup.com',
- description: $('description').text(),
- item: list.map((item, index) => {
- let video = '';
- if (articledata[index].mp4) {
- video = `
`;
- }
- if (articledata[index].youtube) {
- video = `
VIDEO `;
- }
- return {
- title: articledata[index].title,
- description: `${video}+${articledata[index].describe}`,
- link: articledata[index].link,
- };
- }),
- };
-};
diff --git a/lib/routes/azul/namespace.ts b/lib/routes/azul/namespace.ts
new file mode 100644
index 00000000000000..3a578ec608c655
--- /dev/null
+++ b/lib/routes/azul/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Azul',
+ url: 'azul.com',
+ categories: ['programming'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/azul/packages.ts b/lib/routes/azul/packages.ts
new file mode 100644
index 00000000000000..4a9ce21faf41d3
--- /dev/null
+++ b/lib/routes/azul/packages.ts
@@ -0,0 +1,106 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const handler = async (ctx: Context): Promise
=> {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'https://www.azul.com';
+ const apiBaseUrl: string = 'https://api.azul.com';
+ const targetUrl: string = new URL('downloads', baseUrl).href;
+ const apiUrl: string = new URL('metadata/v1/zulu/packages', apiBaseUrl).href;
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'en';
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ availability_types: 'ca',
+ release_status: 'both',
+ page_size: 1000,
+ include_fields: 'java_package_features, release_status, support_term, os, arch, hw_bitness, abi, java_package_type, javafx_bundled, sha256_hash, cpu_gen, size, archive_type, certifications, lib_c_type, crac_supported',
+ page: 1,
+ azul_com: true,
+ },
+ });
+
+ const items: DataItem[] = response.slice(0, limit).map((item): DataItem => {
+ const javaVersion: string = `${item.java_version.join('.')}+${item.openjdk_build_number}`;
+ const distroVersion: string = item.distro_version.join('.');
+
+ const title: string = `[${javaVersion}] (${distroVersion}) ${item.name}`;
+ const linkUrl: string | undefined = item.download_url;
+ const categories: string[] = [item.os, item.arch, item.java_package_type, item.archive_type, item.abi, ...(item.javafx_bundled ? ['javafx'] : []), ...(item.crac_supported ? ['crac'] : [])];
+ const guid: string = `azul-${item.name}`;
+
+ let processedItem: DataItem = {
+ title,
+ link: linkUrl,
+ category: categories,
+ guid,
+ id: guid,
+ language,
+ };
+
+ const enclosureUrl: string | undefined = item.download_url;
+
+ if (enclosureUrl) {
+ const enclosureTitle: string = item.name;
+ const enclosureLength: number = item.size;
+
+ processedItem = {
+ ...processedItem,
+ enclosure_url: enclosureUrl,
+ enclosure_title: enclosureTitle || title,
+ enclosure_length: enclosureLength,
+ };
+ }
+
+ return processedItem;
+ });
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/downloads',
+ name: 'Downloads',
+ url: 'www.azul.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/azul/downloads',
+ parameters: undefined,
+ description: undefined,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.azul.com/downloads'],
+ target: '/downloads',
+ },
+ ],
+ view: ViewType.Notifications,
+};
diff --git a/lib/routes/azurlane/nameplace.ts b/lib/routes/azurlane/nameplace.ts
new file mode 100644
index 00000000000000..1e81ec8d3c5af1
--- /dev/null
+++ b/lib/routes/azurlane/nameplace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Azur Lane',
+ url: 'azurlane.jp',
+ categories: ['game'],
+ lang: 'ja',
+};
diff --git a/lib/routes/azurlane/news.ts b/lib/routes/azurlane/news.ts
new file mode 100644
index 00000000000000..c3a4c6745896ae
--- /dev/null
+++ b/lib/routes/azurlane/news.ts
@@ -0,0 +1,90 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+type Mapping = Record;
+
+const JP: Mapping = {
+ '0': 'すべて',
+ '1': 'お知らせ',
+ '2': 'イベント',
+ '3': 'メインテナンス',
+ '4': '重要',
+};
+
+const mkTable = (mapping: Mapping): string => {
+ const heading: string[] = [];
+ const separator: string[] = [];
+ const body: string[] = [];
+
+ for (const key in mapping) {
+ heading.push(mapping[key]);
+ separator.push(':--:');
+ body.push(key);
+ }
+
+ return [heading.join(' | '), separator.join(' | '), body.join(' | ')].map((s) => `| ${s} |`).join('\n');
+};
+
+const handler: Route['handler'] = async (ctx) => {
+ const { server } = ctx.req.param();
+
+ switch (server.toUpperCase()) {
+ case 'JP':
+ return await ja(ctx);
+ default:
+ throw new Error('Unsupported server');
+ }
+};
+
+const ja: Route['handler'] = async (ctx) => {
+ const { type = '0' } = ctx.req.param();
+
+ const response = await ofetch<{ data: { rows: { id: number; content: string; title: string; publishTime: number }[] } }>('https://www.azurlane.jp/api/news/list', {
+ query: {
+ type,
+ index: 1,
+ size: 15,
+ },
+ });
+
+ const list = response.data?.rows || [];
+ const items = list.map((item) => ({
+ title: item.title,
+ description: item.content,
+ link: `https://www.azurlane.jp/news/${item.id}`,
+ pubDate: parseDate(item.publishTime),
+ }));
+
+ return {
+ title: `アズールレーン - ${JP[type]}`,
+ link: 'https://www.azurlane.jp/news',
+ language: 'ja-JP',
+ image: 'https://play-lh.googleusercontent.com/9QTLYD2_Jd6OIKHwRHkEBnFAgPmVKJwf2xmHjzPk-5w0SRLZumsCoQZGlO8d_kB3Gdld=w480-h960-rw',
+ icon: 'https://play-lh.googleusercontent.com/9QTLYD2_Jd6OIKHwRHkEBnFAgPmVKJwf2xmHjzPk-5w0SRLZumsCoQZGlO8d_kB3Gdld=w480-h960-rw',
+ logo: 'https://play-lh.googleusercontent.com/9QTLYD2_Jd6OIKHwRHkEBnFAgPmVKJwf2xmHjzPk-5w0SRLZumsCoQZGlO8d_kB3Gdld=w480-h960-rw',
+ item: items,
+ };
+};
+
+export const route: Route = {
+ path: '/news/:server/:type?',
+ name: 'News',
+ categories: ['game'],
+ maintainers: ['AnitsuriW'],
+ example: '/azurlane/news/jp/0',
+ parameters: {
+ server: 'game server (ISO 3166 two-letter country code, case-insensitive), only `JP` is supported for now',
+ type: 'news type, see the table below, `0` by default',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ handler,
+ description: mkTable(JP),
+};
diff --git a/lib/routes/baai/events.ts b/lib/routes/baai/events.ts
new file mode 100644
index 00000000000000..6fc1cbbfeed97a
--- /dev/null
+++ b/lib/routes/baai/events.ts
@@ -0,0 +1,47 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+import { apiHost, baseUrl, parseEventDetail, parseItem } from './utils';
+
+export const route: Route = {
+ path: '/hub/events',
+ categories: ['programming'],
+ example: '/baai/hub/events',
+ radar: [
+ {
+ source: ['hub.baai.ac.cn/events', 'hub.baai.ac.cn/'],
+ },
+ ],
+ name: '智源社区 - 活动',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'hub.baai.ac.cn/events',
+};
+
+async function handler() {
+ const response = await ofetch(`${apiHost}/api/v1/events`, {
+ method: 'POST',
+ body: {
+ page: 1,
+ tag_id: '',
+ },
+ });
+
+ const list = response.data.map((item) => parseItem(item));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ item.description = await parseEventDetail(item);
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '活动 - 智源社区',
+ link: `${baseUrl}/events`,
+ item: items,
+ };
+}
diff --git a/lib/routes/baai/hub.ts b/lib/routes/baai/hub.ts
new file mode 100644
index 00000000000000..93877d157c2783
--- /dev/null
+++ b/lib/routes/baai/hub.ts
@@ -0,0 +1,99 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+import { apiHost, baseUrl, getTagsData, parseEventDetail, parseItem } from './utils';
+
+export const route: Route = {
+ path: ['/hub/:tagId?/:sort?/:range?'],
+ categories: ['programming'],
+ example: '/baai/hub',
+ parameters: {
+ tagId: '社群 ID,可在 [社群页](https://hub.baai.ac.cn/taglist) 或 URL 中找到',
+ sort: '排序,见下表,默认为 `new`',
+ range: '时间跨度,仅在排序 `readCnt` 时有效',
+ },
+ description: `排序
+
+| 最新 | 最热 |
+| ---- | ------- |
+| new | readCnt |
+
+时间跨度
+
+| 3 天 | 本周 | 本月 |
+| ---- | ---- | ---- |
+| 3 | 7 | 30 |`,
+ radar: [
+ {
+ source: ['baai.ac.cn/'],
+ target: (_params, url) => {
+ const searchParams = new URL(url).searchParams;
+ const tagId = searchParams.get('tag_id');
+ const sort = searchParams.get('sort');
+ const range = searchParams.get('time_range');
+ return `/baai/hub${tagId ? `/${tagId}` : ''}${sort ? `/${sort}` : ''}${range ? `/${range}` : ''}`;
+ },
+ },
+ ],
+ name: '智源社区',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { tagId = '', sort = 'new', range } = ctx.req.param();
+
+ let title, description, brief, iconUrl;
+ if (tagId) {
+ const tagsData = await getTagsData();
+
+ const tag = (tagsData as Record[]).find((tag) => tag.id === tagId);
+ if (tag) {
+ title = tag.title;
+ description = tag.description;
+ brief = tag.brief;
+ iconUrl = tag.iconUrl;
+ } else {
+ throw new InvalidParameterError('Tag not found');
+ }
+ }
+
+ const response = await ofetch(`${apiHost}/api/v1/story/list`, {
+ method: 'POST',
+ query: {
+ page: 1,
+ sort,
+ tag_id: tagId,
+ time_range: range,
+ },
+ });
+
+ const list = response.data.map((item) => parseItem(item));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (item.eventId) {
+ item.description = await parseEventDetail(item);
+ } else {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ item.description = item.is_event ? $('div.box2').html() : $('.post-content').html();
+ }
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${title ? `${title} - ` : ''}${description ? `${description} - ` : ''}智源社区`,
+ description: brief,
+ link: `${baseUrl}/?${tagId ? `tag_id=${tagId}&` : ''}sort=${sort}${range ? `&time_range=${range}` : ''}`,
+ image: iconUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/baai/namespace.ts b/lib/routes/baai/namespace.ts
new file mode 100644
index 00000000000000..3b7c640abe59ba
--- /dev/null
+++ b/lib/routes/baai/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京智源人工智能研究院',
+ url: 'hub.baai.ac.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/baai/utils.ts b/lib/routes/baai/utils.ts
new file mode 100644
index 00000000000000..c54648ad67fb6b
--- /dev/null
+++ b/lib/routes/baai/utils.ts
@@ -0,0 +1,43 @@
+import { destr } from 'destr';
+
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const baseUrl = 'https://hub.baai.ac.cn';
+const eventUrl = 'https://event.baai.ac.cn';
+const apiHost = 'https://hub-api.baai.ac.cn';
+
+const getTagsData = () =>
+ cache.tryGet('baai:tags', async () => {
+ const { data } = await ofetch(`${apiHost}/api/v1/tags`);
+ return data.map((item) => ({
+ id: item.id,
+ title: item.title,
+ description: item.description,
+ brief: item.brief,
+ iconUrl: item.icon_url,
+ }));
+ });
+
+const parseItem = (item) => ({
+ link: item.is_event ? `${eventUrl}/activities/${item.event_info.id}` : `${baseUrl}/view/${item.story_id}`,
+ title: item.is_event ? item.event_info.name : item.story_info.title,
+ pubDate: timezone(parseDate(item.is_event ? item.event_info.time_desc : item.story_info.created_at.replace('发布', '').replace('分享', '')), 8),
+ author: item.is_event ? item.event_info.company : item.story_info.user_name,
+ category: item.is_event ? null : item.story_info.tag_names.map((tag) => tag.title),
+ eventId: item.is_event ? item.event_info.id : null,
+});
+
+const parseEventDetail = async (item) => {
+ const data = await ofetch(`${eventUrl}/api/api/Activity/IntroductionTypes`, {
+ query: {
+ activityId: item.eventId,
+ },
+ parseResponse: (txt) => destr(txt),
+ });
+ return data.data.ac_desc + data.data.ac_desc_two;
+};
+
+export { apiHost, baseUrl, eventUrl, getTagsData, parseEventDetail, parseItem };
diff --git a/lib/routes/babykingdom/index.js b/lib/routes/babykingdom/index.js
deleted file mode 100644
index 399e812c8ae82e..00000000000000
--- a/lib/routes/babykingdom/index.js
+++ /dev/null
@@ -1,46 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const url = require('url');
-
-const host = 'https://www.baby-kingdom.com';
-
-module.exports = async (ctx) => {
- const id = ctx.params.id;
- const order = ctx.params.order;
-
- let link = `https://www.baby-kingdom.com/forum.php?mod=forumdisplay&fid=${id}`;
-
- if (order === 'dateline') {
- link += '&filter=author&orderby=dateline';
- } else if (order === 'reply') {
- link += '&filter=reply&orderby=replies';
- } else if (order === 'view') {
- link += '&filter=reply&orderby=views';
- } else if (order === 'lastpost') {
- link += '&filter=lastpost&orderby=lastpost';
- } else if (order === 'heat') {
- link += '&filter=heat&orderby=heats';
- }
-
- const response = await got.get(link);
- const $ = cheerio.load(response.data);
-
- const title = $('h1.xs2 a').text();
- const out = $('tbody[id^="normalthread"]')
- .slice(0, 10)
- .map(function () {
- const info = {
- title: $(this).find('td:nth-child(2) > a:nth-child(1)').text(),
- link: url.resolve(host, $(this).find('td:nth-child(2) > a:nth-child(1)').attr('href')),
- author: $(this).find('td.by.by_author cite a').text(),
- };
- return info;
- })
- .get();
-
- ctx.state.data = {
- title: `${title}-親子王國`,
- link,
- item: out,
- };
-};
diff --git a/lib/routes/backlinko/blog.ts b/lib/routes/backlinko/blog.ts
new file mode 100644
index 00000000000000..a90fe1bde01bdb
--- /dev/null
+++ b/lib/routes/backlinko/blog.ts
@@ -0,0 +1,71 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/blog',
+ categories: ['blog'],
+ example: '/backlinko/blog',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['backlinko.com/blog', 'backlinko.com/'],
+ },
+ ],
+ name: 'Blog',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'backlinko.com/blog',
+};
+
+async function handler() {
+ const baseUrl = 'https://backlinko.com';
+ const { data: response, url: link } = await got(`${baseUrl}/blog`);
+
+ const $ = load(response);
+ const nextData = JSON.parse($('#__NEXT_DATA__').text());
+ const {
+ buildId,
+ props: { pageProps },
+ } = nextData;
+
+ const posts = [...pageProps.posts.nodes, ...pageProps.backlinkoLockedPosts.nodes].map((post) => ({
+ title: post.title,
+ link: `${baseUrl}/${post.slug}`,
+ pubDate: parseDate(post.modified),
+ author: post.author.node.name,
+ apiUrl: `${baseUrl}/_next/data/${buildId}/${post.slug}.json`,
+ }));
+
+ const items = await Promise.all(
+ posts.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data } = await got(item.apiUrl);
+ const post = data.pageProps.post || data.pageProps.lockedPost;
+
+ item.description = post.content;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: pageProps.page.seo.title,
+ description: pageProps.page.seo.metaDesc,
+ link,
+ language: 'en',
+ item: items,
+ };
+}
diff --git a/lib/routes/backlinko/namespace.ts b/lib/routes/backlinko/namespace.ts
new file mode 100644
index 00000000000000..ec3016624fae29
--- /dev/null
+++ b/lib/routes/backlinko/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Backlinko',
+ url: 'backlinko.com',
+ lang: 'en',
+};
diff --git a/lib/routes/bad/index.ts b/lib/routes/bad/index.ts
new file mode 100644
index 00000000000000..a821ead9b60b02
--- /dev/null
+++ b/lib/routes/bad/index.ts
@@ -0,0 +1,68 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '*',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://bad.news';
+ const currentUrl = `${rootUrl}${getSubPath(ctx) === '/' ? '' : getSubPath(ctx)}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ $('.option, .pagination').remove();
+
+ const items = $('.entry')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a.title');
+
+ item.find('img').each(function () {
+ $(this).attr('src', $(this).attr('data-echo'));
+ $(this).removeClass('lazy');
+ $(this).removeAttr('data-echo');
+ $(this).removeAttr('id');
+ });
+
+ item.find('video').each(function () {
+ $(this).attr('poster', $(this).attr('data-echo'));
+ $(this).removeAttr('data-echo');
+ $(this).removeAttr('onerror');
+ $(this).removeAttr('id');
+ });
+
+ return {
+ title: a.text(),
+ link: a.attr('href'),
+ description: item.find('.coverdiv').html(),
+ author: item.find('.author').text().trim(),
+ pubDate: timezone(parseDate(item.find('time').attr('datetime')), +8),
+ category: item
+ .find('.label')
+ .toArray()
+ .map((l) => $(l).text().trim()),
+ };
+ });
+
+ return {
+ title: `Bad.news - ${$('.active').text()}${$('.selected').text()}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bad/namespace.ts b/lib/routes/bad/namespace.ts
new file mode 100644
index 00000000000000..9338a21f419430
--- /dev/null
+++ b/lib/routes/bad/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bad.news',
+ url: 'bad.news',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bahamut/creation_index.js b/lib/routes/bahamut/creation_index.js
deleted file mode 100644
index b04370a2902c48..00000000000000
--- a/lib/routes/bahamut/creation_index.js
+++ /dev/null
@@ -1,73 +0,0 @@
-const utils = require('./utils');
-
-const type_map = {
- 0: '達人專欄',
- 1: '達人專欄',
- 2: '最新創作',
- 3: '最新推薦',
- 4: '熱門創作',
- 5: '精選閣樓',
-};
-
-const category_map = {
- 0: '不限',
- 1: '日誌',
- 2: '小說',
- 3: '繪圖',
- 4: 'Cosplay',
- 5: '同人商品',
-};
-
-const subcategory_map = {
- 0: '不限',
- 1: 'ACG相關',
- 2: '生活休閒',
- 3: '巴哈相關',
- 4: '歡樂惡搞',
- 5: '心情日記',
- 6: '模型/公仔',
- 7: '視聽娛樂',
- 8: '興趣嗜好',
- 9: '其他',
- 10: '愛情',
- 11: '奇幻',
- 12: '科幻',
- 13: '武俠',
- 14: '推理驚悚',
- 15: '歡樂惡搞',
- 16: 'BL/GL',
- 17: '輕小說',
- 18: '其他',
- 19: '紙上塗鴉',
- 20: '草稿/線稿',
- 21: '單色人物',
- 22: '彩色人物',
- 23: '完稿作品',
- 24: '漫畫',
- 25: '勇者造型',
- 26: '其他',
- 27: 'ACG相關',
- 28: '特攝',
- 29: '布袋戲',
- 30: '電影',
- 31: '其他',
- 32: '男性向',
- 33: '女性向',
- 34: '其他',
-};
-
-module.exports = async (ctx) => {
- const type = ctx.params.type ? ctx.params.type : '1';
- const category = ctx.params.category ? ctx.params.category : '0';
- const subcategory = ctx.params.subcategory ? ctx.params.subcategory : '0';
-
- const url = `https://home.gamer.com.tw/index.php?k1=${category}&k2=${subcategory}&vt=${type}&sm=3`;
-
- const { items } = await utils.ProcessFeed(url, ctx);
-
- ctx.state.data = {
- title: `巴哈姆特创作大厅${category !== '0' ? ' - ' + category_map[category] : ''}${subcategory !== '0' ? ' - ' + subcategory_map[subcategory] : ''} - ${type_map[type]}`,
- link: url,
- item: items,
- };
-};
diff --git a/lib/routes/bahamut/utils.js b/lib/routes/bahamut/utils.js
deleted file mode 100644
index 3c020aa01ecf22..00000000000000
--- a/lib/routes/bahamut/utils.js
+++ /dev/null
@@ -1,65 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-const base = 'https://home.gamer.com.tw/';
-
-module.exports = {
- ProcessFeed: async (url, ctx) => {
- const response = await got.get(url);
- const $ = cheerio.load(response.data);
-
- const title = $('title').text();
- const list = $('.BH-lbox > .HOME-mainbox1').toArray();
-
- const parseContent = (htmlString) => {
- const $ = cheerio.load(htmlString);
- const content = $('.MSG-list8C');
-
- const images = $('img');
- for (let k = 0; k < images.length; k++) {
- $(images[k]).attr('src', $(images[k]).attr('data-src'));
- }
-
- return {
- description: content.html(),
- };
- };
-
- const items = await Promise.all(
- list.map(async (item) => {
- const $ = cheerio.load(item);
- const title = $('.HOME-mainbox1b > h1 > a');
- const link = base + title.attr('href');
- const author = $('.HOME-mainbox1b > .ST1 > a').text();
- const time = $('.HOME-mainbox1b > .ST1').text().split('│')[1];
-
- const cache = await ctx.cache.get(link);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const topic = {
- title: title.text().trim(),
- link,
- author,
- pubDate: new Date(time),
- };
-
- try {
- const detail_response = await got.get(link);
- const result = parseContent(detail_response.data);
- if (!result.description) {
- return Promise.resolve('');
- }
- topic.description = result.description;
- } catch (err) {
- return Promise.resolve('');
- }
- ctx.cache.set(link, JSON.stringify(topic));
- return Promise.resolve(topic);
- })
- );
-
- return { title, items };
- },
-};
diff --git a/lib/routes/baidu/gushitong/index.ts b/lib/routes/baidu/gushitong/index.ts
new file mode 100644
index 00000000000000..15e0d3174c9f82
--- /dev/null
+++ b/lib/routes/baidu/gushitong/index.ts
@@ -0,0 +1,56 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const STATUS_MAP = {
+ up: '上涨',
+ down: '下跌',
+};
+
+export const route: Route = {
+ path: '/gushitong/index',
+ categories: ['finance'],
+ view: ViewType.Notifications,
+ example: '/baidu/gushitong/index',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['gushitong.baidu.com/'],
+ },
+ ],
+ name: '首页指数',
+ maintainers: ['CaoMeiYouRen'],
+ handler,
+ url: 'gushitong.baidu.com/',
+};
+
+async function handler() {
+ const response = await got('https://finance.pae.baidu.com/api/indexbanner?market=ab&finClientType=pc');
+ const item = response.data.Result.map((e) => ({
+ title: e.name,
+ description: art(path.join(__dirname, '../templates/gushitong.art'), {
+ ...e,
+ status: STATUS_MAP[e.status],
+ market: e.market.toUpperCase(),
+ }),
+ link: `https://gushitong.baidu.com/index/${e.market}-${e.code}`,
+ }));
+ return {
+ title: '百度股市通',
+ description:
+ '百度股市通,汇聚全球金融市场的股票、基金、外汇、期货等实时行情,7*24小时覆盖专业财经资讯,提供客观、准确、及时、全面的沪深港美上市公司股价、财务、股东、分红等信息,让用户在复杂的金融市场,更简单的获取投资信息。',
+ link: 'https://gushitong.baidu.com/',
+ item,
+ };
+}
diff --git a/lib/routes/baidu/namespace.ts b/lib/routes/baidu/namespace.ts
new file mode 100644
index 00000000000000..d7447282ed7e8f
--- /dev/null
+++ b/lib/routes/baidu/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '百度',
+ url: 'www.baidu.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/baidu/search.ts b/lib/routes/baidu/search.ts
new file mode 100644
index 00000000000000..0ea1539cdfaaef
--- /dev/null
+++ b/lib/routes/baidu/search.ts
@@ -0,0 +1,77 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const renderDescription = (description, images) => art(path.join(__dirname, './templates/description.art'), { description, images });
+
+export const route: Route = {
+ path: '/search/:keyword',
+ categories: ['other'],
+ example: '/baidu/search/rss',
+ parameters: { keyword: '搜索关键词' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '搜索',
+ maintainers: ['CaoMeiYouRen'],
+ handler,
+};
+
+async function handler(ctx) {
+ const keyword = ctx.req.param('keyword');
+ const url = `https://www.baidu.com/s?wd=${encodeURIComponent(keyword)}`;
+ const key = `baidu-search:${url}`;
+
+ const items = await cache.tryGet(
+ key,
+ async () => {
+ const response = (await got(url)).data;
+ const visitedLinks = new Set();
+ const $ = load(response);
+ const contentLeft = $('#content_left');
+ const containers = contentLeft.find('.c-container');
+ return containers
+ .toArray()
+ .map((el) => {
+ const element = $(el);
+ const link = element.find('h3 a').first().attr('href');
+ if (link && !visitedLinks.has(link)) {
+ visitedLinks.add(link);
+ const imgs = element
+ .find('img')
+ .toArray()
+ .map((_el) => $(_el).attr('src'));
+ const description = element.find('.c-gap-top-small [class^="content-right_"]').first().text() || element.find('.c-row').first().text() || element.find('.cos-row').first().text();
+ return {
+ title: element.find('h3').first().text(),
+ description: renderDescription(description, imgs),
+ link: element.find('h3 a').first().attr('href'),
+ author: element.find('.c-row .c-color-gray').first().text() || '',
+ };
+ }
+ return null;
+ })
+ .filter((e) => e?.link);
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+ return {
+ title: `${keyword} - 百度搜索`,
+ description: `${keyword} - 百度搜索`,
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/v2/baidu/templates/description.art b/lib/routes/baidu/templates/description.art
similarity index 100%
rename from lib/v2/baidu/templates/description.art
rename to lib/routes/baidu/templates/description.art
diff --git a/lib/v2/baidu/templates/forum.art b/lib/routes/baidu/templates/forum.art
similarity index 100%
rename from lib/v2/baidu/templates/forum.art
rename to lib/routes/baidu/templates/forum.art
diff --git a/lib/v2/baidu/templates/gushitong.art b/lib/routes/baidu/templates/gushitong.art
similarity index 100%
rename from lib/v2/baidu/templates/gushitong.art
rename to lib/routes/baidu/templates/gushitong.art
diff --git a/lib/v2/baidu/templates/post.art b/lib/routes/baidu/templates/post.art
similarity index 100%
rename from lib/v2/baidu/templates/post.art
rename to lib/routes/baidu/templates/post.art
diff --git a/lib/v2/baidu/templates/tieba_search.art b/lib/routes/baidu/templates/tieba_search.art
similarity index 100%
rename from lib/v2/baidu/templates/tieba_search.art
rename to lib/routes/baidu/templates/tieba_search.art
diff --git a/lib/v2/baidu/templates/top.art b/lib/routes/baidu/templates/top.art
similarity index 100%
rename from lib/v2/baidu/templates/top.art
rename to lib/routes/baidu/templates/top.art
diff --git a/lib/routes/baidu/tieba/forum.ts b/lib/routes/baidu/tieba/forum.ts
new file mode 100644
index 00000000000000..04d912a2487d08
--- /dev/null
+++ b/lib/routes/baidu/tieba/forum.ts
@@ -0,0 +1,85 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: ['/tieba/forum/good/:kw/:cid?/:sortBy?', '/tieba/forum/:kw/:sortBy?'],
+ categories: ['bbs'],
+ example: '/baidu/tieba/forum/good/女图',
+ parameters: { kw: '吧名', cid: '精品分类,默认为 `0`(全部分类),如果不传 `cid` 则获取全部分类', sortBy: '排序方式:`created`, `replied`。默认为 `created`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '精品帖子',
+ maintainers: ['u3u'],
+ handler,
+};
+
+async function handler(ctx) {
+ // sortBy: created, replied
+ const { kw, cid = '0', sortBy = 'created' } = ctx.req.param();
+
+ // PC端:https://tieba.baidu.com/f?kw=${encodeURIComponent(kw)}
+ // 移动端接口:https://tieba.baidu.com/mo/q/m?kw=${encodeURIComponent(kw)}&lp=5024&forum_recommend=1&lm=0&cid=0&has_url_param=1&pn=0&is_ajax=1
+ const params = { kw: encodeURIComponent(kw) };
+ ctx.req.path.includes('good') && (params.tab = 'good');
+ cid && (params.cid = cid);
+ const { data } = await got(`https://tieba.baidu.com/f`, {
+ headers: {
+ Referer: 'https://tieba.baidu.com/',
+ },
+ searchParams: params,
+ });
+
+ const threadListHTML = load(data)('code[id="pagelet_html_frs-list/pagelet/thread_list"]')
+ .contents()
+ .filter((e) => e.nodeType === '8');
+
+ const $ = load(threadListHTML.prevObject[0].data);
+ const list = $('#thread_list > .j_thread_list[data-field]')
+ .toArray()
+ .map((element) => {
+ const item = $(element);
+ const { id, author_name } = item.data('field');
+ const time = sortBy === 'created' ? item.find('.is_show_create_time').text().trim() : item.find('.threadlist_reply_date').text().trim();
+ const title = item.find('a.j_th_tit').text().trim();
+ const details = item.find('.threadlist_abs').text().trim();
+ const medias = item
+ .find('.threadlist_media img')
+ .toArray()
+ .map((element) => {
+ const item = $(element);
+ return ` `;
+ })
+ .join('');
+
+ return {
+ title,
+ description: art(path.join(__dirname, '../templates/forum.art'), {
+ details,
+ medias,
+ author_name,
+ }),
+ pubDate: timezone(parseDate(time, ['HH:mm', 'M-D', 'YYYY-MM'], true), +8),
+ link: `https://tieba.baidu.com/p/${id}`,
+ };
+ });
+
+ return {
+ title: `${kw}吧`,
+ description: load(data)('meta[name="description"]').attr('content'),
+ link: `https://tieba.baidu.com/f?kw=${encodeURIComponent(kw)}`,
+ item: list,
+ };
+}
diff --git a/lib/routes/baidu/tieba/post.ts b/lib/routes/baidu/tieba/post.ts
new file mode 100644
index 00000000000000..9ce26f3fc44f64
--- /dev/null
+++ b/lib/routes/baidu/tieba/post.ts
@@ -0,0 +1,102 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+/**
+ * 获取最新的帖子回复(倒序查看)
+ *
+ * @param {*} id 帖子ID
+ * @param {number} [lz=0] 是否只看楼主(0: 查看全部, 1: 只看楼主)
+ * @param {number} [pn=7e6] 帖子最大页码(默认假设为 7e6,如果超出假设则根据返回的最大页码再请求一次,否则可以节省一次请求)
+ * 这个默认值我测试下来 7e6 是比较接近最大值了,因为当我输入 8e6 就会返回第一页的数据而不是最后一页了
+ * @returns
+ */
+async function getPost(id, lz = 0, pn = 7e6) {
+ const { data } = await got(`https://tieba.baidu.com/p/${id}?see_lz=${lz}&pn=${pn}&ajax=1`, {
+ headers: {
+ Referer: 'https://tieba.baidu.com/',
+ },
+ });
+ const $ = load(data);
+ const max = Number.parseInt($('[max-page]').attr('max-page'));
+ if (max > pn) {
+ return getPost(id, max);
+ }
+ return data;
+}
+
+export const route: Route = {
+ path: ['/tieba/post/:id', '/tieba/post/lz/:id'],
+ categories: ['bbs'],
+ example: '/baidu/tieba/post/686961453',
+ parameters: { id: '帖子 ID' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['tieba.baidu.com/p/:id'],
+ },
+ ],
+ name: '帖子动态',
+ maintainers: ['u3u'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const lz = ctx.req.path.includes('lz') ? 1 : 0;
+ const html = await getPost(id, lz);
+ const $ = load(html);
+ const title = $('.core_title_txt').attr('title');
+ // .substr(3);
+ const list = $('.p_postlist > [data-field]:not(:has(.ad_bottom_view))');
+
+ return {
+ title: lz ? `【只看楼主】${title}` : title,
+ link: `https://tieba.baidu.com/p/${id}?see_lz=${lz}`,
+ description: `${title}的最新回复`,
+ item: list.toArray().map((element) => {
+ const item = $(element);
+ const { author, content } = item.data('field');
+ const tempList = item
+ .find('.post-tail-wrap > .tail-info')
+ .toArray()
+ .map((element) => $(element).text());
+ let [pubContent, from, num, time] = ['', '', '', ''];
+ if (0 === tempList.length && 'date' in content) {
+ num = `${content.post_no}楼`;
+ time = content.date;
+ pubContent = item.find('.j_d_post_content').html();
+ } else if (2 === tempList.length) {
+ [num, time] = tempList;
+ pubContent = content.content;
+ } else if (3 === tempList.length) {
+ [from, num, time] = tempList;
+ pubContent = content.content;
+ }
+ return {
+ title: `${author.user_name}回复了帖子《${title}》`,
+ description: art(path.join(__dirname, '../templates/post.art'), {
+ pubContent,
+ author: author.user_name,
+ num,
+ from,
+ }),
+ pubDate: timezone(parseDate(time, 'YYYY-MM-DD hh:mm'), +8),
+ link: `https://tieba.baidu.com/p/${id}?pid=${content.post_id}#${content.post_id}`,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/baidu/tieba/search.ts b/lib/routes/baidu/tieba/search.ts
new file mode 100644
index 00000000000000..e2240fa3ed698f
--- /dev/null
+++ b/lib/routes/baidu/tieba/search.ts
@@ -0,0 +1,92 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/tieba/search/:qw/:routeParams?',
+ categories: ['bbs'],
+ example: '/baidu/tieba/search/neuro',
+ parameters: { qw: '搜索关键词', routeParams: '额外参数;请参阅以下说明和表格' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '贴吧搜索',
+ maintainers: ['JimenezLi'],
+ handler,
+ description: `| 键 | 含义 | 接受的值 | 默认值 |
+| ------------ | ---------------------------------------------------------- | ------------- | ------ |
+| kw | 在名为 kw 的贴吧中搜索 | 任意名称 / 无 | 无 |
+| only_thread | 只看主题帖,默认为 0 关闭 | 0/1 | 0 |
+| rn | 返回条目的数量 | 1-20 | 20 |
+| sm | 排序方式,0 为按时间顺序,1 为按时间倒序,2 为按相关性顺序 | 0/1/2 | 1 |
+
+ 用例:\`/baidu/tieba/search/neuro/kw=neurosama&only_thread=1&sm=2\``,
+};
+
+async function handler(ctx) {
+ const qw = ctx.req.param('qw');
+ const query = new URLSearchParams(ctx.req.param('routeParams'));
+ query.set('ie', 'utf-8');
+ query.set('qw', qw);
+ query.set('rn', query.get('rn') || '20'); // Number of returned items
+ const link = `https://tieba.baidu.com/f/search/res?${query.toString()}`;
+
+ const response = await got.get(link, {
+ headers: {
+ Referer: 'https://tieba.baidu.com',
+ },
+ responseType: 'buffer',
+ });
+ const data = iconv.decode(response.data, 'gbk');
+
+ const $ = load(data);
+ const resultList = $('div.s_post');
+
+ return {
+ title: `${qw} - ${query.get('kw') || '百度贴'}吧搜索`,
+ link,
+ item: resultList.toArray().map((element) => {
+ const item = $(element);
+ const titleItem = item.find('.p_title a');
+ const title = titleItem.text().trim();
+ const link = titleItem.attr('href');
+ const time = item.find('.p_date').text().trim();
+ const details = item.find('.p_content').text().trim();
+ const medias = item
+ .find('.p_mediaCont img')
+ .toArray()
+ .map((element) => {
+ const item = $(element);
+ return ` `;
+ })
+ .join('');
+ const tieba = item.find('a.p_forum').text().trim();
+ const author = item.find('a').last().text().trim();
+
+ return {
+ title,
+ description: art(path.join(__dirname, '../templates/tieba_search.art'), {
+ details,
+ medias,
+ tieba,
+ author,
+ }),
+ author,
+ pubDate: timezone(parseDate(time, 'YYYY-MM-DD HH:mm'), +8),
+ link,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/baidu/tieba/user.ts b/lib/routes/baidu/tieba/user.ts
new file mode 100644
index 00000000000000..a5a9288e45b8a0
--- /dev/null
+++ b/lib/routes/baidu/tieba/user.ts
@@ -0,0 +1,54 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/tieba/user/:uid',
+ categories: ['bbs'],
+ example: '/baidu/tieba/user/斗鱼游戏君',
+ parameters: { uid: '用户 ID' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '用户帖子',
+ maintainers: ['igxlin', 'nczitzk'],
+ handler,
+ description: `用户 ID 可以通过打开用户的主页后查看地址栏的 \`un\` 字段来获取。`,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const response = await got(`https://tieba.baidu.com/home/main?un=${uid}`);
+
+ const data = response.data;
+
+ const $ = load(data);
+ const name = $('span.userinfo_username').text();
+ const list = $('div.n_right.clearfix');
+ let imgurl;
+
+ return {
+ title: `${name} 的贴吧`,
+ link: `https://tieba.baidu.com/home/main?un=${uid}`,
+ item:
+ list &&
+ list.toArray().map((item) => {
+ item = $(item).find('.n_contain');
+ imgurl = item.find('ul.n_media.clearfix img').attr('original');
+ return {
+ title: item.find('div.thread_name a').attr('title'),
+ pubDate: timezone(parseDate(item.parent().find('div .n_post_time').text(), ['YYYY-MM-DD', 'HH:mm']), +8),
+ description: `${item.find('div.n_txt').text()} `,
+ link: item.find('div.thread_name a').attr('href'),
+ };
+ }),
+ };
+}
diff --git a/lib/routes/baidu/top.ts b/lib/routes/baidu/top.ts
new file mode 100644
index 00000000000000..fedce7cdebef39
--- /dev/null
+++ b/lib/routes/baidu/top.ts
@@ -0,0 +1,58 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/top/:board?',
+ categories: ['other'],
+ example: '/baidu/top',
+ parameters: { board: '榜单,默认为 `realtime`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '热搜榜单',
+ maintainers: ['xyqfer'],
+ handler,
+ description: `| 热搜榜 | 小说榜 | 电影榜 | 电视剧榜 | 汽车榜 | 游戏榜 |
+| -------- | ------ | ------ | -------- | ------ | ------ |
+| realtime | novel | movie | teleplay | car | game |`,
+};
+
+async function handler(ctx) {
+ const { board = 'realtime' } = ctx.req.param();
+ const link = `https://top.baidu.com/board?tab=${board}`;
+ const { data: response } = await got(link);
+
+ const $ = load(response);
+
+ const { data } = JSON.parse(
+ $('#sanRoot')
+ .contents()
+ .filter((e) => e.nodeType === 8)
+ .prevObject[0].data.match(/s-data:(.*)/)[1]
+ );
+
+ const items = data.cards[0].content.map((item) => ({
+ title: item.word,
+ description: art(path.join(__dirname, 'templates/top.art'), {
+ item,
+ }),
+ link: item.rawUrl,
+ }));
+
+ return {
+ title: `${data.curBoardName} - 百度热搜`,
+ description: $('meta[name="description"]').attr('content'),
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/baijing/index.ts b/lib/routes/baijing/index.ts
new file mode 100644
index 00000000000000..c0918ba51cebd1
--- /dev/null
+++ b/lib/routes/baijing/index.ts
@@ -0,0 +1,49 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/article',
+ categories: ['new-media'],
+ example: '/baijing/article',
+ url: 'www.baijing.cn/article/',
+ name: '资讯',
+ maintainers: ['p3psi-boo'],
+ handler,
+};
+
+async function handler() {
+ const apiUrl = 'https://www.baijing.cn/index/ajax/get_article/';
+ const response = await ofetch(apiUrl);
+ const data = response.data.article_list;
+
+ const list = data.map((item) => ({
+ title: item.title,
+ link: `https://www.baijing.cn/article/${item.id}`,
+ author: item.user_info.user_name,
+ category: item.topic?.map((t) => t.title),
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+
+ const $ = load(response);
+ item.description = $('.content').html();
+ item.pubDate = parseDate($('.timeago').text());
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '白鲸出海 - 资讯',
+ link: 'https://www.baijing.cn/article/',
+ item: items,
+ };
+}
diff --git a/lib/routes/baijing/namespace.ts b/lib/routes/baijing/namespace.ts
new file mode 100644
index 00000000000000..57d69294cd4383
--- /dev/null
+++ b/lib/routes/baijing/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '白鲸出海',
+ url: 'baijing.cn',
+ description: '白鲸出海',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bakamh/manga.ts b/lib/routes/bakamh/manga.ts
new file mode 100644
index 00000000000000..3bad01f2f8d7d2
--- /dev/null
+++ b/lib/routes/bakamh/manga.ts
@@ -0,0 +1,75 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const url = 'https://bakamh.com';
+
+const handler = async (ctx) => {
+ const { name } = ctx.req.param();
+ const limit = Number.parseInt(ctx.req.query('limit'), 15) || 15;
+
+ const link = `${url}/manga/${name}/`;
+ const response = await ofetch(link);
+ const $ = load(response);
+ const ldJson = JSON.parse($('script[type="application/ld+json"]').text());
+ const list = $('li.wp-manga-chapter')
+ .toArray()
+ .slice(0, limit)
+ .map((item) => {
+ const $item = $(item);
+ const itemDate = $item.find('i').text().replaceAll(' ', '');
+
+ return {
+ title: $item.find('a').text(),
+ link: $item.find('a').attr('href'),
+ guid: $item.find('a').attr('href'),
+ pubDate: itemDate,
+ };
+ });
+
+ if (list.length > 0) {
+ list[0].pubDate = ldJson.dateModified;
+ }
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ const comicpage = $('div.reading-content img');
+ const containerDiv = $('
');
+ comicpage.appendTo(containerDiv);
+ item.description = containerDiv.html();
+ item.pubDate = parseDate(item.pubDate, 'YYYY年M月D日');
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link,
+ description: $('.post-content_item p').text(),
+ image: $('.summary_image a img').attr('src'),
+ item: items,
+ };
+};
+
+export const route: Route = {
+ path: '/manga/:name',
+ categories: ['anime'],
+ example: '/bakamh/manga/最强家丁',
+ parameters: { name: '漫画名称,漫画主页的地址栏中' },
+ radar: [
+ {
+ source: ['bakamh.com/manga/:name/'],
+ },
+ ],
+ name: '漫画更新',
+ maintainers: ['yoyobase'],
+ handler,
+ url: 'bakamh.com',
+};
diff --git a/lib/routes/bakamh/namespace.ts b/lib/routes/bakamh/namespace.ts
new file mode 100644
index 00000000000000..633f0617756bc2
--- /dev/null
+++ b/lib/routes/bakamh/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '巴卡漫画',
+ url: 'bakamh.com',
+ categories: ['anime'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bandcamp/live.ts b/lib/routes/bandcamp/live.ts
new file mode 100644
index 00000000000000..a6c3ef81020e0c
--- /dev/null
+++ b/lib/routes/bandcamp/live.ts
@@ -0,0 +1,67 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/live',
+ categories: ['multimedia'],
+ example: '/bandcamp/live',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bandcamp.com/live_schedule'],
+ },
+ ],
+ name: 'Upcoming Live Streams',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'bandcamp.com/live_schedule',
+};
+
+async function handler() {
+ const rootUrl = 'https://bandcamp.com';
+ const currentUrl = `${rootUrl}/live_schedule`;
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ $('.curated-wrapper').remove();
+
+ const items = $('.live-listing')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ link: item.find('.title-link').attr('href'),
+ title: item.find('.show-title').text(),
+ author: item.find('.show-artist').text(),
+ pubDate: parseDate(item.find('.show-time-container').text().trim().split(' UTC')[0]),
+ description: ` `,
+ };
+ });
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bandcamp/namespace.ts b/lib/routes/bandcamp/namespace.ts
new file mode 100644
index 00000000000000..dc244d34eb8966
--- /dev/null
+++ b/lib/routes/bandcamp/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bandcamp',
+ url: 'bandcamp.com',
+ lang: 'en',
+};
diff --git a/lib/routes/bandcamp/tag.ts b/lib/routes/bandcamp/tag.ts
new file mode 100644
index 00000000000000..42fdc97c700eaf
--- /dev/null
+++ b/lib/routes/bandcamp/tag.ts
@@ -0,0 +1,73 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/tag/:tag?',
+ categories: ['multimedia'],
+ example: '/bandcamp/tag/united-kingdom',
+ parameters: { tag: 'Tag, can be found in URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bandcamp.com/tag/:tag'],
+ target: '/tag/:tag',
+ },
+ ],
+ name: 'Tag',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const tag = ctx.req.param('tag');
+
+ const rootUrl = 'https://bandcamp.com';
+ const currentUrl = `${rootUrl}/tag/${tag}?tab=all_releases`;
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const list = response.data
+ .match(/tralbum_url":"(.*?)","audio_url/g)
+ .slice(0, 10)
+ .map((item) => ({
+ link: item.match(/tralbum_url":"(.*?)","audio_url/)[1].split('"')[0],
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const content = load(detailResponse.data);
+
+ item.title = content('.trackTitle').eq(0).text();
+ item.author = content('h3 span a').text();
+ item.description = content('#tralbumArt').html() + content('#trackInfo').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/v2/bandcamp/templates/weekly.art b/lib/routes/bandcamp/templates/weekly.art
similarity index 100%
rename from lib/v2/bandcamp/templates/weekly.art
rename to lib/routes/bandcamp/templates/weekly.art
diff --git a/lib/routes/bandcamp/weekly.ts b/lib/routes/bandcamp/weekly.ts
new file mode 100644
index 00000000000000..5e8d49d23c8a5a
--- /dev/null
+++ b/lib/routes/bandcamp/weekly.ts
@@ -0,0 +1,55 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/weekly',
+ categories: ['multimedia'],
+ example: '/bandcamp/weekly',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bandcamp.com/'],
+ },
+ ],
+ name: 'Weekly',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'bandcamp.com/',
+};
+
+async function handler() {
+ const rootUrl = 'https://bandcamp.com';
+ const apiUrl = `${rootUrl}/api/bcweekly/3/list`;
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.results.slice(0, 50).map((item) => ({
+ title: item.title,
+ link: `${rootUrl}/?show=${item.id}`,
+ pubDate: parseDate(item.published_date),
+ description: art(path.join(__dirname, 'templates/weekly.art'), {
+ v2_image_id: item.v2_image_id,
+ desc: item.desc,
+ }),
+ }));
+
+ return {
+ title: 'Bandcamp Weekly',
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bandisoft/history.ts b/lib/routes/bandisoft/history.ts
new file mode 100644
index 00000000000000..620a0568bdf1d7
--- /dev/null
+++ b/lib/routes/bandisoft/history.ts
@@ -0,0 +1,330 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const idOptions = [
+ {
+ label: 'Bandizip',
+ value: 'bandizip',
+ },
+ {
+ label: 'Bandizip for Mac',
+ value: 'bandizip.mac',
+ },
+ {
+ label: 'BandiView',
+ value: 'bandiview',
+ },
+ {
+ label: 'Honeycam',
+ value: 'honeycam',
+ },
+];
+
+const languageOptions = [
+ {
+ label: 'English',
+ value: 'en',
+ },
+ {
+ label: '中文(简体)',
+ value: 'cn',
+ },
+ {
+ label: '中文(繁體)',
+ value: 'tw',
+ },
+ {
+ label: '日本語',
+ value: 'jp',
+ },
+ {
+ label: 'Русский',
+ value: 'ru',
+ },
+ {
+ label: 'Español',
+ value: 'es',
+ },
+ {
+ label: 'Français',
+ value: 'fr',
+ },
+ {
+ label: 'Deutsch',
+ value: 'de',
+ },
+ {
+ label: 'Italiano',
+ value: 'it',
+ },
+ {
+ label: 'Slovenčina',
+ value: 'sk',
+ },
+ {
+ label: 'Українська',
+ value: 'uk',
+ },
+ {
+ label: 'Беларуская',
+ value: 'be',
+ },
+ {
+ label: 'Dansk',
+ value: 'da',
+ },
+ {
+ label: 'Polski',
+ value: 'pl',
+ },
+ {
+ label: 'Português Brasileiro',
+ value: 'br',
+ },
+ {
+ label: 'Čeština',
+ value: 'cs',
+ },
+ {
+ label: 'Nederlands',
+ value: 'nl',
+ },
+ {
+ label: 'Slovenščina',
+ value: 'sl',
+ },
+ {
+ label: 'Türkçe',
+ value: 'tr',
+ },
+ {
+ label: 'ภาษาไทย',
+ value: 'th',
+ },
+ {
+ label: 'Ελληνικά',
+ value: 'gr',
+ },
+ {
+ label: "O'zbek",
+ value: 'uz',
+ },
+ {
+ label: 'Romanian',
+ value: 'ro',
+ },
+ {
+ label: '한국어',
+ value: 'kr',
+ },
+];
+
+export const handler = async (ctx: Context): Promise => {
+ const { id = 'bandizip', language = 'en' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '500', 10);
+
+ const validIds: Set = new Set(idOptions.map((option) => option.value));
+
+ if (!validIds.has(id)) {
+ throw new Error(`Invalid id: ${id}. Allowed values are: ${[...validIds].join(', ')}`);
+ }
+
+ const validLanguages: Set = new Set(languageOptions.map((option) => option.value));
+
+ if (!validLanguages.has(language)) {
+ throw new Error(`Invalid language: ${language}. Allowed values are: ${[...validLanguages].join(', ')}`);
+ }
+
+ const baseUrl: string = `https://${language}.bandisoft.com`;
+ const targetUrl: string = new URL(`${id}/history/`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const lang = $('html').attr('lang') ?? 'en';
+ const author: string | undefined = $('meta[name="author"]').attr('content');
+
+ const items: DataItem[] = $('div.row')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const version: string | undefined = $el.find('div.cell1').text();
+ const pubDateStr: string | undefined = $el.find('div.cell2').text();
+
+ const title: string = version;
+ const description: string | undefined = $el.find('ul.cell3').html() ?? undefined;
+
+ const linkUrl: string = targetUrl;
+ const guid: string = `bandisoft-${id}-${language}-${version}`;
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ author,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language: lang,
+ };
+
+ return processedItem;
+ });
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img#logo_light').attr('src'),
+ author,
+ language: lang,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/history/:id?/:language?',
+ name: 'History',
+ url: 'www.bandisoft.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/bandisoft/history/bandizip',
+ parameters: {
+ id: {
+ description: 'ID, `bandizip` by default',
+ options: idOptions,
+ },
+ language: {
+ description: 'Language, `en` by default',
+ options: languageOptions,
+ },
+ },
+ description: `::: tip
+To subscribe to [Bandizip Version History](https://www.bandisoft.com/bandizip/history/), where the source URL is \`https://www.bandisoft.com/bandizip/history/\`, extract the certain parts from this URL to be used as parameters, resulting in the route as [\`/bandisoft/history/bandizip\`](https://rsshub.app/bandisoft/history/bandizip).
+:::
+
+
+ More languages
+
+| Language | ID |
+| -------------------- | --- |
+| English | en |
+| 中文(简体) | cn |
+| 中文(繁體) | tw |
+| 日本語 | jp |
+| Русский | ru |
+| Español | es |
+| Français | fr |
+| Deutsch | de |
+| Italiano | it |
+| Slovenčina | sk |
+| Українська | uk |
+| Беларуская | be |
+| Dansk | da |
+| Polski | pl |
+| Português Brasileiro | br |
+| Čeština | cs |
+| Nederlands | nl |
+| Slovenščina | sl |
+| Türkçe | tr |
+| ภาษาไทย | th |
+| Ελληνικά | gr |
+| Oʻzbek | uz |
+| Romanian | ro |
+| 한국어 | kr |
+
+
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.bandisoft.com/:id/history'],
+ target: (params) => {
+ const id: string = params.id;
+
+ return `/bandisoft/history${id ? `/${id}` : ''}`;
+ },
+ },
+ ],
+ view: ViewType.Articles,
+
+ zh: {
+ path: '/history/:id?/:language?',
+ name: '更新记录',
+ url: 'www.bandisoft.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/bandisoft/history/bandizip',
+ parameters: {
+ id: {
+ description: 'ID, 默认为 `bandizip`,可在对应产品页 URL 中找到',
+ options: idOptions,
+ },
+ language: {
+ description: '地区, 默认为 `en`',
+ options: languageOptions,
+ },
+ },
+ description: `::: tip
+若订阅 [Bandizip 更新记录](https://cn.bandisoft.com/bandizip/history/),网址为 \`https://cn.bandisoft.com/bandizip/history/\`,请截取 \`cn\` 作为 \`category\` 参数填入,此时目标路由为 [\`/bandisoft/:language?/:id?\`](https://rsshub.app/bandisoft/:language?/:id?)。
+:::
+
+
+ 更多语言
+
+| Language | ID |
+| -------------------- | --- |
+| English | en |
+| 中文(简体) | cn |
+| 中文(繁體) | tw |
+| 日本語 | jp |
+| Русский | ru |
+| Español | es |
+| Français | fr |
+| Deutsch | de |
+| Italiano | it |
+| Slovenčina | sk |
+| Українська | uk |
+| Беларуская | be |
+| Dansk | da |
+| Polski | pl |
+| Português Brasileiro | br |
+| Čeština | cs |
+| Nederlands | nl |
+| Slovenščina | sl |
+| Türkçe | tr |
+| ภาษาไทย | th |
+| Ελληνικά | gr |
+| Oʻzbek | uz |
+| Romanian | ro |
+| 한국어 | kr |
+
+
+`,
+ },
+};
diff --git a/lib/routes/bandisoft/index.js b/lib/routes/bandisoft/index.js
deleted file mode 100644
index fe07e3aa3e09b2..00000000000000
--- a/lib/routes/bandisoft/index.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { isValidHost } = require('@/utils/valid-host');
-
-module.exports = async (ctx) => {
- const lang = ctx.params.lang || 'en';
- const id = ctx.params.id || 'bandizip';
- if (!isValidHost(lang)) {
- throw Error('Invalid language code');
- }
-
- const rootUrl = `https://${lang}.bandisoft.com`;
- const currentUrl = `${rootUrl}/${id}/history/`;
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = cheerio.load(response.data);
-
- const items = $('h2')
- .map((_, item) => {
- item = $(item);
-
- const title = item.text();
- item.children('font').remove();
-
- return {
- title,
- link: currentUrl,
- description: item.next().html(),
- pubDate: new Date(item.text()).toUTCString(),
- };
- })
- .get();
-
- ctx.state.data = {
- title: $('title').text(),
- link: currentUrl,
- item: items,
- };
-};
diff --git a/lib/routes/bandisoft/namespace.ts b/lib/routes/bandisoft/namespace.ts
new file mode 100644
index 00000000000000..194b2abff85962
--- /dev/null
+++ b/lib/routes/bandisoft/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bandisoft',
+ url: 'bandisoft.com',
+ categories: ['program-update'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/bangumi.moe/index.ts b/lib/routes/bangumi.moe/index.ts
new file mode 100644
index 00000000000000..765492954997b4
--- /dev/null
+++ b/lib/routes/bangumi.moe/index.ts
@@ -0,0 +1,110 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/*',
+ categories: ['anime'],
+ radar: [
+ {
+ source: ['bangumi.moe/'],
+ },
+ ],
+ name: 'Latest',
+ example: '/bangumi.moe',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'bangumi.moe/',
+};
+
+async function handler(ctx) {
+ const isLatest = getSubPath(ctx) === '/';
+ const rootUrl = 'https://bangumi.moe';
+
+ let response;
+ let tag_id = [];
+
+ if (isLatest) {
+ const apiUrl = `${rootUrl}/api/torrent/latest`;
+
+ response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+ } else {
+ const tagUrl = `${rootUrl}/api/tag/search`;
+ const torrentUrl = `${rootUrl}/api/torrent/search`;
+
+ const params = getSubPath(ctx).split('/').slice(2);
+
+ tag_id = await Promise.all(
+ params.map((param) =>
+ cache.tryGet(param, async () => {
+ const paramResponse = await got({
+ method: 'post',
+ url: tagUrl,
+ json: {
+ name: decodeURIComponent(param),
+ keywords: true,
+ multi: true,
+ },
+ });
+
+ return paramResponse.data.found ? paramResponse.data.tag.map((tag) => tag._id)[0] : '';
+ })
+ )
+ );
+
+ response = await got({
+ method: 'post',
+ url: torrentUrl,
+ json: {
+ tag_id,
+ },
+ });
+ }
+
+ let items =
+ response.data.torrents?.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 30).map((item) => ({
+ title: item.title,
+ link: `${rootUrl}/torrent/${item._id}`,
+ description: item.introduction,
+ pubDate: parseDate(item.publish_time),
+ enclosure_url: item.magnet,
+ enclosure_type: 'application/x-bittorrent',
+ category: item.tag_ids,
+ })) ?? [];
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'post',
+ url: `${rootUrl}/api/tag/fetch`,
+ json: {
+ _ids: item.category,
+ },
+ });
+
+ item.category = [];
+
+ for (const tag of detailResponse.data) {
+ for (const t of tag.synonyms) {
+ item.category.push(t);
+ }
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '萌番组 Bangumi Moe',
+ link: isLatest || items.length === 0 ? rootUrl : `${rootUrl}/search/${tag_id.join('+')}`,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bangumi.moe/namespace.ts b/lib/routes/bangumi.moe/namespace.ts
new file mode 100644
index 00000000000000..697c3a2b4f4b4b
--- /dev/null
+++ b/lib/routes/bangumi.moe/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '萌番组',
+ url: 'bangumi.online',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bangumi.online/namespace.ts b/lib/routes/bangumi.online/namespace.ts
new file mode 100644
index 00000000000000..fd31a2bab5074f
--- /dev/null
+++ b/lib/routes/bangumi.online/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'アニメ新番組',
+ url: 'bangumi.online',
+ lang: 'ja',
+};
diff --git a/lib/routes/bangumi.online/online.ts b/lib/routes/bangumi.online/online.ts
new file mode 100644
index 00000000000000..9166142489dbfd
--- /dev/null
+++ b/lib/routes/bangumi.online/online.ts
@@ -0,0 +1,54 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/',
+ categories: ['anime'],
+ example: '/bangumi.online',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bangumi.online/'],
+ },
+ ],
+ name: '當季新番',
+ maintainers: ['devinmugen'],
+ handler,
+ url: 'bangumi.online/',
+};
+
+async function handler() {
+ const url = 'https://api.bangumi.online/serve/home';
+
+ const response = await got.post(url);
+
+ const list = response.data.data.list;
+
+ const items = list.map((item) => ({
+ title: `${item.title.zh ?? item.title.ja} - 第 ${item.volume} 集`,
+ description: art(path.join(__dirname, 'templates/image.art'), {
+ src: `https:${item.cover}`,
+ alt: `${item.title_zh} - 第 ${item.volume} 集`,
+ }),
+ link: `https://bangumi.online/watch/${item.vid}`,
+ pubDate: parseDate(item.create_time),
+ }));
+
+ return {
+ title: 'アニメ新番組',
+ link: 'https://bangumi.online',
+ item: items,
+ };
+}
diff --git a/lib/v2/bangumi/templates/online/image.art b/lib/routes/bangumi.online/templates/image.art
similarity index 100%
rename from lib/v2/bangumi/templates/online/image.art
rename to lib/routes/bangumi.online/templates/image.art
diff --git a/lib/routes/bangumi.tv/calendar/_base.ts b/lib/routes/bangumi.tv/calendar/_base.ts
new file mode 100644
index 00000000000000..140b9da62aacc7
--- /dev/null
+++ b/lib/routes/bangumi.tv/calendar/_base.ts
@@ -0,0 +1,38 @@
+import { config } from '@/config';
+import got from '@/utils/got';
+
+const getData = (tryGet) => {
+ const bgmCalendarUrl = 'https://api.bgm.tv/calendar';
+ const bgmDataUrl = 'https://cdn.jsdelivr.net/npm/bangumi-data/dist/data.json';
+
+ const urls = [bgmCalendarUrl, bgmDataUrl];
+
+ return Promise.all(
+ urls.map((item, i) =>
+ tryGet(
+ item,
+ async () => {
+ const { data } = await got(item);
+
+ if (i === 1) {
+ // 只保留有 bangumi id 的番剧
+ const items = [];
+ for (const item of data.items) {
+ const bgmSite = item.sites.find((s) => s.site === 'bangumi');
+ if (bgmSite) {
+ item.bgmId = bgmSite.id;
+ items.push(item);
+ }
+ }
+ data.items = items;
+ }
+
+ return data;
+ },
+ config.cache.contentExpire,
+ false
+ )
+ )
+ );
+};
+export default getData;
diff --git a/lib/routes/bangumi.tv/calendar/today.ts b/lib/routes/bangumi.tv/calendar/today.ts
new file mode 100644
index 00000000000000..0ff553425ec520
--- /dev/null
+++ b/lib/routes/bangumi.tv/calendar/today.ts
@@ -0,0 +1,91 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { art } from '@/utils/render';
+
+import getData from './_base';
+
+export const route: Route = {
+ path: '/calendar/today',
+ categories: ['anime'],
+ example: '/bangumi.tv/calendar/today',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/calendar'],
+ },
+ ],
+ name: '放送列表',
+ maintainers: ['magic-akari'],
+ handler,
+ url: 'bgm.tv/calendar',
+};
+
+async function handler() {
+ const [list, data] = await getData(cache.tryGet);
+ const siteMeta = data.siteMeta;
+
+ const today = new Date(Date.now());
+ // 将 UTC 时间向前移动9小时,即可在数值上表示东京时间
+ today.setUTCHours(today.getUTCHours() + 9);
+ const day = today.getUTCDay();
+
+ const todayList = list.find((l) => l.weekday.id % 7 === day);
+ const todayBgmId = new Set(todayList.items.map((t) => t.id.toString()));
+ const images: { [key: string]: string } = {};
+ for (const item of todayList.items) {
+ images[item.id] = (item.images || {}).large;
+ }
+ const todayBgm = data.items.filter((d) => todayBgmId.has(d.bgmId));
+ for (const bgm of todayBgm) {
+ bgm.image = images[bgm.bgmId];
+ }
+
+ return {
+ title: 'bangumi 每日放送',
+ link: 'https://bgm.tv/calendar',
+ item: todayBgm.map((bgm) => {
+ const updated = new Date(Date.now());
+ updated.setSeconds(0);
+ const begin = new Date(bgm.begin || updated);
+ updated.setHours(begin.getHours());
+ updated.setMinutes(begin.getMinutes());
+ updated.setSeconds(begin.getSeconds());
+
+ const link = `https://bangumi.tv/subject/${bgm.bgmId}`;
+ const id = `${link}#${new Intl.DateTimeFormat('zh-CN').format(updated)}`;
+
+ const html = art(path.join(__dirname, '../templates/today.art'), {
+ bgm,
+ siteMeta,
+ });
+
+ return {
+ id,
+ guid: id,
+ title: [
+ bgm.title,
+ Object.values(bgm.titleTranslate)
+ .map((t) => t.join('|'))
+ .join('|'),
+ ]
+ .filter(Boolean) // don't join if empty
+ .join('|'),
+ updated: updated.toISOString(),
+ pubDate: updated.toUTCString(),
+ link,
+ description: html,
+ content: { html },
+ };
+ }),
+ };
+}
diff --git a/lib/routes/bangumi.tv/group/reply.ts b/lib/routes/bangumi.tv/group/reply.ts
new file mode 100644
index 00000000000000..2e4412235e07de
--- /dev/null
+++ b/lib/routes/bangumi.tv/group/reply.ts
@@ -0,0 +1,84 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/topic/:id',
+ categories: ['anime'],
+ example: '/bangumi.tv/topic/367032',
+ parameters: { id: '话题 id, 在话题页面地址栏查看' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/group/topic/:id'],
+ },
+ ],
+ name: '小组话题的新回复',
+ maintainers: ['ylc395'],
+ handler,
+};
+
+async function handler(ctx) {
+ // bangumi.tv未提供获取小组话题的API,因此仍需要通过抓取网页来获取
+ const topicID = ctx.req.param('id');
+ const link = `https://bgm.tv/group/topic/${topicID}`;
+ const html = await ofetch(link);
+ const $ = load(html);
+ const title = $('#pageHeader h1').text();
+ const latestReplies = $('.row_reply')
+ .toArray()
+ .map((el) => {
+ const $el = $(el);
+ return {
+ id: $el.attr('id'),
+ author: $el.find('.userInfo .l').text(),
+ content: $el.find('.reply_content .message').html(),
+ date: $el.children().first().find('small').children().remove().end().text().slice(3),
+ };
+ });
+ const latestSubReplies = $('.sub_reply_bg')
+ .toArray()
+ .map((el) => {
+ const $el = $(el);
+ return {
+ id: $el.attr('id'),
+ author: $el.find('.userName .l').text(),
+ content: $el.find('.cmt_sub_content').html(),
+ date: $el.children().first().find('small').children().remove().end().text().slice(3),
+ };
+ });
+ const finalLatestReplies = [...latestReplies, ...latestSubReplies].toSorted((a, b) => (a.id < b.id ? 1 : -1));
+
+ const postTopic = {
+ title,
+ description: $('.postTopic .topic_content').html(),
+ author: $('.postTopic .inner strong a').first().text(),
+ pubDate: timezone(parseDate($('.postTopic .re_info small').text().trim().slice(5)), +8),
+ link,
+ };
+
+ return {
+ title: `${title}的最新回复`,
+ link,
+ item: [
+ ...finalLatestReplies.map((c) => ({
+ title: `${c.author} 回复了小组话题《${title}》`,
+ description: c.content,
+ pubDate: timezone(parseDate(c.date), +8),
+ author: c.author,
+ link: `${link}#${c.id}`,
+ })),
+ postTopic,
+ ],
+ };
+}
diff --git a/lib/routes/bangumi.tv/group/topic.ts b/lib/routes/bangumi.tv/group/topic.ts
new file mode 100644
index 00000000000000..3b135a7987d0ec
--- /dev/null
+++ b/lib/routes/bangumi.tv/group/topic.ts
@@ -0,0 +1,66 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'https://bgm.tv';
+
+export const route: Route = {
+ path: '/group/:id',
+ categories: ['anime'],
+ example: '/bangumi.tv/group/boring',
+ parameters: { id: '小组 id, 在小组页面地址栏查看' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/group/:id'],
+ },
+ ],
+ name: '小组话题',
+ maintainers: ['SettingDust'],
+ handler,
+};
+
+async function handler(ctx) {
+ const groupID = ctx.req.param('id');
+ const link = `${baseUrl}/group/${groupID}/forum`;
+ const html = await ofetch(link);
+ const $ = load(html);
+ const title = 'Bangumi - ' + $('.SecondaryNavTitle').text();
+
+ const items = await Promise.all(
+ $('.topic_list .topic')
+ .toArray()
+ .map((elem) => {
+ const link = new URL($('.subject a', elem).attr('href'), baseUrl).href;
+ return cache.tryGet(link, async () => {
+ const html = await ofetch(link);
+ const $ = load(html);
+ const fullText = $('.postTopic .topic_content').html();
+ const summary = 'Reply: ' + $('.posts', elem).text();
+ return {
+ link,
+ title: $('.subject a', elem).attr('title'),
+ pubDate: parseDate($('.lastpost .time', elem).text()),
+ description: fullText ? summary + ' ' + fullText : summary,
+ author: $('.author a', elem).text(),
+ };
+ });
+ })
+ );
+
+ return {
+ title,
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/bangumi.tv/namespace.ts b/lib/routes/bangumi.tv/namespace.ts
new file mode 100644
index 00000000000000..b978132087e1e7
--- /dev/null
+++ b/lib/routes/bangumi.tv/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bangumi 番组计划',
+ url: 'bangumi.tv',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bangumi.tv/other/followrank.ts b/lib/routes/bangumi.tv/other/followrank.ts
new file mode 100644
index 00000000000000..804beecf720291
--- /dev/null
+++ b/lib/routes/bangumi.tv/other/followrank.ts
@@ -0,0 +1,70 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/:type/followrank',
+ categories: ['anime'],
+ example: '/bangumi.tv/anime/followrank',
+ parameters: { type: '类型:anime - 动画,book - 图书,music - 音乐,game - 游戏,real - 三次元' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/:type'],
+ target: '/:type/followrank',
+ },
+ ],
+ name: '成员关注榜',
+ maintainers: ['honue', 'zhoukuncheng', 'NekoAria'],
+ handler,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type');
+ const url = `https://bgm.tv/${type}`;
+
+ const response = await ofetch(url);
+
+ const $ = load(response);
+
+ const items = $('.featuredItems .mainItem')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const link = 'https://bgm.tv' + $item.find('a').first().attr('href');
+ const imageUrl = $item
+ .find('.image')
+ .attr('style')
+ ?.match(/url\((.*?)\)/)?.[1];
+ const info = $item.find('small.grey').text();
+ return {
+ title: $item.find('.title').text().trim(),
+ link,
+ description: ` ${info}`,
+ };
+ });
+
+ const RANK_TYPES = {
+ tv: '动画',
+ anime: '动画',
+ book: '图书',
+ music: '音乐',
+ game: '游戏',
+ real: '三次元',
+ };
+
+ return {
+ title: `BangumiTV 成员关注${RANK_TYPES[type]}榜`,
+ link: url,
+ item: items,
+ description: `BangumiTV 首页 - 成员关注${RANK_TYPES[type]}榜`,
+ };
+}
diff --git a/lib/routes/bangumi.tv/person/index.ts b/lib/routes/bangumi.tv/person/index.ts
new file mode 100644
index 00000000000000..cb89b750e8c223
--- /dev/null
+++ b/lib/routes/bangumi.tv/person/index.ts
@@ -0,0 +1,63 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/person/:id',
+ categories: ['anime'],
+ example: '/bangumi.tv/person/32943',
+ parameters: { id: '人物 id, 在人物页面的地址栏查看' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/person/:id'],
+ },
+ ],
+ name: '现实人物的新作品',
+ maintainers: ['ylc395'],
+ handler,
+};
+
+async function handler(ctx) {
+ // bangumi.tv未提供获取“人物信息”的API,因此仍需要通过抓取网页来获取
+ const personID = ctx.req.param('id');
+ const link = `https://bgm.tv/person/${personID}/works?sort=date`;
+ const html = await ofetch(link);
+ const $ = load(html);
+ const personName = $('.nameSingle a').text();
+ const works = $('.item')
+ .toArray()
+ .map((el) => {
+ const $el = $(el);
+ const $workEl = $el.find('.l');
+ return {
+ work: $workEl.text(),
+ workURL: `https://bgm.tv${$workEl.attr('href')}`,
+ workInfo: $el.find('p.info').text(),
+ job: $el.find('.badge_job').text(),
+ };
+ });
+
+ return {
+ title: `${personName}参与的作品`,
+ link,
+ item: works.map((c) => {
+ const match = c.workInfo.match(/(\d{4}[年-]\d{1,2}[月-]\d{1,2})/);
+ return {
+ title: `${personName}以${c.job}的身份参与了作品《${c.work}》`,
+ description: c.workInfo,
+ link: c.workURL,
+ pubDate: match ? parseDate(match[1], ['YYYY-MM-DD', 'YYYY-M-D']) : null,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/bangumi.tv/subject/comments.ts b/lib/routes/bangumi.tv/subject/comments.ts
new file mode 100644
index 00000000000000..f3d1e76d2a1d78
--- /dev/null
+++ b/lib/routes/bangumi.tv/subject/comments.ts
@@ -0,0 +1,49 @@
+import { load } from 'cheerio';
+
+import ofetch from '@/utils/ofetch';
+import { parseDate, parseRelativeDate } from '@/utils/parse-date';
+
+const getComments = async (subjectID, minLength) => {
+ // bangumi.tv未提供获取“吐槽(comments)”的API,因此仍需要通过抓取网页来获取
+ const link = `https://bgm.tv/subject/${subjectID}/comments`;
+ const html = await ofetch(link);
+ const $ = load(html);
+ const title = $('.nameSingle').find('a').text();
+ const comments = $('.item')
+ .toArray()
+ .map((el) => {
+ const $el = $(el);
+ const $rateEl = $el.find('.starlight');
+ let rate = null;
+ if ($rateEl.length > 0) {
+ rate = $rateEl.attr('class').match(/stars(\d)/)[1];
+ }
+
+ const dateString = $el.find('small.grey').text().slice(2);
+
+ const date = dateString.includes('ago')
+ ? parseRelativeDate(dateString) // 处理表示相对日期的字符串
+ : parseDate(dateString); // 表示绝对日期的字符串
+
+ return {
+ user: $el.find('.l').text(),
+ rate: rate || '无',
+ content: $el.find('p').text(),
+ date,
+ };
+ })
+ .filter((obj) => obj.content.length >= minLength);
+
+ return {
+ title: `${title}的 Bangumi 吐槽箱`,
+ link,
+ item: comments.map((c) => ({
+ title: `${c.user}的吐槽`,
+ description: `【评分:${c.rate}】 ${c.content}`,
+ guid: `${link}#${c.user}`,
+ pubDate: c.date,
+ link,
+ })),
+ };
+};
+export default getComments;
diff --git a/lib/routes/bangumi.tv/subject/ep.ts b/lib/routes/bangumi.tv/subject/ep.ts
new file mode 100644
index 00000000000000..60f33d0d6cacee
--- /dev/null
+++ b/lib/routes/bangumi.tv/subject/ep.ts
@@ -0,0 +1,29 @@
+import path from 'node:path';
+
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { getLocalName } from './utils';
+
+const getEps = async (subjectID, showOriginalName) => {
+ const url = `https://api.bgm.tv/subject/${subjectID}?responseGroup=large`;
+ const epsInfo = await ofetch(url);
+ const activeEps = epsInfo.eps.filter((e) => e.status === 'Air');
+
+ return {
+ title: getLocalName(epsInfo, showOriginalName),
+ link: `https://bgm.tv/subject/${subjectID}`,
+ description: epsInfo.summary,
+ item: activeEps.map((e) => ({
+ title: `ep.${e.sort} ${getLocalName(e, showOriginalName)}`,
+ description: art(path.join(__dirname, '../templates/ep.art'), {
+ e,
+ epsInfo,
+ }),
+ pubDate: parseDate(e.airdate),
+ link: e.url.replace('http:', 'https:'),
+ })),
+ };
+};
+export default getEps;
diff --git a/lib/routes/bangumi.tv/subject/index.ts b/lib/routes/bangumi.tv/subject/index.ts
new file mode 100644
index 00000000000000..88d30330400ec2
--- /dev/null
+++ b/lib/routes/bangumi.tv/subject/index.ts
@@ -0,0 +1,58 @@
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import { queryToBoolean } from '@/utils/readable-social';
+
+import getComments from './comments';
+import getEps from './ep';
+import getFromAPI from './offcial-subject-api';
+
+export const route: Route = {
+ path: '/subject/:id/:type?/:showOriginalName?',
+ categories: ['anime'],
+ example: '/bangumi.tv/subject/328609/ep/true',
+ parameters: { id: '条目 id, 在条目页面的地址栏查看', type: '条目类型,可选值为 `ep`, `comments`, `blogs`, `topics`,默认为 `ep`', showOriginalName: '显示番剧标题原名,可选值 0/1/false/true,默认为 false' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/subject/:id'],
+ target: '/tv/subject/:id',
+ },
+ ],
+ name: '条目的通用路由格式',
+ maintainers: ['JimenezLi'],
+ handler,
+ description: `::: warning
+ 此通用路由仅用于对路由参数的描述,具体信息请查看下方与条目相关的路由
+:::`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const type = ctx.req.param('type') || 'ep';
+ const showOriginalName = queryToBoolean(ctx.req.param('showOriginalName'));
+ let response;
+ switch (type) {
+ case 'ep':
+ response = await getEps(id, showOriginalName);
+ break;
+ case 'comments':
+ response = await getComments(id, Number(ctx.req.query('minLength')) || 0);
+ break;
+ case 'blogs':
+ response = await getFromAPI('blog')(id, showOriginalName);
+ break;
+ case 'topics':
+ response = await getFromAPI('topic')(id, showOriginalName);
+ break;
+ default:
+ throw new InvalidParameterError(`暂不支持对${type}的订阅`);
+ }
+ return response;
+}
diff --git a/lib/routes/bangumi.tv/subject/offcial-subject-api.ts b/lib/routes/bangumi.tv/subject/offcial-subject-api.ts
new file mode 100644
index 00000000000000..703beba305e508
--- /dev/null
+++ b/lib/routes/bangumi.tv/subject/offcial-subject-api.ts
@@ -0,0 +1,35 @@
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { getLocalName } from './utils';
+
+const getFromAPI = (type) => {
+ const mapping = {
+ blog: {
+ en: 'reviews',
+ cn: '评论',
+ },
+ topic: {
+ en: 'board',
+ cn: '讨论',
+ },
+ };
+
+ return async (subjectID, showOriginalName) => {
+ // 官方提供的条目API文档见 https://github.com/bangumi/api/blob/3f3fa6390c468816f9883d24be488e41f8946159/docs-raw/Subject-API.md
+ const url = `https://api.bgm.tv/subject/${subjectID}?responseGroup=large`;
+ const subjectInfo = await ofetch(url);
+ return {
+ title: `${getLocalName(subjectInfo, showOriginalName)}的 Bangumi ${mapping[type].cn}`,
+ link: `https://bgm.tv/subject/${subjectInfo.id}/${mapping[type].en}`,
+ item: subjectInfo[type].map((article) => ({
+ title: `${article.user.nickname}:${article.title}`,
+ description: article.summary || '',
+ link: article.url.replace('http:', 'https:'),
+ pubDate: parseDate(article.timestamp, 'X'),
+ author: article.user.nickname,
+ })),
+ };
+ };
+};
+export default getFromAPI;
diff --git a/lib/routes/bangumi.tv/subject/utils.ts b/lib/routes/bangumi.tv/subject/utils.ts
new file mode 100644
index 00000000000000..f9d5b06cb8de80
--- /dev/null
+++ b/lib/routes/bangumi.tv/subject/utils.ts
@@ -0,0 +1,3 @@
+const getLocalName = (obj, showOriginalName) => (showOriginalName ? obj.name || obj.name_cn : obj.name_cn || obj.name);
+
+export { getLocalName };
diff --git a/lib/v2/bangumi/templates/tv/ep.art b/lib/routes/bangumi.tv/templates/ep.art
similarity index 100%
rename from lib/v2/bangumi/templates/tv/ep.art
rename to lib/routes/bangumi.tv/templates/ep.art
diff --git a/lib/routes/bangumi.tv/templates/subject.art b/lib/routes/bangumi.tv/templates/subject.art
new file mode 100644
index 00000000000000..089bf11f11286a
--- /dev/null
+++ b/lib/routes/bangumi.tv/templates/subject.art
@@ -0,0 +1,6 @@
+{{ if routeSubjectType === 'all' }}类型:{{ subjectTypeName }} {{ /if }}
+{{ if subjectType === 2 }}看到:{{ epStatus }} / {{ subjectEps ? subjectEps: '???' }} {{ /if }}
+{{ if subjectType === 1 }}读到:{{ epStatus }} / {{ subjectEps ? subjectEps: '???' }} {{ /if }}
+评分:{{ score }}
+放送时间:{{ date ? date : '未知' }}
+
diff --git a/lib/v2/bangumi/templates/tv/today.art b/lib/routes/bangumi.tv/templates/today.art
similarity index 100%
rename from lib/v2/bangumi/templates/tv/today.art
rename to lib/routes/bangumi.tv/templates/today.art
diff --git a/lib/routes/bangumi.tv/user/blog.ts b/lib/routes/bangumi.tv/user/blog.ts
new file mode 100644
index 00000000000000..c96c32a56b7ab8
--- /dev/null
+++ b/lib/routes/bangumi.tv/user/blog.ts
@@ -0,0 +1,69 @@
+import { load } from 'cheerio';
+
+import type { 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 = {
+ path: '/user/blog/:id',
+ categories: ['anime'],
+ example: '/bangumi.tv/user/blog/sai',
+ parameters: { id: '用户 id, 在用户页面地址栏查看' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/user/:id'],
+ },
+ {
+ source: ['bangumi.tv/user/:id'],
+ },
+ ],
+ name: '用户日志',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const currentUrl = `https://bgm.tv/user/${ctx.req.param('id')}/blog`;
+ const response = await ofetch(currentUrl);
+ const $ = load(response);
+ const list = $('#entry_list div.item')
+ .find('h2.title')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a');
+ return {
+ title: a.text(),
+ link: new URL(a.attr('href'), 'https://bgm.tv').href,
+ pubDate: timezone(parseDate(item.parent().find('small.time').text()), 0),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const res = await ofetch(item.link);
+ const content = load(res);
+
+ item.description = content('#entry_content').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bangumi.tv/user/collections.ts b/lib/routes/bangumi.tv/user/collections.ts
new file mode 100644
index 00000000000000..6514406bb4c1cc
--- /dev/null
+++ b/lib/routes/bangumi.tv/user/collections.ts
@@ -0,0 +1,184 @@
+import path from 'node:path';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+// 合并不同 subjectType 的 type 映射
+const getTypeNames = (subjectType) => {
+ const commonTypeNames = {
+ 1: '想看',
+ 2: '看过',
+ 3: '在看',
+ 4: '搁置',
+ 5: '抛弃',
+ };
+
+ switch (subjectType) {
+ case '1': // 书籍
+ return {
+ 1: '想读',
+ 2: '读过',
+ 3: '在读',
+ 4: '搁置',
+ 5: '抛弃',
+ };
+ case '2': // 动画
+ case '6': // 三次元
+ return commonTypeNames;
+ case '3': // 音乐
+ return {
+ 1: '想听',
+ 2: '听过',
+ 3: '在听',
+ 4: '搁置',
+ 5: '抛弃',
+ };
+ case '4': // 游戏
+ return {
+ 1: '想玩',
+ 2: '玩过',
+ 3: '在玩',
+ 4: '搁置',
+ 5: '抛弃',
+ };
+ default:
+ return commonTypeNames; // 默认使用通用的类型
+ }
+};
+
+export const route: Route = {
+ path: '/user/collections/:id/:subjectType/:type',
+ categories: ['anime'],
+ example: '/bangumi.tv/user/collections/sai/1/1',
+ parameters: {
+ id: '用户 id, 在用户页面地址栏查看',
+ subjectType: {
+ description: '全部类别: `空`、book: `1`、anime: `2`、music: `3`、game: `4`、real: `6`',
+ options: [
+ { value: 'ALL', label: 'all' },
+ { value: 'book', label: '1' },
+ { value: 'anime', label: '2' },
+ { value: 'music', label: '3' },
+ { value: 'game', label: '4' },
+ { value: 'real', label: '6' },
+ ],
+ },
+ type: {
+ description: '全部类别: `空`、想看: `1`、看过: `2`、在看: `3`、搁置: `4`、抛弃: `5`',
+ options: [
+ { value: 'ALL', label: 'all' },
+ { value: '想看', label: '1' },
+ { value: '看过', label: '2' },
+ { value: '在看', label: '3' },
+ { value: '搁置', label: '4' },
+ { value: '抛弃', label: '5' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/anime/list/:id'],
+ target: '/bangumi.tv/user/collections/:id/all/all',
+ },
+ {
+ source: ['bangumi.tv/anime/list/:id'],
+ target: '/bangumi.tv/user/collections/:id/all/all',
+ },
+ {
+ source: ['bgm.tv/anime/list/:id/wish'],
+ target: '/bangumi.tv/user/collections/:id/2/1',
+ },
+ {
+ source: ['bangumi.tv/anime/list/:id/wish'],
+ target: '/bangumi.tv/user/collections/:id/2/1',
+ },
+ ],
+ name: 'Bangumi 用户收藏列表',
+ maintainers: ['youyou-sudo', 'honue'],
+ handler,
+};
+
+async function handler(ctx) {
+ const userId = ctx.req.param('id');
+ const subjectType = ctx.req.param('subjectType') || '';
+ const type = ctx.req.param('type') || '';
+
+ const subjectTypeNames = {
+ 1: '书籍',
+ 2: '动画',
+ 3: '音乐',
+ 4: '游戏',
+ 6: '三次元',
+ };
+
+ const typeNames = getTypeNames(subjectType);
+ const typeName = typeNames[type] || '';
+ const subjectTypeName = subjectTypeNames[subjectType] || '';
+
+ let descriptionFields = '';
+
+ if (typeName && subjectTypeName) {
+ descriptionFields = `${typeName}的${subjectTypeName}列表`;
+ } else if (typeName) {
+ descriptionFields = `${typeName}的列表`;
+ } else if (subjectTypeName) {
+ descriptionFields = `收藏的${subjectTypeName}列表`;
+ } else {
+ descriptionFields = '的Bangumi收藏列表';
+ }
+
+ const userDataUrl = `https://api.bgm.tv/v0/users/${userId}`;
+ const userData = await ofetch(userDataUrl, {
+ headers: {
+ 'User-Agent': config.trueUA,
+ },
+ });
+
+ const collectionDataUrl = `https://api.bgm.tv/v0/users/${userId}/collections?${subjectType && subjectType !== 'all' ? `subject_type=${subjectType}` : ''}${type && type !== 'all' ? `&type=${type}` : ''}`;
+ const collectionData = await ofetch(collectionDataUrl, {
+ headers: {
+ 'User-Agent': config.trueUA,
+ },
+ });
+
+ const userNickname = userData.nickname;
+ const items = collectionData.data.map((item) => {
+ const titles = item.subject.name_cn || item.subject.name;
+ const updateTime = item.updated_at;
+ const subjectId = item.subject_id;
+
+ return {
+ title: `${type === 'all' ? `${getTypeNames(item.subject_type)[item.type]}:` : ''}${titles}`,
+ description: art(path.join(__dirname, '../templates/subject.art'), {
+ routeSubjectType: subjectType,
+ subjectTypeName: subjectTypeNames[item.subject_type],
+ subjectType: item.subject_type,
+ subjectEps: item.subject.eps,
+ epStatus: item.ep_status,
+ score: item.subject.score,
+ date: item.subject.date,
+ picUrl: item.subject.images.large,
+ }),
+ link: `https://bgm.tv/subject/${subjectId}`,
+ pubDate: timezone(parseDate(updateTime), 0),
+ };
+ });
+ return {
+ title: `${userNickname}${descriptionFields}`,
+ link: `https://bgm.tv/user/${userId}/collections`,
+ item: items,
+ description: `${userNickname}${descriptionFields}`,
+ };
+}
diff --git a/lib/routes/banshujiang/index.ts b/lib/routes/banshujiang/index.ts
new file mode 100644
index 00000000000000..9ade65daf0af97
--- /dev/null
+++ b/lib/routes/banshujiang/index.ts
@@ -0,0 +1,765 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '20', 10);
+
+ const baseUrl: string = 'http://banshujiang.cn';
+ const targetUrl: string = new URL(`${category ? 'e_books' : `category/${category}`}/page/1`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = $('ul.small-list li.row')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('span.book-property__title').first().next('a');
+
+ const title: string = $aEl.text().trim();
+ const image: string | undefined = $el.find('meta[property="og:image"]').attr('content') ?? $el.find('img').attr('src');
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: $el.find('div.small-list__item-desc').html(),
+ });
+ const pubDateStr: string | undefined = image?.split(/\?timestamp=/).pop();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const categoryEls: Element[] = $el.find('span.book-property__title').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $(el).next('span').text()).filter(Boolean))];
+ const authors: DataItem['author'] = $el.find('span.book-property__title').eq(1).text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, 'x') : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr, 'x') : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.ebook-title').text().trim();
+ const image: string | undefined = $$('div.span6 img').attr('src');
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: ($$('table').first().parent().html() ?? '') + ($$('div.ebook-markdown').html() ?? ''),
+ });
+
+ $$('ul.inline').parent().parent().remove();
+
+ const pubDateStr: string | undefined = image?.split(/\?timestamp=/).pop();
+ const linkUrl: string | undefined = $$('div.ebook-title a').attr('href');
+ const categories: string[] = [
+ ...new Set(
+ $$('table tr')
+ .toArray()
+ .map((el) => $$(el).find('td').last().text())
+ .filter(Boolean)
+ ),
+ ];
+ const authors: DataItem['author'] = $$('table tr').first().find('td').last().text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, 'x') : item.pubDate,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : item.link,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr, 'x') : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ const title: string = $('title')
+ .text()
+ .replace(/第1页\s-\s/, '');
+
+ return {
+ title,
+ description: title.split(/-/).pop()?.trim(),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: new URL('logo.png?imageView2/2/w/128/h/128/q/100', baseUrl).href,
+ author: $('a.brand').text(),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: '分类',
+ url: 'banshujiang.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/banshujiang/other/人工智能',
+ parameters: {
+ category: {
+ description: '分类,默认为全部,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: 'ActionScript',
+ value: 'programming_language/ActionScript',
+ },
+ {
+ label: 'ASP.net',
+ value: 'programming_language/ASP.net',
+ },
+ {
+ label: 'C',
+ value: 'programming_language/C',
+ },
+ {
+ label: 'C#',
+ value: 'programming_language/C%23',
+ },
+ {
+ label: 'C++',
+ value: 'programming_language/C++',
+ },
+ {
+ label: 'CoffeeScript',
+ value: 'programming_language/CoffeeScript',
+ },
+ {
+ label: 'CSS',
+ value: 'programming_language/CSS',
+ },
+ {
+ label: 'Dart',
+ value: 'programming_language/Dart',
+ },
+ {
+ label: 'Elixir',
+ value: 'programming_language/Elixir',
+ },
+ {
+ label: 'Erlang',
+ value: 'programming_language/Erlang',
+ },
+ {
+ label: 'F#',
+ value: 'programming_language/F%23',
+ },
+ {
+ label: 'Go',
+ value: 'programming_language/Go',
+ },
+ {
+ label: 'Groovy',
+ value: 'programming_language/Groovy',
+ },
+ {
+ label: 'Haskell',
+ value: 'programming_language/Haskell',
+ },
+ {
+ label: 'HTML5',
+ value: 'programming_language/HTML5',
+ },
+ {
+ label: 'Java',
+ value: 'programming_language/Java',
+ },
+ {
+ label: 'JavaScript',
+ value: 'programming_language/JavaScript',
+ },
+ {
+ label: 'Kotlin',
+ value: 'programming_language/Kotlin',
+ },
+ {
+ label: 'Lua',
+ value: 'programming_language/Lua',
+ },
+ {
+ label: 'Objective-C',
+ value: 'programming_language/Objective-C',
+ },
+ {
+ label: 'Perl',
+ value: 'programming_language/Perl',
+ },
+ {
+ label: 'PHP',
+ value: 'programming_language/PHP',
+ },
+ {
+ label: 'PowerShell',
+ value: 'programming_language/PowerShell',
+ },
+ {
+ label: 'Python',
+ value: 'programming_language/Python',
+ },
+ {
+ label: 'R',
+ value: 'programming_language/R',
+ },
+ {
+ label: 'Ruby',
+ value: 'programming_language/Ruby',
+ },
+ {
+ label: 'Rust',
+ value: 'programming_language/Rust',
+ },
+ {
+ label: 'Scala',
+ value: 'programming_language/Scala',
+ },
+ {
+ label: 'Shell Script',
+ value: 'programming_language/Shell%20Script',
+ },
+ {
+ label: 'SQL',
+ value: 'programming_language/SQL',
+ },
+ {
+ label: 'Swift',
+ value: 'programming_language/Swift',
+ },
+ {
+ label: 'TypeScript',
+ value: 'programming_language/TypeScript',
+ },
+ {
+ label: 'Android',
+ value: 'mobile_development/Android',
+ },
+ {
+ label: 'iOS',
+ value: 'mobile_development/iOS',
+ },
+ {
+ label: 'Linux',
+ value: 'operation_system/Linux',
+ },
+ {
+ label: 'Mac OS X',
+ value: 'operation_system/Mac%20OS%20X',
+ },
+ {
+ label: 'Unix',
+ value: 'operation_system/Unix',
+ },
+ {
+ label: 'Windows',
+ value: 'operation_system/Windows',
+ },
+ {
+ label: 'DB2',
+ value: 'database/DB2',
+ },
+ {
+ label: 'MongoDB',
+ value: 'database/MongoDB',
+ },
+ {
+ label: 'MySQL',
+ value: 'database/MySQL',
+ },
+ {
+ label: 'Oracle',
+ value: 'database/Oracle',
+ },
+ {
+ label: 'PostgreSQL',
+ value: 'database/PostgreSQL',
+ },
+ {
+ label: 'SQL Server',
+ value: 'database/SQL%20Server',
+ },
+ {
+ label: 'SQLite',
+ value: 'database/SQLite',
+ },
+ {
+ label: 'Apache 项目',
+ value: 'open_source/Apache项目',
+ },
+ {
+ label: 'Web 开发',
+ value: 'open_source/Web开发',
+ },
+ {
+ label: '区块链',
+ value: 'open_source/区块链',
+ },
+ {
+ label: '程序开发',
+ value: 'open_source/程序开发',
+ },
+ {
+ label: '人工智能',
+ value: 'other/人工智能',
+ },
+ {
+ label: '容器技术',
+ value: 'other/容器技术',
+ },
+ {
+ label: '中文',
+ value: 'language/中文',
+ },
+ {
+ label: '英文',
+ value: 'language/英文',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+订阅 [人工智能](https://banshujiang.cn//category/other/人工智能),其源网址为 \`https://banshujiang.cn//category/other/人工智能\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/banshujiang/category/other/人工智能\`](https://rsshub.app/banshujiang/other/人工智能)。
+:::
+
+
+ 更多分类
+
+#### 编程语言
+
+| 分类 | ID |
+| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| [ActionScript](http://www.banshujiang.cn/category/programming_language/ActionScript/page/1) | [category/programming_language/ActionScript](https://rsshub.app/banshujiang/programming_language/ActionScript) |
+| [ASP.net](http://www.banshujiang.cn/category/programming_language/ASP.net/page/1) | [category/programming_language/ASP.net](https://rsshub.app/banshujiang/programming_language/ASP.net) |
+| [C](http://www.banshujiang.cn/category/programming_language/C) | [category/programming_language/C](https://rsshub.app/banshujiang/programming_language/C) |
+| [C#](http://www.banshujiang.cn/category/programming_language/C%23) | [category/programming_language/C%23](https://rsshub.app/banshujiang/programming_language/C%23) |
+| [C++](http://www.banshujiang.cn/category/programming_language/C++) | [category/programming_language/C++](https://rsshub.app/banshujiang/programming_language/C++) |
+| [CoffeeScript](http://www.banshujiang.cn/category/programming_language/CoffeeScript) | [category/programming_language/CoffeeScript](https://rsshub.app/banshujiang/programming_language/CoffeeScript) |
+| [CSS](http://www.banshujiang.cn/category/programming_language/CSS) | [category/programming_language/CSS) |
+| [Dart](http://www.banshujiang.cn/category/programming_language/Dart) | [category/programming_language/Dart](https://rsshub.app/banshujiang/programming_language/Dart) |
+| [Elixir](http://www.banshujiang.cn/category/programming_language/Elixir) | [category/programming_language/Elixir](https://rsshub.app/banshujiang/programming_language/Elixir) |
+| [Erlang](http://www.banshujiang.cn/category/programming_language/Erlang) | [category/programming_language/Erlang](https://rsshub.app/banshujiang/programming_language/Erlang) |
+| [F#](http://www.banshujiang.cn/category/programming_language/F%23) | [category/programming_language/F%23](https://rsshub.app/banshujiang/programming_language/F%23) |
+| [Go](http://www.banshujiang.cn/category/programming_language/Go) | [category/programming_language/Go](https://rsshub.app/banshujiang/programming_language/Go) |
+| [Groovy](http://www.banshujiang.cn/category/programming_language/Groovy) | [category/programming_language/Groovy](https://rsshub.app/banshujiang/programming_language/Groovy) |
+| [Haskell](http://www.banshujiang.cn/category/programming_language/Haskell) | [category/programming_language/Haskell](https://rsshub.app/banshujiang/programming_language/Haskell) |
+| [HTML5](http://www.banshujiang.cn/category/programming_language/HTML5) | [category/programming_language/HTML5](https://rsshub.app/banshujiang/programming_language/HTML5) |
+| [Java](http://www.banshujiang.cn/category/programming_language/Java) | [category/programming_language/Java](https://rsshub.app/banshujiang/programming_language/Java) |
+| [JavaScript](http://www.banshujiang.cn/category/programming_language/JavaScript) | [category/programming_language/JavaScript](https://rsshub.app/banshujiang/programming_language/JavaScript) |
+| [Kotlin](http://www.banshujiang.cn/category/programming_language/Kotlin) | [category/programming_language/Kotlin](https://rsshub.app/banshujiang/programming_language/Kotlin) |
+| [Lua](http://www.banshujiang.cn/category/programming_language/Lua) | [category/programming_language/Lua](https://rsshub.app/banshujiang/programming_language/Lua) |
+| [Objective-C](http://www.banshujiang.cn/category/programming_language/Objective-C) | [category/programming_language/Objective-C](https://rsshub.app/banshujiang/programming_language/Objective-C) |
+| [Perl](http://www.banshujiang.cn/category/programming_language/Perl) | [category/programming_language/Perl](https://rsshub.app/banshujiang/programming_language/Perl) |
+| [PHP](http://www.banshujiang.cn/category/programming_language/PHP) | [category/programming_language/PHP](https://rsshub.app/banshujiang/programming_language/PHP) |
+| [PowerShell](http://www.banshujiang.cn/category/programming_language/PowerShell) | [category/programming_language/PowerShell](https://rsshub.app/banshujiang/programming_language/PowerShell) |
+| [Python](http://www.banshujiang.cn/category/programming_language/Python) | [category/programming_language/Python](https://rsshub.app/banshujiang/programming_language/Python) |
+| [R](http://www.banshujiang.cn/category/programming_language/R/page/1) | [category/programming_language/R](https://rsshub.app/banshujiang/programming_language/R) |
+| [Ruby](http://www.banshujiang.cn/category/programming_language/Ruby/page/1) | [category/programming_language/Ruby](https://rsshub.app/banshujiang/programming_language/Ruby) |
+| [Rust](http://www.banshujiang.cn/category/programming_language/Rust/page/1) | [category/programming_language/Rust](https://rsshub.app/banshujiang/programming_language/Rust) |
+| [Scala](http://www.banshujiang.cn/category/programming_language/Scala/page/1) | [category/programming_language/Scala](https://rsshub.app/banshujiang/programming_language/Scala) |
+| [Shell Script](http://www.banshujiang.cn/category/programming_language/Shell%20Script/page/1) | [category/programming_language/Shell%20Script](https://rsshub.app/banshujiang/programming_language/Shell%20Script) |
+| [SQL](http://www.banshujiang.cn/category/programming_language/SQL/page/1) | [category/programming_language/SQL](https://rsshub.app/banshujiang/programming_language/SQL) |
+| [Swift](http://www.banshujiang.cn/category/programming_language/Swift/page/1) | [category/programming_language/Swift](https://rsshub.app/banshujiang/programming_language/Swift) |
+| [TypeScript](http://www.banshujiang.cn/category/programming_language/TypeScript/page/1) | [category/programming_language/TypeScript](https://rsshub.app/banshujiang/programming_language/TypeScript) |
+
+#### 移动开发
+
+| 分类 | ID |
+| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| [Android](http://www.banshujiang.cn/category/mobile_development/Android/page/1) | [category/mobile_development/Android](https://rsshub.app/banshujiang/mobile_development/Android) |
+| [iOS](http://www.banshujiang.cn/category/mobile_development/iOS/page/1) | [category/mobile_development/iOS](https://rsshub.app/banshujiang/mobile_development/iOS) |
+
+#### 操作系统
+
+| 分类 | ID |
+| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
+| [Linux](http://www.banshujiang.cn/category/operation_system/Linux/page/1) | [category/operation_system/Linux](https://rsshub.app/banshujiang/operation_system/Linux) |
+| [Mac OS X](http://www.banshujiang.cn/category/operation_system/Mac%20OS%20X/page/1) | [category/operation_system/Mac%20OS%20X](https://rsshub.app/banshujiang/operation_system/Mac%20OS%20X) |
+| [Unix](http://www.banshujiang.cn/category/operation_system/Unix/page/1) | [category/operation_system/Unix](https://rsshub.app/banshujiang/operation_system/Unix) |
+| [Windows](http://www.banshujiang.cn/category/operation_system/Windows/page/1) | [category/operation_system/Windows](https://rsshub.app/banshujiang/operation_system/Windows) |
+
+#### 数据库
+
+| 分类 | ID |
+| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
+| [DB2](http://www.banshujiang.cn/category/database/DB2/page/1) | [category/database/DB2](https://rsshub.app/banshujiang/database/DB2) |
+| [MongoDB](http://www.banshujiang.cn/category/database/MongoDB/page/1) | [category/database/MongoDB](https://rsshub.app/banshujiang/database/MongoDB) |
+| [MySQL](http://www.banshujiang.cn/category/database/MySQL/page/1) | [category/database/MySQL](https://rsshub.app/banshujiang/database/MySQL) |
+| [Oracle](http://www.banshujiang.cn/category/database/Oracle/page/1) | [category/database/Oracle](https://rsshub.app/banshujiang/database/Oracle) |
+| [PostgreSQL](http://www.banshujiang.cn/category/database/PostgreSQL/page/1) | [category/database/PostgreSQL](https://rsshub.app/banshujiang/database/PostgreSQL) |
+| [SQL Server](http://www.banshujiang.cn/category/database/SQL%20Server/page/1) | [category/database/SQL%20Server](https://rsshub.app/banshujiang/database/SQL%20Server) |
+| [SQLite](http://www.banshujiang.cn/category/database/SQLite/page/1) | [category/database/SQLite](https://rsshub.app/banshujiang/database/SQLite) |
+
+#### 开源软件
+
+| 分类 | ID |
+| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
+| [Apache 项目](http://www.banshujiang.cn/category/open_source/Apache项目/page/1) | [category/open_source/Apache 项目](https://rsshub.app/banshujiang/open_source/Apache项目) |
+| [Web 开发](http://www.banshujiang.cn/category/open_source/Web开发/page/1) | [category/open_source/Web 开发](https://rsshub.app/banshujiang/open_source/Web开发) |
+| [区块链](http://www.banshujiang.cn/category/open_source/区块链/page/1) | [category/open_source/区块链](https://rsshub.app/banshujiang/open_source/区块链) |
+| [程序开发](http://www.banshujiang.cn/category/open_source/程序开发/page/1) | [category/open_source/程序开发](https://rsshub.app/banshujiang/open_source/程序开发) |
+
+#### 其他
+
+| 分类 | ID |
+| -------------------------------------------------------------------- | ------------------------------------------------------------------------ |
+| [人工智能](http://www.banshujiang.cn/category/other/人工智能/page/1) | [category/other/人工智能](https://rsshub.app/banshujiang/other/人工智能) |
+| [容器技术](http://www.banshujiang.cn/category/other/容器技术/page/1) | [category/other/容器技术](https://rsshub.app/banshujiang/other/容器技术) |
+
+#### 语言
+
+| 分类 | ID |
+| --------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| [中文](http://www.banshujiang.cn/category/language/中文/page/1) | [category/language/中文](https://rsshub.app/banshujiang/language/中文) |
+| [英文](http://www.banshujiang.cn/category/language/英文/page/1) | [category/language/英文](https://rsshub.app/banshujiang/language/英文) |
+
+
+`,
+ categories: ['reading'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['banshujiang.cn/:category?'],
+ target: (params) => {
+ const category: string = params.category;
+
+ return `/banshujiang${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: 'ActionScript',
+ source: ['banshujiang.cn/programming_language/ActionScript/page/1'],
+ target: '/programming_language/ActionScript',
+ },
+ {
+ title: 'ASP.net',
+ source: ['banshujiang.cn/programming_language/ASP.net/page/1'],
+ target: '/programming_language/ASP.net',
+ },
+ {
+ title: 'C',
+ source: ['banshujiang.cn/programming_language/C'],
+ target: '/programming_language/C',
+ },
+ {
+ title: 'C#',
+ source: ['banshujiang.cn/programming_language/C%23'],
+ target: '/programming_language/C%23',
+ },
+ {
+ title: 'C++',
+ source: ['banshujiang.cn/programming_language/C++'],
+ target: '/programming_language/C++',
+ },
+ {
+ title: 'CoffeeScript',
+ source: ['banshujiang.cn/programming_language/CoffeeScript'],
+ target: '/programming_language/CoffeeScript',
+ },
+ {
+ title: 'CSS',
+ source: ['banshujiang.cn/programming_language/CSS'],
+ target: '/programming_language/CSS',
+ },
+ {
+ title: 'Dart',
+ source: ['banshujiang.cn/programming_language/Dart'],
+ target: '/programming_language/Dart',
+ },
+ {
+ title: 'Elixir',
+ source: ['banshujiang.cn/programming_language/Elixir'],
+ target: '/programming_language/Elixir',
+ },
+ {
+ title: 'Erlang',
+ source: ['banshujiang.cn/programming_language/Erlang'],
+ target: '/programming_language/Erlang',
+ },
+ {
+ title: 'F#',
+ source: ['banshujiang.cn/programming_language/F%23'],
+ target: '/programming_language/F%23',
+ },
+ {
+ title: 'Go',
+ source: ['banshujiang.cn/programming_language/Go'],
+ target: '/programming_language/Go',
+ },
+ {
+ title: 'Groovy',
+ source: ['banshujiang.cn/programming_language/Groovy'],
+ target: '/programming_language/Groovy',
+ },
+ {
+ title: 'Haskell',
+ source: ['banshujiang.cn/programming_language/Haskell'],
+ target: '/programming_language/Haskell',
+ },
+ {
+ title: 'HTML5',
+ source: ['banshujiang.cn/programming_language/HTML5'],
+ target: '/programming_language/HTML5',
+ },
+ {
+ title: 'Java',
+ source: ['banshujiang.cn/programming_language/Java'],
+ target: '/programming_language/Java',
+ },
+ {
+ title: 'JavaScript',
+ source: ['banshujiang.cn/programming_language/JavaScript'],
+ target: '/programming_language/JavaScript',
+ },
+ {
+ title: 'Kotlin',
+ source: ['banshujiang.cn/programming_language/Kotlin'],
+ target: '/programming_language/Kotlin',
+ },
+ {
+ title: 'Lua',
+ source: ['banshujiang.cn/programming_language/Lua'],
+ target: '/programming_language/Lua',
+ },
+ {
+ title: 'Objective-C',
+ source: ['banshujiang.cn/programming_language/Objective-C'],
+ target: '/programming_language/Objective-C',
+ },
+ {
+ title: 'Perl',
+ source: ['banshujiang.cn/programming_language/Perl'],
+ target: '/programming_language/Perl',
+ },
+ {
+ title: 'PHP',
+ source: ['banshujiang.cn/programming_language/PHP'],
+ target: '/programming_language/PHP',
+ },
+ {
+ title: 'PowerShell',
+ source: ['banshujiang.cn/programming_language/PowerShell'],
+ target: '/programming_language/PowerShell',
+ },
+ {
+ title: 'Python',
+ source: ['banshujiang.cn/programming_language/Python'],
+ target: '/programming_language/Python',
+ },
+ {
+ title: 'R',
+ source: ['banshujiang.cn/programming_language/R/page/1'],
+ target: '/programming_language/R',
+ },
+ {
+ title: 'Ruby',
+ source: ['banshujiang.cn/programming_language/Ruby/page/1'],
+ target: '/programming_language/Ruby',
+ },
+ {
+ title: 'Rust',
+ source: ['banshujiang.cn/programming_language/Rust/page/1'],
+ target: '/programming_language/Rust',
+ },
+ {
+ title: 'Scala',
+ source: ['banshujiang.cn/programming_language/Scala/page/1'],
+ target: '/programming_language/Scala',
+ },
+ {
+ title: 'Shell Script',
+ source: ['banshujiang.cn/programming_language/Shell%20Script/page/1'],
+ target: '/programming_language/Shell%20Script',
+ },
+ {
+ title: 'SQL',
+ source: ['banshujiang.cn/programming_language/SQL/page/1'],
+ target: '/programming_language/SQL',
+ },
+ {
+ title: 'Swift',
+ source: ['banshujiang.cn/programming_language/Swift/page/1'],
+ target: '/programming_language/Swift',
+ },
+ {
+ title: 'TypeScript',
+ source: ['banshujiang.cn/programming_language/TypeScript/page/1'],
+ target: '/programming_language/TypeScript',
+ },
+ {
+ title: 'Android',
+ source: ['banshujiang.cn/mobile_development/Android/page/1'],
+ target: '/mobile_development/Android',
+ },
+ {
+ title: 'iOS',
+ source: ['banshujiang.cn/mobile_development/iOS/page/1'],
+ target: '/mobile_development/iOS',
+ },
+ {
+ title: 'Linux',
+ source: ['banshujiang.cn/operation_system/Linux/page/1'],
+ target: '/operation_system/Linux',
+ },
+ {
+ title: 'Mac OS X',
+ source: ['banshujiang.cn/operation_system/Mac%20OS%20X/page/1'],
+ target: '/operation_system/Mac%20OS%20X',
+ },
+ {
+ title: 'Unix',
+ source: ['banshujiang.cn/operation_system/Unix/page/1'],
+ target: '/operation_system/Unix',
+ },
+ {
+ title: 'Windows',
+ source: ['banshujiang.cn/operation_system/Windows/page/1'],
+ target: '/operation_system/Windows',
+ },
+ {
+ title: 'DB2',
+ source: ['banshujiang.cn/database/DB2/page/1'],
+ target: '/database/DB2',
+ },
+ {
+ title: 'MongoDB',
+ source: ['banshujiang.cn/database/MongoDB/page/1'],
+ target: '/database/MongoDB',
+ },
+ {
+ title: 'MySQL',
+ source: ['banshujiang.cn/database/MySQL/page/1'],
+ target: '/database/MySQL',
+ },
+ {
+ title: 'Oracle',
+ source: ['banshujiang.cn/database/Oracle/page/1'],
+ target: '/database/Oracle',
+ },
+ {
+ title: 'PostgreSQL',
+ source: ['banshujiang.cn/database/PostgreSQL/page/1'],
+ target: '/database/PostgreSQL',
+ },
+ {
+ title: 'SQL Server',
+ source: ['banshujiang.cn/database/SQL%20Server/page/1'],
+ target: '/database/SQL%20Server',
+ },
+ {
+ title: 'SQLite',
+ source: ['banshujiang.cn/database/SQLite/page/1'],
+ target: '/database/SQLite',
+ },
+ {
+ title: 'Apache 项目',
+ source: ['banshujiang.cn/open_source/Apache项目/page/1'],
+ target: '/open_source/Apache 项目',
+ },
+ {
+ title: 'Web 开发',
+ source: ['banshujiang.cn/open_source/Web开发/page/1'],
+ target: '/open_source/Web 开发',
+ },
+ {
+ title: '区块链',
+ source: ['banshujiang.cn/open_source/区块链/page/1'],
+ target: '/open_source/区块链',
+ },
+ {
+ title: '程序开发',
+ source: ['banshujiang.cn/open_source/程序开发/page/1'],
+ target: '/open_source/程序开发',
+ },
+ {
+ title: '人工智能',
+ source: ['banshujiang.cn/other/人工智能/page/1'],
+ target: '/other/人工智能',
+ },
+ {
+ title: '容器技术',
+ source: ['banshujiang.cn/other/容器技术/page/1'],
+ target: '/other/容器技术',
+ },
+ {
+ title: '中文',
+ source: ['banshujiang.cn/language/中文/page/1'],
+ target: '/language/中文',
+ },
+ {
+ title: '英文',
+ source: ['banshujiang.cn/language/英文/page/1'],
+ target: '/language/英文',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/banshujiang/namespace.ts b/lib/routes/banshujiang/namespace.ts
new file mode 100644
index 00000000000000..e495d6c53af65e
--- /dev/null
+++ b/lib/routes/banshujiang/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '搬书匠',
+ url: 'banshujiang.cn',
+ categories: ['reading'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/banshujiang/templates/description.art b/lib/routes/banshujiang/templates/description.art
new file mode 100644
index 00000000000000..dfab19230c1108
--- /dev/null
+++ b/lib/routes/banshujiang/templates/description.art
@@ -0,0 +1,17 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/banyuetan/index.js b/lib/routes/banyuetan/index.js
deleted file mode 100644
index 19d065a91b6a25..00000000000000
--- a/lib/routes/banyuetan/index.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const name = ctx.params.name;
-
- const link = `http://www.banyuetan.org/byt/${name}/index.html`;
- const response = await got.get(link);
-
- const $ = cheerio.load(response.data);
-
- const list = $('ul.clearFix li')
- .slice(0, 10)
- .map(function () {
- const info = {
- title: $(this).find('h3 a').text(),
- link: $(this).find('h3 a').attr('href'),
- date: $(this).find('span.tag3').text(),
- };
- return info;
- })
- .get();
-
- const out = await Promise.all(
- list.map(async (info) => {
- const title = info.title;
- const date = info.date;
- const itemUrl = info.link;
-
- const cache = await ctx.cache.get(itemUrl);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const response = await got.get(itemUrl);
-
- const $ = cheerio.load(response.data);
- const description = $('div.detail_content').html() || '文章已被删除';
-
- const single = {
- title,
- link: itemUrl,
- description,
- pubDate: new Date(date).toUTCString(),
- };
- ctx.cache.set(itemUrl, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
-
- ctx.state.data = {
- title: `${name}-半月谈`,
- link,
- item: out,
- };
-};
diff --git a/lib/routes/banyuetan/index.ts b/lib/routes/banyuetan/index.ts
new file mode 100644
index 00000000000000..c953deeb06b76b
--- /dev/null
+++ b/lib/routes/banyuetan/index.ts
@@ -0,0 +1,240 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx: Context): Promise => {
+ const { id = 'jinritan' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'http://www.banyuetan.org';
+ const targetUrl: string = new URL(`byt/${id}/index.html`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = $('div.bty_tbtj_list ul.clearFix li')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('h3 a');
+
+ const title: string = $aEl.text();
+ const image: string | undefined = $el.find('img').attr('src');
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ intro: $el.find('p').text(),
+ });
+ const pubDateStr: string | undefined = $el.find('span.tag3').text();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.detail_tit h1').text();
+ const description: string | undefined =
+ item.description +
+ art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div#detail_content').html(),
+ });
+ const pubDateStr: string | undefined = $$('meta[property="og:release_date"]').attr('content');
+ const categories: string[] = $$('META[name="keywords"]').attr('content')?.split(/,/) ?? [];
+ const authorEls: Element[] = [...$$('META[name="author"]').toArray(), ...$$('META[name="source"]').toArray()];
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl);
+
+ return {
+ name: $$authorEl.attr('content'),
+ url: undefined,
+ avatar: undefined,
+ };
+ });
+ const image: string | undefined = $$('meta[property="og:image"]').attr('content');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: title,
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: new URL('static/v1/image/logo.png', baseUrl).href,
+ author: title.split(/—/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:id?',
+ name: '栏目',
+ url: 'www.banyuetan.org',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/banyuetan/jinritan',
+ parameters: {
+ id: {
+ description: '栏目 ID,默认为 `jinritan`,即今日谈,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '今日谈',
+ value: 'jinritan',
+ },
+ {
+ label: '时政讲解',
+ value: 'shizhengjiangjie',
+ },
+ {
+ label: '评论',
+ value: 'banyuetanpinglun',
+ },
+ {
+ label: '基层治理',
+ value: 'jicengzhili',
+ },
+ {
+ label: '文化',
+ value: 'wenhua',
+ },
+ {
+ label: '教育',
+ value: 'jiaoyu',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+订阅 [今日谈](http://www.banyuetan.org/byt/jinritan/),其源网址为 \`http://www.banyuetan.org/byt/jinritan/\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/banyuetan/jinritan\`](https://rsshub.app/banyuetan/jinritan)。
+:::
+
+| 栏目 | ID |
+| -------------------------------------------------------------------- | ----------------------------------------------------------------- |
+| [今日谈](http://www.banyuetan.org/byt/jinritan/index.html) | [jinritan](https://rsshub.app/banyuetan/jinritan) |
+| [时政讲解](http://www.banyuetan.org/byt/shizhengjiangjie/index.html) | [shizhengjiangjie](https://rsshub.app/banyuetan/shizhengjiangjie) |
+| [评论](http://www.banyuetan.org/byt/banyuetanpinglun/index.html) | [banyuetanpinglun](https://rsshub.app/banyuetan/banyuetanpinglun) |
+| [基层治理](http://www.banyuetan.org/byt/jicengzhili/index.html) | [jicengzhili](https://rsshub.app/banyuetan/jicengzhili) |
+| [文化](http://www.banyuetan.org/byt/wenhua/index.html) | [wenhua](https://rsshub.app/banyuetan/wenhua) |
+| [教育](http://www.banyuetan.org/byt/jiaoyu/index.html) | [jiaoyu](https://rsshub.app/banyuetan/jiaoyu) |
+
+`,
+ categories: ['traditional-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.banyuetan.org/byt/:id'],
+ target: '/:id',
+ },
+ {
+ title: '今日谈',
+ source: ['www.banyuetan.org/byt/jinritan/index.html'],
+ target: '/jinritan',
+ },
+ {
+ title: '时政讲解',
+ source: ['www.banyuetan.org/byt/shizhengjiangjie/index.html'],
+ target: '/shizhengjiangjie',
+ },
+ {
+ title: '评论',
+ source: ['www.banyuetan.org/byt/banyuetanpinglun/index.html'],
+ target: '/banyuetanpinglun',
+ },
+ {
+ title: '基层治理',
+ source: ['www.banyuetan.org/byt/jicengzhili/index.html'],
+ target: '/jicengzhili',
+ },
+ {
+ title: '文化',
+ source: ['www.banyuetan.org/byt/wenhua/index.html'],
+ target: '/wenhua',
+ },
+ {
+ title: '教育',
+ source: ['www.banyuetan.org/byt/jiaoyu/index.html'],
+ target: '/jiaoyu',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/banyuetan/namespace.ts b/lib/routes/banyuetan/namespace.ts
new file mode 100644
index 00000000000000..e213dd662083d2
--- /dev/null
+++ b/lib/routes/banyuetan/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '半月谈',
+ url: 'banyuetan.org',
+ categories: ['traditional-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/banyuetan/templates/description.art b/lib/routes/banyuetan/templates/description.art
new file mode 100644
index 00000000000000..249654e7e618a4
--- /dev/null
+++ b/lib/routes/banyuetan/templates/description.art
@@ -0,0 +1,21 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if intro }}
+ {{ intro }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/baobua/article.ts b/lib/routes/baobua/article.ts
new file mode 100644
index 00000000000000..82fe3bdf75796a
--- /dev/null
+++ b/lib/routes/baobua/article.ts
@@ -0,0 +1,52 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+async function loadArticle(link) {
+ const resp = await got(link);
+ const article = load(resp.body);
+
+ const title = article('title')
+ .text()
+ .replace('BaoBua.Com:', '')
+ .replace(/\| Page \d+\/\d+/, '')
+ .trim();
+ const totalPagesRegex = /Page \d+\/(\d+)/;
+ const totalPagesMatch = totalPagesRegex.exec(article('title').text());
+ const totalPages = totalPagesMatch ? Number.parseInt(totalPagesMatch[1]) : 1;
+
+ let pubDate;
+ const blogPostingScript = article('script:contains("BlogPosting")').first();
+ if (blogPostingScript) {
+ const jsonData = JSON.parse(blogPostingScript.text());
+ pubDate = parseDate(jsonData.datePublished);
+ }
+
+ const contentDiv = article('.contentme2');
+ let description = contentDiv.html() ?? '';
+
+ if (totalPages > 1) {
+ const additionalContents = await Promise.all(
+ Array.from({ length: totalPages - 1 }, async (_, i) => {
+ try {
+ const response = await got(`${link}?page=${i + 2}`);
+ const pageDom = load(response.body);
+ return pageDom('.contentme2').html() ?? '';
+ } catch {
+ return '';
+ }
+ })
+ );
+ description += additionalContents.join('');
+ }
+
+ return {
+ title,
+ description,
+ pubDate,
+ link,
+ };
+}
+
+export default loadArticle;
diff --git a/lib/routes/baobua/category.ts b/lib/routes/baobua/category.ts
new file mode 100644
index 00000000000000..0618fd7dc7ca8f
--- /dev/null
+++ b/lib/routes/baobua/category.ts
@@ -0,0 +1,62 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
+export const route: Route = {
+ path: '/category/:category',
+ categories: ['picture'],
+ example: '/baobua/category/network',
+ parameters: { category: 'Category' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['baobua.com/cat/:category'],
+ target: '/category/:category',
+ },
+ ],
+ name: 'Category',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: 'baobua.com/',
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category');
+ const url = `${SUB_URL}cat/${category}/`;
+
+ const response = await got(url);
+ const $ = load(response.body);
+ const itemRaw = $('.thcovering-video').toArray();
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Category: ${category}`,
+ link: url,
+ item: await Promise.all(
+ itemRaw
+ .map((e) => {
+ const item = $(e);
+ let link = item.find('a').attr('href');
+ if (!link) {
+ return null;
+ }
+ if (link.startsWith('/')) {
+ link = new URL(link, SUB_URL).href;
+ }
+ return cache.tryGet(link, () => loadArticle(link));
+ })
+ .filter(Boolean)
+ ),
+ };
+}
diff --git a/lib/routes/baobua/const.ts b/lib/routes/baobua/const.ts
new file mode 100644
index 00000000000000..c135b83736f4d3
--- /dev/null
+++ b/lib/routes/baobua/const.ts
@@ -0,0 +1,4 @@
+const SUB_NAME_PREFIX = 'BaoBua';
+const SUB_URL = 'https://baobua.com/';
+
+export { SUB_NAME_PREFIX, SUB_URL };
diff --git a/lib/routes/baobua/latest.ts b/lib/routes/baobua/latest.ts
new file mode 100644
index 00000000000000..0fc235ff71751f
--- /dev/null
+++ b/lib/routes/baobua/latest.ts
@@ -0,0 +1,59 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
+export const route: Route = {
+ path: '/',
+ categories: ['picture'],
+ example: '/baobua',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['baobua.com/'],
+ target: '',
+ },
+ ],
+ name: 'Latest',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: 'baobua.com/',
+};
+
+async function handler() {
+ const response = await got(SUB_URL);
+ const $ = load(response.body);
+ const itemRaw = $('.thcovering-video').toArray();
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Latest`,
+ link: SUB_URL,
+ item: await Promise.all(
+ itemRaw
+ .map((e) => {
+ const item = $(e);
+ let link = item.find('a').attr('href');
+ if (!link) {
+ return null;
+ }
+ if (link.startsWith('/')) {
+ link = new URL(link, SUB_URL).href;
+ }
+ return cache.tryGet(link, () => loadArticle(link));
+ })
+ .filter(Boolean)
+ ),
+ };
+}
diff --git a/lib/routes/baobua/namespace.ts b/lib/routes/baobua/namespace.ts
new file mode 100644
index 00000000000000..0d55833957bc8d
--- /dev/null
+++ b/lib/routes/baobua/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BaoBua',
+ url: 'baobua.com',
+ description: 'BaoBua.Com - Hot beauty girl pics, girls photos, free watch online hd photo sets',
+ lang: 'en',
+};
diff --git a/lib/routes/baobua/search.ts b/lib/routes/baobua/search.ts
new file mode 100644
index 00000000000000..11d62506b97ae2
--- /dev/null
+++ b/lib/routes/baobua/search.ts
@@ -0,0 +1,62 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
+export const route: Route = {
+ path: '/search/:keyword',
+ categories: ['picture'],
+ example: '/baobua/search/cos',
+ parameters: { keyword: 'Keyword' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['baobua.com/search'],
+ target: '/search/:keyword',
+ },
+ ],
+ name: 'Search',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: 'baobua.com/',
+};
+
+async function handler(ctx) {
+ const keyword = ctx.req.param('keyword');
+ const url = `${SUB_URL}search?q=${keyword}`;
+
+ const response = await got(url);
+ const $ = load(response.body);
+ const itemRaw = $('.thcovering-video').toArray();
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Search: ${keyword}`,
+ link: url,
+ item: await Promise.all(
+ itemRaw
+ .map((e) => {
+ const item = $(e);
+ let link = item.find('a').attr('href');
+ if (!link) {
+ return null;
+ }
+ if (link.startsWith('/')) {
+ link = new URL(link, SUB_URL).href;
+ }
+ return cache.tryGet(link, () => loadArticle(link));
+ })
+ .filter(Boolean)
+ ),
+ };
+}
diff --git a/lib/routes/baoyu/index.ts b/lib/routes/baoyu/index.ts
new file mode 100644
index 00000000000000..6097f7facca6e7
--- /dev/null
+++ b/lib/routes/baoyu/index.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import parser from '@/utils/rss-parser';
+
+export const route: Route = {
+ path: '/blog',
+ categories: ['blog'],
+ example: '/baoyu/blog',
+ radar: [
+ {
+ source: ['baoyu.io/'],
+ },
+ ],
+ url: 'baoyu.io/',
+ name: 'Blog',
+ maintainers: ['liyaozhong'],
+ handler,
+ description: '宝玉 - 博客文章',
+};
+
+async function handler() {
+ const rootUrl = 'https://baoyu.io';
+ const feedUrl = `${rootUrl}/feed.xml`;
+
+ const feed = await parser.parseURL(feedUrl);
+
+ const items = await Promise.all(
+ feed.items.map((item) => {
+ const link = item.link;
+
+ return cache.tryGet(link as string, async () => {
+ const response = await got(link);
+ const $ = load(response.data);
+
+ const container = $('.container');
+ const content = container.find('.prose').html() || '';
+
+ return {
+ title: item.title,
+ description: content,
+ link,
+ pubDate: item.pubDate ? parseDate(item.pubDate) : undefined,
+ author: item.creator || '宝玉',
+ } as DataItem;
+ });
+ })
+ );
+
+ return {
+ title: '宝玉的博客',
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/baoyu/namespace.ts b/lib/routes/baoyu/namespace.ts
new file mode 100644
index 00000000000000..6bc9da081013b2
--- /dev/null
+++ b/lib/routes/baoyu/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '宝玉',
+ url: 'baoyu.io',
+ description: '宝玉的博客',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/baozimh/index.ts b/lib/routes/baozimh/index.ts
new file mode 100644
index 00000000000000..3cbf55fb814b71
--- /dev/null
+++ b/lib/routes/baozimh/index.ts
@@ -0,0 +1,97 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const rootUrl = 'https://www.baozimh.com';
+
+export const route: Route = {
+ path: '/comic/:name',
+ categories: ['anime'],
+ example: '/baozimh/comic/guowangpaiming-shiricaofu',
+ parameters: { name: '漫画名称,在漫画链接可以得到(`comic/` 后的那段)' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.baozimh.com/comic/:name'],
+ },
+ ],
+ name: '订阅漫画',
+ maintainers: ['Fatpandac'],
+ handler,
+};
+
+async function handler(ctx) {
+ const name = ctx.req.param('name');
+ const url = `${rootUrl}/comic/${name}`;
+
+ const response = await got(url);
+ const $ = load(response.data);
+ const comicTitle = $('div > div.pure-u-1-1.pure-u-sm-2-3.pure-u-md-3-4 > div > h1').text();
+ const list = $('#chapter-items')
+ .first()
+ .children()
+ .toArray()
+ .map((item) => {
+ const title = $(item).find('span').text();
+ const link = rootUrl + $(item).find('a').attr('href');
+
+ return {
+ title,
+ link,
+ };
+ });
+
+ // more chapters
+ const otherList = $('#chapters_other_list')
+ .first()
+ .children()
+ .toArray()
+ .map((item) => {
+ const title = $(item).find('span').text();
+ const link = rootUrl + $(item).find('a').attr('href');
+
+ return {
+ title,
+ link,
+ };
+ });
+
+ const combinedList = [...list, ...otherList];
+ combinedList.reverse();
+
+ const items = await Promise.all(
+ combinedList.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link);
+ const $ = load(detailResponse.data);
+ item.description = art(path.join(__dirname, 'templates/desc.art'), {
+ imgUrlList: $('.comic-contain')
+ .find('amp-img')
+ .toArray()
+ .map((item) => $(item).attr('src')),
+ });
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `包子漫画-${comicTitle}`,
+ description: $('.comics-detail__desc').text(),
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/baozimh/namespace.ts b/lib/routes/baozimh/namespace.ts
new file mode 100644
index 00000000000000..e02b690b284249
--- /dev/null
+++ b/lib/routes/baozimh/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '包子漫画',
+ url: 'www.baozimh.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/baozimh/templates/desc.art b/lib/routes/baozimh/templates/desc.art
similarity index 100%
rename from lib/v2/baozimh/templates/desc.art
rename to lib/routes/baozimh/templates/desc.art
diff --git a/lib/routes/barronschina/index.ts b/lib/routes/barronschina/index.ts
new file mode 100644
index 00000000000000..f5e22d2526a026
--- /dev/null
+++ b/lib/routes/barronschina/index.ts
@@ -0,0 +1,101 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:id?',
+ categories: ['finance'],
+ example: '/barronschina',
+ parameters: { id: '栏目 id,默认为快讯' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['barronschina.com.cn/'],
+ target: '/:category?',
+ },
+ ],
+ name: '栏目',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'barronschina.com.cn/',
+ description: `::: tip
+ 栏目 id 留空则返回快讯,在对应页地址栏 \`columnId=\` 后可以看到。
+:::`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '';
+
+ const rootUrl = 'http://www.barronschina.com.cn';
+ const currentUrl = `${rootUrl}/index/${id ? `column/article?columnId=${id}` : 'shortNews'}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const items = id
+ ? await Promise.all(
+ $('.title')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('.title').text(),
+ link: `${rootUrl}${item.parent().attr('href')}`,
+ };
+ })
+ .map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.description = content('.cont_main').html();
+ item.pubDate = timezone(parseDate(content('.timeTag').text()), +8);
+
+ return item;
+ })
+ )
+ )
+ : $('dd')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.find('strong').text();
+ item.find('strong').remove();
+
+ const description = item.find('.short').html();
+ item.find('.short').remove();
+
+ return {
+ title,
+ description,
+ link: currentUrl,
+ pubDate: timezone(parseDate(`${item.parent().find('dt').text()} ${item.text()}`), +8),
+ };
+ });
+
+ return {
+ title: $('title').text().split(',')[0],
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/barronschina/namespace.ts b/lib/routes/barronschina/namespace.ts
new file mode 100644
index 00000000000000..479ae25e61c868
--- /dev/null
+++ b/lib/routes/barronschina/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '巴伦周刊中文版',
+ url: 'barronschina.com.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/baselang/index.ts b/lib/routes/baselang/index.ts
new file mode 100644
index 00000000000000..2dffe490d3c15d
--- /dev/null
+++ b/lib/routes/baselang/index.ts
@@ -0,0 +1,118 @@
+import type { Context } from 'hono';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Data, Route } from '@/types';
+import logger from '@/utils/logger';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+type WordpressPost = {
+ id: number;
+ date: string;
+ date_gmt?: string;
+ link: string;
+ title?: { rendered?: string };
+ excerpt?: { rendered?: string };
+ content?: { rendered?: string };
+ _embedded?: {
+ author?: Array<{ name?: string }>;
+ 'wp:term'?: Array>;
+ };
+};
+
+const ROOT_URL = 'https://baselang.com';
+const API_BASE = `${ROOT_URL}/wp-json/wp/v2`;
+
+// Supported categories and their WP IDs
+const CATEGORY_SLUG_TO_ID: Record = {
+ 'advanced-grammar': 5,
+ 'basic-grammar': 4,
+ company: 8,
+ confidence: 9,
+ french: 24,
+ humor: 15,
+ medellin: 23,
+ motivation: 6,
+ pronunciation: 11,
+ 'study-tips': 7,
+ 'success-stories': 14,
+ travel: 13,
+ uncategorized: 1,
+ vocabulary: 12,
+};
+
+const CATEGORY_OPTIONS = Object.keys(CATEGORY_SLUG_TO_ID).map((slug) => ({ label: slug, value: slug }));
+
+export const route: Route = {
+ path: '/blog/:category?',
+ categories: ['blog'],
+ example: '/baselang/blog',
+ parameters: {
+ category: {
+ description: 'Optional category filter',
+ options: CATEGORY_OPTIONS,
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['baselang.com/blog', 'baselang.com/blog/:category'],
+ target: '/blog/:category',
+ },
+ ],
+ name: 'Blog',
+ maintainers: ['johan456789'],
+ handler,
+};
+
+async function handler(ctx: Context): Promise {
+ const categoryParam = (ctx.req.param('category') ?? '').toLowerCase();
+ logger.debug(`BaseLang: received request, category='${categoryParam || 'all'}'`);
+
+ if (categoryParam && !Object.hasOwn(CATEGORY_SLUG_TO_ID, categoryParam)) {
+ logger.debug(`BaseLang: invalid category '${categoryParam}'`);
+ throw new InvalidParameterError(`Invalid category: ${categoryParam}. Valid categories are: ${Object.keys(CATEGORY_SLUG_TO_ID).join(', ')}`);
+ }
+
+ const searchParams: string[] = ['per_page=20', '_embed=author,wp:term'];
+ if (categoryParam) {
+ const id = CATEGORY_SLUG_TO_ID[categoryParam];
+ searchParams.push(`categories=${id}`);
+ }
+
+ const apiUrl = `${API_BASE}/posts?${searchParams.join('&')}`;
+
+ const data = await ofetch(apiUrl);
+ logger.debug(`BaseLang: fetched ${data.length} posts`);
+
+ const items = data.map((post) => ({
+ title: post.title?.rendered,
+ description: post.content?.rendered ?? post.excerpt?.rendered ?? '',
+ link: post.link,
+ pubDate: parseDate(post.date_gmt ?? post.date),
+ author: post._embedded?.author?.[0]?.name,
+ category: Array.isArray(post._embedded?.['wp:term'])
+ ? post._embedded['wp:term']
+ .flat()
+ .map((term: any) => term?.name)
+ .filter(Boolean)
+ : undefined,
+ }));
+
+ const titleSuffix = categoryParam ? ` - ${categoryParam}` : '';
+ const link = categoryParam ? `${ROOT_URL}/blog/${categoryParam}/` : `${ROOT_URL}/blog/`;
+
+ return {
+ title: `BaseLang Blog${titleSuffix}`,
+ link,
+ language: 'en',
+ item: items,
+ } as Data;
+}
diff --git a/lib/routes/baselang/namespace.ts b/lib/routes/baselang/namespace.ts
new file mode 100644
index 00000000000000..9d8a40f44c57af
--- /dev/null
+++ b/lib/routes/baselang/namespace.ts
@@ -0,0 +1,6 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BaseLang',
+ lang: 'en',
+};
diff --git a/lib/routes/bast/index.ts b/lib/routes/bast/index.ts
new file mode 100644
index 00000000000000..3b2c9e63cdf7bf
--- /dev/null
+++ b/lib/routes/bast/index.ts
@@ -0,0 +1,85 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '*',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const colPath = getSubPath(ctx).replace(/^\//, '') || '32942';
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
+
+ const rootUrl = 'https://www.bast.net.cn';
+ const currentUrl = `${rootUrl}/${Number.isNaN(colPath) ? colPath : `col/col${colPath}`}/`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ let $ = load(response.data);
+
+ $('.list-title-bif').remove();
+
+ const title = $('title').text();
+ let selection = $('a[title]');
+
+ if (selection.length === 0) {
+ $ = load($('ul.cont-list div script').first().text());
+
+ $('.list-title-bif').remove();
+
+ selection = $('a[title]');
+ }
+
+ let items = selection
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text().trim(),
+ link: item.attr('href'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (/bast\.net\.cn/.test(item.link)) {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.title = content('meta[name="ArticleTitle"]').attr('content');
+ item.author = content('meta[name="contentSource"]').attr('content');
+ item.pubDate = timezone(parseDate(content('meta[name="pubdate"]').attr('content')), +8);
+ item.category = [content('meta[name="ColumnName"]').attr('content')];
+
+ item.description = content('.arccont').html();
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bast/namespace.ts b/lib/routes/bast/namespace.ts
new file mode 100644
index 00000000000000..06cb4bbaa8df96
--- /dev/null
+++ b/lib/routes/bast/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京市科学技术协会',
+ url: 'bast.net.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bbc/index.ts b/lib/routes/bbc/index.ts
new file mode 100644
index 00000000000000..bc48c45df81d89
--- /dev/null
+++ b/lib/routes/bbc/index.ts
@@ -0,0 +1,118 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import parser from '@/utils/rss-parser';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/:site?/:channel?',
+ name: 'News',
+ maintainers: ['HenryQW', 'DIYgod', 'pseudoyu'],
+ handler,
+ example: '/bbc/world-asia',
+ parameters: {
+ site: '语言,简体或繁体中文',
+ channel: 'channel, default to `top stories`',
+ },
+ categories: ['traditional-media'],
+ description: `Provides a better reading experience (full text articles) over the official ones.
+
+ Support major channels, refer to [BBC RSS feeds](https://www.bbc.co.uk/news/10628494). Eg, \`business\` for \`https://feeds.bbci.co.uk/news/business/rss.xml\`.
+
+ - Channel contains sub-directories, such as \`https://feeds.bbci.co.uk/news/world/asia/rss.xml\`, replace \`/\` with \`-\`, \`/bbc/world-asia\`.`,
+};
+
+async function handler(ctx) {
+ let feed, title, link;
+
+ // 为了向下兼容,这里 site 对应的是中文网文档中的 lang,英文网文档中的 channel
+ // 英文网不会用到 channel
+ const { site, channel } = ctx.req.param();
+
+ if (site) {
+ switch (site.toLowerCase()) {
+ case 'chinese':
+ title = 'BBC News 中文网';
+
+ feed = await (channel ? parser.parseURL(`https://www.bbc.co.uk/zhongwen/simp/${channel}/index.xml`) : parser.parseURL('https://www.bbc.co.uk/zhongwen/simp/index.xml'));
+ break;
+
+ case 'traditionalchinese':
+ title = 'BBC News 中文網';
+
+ feed = await (channel ? parser.parseURL(`https://www.bbc.co.uk/zhongwen/trad/${channel}/index.xml`) : parser.parseURL('https://www.bbc.co.uk/zhongwen/trad/index.xml'));
+ link = 'https://www.bbc.com/zhongwen/trad';
+ break;
+
+ // default to bbc.com
+ default:
+ feed = await parser.parseURL(`https://feeds.bbci.co.uk/news/${site.split('-').join('/')}/rss.xml`);
+ title = `BBC News ${site}`;
+ link = `https://www.bbc.co.uk/news/${site.split('-').join('/')}`;
+ break;
+ }
+ } else {
+ feed = await parser.parseURL('https://feeds.bbci.co.uk/news/rss.xml');
+ title = 'BBC News Top Stories';
+ link = 'https://www.bbc.co.uk/news';
+ }
+
+ const items = await Promise.all(
+ feed.items
+ .filter((item) => item && item.link)
+ .map((item) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ const linkURL = new URL(item.link);
+ if (linkURL.hostname === 'www.bbc.com') {
+ linkURL.hostname = 'www.bbc.co.uk';
+ }
+
+ const response = await ofetch(linkURL.href, {
+ retryStatusCodes: [403],
+ });
+
+ const $ = load(response);
+
+ const path = linkURL.pathname;
+
+ let description;
+
+ switch (true) {
+ case path.startsWith('/sport'):
+ description = item.content;
+ break;
+ case path.startsWith('/sounds/play'):
+ description = item.content;
+ break;
+ case path.startsWith('/news/live'):
+ description = item.content;
+ break;
+ default:
+ description = utils.ProcessFeed($);
+ }
+
+ return {
+ title: item.title || '',
+ description: description || '',
+ pubDate: item.pubDate || new Date().toUTCString(),
+ link: item.link,
+ };
+ } catch {
+ return {} as Record;
+ }
+ })
+ )
+ );
+
+ return {
+ title,
+ link,
+ image: 'https://www.bbc.com/favicon.ico',
+ description: title,
+ item: items.filter((item) => Object.keys(item).length > 0),
+ };
+}
diff --git a/lib/routes/bbc/learningenglish.ts b/lib/routes/bbc/learningenglish.ts
new file mode 100644
index 00000000000000..e0acd8f535ee05
--- /dev/null
+++ b/lib/routes/bbc/learningenglish.ts
@@ -0,0 +1,92 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const channelMap = {
+ 'take-away-english': '随身英语',
+ 'authentic-real-english': '地道英语',
+ 'media-english': '媒体英语',
+ lingohack: '英语大破解',
+ 'english-in-a-minute': '一分钟英语',
+ 'phrasal-verbs': '短语动词',
+ 'todays-phrase': '今日短语',
+ 'q-and-a': '你问我答',
+ 'english-at-work': '白领英语',
+ storytellers: '亲子英语故事',
+};
+
+export const route: Route = {
+ name: 'Learning English',
+ maintainers: ['Blank0120'],
+ categories: ['study'],
+ handler,
+ path: '/learningenglish/:channel?',
+ example: '/bbc/learningenglish/take-away-english',
+ parameters: {
+ channel: {
+ description: '英语学习分类栏目',
+ options: Object.entries(channelMap).map(([value, label]) => ({ value, label })),
+ default: 'take-away-english',
+ },
+ },
+};
+
+async function handler(ctx: Context) {
+ // set targetURL
+ const { channel = 'take-away-english' } = ctx.req.param();
+
+ const rootURL = 'https://www.bbc.co.uk';
+ const targerURL = `${rootURL}/learningenglish/chinese/features/${channel}`;
+
+ const response = await ofetch(targerURL, { parseResponse: (txt) => txt });
+ const $ = load(response);
+
+ // get top article links
+ const firstItem: DataItem = {
+ title: $('[data-widget-index=4]').find('h2').text(),
+ link: `${rootURL}${$('[data-widget-index=4]').find('h2 a').attr('href')}`,
+ pubDate: parseDate($('[data-widget-index=4]').find('.details h3').text()),
+ };
+
+ // get rest ul article links
+ const restItems: DataItem[] = $('.threecol li')
+ .toArray()
+ .slice(0, 10)
+ .map((article) => {
+ const $article = load(article);
+
+ return {
+ title: $article('h2').text(),
+ link: `${rootURL}${$article('h2 a').attr('href')}`,
+ pubDate: parseDate($article('.details h3').text()),
+ };
+ });
+
+ // try get article content detail
+ const items: DataItem[] = await Promise.all(
+ [firstItem, ...restItems].map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link!, { parseResponse: (txt) => txt });
+
+ const $content = load(detailResponse);
+
+ item.description = $content('.widget-richtext').html() ?? undefined;
+ return item;
+ });
+ })
+ );
+
+ return {
+ title: `BBC英语学习-${channelMap[channel]}`,
+ link: targerURL,
+ item: items,
+ };
+}
diff --git a/lib/routes/bbc/namespace.ts b/lib/routes/bbc/namespace.ts
new file mode 100644
index 00000000000000..4feae697921ae8
--- /dev/null
+++ b/lib/routes/bbc/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BBC',
+ url: 'bbc.com',
+ lang: 'en',
+};
diff --git a/lib/routes/bbc/utils.ts b/lib/routes/bbc/utils.ts
new file mode 100644
index 00000000000000..e6ad2cbb83e392
--- /dev/null
+++ b/lib/routes/bbc/utils.ts
@@ -0,0 +1,35 @@
+const ProcessFeed = ($) => {
+ // by default treat it as a hybrid news with video and story-body__inner
+ let content = $('#main-content article');
+
+ if (content.length === 0) {
+ // it's a video news with video and story-body
+ content = $('div.story-body');
+ }
+
+ if (content.length === 0) {
+ // chinese version has different structure
+ content = $('main[role="main"]');
+ }
+
+ // remove useless DOMs
+ content.find('header, section, [data-testid="bbc-logo-wrapper"]').remove();
+
+ content.find('noscript').each((i, e) => {
+ $(e).parent().html($(e).html());
+ });
+
+ content.find('img').each((i, e) => {
+ if (!$(e).attr('src') && $(e).attr('srcSet')) {
+ const srcs = $(e).attr('srcSet').split(', ');
+ const lastSrc = srcs.at(-1);
+ $(e).attr('src', lastSrc.split(' ')[0]);
+ }
+ });
+
+ content.find('[data-component="media-block"] figcaption').prepend('View video in browser: ');
+
+ return content.html();
+};
+
+export default { ProcessFeed };
diff --git a/lib/routes/bbcnewslabs/namespace.ts b/lib/routes/bbcnewslabs/namespace.ts
new file mode 100644
index 00000000000000..97d970ec8f00e9
--- /dev/null
+++ b/lib/routes/bbcnewslabs/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BBC News Labs',
+ url: 'bbcnewslabs.co.uk',
+ lang: 'en',
+};
diff --git a/lib/routes/bbcnewslabs/news.ts b/lib/routes/bbcnewslabs/news.ts
new file mode 100644
index 00000000000000..267d5efdf9f4ee
--- /dev/null
+++ b/lib/routes/bbcnewslabs/news.ts
@@ -0,0 +1,57 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['programming'],
+ example: '/bbcnewslabs/news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bbcnewslabs.co.uk/'],
+ },
+ ],
+ name: 'News',
+ maintainers: ['elxy'],
+ handler,
+ url: 'bbcnewslabs.co.uk/',
+};
+
+async function handler() {
+ const rootUrl = 'https://bbcnewslabs.co.uk';
+ const response = await got({
+ method: 'get',
+ url: `${rootUrl}/news`,
+ });
+
+ const $ = load(response.data);
+
+ const items = $('a[href^="/news/20"]')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('h3[class^="thumbnail-module--thumbnailTitle--"]').text(),
+ description: item.find('span[class^="thumbnail-module--thumbnailDescription--"]').text(),
+ pubDate: parseDate(item.find('span[class^="thumbnail-module--thumbnailType--"]').text()),
+ link: rootUrl + item.attr('href'),
+ };
+ });
+
+ return {
+ title: 'News - BBC News Labs',
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bc3ts/list.ts b/lib/routes/bc3ts/list.ts
new file mode 100644
index 00000000000000..2cbdee43a13584
--- /dev/null
+++ b/lib/routes/bc3ts/list.ts
@@ -0,0 +1,70 @@
+import path from 'node:path';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import type { Media, PostResponse } from './types';
+
+export const route: Route = {
+ path: '/post/list/:sort?',
+ example: '/bc3ts/post/list',
+ parameters: {
+ sort: '排序方式,`1` 為最新,`2` 為熱門,默认為 `1`',
+ },
+ features: {
+ antiCrawler: true,
+ },
+ radar: [
+ {
+ source: ['web.bc3ts.net'],
+ },
+ ],
+ name: '動態',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+const baseUrl = 'https://web.bc3ts.net';
+
+const renderMedia = (media: Media[]) => art(path.join(__dirname, 'templates/media.art'), { media });
+
+async function handler(ctx) {
+ const { sort = '1' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+
+ const response = await ofetch('https://app.bc3ts.net/post/list/v2', {
+ headers: {
+ apikey: 'zlF+kaPfem%23we$2@90irpE*_RGjdw',
+ app_version: '3.0.28',
+ version: '2.0.0',
+ 'User-Agent': config.trueUA,
+ },
+ query: {
+ limits: limit,
+ sort_type: sort,
+ },
+ });
+
+ const items = response.data.map((p) => ({
+ title: p.title ?? p.content.split('\n')[0],
+ description: p.content.replaceAll('\n', ' ') + (p.media.length && renderMedia(p.media)),
+ link: `${baseUrl}/post/${p.id}`,
+ author: p.user.name,
+ pubDate: parseDate(p.created_time, 'x'),
+ category: p.group.name,
+ upvotes: p.like_count,
+ comments: p.comment_count,
+ }));
+
+ return {
+ title: `爆料公社${sort === '1' ? '最新' : '熱門'}動態`,
+ link: baseUrl,
+ language: 'zh-TW',
+ image: 'https://img.bc3ts.net/image/web/main/logo-white-new-2023.png',
+ icon: 'https://img.bc3ts.net/image/web/main/logo/logo_icon_6th_2024_192x192.png',
+ item: items,
+ };
+}
diff --git a/lib/routes/bc3ts/namespace.ts b/lib/routes/bc3ts/namespace.ts
new file mode 100644
index 00000000000000..bbae0f8afa272f
--- /dev/null
+++ b/lib/routes/bc3ts/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '爆料公社',
+ url: 'web.bc3ts.net',
+ categories: ['new-media'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bc3ts/templates/media.art b/lib/routes/bc3ts/templates/media.art
new file mode 100644
index 00000000000000..a0e2992fd8f0a0
--- /dev/null
+++ b/lib/routes/bc3ts/templates/media.art
@@ -0,0 +1,10 @@
+
+{{ each media m }}
+ {{ if m.type === 0 }}
+
+ {{ else if m.type === 3 }}
+
+
+
+ {{ /if }}
+{{ /each }}
diff --git a/lib/routes/bc3ts/types.ts b/lib/routes/bc3ts/types.ts
new file mode 100644
index 00000000000000..ff20611f2d2f5e
--- /dev/null
+++ b/lib/routes/bc3ts/types.ts
@@ -0,0 +1,118 @@
+interface Medal {
+ id: number;
+ name: string;
+ image: string;
+}
+
+interface HeadFrame {
+ id: number;
+ image: string;
+}
+
+interface UsingItem {
+ medal: Medal | null;
+ head_frame: HeadFrame | null;
+}
+
+interface Relationship {
+ status: number;
+ that_status: number;
+}
+
+interface User {
+ id: string;
+ name: string;
+ badge: any[];
+ level: number;
+ using_item: UsingItem;
+ relationship: Relationship;
+ boom_verified: number;
+}
+
+interface SafeSearch {
+ racy: string;
+ adult: string;
+ spoof: string;
+ medical: string;
+ violence: string;
+}
+
+export interface Media {
+ id: number;
+ type: number;
+ name: string;
+ width: number;
+ height: number;
+ cover: string | null;
+ status: string;
+ safe_search: SafeSearch;
+ unlock_item: null;
+ media_url: string;
+}
+
+interface Group {
+ id: number;
+ name: string;
+ type: number;
+ god_id: string;
+ status: number;
+ privacy: number;
+ layout_type: number;
+ share_status: number;
+ post_can_use_anonymous: boolean;
+ approve_user_permission_status: number[];
+}
+
+interface Reward {
+ money: number;
+ diamond: number;
+}
+
+interface Post {
+ id: number;
+ user: User;
+ title: string | null;
+ content: string;
+ appendix: object;
+ created_time: number;
+ expired_time: number;
+ media: Media[];
+ like: number;
+ like_count: number;
+ collection: number;
+ top: number;
+ reference_reply_count: number;
+ reference_share_count: number;
+ comment_count: number;
+ group: Group;
+ block_user: any[];
+ tag_user_index: any[];
+ reward: Reward;
+ reference: null;
+ latitude: number | null;
+ longitude: number | null;
+ unique_like_count: number;
+ unique_exposure_count: number;
+ unique_priority_point: number;
+ unique_priority_time: string | null;
+ safe_search: number;
+ unlock_type: null;
+ unlock_item: object;
+ is_unlocked: null;
+ visibility: number;
+ is_anonymous: number;
+ is_me: number;
+ comment_can_use_anonymous: number;
+ who_can_read: number;
+ poll: null;
+ activity: null;
+ poll_status: null;
+ is_cache: boolean;
+ is_editor: boolean;
+ theme_id: null;
+}
+
+export interface PostResponse {
+ code: number;
+ data: Post[];
+}
diff --git a/lib/routes/bdys/index.ts b/lib/routes/bdys/index.ts
new file mode 100644
index 00000000000000..5a112d6994970a
--- /dev/null
+++ b/lib/routes/bdys/index.ts
@@ -0,0 +1,185 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+// Visit https://www.bdys.me for the list of domains
+const allowDomains = new Set(['52bdys.com', 'bde4.icu', 'bdys01.com']);
+
+export const route: Route = {
+ path: '/:caty?/:type?/:area?/:year?/:order?',
+ categories: ['multimedia'],
+ example: '/bdys',
+ parameters: {
+ caty: '影视类型,见下表,默认为 `all` 即不限',
+ type: '资源分类,见下表,默认为 `all` 即不限',
+ area: '制片地区,见下表,默认为 `all` 即不限',
+ year: '上映时间,此处填写年份不小于2000,默认为 `all` 即不限',
+ order: '影视排序,见下表,默认为更新时间',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '首页',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `#### 资源分类
+
+| 不限 | 电影 | 电视剧 |
+| ---- | ---- | ------ |
+| all | 0 | 1 |
+
+#### 影视类型
+
+| 不限 | 动作 | 爱情 | 喜剧 | 科幻 | 恐怖 |
+| ---- | ------- | ------ | ---- | ------ | ------ |
+| all | dongzuo | aiqing | xiju | kehuan | kongbu |
+
+| 战争 | 武侠 | 魔幻 | 剧情 | 动画 | 惊悚 |
+| --------- | ----- | ------ | ------ | ------- | -------- |
+| zhanzheng | wuxia | mohuan | juqing | donghua | jingsong |
+
+| 3D | 灾难 | 悬疑 | 警匪 | 文艺 | 青春 |
+| -- | ------ | ------ | ------- | ----- | -------- |
+| 3D | zainan | xuanyi | jingfei | wenyi | qingchun |
+
+| 冒险 | 犯罪 | 纪录 | 古装 | 奇幻 | 国语 |
+| ------- | ------ | ---- | -------- | ------ | ----- |
+| maoxian | fanzui | jilu | guzhuang | qihuan | guoyu |
+
+| 综艺 | 历史 | 运动 | 原创压制 |
+| ------ | ----- | ------- | ---------- |
+| zongyi | lishi | yundong | yuanchuang |
+
+| 美剧 | 韩剧 | 国产电视剧 | 日剧 | 英剧 | 德剧 |
+| ----- | ----- | ---------- | ---- | ------ | ---- |
+| meiju | hanju | guoju | riju | yingju | deju |
+
+| 俄剧 | 巴剧 | 加剧 | 西剧 | 意大利剧 | 泰剧 |
+| ---- | ---- | ----- | ------- | -------- | ----- |
+| eju | baju | jiaju | spanish | yidaliju | taiju |
+
+| 港台剧 | 法剧 | 澳剧 |
+| --------- | ---- | ---- |
+| gangtaiju | faju | aoju |
+
+#### 制片地区
+
+| 大陆 | 中国香港 | 中国台湾 |
+| ---- | -------- | -------- |
+
+| 美国 | 英国 | 日本 | 韩国 | 法国 |
+| ---- | ---- | ---- | ---- | ---- |
+
+| 印度 | 德国 | 西班牙 | 意大利 | 澳大利亚 |
+| ---- | ---- | ------ | ------ | -------- |
+
+| 比利时 | 瑞典 | 荷兰 | 丹麦 | 加拿大 | 俄罗斯 |
+| ------ | ---- | ---- | ---- | ------ | ------ |
+
+#### 影视排序
+
+| 更新时间 | 豆瓣评分 |
+| -------- | -------- |
+| 0 | 1 |`,
+};
+
+async function handler(ctx) {
+ const caty = ctx.req.param('caty') || 'all';
+ const type = ctx.req.param('type') || 'all';
+ const area = ctx.req.param('area') || 'all';
+ const year = ctx.req.param('year') || 'all';
+ const order = ctx.req.param('order') || '0';
+
+ const site = ctx.req.query('domain') || 'bdys01.com';
+ if (!config.feature.allow_user_supply_unsafe_domain && !allowDomains.has(new URL(`https://${site}`).hostname)) {
+ throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
+ }
+
+ const rootUrl = `https://www.${site}`;
+ const currentUrl = `${rootUrl}/s/${caty}?${type === 'all' ? '' : '&type=' + type}${area === 'all' ? '' : '&area=' + area}${year === 'all' ? '' : '&year=' + year}&order=${order}`;
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let jsessionid = '';
+
+ const list = $('.card-body .card a')
+ .slice(0, 15)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const link = item.attr('href').split(';jsessionid=');
+ jsessionid = link[1];
+ const next = item.next();
+ return {
+ title: next.find('h3').text(),
+ link: `${rootUrl}${link[0]}`,
+ pubDate: parseDate(next.find('.text-muted').text()),
+ };
+ });
+
+ const headers = {
+ cookie: `JSESSIONID=${jsessionid}`,
+ };
+
+ const items = await pMap(
+ list,
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ headers,
+ });
+ const downloadResponse = await got({
+ method: 'get',
+ url: `${rootUrl}/downloadInfo/list?mid=${item.link.split('/')[4].split('.')[0]}`,
+ headers,
+ });
+ const content = load(detailResponse.data);
+
+ content('svg').remove();
+ const torrents = content('.download-list .list-group');
+
+ item.description = art(path.join(__dirname, 'templates/desc.art'), {
+ info: content('.row.mt-3').html(),
+ synopsis: content('#synopsis').html(),
+ links: downloadResponse.data,
+ torrents: torrents.html(),
+ });
+
+ item.pubDate = timezone(parseDate(content('.bg-purple-lt').text().replace('更新时间:', '')), +8);
+ item.guid = `${item.link}#${content('.card h1').text()}`;
+
+ item.enclosure_url = torrents.html() ? `${rootUrl}${torrents.find('a').first().attr('href')}` : downloadResponse.data.pop().url;
+ item.enclosure_type = 'application/x-bittorrent';
+
+ return item;
+ }),
+ { concurrency: 1 }
+ );
+
+ return {
+ title: '哔嘀影视',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bdys/namespace.ts b/lib/routes/bdys/namespace.ts
new file mode 100644
index 00000000000000..78e9a01b3cdb1c
--- /dev/null
+++ b/lib/routes/bdys/namespace.ts
@@ -0,0 +1,10 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '哔嘀影视',
+ url: '52bdys.com',
+ description: `::: tip
+哔嘀影视有多个备用域名,路由默认使用域名 \`https://bdys01.com\`。若该域名无法访问,可以通过在路由最后加上 \`?domain=<域名>\` 指定路由访问的域名。如指定备用域名为 \`https://bde4.icu\`,则在所有哔嘀影视路由最后加上 \`?domain=bde4.icu\` 即可,此时路由为 [\`/bdys?domain=bde4.icu\`](https://rsshub.app/bdys?domain=bde4.icu)
+:::`,
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/bdys/templates/desc.art b/lib/routes/bdys/templates/desc.art
similarity index 100%
rename from lib/v2/bdys/templates/desc.art
rename to lib/routes/bdys/templates/desc.art
diff --git a/lib/routes/behance/namespace.ts b/lib/routes/behance/namespace.ts
new file mode 100644
index 00000000000000..f17e9309c66332
--- /dev/null
+++ b/lib/routes/behance/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Behance',
+ url: 'www.behance.net',
+ lang: 'en',
+};
diff --git a/lib/routes/behance/queries.ts b/lib/routes/behance/queries.ts
new file mode 100644
index 00000000000000..d8101162c0e3cc
--- /dev/null
+++ b/lib/routes/behance/queries.ts
@@ -0,0 +1,1015 @@
+export const getProfileProjectsAndSelectionsQuery = `query GetProfileProjectsAndSections($username: String, $after: String) {
+ user(username: $username) {
+ hasPortfolio
+ profileSections {
+ ...profileSectionFields
+ }
+ profileProjects(first: 12, after: $after) {
+ pageInfo {
+ endCursor
+ hasNextPage
+ }
+ nodes {
+ __typename
+ adminFlags {
+ mature_lock
+ privacy_lock
+ dmca_lock
+ flagged_lock
+ privacy_violation_lock
+ trademark_lock
+ spam_lock
+ eu_ip_lock
+ }
+ canBeBoosted
+ colors {
+ r
+ g
+ b
+ }
+ covers {
+ size_202 {
+ url
+ }
+ size_404 {
+ url
+ }
+ size_808 {
+ url
+ }
+ }
+ features {
+ url
+ name
+ featuredOn
+ ribbon {
+ image
+ image2x
+ image3x
+ }
+ }
+ fields {
+ id
+ label
+ slug
+ url
+ }
+ hasMatureContent
+ id
+ isBoosted
+ isFeatured
+ isHiddenFromWorkTab
+ isMatureReviewSubmitted
+ isMonaReported
+ isOwner
+ isFounder
+ isPinnedToSubscriptionOverview
+ isPrivate
+ sourceFiles {
+ ...sourceFileWithCoverFields
+ }
+ matureAccess
+ modifiedOn
+ name
+ owners {
+ ...OwnerFields
+ images {
+ size_50 {
+ url
+ }
+ }
+ }
+ premium
+ publishedOn
+ privacyLevel
+ profileSectionId
+ stats {
+ appreciations {
+ all
+ }
+ views {
+ all
+ }
+ comments {
+ all
+ }
+ }
+ slug
+ url
+ }
+ }
+ }
+ viewer {
+ flags {
+ hasClickedOnAddProfileSectionButton
+ hasSeenProfilePortfolioUpsellModal
+ hasSeenCreatorProIntroModal
+ lastSeenMarketingPopupTimestamp
+ onboardedAsHirer
+ }
+ }
+ }
+
+ fragment sourceFileWithCoverFields on SourceFile {
+ __typename
+ sourceFileId
+ projectId
+ userId
+ title
+ assetId
+ renditionUrl
+ mimeType
+ size
+ category
+ licenseType
+ unitAmount
+ currency
+ tier
+ hidden
+ extension
+ hasUserPurchased
+ description
+ cover {
+ coverUrl
+ coverX
+ coverY
+ coverScale
+ }
+ }
+
+ fragment OwnerFields on User {
+ displayName
+ hasPremiumAccess
+ id
+ isFollowing
+ isProfileOwner
+ location
+ locationUrl
+ url
+ username
+ isMessageButtonVisible
+ availabilityInfo {
+ availabilityTimeline
+ isAvailableFullTime
+ isAvailableFreelance
+ hiringTimeline {
+ key
+ label
+ }
+ }
+ creatorPro {
+ isActive
+ initialSubscriptionDate
+ }
+ }
+
+ fragment profileSectionFields on ProfileSection {
+ id
+ isDefault
+ name
+ order
+ projectCount
+ userId
+ }`;
+
+export const getAppreciatedQuery = `query GetAppreciatedProjects($username: String, $after: String) {
+ user(username: $username) {
+ appreciatedProjects(first: 24, after: $after) {
+ nodes {
+ __typename
+ colors {
+ r
+ g
+ b
+ }
+ covers {
+ size_202 {
+ url
+ }
+ size_404 {
+ url
+ }
+ size_808 {
+ url
+ }
+ }
+ slug
+ id
+ name
+ url
+ owners {
+ ...OwnerFields
+ images {
+ size_50 {
+ url
+ }
+ }
+ }
+ stats {
+ appreciations {
+ all
+ }
+ views {
+ all
+ }
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
+ }
+ }
+ }
+ }
+ fragment OwnerFields on User {
+ displayName
+ hasPremiumAccess
+ id
+ isFollowing
+ isProfileOwner
+ location
+ locationUrl
+ url
+ username
+ isMessageButtonVisible
+ availabilityInfo {
+ availabilityTimeline
+ isAvailableFullTime
+ isAvailableFreelance
+ hiringTimeline {
+ key
+ label
+ }
+ }
+ creatorPro {
+ isActive
+ initialSubscriptionDate
+ }
+ }`;
+
+export const getProjectPageQuery = `query ProjectPage($projectId: ProjectId!, $projectPassword: String) {
+ viewer {
+ ...Project_Viewer
+ }
+ project(id: $projectId) {
+ id
+ slug
+ premium
+ isPrivate
+ isOwner
+ canvasWidth
+ embedTag
+ url
+ stylesInline
+ ...Project_Project
+ ...EmbedShareModal_Project
+ creator {
+ hasPremiumAccess
+ }
+ owners {
+ id
+ username
+ displayName
+ }
+ allModules(projectPassword: $projectPassword) {
+ __typename
+ }
+ }
+ }
+ fragment Avatar_UserImageSizes on UserImageSizes {
+ ...AvatarImage_UserImageSizes
+ }
+ fragment Avatar_User on User {
+ id
+ url
+ images {
+ ...Avatar_UserImageSizes
+ }
+ creatorPro {
+ isActive
+ }
+ ...CreatorProBadge_User
+ }
+ fragment AvatarImage_UserImageSizes on UserImageSizes {
+ allAvailable {
+ url
+ width
+ type
+ }
+ }
+ fragment CreatorProBadge_User on User {
+ creatorPro {
+ initialSubscriptionDate
+ }
+ }
+ fragment EmbedShareModal_Project on Project {
+ ...EmbedShareProjectCover_Project
+ }
+ fragment Feature_ProjectFeature on ProjectFeature {
+ url
+ name
+ featuredOn
+ ribbon {
+ image
+ image2x
+ }
+ }
+ fragment HireOverlay_User on User {
+ id
+ firstName
+ isResponsiveToHiring
+ isMessageButtonVisible
+ ...Avatar_User
+ ...WideMessageButton_User
+ availabilityInfo {
+ hiringTimeline {
+ key
+ }
+ }
+ }
+ fragment ProjectInfoBox_User on User {
+ id
+ displayName
+ url
+ isFollowing
+ ...MultipleOwners_User
+ }
+ fragment ProjectInfoBox_Project on Project {
+ id
+ name
+ url
+ isOwner
+ covers {
+ allAvailable {
+ url
+ width
+ type
+ }
+ }
+ owners {
+ ...ProjectInfoBox_User
+ }
+ }
+ fragment SourceAssetsPane_SourceFile on SourceFile {
+ ...SourceFileRowsContainer_SourceFile
+ }
+ fragment SourceFileRowsContainer_SourceFile on SourceFile {
+ tier
+ hasUserPurchased
+ ...SourceFileRow_SourceFile
+ }
+ fragment UserInfo_User on User {
+ displayName
+ id
+ location
+ locationUrl
+ url
+ country
+ isProfileOwner
+ city
+ state
+ creatorPro {
+ isActive
+ }
+ ...Avatar_User
+ }
+ fragment UsersTooltip_User on User {
+ displayName
+ id
+ isFollowing
+ isProfileOwner
+ availabilityInfo {
+ isAvailableFullTime
+ isAvailableFreelance
+ }
+ ...UserInfo_User
+ }
+ fragment DominantColor_Colors on Colors {
+ r
+ g
+ b
+ }
+ fragment Tools_Tool on Tool {
+ id
+ title
+ url
+ backgroundImage {
+ size_original {
+ url
+ }
+ }
+ backgroundColor
+ synonym {
+ tagId
+ name
+ title
+ downloadUrl
+ iconUrl
+ }
+ }
+ fragment HireMeForm_UserAvailabilityInfo on UserAvailabilityInfo {
+ isAvailableFreelance
+ isAvailableFullTime
+ budgetMin
+ currency
+ compensationMin
+ hiringTimeline {
+ key
+ }
+ }
+ fragment MessageDialogManager_User on User {
+ id
+ displayName
+ username
+ images {
+ ...AvatarImage_UserImageSizes
+ }
+ creatorPro {
+ isActive
+ }
+ ...SendRegularMessagePane_User
+ ...ServicesPane_User
+ }
+ fragment SendRegularMessagePane_User on User {
+ displayName
+ availabilityInfo {
+ ...HireMeForm_UserAvailabilityInfo
+ }
+ }
+ fragment ServicesPane_User on User {
+ id
+ displayName
+ ...InquireServiceModal_User
+ ...ViewServiceInfoModal_User
+ }
+ fragment MessageManager_User on User {
+ id
+ isMessageButtonVisible
+ displayName
+ username
+ ...MessageDialogManager_User
+ availabilityInfo {
+ availabilityTimeline
+ isAvailableFullTime
+ isAvailableFreelance
+ hiringTimeline {
+ key
+ }
+ }
+ }
+ fragment WideMessageButton_User on User {
+ firstName
+ ...MessageManager_User
+ }
+ fragment AppreciationNotification_Viewer on Viewer {
+ url
+ }
+ fragment Avatar_Project_User on User {
+ id
+ ...Avatar_User
+ }
+ fragment ImageElement_ImageModule on ImageModule {
+ id
+ altText
+ height
+ width
+ fullBleed
+ imageSizes {
+ allAvailable(type: [JPG, WEBP]) {
+ url
+ width
+ height
+ type
+ }
+ size_max_158 {
+ url
+ }
+ }
+ }
+ fragment Module_ProjectModule on ProjectModule {
+ ...Embed_ProjectModule
+ ... on AudioModule {
+ id
+ caption
+ captionAlignment
+ }
+ ... on VideoModule {
+ id
+ caption
+ captionAlignment
+ }
+ ... on EmbedModule {
+ id
+ caption
+ captionAlignment
+ }
+ ... on ImageModule {
+ id
+ ...ThreeD_ImageModule
+ ...SingleImage_ImageModule
+ ...Image_ImageModule
+ }
+ ... on MediaCollectionModule {
+ id
+ caption: captionPlain
+ captionAlignment
+ components {
+ ...Actions_MediaCollectionComponent
+ filename
+ flexHeight
+ flexWidth
+ height
+ width
+ id
+ imageSizes {
+ allAvailable(type: [JPG, WEBP]) {
+ url
+ width
+ height
+ type
+ }
+ }
+ }
+ fullBleed
+ }
+ ... on TextModule {
+ id
+ text
+ alignment
+ }
+ }
+ fragment MultipleOwners_User on User {
+ ...UsersTooltip_User
+ }
+ fragment MultipleOwners_Project on Project {
+ id
+ name
+ }
+ fragment Project_Owners_User on User {
+ id
+ displayName
+ username
+ firstName
+ url
+ isFollowing
+ isMessageButtonVisible
+ isCreatorPro
+ creatorPro {
+ isActive
+ }
+ ...UsersTooltip_User
+ ...MultipleOwners_User
+ ...Avatar_Project_User
+ ...Avatar_User
+ ...HireOverlay_User
+ }
+ fragment Project_Creator_User on User {
+ id
+ isProfileOwner
+ isFollowing
+ hasPremiumAccess
+ hasAllowEmbeds
+ url
+ ...PaneHeader_User
+ }
+ fragment Project_ProjectCoverImageSizes on ProjectCoverImageSizes {
+ allAvailable {
+ url
+ width
+ type
+ }
+ }
+ fragment Project_Tool on Tool {
+ ...Tools_Tool
+ }
+ fragment Project_SourceFile on SourceFile {
+ ...SourceFileRowsContainer_SourceFile
+ ...SourceFilesProjectOverlay_SourceFile
+ ...ProjectExtras_SourceFile
+ }
+ fragment Project_ProjectFeature on ProjectFeature {
+ ...Feature_ProjectFeature
+ }
+ fragment Project_Project on Project {
+ id
+ slug
+ name
+ url
+ description
+ tags {
+ id
+ title
+ }
+ privacyLevel
+ matureAccess
+ canvasWidth
+ isOwner
+ hasMatureContent
+ isPrivate
+ publishedOn
+ canBeAddedToMoodboard
+ isMonaReported
+ isAppreciated
+ projectCTA {
+ ctaType
+ link {
+ description
+ url
+ title
+ }
+ isDefaultCTA
+ }
+ ...MultipleOwners_Project
+ ...ProjectInfoBox_Project
+ ...ProjectLightbox_Project
+ ...ProjectExtras_Project
+ covers {
+ ...Project_ProjectCoverImageSizes
+ }
+ sourceFiles {
+ ...Project_SourceFile
+ }
+ creator {
+ ...Project_Creator_User
+ }
+ owners {
+ ...Project_Owners_User
+ }
+ tools {
+ ...Project_Tool
+ }
+ allModules(projectPassword: $projectPassword) {
+ ... on AudioModule {
+ id
+ fullBleed
+ caption
+ }
+ ... on EmbedModule {
+ id
+ caption
+ }
+ ... on ImageModule {
+ id
+ fullBleed
+ caption
+ imageSizes {
+ size_disp {
+ url
+ }
+ size_disp_still {
+ url
+ }
+ }
+ ...Actions_ImageModule
+ }
+ ... on MediaCollectionModule {
+ id
+ fullBleed
+ caption: captionPlain
+ components {
+ id
+ imageSizes {
+ size_disp {
+ url
+ }
+ size_disp_still {
+ url
+ }
+ }
+ ...Actions_MediaCollectionComponent
+ }
+ }
+ ... on TextModule {
+ id
+ fullBleed
+ }
+ ... on VideoModule {
+ id
+ fullBleed
+ caption
+ }
+ ...Module_ProjectModule
+ }
+ aeroData {
+ externalUrl
+ }
+ adminNotices {
+ title
+ body
+ isReviewable
+ }
+ license {
+ license
+ }
+ features {
+ ...Project_ProjectFeature
+ }
+ stats {
+ appreciations {
+ all
+ }
+ views {
+ all
+ }
+ }
+ styles {
+ spacing {
+ projectTopMargin
+ }
+ }
+ colors {
+ r
+ g
+ b
+ }
+ }
+ fragment Project_Viewer on Viewer {
+ stats {
+ appreciations
+ }
+ pulsePoints {
+ displayFollow
+ displayAppreciate
+ }
+ flags {
+ hasSeenCreatorProIntroModal
+ onboardedAsHirer
+ }
+ creatorPro {
+ isActive
+ }
+ createdOn
+ ...AppreciationNotification_Viewer
+ ...ProjectExtras_Viewer
+ }
+ fragment ProjectComments_Viewer on Viewer {
+ ...ProjectCommentInput_Viewer
+ id
+ }
+ fragment ProjectCommentInput_Viewer on Viewer {
+ url
+ images {
+ size_50 {
+ url
+ }
+ }
+ }
+ fragment ProjectExtras_SourceFile on SourceFile {
+ ...ProjectInfo_SourceFile
+ }
+ fragment ProjectExtras_Project on Project {
+ isCommentingAllowed
+ }
+ fragment ProjectExtras_Viewer on Viewer {
+ ...ProjectInfo_Viewer
+ }
+ fragment ProjectInfo_SourceFile on SourceFile {
+ ...SourceAssetsPane_SourceFile
+ }
+ fragment ProjectInfo_Viewer on Viewer {
+ id
+ ...ProjectComments_Viewer
+ }
+ fragment ProjectLightbox_Project on Project {
+ id
+ slug
+ isOwner
+ ...ProjectInfoBox_Project
+ }
+ fragment SourceFilesProjectOverlay_SourceFile on SourceFile {
+ hasUserPurchased
+ ...SourceFileRow_SourceFile
+ }
+ fragment Video_VideoDisplay on VideoModule {
+ captionPlain
+ embed
+ height
+ id
+ width
+ }
+ fragment Actions_ImageModule on ImageModule {
+ id
+ hasCaiData
+ projectId
+ src
+ width
+ projectId
+ exifData {
+ lens {
+ ...Actions_exifDataValue
+ }
+ software {
+ ...Actions_exifDataValue
+ }
+ makeAndModel {
+ ...Actions_exifDataValue
+ }
+ focalLength {
+ ...Actions_exifDataValue
+ }
+ iso {
+ ...Actions_exifDataValue
+ }
+ location {
+ ...Actions_exifDataValue
+ }
+ flash {
+ ...Actions_exifDataValue
+ }
+ exposureMode {
+ ...Actions_exifDataValue
+ }
+ shutterSpeed {
+ ...Actions_exifDataValue
+ }
+ aperture {
+ ...Actions_exifDataValue
+ }
+ }
+ }
+ fragment Actions_exifDataValue on exifDataValue {
+ id
+ searchValue
+ label
+ value
+ }
+ fragment Actions_MediaCollectionComponent on MediaCollectionComponent {
+ id
+ }
+ fragment Audio_AudioModule on AudioModule {
+ captionPlain
+ embed
+ fullBleed
+ id
+ }
+ fragment Embed_ProjectModule on ProjectModule {
+ ... on EmbedModule {
+ ...ExternalEmbed_EmbedModule
+ }
+ ... on AudioModule {
+ isDoneProcessing
+ ...Audio_AudioModule
+ }
+ ... on VideoModule {
+ isDoneProcessing
+ ...Video_VideoModule
+ }
+ }
+ fragment ExternalEmbed_EmbedModule on EmbedModule {
+ captionPlain
+ fluidEmbed
+ embedModuleFullBleed: fullBleed
+ height
+ id
+ originalEmbed
+ originalHeight
+ originalWidth
+ width
+ widthUnit
+ }
+ fragment Image_ImageModule on ImageModule {
+ id
+ caption
+ captionAlignment
+ fullBleed
+ height
+ width
+ altText
+ ...Actions_ImageModule
+ ...ImageElement_ImageModule
+ }
+ fragment SingleImage_ImageModule on ImageModule {
+ id
+ caption
+ captionAlignment
+ fullBleed
+ height
+ width
+ ...Actions_ImageModule
+ ...ImageElement_ImageModule
+ }
+ fragment ThreeD_ImageModule on ImageModule {
+ id
+ altText
+ threeDData {
+ iframeUrl
+ }
+ }
+ fragment Video_VideoModule on VideoModule {
+ fullBleed
+ id
+ ...Video_VideoDisplay
+ }
+ fragment Avatar_EmbedFragment on User {
+ id
+ images {
+ allAvailable {
+ url
+ width
+ }
+ }
+ displayName
+ }
+ fragment EmbedShareProjectCover_SourceFile on SourceFile {
+ ...PaidAndFreeAssetsCountBadge_Embed_SourceFile
+ }
+ fragment EmbedShareProjectCover_User on User {
+ ...Avatar_EmbedFragment
+ }
+ fragment EmbedShareProjectCover_Project on Project {
+ id
+ isPrivate
+ isPublished
+ hasMatureContent
+ creator {
+ hasAllowEmbeds
+ }
+ colors {
+ ...DominantColor_Colors
+ }
+ sourceFiles {
+ ...EmbedShareProjectCover_SourceFile
+ }
+ name
+ url
+ covers {
+ allAvailable {
+ url
+ width
+ type
+ }
+ size_original_webp {
+ url
+ width
+ type
+ }
+ }
+ owners {
+ url
+ ...EmbedShareProjectCover_User
+ }
+ }
+ fragment PaidAndFreeAssetsCountBadge_Embed_SourceFile on SourceFile {
+ unitAmount
+ tier
+ hidden
+ }
+ fragment ViewServiceInfoModal_User on User {
+ id
+ displayName
+ url
+ images {
+ size_50 {
+ url
+ }
+ }
+ creatorPro {
+ isActive
+ }
+ ...CreatorProBadge_User
+ }
+ fragment InquireServiceModal_User on User {
+ id
+ displayName
+ images {
+ size_50 {
+ url
+ }
+ }
+ availabilityInfo {
+ hiringTimeline {
+ key
+ }
+ }
+ }
+ fragment SourceFileRow_SourceFile on SourceFile {
+ assetId
+ cover {
+ coverUrl
+ }
+ title
+ extension
+ currency
+ unitAmount
+ renditionUrl
+ hasUserPurchased
+ tier
+ sourceFileId
+ projectId
+ mimeType
+ category
+ licenseType
+ size
+ }
+ fragment PaneHeader_User on User {
+ id
+ url
+ displayName
+ images {
+ allAvailable {
+ width
+ url
+ type
+ }
+ }
+ }`;
diff --git a/lib/routes/behance/templates/description.art b/lib/routes/behance/templates/description.art
new file mode 100644
index 00000000000000..698a6ff6b4c254
--- /dev/null
+++ b/lib/routes/behance/templates/description.art
@@ -0,0 +1,23 @@
+{{ if description.length }}
+ {{ description }}
+{{ /if }}
+
+{{ each modules module }}
+ {{ if module.__typename === 'ImageModule' }}
+
+
+ {{ if module.caption.length }}{{ module.caption }} {{ /if }}
+
+ {{ else if module.__typename === 'TextModule' }}
+ {{@ module.text }}
+ {{ else if module.__typename === 'MediaCollectionModule' }}
+ {{ each module.components comp }}
+
+ {{ /each }}
+ {{ else if module.__typename === 'EmbedModule' }}
+ {{@ module.fluidEmbed || module.originalEmbed }}
+ {{ else }}
+ UNHANDLED MODULE: {{ module.__typename }}
+ {{ /if }}
+
+{{ /each }}
diff --git a/lib/routes/behance/user.ts b/lib/routes/behance/user.ts
new file mode 100644
index 00000000000000..1d76c2b9228c51
--- /dev/null
+++ b/lib/routes/behance/user.ts
@@ -0,0 +1,123 @@
+import crypto from 'node:crypto';
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { getAppreciatedQuery, getProfileProjectsAndSelectionsQuery, getProjectPageQuery } from './queries';
+
+export const route: Route = {
+ path: '/:user/:type?',
+ categories: ['design'],
+ view: ViewType.Pictures,
+ example: '/behance/mishapetrick',
+ parameters: {
+ user: 'username',
+ type: {
+ description: 'type',
+ options: [
+ { value: 'projects', label: 'projects' },
+ { value: 'appreciated', label: 'appreciated' },
+ ],
+ default: 'projects',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'User Works',
+ maintainers: ['MisteryMonster'],
+ handler,
+ description: `Behance user's profile URL, like [https://www.behance.net/mishapetrick](https://www.behance.net/mishapetrick) the username will be \`mishapetrick\`。`,
+};
+
+const getUserProfile = async (nodes, user) =>
+ (await cache.tryGet(`behance:profile:${user}`, () => {
+ const profile = nodes.flatMap((item) => item.owners).find((owner) => owner.username === user);
+
+ return Promise.resolve({
+ displayName: profile.displayName,
+ id: profile.id,
+ link: profile.url,
+ image: profile.images.size_50.url.replace('/user/50/', '/user/source/'),
+ });
+ })) as { displayName: string; id: string; link: string; image: string };
+
+async function handler(ctx) {
+ const { user, type = 'projects' } = ctx.req.param();
+
+ const uuid = crypto.randomUUID();
+ const headers = {
+ Cookie: `gk_suid=${Math.random().toString().slice(2, 10)}, gki=; originalReferrer=; bcp=${uuid}`,
+ 'X-BCP': uuid,
+ 'X-Requested-With': 'XMLHttpRequest',
+ };
+
+ const response = await ofetch('https://www.behance.net/v3/graphql', {
+ method: 'POST',
+ headers,
+ body: {
+ query: type === 'projects' ? getProfileProjectsAndSelectionsQuery : getAppreciatedQuery,
+ variables: {
+ username: user,
+ after: '',
+ },
+ },
+ });
+
+ const nodes = type === 'projects' ? response.data.user.profileProjects.nodes : response.data.user.appreciatedProjects.nodes;
+ const list = nodes.map((item) => ({
+ title: item.name,
+ link: item.url,
+ author: item.owners.map((owner) => owner.displayName).join(', '),
+ image: item.covers.size_202.url.replace('/202/', '/source/'),
+ pubDate: item.publishedOn ? parseDate(item.publishedOn, 'X') : undefined,
+ category: item.fields?.map((field) => field.label.toLowerCase()),
+ projectId: item.id,
+ }));
+
+ const profile = await getUserProfile(nodes, user);
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch('https://www.behance.net/v3/graphql', {
+ method: 'POST',
+ headers,
+ body: {
+ query: getProjectPageQuery,
+ variables: {
+ projectId: item.projectId,
+ },
+ },
+ });
+ const project = response.data.project;
+
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ description: project.description,
+ modules: project.allModules,
+ });
+ item.category = [...new Set([...(item.category || []), ...(project.tags?.map((tag) => tag.title.toLowerCase()) || [])])];
+ item.pubDate = item.pubDate || (project.publishedOn ? parseDate(project.publishedOn, 'X') : undefined);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${profile.displayName}'s ${type}`,
+ link: `https://www.behance.net/${user}/${type}`,
+ image: profile.image,
+ item: items,
+ };
+}
diff --git a/lib/routes/beijingprice/index.ts b/lib/routes/beijingprice/index.ts
new file mode 100644
index 00000000000000..519c6f677f255a
--- /dev/null
+++ b/lib/routes/beijingprice/index.ts
@@ -0,0 +1,188 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx) => {
+ const { category = 'jgzx/xwzx' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 15;
+
+ const rootUrl = 'https://www.beijingprice.cn';
+ const currentUrl = new URL(category.endsWith('/') ? category : `${category}/`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('div.jgzx.rightcontent ul li')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a');
+ const link = a.prop('href');
+ const msg = a.prop('msg');
+
+ const title = a.text()?.trim() ?? a.prop('title');
+
+ let enclosureUrl;
+ let enclosureType;
+
+ if (msg) {
+ const parsedMsg = JSON.parse(msg);
+ enclosureUrl = new URL(`${parsedMsg.path}${parsedMsg.fileName}`, rootUrl).href;
+ enclosureType = `application/${parsedMsg.suffix}`;
+ }
+
+ return {
+ title,
+ pubDate: parseDate(item.contents().last().text()),
+ link: enclosureUrl ?? (link.startsWith('http') ? link : new URL(link, rootUrl).href),
+ language,
+ enclosure_url: enclosureUrl,
+ enclosure_type: enclosureType,
+ enclosure_title: enclosureUrl ? title : undefined,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (!item.link.includes('www.beijingprice.cn') || item.link.endsWith('.pdf')) {
+ return item;
+ }
+
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ const title = $$('p.title').text().trim();
+ const description = $$('div.news-content').html();
+ const fromSplits = $$('p.from')
+ .text()
+ .split(/发布时间:/);
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = fromSplits?.length === 0 ? item.pubDate : parseDate(fromSplits?.pop() ?? '', 'YYYY年MM月DD日');
+ item.category = $$('div.map a')
+ .toArray()
+ .map((c) => $$(c).text())
+ .slice(1);
+ item.author = fromSplits?.[0]?.replace(/来源:/, '') ?? undefined;
+ item.content = {
+ html: description,
+ text: $$('div.news-content').text(),
+ };
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const image = new URL($('a.header-logo img').prop('src'), rootUrl).href;
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('meta[name="keywords"]').prop('content'),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: '资讯',
+ url: 'beijingprice.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/beijingprice/jgzx/xwzx',
+ parameters: { category: '分类,默认为 `jgzx/xwzx` 即新闻资讯,可在对应分类页 URL 中找到' },
+ description: `::: tip
+ 若订阅 [新闻资讯](https://www.beijingprice.cn/jgzx/xwzx/),网址为 \`https://www.beijingprice.cn/jgzx/xwzx/\`。截取 \`https://beijingprice.cn/\` 到末尾 \`/\` 的部分 \`jgzx/xwzx\` 作为参数填入,此时路由为 [\`/beijingprice/jgzx/xwzx\`](https://rsshub.app/beijingprice/jgzx/xwzx)。
+:::
+
+#### [价格资讯](https://www.beijingprice.cn/jgzx/xwzx/)
+
+| [新闻资讯](https://www.beijingprice.cn/jgzx/xwzx/) | [工作动态](https://www.beijingprice.cn/jgzx/gzdt/) | [各区动态](https://www.beijingprice.cn/jgzx/gqdt/) | [通知公告](https://www.beijingprice.cn/jgzx/tzgg/) | [价格早报](https://www.beijingprice.cn/jgzx/jgzb/) |
+| ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ |
+| [jgzx/xwzx](https://rsshub.app/beijingprice/jgzx/xwzx) | [jgzx/gzdt](https://rsshub.app/beijingprice/jgzx/gzdt) | [jgzx/gqdt](https://rsshub.app/beijingprice/jgzx/gqdt) | [jgzx/tzgg](https://rsshub.app/beijingprice/jgzx/tzgg) | [jgzx/jgzb](https://rsshub.app/beijingprice/jgzx/jgzb) |
+
+#### [综合信息](https://www.beijingprice.cn/zhxx/cbjs/)
+
+| [价格听证](https://www.beijingprice.cn/zhxx/jgtz/) | [价格监测定点单位名单](https://www.beijingprice.cn/zhxx/jgjcdddwmd/) | [部门预算决算](https://www.beijingprice.cn/bmys/) |
+| ------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------------- |
+| [zhxx/jgtz](https://rsshub.app/beijingprice/zhxx/jgtz) | [zhxx/jgjcdddwmd](https://rsshub.app/beijingprice/zhxx/jgjcdddwmd) | [bmys](https://rsshub.app/beijingprice/bmys) |
+ `,
+ categories: ['government'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['beijingprice.cn/:category?'],
+ target: (params) => {
+ const category = params.category;
+
+ return `/beijingprice${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '价格资讯 - 新闻资讯',
+ source: ['beijingprice.cn/jgzx/xwzx/'],
+ target: '/jgzx/xwzx',
+ },
+ {
+ title: '价格资讯 - 工作动态',
+ source: ['beijingprice.cn/jgzx/gzdt/'],
+ target: '/jgzx/gzdt',
+ },
+ {
+ title: '价格资讯 - 各区动态',
+ source: ['beijingprice.cn/jgzx/gqdt/'],
+ target: '/jgzx/gqdt',
+ },
+ {
+ title: '价格资讯 - 通知公告',
+ source: ['beijingprice.cn/jgzx/tzgg/'],
+ target: '/jgzx/tzgg',
+ },
+ {
+ title: '价格资讯 - 价格早报',
+ source: ['beijingprice.cn/jgzx/jgzb/'],
+ target: '/jgzx/jgzb',
+ },
+ {
+ title: '综合信息 - 价格听证',
+ source: ['beijingprice.cn/zhxx/jgtz/'],
+ target: '/zhxx/jgtz',
+ },
+ {
+ title: '综合信息 - 价格监测定点单位名单',
+ source: ['beijingprice.cn/zhxx/jgjcdddwmd/'],
+ target: '/zhxx/jgjcdddwmd',
+ },
+ {
+ title: '综合信息 - 部门预算决算',
+ source: ['beijingprice.cn/bmys/'],
+ target: '/bmys',
+ },
+ ],
+};
diff --git a/lib/routes/beijingprice/namespace.ts b/lib/routes/beijingprice/namespace.ts
new file mode 100644
index 00000000000000..60a18476ab8541
--- /dev/null
+++ b/lib/routes/beijingprice/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京价格',
+ url: 'beijingprice.cn',
+ categories: ['government'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bellroy/namespace.ts b/lib/routes/bellroy/namespace.ts
new file mode 100644
index 00000000000000..3833468b3e8b0a
--- /dev/null
+++ b/lib/routes/bellroy/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bellroy',
+ url: 'bellroy.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bellroy/new-releases.ts b/lib/routes/bellroy/new-releases.ts
new file mode 100644
index 00000000000000..a706ef4fbed133
--- /dev/null
+++ b/lib/routes/bellroy/new-releases.ts
@@ -0,0 +1,51 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/new-releases',
+ categories: ['shopping'],
+ example: '/bellroy/new-releases',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bellroy.com/collection/new-releases', 'bellroy.com/'],
+ },
+ ],
+ name: 'New Releases',
+ maintainers: ['EthanWng97'],
+ handler,
+ url: 'bellroy.com/collection/new-releases',
+};
+
+async function handler() {
+ const host = 'https://bellroy.com';
+ const url = 'https://production.products.boobook-services.com/products';
+ const response = await got({
+ method: 'get',
+ url,
+ searchParams: {
+ currency_identifier: '1abe985632a1392e6a94b885fe193d5943b7c213',
+ price_group: 'bellroy.com',
+ 'filter[dimensions][web_new_release]': 'new_style',
+ },
+ });
+ const data = response.data.products;
+
+ return {
+ title: 'Bellroy - New Releases',
+ link: 'https://bellroy.com/collection/new-releases',
+ description: 'Bellroy - New Releases',
+ item: data.map((item) => ({
+ title: item.attributes.name + ' - ' + item.attributes.dimensions.color,
+ link: host + item.attributes.canonical_uri,
+ })),
+ };
+}
diff --git a/lib/routes/bendibao/namespace.ts b/lib/routes/bendibao/namespace.ts
new file mode 100644
index 00000000000000..808d8f7abb7222
--- /dev/null
+++ b/lib/routes/bendibao/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '本地宝',
+ url: 'bendibao.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bendibao/news.ts b/lib/routes/bendibao/news.ts
new file mode 100644
index 00000000000000..c97c77a9f53c76
--- /dev/null
+++ b/lib/routes/bendibao/news.ts
@@ -0,0 +1,147 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+import { isValidHost } from '@/utils/valid-host';
+
+export const route: Route = {
+ path: '/news/:city',
+ categories: ['new-media'],
+ example: '/bendibao/news/bj',
+ parameters: { city: '城市缩写,可在该城市页面的 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bendibao.com/'],
+ },
+ ],
+ name: '焦点资讯',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'bendibao.com/',
+ description: `| 城市名 | 缩写 |
+| ------ | ---- |
+| 北京 | bj |
+| 上海 | sh |
+| 广州 | gz |
+| 深圳 | sz |
+
+ 更多城市请参见 [这里](http://www.bendibao.com/city.htm)
+
+ > **香港特别行政区** 和 **澳门特别行政区** 的本地宝城市页面不更新资讯。`,
+};
+
+async function handler(ctx) {
+ const city = ctx.req.param('city');
+ if (!isValidHost(city)) {
+ throw new InvalidParameterError('Invalid city');
+ }
+
+ const rootUrl = `http://${city}.bendibao.com`;
+
+ let response = await got({
+ method: 'get',
+ url: rootUrl,
+ });
+
+ let $ = load(response.data);
+ const title =
+ $('title')
+ .text()
+ .replace(/-爱上本地宝,生活会更好/, '') + `焦点资讯`;
+
+ let items = $('ul.focus-news li')
+ .toArray()
+ .map((item) => {
+ item = $(item).find('a');
+
+ const link = item.attr('href');
+
+ return {
+ title: item.text(),
+ link: link.indexOf('http') === 0 ? link : `${rootUrl}${link}`,
+ };
+ });
+
+ // Cities share 2 sets of ui.
+ //
+ // eg1. http://bj.bendibao.com/
+ // eg2. http://kel.bendibao.com/
+ //
+ // Go to /news to fetch data for the eg2 case.
+
+ if (!items.length) {
+ response = await got({
+ method: 'get',
+ url: `http://${city}.bendibao.com/news`,
+ });
+
+ $ = load(response.data);
+
+ items = $('#listNewsTimeLy div.info')
+ .toArray()
+ .map((item) => {
+ item = $(item).find('a');
+
+ const link = item.attr('href');
+
+ return {
+ title: item.text(),
+ link: link.indexOf('http') === 0 ? link : `${rootUrl}${link}`,
+ };
+ });
+ }
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ // Some links lead to mobile-view pages.
+ // eg. http://m.bj.bendibao.com/news/273517.html
+ // Divs for contents are different from which in desktop-view pages.
+
+ item.description = content('div.content').html() ?? content('div.content-box').html();
+
+ // Spans for publish dates are the same cases as above.
+
+ item.pubDate = timezone(
+ parseDate(
+ content('span.time')
+ .text()
+ .replace(/发布时间:/, '') ?? content('span.public_time').text()
+ ),
+ +8
+ );
+
+ return item;
+ } catch {
+ return '';
+ }
+ })
+ )
+ );
+
+ return {
+ title,
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/benedictevans/recent.js b/lib/routes/benedictevans/recent.js
deleted file mode 100644
index 8fb56b175962c6..00000000000000
--- a/lib/routes/benedictevans/recent.js
+++ /dev/null
@@ -1,41 +0,0 @@
-const cheerio = require('cheerio');
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- const url = `https://www.ben-evans.com/benedictevans/`;
-
- const res = await got.get(url);
- const $ = cheerio.load(res.data);
- const list = $('section > article').get();
-
- const out = await Promise.all(
- list.map(async (item) => {
- const $ = cheerio.load(item);
- const title = $('.BlogList-item-title').text();
- const address = 'https://www.ben-evans.com' + $('.BlogList-item-title').attr('href');
- const time = $('.Blog-meta-item--date').text();
- const cache = await ctx.cache.get(address);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
- const res = await got.get(address);
- const capture = cheerio.load(res.data);
- const contents = capture('.col.sqs-col-12.span-12').html();
- const single = {
- title,
- author: 'Benedict Evans',
- description: contents,
- link: address,
- guid: address,
- pubDate: new Date(time).toUTCString(),
- };
- ctx.cache.set(address, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
- ctx.state.data = {
- title: `Benedict Evans`,
- link: url,
- item: out,
- };
-};
diff --git a/lib/routes/bestblogs/feeds.ts b/lib/routes/bestblogs/feeds.ts
new file mode 100644
index 00000000000000..151f2b1e560bb0
--- /dev/null
+++ b/lib/routes/bestblogs/feeds.ts
@@ -0,0 +1,107 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/feeds/:category?',
+ categories: ['programming'],
+ example: '/bestblogs/feeds/featured',
+ parameters: { category: 'the category of articles. Can be `programming`, `ai`, `product`, `business` or `featured`. Default is `featured`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '文章列表',
+ maintainers: ['zhenlohuang'],
+ handler,
+};
+
+class APIRequest {
+ keyword?: string;
+ qualifiedFilter: string;
+ sourceId?: string;
+ category?: string;
+ timeFilter: string;
+ language: string;
+ userLanguage: string;
+ sortType: string;
+ currentPage: number;
+ pageSize: number;
+
+ constructor({ keyword = '', qualifiedFilter = 'true', sourceId = '', category = '', timeFilter = '1w', language = 'all', userLanguage = 'zh', sortType = 'default', currentPage = 1, pageSize = 10 } = {}) {
+ this.keyword = keyword;
+ this.qualifiedFilter = qualifiedFilter;
+ this.sourceId = sourceId;
+ this.category = category;
+ this.timeFilter = timeFilter;
+ this.language = language;
+ this.userLanguage = userLanguage;
+ this.sortType = sortType;
+ this.currentPage = currentPage;
+ this.pageSize = pageSize;
+ }
+
+ toJson(): string {
+ const requestBody = {
+ keyword: this.keyword,
+ qualifiedFilter: this.qualifiedFilter,
+ sourceId: this.sourceId,
+ category: this.category,
+ timeFilter: this.timeFilter,
+ language: this.language,
+ userLanguage: this.userLanguage,
+ sortType: this.sortType,
+ currentPage: this.currentPage,
+ pageSize: this.pageSize,
+ };
+
+ return JSON.stringify(requestBody);
+ }
+}
+
+async function handler(ctx) {
+ const defaultPageSize = 100;
+ const defaultTimeFilter = '1w';
+ const { category = 'featured' } = ctx.req.param();
+
+ const apiRequest = new APIRequest({
+ category,
+ pageSize: defaultPageSize,
+ qualifiedFilter: category === 'featured' ? 'true' : 'false',
+ timeFilter: defaultTimeFilter,
+ });
+
+ const apiUrl = 'https://api.bestblogs.dev/api/resource/list';
+ const response = await ofetch(apiUrl, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'POST',
+ body: apiRequest.toJson(),
+ });
+
+ if (!response || !response.data || !response.data.dataList) {
+ throw new Error('Invalid API response: ' + JSON.stringify(response));
+ }
+
+ const articles = response.data.dataList;
+
+ const items = articles.map((article) => ({
+ title: article.title,
+ link: article.url,
+ description: article.summary,
+ pubDate: parseDate(article.publishDateTimeStr),
+ author: Array.isArray(article.authors) ? article.authors.map((author) => ({ name: author })) : [{ name: article.authors }],
+ category: article.category,
+ }));
+
+ return {
+ title: `Bestblogs.dev`,
+ link: `https://www.bestblogs.dev/feeds`,
+ item: items,
+ };
+}
diff --git a/lib/routes/bestblogs/namespace.ts b/lib/routes/bestblogs/namespace.ts
new file mode 100644
index 00000000000000..0b54f592a5533a
--- /dev/null
+++ b/lib/routes/bestblogs/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'bestblogs.dev',
+ url: 'www.bestblogs.dev',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bestofjs/monthly.ts b/lib/routes/bestofjs/monthly.ts
new file mode 100644
index 00000000000000..8616904f1598c8
--- /dev/null
+++ b/lib/routes/bestofjs/monthly.ts
@@ -0,0 +1,140 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { art } from '@/utils/render';
+
+const BASEURL = 'https://bestofjs.org/rankings/monthly';
+
+export const route: Route = {
+ path: '/rankings/monthly',
+ categories: ['programming'],
+ example: '/bestofjs/rankings/monthly',
+ view: ViewType.Notifications,
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bestofjs.org/rankings/monthly/:year/:month'],
+ target: '/rankings/monthly',
+ },
+ ],
+ name: 'Monthly Rankings',
+ maintainers: ['ztkuaikuai'],
+ url: 'bestofjs.org/rankings/monthly',
+ handler: async () => {
+ const targetMonths = getLastSixMonths();
+ const allNeededMonthlyRankings = await Promise.all(
+ targetMonths.map((data) => {
+ const [year, month] = data.split('-');
+ return getMonthlyRankings(year, month);
+ })
+ );
+ const items = allNeededMonthlyRankings.flatMap((oneMonthlyRankings, i) => {
+ const [year, month] = targetMonths[i].split('-');
+ const description = art(path.join(__dirname, 'templates/description.art'), { items: oneMonthlyRankings });
+ return {
+ title: `Best of JS Monthly Rankings - ${year}/${month}`,
+ description,
+ link: `${BASEURL}/${year}/${month}`,
+ guid: `${BASEURL}/${year}/${month}`,
+ author: 'Best of JS',
+ };
+ });
+
+ return {
+ title: 'Best of JS Monthly Rankings',
+ link: BASEURL,
+ description: 'Monthly rankings of the most popular JavaScript projects on Best of JS',
+ item: items,
+ language: 'en',
+ };
+ },
+};
+
+const getLastSixMonths = (): string[] => {
+ const now = new Date();
+ const currentYear = now.getFullYear();
+ const currentMonth = now.getMonth() + 1; // 0-based to 1-based
+ return Array.from({ length: 6 }, (_, i) => {
+ let month = currentMonth - (i + 1);
+ let year = currentYear;
+ if (month <= 0) {
+ month += 12;
+ year -= 1;
+ }
+ return `${year}-${month}`;
+ });
+};
+
+interface RankingItem {
+ logo: string;
+ projectName: string;
+ githubLink: string;
+ homepageLink: string;
+ description: string;
+ tags: string[];
+ starCount: string;
+ additionalInfo: string;
+}
+
+const getMonthlyRankings = (year: string, month: string): Promise => {
+ const targetUrl = `${BASEURL}/${year}/${month}`;
+ return cache.tryGet(targetUrl, async () => {
+ const response = await ofetch(targetUrl);
+ const $ = load(response);
+ return $('table.w-full tbody tr[data-testid="project-card"]')
+ .toArray()
+ .map((el) => {
+ const $tr = $(el);
+ // Project logo
+ const logo =
+ $tr
+ .find('td:first img')
+ .attr('src')
+ ?.replace(/.dark./, '.') || '';
+ // Project name and link
+ const projectLink = $tr.find('td:nth-child(2) a[href^="/projects/"]').first();
+ const projectName = projectLink.text().trim();
+ // GitHub and homepage links
+ const githubLink = $tr.find('td:nth-child(2) a[href*="github.com"]').attr('href') || '';
+ const homepageLink = $tr.find('td:nth-child(2) a[href*="http"]:not([href*="github.com"])').attr('href') || '';
+ // Description
+ const description = $tr.find('td:nth-child(2) .font-serif').text().trim();
+ // Tags
+ const tags = $tr
+ .find('td:nth-child(2) [href*="/projects?tags="]')
+ .toArray()
+ .map((tag) => $(tag).text().trim());
+ // Star count
+ const starCount = $tr.find('td:nth-child(4) span:last').text().trim() || $tr.find('td:nth-child(2) .inline-flex span:last-child').text().trim();
+ // Additional info (contributors, created date)
+ const additionalInfo = $tr
+ .find('td:nth-child(3) > div')
+ .toArray()
+ .slice(1)
+ .map((el) => $(el).text().trim())
+ .join('; ');
+ return {
+ logo,
+ projectName,
+ githubLink,
+ homepageLink,
+ description,
+ tags,
+ starCount,
+ additionalInfo,
+ };
+ });
+ });
+};
diff --git a/lib/routes/bestofjs/namespace.ts b/lib/routes/bestofjs/namespace.ts
new file mode 100644
index 00000000000000..0ca296fade4831
--- /dev/null
+++ b/lib/routes/bestofjs/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Best of JS',
+ url: 'bestofjs.org',
+ lang: 'en',
+};
diff --git a/lib/routes/bestofjs/templates/description.art b/lib/routes/bestofjs/templates/description.art
new file mode 100644
index 00000000000000..b7a6b51a5f953e
--- /dev/null
+++ b/lib/routes/bestofjs/templates/description.art
@@ -0,0 +1,36 @@
+
+ {{each items item index}}
+
+ Rank {{index + 1}}
+ {{if item.logo}}
+
+ {{/if}}
+ {{if item.projectName}}
+ Project: {{item.projectName}}
+ {{/if}}
+ {{if item.description}}
+ {{item.description}}
+ {{/if}}
+ {{if item.starCount}}
+ Stars: {{item.starCount}}
+ {{/if}}
+ {{if item.additionalInfo}}
+ Additional Info: {{item.additionalInfo}}
+ {{/if}}
+ {{if item.githubLink}}
+ GitHub: {{item.githubLink}}
+ {{/if}}
+ {{if item.homepageLink}}
+ Homepage: {{item.homepageLink}}
+ {{/if}}
+ {{if item.tags && item.tags.length}}
+ Tags:
+ {{each item.tags tag tIndex}}
+ {{tag}} {{if tIndex < item.tags.length - 1}}, {{/if}}
+ {{/each}}
+
+ {{/if}}
+
+
+ {{/each}}
+
diff --git a/lib/routes/bfl/announcements.ts b/lib/routes/bfl/announcements.ts
new file mode 100644
index 00000000000000..dd4861f468d0fd
--- /dev/null
+++ b/lib/routes/bfl/announcements.ts
@@ -0,0 +1,128 @@
+import { load } from 'cheerio';
+
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const ROOT_URL = 'https://bfl.ai'; // 根 URL 定义为常量
+
+/**
+ * 辅助函数:获取并解析单个公告详情页,提取正文内容,并使用缓存。
+ */
+const fetchDescription = (item: DataItem): Promise =>
+ cache.tryGet(item.link!, async () => {
+ const detailPageHtml = await ofetch(item.link!, {
+ // 不再手动指定 User-Agent,让 RSSHub 自行处理
+ });
+ const $detailPage = load(detailPageHtml);
+ const detailContentSelector = 'div.max-w-3xl.mx-auto.px-6';
+ const fullDescription = $detailPage(detailContentSelector).html()?.trim();
+
+ // 将从列表页获取的 item 与详情页的描述合并后返回
+ // 整个对象将被缓存
+ return {
+ ...item,
+ description: fullDescription || item.description, // 如果获取不到全文,则回退到列表页的摘要
+ };
+ });
+
+/**
+ * 主路由处理函数
+ */
+async function handler(): Promise {
+ const listPageUrl = `${ROOT_URL}/announcements`;
+
+ const listPageHtml = await ofetch(listPageUrl); // 不再手动指定 User-Agent
+ const $ = load(listPageHtml);
+
+ const feedTitle = $('head title').text().trim() || 'BFL AI Announcements';
+ const feedDescription = $('head meta[name="description"]').attr('content')?.trim() || 'Latest announcements from Black Forest Labs (bfl.ai).';
+
+ const listItemsSelector = 'div.flex.flex-col.max-w-3xl.mx-auto.space-y-8 > a[href^="/announcements/"]';
+ const announcementLinks = $(listItemsSelector);
+
+ // 从列表页初步提取每个条目的信息
+ const preliminaryItems: DataItem[] = announcementLinks
+ .toArray()
+ .map((anchorElement) => {
+ const $anchor = $(anchorElement);
+
+ const relativeLink = $anchor.attr('href');
+ const link = relativeLink ? `${ROOT_URL}${relativeLink}` : undefined;
+ const title = $anchor.find('h2[class*="text-xl"]').text().trim();
+
+ const $timeElement = $anchor.find('time');
+ const datetimeAttr = $timeElement.attr('datetime');
+ const timeText = $timeElement.text().trim();
+ const pubDate = datetimeAttr ? parseDate(datetimeAttr) : timeText ? parseDate(timeText) : undefined;
+
+ const summaryDescription = $anchor.find('p[class*="line-clamp-3"]').html()?.trim() || '';
+ const author = 'Black Forest Labs';
+
+ // 只有包含有效标题和链接的条目才被认为是初步有效的
+ if (!title || !link) {
+ return null;
+ }
+
+ // 构造初步的 item 对象
+ const preliminaryItem: DataItem = {
+ title,
+ link,
+ description: summaryDescription,
+ author,
+ };
+
+ if (pubDate) {
+ preliminaryItem.pubDate = pubDate.toUTCString();
+ }
+
+ return preliminaryItem;
+ })
+ .filter((item): item is DataItem => item !== null && item.link !== undefined);
+
+ // 并行获取所有文章的完整描述
+ const items: DataItem[] = await Promise.all(preliminaryItems.map((item) => fetchDescription(item)));
+
+ return {
+ title: feedTitle,
+ link: listPageUrl,
+ description: feedDescription,
+ item: items,
+ language: 'en',
+ };
+}
+
+/**
+ * 定义并导出RSSHub路由对象
+ */
+export const route: Route = {
+ // 路径相对于命名空间 /bfl,所以完整路径是 /bfl/announcements
+ path: '/announcements',
+ // 按照要求,只指定一个分类
+ categories: ['multimedia'],
+ example: '/bfl/announcements',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bfl.ai/announcements'],
+ // target 也要相应修改
+ target: '/announcements',
+ title: 'Announcements',
+ },
+ ],
+ name: 'Announcements',
+ maintainers: ['thirteenkai'],
+ handler,
+ // url 不包含协议名
+ url: 'bfl.ai/announcements',
+ description: 'Fetches the latest announcements from Black Forest Labs (bfl.ai). Provides full article content by default with caching.',
+};
diff --git a/lib/routes/bfl/namespace.ts b/lib/routes/bfl/namespace.ts
new file mode 100644
index 00000000000000..854be75fe3be7a
--- /dev/null
+++ b/lib/routes/bfl/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BFL AI',
+ url: 'bfl.ai',
+ categories: ['multimedia'],
+ description: '来自黑森林实验室(bfl.ai)的公告和更新,这是一个前沿的人工智能实验室。',
+ lang: 'en',
+};
diff --git a/lib/routes/bgmlist/namespace.ts b/lib/routes/bgmlist/namespace.ts
new file mode 100644
index 00000000000000..ebbdd5c6af3409
--- /dev/null
+++ b/lib/routes/bgmlist/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '番组放送',
+ url: 'bgmlist.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bgmlist/onair.ts b/lib/routes/bgmlist/onair.ts
new file mode 100644
index 00000000000000..f2aeb21f7e737c
--- /dev/null
+++ b/lib/routes/bgmlist/onair.ts
@@ -0,0 +1,52 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/onair/:lang?',
+ categories: ['anime'],
+ example: '/bgmlist/onair/zh-Hans',
+ parameters: { lang: '语言' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '开播提醒',
+ maintainers: ['x2cf'],
+ handler,
+};
+
+async function handler(ctx) {
+ const lang = ctx.req.param('lang');
+ const { data: sites } = await got('https://bgmlist.com/api/v1/bangumi/site');
+ const { data } = await got('https://bgmlist.com/api/v1/bangumi/onair');
+
+ return {
+ title: '番组放送 开播提醒',
+ link: 'https://bgmlist.com/',
+ item: data.items.map((item) => {
+ item.sites.push({ site: 'dmhy', id: item.titleTranslate['zh-Hans']?.[0] ?? item.title });
+ return {
+ title: item.titleTranslate[lang]?.[0] ?? item.title,
+ link: item.officialSite,
+ description: art(
+ path.join(__dirname, 'templates/description.art'),
+ item.sites.map((site) => ({
+ title: sites[site.site].title,
+ url: sites[site.site].urlTemplate.replaceAll('{{id}}', site.id),
+ begin: site.begin,
+ }))
+ ),
+ pubDate: parseDate(item.begin),
+ guid: item.id,
+ };
+ }),
+ };
+}
diff --git a/lib/v2/bgmlist/templates/description.art b/lib/routes/bgmlist/templates/description.art
similarity index 100%
rename from lib/v2/bgmlist/templates/description.art
rename to lib/routes/bgmlist/templates/description.art
diff --git a/lib/routes/bibgame/category.js b/lib/routes/bibgame/category.js
deleted file mode 100644
index 5547fe1a7fb298..00000000000000
--- a/lib/routes/bibgame/category.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const timezone = require('@/utils/timezone');
-
-module.exports = async (ctx) => {
- const category = ctx.params.category || 'pcgame';
- const type = ctx.params.type || '';
-
- const rootUrl = 'https://www.bibgame.com';
- const currentUrl = `${rootUrl}/${category}/${type}`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = cheerio.load(response.data);
-
- const list = $('.info_box a')
- .slice(0, 15)
- .map((_, item) => {
- item = $(item);
- const link = item.attr('href');
-
- return {
- title: item.text(),
- link: link.indexOf('http') > -1 ? link : `${rootUrl}${link}`,
- };
- })
- .get();
-
- const items = await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const content = cheerio.load(detailResponse.data);
-
- content('#comment').parent().remove();
-
- item.description = content('.abstract').html();
- item.pubDate = timezone(new Date(content('.time_box').text().trim()), +8);
-
- return item;
- })
- )
- );
-
- ctx.state.data = {
- title: $('title').text(),
- link: currentUrl,
- item: items,
- };
-};
diff --git a/lib/routes/bigquant/collections.ts b/lib/routes/bigquant/collections.ts
new file mode 100644
index 00000000000000..6bd3d11d0aab8b
--- /dev/null
+++ b/lib/routes/bigquant/collections.ts
@@ -0,0 +1,65 @@
+import MarkdownIt from 'markdown-it';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const md = MarkdownIt({
+ html: true,
+});
+
+export const route: Route = {
+ path: '/collections',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/bigquant/collections',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bigquant.com/'],
+ },
+ ],
+ name: '专题报告',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'bigquant.com/',
+};
+
+async function handler() {
+ const rootUrl = 'https://bigquant.com';
+ const currentUrl = `${rootUrl}/wiki/api/documents.list`;
+
+ const response = await got({
+ method: 'post',
+ url: currentUrl,
+ json: {
+ collectionId: 'c6874e5d-7f45-4e90-8cd9-5e43df3b44ef',
+ direction: 'DESC',
+ limit: 25,
+ offset: 0,
+ sort: 'publishedAt',
+ },
+ });
+
+ const items = response.data.data.map((item) => ({
+ title: item.title,
+ link: `${rootUrl}/wiki${item.url}`,
+ description: md.render(item.text),
+ pubDate: parseDate(item.publishedAt),
+ }));
+
+ return {
+ title: '专题报告 - AI量化知识库 - BigQuant',
+ link: `${rootUrl}/wiki/collections/c6874e5d-7f45-4e90-8cd9-5e43df3b44ef`,
+ item: items,
+ };
+}
diff --git a/lib/routes/bigquant/namespace.ts b/lib/routes/bigquant/namespace.ts
new file mode 100644
index 00000000000000..7176b5aa86d711
--- /dev/null
+++ b/lib/routes/bigquant/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BigQuant',
+ url: 'bigquant.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bilibili/api-interface.d.ts b/lib/routes/bilibili/api-interface.d.ts
new file mode 100644
index 00000000000000..89b2038ee55741
--- /dev/null
+++ b/lib/routes/bilibili/api-interface.d.ts
@@ -0,0 +1,493 @@
+interface Likeicon {
+ action_url: string;
+ end_url: string;
+ id: number;
+ start_url: string;
+}
+interface Basic {
+ comment_id_str: string;
+ comment_type: number;
+ jump_url?: string;
+ like_icon: Likeicon;
+ rid_str: string;
+}
+interface Containersize {
+ height: number;
+ width: number;
+}
+interface Posspec {
+ axis_x: number;
+ axis_y: number;
+ coordinate_pos: number;
+}
+interface Renderspec {
+ opacity: number;
+}
+interface Generalspec {
+ pos_spec: Posspec;
+ render_spec: Renderspec;
+ size_spec: Containersize;
+}
+/* eslint-disable-next-line @typescript-eslint/no-empty-object-type */
+interface AVATARLAYER {}
+interface Webcssstyle {
+ borderRadius: string;
+ 'background-color'?: string;
+ border?: string;
+ boxSizing?: string;
+}
+interface Generalconfig {
+ web_css_style: Webcssstyle;
+}
+interface GENERALCFG {
+ config_type: number;
+ general_config: Generalconfig;
+}
+interface Tags {
+ AVATAR_LAYER?: AVATARLAYER;
+ GENERAL_CFG: GENERALCFG;
+ ICON_LAYER?: AVATARLAYER;
+}
+interface Layerconfig {
+ is_critical?: boolean;
+ tags: Tags;
+}
+interface Remote {
+ bfs_style: string;
+ url: string;
+}
+interface Imagesrc {
+ placeholder?: number;
+ remote?: Remote;
+ src_type: number;
+ local?: number;
+}
+interface Resimage {
+ image_src: Imagesrc;
+}
+interface Resource {
+ res_image: Resimage;
+ res_type: number;
+}
+interface Layer {
+ general_spec: Generalspec;
+ layer_config: Layerconfig;
+ resource: Resource;
+ visible: boolean;
+}
+interface Fallbacklayers {
+ is_critical_group: boolean;
+ layers: Layer[];
+}
+interface Avatar {
+ container_size: Containersize;
+ fallback_layers: Fallbacklayers;
+ mid: string;
+}
+interface Fan {
+ color: string;
+ is_fan: boolean;
+ num_str: string;
+ number: number;
+}
+interface Decorate {
+ card_url: string;
+ fan: Fan;
+ id: number;
+ jump_url: string;
+ name: string;
+ type: number;
+}
+interface Officialverify {
+ desc: string;
+ type: number;
+}
+interface Pendant {
+ expire: number;
+ image: string;
+ image_enhance: string;
+ image_enhance_frame: string;
+ n_pid: number;
+ name: string;
+ pid: number;
+}
+interface Label {
+ bg_color: string;
+ bg_style: number;
+ border_color: string;
+ img_label_uri_hans: string;
+ img_label_uri_hans_static: string;
+ img_label_uri_hant: string;
+ img_label_uri_hant_static: string;
+ label_theme: string;
+ path: string;
+ text: string;
+ text_color: string;
+ use_img_label: boolean;
+}
+interface Vip {
+ avatar_subscript: number;
+ avatar_subscript_url: string;
+ due_date: number;
+ label: Label;
+ nickname_color: string;
+ status: number;
+ theme_type: number;
+ type: number;
+}
+interface Moduleauthor {
+ avatar: Avatar;
+ decorate?: Decorate;
+ face: string;
+ face_nft: boolean;
+ following: boolean;
+ jump_url: string;
+ label: string;
+ mid: number;
+ name: string;
+ official_verify: Officialverify;
+ pendant: Pendant;
+ pub_action: string;
+ pub_location_text?: string;
+ pub_time: string;
+ pub_ts: number;
+ type: AuthorType;
+ vip: Vip;
+}
+interface Jumpstyle {
+ icon_url: string;
+ text: string;
+}
+interface Button {
+ jump_style: Jumpstyle;
+ jump_url: string;
+ type: number;
+}
+
+interface Common {
+ button: Button;
+ cover: string;
+ desc1: string;
+ desc2: string;
+ head_text: string;
+ id_str: string;
+ jump_url: string;
+ style: number;
+ sub_type: string;
+ title: string;
+}
+interface Additional {
+ common: Common;
+ type: string;
+}
+interface Richtextnode {
+ orig_text: string;
+ rid?: string;
+ text: string;
+ type: string;
+ jump_url?: string;
+ emoji?: Emoji;
+ pics?: Pic2[];
+}
+interface Pic2 {
+ height: number;
+ size: number;
+ src: string;
+ width: number;
+}
+
+interface Desc {
+ rich_text_nodes: Richtextnode[];
+ text: string;
+}
+interface Pic {
+ height: number;
+ size: number;
+ url: string;
+ width: number;
+}
+interface Opus {
+ fold_action: string[];
+ jump_url: string;
+ pics: Pic[];
+ summary: Desc;
+ title?: string;
+}
+interface Badge {
+ bg_color: string;
+ color: string;
+ icon_url?: string;
+ text: string;
+}
+interface Stat {
+ danmaku: string;
+ play: string;
+}
+interface Pgc {
+ badge: Badge;
+ cover: string;
+ epid: number;
+ jump_url: string;
+ season_id: number;
+ stat: Stat;
+ stat_hidden: number;
+ sub_type: number;
+ title: string;
+ type: number;
+}
+interface Live {
+ badge: Badge;
+ cover: string;
+ desc_first: string;
+ desc_second: string;
+ id: number;
+ jump_url: string;
+ live_state: number;
+ reserve_type: number;
+ title: string;
+}
+interface Common2 {
+ badge: Badge;
+ biz_type: number;
+ cover: string;
+ desc: string;
+ id: string;
+ jump_url: string;
+ label: string;
+ sketch_id: string;
+ style: number;
+ title: string;
+}
+interface Archive {
+ aid: string;
+ badge: Badge;
+ bvid: string;
+ cover: string;
+ desc: string;
+ disable_preview: number;
+ duration_text: string;
+ jump_url: string;
+ stat: Stat;
+ title: string;
+ type: number;
+}
+interface UgcSeason {
+ aid: number; // 视频AV号
+ badge: Badge; // 角标信息
+ cover: string; // 视频封面
+ desc: string; // 视频简介
+ disable_preview: number; // 0
+ duration_text: string; // 时长
+ jump_url: string; // 跳转URL
+ stat: Stat; // 统计信息
+ title: string; // 视频标题
+}
+
+interface Article {
+ covers: string[]; // 封面图数组,最多三张
+ desc: string; // 文章摘要
+ id: number; // 文章CV号
+ jump_url: string; // 文章跳转地址
+ label: string; // 文章阅读量
+ title: string; // 文章标题
+}
+
+interface DrawItem {
+ height: number; // 图片高度
+ size: number; // 图片大小,单位KB
+ src: string; // 图片URL
+ tags: any[]; // 标签数组
+ width: number; // 图片宽度
+}
+
+interface Draw {
+ id: number; // 对应相簿id
+ items: DrawItem[]; // 图片信息列表
+}
+
+interface LiveRcmd {
+ content: string; // 直播间内容JSON
+ reserve_type: number; // 0
+}
+
+interface Course {
+ badge: Badge; // 角标信息
+ cover: string; // 封面图URL
+ desc: string; // 更新状态描述
+ id: number; // 课程id
+ jump_url: string; // 跳转URL
+ sub_title: string; // 课程副标题
+ title: string; // 课程标题
+}
+
+interface Music {
+ cover: string; // 音频封面
+ id: number; // 音频AUID
+ jump_url: string; // 跳转URL
+ label: string; // 音频分类
+ title: string; // 音频标题
+}
+
+interface None {
+ tips: string; // 动态失效显示文案
+}
+
+interface Major {
+ type: MajorType;
+ ugc_season?: UgcSeason; // 合集信息
+ article?: Article; // 专栏
+ draw?: Draw; // 带图动态
+ archive?: Archive; // 视频信息
+ live_rcmd?: LiveRcmd; // 直播状态
+ common?: Common2; // 一般类型
+ opus?: Opus; // 图文动态
+ pgc?: Pgc; // 剧集信息
+ courses?: Course; // 课程信息
+ music?: Music; // 音频信息
+ live?: Live; // 直播信息
+ none?: None; // 动态失效
+}
+interface Topic {
+ id: number; // 话题id
+ jump_url: string; // 跳转URL
+ name: string; // 话题名称
+}
+
+interface Moduledynamic {
+ additional?: Additional;
+ desc?: Desc;
+ major?: Major;
+ topic?: Topic;
+}
+interface Emoji {
+ icon_url: string;
+ size: number;
+ text: string;
+ type: number;
+}
+
+interface Item {
+ desc: Desc;
+ type: number;
+}
+interface Moduleinteraction {
+ items: Item[];
+}
+interface Threepointitem {
+ label: string;
+ type: string;
+}
+interface Modulemore {
+ three_point_items: Threepointitem[];
+}
+interface Comment {
+ count: number;
+ forbidden: boolean;
+}
+interface Like {
+ count: number;
+ forbidden: boolean;
+ status: boolean;
+}
+interface Modulestat {
+ comment: Comment;
+ forward: Comment;
+ like: Like;
+}
+interface Moduletag {
+ text: string;
+}
+interface Modules {
+ module_author?: Moduleauthor;
+ module_dynamic?: Moduledynamic;
+ module_interaction?: Moduleinteraction;
+ module_more?: Modulemore;
+ module_stat?: Modulestat;
+ module_tag?: Moduletag;
+}
+
+export interface Orig {
+ basic: Basic;
+ id_str: string;
+ modules: Modules;
+ type: DynamicType;
+ visible: boolean;
+}
+export interface Item2 {
+ basic: Basic;
+ id_str: string;
+ modules: Modules;
+ type: DynamicType;
+ visible: boolean;
+ orig?: Orig;
+}
+export interface Data {
+ has_more: boolean;
+ items: Item2[];
+ offset: string;
+ update_baseline: string;
+ update_num: number;
+}
+// https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space
+export interface BilibiliWebDynamicResponse {
+ code: number;
+ message: string;
+ ttl: number;
+ data: Data;
+}
+
+/**
+ * 作者类型
+ * 更多类型请参考:https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/dynamic/dynamic_enum.md#%E4%BD%9C%E8%80%85%E7%B1%BB%E5%9E%8B
+ */
+export type AuthorType = 'AUTHOR_TYPE_PGC' | 'AUTHOR_TYPE_NORMAL' | 'AUTHOR_TYPE_UGC_SEASON' | 'AUTHOR_TYPE_NONE';
+
+/**
+ * 动态类型
+ * 更多类型请参考:https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/dynamic/dynamic_enum.md#%E5%8A%A8%E6%80%81%E7%B1%BB%E5%9E%8B
+ */
+export type DynamicType =
+ | 'DYNAMIC_TYPE_NONE' // 无效动态, 示例: 716510857084796964
+ | 'DYNAMIC_TYPE_FORWARD' // 动态转发
+ | 'DYNAMIC_TYPE_AV' // 投稿视频
+ | 'DYNAMIC_TYPE_PGC' // 剧集(番剧、电影、纪录片)
+ | 'DYNAMIC_TYPE_COURSES' // 课程
+ | 'DYNAMIC_TYPE_WORD' // 纯文字动态, 示例: 718377531474968613
+ | 'DYNAMIC_TYPE_DRAW' // 带图动态, 示例: 718384798557536290
+ | 'DYNAMIC_TYPE_ARTICLE' // 投稿专栏, 示例: 718372214316990512
+ | 'DYNAMIC_TYPE_MUSIC' // 音乐
+ | 'DYNAMIC_TYPE_COMMON_SQUARE' // 装扮, 剧集点评, 普通分享, 示例: 551309621391003098, 716503778995470375, 716481612656672789
+ | 'DYNAMIC_TYPE_COMMON_VERTICAL' // 垂直动态
+ | 'DYNAMIC_TYPE_LIVE' // 直播间分享, 示例: 216042859353895488
+ | 'DYNAMIC_TYPE_MEDIALIST' // 收藏夹, 示例: 534428265320147158
+ | 'DYNAMIC_TYPE_COURSES_SEASON' // 课程, 示例: 717906712866062340
+ | 'DYNAMIC_TYPE_COURSES_BATCH' // 课程批次
+ | 'DYNAMIC_TYPE_AD' // 广告
+ | 'DYNAMIC_TYPE_APPLET' // 小程序
+ | 'DYNAMIC_TYPE_SUBSCRIPTION' // 订阅
+ | 'DYNAMIC_TYPE_LIVE_RCMD' // 直播开播, 示例: 718371505648435205
+ | 'DYNAMIC_TYPE_BANNER' // 横幅
+ | 'DYNAMIC_TYPE_UGC_SEASON' // 合集更新, 示例: 718390979031203873
+ | 'DYNAMIC_TYPE_SUBSCRIPTION_NEW'; // 新订阅
+/**
+ * 动态主体类型
+ * 更多类型请参考:https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/dynamic/dynamic_enum.md#%E5%8A%A8%E6%80%81%E4%B8%BB%E4%BD%93%E7%B1%BB%E5%9E%8B
+ */
+export type MajorType =
+ | 'MAJOR_TYPE_NONE' // 动态失效, 示例: 716510857084796964
+ | 'MAJOR_TYPE_NONE' // 转发动态, 示例: 866756840240709701
+ | 'MAJOR_TYPE_OPUS' // 图文动态, 示例: 870176712256651305
+ | 'MAJOR_TYPE_ARCHIVE' // 视频, 示例: 716526237365829703
+ | 'MAJOR_TYPE_PGC' // 剧集更新, 示例: 645981661420322824
+ | 'MAJOR_TYPE_COURSES' // 课程
+ | 'MAJOR_TYPE_DRAW' // 带图动态, 示例: 716358050743582725
+ | 'MAJOR_TYPE_ARTICLE' // 文章
+ | 'MAJOR_TYPE_MUSIC' // 音频更新
+ | 'MAJOR_TYPE_COMMON' // 一般类型, 示例: 716481612656672789
+ | 'MAJOR_TYPE_LIVE' // 直播间分享, 示例: 267505569812738175
+ | 'MAJOR_TYPE_MEDIALIST' // 收藏夹
+ | 'MAJOR_TYPE_APPLET' // 小程序
+ | 'MAJOR_TYPE_SUBSCRIPTION' // 订阅
+ | 'MAJOR_TYPE_LIVE_RCMD' // 直播状态
+ | 'MAJOR_TYPE_UGC_SEASON' // 合计更新, 示例: 716509100448415814
+ | 'MAJOR_TYPE_SUBSCRIPTION_NEW'; // 新订阅
diff --git a/lib/routes/bilibili/app.ts b/lib/routes/bilibili/app.ts
new file mode 100644
index 00000000000000..408e846e871e39
--- /dev/null
+++ b/lib/routes/bilibili/app.ts
@@ -0,0 +1,55 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+const config = {
+ android: '安卓版',
+ iphone: 'iPhone 版',
+ ipad: 'iPad HD 版',
+ win: 'UWP 版',
+ android_tv_yst: 'TV 版',
+};
+
+export const route: Route = {
+ path: '/app/:id?',
+ categories: ['program-update'],
+ example: '/bilibili/app/android',
+ parameters: { id: '客户端 id,见下表,默认为安卓版' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '更新情报',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 安卓版 | iPhone 版 | iPad HD 版 | UWP 版 | TV 版 |
+| ------- | --------- | ---------- | ------ | ---------------- |
+| android | iphone | ipad | win | android\_tv\_yst |`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') || 'android';
+
+ const rootUrl = 'https://app.bilibili.com';
+ const apiUrl = `${rootUrl}/x/v2/version?mobi_app=${id}`;
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.data.map((item) => ({
+ link: rootUrl,
+ title: item.version,
+ pubDate: new Date(item.ptime * 1000).toUTCString(),
+ description: `${item.desc.split('\n-').join(' -')} `,
+ }));
+
+ return {
+ title: `哔哩哔哩更新情报 - ${config[id]}`,
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bilibili/article.ts b/lib/routes/bilibili/article.ts
new file mode 100644
index 00000000000000..828f674657d0cf
--- /dev/null
+++ b/lib/routes/bilibili/article.ts
@@ -0,0 +1,87 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cacheGeneral from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/user/article/:uid',
+ categories: ['social-media'],
+ example: '/bilibili/user/article/334958638',
+ parameters: { uid: '用户 id, 可在 UP 主主页中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['space.bilibili.com/:uid'],
+ },
+ ],
+ name: 'UP 主图文',
+ maintainers: ['lengthmin', 'Qixingchen', 'hyoban'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const name = await cache.getUsernameFromUID(uid);
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/polymer/web-dynamic/v1/opus/feed/space?host_mid=${uid}`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/article`,
+ },
+ });
+ const data = response.data.data;
+ const title = `${name} 的 bilibili 图文`;
+ const link = `https://space.bilibili.com/${uid}/article`;
+ const description = `${name} 的 bilibili 图文`;
+ const cookie = await cache.getCookie();
+
+ const item = await Promise.all(
+ data.items.map(async (item) => {
+ const link = 'https:' + item.jump_url;
+ const data = await cacheGeneral.tryGet(
+ link,
+ async () =>
+ (
+ await got({
+ method: 'get',
+ url: link,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/article`,
+ Cookie: cookie,
+ },
+ })
+ ).data
+ );
+
+ const $ = load(data as string);
+ const description = $('.opus-module-content').html();
+ const pubDate = $('.opus-module-author__pub__text').text().replace('编辑于 ', '');
+
+ const single = {
+ title: item.content,
+ link,
+ description: description || item.content,
+ // 2019年11月11日 08:50
+ pubDate: pubDate ? parseDate(pubDate, 'YYYY年MM月DD日 HH:mm') : undefined,
+ };
+ return single;
+ })
+ );
+ return {
+ title,
+ link,
+ description,
+ item,
+ };
+}
diff --git a/lib/routes/bilibili/audio.ts b/lib/routes/bilibili/audio.ts
new file mode 100644
index 00000000000000..b1a1fe48abb6fd
--- /dev/null
+++ b/lib/routes/bilibili/audio.ts
@@ -0,0 +1,61 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+const audio = 'https://www.bilibili.com/audio/au';
+
+export const route: Route = {
+ path: '/audio/:id',
+ categories: ['social-media'],
+ example: '/bilibili/audio/10624',
+ parameters: { id: '歌单 id, 可在歌单页 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '歌单',
+ maintainers: ['LogicJake'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = Number.parseInt(ctx.req.param('id'));
+ const link = `https://www.bilibili.com/audio/am${id}`;
+
+ const apiMenuUrl = `https://www.bilibili.com/audio/music-service-c/web/menu/info?sid=${id}`;
+ const menuResponse = await got.get(apiMenuUrl);
+ const menuData = menuResponse.data.data;
+ const introduction = menuData.intro;
+ const title = menuData.title;
+
+ const apiUrl = `https://www.bilibili.com/audio/music-service-c/web/song/of-menu?sid=${id}&pn=1&ps=100`;
+ const response = await got.get(apiUrl);
+ const data = response.data.data.data;
+
+ const out = data.map((item) => {
+ const title = item.title;
+ const link = audio + item.statistic.sid;
+ const author = item.author;
+ const description = item.intro + ` `;
+
+ const single = {
+ title,
+ link,
+ author,
+ pubDate: new Date(item.passtime * 1000).toUTCString(),
+ description,
+ };
+
+ return single;
+ });
+
+ return {
+ title,
+ link,
+ description: introduction,
+ item: out,
+ };
+}
diff --git a/lib/routes/bilibili/bangumi.ts b/lib/routes/bilibili/bangumi.ts
new file mode 100644
index 00000000000000..49369c150ba345
--- /dev/null
+++ b/lib/routes/bilibili/bangumi.ts
@@ -0,0 +1,70 @@
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+
+import type { EpisodeResult } from './types';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/bangumi/media/:mediaid/:embed?',
+ name: '番剧',
+ parameters: {
+ mediaid: '番剧媒体 id, 番剧主页 URL 中获取',
+ embed: '默认为开启内嵌视频, 任意值为关闭',
+ },
+ example: '/bilibili/bangumi/media/9192',
+ categories: ['social-media'],
+ view: ViewType.Videos,
+ maintainers: ['DIYgod', 'nuomi1'],
+ handler,
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportRadar: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+};
+
+async function handler(ctx) {
+ const mediaId = ctx.req.param('mediaid');
+ const embed = !ctx.req.param('embed');
+
+ const mediaData = await utils.getBangumi(mediaId, cache);
+ const seasonId = String(mediaData.season_id);
+ const seasonData = await utils.getBangumiItems(seasonId, cache);
+
+ const episodes: DataItem[] = [];
+
+ const getEpisode = (item: EpisodeResult, title: string) =>
+ ({
+ title,
+ description: utils.renderOGVDescription(embed, item.cover, item.long_title, seasonId, String(item.id)),
+ link: item.share_url,
+ image: item.cover.replace('http://', 'https://'),
+ language: 'zh-cn',
+ }) as DataItem;
+
+ for (const item of seasonData.main_section.episodes) {
+ const episode = getEpisode(item, `第${item.title}话 ${item.long_title}`);
+ episodes.push(episode);
+ }
+
+ for (const section of seasonData.section) {
+ for (const item of section.episodes) {
+ const episode = getEpisode(item, `${item.title} ${item.long_title}`);
+ episodes.push(episode);
+ }
+ }
+
+ return {
+ title: mediaData.title,
+ description: mediaData.evaluate,
+ link: mediaData.share_url,
+ item: episodes,
+ image: mediaData.cover.replace('http://', 'https://'),
+ language: 'zh-cn',
+ } as Data;
+}
diff --git a/lib/routes/bilibili/bilibili-recommend.ts b/lib/routes/bilibili/bilibili-recommend.ts
new file mode 100644
index 00000000000000..6a49ca86bc6950
--- /dev/null
+++ b/lib/routes/bilibili/bilibili-recommend.ts
@@ -0,0 +1,43 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/precious/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/precious',
+ parameters: { embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '入站必刷',
+ maintainers: ['liuyuhe666'],
+ handler,
+};
+
+async function handler(ctx) {
+ const embed = !ctx.req.param('embed');
+ const response = await got({
+ method: 'get',
+ url: 'https://api.bilibili.com/x/web-interface/popular/precious',
+ headers: {
+ Referer: 'https://www.bilibili.com/v/popular/history',
+ },
+ });
+ const data = response.data.data.list;
+ return {
+ title: '哔哩哔哩入站必刷',
+ link: 'https://www.bilibili.com/v/popular/history',
+ item: data.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.desc || item.title, item.aid, undefined, item.bvid),
+ link: item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/cache.ts b/lib/routes/bilibili/cache.ts
new file mode 100644
index 00000000000000..cbacd15e757f2f
--- /dev/null
+++ b/lib/routes/bilibili/cache.ts
@@ -0,0 +1,389 @@
+import { load } from 'cheerio';
+import { JSDOM } from 'jsdom';
+import { RateLimiterMemory, RateLimiterQueue } from 'rate-limiter-flexible';
+
+import { config } from '@/config';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import logger from '@/utils/logger';
+import { getPuppeteerPage } from '@/utils/puppeteer';
+
+import utils from './utils';
+
+const subtitleLimiter = new RateLimiterMemory({
+ points: 5,
+ duration: 1,
+ execEvenly: true,
+});
+
+const subtitleLimiterQueue = new RateLimiterQueue(subtitleLimiter, {
+ maxQueueSize: 4800,
+});
+
+const getCookie = (disableConfig = false) => {
+ if (Object.keys(config.bilibili.cookies).length > 0 && !disableConfig) {
+ // Update b_lsid in cookies
+ for (const key of Object.keys(config.bilibili.cookies)) {
+ const cookie = config.bilibili.cookies[key];
+ if (cookie) {
+ const updatedCookie = cookie.replace(/b_lsid=[0-9A-F]+_[0-9A-F]+/, `b_lsid=${utils.lsid()}`);
+ config.bilibili.cookies[key] = updatedCookie;
+ }
+ }
+
+ return config.bilibili.cookies[Object.keys(config.bilibili.cookies)[Math.floor(Math.random() * Object.keys(config.bilibili.cookies).length)]] || '';
+ }
+ const key = 'bili-cookie';
+ return cache.tryGet(key, async () => {
+ let waitForRequest: Promise = new Promise((resolve) => {
+ resolve('');
+ });
+ const { destory } = await getPuppeteerPage('https://space.bilibili.com/1/dynamic', {
+ onBeforeLoad: (page) => {
+ waitForRequest = new Promise((resolve) => {
+ page.on('requestfinished', async (request) => {
+ if (request.url() === 'https://api.bilibili.com/x/web-interface/nav') {
+ const cookies = await page.cookies();
+ let cookieString = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
+ cookieString = cookieString.replace(/b_lsid=[0-9A-F]+_[0-9A-F]+/, `b_lsid=${utils.lsid()}`);
+ resolve(cookieString);
+ }
+ });
+ });
+ },
+ });
+ const cookieString = await waitForRequest;
+ logger.debug(`Got bilibili cookie: ${cookieString}`);
+ await destory();
+ return cookieString;
+ });
+};
+
+const getRenderData = (uid) => {
+ const key = 'bili-web-render-data';
+ return cache.tryGet(key, async () => {
+ const cookie = await getCookie();
+ const { data: response } = await got(`https://space.bilibili.com/${uid}`, {
+ headers: {
+ Referer: 'https://www.bilibili.com/',
+ Cookie: cookie,
+ },
+ });
+ const dom = new JSDOM(response);
+ const document = dom.window.document;
+ const scriptElement = document.querySelector('#__RENDER_DATA__');
+ const innerText = scriptElement ? scriptElement.textContent || '{}' : '{}';
+ const renderData = JSON.parse(decodeURIComponent(innerText));
+ const accessId = renderData.access_id;
+ return accessId;
+ });
+};
+
+const getWbiVerifyString = () => {
+ const key = 'bili-wbi-verify-string';
+ return cache.tryGet(key, async () => {
+ const cookie = await getCookie();
+ const { data: navResponse } = await got('https://api.bilibili.com/x/web-interface/nav', {
+ headers: {
+ Referer: 'https://www.bilibili.com/',
+ Cookie: cookie,
+ },
+ });
+ const imgUrl = navResponse.data.wbi_img.img_url;
+ const subUrl = navResponse.data.wbi_img.sub_url;
+ const r = imgUrl.substring(imgUrl.lastIndexOf('/') + 1, imgUrl.length).split('.')[0] + subUrl.substring(subUrl.lastIndexOf('/') + 1, subUrl.length).split('.')[0];
+ // const { body: spaceResponse } = await got('https://space.bilibili.com/1', {
+ // headers: {
+ // Referer: 'https://www.bilibili.com/',
+ // Cookie: cookie,
+ // },
+ // });
+ // const jsUrl = 'https:' + spaceResponse.match(/[^"]*9.space[^"]*/);
+ const jsUrl = 'https://s1.hdslb.com/bfs/seed/laputa-header/bili-header.umd.js';
+ const { body: jsResponse } = await got(jsUrl, {
+ headers: {
+ Referer: 'https://space.bilibili.com/1',
+ },
+ });
+ // const array = [
+ // 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57,
+ // 62, 11, 36, 20, 34, 44, 52,
+ // ];
+ const array = JSON.parse(jsResponse.match(/\[(?:\d+,){63}\d+]/));
+ const o = [];
+ for (const t of array) {
+ r.charAt(t) && o.push(r.charAt(t));
+ }
+ return o.join('').slice(0, 32);
+ });
+};
+
+const getUsernameFromUID = (uid) => {
+ const key = 'bili-username-from-uid-' + uid;
+ return cache.tryGet(key, async () => {
+ const cookie = await getCookie();
+ const wbiVerifyString = await getWbiVerifyString();
+ // await got(`https://space.bilibili.com/${uid}/`, {
+ // headers: {
+ // Referer: 'https://www.bilibili.com/',
+ // Cookie: cookie,
+ // },
+ // });
+ const params = utils.addWbiVerifyInfo(`mid=${uid}&token=&platform=web&web_location=1550101`, wbiVerifyString);
+ const { data: nameResponse } = await got(`https://api.bilibili.com/x/space/wbi/acc/info?${params}`, {
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ return nameResponse.data ? nameResponse.data.name : undefined;
+ });
+};
+
+const getUsernameAndFaceFromUID = async (uid) => {
+ const nameKey = 'bili-username-from-uid-' + uid;
+ const faceKey = 'bili-userface-from-uid-' + uid;
+ let name = await cache.get(nameKey);
+ let face = await cache.get(faceKey);
+ if (!name || !face) {
+ const cookie = await getCookie();
+ const wbiVerifyString = await getWbiVerifyString();
+ const dmImgList = utils.getDmImgList();
+ const renderData = await getRenderData(uid);
+ const params = utils.addWbiVerifyInfo(utils.addRenderData(utils.addDmVerifyInfo(`mid=${uid}&token=&platform=web&web_location=1550101`, dmImgList), renderData), wbiVerifyString);
+ const { data: nameResponse } = await got(`https://api.bilibili.com/x/space/wbi/acc/info?${params}`, {
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ if (nameResponse.data.name) {
+ name = nameResponse.data.name;
+ face = nameResponse.data.face;
+ cache.set(nameKey, nameResponse.data.name);
+ cache.set(faceKey, nameResponse.data.face);
+ } else {
+ logger.error(`Error when visiting /x/space/wbi/acc/info: ${JSON.stringify(nameResponse)}`);
+ }
+ }
+ return [name, face];
+};
+
+const getLiveIDFromShortID = (shortID) => {
+ const key = `bili-liveID-from-shortID-${shortID}`;
+ return cache.tryGet(key, async () => {
+ const { data: liveIDResponse } = await got(`https://api.live.bilibili.com/room/v1/Room/room_init?id=${shortID}`, {
+ headers: {
+ Referer: `https://live.bilibili.com/${shortID}`,
+ },
+ });
+ return liveIDResponse.data.room_id;
+ });
+};
+
+const getUserInfoFromLiveID = (liveID) => {
+ const key = `bili-userinfo-from-liveID-${liveID}`;
+ return cache.tryGet(key, async () => {
+ const { data: nameResponse } = await got(`https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room?roomid=${liveID}`, {
+ headers: {
+ Referer: `https://live.bilibili.com/${liveID}`,
+ },
+ });
+ return nameResponse.data.info;
+ });
+};
+
+const getVideoNameFromId = (aid, bvid) => {
+ const key = `bili-videoname-from-id-${bvid || aid}`;
+ return cache.tryGet(key, async () => {
+ const { data } = await got(`https://api.bilibili.com/x/web-interface/view`, {
+ searchParams: {
+ aid: aid || undefined,
+ bvid: bvid || undefined,
+ },
+ referer: `https://www.bilibili.com/video/${bvid || `av${aid}`}`,
+ });
+ return data.data.title;
+ });
+};
+
+const getCidFromId = (aid, pid, bvid) => {
+ const key = `bili-cid-from-id-${bvid || aid}-${pid}`;
+ return cache.tryGet(key, async () => {
+ const { data } = await got(`https://api.bilibili.com/x/web-interface/view?${bvid ? `bvid=${bvid}` : `aid=${aid}`}`, {
+ referer: `https://www.bilibili.com/video/${bvid || `av${aid}`}`,
+ });
+ return data?.data?.pages[pid - 1]?.cid;
+ });
+};
+
+interface SubtitleEntry {
+ from: number;
+ to: number;
+ sid: number;
+ content: string;
+ music: number;
+}
+
+function secondsToSrtTime(seconds: number): string {
+ const date = new Date(seconds * 1000);
+ const hh = String(date.getUTCHours()).padStart(2, '0');
+ const mm = String(date.getUTCMinutes()).padStart(2, '0');
+ const ss = String(date.getUTCSeconds()).padStart(2, '0');
+ const ms = String(date.getUTCMilliseconds()).padStart(3, '0');
+ return `${hh}:${mm}:${ss},${ms}`;
+}
+
+function convertJsonToSrt(body: SubtitleEntry[]): string {
+ return body
+ .map((item, index) => {
+ const start = secondsToSrtTime(item.from);
+ const end = secondsToSrtTime(item.to);
+ return `${index + 1}\n${start} --> ${end}\n${item.content}\n`;
+ })
+ .join('\n');
+}
+
+const getVideoSubtitle = async (
+ bvid: string
+): Promise<
+ Array<{
+ content: string;
+ lan_doc: string;
+ }>
+> => {
+ if (!bvid) {
+ return [];
+ }
+
+ const cid = await getCidFromId(undefined, 1, bvid);
+ if (!cid) {
+ return [];
+ }
+
+ return cache.tryGet(`bili-video-subtitle-${bvid}`, async () => {
+ await subtitleLimiterQueue.removeTokens(1);
+
+ const getSubtitleData = async (cookie: string) => {
+ const response = await got(`https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`, {
+ headers: {
+ Referer: `https://www.bilibili.com/video/${bvid}`,
+ Cookie: cookie,
+ },
+ });
+ return response;
+ };
+
+ const cookie = await getCookie();
+ const response = await getSubtitleData(cookie);
+ const subtitles = response?.data?.data?.subtitle?.subtitles || [];
+
+ return await Promise.all(
+ subtitles.map(async (subtitle) => {
+ const url = `https:${subtitle.subtitle_url}`;
+ const subtitleData = await cache.tryGet(url, async () => {
+ const subtitleResponse = await got(url);
+ return convertJsonToSrt(subtitleResponse?.data?.body || []);
+ });
+ return {
+ content: subtitleData,
+ lan_doc: subtitle.lan_doc,
+ };
+ })
+ );
+ });
+};
+
+const getVideoSubtitleAttachment = async (bvid: string) => {
+ const subtitles = await getVideoSubtitle(bvid);
+ return subtitles.map((subtitle) => ({
+ url: `data:text/plain;charset=utf-8,${encodeURIComponent(subtitle.content)}`,
+ mime_type: 'text/srt',
+ title: `字幕 - ${subtitle.lan_doc}`,
+ }));
+};
+
+const getAidFromBvid = async (bvid) => {
+ const key = `bili-cid-from-bvid-${bvid}`;
+ let aid = await cache.get(key);
+ if (!aid) {
+ const response = await got(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`, {
+ headers: {
+ Referer: `https://www.bilibili.com/video/${bvid}`,
+ },
+ });
+ if (response.data && response.data.data && response.data.data.aid) {
+ aid = response.data.data.aid;
+ }
+ cache.set(key, aid);
+ }
+ return aid;
+};
+
+const getArticleDataFromCvid = async (cvid, uid) => {
+ const url = `https://www.bilibili.com/read/cv${cvid}/`;
+ const data = await cache.tryGet(
+ url,
+ async () =>
+ (
+ await got({
+ method: 'get',
+ url,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ },
+ })
+ ).data
+ );
+ const $ = load(data);
+ let description = $('#read-article-holder').html();
+ if (!description) {
+ try {
+ const newFormatData = JSON.parse(
+ $('script:contains("window.__INITIAL_STATE__")')
+ .text()
+ .match(/window\.__INITIAL_STATE__\s*=\s*(.*?);\(/)[1]
+ );
+
+ if (newFormatData?.readInfo?.opus?.content?.paragraphs) {
+ description = '';
+ for (const element of newFormatData.readInfo.opus.content.paragraphs) {
+ if (element.para_type === 1) {
+ for (const text of element.text.nodes) {
+ if (text?.word?.words) {
+ description += `${text.word.words}
`;
+ }
+ }
+ }
+ if (element.para_type === 2) {
+ for (const image of element.pic.pics) {
+ description += `
`;
+ }
+ }
+ if (element.para_type === 3 && element.line?.pic?.url) {
+ description += ` `;
+ }
+ }
+ }
+ } catch {
+ /* empty */
+ }
+ }
+ return { url, description };
+};
+
+export default {
+ getCookie,
+ getWbiVerifyString,
+ getUsernameFromUID,
+ getUsernameAndFaceFromUID,
+ getLiveIDFromShortID,
+ getUserInfoFromLiveID,
+ getVideoNameFromId,
+ getCidFromId,
+ getAidFromBvid,
+ getArticleDataFromCvid,
+ getRenderData,
+ getVideoSubtitle,
+ getVideoSubtitleAttachment,
+};
diff --git a/lib/routes/bilibili/check-cookie.ts b/lib/routes/bilibili/check-cookie.ts
new file mode 100644
index 00000000000000..f320751b657874
--- /dev/null
+++ b/lib/routes/bilibili/check-cookie.ts
@@ -0,0 +1,42 @@
+import type { APIRoute } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import cacheIn from './cache';
+
+export const apiRoute: APIRoute = {
+ path: '/check-cookie',
+ description: '检查 bilibili cookie 是否有效',
+ maintainers: ['DIYgod'],
+ handler,
+};
+
+async function handler() {
+ const cookie = await cacheIn.getCookie();
+
+ if (!cookie) {
+ return {
+ code: -1,
+ };
+ }
+
+ const response = await ofetch(`https://api.bilibili.com/x/web-interface/nav`, {
+ headers: {
+ Referer: `https://space.bilibili.com/1/`,
+ Cookie: cookie as string,
+ },
+ });
+ const isResponseValid = response.code === 0 && !!response.data.mid;
+
+ const subtitleResponse = await ofetch(`https://api.bilibili.com/x/player/wbi/v2?bvid=BV1iU411o7R2&cid=1550543560`, {
+ headers: {
+ Referer: `https://www.bilibili.com/video/BV1iU411o7R2`,
+ Cookie: cookie,
+ },
+ });
+ const subtitles = subtitleResponse?.data?.subtitle?.subtitles || [];
+ const isSubtitleResponseValid = subtitleResponse?.data?.permission !== '0' && subtitles.length > 0;
+
+ return {
+ code: isResponseValid && isSubtitleResponseValid ? 0 : -1,
+ };
+}
diff --git a/lib/routes/bilibili/coin.ts b/lib/routes/bilibili/coin.ts
new file mode 100644
index 00000000000000..3936af22d6f073
--- /dev/null
+++ b/lib/routes/bilibili/coin.ts
@@ -0,0 +1,61 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/user/coin/:uid/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/user/coin/208259',
+ parameters: { uid: '用户 id, 可在 UP 主主页中找到', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['space.bilibili.com/:uid'],
+ target: '/user/coin/:uid',
+ },
+ ],
+ name: 'UP 主投币视频',
+ maintainers: ['DIYgod'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const embed = !ctx.req.param('embed');
+
+ const name = await cache.getUsernameFromUID(uid);
+
+ const response = await got({
+ url: `https://api.bilibili.com/x/space/coin/video?vmid=${uid}`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ },
+ });
+ const { data, code, message } = response.data;
+ if (code) {
+ throw new Error(message ?? code);
+ }
+
+ return {
+ title: `${name} 的 bilibili 投币视频`,
+ link: `https://space.bilibili.com/${uid}`,
+ description: `${name} 的 bilibili 投币视频`,
+ item: data.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.desc, item.aid, undefined, item.bvid),
+ pubDate: parseDate(item.time * 1000),
+ link: item.time > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: item.owner.name,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/danmaku.ts b/lib/routes/bilibili/danmaku.ts
new file mode 100644
index 00000000000000..a29a8df35585f3
--- /dev/null
+++ b/lib/routes/bilibili/danmaku.ts
@@ -0,0 +1,81 @@
+import zlib from 'node:zlib';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+
+const processFloatTime = (time) => {
+ const totalSeconds = Number.parseInt(time);
+ const seconds = totalSeconds % 60;
+ const minutes = ((totalSeconds - seconds) / 60) % 60;
+ const hours = (totalSeconds - seconds - 60 * minutes) / 3600;
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+};
+
+export const route: Route = {
+ path: '/video/danmaku/:bvid/:pid?',
+ categories: ['social-media'],
+ example: '/bilibili/video/danmaku/BV1vA411b7ip/1',
+ parameters: { bvid: '视频AV号,可在视频页 URL 中找到', pid: '分P号,不填默认为1' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '视频弹幕',
+ maintainers: ['Qixingchen'],
+ handler,
+};
+
+async function handler(ctx) {
+ let bvid = ctx.req.param('bvid');
+ let aid;
+ if (!bvid.startsWith('BV')) {
+ aid = bvid;
+ bvid = null;
+ }
+ const pid = Number(ctx.req.param('pid') || 1);
+ const limit = 50;
+ const cid = await cache.getCidFromId(aid, pid, bvid);
+
+ const videoName = await cache.getVideoNameFromId(aid, bvid);
+
+ const link = `https://www.bilibili.com/video/${bvid || `av${aid}`}`;
+ const danmakuResponse = await got.get(`https://comment.bilibili.com/${cid}.xml`, {
+ decompress: false,
+ responseType: 'buffer',
+ headers: {
+ Referer: link,
+ },
+ });
+
+ let danmakuText = danmakuResponse.body;
+
+ danmakuText = await ((danmakuText[0] & 0x0f) === 0x08 ? zlib.inflateSync(danmakuText) : zlib.inflateRawSync(danmakuText));
+
+ let danmakuList = [];
+ const $ = load(danmakuText, { xmlMode: true });
+ $('d').each((index, item) => {
+ danmakuList.push({ p: $(item).attr('p'), text: $(item).text() });
+ });
+
+ danmakuList = danmakuList.toReversed().slice(0, limit);
+
+ return {
+ title: `${videoName} 的 弹幕动态`,
+ link,
+ description: `${videoName} 的 弹幕动态`,
+ item: danmakuList.map((item) => ({
+ title: `[${processFloatTime(item.p.split(',')[0])}] ${item.text}`,
+ pubDate: new Date(item.p.split(',')[4] * 1000).toUTCString(),
+ guid: `${cid}-${item.p.split(',')[4]}-${item.p.split(',')[7]}`,
+ link,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/dynamic.ts b/lib/routes/bilibili/dynamic.ts
new file mode 100644
index 00000000000000..c5adbaac4e9afc
--- /dev/null
+++ b/lib/routes/bilibili/dynamic.ts
@@ -0,0 +1,461 @@
+import JSONbig from 'json-bigint';
+
+import { config } from '@/config';
+import CaptchaError from '@/errors/types/captcha';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDuration } from '@/utils/helpers';
+import { parseDate } from '@/utils/parse-date';
+import { fallback, queryToBoolean } from '@/utils/readable-social';
+
+import type { BilibiliWebDynamicResponse, Item2, Modules } from './api-interface';
+import cacheIn from './cache';
+import utils, { getLiveUrl, getVideoUrl } from './utils';
+
+export const route: Route = {
+ path: '/user/dynamic/:uid/:routeParams?',
+ categories: ['social-media'],
+ view: ViewType.SocialMedia,
+ example: '/bilibili/user/dynamic/2267573',
+ parameters: {
+ uid: '用户 id, 可在 UP 主主页中找到',
+ routeParams: `
+| 键 | 含义 | 接受的值 | 默认值 |
+| ---------- | --------------------------------- | -------------- | ------ |
+| showEmoji | 显示或隐藏表情图片 | 0/1/true/false | false |
+| embed | 默认开启内嵌视频 | 0/1/true/false | true |
+| useAvid | 视频链接使用 AV 号 (默认为 BV 号) | 0/1/true/false | false |
+| directLink | 使用内容直链 | 0/1/true/false | false |
+| hideGoods | 隐藏带货动态 | 0/1/true/false | false |
+| offset | 偏移状态 | string | "" |
+
+用例:\`/bilibili/user/dynamic/2267573/showEmoji=1&embed=0&useAvid=1\``,
+ },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ optional: true,
+ description: `如果没有此配置,那么必须开启 puppeteer 支持;BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+2. 打开控制台,切换到 Network 面板,刷新
+3. 点击 dynamic_new 请求,找到 Cookie
+4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['space.bilibili.com/:uid'],
+ target: '/user/dynamic/:uid',
+ },
+ ],
+ name: 'UP 主动态',
+ maintainers: ['DIYgod', 'zytomorrow', 'CaoMeiYouRen', 'JimenezLi'],
+ handler,
+};
+
+const getTitle = (data: Modules): string => {
+ const major = data.module_dynamic?.major;
+ if (!major) {
+ return '';
+ }
+ if (major.none) {
+ return major.none.tips;
+ }
+ if (major.courses) {
+ return `${major.courses?.title} - ${major.courses?.sub_title}`;
+ }
+ if (major.live_rcmd?.content) {
+ // 正在直播的动态
+ return JSON.parse(major.live_rcmd.content)?.live_play_info?.title;
+ }
+ const type = major.type.replace('MAJOR_TYPE_', '').toLowerCase();
+ return major[type]?.title;
+};
+const getDes = (data: Modules): string => {
+ let desc = '';
+ if (data.module_dynamic?.desc?.text) {
+ desc += data.module_dynamic.desc.text;
+ }
+ const major = data.module_dynamic?.major;
+ // 普通转发
+ if (!major) {
+ return desc;
+ }
+ // 普通分享
+ if (major?.common?.desc) {
+ desc += desc ? ` //转发自: ${major.common.desc}` : major.common.desc;
+ return desc;
+ }
+ // 转发的直播间
+ if (major?.live) {
+ return `${major.live?.desc_first} ${major.live?.desc_second}`;
+ }
+ // 正在直播的动态
+ if (major.live_rcmd?.content) {
+ const live_play_info = JSON.parse(major.live_rcmd.content)?.live_play_info;
+ return `${live_play_info?.area_name}·${live_play_info?.watched_show?.text_large}`;
+ }
+ // 图文动态
+ if (major?.opus) {
+ return major?.opus?.summary?.text;
+ }
+ const type = major.type.replace('MAJOR_TYPE_', '').toLowerCase();
+ return major[type]?.desc;
+};
+
+const getOriginTitle = (data?: Modules) => data && getTitle(data);
+const getOriginDes = (data?: Modules) => data && getDes(data);
+const getOriginName = (data?: Modules) => data?.module_author?.name;
+const getIframe = (data?: Modules, embed: boolean = true) => {
+ if (!embed) {
+ return '';
+ }
+ const aid = data?.module_dynamic?.major?.archive?.aid;
+ const bvid = data?.module_dynamic?.major?.archive?.bvid;
+ if (aid === undefined && bvid === undefined) {
+ return '';
+ }
+ // 不通过 utils.renderUGCDescription 渲染 img/description 以兼容其他格式的动态
+ return utils.renderUGCDescription(embed, '', '', aid, undefined, bvid);
+};
+
+const getImgs = (data?: Modules) => {
+ const imgUrls: {
+ url: string;
+ width?: number;
+ height?: number;
+ }[] = [];
+ const major = data?.module_dynamic?.major;
+ if (!major) {
+ return '';
+ }
+ // 动态图片
+ if (major.opus?.pics?.length) {
+ imgUrls.push(
+ ...major.opus.pics.map((e) => ({
+ url: e.url,
+ width: e.width,
+ height: e.height,
+ }))
+ );
+ }
+ // 专栏封面
+ if (major.article?.covers?.length) {
+ imgUrls.push(
+ ...major.article.covers.map((e) => ({
+ url: e,
+ }))
+ );
+ }
+ // 相簿
+ if (major.draw?.items?.length) {
+ imgUrls.push(
+ ...major.draw.items.map((e) => ({
+ url: e.src,
+ width: e.width,
+ height: e.height,
+ }))
+ );
+ }
+ // 正在直播的动态
+ if (major.live_rcmd?.content) {
+ imgUrls.push({
+ url: JSON.parse(major.live_rcmd.content)?.live_play_info?.cover,
+ });
+ }
+ const type = major.type.replace('MAJOR_TYPE_', '').toLowerCase();
+ if (major[type]?.cover) {
+ imgUrls.push({
+ url: major[type]?.cover,
+ });
+ }
+ return imgUrls
+ .filter(Boolean)
+ .map((img) => ` `)
+ .join('');
+};
+
+const getUrl = (item?: Item2, useAvid = false) => {
+ const data = item?.modules;
+ if (!data) {
+ return null;
+ }
+ let url = '';
+ let text = '';
+ let videoPageUrl;
+ const major = data.module_dynamic?.major;
+ if (!major) {
+ return null;
+ }
+ switch (major?.type) {
+ case 'MAJOR_TYPE_UGC_SEASON':
+ url = major?.ugc_season?.jump_url || '';
+ text = `合集地址:${url} `;
+ break;
+ case 'MAJOR_TYPE_ARTICLE':
+ url = `https://www.bilibili.com/read/cv${major?.article?.id}`;
+ text = `专栏地址:${url} `;
+ break;
+ case 'MAJOR_TYPE_ARCHIVE': {
+ const archive = major?.archive;
+ const id = useAvid ? `av${archive?.aid}` : archive?.bvid;
+ url = `https://www.bilibili.com/video/${id}`;
+ text = `视频地址:${url} `;
+ videoPageUrl = getVideoUrl(archive?.bvid);
+ break;
+ }
+ case 'MAJOR_TYPE_COMMON':
+ url = major?.common?.jump_url || '';
+ text = `地址:${url} `;
+ break;
+ case 'MAJOR_TYPE_OPUS':
+ if (item?.type === 'DYNAMIC_TYPE_ARTICLE') {
+ url = `https:${major?.opus?.jump_url}`;
+ text = `专栏地址:${url} `;
+ } else if (item?.type === 'DYNAMIC_TYPE_DRAW') {
+ url = `https:${major?.opus?.jump_url}`;
+ text = `图文地址:${url} `;
+ }
+ break;
+ case 'MAJOR_TYPE_PGC': {
+ const pgc = major?.pgc;
+ url = `https://www.bilibili.com/bangumi/play/ep${pgc?.epid}&season_id=${pgc?.season_id}`;
+ text = `剧集地址:${url} `;
+ break;
+ }
+ case 'MAJOR_TYPE_COURSES':
+ url = `https://www.bilibili.com/cheese/play/ss${major?.courses?.id}`;
+ text = `课程地址:${url} `;
+ break;
+ case 'MAJOR_TYPE_MUSIC':
+ url = `https://www.bilibili.com/audio/au${major?.music?.id}`;
+ text = `音频地址:${url} `;
+ break;
+ case 'MAJOR_TYPE_LIVE':
+ url = `https://live.bilibili.com/${major?.live?.id}`;
+ text = `直播间地址:${url} `;
+ break;
+ case 'MAJOR_TYPE_LIVE_RCMD': {
+ const live_play_info = JSON.parse(major.live_rcmd?.content || '{}')?.live_play_info;
+ url = `https://live.bilibili.com/${live_play_info?.room_id}`;
+ videoPageUrl = getLiveUrl(live_play_info?.room_id);
+ text = `直播间地址:${url} `;
+ break;
+ }
+ default:
+ return null;
+ }
+ return {
+ url,
+ text,
+ videoPageUrl,
+ };
+};
+
+async function handler(ctx) {
+ const isJsonFeed = ctx.req.query('format') === 'json';
+ const uid = ctx.req.param('uid');
+ const routeParams = Object.fromEntries(new URLSearchParams(ctx.req.param('routeParams')));
+ const showEmoji = fallback(undefined, queryToBoolean(routeParams.showEmoji), false);
+ const embed = fallback(undefined, queryToBoolean(routeParams.embed), false);
+ const displayArticle = ctx.req.query('mode') === 'fulltext';
+ const offset = fallback(undefined, routeParams.offset, '');
+ const useAvid = fallback(undefined, queryToBoolean(routeParams.useAvid), false);
+ const directLink = fallback(undefined, queryToBoolean(routeParams.directLink), false);
+ const hideGoods = fallback(undefined, queryToBoolean(routeParams.hideGoods), false);
+
+ const getDynamic = async (cookie: string) => {
+ const params = utils.addDmVerifyInfo(`offset=${offset}&host_mid=${uid}&platform=web&features=itemOpusStyle,listOnlyfans,opusBigCover,onlyfansVote`, utils.getDmImgList());
+ const response = await got(`https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space?${params}`, {
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ const body = JSONbig.parse(response.body);
+ return body;
+ };
+
+ let body: BilibiliWebDynamicResponse;
+
+ const cookie = (await cacheIn.getCookie()) as string;
+ body = await getDynamic(cookie);
+
+ if (body?.code === -352) {
+ const cookie = (await cacheIn.getCookie(true)) as string;
+ body = await getDynamic(cookie);
+
+ if (body?.code === -352) {
+ cache.set('bili-cookie', '');
+ throw new CaptchaError('遇到源站风控校验,请稍后再试');
+ }
+ }
+ const items = (body as BilibiliWebDynamicResponse)?.data?.items;
+
+ let author = items[0]?.modules?.module_author?.name;
+ let face = items[0]?.modules?.module_author?.face;
+ if (!face || !author) {
+ const usernameAndFace = await cacheIn.getUsernameAndFaceFromUID(uid);
+ author = usernameAndFace[0] || items[0]?.modules?.module_author?.name;
+ face = usernameAndFace[1] || items[0]?.modules?.module_author?.face;
+ } else {
+ cache.set(`bili-username-from-uid-${uid}`, author);
+ cache.set(`bili-userface-from-uid-${uid}`, face);
+ }
+
+ const rssItems = await Promise.all(
+ items
+ .filter((item) => {
+ if (hideGoods) {
+ return item.modules.module_dynamic?.additional?.type !== 'ADDITIONAL_TYPE_GOODS';
+ }
+ return true;
+ })
+ .map(async (item) => {
+ // const parsed = JSONbig.parse(item.card);
+
+ const data = item.modules;
+ const origin = item?.orig?.modules;
+ const bvid = data?.module_dynamic?.major?.archive?.bvid;
+
+ // link
+ let link = '';
+ if (item.id_str) {
+ link = `https://t.bilibili.com/${item.id_str}`;
+ }
+
+ const originalDescription = getDes(data) || '';
+ let description = originalDescription;
+ const title = getTitle(data);
+ const category: string[] = [];
+ // emoji
+ if (data.module_dynamic?.desc?.rich_text_nodes?.length) {
+ const nodes = data.module_dynamic.desc.rich_text_nodes;
+ for (const node of nodes) {
+ // 处理 emoji 的情况
+ if (showEmoji && node?.emoji) {
+ const emoji = node.emoji;
+ description = description.replaceAll(
+ emoji.text,
+ ` `
+ );
+ }
+ // 处理转发带图评论的情况
+ if (node?.pics?.length) {
+ const { pics, text } = node;
+ description = description.replaceAll(
+ text,
+ pics
+ .map(
+ (pic) =>
+ ` `
+ )
+ .join(' ')
+ );
+ }
+ if (node?.type === 'RICH_TEXT_NODE_TYPE_TOPIC') {
+ // 将话题作为 category
+ category.push(node.text.match(/#(\S+)#/)?.[1] || '');
+ }
+ }
+ }
+
+ if (data.module_dynamic?.major?.opus?.summary?.rich_text_nodes?.length) {
+ const nodes = data.module_dynamic.major.opus.summary.rich_text_nodes;
+ for (const node of nodes) {
+ if (node?.type === 'RICH_TEXT_NODE_TYPE_TOPIC') {
+ // 将话题作为 category
+ category.push(node.text.match(/#(\S+)#/)?.[1] || '');
+ }
+ }
+ }
+ if (data.module_dynamic?.topic?.name) {
+ // 将话题作为 category
+ category.push(data.module_dynamic.topic.name);
+ }
+
+ if (item.type === 'DYNAMIC_TYPE_ARTICLE' && displayArticle) {
+ // 抓取专栏全文
+ const cvid = data.module_dynamic?.major?.opus?.jump_url?.match?.(/cv(\d+)/)?.[0];
+ if (cvid) {
+ description = (await cacheIn.getArticleDataFromCvid(cvid, uid)).description || '';
+ }
+ }
+
+ const urlResult = getUrl(item, useAvid);
+ const urlText = urlResult?.text;
+ if (urlResult && directLink) {
+ link = urlResult.url;
+ }
+
+ const originUrlResult = getUrl(item?.orig, useAvid);
+ const originUrlText = originUrlResult?.text;
+ if (originUrlResult && directLink) {
+ link = originUrlResult.url;
+ }
+
+ let originDescription = '';
+ const originName = getOriginName(origin);
+ const originTitle = getOriginTitle(origin);
+ const originDes = getOriginDes(origin);
+ if (originName) {
+ originDescription += `//转发自: @${getOriginName(origin)}: `;
+ }
+ if (originTitle) {
+ originDescription += originTitle;
+ }
+ if (originDes) {
+ originDescription += ` ${originDes}`;
+ }
+
+ // 换行处理
+ description = description.replaceAll('\r\n', ' ').replaceAll('\n', ' ');
+ originDescription = originDescription.replaceAll('\r\n', ' ').replaceAll('\n', ' ');
+ const descriptions = [title, description, getIframe(data, embed), getImgs(data), urlText, originDescription, getIframe(origin, embed), getImgs(origin), originUrlText]
+ .map((e) => e?.trim())
+ .filter(Boolean)
+ .join(' ');
+
+ const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && bvid ? await cacheIn.getVideoSubtitleAttachment(bvid) : [];
+
+ return {
+ title: title || originalDescription,
+ description: descriptions,
+ pubDate: data.module_author?.pub_ts ? parseDate(data.module_author.pub_ts, 'X') : undefined,
+ link,
+ author,
+ category: category.length ? [...new Set(category)] : undefined,
+ attachments:
+ urlResult?.videoPageUrl || originUrlResult?.videoPageUrl
+ ? [
+ {
+ url: urlResult?.videoPageUrl || originUrlResult?.videoPageUrl,
+ mime_type: 'text/html',
+ duration_in_seconds: data.module_dynamic?.major?.archive?.duration_text ? parseDuration(data.module_dynamic.major.archive.duration_text) : undefined,
+ },
+ ...subtitles,
+ ]
+ : undefined,
+ };
+ })
+ );
+
+ return {
+ title: `${author} 的 bilibili 动态`,
+ link: `https://space.bilibili.com/${uid}/dynamic`,
+ description: `${author} 的 bilibili 动态`,
+ image: face,
+ logo: face,
+ icon: face,
+ item: rssItems,
+ };
+}
diff --git a/lib/routes/bilibili/fav.ts b/lib/routes/bilibili/fav.ts
new file mode 100644
index 00000000000000..f1d1045480226d
--- /dev/null
+++ b/lib/routes/bilibili/fav.ts
@@ -0,0 +1,61 @@
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/fav/:uid/:fid/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/fav/756508/50948568',
+ parameters: { uid: '用户 id, 可在 UP 主主页中找到', fid: '收藏夹 ID, 可在收藏夹的 URL 中找到, 默认收藏夹建议使用 UP 主默认收藏夹功能', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'UP 主非默认收藏夹',
+ maintainers: ['Qixingchen'],
+ handler,
+};
+
+async function handler(ctx) {
+ const fid = ctx.req.param('fid');
+ const uid = ctx.req.param('uid');
+ const embed = !ctx.req.param('embed');
+
+ const response = await got({
+ url: `https://api.bilibili.com/x/v3/fav/resource/list?media_id=${fid}&ps=20`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: config.bilibili.cookies[uid],
+ },
+ });
+ const { data, code, message } = response.data;
+ if (code) {
+ throw new Error(message ?? code);
+ }
+
+ const userName = data.info.upper.name;
+ const favName = data.info.title;
+
+ return {
+ title: `${userName} 的 bilibili 收藏夹 ${favName}`,
+ link: `https://space.bilibili.com/${uid}/#/favlist?fid=${fid}`,
+ description: `${userName} 的 bilibili 收藏夹 ${favName}`,
+
+ item:
+ data.medias &&
+ data.medias.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.cover, item.intro, item.id, undefined, item.bvid),
+ pubDate: parseDate(item.fav_time * 1000),
+ link: item.fav_time > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.id}`,
+ author: item.upper.name,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/followers.ts b/lib/routes/bilibili/followers.ts
new file mode 100644
index 00000000000000..b0b34adf13d67f
--- /dev/null
+++ b/lib/routes/bilibili/followers.ts
@@ -0,0 +1,88 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/user/followers/:uid/:loginUid',
+ categories: ['social-media'],
+ example: '/bilibili/user/followers/2267573/3',
+ parameters: { uid: '用户 id, 可在 UP 主主页中找到', loginUid: '用于登入的用户id,需要配置对应的 Cookie 值' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+2. 打开控制台,切换到 Network 面板,刷新
+3. 点击 dynamic_new 请求,找到 Cookie
+4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['space.bilibili.com/:uid'],
+ target: '/user/followers/:uid',
+ },
+ ],
+ name: 'UP 主粉丝',
+ maintainers: ['Qixingchen'],
+ handler,
+ description: `::: warning
+ UP 主粉丝现在需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const loginUid = ctx.req.param('loginUid');
+
+ const cookie = config.bilibili.cookies[loginUid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 loginUid 的 Bilibili 用户登录后的 Cookie 值 bilibili 用户关注动态系列路由 ');
+ }
+
+ const name = await cache.getUsernameFromUID(uid);
+
+ const countResponse = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/relation/stat?vmid=${uid}`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ },
+ });
+ const count = countResponse.data.data.follower;
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/relation/followers?vmid=${uid}`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ if (response.data.code === -6 || response.data.code === -101) {
+ throw new ConfigNotFoundError('对应 loginUid 的 Bilibili 用户的 Cookie 已过期');
+ }
+ const data = response.data.data.list;
+
+ return {
+ title: `${name} 的 bilibili 粉丝`,
+ link: `https://space.bilibili.com/${uid}/#/fans/fans`,
+ description: `${name} 的 bilibili 粉丝`,
+ item: data.map((item) => ({
+ title: `${name} 新粉丝 ${item.uname}`,
+ description: `${item.uname} ${item.sign} 总计${count}`,
+ pubDate: new Date(item.mtime * 1000),
+ link: `https://space.bilibili.com/${item.mid}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/followings-article.ts b/lib/routes/bilibili/followings-article.ts
new file mode 100644
index 00000000000000..1798fd9352bf81
--- /dev/null
+++ b/lib/routes/bilibili/followings-article.ts
@@ -0,0 +1,81 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/followings/article/:uid',
+ categories: ['social-media'],
+ example: '/bilibili/followings/article/99800931',
+ parameters: { uid: '用户 id' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '用户关注专栏',
+ maintainers: ['woshiluo'],
+ handler,
+ description: `::: warning
+ 用户动态需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+async function handler(ctx) {
+ const uid = String(ctx.req.param('uid'));
+ const name = await cache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=${uid}&type=64`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ if (response.data.code === -6) {
+ throw new ConfigNotFoundError('对应 uid 的 Bilibili 用户的 Cookie 已过期');
+ }
+ const cards = response.data.data.cards;
+
+ const out = await Promise.all(
+ cards.map(async (card) => {
+ const card_data = JSON.parse(card.card);
+ const { url: link, description } = await cache.getArticleDataFromCvid(card_data.id, uid);
+
+ const item = {
+ title: card_data.title,
+ description,
+ pubDate: new Date(card_data.publish_time * 1000).toUTCString(),
+ link,
+ author: card.desc.user_profile.info.uname,
+ };
+ return item;
+ })
+ );
+
+ return {
+ title: `${name} 关注专栏动态`,
+ link: `https://t.bilibili.com/?tab=64`,
+ item: out,
+ };
+}
diff --git a/lib/routes/bilibili/followings-dynamic.ts b/lib/routes/bilibili/followings-dynamic.ts
new file mode 100644
index 00000000000000..5bdda1f5d7bb4f
--- /dev/null
+++ b/lib/routes/bilibili/followings-dynamic.ts
@@ -0,0 +1,219 @@
+import querystring from 'node:querystring';
+
+import JSONbig from 'json-bigint';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import logger from '@/utils/logger';
+import { fallback, queryToBoolean } from '@/utils/readable-social';
+
+import cache from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/followings/dynamic/:uid/:routeParams?',
+ categories: ['social-media'],
+ example: '/bilibili/followings/dynamic/109937383',
+ parameters: {
+ uid: '用户 id, 可在 UP 主主页中找到',
+ routeParams: `
+| 键 | 含义 | 接受的值 | 默认值 |
+| ---------- | --------------------------------- | -------------- | ------ |
+| showEmoji | 显示或隐藏表情图片 | 0/1/true/false | false |
+| embed | 默认开启内嵌视频 | 0/1/true/false | true |
+| useAvid | 视频链接使用 AV 号 (默认为 BV 号) | 0/1/true/false | false |
+| directLink | 使用内容直链 | 0/1/true/false | false |
+| hideGoods | 隐藏带货动态 | 0/1/true/false | false |
+
+用例:\`/bilibili/followings/dynamic/2267573/showEmoji=1&embed=0&useAvid=1\``,
+ },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '用户关注动态',
+ maintainers: ['TigerCubDen', 'JimenezLi'],
+ handler,
+ description: `::: warning
+ 用户动态需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+async function handler(ctx) {
+ const uid = String(ctx.req.param('uid'));
+ const routeParams = querystring.parse(ctx.req.param('routeParams'));
+
+ const showEmoji = fallback(undefined, queryToBoolean(routeParams.showEmoji), false);
+ const embed = fallback(undefined, queryToBoolean(routeParams.embed), true);
+ const displayArticle = fallback(undefined, queryToBoolean(routeParams.displayArticle), false);
+
+ const name = await cache.getUsernameFromUID(uid);
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=${uid}&type_list=268435455`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ if (response.data.code === -6) {
+ throw new ConfigNotFoundError('对应 uid 的 Bilibili 用户的 Cookie 已过期');
+ }
+ if (response.data.code === 4_100_000) {
+ throw new ConfigNotFoundError('对应 uid 的 Bilibili 用户 请求失败');
+ }
+ const data = JSONbig.parse(response.body).data.cards;
+
+ const getTitle = (data) => (data ? data.title || data.description || data.content || (data.vest && data.vest.content) || '' : '');
+ const getDes = (data) =>
+ data.dynamic || data.desc || data.description || data.content || data.summary || (data.vest && data.vest.content) + (data.sketch && ` ${data.sketch.title} ${data.sketch.desc_text}`) || data.intro || '';
+ const getOriginDes = (data) => (data && (data.apiSeasonInfo && data.apiSeasonInfo.title && `//转发自: ${data.apiSeasonInfo.title}`) + (data.index_title && ` ${data.index_title}`)) || '';
+ const getOriginName = (data) => data.uname || (data.author && data.author.name) || (data.upper && data.upper.name) || (data.user && (data.user.uname || data.user.name)) || (data.owner && data.owner.name) || '';
+ const getOriginTitle = (data) => (data.title ? `${data.title} ` : '');
+ const getIframe = (data) => {
+ if (!embed) {
+ return '';
+ }
+ const aid = data?.aid;
+ const bvid = data?.bvid;
+ if (aid === undefined && bvid === undefined) {
+ return '';
+ }
+ return utils.renderUGCDescription(embed, '', '', aid, undefined, bvid);
+ };
+ const getImgs = (data) => {
+ let imgs = '';
+ // 动态图片
+ if (data.pictures) {
+ for (let i = 0; i < data.pictures.length; i++) {
+ imgs += ` `;
+ }
+ }
+ // 专栏封面
+ if (data.image_urls) {
+ for (let i = 0; i < data.image_urls.length; i++) {
+ imgs += ` `;
+ }
+ }
+ // 视频封面
+ if (data.pic) {
+ imgs += ` `;
+ }
+ // 音频/番剧/直播间封面/小视频封面
+ if (data.cover && data.cover.unclipped) {
+ imgs += ` `;
+ } else if (data.cover) {
+ imgs += ` `;
+ }
+ // 专题页封面
+ if (data.sketch && data.sketch.cover_url) {
+ imgs += ` `;
+ }
+ return imgs;
+ };
+
+ const items = await Promise.all(
+ data.map(async (item) => {
+ const parsed = JSONbig.parse(item.card);
+ const data = parsed.apiSeasonInfo || (getTitle(parsed.item) ? parsed.item : parsed);
+ let origin = parsed.origin;
+ if (origin) {
+ try {
+ origin = JSONbig.parse(origin);
+ } catch {
+ logger.warn(`card.origin '${origin}' is not falsy-valued or a JSON string, fall back to unparsed value`);
+ }
+ }
+
+ // img
+ let imgHTML = '';
+
+ imgHTML += getImgs(data);
+
+ if (origin) {
+ imgHTML += getImgs(origin.item || origin);
+ }
+ // video小视频
+ let videoHTML = '';
+ if (data.video_playurl) {
+ videoHTML += ` `;
+ }
+ // some rss readers disallow http content.
+ // 部分 RSS 阅读器要求内容必须使用https传输
+ // bilibili short video does support https request, but https request may timeout ocassionally.
+ // to maximize content availability, here add two source tags.
+ // bilibili的API中返回的视频地址采用http,然而经验证,短视频地址支持https访问,但偶尔会返回超时错误(可能是网络原因)。
+ // 因此保险起见加入两个source标签
+ // link
+ let link = '';
+ if (data.dynamic_id) {
+ link = `https://t.bilibili.com/${data.dynamic_id}`;
+ } else if (item.desc?.dynamic_id) {
+ link = `https://t.bilibili.com/${item.desc.dynamic_id}`;
+ }
+
+ // emoji
+ let data_content = getDes(data);
+ if (item.display && item.display.emoji_info && showEmoji) {
+ const emoji = item.display.emoji_info.emoji_details;
+ for (const item of emoji) {
+ data_content = data_content.replaceAll(
+ new RegExp(`\\${item.text}`, 'g'),
+ ` `
+ );
+ }
+ }
+ // 作者信息
+ let author = '';
+ if (item.desc?.user_profile) {
+ author = item.desc.user_profile.info.uname;
+ }
+
+ if (data.image_urls && displayArticle) {
+ data_content = (await cache.getArticleDataFromCvid(data.id, uid)).description;
+ }
+
+ return {
+ title: getTitle(data),
+ author,
+ description: (() => {
+ const description = parsed.new_desc || data_content || getDes(data);
+ const originName = origin && getOriginName(origin) ? ` //转发自: @${getOriginName(origin)}: ${getOriginTitle(origin.item || origin)}${getDes(origin.item || origin)}` : getOriginDes(origin);
+ const imgHTMLSource = imgHTML ? ` ${imgHTML}` : '';
+ const videoHTMLSource = videoHTML ? ` ${videoHTML}` : '';
+
+ return `${description}${originName}${getIframe(data)}${getIframe(origin)}${imgHTMLSource}${videoHTMLSource}`;
+ })(),
+ pubDate: new Date(item.desc?.timestamp * 1000).toUTCString(),
+ link,
+ };
+ })
+ );
+
+ return {
+ title: `${name} 关注的动态`,
+ link: `https://t.bilibili.com`,
+ description: `${name} 关注的动态`,
+ item: items,
+ };
+}
diff --git a/lib/routes/bilibili/followings-video.ts b/lib/routes/bilibili/followings-video.ts
new file mode 100644
index 00000000000000..dd64b01bd10eba
--- /dev/null
+++ b/lib/routes/bilibili/followings-video.ts
@@ -0,0 +1,85 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import logger from '@/utils/logger';
+
+import cache from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/followings/video/:uid/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/followings/video/2267573',
+ parameters: { uid: '用户 id', embed: '默认为开启内嵌视频,任意值为关闭' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '用户关注视频动态',
+ maintainers: ['LogicJake'],
+ handler,
+ description: `::: warning
+ 用户动态需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+async function handler(ctx) {
+ const uid = String(ctx.req.param('uid'));
+ const embed = !ctx.req.param('embed');
+ const name = await cache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=${uid}&type=8`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ const data = response.data;
+ if (data.code) {
+ logger.error(JSON.stringify(data));
+ if (data.code === -6 || data.code === 4_100_000) {
+ throw new ConfigNotFoundError('对应 uid 的 Bilibili 用户的 Cookie 已过期');
+ }
+ throw new Error(`Got error code ${data.code} while fetching: ${data.message}`);
+ }
+ const cards = data.data.cards;
+
+ const out = cards.map((card) => {
+ const card_data = JSON.parse(card.card);
+
+ return {
+ title: card_data.title,
+ description: utils.renderUGCDescription(embed, card_data.pic, card_data.desc, card_data.aid, undefined, card.desc.bvid),
+ pubDate: new Date(card_data.pubdate * 1000).toUTCString(),
+ link: card_data.pubdate > utils.bvidTime && card.desc.bvid ? `https://www.bilibili.com/video/${card.desc.bvid}` : `https://www.bilibili.com/video/av${card_data.aid}`,
+ author: card.desc.user_profile.info.uname,
+ };
+ });
+
+ return {
+ title: `${name} 关注视频动态`,
+ link: `https://t.bilibili.com/?tab=8`,
+ item: out,
+ };
+}
diff --git a/lib/routes/bilibili/followings.ts b/lib/routes/bilibili/followings.ts
new file mode 100644
index 00000000000000..55644f48aa1c5d
--- /dev/null
+++ b/lib/routes/bilibili/followings.ts
@@ -0,0 +1,92 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/user/followings/:uid/:loginUid',
+ categories: ['social-media'],
+ example: '/bilibili/user/followings/2267573/3',
+ parameters: { uid: '用户 id, 可在 UP 主主页中找到', loginUid: '用于登入的用户id,需要配置对应的 Cookie 值' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['space.bilibili.com/:uid'],
+ target: '/user/followings/:uid',
+ },
+ ],
+ name: 'UP 主关注用户',
+ maintainers: ['Qixingchen'],
+ handler,
+ description: `::: warning
+ UP 主关注用户现在需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+async function handler(ctx) {
+ const loginUid = ctx.req.param('loginUid');
+ const cookie = config.bilibili.cookies[loginUid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 loginUid 的 Bilibili 用户登录后的 Cookie 值 bilibili 用户关注动态系列路由 ');
+ }
+
+ const uid = ctx.req.param('uid');
+ const name = await cache.getUsernameFromUID(uid);
+
+ const countResponse = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/relation/stat?vmid=${uid}`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ },
+ });
+ const count = countResponse.data.data.following;
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/relation/followings?vmid=${uid}`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ if (response.data.code === -6) {
+ throw new ConfigNotFoundError('对应 loginUid 的 Bilibili 用户的 Cookie 已过期');
+ }
+ // 22115 : 用户已设置隐私,无法查看
+ if (response.data.code === 22115) {
+ throw new InvalidParameterError(response.data.message);
+ }
+ const data = response.data.data.list;
+
+ return {
+ title: `${name} 的 bilibili 关注`,
+ link: `https://space.bilibili.com/${uid}/#/fans/follow`,
+ description: `${name} 的 bilibili 关注`,
+ item: data.map((item) => ({
+ title: `${name} 新关注 ${item.uname}`,
+ description: `${item.uname} ${item.sign} 总计${count}`,
+ pubDate: new Date(item.mtime * 1000),
+ link: `https://space.bilibili.com/${item.mid}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/hot-search.ts b/lib/routes/bilibili/hot-search.ts
new file mode 100644
index 00000000000000..ecfaff915bf775
--- /dev/null
+++ b/lib/routes/bilibili/hot-search.ts
@@ -0,0 +1,55 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/hot-search',
+ categories: ['social-media'],
+ example: '/bilibili/hot-search',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.bilibili.com/', 'm.bilibili.com/'],
+ },
+ ],
+ name: '热搜',
+ maintainers: ['CaoMeiYouRen'],
+ handler,
+ url: 'www.bilibili.com/',
+};
+
+async function handler() {
+ const wbiVerifyString = await cache.getWbiVerifyString();
+ const params = utils.addWbiVerifyInfo('limit=10&platform=web', wbiVerifyString);
+ const url = `https://api.bilibili.com/x/web-interface/wbi/search/square?${params}`;
+ const response = await got({
+ method: 'get',
+ url,
+ headers: {
+ Referer: `https://api.bilibili.com`,
+ },
+ });
+ const trending = response?.data?.data?.trending;
+ const title = trending?.title;
+ const list = trending?.list || [];
+ return {
+ title,
+ link: url,
+ description: 'bilibili热搜',
+ item: list.map((item) => ({
+ title: item.keyword,
+ description: `${item.keyword} ${item.icon ? ` ` : ''}`,
+ link: item.link || item.goto || `https://search.bilibili.com/all?${new URLSearchParams({ keyword: item.keyword })}&from_source=webtop_search`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/like.ts b/lib/routes/bilibili/like.ts
new file mode 100644
index 00000000000000..e930c4d1c87d39
--- /dev/null
+++ b/lib/routes/bilibili/like.ts
@@ -0,0 +1,61 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/user/like/:uid/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/user/like/208259',
+ parameters: { uid: '用户 id, 可在 UP 主主页中找到', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['space.bilibili.com/:uid'],
+ target: '/user/like/:uid',
+ },
+ ],
+ name: 'UP 主点赞视频',
+ maintainers: ['ygguorun'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const embed = !ctx.req.param('embed');
+
+ const name = await cache.getUsernameFromUID(uid);
+
+ const response = await got({
+ url: `https://api.bilibili.com/x/space/like/video?vmid=${uid}`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ },
+ });
+ const { data, code, message } = response.data;
+ if (code) {
+ throw new Error(message ?? code);
+ }
+
+ return {
+ title: `${name} 的 bilibili 点赞视频`,
+ link: `https://space.bilibili.com/${uid}`,
+ description: `${name} 的 bilibili 点赞视频`,
+ item: data.list.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.desc, item.aid, undefined, item.bvid),
+ pubDate: parseDate(item.pubdate * 1000),
+ link: item.pubdate > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: item.owner.name,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/link-news.ts b/lib/routes/bilibili/link-news.ts
new file mode 100644
index 00000000000000..3a43bc62b4eb5e
--- /dev/null
+++ b/lib/routes/bilibili/link-news.ts
@@ -0,0 +1,61 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/link/news/:product',
+ categories: ['social-media'],
+ example: '/bilibili/link/news/live',
+ parameters: { product: '公告分类, 包括 直播:live 小视频:vc 相簿:wh' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'link 公告',
+ maintainers: ['Qixingchen'],
+ handler,
+};
+
+async function handler(ctx) {
+ const product = ctx.req.param('product');
+
+ let productTitle = '';
+
+ switch (product) {
+ case 'live':
+ productTitle = '直播';
+ break;
+ case 'vc':
+ productTitle = '小视频';
+ break;
+ case 'wh':
+ productTitle = '相簿';
+ break;
+ }
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.vc.bilibili.com/news/v1/notice/list?platform=pc&product=${product}&category=all&page_no=1&page_size=20`,
+ headers: {
+ Referer: 'https://link.bilibili.com/p/eden/news',
+ },
+ });
+ const data = response.data.data.items;
+
+ return {
+ title: `bilibili ${productTitle}公告`,
+ link: `https://link.bilibili.com/p/eden/news#/?tab=${product}&tag=all&page_no=1`,
+ description: `bilibili ${productTitle}公告`,
+ item:
+ data &&
+ data.map((item) => ({
+ title: item.title,
+ description: `${item.mark} `,
+ pubDate: new Date(item.ctime.replace(' ', 'T') + '+08:00').toUTCString(),
+ link: item.announce_link ?? `https://link.bilibili.com/p/eden/news#/newsdetail?id=${item.id}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/live-area.ts b/lib/routes/bilibili/live-area.ts
new file mode 100644
index 00000000000000..d1953061596446
--- /dev/null
+++ b/lib/routes/bilibili/live-area.ts
@@ -0,0 +1,86 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/live/area/:areaID/:order',
+ categories: ['live'],
+ example: '/bilibili/live/area/207/online',
+ parameters: { areaID: '分区 ID 分区增删较多, 可通过 [分区列表](https://api.live.bilibili.com/room/v1/Area/getList) 查询', order: '排序方式, live_time 开播时间, online 人气' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '直播分区',
+ maintainers: ['Qixingchen'],
+ handler,
+ description: `::: warning
+ 由于接口未提供开播时间,如果直播间未更换标题与分区,将视为一次。如果直播间更换分区与标题,将视为另一项
+:::`,
+};
+
+async function handler(ctx) {
+ const areaID = ctx.req.param('areaID');
+ const order = ctx.req.param('order');
+
+ let orderTitle = '';
+ switch (order) {
+ case 'live_time':
+ orderTitle = '最新开播';
+ break;
+ case 'online':
+ orderTitle = '人气直播';
+ break;
+ }
+
+ const nameResponse = await got({
+ method: 'get',
+ url: 'https://api.live.bilibili.com/room/v1/Area/getList',
+ headers: {
+ Referer: 'https://link.bilibili.com/p/center/index',
+ },
+ });
+
+ let parentTitle = '';
+ let parentID = '';
+ let areaTitle = '';
+ let areaLink = '';
+
+ for (const parentArea of nameResponse.data.data) {
+ for (const area of parentArea.list) {
+ if (area.id === areaID) {
+ parentTitle = parentArea.name;
+ parentID = parentArea.id;
+ areaTitle = area.name;
+ // cateID = area.cate_id;
+ areaLink = `https://live.bilibili.com/p/eden/area-tags?parentAreaId=${parentID}&areaId=${areaID}`;
+ break;
+ }
+ }
+ }
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.live.bilibili.com/room/v1/area/getRoomList?area_id=${areaID}&sort_type=${order}&page_size=30&page_no=1`,
+ headers: {
+ Referer: 'https://live.bilibili.com/p/eden/area-tags',
+ },
+ });
+ const data = response.data.data;
+
+ return {
+ title: `哔哩哔哩直播-${parentTitle}·${areaTitle}分区-${orderTitle}`,
+ link: areaLink,
+ description: `哔哩哔哩直播-${parentTitle}·${areaTitle}分区-${orderTitle}`,
+ item: data.map((item) => ({
+ title: `${item.uname} ${item.title}`,
+ description: `${item.uname} ${item.title}`,
+ pubDate: new Date().toUTCString(),
+ guid: `https://live.bilibili.com/${item.roomid} ${item.title}`,
+ link: `https://live.bilibili.com/${item.roomid}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/live-room.ts b/lib/routes/bilibili/live-room.ts
new file mode 100644
index 00000000000000..800b4c83f63a4f
--- /dev/null
+++ b/lib/routes/bilibili/live-room.ts
@@ -0,0 +1,69 @@
+import { decodeHTML } from 'entities';
+
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/live/room/:roomID',
+ categories: ['live'],
+ example: '/bilibili/live/room/3',
+ parameters: { roomID: '房间号, 可在直播间 URL 中找到, 长短号均可' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['live.bilibili.com/:roomID'],
+ },
+ ],
+ name: '直播开播',
+ maintainers: ['Qixingchen'],
+ handler,
+};
+
+async function handler(ctx) {
+ let roomID = ctx.req.param('roomID');
+
+ // 短号查询长号
+ if (Number.parseInt(roomID, 10) < 10000) {
+ roomID = await cache.getLiveIDFromShortID(roomID);
+ }
+ const info = await cache.getUserInfoFromLiveID(roomID);
+
+ const response = await ofetch(`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${roomID}&from=room`, {
+ headers: {
+ Referer: `https://live.bilibili.com/${roomID}`,
+ },
+ });
+ const data = response.data;
+
+ const liveItem: DataItem[] = [];
+
+ if (data.live_status === 1) {
+ liveItem.push({
+ title: `${data.title} ${data.live_time}`,
+ description: ` ${decodeHTML(data.description)}`,
+ pubDate: timezone(parseDate(data.live_time), 8),
+ guid: `https://live.bilibili.com/${roomID} ${data.live_time}`,
+ link: `https://live.bilibili.com/${roomID}`,
+ });
+ }
+
+ return {
+ title: `${info.uname} 直播间开播状态`,
+ link: `https://live.bilibili.com/${roomID}`,
+ description: `${info.uname} 直播间开播状态`,
+ image: info.face,
+ item: liveItem,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bilibili/live-search.ts b/lib/routes/bilibili/live-search.ts
new file mode 100644
index 00000000000000..e06e4b8267d98a
--- /dev/null
+++ b/lib/routes/bilibili/live-search.ts
@@ -0,0 +1,68 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/live/search/:key/:order',
+ categories: ['live'],
+ example: '/bilibili/live/search/dota/online',
+ parameters: { key: '搜索关键字', order: '排序方式, live_time 开播时间, online 人气' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '直播搜索',
+ maintainers: ['Qixingchen'],
+ handler,
+};
+
+async function handler(ctx) {
+ const key = ctx.req.param('key');
+ const order = ctx.req.param('order');
+
+ const urlEncodedKey = encodeURIComponent(key);
+ let orderTitle = '';
+
+ switch (order) {
+ case 'live_time':
+ orderTitle = '最新开播';
+ break;
+ case 'online':
+ orderTitle = '人气直播';
+ break;
+ }
+ const wbiVerifyString = await cache.getWbiVerifyString();
+ let params = `__refresh__=true&_extra=&context=&page=1&page_size=42&order=${order}&duration=&from_source=&from_spmid=333.337&platform=pc&highlight=1&single_column=0&keyword=${urlEncodedKey}&ad_resource=&source_tag=3&gaia_vtoken=&category_id=&search_type=live&dynamic_offset=0&web_location=1430654`;
+ params = utils.addWbiVerifyInfo(params, wbiVerifyString);
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/web-interface/wbi/search/type?${params}`,
+ headers: {
+ Referer: `https://search.bilibili.com/live?keyword=${urlEncodedKey}&from_source=webtop_search&spm_id_from=444.7&search_source=3&search_type=live_room`,
+ Cookie: await cache.getCookie(),
+ },
+ });
+ const data = response.data.data.result.live_room;
+
+ return {
+ title: `哔哩哔哩直播-${key}-${orderTitle}`,
+ link: `https://search.bilibili.com/live?keyword=${urlEncodedKey}&order=${order}&coverType=user_cover&page=1&search_type=live`,
+ description: `哔哩哔哩直播-${key}-${orderTitle}`,
+ item:
+ data &&
+ data.map((item) => ({
+ title: `${item.uname} ${item.title} (${item.cate_name}-${item.live_time})`,
+ description: `${item.uname} ${item.title} (${item.cate_name}-${item.live_time})`,
+ pubDate: new Date(item.live_time.replace(' ', 'T') + '+08:00').toUTCString(),
+ guid: `https://live.bilibili.com/${item.roomid} ${item.live_time}`,
+ link: `https://live.bilibili.com/${item.roomid}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/mall-ip.ts b/lib/routes/bilibili/mall-ip.ts
new file mode 100644
index 00000000000000..fb590d67388d1a
--- /dev/null
+++ b/lib/routes/bilibili/mall-ip.ts
@@ -0,0 +1,53 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/mall/ip/:id',
+ categories: ['social-media'],
+ example: '/bilibili/mall/ip/0_3000294',
+ parameters: { id: '作品 id, 可在作品列表页 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '会员购作品',
+ maintainers: ['DIYgod'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+
+ const detail = await got({
+ method: 'get',
+ url: `https://mall.bilibili.com/mall-c-search/ipright/detail?type=ip&id=${id}`,
+ headers: {
+ Referer: `https://mall.bilibili.com/ip.html?noTitleBar=1&ip=${id}&from=detail`,
+ },
+ });
+
+ const response = await got({
+ method: 'get',
+ url: `https://mall.bilibili.com/mall-c-search/ipright/newitems?type=ip&id=${id}`,
+ headers: {
+ Referer: `https://mall.bilibili.com/ip.html?noTitleBar=1&ip=${id}&from=detail`,
+ },
+ });
+
+ const data = response.data.data;
+
+ return {
+ title: `${detail.data.data.name} - 会员购`,
+ description: detail.data.data.intro,
+ link: `https://mall.bilibili.com/list.html?ip=${id}`,
+ item: data.map((item) => ({
+ title: item.name,
+ description: `${item.name} ¥${item.price} `,
+ link: item.jumpUrlH5,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/mall-new.ts b/lib/routes/bilibili/mall-new.ts
new file mode 100644
index 00000000000000..1c3a1895fd8e6b
--- /dev/null
+++ b/lib/routes/bilibili/mall-new.ts
@@ -0,0 +1,53 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/mall/new/:category?',
+ categories: ['social-media'],
+ example: '/bilibili/mall/new/1',
+ parameters: { category: '分类,默认全部,见下表' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '会员购新品上架',
+ maintainers: ['DIYgod'],
+ handler,
+ description: `| 全部 | 手办 | 魔力赏 | 周边 | 游戏 |
+| ---- | ---- | ------ | ---- | ---- |
+| 0 | 1 | 7 | 3 | 6 |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') || 0;
+
+ const response = await got({
+ method: 'get',
+ url: `https://mall.bilibili.com/mall-c-search/home/new_items/list?pageNum=1&pageSize=20&version=1.0&cityId=0&cateType=${category}`,
+ headers: {
+ Referer: 'https://mall.bilibili.com/newdate.html?noTitleBar=1&page=new&from=new_product&loadingShow=1',
+ },
+ });
+
+ const days = response.data.data.vo.days;
+ const items = [];
+ for (const day of days) {
+ items.push(...day.presaleItems);
+ }
+
+ const type = response.data.data.vo.cateTabs.find((item) => item.cateType === response.data.data.vo.currentCateType).cateName;
+
+ return {
+ title: `会员购新品上架-${type}`,
+ link: 'https://mall.bilibili.com/newdate.html?noTitleBar=1&page=new&from=new_product&loadingShow=1',
+ item: items.map((item) => ({
+ title: item.name,
+ description: `${item.name} ${item.priceDesc ? `${item.pricePrefix}${item.priceSymbol}${item.priceDesc[0]}` : ''}APP 内打开 `,
+ link: item.itemUrlForH5,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/manga-followings.ts b/lib/routes/bilibili/manga-followings.ts
new file mode 100644
index 00000000000000..29af1585a3ff7c
--- /dev/null
+++ b/lib/routes/bilibili/manga-followings.ts
@@ -0,0 +1,72 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/manga/followings/:uid/:limits?',
+ categories: ['social-media'],
+ example: '/bilibili/manga/followings/26009',
+ parameters: { uid: '用户 id', limits: '抓取最近更新前多少本漫画,默认为10' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '用户追漫更新',
+ maintainers: ['yindaheng98'],
+ handler,
+ description: `::: warning
+ 用户追漫需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+async function handler(ctx) {
+ const uid = String(ctx.req.param('uid'));
+ const name = await cache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+ const page_size = ctx.req.param('limits') || 10;
+ const link = 'https://manga.bilibili.com/account-center';
+ const response = await got({
+ method: 'POST',
+ url: `https://manga.bilibili.com/twirp/bookshelf.v1.Bookshelf/ListFavorite?device=pc&platform=web`,
+ json: { page_num: 1, page_size, order: 2, wait_free: 0 },
+ headers: {
+ Referer: link,
+ Cookie: cookie,
+ },
+ });
+ if (response.data.code === -6) {
+ throw new ConfigNotFoundError('对应 uid 的 Bilibili 用户的 Cookie 已过期');
+ }
+ const comics = response.data.data;
+
+ return {
+ title: `${name} 的追漫更新 - 哔哩哔哩漫画`,
+ link,
+ item: comics.map((item) => ({
+ title: `${item.title} ${item.latest_ep_short_title}`,
+ description: ` `,
+ pubDate: new Date(item.last_ep_publish_time + ' +0800'),
+ link: `https://manga.bilibili.com/detail/mc${item.comic_id}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/manga-update.ts b/lib/routes/bilibili/manga-update.ts
new file mode 100644
index 00000000000000..cae76c3ba73f44
--- /dev/null
+++ b/lib/routes/bilibili/manga-update.ts
@@ -0,0 +1,93 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/manga/update/:comicid',
+ categories: ['social-media'],
+ example: '/bilibili/manga/update/26009',
+ parameters: { comicid: '漫画 id, 可在 URL 中找到, 支持带有`mc`前缀' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['manga.bilibili.com/detail/:comicid'],
+ },
+ ],
+ name: '漫画更新',
+ maintainers: ['hoilc'],
+ handler,
+};
+
+// Based on https://github.com/SocialSisterYi/bilibili-API-collect/issues/1168#issuecomment-2620749895
+async function genReqSign(query, body) {
+ // Don't import on top-level to avoid a cyclic dependency - wasm-exec.js generated via `pnpm build`, which in turn needs wasm-exec.js to import routes correctly
+ const { Go } = await import('./wasm-exec');
+
+ // Cache the wasm binary as it's quite large (~2MB)
+ // Here the binary is saved as base64 as the cache stores strings
+ const wasmBufferBase64 = await cache.tryGet('bilibili-manga-wasm-20250208', async () => {
+ const wasmResp = await got('https://s1.hdslb.com/bfs/manga-static/manga-pc/6732b1bf426cfc634293.wasm', {
+ responseType: 'arrayBuffer',
+ });
+ return Buffer.from(wasmResp.data).toString('base64');
+ });
+ const wasmBuffer = Buffer.from(wasmBufferBase64, 'base64');
+
+ const go = new Go();
+ const { instance } = await WebAssembly.instantiate(wasmBuffer, go.importObject);
+ go.run(instance);
+ if (void 0 === globalThis.genReqSign) {
+ throw new Error('WASM function not available');
+ }
+
+ const signature = globalThis.genReqSign(query, body, Date.now());
+
+ return signature.sign;
+}
+
+async function handler(ctx) {
+ const comic_id = ctx.req.param('comicid').startsWith('mc') ? ctx.req.param('comicid').replace('mc', '') : ctx.req.param('comicid');
+ const link = `https://manga.bilibili.com/detail/mc${comic_id}`;
+
+ const spi_response = await got('https://api.bilibili.com/x/frontend/finger/spi');
+
+ const query = 'device=pc&platform=web&nov=25';
+ const body = JSON.stringify({
+ comic_id: Number(comic_id),
+ });
+
+ const ultraSign = await genReqSign(query, body);
+
+ const response = await got({
+ method: 'POST',
+ url: `https://manga.bilibili.com/twirp/comic.v2.Comic/ComicDetail?${query}&ultra_sign=${ultraSign}`,
+ body,
+ headers: {
+ Referer: link,
+ Cookie: `buvid3=${spi_response.data.data.b_3}; buvid4=${spi_response.data.data.b_4}`,
+ },
+ });
+ const data = response.data.data;
+ const author = data.author_name.join(', ');
+
+ return {
+ title: `${data.title} - 哔哩哔哩漫画`,
+ link,
+ image: data.vertical_cover,
+ description: data.classic_lines,
+ item: data.ep_list.slice(0, 20).map((item) => ({
+ title: item.short_title === item.title ? item.short_title : `${item.short_title} ${item.title}`,
+ author,
+ description: ` `,
+ pubDate: new Date(item.pub_time + ' +0800'),
+ link: `https://manga.bilibili.com/mc${comic_id}/${item.id}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/namespace.ts b/lib/routes/bilibili/namespace.ts
new file mode 100644
index 00000000000000..bfcaeb80a07c10
--- /dev/null
+++ b/lib/routes/bilibili/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '哔哩哔哩 bilibili',
+ url: 'www.bilibili.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bilibili/page.ts b/lib/routes/bilibili/page.ts
new file mode 100644
index 00000000000000..33551058eb1d5a
--- /dev/null
+++ b/lib/routes/bilibili/page.ts
@@ -0,0 +1,57 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/video/page/:bvid/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/video/page/BV1i7411M7N9',
+ parameters: { bvid: '可在视频页 URL 中找到', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '视频选集列表',
+ maintainers: ['sxzz'],
+ handler,
+};
+
+async function handler(ctx) {
+ let bvid = ctx.req.param('bvid');
+ let aid;
+ if (!bvid.startsWith('BV')) {
+ aid = bvid;
+ bvid = null;
+ }
+ const embed = !ctx.req.param('embed');
+ const link = `https://www.bilibili.com/video/${bvid || `av${aid}`}`;
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/web-interface/view?${bvid ? `bvid=${bvid}` : `aid=${aid}`}`,
+ headers: {
+ Referer: link,
+ },
+ });
+
+ const respdata = response.data.data;
+ const { title: name, pages: data } = response.data.data;
+
+ return {
+ title: `视频 ${name} 的选集列表`,
+ link,
+ description: `视频 ${name} 的视频选集列表`,
+ item: data
+ .toSorted((a, b) => b.page - a.page)
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 10)
+ .map((item) => ({
+ title: item.part,
+ description: utils.renderUGCDescription(embed, respdata.pic, `${item.part} - ${name}`, respdata.aid, item.cid, respdata.bvid),
+ link: `${link}?p=${item.page}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/partion-ranking.ts b/lib/routes/bilibili/partion-ranking.ts
new file mode 100644
index 00000000000000..1b160d167367eb
--- /dev/null
+++ b/lib/routes/bilibili/partion-ranking.ts
@@ -0,0 +1,78 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import utils from './utils';
+
+const got_ins = got.extend({
+ headers: {
+ Referer: 'https://www.bilibili.com/',
+ },
+});
+
+function formatDate(now) {
+ const year = now.getFullYear();
+ const month = now.getMonth() + 1;
+ const date = now.getDate();
+ const dateTime = year + '' + (month >= 10 ? month : '0' + month) + '' + (date >= 10 ? date : '0' + date);
+ return dateTime;
+}
+
+export const route: Route = {
+ path: '/partion/ranking/:tid/:days?/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/partion/ranking/171/3',
+ parameters: { tid: '分区 id, 见上方表格', days: '缺省为 7, 指最近多少天内的热度排序', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '分区视频排行榜',
+ maintainers: ['lengthmin'],
+ handler,
+};
+
+async function handler(ctx) {
+ const tid = ctx.req.param('tid');
+ const days = ctx.req.param('days') ?? 7;
+ const embed = !ctx.req.param('embed');
+
+ const responseApi = `https://api.bilibili.com/x/web-interface/newlist?ps=15&rid=${tid}&_=${Date.now()}`;
+
+ const response = await got_ins.get(responseApi);
+ const items = [];
+ let name = '未知';
+ let list = {};
+
+ list = response.data.data.archives;
+ if (list && list[0] && list[0].tname) {
+ name = list[0].tname;
+ }
+
+ const time_from = formatDate(new Date(Date.now() - 1000 * 60 * 60 * 24 * days)); // n天前的日期
+ const time_to = formatDate(new Date()); // 今天的日期
+ const HotRankResponseApi = `https://s.search.bilibili.com/cate/search?main_ver=v3&search_type=video&view_type=hot_rank&cate_id=${tid}&time_from=${time_from}&time_to=${time_to}&_=${Date.now()}`;
+ const HotRankResponse = await got_ins.get(HotRankResponseApi);
+ const hotlist = HotRankResponse.data.result;
+
+ for (let item of hotlist) {
+ item = {
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, `${item.description} - ${item.tag}`, item.id, undefined, item.bvid),
+ pubDate: new Date(item.pubdate).toUTCString(),
+ link: item.pubdate > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.id}`,
+ author: item.author,
+ };
+ items.push(item);
+ }
+
+ return {
+ title: `bilibili ${name} 最热视频`,
+ link: 'https://www.bilibili.com',
+ description: `bilibili ${name}分区 最热视频`,
+ item: items,
+ };
+}
diff --git a/lib/routes/bilibili/partion.ts b/lib/routes/bilibili/partion.ts
new file mode 100644
index 00000000000000..bce20691edd9ef
--- /dev/null
+++ b/lib/routes/bilibili/partion.ts
@@ -0,0 +1,169 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/partion/:tid/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/partion/33',
+ parameters: { tid: '分区 id', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '分区视频',
+ maintainers: ['DIYgod'],
+ handler,
+ description: `动画
+
+| MAD·AMV | MMD·3D | 短片・手书・配音 | 特摄 | 综合 |
+| ------- | ------ | ---------------- | ---- | ---- |
+| 24 | 25 | 47 | 86 | 27 |
+
+ 番剧
+
+| 连载动画 | 完结动画 | 资讯 | 官方延伸 |
+| -------- | -------- | ---- | -------- |
+| 33 | 32 | 51 | 152 |
+
+ 国创
+
+| 国产动画 | 国产原创相关 | 布袋戏 | 动态漫・广播剧 | 资讯 |
+| -------- | ------------ | ------ | -------------- | ---- |
+| 153 | 168 | 169 | 195 | 170 |
+
+ 音乐
+
+| 原创音乐 | 翻唱 | VOCALOID·UTAU | 电音 | 演奏 | MV | 音乐现场 | 音乐综合 | ~~OP/ED/OST~~ |
+| -------- | ---- | ------------- | ---- | ---- | --- | -------- | -------- | ------------- |
+| 28 | 31 | 30 | 194 | 59 | 193 | 29 | 130 | 54 |
+
+ 舞蹈
+
+| 宅舞 | 街舞 | 明星舞蹈 | 中国舞 | 舞蹈综合 | 舞蹈教程 |
+| ---- | ---- | -------- | ------ | -------- | -------- |
+| 20 | 198 | 199 | 200 | 154 | 156 |
+
+ 游戏
+
+| 单机游戏 | 电子竞技 | 手机游戏 | 网络游戏 | 桌游棋牌 | GMV | 音游 | Mugen |
+| -------- | -------- | -------- | -------- | -------- | --- | ---- | ----- |
+| 17 | 171 | 172 | 65 | 173 | 121 | 136 | 19 |
+
+ 知识
+
+| 科学科普 | 社科人文 | 财经 | 校园学习 | 职业职场 | 野生技术协会 |
+| -------- | -------- | ---- | -------- | -------- | ------------ |
+| 201 | 124 | 207 | 208 | 209 | 122 |
+
+ ~~科技~~
+
+| ~~演讲・公开课~~ | ~~星海~~ | ~~机械~~ | ~~汽车~~ |
+| ---------------- | -------- | -------- | -------- |
+| 39 | 96 | 98 | 176 |
+
+ 数码
+
+| 手机平板 | 电脑装机 | 摄影摄像 | 影音智能 |
+| -------- | -------- | -------- | -------- |
+| 95 | 189 | 190 | 191 |
+
+ 生活
+
+| 搞笑 | 日常 | 美食圈 | 动物圈 | 手工 | 绘画 | 运动 | 汽车 | 其他 | ~~ASMR~~ |
+| ---- | ---- | ------ | ------ | ---- | ---- | ---- | ---- | ---- | -------- |
+| 138 | 21 | 76 | 75 | 161 | 162 | 163 | 176 | 174 | 175 |
+
+ 鬼畜
+
+| 鬼畜调教 | 音 MAD | 人力 VOCALOID | 教程演示 |
+| -------- | ------ | ------------- | -------- |
+| 22 | 26 | 126 | 127 |
+
+ 时尚
+
+| 美妆 | 服饰 | 健身 | T 台 | 风向标 |
+| ---- | ---- | ---- | ---- | ------ |
+| 157 | 158 | 164 | 159 | 192 |
+
+ ~~广告~~
+
+| ~~广告~~ |
+| -------- |
+| 166 |
+
+ 资讯
+
+| 热点 | 环球 | 社会 | 综合 |
+| ---- | ---- | ---- | ---- |
+| 203 | 204 | 205 | 206 |
+
+ 娱乐
+
+| 综艺 | 明星 | Korea 相关 |
+| ---- | ---- | ---------- |
+| 71 | 137 | 131 |
+
+ 影视
+
+| 影视杂谈 | 影视剪辑 | 短片 | 预告・资讯 |
+| -------- | -------- | ---- | ---------- |
+| 182 | 183 | 85 | 184 |
+
+ 纪录片
+
+| 全部 | 人文・历史 | 科学・探索・自然 | 军事 | 社会・美食・旅行 |
+| ---- | ---------- | ---------------- | ---- | ---------------- |
+| 177 | 37 | 178 | 179 | 180 |
+
+ 电影
+
+| 全部 | 华语电影 | 欧美电影 | 日本电影 | 其他国家 |
+| ---- | -------- | -------- | -------- | -------- |
+| 23 | 147 | 145 | 146 | 83 |
+
+ 电视剧
+
+| 全部 | 国产剧 | 海外剧 |
+| ---- | ------ | ------ |
+| 11 | 185 | 187 |`,
+};
+
+async function handler(ctx) {
+ const tid = ctx.req.param('tid');
+ const embed = !ctx.req.param('embed');
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/web-interface/newlist?ps=15&rid=${tid}&_=${Date.now()}`,
+ headers: {
+ Referer: 'https://www.bilibili.com/',
+ },
+ });
+
+ const list = response.data.data.archives;
+ let name = '未知';
+ if (list && list[0] && list[0].tname) {
+ name = list[0].tname;
+ }
+
+ return {
+ title: `bilibili ${name}分区`,
+ link: 'https://www.bilibili.com',
+ description: `bilibili ${name}分区`,
+ item:
+ list &&
+ list.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.desc, item.aid, undefined, item.bvid),
+ pubDate: new Date(item.pubdate * 1000).toUTCString(),
+ link: item.pubdate > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: item.owner.name,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/platform.ts b/lib/routes/bilibili/platform.ts
new file mode 100644
index 00000000000000..d92d957cc7ab1a
--- /dev/null
+++ b/lib/routes/bilibili/platform.ts
@@ -0,0 +1,77 @@
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/platform/:area?/:p_type?/:uid?',
+ categories: ['social-media'],
+ example: '/bilibili/platform/-1',
+ parameters: {
+ area: '省市-国标码,默认为-1即全国',
+ p_type: '类型:见下表,默认为全部类型',
+ uid: '用户id,可以不填,不过不填不设置cookie,搜索结果与登入账号后搜索结果不一样。可以在url中找到,需要配置cookie值,只需要SESSDATA的值即可',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['show.bilibili.com/platform'],
+ },
+ ],
+ name: '会员购票务',
+ maintainers: ['nightmare-mio'],
+ handler,
+ url: 'show.bilibili.com/platform',
+ description: `| 类型 |
+| -------- |
+| 演出 |
+| 展览 |
+| 本地生活 |`,
+};
+
+async function handler(ctx) {
+ const { area = -1, type = '全部类型', uid } = ctx.req.param();
+ const cookie = config.bilibili.cookies[uid];
+ const link = 'https://show.bilibili.com/api/ticket/project/listV2';
+
+ const headers = {
+ Referer: 'https://space.bilibili.com',
+ Cookie: cookie ? `SESSDATA=${cookie}` : undefined,
+ };
+
+ const { data: response } = await got({
+ method: 'get',
+ url: `${link}?version=134&page=1&pagesize=16&area=${area}&filter=&platform=web&p_type=${type}`,
+ headers,
+ });
+ // 列表
+ const list = response.data.result;
+
+ const items = list.map((item) => {
+ const bodyHtml = ` `;
+ const coordinate = `活动地点: ${item.city} ${item.venue_name}
`;
+ const liveTime = `活动时间: ${item.tlabel}
`;
+ const staff = `参展览嘉宾: ${item.staff}
`;
+ const countdown = `结束日期: ${item.countdown}
`;
+ const price = `最低价: ${item.price_low / 100} ; 最高价: ${item.price_high / 100}
`;
+ return {
+ title: item.project_name,
+ link: item.url,
+ description: bodyHtml + coordinate + liveTime + staff + countdown + price,
+ pubDate: parseDate(item.sale_start_time * 1000),
+ };
+ });
+
+ return {
+ title: `bilibili会员购票务-${area}`,
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/bilibili/popular.ts b/lib/routes/bilibili/popular.ts
new file mode 100644
index 00000000000000..6398942aecd000
--- /dev/null
+++ b/lib/routes/bilibili/popular.ts
@@ -0,0 +1,51 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/popular/all/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/popular/all',
+ parameters: {
+ embed: '默认为开启内嵌视频, 任意值为关闭',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '综合热门',
+ maintainers: ['ziminliu'],
+ handler,
+};
+
+async function handler(ctx) {
+ const embed = !ctx.req.param('embed');
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/web-interface/popular`,
+ headers: {
+ Referer: 'https://www.bilibili.com/',
+ },
+ });
+ const list = response.data.data.list;
+
+ return {
+ title: `bilibili 综合热门`,
+ link: 'https://www.bilibili.com',
+ description: `bilibili 综合热门`,
+ item:
+ list &&
+ list.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.desc, item.aid, undefined, item.bvid),
+ pubDate: new Date(item.pubdate * 1000).toUTCString(),
+ link: item.pubdate > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: item.owner.name,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/ranking.ts b/lib/routes/bilibili/ranking.ts
new file mode 100644
index 00000000000000..20f4aec24d04b2
--- /dev/null
+++ b/lib/routes/bilibili/ranking.ts
@@ -0,0 +1,285 @@
+import { config } from '@/config';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+import utils, { getVideoUrl } from './utils';
+
+// https://www.bilibili.com/v/popular/rank/all
+
+// 0 all https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=all&web_location=333.934&w_rid=&wts=
+// 1 anime https://api.bilibili.com/pgc/web/rank/list?day=3&season_type=1&web_location=333.934&w_rid=&wts=
+// 2 guochuang https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=4&web_location=333.934&w_rid=&wts=
+// 4 documentary https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=3&web_location=333.934&w_rid=&wts=
+// 5 movie https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=2&web_location=333.934&w_rid=&wts=
+// 6 tv https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=5&web_location=333.934&w_rid=&wts=
+// 7 variety https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=7&web_location=333.934&w_rid=&wts=
+// 8 douga https://api.bilibili.com/x/web-interface/ranking/v2?rid=1005&type=all&web_location=333.934&w_rid=&wts=
+// 9 game https://api.bilibili.com/x/web-interface/ranking/v2?rid=1008&type=all&web_location=333.934&w_rid=&wts=
+// 10 kichiku https://api.bilibili.com/x/web-interface/ranking/v2?rid=1007&type=all&web_location=333.934&w_rid=&wts=
+// 11 music https://api.bilibili.com/x/web-interface/ranking/v2?rid=1003&type=all&web_location=333.934&w_rid=&wts=
+// 12 dance https://api.bilibili.com/x/web-interface/ranking/v2?rid=1004&type=all&web_location=333.934&w_rid=&wts=
+// 13 cinephile https://api.bilibili.com/x/web-interface/ranking/v2?rid=1001&type=all&web_location=333.934&w_rid=&wts=
+// 14 ent https://api.bilibili.com/x/web-interface/ranking/v2?rid=1002&type=all&web_location=333.934&w_rid=&wts=
+// 15 knowledge https://api.bilibili.com/x/web-interface/ranking/v2?rid=1010&type=all&web_location=333.934&w_rid=&wts=
+// 16 tech https://api.bilibili.com/x/web-interface/ranking/v2?rid=1012&type=all&web_location=333.934&w_rid=&wts=
+// 17 food https://api.bilibili.com/x/web-interface/ranking/v2?rid=1020&type=all&web_location=333.934&w_rid=&wts=
+// 18 car https://api.bilibili.com/x/web-interface/ranking/v2?rid=1013&type=all&web_location=333.934&w_rid=&wts=
+// 19 fashion https://api.bilibili.com/x/web-interface/ranking/v2?rid=1014&type=all&web_location=333.934&w_rid=&wts=
+// 20 sports https://api.bilibili.com/x/web-interface/ranking/v2?rid=1018&type=all&web_location=333.934&w_rid=&wts=
+// 21 animal https://api.bilibili.com/x/web-interface/ranking/v2?rid=1024&type=all&web_location=333.934&w_rid=&wts=
+
+const ridList = {
+ 0: {
+ chinese: '全站',
+ english: 'all',
+ type: 'x/rid',
+ },
+ 1: {
+ chinese: '番剧',
+ english: 'bangumi',
+ type: 'pgc/web',
+ },
+ 4: {
+ chinese: '国创',
+ english: 'guochuang',
+ type: 'pgc/season',
+ },
+ 3: {
+ chinese: '纪录片',
+ english: 'documentary',
+ type: 'pgc/season',
+ },
+ 2: {
+ chinese: '电影',
+ english: 'movie',
+ type: 'pgc/season',
+ },
+ 5: {
+ chinese: '电视剧',
+ english: 'tv',
+ type: 'pgc/season',
+ },
+ 7: {
+ chinese: '综艺',
+ english: 'variety',
+ type: 'pgc/season',
+ },
+ 1005: {
+ chinese: '动画',
+ english: 'douga',
+ type: 'x/rid',
+ },
+ 1008: {
+ chinese: '游戏',
+ english: 'game',
+ type: 'x/rid',
+ },
+ 1007: {
+ chinese: '鬼畜',
+ english: 'kichiku',
+ type: 'x/rid',
+ },
+ 1003: {
+ chinese: '音乐',
+ english: 'music',
+ type: 'x/rid',
+ },
+ 1004: {
+ chinese: '舞蹈',
+ english: 'dance',
+ type: 'x/rid',
+ },
+ 1001: {
+ chinese: '影视',
+ english: 'cinephile',
+ type: 'x/rid',
+ },
+ 1002: {
+ chinese: '娱乐',
+ english: 'ent',
+ type: 'x/rid',
+ },
+ 1010: {
+ chinese: '知识',
+ english: 'knowledge',
+ type: 'x/rid',
+ },
+ 1012: {
+ chinese: '科技数码',
+ english: 'tech',
+ type: 'x/rid',
+ },
+ 1020: {
+ chinese: '美食',
+ english: 'food',
+ type: 'x/rid',
+ },
+ 1013: {
+ chinese: '汽车',
+ english: 'car',
+ type: 'x/rid',
+ },
+ 1014: {
+ chinese: '时尚美妆',
+ english: 'fashion',
+ type: 'x/rid',
+ },
+ 1018: {
+ chinese: '体育运动',
+ english: 'sports',
+ type: 'x/rid',
+ },
+ 1024: {
+ chinese: '动物',
+ english: 'animal',
+ type: 'x/rid',
+ },
+};
+
+export const route: Route = {
+ path: '/ranking/:rid?/:embed?/:redirect1?/:redirect2?',
+ name: '排行榜',
+ maintainers: ['DIYgod', 'hyoban'],
+ categories: ['social-media'],
+ view: ViewType.Videos,
+ example: '/bilibili/ranking/all',
+ parameters: {
+ rid: {
+ description: '排行榜分区代号或 rid,可在 URL 中找到',
+ default: 'all',
+ options: Object.values(ridList)
+ .filter((v) => !v.type.startsWith('pgc/'))
+ .map((v) => ({
+ value: v.english,
+ label: v.chinese,
+ })),
+ },
+ embed: '默认为开启内嵌视频,任意值为关闭',
+ redirect1: '留空,用于兼容之前的路由',
+ redirect2: '留空,用于兼容之前的路由',
+ },
+ radar: [
+ {
+ source: ['www.bilibili.com/v/popular/rank/:rid'],
+ target: '/ranking/:rid',
+ },
+ ],
+ handler,
+};
+
+function getAPI(isNumericRid: boolean, rid: string | number) {
+ if (isNumericRid) {
+ const zone = ridList[rid as number];
+ return {
+ apiBase: 'https://api.bilibili.com/x/web-interface/ranking/v2',
+ apiParams: `rid=${rid}&type=all&web_location=333.934`,
+ referer: 'https://www.bilibili.com/v/popular/rank/all',
+ ridChinese: zone?.chinese ?? '',
+ ridType: 'x/rid',
+ link: 'https://www.bilibili.com/v/popular/rank/all',
+ };
+ }
+
+ const zone = Object.entries(ridList).find(([_, v]) => v.english === rid);
+ if (!zone) {
+ throw new Error('Invalid rid');
+ }
+ const numericRid = zone[0];
+ const ridType = zone[1].type;
+ const ridChinese = zone[1].chinese;
+ const ridEnglish = zone[1].english;
+
+ let apiBase = 'https://api.bilibili.com/x/web-interface/ranking/v2';
+ let apiParams = '';
+
+ switch (ridType) {
+ case 'x/rid':
+ apiParams = `rid=${numericRid}&type=all&web_location=333.934`;
+ break;
+ case 'pgc/web':
+ apiBase = 'https://api.bilibili.com/pgc/web/rank/list';
+ apiParams = `day=3&season_type=${numericRid}&web_location=333.934`;
+ break;
+ case 'pgc/season':
+ apiBase = 'https://api.bilibili.com/pgc/season/rank/web/list';
+ apiParams = `day=3&season_type=${numericRid}&web_location=333.934`;
+ break;
+ // case 'x/type':
+ // apiUrl = `https://api.bilibili.com/x/web-interface/ranking?rid=0&type=${numericRid}&web_location=333.934`;
+ // break;
+ default:
+ throw new Error('Invalid rid type');
+ }
+
+ return {
+ apiBase,
+ apiParams,
+ referer: `https://www.bilibili.com/v/popular/rank/${ridEnglish}`,
+ ridChinese,
+ ridType,
+ link: `https://www.bilibili.com/v/popular/rank/${ridEnglish}`,
+ };
+}
+
+async function handler(ctx) {
+ const isJsonFeed = ctx.req.query('format') === 'json';
+ const args = ctx.req.param();
+ if (args.redirect1 || args.redirect2) {
+ // redirect old routes like /bilibili/ranking/0/3/1 or /bilibili/ranking/0/3/1/xxx
+ const embedArg = args.redirect2 ? '/' + args.redirect2 : '';
+ ctx.set('redirect', `/bilibili/ranking/${args.rid}${embedArg}`);
+ return null;
+ }
+
+ const rid = ctx.req.param('rid') || 'all';
+ const embed = !ctx.req.param('embed');
+ const isNumericRid = /^\d+$/.test(rid);
+
+ const { apiBase, apiParams, referer, ridChinese, link, ridType } = getAPI(isNumericRid, rid);
+ if (ridType.startsWith('pgc/')) {
+ throw new Error('This type of ranking is not supported yet');
+ }
+
+ const response = await ofetch(`${apiBase}?${apiParams}`, {
+ headers: {
+ Referer: referer,
+ origin: 'https://www.bilibili.com',
+ },
+ });
+
+ if (response.code !== 0) {
+ throw new Error(response.message);
+ }
+ const data = response.data || response.result;
+ const list = data.list || [];
+ return {
+ title: `bilibili 排行榜-${ridChinese}`,
+ link,
+ item: await Promise.all(
+ list.map(async (item) => {
+ const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && item.bvid ? await cache.getVideoSubtitleAttachment(item.bvid) : [];
+ return {
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.desc || item.title, item.aid, undefined, item.bvid),
+ pubDate: item.ctime && parseDate(item.ctime, 'X'),
+ author: item.owner.name,
+ link: !item.ctime || (item.ctime > utils.bvidTime && item.bvid) ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ image: item.pic,
+ attachments: item.bvid
+ ? [
+ {
+ url: getVideoUrl(item.bvid),
+ mime_type: 'text/html',
+ duration_in_seconds: item.duration,
+ },
+ ...subtitles,
+ ]
+ : undefined,
+ };
+ })
+ ),
+ };
+}
diff --git a/lib/routes/bilibili/readlist.ts b/lib/routes/bilibili/readlist.ts
new file mode 100644
index 00000000000000..16081d6349b4e2
--- /dev/null
+++ b/lib/routes/bilibili/readlist.ts
@@ -0,0 +1,52 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/readlist/:listid',
+ categories: ['social-media'],
+ view: ViewType.Articles,
+ example: '/bilibili/readlist/25611',
+ parameters: { listid: '文集 id, 可在专栏文集 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '专栏文集',
+ maintainers: ['hoilc'],
+ handler,
+};
+
+async function handler(ctx) {
+ const listid = ctx.req.param('listid');
+ const listurl = `https://www.bilibili.com/read/readlist/rl${listid}`;
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/article/list/web/articles?id=${listid}&jsonp=jsonp`,
+ headers: {
+ Referer: listurl,
+ },
+ });
+ const data = response.data.data;
+
+ return {
+ title: `bilibili 专栏文集 - ${data.list.name}`,
+ link: listurl,
+ image: data.list.image_url,
+ description: data.list.summary ?? '作者很懒,还木有写简介.....((/- -)/',
+ item:
+ data.articles &&
+ data.articles.map((item) => ({
+ title: item.title,
+ author: data.author.name,
+ description: `${item.summary}… `,
+ pubDate: new Date(item.publish_time * 1000).toUTCString(),
+ link: `https://www.bilibili.com/read/cv${item.id}/?from=readlist`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/reply.ts b/lib/routes/bilibili/reply.ts
new file mode 100644
index 00000000000000..5af8d0aa717e55
--- /dev/null
+++ b/lib/routes/bilibili/reply.ts
@@ -0,0 +1,60 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/video/reply/:bvid',
+ categories: ['social-media'],
+ example: '/bilibili/video/reply/BV1vA411b7ip',
+ parameters: { bvid: '可在视频页 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '视频评论',
+ maintainers: ['Qixingchen'],
+ handler,
+};
+
+async function handler(ctx) {
+ let bvid = ctx.req.param('bvid');
+ let aid;
+ if (!bvid.startsWith('BV')) {
+ aid = bvid;
+ bvid = null;
+ }
+ const name = await cache.getVideoNameFromId(aid, bvid);
+ if (!aid) {
+ aid = await cache.getAidFromBvid(bvid);
+ }
+
+ const link = `https://www.bilibili.com/video/${bvid || `av${aid}`}`;
+ const cookie = await cache.getCookie();
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/v2/reply?type=1&oid=${aid}&sort=0`,
+ headers: {
+ Referer: link,
+ Cookie: cookie,
+ },
+ });
+
+ const data = response.data.data.replies;
+
+ return {
+ title: `${name} 的 评论`,
+ link,
+ description: `${name} 的评论`,
+ item: data.map((item) => ({
+ title: `${item.member.uname} : ${item.content.message}`,
+ description: `${item.member.uname} : ${item.content.message}`,
+ pubDate: new Date(item.ctime * 1000).toUTCString(),
+ link: `${link}/#reply${item.rpid}`,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/templates/description.art b/lib/routes/bilibili/templates/description.art
new file mode 100644
index 00000000000000..5f6e5847d51ec8
--- /dev/null
+++ b/lib/routes/bilibili/templates/description.art
@@ -0,0 +1,14 @@
+{{ if embed }}
+{{ if ugc }}
+
+{{ /if }}
+{{ if ogv }}
+
+{{ /if }}
+
+{{ /if }}
+{{ if img}}
+
+
+{{ /if }}
+{{@ description }}
diff --git a/lib/routes/bilibili/types.ts b/lib/routes/bilibili/types.ts
new file mode 100644
index 00000000000000..bc53df24f2cf07
--- /dev/null
+++ b/lib/routes/bilibili/types.ts
@@ -0,0 +1,52 @@
+export interface ResultResponse {
+ result: Result;
+}
+
+/**
+ * 番剧信息
+ *
+ * @interface MediaResult
+ *
+ * @property {string} cover - 封面。
+ * @property {string} evaluate - 摘要。
+ * @property {number} media_id - 媒体 ID。
+ * @property {number} season_id - 季度 ID。
+ * @property {string} share_url - 分享 URL。此属性是注入的。
+ * @property {string} title - 标题。
+ */
+export interface MediaResult {
+ cover: string;
+ evaluate: string;
+ media_id: number;
+ season_id: number;
+ share_url: string; // injected
+ title: string;
+}
+
+export interface SeasonResult {
+ main_section: SectionResult;
+ section: SectionResult[];
+}
+
+export interface SectionResult {
+ episodes: EpisodeResult[];
+}
+
+/**
+ * 番剧剧集信息
+ *
+ * @interface EpisodeResult
+ *
+ * @property {string} cover - 封面。
+ * @property {number} id - 剧集 ID。
+ * @property {string} long_title - 完整标题。
+ * @property {string} share_url - 分享 URL。
+ * @property {string} title - 短标题。
+ */
+export interface EpisodeResult {
+ cover: string;
+ id: number;
+ long_title: string;
+ share_url: string;
+ title: string;
+}
diff --git a/lib/routes/bilibili/user-bangumi.ts b/lib/routes/bilibili/user-bangumi.ts
new file mode 100644
index 00000000000000..889fbae4816ea8
--- /dev/null
+++ b/lib/routes/bilibili/user-bangumi.ts
@@ -0,0 +1,62 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/user/bangumi/:uid/:type?',
+ categories: ['social-media'],
+ example: '/bilibili/user/bangumi/208259',
+ parameters: { uid: '用户 id', type: '1为番,2为剧,留空为1' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['space.bilibili.com/:uid'],
+ target: '/user/bangumi/:uid',
+ },
+ ],
+ name: '用户追番列表',
+ maintainers: ['wdssmq'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const type = Number(ctx.req.param('type') || 1);
+ const type_name = ((t) => ['', 'bangumi', 'cinema'][t])(type);
+ const name = await cache.getUsernameFromUID(uid);
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/space/bangumi/follow/list?type=${type}&follow_status=0&pn=1&ps=15&vmid=${uid}`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/${type_name}`,
+ },
+ });
+ const data = response.data;
+ if (data.code !== 0) {
+ throw new Error(`It looks like something went wrong when querying the Bilibili API: code = ${data.code}, message = ${data.message}`);
+ }
+
+ return {
+ title: `${name} 的追番列表`,
+ link: `https://space.bilibili.com/${uid}/${type_name}`,
+ description: `${name} 的追番列表`,
+ item:
+ data.data &&
+ data.data.list &&
+ data.data.list.map((item) => ({
+ title: `[${item.new_ep.index_show}]${item.title}`,
+ description: `${item.evaluate} `,
+ pubDate: new Date(item.new_ep.pub_time ?? Date.now()).toUTCString(),
+ link: `https://www.bilibili.com/bangumi/play/` + (item.new_ep.id ? `ep${item.new_ep.id}` : `ss${item.season_id}`),
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/user-channel.ts b/lib/routes/bilibili/user-channel.ts
new file mode 100644
index 00000000000000..4e8ba7ace767d6
--- /dev/null
+++ b/lib/routes/bilibili/user-channel.ts
@@ -0,0 +1,83 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import cacheIn from './cache';
+import utils from './utils';
+
+const notFoundData = {
+ title: '此 bilibili 频道不存在',
+};
+
+export const route: Route = {
+ path: '/user/channel/:uid/:sid/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/user/channel/2267573/396050',
+ parameters: { uid: '用户 id, 可在 UP 主主页中找到', sid: '频道 id, 可在频道的 URL 中找到', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'UP 主频道的视频列表',
+ maintainers: ['weirongxu'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = Number.parseInt(ctx.req.param('uid'));
+ const sid = Number.parseInt(ctx.req.param('sid'));
+ const embed = !ctx.req.param('embed');
+ const limit = ctx.req.query('limit') ?? 25;
+
+ const link = `https://space.bilibili.com/${uid}/channel/seriesdetail?sid=${sid}`;
+
+ // 获取频道信息
+ const channelInfoLink = `https://api.bilibili.com/x/series/series?series_id=${sid}`;
+ const channelInfo = await cache.tryGet(channelInfoLink, async () => {
+ const response = await got(channelInfoLink, {
+ headers: {
+ Referer: link,
+ },
+ });
+ // 频道不存在时返回 null
+ return response.data.data;
+ });
+
+ if (!channelInfo) {
+ return notFoundData;
+ }
+ const [userName, face] = await cacheIn.getUsernameAndFaceFromUID(uid);
+ const host = `https://api.bilibili.com/x/series/archives?mid=${uid}&series_id=${sid}&only_normal=true&sort=desc&pn=1&ps=${limit}`;
+
+ const response = await got(host, {
+ headers: {
+ Referer: link,
+ },
+ });
+
+ const data = response.data.data;
+ if (!data.archives) {
+ return notFoundData;
+ }
+
+ return {
+ title: `${userName} 的 bilibili 频道 ${channelInfo.meta.name}`,
+ link,
+ description: `${userName} 的 bilibili 频道`,
+ image: face,
+ logo: face,
+ icon: face,
+ item: data.archives.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, '', item.aid, undefined, item.bvid),
+ pubDate: parseDate(item.pubdate, 'X'),
+ link: item.pubdate > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: userName,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/user-collection.ts b/lib/routes/bilibili/user-collection.ts
new file mode 100644
index 00000000000000..fa09627dc240fc
--- /dev/null
+++ b/lib/routes/bilibili/user-collection.ts
@@ -0,0 +1,75 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { queryToBoolean } from '@/utils/readable-social';
+
+import cache from './cache';
+import utils from './utils';
+
+const notFoundData = {
+ title: '此 bilibili 频道不存在',
+};
+
+export const route: Route = {
+ path: '/user/collection/:uid/:sid/:embed?/:sortReverse?/:page?',
+ categories: ['social-media'],
+ example: '/bilibili/user/collection/245645656/529166',
+ parameters: {
+ uid: '用户 id, 可在 UP 主主页中找到',
+ sid: '合集 id, 可在合集页面的 URL 中找到',
+ embed: '默认为开启内嵌视频, 任意值为关闭',
+ sortReverse: '默认:默认排序 1:升序排序',
+ page: '页码, 默认1',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'UP 主频道的合集',
+ maintainers: ['shininome', 'cscnk52'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = Number.parseInt(ctx.req.param('uid'));
+ const sid = Number.parseInt(ctx.req.param('sid'));
+ const embed = queryToBoolean(ctx.req.param('embed') || 'true');
+ const sortReverse = Number.parseInt(ctx.req.param('sortReverse')) === 1;
+ const page = ctx.req.param('page') ? Number.parseInt(ctx.req.param('page')) : 1;
+ const limit = ctx.req.query('limit') ?? 25;
+
+ const link = `https://space.bilibili.com/${uid}/channel/collectiondetail?sid=${sid}`;
+ const [userName, face] = await cache.getUsernameAndFaceFromUID(uid);
+ const host = `https://api.bilibili.com/x/polymer/web-space/seasons_archives_list?mid=${uid}&season_id=${sid}&sort_reverse=${sortReverse}&page_num=${page}&page_size=${limit}`;
+
+ const response = await got(host, {
+ headers: {
+ Referer: link,
+ },
+ });
+
+ const data = response.data.data;
+ if (!data.archives) {
+ return notFoundData;
+ }
+
+ return {
+ title: `${userName} 的 bilibili 合集 ${data.meta.name}`,
+ link,
+ description: `${userName} 的 bilibili 合集`,
+ image: face,
+ logo: face,
+ icon: face,
+ item: data.archives.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, '', item.aid, undefined, item.bvid),
+ pubDate: parseDate(item.pubdate, 'X'),
+ link: item.pubdate > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: userName,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/user-fav.ts b/lib/routes/bilibili/user-fav.ts
new file mode 100644
index 00000000000000..fc6cee593deffe
--- /dev/null
+++ b/lib/routes/bilibili/user-fav.ts
@@ -0,0 +1,63 @@
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/user/fav/:uid/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/user/fav/2267573',
+ parameters: { uid: '用户 id, 可在 UP 主主页中找到', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['space.bilibili.com/:uid', 'space.bilibili.com/:uid/favlist'],
+ target: '/user/fav/:uid',
+ },
+ ],
+ name: 'UP 主默认收藏夹',
+ maintainers: ['DIYgod'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const embed = !ctx.req.param('embed');
+ const name = await cache.getUsernameFromUID(uid);
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/v2/fav/video?vmid=${uid}&ps=30&tid=0&keyword=&pn=1&order=fav_time`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/#/favlist`,
+ Cookie: config.bilibili.cookies[uid],
+ },
+ });
+ const data = response.data;
+
+ return {
+ title: `${name} 的 bilibili 收藏夹`,
+ link: `https://space.bilibili.com/${uid}/#/favlist`,
+ description: `${name} 的 bilibili 收藏夹`,
+
+ item:
+ data.data &&
+ data.data.archives &&
+ data.data.archives.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.desc, item.aid, undefined, item.bvid),
+ pubDate: new Date(item.fav_at * 1000).toUTCString(),
+ link: item.fav_at > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: item.owner.name,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/utils.ts b/lib/routes/bilibili/utils.ts
new file mode 100644
index 00000000000000..bee611e0e63406
--- /dev/null
+++ b/lib/routes/bilibili/utils.ts
@@ -0,0 +1,324 @@
+import path from 'node:path';
+
+import CryptoJS from 'crypto-js';
+
+import { config } from '@/config';
+import md5 from '@/utils/md5';
+import ofetch from '@/utils/ofetch';
+import { art } from '@/utils/render';
+
+import type { MediaResult, ResultResponse, SeasonResult } from './types';
+
+// a
+function randomHexStr(length) {
+ let string = '';
+ for (let r = 0; r < length; r++) {
+ string += dec2HexUpper(16 * Math.random());
+ }
+ return padStringWithZeros(string, length);
+}
+
+// o
+function dec2HexUpper(e) {
+ return Math.ceil(e).toString(16).toUpperCase();
+}
+
+// s
+function padStringWithZeros(string, length) {
+ let padding = '';
+ if (string.length < length) {
+ for (let n = 0; n < length - string.length; n++) {
+ padding += '0';
+ }
+ }
+ return padding + string;
+}
+
+function lsid() {
+ const e = Date.now().toString(16).toUpperCase();
+ const lsid = randomHexStr(8) + '_' + e;
+ return lsid;
+}
+
+function _uuid() {
+ const e = randomHexStr(8);
+ const t = randomHexStr(4);
+ const r = randomHexStr(4);
+ const n = randomHexStr(4);
+ const o = randomHexStr(12);
+ const i = Date.now();
+ return e + '-' + t + '-' + r + '-' + n + '-' + o + padStringWithZeros((i % 100000).toString(), 5) + 'infoc';
+}
+
+// P
+function shiftCharByOne(string) {
+ let shiftedStr = '';
+ for (let n = 0; n < string.length; n++) {
+ shiftedStr += String.fromCharCode(string.charCodeAt(n) - 1);
+ }
+ return shiftedStr;
+}
+
+// o
+function hexsign(e) {
+ const n = 'YhxToH[2q';
+ const r = CryptoJS.HmacSHA256('ts'.concat(e), shiftCharByOne(n));
+ const o = CryptoJS.enc.Hex.stringify(r);
+ return o;
+}
+
+function addRenderData(params, renderData) {
+ return `${params}&w_webid=${encodeURIComponent(renderData)}`;
+}
+
+function addWbiVerifyInfo(params, wbiVerifyString) {
+ const searchParams = new URLSearchParams(params);
+ searchParams.sort();
+ const verifyParam = searchParams.toString();
+ const wts = Math.round(Date.now() / 1000);
+ const w_rid = md5(`${verifyParam}&wts=${wts}${wbiVerifyString}`);
+ return `${params}&w_rid=${w_rid}&wts=${wts}`;
+}
+
+// https://github.com/errcw/gaussian/blob/master/lib/box-muller.js
+function generateGaussianInteger(mean, std) {
+ const _2PI = Math.PI * 2;
+ const u1 = Math.random();
+ const u2 = Math.random();
+
+ const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(_2PI * u2);
+
+ return Math.round(z0 * std + mean);
+}
+
+function getDmImgList() {
+ if (config.bilibili.dmImgList !== undefined) {
+ const dmImgList = JSON.parse(config.bilibili.dmImgList);
+ return JSON.stringify([dmImgList[Math.floor(Math.random() * dmImgList.length)]]);
+ }
+ const x = Math.max(generateGaussianInteger(1245, 5), 0);
+ const y = Math.max(generateGaussianInteger(1285, 5), 0);
+ const path = [
+ {
+ x: 3 * x + 2 * y,
+ y: 4 * x - 5 * y,
+ z: 0,
+ timestamp: Math.max(generateGaussianInteger(30, 5), 0),
+ type: 0,
+ },
+ ];
+ return JSON.stringify(path);
+}
+
+function getDmImgInter() {
+ if (config.bilibili.dmImgInter !== undefined) {
+ const dmImgInter = JSON.parse(config.bilibili.dmImgInter);
+ return JSON.stringify([dmImgInter[Math.floor(Math.random() * dmImgInter.length)]]);
+ }
+ const p1 = getDmImgInterWh(274, 601);
+ const s1 = getDmImgInterOf(134, 30);
+ const p2 = getDmImgInterWh(332, 64);
+ const s2 = getDmImgInterOf(1101, 338);
+ const of = getDmImgInterOf(0, 0);
+ const wh = getDmImgInterWh(1245, 1285);
+ const ds = [
+ {
+ t: getDmImgInterT('div'),
+ c: getDmImgInterC('clearfix g-search search-container'),
+ p: [p1[0], p1[2], p1[1]],
+ s: [s1[2], s1[0], s1[1]],
+ },
+ {
+ t: getDmImgInterT('div'),
+ c: getDmImgInterC('wrapper'),
+ p: [p2[0], p2[2], p2[1]],
+ s: [s2[2], s2[0], s2[1]],
+ },
+ ];
+ return JSON.stringify({ ds, wh, of });
+}
+
+function getDmImgInterT(tag: string) {
+ return {
+ a: 4,
+ article: 29,
+ button: 7,
+ div: 2,
+ em: 27,
+ form: 17,
+ h1: 11,
+ h2: 12,
+ h3: 13,
+ h4: 14,
+ h5: 15,
+ h6: 16,
+ img: 5,
+ input: 6,
+ label: 25,
+ li: 10,
+ ol: 9,
+ option: 20,
+ p: 3,
+ section: 28,
+ select: 19,
+ span: 1,
+ strong: 26,
+ table: 21,
+ td: 23,
+ textarea: 18,
+ th: 24,
+ tr: 22,
+ ul: 8,
+ }[tag];
+}
+
+function getDmImgInterC(className: string) {
+ return Buffer.from(className).toString('base64').slice(0, -2);
+}
+
+function getDmImgInterOf(top: number, left: number) {
+ const seed = Math.floor(514 * Math.random());
+ return [3 * top + 2 * left + seed, 4 * top - 4 * left + 2 * seed, seed];
+}
+
+function getDmImgInterWh(width: number, height: number) {
+ const seed = Math.floor(114 * Math.random());
+ return [2 * width + 2 * height + 3 * seed, 4 * width - height + seed, seed];
+}
+
+function addDmVerifyInfo(params: string, dmImgList: string) {
+ const dmImgStr = Buffer.from('no webgl').toString('base64').slice(0, -2);
+ const dmCoverImgStr = Buffer.from('no webgl').toString('base64').slice(0, -2);
+ return `${params}&dm_img_list=${dmImgList}&dm_img_str=${dmImgStr}&dm_cover_img_str=${dmCoverImgStr}`;
+}
+
+function addDmVerifyInfoWithInter(params: string, dmImgList: string, dmImgInter: string) {
+ return `${addDmVerifyInfo(params, dmImgList)}&dm_img_inter=${dmImgInter}`;
+}
+
+const bvidTime = 1_589_990_400;
+
+/**
+ * 获取番剧信息并缓存
+ *
+ * @param {string} id - 番剧 ID。
+ * @param cache - 缓存 module。
+ * @returns {Promise} 番剧信息。
+ */
+export const getBangumi = (id: string, cache): Promise =>
+ cache.tryGet(
+ `bilibili:getBangumi:${id}`,
+ async () => {
+ const res = await ofetch>('https://api.bilibili.com/pgc/view/web/media', {
+ query: {
+ media_id: id,
+ },
+ });
+ if (res.result.share_url === undefined) {
+ // reference: https://api.bilibili.com/pgc/review/user?media_id=${id}
+ res.result.share_url = `https://www.bilibili.com/bangumi/media/md${res.result.media_id}`;
+ }
+ return res.result;
+ },
+ config.cache.routeExpire,
+ false
+ ) as Promise;
+
+/**
+ * 获取番剧分集信息并缓存
+ *
+ * @param {string} id - 番剧 ID。
+ * @param cache - 缓存 module。
+ * @returns {Promise} 番剧分集信息。
+ */
+export const getBangumiItems = (id: string, cache): Promise =>
+ cache.tryGet(
+ `bilibili:getBangumiItems:${id}`,
+ async () => {
+ const res = await ofetch>('https://api.bilibili.com/pgc/web/season/section', {
+ query: {
+ season_id: id,
+ },
+ });
+ return res.result;
+ },
+ config.cache.routeExpire,
+ false
+ ) as Promise;
+
+/**
+ * 使用模板渲染 UGC(用户生成内容)描述。
+ *
+ * @param {boolean} embed - 是否嵌入视频。
+ * @param {string} img - 要包含在描述中的图片 URL。
+ * @param {string} description - UGC 的文本描述。
+ * @param {string} [aid] - 可选。UGC 的 aid。
+ * @param {string} [cid] - 可选。UGC 的 cid。
+ * @param {string} [bvid] - 可选。UGC 的 bvid。
+ * @returns {string} 渲染的 UGC 描述。
+ *
+ * @see https://player.bilibili.com/ 获取更多信息。
+ */
+export const renderUGCDescription = (embed: boolean, img: string, description: string, aid?: string, cid?: string, bvid?: string): string => {
+ // docs: https://player.bilibili.com/
+ const rendered = art(path.join(__dirname, 'templates/description.art'), {
+ embed,
+ ugc: true,
+ aid,
+ cid,
+ bvid,
+ img: img.replace('http://', 'https://'),
+ description,
+ });
+ return rendered;
+};
+
+/**
+ * 使用模板渲染 OGV(原创视频)描述。
+ *
+ * @param {boolean} embed - 是否嵌入视频。
+ * @param {string} img - 要包含在描述中的图片 URL。
+ * @param {string} description - OGV 的文本描述。
+ * @param {string} [seasonId] - 可选。OGV 的季 ID。
+ * @param {string} [episodeId] - 可选。OGV 的集 ID。
+ * @returns {string} 渲染的 OGV 描述。
+ *
+ * @see https://player.bilibili.com/ 获取更多信息。
+ */
+export const renderOGVDescription = (embed: boolean, img: string, description: string, seasonId?: string, episodeId?: string): string => {
+ // docs: https://player.bilibili.com/
+ const rendered = art(path.join(__dirname, 'templates/description.art'), {
+ embed,
+ ogv: true,
+ seasonId,
+ episodeId,
+ img: img.replace('http://', 'https://'),
+ description,
+ });
+ return rendered;
+};
+
+export function getVideoUrl(bvid: string): string;
+export function getVideoUrl(bvid?: string): string | undefined;
+export function getVideoUrl(bvid?: string): string | undefined {
+ return bvid ? `https://www.bilibili.com/blackboard/newplayer.html?isOutside=true&autoplay=true&danmaku=true&muted=false&highQuality=true&bvid=${bvid}` : undefined;
+}
+export const getLiveUrl = (roomId?: string) => (roomId ? `https://www.bilibili.com/blackboard/live/live-activity-player.html?cid=${roomId}` : undefined);
+
+export default {
+ lsid,
+ _uuid,
+ hexsign,
+ addWbiVerifyInfo,
+ getDmImgList,
+ getDmImgInter,
+ addDmVerifyInfo,
+ addDmVerifyInfoWithInter,
+ bvidTime,
+ addRenderData,
+ getBangumi,
+ getBangumiItems,
+ renderUGCDescription,
+ renderOGVDescription,
+ getVideoUrl,
+};
diff --git a/lib/routes/bilibili/video-all.ts b/lib/routes/bilibili/video-all.ts
new file mode 100644
index 00000000000000..ea5e55655ee646
--- /dev/null
+++ b/lib/routes/bilibili/video-all.ts
@@ -0,0 +1,90 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/user/video-all/:uid/:embed?',
+ name: '用户所有视频',
+ maintainers: [],
+ handler,
+ example: '/bilibili/user/video-all/2267573',
+ parameters: {
+ uid: '用户 id, 可在 UP 主主页中找到',
+ embed: '默认为开启内嵌视频, 任意值为关闭',
+ },
+ categories: ['social-media'],
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const embed = !ctx.req.param('embed');
+ const cookie = await cache.getCookie();
+ const wbiVerifyString = await cache.getWbiVerifyString();
+ const dmImgList = utils.getDmImgList();
+ const [name, face] = await cache.getUsernameAndFaceFromUID(uid);
+
+ await got(`https://space.bilibili.com/${uid}/video?tid=0&page=1&keyword=&order=pubdate`, {
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ const params = utils.addWbiVerifyInfo(utils.addDmVerifyInfo(`mid=${uid}&ps=30&tid=0&pn=1&keyword=&order=pubdate&platform=web&web_location=1550101&order_avoided=true`, dmImgList), wbiVerifyString);
+ const response = await got(`https://api.bilibili.com/x/space/wbi/arc/search?${params}`, {
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/video?tid=0&page=1&keyword=&order=pubdate`,
+ Cookie: cookie,
+ },
+ });
+
+ const vlist = [...response.data.data.list.vlist];
+ const pageTotal = Math.ceil(response.data.data.page.count / response.data.data.page.ps);
+
+ const getPage = async (pageId) => {
+ const cookie = await cache.getCookie();
+ await got(`https://space.bilibili.com/${uid}/video?tid=0&page=${pageId}&keyword=&order=pubdate`, {
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ const params = utils.addWbiVerifyInfo(utils.addDmVerifyInfo(`mid=${uid}&ps=30&tid=0&pn=${pageId}&keyword=&order=pubdate&platform=web&web_location=1550101&order_avoided=true`, dmImgList), wbiVerifyString);
+ return got(`https://api.bilibili.com/x/space/wbi/arc/search?${params}`, {
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/video?tid=0&page=${pageId}&keyword=&order=pubdate`,
+ Cookie: cookie,
+ },
+ });
+ };
+
+ const promises = [];
+
+ if (pageTotal > 1) {
+ for (let i = 2; i <= pageTotal; i++) {
+ promises.push(getPage(i));
+ }
+ const rets = await Promise.all(promises);
+ for (const ret of rets) {
+ vlist.push(...ret.data.data.list.vlist);
+ }
+ }
+
+ return {
+ title: name,
+ link: `https://space.bilibili.com/${uid}/video`,
+ description: `${name} 的 bilibili 所有视频`,
+ logo: face,
+ icon: face,
+ item: vlist.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.description, item.aid, undefined, item.bvid),
+ pubDate: parseDate(item.created, 'X'),
+ link: item.created > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: name,
+ comments: item.comment,
+ })),
+ };
+}
diff --git a/lib/routes/bilibili/video.ts b/lib/routes/bilibili/video.ts
new file mode 100644
index 00000000000000..9448965ecdbe56
--- /dev/null
+++ b/lib/routes/bilibili/video.ts
@@ -0,0 +1,105 @@
+import type { Context } from 'hono';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDuration } from '@/utils/helpers';
+import logger from '@/utils/logger';
+
+import cache from './cache';
+import utils, { getVideoUrl } from './utils';
+
+export const route: Route = {
+ path: '/user/video/:uid/:embed?',
+ categories: ['social-media'],
+ view: ViewType.Videos,
+ example: '/bilibili/user/video/2267573',
+ parameters: { uid: '用户 id, 可在 UP 主主页中找到', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['space.bilibili.com/:uid'],
+ target: '/user/video/:uid',
+ },
+ ],
+ name: 'UP 主投稿',
+ maintainers: ['DIYgod', 'Konano', 'pseudoyu'],
+ handler,
+};
+
+async function handler(ctx: Context) {
+ const isJsonFeed = ctx.req.query('format') === 'json';
+
+ const uid = ctx.req.param('uid');
+ const embed = !ctx.req.param('embed');
+ const cookie = await cache.getCookie();
+ const wbiVerifyString = await cache.getWbiVerifyString();
+ const dmImgList = utils.getDmImgList();
+ const dmImgInter = utils.getDmImgInter();
+ const renderData = await cache.getRenderData(uid);
+
+ const params = utils.addWbiVerifyInfo(
+ utils.addRenderData(utils.addDmVerifyInfoWithInter(`mid=${uid}&ps=30&tid=0&pn=1&keyword=&order=pubdate&platform=web&web_location=1550101&order_avoided=true`, dmImgList, dmImgInter), renderData),
+ wbiVerifyString
+ );
+ const response = await got(`https://api.bilibili.com/x/space/wbi/arc/search?${params}`, {
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}`,
+ origin: `https://space.bilibili.com`,
+ Cookie: cookie,
+ },
+ });
+ const data = response.data;
+ if (data.code) {
+ logger.error(JSON.stringify(data.data));
+ throw new Error(`Got error code ${data.code} while fetching: ${data.message}`);
+ }
+
+ const usernameAndFace = await cache.getUsernameAndFaceFromUID(uid);
+ const name = usernameAndFace[0] || data.data.list.vlist[0]?.author;
+ const face = usernameAndFace[1];
+
+ return {
+ title: `${name} 的 bilibili 空间`,
+ link: `https://space.bilibili.com/${uid}`,
+ description: `${name} 的 bilibili 空间`,
+ image: face ?? undefined,
+ logo: face ?? undefined,
+ icon: face ?? undefined,
+ item:
+ data.data &&
+ data.data.list &&
+ data.data.list.vlist &&
+ (await Promise.all(
+ data.data.list.vlist.map(async (item) => {
+ const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && item.bvid ? await cache.getVideoSubtitleAttachment(item.bvid) : [];
+ return {
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.description, item.aid, undefined, item.bvid),
+ pubDate: new Date(item.created * 1000).toUTCString(),
+ link: item.created > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: name,
+ comments: item.comment,
+ attachments: item.bvid
+ ? [
+ {
+ url: getVideoUrl(item.bvid),
+ mime_type: 'text/html',
+ duration_in_seconds: parseDuration(item.length),
+ },
+ ...subtitles,
+ ]
+ : undefined,
+ };
+ })
+ )),
+ };
+}
diff --git a/lib/routes/bilibili/vsearch.ts b/lib/routes/bilibili/vsearch.ts
new file mode 100644
index 00000000000000..7f2c94538be27c
--- /dev/null
+++ b/lib/routes/bilibili/vsearch.ts
@@ -0,0 +1,111 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import cacheIn from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/vsearch/:kw/:order?/:embed?/:tid?',
+ categories: ['social-media'],
+ example: '/bilibili/vsearch/RSSHub',
+ parameters: {
+ kw: '检索关键字',
+ order: '排序方式, 综合:totalrank 最多点击:click 最新发布:pubdate(缺省) 最多弹幕:dm 最多收藏:stow',
+ embed: '默认为开启内嵌视频, 任意值为关闭',
+ tid: '分区 id',
+ },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ optional: true,
+ description: `如果没有此配置,那么必须开启 puppeteer 支持;BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+2. 打开控制台,切换到 Network 面板,刷新
+3. 点击 dynamic_new 请求,找到 Cookie
+4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '视频搜索',
+ maintainers: ['pcrtool', 'DIYgod'],
+ handler,
+ description: `分区 id 的取值请参考下表:
+
+| 全部分区 | 动画 | 番剧 | 国创 | 音乐 | 舞蹈 | 游戏 | 知识 | 科技 | 运动 | 汽车 | 生活 | 美食 | 动物圈 | 鬼畜 | 时尚 | 资讯 | 娱乐 | 影视 | 纪录片 | 电影 | 电视剧 |
+| -------- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ------ | ---- | ---- | ---- | ---- | ---- | ------ | ---- | ------ |
+| 0 | 1 | 13 | 167 | 3 | 129 | 4 | 36 | 188 | 234 | 223 | 160 | 211 | 217 | 119 | 155 | 202 | 5 | 181 | 177 | 23 | 11 |`,
+};
+
+const getIframe = (data, embed: boolean = true) => {
+ if (!embed) {
+ return '';
+ }
+ const aid = data?.aid;
+ const bvid = data?.bvid;
+ if (aid === undefined && bvid === undefined) {
+ return '';
+ }
+ return utils.renderUGCDescription(embed, '', '', aid, undefined, bvid);
+};
+
+async function handler(ctx) {
+ const kw = ctx.req.param('kw');
+ const order = ctx.req.param('order') || 'pubdate';
+ const embed = !ctx.req.param('embed');
+ const kw_url = encodeURIComponent(kw);
+ const tids = ctx.req.param('tid') ?? 0;
+ const cookie = await cacheIn.getCookie();
+
+ const response = await got('https://api.bilibili.com/x/web-interface/search/type', {
+ headers: {
+ Referer: `https://search.bilibili.com/all?keyword=${kw_url}`,
+ Cookie: cookie,
+ },
+ searchParams: {
+ search_type: 'video',
+ highlight: 1,
+ keyword: kw,
+ order,
+ tids,
+ },
+ });
+ const data = response.data.data.result;
+
+ return {
+ title: `${kw} - bilibili`,
+ link: `https://search.bilibili.com/all?keyword=${kw}&order=${order}`,
+ description: `Result from ${kw} bilibili search, ordered by ${order}.`,
+ item: data.map((item) => {
+ const l = item.duration
+ .split(':')
+ .map((i) => [i.length > 1 ? i : ('00' + i).slice(-2)])
+ .join(':');
+ const des = item.description.replaceAll('\n', ' ');
+ const img = item.pic.replaceAll(/^\/\//g, 'http://');
+ return {
+ title: item.title.replaceAll(/<[ /]?em[^>]*>/g, ''),
+ author: item.author,
+ category: [...item.tag.split(','), item.typename],
+ description:
+ `Length: ${l} ` +
+ `AuthorID: ${item.mid} ` +
+ `Play: ${item.play} Favorite: ${item.favorites} ` +
+ `Danmaku: ${item.video_review} Comment: ${item.review} ` +
+ ` ${des} ` +
+ ` ` +
+ `Match By: ${item.hit_columns?.join(',') || ''}` +
+ getIframe(item, embed),
+ pubDate: parseDate(item.pubdate, 'X'),
+ guid: item.arcurl,
+ link: item.arcurl,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/bilibili/wasm-exec.ts b/lib/routes/bilibili/wasm-exec.ts
new file mode 100644
index 00000000000000..7cfb8a02f44e31
--- /dev/null
+++ b/lib/routes/bilibili/wasm-exec.ts
@@ -0,0 +1,647 @@
+/* eslint-disable prefer-rest-params */
+/* eslint-disable default-case */
+/* eslint-disable unicorn/consistent-function-scoping */
+/* eslint-disable no-console */
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+'use strict';
+
+(() => {
+ const enosys = () => {
+ const err = new Error('not implemented');
+ err.code = 'ENOSYS';
+ return err;
+ };
+
+ if (!globalThis.fs) {
+ let outputBuf = '';
+ globalThis.fs = {
+ constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
+ writeSync(fd, buf) {
+ outputBuf += decoder.decode(buf);
+ const nl = outputBuf.lastIndexOf('\n');
+ if (nl !== -1) {
+ console.log(outputBuf.slice(0, nl));
+ outputBuf = outputBuf.slice(nl + 1);
+ }
+ return buf.length;
+ },
+ write(fd, buf, offset, length, position, callback) {
+ if (offset !== 0 || length !== buf.length || position !== null) {
+ callback(enosys());
+ return;
+ }
+ const n = this.writeSync(fd, buf);
+ callback(null, n);
+ },
+ chmod(path, mode, callback) {
+ callback(enosys());
+ },
+ chown(path, uid, gid, callback) {
+ callback(enosys());
+ },
+ close(fd, callback) {
+ callback(enosys());
+ },
+ fchmod(fd, mode, callback) {
+ callback(enosys());
+ },
+ fchown(fd, uid, gid, callback) {
+ callback(enosys());
+ },
+ fstat(fd, callback) {
+ callback(enosys());
+ },
+ fsync(fd, callback) {
+ callback(null);
+ },
+ ftruncate(fd, length, callback) {
+ callback(enosys());
+ },
+ lchown(path, uid, gid, callback) {
+ callback(enosys());
+ },
+ link(path, link, callback) {
+ callback(enosys());
+ },
+ lstat(path, callback) {
+ callback(enosys());
+ },
+ mkdir(path, perm, callback) {
+ callback(enosys());
+ },
+ open(path, flags, mode, callback) {
+ callback(enosys());
+ },
+ read(fd, buffer, offset, length, position, callback) {
+ callback(enosys());
+ },
+ readdir(path, callback) {
+ callback(enosys());
+ },
+ readlink(path, callback) {
+ callback(enosys());
+ },
+ rename(from, to, callback) {
+ callback(enosys());
+ },
+ rmdir(path, callback) {
+ callback(enosys());
+ },
+ stat(path, callback) {
+ callback(enosys());
+ },
+ symlink(path, link, callback) {
+ callback(enosys());
+ },
+ truncate(path, length, callback) {
+ callback(enosys());
+ },
+ unlink(path, callback) {
+ callback(enosys());
+ },
+ utimes(path, atime, mtime, callback) {
+ callback(enosys());
+ },
+ };
+ }
+
+ if (!globalThis.process) {
+ globalThis.process = {
+ getuid() {
+ return -1;
+ },
+ getgid() {
+ return -1;
+ },
+ geteuid() {
+ return -1;
+ },
+ getegid() {
+ return -1;
+ },
+ getgroups() {
+ throw enosys();
+ },
+ pid: -1,
+ ppid: -1,
+ umask() {
+ throw enosys();
+ },
+ cwd() {
+ throw enosys();
+ },
+ chdir() {
+ throw enosys();
+ },
+ };
+ }
+
+ if (!globalThis.path) {
+ globalThis.path = {
+ resolve(...pathSegments) {
+ return pathSegments.join('/');
+ },
+ };
+ }
+
+ if (!globalThis.crypto) {
+ throw new Error('globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)');
+ }
+
+ if (!globalThis.performance) {
+ throw new Error('globalThis.performance is not available, polyfill required (performance.now only)');
+ }
+
+ if (!globalThis.TextEncoder) {
+ throw new Error('globalThis.TextEncoder is not available, polyfill required');
+ }
+
+ if (!globalThis.TextDecoder) {
+ throw new Error('globalThis.TextDecoder is not available, polyfill required');
+ }
+
+ const encoder = new TextEncoder('utf-8');
+ const decoder = new TextDecoder('utf-8');
+
+ globalThis.Go = class {
+ constructor() {
+ this.argv = ['js'];
+ this.env = {};
+ this.exit = (code) => {
+ if (code !== 0) {
+ console.warn('exit code:', code);
+ }
+ };
+ this._exitPromise = new Promise((resolve) => {
+ this._resolveExitPromise = resolve;
+ });
+ this._pendingEvent = null;
+ this._scheduledTimeouts = new Map();
+ this._nextCallbackTimeoutID = 1;
+
+ const setInt64 = (addr, v) => {
+ this.mem.setUint32(addr + 0, v, true);
+ this.mem.setUint32(addr + 4, Math.floor(v / 4_294_967_296), true);
+ };
+
+ const getInt64 = (addr) => {
+ const low = this.mem.getUint32(addr + 0, true);
+ const high = this.mem.getInt32(addr + 4, true);
+ return low + high * 4_294_967_296;
+ };
+
+ const loadValue = (addr) => {
+ const f = this.mem.getFloat64(addr, true);
+ if (f === 0) {
+ return;
+ }
+ if (!Number.isNaN(f)) {
+ return f;
+ }
+
+ const id = this.mem.getUint32(addr, true);
+ return this._values[id];
+ };
+
+ const storeValue = (addr, v) => {
+ const nanHead = 0x7f_f8_00_00;
+
+ if (typeof v === 'number' && v !== 0) {
+ if (Number.isNaN(v)) {
+ this.mem.setUint32(addr + 4, nanHead, true);
+ this.mem.setUint32(addr, 0, true);
+ return;
+ }
+ this.mem.setFloat64(addr, v, true);
+ return;
+ }
+
+ if (v === undefined) {
+ this.mem.setFloat64(addr, 0, true);
+ return;
+ }
+
+ let id = this._ids.get(v);
+ if (id === undefined) {
+ id = this._idPool.pop();
+ if (id === undefined) {
+ id = this._values.length;
+ }
+ this._values[id] = v;
+ this._goRefCounts[id] = 0;
+ this._ids.set(v, id);
+ }
+ this._goRefCounts[id]++;
+ let typeFlag = 0;
+ switch (typeof v) {
+ case 'object':
+ if (v !== null) {
+ typeFlag = 1;
+ }
+ break;
+ case 'string':
+ typeFlag = 2;
+ break;
+ case 'symbol':
+ typeFlag = 3;
+ break;
+ case 'function':
+ typeFlag = 4;
+ break;
+ }
+ this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
+ this.mem.setUint32(addr, id, true);
+ };
+
+ const loadSlice = (addr) => {
+ const array = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ return new Uint8Array(this._inst.exports.mem.buffer, array, len);
+ };
+
+ const loadSliceOfValues = (addr) => {
+ const array = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ const a = Array.from({ length: len });
+ for (let i = 0; i < len; i++) {
+ a[i] = loadValue(array + i * 8);
+ }
+ return a;
+ };
+
+ const loadString = (addr) => {
+ const saddr = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
+ };
+
+ const testCallExport = (a, b) => {
+ this._inst.exports.testExport0();
+ return this._inst.exports.testExport(a, b);
+ };
+
+ const timeOrigin = Date.now() - performance.now();
+ this.importObject = {
+ _gotest: {
+ add: (a, b) => a + b,
+ callExport: testCallExport,
+ },
+ gojs: {
+ // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
+ // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
+ // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
+ // This changes the SP, thus we have to update the SP used by the imported function.
+
+ // func wasmExit(code int32)
+ 'runtime.wasmExit': (sp) => {
+ sp >>>= 0;
+ const code = this.mem.getInt32(sp + 8, true);
+ this.exited = true;
+ delete this._inst;
+ delete this._values;
+ delete this._goRefCounts;
+ delete this._ids;
+ delete this._idPool;
+ this.exit(code);
+ },
+
+ // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
+ 'runtime.wasmWrite': (sp) => {
+ sp >>>= 0;
+ const fd = getInt64(sp + 8);
+ const p = getInt64(sp + 16);
+ const n = this.mem.getInt32(sp + 24, true);
+ globalThis.fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
+ },
+
+ // func resetMemoryDataView()
+ 'runtime.resetMemoryDataView': (sp) => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ sp >>>= 0;
+ this.mem = new DataView(this._inst.exports.mem.buffer);
+ },
+
+ // func nanotime1() int64
+ 'runtime.nanotime1': (sp) => {
+ sp >>>= 0;
+ setInt64(sp + 8, (timeOrigin + performance.now()) * 1_000_000);
+ },
+
+ // func walltime() (sec int64, nsec int32)
+ 'runtime.walltime': (sp) => {
+ sp >>>= 0;
+ const msec = Date.now();
+ setInt64(sp + 8, msec / 1000);
+ this.mem.setInt32(sp + 16, (msec % 1000) * 1_000_000, true);
+ },
+
+ // func scheduleTimeoutEvent(delay int64) int32
+ 'runtime.scheduleTimeoutEvent': (sp) => {
+ sp >>>= 0;
+ const id = this._nextCallbackTimeoutID;
+ this._nextCallbackTimeoutID++;
+ this._scheduledTimeouts.set(
+ id,
+ setTimeout(
+ () => {
+ this._resume();
+ while (this._scheduledTimeouts.has(id)) {
+ // for some reason Go failed to register the timeout event, log and try again
+ // (temporary workaround for https://github.com/golang/go/issues/28975)
+ console.warn('scheduleTimeoutEvent: missed timeout event');
+ this._resume();
+ }
+ },
+ getInt64(sp + 8)
+ )
+ );
+ this.mem.setInt32(sp + 16, id, true);
+ },
+
+ // func clearTimeoutEvent(id int32)
+ 'runtime.clearTimeoutEvent': (sp) => {
+ sp >>>= 0;
+ const id = this.mem.getInt32(sp + 8, true);
+ clearTimeout(this._scheduledTimeouts.get(id));
+ this._scheduledTimeouts.delete(id);
+ },
+
+ // func getRandomData(r []byte)
+ 'runtime.getRandomData': (sp) => {
+ sp >>>= 0;
+ crypto.getRandomValues(loadSlice(sp + 8));
+ },
+
+ // func finalizeRef(v ref)
+ 'syscall/js.finalizeRef': (sp) => {
+ sp >>>= 0;
+ const id = this.mem.getUint32(sp + 8, true);
+ this._goRefCounts[id]--;
+ if (this._goRefCounts[id] === 0) {
+ const v = this._values[id];
+ this._values[id] = null;
+ this._ids.delete(v);
+ this._idPool.push(id);
+ }
+ },
+
+ // func stringVal(value string) ref
+ 'syscall/js.stringVal': (sp) => {
+ sp >>>= 0;
+ storeValue(sp + 24, loadString(sp + 8));
+ },
+
+ // func valueGet(v ref, p string) ref
+ 'syscall/js.valueGet': (sp) => {
+ sp >>>= 0;
+ const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 32, result);
+ },
+
+ // func valueSet(v ref, p string, x ref)
+ 'syscall/js.valueSet': (sp) => {
+ sp >>>= 0;
+ Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
+ },
+
+ // func valueDelete(v ref, p string)
+ 'syscall/js.valueDelete': (sp) => {
+ sp >>>= 0;
+ Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
+ },
+
+ // func valueIndex(v ref, i int) ref
+ 'syscall/js.valueIndex': (sp) => {
+ sp >>>= 0;
+ storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
+ },
+
+ // valueSetIndex(v ref, i int, x ref)
+ 'syscall/js.valueSetIndex': (sp) => {
+ sp >>>= 0;
+ Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
+ },
+
+ // func valueCall(v ref, m string, args []ref) (ref, bool)
+ 'syscall/js.valueCall': (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const m = Reflect.get(v, loadString(sp + 16));
+ const args = loadSliceOfValues(sp + 32);
+ const result = Reflect.apply(m, v, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 56, result);
+ this.mem.setUint8(sp + 64, 1);
+ } catch (error) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 56, error);
+ this.mem.setUint8(sp + 64, 0);
+ }
+ },
+
+ // func valueInvoke(v ref, args []ref) (ref, bool)
+ 'syscall/js.valueInvoke': (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const args = loadSliceOfValues(sp + 16);
+ const result = Reflect.apply(v, undefined, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, result);
+ this.mem.setUint8(sp + 48, 1);
+ } catch (error) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, error);
+ this.mem.setUint8(sp + 48, 0);
+ }
+ },
+
+ // func valueNew(v ref, args []ref) (ref, bool)
+ 'syscall/js.valueNew': (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const args = loadSliceOfValues(sp + 16);
+ const result = Reflect.construct(v, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, result);
+ this.mem.setUint8(sp + 48, 1);
+ } catch (error) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, error);
+ this.mem.setUint8(sp + 48, 0);
+ }
+ },
+
+ // func valueLength(v ref) int
+ 'syscall/js.valueLength': (sp) => {
+ sp >>>= 0;
+ setInt64(sp + 16, Number.parseInt(loadValue(sp + 8).length));
+ },
+
+ // valuePrepareString(v ref) (ref, int)
+ 'syscall/js.valuePrepareString': (sp) => {
+ sp >>>= 0;
+ const str = encoder.encode(String(loadValue(sp + 8)));
+ storeValue(sp + 16, str);
+ setInt64(sp + 24, str.length);
+ },
+
+ // valueLoadString(v ref, b []byte)
+ 'syscall/js.valueLoadString': (sp) => {
+ sp >>>= 0;
+ const str = loadValue(sp + 8);
+ loadSlice(sp + 16).set(str);
+ },
+
+ // func valueInstanceOf(v ref, t ref) bool
+ 'syscall/js.valueInstanceOf': (sp) => {
+ sp >>>= 0;
+ this.mem.setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16) ? 1 : 0);
+ },
+
+ // func copyBytesToGo(dst []byte, src ref) (int, bool)
+ 'syscall/js.copyBytesToGo': (sp) => {
+ sp >>>= 0;
+ const dst = loadSlice(sp + 8);
+ const src = loadValue(sp + 32);
+ if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
+ this.mem.setUint8(sp + 48, 0);
+ return;
+ }
+ const toCopy = src.subarray(0, dst.length);
+ dst.set(toCopy);
+ setInt64(sp + 40, toCopy.length);
+ this.mem.setUint8(sp + 48, 1);
+ },
+
+ // func copyBytesToJS(dst ref, src []byte) (int, bool)
+ 'syscall/js.copyBytesToJS': (sp) => {
+ sp >>>= 0;
+ const dst = loadValue(sp + 8);
+ const src = loadSlice(sp + 16);
+ if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
+ this.mem.setUint8(sp + 48, 0);
+ return;
+ }
+ const toCopy = src.subarray(0, dst.length);
+ dst.set(toCopy);
+ setInt64(sp + 40, toCopy.length);
+ this.mem.setUint8(sp + 48, 1);
+ },
+
+ debug: (value) => {
+ console.log(value);
+ },
+ },
+ };
+ }
+
+ async run(instance) {
+ if (!(instance instanceof WebAssembly.Instance)) {
+ throw new TypeError('Go.run: WebAssembly.Instance expected');
+ }
+ this._inst = instance;
+ this.mem = new DataView(this._inst.exports.mem.buffer);
+ this._values = [
+ // JS values that Go currently has references to, indexed by reference id
+ NaN,
+ 0,
+ null,
+ true,
+ false,
+ globalThis,
+ this,
+ ];
+ this._goRefCounts = Array.from({ length: this._values.length }).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
+ this._ids = new Map([
+ // mapping from JS values to reference ids
+ [0, 1],
+ [null, 2],
+ [true, 3],
+ [false, 4],
+ [globalThis, 5],
+ [this, 6],
+ ]);
+ this._idPool = []; // unused ids that have been garbage collected
+ this.exited = false; // whether the Go program has exited
+
+ // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
+ let offset = 4096;
+
+ const strPtr = (str) => {
+ const ptr = offset;
+ const bytes = encoder.encode(str + '\0');
+ new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
+ offset += bytes.length;
+ if (offset % 8 !== 0) {
+ offset += 8 - (offset % 8);
+ }
+ return ptr;
+ };
+
+ const argc = this.argv.length;
+
+ const argvPtrs = [];
+ for (const arg of this.argv) {
+ argvPtrs.push(strPtr(arg));
+ }
+ argvPtrs.push(0);
+
+ const keys = Object.keys(this.env).toSorted();
+ for (const key of keys) {
+ argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
+ }
+ argvPtrs.push(0);
+
+ const argv = offset;
+ for (const ptr of argvPtrs) {
+ this.mem.setUint32(offset, ptr, true);
+ this.mem.setUint32(offset + 4, 0, true);
+ offset += 8;
+ }
+
+ // The linker guarantees global data starts from at least wasmMinDataAddr.
+ // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
+ const wasmMinDataAddr = 4096 + 8192;
+ if (offset >= wasmMinDataAddr) {
+ throw new Error('total length of command line and environment variables exceeds limit');
+ }
+
+ this._inst.exports.run(argc, argv);
+ if (this.exited) {
+ this._resolveExitPromise();
+ }
+ await this._exitPromise;
+ }
+
+ _resume() {
+ if (this.exited) {
+ throw new Error('Go program has already exited');
+ }
+ this._inst.exports.resume();
+ if (this.exited) {
+ this._resolveExitPromise();
+ }
+ }
+
+ _makeFuncWrapper(id) {
+ // somehow avoiding aliasing this with an arrow function doesn't work
+ // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias
+ const go = this;
+ return function () {
+ const event = { id, this: this, args: arguments };
+ go._pendingEvent = event;
+ go._resume();
+ return event.result;
+ };
+ }
+ };
+})();
+
+export const Go = globalThis.Go;
diff --git a/lib/routes/bilibili/watchlater.ts b/lib/routes/bilibili/watchlater.ts
new file mode 100644
index 00000000000000..2be2aee30cf7d4
--- /dev/null
+++ b/lib/routes/bilibili/watchlater.ts
@@ -0,0 +1,77 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/watchlater/:uid/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/watchlater/2267573',
+ parameters: { uid: '用户 id', embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '用户稍后再看',
+ maintainers: ['JimenezLi'],
+ handler,
+ description: `::: warning
+ 用户稍后再看需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const embed = !ctx.req.param('embed');
+ const name = await cache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ const response = await got({
+ method: 'get',
+ url: `https://api.bilibili.com/x/v2/history/toview`,
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ if (response.data.code) {
+ const message = response.data.code === -6 ? '对应 uid 的 Bilibili 用户的 Cookie 已过期' : response.data.message;
+ throw new ConfigNotFoundError(`Error code ${response.data.code}: ${message}`);
+ }
+ const list = response.data.data.list || [];
+
+ const out = list.map((item) => ({
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, `${item.desc}在稍后再看列表中查看 `, item.aid, undefined, item.bvid),
+ pubDate: parseDate(item.add_at * 1000),
+ link: item.pubdate > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: item.owner.name,
+ }));
+
+ return {
+ title: `${name} 稍后再看`,
+ link: 'https://www.bilibili.com/watchlater#/list',
+ item: out,
+ };
+}
diff --git a/lib/routes/bilibili/weekly-recommend.ts b/lib/routes/bilibili/weekly-recommend.ts
new file mode 100644
index 00000000000000..ad30fca8cf4141
--- /dev/null
+++ b/lib/routes/bilibili/weekly-recommend.ts
@@ -0,0 +1,73 @@
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDuration } from '@/utils/helpers';
+
+import cache from './cache';
+import utils, { getVideoUrl } from './utils';
+
+export const route: Route = {
+ path: '/weekly/:embed?',
+ categories: ['social-media'],
+ example: '/bilibili/weekly',
+ parameters: { embed: '默认为开启内嵌视频, 任意值为关闭' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'B 站每周必看',
+ maintainers: ['ttttmr'],
+ handler,
+};
+
+async function handler(ctx) {
+ const isJsonFeed = ctx.req.query('format') === 'json';
+ const embed = !ctx.req.param('embed');
+
+ const status_response = await got({
+ method: 'get',
+ url: 'https://app.bilibili.com/x/v2/show/popular/selected/series?type=weekly_selected',
+ headers: {
+ Referer: 'https://www.bilibili.com/h5/weekly-recommend',
+ },
+ });
+ const weekly_number = status_response.data.data[0].number;
+ const weekly_name = status_response.data.data[0].name;
+
+ const response = await got({
+ method: 'get',
+ url: `https://app.bilibili.com/x/v2/show/popular/selected?type=weekly_selected&number=${weekly_number}`,
+ headers: {
+ Referer: `https://www.bilibili.com/h5/weekly-recommend?num=${weekly_number}&navhide=1`,
+ },
+ });
+ const data = response.data.data.list;
+
+ return {
+ title: 'B站每周必看',
+ link: 'https://www.bilibili.com/h5/weekly-recommend',
+ description: 'B站每周必看',
+ item: data.map(async (item) => {
+ const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && item.bvid ? await cache.getVideoSubtitleAttachment(item.bvid) : [];
+ return {
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.cover, `${weekly_name} ${item.title} - ${item.rcmd_reason}`, item.param, undefined, item.bvid),
+ link: weekly_number > 60 && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.param}`,
+ attachments: item.bvid
+ ? [
+ {
+ url: getVideoUrl(item.bvid),
+ mime_type: 'text/html',
+ duration_in_seconds: parseDuration(item.cover_right_text_1),
+ },
+ ...subtitles,
+ ]
+ : undefined,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/binance/announcement.ts b/lib/routes/binance/announcement.ts
new file mode 100644
index 00000000000000..d2b90ad9f1cfae
--- /dev/null
+++ b/lib/routes/binance/announcement.ts
@@ -0,0 +1,172 @@
+import * as cheerio from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import type { AnnouncementCatalog, AnnouncementsConfig } from './types';
+
+interface AnnouncementFragment {
+ reactRoot: [{ id: 'Fragment'; children: { id: string; props: object }[]; props: object }];
+}
+
+const ROUTE_PARAMETERS_CATALOGID_MAPPING = {
+ 'new-cryptocurrency-listing': 48,
+ 'latest-binance-news': 49,
+ 'latest-activities': 93,
+ 'new-fiat-listings': 50,
+ 'api-updates': 51,
+ 'crypto-airdrop': 128,
+ 'wallet-maintenance-updates': 157,
+ delisting: 161,
+};
+
+function assertAnnouncementsConfig(playlist: unknown): playlist is AnnouncementFragment {
+ if (!playlist || typeof playlist !== 'object') {
+ return false;
+ }
+ if (!('reactRoot' in (playlist as { reactRoot: unknown[] }))) {
+ return false;
+ }
+ if (!Array.isArray((playlist as { reactRoot: unknown[] }).reactRoot)) {
+ return false;
+ }
+ if ((playlist as { reactRoot: { id: string }[] }).reactRoot?.[0]?.id !== 'Fragment') {
+ return false;
+ }
+ return true;
+}
+
+function assertAnnouncementsConfigList(props: unknown): props is { config: { list: AnnouncementsConfig[] } } {
+ if (!props || typeof props !== 'object') {
+ return false;
+ }
+ if (!('config' in props)) {
+ return false;
+ }
+ if (!('list' in (props.config as { list: AnnouncementsConfig[] }))) {
+ return false;
+ }
+ return true;
+}
+
+const handler: Route['handler'] = async (ctx) => {
+ const baseUrl = 'https://www.binance.com';
+ const announcementCategoryUrl = `${baseUrl}/support/announcement`;
+ const { type } = ctx.req.param<'/binance/announcement/:type'>();
+ const language = ctx.req.header('Accept-Language');
+ const headers = {
+ Referer: baseUrl,
+ 'Accept-Language': language ?? 'en-US,en;q=0.9',
+ };
+ const announcementsConfig = (await cache.tryGet(`binance:announcements:${language}`, async () => {
+ const announcementRes = await ofetch(announcementCategoryUrl, { headers });
+ const $ = cheerio.load(announcementRes);
+
+ const appData = JSON.parse($('#__APP_DATA').text());
+
+ const announcements = Object.values(appData.appState.loader.dataByRouteId as Record).find((value) => 'playlist' in value) as { playlist: unknown };
+
+ if (!assertAnnouncementsConfig(announcements.playlist)) {
+ throw new Error('Get announcement config failed');
+ }
+
+ const listConfigProps = announcements.playlist.reactRoot[0].children.find((i) => i.id === 'TopicCardList')?.props;
+
+ if (!assertAnnouncementsConfigList(listConfigProps)) {
+ throw new Error("Can't get announcement config list");
+ }
+
+ return listConfigProps.config.list;
+ })) as AnnouncementsConfig[];
+
+ const announcementCatalogId = ROUTE_PARAMETERS_CATALOGID_MAPPING[type];
+
+ if (!announcementCatalogId) {
+ throw new Error(`${type} is not supported`);
+ }
+
+ const targetItem = announcementsConfig.find((i) => i.url.includes(`c-${announcementCatalogId}`));
+
+ if (!targetItem) {
+ throw new Error('Unexpected announcements config');
+ }
+
+ const link = new URL(targetItem.url, baseUrl).toString();
+
+ const response = await ofetch(link, { headers });
+
+ const $ = cheerio.load(response);
+ const appData = JSON.parse($('#__APP_DATA').text());
+
+ const values = Object.values(appData.appState.loader.dataByRouteId as Record);
+ const catalogs = values.find((value) => 'catalogs' in value) as { catalogs: AnnouncementCatalog[] };
+ const catalog = catalogs.catalogs.find((catalog) => catalog.catalogId === announcementCatalogId);
+
+ const item = await Promise.all(
+ catalog!.articles.map((i) => {
+ const link = `${announcementCategoryUrl}/${i.code}`;
+ const item = {
+ title: i.title,
+ link,
+ description: i.title,
+ pubDate: parseDate(i.releaseDate),
+ } as DataItem;
+ return cache.tryGet(`binance:announcement:${i.code}:${language}`, async () => {
+ const res = await ofetch(link, { headers });
+ const $ = cheerio.load(res);
+ const descriptionEl = $('#support_article > div').first();
+ descriptionEl.find('style').remove();
+ item.description = descriptionEl.html() ?? '';
+ return item;
+ }) as Promise;
+ })
+ );
+
+ return {
+ title: targetItem.title,
+ link,
+ description: targetItem.description,
+ item,
+ };
+};
+
+export const route: Route = {
+ path: '/announcement/:type',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/binance/announcement/new-cryptocurrency-listing',
+ parameters: {
+ type: {
+ description: 'Binance Announcement type',
+ default: 'new-cryptocurrency-listing',
+ options: [
+ { value: 'new-cryptocurrency-listing', label: 'New Cryptocurrency Listing' },
+ { value: 'latest-binance-news', label: 'Latest Binance News' },
+ { value: 'latest-activities', label: 'Latest Activities' },
+ { value: 'new-fiat-listings', label: 'New Fiat Listings' },
+ { value: 'api-updates', label: 'API Updates' },
+ { value: 'crypto-airdrop', label: 'Crypto Airdrop' },
+ { value: 'wallet-maintenance-updates', label: 'Wallet Maintenance Updates' },
+ { value: 'delisting', label: 'Delisting' },
+ ],
+ },
+ },
+ name: 'Announcement',
+ description: `
+Type category
+
+ - new-cryptocurrency-listing => New Cryptocurrency Listing
+ - latest-binance-news => Latest Binance News
+ - latest-activities => Latest Activities
+ - new-fiat-listings => New Fiat Listings
+ - api-updates => API Updates
+ - crypto-airdrop => Crypto Airdrop
+ - wallet-maintenance-updates => Wallet Maintenance Updates
+ - delisting => Delisting
+`,
+ maintainers: ['enpitsulin'],
+ handler,
+};
diff --git a/lib/routes/binance/launchpool.ts b/lib/routes/binance/launchpool.ts
new file mode 100644
index 00000000000000..de966316864bc9
--- /dev/null
+++ b/lib/routes/binance/launchpool.ts
@@ -0,0 +1,47 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/launchpool',
+ categories: ['finance'],
+ example: '/binance/launchpool',
+ radar: [
+ {
+ source: ['binance.com/:lang/support/announcement'],
+ },
+ ],
+ name: 'Binance数字货币及交易对上新',
+ maintainers: ['zhenlohuang'],
+ handler,
+};
+
+async function handler() {
+ const baseUrl = 'https://www.binance.com/zh-CN/support/announcement';
+ const url = `${baseUrl}/数字货币及交易对上新?c=48&navId=48`;
+
+ const response = await got({
+ url,
+ headers: {
+ Referer: 'https://www.binance.com/',
+ },
+ });
+
+ const $ = load(response.data);
+ const appData = JSON.parse($('#__APP_DATA').text());
+ const articles = appData.appState.loader.dataByRouteId.d969.catalogs.find((catalog) => catalog.catalogId === 48).articles.filter((article) => article.title.includes('币安新币挖矿上线'));
+
+ return {
+ title: 'Binance | 数字货币及交易对上新',
+ link: url,
+ description: '数字货币及交易对上新',
+ item: articles.map((item) => ({
+ title: item.title,
+ link: `${baseUrl}/${item.code}`,
+ description: item.title,
+ pubDate: parseDate(item.releaseDate),
+ })),
+ };
+}
diff --git a/lib/routes/binance/namespace.ts b/lib/routes/binance/namespace.ts
new file mode 100644
index 00000000000000..361bdeb4cf7292
--- /dev/null
+++ b/lib/routes/binance/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Binance',
+ url: 'binance.com',
+ lang: 'en',
+};
diff --git a/lib/routes/binance/types.ts b/lib/routes/binance/types.ts
new file mode 100644
index 00000000000000..4078150e634a9c
--- /dev/null
+++ b/lib/routes/binance/types.ts
@@ -0,0 +1,26 @@
+export interface AnnouncementsConfig {
+ title: string;
+ description: string;
+ url: string;
+ imgUrl: string;
+}
+
+export interface AnnouncementCatalog {
+ articles: AnnouncementArticle[];
+ catalogId: number;
+ catalogName: string;
+ catalogType: 1;
+ catalogs: [];
+ description: null;
+ icon: string;
+ parentCatalogId: null;
+ total: number;
+}
+
+export interface AnnouncementArticle {
+ id: number;
+ code: string;
+ title: string;
+ type: number;
+ releaseDate: number;
+}
diff --git a/lib/routes/bing/daily-wallpaper.ts b/lib/routes/bing/daily-wallpaper.ts
new file mode 100644
index 00000000000000..c19a684de382e6
--- /dev/null
+++ b/lib/routes/bing/daily-wallpaper.ts
@@ -0,0 +1,84 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:routeParams?',
+ parameters: {
+ routeParams: '额外参数type,story和lang:请参阅以下说明和表格',
+ },
+ radar: [
+ {
+ source: ['www.bing.com/'],
+ target: '',
+ },
+ {
+ source: ['cn.bing.com/'],
+ target: '',
+ },
+ ],
+ name: '每日壁纸',
+ maintainers: ['FHYunCai', 'LLLLLFish'],
+ handler,
+ url: 'www.bing.com/',
+ example: '/bing/type=UHD&story=1&lang=zh-CN',
+ description: `| 参数 | 含义 | 接受的值 | 默认值 | 备注 |
+|-------|--------------------|-----------------------------------------------------------|-----------|--------------------------------------------------------|
+| type | 输出壁纸的像素类型 | UHD/1920x1080/1920x1200/768x1366/1080x1920/1080x1920_logo | 1920x1080 | 1920x1200与1080x1920_logo带有水印,输入的值不在接受范围内都会输出成1920x1080 |
+| story | 是否输出壁纸的故事 | 1/0 | 0 | 输入的值不为1都不会输出故事 |
+| lang | 输出壁纸图文的地区(中文或者是英文) | zh/en | zh | zh/en输出的壁纸图文不一定是一样的;如果en不生效,试着部署到其他地方 |
+`,
+};
+
+async function handler(ctx) {
+ const routeParams = new URLSearchParams(ctx.req.param('routeParams'));
+ let type = routeParams.get('type') || '1920x1080';
+ let lang = routeParams.get('lang');
+ let apiUrl = '';
+ const allowedTypes = ['UHD', '1920x1080', '1920x1200', '768x1366', '1080x1920', '1080x1920_logo'];
+ if (lang !== 'zh' && lang !== 'en') {
+ lang = 'zh';
+ }
+ if (lang === 'zh') {
+ lang = 'zh-CN';
+ apiUrl = 'https://cn.bing.com';
+ } else {
+ lang = 'en-US';
+ apiUrl = 'https://www.bing.com';
+ }
+ if (!allowedTypes.includes(type)) {
+ type = '1920x1080';
+ }
+ const story = routeParams.get('story') === '1';
+ const resp = await ofetch('/hp/api/model', {
+ baseURL: apiUrl,
+ method: 'GET',
+ query: {
+ mtk: lang,
+ },
+ });
+ const items = resp.MediaContents.map((item) => {
+ const ssd = item.Ssd;
+ const link = `${apiUrl}${item.ImageContent.Image.Url.match(/\/th\?id=[^_]+_[^_]+/)[0].replace(/(_\d+x\d+\.webp)$/i, '')}_${type}.jpg`;
+ let description = ` `;
+ if (story) {
+ description += `${item.ImageContent.Headline} `;
+ description += `${item.ImageContent.QuickFact.MainText} `;
+ description += `${item.ImageContent.Description}
`;
+ }
+ return {
+ title: item.ImageContent.Title,
+ description,
+ link: `${apiUrl}${item.ImageContent.BackstageUrl}`,
+ author: item.ImageContent.Copyright,
+ pubDate: timezone(parseDate(ssd, 'YYYYMMDD_HHmm'), 0),
+ };
+ });
+ return {
+ title: 'Bing每日壁纸',
+ link: apiUrl,
+ description: 'Bing每日壁纸',
+ item: items,
+ };
+}
diff --git a/lib/routes/bing/namespace.ts b/lib/routes/bing/namespace.ts
new file mode 100644
index 00000000000000..abaf432718e02f
--- /dev/null
+++ b/lib/routes/bing/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bing',
+ url: 'cn.bing.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bing/search.ts b/lib/routes/bing/search.ts
new file mode 100644
index 00000000000000..85f352e415f16c
--- /dev/null
+++ b/lib/routes/bing/search.ts
@@ -0,0 +1,57 @@
+import 'dayjs/locale/zh-cn.js';
+
+import dayjs from 'dayjs';
+import localizedFormat from 'dayjs/plugin/localizedFormat.js';
+
+import type { Route } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+import parser from '@/utils/rss-parser';
+
+dayjs.extend(localizedFormat);
+
+export const route: Route = {
+ path: '/search/:keyword',
+ categories: ['other'],
+ example: '/bing/search/rss',
+ parameters: { keyword: '搜索关键词' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cn.bing.com/'],
+ target: '',
+ },
+ ],
+ name: '搜索',
+ maintainers: ['CaoMeiYouRen'],
+ handler,
+ url: 'cn.bing.com/',
+};
+
+async function handler(ctx) {
+ const q = ctx.req.param('keyword');
+ const searchParams = new URLSearchParams({
+ format: 'rss',
+ q,
+ });
+ const url = new URL('https://cn.bing.com/search');
+ url.search = searchParams.toString();
+ const data = await parser.parseURL(url.toString());
+ return {
+ title: data.title,
+ link: data.link,
+ description: data.description + ' - ' + data.copyright,
+ image: data.image.url,
+ item: data.items.map((e) => ({
+ ...e,
+ description: e.content,
+ pubDate: parseDate(e.pubDate, 'dddd, DD MMM YYYY HH:mm:ss [GMT]', 'zh-cn'),
+ })),
+ };
+}
diff --git a/lib/routes/biodiscover/index.ts b/lib/routes/biodiscover/index.ts
new file mode 100644
index 00000000000000..9f576d06fa32fd
--- /dev/null
+++ b/lib/routes/biodiscover/index.ts
@@ -0,0 +1,60 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:channel?',
+ radar: [
+ {
+ source: ['www.biodiscover.com/:channel'],
+ target: '/:channel',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['aidistan'],
+ handler,
+};
+
+async function handler(ctx) {
+ const channel = ctx.req.param('channel');
+ const listUrl = 'http://www.biodiscover.com/' + channel;
+ const response = await got({ url: listUrl });
+ const $ = load(response.data);
+
+ const items = $('.new_list .newList_box')
+ .toArray()
+ .map((item) => ({
+ pubDate: parseDate($(item).find('.news_flow_tag .times').text().trim()),
+ link: 'http://www.biodiscover.com' + $(item).find('h2 a').attr('href'),
+ }));
+
+ return {
+ title: '生物探索 - ' + $('.header li.sel a').text(),
+ link: listUrl,
+ description: $('meta[name=description]').attr('content'),
+ item: await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({ url: item.link });
+ const $ = load(detailResponse.data);
+
+ // remove sharing info if exists
+ const lastNode = $('.main_info').children().last();
+ if (lastNode.css('display') === 'none') {
+ lastNode.remove();
+ }
+
+ return {
+ title: $('h1').text().trim(),
+ description: $('.main_info').html(),
+ pubDate: item.pubDate,
+ link: item.link,
+ };
+ })
+ )
+ ),
+ };
+}
diff --git a/lib/routes/biodiscover/namespace.ts b/lib/routes/biodiscover/namespace.ts
new file mode 100644
index 00000000000000..e81481e842ebeb
--- /dev/null
+++ b/lib/routes/biodiscover/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'biodiscover.com 生物探索',
+ url: 'www.biodiscover.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bioone/featured.ts b/lib/routes/bioone/featured.ts
new file mode 100644
index 00000000000000..c8c9f8ea892fd3
--- /dev/null
+++ b/lib/routes/bioone/featured.ts
@@ -0,0 +1,79 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/featured',
+ categories: ['journal'],
+ example: '/bioone/featured',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bioone.org/'],
+ },
+ ],
+ name: 'Featured articles',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'bioone.org/',
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://bioone.org';
+ const response = await got(rootUrl, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.items h4 a')
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 10)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const link = item.attr('href').split('?')[0];
+
+ return {
+ title: item.text(),
+ link: link.includes('http') ? link : `${rootUrl}${link}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+ const content = load(detailResponse.data);
+
+ item.description = content('#divARTICLECONTENTTop').html();
+ item.doi = content('meta[name="dc.Identifier"]').attr('content');
+ item.pubDate = parseDate(content('meta[name="dc.Date"]').attr('content'));
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: 'Featured articles - BioOne',
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bioone/journal.ts b/lib/routes/bioone/journal.ts
new file mode 100644
index 00000000000000..b7a0a7b551f71c
--- /dev/null
+++ b/lib/routes/bioone/journal.ts
@@ -0,0 +1,87 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/journals/:journal?',
+ categories: ['journal'],
+ example: '/bioone/journals/acta-chiropterologica',
+ parameters: { journal: 'Journals, can be found in URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bioone.org/journals/:journal', 'bioone.org/'],
+ target: '/journals/:journal',
+ },
+ ],
+ name: 'Journals',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const journal = ctx.req.param('journal') ?? 'acta-chiropterologica';
+
+ const rootUrl = 'https://bioone.org';
+ const currentUrl = `${rootUrl}/journals/${journal}/current`;
+ const response = await got(currentUrl, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.TOCLineItemBoldText')
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 20)
+ .toArray()
+ .map((item) => {
+ item = $(item).parent();
+
+ return {
+ link: `${rootUrl}${item.attr('href')}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+
+ const content = load(detailResponse.data);
+
+ content('.ProceedingsArticleOpenAccessPanel').remove();
+ content('#divNotSignedSection, #rightRail').remove();
+
+ item.description = content('.panel-body').html();
+ item.title = content('meta[name="dc.Title"]').attr('content');
+ item.author = content('meta[name="dc.Creator"]').attr('content');
+ item.doi = content('meta[name="dc.Identifier"]').attr('content');
+ item.pubDate = parseDate(content('meta[name="dc.Date"]').attr('content'));
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('.panel-body .row a').first().text()} - BioOne`,
+ description: $('.panel-body .row text').first().text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bioone/namespace.ts b/lib/routes/bioone/namespace.ts
new file mode 100644
index 00000000000000..f2a193208ed7b8
--- /dev/null
+++ b/lib/routes/bioone/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BioOne',
+ url: 'bioone.org',
+ lang: 'en',
+};
diff --git a/lib/routes/biquge/index.ts b/lib/routes/biquge/index.ts
new file mode 100644
index 00000000000000..817222c6c6c960
--- /dev/null
+++ b/lib/routes/biquge/index.ts
@@ -0,0 +1,112 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const allowHost = new Set([
+ 'www.xbiquwx.la',
+ 'www.biqu5200.net',
+ 'www.xbiquge.so',
+ 'www.biqugeu.net',
+ 'www.b520.cc',
+ 'www.ahfgb.com',
+ 'www.ibiquge.la',
+ 'www.biquge.tv',
+ 'www.bswtan.com',
+ 'www.biquge.co',
+ 'www.bqzhh.com',
+ 'www.biqugse.com',
+ 'www.ibiquge.info',
+ 'www.ishuquge.com',
+ 'www.mayiwxw.com',
+]);
+
+export const route: Route = {
+ path: '*',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const rootUrl = getSubPath(ctx).split('/').slice(1, 4).join('/');
+ const currentUrl = getSubPath(ctx).slice(1);
+ if (!config.feature.allow_user_supply_unsafe_domain && !allowHost.has(new URL(rootUrl).hostname)) {
+ throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
+ }
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ responseType: 'buffer',
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+
+ const isGBK = /charset="?'?gb/i.test(response.data.toString());
+ const encoding = isGBK ? 'gbk' : 'utf-8';
+
+ const $ = load(iconv.decode(response.data, encoding));
+ const author = $('meta[property="og:novel:author"]').attr('content');
+ const pubDate = timezone(parseDate($('meta[property="og:novel:update_time"]').attr('content')), +8);
+
+ let items = $('dl dd a')
+ .toArray()
+ .toReversed()
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 1)
+ .map((item) => {
+ item = $(item);
+
+ let link = '';
+ const url = item.attr('href');
+ if (url.startsWith('http')) {
+ link = url;
+ } else if (/^\//.test(url)) {
+ link = `${rootUrl}${url}`;
+ } else {
+ link = `${currentUrl}/${url}`;
+ }
+
+ return {
+ title: item.text(),
+ link,
+ author,
+ pubDate,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ responseType: 'buffer',
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+
+ const content = load(iconv.decode(detailResponse.data, encoding));
+
+ item.description = content('#content').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('meta[property="og:title"]').attr('content')} - 笔趣阁`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/biquge/namespace.ts b/lib/routes/biquge/namespace.ts
new file mode 100644
index 00000000000000..25da181955d1f8
--- /dev/null
+++ b/lib/routes/biquge/namespace.ts
@@ -0,0 +1,28 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '笔趣阁',
+ url: 'xbiquwx.la',
+ description: `::: tip
+此处的 **笔趣阁** 指网络上使用和 **笔趣阁** 样式相似模板的小说阅读网站,包括但不限于下方列举的网址。
+:::
+
+| 网址 | 名称 |
+| ---------------------------------------------------- | ---------- |
+| [https://www.xbiquwx.la](https://www.xbiquwx.la) | 笔尖中文 |
+| [http://www.biqu5200.net](http://www.biqu5200.net) | 笔趣阁 |
+| [https://www.xbiquge.so](https://www.xbiquge.so) | 笔趣阁 |
+| [https://www.biqugeu.net](https://www.biqugeu.net) | 顶点小说网 |
+| [http://www.b520.cc](http://www.b520.cc) | 笔趣阁 |
+| [https://www.ahfgb.com](https://www.ahfgb.com) | 笔趣鸽 |
+| [https://www.ibiquge.la](https://www.ibiquge.la) | 香书小说 |
+| [https://www.biquge.tv](https://www.biquge.tv) | 笔趣阁 |
+| [https://www.bswtan.com](https://www.bswtan.com) | 笔书网 |
+| [https://www.biquge.co](https://www.biquge.co) | 笔趣阁 |
+| [https://www.bqzhh.com](https://www.bqzhh.com) | 笔趣阁 |
+| [http://www.biqugse.com](http://www.biqugse.com) | 笔趣阁 |
+| [https://www.ibiquge.info](https://www.ibiquge.info) | 爱笔楼 |
+| [https://www.ishuquge.com](https://www.ishuquge.com) | 书趣阁 |
+| [https://www.mayiwxw.com](https://www.mayiwxw.com) | 蚂蚁文学 |`,
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bit/cs/cs.ts b/lib/routes/bit/cs/cs.ts
new file mode 100644
index 00000000000000..ed0141fb2900a1
--- /dev/null
+++ b/lib/routes/bit/cs/cs.ts
@@ -0,0 +1,52 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import util from './utils';
+
+export const route: Route = {
+ path: '/cs',
+ categories: ['university'],
+ example: '/bit/cs',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cs.bit.edu.cn/tzgg', 'cs.bit.edu.cn/'],
+ },
+ ],
+ name: '计院通知',
+ maintainers: ['sinofp'],
+ handler,
+ url: 'cs.bit.edu.cn/tzgg',
+};
+
+async function handler() {
+ const link = 'https://cs.bit.edu.cn/tzgg/';
+ const response = await got({
+ method: 'get',
+ url: link,
+ });
+
+ const $ = load(response.data);
+
+ const list = $('.box_list01 li').toArray();
+
+ const result = await util.ProcessFeed(list, cache);
+
+ return {
+ title: $('title').text(),
+ link,
+ description: $('meta[name="description"]').attr('content'),
+ item: result,
+ };
+}
diff --git a/lib/routes/bit/cs/utils.ts b/lib/routes/bit/cs/utils.ts
new file mode 100644
index 00000000000000..ad67790a430737
--- /dev/null
+++ b/lib/routes/bit/cs/utils.ts
@@ -0,0 +1,52 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+// 专门定义一个function用于加载文章内容
+async function loadContent(item) {
+ // 异步请求文章
+ const response = await got(item.link);
+ // 加载文章内容
+ const $ = load(response.data);
+
+ // 提取作者
+ const zuozhe = $('.zuozhe').children().next();
+ item.author = zuozhe.first().text();
+
+ // 提取内容
+ item.description = $('.wz_art').html();
+
+ // 返回解析的结果
+ return item;
+}
+
+const ProcessFeed = (list, caches) => {
+ const host = 'https://cs.bit.edu.cn/tzgg/';
+
+ return Promise.all(
+ // 遍历每一篇文章
+ list.map((item) => {
+ const $ = load(item);
+
+ const $title = $('a');
+ // 还原相对链接为绝对链接
+ const itemUrl = new URL($title.attr('href'), host).href;
+
+ // 列表上提取到的信息
+ const single = {
+ title: $title.text(),
+ link: itemUrl,
+ // author: '教务部',
+ pubDate: timezone(parseDate($('span').text()), 8),
+ };
+
+ // 使用tryGet方法从缓存获取内容。
+ // 当缓存中无法获取到链接内容的时候,则使用load方法加载文章内容。
+ return caches.tryGet(single.link, () => loadContent(single));
+ })
+ );
+};
+
+export default { ProcessFeed };
diff --git a/lib/routes/bit/jwc/jwc.ts b/lib/routes/bit/jwc/jwc.ts
new file mode 100644
index 00000000000000..c0e1d405254dc2
--- /dev/null
+++ b/lib/routes/bit/jwc/jwc.ts
@@ -0,0 +1,52 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import util from './utils';
+
+export const route: Route = {
+ path: '/jwc',
+ categories: ['university'],
+ example: '/bit/jwc',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['jwc.bit.edu.cn/tzgg', 'jwc.bit.edu.cn/'],
+ },
+ ],
+ name: '教务处通知',
+ maintainers: ['sinofp'],
+ handler,
+ url: 'jwc.bit.edu.cn/tzgg',
+};
+
+async function handler() {
+ const link = 'https://jwc.bit.edu.cn/tzgg/';
+ const response = await got({
+ method: 'get',
+ url: link,
+ });
+
+ const $ = load(response.data);
+
+ const list = $('li.gpTextArea').toArray();
+
+ const result = await util.ProcessFeed(list, cache);
+
+ return {
+ title: $('title').text(),
+ link,
+ description: '北京理工大学教务部',
+ item: result,
+ };
+}
diff --git a/lib/routes/bit/jwc/utils.ts b/lib/routes/bit/jwc/utils.ts
new file mode 100644
index 00000000000000..1bcc9b522bda5a
--- /dev/null
+++ b/lib/routes/bit/jwc/utils.ts
@@ -0,0 +1,48 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+// 专门定义一个function用于加载文章内容
+async function loadContent(item) {
+ // 异步请求文章
+ const response = await got(item.link);
+ // 加载文章内容
+ const $ = load(response.data);
+
+ // 提取内容
+ item.description = $('.gp-article').html();
+
+ // 返回解析的结果
+ return item;
+}
+
+const ProcessFeed = (list, caches) => {
+ const host = 'https://jwc.bit.edu.cn/tzgg/';
+
+ return Promise.all(
+ // 遍历每一篇文章
+ list.map((item) => {
+ const $ = load(item);
+
+ const $title = $('a');
+ // 还原相对链接为绝对链接
+ const itemUrl = new URL($title.attr('href'), host).href;
+
+ // 列表上提取到的信息
+ const single = {
+ title: $title.text(),
+ link: itemUrl,
+ author: '教务部',
+ pubDate: timezone(parseDate($('span').text()), 8),
+ };
+
+ // 使用tryGet方法从缓存获取内容。
+ // 当缓存中无法获取到链接内容的时候,则使用load方法加载文章内容。
+ return caches.tryGet(single.link, () => loadContent(single));
+ })
+ );
+};
+
+export default { ProcessFeed };
diff --git a/lib/routes/bit/namespace.ts b/lib/routes/bit/namespace.ts
new file mode 100644
index 00000000000000..0566caa96cb222
--- /dev/null
+++ b/lib/routes/bit/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京理工大学',
+ url: 'cs.bit.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bit/rszhaopin.ts b/lib/routes/bit/rszhaopin.ts
new file mode 100644
index 00000000000000..b966cc9242fb6e
--- /dev/null
+++ b/lib/routes/bit/rszhaopin.ts
@@ -0,0 +1,55 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/rszhaopin',
+ categories: ['university'],
+ example: '/bit/rszhaopin',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['rszhaopin.bit.edu.cn/'],
+ },
+ ],
+ name: '人才招聘',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'rszhaopin.bit.edu.cn/',
+};
+
+async function handler() {
+ const rootUrl = 'https://rszhaopin.bit.edu.cn';
+ const apiUrl = `${rootUrl}/ajax/ajaxService`;
+ const currentUrl = `${rootUrl}/zp.html#/notices/0`;
+
+ const response = await got({
+ method: 'post',
+ url: apiUrl,
+ form: {
+ __xml: 'A2GgW6kPrLjaqavT0I8o9cOIXCYxazGialM66OpRk0MhwwOeUI1mF8yRBJHzicA9uL8Y9gYrXjdMocslRUopTMDJSRAykGXsjUoPibT4uK8Rz8Zj7U00coBCcJibpVwRZzFk',
+ __type: 'extTrans',
+ },
+ });
+
+ const items = response.data.return_data.list.map((item) => ({
+ title: item.title,
+ description: item.content,
+ pubDate: parseDate(item.createtime),
+ link: `${rootUrl}/zp.html#/notice/${item.id}`,
+ }));
+
+ return {
+ title: '人才招聘 - 北京理工大学',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bit/yjs.ts b/lib/routes/bit/yjs.ts
new file mode 100644
index 00000000000000..c20f2aa017dbef
--- /dev/null
+++ b/lib/routes/bit/yjs.ts
@@ -0,0 +1,53 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/yjs',
+ categories: ['university'],
+ example: '/bit/yjs',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['grd.bit.edu.cn/zsgz/zsxx/index.htm', 'grd.bit.edu.cn/'],
+ },
+ ],
+ name: '研究生院招生信息',
+ maintainers: ['shengmaosu'],
+ handler,
+ url: 'grd.bit.edu.cn/zsgz/zsxx/index.htm',
+};
+
+async function handler() {
+ const link = 'https://grd.bit.edu.cn/zsgz/zsxx/index.htm';
+ const response = await got(link);
+ const $ = load(response.data);
+ const list = $('.tongzhigonggao li');
+
+ return {
+ title: '北京理工大学研究生院',
+ link,
+ description: '北京理工大学研究生院通知公告',
+ item:
+ list &&
+ list.toArray().map((item) => {
+ item = $(item);
+ const a = item.find('a');
+ return {
+ title: a.text(),
+ link: new URL(a.attr('href'), link).href,
+ pubDate: parseDate(item.find('span').text(), 'YYYY-MM-DD'),
+ };
+ }),
+ };
+}
diff --git a/lib/routes/bitbucket/commits.ts b/lib/routes/bitbucket/commits.ts
new file mode 100644
index 00000000000000..4d3d3ee89c2e32
--- /dev/null
+++ b/lib/routes/bitbucket/commits.ts
@@ -0,0 +1,65 @@
+import queryString from 'query-string';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/commits/:workspace/:repo_slug',
+ categories: ['programming'],
+ example: '/bitbucket/commits/blaze-lib/blaze',
+ parameters: { workspace: 'Workspace', repo_slug: 'Repository' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bitbucket.com/commits/:workspace/:repo_slug'],
+ },
+ ],
+ name: 'Commits',
+ maintainers: ['AuroraDysis'],
+ handler,
+};
+
+async function handler(ctx) {
+ const workspace = ctx.req.param('workspace');
+ const repo_slug = ctx.req.param('repo_slug');
+
+ const headers = {
+ Accept: 'application/json',
+ };
+ let auth = '';
+ if (config.bitbucket && config.bitbucket.username && config.bitbucket.password) {
+ auth = config.bitbucket.username + ':' + config.bitbucket.password + '@';
+ }
+ const response = await got({
+ method: 'get',
+ url: `https://${auth}api.bitbucket.org/2.0/repositories/${workspace}/${repo_slug}/commits/`,
+ searchParams: queryString.stringify({
+ sort: '-target.date',
+ }),
+ headers,
+ });
+ const data = response.data.values;
+ return {
+ allowEmpty: true,
+ title: `Recent Commits to ${workspace}/${repo_slug}`,
+ link: `https://bitbucket.org/${workspace}/${repo_slug}`,
+ item:
+ data &&
+ data.map((item) => ({
+ title: item.message,
+ author: item.author.raw,
+ description: item.rendered.message.html || 'No description',
+ pubDate: parseDate(item.date),
+ link: item.links.html.href,
+ })),
+ };
+}
diff --git a/lib/routes/bitbucket/namespace.ts b/lib/routes/bitbucket/namespace.ts
new file mode 100644
index 00000000000000..a5607f530fa05a
--- /dev/null
+++ b/lib/routes/bitbucket/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bitbucket',
+ url: 'bitbucket.com',
+ lang: 'en',
+};
diff --git a/lib/routes/bitbucket/tags.ts b/lib/routes/bitbucket/tags.ts
new file mode 100644
index 00000000000000..9add7af6222ec0
--- /dev/null
+++ b/lib/routes/bitbucket/tags.ts
@@ -0,0 +1,60 @@
+import queryString from 'query-string';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/tags/:workspace/:repo_slug',
+ categories: ['programming'],
+ example: '/bitbucket/tags/blaze-lib/blaze',
+ parameters: { workspace: 'Workspace', repo_slug: 'Repository' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Tags',
+ maintainers: ['AuroraDysis'],
+ handler,
+};
+
+async function handler(ctx) {
+ const workspace = ctx.req.param('workspace');
+ const repo_slug = ctx.req.param('repo_slug');
+
+ const headers = {
+ Accept: 'application/json',
+ };
+ let auth = '';
+ if (config.bitbucket && config.bitbucket.username && config.bitbucket.password) {
+ auth = config.bitbucket.username + ':' + config.bitbucket.password + '@';
+ }
+ const response = await got({
+ method: 'get',
+ url: `https://${auth}api.bitbucket.org/2.0/repositories/${workspace}/${repo_slug}/refs/tags/`,
+ searchParams: queryString.stringify({
+ sort: '-target.date',
+ }),
+ headers,
+ });
+ const data = response.data.values;
+ return {
+ allowEmpty: true,
+ title: `Recent Tags in ${workspace}/${repo_slug}`,
+ link: `https://bitbucket.org/${workspace}/${repo_slug}`,
+ item:
+ data &&
+ data.map((item) => ({
+ title: item.name,
+ author: item.tagger.raw,
+ description: item.message || 'No description',
+ pubDate: parseDate(item.date),
+ link: item.links.html.href,
+ })),
+ };
+}
diff --git a/lib/routes/bitget/announcement.ts b/lib/routes/bitget/announcement.ts
new file mode 100644
index 00000000000000..46807eb2867d4f
--- /dev/null
+++ b/lib/routes/bitget/announcement.ts
@@ -0,0 +1,204 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import type { BitgetResponse } from './type';
+
+const handler: Route['handler'] = async (ctx) => {
+ const baseUrl = 'https://www.bitget.com';
+ const announcementApiUrl = `${baseUrl}/v1/msg/push/stationLetterNew`;
+ const { type, lang = 'zh-CN' } = ctx.req.param<'/bitget/announcement/:type/:lang?'>();
+ const languageCode = lang.replace('-', '_');
+ const headers = {
+ Referer: baseUrl,
+ accept: 'application/json, text/plain, */*',
+ 'content-type': 'application/json;charset=UTF-8',
+ language: languageCode,
+ locale: languageCode,
+ };
+ const pageSize = ctx.req.query('limit') ?? '10';
+
+ // stationLetterType: 0 表示全部通知,02 表示新币上线,01 表示最新活动,06 表示最新公告
+ const reqBody: {
+ pageSize: string;
+ openUnread: number;
+ stationLetterType: string;
+ isPre: boolean;
+ lastEndId: null;
+ languageType: number;
+ excludeStationLetterType?: string;
+ } = {
+ pageSize,
+ openUnread: 0,
+ stationLetterType: '0',
+ isPre: false,
+ lastEndId: null,
+ languageType: 1,
+ };
+
+ // 根据 type 判断 reqBody 的 stationLetterType 的值
+ switch (type) {
+ case 'new-listing':
+ reqBody.stationLetterType = '02';
+ break;
+
+ case 'latest-activities':
+ reqBody.stationLetterType = '01';
+ break;
+
+ case 'new-announcement':
+ reqBody.stationLetterType = '06';
+ break;
+
+ case 'all':
+ reqBody.stationLetterType = '0';
+ reqBody.excludeStationLetterType = '00';
+ break;
+
+ default:
+ throw new Error('Invalid type');
+ }
+
+ const response = (await cache.tryGet(
+ `bitget:announcement:${type}:${pageSize}:${lang}`,
+ async () => {
+ const result = await ofetch(announcementApiUrl, {
+ method: 'POST',
+ body: reqBody,
+ headers,
+ });
+ if (result?.code !== '200') {
+ throw new Error('Failed to fetch announcements, error code: ' + result?.code);
+ }
+ return result;
+ },
+ config.cache.routeExpire,
+ false
+ )) as BitgetResponse;
+
+ if (!response) {
+ throw new Error('Failed to fetch announcements');
+ }
+ const items = response.data.items;
+ const data = await Promise.all(
+ items.map(
+ (item) =>
+ cache.tryGet(`bitget:announcement:${item.id}:${pageSize}:${lang}`, async () => {
+ // 从 unix 时间戳转换为日期
+ const date = parseDate(Number(item.sendTime));
+ const dataItem: DataItem = {
+ title: item.title ?? '',
+ link: item.openUrl ?? '',
+ pubDate: item.sendTime ? date : undefined,
+ description: item.content ?? '',
+ };
+
+ if (item.imgUrl) {
+ dataItem.image = item.imgUrl;
+ }
+
+ if (item.stationLetterType === '01' || item.stationLetterType === '06') {
+ try {
+ const itemResponse = await ofetch(item.openUrl ?? '', {
+ headers,
+ });
+ const $ = load(itemResponse);
+ const nextData = JSON.parse($('script#__NEXT_DATA__').text());
+ dataItem.description = nextData.props.pageProps.details?.content || nextData.props.pageProps.pageInitInfo?.ruleContent || item.content || '';
+ } catch (error: any) {
+ if (error.name && (error.name === 'HTTPError' || error.name === 'RequestError' || error.name === 'FetchError')) {
+ dataItem.description = item.content ?? '';
+ } else {
+ throw error;
+ }
+ }
+ }
+ return dataItem;
+ }) as Promise
+ )
+ );
+
+ return {
+ title: `Bitget | ${findTypeLabel(type)}`,
+ link: `https://www.bitget.com/${lang}/inmail`,
+ item: data,
+ };
+};
+
+const findTypeLabel = (type: string) => {
+ const typeMap = {
+ all: 'All',
+ 'new-listing': 'New Listing',
+ 'latest-activities': 'Latest Activities',
+ 'new-announcement': 'New Announcement',
+ };
+ return typeMap[type];
+};
+
+export const route: Route = {
+ path: '/announcement/:type/:lang?',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/bitget/announcement/all/zh-CN',
+ parameters: {
+ type: {
+ description: 'Bitget 通知类型',
+ default: 'all',
+ options: [
+ { value: 'all', label: '全部通知' },
+ { value: 'new-listing', label: '新币上线' },
+ { value: 'latest-activities', label: '最新活动' },
+ { value: 'new-announcement', label: '最新公告' },
+ ],
+ },
+ lang: {
+ description: '语言',
+ default: 'zh-CN',
+ options: [
+ { value: 'zh-CN', label: '中文' },
+ { value: 'en-US', label: 'English' },
+ { value: 'es-ES', label: 'Español' },
+ { value: 'fr-FR', label: 'Français' },
+ { value: 'de-DE', label: 'Deutsch' },
+ { value: 'ja-JP', label: '日本語' },
+ { value: 'ru-RU', label: 'Русский' },
+ { value: 'ar-SA', label: 'العربية' },
+ ],
+ },
+ },
+ radar: [
+ {
+ source: ['www.bitget.com/:lang/inmail'],
+ target: '/announcement/all/:lang',
+ },
+ ],
+ name: 'Announcement',
+ description: `
+type:
+| Type | Description |
+| --- | --- |
+| all | 全部通知 |
+| new-listing | 新币上线 |
+| latest-activities | 最新活动 |
+| new-announcement | 最新公告 |
+
+lang:
+| Lang | Description |
+| --- | --- |
+| zh-CN | 中文 |
+| en-US | English |
+| es-ES | Español |
+| fr-FR | Français |
+| de-DE | Deutsch |
+| ja-JP | 日本語 |
+| ru-RU | Русский |
+| ar-SA | العربية |
+`,
+ maintainers: ['YukiCoco'],
+ handler,
+};
diff --git a/lib/routes/bitget/namespace.ts b/lib/routes/bitget/namespace.ts
new file mode 100644
index 00000000000000..808a994ef2fe24
--- /dev/null
+++ b/lib/routes/bitget/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bitget',
+ url: 'bitget.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bitget/type.ts b/lib/routes/bitget/type.ts
new file mode 100644
index 00000000000000..3e87c360b48c22
--- /dev/null
+++ b/lib/routes/bitget/type.ts
@@ -0,0 +1,26 @@
+export interface BitgetResponse {
+ code: string;
+ data: {
+ endId: string;
+ hasNextPage: boolean;
+ hasPrePage: boolean;
+ items: Array<{
+ businessType?: number;
+ content?: string;
+ id: string;
+ imgUrl?: string;
+ openUrl?: string;
+ openUrlName?: string;
+ readStats?: number;
+ sendTime?: string;
+ stationLetterType?: string;
+ title?: string;
+ }>;
+ notifyFlag: boolean;
+ page: number;
+ pageSize: number;
+ startId: string;
+ total: number;
+ };
+ params: any[];
+}
diff --git a/lib/routes/bitmovin/blog.ts b/lib/routes/bitmovin/blog.ts
new file mode 100644
index 00000000000000..d7ff08da8f9ffa
--- /dev/null
+++ b/lib/routes/bitmovin/blog.ts
@@ -0,0 +1,52 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'https://bitmovin.com';
+
+export const route: Route = {
+ path: '/blog',
+ categories: ['programming'],
+ example: '/bitmovin/blog',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bitmovin.com/blog', 'bitmovin.com/'],
+ },
+ ],
+ name: 'Blog',
+ maintainers: ['elxy'],
+ handler,
+ url: 'bitmovin.com/blog',
+};
+
+async function handler(ctx) {
+ const apiUrl = `${baseUrl}/wp-json/wp/v2`;
+ const { data } = await got(`${apiUrl}/posts`, {
+ searchParams: {
+ per_page: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 100,
+ },
+ });
+
+ const items = data.map((item) => ({
+ title: item.title.rendered,
+ author: item.authors.map((a) => a.display_name).join(', '),
+ description: item.content.rendered,
+ pubDate: parseDate(item.date_gmt),
+ link: item.link,
+ }));
+
+ return {
+ title: 'Blog - Bitmovin',
+ link: `${baseUrl}/blog/`,
+ item: items,
+ };
+}
diff --git a/lib/routes/bitmovin/namespace.ts b/lib/routes/bitmovin/namespace.ts
new file mode 100644
index 00000000000000..85fa757746ab20
--- /dev/null
+++ b/lib/routes/bitmovin/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bitmovin',
+ url: 'bitmovin.com',
+ lang: 'en',
+};
diff --git a/lib/routes/bjfu/grs.ts b/lib/routes/bjfu/grs.ts
new file mode 100644
index 00000000000000..17cb96940fb0f1
--- /dev/null
+++ b/lib/routes/bjfu/grs.ts
@@ -0,0 +1,78 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/grs',
+ categories: ['university'],
+ example: '/bjfu/grs',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['graduate.bjfu.edu.cn/'],
+ },
+ ],
+ name: '研究生院培养动态',
+ maintainers: ['markmingjie'],
+ handler,
+ url: 'graduate.bjfu.edu.cn/',
+};
+
+async function handler() {
+ const url = 'http://graduate.bjfu.edu.cn/pygl/pydt/index.html';
+ const response = await got.get(url);
+ const data = response.data;
+ const $ = load(data);
+ const list = $('.itemList li')
+ .slice(0, 11)
+ .toArray()
+ .map((e) => {
+ const element = $(e);
+ const title = element.find('li a').attr('title');
+ const link = element.find('li a').attr('href');
+ const date = element
+ .find('li a')
+ .text()
+ .match(/\d{4}-\d{2}-\d{2}/);
+ const pubDate = timezone(parseDate(date), 8);
+
+ return {
+ title,
+ link: 'http://graduate.bjfu.edu.cn/pygl/pydt/' + link,
+ author: '北京林业大学研究生院培养动态',
+ pubDate,
+ };
+ });
+
+ const result = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const itemReponse = await got.get(item.link);
+ const data = itemReponse.data;
+ const itemElement = load(data);
+
+ item.description = itemElement('.articleTxt').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '北林研培养动态',
+ link: url,
+ item: result,
+ };
+}
diff --git a/lib/routes/bjfu/it/index.ts b/lib/routes/bjfu/it/index.ts
new file mode 100644
index 00000000000000..b9028da852ed0f
--- /dev/null
+++ b/lib/routes/bjfu/it/index.ts
@@ -0,0 +1,81 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import util from './utils';
+
+export const route: Route = {
+ path: '/it/:type',
+ categories: ['university'],
+ example: '/bjfu/it/xyxw',
+ parameters: { type: '通知类别' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['it.bjfu.edu.cn/:type/index.html'],
+ },
+ ],
+ name: '信息学院通知',
+ maintainers: ['wzc-blog'],
+ handler,
+ description: `| 学院新闻 | 科研动态 | 本科生培养 | 研究生培养 |
+| -------- | -------- | ---------- | ---------- |
+| xyxw | kydt | pydt | pydt2 |`,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type');
+ let title, path;
+ switch (type) {
+ case 'kydt':
+ title = '科研动态';
+ path = 'kyxz/kydt/';
+ break;
+ case 'pydt':
+ title = '本科生培养';
+ path = 'bkspy/pydt/';
+ break;
+ case 'pydt2':
+ title = '研究生培养';
+ path = 'yjspy/pydt2/';
+ break;
+ default:
+ title = '学院新闻';
+ path = 'xyxw/';
+ }
+ const base = 'http://it.bjfu.edu.cn/' + path;
+
+ const response = await got({
+ method: 'get',
+ responseType: 'buffer',
+ url: base,
+ });
+
+ const data = response.data;
+ let $ = load(iconv.decode(data, 'utf-8'));
+ const charset = $('meta[charset]').attr('charset');
+ if (charset?.toLowerCase() !== 'utf-8') {
+ $ = load(iconv.decode(data, charset ?? 'utf-8'));
+ }
+
+ const list = $('.item-content').toArray();
+
+ const result = await util.ProcessFeed(base, list, cache); // 感谢@hoilc指导
+
+ return {
+ title: '北林信息 - ' + title,
+ link: 'http://it.bjfu.edu.cn/' + path,
+ description: '北京林业大学信息学院 - ' + title,
+ item: result,
+ };
+}
diff --git a/lib/routes/bjfu/it/utils.ts b/lib/routes/bjfu/it/utils.ts
new file mode 100644
index 00000000000000..ef3517502e2525
--- /dev/null
+++ b/lib/routes/bjfu/it/utils.ts
@@ -0,0 +1,72 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+// 完整文章页
+async function loadContent(link) {
+ let response;
+ try {
+ response = await got.get(link, {
+ responseType: 'buffer',
+ });
+ } catch {
+ return { description: '' };
+ }
+
+ const data = response.data; // 不用转码
+
+ // 加载文章内容
+ let $ = load(iconv.decode(data, 'utf-8'));
+ const charset = $('meta[charset]').attr('charset');
+ if (charset?.toLowerCase() !== 'utf-8') {
+ $ = load(iconv.decode(data, charset ?? 'utf-8'));
+ }
+
+ // 提取内容
+ const description = ($('.template-body').length ? $('.template-body').html() : '') + ($('.template-tail').length ? $('.template-tail').html() : '');
+
+ // 返回解析的结果
+ return { description };
+}
+
+const ProcessFeed = (base, list, caches) =>
+ // 使用 Promise.all() 进行 async 并发
+ Promise.all(
+ // 遍历每一篇文章
+ list.map((item) => {
+ const $ = load(item);
+
+ const $title = $('a');
+ // 还原相对链接为绝对链接
+ const itemUrl = new URL($title.attr('href'), base).href; // 感谢@hoilc指导
+
+ // 解析日期
+ const pubDate = timezone(
+ parseDate(
+ $('span')
+ .text()
+ .match(/\d{4}-\d{2}-\d{2}/)
+ ),
+ +8
+ );
+
+ // 使用tryGet方法从缓存获取内容。
+ // 当缓存中无法获取到链接内容的时候,则使用load方法加载文章内容。
+ return caches.tryGet(itemUrl, async () => {
+ const { description } = await loadContent(itemUrl);
+
+ // 列表上提取到的信息
+ return {
+ title: $title.text(),
+ link: itemUrl,
+ author: '北林信息',
+ description,
+ pubDate,
+ };
+ });
+ })
+ );
+export default { ProcessFeed };
diff --git a/lib/routes/bjfu/jwc/index.ts b/lib/routes/bjfu/jwc/index.ts
new file mode 100644
index 00000000000000..60cbe744410e1e
--- /dev/null
+++ b/lib/routes/bjfu/jwc/index.ts
@@ -0,0 +1,80 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import util from './utils';
+
+export const route: Route = {
+ path: '/jwc/:type',
+ categories: ['university'],
+ example: '/bjfu/jwc/jwkx',
+ parameters: { type: '通知类别' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['jwc.bjfu.edu.cn/:type/index.html'],
+ },
+ ],
+ name: '教务处通知公告',
+ maintainers: ['markmingjie'],
+ handler,
+ description: `| 教务快讯 | 考试信息 | 课程信息 | 教改动态 | 图片新闻 |
+| -------- | -------- | -------- | -------- | -------- |
+| jwkx | ksxx | kcxx | jgdt | tpxw |`,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type');
+ let title, path;
+ switch (type) {
+ case 'jgdt':
+ title = '教改动态';
+ path = 'jgdt/';
+ break;
+ case 'ksxx':
+ title = '考试信息';
+ path = 'ksxx/';
+ break;
+ case 'kcxx':
+ title = '课程信息';
+ path = 'tkxx/';
+ break;
+ case 'tpxw':
+ title = '图片新闻';
+ path = 'tpxw/';
+ break;
+ case 'jwkx':
+ default:
+ title = '教务快讯';
+ path = 'jwkx/';
+ }
+ const base = 'http://jwc.bjfu.edu.cn/' + path;
+
+ const response = await got({
+ method: 'get',
+ url: base,
+ });
+
+ const data = response.data; // 不用转码
+ const $ = load(data);
+
+ const list = $('.list_c li').slice(0, 15).toArray();
+
+ const result = await util.ProcessFeed(base, list, cache); // 感谢@hoilc指导
+
+ return {
+ title: '北林教务处 - ' + title,
+ link: 'http://jwc.bjfu.edu.cn/' + path,
+ description: '北京林业大学教务处 - ' + title,
+ item: result,
+ };
+}
diff --git a/lib/routes/bjfu/jwc/utils.ts b/lib/routes/bjfu/jwc/utils.ts
new file mode 100644
index 00000000000000..c0e633ebae647c
--- /dev/null
+++ b/lib/routes/bjfu/jwc/utils.ts
@@ -0,0 +1,59 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+// 完整文章页
+async function loadContent(link) {
+ const response = await got.get(link);
+
+ const data = response.data; // 不用转码
+
+ // 加载文章内容
+ const $ = load(data);
+
+ // 提取内容
+ const description = ($('#con_c').length ? $('#con_c').html() : '') + ($('#con_fujian').length ? $('#con_fujian').html() : '');
+
+ // 返回解析的结果
+ return { description };
+}
+
+const ProcessFeed = (base, list, caches) =>
+ Promise.all(
+ // 遍历每一篇文章
+ list.map((item) => {
+ const $ = load(item);
+
+ const $title = $('a');
+ // 还原相对链接为绝对链接
+ const itemUrl = new URL($title.attr('href'), base).href; // 感谢@hoilc指导
+
+ // 解析日期
+ const pubDate = timezone(
+ parseDate(
+ $('.datetime')
+ .text()
+ .match(/\d{4}-\d{2}-\d{2}/)
+ ),
+ +8
+ );
+
+ // 使用tryGet方法从缓存获取内容。
+ // 当缓存中无法获取到链接内容的时候,则使用load方法加载文章内容。
+ return caches.tryGet(itemUrl, async () => {
+ const { description } = await loadContent(itemUrl);
+
+ // 列表上提取到的信息
+ return {
+ title: $title.text(),
+ link: itemUrl,
+ author: '北林教务处',
+ description,
+ pubDate,
+ };
+ });
+ })
+ );
+export default { ProcessFeed };
diff --git a/lib/routes/bjfu/kjc.ts b/lib/routes/bjfu/kjc.ts
new file mode 100644
index 00000000000000..dee0e77ed398f8
--- /dev/null
+++ b/lib/routes/bjfu/kjc.ts
@@ -0,0 +1,79 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/kjc',
+ categories: ['university'],
+ example: '/bjfu/kjc',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['kyc.bjfu.edu.cn/'],
+ },
+ ],
+ name: '科技处通知公告',
+ maintainers: ['markmingjie'],
+ handler,
+ url: 'kyc.bjfu.edu.cn/',
+};
+
+async function handler() {
+ const url = 'http://kyc.bjfu.edu.cn/tztg/index.html';
+ const response = await got.get(url);
+ const data = response.data;
+ const $ = load(data);
+ const list = $('.ll_con_r_b li')
+ .slice(0, 15)
+ .toArray()
+ .map((e) => {
+ const element = $(e);
+ const title = element.find('.ll_con_r_b_title a').text();
+ const link = element.find('a').attr('href');
+ const date = element
+ .find('.ll_con_r_b_time')
+ .text()
+ .match(/\d{4}-\d{2}-\d{2}/);
+ const pubDate = timezone(parseDate(date), 8);
+
+ return {
+ title,
+ link: 'http://kyc.bjfu.edu.cn/tztg/' + link,
+ author: '北京林业大学科技处通知公告',
+ pubDate,
+ };
+ });
+
+ const result = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const itemReponse = await got.get(item.link);
+ const data = itemReponse.data;
+ const itemElement = load(data);
+
+ item.description = itemElement('#a_con_l_con').html();
+ item.title = item.title.includes('...') ? itemElement('#a_con_l_title').text() : item.title;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '北林科技处通知',
+ link: url,
+ item: result,
+ };
+}
diff --git a/lib/routes/bjfu/namespace.ts b/lib/routes/bjfu/namespace.ts
new file mode 100644
index 00000000000000..91be22f07922da
--- /dev/null
+++ b/lib/routes/bjfu/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京林业大学',
+ url: 'graduate.bjfu.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bjfu/news/index.ts b/lib/routes/bjfu/news/index.ts
new file mode 100644
index 00000000000000..843c7858eccd1a
--- /dev/null
+++ b/lib/routes/bjfu/news/index.ts
@@ -0,0 +1,88 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import util from './utils';
+
+export const route: Route = {
+ path: '/news/:type',
+ categories: ['university'],
+ example: '/bjfu/news/lsyw',
+ parameters: { type: '新闻栏目' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['news.bjfu.edu.cn/:type/index.html'],
+ },
+ ],
+ name: '绿色新闻网',
+ maintainers: ['markmingjie'],
+ handler,
+ description: `| 绿色要闻 | 校园动态 | 教学科研 | 党建思政 | 一周排行 |
+| -------- | -------- | -------- | -------- | -------- |
+| lsyw | xydt | jxky | djsz | yzph |`,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type');
+ let title, path;
+ switch (type) {
+ case 'xydt':
+ title = '校园动态';
+ path = 'lsxy/';
+ break;
+ case 'jxky':
+ title = '教学科研';
+ path = 'jxky/';
+ break;
+ case 'djsz':
+ title = '党建思政';
+ path = 'djsz/';
+ break;
+ case 'yzph':
+ title = '一周排行';
+ path = 'yzph/';
+ break;
+ case 'lsyw':
+ default:
+ title = '绿色要闻';
+ path = 'lsyw/';
+ }
+ const base = 'http://news.bjfu.edu.cn/' + path;
+
+ const response = await got({
+ method: 'get',
+ responseType: 'buffer',
+ url: base,
+ });
+
+ const data = response.data;
+ let $ = load(iconv.decode(data, 'utf-8'));
+ const charset = $('meta[http-equiv="Content-Type"]')
+ .attr('content')
+ .match(/charset=(.*)/)?.[1];
+ if (charset?.toLowerCase() !== 'utf-8') {
+ $ = load(iconv.decode(data, charset ?? 'utf-8'));
+ }
+
+ const list = $('.news_ul li').slice(0, 12).toArray();
+
+ const result = await util.ProcessFeed(base, list, cache); // 感谢@hoilc指导
+
+ return {
+ title: '北林新闻- ' + title,
+ link: 'http://news.bjfu.edu.cn/' + path,
+ description: '绿色新闻网 - ' + title,
+ item: result,
+ };
+}
diff --git a/lib/routes/bjfu/news/utils.ts b/lib/routes/bjfu/news/utils.ts
new file mode 100644
index 00000000000000..3511db1b2e4105
--- /dev/null
+++ b/lib/routes/bjfu/news/utils.ts
@@ -0,0 +1,60 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+// 完整文章页
+async function loadContent(link) {
+ const response = await got.get(link);
+
+ const data = response.data; // 不用转码
+
+ // 加载文章内容
+ const $ = load(data);
+
+ // 解析日期
+ const pubDate = timezone(
+ parseDate(
+ $('.article')
+ .text()
+ .match(/\d{4}(?:\/\d{2}){2}/)
+ ),
+ +8
+ );
+
+ // 提取内容
+ const description = $('.article_con').html();
+ const title = $('h2').text();
+
+ // 返回解析的结果
+ return { description, pubDate, title };
+}
+
+const ProcessFeed = (base, list, caches) =>
+ Promise.all(
+ // 遍历每一篇文章
+ list.map((item) => {
+ const $ = load(item);
+
+ const $title = $('a');
+ // 还原相对链接为绝对链接
+ const itemUrl = new URL($title.attr('href'), base).href; // 感谢@hoilc指导
+
+ // 使用tryGet方法从缓存获取内容。
+ // 当缓存中无法获取到链接内容的时候,则使用load方法加载文章内容。
+ return caches.tryGet(itemUrl, async () => {
+ const { description, pubDate, title } = await loadContent(itemUrl);
+
+ // 列表上提取到的信息
+ return {
+ title: $title.text().includes('...') ? title : $title.text(),
+ link: itemUrl,
+ author: '绿色新闻网',
+ description,
+ pubDate,
+ };
+ });
+ })
+ );
+export default { ProcessFeed };
diff --git a/lib/routes/bjnews/cat.ts b/lib/routes/bjnews/cat.ts
new file mode 100644
index 00000000000000..ce7296654c9638
--- /dev/null
+++ b/lib/routes/bjnews/cat.ts
@@ -0,0 +1,45 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import { fetchArticle } from './utils';
+
+export const route: Route = {
+ path: '/cat/:cat',
+ categories: ['traditional-media'],
+ example: '/bjnews/cat/depth',
+ parameters: { cat: '分类, 可从URL中找到' },
+ features: {},
+ radar: [
+ {
+ source: ['www.bjnews.com.cn/:cat'],
+ },
+ ],
+ name: '分类',
+ maintainers: ['dzx-dzx'],
+ handler,
+ url: 'www.bjnews.com.cn',
+};
+
+async function handler(ctx) {
+ const url = `https://www.bjnews.com.cn/${ctx.req.param('cat')}`;
+ const res = await ofetch(url);
+ const $ = load(res);
+ const list = $('#waterfall-container .pin_demo > a')
+ .toArray()
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 15)
+ .map((a) => ({
+ title: $(a).text(),
+ link: $(a).attr('href'),
+ category: $(a).parent().find('.source').text().trim(),
+ }));
+
+ const out = await pMap(list, (item) => fetchArticle(item), { concurrency: 2 });
+ return {
+ title: `新京报 - 分类 - ${$('.cur').text().trim()}`,
+ link: url,
+ item: out,
+ };
+}
diff --git a/lib/routes/bjnews/column.ts b/lib/routes/bjnews/column.ts
new file mode 100644
index 00000000000000..6529d9b3e3443a
--- /dev/null
+++ b/lib/routes/bjnews/column.ts
@@ -0,0 +1,43 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import { fetchArticle } from './utils';
+
+export const route: Route = {
+ path: '/column/:column',
+ categories: ['traditional-media'],
+ example: '/bjnews/column/204',
+ parameters: { column: '栏目ID, 可从手机版网页URL中找到' },
+ features: {},
+ radar: [
+ {
+ source: ['m.bjnews.com.cn/column/:column.htm'],
+ },
+ ],
+ name: '分类',
+ maintainers: ['dzx-dzx'],
+ handler,
+ url: 'www.bjnews.com.cn',
+};
+
+async function handler(ctx) {
+ const columnID = ctx.req.param('column');
+ const url = `https://api.bjnews.com.cn/api/v101/news/column_news.php?column_id=${columnID}`;
+ const res = await ofetch(url);
+ const list = res.data.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 15).map((e) => ({
+ title: e.row.title,
+ guid: e.uuid,
+ pubDate: timezone(parseDate(e.row.publish_time), +8),
+ updated: timezone(parseDate(e.row.update_time), +8),
+ link: `https://www.bjnews.com.cn/detail/${e.uuid}.html`,
+ }));
+
+ const out = await Promise.all(list.map((item) => fetchArticle(item)));
+ return {
+ title: `新京报 - 栏目 - ${res.data[0].row.column_info[0].column_name}`,
+ link: `https://m.bjnews.com.cn/column/${columnID}.html`,
+ item: out,
+ };
+}
diff --git a/lib/routes/bjnews/namespace.ts b/lib/routes/bjnews/namespace.ts
new file mode 100644
index 00000000000000..9a69d93d2f7a2d
--- /dev/null
+++ b/lib/routes/bjnews/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '新京报',
+ url: 'www.bjnews.com.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bjnews/news.js b/lib/routes/bjnews/news.js
deleted file mode 100644
index 19c7096c392d77..00000000000000
--- a/lib/routes/bjnews/news.js
+++ /dev/null
@@ -1,43 +0,0 @@
-const cheerio = require('cheerio');
-const date = require('@/utils/date');
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- const url = `http://www.bjnews.com.cn/${ctx.params.cat}`;
- const res = await got.get(url);
- const $ = cheerio.load(res.data);
- const list = $('#waterfall-container .pin_demo > a').get();
-
- const out = await Promise.all(
- list.map(async (item) => {
- const $ = cheerio.load(item);
-
- const title = $('.pin_tit').text();
- const itemUrl = $('a').attr('href');
- const cache = await ctx.cache.get(itemUrl);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const responses = await got.get(itemUrl);
- const $d = cheerio.load(responses.data);
- $d('img').each((i, e) => $(e).attr('referrerpolicy', 'no-referrer'));
-
- const single = {
- title,
- pubDate: date($d('.left-info .timer').text(), +8),
- author: $d('.left-info .reporter').text(),
- link: itemUrl,
- guid: itemUrl,
- description: $d('#contentStr').html(),
- };
- ctx.cache.set(itemUrl, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
- ctx.state.data = {
- title: $('title').text(),
- link: url,
- item: out,
- };
-};
diff --git a/lib/routes/bjnews/utils.ts b/lib/routes/bjnews/utils.ts
new file mode 100644
index 00000000000000..86379eae19ea72
--- /dev/null
+++ b/lib/routes/bjnews/utils.ts
@@ -0,0 +1,20 @@
+import { load } from 'cheerio';
+
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export function fetchArticle(item) {
+ return cache.tryGet(item.link, async () => {
+ const responses = await ofetch(item.link);
+ const $d = load(responses);
+ // $d('img').each((i, e) => $(e).attr('referrerpolicy', 'no-referrer'));
+
+ item.pubDate = timezone(parseDate($d('.left-info .timer').text()), +8);
+ item.author = $d('.left-info .reporter').text();
+ item.description = $d('#contentStr').html();
+
+ return item;
+ });
+}
diff --git a/lib/routes/bjp/apod.ts b/lib/routes/bjp/apod.ts
new file mode 100644
index 00000000000000..5e17a040401e10
--- /dev/null
+++ b/lib/routes/bjp/apod.ts
@@ -0,0 +1,74 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/apod',
+ categories: ['picture'],
+ view: ViewType.Pictures,
+ example: '/bjp/apod',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bjp.org.cn/APOD/today.shtml', 'bjp.org.cn/APOD/list.shtml', 'bjp.org.cn/'],
+ },
+ ],
+ name: '每日一图',
+ maintainers: ['HenryQW'],
+ handler,
+ url: 'bjp.org.cn/APOD/today.shtml',
+};
+
+async function handler(ctx) {
+ const baseUrl = 'https://www.bjp.org.cn';
+ const listUrl = `${baseUrl}/APOD/list.shtml`;
+
+ const res = await got(listUrl);
+
+ const $ = load(res.data);
+
+ const list = $('td[align=left] b')
+ .toArray()
+ .map((e) => {
+ e = $(e);
+ return {
+ title: e.find('a').attr('title'),
+ link: `${baseUrl}${e.find('a').attr('href')}`,
+ pubDate: timezone(parseDate(e.find('span').text().replace(':', ''), 'YYYY-MM-DD'), 8),
+ };
+ })
+ .toSorted((a, b) => b.pubDate - a.pubDate)
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 10);
+
+ const items = await Promise.all(
+ list.map((e) =>
+ cache.tryGet(e.link, async () => {
+ const { data } = await got.get(e.link);
+ const $ = load(data);
+
+ e.description = $('.juzhong').html();
+
+ return e;
+ })
+ )
+ );
+ return {
+ title: $('head title').text(),
+ description: '探索宇宙!每天发布一张迷人宇宙的影像,以及由专业天文学家撰写的简要说明。',
+ link: listUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bjp/namespace.ts b/lib/routes/bjp/namespace.ts
new file mode 100644
index 00000000000000..3a5e74689f2fdc
--- /dev/null
+++ b/lib/routes/bjp/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京天文馆',
+ url: 'www.bjp.org.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bjsk/index.ts b/lib/routes/bjsk/index.ts
new file mode 100644
index 00000000000000..c89e2a7a620bcf
--- /dev/null
+++ b/lib/routes/bjsk/index.ts
@@ -0,0 +1,78 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'https://www.bjsk.org.cn';
+
+export const route: Route = {
+ path: '/:path?',
+ categories: ['government'],
+ example: '/bjsk/newslist-1394-1474-0',
+ parameters: { path: '路径,默认为 `newslist-1486-0-0`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '通用',
+ maintainers: ['TonyRL'],
+ handler,
+ description: `::: tip
+ 路径处填写对应页面 URL 中 \`https://www.bjsk.org.cn/\` 和 \`.html\` 之间的字段。下面是一个例子。
+
+ 若订阅 [社科资讯 > 社科要闻](https://www.bjsk.org.cn/newslist-1394-1474-0.html) 则将对应页面 URL \`https://www.bjsk.org.cn/newslist-1394-1474-0.html\` 中 \`https://www.bjsk.org.cn/\` 和 \`.html\` 之间的字段 \`newslist-1394-1474-0\` 作为路径填入。此时路由为 [\`/bjsk/newslist-1394-1474-0\`](https://rsshub.app/bjsk/newslist-1394-1474-0)
+:::`,
+};
+
+async function handler(ctx) {
+ const { path = 'newslist-1486-0-0' } = ctx.req.param();
+ const link = `${baseUrl}/${path}.html`;
+ const { data: response } = await got(link, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+ const $ = load(response);
+
+ const list = $('.article-list a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.attr('title'),
+ link: `${baseUrl}${item.attr('href')}`,
+ pubDate: parseDate(item.find('.time').text(), 'YYYY.MM.DD'),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: response } = await got(item.link, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+ const $ = load(response);
+ item.description = $('.article-main').html();
+ item.author = $('.info')
+ .text()
+ .match(/作者:(.*)\s+来源/)[1];
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('head title').text(),
+ link,
+ image: 'https://www.bjsk.org.cn/favicon.ico',
+ item: items,
+ };
+}
diff --git a/lib/routes/bjsk/keti.ts b/lib/routes/bjsk/keti.ts
new file mode 100644
index 00000000000000..ea706b6f662aa6
--- /dev/null
+++ b/lib/routes/bjsk/keti.ts
@@ -0,0 +1,85 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/keti/:id?',
+ categories: ['government'],
+ example: '/bjsk/keti',
+ parameters: { id: '分类 id,见下表,默认为通知公告' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['keti.bjsk.org.cn/indexAction!to_index.action', 'keti.bjsk.org.cn/'],
+ target: '/keti/:id',
+ },
+ ],
+ name: '基金项目管理平台',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'keti.bjsk.org.cn/indexAction!to_index.action',
+ description: `| 通知公告 | 资料下载 |
+| -------------------------------- | -------------------------------- |
+| 402881027cbb8c6f017cbb8e17710002 | 2c908aee818e04f401818e08645c0002 |`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '402881027cbb8c6f017cbb8e17710002';
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 100;
+
+ const rootUrl = 'https://keti.bjsk.org.cn';
+ const currentUrl = `${rootUrl}/articleAction!to_moreList.action?entity.columnId=${id}&pagination.pageSize=${limit}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('a.news')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.find('.zizizi').text(),
+ link: `${rootUrl}${item.attr('href')}`,
+ pubDate: parseDate(item.find('.date').text()),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.description = content('.d_text').html();
+ item.author = content('div.d_information p span').last().text();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `北京社科基金项目管理平台 - ${$('.noticetop').text()}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bjsk/namespace.ts b/lib/routes/bjsk/namespace.ts
new file mode 100644
index 00000000000000..98b823b5d52114
--- /dev/null
+++ b/lib/routes/bjsk/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京社科网',
+ url: 'bjsk.org.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bjtu/gs.ts b/lib/routes/bjtu/gs.ts
new file mode 100644
index 00000000000000..af3ab85f7cc943
--- /dev/null
+++ b/lib/routes/bjtu/gs.ts
@@ -0,0 +1,225 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const rootURL = 'https://gs.bjtu.edu.cn';
+const urlCms = `${rootURL}/cms/item/?tag=`;
+const urlZszt = `${rootURL}/cms/zszt/item/?cat=`;
+const title = ' - 北京交通大学研究生院';
+const zsztRegex = /_zszt/;
+const struct = {
+ noti_zs: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 1,
+ name: '通知公告_招生',
+ },
+ noti: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 2,
+ name: '通知公告',
+ },
+ news: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 3,
+ name: '新闻动态',
+ },
+ zsxc: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 4,
+ name: '招生宣传',
+ },
+ py: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 5,
+ name: '培养',
+ },
+ zs: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 6,
+ name: '招生',
+ },
+ xw: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 7,
+ name: '学位',
+ },
+ ygb: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 8,
+ name: '研工部',
+ },
+ ygbtzgg: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 9,
+ name: '通知公告 - 研工部',
+ },
+ ygbnews: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 10,
+ name: '新闻动态 - 研工部',
+ },
+ ygbnewscover: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 11,
+ name: '新闻封面 - 研工部',
+ },
+ all: {
+ selector: {
+ list: '.tab-content li',
+ },
+ tag: 12,
+ name: '文章列表',
+ },
+ bszs_zszt: {
+ selector: {
+ list: '.mainleft_box li',
+ },
+ tag: 2,
+ name: '博士招生 - 招生专题',
+ },
+ sszs_zszt: {
+ selector: {
+ list: '.mainleft_box li',
+ },
+ tag: 3,
+ name: '硕士招生 - 招生专题',
+ },
+ zsjz_zszt: {
+ selector: {
+ list: '.mainleft_box li',
+ },
+ tag: 4,
+ name: '招生简章 - 招生专题',
+ },
+ zcfg_zszt: {
+ selector: {
+ list: '.mainleft_box li',
+ },
+ tag: 5,
+ name: '政策法规 - 招生专题',
+ },
+};
+
+const getItem = (item, selector) => {
+ const newsInfo = item.find('a');
+ const newsDate = item
+ .find('span')
+ .text()
+ .match(/\d{4}(-|\/|.)\d{1,2}\1\d{1,2}/)[0];
+
+ const infoTitle = newsInfo.text();
+ const link = rootURL + newsInfo.attr('href');
+ return cache.tryGet(link, async () => {
+ const resp = await ofetch(link);
+ const $$ = load(resp);
+ const infoText = $$(selector).html();
+
+ return {
+ title: infoTitle,
+ pubDate: parseDate(newsDate),
+ link,
+ description: infoText,
+ };
+ }) as any;
+};
+
+export const route: Route = {
+ path: '/gs/:type?',
+ categories: ['university'],
+ example: '/bjtu/gs/noti',
+ parameters: { type: 'Article type' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['gs.bjtu.edu.cn'],
+ },
+ ],
+ name: '研究生院',
+ maintainers: ['E1nzbern'],
+ handler,
+ description: `
+| 文章来源 | 参数 |
+| ----------------- | ------------ |
+| 通知公告_招生 | noti_zs |
+| 通知公告 | noti |
+| 新闻动态 | news |
+| 招生宣传 | zsxc |
+| 培养 | py |
+| 招生 | zs |
+| 学位 | xw |
+| 研工部 | ygb |
+| 通知公告 - 研工部 | ygbtzgg |
+| 新闻动态 - 研工部 | ygbnews |
+| 新闻封面 - 研工部 | ygbnewscover |
+| 文章列表 | all |
+| 博士招生 - 招生专题 | bszs_zszt |
+| 硕士招生 - 招生专题 | sszs_zszt |
+| 招生简章 - 招生专题 | zsjz_zszt |
+| 政策法规 - 招生专题 | zcfg_zszt |
+
+::: tip
+ 文章来源的命名均来自研究生院网站标题。
+ 最常用的几项有“通知公告_招生”、“通知公告”、“博士招生 - 招生专题”、“硕士招生 - 招生专题”。
+:::`,
+};
+
+async function handler(ctx) {
+ const { type = 'noti' } = ctx.req.param();
+ let url = urlCms;
+ let selectorArticle = 'div.main_left.main_left_list';
+ if (zsztRegex.test(type)) {
+ url = urlZszt;
+ selectorArticle = 'div.mainleft_box';
+ }
+ const urlAddr = `${url}${struct[type].tag}`;
+ const resp = await ofetch(urlAddr);
+ const $ = load(resp);
+
+ const list = $(struct[type].selector.list);
+
+ const items = await Promise.all(
+ list.toArray().map((i) => {
+ const item = $(i);
+ return getItem(item, selectorArticle);
+ })
+ );
+
+ return {
+ title: `${struct[type].name}${title}`,
+ link: urlAddr,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bjtu/namespace.ts b/lib/routes/bjtu/namespace.ts
new file mode 100644
index 00000000000000..7b2e129cac91b3
--- /dev/null
+++ b/lib/routes/bjtu/namespace.ts
@@ -0,0 +1,10 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Beijing Jiaotong University',
+ url: 'bjtu.edu.cn',
+ zh: {
+ name: '北京交通大学',
+ },
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bjwxdxh/index.ts b/lib/routes/bjwxdxh/index.ts
new file mode 100644
index 00000000000000..b90d7a8aff02a9
--- /dev/null
+++ b/lib/routes/bjwxdxh/index.ts
@@ -0,0 +1,76 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:type?',
+ categories: ['government'],
+ example: '/bjwxdxh/114',
+ parameters: { type: '类型,见下表,默认为全部' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '最新资讯',
+ maintainers: ['Misaka13514'],
+ handler,
+ description: `| 协会活动 | 公告通知 | 会议情况 | 简报 | 政策法规 | 学习园地 | 业余无线电服务中心 | 经验交流 | 新技术推介 | 活动通知 | 爱好者园地 | 结果查询 | 资料下载 | 会员之家 | 会员简介 | 会员风采 | 活动报道 |
+| -------- | -------- | -------- | ---- | -------- | -------- | ------------------ | -------- | ---------- | -------- | ---------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| 86 | 99 | 102 | 103 | 106 | 107 | 108 | 111 | 112 | 114 | 115 | 116 | 118 | 119 | 120 | 121 | 122 |`,
+};
+
+async function handler(ctx) {
+ const baseUrl = 'http://www.bjwxdxh.org.cn';
+ const type = ctx.req.param('type');
+ const link = type ? `${baseUrl}/news/class/?${type}.html` : `${baseUrl}/news/class/`;
+
+ const response = await got({
+ method: 'get',
+ url: link,
+ });
+
+ const $ = load(response.data);
+ const list = $('div#newsquery > ul > li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('div.title > a').text(),
+ link: new URL(item.find('div.title > a').attr('href'), baseUrl).href,
+ // pubDate: parseDate(item.find('div.time').text(), 'YYYY-MM-DD'),
+ };
+ });
+
+ await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const content = load(response.data);
+ const info = content('div.info')
+ .text()
+ .match(/作者:(.*?)\s+发布于:(.*?\s+.*?)\s/);
+ item.author = info[1];
+ item.pubDate = timezone(parseDate(info[2], 'YYYY-MM-DD HH:mm:ss'), +8);
+ item.description = content('div#con').html().trim().replaceAll('\n', '');
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link,
+ item: list,
+ };
+}
diff --git a/lib/routes/bjwxdxh/namespace.ts b/lib/routes/bjwxdxh/namespace.ts
new file mode 100644
index 00000000000000..912e1b7b3685fd
--- /dev/null
+++ b/lib/routes/bjwxdxh/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京无线电协会',
+ url: 'www.bjwxdxh.org.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bjx/fd.ts b/lib/routes/bjx/fd.ts
new file mode 100644
index 00000000000000..899c703889ab07
--- /dev/null
+++ b/lib/routes/bjx/fd.ts
@@ -0,0 +1,64 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/fd/:type',
+ categories: ['traditional-media'],
+ example: '/bjx/fd/yw',
+ parameters: { type: '文章分类,详见下表' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '风电',
+ maintainers: ['hualiong'],
+ description: `\`:type\` 类型可选如下
+
+| 要闻 | 政策 | 数据 | 市场 | 企业 | 招标 | 技术 | 报道 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| yw | zc | sj | sc | mq | zb | js | bd |`,
+ handler: async (ctx) => {
+ const type = ctx.req.param('type');
+ const response = await ofetch(`https://fd.bjx.com.cn/${type}/`);
+
+ const $ = load(response);
+ const typeName = $('div.box2 em:last-child').text();
+ const list = $('div.cc-list-content ul li:nth-child(-n+20)')
+ .toArray()
+ .map((item): DataItem => {
+ const each = $(item);
+ return {
+ title: each.find('a').attr('title')!,
+ link: each.find('a').attr('href'),
+ pubDate: parseDate(each.find('span').text()),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link!, async () => {
+ const response = await ofetch(item.link!);
+ const $ = load(response);
+
+ item.description = $('#article_cont').html()!;
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `北极星风力发电网${typeName}`,
+ description: $('meta[name="Description"]').attr('content'),
+ link: `https://fd.bjx.com.cn/${type}/`,
+ item: items as DataItem[],
+ };
+ },
+};
diff --git a/lib/routes/bjx/huanbao.ts b/lib/routes/bjx/huanbao.ts
new file mode 100644
index 00000000000000..a2e2fc1044fa35
--- /dev/null
+++ b/lib/routes/bjx/huanbao.ts
@@ -0,0 +1,97 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/huanbao',
+ categories: ['traditional-media'],
+ example: '/bjx/huanbao',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['huanbao.bjx.com.cn/yw', 'huanbao.bjx.com.cn/'],
+ },
+ ],
+ name: '环保要闻',
+ maintainers: ['zsimple'],
+ handler,
+ url: 'huanbao.bjx.com.cn/yw',
+};
+
+async function handler() {
+ const listURL = 'https://huanbao.bjx.com.cn/yw/';
+ const response = await got(listURL);
+
+ const $ = load(response.data);
+ let items = $('.cc-layout-3 .cc-list-content li')
+ .toArray()
+ .map((e) => {
+ e = $(e);
+ return {
+ title: e.find('a').attr('title'),
+ link: e.find('a').attr('href'),
+ pubDate: parseDate(e.find('span').text()),
+ };
+ });
+
+ items = await pMap(
+ // 服务器禁止单个IP大并发访问,只能少返回几条
+ items,
+ (item) => fetchPage(item.link),
+ { concurrency: 3 }
+ );
+
+ return {
+ title: '北极星环保 - 环保行业垂直门户网站',
+ link: listURL,
+ item: items,
+ };
+}
+
+const fetchPage = (link) =>
+ cache.tryGet(link, async () => {
+ // 可能一篇文章过长会分成多页
+ const pages = [];
+
+ const result = await got(link);
+ const $page = load(result.data);
+ pages.push($page);
+
+ // 如果是有分页链接,则使用顺序加载以保证顺序
+ const pagelinks = $page('#article_cont .cc-paging a');
+
+ if (pagelinks.length > 0) {
+ for (const pagelink of pagelinks) {
+ const $a = $page(pagelink);
+ if (!/^\d+$/.test($a.text().trim())) {
+ continue;
+ }
+ const sublink = new URL($a.attr('href'), link).href;
+ /* eslint-disable no-await-in-loop */
+ const result = await got(sublink);
+ pages.push(load(result.data));
+ }
+ }
+
+ const item = {
+ title: $page('title').text(),
+ description: pages.reduce((desc, $p) => desc + $p('.cc-article').html(), ''),
+ pubDate: timezone(parseDate($page('.cc-headline .box p span').eq(0).text()), +8),
+ link,
+ author: $page('.cc-headline .box p span').eq(1).text(),
+ };
+ return item;
+ });
diff --git a/lib/routes/bjx/namespace.ts b/lib/routes/bjx/namespace.ts
new file mode 100644
index 00000000000000..3904d7fa61a7b2
--- /dev/null
+++ b/lib/routes/bjx/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北极星电力网',
+ url: 'www.bjx.com.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bjx/types.ts b/lib/routes/bjx/types.ts
new file mode 100644
index 00000000000000..3dd8f58c99f967
--- /dev/null
+++ b/lib/routes/bjx/types.ts
@@ -0,0 +1,54 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/gf/:type',
+ categories: ['traditional-media'],
+ example: '/bjx/gf/sc',
+ parameters: { type: '分类,北极星光伏最后的`type`字段' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '光伏',
+ maintainers: ['Sxuet'],
+ handler,
+ description: `\`:type\` 类型可选如下
+
+| 要闻 | 政策 | 市场行情 | 企业动态 | 独家观点 | 项目工程 | 招标采购 | 财经 | 国际行情 | 价格趋势 | 技术跟踪 |
+| ---- | ---- | -------- | -------- | -------- | -------- | -------- | ---- | -------- | -------- | -------- |
+| yw | zc | sc | mq | dj | xm | zb | cj | gj | sj | js |`,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type');
+ const response = await got({
+ method: 'get',
+ url: `https://guangfu.bjx.com.cn/${type}/`,
+ });
+ const data = response.data;
+ const $ = load(data);
+ const typeName = $('div.box2 em:last').text();
+ const list = $('div.cc-list-content ul li');
+ return {
+ title: `北极星太阳能光大网${typeName}`,
+ description: $('meta[name="Description"]').attr('content'),
+ link: `https://guangfu.bjx.com.cn/${type}/`,
+ item: list.toArray().map((item) => {
+ item = $(item);
+ return {
+ title: item.find('a').attr('title'),
+ description: item.html(),
+ link: item.find('a').attr('href'),
+ pubDate: parseDate(item.find('span').text()),
+ };
+ }),
+ };
+}
diff --git a/lib/routes/blizzard/namespace.ts b/lib/routes/blizzard/namespace.ts
new file mode 100644
index 00000000000000..4f1141a3fe3e99
--- /dev/null
+++ b/lib/routes/blizzard/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Blizzard',
+ url: 'news.blizzard.com',
+ lang: 'en',
+};
diff --git a/lib/routes/blizzard/news-cn.ts b/lib/routes/blizzard/news-cn.ts
new file mode 100644
index 00000000000000..7580c1fc9315af
--- /dev/null
+++ b/lib/routes/blizzard/news-cn.ts
@@ -0,0 +1,131 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news-cn/:category?',
+ categories: ['game'],
+ example: '/blizzard/news-cn/ow',
+ parameters: { category: '游戏类别, 默认为 ow' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['ow.blizzard.cn', 'wow.blizzard.cn', 'hs.blizzard.cn'],
+ target: '/news-cn/',
+ },
+ ],
+ name: '暴雪游戏国服新闻',
+ maintainers: ['zhangpeng2k'],
+ description: `
+| 守望先锋 | 炉石传说 | 魔兽世界 |
+|----------|----------|---------|
+| ow | hs | wow |
+`,
+ handler,
+};
+
+const categoryNames = {
+ ow: '守望先锋',
+ hs: '炉石传说',
+ wow: '魔兽世界',
+};
+
+/* 列表解析逻辑 */
+const parsers = {
+ ow: ($) =>
+ $('.list-data-container .list-item-container')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('.content-title').text(),
+ link: item.find('.fill-link').attr('href'),
+ description: item.find('.content-intro').text(),
+ pubDate: parseDate(item.find('.content-date').text()),
+ image: item.find('.item-pic').attr('src'),
+ };
+ }),
+ hs: ($) =>
+ $('.article-container>a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('.title').text(),
+ link: item.attr('href'),
+ description: item.find('.desc').text(),
+ pubDate: parseDate(item.find('.date').attr('data-time')),
+ image: item.find('.article-img img').attr('src'),
+ };
+ }),
+ wow: ($) =>
+ $('.Pane-list>a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('.list-title').text(),
+ link: item.attr('href'),
+ description: item.find('.list-desc').text(),
+ pubDate: parseDate(item.find('.list-time').attr('data-time')),
+ image: item.find('.img-box img').attr('src'),
+ };
+ }),
+};
+
+// 详情页解析逻辑
+const detailParsers = {
+ ow: ($) => $('.deatil-content').first().html(),
+ hs: ($) => $('.article').first().html(),
+ wow: ($) => $('.detail').first().html(),
+};
+
+function getList(category, $) {
+ return parsers[category] ? parsers[category]($) : [];
+}
+
+async function fetchDetail(item, category) {
+ return await cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+
+ const parseDetail = detailParsers[category];
+ item.description = parseDetail($);
+ return item;
+ });
+}
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') || 'ow';
+ if (!categoryNames[category]) {
+ throw new Error('Invalid category');
+ }
+
+ const rootUrl = `https://${category}.blizzard.cn/news`;
+
+ const response = await ofetch(rootUrl);
+ const $ = load(response);
+
+ const list = getList(category, $);
+ if (!list.length) {
+ throw new Error('No news found');
+ }
+
+ const items = await Promise.all(list.map((item) => fetchDetail(item, category)));
+
+ return {
+ title: `${categoryNames[category]}新闻`,
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/blizzard/news.ts b/lib/routes/blizzard/news.ts
new file mode 100644
index 00000000000000..4cbf0dcc555b00
--- /dev/null
+++ b/lib/routes/blizzard/news.ts
@@ -0,0 +1,198 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/news/:language?/:category?',
+ categories: ['game'],
+ example: '/blizzard/news',
+ parameters: { language: 'Language code, see below, en-US by default', category: 'Category, see below, All News by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'News',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `Categories
+
+| Category | Slug |
+| ---------------------- | ------------------- |
+| All News | |
+| Diablo II: Resurrected | diablo2 |
+| Diablo III | diablo3 |
+| Diablo IV | diablo4 |
+| Diablo Immortal | diablo-immortal |
+| Hearthstone | hearthstone |
+| Heroes of the Storm | heroes-of-the-storm |
+| Overwatch 2 | overwatch |
+| StarCraft: Remastered | starcraft |
+| StarCraft II | starcraft2 |
+| World of Warcraft | world-of-warcraft |
+| Warcraft 3: Reforged | warcraft3 |
+| Warcraft Rumble | warcraft-rumble |
+| Battle.net | battlenet |
+| BlizzCon | blizzcon |
+| Inside Blizzard | blizzard |
+
+ Language codes
+
+| Language | Code |
+| ------------------ | ----- |
+| Deutsch | de-de |
+| English (US) | en-us |
+| English (EU) | en-gb |
+| Español (EU) | es-es |
+| Español (Latino) | es-mx |
+| Français | fr-fr |
+| Italiano | it-it |
+| Português (Brasil) | pt-br |
+| Polski | pl-pl |
+| Русский | ru-ru |
+| 한국어 | ko-kr |
+| ภาษาไทย | th-th |
+| 日本語 | ja-jp |
+| 繁體中文 | zh-tw |`,
+};
+
+const GAME_MAP = {
+ diablo2: {
+ key: 'diablo2',
+ value: 'diablo-2-resurrected',
+ id: 'blt54fbd3787a705054',
+ },
+ diablo3: {
+ key: 'diablo3',
+ value: 'diablo-3',
+ id: 'blt2031aef34200656d',
+ },
+ diablo4: {
+ key: 'diablo4',
+ value: 'diablo-4',
+ id: 'blt795c314400d7ded9',
+ },
+ 'diablo-immortal': {
+ key: 'diablo-immortal',
+ value: 'diablo-immortal',
+ id: 'blt525c436e4a1b0a97',
+ },
+ hearthstone: {
+ key: 'hearthstone',
+ value: 'hearthstone',
+ id: 'blt5cfc6affa3ca0638',
+ },
+ 'heroes-of-the-storm': {
+ key: 'heroes-of-the-storm',
+ value: 'heroes-of-the-storm',
+ id: 'blt2e50e1521bb84dc6',
+ },
+ overwatch: {
+ key: 'overwatch',
+ value: 'overwatch',
+ id: 'blt376fb94931906b6f',
+ },
+ starcraft: {
+ key: 'starcraft',
+ value: 'starcraft',
+ id: 'blt81d46fcb05ab8811',
+ },
+ starcraft2: {
+ key: 'starcraft2',
+ value: 'starcraft-2',
+ id: 'bltede2389c0a8885aa',
+ },
+ 'world-of-warcraft': {
+ key: 'world-of-warcraft',
+ value: 'world-of-warcraft',
+ id: 'blt2caca37e42f19839',
+ },
+ warcraft3: {
+ key: 'warcraft3',
+ value: 'warcraft-3',
+ id: 'blt24859ba8086fb294',
+ },
+ 'warcraft-rumble': {
+ key: 'warcraft-rumble',
+ value: 'warcraft-rumble',
+ id: 'blte27d02816a8ff3e1',
+ },
+ battlenet: {
+ key: 'battlenet',
+ value: 'battle-net',
+ id: 'blt90855744d00cd378',
+ },
+ blizzcon: {
+ key: 'blizzcon',
+ value: 'blizzcon',
+ id: 'bltec70ad0ea4fd6d1d',
+ },
+ blizzard: {
+ key: 'blizzard',
+ value: 'blizzard',
+ id: 'blt500c1f8b5470bfdb',
+ },
+};
+
+function getSearchParams(category = 'all') {
+ return category === 'all'
+ ? Object.values(GAME_MAP)
+ .map((item) => `feedCxpProductIds[]=${item.id}`)
+ .join('&')
+ : `feedCxpProductIds[]=${GAME_MAP[category].id}`;
+}
+
+async function handler(ctx) {
+ const category = GAME_MAP[ctx.req.param('category')]?.key || 'all';
+ const language = ctx.req.param('language') || 'en-us';
+ const rootUrl = `https://news.blizzard.com/${language}`;
+ const currentUrl = category === 'all' ? rootUrl : `${rootUrl}/?filter=${GAME_MAP[category].value}`;
+ const apiUrl = `${rootUrl}/api/news/blizzard`;
+ let rssTitle = '';
+
+ const {
+ data: {
+ feed: { contentItems: response },
+ },
+ } = await got(`${apiUrl}?${getSearchParams(category)}`);
+
+ const list = response.map((item) => {
+ const content = item.properties;
+ rssTitle = category === 'all' ? 'All News' : content.cxpProduct.title; // 这个是用来填充 RSS 订阅源频道级别 title,没别的地方能拿到了(而且会根据语言切换)
+ return {
+ title: content.title,
+ link: content.newsUrl,
+ author: content.author,
+ category: content.category,
+ guid: content.newsId,
+ description: content.summary,
+ pubDate: content.lastUpdated,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ const { data: response } = await got(item.link);
+ const $ = load(response);
+ item.description = $('.Content').html();
+ return item;
+ } catch {
+ return item;
+ }
+ })
+ )
+ );
+
+ return {
+ title: rssTitle,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/blockworks/index.ts b/lib/routes/blockworks/index.ts
new file mode 100644
index 00000000000000..e1a5ad6b77c47b
--- /dev/null
+++ b/lib/routes/blockworks/index.ts
@@ -0,0 +1,129 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import logger from '@/utils/logger';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import parser from '@/utils/rss-parser';
+
+export const route: Route = {
+ path: '/',
+ categories: ['finance'],
+ example: '/blockworks',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['blockworks.co/'],
+ target: '/',
+ },
+ ],
+ name: 'News',
+ maintainers: ['pseudoyu'],
+ handler,
+ description: 'Blockworks news with full text support.',
+};
+
+async function handler(ctx): Promise {
+ const rssUrl = 'https://blockworks.co/feed';
+ const feed = await parser.parseURL(rssUrl);
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+ // Limit to 20 items
+ const limitedItems = feed.items.slice(0, limit);
+
+ const buildId = await getBuildId();
+
+ const items = await Promise.all(
+ limitedItems
+ .map((item) => ({
+ ...item,
+ link: item.link?.split('?')[0],
+ }))
+ .map((item) =>
+ cache.tryGet(item.link!, async () => {
+ // Get cached content or fetch new content
+ const content = await extractFullText(item.link!.split('/').pop()!, buildId);
+
+ return {
+ title: item.title || 'Untitled',
+ pubDate: item.isoDate ? parseDate(item.isoDate) : undefined,
+ link: item.link,
+ description: content.description || item.content || item.contentSnippet || item.summary || '',
+ author: item.author,
+ category: content.category,
+ media: content.imageUrl
+ ? {
+ content: { url: content.imageUrl },
+ }
+ : undefined,
+ } as DataItem;
+ })
+ )
+ );
+
+ return {
+ title: feed.title || 'Blockworks News',
+ link: feed.link || 'https://blockworks.co',
+ description: feed.description || 'Latest news from Blockworks',
+ item: items,
+ language: feed.language || 'en',
+ };
+}
+
+async function extractFullText(slug: string, buildId: string): Promise<{ description: string; imageUrl: string; category: string[] }> {
+ try {
+ const response = await ofetch(`https://blockworks.co/_next/data/${buildId}/news/${slug}.json?slug=${slug}`);
+ const article = response.pageProps.article;
+ const $ = load(article.content, null, false);
+
+ // Remove promotional content at the end
+ $('hr').remove();
+ $('p > em, p > strong').each((_, el) => {
+ const $el = $(el);
+ if ($el.text().includes('To read full editions') || $el.text().includes('Get the news in your inbox')) {
+ $el.parent().remove();
+ }
+ });
+ $('ul.wp-block-list > li > a').each((_, el) => {
+ const $el = $(el);
+ if ($el.attr('href') === 'https://blockworks.co/newsletter/daily') {
+ $el.parent().parent().remove();
+ }
+ });
+
+ return {
+ description: $.html(),
+ imageUrl: article.imageUrl,
+ category: [...new Set([...article.categories, ...article.tags])],
+ };
+ } catch (error) {
+ logger.error('Error extracting full text from Blockworks:', error);
+ return { description: '', imageUrl: '', category: [] };
+ }
+}
+
+const getBuildId = () =>
+ cache.tryGet(
+ 'blockworks:buildId',
+ async () => {
+ const response = await ofetch('https://blockworks.co');
+ const $ = load(response);
+
+ return (
+ $('script#__NEXT_DATA__')
+ .text()
+ ?.match(/"buildId":"(.*?)",/)?.[1] || ''
+ );
+ },
+ config.cache.routeExpire,
+ false
+ ) as Promise;
diff --git a/lib/routes/blockworks/namespace.ts b/lib/routes/blockworks/namespace.ts
new file mode 100644
index 00000000000000..0a48cd8d682972
--- /dev/null
+++ b/lib/routes/blockworks/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Blockworks',
+ url: 'blockworks.co',
+ lang: 'en',
+};
diff --git a/lib/routes/blogread/index.ts b/lib/routes/blogread/index.ts
new file mode 100644
index 00000000000000..c6091c6c17ffd3
--- /dev/null
+++ b/lib/routes/blogread/index.ts
@@ -0,0 +1,45 @@
+import * as cheerio from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/newest',
+ categories: ['programming'],
+ example: '/blogread/newest',
+ radar: [
+ {
+ source: ['blogread.cn/news/newest.php'],
+ },
+ ],
+ name: '最新文章',
+ maintainers: ['fashioncj'],
+ handler,
+};
+
+async function handler() {
+ const url = 'https://blogread.cn/news/newest.php';
+ const response = await got({
+ method: 'get',
+ url,
+ });
+ const $ = cheerio.load(response.data);
+ const resultItem = $('.media')
+ .toArray()
+ .map((elem) => {
+ elem = $(elem);
+ const $link = elem.find('dt a');
+ return {
+ title: $link.text(),
+ description: elem.find('dd').eq(0).text(),
+ link: $link.attr('href'),
+ author: elem.find('.small a').eq(0).text(),
+ pubDate: elem.find('dd').eq(1).text().split('\n')[2],
+ };
+ });
+ return {
+ title: '技术头条',
+ link: url,
+ item: resultItem,
+ };
+}
diff --git a/lib/routes/blogread/namespace.ts b/lib/routes/blogread/namespace.ts
new file mode 100644
index 00000000000000..228e4b958885f1
--- /dev/null
+++ b/lib/routes/blogread/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '技术头条',
+ url: 'blogread.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/blogread/newest.js b/lib/routes/blogread/newest.js
deleted file mode 100644
index 8e1872b2cc340f..00000000000000
--- a/lib/routes/blogread/newest.js
+++ /dev/null
@@ -1,30 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const url = 'http://blogread.cn/news/newest.php';
- const response = await got({
- method: 'get',
- url,
- });
- const $ = cheerio.load(response.data);
- const resultItem = $('.media')
- .map((index, elem) => {
- elem = $(elem);
- const $link = elem.find('dt a');
-
- return {
- title: $link.text(),
- description: elem.find('dd').eq(0).text(),
- link: $link.attr('href'),
- author: elem.find('.small a').eq(0).text(),
- };
- })
- .get();
-
- ctx.state.data = {
- title: '技术头条',
- link: url,
- item: resultItem,
- };
-};
diff --git a/lib/routes/blogs/hedwig.js b/lib/routes/blogs/hedwig.js
deleted file mode 100644
index afa559d2443377..00000000000000
--- a/lib/routes/blogs/hedwig.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const md = require('markdown-it')({
- html: true,
-});
-const dayjs = require('dayjs');
-const { isValidHost } = require('@/utils/valid-host');
-
-module.exports = async (ctx) => {
- const type = ctx.params.type;
- if (!isValidHost(type)) {
- throw Error('Invalid type');
- }
-
- const url = `https://${type}.hedwig.pub`;
- const res = await got({
- method: 'get',
- url,
- });
- const $ = cheerio.load(res.data);
- const content = JSON.parse($('#__NEXT_DATA__')[0].children[0].data);
- const title = $('title').text();
-
- const list = content.props.pageProps.issuesByNewsletter.map((item) => {
- let description = '';
- item.blocks.forEach((block) => {
- description += md.render(block.markdown.text);
- });
- return {
- title: item.subject,
- description,
- pubDate: dayjs(`${item.publishAt} +0800`, "YYYY-MM-DD'T'HH:mm:ss.SSS'Z'").toString(),
- link: `https://${type}.hedwig.pub/i/${item.urlFriendlyName}`,
- };
- });
- ctx.state.data = {
- title: `Ⓙ Hedwig - ${title}`,
- description: content.props.pageProps.newsletter.about,
- link: `https://${type}.hedwig.pub`,
- item: list,
- };
-};
diff --git a/lib/routes/blogs/wordpress.js b/lib/routes/blogs/wordpress.js
deleted file mode 100644
index 7c414e6f4fdf5f..00000000000000
--- a/lib/routes/blogs/wordpress.js
+++ /dev/null
@@ -1,50 +0,0 @@
-const parser = require('@/utils/rss-parser');
-const config = require('@/config').value;
-const allowDomain = ['lawrence.code.blog'];
-
-module.exports = async (ctx) => {
- if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.includes(ctx.params.domain)) {
- ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
- }
-
- const scheme = ctx.params.https || 'https';
- const cdn = config.wordpress.cdnUrl;
-
- const domain = `${scheme}://${ctx.params.domain}`;
- const feed = await parser.parseURL(`${domain}/feed/`);
- const items = await Promise.all(
- feed.items.map(async (item) => {
- const cache = await ctx.cache.get(item.link);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
- const description =
- scheme === 'https' || !cdn
- ? item['content:encoded']
- : item['content:encoded'].replace(/(?<= )/g, (match, p) => {
- if (p[0] === '/') {
- return cdn + feed.link + p;
- } else if (p.slice(0, 5) === 'http:') {
- return cdn + p;
- } else {
- return p;
- }
- });
- const article = {
- title: item.title,
- description,
- pubDate: item.pubDate,
- link: item.link,
- author: item.creator,
- };
- return Promise.resolve(article);
- })
- );
-
- ctx.state.data = {
- title: feed.title,
- link: feed.link,
- description: feed.description,
- item: items,
- };
-};
diff --git a/lib/routes/bloomberg/authors.ts b/lib/routes/bloomberg/authors.ts
new file mode 100644
index 00000000000000..bcee63b7f83f5d
--- /dev/null
+++ b/lib/routes/bloomberg/authors.ts
@@ -0,0 +1,80 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import rssParser from '@/utils/rss-parser';
+
+import { parseArticle } from './utils';
+
+const parseAuthorNewsList = async (slug) => {
+ const baseURL = `https://www.bloomberg.com/authors/${slug}`;
+ const apiUrl = `https://www.bloomberg.com/lineup/api/lazy_load_author_stories?slug=${slug}&authorType=default&page=1`;
+ const resp = await ofetch(apiUrl);
+ // Likely rate limited
+ if (!resp.html) {
+ return [];
+ }
+ const $ = load(resp.html);
+ const articles = $('article.story-list-story');
+ return articles.toArray().map((item) => {
+ item = $(item);
+ const headline = item.find('a.story-list-story__info__headline-link');
+ return {
+ title: headline.text(),
+ pubDate: item.attr('data-updated-at'),
+ guid: `bloomberg:${item.attr('data-id')}`,
+ link: new URL(headline.attr('href'), baseURL).href,
+ };
+ });
+};
+
+export const route: Route = {
+ path: '/authors/:id/:slug/:source?',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/bloomberg/authors/ARbTQlRLRjE/matthew-s-levine',
+ parameters: { id: 'Author ID, can be found in URL', slug: 'Author Slug, can be found in URL', source: 'Data source, either `api` or `rss`,`api` by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.bloomberg.com/*/authors/:id/:slug', 'www.bloomberg.com/authors/:id/:slug'],
+ target: '/authors/:id/:slug',
+ },
+ ],
+ name: 'Authors',
+ maintainers: ['josh', 'pseudoyu'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { id, slug, source } = ctx.req.param();
+ const link = `https://www.bloomberg.com/authors/${id}/${slug}`;
+
+ let list = [];
+ if (!source || source === 'api') {
+ list = await parseAuthorNewsList(`${id}/${slug}`);
+ }
+ // Fallback to rss if api failed or requested by param
+ if (source === 'rss' || list.length === 0) {
+ list = (await rssParser.parseURL(`${link}.rss`)).items;
+ }
+
+ const item = await pMap(list, (item) => parseArticle(item), { concurrency: 1 });
+ const authorName = item.find((i) => i.author)?.author ?? slug;
+
+ return {
+ title: `Bloomberg - ${authorName}`,
+ link,
+ language: 'en-us',
+ item,
+ };
+}
diff --git a/lib/routes/bloomberg/index.ts b/lib/routes/bloomberg/index.ts
new file mode 100644
index 00000000000000..d2e8f6a35f8bc0
--- /dev/null
+++ b/lib/routes/bloomberg/index.ts
@@ -0,0 +1,74 @@
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+
+import { parseArticle, parseNewsList, rootUrl } from './utils';
+
+const siteTitleMapping = {
+ '/': 'News',
+ bpol: 'Politics',
+ bbiz: 'Business',
+ markets: 'Markets',
+ technology: 'Technology',
+ green: 'Green',
+ wealth: 'Wealth',
+ pursuits: 'Pursuits',
+ bview: 'Opinion',
+ equality: 'Equality',
+ businessweek: 'Businessweek',
+ citylab: 'CityLab',
+};
+
+export const route: Route = {
+ path: '/:site?',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/bloomberg/bbiz',
+ parameters: {
+ site: {
+ description: 'Site ID, can be found below',
+ options: Object.keys(siteTitleMapping).map((key) => ({ value: key, label: siteTitleMapping[key] })),
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Bloomberg Site',
+ maintainers: ['bigfei'],
+ description: `
+| Site ID | Title |
+| ------------ | ------------ |
+| / | News |
+| bpol | Politics |
+| bbiz | Business |
+| markets | Markets |
+| technology | Technology |
+| green | Green |
+| wealth | Wealth |
+| pursuits | Pursuits |
+| bview | Opinion |
+| equality | Equality |
+| businessweek | Businessweek |
+| citylab | CityLab |
+ `,
+ handler,
+};
+
+async function handler(ctx) {
+ const site = ctx.req.param('site');
+ const currentUrl = site ? `${rootUrl}/${site}/sitemap_news.xml` : `${rootUrl}/sitemap_news.xml`;
+
+ const list = await parseNewsList(currentUrl, ctx);
+ const items = await pMap(list, (item) => parseArticle(item), { concurrency: 1 });
+ return {
+ title: `Bloomberg - ${siteTitleMapping[site ?? '/']}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bloomberg/namespace.ts b/lib/routes/bloomberg/namespace.ts
new file mode 100644
index 00000000000000..b0c4f21f3206d8
--- /dev/null
+++ b/lib/routes/bloomberg/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bloomberg',
+ url: 'www.bloomberg.com',
+ lang: 'en',
+};
diff --git a/lib/v2/bloomberg/templates/audio_media.art b/lib/routes/bloomberg/templates/audio_media.art
similarity index 100%
rename from lib/v2/bloomberg/templates/audio_media.art
rename to lib/routes/bloomberg/templates/audio_media.art
diff --git a/lib/v2/bloomberg/templates/chart_media.art b/lib/routes/bloomberg/templates/chart_media.art
similarity index 100%
rename from lib/v2/bloomberg/templates/chart_media.art
rename to lib/routes/bloomberg/templates/chart_media.art
diff --git a/lib/v2/bloomberg/templates/image_figure.art b/lib/routes/bloomberg/templates/image_figure.art
similarity index 100%
rename from lib/v2/bloomberg/templates/image_figure.art
rename to lib/routes/bloomberg/templates/image_figure.art
diff --git a/lib/v2/bloomberg/templates/lede_media.art b/lib/routes/bloomberg/templates/lede_media.art
similarity index 100%
rename from lib/v2/bloomberg/templates/lede_media.art
rename to lib/routes/bloomberg/templates/lede_media.art
diff --git a/lib/v2/bloomberg/templates/video_media.art b/lib/routes/bloomberg/templates/video_media.art
similarity index 100%
rename from lib/v2/bloomberg/templates/video_media.art
rename to lib/routes/bloomberg/templates/video_media.art
diff --git a/lib/routes/bloomberg/utils.ts b/lib/routes/bloomberg/utils.ts
new file mode 100644
index 00000000000000..ae2a85c8fda200
--- /dev/null
+++ b/lib/routes/bloomberg/utils.ts
@@ -0,0 +1,607 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import { destr } from 'destr';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+const rootUrl = 'https://www.bloomberg.com/feeds';
+const idSel = 'script[id^="article-info"][type="application/json"], script[class^="article-info"][type="application/json"], script#dvz-config';
+const idUrl = 'https://www.bloomberg.com/article/api/story/id/';
+
+const headers = {
+ accept: 'application/json',
+ 'cache-control': 'no-cache',
+ referer: 'https://www.bloomberg.com',
+};
+
+const apiEndpoints = {
+ articles: {
+ url: 'https://www.bloomberg.com/article/api/story/slug/',
+ },
+ features: {
+ // https://www.bloomberg.com/news/features/2023-08-12/boston-university-data-science-hub-is-a-textbook-example-of-jenga-architecture
+ url: 'https://www.bloomberg.com/article/api/story/slug/',
+ },
+ audio: {
+ // https://www.bloomberg.com/news/audio/2023-07-26/daybreak-deutsche-traders-outperform-as-costs-rise-podcast
+ url: 'https://www.bloomberg.com/news/audio/',
+ sel: 'script#__NEXT_DATA__',
+ },
+ videos: {
+ url: 'https://www.bloomberg.com/news/videos/',
+ sel: 'script',
+ },
+ newsletters: {
+ // https://www.bloomberg.com/news/newsletters/2023-07-20/key-votes-the-bloomberg-open-europe-edition
+ url: 'https://www.bloomberg.com/article/api/story/slug/',
+ },
+ 'photo-essays': {
+ url: 'https://www.bloomberg.com/javelin/api/photo-essay_transporter/',
+ sel: 'script[type = "application/json"][data-component-props]',
+ },
+ 'features/': {
+ // https://www.bloomberg.com/features/2023-stradivarius-murders/
+ url: 'https://www.bloomberg.com/features/',
+ sel: idSel,
+ prop: 'id',
+ },
+};
+
+const pageTypeRegex1 = /\/(?[\w-]*?)\/(? \d{4}-\d{2}-\d{2}\/.*)/;
+const pageTypeRegex2 = /(?features\/|graphics\/)(? .*)/;
+const regex = [pageTypeRegex1, pageTypeRegex2];
+
+const capRegex = /|<\/p>/g;
+const emptyRegex = /
]*>( |\s)<\/p>/g;
+
+const redirectGot = (url) =>
+ ofetch.raw(url, {
+ headers,
+ parseResponse: (responseText) => ({
+ data: destr(responseText),
+ body: responseText,
+ }),
+ });
+
+const parseNewsList = async (url, ctx) => {
+ const resp = await got(url);
+ const $ = load(resp.data, {
+ xml: {
+ xmlMode: true,
+ },
+ });
+ const urls = $('urlset url');
+ return urls
+ .toArray()
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50)
+ .map((u) => {
+ u = $(u);
+ const item = {
+ title: u.find(String.raw`news\:title`).text(),
+ link: u.find('loc').text(),
+ pubDate: parseDate(u.find(String.raw`news\:publication_date`).text()),
+ };
+ return item;
+ });
+};
+
+const parseArticle = (item) =>
+ cache.tryGet(item.link, async () => {
+ const group = regex
+ .map((r) => r.exec(item.link))
+ .filter((e) => e && e.groups)
+ .map((a) => a && a.groups)[0];
+ if (group) {
+ const { page, link } = group;
+ if (apiEndpoints[page]) {
+ const api = { ...apiEndpoints[page] };
+ let res;
+
+ try {
+ const apiUrl = `${api.url}${link}`;
+ res = await redirectGot(apiUrl);
+ } catch (error) {
+ // fallback
+ if (error.name && (error.name === 'HTTPError' || error.name === 'RequestError' || error.name === 'FetchError')) {
+ try {
+ res = await redirectGot(item.link);
+ } catch {
+ // return the default one
+ return {
+ title: item.title,
+ link: item.link,
+ pubDate: item.pubDate,
+ };
+ }
+ }
+ }
+
+ // Blocked by PX3, or 404 by both api and direct link, return the default
+ if ((res.redirected && new URL(res.url).pathname === '/tosv2.html') || res.status === 404) {
+ return {
+ title: item.title,
+ link: item.link,
+ pubDate: item.pubDate,
+ };
+ }
+
+ switch (page) {
+ case 'audio':
+ return parseAudioPage(res._data, api, item);
+ case 'videos':
+ return parseVideoPage(res._data, api, item);
+ case 'photo-essays':
+ return parsePhotoEssaysPage(res._data, api, item);
+ case 'features/': // single features page
+ return parseReactRendererPage(res._data, api, item);
+ default: // use story api to get json
+ return parseStoryJson(res._data.data, item);
+ }
+ }
+ }
+ return item;
+ });
+
+const parseAudioPage = async (res, api, item) => {
+ const audio_json = JSON.parse(load(res.data)(api.sel).html()).props.pageProps;
+ const episode = audio_json.episode;
+ const rss_item = {
+ title: episode.title || item.title,
+ link: audio_json.pageInfo.canonicalUrl || item.link,
+ guid: `bloomberg:${episode.id}`,
+ description: (await processBody(episode.articleBody, audio_json)).replaceAll(emptyRegex, ''),
+ pubDate: parseDate(episode.publishedAt) || item.pubDate,
+ author: audio_json.hero.showTitle,
+ media: {
+ content: { url: episode.image },
+ thumbnails: { url: episode.image },
+ },
+ enclosure_type: 'audio/mpeg',
+ enclosure_url: episode.url,
+ itunes_item_image: episode.image || audio_json.pageInfo.image.url,
+ };
+ return rss_item;
+};
+
+const parseVideoPage = async (res, api, item) => {
+ const $ = load(res.data);
+ const script = $(api.sel).filter((i, el) => $(el).text().includes('__PRELOADED_STATE__'));
+ const json = script
+ .text()
+ .trim()
+ .match(/window\.__PRELOADED_STATE__ = (.*?);/)?.[1];
+ const article_json = JSON.parse(json || '{}');
+ const video_story = article_json.video?.videoStory ?? article_json.quicktakeVideo?.videoStory;
+ if (video_story) {
+ const desc = await processVideo(video_story.video.bmmrId, video_story.summary.html.replaceAll(emptyRegex, ''));
+ const rss_item = {
+ title: video_story.headline.text || item.title,
+ link: video_story.url || item.link,
+ guid: `bloomberg:${video_story.id}`,
+ description: art(path.join(__dirname, 'templates/video_media.art'), desc),
+ pubDate: parseDate(video_story.publishedAt) || item.pubDate,
+ media: {
+ content: { url: video_story.video?.thumbnail.url || '' },
+ thumbnails: { url: video_story.video?.thumbnail.url || '' },
+ },
+ category: desc.keywords ?? [],
+ };
+ return rss_item;
+ }
+ return item;
+};
+
+const parsePhotoEssaysPage = async (res, api, item) => {
+ const $ = load(res.data.html);
+ const article_json = {};
+ for (const e of $(api.sel).toArray()) {
+ Object.assign(article_json, JSON.parse($(e).html()));
+ }
+ const rss_item = {
+ title: article_json.headline || item.title,
+ link: article_json.canonical || item.link,
+ guid: `bloomberg:${article_json.id}`,
+ description: (await processBody(article_json.body, article_json)).replaceAll(emptyRegex, ''),
+ pubDate: item.pubDate,
+ author: article_json.authors?.map((a) => a.name).join(', ') ?? [],
+ };
+ return rss_item;
+};
+
+const parseReactRendererPage = async (res, api, item) => {
+ const json = load(res.data)(api.sel).text().trim();
+ const story_id = JSON.parse(json)[api.prop];
+ try {
+ const res = await redirectGot(`${idUrl}${story_id}`);
+ return await parseStoryJson(res._data, item);
+ } catch (error) {
+ // fallback
+ if (error.name && (error.name === 'HTTPError' || error.name === 'RequestError' || error.name === 'FetchError')) {
+ return {
+ title: item.title,
+ link: item.link,
+ pubDate: item.pubDate,
+ };
+ }
+ }
+};
+
+const parseStoryJson = async (story_json, item) => {
+ const media_img = story_json.ledeImageUrl || Object.values(story_json.imageAttachments ?? {})[0]?.url;
+ const rss_item = {
+ title: story_json.headline || item.title,
+ link: story_json.url || item.link,
+ guid: `bloomberg:${story_json.id}`,
+ description: processHeadline(story_json) + (await processLedeMedia(story_json)) + (await documentToHtmlString(story_json.body || '')),
+ pubDate: parseDate(story_json.publishedAt) || item.pubDate,
+ author: story_json.authors?.map((a) => a.name).join(', ') ?? [],
+ category: story_json.mostRelevantTags ?? [],
+ media: {
+ content: { url: media_img },
+ thumbnails: { url: media_img },
+ },
+ };
+ return rss_item;
+};
+
+const processHeadline = (story_json) => {
+ const dek = story_json.dek || story_json.summary || story_json.headline || '';
+ const abs = story_json.abstract?.map((a) => `
${a} `).join('');
+ return abs ? dek + `` : dek;
+};
+
+const processLedeMedia = async (story_json) => {
+ if (story_json.ledeMediaKind) {
+ const kind = story_json.ledeMediaKind;
+
+ const media = {
+ kind: story_json.ledeMediaKind,
+ caption: story_json.ledeCaption?.replaceAll(capRegex, '') ?? '',
+ description: story_json.ledeDescription?.replaceAll(capRegex, '') ?? '',
+ credit: story_json.ledeCredit?.replaceAll(capRegex, '') ?? '',
+ src: story_json.ledeImageUrl,
+ video: kind === 'video' && (await processVideo(story_json.ledeAttachment.bmmrId)),
+ };
+ return art(path.join(__dirname, 'templates/lede_media.art'), { media });
+ } else if (story_json.lede) {
+ const lede = story_json.lede;
+ const image = {
+ src: lede.url,
+ alt: lede.alt || lede.title,
+ caption: lede.caption?.replaceAll(capRegex, '') ?? '',
+ credit: lede.credit?.replaceAll(capRegex, '') ?? '',
+ };
+ return art(path.join(__dirname, 'templates/image_figure.art'), image);
+ } else if (story_json.imageAttachments) {
+ const attachment = Object.values(story_json.imageAttachments)[0];
+ if (attachment) {
+ const image = {
+ src: attachment.baseUrl || attachment.url,
+ alt: attachment.alt || attachment.title,
+ caption: attachment.caption?.replaceAll(capRegex, '') ?? '',
+ credit: attachment.credit?.replaceAll(capRegex, '') ?? '',
+ };
+ return art(path.join(__dirname, 'templates/image_figure.art'), image);
+ }
+ return '';
+ } else if (story_json.type === 'Lede') {
+ const props = story_json.props;
+
+ const media = {
+ kind: props.media,
+ caption: props.caption?.replaceAll(capRegex, '') ?? '',
+ description: props.dek?.replaceAll(capRegex, '') ?? '',
+ credit: props.credit?.replaceAll(capRegex, '') ?? '',
+ src: props.url,
+ };
+ return art(path.join(__dirname, 'templates/lede_media.art'), { media });
+ }
+};
+
+const processBody = async (body_html, story_json) => {
+ const removeSel = ['meta', 'script', '*[class$="-footnotes"]', '*[class$="for-you"]', '*[class$="-newsletter"]', '*[class$="page-ad"]', '*[class$="-recirc"]', '*[data-ad-placeholder="Advertisement"]'];
+
+ const $ = load(body_html);
+ for (const sel of removeSel) {
+ $(sel).remove();
+ }
+ $('.paywall').removeAttr('class');
+
+ // Asynchronous iteration intentionally
+ // https://github.com/eslint/eslint/blob/8a159686f9d497262d573dd601855ce28362199b/tests/lib/rules/no-await-in-loop.js#L50
+ for await (const e of $('figure')) {
+ const imageType = $(e).data('image-type');
+ const type = $(e).data('type');
+
+ let new_figure = '';
+ if (imageType === 'audio') {
+ let audio = {};
+ if (story_json.audios) {
+ const attachment = story_json.audios.find((a) => a.id.toString() === $(e).data('id').toString());
+ audio = {
+ img: attachment.image?.url || $(e).find('img').attr('src'),
+ src: attachment.url || $(e).find('audio source').attr('src'),
+ caption: $(e).find('[class$="text"]').html()?.trim() ?? '',
+ credit: $(e).find('[class$="credit"]').html()?.trim() ?? '',
+ };
+ }
+ if (story_json.episode) {
+ const episode = story_json.episode;
+ audio = {
+ src: episode.url,
+ img: episode.image || story_json.pageInfo.image.url,
+
+ caption: episode.description || ($(e).find('[class$="text"]').html()?.trim() ?? ''),
+ credit: (episode.credits.map((c) => c.name).join(', ') ?? []) || ($(e).find('[class$="credit"]').html()?.trim() ?? ''),
+ };
+ }
+ new_figure = art(path.join(__dirname, 'templates/audio_media.art'), audio);
+ } else if (imageType === 'video') {
+ if (story_json.videoAttachments) {
+ const attachment = story_json.videoAttachments[$(e).data('id')];
+ const video = await processVideo(attachment.bmmrId);
+ new_figure = art(path.join(__dirname, 'templates/video_media.art'), video);
+ }
+ } else if (imageType === 'photo' || imageType === 'image' || type === 'image') {
+ let src, alt;
+ if (story_json.imageAttachments) {
+ const attachment = story_json.imageAttachments[$(e).data('id')];
+ alt = attachment?.alt || $(e).find('img').attr('alt')?.trim();
+ src = attachment?.baseUrl;
+ } else {
+ alt = $(e).find('img').attr('alt').trim();
+ src = $(e).find('img').data('native-src');
+ }
+ const caption = $(e).find('[class$="text"], .caption, .photo-essay__text').html()?.trim() ?? '';
+ const credit = $(e).find('[class$="credit"], .credit, .photo-essay__source').html()?.trim() ?? '';
+ const image = { src, alt, caption, credit };
+ new_figure = art(path.join(__dirname, 'templates/image_figure.art'), image);
+ }
+ $(new_figure).insertAfter(e);
+ $(e).remove();
+ }
+
+ return $.html();
+};
+
+const processVideo = async (bmmrId, summary) => {
+ const api = `https://www.bloomberg.com/multimedia/api/embed?id=${bmmrId}`;
+ const res = await redirectGot(api);
+
+ // Blocked by PX3, return the default
+ if ((res.redirected && new URL(res.url).pathname === '/tosv2.html') || res.status === 404) {
+ return {
+ stream: '',
+ mp4: '',
+ coverUrl: '',
+ caption: summary,
+ };
+ }
+
+ if (res._data.data) {
+ const video_json = res._data.data;
+ return {
+ stream: video_json.streams ? video_json.streams[0]?.url : '',
+ mp4: video_json.downloadURLs ? video_json.downloadURLs['600'] : '',
+ coverUrl: video_json.thumbnail?.baseUrl ?? '',
+ caption: video_json.description || video_json.title || summary,
+ };
+ }
+ return {
+ stream: '',
+ mp4: '',
+ coverUrl: '',
+ caption: summary,
+ };
+};
+
+const nodeRenderers = {
+ paragraph: async (node, nextNode) => `${await nextNode(node.content)}
`,
+ text: (node) => {
+ const { attributes: attr, value: val } = node;
+ if (attr?.emphasis && attr?.strong) {
+ return `${val} `;
+ } else if (attr?.emphasis) {
+ return `${val} `;
+ } else if (attr?.strong) {
+ return `${val} `;
+ } else {
+ return val;
+ }
+ },
+ 'inline-newsletter': async (node, nextNode) => `${await nextNode(node.content)}
`,
+ 'inline-recirc': async (node, nextNode) => `${await nextNode(node.content)}
`,
+ heading: async (node, nextNode) => {
+ const nodeData = node.data;
+ if (nodeData.level === 2 || nodeData.level === 3) {
+ return `${await nextNode(node.content)} `;
+ }
+ },
+ link: async (node, nextNode) => {
+ const dest = node.data.destination;
+ const web = dest.web;
+ const bbg = dest.bbg;
+ const title = node.data.title;
+ if (web) {
+ return `${await nextNode(node.content)} `;
+ }
+
+ if (bbg && bbg.startsWith('bbg://news/stories')) {
+ const o = bbg.split('bbg://news/stories/').pop();
+ const s = 'https://www.bloomberg.com/news/terminal/'.concat(o);
+ return `${await nextNode(node.content)} `;
+ }
+ return String(await nextNode(node.content));
+ },
+ entity: async (node, nextNode) => {
+ const t = node.subType;
+ const linkDest = node.data.link.destination;
+ const web = linkDest.web;
+ if (t === 'person') {
+ return nextNode(node.content);
+ }
+ if (t === 'story') {
+ if (web) {
+ return `${await nextNode(node.content)} `;
+ }
+ const a = node.data.story.identifiers.suid;
+ const o = 'https://www.bloomberg.com/news/terminal/'.concat(a);
+ return `${await nextNode(node.content)} `;
+ }
+ if (t === 'security') {
+ const s = node.data.security.identifiers.parsekey;
+ if (s) {
+ const c = s.split(' ');
+ const href = [...'https://www.bloomberg.com/quote/'.concat(c[0], ':'), c[1]];
+ return `${await nextNode(node.content)} `;
+ }
+ }
+ return nextNode(node.content);
+ },
+ br: () => ` `,
+ hr: () => ` `,
+ ad: () => {},
+ blockquote: async (node, nextNode) => `${await nextNode(node.content)} `,
+ quote: async (node, nextNode) => `${await nextNode(node.content)} `,
+ aside: async (node, nextNode) => `${await nextNode(node.content)} `,
+ list: async (node, nextNode) => {
+ const t = node.subType;
+ if (t === 'unordered') {
+ return `${await nextNode(node.content)} `;
+ }
+ if (t === 'ordered') {
+ return `${await nextNode(node.content)} `;
+ }
+ },
+ listItem: async (node, nextNode) => `${await nextNode(node.content)} `,
+ media: async (node) => {
+ const t = node.subType;
+ if (t === 'chart' && node.data.attachment) {
+ if (node.data.attachment.creator === 'TOASTER') {
+ const c = node.data.chart;
+ const e = {
+ src: (c && c.fallback) || '',
+ chart: node.data.attachment,
+ id: (c && c.id) || '',
+ alt: (c && c.alt) || '',
+ };
+ const w = e.chart;
+
+ const chart = {
+ source: w.source,
+ footnote: w.footnote,
+ url: w.url,
+ title: w.title,
+ subtitle: w.subtitle,
+ chartId: 'toaster-chart-'.concat(e.id),
+ chartAlt: e.alt,
+ fallback: e.src,
+ };
+ return art(path.join(__dirname, 'templates/chart_media.art'), { chart });
+ }
+ const image = {
+ alt: node.data.attachment?.footnote || '',
+ caption: node.data.attachment?.title + node.data.attachment.subtitle || '',
+ credit: node.data.attachment?.source || '',
+ src: node.data.chart?.fallback || '',
+ };
+ return art(path.join(__dirname, 'templates/image_figure.art'), image);
+ }
+ if (t === 'photo') {
+ const h = node.data;
+ let img = '';
+ if (h.attachment) {
+ const image = { src: h.photo?.src, alt: h.photo?.alt, caption: h.photo?.caption, credit: h.photo?.credit };
+ img = art(path.join(__dirname, 'templates/image_figure.art'), image);
+ }
+ if (h.link && h.link.destination && h.link.destination.web) {
+ const href = h.link.destination.web;
+ return `${img} `;
+ }
+ return img;
+ }
+ if (t === 'video') {
+ const h = node.data;
+ const id = h.attachment?.id;
+ if (id) {
+ const desc = await processVideo(id, h.attachment?.title);
+ return art(path.join(__dirname, 'templates/video_media.art'), desc);
+ }
+ }
+ if (t === 'audio' && node.data.attachment) {
+ const B = node.data.attachment;
+ const P = B.title;
+ const D = B.url;
+ const M = B.image;
+ if (P && D) {
+ const audio = {
+ src: D,
+ img: M.url,
+ caption: P,
+ credit: '',
+ };
+ return art(path.join(__dirname, 'templates/audio_media.art'), audio);
+ }
+ }
+ return '';
+ },
+ tabularData: async (node, nextNode) => `${await nextNode(node.content)}
`,
+ columns: (node) => {
+ const cols = node.data.definitions
+ .map((e) => ({
+ title: e.title,
+ span: e.colSpan || 1,
+ type: e.dataType,
+ }))
+ .map((e) => `${e.title} `);
+ return `${cols} `;
+ },
+ row: async (node, nextNode) => `${await nextNode(node.content)} `,
+ cell: async (node, nextNode) => {
+ const types = { 'news-rsf-table-number': 'number', 'news-rsf-table-string': 'text' };
+ const cellType = types[node.data.class] || 'text';
+ return `${await nextNode(node.content)} `;
+ },
+};
+
+const nextNode = async (nodes) => {
+ const nodeStr = await nodeListToHtmlString(nodes);
+ return nodeStr;
+};
+
+const nodeToHtmlString = async (node, obj) => {
+ if (!node.type || !nodeRenderers[node.type]) {
+ return `${node.type} `;
+ }
+ const str = await nodeRenderers[node.type](node, nextNode, obj);
+ return str;
+};
+
+const nodeListToHtmlString = async (nodes) => {
+ const res = await Promise.all(
+ nodes.map(async (node, index) => {
+ const str = await nodeToHtmlString(node, {
+ index,
+ prev: nodes[index - 1]?.type,
+ next: nodes[index + 1]?.type,
+ });
+ return str;
+ })
+ );
+ return res.join('');
+};
+
+const documentToHtmlString = async (document) => {
+ if (!document || !document.content) {
+ return '';
+ }
+ const str = await nodeListToHtmlString(document.content);
+ return str;
+};
+
+export { parseArticle, parseNewsList, rootUrl };
diff --git a/lib/routes/blow-studio/work.js b/lib/routes/blow-studio/work.js
deleted file mode 100755
index b323963164d044..00000000000000
--- a/lib/routes/blow-studio/work.js
+++ /dev/null
@@ -1,71 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const response = await got({
- method: 'get',
- url: `https://www.blowstudio.es/work/`,
- });
- const data = response.data;
- const $ = cheerio.load(data); // 使用 cheerio 加载返回的 HTML
- const list = $('.portfolios.normal > ul > li').get().slice(0, 10);
- const articledata = await Promise.all(
- list.map(async (item) => {
- const link = `https://www.blowstudio.es${$(item).find('a').attr('href')}/`;
-
- const cache = await ctx.cache.get(link);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const response2 = await got({
- method: 'get',
- url: link,
- });
-
- const articleHtml = response2.data;
- const $2 = cheerio.load(articleHtml);
- const imglist = $2('div.attachment-grid img').get();
- const mainvideo = $2('.single-page-vimeo-div').attr('data-video-id');
-
- const img = imglist.map((item) => ({
- img: $2(item).attr('src'),
- }));
- const single = {
- mainvideo,
- describe: $2('div.portfolio-content > div:nth-child(2)').html(),
- title: $2('div.portfolio-content > div:nth-child(1)').text(),
- images: img,
- link,
- };
- ctx.cache.set(link, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
-
- ctx.state.data = {
- title: 'Blow Studio',
- link: 'http://blowstudio.es',
- description: $('description').text(),
- item: list.map((item, index) => {
- let content = '';
- const videostyle = `width="640" height="360"`;
- const imgstyle = `style="max-width: 650px; height: auto; object-fit: contain; flex: 0 0 auto;"`;
-
- content += ` `;
- content += articledata[index].describe;
-
- if (articledata[index].images) {
- for (let p = 0; p < articledata[index].images.length; p++) {
- content += ` `;
- }
- }
-
- return {
- title: articledata[index].title,
- description: content,
- link: articledata[index].link,
- };
- }),
- };
-};
diff --git a/lib/routes/bluearchive/namespace.ts b/lib/routes/bluearchive/namespace.ts
new file mode 100644
index 00000000000000..8964009f943eda
--- /dev/null
+++ b/lib/routes/bluearchive/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Blue Archive',
+ url: 'bluearchive.jp',
+ categories: ['game'],
+ lang: 'ja',
+};
diff --git a/lib/routes/bluearchive/news.ts b/lib/routes/bluearchive/news.ts
new file mode 100644
index 00000000000000..42415851ab0446
--- /dev/null
+++ b/lib/routes/bluearchive/news.ts
@@ -0,0 +1,88 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+// type id => display name
+type Mapping = Record;
+
+const JP: Mapping = {
+ '0': '全て',
+ '1': 'イベント',
+ '2': 'お知らせ',
+ '3': 'メンテナンス',
+};
+
+// render into MD table
+const mkTable = (mapping: Mapping): string => {
+ const heading: string[] = [],
+ separator: string[] = [],
+ body: string[] = [];
+
+ for (const key in mapping) {
+ heading.push(mapping[key]);
+ separator.push(':--:');
+ body.push(key);
+ }
+
+ return [heading.join(' | '), separator.join(' | '), body.join(' | ')].map((s) => `| ${s} |`).join('\n');
+};
+
+const handler: Route['handler'] = async (ctx) => {
+ const { server } = ctx.req.param();
+
+ switch (server.toUpperCase()) {
+ case 'JP':
+ return await ja(ctx);
+ default:
+ throw [];
+ }
+};
+
+const ja: Route['handler'] = async (ctx) => {
+ const { type = '0' } = ctx.req.param();
+
+ const data = await ofetch<{ data: { rows: { id: number; content: string; summary: string; publishTime: number }[] } }, 'json'>('https://api-web.bluearchive.jp/api/news/list', {
+ query: {
+ typeId: type,
+ pageNum: 16,
+ pageIndex: 1,
+ },
+ });
+
+ return {
+ title: `ブルアカ - ${JP[type]}`,
+ link: 'https://bluearchive.jp/news/newsJump',
+ language: 'ja-JP',
+ image: 'https://webcnstatic.yostar.net/ba_cn_web/prod/web/favicon.png', // The CN website has a larger one
+ icon: 'https://webcnstatic.yostar.net/ba_cn_web/prod/web/favicon.png',
+ logo: 'https://webcnstatic.yostar.net/ba_cn_web/prod/web/favicon.png',
+ item: data.data.rows.map((row) => ({
+ title: row.summary,
+ description: row.content,
+ link: `https://bluearchive.jp/news/newsJump/${row.id}`,
+ pubDate: parseDate(row.publishTime),
+ })),
+ };
+};
+
+export const route: Route = {
+ path: '/news/:server/:type?',
+ name: 'News',
+ categories: ['game'],
+ maintainers: ['equt'],
+ example: '/bluearchive/news/jp',
+ parameters: {
+ server: 'game server (ISO 3166 two-letter country code, case-insensitive), only `JP` is supported for now',
+ type: 'news type, checkout the table below for details',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ handler,
+ description: [JP].map((el) => mkTable(el)).join('\n\n'),
+};
diff --git a/lib/routes/bluestacks/namespace.ts b/lib/routes/bluestacks/namespace.ts
new file mode 100644
index 00000000000000..b4f0b17c3cb6e8
--- /dev/null
+++ b/lib/routes/bluestacks/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BlueStacks',
+ url: 'bluestacks.com',
+ lang: 'en',
+};
diff --git a/lib/routes/bluestacks/release.ts b/lib/routes/bluestacks/release.ts
new file mode 100644
index 00000000000000..8823a1da4d7476
--- /dev/null
+++ b/lib/routes/bluestacks/release.ts
@@ -0,0 +1,91 @@
+import * as cheerio from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { parseDate } from '@/utils/parse-date';
+import puppeteer from '@/utils/puppeteer';
+
+const pageUrl = 'https://support.bluestacks.com/hc/en-us/articles/360056960211-Release-Notes-BlueStacks-5';
+
+export const route: Route = {
+ path: '/release/5',
+ categories: ['program-update'],
+ example: '/bluestacks/release/5',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: true,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bluestacks.com/hc/en-us/articles/360056960211-Release-Notes-BlueStacks-5', 'bluestacks.com/'],
+ },
+ ],
+ name: 'BlueStacks 5 Release Notes',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'bluestacks.com/hc/en-us/articles/360056960211-Release-Notes-BlueStacks-5',
+};
+
+async function handler() {
+ const browser = await puppeteer();
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' || request.resourceType() === 'script' ? request.continue() : request.abort();
+ });
+ await page.goto(pageUrl, {
+ waitUntil: 'domcontentloaded',
+ });
+ const res = await page.evaluate(() => document.documentElement.innerHTML);
+ await page.close();
+
+ const $ = cheerio.load(res);
+
+ const items = $('div h3 a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.text(),
+ link: item.attr('href'),
+ };
+ });
+
+ await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' || request.resourceType() === 'script' ? request.continue() : request.abort();
+ });
+ await page.goto(item.link, {
+ waitUntil: 'domcontentloaded',
+ });
+ const res = await page.evaluate(() => document.documentElement.innerHTML);
+ const $ = cheerio.load(res);
+ await page.close();
+
+ item.description = $('div.article__body').html();
+ item.pubDate = parseDate($('div.meta time').attr('datetime'));
+
+ return item;
+ })
+ )
+ );
+
+ await browser.close();
+
+ return {
+ title: $('.article__title').text().trim(),
+ description: $('meta[name=description]').text().trim(),
+ link: pageUrl,
+ image: $('link[rel="shortcut icon"]').attr('href'),
+ item: items,
+ };
+}
diff --git a/lib/routes/blur-studio/index.js b/lib/routes/blur-studio/index.js
deleted file mode 100755
index da070395ff89da..00000000000000
--- a/lib/routes/blur-studio/index.js
+++ /dev/null
@@ -1,81 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const response = await got({
- method: 'get',
- url: `http://blur.com`,
- });
- const data = response.data;
- const $ = cheerio.load(data); // 使用 cheerio 加载返回的 HTML
- const list = $('.page-title a').get();
- const articledata = await Promise.all(
- list.map(async (item) => {
- const link = $(item).attr('href');
- const cache = await ctx.cache.get(link);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
- const response2 = await got({
- method: 'get',
- url: link,
- });
-
- const articleHtml = response2.data;
- const $2 = cheerio.load(articleHtml);
- const imglist = $2('.gridded img').get();
- const videolist = $2('.gridded iframe').get();
- const img = imglist.map((item) => ({
- img: $2(item).attr('src'),
- }));
- const video = videolist.map((item) => ({
- video: $2(item).attr('src'),
- }));
- const single = {
- mainvideo: $2('div.project-title').attr('data-video'),
- describe: $2('p').text(),
- title: $2('h1.page-title').text(),
- client: $2('div.client').text(),
- images: img,
- video,
- link,
- };
- ctx.cache.set(link, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
-
- ctx.state.data = {
- title: 'Blur Studio',
- link: 'http://blur.com',
- description: $('description').text(),
- item: list.map((item, index) => {
- const Num = /[0-9]+/;
- let content = '';
- const videostyle = `width="640" height="360"`;
- const imgstyle = `style="max-width: 650px; height: auto; object-fit: contain; flex: 0 0 auto;"`;
- content += `Client:${articledata[index].client} ${articledata[index].describe}`;
- if (Num.test(articledata[index].mainvideo)) {
- content += ` `;
- } else {
- content += `VIDEO `;
- }
- if (articledata[index].images) {
- for (let p = 0; p < articledata[index].images.length; p++) {
- content += ` `;
- }
- }
- if (articledata[index].video) {
- for (let v = 0; v < articledata[index].video.length; v++) {
- content += ` `;
- }
- }
-
- return {
- title: articledata[index].title,
- description: content,
- link: articledata[index].link,
- };
- }),
- };
-};
diff --git a/lib/routes/bmkg/earthquake.ts b/lib/routes/bmkg/earthquake.ts
new file mode 100644
index 00000000000000..d60f1dd89ac7e6
--- /dev/null
+++ b/lib/routes/bmkg/earthquake.ts
@@ -0,0 +1,55 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/earthquake',
+ categories: ['forecast'],
+ example: '/bmkg/earthquake',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bmkg.go.id/', 'bmkg.go.id/gempabumi-terkini.html'],
+ },
+ ],
+ name: 'Recent Earthquakes',
+ maintainers: ['Shinanory'],
+ handler,
+ url: 'bmkg.go.id/',
+};
+
+async function handler() {
+ const url = 'https://www.bmkg.go.id/gempabumi-terkini.html';
+ const response = await got(url);
+ const $ = load(response.data);
+ const items = $('div .table-responsive tbody tr')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const td = item.find('td');
+ return {
+ title: `${td[2].children[0].data}|${td[3].children[0].data}|${td[4].children[0].data}|${td[5].children[0].data}|${td[6].children[0].data}`,
+ link: url,
+ pubDate: timezone(parseDate(`${td[1].children[0].data} ${td[1].children[2].data.slice(0, 8)}`, 'DD-MM-YY HH:mm:ss'), +7),
+ };
+ });
+
+ return {
+ title: $('title').text(),
+ link: url,
+ description: '印尼气象气候和地球物理局 最近的地震(M ≥ 5.0) | BMKG earthquake',
+ item: items,
+ language: 'in',
+ };
+}
diff --git a/lib/routes/bmkg/namespace.ts b/lib/routes/bmkg/namespace.ts
new file mode 100644
index 00000000000000..f65228885c5bc5
--- /dev/null
+++ b/lib/routes/bmkg/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BADAN METEOROLOGI, KLIMATOLOGI, DAN GEOFISIKA(Indonesian)',
+ url: 'bmkg.go.id',
+ lang: 'en',
+};
diff --git a/lib/routes/bmkg/news.ts b/lib/routes/bmkg/news.ts
new file mode 100644
index 00000000000000..7a9a21693a02a2
--- /dev/null
+++ b/lib/routes/bmkg/news.ts
@@ -0,0 +1,69 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['forecast'],
+ example: '/bmkg/news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bmkg.go.id/', 'bmkg.go.id/berita'],
+ },
+ ],
+ name: 'News',
+ maintainers: ['Shinanory'],
+ handler,
+ url: 'bmkg.go.id/',
+};
+
+async function handler() {
+ const url = 'https://www.bmkg.go.id';
+ const response = await got(url);
+ const $ = load(response.data);
+ const list = $('div .ms-slide')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a');
+ const img = item.find('img');
+
+ return {
+ title: a.text(),
+ link: `${url}/${a.attr('href')}`,
+ itunes_item_image: img.attr('data-src'),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+ const p = $('div .blog-grid').find('p');
+ item.description = p.text();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: url,
+ description: '印尼气象气候和地球物理局 新闻 | BMKG news',
+ item: items,
+ language: 'in',
+ };
+}
diff --git a/lib/routes/bnext/index.ts b/lib/routes/bnext/index.ts
new file mode 100644
index 00000000000000..2c9848c166cfa6
--- /dev/null
+++ b/lib/routes/bnext/index.ts
@@ -0,0 +1,59 @@
+import type { Item } from 'rss-parser';
+
+import type { Route } from '@/types';
+import parser from '@/utils/rss-parser';
+
+const FEED_URL = 'https://rss.bnextmedia.com.tw/feed/bnext';
+
+export const route: Route = {
+ path: '/',
+ categories: ['traditional-media'],
+ example: '/bnext',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.bnext.com.tw'],
+ target: '/bnext',
+ },
+ ],
+ name: '最新文章',
+ maintainers: ['johan456789'],
+ handler,
+ url: 'www.bnext.com.tw',
+};
+
+async function handler() {
+ const feed = await parser.parseURL(FEED_URL);
+ const items = (feed.items as Item[]).map((item) => {
+ const enclosure = item.enclosure;
+ const enclosure_url = enclosure?.url;
+ const enclosure_type = enclosure?.type;
+ const enclosure_length = enclosure?.length ? Number(enclosure.length) : undefined;
+
+ return {
+ title: item.title ?? item.link ?? 'Untitled',
+ link: item.link,
+ description: item.content ?? item.summary ?? item.contentSnippet,
+ pubDate: item.isoDate ?? item.pubDate,
+ enclosure_url,
+ enclosure_type,
+ enclosure_length,
+ };
+ });
+
+ return {
+ title: feed.title ?? '數位時代 BusinessNext',
+ link: feed.link ?? 'https://www.bnext.com.tw',
+ description: feed.description ?? '',
+ item: items,
+ allowEmpty: true, // the official feed clears all items every day at 00:00 UTC+8
+ };
+}
diff --git a/lib/routes/bnext/namespace.ts b/lib/routes/bnext/namespace.ts
new file mode 100644
index 00000000000000..01cd5aa04b19cc
--- /dev/null
+++ b/lib/routes/bnext/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '數位時代 BusinessNext',
+ url: 'bnext.com.tw',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/bntnews/index.ts b/lib/routes/bntnews/index.ts
new file mode 100644
index 00000000000000..4db339beb58d42
--- /dev/null
+++ b/lib/routes/bntnews/index.ts
@@ -0,0 +1,103 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const categories = {
+ bnt003000000: 'Beauty',
+ bnt002000000: 'Fashion',
+ bnt004000000: 'Star',
+ bnt007000000: 'Style+',
+ bnt009000000: 'Photo',
+ bnt005000000: 'Life',
+ bnt008000000: 'Now',
+};
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/bntnews/bnt003000000',
+ parameters: { category: 'Category ID, see table below, default to Now (bnt008000000)' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Category',
+ maintainers: ['iamsnn'],
+ handler,
+ description: `| Beauty | Fashion | Star | Style+ | Photo | Life | Now |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| bnt003000000 | bnt002000000 | bnt004000000 | bnt007000000 | bnt009000000 | bnt005000000 | bnt008000000 |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') || 'bnt008000000';
+ const rootUrl = 'https://www.bntnews.co.kr';
+ const currentUrl = `${rootUrl}/article/list/${category}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ searchParams: {
+ returnType: 'ajax',
+ },
+ });
+
+ const articles = response.data.result?.items || [];
+
+ const list = articles.map((article) => {
+ const link = `${rootUrl}/article/view/${article.aid}`;
+
+ return {
+ title: article.title,
+ link,
+ description: article.content,
+ pubDate: timezone(parseDate(article.firstPublishDate), +9),
+ author: article.reporter?.[0]?.name || '',
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ const $content = content('.body_wrap .content');
+
+ // Remove ads
+ $content.find('.googleBanner').remove();
+ $content.find('script').remove();
+ $content.find('style').remove();
+
+ if ($content.length > 0) {
+ item.description = $content.html();
+ } else {
+ const $articleView = content('.article_view');
+ if ($articleView.length > 0) {
+ item.description = $articleView.html();
+ }
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `bntnews - ${categories[category] || category}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bntnews/namespace.ts b/lib/routes/bntnews/namespace.ts
new file mode 100644
index 00000000000000..81d4aa4217f5c7
--- /dev/null
+++ b/lib/routes/bntnews/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'bntnews',
+ url: 'bntnews.co.kr',
+ lang: 'ko',
+};
diff --git a/lib/routes/bnu/bs.ts b/lib/routes/bnu/bs.ts
new file mode 100644
index 00000000000000..4693d6946878dd
--- /dev/null
+++ b/lib/routes/bnu/bs.ts
@@ -0,0 +1,82 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/bs/:category?',
+ categories: ['university'],
+ example: '/bnu/bs',
+ parameters: { category: '分类,见下表,默认为学院新闻' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bs.bnu.edu.cn/:category/index.html'],
+ target: '/bs/:category',
+ },
+ ],
+ name: '经济与工商管理学院',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 学院新闻 | 通知公告 | 学术成果 | 学术讲座 | 教师观点 | 人才招聘 |
+| -------- | -------- | -------- | -------- | -------- | -------- |
+| xw | zytzyyg | xzcg | xzjz | xz | bshzs |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'xw';
+
+ const rootUrl = 'http://bs.bnu.edu.cn';
+ const currentUrl = `${rootUrl}/${category}/index.html`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const list = $('a[title]')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.attr('title'),
+ pubDate: parseDate(item.prev().text()),
+ link: `${rootUrl}/${category}/${item.attr('href')}`,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.description = content('.right-c-content-con').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('.right-c-title').text()} - ${$('title').text()}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bnu/dwxgb.ts b/lib/routes/bnu/dwxgb.ts
new file mode 100644
index 00000000000000..4b67dc0bc47d56
--- /dev/null
+++ b/lib/routes/bnu/dwxgb.ts
@@ -0,0 +1,79 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/dwxgb/:category/:type',
+ categories: ['university'],
+ example: '/bnu/dwxgb/xwzx/tzgg',
+ parameters: { category: '大分类', type: '子分类,例子如下' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['dwxgb.bnu.edu.cn/:category/:type/index.html'],
+ },
+ ],
+ name: '党委学生工作部',
+ maintainers: ['Fatpandac'],
+ handler,
+ description: `\`https://dwxgb.bnu.edu.cn/xwzx/tzgg/index.html\` 则对应为 \`/bnu/dwxgb/xwzx/tzgg`,
+};
+
+async function handler(ctx) {
+ const { category, type } = ctx.req.param();
+
+ const rootUrl = 'https://dwxgb.bnu.edu.cn';
+ const currentUrl = `${rootUrl}/${category}/${type}/index.html`;
+
+ let response;
+ try {
+ response = await got(currentUrl);
+ } catch {
+ try {
+ response = await got(`${rootUrl}/${category}/${type}/index.htm`);
+ } catch {
+ return;
+ }
+ }
+
+ const $ = load(response.data);
+
+ const list = $('ul.container.list > li')
+ .toArray()
+ .map((item) => {
+ const link = $(item).find('a').attr('href');
+ const absoluteLink = new URL(link, currentUrl).href;
+ return {
+ title: $(item).find('a').text().trim(),
+ pubDate: parseDate($(item).find('span').text()),
+ link: absoluteLink,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link);
+ const content = load(detailResponse.data);
+ item.description = content('div.article.typo').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('div.breadcrumb1 > a:nth-child(3)').text()} - ${$('div.breadcrumb1 > a:nth-child(4)').text()}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bnu/fdy.ts b/lib/routes/bnu/fdy.ts
new file mode 100644
index 00000000000000..b94dbfc775abed
--- /dev/null
+++ b/lib/routes/bnu/fdy.ts
@@ -0,0 +1,51 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/fdy/:path{.+}?',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const baseUrl = 'http://fdy.bnu.edu.cn';
+ const { path = 'tzgg' } = ctx.req.param();
+ const link = `${baseUrl}/${path}/index.htm`;
+
+ const { data: response } = await got(link);
+ const $ = load(response);
+
+ const list = $('.listconrl li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a');
+ return {
+ title: a.attr('title'),
+ link: new URL(a.attr('href'), link).href,
+ pubDate: parseDate(item.find('.news-dates').text()),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: response } = await got(item.link);
+ const $ = load(response);
+ item.description = $('.listconrc-newszw').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('head title').text(),
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/bnu/fe.ts b/lib/routes/bnu/fe.ts
new file mode 100644
index 00000000000000..68ff77d1288e1f
--- /dev/null
+++ b/lib/routes/bnu/fe.ts
@@ -0,0 +1,73 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/fe/:category',
+ categories: ['university'],
+ example: '/bnu/fe/18',
+ parameters: {},
+ radar: [
+ {
+ source: ['fe.bnu.edu.cn/pc/cms1info/list/1/:category'],
+ },
+ ],
+ name: '教育学部-培养动态',
+ maintainers: ['etShaw-zh'],
+ handler,
+ description: `\`https://fe.bnu.edu.cn/pc/cms1info/list/1/18\` 则对应为 \`/bnu/fe/18`,
+};
+
+async function handler(ctx) {
+ const { category } = ctx.req.param();
+ const apiUrl = 'https://fe.bnu.edu.cn/pc/cmscommon/nlist';
+ let response;
+ try {
+ // 发送 POST 请求
+ response = await got.post(apiUrl, {
+ headers: {
+ Accept: 'application/json, text/javascript, */*; q=0.01',
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
+ Origin: 'https://fe.bnu.edu.cn',
+ Referer: 'https://fe.bnu.edu.cn/pc/cms1info/list/1/18',
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ body: `columnid=${category}&page=1`, // POST 数据
+ });
+ } catch {
+ throw new Error('Failed to fetch data from API');
+ }
+ const jsonData = JSON.parse(response.body);
+ // 检查返回的 code
+ if (jsonData.code !== 0 || !jsonData.data) {
+ throw new Error('Invalid API response');
+ }
+
+ const list = jsonData.data.map((item) => ({
+ title: item.title,
+ link: `https://fe.bnu.edu.cn/html/1/news/${item.htmlpath}/n${item.newsid}.html`,
+ pubDate: parseDate(item.happendate, 'YYYY-MM-DD'),
+ }));
+
+ const out = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+ item.author = '北京师范大学教育学部';
+ item.description = $('.news02_div').html() || '暂无详细内容';
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '北京师范大学教育学部-培养动态',
+ link: 'https://fe.bnu.edu.cn/pc/cms1info/list/1/18',
+ description: '北京师范大学教育学部-培养动态最新通知',
+ item: out,
+ };
+}
diff --git a/lib/routes/bnu/jwb.ts b/lib/routes/bnu/jwb.ts
new file mode 100644
index 00000000000000..f28cb29c9c3add
--- /dev/null
+++ b/lib/routes/bnu/jwb.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/jwb',
+ categories: ['university'],
+ example: '/bnu/jwb',
+ parameters: {},
+ radar: [
+ {
+ source: ['jwb.bnu.edu.cn'],
+ },
+ ],
+ name: '教务部(研究生院)',
+ maintainers: ['ladeng07'],
+ handler,
+ url: 'jwb.bnu.edu.cn/tzgg/index.htm',
+};
+
+async function handler() {
+ const link = 'https://jwb.bnu.edu.cn/tzgg/index.htm';
+ const response = await got(link);
+ const $ = load(response.data);
+ const list = $('.article-list .boxlist ul li')
+ .toArray()
+ .map((e) => {
+ e = $(e);
+ const a = e.find('a');
+ return {
+ title: e.find('a span').text(),
+ link: a.attr('href').startsWith('http') ? a.attr('href') : 'https://jwb.bnu.edu.cn' + a.attr('href').slice(2),
+ pubDate: parseDate(e.find('span.fr.text-muted').text(), 'YYYY-MM-DD'),
+ };
+ });
+
+ const out = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+ item.author = '北京师范大学教务部';
+ item.description = $('.contenttxt').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '北京师范大学教务部',
+ link,
+ description: '北京师范大学教务部最新通知',
+ item: out,
+ };
+}
diff --git a/lib/routes/bnu/lib.ts b/lib/routes/bnu/lib.ts
new file mode 100644
index 00000000000000..12c815ad8f961d
--- /dev/null
+++ b/lib/routes/bnu/lib.ts
@@ -0,0 +1,56 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/lib/:category?',
+ radar: [
+ {
+ source: ['www.lib.bnu.edu.cn/:category/index.htm'],
+ target: '/lib/:category',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const baseUrl = 'http://www.lib.bnu.edu.cn';
+ const { category = 'zydt' } = ctx.req.param();
+ const link = `${baseUrl}/${category}/index.htm`;
+
+ const { data: response } = await got(link);
+ const $ = load(response);
+
+ const list = $('.view-content .item-list li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('a').text(),
+ link: `${baseUrl}/${category}/${item.find('a').attr('href')}`,
+ pubDate: parseDate(item.find('span > span').eq(1).text(), 'YYYY-MM-DD'),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: response } = await got(item.link);
+ const $ = load(response);
+ item.description = $('#block-system-main .content .content').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('head title').text(),
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/bnu/mba.ts b/lib/routes/bnu/mba.ts
new file mode 100644
index 00000000000000..09715046c09eff
--- /dev/null
+++ b/lib/routes/bnu/mba.ts
@@ -0,0 +1,202 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx) => {
+ const { category = 'xwdt' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+
+ const rootUrl = 'https://mba.bnu.edu.cn';
+ const currentUrl = new URL(`${category.replace(/\/$/, '')}/`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('ul.concrcc li')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a.listlj');
+ const title = a.text();
+
+ return {
+ title,
+ pubDate: parseDate(item.find('div.crq').text()),
+ link: new URL(a.prop('href'), currentUrl).href,
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ const title = $$('div.connewst').text();
+ const description = $$('div.concrczw').html();
+ const image = $$('div.concrczw img').first().prop('src');
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = parseDate($$('div.connewstis-time').text().split(/:/).pop());
+ item.content = {
+ html: description,
+ text: $$('div.concrczw').text(),
+ };
+ item.image = image;
+ item.banner = image;
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const author = $('title').text();
+ const image = new URL('images/logo5.png', rootUrl).href;
+
+ return {
+ title: `${author} - ${$('div.concrchbt').text()}`,
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author,
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/mba/:category{.+}?',
+ name: '经济与工商管理学院MBA',
+ url: 'mba.bnu.edu.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/bnu/mba/xwdt',
+ parameters: { category: '分类,默认为 xwdt,即新闻聚焦' },
+ description: `::: tip
+ 若订阅 [新闻聚焦](https://mba.bnu.edu.cn/xwdt/index.html),网址为 \`https://mba.bnu.edu.cn/xwdt/index.html\`。截取 \`https://mba.bnu.edu.cn/\` 到末尾 \`/index.html\` 的部分 \`xwdt\` 作为参数填入,此时路由为 [\`/bnu/mba/xwdt\`](https://rsshub.app/bnu/mba/xwdt)。
+:::
+
+#### [主页](https://mba.bnu.edu.cn)
+
+| [新闻聚焦](https://mba.bnu.edu.cn/xwdt/index.html) | [通知公告](https://mba.bnu.edu.cn/tzgg/index.html) | [MBA 系列讲座](https://mba.bnu.edu.cn/mbaxljz/index.html) |
+| -------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------- |
+| [xwdt](https://rsshub.app/bnu/mba/xwdt) | [tzgg](https://rsshub.app/bnu/mba/tzgg) | [mbaxljz](https://rsshub.app/bnu/mba/mbaxljz) |
+
+#### [招生动态](https://mba.bnu.edu.cn/zsdt/zsjz/index.html)
+
+| [下载专区](https://mba.bnu.edu.cn/zsdt/cjwt/index.html) |
+| ------------------------------------------------------- |
+| [zsdt/cjwt](https://rsshub.app/bnu/mba/zsdt/cjwt) |
+
+#### [国际视野](https://mba.bnu.edu.cn/gjhz/hwjd/index.html)
+
+| [海外基地](https://mba.bnu.edu.cn/gjhz/hwjd/index.html) | [学位合作](https://mba.bnu.edu.cn/gjhz/xwhz/index.html) | [长期交换](https://mba.bnu.edu.cn/gjhz/zqjh/index.html) | [短期项目](https://mba.bnu.edu.cn/gjhz/dqxm/index.html) |
+| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
+| [gjhz/hwjd](https://rsshub.app/bnu/mba/gjhz/hwjd) | [gjhz/xwhz](https://rsshub.app/bnu/mba/gjhz/xwhz) | [gjhz/zqjh](https://rsshub.app/bnu/mba/gjhz/zqjh) | [gjhz/dqxm](https://rsshub.app/bnu/mba/gjhz/dqxm) |
+
+#### [校园生活](https://mba.bnu.edu.cn/xysh/xszz/index.html)
+
+| [学生组织](https://mba.bnu.edu.cn/xysh/xszz/index.html) |
+| ------------------------------------------------------- |
+| [xysh/xszz](https://rsshub.app/bnu/mba/xysh/xszz) |
+
+#### [职业发展](https://mba.bnu.edu.cn/zyfz/xwds/index.html)
+
+| [校外导师](https://mba.bnu.edu.cn/zyfz/xwds/index.html) | [企业实践](https://mba.bnu.edu.cn/zyfz/zycp/index.html) | [就业创业](https://mba.bnu.edu.cn/zyfz/jycy/index.html) |
+| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
+| [zyfz/xwds](https://rsshub.app/bnu/mba/zyfz/xwds) | [zyfz/zycp](https://rsshub.app/bnu/mba/zyfz/zycp) | [zyfz/jycy](https://rsshub.app/bnu/mba/zyfz/jycy) |
+ `,
+ categories: ['university'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['mba.bnu.edu.cn/:category?'],
+ target: (params) => {
+ const category = params.category;
+
+ return category ? `/${category.replace(/\/index\.html$/, '')}` : '';
+ },
+ },
+ {
+ title: '新闻聚焦',
+ source: ['mba.bnu.edu.cn/xwdt/index.html'],
+ target: '/mba/xwdt',
+ },
+ {
+ title: '通知公告',
+ source: ['mba.bnu.edu.cn/tzgg/index.html'],
+ target: '/mba/tzgg',
+ },
+ {
+ title: 'MBA系列讲座',
+ source: ['mba.bnu.edu.cn/mbaxljz/index.html'],
+ target: '/mba/mbaxljz',
+ },
+ {
+ title: '招生动态 - 下载专区',
+ source: ['mba.bnu.edu.cn/zsdt/cjwt/index.html'],
+ target: '/mba/zsdt/cjwt',
+ },
+ {
+ title: '国际视野 - 海外基地',
+ source: ['mba.bnu.edu.cn/gjhz/hwjd/index.html'],
+ target: '/mba/gjhz/hwjd',
+ },
+ {
+ title: '国际视野 - 学位合作',
+ source: ['mba.bnu.edu.cn/gjhz/xwhz/index.html'],
+ target: '/mba/gjhz/xwhz',
+ },
+ {
+ title: '国际视野 - 长期交换',
+ source: ['mba.bnu.edu.cn/gjhz/zqjh/index.html'],
+ target: '/mba/gjhz/zqjh',
+ },
+ {
+ title: '国际视野 - 短期项目',
+ source: ['mba.bnu.edu.cn/gjhz/dqxm/index.html'],
+ target: '/mba/gjhz/dqxm',
+ },
+ {
+ title: '校园生活 - 学生组织',
+ source: ['mba.bnu.edu.cn/xysh/xszz/index.html'],
+ target: '/mba/xysh/xszz',
+ },
+ {
+ title: '职业发展 - 校外导师',
+ source: ['mba.bnu.edu.cn/zyfz/xwds/index.html'],
+ target: '/mba/zyfz/xwds',
+ },
+ {
+ title: '职业发展 - 企业实践',
+ source: ['mba.bnu.edu.cn/zyfz/zycp/index.html'],
+ target: '/mba/zyfz/zycp',
+ },
+ {
+ title: '职业发展 - 就业创业',
+ source: ['mba.bnu.edu.cn/zyfz/jycy/index.html'],
+ target: '/mba/zyfz/jycy',
+ },
+ ],
+};
diff --git a/lib/routes/bnu/namespace.ts b/lib/routes/bnu/namespace.ts
new file mode 100644
index 00000000000000..1a2f6fea8ba4aa
--- /dev/null
+++ b/lib/routes/bnu/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京师范大学',
+ url: 'bs.bnu.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/boc/namespace.ts b/lib/routes/boc/namespace.ts
new file mode 100644
index 00000000000000..cbcdaae031b05e
--- /dev/null
+++ b/lib/routes/boc/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国银行',
+ url: 'boc.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/boc/whpj.ts b/lib/routes/boc/whpj.ts
new file mode 100644
index 00000000000000..51ee1f48977942
--- /dev/null
+++ b/lib/routes/boc/whpj.ts
@@ -0,0 +1,130 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/whpj/:format?',
+ categories: ['other'],
+ example: '/boc/whpj/zs?filter_title=%E8%8B%B1%E9%95%91',
+ parameters: { format: '输出的标题格式,默认为标题 + 所有价格。短格式仅包含货币名称。' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['boc.cn/sourcedb/whpj', 'boc.cn/'],
+ target: '/whpj',
+ },
+ ],
+ name: '外汇牌价',
+ maintainers: ['LogicJake', 'HenryQW'],
+ handler,
+ url: 'boc.cn/sourcedb/whpj',
+ description: `| 短格式 | 中行折算价 | 现汇买卖 | 现钞买卖 | 现汇买入 | 现汇卖出 | 现钞买入 | 现钞卖出 |
+| ------ | ---------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| short | zs | xh | xc | xhmr | xhmc | xcmr | xcmc |`,
+};
+
+async function handler(ctx) {
+ const link = 'https://www.boc.cn/sourcedb/whpj/';
+ const response = await got(link);
+ const $ = load(response.data);
+
+ const format = ctx.req.param('format');
+
+ const en_names = {
+ 阿联酋迪拉姆: 'AED',
+ 澳大利亚元: 'AUD',
+ 巴西里亚尔: 'BRL',
+ 加拿大元: 'CAD',
+ 瑞士法郎: 'CHF',
+ 丹麦克朗: 'DKK',
+ 欧元: 'EUR',
+ 英镑: 'GBP',
+ 港币: 'HKD',
+ 印尼卢比: 'IDR',
+ 印度卢比: 'INR',
+ 日元: 'JPY',
+ 韩国元: 'KRW',
+ 澳门元: 'MOP',
+ 林吉特: 'MYR',
+ 挪威克朗: 'NOK',
+ 新西兰元: 'NZD',
+ 菲律宾比索: 'PHP',
+ 卢布: 'RUB',
+ 沙特里亚尔: 'SAR',
+ 瑞典克朗: 'SEK',
+ 新加坡元: 'SGD',
+ 泰国铢: 'THB',
+ 土耳其里拉: 'TRY',
+ 新台币: 'TWD',
+ 美元: 'USD',
+ 南非兰特: 'ZAR',
+ };
+
+ const out = $('div.publish table tbody tr')
+ .slice(2)
+ .toArray()
+ .map((e) => {
+ e = $(e);
+ const zh_name = e.find('td:nth-child(1)').text();
+ const en_name = en_names[zh_name] || '';
+ const name = `${zh_name} ${en_name}`;
+ const date = e.find('td:nth-child(7)').text();
+
+ const xhmr = `现汇买入价:${e.find('td:nth-child(2)').text()}`;
+
+ const xcmr = `现钞买入价:${e.find('td:nth-child(3)').text()}`;
+
+ const xhmc = `现汇卖出价:${e.find('td:nth-child(4)').text()}`;
+
+ const xcmc = `现钞卖出价:${e.find('td:nth-child(5)').text()}`;
+
+ const zs = `中行折算价:${e.find('td:nth-child(6)').text()}`;
+
+ const content = `${xhmr} ${xcmr} ${xhmc} ${xcmc} ${zs}`;
+
+ const formatTitle = () => {
+ switch (format) {
+ case 'short':
+ return name;
+ case 'xh':
+ return `${name} ${xhmr} ${xhmc}`;
+ case 'xc':
+ return `${name} ${xcmr} ${xcmc}`;
+ case 'zs':
+ return `${name} ${zs}`;
+ case 'xhmr':
+ return `${name} ${xhmr}`;
+ case 'xhmc':
+ return `${name} ${xhmc}`;
+ case 'xcmr':
+ return `${name} ${xcmr}`;
+ case 'xcmc':
+ return `${name} ${xcmc}`;
+ default:
+ return `${name} ${content}`;
+ }
+ };
+
+ const info = {
+ title: formatTitle(),
+ description: content.replaceAll(/\s/g, ' '),
+ pubDate: new Date(date).toUTCString(),
+ guid: `${name} ${content}`,
+ };
+ return info;
+ });
+
+ return {
+ title: '中国银行外汇牌价',
+ link,
+ item: out,
+ };
+}
diff --git a/lib/routes/bookfere/category.ts b/lib/routes/bookfere/category.ts
new file mode 100644
index 00000000000000..777914120de94b
--- /dev/null
+++ b/lib/routes/bookfere/category.ts
@@ -0,0 +1,68 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:category',
+ categories: ['reading'],
+ view: ViewType.Articles,
+ example: '/bookfere/skills',
+ parameters: {
+ category: {
+ description: '分类名',
+ options: [
+ { value: 'weekly', label: '每周一书' },
+ { value: 'skills', label: '使用技巧' },
+ { value: 'books', label: '图书推荐' },
+ { value: 'news', label: '新闻速递' },
+ { value: 'essay', label: '精选短文' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '分类',
+ maintainers: ['OdinZhang'],
+ handler,
+ description: `| 每周一书 | 使用技巧 | 图书推荐 | 新闻速递 | 精选短文 |
+| -------- | -------- | -------- | -------- | -------- |
+| weekly | skills | books | news | essay |`,
+};
+
+async function handler(ctx) {
+ const url = 'https://bookfere.com/category/' + ctx.req.param('category');
+ const response = await got({
+ method: 'get',
+ url,
+ });
+
+ const data = response.data;
+
+ const $ = load(data);
+ const list = $('main div div section');
+
+ return {
+ title: $('head title').text(),
+ link: url,
+ item: list.toArray().map((item) => {
+ item = $(item);
+ const date = item.find('time').attr('datetime');
+ const pubDate = parseDate(date);
+ return {
+ title: item.find('h2 a').text(),
+ link: item.find('h2 a').attr('href'),
+ pubDate,
+ description: item.find('p').text(),
+ };
+ }),
+ };
+}
diff --git a/lib/routes/bookfere/namespace.ts b/lib/routes/bookfere/namespace.ts
new file mode 100644
index 00000000000000..aa4ad8dcdc061a
--- /dev/null
+++ b/lib/routes/bookfere/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '书伴',
+ url: 'bookfere.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bookwalker/namespace.ts b/lib/routes/bookwalker/namespace.ts
new file mode 100644
index 00000000000000..69a1c6d9bb3fd9
--- /dev/null
+++ b/lib/routes/bookwalker/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BOOKWALKER電子書',
+ url: 'bookwalker.com.tw',
+ categories: ['shopping'],
+ description: '',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/bookwalker/search.ts b/lib/routes/bookwalker/search.ts
new file mode 100644
index 00000000000000..4ac2f6b2b539f0
--- /dev/null
+++ b/lib/routes/bookwalker/search.ts
@@ -0,0 +1,116 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const { filter = 'order=sell_desc' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '24', 10);
+
+ const baseUrl: string = 'https://www.bookwalker.com.tw';
+ const targetUrl: string = new URL(`search?${filter}`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-TW';
+
+ const items: DataItem[] = $('div.bwbook_package')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const name: string = $el.find('h4.bookname').text();
+ const price: string = $el.find('h5.bprice').text();
+ const authorStr: string = $el.find('h5.booknamesub').text().trim();
+
+ const title: string = `${name} - ${authorStr} ${price}`;
+ const image: string | undefined = $el
+ .find('img')
+ .attr('data-src')
+ ?.replace(/_\d+(\.\w+)$/, '$1');
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: name,
+ },
+ ]
+ : undefined,
+ });
+ const linkUrl: string | undefined = $el.find('div.bwbookitem a').attr('href');
+ const authors: DataItem['author'] = authorStr.split(/,/).map((a) => ({
+ name: a,
+ }));
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/search/:filter?',
+ name: '搜尋',
+ url: 'www.bookwalker.com.tw',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/bookwalker/search/order=sell_desc&s=34',
+ parameters: {
+ filter: {
+ description: '过滤器,默认为 `order=sell_desc`,即依發售日新至舊排序',
+ },
+ },
+ description: `::: tip
+订阅 [依發售日新至舊排序的文學小說](https://www.bookwalker.com.tw/search?order=sell_desc&s=34),其源网址为 \`https://www.bookwalker.com.tw/search?order=sell_desc&s=34\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/bookwalker/search/order=sell_desc&s=34\`](https://rsshub.app/bookwalker/search/order=sell_desc&s=34)。
+:::`,
+ categories: ['shopping'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.bookwalker.com.tw/search'],
+ target: '/bookwalker/search',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/bookwalker/templates/description.art b/lib/routes/bookwalker/templates/description.art
new file mode 100644
index 00000000000000..0a7f83a6f60fb1
--- /dev/null
+++ b/lib/routes/bookwalker/templates/description.art
@@ -0,0 +1,13 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/booru/mmda.ts b/lib/routes/booru/mmda.ts
new file mode 100644
index 00000000000000..90b38530694f29
--- /dev/null
+++ b/lib/routes/booru/mmda.ts
@@ -0,0 +1,134 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import queryString from 'query-string';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/mmda/tags/:tags?',
+ categories: ['picture'],
+ example: '/booru/mmda/tags/full_body%20blue_eyes',
+ parameters: { tags: '标签,多个标签使用 `%20` 连接,如需根据作者查询则在 `user:` 后接上作者名,如:`user:xxxx`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['mmda.booru.org/index.php'],
+ },
+ ],
+ name: 'MMDArchive 标签查询',
+ maintainers: ['N78Wy'],
+ handler,
+ description: `For example:
+
+ - 默认查询 (什么 tag 都不加):\`/booru/mmda/tags\`
+ - 默认查询单个 tag:\`/booru/mmda/tags/full_body\`
+ - 默认查询多个 tag:\`/booru/mmda/tags/full_body%20blue_eyes\`
+ - 默认查询根据作者查询:\`/booru/mmda/tags/user:xxxx\``,
+};
+
+async function handler(ctx) {
+ const baseUrl = 'https://mmda.booru.org';
+ const tags = ctx.req.param('tags');
+
+ const query = queryString.stringify(
+ {
+ tags,
+ page: 'post',
+ s: 'list',
+ },
+ {
+ skipNull: true,
+ }
+ );
+
+ const { data: response } = await got(`${baseUrl}/index.php`, {
+ searchParams: query,
+ });
+
+ const $ = load(response);
+ const list = $('#post-list > div.content > div > div:nth-child(3) span')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a').first();
+
+ const scriptStr = item.find('script[type="text/javascript"]').first().text();
+ const user = scriptStr.match(/user':'(.*?)'/)?.[1] ?? '';
+
+ const title = a.find('img').first().attr('title') ?? '';
+ const imageSrc = a.find('img').first().attr('src') ?? '';
+
+ return {
+ title,
+ link: `${baseUrl}/${a.attr('href')}`,
+ image: imageSrc,
+ author: user,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ title,
+ image: imageSrc,
+ by: user,
+ }),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: response } = await got(item.link);
+ const $ = load(response);
+
+ // 获取左侧的Statistics统计信息
+ const statisticsTages = $('#tag_list > ul');
+ statisticsTages.find('li, br, strong').remove();
+ const statisticsStr = statisticsTages.text();
+
+ const regex = /(?[^\s:]+)\s*:\s*(?.+)/gm;
+ const result = {};
+ for (const match of statisticsStr.matchAll(regex)) {
+ const { key, value } = match.groups ?? ({} as { key: string; value: string });
+ result[key.trim().toLocaleLowerCase()] = value.trim();
+ }
+
+ // 获取大图
+ const bigImage = $('#image').attr('src');
+
+ // 获取发布时间
+ if (result.posted) {
+ item.pubDate = parseDate(result.posted);
+ }
+
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ title: item.title,
+ image: bigImage ?? item.image,
+ posted: item.pubDate ?? '',
+ by: result.by,
+ source: result.source,
+ rating: result.rating,
+ score: result.score,
+ });
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: tags,
+ link: `${baseUrl}/index.php?${query}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/booru/namespace.ts b/lib/routes/booru/namespace.ts
new file mode 100644
index 00000000000000..e4d8c2dc515864
--- /dev/null
+++ b/lib/routes/booru/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Booru',
+ url: 'mmda.booru.org',
+ lang: 'en',
+};
diff --git a/lib/routes/booru/templates/description.art b/lib/routes/booru/templates/description.art
new file mode 100644
index 00000000000000..dde6382def4133
--- /dev/null
+++ b/lib/routes/booru/templates/description.art
@@ -0,0 +1,25 @@
+
+ {{if image }}
+
+ {{/if}}
+
+ {{if posted }}
+
posted: {{ posted }}
+ {{/if}}
+
+ {{if by }}
+
by: {{ by }}
+ {{/if}}
+
+ {{if source }}
+
source: {{ source }}
+ {{/if}}
+
+ {{if rating }}
+
rating: {{ rating }}
+ {{/if}}
+
+ {{if score }}
+
score: {{ score }}
+ {{/if}}
+
diff --git a/lib/routes/booth-pm/shop.js b/lib/routes/booth-pm/shop.js
deleted file mode 100644
index 22ae1983993ac0..00000000000000
--- a/lib/routes/booth-pm/shop.js
+++ /dev/null
@@ -1,60 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { isValidHost } = require('@/utils/valid-host');
-const maxPages = 5;
-
-module.exports = async (ctx) => {
- const { subdomain } = ctx.params;
- if (!isValidHost(subdomain)) {
- throw Error('Invalid subdomain');
- }
- const shopUrl = `https://${subdomain}.booth.pm`;
-
- let shopName;
- const items = [];
- for (let page = 1; page <= maxPages; page++) {
- const pageUrl = `${shopUrl}/items?page=${page}`;
- // eslint-disable-next-line no-await-in-loop
- const response = await got({
- method: 'get',
- url: pageUrl,
- });
-
- const data = response.data;
-
- const $ = cheerio.load(data);
- shopName = $('div.shop-name > span').text();
- const pageItems = $('li.item');
-
- if (pageItems.length === 0) {
- break;
- }
-
- for (let i = 0; i < pageItems.length; ++i) {
- const pageItem = pageItems[i];
-
- // extract item name
- const itemName = $('h2.item-name > a', pageItem).text();
-
- // extract item url
- const itemUrl = shopUrl + $('h2.item-name > a', pageItem).attr('href');
-
- // extract item preview url
- const itemPreviewUrl = $('div.swap-image > img', pageItem).attr('src');
-
- items.push({
- title: itemName,
- description: ` `,
- link: itemUrl,
- });
- }
- }
-
- ctx.state.data = {
- title: shopName,
- link: shopUrl,
- description: shopName,
- allowEmpty: true,
- item: items,
- };
-};
diff --git a/lib/routes/bossdesign/index.ts b/lib/routes/bossdesign/index.ts
new file mode 100644
index 00000000000000..fa4db84ceb6dda
--- /dev/null
+++ b/lib/routes/bossdesign/index.ts
@@ -0,0 +1,64 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['design'],
+ example: '/bossdesign',
+ parameters: { category: '分类,可在对应分类页 URL 中找到,留空为全部' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '分类',
+ maintainers: ['TonyRL'],
+ handler,
+ description: `| Boss 笔记 | 电脑日志 | 素材资源 | 设计师神器 | 设计教程 | 设计资讯 |
+| --------- | --------------- | ---------------- | --------------- | --------------- | ------------------- |
+| note | computer-skills | design-resources | design-software | design-tutorial | design\_information |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category');
+ const limit = Number.parseInt(ctx.req.query('limit'), 10) || undefined;
+ const baseUrl = 'https://www.bossdesign.cn';
+
+ const currentCategory = await cache.tryGet(`bossdesign:categories:${category}`, async () => {
+ const { data: categories } = await got(`${baseUrl}/wp-json/wp/v2/categories`);
+ return categories.find((item) => item.slug === category || item.name === category);
+ });
+
+ const categoryId = currentCategory?.id;
+
+ const { data: posts } = await got(`${baseUrl}/wp-json/wp/v2/posts`, {
+ searchParams: {
+ categories: categoryId,
+ per_page: limit,
+ _embed: '',
+ },
+ });
+
+ const items = posts.map((item) => ({
+ title: item.title.rendered,
+ description: item.content.rendered,
+ pubDate: parseDate(item.date_gmt),
+ updated: parseDate(item.modified_gmt),
+ link: item.link,
+ guid: item.guid.rendered,
+ category: [...new Set([...item._embedded['wp:term'][0].map((item) => item.name), ...item._embedded['wp:term'][1].map((item) => item.name)])],
+ }));
+
+ return {
+ title: currentCategory?.name ? `${currentCategory.name} | Boss设计` : 'Boss设计 | 收集国外设计素材网站的资源平台。',
+ description: currentCategory?.description ?? 'Boss设计-收集国外设计素材网站的资源平台。专注于收集国外设计素材和国外设计网站,以及超实用的设计师神器,只为设计初学者和设计师提供海量的资源平台。..',
+ image: currentCategory?.cover ?? `${baseUrl}/wp-content/themes/pinghsu/images/Bossdesign-ico.ico`,
+ link: currentCategory?.link ?? baseUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bossdesign/namespace.ts b/lib/routes/bossdesign/namespace.ts
new file mode 100644
index 00000000000000..61cd691dcfb874
--- /dev/null
+++ b/lib/routes/bossdesign/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Boss 设计',
+ url: 'bossdesign.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/brave/latest.ts b/lib/routes/brave/latest.ts
new file mode 100644
index 00000000000000..9fd36130e27ec2
--- /dev/null
+++ b/lib/routes/brave/latest.ts
@@ -0,0 +1,66 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/latest',
+ categories: ['program-update'],
+ example: '/brave/latest',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['brave.com/latest', 'brave.com/'],
+ },
+ ],
+ name: 'Release Notes',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'brave.com/latest',
+};
+
+async function handler() {
+ const rootUrl = 'https://brave.com';
+ const currentUrl = `${rootUrl}/latest`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const items = $('.box h3')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.text();
+ const device = item.parent().find('h2').text();
+ const matchVersion = title.match(/(v[\d.]+)/);
+ const matchDate = title.match(/\((.*?)\)/);
+
+ return {
+ title: `[${device}] ${title}`,
+ link: currentUrl,
+ guid: `${currentUrl}#${device}-${matchVersion?.[1] ?? title}`,
+ description: item.next().html(),
+ pubDate: parseDate(matchDate?.[1].replace(/(st|nd|rd|th)?,/, ''), ['MMMM D YYYY', 'MMM D YYYY']),
+ };
+ });
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/brave/namespace.ts b/lib/routes/brave/namespace.ts
new file mode 100644
index 00000000000000..b38a081e38d1a3
--- /dev/null
+++ b/lib/routes/brave/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Brave',
+ url: 'brave.com',
+ lang: 'en',
+};
diff --git a/lib/routes/brooklynmuseum/exhibitions.ts b/lib/routes/brooklynmuseum/exhibitions.ts
new file mode 100644
index 00000000000000..9e7980ccf52bfa
--- /dev/null
+++ b/lib/routes/brooklynmuseum/exhibitions.ts
@@ -0,0 +1,46 @@
+import type { Route } from '@/types';
+import buildData from '@/utils/common-config';
+
+export const route: Route = {
+ path: '/exhibitions/:state?',
+ categories: ['travel'],
+ example: '/brooklynmuseum/exhibitions',
+ parameters: { state: '展览进行的状态:`current` 对应展览当前正在进行,`past` 对应过去的展览,`upcoming` 对应即将举办的展览,默认为 `current`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Exhibitions',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ let link;
+ const state = ctx.req.param('state');
+
+ switch (state) {
+ case undefined:
+ case 'current':
+ link = 'https://www.brooklynmuseum.org/exhibitions/';
+ break;
+ default:
+ link = `https://www.brooklynmuseum.org/exhibitions/${state}`;
+ }
+
+ return await buildData({
+ link,
+ url: link,
+ title: 'Brooklyn Museum - Exhibitions',
+ item: {
+ item: '.exhibitions .image-card',
+ title: `$('h2 > a, h3 > a').text()`,
+ link: `$('h2 > a, h3 > a').attr('href')`,
+ description: `$('h6').text()`,
+ },
+ });
+}
diff --git a/lib/routes/brooklynmuseum/namespace.ts b/lib/routes/brooklynmuseum/namespace.ts
new file mode 100644
index 00000000000000..34e7807709c4dc
--- /dev/null
+++ b/lib/routes/brooklynmuseum/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Brooklyn Museum',
+ url: 'www.brooklynmuseum.org',
+ lang: 'en',
+};
diff --git a/lib/routes/bse/index.ts b/lib/routes/bse/index.ts
new file mode 100644
index 00000000000000..96a3d17120dfb2
--- /dev/null
+++ b/lib/routes/bse/index.ts
@@ -0,0 +1,216 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const nodes = {
+ important_news: {
+ id: 1289,
+ title: '本所要闻',
+ url: '/news/important_news.html',
+ type: '/info/listse',
+ },
+ recruit: {
+ id: 1380,
+ title: '人才招聘',
+ url: '/company/recruit.html',
+ type: '/info/listse',
+ },
+ purchase: {
+ id: 1381,
+ title: '采购信息',
+ url: '/purchase/list.html',
+ type: '/info/listse',
+ },
+ news_list: {
+ id: 2676,
+ title: '业务通知',
+ url: '/news/news_list.html',
+ type: '/info/listse',
+ },
+ law_list: {
+ id: 1322,
+ title: '法律法规',
+ url: '/rule/law_list.html',
+ type: '/info/listse',
+ },
+ public_opinion: {
+ id: 1307,
+ title: '公开征求意见',
+ url: '/rule/public_opinion.html',
+ type: '/info/listse',
+ },
+ regulation_list: {
+ id: 1300,
+ title: '部门规章',
+ url: '/rule/regulation_list.html',
+ type: '/info/listse',
+ },
+ fxrz_list: {
+ id: 1302,
+ title: '发行融资',
+ url: '/business/fxrz_list.html',
+ type: '/info/listse',
+ },
+ cxjg_list: {
+ id: 1303,
+ title: '持续监管',
+ url: '/business/cxjg_list.html',
+ type: '/info/listse',
+ },
+ jygl_list: {
+ id: 1304,
+ title: '交易管理',
+ url: '/business/jygl_list.html',
+ type: '/info/listse',
+ },
+ scgl_list: {
+ id: 1306,
+ title: '市场管理',
+ url: '/business/scgl_list.html',
+ type: '/info/listse',
+ },
+ meeting_notice: {
+ id: '9531-1001',
+ title: '上市委会议公告',
+ url: '/notice/meeting_notice.html',
+ type: '/disclosureInfoController/zoneInfoResult',
+ },
+ meeting_result: {
+ id: '9531-1002',
+ title: '上市委会议结果公告',
+ url: '/notice/meeting_result.html',
+ type: '/disclosureInfoController/zoneInfoResult',
+ },
+ meeting_change: {
+ id: '9531-1003',
+ title: '上市委会议变更公告',
+ url: '/notice/meeting_notice.html',
+ type: '/disclosureInfoController/zoneInfoResult',
+ },
+ bgcz_notice: {
+ id: '9536-1001',
+ title: '并购重组委会议公告',
+ url: '/notice/bgcz_notice.html',
+ type: '/disclosureInfoController/zoneInfoResult',
+ },
+ bgcz_result: {
+ id: '9536-1002',
+ title: '并购重组委会议结果公告',
+ url: '/notice/bgcz_result.html',
+ type: '/disclosureInfoController/zoneInfoResult',
+ },
+ bgcz_change: {
+ id: '9536-1003',
+ title: '并购重组委会议变更公告',
+ url: '/notice/bgcz_notice.html',
+ type: '/disclosureInfoController/zoneInfoResult',
+ },
+ termination_audit: {
+ id: '9530-3001',
+ title: '终止审核',
+ url: '/notice/termination_audit.html',
+ type: '/disclosureInfoController/zoneInfoResult',
+ },
+ audit_result: {
+ id: '9532-1001',
+ title: '注册结果',
+ url: '/notice/audit_result.html',
+ type: '/disclosureInfoController/zoneInfoResult',
+ },
+};
+
+export const route: Route = {
+ path: '/:category?/:keyword?',
+ categories: ['finance'],
+ example: '/bse',
+ parameters: { category: '分类,见下表,默认为本所要闻', keyword: '关键字,默认为空' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bse.cn/'],
+ },
+ ],
+ name: '栏目',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'bse.cn/',
+ description: `| 本所要闻 | 人才招聘 | 采购信息 | 业务通知 |
+| --------------- | -------- | -------- | ---------- |
+| important\_news | recruit | purchase | news\_list |
+
+| 法律法规 | 公开征求意见 | 部门规章 | 发行融资 |
+| --------- | --------------- | ---------------- | ---------- |
+| law\_list | public\_opinion | regulation\_list | fxrz\_list |
+
+| 持续监管 | 交易管理 | 市场管理 | 上市委会议公告 |
+| ---------- | ---------- | ---------- | --------------- |
+| cxjg\_list | jygl\_list | scgl\_list | meeting\_notice |
+
+| 上市委会议结果公告 | 上市委会议变更公告 | 并购重组委会议公告 |
+| ------------------ | ------------------ | ------------------ |
+| meeting\_result | meeting\_change | bgcz\_notice |
+
+| 并购重组委会议结果公告 | 并购重组委会议变更公告 | 终止审核 | 注册结果 |
+| ---------------------- | ---------------------- | ------------------ | ------------- |
+| bgcz\_result | bgcz\_change | termination\_audit | audit\_result |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'important_news';
+ const keyword = ctx.req.param('keyword') ?? '';
+
+ const type = nodes[category].type;
+ const rootUrl = 'http://www.bse.cn';
+ const currentUrl = `${rootUrl}${type}.do`;
+
+ const response = await got({
+ method: 'post',
+ url: currentUrl,
+ form: {
+ page: 0,
+ pageSize: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50,
+ keywords: keyword,
+ 'nodeIds[]': type === '/info/listse' ? nodes[category].id : undefined,
+ disclosureSubtype: type === '/info/listse' ? undefined : nodes[category].id,
+ },
+ });
+
+ const data = JSON.parse(response.data.match(/null\(\[({.*})]\)/)[1]);
+
+ let items = [];
+
+ switch (nodes[category].type) {
+ case '/info/listse':
+ items = data.data.content.map((item) => ({
+ title: item.title,
+ category: item.tags,
+ description: item.text,
+ link: `${rootUrl}${item.htmlUrl}`,
+ pubDate: timezone(parseDate(item.publishDate), +8),
+ }));
+ break;
+
+ case '/disclosureInfoController/zoneInfoResult':
+ items = data.listInfo.content.map((item) => ({
+ title: item.disclosureTitle,
+ link: `${rootUrl}${item.destFilePath}`,
+ pubDate: parseDate(item.pubDate.time),
+ }));
+ break;
+ }
+
+ return {
+ title: `${nodes[category].title} - 北京证券交易所`,
+ link: `${rootUrl}${nodes[category].url}`,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bse/namespace.ts b/lib/routes/bse/namespace.ts
new file mode 100644
index 00000000000000..042bb07cff0d17
--- /dev/null
+++ b/lib/routes/bse/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京证券交易所',
+ url: 'bse.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bsky/feeds.ts b/lib/routes/bsky/feeds.ts
new file mode 100644
index 00000000000000..852b2ddd810786
--- /dev/null
+++ b/lib/routes/bsky/feeds.ts
@@ -0,0 +1,72 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { getFeed, getFeedGenerator, resolveHandle } from './utils';
+
+export const route: Route = {
+ path: '/profile/:handle/feed/:space/:routeParams?',
+ categories: ['social-media'],
+ view: ViewType.SocialMedia,
+ example: '/bsky.app/profile/jaz.bsky.social/feed/cv:cat',
+ parameters: {
+ handle: 'User handle, can be found in URL',
+ space: 'Space ID, can be found in URL',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Feeds',
+ maintainers: ['FerrisChi'],
+ handler,
+};
+
+async function handler(ctx) {
+ const handle = ctx.req.param('handle');
+ const space = ctx.req.param('space');
+
+ const DID = await resolveHandle(handle, cache.tryGet);
+ const uri = `at://${DID}/app.bsky.feed.generator/${space}`;
+ const profile = await getFeedGenerator(uri, cache.tryGet);
+ const feeds = await getFeed(uri, cache.tryGet);
+
+ const items = feeds.feed.map(({ post }) => ({
+ title: post.record.text.split('\n')[0],
+ description: art(path.join(__dirname, 'templates/post.art'), {
+ text: post.record.text.replaceAll('\n', ' '),
+ embed: post.embed,
+ // embed.$type "app.bsky.embed.record#view" and "app.bsky.embed.recordWithMedia#view" are not handled
+ }),
+ author: post.author.displayName,
+ pubDate: parseDate(post.record.createdAt),
+ link: `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('app.bsky.feed.post/')[1]}`,
+ upvotes: post.likeCount,
+ comments: post.replyCount,
+ }));
+
+ ctx.set('json', {
+ DID,
+ profile,
+ feeds,
+ });
+
+ return {
+ title: `${profile.view.displayName} — Bluesky`,
+ description: profile.view.description?.replaceAll('\n', ' '),
+ link: `https://bsky.app/profile/${handle}/feed/${space}`,
+ image: profile.view.avatar,
+ icon: profile.view.avatar,
+ logo: profile.view.avatar,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bsky/keyword.ts b/lib/routes/bsky/keyword.ts
new file mode 100644
index 00000000000000..53ec169cbac7ae
--- /dev/null
+++ b/lib/routes/bsky/keyword.ts
@@ -0,0 +1,46 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/keyword/:keyword',
+ categories: ['social-media'],
+ example: '/bsky/keyword/hello',
+ parameters: { keyword: 'N' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Keywords',
+ maintainers: ['untitaker', 'DIYgod'],
+ handler,
+};
+
+async function handler(ctx) {
+ const keyword = ctx.req.param('keyword');
+
+ const data = await ofetch(`https://api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(keyword)}&limit=25&sort=latest`);
+
+ const items = data.posts.map((post) => ({
+ title: post.record.text,
+ link: `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`,
+ description: post.record.text,
+ pubDate: new Date(post.record.createdAt),
+ author: [
+ {
+ name: post.author.displayName,
+ url: `https://bsky.app/profile/${post.author.handle}`,
+ avatar: post.author.avatar,
+ },
+ ],
+ }));
+
+ return {
+ title: `Bluesky Keyword - ${keyword}`,
+ link: `https://bsky.app/search?q=${encodeURIComponent(keyword)}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/bsky/namespace.ts b/lib/routes/bsky/namespace.ts
new file mode 100644
index 00000000000000..634fd3ab4bae08
--- /dev/null
+++ b/lib/routes/bsky/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bluesky (bsky)',
+ url: 'bsky.app',
+ lang: 'en',
+};
diff --git a/lib/routes/bsky/posts.ts b/lib/routes/bsky/posts.ts
new file mode 100644
index 00000000000000..a6d6aea6efb196
--- /dev/null
+++ b/lib/routes/bsky/posts.ts
@@ -0,0 +1,90 @@
+import path from 'node:path';
+import querystring from 'node:querystring';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { getAuthorFeed, getProfile, resolveHandle } from './utils';
+
+export const route: Route = {
+ path: '/profile/:handle/:routeParams?',
+ categories: ['social-media'],
+ view: ViewType.SocialMedia,
+ example: '/bsky/profile/bsky.app',
+ parameters: {
+ handle: 'User handle, can be found in URL',
+ routeParams: 'Filter parameter, Use filter to customize content types',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bsky.app/profile/:handle'],
+ },
+ ],
+ name: 'Post',
+ maintainers: ['TonyRL'],
+ handler,
+ description: `
+| Filter Value | Description |
+|--------------|-------------|
+| posts_with_replies | Includes Posts, Replies, and Reposts |
+| posts_no_replies | Includes Posts and Reposts, without Replies |
+| posts_with_media | Shows only Posts containing media |
+| posts_and_author_threads | Shows Posts and Threads, without Replies and Reposts |
+
+Default value for filter is \`posts_and_author_threads\` if not specified.
+
+Example:
+- \`/bsky/profile/bsky.app/filter=posts_with_replies\``,
+};
+
+async function handler(ctx) {
+ const handle = ctx.req.param('handle');
+ const routeParams = querystring.parse(ctx.req.param('routeParams'));
+ const filter = routeParams.filter || 'posts_and_author_threads';
+
+ const DID = await resolveHandle(handle, cache.tryGet);
+ const profile = await getProfile(DID, cache.tryGet);
+ const authorFeed = await getAuthorFeed(DID, filter, cache.tryGet);
+
+ const items = authorFeed.feed.map(({ post }) => ({
+ title: post.record.text.split('\n')[0],
+ description: art(path.join(__dirname, 'templates/post.art'), {
+ text: post.record.text.replaceAll('\n', ' '),
+ embed: post.embed,
+ // embed.$type "app.bsky.embed.record#view" and "app.bsky.embed.recordWithMedia#view" are not handled
+ }),
+ author: post.author.displayName,
+ pubDate: parseDate(post.record.createdAt),
+ link: `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('app.bsky.feed.post/')[1]}`,
+ upvotes: post.likeCount,
+ comments: post.replyCount,
+ }));
+
+ ctx.set('json', {
+ DID,
+ profile,
+ authorFeed,
+ });
+
+ return {
+ title: `${profile.displayName} (@${profile.handle}) — Bluesky`,
+ description: profile.description?.replaceAll('\n', ' '),
+ link: `https://bsky.app/profile/${profile.handle}`,
+ image: profile.avatar,
+ icon: profile.avatar,
+ logo: profile.avatar,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bsky/templates/post.art b/lib/routes/bsky/templates/post.art
new file mode 100644
index 00000000000000..80d41fea1844ca
--- /dev/null
+++ b/lib/routes/bsky/templates/post.art
@@ -0,0 +1,24 @@
+{{ if text }}
+ {{@ text }}
+{{ /if }}
+
+{{ if embed }}
+ {{ if embed.$type === 'app.bsky.embed.images#view' }}
+ {{ each embed.images i }}
+
+ {{ /each }}
+ {{ else if embed.$type === 'app.bsky.embed.video#view' }}
+
+
+ Your browser does not support HTML5 video playback.
+
+ {{ else if embed.$type === 'app.bsky.embed.external#view' }}
+ {{ embed.external.title }}
+ {{ embed.external.description }}
+
+ {{ /if }}
+{{ /if }}
diff --git a/lib/routes/bsky/utils.ts b/lib/routes/bsky/utils.ts
new file mode 100644
index 00000000000000..f167308a2a0bbe
--- /dev/null
+++ b/lib/routes/bsky/utils.ts
@@ -0,0 +1,81 @@
+import { config } from '@/config';
+import got from '@/utils/got';
+
+/**
+ * docs: https://atproto.com/lexicons/app-bsky
+ */
+
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/identity/resolveHandle.json
+const resolveHandle = (handle, tryGet) =>
+ tryGet(`bsky:${handle}`, async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle', {
+ searchParams: {
+ handle,
+ },
+ });
+ return data.did;
+ });
+
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/actor/getProfile.json
+const getProfile = (did, tryGet) =>
+ tryGet(`bsky:profile:${did}`, async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile', {
+ searchParams: {
+ actor: did,
+ },
+ });
+ return data;
+ });
+
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/getAuthorFeed.json
+const getAuthorFeed = (did, filter, tryGet) =>
+ tryGet(
+ `bsky:authorFeed:${did}:${filter}`,
+ async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed', {
+ searchParams: {
+ actor: did,
+ filter,
+ limit: 30,
+ },
+ });
+ return data;
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/getFeed.json
+const getFeed = (uri, tryGet) =>
+ tryGet(
+ `bsky:feed:${uri}`,
+ async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/app.bsky.feed.getFeed', {
+ searchParams: {
+ feed: uri,
+ limit: 30,
+ },
+ });
+ return data;
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/getFeedGenerator.json
+const getFeedGenerator = (uri, tryGet) =>
+ tryGet(
+ `bsky:feedGenerator:${uri}`,
+ async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/app.bsky.feed.getFeedGenerator', {
+ searchParams: {
+ feed: uri,
+ },
+ });
+ return data;
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+export { getAuthorFeed, getFeed, getFeedGenerator, getProfile, resolveHandle };
diff --git a/lib/routes/bt0/mv.ts b/lib/routes/bt0/mv.ts
new file mode 100644
index 00000000000000..4fe24a61af7ee4
--- /dev/null
+++ b/lib/routes/bt0/mv.ts
@@ -0,0 +1,66 @@
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+
+import { doGot, genSize } from './util';
+
+export const route: Route = {
+ path: '/mv/:number/:domain?',
+ categories: ['multimedia'],
+ example: '/bt0/mv/35575567/2',
+ parameters: { number: '影视详情id, 网页路径为`/mv/{id}.html`其中的id部分, 一般为8位纯数字', domain: '数字1-9, 比如1表示请求域名为 1bt0.com, 默认为 2' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: true,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['2bt0.com/mv/'],
+ },
+ ],
+ name: '影视资源下载列表',
+ maintainers: ['miemieYaho'],
+ handler,
+};
+
+async function handler(ctx) {
+ const domain = ctx.req.param('domain') ?? '2';
+ const number = ctx.req.param('number');
+ if (!/^[1-9]$/.test(domain)) {
+ throw new InvalidParameterError('Invalid domain');
+ }
+ const regex = /^\d{6,}$/;
+ if (!regex.test(number)) {
+ throw new InvalidParameterError('Invalid number');
+ }
+
+ const host = `https://www.${domain}bt0.com`;
+ const _link = `${host}/prod/core/system/getVideoDetail/${number}`;
+
+ const data = (await doGot(0, host, _link)).data;
+ const items = Object.values(data.ecca).flatMap((item) =>
+ item.map((i) => ({
+ title: i.zname,
+ guid: i.zname,
+ description: `${i.zname}[${i.zsize}]`,
+ link: `${host}/tr/${i.id}.html`,
+ pubDate: i.ezt,
+ enclosure_type: 'application/x-bittorrent',
+ enclosure_url: i.zlink,
+ enclosure_length: genSize(i.zsize),
+ category: strsJoin(i.zqxd, i.text_html, i.audio_html, i.new === 1 ? '新' : ''),
+ }))
+ );
+ return {
+ title: data.title,
+ link: `${host}/mv/${number}.html`,
+ item: items,
+ };
+}
+
+function strsJoin(...strings) {
+ return strings.filter((str) => str !== '').join(',');
+}
diff --git a/lib/routes/bt0/namespace.ts b/lib/routes/bt0/namespace.ts
new file mode 100644
index 00000000000000..9aec8c046775e0
--- /dev/null
+++ b/lib/routes/bt0/namespace.ts
@@ -0,0 +1,10 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '不太灵影视',
+ url: '2bt0.com',
+ description: `::: tip
+ (1-9)bt0.com 都指向同一个
+:::`,
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bt0/tlist.ts b/lib/routes/bt0/tlist.ts
new file mode 100644
index 00000000000000..55dc9763ae1d53
--- /dev/null
+++ b/lib/routes/bt0/tlist.ts
@@ -0,0 +1,68 @@
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import { parseRelativeDate } from '@/utils/parse-date';
+
+import { doGot, genSize } from './util';
+
+const categoryDict = {
+ 1: '电影',
+ 2: '电视剧',
+ 3: '近日热门',
+ 4: '本周热门',
+ 5: '本月热门',
+};
+
+export const route: Route = {
+ path: '/tlist/:sc/:domain?',
+ categories: ['multimedia'],
+ example: '/bt0/tlist/1',
+ parameters: { sc: '分类(1-5), 1:电影, 2:电视剧, 3:近日热门, 4:本周热门, 5:本月热门', domain: '数字1-9, 比如1表示请求域名为 1bt0.com, 默认为 2' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: true,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['2bt0.com/tlist/'],
+ },
+ ],
+ name: '最新资源列表',
+ maintainers: ['miemieYaho'],
+ handler,
+};
+
+async function handler(ctx) {
+ const domain = ctx.req.param('domain') ?? '2';
+ const sc = ctx.req.param('sc');
+ if (!/^[1-9]$/.test(domain)) {
+ throw new InvalidParameterError('Invalid domain');
+ }
+ if (!/^[1-5]$/.test(sc)) {
+ throw new InvalidParameterError('Invalid sc');
+ }
+
+ const host = `https://www.${domain}bt0.com`;
+ const _link = `${host}/prod/core/system/getTList?sc=${sc}`;
+
+ const data = await doGot(0, host, _link);
+ const items = data.data.list.map((item) => ({
+ title: item.zname,
+ guid: item.zname,
+ description: `《${item.title}》 导演: ${item.daoyan} 编剧: ${item.bianji} 演员: ${item.yanyuan} 简介: ${item.conta.trim()}`,
+ link: host + item.aurl,
+ pubDate: item.eztime.endsWith('前') ? parseRelativeDate(item.eztime) : item.eztime,
+ enclosure_type: 'application/x-bittorrent',
+ enclosure_url: item.zlink,
+ enclosure_length: genSize(item.zsize),
+ itunes_item_image: item.epic,
+ }));
+ return {
+ title: `不太灵-最新资源列表-${categoryDict[sc]}`,
+ link: `${host}/tlist/${sc}_1.html`,
+ item: items,
+ };
+}
diff --git a/lib/routes/bt0/util.ts b/lib/routes/bt0/util.ts
new file mode 100644
index 00000000000000..dfcd8d72038ec8
--- /dev/null
+++ b/lib/routes/bt0/util.ts
@@ -0,0 +1,53 @@
+import { CookieJar } from 'tough-cookie';
+
+import got from '@/utils/got';
+
+const cookieJar = new CookieJar();
+
+async function doGot(num, host, link) {
+ if (num > 4) {
+ throw new Error('The number of attempts has exceeded 5 times');
+ }
+ const response = await got.get(link, {
+ cookieJar,
+ });
+ const data = response.data;
+ if (typeof data === 'string') {
+ const regex = /document\.cookie\s*=\s*"([^"]*)"/;
+ const match = data.match(regex);
+ if (!match) {
+ throw new Error('api error');
+ }
+ cookieJar.setCookieSync(match[1], host);
+ return doGot(++num, host, link);
+ }
+ return data;
+}
+
+const genSize = (sizeStr) => {
+ // 正则表达式,用于匹配数字和单位 GB 或 MB
+ const regex = /^(\d+(\.\d+)?)\s*(gb|mb)$/i;
+ const match = sizeStr.match(regex);
+
+ if (!match) {
+ return 0;
+ }
+
+ const value = Number.parseFloat(match[1]);
+ const unit = match[3].toUpperCase();
+
+ let bytes;
+ switch (unit) {
+ case 'GB':
+ bytes = Math.floor(value * 1024 * 1024 * 1024);
+ break;
+ case 'MB':
+ bytes = Math.floor(value * 1024 * 1024);
+ break;
+ default:
+ bytes = 0;
+ }
+ return bytes;
+};
+
+export { doGot, genSize };
diff --git a/lib/routes/btzj/index.ts b/lib/routes/btzj/index.ts
new file mode 100644
index 00000000000000..0bc11e7e101b4d
--- /dev/null
+++ b/lib/routes/btzj/index.ts
@@ -0,0 +1,164 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const allowDomain = new Set(['2btjia.com', '88btbtt.com', 'btbtt15.com', 'btbtt20.com']);
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['multimedia'],
+ example: '/btzj',
+ parameters: { category: '分类,可在对应分类页 URL 中找到,默认为首页' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['btbtt20.com/'],
+ },
+ ],
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'btbtt20.com/',
+ description: `::: tip
+ 分类页中域名末尾到 \`.htm\` 前的字段即为对应分类,如 [电影](https://www.btbtt20.com/forum-index-fid-951.htm) \`https://www.btbtt20.com/forum-index-fid-951.htm\` 中域名末尾到 \`.htm\` 前的字段为 \`forum-index-fid-951\`,所以路由应为 [\`/btzj/forum-index-fid-951\`](https://rsshub.app/btzj/forum-index-fid-951)
+
+ 部分分类页,如 [电影](https://www.btbtt20.com/forum-index-fid-951.htm)、[剧集](https://www.btbtt20.com/forum-index-fid-950.htm) 等,提供了更复杂的分类筛选。你可以将选项选中后,获得结果分类页 URL 中分类参数,构成路由。如选中分类 [高清电影 - 年份:2021 - 地区:欧美](https://www.btbtt20.com/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0.htm) \`https://www.btbtt20.com/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0.htm\` 中域名末尾到 \`.htm\` 前的字段为 \`forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0\`,所以路由应为 [\`/btzj/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0\`](https://rsshub.app/btzj/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0)
+:::
+
+ 基础分类如下:
+
+| 交流 | 电影 | 剧集 | 高清电影 |
+| ------------------- | ------------------- | ------------------- | -------------------- |
+| forum-index-fid-975 | forum-index-fid-951 | forum-index-fid-950 | forum-index-fid-1183 |
+
+| 音乐 | 动漫 | 游戏 | 综艺 |
+| ------------------- | ------------------- | ------------------- | -------------------- |
+| forum-index-fid-953 | forum-index-fid-981 | forum-index-fid-955 | forum-index-fid-1106 |
+
+| 图书 | 美图 | 站务 | 科技 |
+| -------------------- | ------------------- | ----------------- | ------------------- |
+| forum-index-fid-1151 | forum-index-fid-957 | forum-index-fid-2 | forum-index-fid-952 |
+
+| 求助 | 音轨字幕 |
+| -------------------- | -------------------- |
+| forum-index-fid-1187 | forum-index-fid-1191 |
+
+::: tip
+ BT 之家的域名会变更,本路由以 \`https://www.btbtt20.com\` 为默认域名,若该域名无法访问,可以通过在路由后方加上 \`?domain=<域名>\` 指定路由访问的域名。如指定域名为 \`https://www.btbtt15.com\`,则在 \`/btzj\` 后加上 \`?domain=btbtt15.com\` 即可,此时路由为 [\`/btzj?domain=btbtt15.com\`](https://rsshub.app/btzj?domain=btbtt15.com)
+
+ 如果加入了分类参数,直接在分类参数后加入 \`?domain=<域名>\` 即可。如指定分类 [剧集](https://www.btbtt20.com/forum-index-fid-950.htm) \`https://www.btbtt20.com/forum-index-fid-950.htm\` 并指定域名为 \`https://www.btbtt15.com\`,即在 \`/btzj/forum-index-fid-950\` 后加上 \`?domain=btbtt15.com\`,此时路由为 [\`/btzj/forum-index-fid-950?domain=btbtt15.com\`](https://rsshub.app/btzj/forum-index-fid-950?domain=btbtt15.com)
+
+ 目前,你可以选择的域名有 \`btbtt10-20.com\` 共 10 个,或 \`88btbbt.com\`,该站也提供了专用网址查询工具。详见 [此贴](https://www.btbtt20.com/thread-index-fid-2-tid-4550191.htm)
+:::`,
+};
+
+async function handler(ctx) {
+ let category = ctx.req.param('category') ?? '';
+ let domain = ctx.req.query('domain') ?? 'btbtt15.com';
+ if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.has(new URL(`http://${domain}/`).hostname)) {
+ throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
+ }
+
+ if (category === 'base') {
+ category = '';
+ domain = '88btbtt.com';
+ } else if (category === 'govern') {
+ category = '';
+ domain = '2btjia.com';
+ }
+
+ const rootUrl = `https://www.${domain}`;
+ const currentUrl = `${rootUrl}${category ? `/${category}.htm` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ $('.bg2').prevAll('table').remove();
+
+ let items = $('#threadlist table')
+ .toArray()
+ .map((item) => {
+ const a = $(item).find('.subject_link');
+
+ return {
+ title: a.text(),
+ link: `${rootUrl}/${a.attr('href')}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ content('h2, .message').remove();
+
+ content('.attachlist')
+ .find('a')
+ .each(function () {
+ content(this)
+ .children('img')
+ .attr('src', `${rootUrl}${content(this).children('img').attr('src')}`);
+ content(this).attr(
+ 'href',
+ `${rootUrl}/${content(this)
+ .attr('href')
+ .replace(/^attach-dialog/, 'attach-download')}`
+ );
+ });
+
+ const torrents = content('.attachlist').find('a');
+
+ item.description = content('.post').html();
+ item.author = content('.purple, .grey').first().prev().text();
+ item.pubDate = timezone(parseDate(content('.bg2 b').first().text()), +8);
+
+ if (torrents.length > 0) {
+ item.description += art(path.join(__dirname, 'templates/torrents.art'), {
+ torrents: torrents.toArray().map((t) => content(t).parent().html()),
+ });
+ item.enclosure_type = 'application/x-bittorrent';
+ item.enclosure_url = torrents.first().attr('href');
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('#menu, #threadtype')
+ .find('.checked')
+ .toArray()
+ .map((c) => $(c).text())
+ .filter((c) => c !== '全部')
+ .join('|')} - BT之家`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/btzj/namespace.ts b/lib/routes/btzj/namespace.ts
new file mode 100644
index 00000000000000..f6fea9869ef79c
--- /dev/null
+++ b/lib/routes/btzj/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BT 之家',
+ url: 'btbtt20.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/btzj/templates/torrents.art b/lib/routes/btzj/templates/torrents.art
similarity index 100%
rename from lib/v2/btzj/templates/torrents.art
rename to lib/routes/btzj/templates/torrents.art
diff --git a/lib/routes/buaa/jiaowu.ts b/lib/routes/buaa/jiaowu.ts
new file mode 100644
index 00000000000000..3f7af41101fe77
--- /dev/null
+++ b/lib/routes/buaa/jiaowu.ts
@@ -0,0 +1,125 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const BASE_URL = 'https://jiaowu.buaa.edu.cn/bhjwc2.0/index/newsList.do';
+
+export const route: Route = {
+ path: '/jiaowu/:cddm?',
+ name: '教务部',
+ url: 'jiaowu.buaa.edu.cn',
+ maintainers: ['OverflowCat'],
+ handler,
+ example: '/buaa/jiaowu/02',
+ parameters: {
+ cddm: '菜单代码,可以是 2 位或者 4 位,默认为 `02`(通知公告)',
+ },
+ description: `::: tip
+
+菜单代码(\`cddm\`)应填写链接中调用的 newsList 接口的参数,可以是 2 位或者 4 位数字。若为 2 位,则为 \`fcd\`(父菜单);若为 4 位,则为 \`cddm\`(菜单代码),其中前 2 位为 \`fcd\`。
+示例:
+
+1. 新闻快讯页面的链接中 \`onclick="javascript:onNewsList('03');return false;"\`,对应的路径参数为 \`03\`,完整路由为 \`/buaa/jiaowu/03\`;
+2. 通知公告 > 公示专区页面的链接中 \`onclick="javascript:onNewsList2('0203','2');return false;"\`,对应的路径参数为 \`0203\`,完整路由为 \`/buaa/jiaowu/0203\`。
+:::`,
+ categories: ['university'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+};
+
+async function handler(ctx: Context): Promise {
+ let cddm = ctx.req.param('cddm');
+ if (!cddm) {
+ cddm = '02';
+ }
+ if (cddm.length !== 2 && cddm.length !== 4) {
+ throw new Error('cddm should be 2 or 4 digits');
+ }
+
+ const { title, list } = await getList(BASE_URL, {
+ id: '',
+ fcdTab: cddm.slice(0, 2),
+ cddmTab: cddm,
+ xsfsTab: '2',
+ tplbid: '',
+ xwid: '',
+ zydm: '',
+ zymc: '',
+ yxdm: '',
+ pyzy: '',
+ szzqdm: '',
+ });
+ const item = await getItems(list);
+
+ return {
+ title,
+ item,
+ link: BASE_URL,
+ author: '北航教务部',
+ language: 'zh-CN',
+ };
+}
+
+function getArticleUrl(onclick?: string) {
+ if (!onclick) {
+ return null;
+ }
+ const xwid = onclick.match(/'(\d+)'/)?.at(1);
+ if (!xwid) {
+ return null;
+ }
+ return `http://jiaowu.buaa.edu.cn/bhjwc2.0/index/newsView.do?xwid=${xwid}`;
+}
+
+async function getList(url: string | URL, form: Record = {}) {
+ const { body } = await got.post(url, { form });
+ const $ = load(body);
+ const title = $('#main > div.dqwz > a').last().text() || '北京航空航天大学教务部';
+ const list = $('#main div.news_list > ul > li')
+ .toArray()
+ .map((item) => {
+ const $ = load(item);
+ const link = getArticleUrl($('a').attr('onclick'));
+ if (link === null) {
+ return null;
+ }
+ return {
+ title: $('a').text(),
+ link,
+ pubDate: timezone(parseDate($('span.Floatright').text()), +8),
+ };
+ })
+ .filter((item) => item !== null);
+
+ return {
+ title,
+ list,
+ };
+}
+
+function getItems(list) {
+ return Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: descrptionResponse } = await got(item.link);
+ const $descrption = load(descrptionResponse);
+ const desc = $descrption('#main > div.content > div.search_height > div.search_con:has(p)').html();
+ item.description = desc?.replaceAll(/(\r|\n)+/g, ' ');
+ item.author = $descrption('#main > div.content > div.search_height > span.search_con').text().split('发布者:').at(-1) || '教务部';
+ return item;
+ })
+ )
+ );
+}
diff --git a/lib/routes/buaa/lib/space/newbook.ts b/lib/routes/buaa/lib/space/newbook.ts
new file mode 100644
index 00000000000000..303cb1e46fa772
--- /dev/null
+++ b/lib/routes/buaa/lib/space/newbook.ts
@@ -0,0 +1,171 @@
+import path from 'node:path';
+
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+interface Book {
+ bibId: string;
+ inBooklist: number;
+ thumb: string;
+ holdingTypes: string[];
+ author: string;
+ callno: string[];
+ docType: string;
+ onSelfDate: string;
+ groupId: string;
+ isbn: string;
+ inDate: number;
+ language: string;
+ bibNo: string;
+ abstract: string;
+ docTypeDesc: string;
+ title: string;
+ itemCount: number;
+ tags: string[];
+ circCount: number;
+ pub_year: string;
+ classno: string;
+ publisher: string;
+ holdings: string;
+}
+
+interface Holding {
+ classMethod: string;
+ callNo: string;
+ inDate: number;
+ shelfMark: string;
+ itemsCount: number;
+ barCode: string;
+ tempLocation: string;
+ circStatus: number;
+ itemId: number;
+ vol: string;
+ library: string;
+ itemStatus: string;
+ itemsAvailable: number;
+ location: string;
+ extenStatus: number;
+ donatorId: null;
+ status: string;
+ locationName: string;
+}
+
+interface Info {
+ _id: string;
+ imageUrl: string | null;
+ authorInfo: string;
+ catalog: string | null;
+ content: string;
+ title: string;
+}
+
+export const route: Route = {
+ path: String.raw`/lib/space/:path{newbook.*}`,
+ name: '图书馆 - 新书速递',
+ url: 'space.lib.buaa.edu.cn/mspace/newBook',
+ maintainers: ['OverflowCat'],
+ example: '/buaa/lib/space/newbook/',
+ handler,
+ description: `可通过参数进行筛选:\`/buaa/lib/space/newbook/key1=value1&key2=value2...\`
+- \`dcpCode\`:学科分类代码
+ - 例:
+ - 工学:\`08\`
+ - 工学 > 计算机 > 计算机科学与技术:\`080901\`
+ - 默认值:\`nolimit\`
+ - 注意事项:不可与 \`clsNo\` 同时使用。
+- \`clsNo\`:中图分类号
+ - 例:
+ - 计算机科学:\`TP3\`
+ - 默认值:无
+ - 注意事项
+ - 不可与 \`dcpCode\` 同时使用。
+ - 此模式下获取不到上架日期。
+- \`libCode\`:图书馆代码
+ - 例:
+ - 本馆:\`00000\`
+ - 默认值:无
+ - 注意事项:只有本馆一个可选值。
+- \`locaCode\`:馆藏地代码
+ - 例:
+ - 五层西-中文新书借阅室(A-Z类):\`02503\`
+ - 默认值:无
+ - 注意事项:必须与 \`libCode\` 同时使用。
+
+示例:
+- \`buaa/lib/space/newbook\` 为所有新书
+- \`buaa/lib/space/newbook/clsNo=U&libCode=00000&locaCode=60001\` 为沙河教2图书馆所有中图分类号为 U(交通运输)的书籍
+`,
+ categories: ['university'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+};
+
+async function handler(ctx: Context): Promise {
+ const path = ctx.req.param('path');
+ const i = path.indexOf('/');
+ const params = i === -1 ? '' : path.slice(i + 1);
+ const searchParams = new URLSearchParams(params);
+ const dcpCode = searchParams.get('dcpCode'); // Filter by subject (discipline code)
+ const clsNo = searchParams.get('clsNo'); // Filter by class (Chinese Library Classification)
+ if (dcpCode && clsNo) {
+ throw new Error('dcpCode and clsNo cannot be used at the same time');
+ }
+ searchParams.set('pageSize', '100'); // Max page size. Any larger value will be ignored
+ searchParams.set('page', '1');
+ !dcpCode && !clsNo && searchParams.set('dcpCode', 'nolimit'); // No classification filter
+ const url = `https://space.lib.buaa.edu.cn/meta-local/opac/new/100/${clsNo ? 'byclass' : 'bysubject'}?${searchParams.toString()}`;
+ const { data } = await got(url);
+ const list = (data?.data?.dataList || []) as Book[];
+ const item = await Promise.all(list.map(async (item: Book) => await getItem(item)));
+ const res: Data = {
+ title: '北航图书馆 - 新书速递',
+ item,
+ description: '北京航空航天大学图书馆新书速递',
+ language: 'zh-CN',
+ link: 'https://space.lib.buaa.edu.cn/space/newBook',
+ author: '北京航空航天大学图书馆',
+ allowEmpty: true,
+ image: 'https://lib.buaa.edu.cn/apple-touch-icon.png',
+ };
+ return res;
+}
+
+async function getItem(item: Book): Promise {
+ return (await cache.tryGet(item.isbn, async () => {
+ const info = await getItemInfo(item.isbn);
+ const holdings = JSON.parse(item.holdings) as Holding[];
+ const link = `https://space.lib.buaa.edu.cn/space/searchDetailLocal/${item.bibId}`;
+ const content = art(path.join(__dirname, 'templates/newbook.art'), {
+ item,
+ info,
+ holdings,
+ });
+ return {
+ language: item.language === 'eng' ? 'en' : 'zh-CN',
+ title: item.title,
+ pubDate: item.onSelfDate ? timezone(parseDate(item.onSelfDate), +8) : undefined,
+ description: content,
+ link,
+ };
+ })) as DataItem;
+}
+
+async function getItemInfo(isbn: string): Promise {
+ const url = `https://space.lib.buaa.edu.cn/meta-local/opac/third_api/douban/${isbn}/info`;
+ const response = await got(url);
+ return JSON.parse(response.body).data;
+}
diff --git a/lib/routes/buaa/lib/space/templates/newbook.art b/lib/routes/buaa/lib/space/templates/newbook.art
new file mode 100644
index 00000000000000..6068de6df656eb
--- /dev/null
+++ b/lib/routes/buaa/lib/space/templates/newbook.art
@@ -0,0 +1,44 @@
+{{if info.imageUrl}}
+
+{{/if}}
+书籍信息
+
+ {{item.callno.at(0) || '无'}} /
+ {{item.author}} /
+ {{item.publisher}} /
+ {{item.pub_year}}
+
+简介
+{{info?.content}}
+
+ ISBN {{item.isbn}}
+ 语言 {{item.language}}
+ 类型 {{item.docTypeDesc}}
+
+{{if info.authorInfo}}
+作者简介
+{{info.authorInfo}}
+{{/if}}
+馆藏信息
+{{if item.onSelfDate}}
+上架时间 :
+{{item.onSelfDate}}
+{{/if}}
+
+馆藏地点
+
+ {{each holdings holding}}
+ 所属馆藏地 {{holding.location}}
+ 索书号 {{holding.callNo}}
+ 条码号 {{holding.barCode}}
+ 编号 {{holding.itemId}}
+
+ 书刊状态
+ {{holding.status}}
+
+ {{/each}}
+
+{{if info.catalog}}
+目录
+{{@ info.catalog}}
+{{/if}}
\ No newline at end of file
diff --git a/lib/routes/buaa/namespace.ts b/lib/routes/buaa/namespace.ts
new file mode 100644
index 00000000000000..9ed95faf65b4d0
--- /dev/null
+++ b/lib/routes/buaa/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京航空航天大学',
+ url: 'news.buaa.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/buaa/news/index.ts b/lib/routes/buaa/news/index.ts
new file mode 100644
index 00000000000000..24fef9850a8e41
--- /dev/null
+++ b/lib/routes/buaa/news/index.ts
@@ -0,0 +1,72 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/news/:type',
+ categories: ['university'],
+ example: '/buaa/news/zhxw',
+ parameters: { type: '新闻版块' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '新闻网',
+ maintainers: ['AlanDecode'],
+ handler,
+ description: `| 综合新闻 | 信息公告 | 学术文化 | 校园风采 | 科教在线 | 媒体北航 | 专题新闻 | 北航人物 |
+| -------- | -------- | ----------- | -------- | -------- | -------- | -------- | -------- |
+| zhxw | xxgg_new | xsjwhhd_new | xyfc_new | kjzx_new | mtbh_new | ztxw | bhrw |`,
+};
+
+async function handler(ctx: Context): Promise {
+ const baseUrl = 'https://news.buaa.edu.cn';
+ const type = ctx.req.param('type');
+
+ const { data: response, url: link } = await got(`${baseUrl}/${type}.htm`);
+
+ const $ = load(response);
+ const title = $('.subnav span').text().trim();
+ const list: DataItem[] = $('.mainleft > .listlefttop > .listleftop1')
+ .toArray()
+ .map((item_) => {
+ const item = $(item_);
+ const title = item.find('h2 > a');
+ return {
+ title: title.text(),
+ link: new URL(title.attr('href')!, baseUrl).href,
+ pubDate: timezone(parseDate(item.find('h2 em').text(), '[YYYY-MM-DD]'), +8),
+ };
+ });
+
+ const result = (await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link!, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+
+ item.description = $('.v_news_content').html() || '';
+ item.author = $('.vsbcontent_end').text().trim();
+
+ return item;
+ })
+ )
+ )) as DataItem[];
+
+ return {
+ title: `北航新闻 - ${title}`,
+ link,
+ description: `北京航空航天大学新闻网 - ${title}`,
+ language: 'zh-CN',
+ item: result,
+ };
+}
diff --git a/lib/routes/buaa/sme.ts b/lib/routes/buaa/sme.ts
new file mode 100755
index 00000000000000..2e370a20ed3805
--- /dev/null
+++ b/lib/routes/buaa/sme.ts
@@ -0,0 +1,102 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const BASE_URL = 'http://www.sme.buaa.edu.cn';
+
+export const route: Route = {
+ path: '/sme/:path{.+}?',
+ name: '集成电路科学与工程学院',
+ url: 'www.sme.buaa.edu.cn',
+ maintainers: ['MeanZhang'],
+ handler,
+ example: '/buaa/sme/tzgg',
+ parameters: {
+ path: '版块路径,默认为 `tzgg`(通知公告)',
+ },
+ description: `::: tip
+
+版块路径(\`path\`)应填写板块 URL 中 \`http://www.sme.buaa.edu.cn/\` 和 \`.htm\` 之间的字段。
+
+示例:
+
+1. [通知公告](http://www.sme.buaa.edu.cn/tzgg.htm) 页面的 URL 为 \`http://www.sme.buaa.edu.cn/tzgg.htm\`,对应的路径参数为 \`tzgg\`,完整路由为 \`/buaa/sme/tzgg\`;
+2. [就业信息](http://www.sme.buaa.edu.cn/zsjy/jyxx.htm) 页面的 URL 为 \`http://www.sme.buaa.edu.cn/zsjy/jyxx.htm\`,对应的路径参数为 \`zsjy/jyxx\`,完整路由为 \`/buaa/sme/zsjy/jyxx\`。
+
+:::
+
+::: warning
+
+部分页面(如[学院介绍](http://www.sme.buaa.edu.cn/xygk/xyjs.htm)、[微纳中心](http://www.sme.buaa.edu.cn/wnzx.htm)、[院学生会](http://www.sme.buaa.edu.cn/xsgz/yxsh.htm))存在无内容、内容跳转至外站等情况,因此可能出现解析失败的现象。
+
+:::`,
+ categories: ['university'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+};
+
+async function handler(ctx) {
+ const { path = 'tzgg' } = ctx.req.param();
+ const url = `${BASE_URL}/${path}.htm`;
+ const { title, list } = await getList(url);
+ return {
+ // 源标题
+ title,
+ // 源链接
+ link: url,
+ // 源文章
+ item: await getItems(list),
+ // 语言
+ language: 'zh-CN',
+ };
+}
+
+async function getList(url) {
+ const { data } = await got(url);
+ const $ = load(data);
+ const title = $('.nytit .fr a')
+ .toArray()
+ .slice(1)
+ .map((item) => $(item).text().trim())
+ .join(' - ');
+ const list = $("div[class='Newslist'] > ul > li")
+ .toArray()
+ .map((item_) => {
+ const item = $(item_);
+ const $a = item.find('a');
+ const link = $a.attr('href');
+ return {
+ title: item.find('a').text(),
+ link: link?.startsWith('http') ? link : `${BASE_URL}/${link}`, // 有些链接是相对路径
+ pubDate: timezone(parseDate(item.find('span').text()), +8),
+ };
+ });
+ return {
+ title,
+ list,
+ };
+}
+
+function getItems(list) {
+ return Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: descrptionResponse } = await got(item.link);
+ const $descrption = load(descrptionResponse);
+ item.description = $descrption('div[class="v_news_content"]').html();
+ return item;
+ })
+ )
+ );
+}
diff --git a/lib/routes/buct/cist.ts b/lib/routes/buct/cist.ts
new file mode 100644
index 00000000000000..64b358b944fbd5
--- /dev/null
+++ b/lib/routes/buct/cist.ts
@@ -0,0 +1,59 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/cist',
+ categories: ['university'],
+ example: '/buct/cist',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [{ source: ['cist.buct.edu.cn/xygg/list.htm', 'cist.buct.edu.cn/xygg/main.htm'], target: '/cist' }],
+ name: '信息学院',
+ maintainers: ['Epic-Creeper'],
+ handler,
+ url: 'buct.edu.cn/',
+};
+
+async function handler() {
+ const rootUrl = 'https://cist.buct.edu.cn';
+ const currentUrl = `${rootUrl}/xygg/list.htm`;
+
+ const response = await got.get(currentUrl);
+ const $ = load(response.data);
+ const list = $('ul.wp_article_list > li.list_item')
+ .toArray()
+ .map((item) => ({
+ pubDate: $(item).find('.Article_PublishDate').text(),
+ title: $(item).find('a').attr('title'),
+ link: `${rootUrl}${$(item).find('a').attr('href')}`,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got.get(item.link);
+ const content = load(detailResponse.data);
+
+ item.description = content('.wp_articlecontent').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/buct/gr.ts b/lib/routes/buct/gr.ts
new file mode 100644
index 00000000000000..eeabde4d22f81e
--- /dev/null
+++ b/lib/routes/buct/gr.ts
@@ -0,0 +1,94 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/gr/:type',
+ categories: ['university'],
+ example: '/buct/gr/jzml',
+ parameters: {
+ type: {
+ description: '信息类型,可选值:tzgg(通知公告),jzml(简章目录),xgzc(相关政策)',
+ options: [
+ { value: 'tzgg', label: '通知公告' },
+ { value: 'jzml', label: '简章目录' },
+ { value: 'xgzc', label: '相关政策' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ { source: ['graduate.buct.edu.cn/1392/list.htm'], target: '/gr/tzgg' },
+ { source: ['graduate.buct.edu.cn/jzml/list.htm'], target: '/gr/jzml' },
+ { source: ['graduate.buct.edu.cn/1393/list.htm'], target: '/gr/xgzc' },
+ ],
+ name: '研究生院',
+ maintainers: ['Epic-Creeper'],
+ handler,
+ url: 'buct.edu.cn/',
+};
+
+async function handler(ctx: Context) {
+ const type = ctx.req.param('type');
+ const rootUrl = 'https://graduate.buct.edu.cn';
+ let currentUrl;
+
+ switch (type) {
+ case 'tzgg':
+ currentUrl = `${rootUrl}/1392/list.htm`;
+
+ break;
+
+ case 'jzml':
+ currentUrl = `${rootUrl}/jzml/list.htm`;
+
+ break;
+
+ case 'xgzc':
+ currentUrl = `${rootUrl}/1393/list.htm`;
+
+ break;
+
+ default:
+ throw new Error('Invalid type parameter');
+ }
+
+ const response = await got.get(currentUrl);
+
+ const $ = load(response.data);
+ const list = $('ul.wp_article_list > li.list_item')
+ .toArray()
+ .map((item) => ({
+ pubDate: $(item).find('.Article_PublishDate').text(),
+ title: $(item).find('a').attr('title'),
+ link: `${rootUrl}${$(item).find('a').attr('href')}`,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got.get(item.link);
+ const content = load(detailResponse.data);
+ item.description = content('.wp_articlecontent').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/buct/jwc.ts b/lib/routes/buct/jwc.ts
new file mode 100644
index 00000000000000..35c4b7dcc50afb
--- /dev/null
+++ b/lib/routes/buct/jwc.ts
@@ -0,0 +1,65 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/jwc',
+ categories: ['university'],
+ example: '/buct/jwc',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [{ source: ['jiaowuchu.buct.edu.cn/610/list.htm', 'jiaowuchu.buct.edu.cn/611/main.htm'], target: '/jwc' }],
+ name: '教务处',
+ maintainers: ['Epic-Creeper'],
+ handler,
+ url: 'buct.edu.cn/',
+};
+
+async function handler() {
+ const rootUrl = 'https://jiaowuchu.buct.edu.cn';
+ const currentUrl = `${rootUrl}/610/list.htm`;
+
+ const response = await got.get(currentUrl);
+
+ const $ = load(response.data);
+ const list = $('div.list02 ul > li')
+ .not('#wp_paging_w66 li')
+ .toArray()
+ .map((item) => ({
+ pubDate: $(item).find('span').text(),
+ title: $(item).find('a').attr('title'),
+ link: `${rootUrl}${$(item).find('a').attr('href')}`,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got.get(item.link);
+ const content = load(detailResponse.data);
+ const iframeSrc = content('.wp_pdf_player').attr('pdfsrc');
+ if (iframeSrc) {
+ const pdfUrl = `${rootUrl}${iframeSrc}`;
+ item.description = `此页面为PDF文档:点击查看pdf `;
+ return item;
+ }
+ item.description = content('.rt_zhengwen').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/buct/namespace.ts b/lib/routes/buct/namespace.ts
new file mode 100644
index 00000000000000..40e9971dc45338
--- /dev/null
+++ b/lib/routes/buct/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京化工大学',
+ url: 'buct.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bugzilla/bug.ts b/lib/routes/bugzilla/bug.ts
new file mode 100644
index 00000000000000..da5530d1c49ab3
--- /dev/null
+++ b/lib/routes/bugzilla/bug.ts
@@ -0,0 +1,60 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const INSTANCES = new Map([
+ ['apache', 'bz.apache.org/bugzilla'],
+ ['apache.ooo', 'bz.apache.org/ooo'], // Apache OpenOffice
+ ['apache.SpamAssassin', 'bz.apache.org/SpamAssassin'],
+ ['kernel', 'bugzilla.kernel.org'],
+ ['mozilla', 'bugzilla.mozilla.org'],
+ ['webkit', 'bugs.webkit.org'],
+]);
+
+async function handler(ctx: Context): Promise {
+ const { site, bugId } = ctx.req.param();
+ if (!INSTANCES.has(site)) {
+ throw new InvalidParameterError(`unknown site: ${site}`);
+ }
+ const link = `https://${INSTANCES.get(site)}/show_bug.cgi?id=${bugId}`;
+ const $ = load(await ofetch(`${link}&ctype=xml`));
+ const items = $('long_desc').map((index, rawItem) => {
+ const $ = load(rawItem, null, false);
+ return {
+ title: `comment #${$('commentid').text()}`,
+ link: `${link}#c${index}`,
+ description: $('thetext').text(),
+ pubDate: parseDate($('bug_when').text()),
+ author: $('who').attr('name'),
+ } as DataItem;
+ });
+ return { title: $('short_desc').text(), link, item: items.toArray() };
+}
+
+function markdownFrom(instances: Map, separator: string = ', '): string {
+ return [...instances.entries()].map(([k, v]) => `[\`${k}\`](https://${v})`).join(separator);
+}
+
+export const route: Route = {
+ path: '/bug/:site/:bugId',
+ name: 'bugs',
+ maintainers: ['FranklinYu'],
+ handler,
+ example: '/bugzilla/bug/webkit/251528',
+ parameters: {
+ site: 'site identifier',
+ bugId: 'numeric identifier of the bug in the site',
+ },
+ description: `Supported site identifiers: ${markdownFrom(INSTANCES)}.`,
+ categories: ['programming'],
+
+ // Radar is infeasible, because it needs access to URL parameters.
+ zh: {
+ name: 'bugs',
+ description: `支持的站点标识符:${markdownFrom(INSTANCES, '、')}。`,
+ },
+};
diff --git a/lib/routes/bugzilla/namespace.ts b/lib/routes/bugzilla/namespace.ts
new file mode 100644
index 00000000000000..012d6382f7e9be
--- /dev/null
+++ b/lib/routes/bugzilla/namespace.ts
@@ -0,0 +1,12 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bugzilla',
+ url: 'bugzilla.org',
+ description: 'Bugzilla instances hosted by organizations.',
+ zh: {
+ name: 'Bugzilla',
+ description: '各组织自建的Bugzilla实例。',
+ },
+ lang: 'en',
+};
diff --git a/lib/routes/bulianglin/namespace.ts b/lib/routes/bulianglin/namespace.ts
new file mode 100644
index 00000000000000..f269e43ca22a99
--- /dev/null
+++ b/lib/routes/bulianglin/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '不良林',
+ url: 'bulianglin.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bulianglin/rss.ts b/lib/routes/bulianglin/rss.ts
new file mode 100644
index 00000000000000..4dd3edc1a1e99b
--- /dev/null
+++ b/lib/routes/bulianglin/rss.ts
@@ -0,0 +1,49 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/',
+ categories: ['blog'],
+ example: '/bulianglin',
+ radar: [
+ {
+ source: ['bulianglin.com/'],
+ },
+ ],
+ name: '全部文章',
+ maintainers: ['cnkmmk'],
+ handler,
+ url: 'bulianglin.com/',
+};
+
+async function handler() {
+ const url = 'https://bulianglin.com/';
+ const response = await got({ method: 'get', url });
+ const $ = load(response.data);
+
+ const list = $('div.single-post')
+ .toArray()
+ .map((e) => {
+ const element = $(e);
+ const title = element.find('h2 > a').text();
+ const link = element.find('h2 > a').attr('href');
+ const description = element.find('p.summary').text();
+ const dateraw = element.find('div.text-muted').find('li').eq(1).text();
+
+ return {
+ title,
+ description,
+ link,
+ pubDate: parseDate(dateraw, 'YYYY 年 MM 月 DD 日'),
+ };
+ });
+
+ return {
+ title: '不良林',
+ link: url,
+ item: list,
+ };
+}
diff --git a/lib/routes/bullionvault/gold-news.ts b/lib/routes/bullionvault/gold-news.ts
new file mode 100644
index 00000000000000..f3dc7e9f8d4bc8
--- /dev/null
+++ b/lib/routes/bullionvault/gold-news.ts
@@ -0,0 +1,233 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'https://bullionvault.com';
+ const targetUrl: string = new URL(`gold-news${category ? `/${category}` : ''}`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+
+ let items: DataItem[] = [];
+
+ items = $('section#block-bootstrap-views-block-latest-articles-block div.media, div.gold-news-content table tr')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('td.views-field-title a, div.views-field-title a').first();
+
+ const title: string = $aEl.text();
+ const pubDateStr: string | undefined = $el.find('td.views-field-created, div.views-field-created').text().trim();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const authorEls: Element[] = $el.find('a.username').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $authorEl: Cheerio = $(authorEl);
+
+ return {
+ name: $authorEl.text(),
+ url: $authorEl.attr('href'),
+ avatar: undefined,
+ };
+ });
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ author: authors,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('article.article h1').text();
+ const description: string | undefined = $$('div.content').html() ?? '';
+ const pubDateStr: string | undefined = $$('div.submitted').text().split(/,/).pop();
+ const categories: string[] = $$('meta[name="news_keywords"]').attr('content')?.split(/,/) ?? [];
+ const authorEls: Element[] = $$('div.view-author-bio').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl);
+
+ return {
+ name: $$authorEl.find('h1').text(),
+ url: undefined,
+ avatar: $$authorEl.find('img').attr('src'),
+ };
+ });
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: $('meta[property="og:title"]').attr('content')?.split(/\|/).pop(),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/gold-news/:category?',
+ name: 'Gold News',
+ url: 'bullionvault.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/bullionvault/gold-news',
+ parameters: {
+ category: {
+ description: 'Category',
+ options: [
+ {
+ label: 'Gold market analysis & gold investment research',
+ value: '',
+ },
+ {
+ label: 'Opinion & Analysis',
+ value: 'opinion-analysis',
+ },
+ {
+ label: 'Gold Price News',
+ value: 'gold-price-news',
+ },
+ {
+ label: 'Investment News',
+ value: 'news',
+ },
+ {
+ label: 'Gold Investor Index',
+ value: 'gold-investor-index',
+ },
+ {
+ label: 'Gold Infographics',
+ value: 'infographics',
+ },
+ {
+ label: 'Market Fundamentals',
+ value: 'market-fundamentals',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+If you subscribe to [Gold Price News](https://www.bullionvault.com/gold-news/gold-price-news),where the URL is \`https://www.bullionvault.com/gold-news/gold-price-news\`, extract the part \`https://www.bullionvault.com/gold-news/\` to the end, and use it as the parameter to fill in. Therefore, the route will be [\`/bullionvault/gold-news/gold-price-news\`](https://rsshub.app/bullionvault/gold-news/gold-price-news).
+:::
+
+| Category | ID |
+| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
+| [Opinion & Analysis](https://www.bullionvault.com/gold-news/opinion-analysis) | [opinion-analysis](https://rsshub.app/bullionvault/gold-news/opinion-analysis) |
+| [Gold Price News](https://www.bullionvault.com/gold-news/gold-price-news) | [gold-price-news](https://rsshub.app/bullionvault/gold-news/gold-price-news) |
+| [Investment News](https://www.bullionvault.com/gold-news/news) | [news](https://rsshub.app/bullionvault/gold-news/news) |
+| [Gold Investor Index](https://www.bullionvault.com/gold-news/gold-investor-index) | [gold-investor-index](https://rsshub.app/bullionvault/gold-news/gold-investor-index) |
+| [Gold Infographics](https://www.bullionvault.com/gold-news/infographics) | [infographics](https://rsshub.app/bullionvault/gold-news/infographics) |
+| [Market Fundamentals](https://www.bullionvault.com/gold-news/market-fundamentals) | [market-fundamentals](https://rsshub.app/bullionvault/gold-news/market-fundamentals) |
+`,
+ categories: ['finance'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bullionvault.com/gold-news/:category'],
+ target: (params) => {
+ const category: string = params.category;
+
+ return `/bullionvault/gold-news${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: 'Gold market analysis & gold investment research',
+ source: ['bullionvault.com/gold-news'],
+ target: '/gold-news',
+ },
+ {
+ title: 'Opinion & Analysis',
+ source: ['bullionvault.com/gold-news/opinion-analysis'],
+ target: '/gold-news/opinion-analysis',
+ },
+ {
+ title: 'Gold Price News',
+ source: ['bullionvault.com/gold-news/gold-price-news'],
+ target: '/gold-news/gold-price-news',
+ },
+ {
+ title: 'Investment News',
+ source: ['bullionvault.com/gold-news/news'],
+ target: '/gold-news/news',
+ },
+ {
+ title: 'Gold Investor Index',
+ source: ['bullionvault.com/gold-news/gold-investor-index'],
+ target: '/gold-news/gold-investor-index',
+ },
+ {
+ title: 'Gold Infographics',
+ source: ['bullionvault.com/gold-news/infographics'],
+ target: '/gold-news/infographics',
+ },
+ {
+ title: 'Market Fundamentals',
+ source: ['bullionvault.com/gold-news/market-fundamentals'],
+ target: '/gold-news/market-fundamentals',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/bullionvault/namespace.ts b/lib/routes/bullionvault/namespace.ts
new file mode 100644
index 00000000000000..2dd9d86b52b033
--- /dev/null
+++ b/lib/routes/bullionvault/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BullionVault',
+ url: 'bullionvault.com',
+ categories: ['finance'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/bupt/jwc.ts b/lib/routes/bupt/jwc.ts
new file mode 100644
index 00000000000000..f1c9507ebb5ec0
--- /dev/null
+++ b/lib/routes/bupt/jwc.ts
@@ -0,0 +1,131 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/jwc/:type',
+ categories: ['university'],
+ example: '/bupt/jwc/tzgg',
+ parameters: {
+ type: {
+ type: 'string',
+ optional: false,
+ description: '信息类型,可选值:tzgg(通知公告),xwzx(新闻资讯)',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['jwc.bupt.edu.cn/tzgg1.htm'],
+ target: '/jwc/tzgg',
+ },
+ {
+ source: ['jwc.bupt.edu.cn/xwzx2.htm'],
+ target: '/jwc/xwzx',
+ },
+ ],
+ name: '教务处',
+ maintainers: ['Yoruet'],
+ handler,
+ url: 'jwc.bupt.edu.cn',
+};
+
+async function handler(ctx: Context) {
+ let type = ctx.req.param('type'); // 默认类型为通知公告
+ if (!type) {
+ type = 'tzgg';
+ }
+ const rootUrl = 'https://jwc.bupt.edu.cn';
+ let currentUrl;
+ let pageTitle;
+
+ if (type === 'tzgg') {
+ currentUrl = `${rootUrl}/tzgg1.htm`;
+ pageTitle = '通知公告';
+ } else if (type === 'xwzx') {
+ currentUrl = `${rootUrl}/xwzx2.htm`;
+ pageTitle = '新闻资讯';
+ } else {
+ throw new Error('Invalid type parameter');
+ }
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const list = $('.txt-elise')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const $link = $item.find('a');
+ // Skip elements without links or with empty href
+ if ($link.length === 0 || !$link.attr('href')) {
+ return null;
+ }
+ return {
+ title: $link.text().trim(),
+ link: rootUrl + '/' + $link.attr('href'),
+ };
+ })
+ .filter(Boolean);
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ // 选择包含新闻内容的元素
+ const newsContent = content('.v_news_content');
+
+ // 移除不必要的标签,比如 和 中无用的内容
+ newsContent.find('p, span, strong').each(function () {
+ const element = content(this);
+ const text = element.text().trim();
+
+ // 删除没有有用文本的元素,防止空元素被保留
+ if (text === '') {
+ element.remove();
+ } else {
+ // 去除多余的嵌套标签,但保留其内容
+ element.replaceWith(text);
+ }
+ });
+
+ // 清理后的内容转换为文本
+ const cleanedDescription = newsContent.text().trim();
+
+ // 提取并格式化发布时间
+ item.description = cleanedDescription;
+ item.pubDate = timezone(parseDate(content('.info').text().replace('发布时间:', '').trim()), +8);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `北京邮电大学教务处 - ${pageTitle}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bupt/namespace.ts b/lib/routes/bupt/namespace.ts
new file mode 100644
index 00000000000000..6a0dca8e8498db
--- /dev/null
+++ b/lib/routes/bupt/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京邮电大学',
+ url: 'bupt.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bupt/rczp.ts b/lib/routes/bupt/rczp.ts
new file mode 100644
index 00000000000000..acf1d795c263b1
--- /dev/null
+++ b/lib/routes/bupt/rczp.ts
@@ -0,0 +1,78 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/rczp',
+ categories: ['university'],
+ example: '/bupt/rczp',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bupt.edu.cn/'],
+ },
+ ],
+ name: '人才招聘',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'bupt.edu.cn/',
+};
+
+async function handler() {
+ const rootUrl = 'https://www.bupt.edu.cn';
+ const currentUrl = `${rootUrl}/rczp.htm`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const list = $('.date-block')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.next().text(),
+ link: `${rootUrl}/${item.next().attr('href')}`,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.description = content('.v_news_content').html();
+ item.pubDate = timezone(parseDate(content('.info span').first().text().replace('发布时间 : ', '')), +8);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bupt/scss.ts b/lib/routes/bupt/scss.ts
new file mode 100644
index 00000000000000..3a87e867e90951
--- /dev/null
+++ b/lib/routes/bupt/scss.ts
@@ -0,0 +1,99 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/scss/tzgg',
+ categories: ['university'],
+ example: '/bupt/scss/tzgg',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['scss.bupt.edu.cn/index/tzgg1.htm'],
+ target: '/scss/tzgg',
+ },
+ ],
+ name: '网络空间安全学院 - 通知公告',
+ maintainers: ['ziri2004'],
+ handler,
+ url: 'scss.bupt.edu.cn',
+};
+
+async function handler() {
+ const rootUrl = 'https://scss.bupt.edu.cn';
+ const currentUrl = `${rootUrl}/index/tzgg1.htm`;
+ const pageTitle = '通知公告';
+ const selector = '.Newslist li';
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+ const list = $(selector)
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const $link = $item.find('a');
+ if ($link.length === 0 || !$link.attr('href')) {
+ return null;
+ }
+
+ const link = new URL($link.attr('href'), rootUrl).href;
+ const rawDate = $item.find('span').text().replace('发布时间:', '').trim();
+
+ return {
+ title: $link.text().trim(),
+ link,
+ pubDateRaw: rawDate,
+ };
+ })
+ .filter(Boolean);
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const content = load(detailResponse.data);
+ const newsContent = content('.v_news_content');
+
+ newsContent.find('p, span, strong').each(function () {
+ const element = content(this);
+ const text = element.text().trim();
+ if (text === '') {
+ element.remove();
+ } else {
+ element.replaceWith(text);
+ }
+ });
+
+ item.description = newsContent.text();
+ item.pubDate = timezone(parseDate(item.pubDateRaw), +8);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `北京邮电大学网络空间安全学院 - ${pageTitle}`,
+ link: currentUrl,
+ item: items as Data['item'],
+ };
+}
diff --git a/lib/routes/bwsg/index.ts b/lib/routes/bwsg/index.ts
new file mode 100644
index 00000000000000..83e0fe54601a00
--- /dev/null
+++ b/lib/routes/bwsg/index.ts
@@ -0,0 +1,70 @@
+import { load } from 'cheerio';
+
+import type { Data, DataItem, Route } from '@/types';
+import { getSubPath } from '@/utils/common-utils';
+import ofetch from '@/utils/ofetch';
+
+const FEED_TITLE = 'Immobilien - BWSG' as const;
+const FEED_LANGUAGE = 'de' as const;
+const FEED_LOGO = 'https://www.bwsg.at/wp-content/uploads/2024/06/favicon-bwsg.png';
+const SITE_URL = 'https://www.bwsg.at' as const;
+const BASE_URL = `${SITE_URL}/immobilien/immobilie-suchen/`;
+
+export const route: Route = {
+ name: 'Angebote',
+ example: '/bwsg/_vermarktungsart=miete&_objektart=wohnung&_zimmer=2,3&_wohnflaeche=45,70&_plz=1210,1220',
+ path: '*',
+ maintainers: ['sk22'],
+ categories: ['other'],
+ description: `
+Copy the query parameters for your https://www.bwsg.at/immobilien/immobilie-suchen
+search, omitting the leading \`?\`
+
+::: tip
+Since there's no parameter available that sorts by "last added" (and there's no
+obvious pattern to the default ordering), and since this RSS feed only fetches
+the first page of results, you probably want to specify enough search
+parameters to make sure you only get one page of results – because else, your
+RSS feed might not get all items.
+:::`,
+
+ async handler(ctx) {
+ let params = getSubPath(ctx).slice(1);
+ if (params.startsWith('&')) {
+ params = params.slice(1);
+ }
+
+ const link = `${BASE_URL}?${params}`;
+ const response = await ofetch(link);
+ const $ = load(response);
+
+ const items = $('[data-objektnummer] > a')
+ .toArray()
+ .map((el) => {
+ const $el = $(el);
+ const link = el.attribs.href;
+ const image = $el.find('.res_immobiliensuche__immobilien__item__thumb > img').attr('src');
+ const title = $el.find('.res_immobiliensuche__immobilien__item__content__title').text().trim();
+ const location = $el.find('.res_immobiliensuche__immobilien__item__content__meta__location').text().trim();
+ const price = $el.find('.res_immobiliensuche__immobilien__item__content__meta__preis').text().trim();
+ const metadata = $el.find('.res_immobiliensuche__immobilien__item__content__meta__row_1').text().trim();
+
+ return {
+ title: `${location}, ${title}`,
+ description: (price ? `${price} | ` : '') + metadata,
+ link,
+ image,
+ // no pubDate :(
+ } satisfies DataItem;
+ });
+
+ return {
+ title: FEED_TITLE,
+ language: FEED_LANGUAGE,
+ logo: FEED_LOGO,
+ allowEmpty: true,
+ item: items,
+ link,
+ } satisfies Data;
+ },
+};
diff --git a/lib/routes/bwsg/namespace.ts b/lib/routes/bwsg/namespace.ts
new file mode 100644
index 00000000000000..6482c7d72b8bfd
--- /dev/null
+++ b/lib/routes/bwsg/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BWSG',
+ url: 'bwsg.at',
+ description: 'BWS Gemeinnützige allgemeine Bau-, Wohn- und Siedlungsgenossenschaft, registrierte Genossenschaft mit beschränkter Haftung',
+};
diff --git a/lib/routes/byau/namespace.ts b/lib/routes/byau/namespace.ts
new file mode 100644
index 00000000000000..e256a5556e3bee
--- /dev/null
+++ b/lib/routes/byau/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '黑龙江八一农垦大学',
+ url: 'byau.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/byau/xinwen/index.ts b/lib/routes/byau/xinwen/index.ts
new file mode 100644
index 00000000000000..aa1892012b5071
--- /dev/null
+++ b/lib/routes/byau/xinwen/index.ts
@@ -0,0 +1,73 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/news/:type_id',
+ categories: ['university'],
+ example: '/byau/news/3674',
+ parameters: { type_id: '栏目类型(从菜单栏获取对应 ID)' },
+ radar: [
+ {
+ source: ['xinwen.byau.edu.cn/:type_id/list.htm'],
+ target: '/news/:type_id',
+ },
+ ],
+ name: '新闻网',
+ maintainers: ['ueiu'],
+ handler,
+ url: 'xinwen.byau.edu.cn',
+ description: `| 学校要闻 | 校园动态 |
+| ---- | ----------- |
+| 3674 | 3676 |`,
+};
+
+async function handler(ctx) {
+ const baseUrl = 'http://xinwen.byau.edu.cn/';
+
+ const typeID = ctx.req.param('type_id');
+ const url = `${baseUrl}${typeID}/list.htm`;
+
+ const response = await got(url);
+ const $ = load(response.data);
+
+ const list = $('.news')
+ .toArray()
+ .map((item) => {
+ const $$ = load(item);
+
+ const originalItemUrl = $$('a').attr('href');
+ // 因为学校要闻的头两个像是固定了跳转专栏页面的,不能相同处理
+ const startsWithHttp = originalItemUrl.startsWith('http');
+ const itemUrl = startsWithHttp ? originalItemUrl : new URL(originalItemUrl, baseUrl).href;
+
+ return {
+ title: $$('a').text(),
+ link: itemUrl,
+ pubDate: timezone(parseDate($$('.news_meta').text()), +8),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+
+ item.description = $('.col_news_con').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/byteclicks/index.ts b/lib/routes/byteclicks/index.ts
new file mode 100644
index 00000000000000..67865b163b7c85
--- /dev/null
+++ b/lib/routes/byteclicks/index.ts
@@ -0,0 +1,39 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import { parseItem } from './utils';
+
+const baseUrl = 'https://byteclicks.com';
+
+export const route: Route = {
+ path: '/',
+ radar: [
+ {
+ source: ['byteclicks.com/'],
+ target: '',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'byteclicks.com/',
+};
+
+async function handler(ctx) {
+ const { data } = await got(`${baseUrl}/wp-json/wp/v2/posts`, {
+ searchParams: {
+ per_page: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 100,
+ },
+ });
+
+ const items = parseItem(data);
+
+ return {
+ title: '字节点击 - 聚合全球优质资源,跟踪世界前沿科技',
+ description:
+ 'byteclicks.com 最专业的前沿科技网站。聚合全球优质资源,跟踪世界前沿科技,精选推荐一些很棒的互联网好资源好工具好产品。寻找有前景好项目、找论文、找报告、找数据、找课程、找电子书上byteclicks!byteclicks.com是投资人、科研学者、学生每天必看的网站。',
+ image: 'https://byteclicks.com/wp-content/themes/RK-Blogger/images/wbolt.ico',
+ link: baseUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/byteclicks/namespace.ts b/lib/routes/byteclicks/namespace.ts
new file mode 100644
index 00000000000000..159f94cf99d87a
--- /dev/null
+++ b/lib/routes/byteclicks/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '字节点击',
+ url: 'byteclicks.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/byteclicks/tag.ts b/lib/routes/byteclicks/tag.ts
new file mode 100644
index 00000000000000..8a15daa89ed77e
--- /dev/null
+++ b/lib/routes/byteclicks/tag.ts
@@ -0,0 +1,57 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import { parseItem } from './utils';
+
+const baseUrl = 'https://byteclicks.com';
+
+export const route: Route = {
+ path: '/tag/:tag',
+ categories: ['new-media'],
+ example: '/byteclicks/tag/人工智能',
+ parameters: { tag: '标签,可在URL中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['byteclicks.com/tag/:tag'],
+ },
+ ],
+ name: '标签',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'byteclicks.com/',
+};
+
+async function handler(ctx) {
+ const tag = ctx.req.param('tag');
+ const { data: search } = await got(`${baseUrl}/wp-json/wp/v2/tags`, {
+ searchParams: {
+ search: tag,
+ per_page: 100,
+ },
+ });
+ const tagData = search.find((item) => item.name === tag);
+
+ const { data } = await got(`${baseUrl}/wp-json/wp/v2/posts`, {
+ searchParams: {
+ per_page: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 100,
+ tags: tagData.id,
+ },
+ });
+
+ const items = parseItem(data);
+
+ return {
+ title: `${tagData.name} - 字节点击`,
+ image: 'https://byteclicks.com/wp-content/themes/RK-Blogger/images/wbolt.ico',
+ link: tagData.link,
+ item: items,
+ };
+}
diff --git a/lib/routes/byteclicks/utils.ts b/lib/routes/byteclicks/utils.ts
new file mode 100644
index 00000000000000..d8073f39eb011a
--- /dev/null
+++ b/lib/routes/byteclicks/utils.ts
@@ -0,0 +1,11 @@
+import { parseDate } from '@/utils/parse-date';
+
+const parseItem = (data) =>
+ data.map((item) => ({
+ title: item.title.rendered,
+ description: item.content.rendered,
+ pubDate: parseDate(item.date_gmt),
+ link: item.link,
+ }));
+
+export { parseItem };
diff --git a/lib/routes/bytes/bytes.ts b/lib/routes/bytes/bytes.ts
new file mode 100644
index 00000000000000..906306c1106a79
--- /dev/null
+++ b/lib/routes/bytes/bytes.ts
@@ -0,0 +1,42 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const currentURL = 'https://bytes.dev/archives';
+
+export const route: Route = {
+ path: '/',
+ radar: [
+ {
+ source: ['bytes.dev/archives', 'bytes.dev/'],
+ target: '',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['meixger'],
+ handler,
+ url: 'bytes.dev/archives',
+};
+
+async function handler() {
+ const resp = await got(currentURL);
+ const $ = load(resp.data);
+ const text = $('script#__NEXT_DATA__').text();
+ const json = JSON.parse(text);
+ const posts = [json.props.pageProps.featuredPost, ...json.props.pageProps.posts];
+ const items = posts.map((item) => ({
+ title: `Issue ${item.slug}`,
+ pubDate: parseDate(item.date),
+ description: item.title,
+ link: `/archives/${item.slug}`,
+ }));
+
+ return {
+ title: 'bytes.dev',
+ description: 'Your weekly dose of JS',
+ link: currentURL,
+ item: items,
+ };
+}
diff --git a/lib/routes/bytes/namespace.ts b/lib/routes/bytes/namespace.ts
new file mode 100644
index 00000000000000..7b040e171a07eb
--- /dev/null
+++ b/lib/routes/bytes/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ui.dev',
+ url: 'bytes.dev',
+ lang: 'en',
+};
diff --git a/lib/routes/c114/namespace.ts b/lib/routes/c114/namespace.ts
new file mode 100644
index 00000000000000..dd5c3be2afd3ae
--- /dev/null
+++ b/lib/routes/c114/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'C114 通信网',
+ url: 'c114.com.cn',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/c114/roll.ts b/lib/routes/c114/roll.ts
new file mode 100644
index 00000000000000..12629867a83ac6
--- /dev/null
+++ b/lib/routes/c114/roll.ts
@@ -0,0 +1,112 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx) => {
+ const { original = 'false' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 15;
+
+ const rootUrl = 'https://www.c114.com.cn';
+ const currentUrl = new URL(`news/roll.asp${original === 'true' ? `?o=true` : ''}`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(iconv.decode(response, 'gbk'));
+
+ const language = $('html').prop('lang');
+
+ let items = $('div.new_list_c')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.find('h6 a').text(),
+ pubDate: timezone(parseDate(item.find('div.new_list_time').text(), ['HH:mm', 'M/D']), +8),
+ link: new URL(item.find('h6 a').prop('href'), rootUrl).href,
+ author: item.find('div.new_list_author').text().trim(),
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link, {
+ responseType: 'buffer',
+ });
+
+ const $$ = load(iconv.decode(detailResponse, 'gbk'));
+
+ const title = $$('h1').text();
+ const description = $$('div.text').html();
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = timezone(parseDate($$('div.r_time').text(), 'YYYY/M/D HH:mm'), +8);
+ item.author = $$('div.author').first().text().trim();
+ item.content = {
+ html: description,
+ text: $$('.text').text(),
+ };
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const image = new URL($('div.top2-1 a img').prop('src'), rootUrl).href;
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('p.top1-1-1 a').first().text(),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/roll/:original?',
+ name: '滚动资讯',
+ url: 'c114.com.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/c114/roll',
+ parameters: { original: '只看原创,可选 true 和 false,默认为 false' },
+ description: '',
+ categories: ['new-media'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['c114.com.cn/news/roll.asp'],
+ target: (_, url) => {
+ url = new URL(url);
+ const original = url.searchParams.get('o');
+
+ return `/roll${original ? `/${original}` : ''}`;
+ },
+ },
+ ],
+};
diff --git a/lib/routes/caai/index.ts b/lib/routes/caai/index.ts
new file mode 100644
index 00000000000000..360519205d9c14
--- /dev/null
+++ b/lib/routes/caai/index.ts
@@ -0,0 +1,44 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/:caty',
+ categories: ['study'],
+ example: '/caai/45',
+ parameters: { caty: '分类 ID,可在 URL 找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '学会动态',
+ maintainers: ['tudou027'],
+ handler,
+};
+
+async function handler(ctx) {
+ const base = utils.urlBase(ctx.req.param('caty'));
+ const res = await got(base);
+ const info = utils.fetchAllArticles(res.data);
+ const $ = load(res.data);
+
+ const details = await Promise.all(info.map((e) => utils.detailPage(e, cache)));
+
+ ctx.set('json', {
+ info,
+ });
+
+ return {
+ title: '中国人工智能学会 - ' + $('.article-list h1').text(),
+ link: base,
+ item: details,
+ };
+}
diff --git a/lib/routes/caai/namespace.ts b/lib/routes/caai/namespace.ts
new file mode 100644
index 00000000000000..bf586f31ff981f
--- /dev/null
+++ b/lib/routes/caai/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国人工智能学会',
+ url: 'caai.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/caai/templates/description.art b/lib/routes/caai/templates/description.art
similarity index 100%
rename from lib/v2/caai/templates/description.art
rename to lib/routes/caai/templates/description.art
diff --git a/lib/routes/caai/utils.ts b/lib/routes/caai/utils.ts
new file mode 100644
index 00000000000000..0894a5ca853ac1
--- /dev/null
+++ b/lib/routes/caai/utils.ts
@@ -0,0 +1,48 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const base = 'http://www.caai.cn';
+
+const urlBase = (caty) => base + `/index.php?s=/home/article/index/id/${caty}.html`;
+
+const renderDesc = (desc) =>
+ art(path.join(__dirname, 'templates/description.art'), {
+ desc,
+ });
+
+const detailPage = (e, cache) =>
+ cache.tryGet(e.link, async () => {
+ const result = await got(e.link);
+ const $ = load(result.data);
+ e.description = $('div.article').html();
+ return e;
+ });
+
+const fetchAllArticles = (data) => {
+ const $ = load(data);
+ const articles = $('div.article-list > ul > li');
+ const info = articles.toArray().map((e) => {
+ const c = $(e);
+ const r = {
+ title: c.find('h3 a[href]').text().trim(),
+ link: base + c.find('h3 a[href]').attr('href'),
+ pubDate: timezone(parseDate(c.find('h4').text().trim(), 'YYYY-MM-DD'), +8),
+ };
+ return r;
+ });
+ return info;
+};
+
+export default {
+ BASE: base,
+ urlBase,
+ fetchAllArticles,
+ detailPage,
+ renderDesc,
+};
diff --git a/lib/routes/caam/index.ts b/lib/routes/caam/index.ts
new file mode 100644
index 00000000000000..9753cb9086f4ff
--- /dev/null
+++ b/lib/routes/caam/index.ts
@@ -0,0 +1,74 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:category?',
+ name: 'Unknown',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { category = '1' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'http://www.caam.org.cn';
+ const currentUrl = new URL(`chn/1/cate_${category}/list_1.html`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ let items = $('span.cont')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.parent();
+
+ return {
+ title: item.text(),
+ link: new URL(a.prop('href'), currentUrl).href,
+ pubDate: parseDate(a.find('span.time').text(), '[YYYY.MM.DD]'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const content = load(detailResponse);
+
+ const infoEls = content('div.fourTop em');
+
+ item.title = content('div.fourTop h2').text();
+ item.description = content('div.fourBox').html();
+ item.author = infoEls.length <= 1 ? undefined : content('div.fourTop em').last().text();
+ item.pubDate = parseDate(infoEls.first().text());
+
+ return item;
+ })
+ )
+ );
+
+ const author = $('div.footer a').first().text();
+ const subtitle = $('div.topMeuns ul li a').last().text();
+ const image = new URL('images/header-back-7.png', rootUrl).href;
+
+ return {
+ item: items,
+ title: `${author} - ${subtitle}`,
+ link: currentUrl,
+ description: $('meta[property="og:description"]').prop('content'),
+ language: $('html').prop('lang'),
+ image,
+ subtitle,
+ author,
+ };
+}
diff --git a/lib/routes/caam/namespace.ts b/lib/routes/caam/namespace.ts
new file mode 100644
index 00000000000000..fe756ac0a46881
--- /dev/null
+++ b/lib/routes/caam/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国汽车工业协会',
+ url: 'caam.org.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/caareviews/book.ts b/lib/routes/caareviews/book.ts
new file mode 100644
index 00000000000000..5950fa4b9240c5
--- /dev/null
+++ b/lib/routes/caareviews/book.ts
@@ -0,0 +1,40 @@
+import type { Route } from '@/types';
+
+import { getItems, getList, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/book',
+ categories: ['journal'],
+ example: '/caareviews/book',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['caareviews.org/reviews/book'],
+ },
+ ],
+ name: 'Book Reviews',
+ maintainers: ['Fatpandac'],
+ handler,
+ url: 'caareviews.org/reviews/book',
+};
+
+async function handler(ctx) {
+ const url = `${rootUrl}/reviews/book`;
+
+ const list = await getList(url);
+ const items = await getItems(ctx, list);
+
+ return {
+ title: 'Book Reviews',
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/caareviews/essay.ts b/lib/routes/caareviews/essay.ts
new file mode 100644
index 00000000000000..2b404cfe597401
--- /dev/null
+++ b/lib/routes/caareviews/essay.ts
@@ -0,0 +1,40 @@
+import type { Route } from '@/types';
+
+import { getItems, getList, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/essay',
+ categories: ['journal'],
+ example: '/caareviews/essay',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['caareviews.org/reviews/essay'],
+ },
+ ],
+ name: 'Essays',
+ maintainers: ['Fatpandac'],
+ handler,
+ url: 'caareviews.org/reviews/essay',
+};
+
+async function handler(ctx) {
+ const url = `${rootUrl}/reviews/essay`;
+
+ const list = await getList(url);
+ const items = await getItems(ctx, list);
+
+ return {
+ title: 'Essays',
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/caareviews/exhibition.ts b/lib/routes/caareviews/exhibition.ts
new file mode 100644
index 00000000000000..fc9ab6c52624b9
--- /dev/null
+++ b/lib/routes/caareviews/exhibition.ts
@@ -0,0 +1,40 @@
+import type { Route } from '@/types';
+
+import { getItems, getList, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '/exhibition',
+ categories: ['journal'],
+ example: '/caareviews/exhibition',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['caareviews.org/reviews/exhibition'],
+ },
+ ],
+ name: 'Exhibition Reviews',
+ maintainers: ['Fatpandac'],
+ handler,
+ url: 'caareviews.org/reviews/exhibition',
+};
+
+async function handler(ctx) {
+ const url = `${rootUrl}/reviews/exhibition`;
+
+ const list = await getList(url);
+ const items = await getItems(ctx, list);
+
+ return {
+ title: 'Exhibition Reviews',
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/caareviews/namespace.ts b/lib/routes/caareviews/namespace.ts
new file mode 100644
index 00000000000000..5ebfc5382e2d82
--- /dev/null
+++ b/lib/routes/caareviews/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'caa.reviews',
+ url: 'caareviews.org',
+ lang: 'en',
+};
diff --git a/lib/v2/caareviews/templates/utils.art b/lib/routes/caareviews/templates/utils.art
similarity index 100%
rename from lib/v2/caareviews/templates/utils.art
rename to lib/routes/caareviews/templates/utils.art
diff --git a/lib/routes/caareviews/utils.ts b/lib/routes/caareviews/utils.ts
new file mode 100644
index 00000000000000..98a47817886404
--- /dev/null
+++ b/lib/routes/caareviews/utils.ts
@@ -0,0 +1,48 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+const rootUrl = 'http://www.caareviews.org';
+
+const getList = async (url) => {
+ const response = await got(url);
+ const $ = load(response.data);
+ const list = $('#infinite-content > div')
+ .toArray()
+ .map((item) => ({
+ title: $(item).find('div.title').text().trim(),
+ link: new URL($(item).find('div.title > em > a').attr('href'), rootUrl).href,
+ author: $(item).find('div.contributors').text().trim(),
+ }));
+
+ return list;
+};
+
+const getItems = (ctx, list) =>
+ Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link);
+ const $ = load(detailResponse.data);
+
+ const coverUrl = new URL($('div.cover > a').attr('href'), rootUrl).href;
+ const content = $('div.content.full-review').html();
+ item.description = art(path.join(__dirname, 'templates/utils.art'), {
+ coverUrl,
+ content,
+ });
+ $('div.review_heading').remove();
+ item.pubDate = parseDate($('div.header-text > div.clearfix').text());
+ item.doi = $('div.crossref > a').attr('href').replace('http://dx.doi.org/', '');
+
+ return item;
+ })
+ )
+ );
+
+export { getItems, getList, rootUrl };
diff --git a/lib/routes/cags/edu/index.ts b/lib/routes/cags/edu/index.ts
new file mode 100644
index 00000000000000..9a117f4e49cb16
--- /dev/null
+++ b/lib/routes/cags/edu/index.ts
@@ -0,0 +1,84 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const host = 'https://edu.cags.ac.cn';
+
+const titles = {
+ tzgg: '通知公告',
+ ywjx: '要闻简讯',
+ zs_bss: '博士生招生',
+ zs_sss: '硕士生招生',
+ zs_dxsxly: '大学生夏令营',
+};
+
+export const route: Route = {
+ path: '/edu/:category',
+ categories: ['university'],
+ example: '/cags/edu/tzgg',
+ parameters: {
+ category: '通知频道,可选 tzgg/ywjx/zs_bss/zs_sss/zs_dxsxly',
+ },
+ features: {
+ antiCrawler: false,
+ requireConfig: false,
+ requirePuppeteer: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '研究生院',
+ maintainers: ['Chikit-L'],
+ radar: [
+ {
+ source: ['edu.cags.ac.cn/'],
+ },
+ ],
+ handler,
+ description: `
+| 通知公告 | 要闻简讯 | 博士生招生 | 硕士生招生 | 大学生夏令营 |
+| -------- | -------- | ---------- | ---------- | ------------ |
+| tzgg | ywjx | zs_bss | zs_sss | zs_dxsxly |
+`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category');
+ const title = titles[category];
+
+ if (!title) {
+ throw new Error(`Invalid category: ${category}`);
+ }
+
+ const API_URL = `${host}/api/cms/cmsNews/pageByCmsNavBarId/${category}/1/10/0`;
+ const response = await ofetch(API_URL);
+ const data = response.data;
+
+ const items = data.map((item) => {
+ const id = item.id;
+ const title = item.title;
+
+ let pubDate = null;
+ if (item.publishDate) {
+ pubDate = parseDate(item.publishDate, 'YYYY-MM-DD');
+ pubDate = timezone(pubDate, 8);
+ }
+
+ const link = `${host}/#/dky/view/id=${id}/barId=${category}`;
+
+ return {
+ title,
+ description: item.introduction,
+ link,
+ guid: link,
+ pubDate,
+ };
+ });
+
+ return {
+ title,
+ link: `${host}/#/dky/list/barId=${category}/cmsNavCategory=1`,
+ item: items,
+ };
+}
diff --git a/lib/routes/cags/namespace.ts b/lib/routes/cags/namespace.ts
new file mode 100644
index 00000000000000..abb34c06bfc9fb
--- /dev/null
+++ b/lib/routes/cags/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Chinese Academy of Geological Sciences',
+ url: 'cags.cgs.gov.cn',
+ zh: {
+ name: '中国地质科学院',
+ },
+};
diff --git a/lib/routes/cahkms/index.ts b/lib/routes/cahkms/index.ts
new file mode 100644
index 00000000000000..7b7e6e5dde7e90
--- /dev/null
+++ b/lib/routes/cahkms/index.ts
@@ -0,0 +1,103 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const titles = {
+ '01': '关于我们',
+ '02': '港澳新闻',
+ '03': '重要新闻',
+ '04': '顾问点评、会员观点',
+ '05': '专题汇总',
+ '06': '港澳时评',
+ '07': '图片新闻',
+ '08': '视频中心',
+ '09': '港澳研究',
+ 10: '最新书讯',
+ 11: '研究资讯',
+};
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/cahkms',
+ parameters: { category: '分类,见下表,默认为重要新闻' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cahkms.org/'],
+ },
+ ],
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'cahkms.org/',
+ description: `| 关于我们 | 港澳新闻 | 重要新闻 | 顾问点评、会员观点 | 专题汇总 |
+| -------- | -------- | -------- | ------------------ | -------- |
+| 01 | 02 | 03 | 04 | 05 |
+
+| 港澳时评 | 图片新闻 | 视频中心 | 港澳研究 | 最新书讯 | 研究资讯 |
+| -------- | -------- | -------- | -------- | -------- | -------- |
+| 06 | 07 | 08 | 09 | 10 | 11 |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? '03';
+
+ const rootUrl = 'http://www.cahkms.org';
+ const currentUrl = `${rootUrl}/HKMAC/indexMac/getRightList?dm=${category}&page=1&countPage=${ctx.req.query('limit') ?? 10}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ let items = response.data
+ .filter((item) => item.ID)
+ .map((item) => ({
+ title: item.TITLE,
+ description: `${item.GJZ}
`,
+ pubDate: timezone(parseDate(item.JDRQ), +8),
+ link: `${rootUrl}/HKMAC/indexMac/getWzxx?id=${item.ID}`,
+ }));
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ item.author = detailResponse.data.WZLY;
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ rootUrl,
+ content: detailResponse.data.CONTENT,
+ image: detailResponse.data.URL,
+ files: detailResponse.data.fjlist,
+ video: detailResponse.data.VIDEO.indexOf('.mp4') > 0 ? detailResponse.data.VIDEO : null,
+ });
+ item.link = `${rootUrl}/HKMAC/webView/mc/AboutUs_1.html?${category}&${titles[category]}`;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${titles[category]} - 全国港澳研究会`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/cahkms/namespace.ts b/lib/routes/cahkms/namespace.ts
new file mode 100644
index 00000000000000..941ab11a9aca19
--- /dev/null
+++ b/lib/routes/cahkms/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '全国港澳研究会',
+ url: 'cahkms.org',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/cahkms/templates/description.art b/lib/routes/cahkms/templates/description.art
similarity index 100%
rename from lib/v2/cahkms/templates/description.art
rename to lib/routes/cahkms/templates/description.art
diff --git a/lib/routes/caijing/namespace.ts b/lib/routes/caijing/namespace.ts
new file mode 100644
index 00000000000000..4483b85f997c7c
--- /dev/null
+++ b/lib/routes/caijing/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '财经网',
+ url: 'roll.caijing.com.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/caijing/roll.ts b/lib/routes/caijing/roll.ts
new file mode 100644
index 00000000000000..eac82084e24015
--- /dev/null
+++ b/lib/routes/caijing/roll.ts
@@ -0,0 +1,73 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/roll',
+ categories: ['finance'],
+ example: '/caijing/roll',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['roll.caijing.com.cn/index1.html', 'roll.caijing.com.cn/'],
+ },
+ ],
+ name: '滚动新闻',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'roll.caijing.com.cn/index1.html',
+};
+
+async function handler() {
+ const baseUrl = 'https://roll.caijing.com.cn';
+ const response = await got(`${baseUrl}/ajax_lists.php`, {
+ searchParams: {
+ modelid: 0,
+ time: Math.random(),
+ },
+ });
+
+ const list = response.data.map((item) => ({
+ title: item.title,
+ link: item.url.replace('http://', 'https://'),
+ pubDate: timezone(parseDate(item.published, 'MM-DD HH:mm'), +8),
+ category: item.cat,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+ item.author = $('.editor').text().trim() || $('#editor_baidu').text().trim().replaceAll(/[()]/g, '');
+ item.description = $('.article-content').html();
+ item.category = [
+ item.category,
+ ...$('.news_keywords span')
+ .toArray()
+ .map((e) => $(e).text()),
+ ];
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '滚动新闻-财经网',
+ image: 'https://www.caijing.com.cn/favicon.ico',
+ link: response.url,
+ item: items,
+ };
+}
diff --git a/lib/routes/caixin/article.ts b/lib/routes/caixin/article.ts
new file mode 100644
index 00000000000000..522d1f50640fb7
--- /dev/null
+++ b/lib/routes/caixin/article.ts
@@ -0,0 +1,54 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import { parseArticle } from './utils';
+
+export const route: Route = {
+ path: '/article',
+ categories: ['traditional-media'],
+ example: '/caixin/article',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: true,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['caixin.com/'],
+ },
+ ],
+ name: '首页新闻',
+ maintainers: ['EsuRt'],
+ handler,
+ url: 'caixin.com/',
+};
+
+async function handler() {
+ const { data: response } = await got('https://mapiv5.caixin.com/m/api/getWapIndexListByPage');
+
+ const list = response.data.list.map((item) => ({
+ title: item.title,
+ description: item.summary,
+ author: item.author_name,
+ pubDate: parseDate(item.time, 'X'),
+ link: item.web_url,
+ pics: item.pics,
+ audio: item.cms_audio_url,
+ audio_image_url: item.audio_image_url,
+ }));
+
+ const items = await Promise.all(list.map((item) => cache.tryGet(item.link, () => parseArticle(item))));
+
+ return {
+ title: '财新网 - 首页',
+ link: 'https://www.caixin.com',
+ description: '财新网 - 首页',
+ item: items,
+ };
+}
diff --git a/lib/routes/caixin/blog.ts b/lib/routes/caixin/blog.ts
new file mode 100644
index 00000000000000..89d61546fb49d7
--- /dev/null
+++ b/lib/routes/caixin/blog.ts
@@ -0,0 +1,104 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { isValidHost } from '@/utils/valid-host';
+
+import { parseBlogArticle } from './utils';
+
+export const route: Route = {
+ path: '/blog/:column?',
+ categories: ['blog'],
+ example: '/caixin/blog/zhangwuchang',
+ parameters: { column: '博客名称,可在博客主页的 URL 找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '用户博客',
+ maintainers: [],
+ handler,
+ description: `通过提取文章全文,以提供比官方源更佳的阅读体验.`,
+};
+
+async function handler(ctx) {
+ const column = ctx.req.param('column');
+ const { limit = 20 } = ctx.req.query();
+ if (column) {
+ if (!isValidHost(column)) {
+ throw new InvalidParameterError('Invalid column');
+ }
+ const link = `https://${column}.blog.caixin.com`;
+ const { data: response } = await got(link);
+ const $ = load(response);
+ const user = $('div.indexMainConri > script[type="text/javascript"]')
+ .text()
+ .slice('window.user = '.length + 1)
+ .split(';')[0]
+ .replaceAll(/\s/g, '');
+ const authorId = user.match(/id:"(\d+)"/)[1];
+ const authorName = user.match(/name:"(.*?)"/)[1];
+ const avatar = user.match(/avatar:"(.*?)"/)[1];
+ const introduce = user.match(/introduce:"(.*?)"/)[1];
+
+ const {
+ data: { data },
+ } = await got('https://blog.caixin.com/blog-api/post/posts', {
+ searchParams: {
+ page: 1,
+ size: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 20,
+ content: '',
+ authorId,
+ sort: 'publishTime',
+ direction: 'DESC',
+ },
+ });
+
+ const posts = data.map((item) => ({
+ title: item.title,
+ description: item.brief,
+ author: item.displayName,
+ link: item.guid.replace('http://', 'https://'),
+ pubDate: parseDate(item.publishTime, 'x'),
+ }));
+
+ const items = await Promise.all(posts.map((item) => cache.tryGet(item.link, () => parseBlogArticle(item))));
+ return {
+ title: `财新博客 - ${authorName}`,
+ link,
+ description: introduce,
+ image: avatar,
+ item: items,
+ };
+ } else {
+ const { data } = await got('https://blog.caixin.com/blog-api/post/index', {
+ searchParams: {
+ page: 1,
+ size: limit,
+ },
+ });
+ const posts = data.data.map((item) => ({
+ title: item.title,
+ description: item.brief,
+ author: item.authorName,
+ link: item.postUrl.replace('http://', 'https://'),
+ pubDate: parseDate(item.publishTime, 'x'),
+ }));
+ const items = await Promise.all(posts.map((item) => cache.tryGet(item.link, () => parseBlogArticle(item))));
+
+ return {
+ title: `财新博客 - 全部`,
+ link: 'https://blog.caixin.com',
+ // description: introduce,
+ // image: avatar,
+ item: items,
+ };
+ }
+}
diff --git a/lib/routes/caixin/category.ts b/lib/routes/caixin/category.ts
new file mode 100644
index 00000000000000..ba2d71265a47fc
--- /dev/null
+++ b/lib/routes/caixin/category.ts
@@ -0,0 +1,96 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+import { isValidHost } from '@/utils/valid-host';
+
+import { parseArticle } from './utils';
+
+export const route: Route = {
+ path: '/:column/:category',
+ categories: ['traditional-media'],
+ example: '/caixin/finance/regulation',
+ parameters: { column: '栏目名', category: '栏目下的子分类名' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: true,
+ supportScihub: false,
+ },
+ name: '新闻分类',
+ maintainers: ['idealclover'],
+ handler,
+ description: `Column 列表:
+
+| 经济 | 金融 | 政经 | 环科 | 世界 | 观点网 | 文化 | 周刊 |
+| ------- | ------- | ----- | ------- | ------------- | ------- | ------- | ------ |
+| economy | finance | china | science | international | opinion | culture | weekly |
+
+ 以金融板块为例的 category 列表:(其余 column 以类似方式寻找)
+
+| 监管 | 银行 | 证券基金 | 信托保险 | 投资 | 创新 | 市场 |
+| ---------- | ---- | -------- | ---------------- | ---------- | ---------- | ------ |
+| regulation | bank | stock | insurance\_trust | investment | innovation | market |
+
+ Category 列表:
+
+| 封面报道 | 开卷 | 社论 | 时事 | 编辑寄语 | 经济 | 金融 | 商业 | 环境与科技 | 民生 | 副刊 |
+| ---------- | ----- | --------- | ---------------- | ------------ | ------- | ------- | -------- | ----------------------- | ------- | ------ |
+| coverstory | first | editorial | current\_affairs | editor\_desk | economy | finance | business | environment\_technology | cwcivil | column |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category');
+ const column = ctx.req.param('column');
+ const url = `https://${column}.caixin.com/${category}`;
+ if (!isValidHost(column)) {
+ throw new InvalidParameterError('Invalid column');
+ }
+
+ const response = await got(url);
+
+ const $ = load(response.data);
+ const title = $('head title').text();
+ const entity = JSON.parse(
+ $('script')
+ .text()
+ .match(/var entity = ({.*?})/)[1]
+ );
+
+ const {
+ data: { datas: data },
+ } = await got('https://gateway.caixin.com/api/extapi/homeInterface.jsp', {
+ searchParams: {
+ subject: entity.id,
+ type: 0,
+ count: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 25,
+ picdim: '_266_177',
+ start: 0,
+ },
+ });
+
+ const list = data.map((item) => ({
+ title: item.desc,
+ description: item.summ,
+ link: item.link.replace('http://', 'https://'),
+ pubDate: timezone(parseDate(item.time), +8),
+ category: item.keyword.split(' '),
+ audio: item.audioUrl,
+ audio_image_url: item.pict.imgs[0].url,
+ }));
+
+ const items = await Promise.all(list.map((item) => cache.tryGet(item.link, () => parseArticle(item))));
+
+ return {
+ title,
+ link: url,
+ description: '财新网 - 提供财经新闻及资讯服务',
+ item: items,
+ };
+}
diff --git a/lib/routes/caixin/database.ts b/lib/routes/caixin/database.ts
new file mode 100644
index 00000000000000..5909b3b7a7593e
--- /dev/null
+++ b/lib/routes/caixin/database.ts
@@ -0,0 +1,76 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/database',
+ categories: ['traditional-media'],
+ example: '/caixin/database',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['k.caixin.com/web', 'k.caixin.com/'],
+ },
+ ],
+ name: '财新数据通',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'k.caixin.com/web',
+};
+
+async function handler() {
+ const rootUrl = 'https://database.caixin.com';
+ const currentUrl = `${rootUrl}/news/`;
+ const response = await got(currentUrl);
+
+ const $ = load(response.data);
+
+ const list = $('h4 a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.text(),
+ link: item.attr('href').replace('http://', 'https://'),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link);
+ const content = load(detailResponse.data);
+
+ item.pubDate = timezone(parseDate(content('#pubtime_baidu').text()), +8);
+ item.description = art(path.join(__dirname, 'templates/article.art'), {
+ item,
+ $: content,
+ });
+ item.author = content('.top-author').text();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '财新数据通 - 专享资讯',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/caixin/k.ts b/lib/routes/caixin/k.ts
new file mode 100644
index 00000000000000..9328f5aa5149a4
--- /dev/null
+++ b/lib/routes/caixin/k.ts
@@ -0,0 +1,59 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/k',
+ categories: ['traditional-media'],
+ example: '/caixin/k',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: true,
+ supportScihub: false,
+ },
+ name: '财新一线',
+ maintainers: ['boypt'],
+ handler,
+};
+
+async function handler() {
+ const response = await got('https://k.caixin.com/app/v1/list', {
+ searchParams: {
+ productIdList: '8,28',
+ uid: '',
+ unit: 1,
+ name: '',
+ code: '',
+ deviceType: '',
+ device: '',
+ userTag: '',
+ p: 1,
+ c: 20,
+ },
+ });
+
+ const data = response.data.data.list;
+ const items = data.map((item) => {
+ const hasAudio = item.audio_url || Object.values(item.audios)[0];
+ return {
+ title: item.title,
+ description: item.text,
+ link: `http://k.caixin.com/web/detail_${item.oneline_news_code}`,
+ pubDate: parseDate(item.ts, 'x'),
+ author: '财新一线',
+ enclosure_url: hasAudio ? item.audio_url || Object.values(item.audios)[0] : undefined,
+ enclosure_type: hasAudio ? 'audio/mpeg' : undefined,
+ };
+ });
+
+ return {
+ title: '财新网 - 财新一线新闻',
+ link: 'https://k.caixin.com/web/',
+ description: '财新网 - 财新一线新闻',
+ item: items,
+ };
+}
diff --git a/lib/routes/caixin/latest.ts b/lib/routes/caixin/latest.ts
new file mode 100644
index 00000000000000..1c1a0ac7327293
--- /dev/null
+++ b/lib/routes/caixin/latest.ts
@@ -0,0 +1,73 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { parseArticle } from './utils';
+import { getFulltext } from './utils-fulltext';
+
+export const route: Route = {
+ path: '/latest',
+ categories: ['traditional-media'],
+ view: ViewType.Articles,
+ example: '/caixin/latest',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['caixin.com/'],
+ },
+ ],
+ name: '最新文章',
+ maintainers: ['tpnonthealps'],
+ handler,
+ url: 'caixin.com/',
+ description: `说明:此 RSS feed 会自动抓取财新网的最新文章,但不包含 FM 及视频内容。订阅用户可根据文档设置环境变量后,在url传入\`fulltext=\`以解锁全文。`,
+};
+
+async function handler(ctx) {
+ const { data } = await got('https://gateway.caixin.com/api/dataplatform/scroll/index');
+
+ const list = data.data.articleList
+ .map((e) => ({
+ title: e.title,
+ link: e.url,
+ pubDate: e.time,
+ category: e.channelObject.name,
+ }))
+ .filter((item) => !item.link.startsWith('https://fm.caixin.com/') && !item.link.startsWith('https://video.caixin.com/') && !item.link.startsWith('https://datanews.caixin.com/')); // content filter
+
+ const rss = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(`caixin:latest:${item.link}`, async () => {
+ // desc
+ const desc = await parseArticle(item);
+
+ if (ctx.req.query('fulltext') === 'true') {
+ const authorizedFullText = await getFulltext(item.link);
+ item.description = authorizedFullText === '' ? desc.description : authorizedFullText;
+ } else {
+ item.description = desc.description;
+ }
+ // prevent cache coliision with /caixin/article and /caixin/:column/:category
+ // since those have podcasts
+ item.guid = `caixin:latest:${item.link}`;
+
+ return { ...desc, ...item };
+ })
+ )
+ );
+
+ return {
+ title: '财新网 - 最新文章',
+ link: 'https://www.caixin.com/',
+ item: rss,
+ };
+}
diff --git a/lib/routes/caixin/namespace.ts b/lib/routes/caixin/namespace.ts
new file mode 100644
index 00000000000000..b00547f4e02ca1
--- /dev/null
+++ b/lib/routes/caixin/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '财新博客',
+ url: 'caixin.com',
+ description: `> 网站部分内容需要付费订阅,RSS 仅做更新提醒,不含付费内容。若需要得到付费内容全文,请使用订阅账户在手机网页版登录,然后设置\`CAIXIN_COOKIE\`为至少包含cookie中的以下字段: \`SA_USER_UID\`, \`SA_USER_UNIT\`, \`SA_USER_DEVICE_TYPE\`, \`USER_LOGIN_CODE\``,
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/caixin/templates/article.art b/lib/routes/caixin/templates/article.art
new file mode 100644
index 00000000000000..25839ba2ac18f6
--- /dev/null
+++ b/lib/routes/caixin/templates/article.art
@@ -0,0 +1,40 @@
+{{ if item.audio }}
+
+{{ /if }}
+{{ if $('.article .subhead').length }}
+ {{@ $('.article .subhead').html() }}
+
+{{ /if }}
+
+{{ if $('.article .media').length }}
+ {{@ $('.article .media').html() }}
+
+{{ /if }}
+
+{{ if $('.article .content_video').length }}
+ <% const video = $('script').text().match(/initPlayer\('(.*?)','(.*?)'\)/); %>
+ {{ if video}}
+ <% const videoUrl = video[1]; %>
+ <% const poster = video[2]; %>
+
+
+ {{ /if }}
+{{ /if }}
+
+{{ if $('div#Main_Content_Val.text').length }}
+ {{@ $('div#Main_Content_Val.text').html() }}
+{{ else }}
+ {{ if item.summary }}
+ {{ item.summary }}
+
+ {{ /if }}
+ {{ if item.pics?.includes('#') }}
+ {{ each item.pics.split('#') pic }}
+
+
+ {{ /each }}
+ {{ else }}
+
+
+ {{ /if }}
+{{ /if }}
diff --git a/lib/routes/caixin/utils-fulltext.ts b/lib/routes/caixin/utils-fulltext.ts
new file mode 100644
index 00000000000000..3d1b01da06d33f
--- /dev/null
+++ b/lib/routes/caixin/utils-fulltext.ts
@@ -0,0 +1,53 @@
+import crypto from 'node:crypto';
+
+import { hextob64, KJUR } from 'jsrsasign';
+
+import { config } from '@/config';
+import ofetch from '@/utils/ofetch';
+
+// The following constant is extracted from this script: https://file.caixin.com/pkg/cx-pay-layer/js/wap.js?v=5.15.421933 . It is believed to contain no sensitive information.
+// Refer to this discussion for further explanation: https://github.com/DIYgod/RSSHub/pull/17231
+const rsaPrivateKey =
+ '-----BEGIN PRIVATE KEY-----MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCLci8q2u3NGFyFlMUwjCP91PsvGjHdRAq9fmqZLxvue+n+RhzNxnKKYOv35pLgFKWXsGq2TV+5Xrv6xZgNx36IUkqbmrO+eCa8NFmti04wvMfG3DCNdKA7Lue880daNiK3BOhlQlZPykUXt1NftMNS/z+e70W+Vpv1ZxCx5BipqZkdoceM3uin0vUQmqmHqjxi5qKUuov90dXLaMxypCA0TDsIDnX8RPvPtqKff1p2TMW2a0XYe7CPYhRggaQMpmo0TcFutgrM1Vywyr2TPxYR+H/tpuuWRET7tUIQykBYoO1WKfL2dX6cxarjAJfnYnod3sMzppHouyp8Pt7gHVG7AgMBAAECggEAEFshSy6IrADKgWSUyH/3jMNZfwnchW6Ar/9O847CAPQJ2yhQIpa/Qpnhs58Y5S2myqcHrUBgFPcWp3BbyGn43naAh8XahWHEcVjWl/N6BV9vM1UKYN0oGikDR3dljCBDbCIoPBBO3WcFOaXoIpaqPmbwCG1aSdwQyPUA0UzG08eDbuHK6L5jvbe3xv5kLpWTVddrocW+SakbZRAX1Ykp7IujOce235nM7GOfoq4b8jmK5CLg6VIZGQV20wnn9YxuFOndRSjneFberzfzBMhVLpPsQ16M2xDLpZaDTggZnq2L6nZygds8Hda++ga3WbD3TcgjJNYuENu1S88IowYhSQKBgQDFqRA+38mo6KsxVDCNWcuEk2hSq8NEUzRHJpS7/QjZmEIYpFzDXgSGwhZJ0WNsQtaxJeBbc7B/OOqh8TL1reLl5AdTimS1OLHWVf/MUsLVS7Y82hx/hpYWxZnRSq41oI3P8FO/53FiQMYo2wbwqF6uQjB1y8h58aqL3OYpTH/5xQKBgQC0mobALJ+bU4nCPzkVDZuD6RyNWPwS1aE3+925wDSN2rJ0iLIb4N5czWZmHb66VlAtfGbp2q+amsCV4r6UR19A/y8k9SFB0mdtxix6mjEfaGhVJm4B1mkvsn0OHMAanKkohUvCjROQc3sziyp2gqSEQ98G7//VMPx/3dhgyQpVfwKBgQCycsqu6N0n+D6t/0MCKiJaI7bYhCd7JN8aqVM4UN5PjG2Hz8PLwbK2cr0qkbaAA+vN7NMb3Vtn0FvMLnUCZqVlRTP0EQqQrYmoZuXUcpdhd8QkNgnqe/g+wND4qcKTucquA1uo8mtj9/Su5+bhGDC6hBk6D+uDZFHDiX/loyIavQKBgQCXF6AcLjjpDZ52b8Yloti0JtXIOuXILAlQeNoqiG5vLsOVUrcPM7VUFlLQo5no8kTpiOXgRyAaS9VKkAO4sW0zR0n9tUY5dvkokV6sw0rNZ9/BPQFTcDlXug99OvhMSzwJtlqHTNdNRg+QM6E2vF0+ejmf6DEz/mN/5e0cK5UFqQKBgCR2hVfbRtDz9Cm/P8chPqaWFkH5ulUxBpc704Igc6bVH5DrEoWo6akbeJixV2obAZO3sFyeJqBUqaCvqG17Xei6jn3Hc3WMz9nLrAJEI9BTCfwvuxCOyY0IxqAAYT28xYv42I4+ADT/PpCq2Dj5u43X0dapAjZBZDfVVis7q1Bw-----END PRIVATE KEY-----';
+
+export async function getFulltext(url: string) {
+ if (!config.caixin.cookie) {
+ return;
+ }
+ if (!/(\d+)\.html/.test(url)) {
+ return;
+ }
+ const articleID = url.match(/(\d+)\.html/)[1];
+
+ const nonce = crypto.randomUUID().replaceAll('-', '').toUpperCase();
+
+ const userID = config.caixin.cookie
+ .split(';')
+ .find((e) => e.includes('SA_USER_UID'))
+ ?.split('=')[1]; //
+
+ const rawString = `id=${articleID}&uid=${userID}&${nonce}=nonce`;
+
+ const sig = new KJUR.crypto.Signature({ alg: 'SHA256withRSA' });
+ sig.init(rsaPrivateKey);
+ sig.updateString(rawString);
+ const sigValueHex = hextob64(sig.sign());
+
+ const isWeekly = url.includes('weekly');
+ const res = await ofetch(`https://gateway.caixin.com/api/newauth/checkAuthByIdJsonp`, {
+ query: {
+ type: 1,
+ page: isWeekly ? 0 : 1,
+ rand: Math.random(),
+ id: articleID,
+ },
+ headers: {
+ 'X-Sign': encodeURIComponent(sigValueHex),
+ 'X-Nonce': encodeURIComponent(nonce),
+ Cookie: config.caixin.cookie,
+ },
+ });
+
+ const { content = '', pictureList } = JSON.parse(res.data.match(/resetContentInfo\((.*)\)/)[1]);
+ return content + (pictureList ? pictureList.map((e) => `${e.desc} `).join('') : '');
+}
diff --git a/lib/routes/caixin/utils.ts b/lib/routes/caixin/utils.ts
new file mode 100644
index 00000000000000..9268e05d8a52be
--- /dev/null
+++ b/lib/routes/caixin/utils.ts
@@ -0,0 +1,48 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const parseArticle = async (item) => {
+ if (/\.blog\.caixin\.com$/.test(new URL(item.link).hostname)) {
+ return parseBlogArticle(item);
+ } else {
+ const { data: response } = await got(item.link);
+
+ const $ = load(response);
+
+ item.description = art(path.join(__dirname, 'templates/article.art'), {
+ item,
+ $,
+ });
+
+ if (item.audio) {
+ item.itunes_item_image = item.audio_image_url;
+ item.enclosure_url = item.audio;
+ item.enclosure_type = 'audio/mpeg';
+ }
+
+ return item;
+ }
+};
+
+const parseBlogArticle = async (item) => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+ const article = $('#the_content').removeAttr('style');
+ article.find('img').removeAttr('style');
+ article
+ .find('p')
+ // Non-breaking space U+00A0, ` ` in html
+ // element.children[0].data === $(element, article).text()
+ .filter((_, element) => element.children[0].data === String.fromCodePoint(160))
+ .remove();
+
+ item.description = article.html();
+
+ return item;
+};
+
+export { parseArticle, parseBlogArticle };
diff --git a/lib/routes/caixin/weekly.ts b/lib/routes/caixin/weekly.ts
new file mode 100644
index 00000000000000..c67ef5b04c12e4
--- /dev/null
+++ b/lib/routes/caixin/weekly.ts
@@ -0,0 +1,78 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/weekly',
+ categories: ['traditional-media'],
+ example: '/caixin/weekly',
+ radar: [
+ {
+ source: ['weekly.caixin.com/', 'weekly.caixin.com/*'],
+ },
+ ],
+ name: '财新周刊',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'weekly.caixin.com/',
+};
+
+async function handler(ctx) {
+ const link = 'https://weekly.caixin.com';
+
+ const { data: response } = await got(link);
+ const $ = load(response);
+
+ const list = [
+ ...$('.mi')
+ .toArray()
+ .map((item) => ({
+ link: $(item).find('a').attr('href')?.replace('http:', 'https:'),
+ })),
+ ...$('.xsjCon a')
+ .toArray()
+ .map((item) => ({
+ link: $(item).attr('href'),
+ })),
+ ].slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 10) as DataItem[];
+
+ const items = (await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data } = await got(item.link);
+ const $ = load(data);
+
+ item.title = $('head title')
+ .text()
+ .replace(/_财新周刊频道_财新网$/, '')
+ .trim();
+ item.pubDate = parseDate(
+ $('.source')
+ .text()
+ .match(/出版日期:(\d{4}-\d{2}-\d{2})/)[1]
+ );
+
+ $('.subscribe').remove();
+
+ const report = $('.report');
+ report.find('.title, .source, .date').remove();
+
+ item.description = $('.cover').html() + report.html() + $('.magIntro2').html();
+
+ return item;
+ })
+ )
+ )) as DataItem[];
+
+ return {
+ title: $('head title')
+ .text()
+ .replace(/_财新周刊频道_财新网$/, '')
+ .trim(),
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/caixinglobal/latest.ts b/lib/routes/caixinglobal/latest.ts
new file mode 100644
index 00000000000000..87b02b2384cb32
--- /dev/null
+++ b/lib/routes/caixinglobal/latest.ts
@@ -0,0 +1,91 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/latest',
+ categories: ['traditional-media'],
+ example: '/caixinglobal/latest',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['caixinglobal.com/news', 'caixinglobal.com/'],
+ },
+ ],
+ name: 'Latest News',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'caixinglobal.com/news',
+};
+
+async function handler(ctx) {
+ const { data } = await got('https://gateway.caixin.com/api/extapi/homeInterface.jsp', {
+ searchParams: {
+ subject: '100990318;100990314;100990311',
+ start: 0,
+ count: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20,
+ type: '2',
+ _: Date.now(),
+ },
+ });
+
+ const list = data.datas.map((e) => ({
+ title: e.desc,
+ description: e.summ,
+ link: e.link,
+ pubDate: parseDate(e.time),
+ category: e.tags.map((t) => t.name),
+ nid: e.nid,
+ attr: e.attr,
+ enclosure_url: e.audioUrl,
+ enclosure_type: e.audioUrl ? 'audio/mpeg' : undefined,
+ itunes_item_image: e.audioUrl ? e.pict.imgs[0].url : undefined,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data } = await got(item.link);
+ const $ = load(data);
+ $('.loadingBox, .cons-pay-tip').remove();
+
+ let content = $('#appContent').prop('outerHTML');
+
+ if (item.attr === 0) {
+ const { data } = await got('https://u.caixinglobal.com/get/reading.do', {
+ searchParams: {
+ id: item.nid,
+ source: '',
+ url: item.link,
+ _: Date.now(),
+ },
+ });
+ content = data.data.content;
+ }
+
+ item.description = $('.cons-photo').prop('outerHTML') + content;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: 'The Latest Top Headlines on China - Caixin Global',
+ description: 'The latest headlines on China finance, companies, politics, international affairs and other China-related issues from around the world. Caixin Global',
+ language: 'en',
+ link: 'https://www.caixinglobal.com/news/',
+ item: items,
+ };
+}
diff --git a/lib/routes/caixinglobal/namespace.ts b/lib/routes/caixinglobal/namespace.ts
new file mode 100644
index 00000000000000..8d4880dd8533cc
--- /dev/null
+++ b/lib/routes/caixinglobal/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Caixin Global',
+ url: 'caixinglobal.com',
+ lang: 'en',
+};
diff --git a/lib/routes/camchina/index.ts b/lib/routes/camchina/index.ts
new file mode 100644
index 00000000000000..811d9311caae89
--- /dev/null
+++ b/lib/routes/camchina/index.ts
@@ -0,0 +1,81 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/:id?',
+ categories: ['study'],
+ example: '/camchina',
+ parameters: { id: '分类,见下表,默认为 1,即新闻' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cste.org.cn/categories/:id', 'cste.org.cn/'],
+ },
+ ],
+ name: '栏目',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 新闻 | 通告栏 |
+| ---- | ------ |
+| 1 | 2 |`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '1';
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
+
+ const rootUrl = 'http://www.camchina.org.cn';
+ const currentUrl = `${rootUrl}/categories/${id}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.M-main-l p a')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text(),
+ link: `${rootUrl}${item.attr('href')}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.description = content('.content').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `中国管理现代化研究会 - ${$('.title_red').text()}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/camchina/namespace.ts b/lib/routes/camchina/namespace.ts
new file mode 100644
index 00000000000000..468a798bd240ff
--- /dev/null
+++ b/lib/routes/camchina/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国管理现代化研究会',
+ url: 'cste.org.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/canada.ca/namespace.ts b/lib/routes/canada.ca/namespace.ts
new file mode 100644
index 00000000000000..c3b29b8b29eee1
--- /dev/null
+++ b/lib/routes/canada.ca/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Canada.ca',
+ url: 'www.canada.ca',
+ description: 'Government of Canada news by department',
+ lang: 'en',
+};
diff --git a/lib/routes/canada.ca/news.ts b/lib/routes/canada.ca/news.ts
new file mode 100644
index 00000000000000..5892399e2d4233
--- /dev/null
+++ b/lib/routes/canada.ca/news.ts
@@ -0,0 +1,98 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news/:lang/:department?',
+ categories: ['government'],
+ example: '/canada.ca/news/en/departmentfinance',
+ parameters: { lang: 'Language, en or fr', department: 'dprtmnt query value' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ // Department of Finance
+ {
+ source: ['www.canada.ca/:lang/department-finance.html', 'www.canada.ca/:lang/ministere-finances.html', 'www.canada.ca/:lang/department-finance/news/*', 'www.canada.ca/:lang/ministere-finances/nouvelles/*'],
+ target: '/news/:lang/departmentfinance',
+ },
+ // Innovation, Science and Economic Development Canada
+ {
+ source: [
+ 'ised-isde.canada.ca/site/ised/:lang',
+ 'ised-isde.canada.ca/site/isde/:lang',
+ 'www.canada.ca/:lang/innovation-science-economic-development/news/*',
+ 'www.canada.ca/:lang/innovation-sciences-developpement-economique/nouvelles/*',
+ ],
+ target: '/news/:lang/departmentofindustry',
+ },
+ // All news
+ {
+ source: ['www.canada.ca/:lang/news/advanced-news-search/news-results.html', 'www.canada.ca/:lang/nouvelles/recherche-avancee-de-nouvelles/resultats-de-nouvelles.html'],
+ target: '/news/:lang',
+ },
+ ],
+ name: 'News by Department',
+ maintainers: ['elibroftw'],
+ handler,
+ description: 'News from specific Canadian government departments',
+};
+
+async function handler(ctx) {
+ const lang = ctx.req.param('lang');
+ const department = ctx.req.param('department');
+
+ const baseUrl = 'https://www.canada.ca';
+ const pathMap = {
+ en: '/en/news/advanced-news-search/news-results.html',
+ fr: '/fr/nouvelles/recherche-avancee-de-nouvelles/resultats-de-nouvelles.html',
+ };
+ const path = pathMap[lang];
+
+ const currentUrl = department ? `${baseUrl}${path}?dprtmnt=${department}` : `${baseUrl}${path}`;
+
+ const response = await ofetch(currentUrl);
+ const $ = load(response);
+
+ const list = $('article.item')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const $link = $item.find('h3 a');
+ const title = $link.text().trim();
+ const link = $link.attr('href');
+ if (!link) {
+ return null;
+ }
+ const pubDateStr = $item.find('time').attr('datetime');
+ const pubDate = pubDateStr ? parseDate(pubDateStr) : undefined;
+ const metadataText = $item.find('p').first().text().split('|');
+ const departmentName = metadataText[1].trim();
+ const categoryStr = metadataText[2].trim();
+ const description = $item.find('p').last().text().trim();
+
+ return {
+ title,
+ link: link.startsWith('http') ? link : `${baseUrl}${link}`,
+ pubDate,
+ category: categoryStr ? [categoryStr] : [],
+ description,
+ author: departmentName,
+ };
+ })
+ .filter((item) => item !== null);
+
+ return {
+ title: department ? `${department.toUpperCase()} Canada` : 'Government of Canada News',
+ link: currentUrl,
+ item: list,
+ language: lang,
+ };
+}
diff --git a/lib/routes/cankaoxiaoxi/index.ts b/lib/routes/cankaoxiaoxi/index.ts
new file mode 100644
index 00000000000000..b6bc80fb3d36fa
--- /dev/null
+++ b/lib/routes/cankaoxiaoxi/index.ts
@@ -0,0 +1,110 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: ['/column/:id?', '/:id?'],
+ categories: ['traditional-media'],
+ example: '/cankaoxiaoxi/column/diyi',
+ parameters: { id: '栏目 id,默认为 `diyi`,即第一关注' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '栏目',
+ maintainers: ['yuxinliu-alex', 'nczitzk'],
+ handler,
+ description: `| 栏目 | id |
+| -------------- | -------- |
+| 第一关注 | diyi |
+| 中国 | zhongguo |
+| 国际 | gj |
+| 观点 | guandian |
+| 锐参考 | ruick |
+| 体育健康 | tiyujk |
+| 科技应用 | kejiyy |
+| 文化旅游 | wenhualy |
+| 参考漫谈 | cankaomt |
+| 研究动态 | yjdt |
+| 海外智库 | hwzk |
+| 业界信息・观点 | yjxx |
+| 海外看中国城市 | hwkzgcs |
+| 译名趣谈 | ymymqt |
+| 译名发布 | ymymfb |
+| 双语汇 | ymsyh |
+| 参考视频 | video |
+| 军事 | junshi |
+| 参考人物 | cankaorw |`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? 'diyi';
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
+
+ const rootUrl = 'https://china.cankaoxiaoxi.com';
+ const listApiUrl = `${rootUrl}/json/channel/${id}/list.json`;
+ const channelApiUrl = `${rootUrl}/json/channel/${id}.channeljson`;
+ const currentUrl = `${rootUrl}/#/generalColumns/${id}`;
+
+ const listResponse = await got({
+ method: 'get',
+ url: listApiUrl,
+ });
+
+ const channelResponse = await got({
+ method: 'get',
+ url: channelApiUrl,
+ });
+
+ let items = listResponse.data.list.slice(0, limit).map((item) => ({
+ title: item.data.title,
+ author: item.data.userName,
+ category: item.data.channelName,
+ pubDate: timezone(parseDate(item.data.publishTime), +8),
+ link: item.data.moVideoPath ? item.data.sourceUrl : `${rootUrl}/json/content/${item.data.url.match(/\/pages\/(.*?)\.html/)[1]}.detailjson`,
+ video: item.data.moVideoPath,
+ cover: item.data.mCoverImg,
+ }));
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (item.video) {
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ video: item.video,
+ cover: item.cover,
+ });
+ } else {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const data = detailResponse.data;
+
+ item.link = `${rootUrl}/#/detailsPage/${id}/${data.id}/1/${data.publishTime.split(' ')[0]}`;
+ item.description = data.txt;
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `参考消息 - ${channelResponse.data.name}`,
+ link: currentUrl,
+ description: '参考消息',
+ language: 'zh-cn',
+ item: items,
+ };
+}
diff --git a/lib/routes/cankaoxiaoxi/namespace.ts b/lib/routes/cankaoxiaoxi/namespace.ts
new file mode 100644
index 00000000000000..8520e948b2e095
--- /dev/null
+++ b/lib/routes/cankaoxiaoxi/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '参考消息',
+ url: 'cankaoxiaoxi.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/cankaoxiaoxi/templates/description.art b/lib/routes/cankaoxiaoxi/templates/description.art
similarity index 100%
rename from lib/v2/cankaoxiaoxi/templates/description.art
rename to lib/routes/cankaoxiaoxi/templates/description.art
diff --git a/lib/routes/capitalmind/insights.ts b/lib/routes/capitalmind/insights.ts
new file mode 100644
index 00000000000000..4ed414609d0a7f
--- /dev/null
+++ b/lib/routes/capitalmind/insights.ts
@@ -0,0 +1,41 @@
+import type { Data, Route } from '@/types';
+
+import { baseUrl, fetchArticles } from './utils';
+
+export const route: Route = {
+ path: '/insights',
+ example: '/capitalmind/insights',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['capitalmind.in/insights'],
+ target: '/insights',
+ },
+ ],
+ name: 'Insights',
+ maintainers: ['Rjnishant530'],
+ handler,
+};
+
+async function handler() {
+ const items = await fetchArticles('insights');
+
+ return {
+ title: 'Capitalmind Insights',
+ link: `${baseUrl}/insights`,
+ description: 'Financial insights and analysis from Capitalmind',
+ language: 'en',
+ item: items,
+ allowEmpty: false,
+ image: `${baseUrl}/favicons/favicon.ico`,
+ icon: `${baseUrl}/favicons/favicon.ico`,
+ logo: `${baseUrl}/favicons/favicon.ico`,
+ } as Data;
+}
diff --git a/lib/routes/capitalmind/namespace.ts b/lib/routes/capitalmind/namespace.ts
new file mode 100644
index 00000000000000..ad60ac8f1a9024
--- /dev/null
+++ b/lib/routes/capitalmind/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Capitalmind',
+ url: 'capitalmind.in',
+ lang: 'en',
+ categories: ['finance'],
+};
diff --git a/lib/routes/capitalmind/podcasts.ts b/lib/routes/capitalmind/podcasts.ts
new file mode 100644
index 00000000000000..4a1bfa7d863457
--- /dev/null
+++ b/lib/routes/capitalmind/podcasts.ts
@@ -0,0 +1,42 @@
+import type { Data, Route } from '@/types';
+
+import { baseUrl, fetchArticles } from './utils';
+
+export const route: Route = {
+ path: '/podcasts',
+ example: '/capitalmind/podcasts',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: true,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['capitalmind.in/podcasts'],
+ target: '/podcasts',
+ },
+ ],
+ name: 'Podcasts',
+ maintainers: ['Rjnishant530'],
+ handler,
+};
+
+async function handler() {
+ const items = await fetchArticles('podcasts');
+
+ return {
+ title: 'Capitalmind Podcasts',
+ link: `${baseUrl}/podcasts`,
+ description: 'Podcasts from Capitalmind on investing and finance',
+ language: 'en',
+ item: items,
+ allowEmpty: false,
+ itunes_author: 'Capitalmind',
+ image: `${baseUrl}/favicons/apple-touch-icon.png`,
+ icon: `${baseUrl}/favicons/favicon.ico`,
+ logo: `${baseUrl}/favicons/favicon.ico`,
+ } as Data;
+}
diff --git a/lib/routes/capitalmind/utils.ts b/lib/routes/capitalmind/utils.ts
new file mode 100644
index 00000000000000..5f92b42769115a
--- /dev/null
+++ b/lib/routes/capitalmind/utils.ts
@@ -0,0 +1,123 @@
+import { load } from 'cheerio';
+
+import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+export const baseUrl = 'https://www.capitalmind.in';
+
+export async function fetchArticles(path) {
+ const url = `${baseUrl}/${path}/page/1`;
+ const response = await ofetch(url);
+ const $ = load(response);
+
+ const articlePromises = $('.article-wrapper a.article-card-wrapper')
+ .toArray()
+ .map(async (element) => {
+ const $element = $(element);
+ const link = baseUrl + $element.attr('href');
+ return await cache.tryGet(link, async () => {
+ const title = $element.find('h3').text().trim();
+ const author = $element
+ .find(String.raw`div.text-[16px]`)
+ .text()
+ .trim();
+ const image = $element.find('img').attr('src');
+ const imageUrl = image?.startsWith('/_next/image') ? image.split('url=')[1].split('&')[0] : image;
+ const decodedImageUrl = imageUrl ? decodeURIComponent(imageUrl) : '';
+
+ // Fetch full article content
+ const articleResponse = await ofetch(link);
+ const $articlePage = load(articleResponse);
+ const $article = $articlePage('article').clone();
+
+ // Extract tags from footer
+ const tags: string[] = $article
+ .find('footer div')
+ .toArray()
+ .map((el) => {
+ const $el = $articlePage(el);
+ $el.find('.sr-only').remove();
+ const tag = $el.text().trim();
+ return tag;
+ })
+ .filter(Boolean);
+
+ // Extract publication date from header
+ let pubDate = '';
+ const $header = $article.find('header');
+ const $time = $header.find('time');
+ if ($time.length) {
+ pubDate = $time.attr('datetime') || $time.text().trim();
+ }
+
+ const $content = $article.find('section[aria-label="Post content"]').clone();
+
+ // Remove footer
+ $content.find('footer').remove();
+
+ // Process Libsyn podcast iframe (assuming only one)
+ let podcastData: { mediaUrl?: string; itunes_duration?: number; image?: string } = {};
+
+ const $iframe = $content.find('iframe[src*="libsyn.com/embed/episode/id/"]');
+ if ($iframe.length) {
+ const src = $iframe.attr('src');
+ if (src) {
+ const idMatch = src.match(/\/id\/(\d+)\//);
+ if (idMatch && idMatch[1]) {
+ const episodeId = idMatch[1];
+ try {
+ const episodeData = await ofetch(`https://html5-player.libsyn.com/api/episode/id/${episodeId}`);
+ if (episodeData && episodeData._item && episodeData._item._primary_content) {
+ podcastData = {
+ mediaUrl: episodeData._item._primary_content._download_url,
+ image: `https://assets.libsyn.com/item/${episodeId}`,
+ itunes_duration: episodeData._item._primary_content.duration,
+ };
+ }
+ } catch {
+ logger.info(`Failed to fetch podcast data for episode ID ${episodeId}`);
+ }
+ }
+ }
+ }
+
+ // Convert relative image URLs to absolute URLs only in figure tags
+ // and remove srcset attribute
+ $content.find('figure img').each((_, img) => {
+ const $img = $articlePage(img);
+ const src = $img.attr('src');
+
+ // Remove srcset attribute
+ $img.removeAttr('srcset');
+
+ if (src && src.startsWith('/_next/image')) {
+ // Extract the original URL from the Next.js image URL
+ const urlMatch = src.match(/url=([^&]+)/);
+ if (urlMatch && urlMatch[1]) {
+ const originalUrl = decodeURIComponent(urlMatch[1]);
+ $img.attr('src', originalUrl);
+ } else if (src.startsWith('/')) {
+ // Handle other relative URLs
+ $img.attr('src', baseUrl + src);
+ }
+ }
+ });
+ return {
+ title,
+ link,
+ author,
+ description: $content.html() || `
Author: ${author}
`,
+ guid: link,
+ itunes_item_image: podcastData?.image || decodedImageUrl,
+ category: tags,
+ pubDate,
+ enclosure_url: podcastData?.mediaUrl || null,
+ itunes_duration: podcastData?.itunes_duration || null,
+ enclosure_type: podcastData?.mediaUrl ? 'audio/mpeg' : null,
+ } as DataItem;
+ });
+ });
+
+ return Promise.all(articlePromises);
+}
diff --git a/lib/routes/cara/constant.ts b/lib/routes/cara/constant.ts
new file mode 100644
index 00000000000000..4f1b7bec4fcdd1
--- /dev/null
+++ b/lib/routes/cara/constant.ts
@@ -0,0 +1,5 @@
+export const HOST = 'https://cara.app';
+
+export const API_HOST = `${HOST}/api`;
+
+export const CDN_HOST = 'https://cdn.cara.app';
diff --git a/lib/routes/cara/likes.ts b/lib/routes/cara/likes.ts
new file mode 100644
index 00000000000000..30b82896dc0ae4
--- /dev/null
+++ b/lib/routes/cara/likes.ts
@@ -0,0 +1,55 @@
+import path from 'node:path';
+
+import type { Data, DataItem, Route } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { API_HOST, CDN_HOST, HOST } from './constant';
+import type { PostsResponse } from './types';
+import { customFetch, parseUserData } from './utils';
+
+export const route: Route = {
+ path: ['/likes/:user'],
+ categories: ['social-media'],
+ example: '/cara/likes/fengz',
+ parameters: { user: 'username' },
+ name: 'Likes',
+ maintainers: ['KarasuShin'],
+ handler,
+ radar: [
+ {
+ source: ['cara.app/:user', 'cara.app/:user/*'],
+ target: '/likes/:user',
+ },
+ ],
+};
+
+async function handler(ctx): Promise {
+ const user = ctx.req.param('user');
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 15;
+ const userInfo = await parseUserData(user);
+
+ const api = `${API_HOST}/posts/getAllLikesByUser?slug=${userInfo.slug}&take=${limit}`;
+
+ const timelineResponse = await customFetch(api);
+
+ const items = timelineResponse.data.map((item) => {
+ const description = art(path.join(__dirname, 'templates/post.art'), {
+ content: item.content,
+ images: item.images.filter((i) => !i.isCoverImg).map((i) => ({ ...i, src: `${CDN_HOST}/${i.src}` })),
+ });
+ return {
+ title: item.title || item.content,
+ pubDate: parseDate(item.createdAt),
+ link: `${HOST}/post/${item.id}`,
+ description,
+ } as DataItem;
+ });
+
+ return {
+ title: `Likes - ${userInfo.name}`,
+ link: `${HOST}/${user}/likes`,
+ image: `${CDN_HOST}/${userInfo.photo}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/cara/namespace.ts b/lib/routes/cara/namespace.ts
new file mode 100644
index 00000000000000..aaeac1780bee95
--- /dev/null
+++ b/lib/routes/cara/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Cara',
+ url: 'cara.app',
+ lang: 'en',
+};
diff --git a/lib/routes/cara/portfolio.ts b/lib/routes/cara/portfolio.ts
new file mode 100644
index 00000000000000..48e57f2f7bddec
--- /dev/null
+++ b/lib/routes/cara/portfolio.ts
@@ -0,0 +1,41 @@
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+
+import { API_HOST, CDN_HOST, HOST } from './constant';
+import type { PortfolioResponse } from './types';
+import { customFetch, fetchPortfolioItem, parseUserData } from './utils';
+
+export const route: Route = {
+ path: ['/portfolio/:user'],
+ categories: ['social-media'],
+ example: '/cara/portfolio/fengz',
+ parameters: { user: 'username' },
+ name: 'Portfolio',
+ maintainers: ['KarasuShin'],
+ handler,
+ radar: [
+ {
+ source: ['cara.app/:user', 'cara.app/:user/*'],
+ target: '/portfolio/:user',
+ },
+ ],
+};
+
+async function handler(ctx): Promise {
+ const user = ctx.req.param('user');
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 15;
+ const userInfo = await parseUserData(user);
+
+ const api = `${API_HOST}/profiles/portfolio?id=${userInfo.id}&take=${limit}`;
+
+ const portfolioResponse = await customFetch(api);
+
+ const items = await Promise.all(portfolioResponse.data.map((item) => cache.tryGet(`${HOST}/post/${item.postId}`, async () => await fetchPortfolioItem(item)) as unknown as DataItem));
+
+ return {
+ title: `Portfolio - ${userInfo.name}`,
+ link: `${HOST}/${user}/portfolio`,
+ image: `${CDN_HOST}/${userInfo.photo}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/cara/templates/post.art b/lib/routes/cara/templates/post.art
new file mode 100644
index 00000000000000..2cceed7e5f4f9f
--- /dev/null
+++ b/lib/routes/cara/templates/post.art
@@ -0,0 +1,6 @@
+{{ if content }}
+{{ content }}
+{{ /if }}
+{{ each images image }}
+
+{{ /each }}
diff --git a/lib/routes/cara/timeline.ts b/lib/routes/cara/timeline.ts
new file mode 100644
index 00000000000000..00ff39209dc2cd
--- /dev/null
+++ b/lib/routes/cara/timeline.ts
@@ -0,0 +1,55 @@
+import path from 'node:path';
+
+import type { Data, DataItem, Route } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { API_HOST, CDN_HOST, HOST } from './constant';
+import type { PostsResponse } from './types';
+import { customFetch, parseUserData } from './utils';
+
+export const route: Route = {
+ path: ['/timeline/:user'],
+ categories: ['social-media'],
+ example: '/cara/timeline/fengz',
+ parameters: { user: 'username' },
+ name: 'Timeline',
+ maintainers: ['KarasuShin'],
+ handler,
+ radar: [
+ {
+ source: ['cara.app/:user', 'cara.app/:user/*'],
+ target: '/timeline/:user',
+ },
+ ],
+};
+
+async function handler(ctx): Promise {
+ const user = ctx.req.param('user');
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 15;
+ const userInfo = await parseUserData(user);
+
+ const api = `${API_HOST}/posts/getAllByUser?slug=${userInfo.slug}&take=${limit}`;
+
+ const timelineResponse = await customFetch(api);
+
+ const items = timelineResponse.data.map((item) => {
+ const description = art(path.join(__dirname, 'templates/post.art'), {
+ content: item.content,
+ images: item.images.filter((i) => !i.isCoverImg).map((i) => ({ ...i, src: `${CDN_HOST}/${i.src}` })),
+ });
+ return {
+ title: item.title || item.content,
+ pubDate: parseDate(item.createdAt),
+ link: `${HOST}/post/${item.id}`,
+ description,
+ } as DataItem;
+ });
+
+ return {
+ title: `Timeline - ${userInfo.name}`,
+ link: `${HOST}/${user}/all`,
+ image: `${CDN_HOST}/${userInfo.photo}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/cara/types.ts b/lib/routes/cara/types.ts
new file mode 100644
index 00000000000000..7d3aea28a023c3
--- /dev/null
+++ b/lib/routes/cara/types.ts
@@ -0,0 +1,45 @@
+export interface UserNextData {
+ pageProps: {
+ user: {
+ id: string;
+ name: string;
+ slug: string;
+ photo: string;
+ };
+ };
+}
+
+export interface PortfolioResponse {
+ data: {
+ url: string;
+ postId: string;
+ imageNum: number;
+ }[];
+}
+
+export interface PortfolioDetailResponse {
+ data: {
+ createdAt: string;
+ images: {
+ src: string;
+ isCoverImg: boolean;
+ }[];
+ title: string;
+ content: string;
+ };
+}
+
+export interface PostsResponse {
+ data: {
+ name: string;
+ photo: string;
+ createdAt: string;
+ images: {
+ src: string;
+ isCoverImg: boolean;
+ }[];
+ id: string;
+ title: string;
+ content: string;
+ }[];
+}
diff --git a/lib/routes/cara/utils.ts b/lib/routes/cara/utils.ts
new file mode 100644
index 00000000000000..39c2aa74b71a89
--- /dev/null
+++ b/lib/routes/cara/utils.ts
@@ -0,0 +1,55 @@
+import { load } from 'cheerio';
+import type { FetchOptions, FetchRequest, ResponseType } from 'ofetch';
+
+import { config } from '@/config';
+import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { API_HOST, CDN_HOST, HOST } from './constant';
+import type { PortfolioDetailResponse, PortfolioResponse, UserNextData } from './types';
+
+export function customFetch(request: FetchRequest, options?: FetchOptions) {
+ return ofetch(request, {
+ ...options,
+ headers: {
+ 'user-agent': config.trueUA,
+ },
+ });
+}
+
+export async function parseUserData(user: string) {
+ const buildId = await cache.tryGet(
+ `${HOST}:buildId`,
+ async () => {
+ const res = await customFetch(`${HOST}/explore`);
+ const $ = load(res);
+ return JSON.parse($('#__NEXT_DATA__')?.text() ?? '{}').buildId;
+ },
+ config.cache.routeExpire,
+ false
+ );
+ return (await cache.tryGet(`${HOST}:${user}`, async () => {
+ const data = await customFetch(`${HOST}/_next/data/${buildId}/${user}.json`);
+ return data.pageProps.user;
+ })) as Promise;
+}
+
+export async function fetchPortfolioItem(item: PortfolioResponse['data'][number]) {
+ const res = await customFetch(`${API_HOST}/posts/${item.postId}`);
+
+ const description = res.data.images
+ .filter((i) => !i.isCoverImg)
+ .map((image) => ` `)
+ .join(' ');
+
+ const dataItem: DataItem = {
+ title: res.data.title || res.data.content,
+ pubDate: parseDate(res.data.createdAt),
+ link: `${HOST}/post/${item.postId}`,
+ description,
+ };
+
+ return dataItem;
+}
diff --git a/lib/routes/carousell/index.ts b/lib/routes/carousell/index.ts
new file mode 100644
index 00000000000000..8657d8853759a0
--- /dev/null
+++ b/lib/routes/carousell/index.ts
@@ -0,0 +1,302 @@
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import type { ListingCard } from './types';
+
+export const route: Route = {
+ path: '/:region/:keyword',
+ categories: ['shopping'],
+ example: '/carousell/sg/iphone',
+ parameters: {
+ region: {
+ description: 'Region code',
+ options: [
+ { value: 'au', label: 'Australia' },
+ { value: 'ca', label: 'Canada' },
+ { value: 'hk', label: 'Hong Kong' },
+ { value: 'id', label: 'Indonesia' },
+ { value: 'my', label: 'Malaysia' },
+ { value: 'nz', label: 'New Zealand' },
+ { value: 'ph', label: 'Philippines' },
+ { value: 'sg', label: 'Singapore' },
+ { value: 'tw', label: 'Taiwan' },
+ ],
+ },
+ keyword: {
+ description: 'Search keyword',
+ },
+ },
+ name: 'Keyword Search',
+ maintainers: ['TonyRL'],
+ handler,
+ radar: [
+ { source: ['au.carousell.com/search/:keyword'], target: '/au/:keyword' },
+ { source: ['ca.carousell.com/search/:keyword'], target: '/ca/:keyword' },
+ { source: ['www.carousell.com.hk/search/:keyword'], target: '/hk/:keyword' },
+ { source: ['id.carousell.com/search/:keyword'], target: '/id/:keyword' },
+ { source: ['www.carousell.com.my/search/:keyword'], target: '/my/:keyword' },
+ { source: ['nz.carousell.com/search/:keyword'], target: '/nz/:keyword' },
+ { source: ['www.carousell.ph/search/:keyword'], target: '/ph/:keyword' },
+ { source: ['www.carousell.sg/search/:keyword'], target: '/sg/:keyword' },
+ { source: ['tw.carousell.com/search/:keyword'], target: '/tw/:keyword' },
+ ],
+};
+
+const regionMap = {
+ au: {
+ api: 'https://au.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://au.carousell.com',
+ payload: {
+ bestMatchEnabled: false,
+ canChangeKeyword: false,
+ ccid: '1071',
+ count: 48,
+ countryCode: 'AU',
+ countryId: '2077456',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' }, // most recent
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+
+ referer: (query) => `https://au.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ ca: {
+ api: 'https://ca.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://ca.carousell.com',
+ payload: {
+ bestMatchEnabled: false,
+ canChangeKeyword: false,
+ ccid: '976',
+ count: 48,
+ countryCode: 'CA',
+ countryId: '6251999',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://ca.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ hk: {
+ api: 'https://www.carousell.com.hk/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://www.carousell.com.hk',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: false,
+ ccid: '5365',
+ count: 48,
+ countryCode: 'HK',
+ countryId: '1819730',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'zh-Hant-HK',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://www.carousell.com.hk/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ id: {
+ api: 'https://id.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://id.carousell.com',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: false,
+ ccid: '726',
+ count: 48,
+ countryCode: 'ID',
+ countryId: '1643084',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'id',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://id.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ my: {
+ api: 'https://www.carousell.com.my/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://www.carousell.com.my',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: false,
+ ccid: '6003',
+ count: 48,
+ countryCode: 'MY',
+ countryId: '1733045',
+ filters: [{ boolean: { value: false }, fieldName: '_delivery' }],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: true,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://www.carousell.com.my/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ nz: {
+ api: 'https://nz.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://nz.carousell.com',
+ payload: {
+ bestMatchEnabled: false,
+ canChangeKeyword: false,
+ ccid: '1414',
+ count: 48,
+ countryCode: 'NZ',
+ countryId: '2186224',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://nz.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ ph: {
+ api: 'https://www.carousell.ph/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://www.carousell.ph',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: 'true',
+ ccid: '5050',
+ count: 48,
+ countryCode: 'PH',
+ countryId: '1694008',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://www.carousell.ph/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ sg: {
+ api: 'https://www.carousell.sg/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://www.carousell.sg',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: 'true',
+ ccid: '5727',
+ count: 48,
+ countryCode: 'SG',
+ countryId: '1880251',
+ filters: [{ boolean: { value: false }, fieldName: '_delivery' }],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://www.carousell.sg/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ tw: {
+ api: 'https://tw.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://tw.carousell.com',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: false,
+ ccid: '6445',
+ count: 48,
+ countryCode: 'TW',
+ countryId: '1668284',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'zh-Hant-TW',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://tw.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+};
+
+async function handler(ctx): Promise {
+ const { region, keyword } = ctx.req.param();
+
+ if (!Object.keys(regionMap).includes(region)) {
+ throw new Error(`Unsupported region code: ${region}`);
+ }
+
+ const baseUrl = regionMap[region].baseUrl;
+ const siteResponse = await ofetch.raw(baseUrl);
+ const cookies = siteResponse.headers
+ .getSetCookie()
+ ?.map((c) => c.split(';')[0])
+ .join('; ');
+ const csrfToken = siteResponse._data.match(/"csrfToken":"(.*?)","/)[1];
+
+ const response = await ofetch(regionMap[region].api, {
+ method: 'POST',
+ headers: {
+ cookie: cookies,
+ 'csrf-token': csrfToken,
+ referer: regionMap[region].referer(keyword),
+ },
+ body: {
+ ...regionMap[region].payload,
+ query: keyword,
+ },
+ });
+
+ const items = response.data.results
+ .filter((i) => i.listingCard)
+ .map(({ listingCard: item }: { listingCard: ListingCard }) => ({
+ title: item.title,
+ description: `${item.photoUrls.map((url) => ` `).join('')} ${Object.values(item.belowFold)
+ .map((v) => v.stringContent.replaceAll('\r', ' '))
+ .join(' ')}`,
+ author: `${item.seller.firstName ?? ''} ${item.seller.lastName ?? ''} (@${item.seller.username})`.trim(),
+ pubDate: parseDate(item.overlayContent.timestampContent.seconds.low, 'X'),
+ guid: `${region}:${item.id}`,
+ link: `${baseUrl}/p/${item.id}/`,
+ })) satisfies DataItem[];
+
+ return {
+ title: `Carousell ${region.toUpperCase()} Search - ${keyword}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/carousell/namespace.ts b/lib/routes/carousell/namespace.ts
new file mode 100644
index 00000000000000..a61c1d2b5b506e
--- /dev/null
+++ b/lib/routes/carousell/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Carousell',
+ url: 'carousell.com',
+ lang: 'en',
+};
diff --git a/lib/routes/carousell/types.ts b/lib/routes/carousell/types.ts
new file mode 100644
index 00000000000000..6637c25134719d
--- /dev/null
+++ b/lib/routes/carousell/types.ts
@@ -0,0 +1,126 @@
+type TimestampSeconds = {
+ low: number;
+ high: number;
+ unsigned: boolean;
+};
+type TimestampContent = {
+ seconds: TimestampSeconds;
+};
+type Seller = {
+ id: string;
+ profilePicture: string;
+ username: string;
+ firstName?: string;
+ lastName?: string;
+};
+type AboveFoldItem = {
+ component: string;
+ timestampContent: TimestampContent;
+};
+type BelowFoldItem = {
+ component: string;
+ stringContent: string;
+};
+type MarketPlaceId = {
+ low: number;
+ high: number;
+ unsigned: boolean;
+};
+type CountryId = {
+ low: number;
+ high: number;
+ unsigned: boolean;
+};
+type Country = {
+ code: string;
+ id: CountryId;
+ name: string;
+};
+type Location = {
+ longitude: number;
+ latitude: number;
+};
+type MarketPlace = {
+ id: MarketPlaceId;
+ name: string;
+ country: Country;
+ location: Location;
+};
+type Photo = {
+ thumbnailUrl: string;
+ thumbnailProgressiveUrl: string;
+ thumbnailProgressiveLowRange: number;
+ thumbnailProgressiveMediumRange: number;
+ thumbnailHeight: number;
+ thumbnailWidth: number;
+};
+type PhotoItem = {
+ url: string;
+ progressiveUrl: string;
+ progressiveLowRange: number;
+ progressiveMediumRange: number;
+ height: number;
+ width: number;
+};
+type VideoThumbnail = {
+ url: string;
+ progressiveUrl: string;
+ progressiveLowRange: number;
+ progressiveMediumRange: number;
+ height: number;
+ width: number;
+};
+type SupportedFormat = {
+ dash: string;
+ hls: string;
+};
+type PlayConfig = {
+ isLoop: boolean;
+ onlyWifi: boolean;
+ isAutoPlay: boolean;
+ isMuted: boolean;
+};
+type VideoItem = {
+ thumbnail: VideoThumbnail;
+ supportedFormat: SupportedFormat;
+ playConfig: PlayConfig;
+};
+type MediaItem = {
+ photoItem?: PhotoItem;
+ videoItem?: VideoItem;
+};
+type OverlayContent = {
+ timestampContent: TimestampContent;
+ iconUrl?: {
+ value: string;
+ };
+};
+type OriginalPrice = {
+ value: string;
+};
+type Tag = {
+ content: string;
+ backgroundColor: string;
+ fontColor: string;
+};
+export type ListingCard = {
+ id: string;
+ seller: Seller;
+ photoUrls: string[];
+ aboveFold: AboveFoldItem[];
+ belowFold: BelowFoldItem[];
+ status: string;
+ marketPlace: MarketPlace;
+ photos: Photo[];
+ media: MediaItem[];
+ price: string;
+ title: string;
+ overlayContent: OverlayContent;
+ isSellerVisible: boolean;
+ countryCollectionId: string;
+ ctaButtons: string[];
+ likesCount?: number;
+ originalPrice?: OriginalPrice;
+ cardType?: number;
+ tags?: Tag[];
+};
diff --git a/lib/routes/cartoonmad/comic.ts b/lib/routes/cartoonmad/comic.ts
new file mode 100644
index 00000000000000..75eddc6f08034f
--- /dev/null
+++ b/lib/routes/cartoonmad/comic.ts
@@ -0,0 +1,97 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const baseUrl = 'https://www.cartoonmad.com';
+const KEY = '5e585';
+
+const loadContent = (id, { chapter, pages }) => {
+ let description = '';
+ for (let page = 1; page <= pages; page++) {
+ const url = `${baseUrl}/${KEY}/${id}/${chapter}/${String(page).padStart(3, '0')}.jpg`;
+ description += art(path.join(__dirname, 'templates/chapter.art'), {
+ url,
+ });
+ }
+ return description;
+};
+
+const getChapters = (id, list, tryGet) =>
+ Promise.all(
+ list.map((item) =>
+ tryGet(item.link, () => {
+ item.description = loadContent(id, item);
+
+ return item;
+ })
+ )
+ );
+
+export const route: Route = {
+ path: '/comic/:id',
+ categories: ['anime'],
+ example: '/cartoonmad/comic/5827',
+ parameters: { id: '漫画ID' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cartoonmad.com/comic/:id'],
+ },
+ ],
+ name: '漫画更新',
+ maintainers: ['KellyHwong'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const link = `${baseUrl}/comic/${id}`;
+
+ const { data } = await got(link, {
+ responseType: 'buffer',
+ headers: {
+ Referer: 'https://www.cartoonmad.com/',
+ },
+ });
+ const content = iconv.decode(data, 'big5');
+ const $ = load(content);
+
+ const bookIntro = $('#info').eq(0).find('td').text().trim();
+ // const coverImgSrc = $('.cover').parent().find('img').attr('src');
+ const list = $('#info')
+ .eq(1)
+ .find('a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.text(),
+ link: `${baseUrl}${item.attr('href')}`,
+ chapter: item.text().match(/\d+/)[0],
+ pages: item.next('font').text().match(/\d+/)[0],
+ };
+ })
+ .toReversed();
+
+ const chapters = await getChapters(id, list, cache.tryGet);
+
+ return {
+ title: $('head title').text(),
+ link,
+ description: bookIntro,
+ item: chapters,
+ };
+}
diff --git a/lib/routes/cartoonmad/namespace.ts b/lib/routes/cartoonmad/namespace.ts
new file mode 100644
index 00000000000000..d267db9e5f53a4
--- /dev/null
+++ b/lib/routes/cartoonmad/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '動漫狂',
+ url: 'cartoonmad.com',
+ lang: 'zh-TW',
+};
diff --git a/lib/v2/cartoonmad/templates/chapter.art b/lib/routes/cartoonmad/templates/chapter.art
similarity index 100%
rename from lib/v2/cartoonmad/templates/chapter.art
rename to lib/routes/cartoonmad/templates/chapter.art
diff --git a/lib/routes/cas/cg/index.ts b/lib/routes/cas/cg/index.ts
new file mode 100644
index 00000000000000..a4aba4e7c784d8
--- /dev/null
+++ b/lib/routes/cas/cg/index.ts
@@ -0,0 +1,81 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/cg/:caty?',
+ categories: ['university'],
+ example: '/cas/cg/cgzhld',
+ parameters: { caty: '分类,见下表,默认为工作动态' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.cas.cn/cg/:caty?'],
+ },
+ ],
+ name: '成果转化',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 工作动态 | 科技成果转移转化亮点工作 |
+| -------- | ------------------------ |
+| zh | cgzhld |`,
+};
+
+async function handler(ctx) {
+ const caty = ctx.req.param('caty') || 'zh';
+
+ const rootUrl = 'https://www.cas.cn';
+ const currentUrl = `${rootUrl}/cg/${caty}/`;
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+ const list = $('#content li')
+ .not('.gl_line')
+ .slice(0, 15)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a');
+ return {
+ title: a.text(),
+ link: `${rootUrl}/cg/${caty}${a.attr('href').replace('.', '')}`,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const content = load(detailResponse.data);
+
+ item.description = content('.TRS_Editor').html();
+ item.pubDate = timezone(parseDate(content('meta[name="PubDate"]').attr('content')), 8);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text().replace('----', ' - '),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/cas/genetics/index.ts b/lib/routes/cas/genetics/index.ts
new file mode 100644
index 00000000000000..842288e94b7e65
--- /dev/null
+++ b/lib/routes/cas/genetics/index.ts
@@ -0,0 +1,72 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'https://genetics.cas.cn';
+
+export const route: Route = {
+ path: '/genetics/:path{.+}',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const path = ctx.req.param('path');
+
+ const currentUrl = `${baseUrl}/${path}/`;
+
+ const { data: response } = await got(currentUrl);
+ const $ = load(response);
+
+ let items;
+
+ if (path.slice(0, 3) === 'edu') {
+ items = $('li.box-s.h16')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a').first();
+ const date = item.find('.box-date').first();
+ return {
+ title: a.text(),
+ link: new URL(a.attr('href'), currentUrl).href,
+ pubDate: parseDate(date.text(), 'YYYY-MM-DD'),
+ };
+ });
+ } else if (path.slice(0, 4) === 'dqyd') {
+ items = $('div.list-tab ul li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a').first();
+ const date = item.find('.right').first();
+ return {
+ title: a.text(),
+ link: new URL(a.attr('href'), currentUrl).href,
+ pubDate: parseDate(date.text(), 'YYYY-MM-DD'),
+ };
+ });
+ } else {
+ items = $('li.row.no-gutters.py-1')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a').first();
+ const date = item.find('.col-news-date').first();
+ return {
+ title: a.text(),
+ link: new URL(a.attr('href'), currentUrl).href,
+ pubDate: parseDate(date.text(), 'YYYY.MM.DD'),
+ };
+ });
+ }
+
+ return {
+ title: $('head title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/cas/ia/yjs.ts b/lib/routes/cas/ia/yjs.ts
new file mode 100644
index 00000000000000..d37b8a99c8b3e5
--- /dev/null
+++ b/lib/routes/cas/ia/yjs.ts
@@ -0,0 +1,51 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/ia/yjs',
+ categories: ['university'],
+ example: '/cas/ia/yjs',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.ia.cas.cn/yjsjy/zs/sszs', 'www.ia.cas.cn/'],
+ },
+ ],
+ name: '自动化所',
+ maintainers: ['shengmaosu'],
+ handler,
+ url: 'www.ia.cas.cn/yjsjy/zs/sszs',
+};
+
+async function handler() {
+ const link = 'http://www.ia.cas.cn/yjsjy/zs/sszs/';
+ const response = await got(link);
+ const $ = load(response.data);
+ const list = $('.col-md-9 li');
+
+ return {
+ title: '中科院自动化所',
+ link,
+ description: '中科院自动化所通知公告',
+ item:
+ list &&
+ list.toArray().map((item) => {
+ item = $(item);
+ return {
+ title: item.find('li a').text(),
+ description: item.find('li a').text(),
+ link: item.find('li a').attr('href'),
+ };
+ }),
+ };
+}
diff --git a/lib/routes/cas/iee/kydt.ts b/lib/routes/cas/iee/kydt.ts
new file mode 100644
index 00000000000000..8d5e23ffc15ed3
--- /dev/null
+++ b/lib/routes/cas/iee/kydt.ts
@@ -0,0 +1,75 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/iee/kydt',
+ categories: ['university'],
+ example: '/cas/iee/kydt',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.iee.cas.cn/xwzx/kydt', 'www.iee.cas.cn/'],
+ },
+ ],
+ name: '电工研究所 科研动态',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'www.iee.cas.cn/xwzx/kydt',
+};
+
+async function handler() {
+ const rootUrl = 'http://www.iee.cas.cn/xwzx/kydt/';
+ const response = await got({
+ method: 'get',
+ url: rootUrl,
+ });
+
+ const $ = load(response.data);
+ const list = $('li.entry .entry-content-title')
+ .slice(0, 15)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a');
+ return {
+ title: a.text(),
+ link: a.attr('href'),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const content = load(detailResponse.data);
+
+ item.description = content('.article-content').html();
+ item.pubDate = timezone(parseDate(content('time').text().split(':')[1]), 8);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '科研成果 - 中国科学院电工研究所',
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/cas/is/index.ts b/lib/routes/cas/is/index.ts
new file mode 100644
index 00000000000000..ec7390918db578
--- /dev/null
+++ b/lib/routes/cas/is/index.ts
@@ -0,0 +1,54 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'https://is.cas.cn';
+
+export const route: Route = {
+ path: '/is/:path{.+}',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const path = ctx.req.param('path');
+ const response = await got(`${baseUrl}/${path}/`);
+
+ const $ = load(response.data);
+ const items = $('.list-news ul li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('a').text(),
+ link: new URL(item.find('a').attr('href'), response.url).href,
+ pubDate: parseDate(item.find('span').text().replaceAll('[]', '')),
+ };
+ });
+
+ await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (!item.link.startsWith(`${baseUrl}/`)) {
+ return item;
+ }
+
+ const response = await got(item.link);
+ const $ = load(response.data);
+
+ item.description = $('.TRS_Editor').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('head title').text(),
+ link: `${baseUrl}/${path}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/cas/mesalab/kb.ts b/lib/routes/cas/mesalab/kb.ts
new file mode 100644
index 00000000000000..35bebdfa897bf9
--- /dev/null
+++ b/lib/routes/cas/mesalab/kb.ts
@@ -0,0 +1,68 @@
+import * as cheerio from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/mesalab/kb',
+ categories: ['university'],
+ example: '/cas/mesalab/kb',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.mesalab.cn/f/article/articleList', 'www.mesalab.cn/'],
+ },
+ ],
+ name: '信息工程研究所 第二研究室 处理架构组 知识库',
+ maintainers: ['renzhexigua'],
+ handler,
+ url: 'www.mesalab.cn/f/article/articleList',
+};
+
+async function handler() {
+ const homepage = 'https://www.mesalab.cn';
+ const url = `${homepage}/f/article/articleList?pageNo=1&pageSize=15&createTimeSort=DESC`;
+ const response = await got(url);
+
+ const $ = cheerio.load(response.data);
+ const articles = $('.aw-item').toArray();
+
+ const items = await Promise.all(
+ articles.map((item) => {
+ const a = $(item).find('a').first();
+ const title = a.text().trim();
+ const link = `${homepage}${a.attr('href')}`;
+
+ return cache.tryGet(link, async () => {
+ const result = await got(link);
+ const $ = cheerio.load(result.data);
+ return {
+ title,
+ author: $('.user_name').text(),
+ pubDate: timezone(parseDate($('.link_postdate').text().replaceAll(/\s+/g, ' ')), 8),
+ description: $('#article_content').html() + ($('.attachment').length ? $('.attachment').html() : ''),
+ link,
+ category: $('.category .category_r span').first().text(),
+ };
+ });
+ })
+ );
+
+ return {
+ title: 'MESA 知识库',
+ description: '中国科学院信息工程研究所 第二研究室 处理架构组',
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/cas/namespace.ts b/lib/routes/cas/namespace.ts
new file mode 100644
index 00000000000000..5cd8916c10051b
--- /dev/null
+++ b/lib/routes/cas/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国科学院',
+ url: 'www.cas.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/cas/sim/kyjz.ts b/lib/routes/cas/sim/kyjz.ts
new file mode 100644
index 00000000000000..116fc6ccb3165f
--- /dev/null
+++ b/lib/routes/cas/sim/kyjz.ts
@@ -0,0 +1,74 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const host = 'http://www.sim.cas.cn/';
+
+export const route: Route = {
+ path: '/sim/kyjz',
+ categories: ['university'],
+ example: '/cas/sim/kyjz',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.sim.cas.cn/xwzx2016/kyjz', 'www.sim.cas.cn/'],
+ },
+ ],
+ name: '上海微系统与信息技术研究所 科技进展',
+ maintainers: ['HenryQW'],
+ handler,
+ url: 'www.sim.cas.cn/xwzx2016/kyjz',
+};
+
+async function handler() {
+ const link = new URL('xwzx2016/kyjz/', host).href;
+ const response = await got(link);
+
+ const $ = load(response.data);
+
+ const list = $('.list-news li')
+ .toArray()
+ .filter((e) => !$(e).attr('style'))
+ .map((e) => {
+ e = $(e);
+ return {
+ link: new URL(e.find('a').attr('href'), link).href,
+ pubDate: e.find('span').text().replace('[', '').replace(']', ''),
+ };
+ });
+
+ const out = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+
+ const author = $('.qtinfo.hidden-lg.hidden-md.hidden-sm').text();
+ const reg = /文章来源:(.*?)\|/g;
+
+ item.title = $('p.wztitle').text().trim();
+ item.author = reg.exec(author)[1].toString().trim();
+ item.description = $('.TRS_Editor').html();
+ item.pubDate = parseDate(item.pubDate);
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '中国科学院上海微系统与信息技术研究所 -- 科技进展',
+ link,
+ item: out,
+ };
+}
diff --git a/lib/routes/casssp/namespace.ts b/lib/routes/casssp/namespace.ts
new file mode 100644
index 00000000000000..1ba8855ad0f181
--- /dev/null
+++ b/lib/routes/casssp/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国科学学与科技政策研究会',
+ url: 'casssp.org.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/casssp/news.ts b/lib/routes/casssp/news.ts
new file mode 100644
index 00000000000000..ec9e359fbb69ee
--- /dev/null
+++ b/lib/routes/casssp/news.ts
@@ -0,0 +1,83 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news/:category?',
+ categories: ['government'],
+ example: '/casssp/news/3',
+ parameters: { category: '分类,见下表,默认为通知公告' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '研究会动态',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 通知公告 | 新闻动态 | 信息公开 | 时政要闻 |
+| -------- | -------- | -------- | -------- |
+| 3 | 2 | 92 | 93 |`,
+};
+
+async function handler(ctx) {
+ const { category = '3' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 15;
+
+ const rootUrl = 'http://www.casssp.org.cn';
+ const currentUrl = new URL(`news/${category}/`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ let items = $('p.e_text-22.s_title a, p.e_text-18.s_title a')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text(),
+ link: new URL(item.prop('href'), rootUrl).href,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const content = load(detailResponse);
+
+ item.description = content('div.e_richText-25, div.e_richText-3').html();
+ item.pubDate = parseDate(`${content('p.e_timeFormat-15').text()}-${content('p.e_timeFormat-14').text()}-${content('p.e_timeFormat-11').text()}`);
+
+ return item;
+ })
+ )
+ );
+
+ const image = $('img[la="la"]').first().prop('src');
+ const icon = new URL($('link[rel="shortcut icon "]').prop('href'), rootUrl).href;
+
+ return {
+ item: items,
+ title: $('title').text(),
+ link: currentUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: $('html').prop('lang'),
+ image,
+ icon,
+ logo: icon,
+ subtitle: $('meta[name="keywords"]').prop('content'),
+ author: $('img[la="la"]').first().prop('title'),
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/cast/index.ts b/lib/routes/cast/index.ts
new file mode 100644
index 00000000000000..887283ca4d1408
--- /dev/null
+++ b/lib/routes/cast/index.ts
@@ -0,0 +1,117 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const baseUrl = 'https://www.cast.org.cn';
+
+async function parsePage(html: string) {
+ return await Promise.all(
+ load(html)('li')
+ .toArray()
+ .map((el) => {
+ const title = load(el)('a');
+ let articleUrl = title.attr('href');
+
+ if (articleUrl?.startsWith('http')) {
+ return {
+ title: title.text(),
+ link: title.attr('href'),
+ };
+ }
+ articleUrl = `${baseUrl}${title.attr('href')}`;
+
+ return cache.tryGet(articleUrl, async () => {
+ const res = await got.get(articleUrl!);
+ const article = load(res.data);
+ const pubDate = timezone(parseDate(article('meta[name=PubDate]').attr('content')!, 'YYYY-MM-DD HH:mm'), +8);
+
+ return {
+ title: title.text(),
+ pubDate,
+ description: article('#zoom').html(),
+ link: articleUrl,
+ };
+ });
+ })
+ );
+}
+
+export const route: Route = {
+ path: '/:column/:subColumn/:category?',
+ categories: ['government'],
+ example: '/cast/xw/tzgg/ZH',
+ parameters: { column: '栏目编号,见下表', subColumn: '二级栏目编号', category: '分类' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cast.org.cn/:column/:subColumn/:category/index.html', 'cast.org.cn/:column/:subColumn/index.html'],
+ target: '/:column/:subColumn/:category?',
+ },
+ ],
+ name: '通用',
+ maintainers: ['KarasuShin', 'TonyRL'],
+ handler,
+ description: `::: tip
+ 在路由末尾处加上 \`?limit=限制获取数目\` 来限制获取条目数量,默认值为\`10\`
+:::
+
+| 分类 | 编码 |
+| -------- | ---- |
+| 全景科协 | qjkx |
+| 智库 | zk |
+| 学术 | xs |
+| 科普 | kp |
+| 党建 | dj |
+| 数据 | sj |
+| 新闻 | xw |`,
+};
+
+async function handler(ctx) {
+ const { column, subColumn, category } = ctx.req.param();
+ const { limit = 10 } = ctx.req.query();
+ let link = `${baseUrl}/${column}/${subColumn}`;
+ if (category) {
+ link += `/${category}/index.html`;
+ }
+ const { data: indexData } = await got.get(link);
+
+ const $ = load(indexData);
+
+ let items: any[] = [];
+
+ // 新闻-视频首页特殊处理
+ if (column === 'xw' && subColumn === 'SP' && !category) {
+ items = await parsePage(indexData);
+ } else {
+ const buildUnitScript = $('script[parseType="bulidstatic"]');
+ const queryUrl = `${baseUrl}${buildUnitScript.attr('url')}`;
+ const queryData = JSON.parse(buildUnitScript.attr('querydata')?.replaceAll("'", '"') ?? '{}');
+ queryData.paramJson = `{"pageNo":1,"pageSize":${limit}}`;
+
+ const { data } = await got.get<{ data: { html: string } }>(queryUrl, {
+ searchParams: new URLSearchParams(queryData),
+ });
+
+ items = await parsePage(data.data.html);
+ }
+
+ const pageTitle = $('head title').text();
+
+ return {
+ title: pageTitle,
+ link,
+ image: 'https://www.cast.org.cn/favicon.ico',
+ item: items,
+ };
+}
diff --git a/lib/routes/cast/namespace.ts b/lib/routes/cast/namespace.ts
new file mode 100644
index 00000000000000..8c1d0f7eb9a53e
--- /dev/null
+++ b/lib/routes/cast/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国科学技术协会',
+ url: 'cast.org.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/catti/namespace.ts b/lib/routes/catti/namespace.ts
new file mode 100644
index 00000000000000..59c463f72d34ee
--- /dev/null
+++ b/lib/routes/catti/namespace.ts
@@ -0,0 +1,6 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '全国翻译专业资格水平考试 (CATTI)',
+ url: 'www.catticenter.com',
+};
diff --git a/lib/routes/catti/news.ts b/lib/routes/catti/news.ts
new file mode 100644
index 00000000000000..20fd5aae4f7a8a
--- /dev/null
+++ b/lib/routes/catti/news.ts
@@ -0,0 +1,121 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+type NewsCategory = {
+ title: string;
+ description: string;
+};
+
+const NEWS_TYPES: Record = {
+ ggl: {
+ title: '通知公告',
+ description: 'CATTI 考试通知和公告',
+ },
+ ywdt: {
+ title: '要闻动态',
+ description: 'CATTI 考试要闻动态',
+ },
+ zxzc: {
+ title: '最新政策',
+ description: 'CATTI 考试最新政策',
+ },
+};
+
+const handler: Route['handler'] = async (ctx) => {
+ const category = ctx.req.param('category');
+
+ const BASE_URL = `https://www.catticenter.com/${category}`;
+
+ // Fetch the index page
+ const { data: listPage } = await got(BASE_URL);
+ const $ = load(listPage);
+
+ // Select all list items containing news information
+ const ITEM_SELECTOR = 'ul.ui-card.ui-card-a > li';
+ const listItems = $(ITEM_SELECTOR);
+
+ // Map through each list item to extract details
+ const contentLinkList = listItems.toArray().map((element) => {
+ const date = $(element).find('span.ui-right-time').text();
+ const title = $(element).find('a').attr('title')!;
+ const relativeLink = $(element).find('a').attr('href')!;
+ const absoluteLink = `https://www.catticenter.com${relativeLink}`;
+ const formattedDate = parseDate(date);
+ return {
+ date: formattedDate,
+ title,
+ link: absoluteLink,
+ };
+ });
+
+ return {
+ title: NEWS_TYPES[category].title,
+ description: NEWS_TYPES[category].description,
+ link: BASE_URL,
+ image: 'https://www.catticenter.com/img/applogo.png',
+ item: (await Promise.all(
+ contentLinkList.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const CONTENT_SELECTOR = 'div.ui-article-cont';
+ const { data: contentResponse } = await got(item.link);
+ const contentPage = load(contentResponse);
+ const content = contentPage(CONTENT_SELECTOR).html() || '';
+ return {
+ title: item.title,
+ pubDate: item.date,
+ link: item.link,
+ description: content,
+ category: ['study'],
+ guid: item.link,
+ id: item.link,
+ image: 'https://www.catticenter.com/img/applogo.png',
+ content,
+ updated: item.date,
+ language: 'zh-cn',
+ };
+ })
+ )
+ )) as DataItem[],
+ allowEmpty: true,
+ language: 'zh-cn',
+ feedLink: 'https://rsshub.app/ruankao/news',
+ id: 'https://rsshub.app/ruankao/news',
+ };
+};
+
+export const route: Route = {
+ path: '/news/:category',
+ name: 'CATTI 考试消息',
+ maintainers: ['PrinOrange'],
+ description: `
+| Category | 标题 | 描述 |
+|-----------|------------|--------------------|
+| ggl | 通知公告 | CATTI 考试通知和公告 |
+| ywdt | 要闻动态 | CATTI 考试要闻动态 |
+| zxzc | 最新政策 | CATTI 考试最新政策 |
+`,
+ handler,
+ categories: ['study'],
+ parameters: {
+ category: '消息分类名,可在下面的描述中找到。',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ supportRadar: true,
+ },
+ example: '/catti/news/zxzc',
+ radar: [
+ {
+ source: ['www.catticenter.com/:category'],
+ },
+ ],
+};
diff --git a/lib/routes/cau/ele.ts b/lib/routes/cau/ele.ts
new file mode 100644
index 00000000000000..99d2510407162e
--- /dev/null
+++ b/lib/routes/cau/ele.ts
@@ -0,0 +1,69 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/ele',
+ categories: ['university'],
+ example: '/cau/ele',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['ciee.cau.edu.cn/col/col26712/index.html', 'ciee.cau.edu.cn/'],
+ },
+ ],
+ name: '研招网通知公告',
+ maintainers: ['shengmaosu'],
+ handler,
+ url: 'ciee.cau.edu.cn/col/col26712/index.html',
+ description: `#### 信电学院 {#zhong-guo-nong-ye-da-xue-yan-zhao-wang-tong-zhi-gong-gao-xin-dian-xue-yuan}`,
+};
+
+async function handler() {
+ const baseUrl = 'https://ciee.cau.edu.cn';
+ const link = `${baseUrl}/col/col26712/index.html`;
+ const response = await got(`${baseUrl}/module/web/jpage/dataproxy.jsp`, {
+ searchParams: {
+ page: 1,
+ appid: 1,
+ webid: 107,
+ path: '/',
+ columnid: 26712,
+ unitid: 38467,
+ webname: '信息与电气工程学院',
+ permissiontype: 0,
+ },
+ });
+ const $ = load(response.data);
+ const list = $('recordset record');
+
+ return {
+ title: '中国农业大学信电学院',
+ link,
+ description: '中国农业大学信电学院通知公告',
+ item:
+ list &&
+ list.toArray().map((item) => {
+ item = $(item);
+ const a = item.find('a');
+ const title = a.attr('title');
+ const link = `${baseUrl}${a.attr('href')}`;
+ return {
+ title,
+ link,
+ pubDate: parseDate(item.find('.col-lg-1').text()),
+ guid: `${link}#${title}`,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/cau/namespace.ts b/lib/routes/cau/namespace.ts
new file mode 100644
index 00000000000000..6d069cf3c41888
--- /dev/null
+++ b/lib/routes/cau/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国农业大学',
+ url: 'ciee.cau.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/cau/yjs.ts b/lib/routes/cau/yjs.ts
new file mode 100644
index 00000000000000..a2a77d545a3995
--- /dev/null
+++ b/lib/routes/cau/yjs.ts
@@ -0,0 +1,68 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/yjs',
+ categories: ['university'],
+ example: '/cau/yjs',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['yz.cau.edu.cn/col/col41740/index.html', 'yz.cau.edu.cn/'],
+ },
+ ],
+ name: '研招网通知公告',
+ maintainers: ['shengmaosu'],
+ handler,
+ url: 'yz.cau.edu.cn/col/col41740/index.html',
+};
+
+async function handler() {
+ const baseUrl = 'https://yz.cau.edu.cn';
+ const link = `${baseUrl}/col/col41740/index.html`;
+ const response = await got(`${baseUrl}/module/web/jpage/dataproxy.jsp`, {
+ searchParams: {
+ page: 1,
+ appid: 1,
+ webid: 146,
+ path: '/',
+ columnid: 41740,
+ unitid: 69493,
+ webname: '中国农业大学研究生院',
+ permissiontype: 0,
+ },
+ });
+ const $ = load(response.data);
+ const list = $('recordset record');
+
+ return {
+ title: '中农研究生学院',
+ link,
+ description: '中农研究生学院',
+ item:
+ list &&
+ list.toArray().map((item) => {
+ item = $(item);
+ const a = item.find('a');
+ return {
+ title: a
+ .contents()
+ .filter((_, node) => node.type === 'text')
+ .text(),
+ link: `${baseUrl}${a.attr('href')}`,
+ pubDate: parseDate(item.find('span').text().replaceAll(/[[\]]/g, '')),
+ };
+ }),
+ };
+}
diff --git a/lib/routes/caus/index.ts b/lib/routes/caus/index.ts
new file mode 100644
index 00000000000000..d93a108da17e53
--- /dev/null
+++ b/lib/routes/caus/index.ts
@@ -0,0 +1,111 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const categories = {
+ 0: {
+ title: '全部',
+ link: 'home?id=0',
+ },
+ 1: {
+ title: '要闻',
+ link: 'focus_news?id=1',
+ },
+ 2: {
+ title: '商业',
+ link: 'business?id=2',
+ },
+ 3: {
+ title: '快讯',
+ link: 'hours?id=3',
+ },
+ 8: {
+ title: '财富',
+ link: 'fortune?id=8',
+ },
+ 6: {
+ title: '生活',
+ link: 'life?id=6',
+ },
+};
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/caus',
+ parameters: { category: '分类,见下表,默认为全部' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 全部 | 要闻 | 商业 | 快讯 | 财富 | 生活 |
+| ---- | ---- | ---- | ---- | ---- | ---- |
+| 0 | 1 | 2 | 3 | 8 | 6 |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') || '0';
+
+ const isHome = category === '0';
+
+ const rootUrl = 'https://www.caus.com';
+ const apiRootUrl = 'https://api.caus.money';
+
+ const currentUrl = `${rootUrl}/${categories[category].link}`;
+ const searchUrl = `${apiRootUrl}/toronto/display/searchList`;
+ const listUrl = `${apiRootUrl}/toronto/display/lanmuArticlelistNew`;
+
+ const response = await got({
+ method: 'post',
+ url: isHome ? searchUrl : listUrl,
+ json: isHome
+ ? {
+ pageQ: {
+ pageSize: 10,
+ sortFiled: 'id',
+ sortType: 'DESC',
+ },
+ types: ['ARTICLE', 'VIDEO'],
+ }
+ : {
+ filterIds: [],
+ lanmuId: Number.parseInt(category),
+ },
+ });
+
+ const list = (isHome ? response.data.data : response.data.data.articleList).map((item) => ({
+ title: item.title,
+ link: `${rootUrl}/detail/${item.contentId}`,
+ contentId: item.contentId,
+ pubDate: new Date(item.createTime),
+ category: [...new Set([...item.lanmus.map((lanmu) => lanmu.name), ...item.tags.map((tag) => tag.name)])],
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: `${apiRootUrl}/toronto/display/contentWithRelate?contentId=${item.contentId}`,
+ });
+
+ item.description = detailResponse.data.data.content.content;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${categories[category].title} - 加美财经`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/caus/namespace.ts b/lib/routes/caus/namespace.ts
new file mode 100644
index 00000000000000..52f68de483d495
--- /dev/null
+++ b/lib/routes/caus/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '加美财经',
+ url: 'caus.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/cbaigui/index.ts b/lib/routes/cbaigui/index.ts
new file mode 100644
index 00000000000000..fab210c7baa6e7
--- /dev/null
+++ b/lib/routes/cbaigui/index.ts
@@ -0,0 +1,111 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { apiSlug, GetFilterId, rootUrl } from './utils';
+
+export const route: Route = {
+ path: '*',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50;
+
+ let filterName;
+
+ const currentUrl = new URL(getSubPath(ctx).replace(/^\/cbaigui/, ''), rootUrl).href;
+ let apiUrl = new URL(`${apiSlug}/posts?_embed=true&per_page=${limit}`, rootUrl).href;
+
+ const filterMatches = getSubPath(ctx).match(/^\/post-(tag|category)\/(.*)$/);
+
+ if (filterMatches) {
+ filterName = decodeURI(filterMatches[2].split('/').pop());
+ const filterType = filterMatches[1] === 'tag' ? 'tags' : 'categories';
+ const filterId = await GetFilterId(filterType, filterName);
+
+ if (filterId) {
+ apiUrl = new URL(`${apiSlug}/posts?_embed=true&per_page=${limit}&${filterType}=${filterId}`, rootUrl).href;
+ }
+ }
+
+ const { data: response } = await got(apiUrl);
+
+ const items = response.slice(0, limit).map((item) => {
+ const terminologies = item._embedded['wp:term'];
+
+ const content = load(item.content?.rendered ?? item.content);
+
+ // To handle lazy-loaded images from external sites.
+
+ content('figure').each(function () {
+ const image = content(this).find('img');
+ const src = image.prop('data-actualsrc') ?? image.prop('data-original');
+ const width = image.prop('data-rawwidth');
+ const height = image.prop('data-rawheight');
+
+ content(this).replaceWith(
+ art(path.join(__dirname, 'templates/figure.art'), {
+ src,
+ width,
+ height,
+ })
+ );
+ });
+
+ // To remove watermarks on images.
+
+ content('p img').each(function () {
+ const image = content(this);
+ const src = image.prop('src').split('!')[0];
+ const width = image.prop('width');
+ const height = image.prop('height');
+
+ content(this).replaceWith(
+ art(path.join(__dirname, 'templates/figure.art'), {
+ src,
+ width,
+ height,
+ })
+ );
+ });
+
+ return {
+ title: item.title?.rendered ?? item.title,
+ link: item.link,
+ description: content.html(),
+ author: item._embedded.author.map((a) => a.name).join('/'),
+ category: [...terminologies[0], ...terminologies[1]].map((c) => c.name),
+ guid: item.guid?.rendered ?? item.guid,
+ pubDate: parseDate(item.date_gmt),
+ updated: parseDate(item.modified_gmt),
+ };
+ });
+
+ const { data: currentResponse } = await got(currentUrl);
+
+ const $ = load(currentResponse);
+
+ const icon = $('link[rel="apple-touch-icon"]').first().prop('href');
+
+ return {
+ item: items,
+ title: `纪妖${filterName ? ` - ${filterName}` : ''}`,
+ link: currentUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: 'zh-cn',
+ image: $('meta[name="msapplication-TileImage"]').prop('content'),
+ icon,
+ logo: icon,
+ subtitle: $('p.site-description').text(),
+ author: $('p.site-title').text(),
+ };
+}
diff --git a/lib/routes/cbaigui/namespace.ts b/lib/routes/cbaigui/namespace.ts
new file mode 100644
index 00000000000000..5095c30cb37728
--- /dev/null
+++ b/lib/routes/cbaigui/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '纪妖',
+ url: 'cbaigui.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/cbaigui/templates/figure.art b/lib/routes/cbaigui/templates/figure.art
similarity index 100%
rename from lib/v2/cbaigui/templates/figure.art
rename to lib/routes/cbaigui/templates/figure.art
diff --git a/lib/routes/cbaigui/utils.ts b/lib/routes/cbaigui/utils.ts
new file mode 100644
index 00000000000000..1adfa72d90d6b2
--- /dev/null
+++ b/lib/routes/cbaigui/utils.ts
@@ -0,0 +1,14 @@
+import got from '@/utils/got';
+
+const rootUrl = 'https://cbaigui.com';
+const apiSlug = 'wp-json/wp/v2';
+
+const GetFilterId = async (type, name) => {
+ const filterApiUrl = new URL(`${apiSlug}/${type}?search=${name}`, rootUrl).href;
+
+ const { data: filterResponse } = await got(filterApiUrl);
+
+ return filterResponse.findLast((f) => f.name === name)?.id ?? undefined;
+};
+
+export { apiSlug, GetFilterId, rootUrl };
diff --git a/lib/routes/cbc/namespace.ts b/lib/routes/cbc/namespace.ts
new file mode 100644
index 00000000000000..198963f7c307d5
--- /dev/null
+++ b/lib/routes/cbc/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Canadian Broadcasting Corporation',
+ url: 'cbc.ca',
+ lang: 'en',
+};
diff --git a/lib/routes/cbc/topics.ts b/lib/routes/cbc/topics.ts
new file mode 100644
index 00000000000000..5b9721eed7d54f
--- /dev/null
+++ b/lib/routes/cbc/topics.ts
@@ -0,0 +1,86 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/topics/:topic?',
+ categories: ['traditional-media'],
+ example: '/cbc/topics',
+ parameters: { topic: 'Channel,`Top Stories` by default. For secondary channel like `canada/toronto`, use `-` to replace `/`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cbc.ca/news'],
+ target: '/topics',
+ },
+ ],
+ name: 'News',
+ maintainers: ['wb14123'],
+ handler,
+ url: 'cbc.ca/news',
+};
+
+async function handler(ctx) {
+ const baseUrl = 'https://www.cbc.ca';
+ const topic = ctx.req.param('topic') || '';
+ const url = `${baseUrl}/news${topic ? `/${topic.replace('-', '/')}` : ''}`;
+
+ const response = await got(url);
+
+ const data = response.data;
+
+ const $ = load(data);
+ const links = [];
+
+ function pushLinks(index, item) {
+ const link = item.attribs.href;
+ if (link.startsWith('/')) {
+ links.push(baseUrl + link);
+ }
+ }
+
+ $('a.contentWrapper').each(pushLinks);
+ $('a.card').each(pushLinks);
+
+ const out = await Promise.all(
+ links.map((link) =>
+ cache.tryGet(link, async () => {
+ const result = await got(link);
+
+ const $ = load(result.data);
+
+ const head = JSON.parse($('script[type="application/ld+json"]').first().text());
+ if (!head) {
+ return [];
+ }
+
+ const title = head.headline;
+ let author = '';
+ if (head.author) {
+ author = head.author.map((author) => author.name).join(' & ');
+ }
+ const pubDate = head.datePublished;
+ const descriptionDom = $('div[data-cy=storyWrapper]');
+ descriptionDom.find('div[class=share]').remove();
+ const description = descriptionDom.html();
+
+ return { title, author, pubDate, description, link };
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: url,
+ item: out.filter((x) => x.title),
+ };
+}
diff --git a/lib/routes/cbirc/index.ts b/lib/routes/cbirc/index.ts
new file mode 100644
index 00000000000000..41a1bbfdc337bf
--- /dev/null
+++ b/lib/routes/cbirc/index.ts
@@ -0,0 +1,133 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const categories = {
+ jgdt: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '监管动态',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=915,pageIndex=1,pageSize=18.json`,
+ title: '监管动态',
+ },
+ ggtz: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '公告通知',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=925,pageIndex=1,pageSize=18.json`,
+ title: '公告通知',
+ },
+ zcfg: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '政策法规',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=926,pageIndex=1,pageSize=18.json`,
+ title: '政策法规',
+ },
+ zcjd: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '政策解读',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=916,pageIndex=1,pageSize=18.json`,
+ title: '政策解读',
+ },
+ zqyj: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '征求意见',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=951,pageIndex=1,pageSize=18.json`,
+ title: '征求意见',
+ },
+ xzxk: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '行政许可',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=930,pageIndex=1,pageSize=18.json`,
+ title: '行政许可',
+ },
+ xzcf: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '行政处罚',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=931,pageIndex=1,pageSize=18.json`,
+ title: '行政处罚',
+ },
+ xzjgcs: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '行政监管措施',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=932,pageIndex=1,pageSize=18.json`,
+ title: '行政监管措施',
+ },
+ gzlw: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '工作论文',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=934,pageIndex=1,pageSize=18.json`,
+ title: '工作论文',
+ },
+ jrzgyj: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '金融监管研究',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=935,pageIndex=1,pageSize=18.json`,
+ title: '金融监管研究',
+ },
+ tjxx: {
+ baseUrl: `http://www.cbirc.gov.cn`,
+ description: '统计信息',
+ link: `http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectDocByItemIdAndChild/data_itemId=954,pageIndex=1,pageSize=18.json`,
+ title: '统计信息',
+ },
+};
+
+async function getContent(item) {
+ const response = await got({
+ method: 'get',
+ url: 'http://www.cbirc.gov.cn/cn/static/data/DocInfo/SelectByDocId/data_docId=' + item.docId + '.json',
+ });
+ return response.data.data.docClob;
+}
+
+export const route: Route = {
+ path: '/:category?',
+ radar: [
+ {
+ source: ['cbirc.gov.cn/:category', 'cbirc.gov.cn/'],
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['JkCheung'],
+ handler,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'ggtz';
+ const cat = categories[category];
+
+ // 请求集合
+ const response = await cache.tryGet(cat.link, async () => {
+ const resp = await got({
+ method: 'get',
+ url: cat.link,
+ headers: {
+ Referer: `http://www.cbirc.gov.cn`,
+ },
+ });
+ return resp.data;
+ });
+
+ // 遍历数据集合
+ const dataLs = await Promise.all(
+ response.data.rows.map(async (item) => {
+ const content = await getContent(item);
+ return {
+ title: item.docTitle,
+ // 文章正文
+ description: content,
+ // 文章发布时间
+ pubDate: item.publishDate,
+ // 文章链接
+ link: `http://www.cbirc.gov.cn/cn/view/pages/ItemDetail.html?docId=${item.docId}&itemId=925&generaltype=0`,
+ };
+ })
+ );
+
+ return {
+ title: `中国银保监会-${cat.title}`,
+ link: cat.link,
+ description: `中国银保监会-${cat.title}`,
+ item: dataLs,
+ language: 'zh-CN',
+ };
+}
diff --git a/lib/routes/cbirc/namespace.ts b/lib/routes/cbirc/namespace.ts
new file mode 100644
index 00000000000000..c21982ff11e9d2
--- /dev/null
+++ b/lib/routes/cbirc/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中国银行保险监督管理委员会',
+ url: 'cbirc.gov.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/cbndata/information.ts b/lib/routes/cbndata/information.ts
new file mode 100644
index 00000000000000..525964d9ff3aa1
--- /dev/null
+++ b/lib/routes/cbndata/information.ts
@@ -0,0 +1,252 @@
+import path from 'node:path';
+
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const { id = 'all' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '50', 10);
+
+ const baseUrl: string = 'https://www.cbndata.com';
+ const targetUrl: string = new URL(`information?tag_id=${id}`, baseUrl).href;
+ const apiUrl: string = new URL('api/v3/informations', baseUrl).href;
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ page: 1,
+ per_page: limit,
+ },
+ });
+
+ let items: DataItem[] = [];
+
+ items = response.data.slice(0, limit).map((item): DataItem => {
+ const title: string = item.title;
+ const image: string | undefined = item.image;
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ });
+ const pubDate: number | string = item.date;
+ const linkUrl: string | undefined = item.id ? `information/${item.id}` : undefined;
+ const categories: string[] = item.tags;
+ const guid: string = `cbndata-information-${item.id}`;
+ const updated: number | string = pubDate;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDate ? parseDate(pubDate) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: updated ? parseDate(updated) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+
+ const dataStr: string | undefined = detailResponse.match(/">`;
- break;
- }
- if (url) {
- ret += `Click here if embedded content is not loaded. `;
- }
- } catch (_) {
- _;
- }
-
- return ret;
-}
-
-// render
blocks
-function passage_conv(p) {
- const seg = p.text.split('');
- // 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/fanbox/header.js b/lib/routes/fanbox/header.js
deleted file mode 100644
index 55eb6b99bb0e8f..00000000000000
--- a/lib/routes/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/fanbox/index.ts b/lib/routes/fanbox/index.ts
new file mode 100644
index 00000000000000..e4cbeec3b92ff1
--- /dev/null
+++ b/lib/routes/fanbox/index.ts
@@ -0,0 +1,65 @@
+import type { Context } from 'hono';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Data, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { isValidHost } from '@/utils/valid-host';
+
+import type { PostListResponse, UserInfoResponse } from './types';
+import { getHeaders, parseItem } from './utils';
+
+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,
+ },
+ ],
+ nsfw: 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.map((i) => parseItem(i)));
+
+ return {
+ title,
+ link: `https://${creator}.fanbox.cc`,
+ description,
+ image,
+ item: items,
+ };
+}
diff --git a/lib/routes/fanbox/main.js b/lib/routes/fanbox/main.js
deleted file mode 100644
index c173d98debc744..00000000000000
--- a/lib/routes/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 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 (_) {
- _;
- }
-
- // 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/namespace.ts b/lib/routes/fanbox/namespace.ts
new file mode 100644
index 00000000000000..64c6b112f2b5b7
--- /dev/null
+++ b/lib/routes/fanbox/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'fanbox',
+ url: 'www.fanbox.cc',
+ lang: 'zh-CN',
+};
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..027a9e1c3337e5
--- /dev/null
+++ b/lib/routes/fanbox/types.ts
@@ -0,0 +1,231 @@
+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: PostItem[];
+}
+
+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;
+ };
+ }[];
+ };
+ 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;
+ };
+}
+
+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..b346d6b8650f65
--- /dev/null
+++ b/lib/routes/fanbox/utils.ts
@@ -0,0 +1,187 @@
+import path from 'node:path';
+
+import { config } from '@/config';
+import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import type { ArticlePost, FilePost, ImagePost, PostDetailResponse, PostItem, TextPost, VideoPost } from './types';
+
+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(), 'User-Agent': config.trueUA } })) 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;
+}
diff --git a/lib/routes/fanfou/favorites.js b/lib/routes/fanfou/favorites.js
deleted file mode 100644
index 589e8993ad8ea2..00000000000000
--- a/lib/routes/fanfou/favorites.js
+++ /dev/null
@@ -1,36 +0,0 @@
-const config = require('@/config').value;
-const utils = require('./utils');
-
-module.exports = async (ctx) => {
- if (!config.fanfou || !config.fanfou.consumer_key || !config.fanfou.consumer_secret || !config.fanfou.username || !config.fanfou.password) {
- throw 'Fanfou RSS is disabled due to the lack of relevant config ';
- }
-
- const uid = ctx.params.uid;
- const fanfou = await utils.getFanfou();
- const timeline = await fanfou.get(`/favorites/${encodeURIComponent(uid)}`, { id: uid, mode: 'lite', format: 'html' });
-
- const result = timeline.map((item) => {
- let img_html = '';
- if (item.photo) {
- img_html = ` `;
- }
- return {
- title: item.text,
- author: item.user.name,
- description: item.text + img_html,
- pubDate: item.created_at,
- link: `https://fanfou.com/statuses/${item.id}`,
- };
- });
-
- const users = await fanfou.get(`/users/show`, { id: uid });
- const authorName = users.screen_name;
-
- ctx.state.data = {
- title: `${authorName}的饭否收藏`,
- link: `https://fanfou.com/favorites/${encodeURIComponent(uid)}`,
- description: `${authorName}的饭否收藏`,
- item: result,
- };
-};
diff --git a/lib/routes/fanfou/home_timeline.js b/lib/routes/fanfou/home_timeline.js
deleted file mode 100644
index f5fc817ac15149..00000000000000
--- a/lib/routes/fanfou/home_timeline.js
+++ /dev/null
@@ -1,32 +0,0 @@
-const config = require('@/config').value;
-const utils = require('./utils');
-
-module.exports = async (ctx) => {
- if (!config.fanfou || !config.fanfou.consumer_key || !config.fanfou.consumer_secret || !config.fanfou.username || !config.fanfou.password) {
- throw 'Fanfou RSS is disabled due to the lack of relevant config ';
- }
-
- const fanfou = await utils.getFanfou();
- const timeline = await fanfou.get('/statuses/home_timeline', { mode: 'lite', format: 'html' });
-
- const result = timeline.map((item) => {
- let img_html = '';
- if (item.photo) {
- img_html = ` `;
- }
- return {
- title: item.plain_text,
- author: item.user.name,
- description: item.text + img_html,
- pubDate: item.created_at,
- link: `http://fanfou.com/statuses/${item.id}`,
- };
- });
-
- ctx.state.data = {
- title: `我的饭否动态`,
- link: `https://fanfou.com/home`,
- description: `我的饭否动态`,
- item: result,
- };
-};
diff --git a/lib/routes/fanfou/public_timeline.js b/lib/routes/fanfou/public_timeline.js
deleted file mode 100644
index c28df8ef2e4c2f..00000000000000
--- a/lib/routes/fanfou/public_timeline.js
+++ /dev/null
@@ -1,34 +0,0 @@
-const config = require('@/config').value;
-const utils = require('./utils');
-
-module.exports = async (ctx) => {
- if (!config.fanfou || !config.fanfou.consumer_key || !config.fanfou.consumer_secret || !config.fanfou.username || !config.fanfou.password) {
- throw 'Fanfou RSS is disabled due to the lack of relevant config ';
- }
-
- const keyword = ctx.params.keyword;
- const fanfou = await utils.getFanfou();
- const timeline = await fanfou.get('/search/public_timeline', { q: keyword, mode: 'lite', format: 'html' });
-
- const result = timeline.map((item) => {
- let img_html = '';
- if (item.photo) {
- img_html = ` `;
- }
-
- return {
- title: item.plain_text,
- author: item.user.name,
- description: item.text + img_html,
- pubDate: item.created_at,
- link: `https://fanfou.com/statuses/${item.id}`,
- };
- });
-
- ctx.state.data = {
- title: `饭否搜索-${keyword}`,
- link: `https://fanfou.com/q/${keyword}`,
- description: `饭否搜索-${keyword}`,
- item: result,
- };
-};
diff --git a/lib/routes/fanfou/user_timeline.js b/lib/routes/fanfou/user_timeline.js
deleted file mode 100644
index a4e276a080dd07..00000000000000
--- a/lib/routes/fanfou/user_timeline.js
+++ /dev/null
@@ -1,35 +0,0 @@
-const config = require('@/config').value;
-const utils = require('./utils');
-
-module.exports = async (ctx) => {
- if (!config.fanfou || !config.fanfou.consumer_key || !config.fanfou.consumer_secret || !config.fanfou.username || !config.fanfou.password) {
- throw 'Fanfou RSS is disabled due to the lack of relevant config ';
- }
-
- const uid = ctx.params.uid;
- const fanfou = await utils.getFanfou();
- const timeline = await fanfou.get('/statuses/user_timeline', { id: uid, mode: 'lite', format: 'html' });
-
- const result = timeline.map((item) => {
- let img_html = '';
- if (item.photo) {
- img_html = ` `;
- }
- return {
- title: item.plain_text,
- author: item.user.name,
- description: item.text + img_html,
- pubDate: item.created_at,
- link: `https://fanfou.com/statuses/${item.id}`,
- };
- });
-
- const authorName = result[0].author;
-
- ctx.state.data = {
- title: `${authorName}的饭否`,
- link: `https://fanfou.com/${uid}`,
- description: `${authorName}的饭否`,
- item: result,
- };
-};
diff --git a/lib/routes/fanfou/utils.js b/lib/routes/fanfou/utils.js
deleted file mode 100644
index 1c4d6873c2ff12..00000000000000
--- a/lib/routes/fanfou/utils.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const logger = require('@/utils/logger');
-const config = require('@/config').value;
-const Fanfou = require('fanfou-sdk');
-
-const consumerKey = config.fanfou.consumer_key;
-const consumerSecret = config.fanfou.consumer_secret;
-const username = config.fanfou.username;
-const password = config.fanfou.password;
-
-let fanfou_client;
-let authed = false;
-
-const getFanfou = async () => {
- if (authed === true) {
- return fanfou_client;
- } else {
- fanfou_client = new Fanfou({
- consumerKey,
- consumerSecret,
- username,
- password,
- protocol: 'https:',
- hooks: {
- baseString(str) {
- return str.replace('https', 'http');
- },
- },
- });
-
- await fanfou_client.xauth();
- logger.info('Fanfou login success.');
-
- authed = true;
- return fanfou_client;
- }
-};
-
-module.exports = {
- getFanfou,
-};
diff --git a/lib/routes/fangchan/list.ts b/lib/routes/fangchan/list.ts
new file mode 100644
index 00000000000000..1b9085685696de
--- /dev/null
+++ b/lib/routes/fangchan/list.ts
@@ -0,0 +1,224 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx: Context): Promise => {
+ const { id = 'datalist' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'http://www.fangchan.com';
+ const apiBaseUrl: string = 'http://news.fangchan.com';
+ const targetUrl: string = new URL(id.endsWith('/') ? id : `${id}/`, baseUrl).href;
+ const apiUrl: string = new URL(`api/${id.endsWith('/') ? id.replace(/\/$/, '') : id}.json`, apiBaseUrl).href;
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ pagesize: limit,
+ page: 1,
+ },
+ });
+
+ let items: DataItem[] = [];
+
+ items = response.data.slice(0, limit).map((item): DataItem => {
+ const title: string = item.title;
+ const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ intro: item.zhaiyao,
+ });
+ const pubDate: number | string = item.createtime;
+ const linkUrl: string | undefined = item.url;
+ const categories: string[] = [...new Set([item.topcolumn, item.subcolumn, ...(item.keyword?.split(/,/) ?? [])].filter(Boolean))];
+ const image: string | undefined = item.pic;
+ const updated: number | string = item.createtime;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDate ? parseDate(pubDate, 'X') : undefined,
+ link: linkUrl,
+ id: categories,
+ content: {
+ html: description,
+ text: item.zhaiyao ?? description,
+ },
+ image,
+ banner: image,
+ updated: updated ? parseDate(updated, 'X') : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.summary-text h').text();
+ const description: string = (item.description ?? '') + ($$('div.top-info').html() ?? '') + ($$('div.summary-text-p').html() ?? '');
+ const pubDateStr: string | undefined = $$('span.news-date')
+ .text()
+ .match(/\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}/)?.[1];
+ const idEls: Element[] = $$('a.news-column, div.label span').toArray();
+ const categories: string[] = [...new Set([...(item.id as string[]), ...idEls.map((el) => $$(el).text()).filter(Boolean)].filter(Boolean))];
+ const authors: DataItem['author'] = $$('span.news-date')
+ .text()
+ ?.split(/\d{4}-\d{2}-\d{2}/)?.[0]
+ ?.trim()
+ ?.split(/\s/)
+ ?.map((author) => ({
+ name: author,
+ }));
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : item.pubDate,
+ id: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : item.updated,
+ language,
+ };
+
+ const extraLinkEls: Element[] = $$('ul.xgxw-ul li a').toArray();
+ const extraLinks = extraLinkEls
+ .map((extraLinkEl) => {
+ const $$extraLinkEl: Cheerio = $$(extraLinkEl);
+
+ return {
+ url: $$extraLinkEl.attr('href'),
+ type: 'related',
+ content_html: $$extraLinkEl.text(),
+ };
+ })
+ .filter((_): _ is { url: string; type: string; content_html: string } => true);
+
+ if (extraLinks) {
+ processedItem = {
+ ...processedItem,
+ _extra: {
+ links: extraLinks,
+ },
+ };
+ }
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const author: string = '中房网';
+
+ return {
+ title: `${author} - ${$('div.curmbs a').text()}`,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ author,
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/list/:id?',
+ name: '列表',
+ url: 'www.fangchan.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/fangchan/list/datalist',
+ parameters: {
+ id: {
+ description: '分类,默认为 `datalist`,即数据研究,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '数据研究',
+ value: 'datalist',
+ },
+ {
+ label: '行业测评',
+ value: 'industrylist',
+ },
+ {
+ label: '政策法规',
+ value: 'policylist',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+若订阅 [列表](https://www.fangchan.com/),网址为 \`https://www.fangchan.com/\`,请截取 \`https://www.fangchan.com/\` 到末尾 \`.html\` 的部分 \`datalist\` 作为 \`id\` 参数填入,此时目标路由为 [\`/fangchan/datalist\`](https://rsshub.app/fangchan/datalist)。
+:::
+
+| [数据研究](https://www.fangchan.com/datalist) | [行业测评](https://www.fangchan.com/industrylist) | [政策法规](https://www.fangchan.com/policylist) |
+| ----------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- |
+| [datalist](https://rsshub.app/fangchan/list/datalist) | [industrylist](https://rsshub.app/fangchan/list/industrylist) | [policylist](https://rsshub.app/fangchan/list/policylist) |
+`,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.fangchan.com/:id'],
+ target: (params) => {
+ const id: string = params.id;
+
+ return `/fangchan/list/${id ? `/${id}` : ''}`;
+ },
+ },
+ {
+ title: '数据研究',
+ source: ['www.fangchan.com/datalist'],
+ target: '/list/datalist',
+ },
+ {
+ title: '行业测评',
+ source: ['www.fangchan.com/industrylist'],
+ target: '/list/industrylist',
+ },
+ {
+ title: '政策法规',
+ source: ['www.fangchan.com/policylist'],
+ target: '/list/policylist',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/fangchan/namespace.ts b/lib/routes/fangchan/namespace.ts
new file mode 100644
index 00000000000000..eae3dd9c1ace39
--- /dev/null
+++ b/lib/routes/fangchan/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中房网',
+ url: 'fangchan.com',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/fangchan/templates/description.art b/lib/routes/fangchan/templates/description.art
new file mode 100644
index 00000000000000..57498ab45a9d86
--- /dev/null
+++ b/lib/routes/fangchan/templates/description.art
@@ -0,0 +1,7 @@
+{{ if intro }}
+ {{ intro }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/fanqienovel/namespace.ts b/lib/routes/fanqienovel/namespace.ts
new file mode 100644
index 00000000000000..87016d7cc645a6
--- /dev/null
+++ b/lib/routes/fanqienovel/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '番茄小说',
+ url: 'fanqienovel.com',
+ categories: ['reading'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/fanqienovel/page.ts b/lib/routes/fanqienovel/page.ts
new file mode 100644
index 00000000000000..58bc7e73e4da31
--- /dev/null
+++ b/lib/routes/fanqienovel/page.ts
@@ -0,0 +1,101 @@
+import * as cheerio from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+interface Chapter {
+ itemId: string;
+ needPay: number;
+ title: string;
+ isChapterLock: boolean;
+ isPaidPublication: boolean;
+ isPaidStory: boolean;
+ volume_name: string;
+ firstPassTime: string;
+}
+
+interface Page {
+ hasFetch: boolean;
+ author: string;
+ authorId: string;
+ bookId: string;
+ mediaId: string;
+ bookName: string;
+ status: number;
+ category: string;
+ categoryV2: string;
+ abstract: string;
+ thumbUri: string;
+ creationStatus: number;
+ wordNumber: number;
+ readCount: number;
+ description: string;
+ avatarUri: string;
+ creatorId: string;
+ lastPublishTime: string;
+ lastChapterItemId: string;
+ lastChapterTitle: string;
+ volumeNameList: string[];
+ chapterListWithVolume: Chapter[][];
+ chapterTotal: number;
+ followStatus: number;
+ itemIds: string[];
+ hasFetchDirectory: boolean;
+ chapterList: any[];
+ serverRendered: boolean;
+ genre: string;
+ platform: string;
+ type: string;
+ originalAuthors: string;
+ completeCategory: string;
+}
+
+export const route: Route = {
+ path: '/page/:bookId',
+ example: '/fanqienovel/page/6621052928482348040',
+ parameters: { bookId: '小说 ID,可在 URL 中找到' },
+ maintainers: ['TonyRL'],
+ name: '小说更新',
+ handler,
+ radar: [
+ {
+ source: ['fanqienovel.com/page/:bookId'],
+ },
+ ],
+};
+
+async function handler(ctx: Context): Promise {
+ const { bookId } = ctx.req.param();
+ const link = `https://fanqienovel.com/page/${bookId}`;
+
+ const response = await ofetch(link);
+ const $ = cheerio.load(response);
+
+ const initialState = JSON.parse(
+ $('script:contains("window.__INITIAL_STATE__")')
+ .text()
+ .match(/window\.__INITIAL_STATE__\s*=\s*(.*);/)?.[1] ?? '{}'
+ );
+
+ const page = initialState.page as Page;
+ const items = page.chapterListWithVolume.flatMap((volume) =>
+ volume.map((chapter) => ({
+ title: chapter.title,
+ link: `https://fanqienovel.com/reader/${chapter.itemId}`,
+ description: chapter.volume_name,
+ pubDate: parseDate(chapter.firstPassTime, 'X'),
+ author: page.author,
+ }))
+ );
+
+ return {
+ title: `${page.bookName} - ${page.author}`,
+ description: page.abstract,
+ link,
+ language: 'zh-CN',
+ image: page.thumbUri,
+ item: items,
+ };
+}
diff --git a/lib/routes/fansly/namespace.ts b/lib/routes/fansly/namespace.ts
new file mode 100644
index 00000000000000..044e76beba00c8
--- /dev/null
+++ b/lib/routes/fansly/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Fansly',
+ url: 'fansly.com',
+ lang: 'en',
+};
diff --git a/lib/routes/fansly/post.ts b/lib/routes/fansly/post.ts
new file mode 100644
index 00000000000000..bb41701a18b758
--- /dev/null
+++ b/lib/routes/fansly/post.ts
@@ -0,0 +1,55 @@
+import type { Route } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+
+import { baseUrl, getAccountByUsername, getTimelineByAccountId, parseDescription } from './utils';
+
+export const route: Route = {
+ path: '/user/:username',
+ categories: ['social-media'],
+ example: '/fansly/user/AeriGoMoo',
+ parameters: { username: 'User ID' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['fansly.com/:username/posts', 'fansly.com/:username/media'],
+ },
+ ],
+ name: 'User Timeline',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const username = ctx.req.param('username');
+
+ const account = await getAccountByUsername(username);
+ const timeline = await getTimelineByAccountId(account.id);
+
+ const items = timeline.posts.map((post) => ({
+ title: post.content.split('\n')[0],
+ description: parseDescription(post, timeline),
+ pubDate: parseDate(post.createdAt, 'X'),
+ link: `${baseUrl}/post/${post.id}`,
+ author: `${account.displayName ?? account.username} (@${account.username})`,
+ }));
+
+ return {
+ title: `${account.displayName ?? account.username} (@${account.username}) - Fansly`,
+ link: `${baseUrl}/${account.username}`,
+ description: account.about.replaceAll('\n', ' '),
+ image: account.banner.locations[0].location,
+ icon: account.avatar.locations[0].location,
+ logo: account.avatar.locations[0].location,
+ language: 'en',
+ allowEmpty: true,
+ item: items,
+ };
+}
diff --git a/lib/routes/fansly/tag.ts b/lib/routes/fansly/tag.ts
new file mode 100644
index 00000000000000..bed607760a1a2c
--- /dev/null
+++ b/lib/routes/fansly/tag.ts
@@ -0,0 +1,56 @@
+import type { Route } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+
+import { baseUrl, findAccountById, getTagId, getTagSuggestion, icon, parseDescription } from './utils';
+
+export const route: Route = {
+ path: '/tag/:tag',
+ categories: ['social-media'],
+ example: '/fansly/tag/free',
+ parameters: { tag: 'Hashtag' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['fansly.com/explore/tag/:tag'],
+ },
+ ],
+ name: 'Hashtag',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const tag = ctx.req.param('tag');
+
+ const tagId = await getTagId(tag);
+ const suggestion = await getTagSuggestion(tagId);
+
+ const items = suggestion.aggregationData?.posts.map((post) => {
+ const account = findAccountById(post.accountId, suggestion.aggregationData.accounts);
+ return {
+ title: post.content.split('\n')[0],
+ description: parseDescription(post, suggestion.aggregationData),
+ pubDate: parseDate(post.createdAt, 'X'),
+ link: `${baseUrl}/post/${post.id}`,
+ author: `${account.displayName ?? account.username} (@${account.username})`,
+ };
+ });
+
+ return {
+ title: `#${tag} - Fansly`,
+ link: `${baseUrl}/explore/tag/${tag}`,
+ image: icon,
+ icon,
+ logo: icon,
+ language: 'en',
+ item: items,
+ };
+}
diff --git a/lib/routes/fansly/templates/media.art b/lib/routes/fansly/templates/media.art
new file mode 100644
index 00000000000000..791f06adcadeee
--- /dev/null
+++ b/lib/routes/fansly/templates/media.art
@@ -0,0 +1,8 @@
+{{ if poster && src }}
+
+
+
+{{ else if src }}
+
+{{ /if }}
+
diff --git a/lib/routes/fansly/templates/poll.art b/lib/routes/fansly/templates/poll.art
new file mode 100644
index 00000000000000..26c2e99d9b258b
--- /dev/null
+++ b/lib/routes/fansly/templates/poll.art
@@ -0,0 +1,4 @@
+{{ title }}
+{{ each options option }}
+ {{ option.voteCount }}/{{ version }} {{ option.title }}
+{{ /each }}
diff --git a/lib/routes/fansly/templates/tip-goal.art b/lib/routes/fansly/templates/tip-goal.art
new file mode 100644
index 00000000000000..ac932742b101d3
--- /dev/null
+++ b/lib/routes/fansly/templates/tip-goal.art
@@ -0,0 +1,2 @@
+{{ label }}
+{{ currentPercentage }}% ${{ currentAmount / 1000 }} / ${{ goalAmount / 1000 }}
diff --git a/lib/routes/fansly/utils.ts b/lib/routes/fansly/utils.ts
new file mode 100644
index 00000000000000..7d35ce46a596e4
--- /dev/null
+++ b/lib/routes/fansly/utils.ts
@@ -0,0 +1,165 @@
+import path from 'node:path';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const apiBaseUrl = 'https://apiv3.fansly.com';
+const baseUrl = 'https://fansly.com';
+const icon = `${baseUrl}/assets/images/icons/apple-touch-icon.png`;
+
+const findAccountById = (accountId, accounts) => {
+ const account = accounts.find((account) => account.id === accountId);
+ return {
+ displayName: account.displayName,
+ username: account.username,
+ };
+};
+
+const getAccountByUsername = (username) =>
+ cache.tryGet(`fansly:account:${username.toLowerCase()}`, async () => {
+ const { data: accountResponse } = await got(`${apiBaseUrl}/api/v1/account`, {
+ searchParams: {
+ usernames: username,
+ 'ngsw-bypass': true,
+ },
+ });
+
+ if (!accountResponse.response.length) {
+ throw new InvalidParameterError('This profile or page does not exist.');
+ }
+
+ return accountResponse.response[0];
+ });
+
+const getTimelineByAccountId = async (accountId) => {
+ const { data: timeline } = await got(`${apiBaseUrl}/api/v1/timelinenew/${accountId}`, {
+ searchParams: {
+ before: 0,
+ after: 0,
+ wallId: '',
+ contentSearch: '',
+ 'ngsw-bypass': true,
+ },
+ });
+
+ return timeline.response;
+};
+
+const getTagId = (tag) =>
+ cache.tryGet(`fansly:tag:${tag.toLowerCase()}`, async () => {
+ const { data: tagResponse } = await got(`${apiBaseUrl}/api/v1/contentdiscovery/media/tag`, {
+ searchParams: {
+ tag,
+ 'ngsw-bypass': true,
+ },
+ });
+
+ if (!tagResponse.response.mediaOfferSuggestionTag) {
+ throw new Error("Couldn't find this hashtag.");
+ }
+
+ return tagResponse.response.mediaOfferSuggestionTag.id;
+ });
+
+const getTagSuggestion = async (tagId) => {
+ const { data: suggestionResponse } = await got(`${apiBaseUrl}/api/v1/contentdiscovery/media/suggestionsnew`, {
+ searchParams: {
+ before: 0,
+ after: 0,
+ tagIds: tagId,
+ limit: 25,
+ offset: 0,
+ 'ngsw-bypass': true,
+ },
+ });
+
+ return suggestionResponse.response;
+};
+
+const parseDescription = (post, aggregationData) => post.content.replaceAll('\n', ' ') + ' ' + parseAttachments(post.attachments, aggregationData);
+
+const parseAttachments = (attachments, aggregationData) =>
+ attachments
+ .map((attachment) => {
+ switch (attachment.contentType) {
+ case 1:
+ // single media
+ return parseMedia(attachment.contentId, aggregationData.accountMedia);
+ case 2: {
+ // media bundle
+ let attachments = '';
+ const bundle = aggregationData.accountMediaBundles.find((bundle) => bundle.id === attachment.contentId);
+ for (const mediaId of bundle.accountMediaIds) {
+ attachments += parseMedia(mediaId, aggregationData.accountMedia);
+ }
+ return attachments;
+ }
+ case 8: {
+ // aggregated post (repost)
+ let attachments = ' ';
+ const repost = aggregationData.aggregatedPosts.find((post) => post.id === attachment.contentId) || aggregationData.posts.find((post) => post.id === attachment.contentId);
+ attachments += parseDescription(repost, aggregationData);
+
+ return attachments;
+ }
+
+ case 7100:
+ return renderTipGoal(attachment.contentId, aggregationData.tipGoals);
+ case 32001:
+ // unknown
+ return '';
+ case 42001:
+ return renderPoll(attachment.contentId, aggregationData.polls);
+
+ default:
+ throw new Error(`Unhandled attachment type: ${attachment.contentType} for post ${attachment.postId}`);
+ }
+ })
+ .join('');
+
+const parseMedia = (contentId, accountMedia) => {
+ const media = accountMedia.find((media) => media.id === contentId);
+ if (!media) {
+ return '';
+ }
+ return renderMedia(media.preview ?? media.media);
+};
+
+const renderMedia = (media) => {
+ switch (media.mimetype) {
+ case 'image/gif':
+ case 'image/jpeg':
+ case 'image/png':
+ case 'video/mp4':
+ case 'audio/mp4':
+ return art(path.join(__dirname, 'templates/media.art'), {
+ poster: media.mimetype === 'video/mp4' ? media.variants[0].locations[0] : null,
+ src: media.locations[0],
+ });
+ default:
+ throw new Error(`Unhandled media type: ${media.mimetype}`);
+ }
+};
+
+const renderPoll = (pollId, polls) => {
+ const poll = polls.find((poll) => poll.id === pollId);
+ return art(path.join(__dirname, 'templates/poll.art'), {
+ title: poll.question,
+ options: poll.options,
+ version: poll.version,
+ });
+};
+const renderTipGoal = (tipGoalId, tipGoals) => {
+ const goal = tipGoals.find((goal) => goal.id === tipGoalId);
+ return art(path.join(__dirname, 'templates/tip-goal.art'), {
+ label: goal.label,
+ description: goal.description,
+ currentAmount: goal.currentAmount,
+ goalAmount: goal.goalAmount,
+ currentPercentage: goal.currentPercentage,
+ });
+};
+
+export { baseUrl, findAccountById, getAccountByUsername, getTagId, getTagSuggestion, getTimelineByAccountId, icon, parseAttachments, parseDescription, parseMedia, renderMedia, renderPoll, renderTipGoal };
diff --git a/lib/routes/fantia/namespace.ts b/lib/routes/fantia/namespace.ts
new file mode 100644
index 00000000000000..b419494dee49f7
--- /dev/null
+++ b/lib/routes/fantia/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Fantia',
+ url: 'fantia.jp',
+ lang: 'ja',
+};
diff --git a/lib/routes/fantia/search.ts b/lib/routes/fantia/search.ts
new file mode 100644
index 00000000000000..78a386c655ad25
--- /dev/null
+++ b/lib/routes/fantia/search.ts
@@ -0,0 +1,211 @@
+import { config } from '@/config';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/search/:type?/:caty?/:period?/:order?/:rating?/:keyword?',
+ categories: ['picture'],
+ view: ViewType.Pictures,
+ example: '/fantia/search/posts/all/daily',
+ parameters: {
+ type: {
+ description: 'Type, see the table below, `posts` by default',
+ options: [
+ { value: 'fanclubs', label: 'クリエイター' },
+ { value: 'posts', label: '投稿' },
+ { value: 'products', label: '商品' },
+ { value: 'commissions', label: 'コミッション' },
+ ],
+ default: 'posts',
+ },
+ caty: {
+ description: 'Category, see the table below, can also be found in search page URL, `すべてのクリエイター` by default',
+ options: [
+ { value: 'all', label: 'すべてのクリエイター' },
+ { value: 'illust', label: 'イラスト' },
+ { value: 'comic', label: '漫画' },
+ { value: 'cosplay', label: 'コスプレ' },
+ { value: 'youtuber', label: 'YouTuber・配信者' },
+ { value: 'vtuber', label: 'Vtuber' },
+ { value: 'voice', label: '音声作品・ASMR' },
+ { value: 'voiceactor', label: '声優・歌い手' },
+ { value: 'idol', label: 'アイドル' },
+ { value: 'anime', label: 'アニメ・映像・写真' },
+ { value: '3d', label: '3D' },
+ { value: 'game', label: 'ゲーム制作' },
+ { value: 'music', label: '音楽' },
+ { value: 'novel', label: '小説' },
+ { value: 'doll', label: 'ドール' },
+ { value: 'art', label: 'アート・デザイン' },
+ { value: 'program', label: 'プログラム' },
+ { value: 'handmade', label: '創作・ハンドメイド' },
+ { value: 'history', label: '歴史・評論・情報' },
+ { value: 'railroad', label: '鉄道・旅行・ミリタリー' },
+ { value: 'shop', label: 'ショップ' },
+ { value: 'other', label: 'その他' },
+ ],
+ default: 'all',
+ },
+ period: {
+ description: 'Ranking period, see the table below, empty by default',
+ options: [
+ { value: 'daily', label: 'デイリー' },
+ { value: 'weekly', label: 'ウィークリー' },
+ { value: 'monthly', label: 'マンスリー' },
+ { value: 'all', label: '全期間' },
+ ],
+ default: '',
+ },
+ order: {
+ description: 'Sorting, see the table below, `更新の新しい順` by default',
+ options: [
+ { value: 'updater', label: '更新の新しい順' },
+ { value: 'update_old', label: '更新の古い順' },
+ { value: 'newer', label: '投稿の新しい順' },
+ { value: 'create_old', label: '投稿の古い順' },
+ { value: 'popular', label: 'お気に入り数順' },
+ ],
+ default: 'updater',
+ },
+ rating: {
+ description: 'Rating, see the table below, `すべて` by default',
+ options: [
+ { value: 'all', label: 'すべて' },
+ { value: 'general', label: '一般のみ' },
+ { value: 'adult', label: 'R18 のみ' },
+ ],
+ default: 'all',
+ },
+ keyword: 'Keyword, empty by default',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ name: 'Search',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `Type
+
+| クリエイター | 投稿 | 商品 | コミッション |
+| ------------ | ----- | -------- | ------------ |
+| fanclubs | posts | products | commissions |
+
+ Category
+
+| 分类 | 分类名 |
+| ---------------------- | ---------- |
+| イラスト | illust |
+| 漫画 | comic |
+| コスプレ | cosplay |
+| YouTuber・配信者 | youtuber |
+| Vtuber | vtuber |
+| 音声作品・ASMR | voice |
+| 声優・歌い手 | voiceactor |
+| アイドル | idol |
+| アニメ・映像・写真 | anime |
+| 3D | 3d |
+| ゲーム制作 | game |
+| 音楽 | music |
+| 小説 | novel |
+| ドール | doll |
+| アート・デザイン | art |
+| プログラム | program |
+| 創作・ハンドメイド | handmade |
+| 歴史・評論・情報 | history |
+| 鉄道・旅行・ミリタリー | railroad |
+| ショップ | shop |
+| その他 | other |
+
+ Ranking period
+
+| デイリー | ウィークリー | マンスリー | 全期間 |
+| -------- | ------------ | ---------- | ------ |
+| daily | weekly | monthly | all |
+
+ Sorting
+
+| 更新の新しい順 | 更新の古い順 | 投稿の新しい順 | 投稿の古い順 | お気に入り数順 |
+| -------------- | ------------ | -------------- | ------------ | -------------- |
+| updater | update\_old | newer | create\_old | popular |
+
+ Rating
+
+| すべて | 一般のみ | R18 のみ |
+| ------ | -------- | -------- |
+| all | general | adult |`,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type') || 'posts';
+ const caty = ctx.req.param('caty') || '';
+ const order = ctx.req.param('order') || 'updater';
+ const rating = ctx.req.param('rating') || 'all';
+ const keyword = ctx.req.param('keyword') || '';
+ const period = ctx.req.param('period') || '';
+
+ const rootUrl = 'https://fantia.jp';
+ const apiUrl = `${rootUrl}/api/v1/search/${type}?keyword=${keyword}&peroid=${period}&brand_type=0&category=${caty === 'all' ? '' : caty}&order=${order}${
+ rating === 'all' ? '' : rating === 'general' ? '&rating=general' : '&adult=1'
+ }&per_page=30`;
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ headers: {
+ Cookie: config.fantia.cookies ?? '',
+ },
+ });
+
+ let items = {};
+
+ switch (type) {
+ case 'fanclubs':
+ items = response.data.fanclubs.map((item) => ({
+ title: item.fanclub_name_with_creator_name,
+ link: `${rootUrl}/fanclubs/${item.id}`,
+ description: `${item.icon ? ` ` : ''}">${item.title}
`,
+ }));
+ break;
+
+ case 'posts':
+ items = response.data.posts.map((item) => ({
+ title: item.title,
+ link: `${rootUrl}/posts/${item.id}`,
+ pubDate: parseDate(item.posted_at),
+ author: item.fanclub.fanclub_name_with_creator_name,
+ description: `${item.comment ? `${item.comment}
` : ''} `,
+ }));
+ break;
+
+ case 'products':
+ items = response.data.products.map((item) => ({
+ title: item.name,
+ link: `${rootUrl}/products/${item.id}`,
+ author: item.fanclub.fanclub_name_with_creator_name,
+ description: `${item.buyable_lowest_plan.description ? `${item.buyable_lowest_plan.description}
` : ''} `,
+ }));
+ break;
+
+ case 'commissions':
+ items = response.data.commissions.map((item) => ({
+ title: item.name,
+ link: `${rootUrl}/commissions/${item.id}`,
+ author: item.fanclub.fanclub_name_with_creator_name,
+ description: `${item.buyable_lowest_plan.description ? `${item.buyable_lowest_plan.description}
` : ''} `,
+ }));
+ break;
+ }
+
+ return {
+ title: `Fantia - Search ${type}`,
+ link: apiUrl.replace('api/v1/search/', ''),
+ item: items,
+ };
+}
diff --git a/lib/routes/fantia/user.ts b/lib/routes/fantia/user.ts
new file mode 100644
index 00000000000000..4847145fee7282
--- /dev/null
+++ b/lib/routes/fantia/user.ts
@@ -0,0 +1,88 @@
+import { config } from '@/config';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/user/:id',
+ categories: ['picture'],
+ view: ViewType.Pictures,
+ example: '/fantia/user/3498',
+ parameters: { id: 'User id, can be found in user profile URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['fantia.jp/fanclubs/:id'],
+ },
+ ],
+ name: 'User Posts',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://fantia.jp';
+ const userUrl = `${rootUrl}/api/v1/fanclubs/${ctx.req.param('id')}`;
+
+ const initalResponse = await got({
+ method: 'get',
+ url: rootUrl,
+ headers: {
+ Cookie: config.fantia.cookies ?? '',
+ },
+ });
+
+ const csrfToken = initalResponse.data.match(/name="csrf-token" content="(.*?)"\s?\/>/)[1];
+
+ const response = await got({
+ method: 'get',
+ url: userUrl,
+ headers: {
+ Cookie: config.fantia.cookies ?? '',
+ },
+ });
+
+ const list = response.data.fanclub.recent_posts.map((item) => ({
+ title: item.title,
+ link: `${rootUrl}/api/v1/posts/${item.id}`,
+ description: `${item.comment}
`,
+ pubDate: parseDate(item.posted_at),
+ }));
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const contentResponse = await got({
+ method: 'get',
+ url: item.link,
+ headers: {
+ Cookie: config.fantia.cookies ?? '',
+ 'X-CSRF-Token': csrfToken,
+ Accept: 'application/json, text/plain, */*',
+ Referer: `${rootUrl}/`,
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ });
+ item.link = item.link.replace('api/v1/', '');
+ item.description += ` `;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `Fantia - ${response.data.fanclub.fanclub_name_with_creator_name}`,
+ link: `${rootUrl}/fanclubs/${ctx.req.param('id')}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/fantube/creator.ts b/lib/routes/fantube/creator.ts
new file mode 100644
index 00000000000000..dc49ec7cee7bdf
--- /dev/null
+++ b/lib/routes/fantube/creator.ts
@@ -0,0 +1,71 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { baseUrl, getCreatorFragment, getCreatorPostReelList } from './utils';
+
+export const route: Route = {
+ path: '/r18/creator/:identifier',
+ categories: ['multimedia'],
+ example: '/fantube/r18/creator/miyuu',
+ parameters: { identifier: 'User handle' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.fantube.tokyo/r18/creator/:identifier'],
+ },
+ ],
+ name: 'User Posts',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+const render = ({ description, thumbnailUrl, sampleVideoId, imageUrls }) =>
+ art(path.join(__dirname, 'templates/post.art'), {
+ description,
+ thumbnailUrl,
+ sampleVideoId,
+ imageUrls,
+ });
+
+async function handler(ctx) {
+ const { identifier } = ctx.req.param();
+ const limit = Number.parseInt(ctx.req.query('limit') || 18, 10);
+
+ const creatorInfo = await getCreatorFragment(identifier);
+ const posts = await getCreatorPostReelList(identifier, limit);
+
+ const items = posts.map((p) => ({
+ title: p.title.replaceAll('\n', ' ').trim(),
+ description: render({
+ description: p.description,
+ thumbnailUrl: p.thumbnailUrl,
+ sampleVideoId: p.sampleVideoId,
+ imageUrls: p.contentData?.imageUrls || [],
+ }),
+ link: `${baseUrl}/r18/post/${p.id}?creator=${identifier}`,
+ author: p.creator.displayName,
+ pubDate: parseDate(p.publishStartAt),
+ image: p.thumbnailUrl,
+ }));
+
+ return {
+ title: `${creatorInfo.displayName}のプロフィール|クリエイターページ|FANTUBE(ファンチューブ)`,
+ link: `${baseUrl}/r18/creator/${identifier}`,
+ description: creatorInfo.description,
+ image: creatorInfo.avatarImageUrl,
+ icon: creatorInfo.avatarImageUrl,
+ logo: creatorInfo.avatarImageUrl,
+ language: 'ja',
+ item: items,
+ };
+}
diff --git a/lib/routes/fantube/namespace.ts b/lib/routes/fantube/namespace.ts
new file mode 100644
index 00000000000000..b214b184e39d9f
--- /dev/null
+++ b/lib/routes/fantube/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FANTUBE',
+ url: 'www.fantube.tokyo',
+ lang: 'ja',
+};
diff --git a/lib/routes/fantube/templates/post.art b/lib/routes/fantube/templates/post.art
new file mode 100644
index 00000000000000..4d1d38589f990e
--- /dev/null
+++ b/lib/routes/fantube/templates/post.art
@@ -0,0 +1,17 @@
+{{ if thumbnailUrl }}
+
+{{ /if }}
+
+{{ if imageUrls }}
+ {{ each imageUrls img }}
+
+ {{ /each }}
+{{ /if }}
+
+{{ if sampleVideoId }}
+
+{{ /if }}
+
+{{ if description }}
+ {{@ description.replaceAll('\n', ' ') }}
+{{ /if }}
diff --git a/lib/routes/fantube/types.ts b/lib/routes/fantube/types.ts
new file mode 100644
index 00000000000000..9908eab702ec13
--- /dev/null
+++ b/lib/routes/fantube/types.ts
@@ -0,0 +1,100 @@
+interface PlanPost {
+ id: string;
+ thumbnailUrl: string;
+ title: string;
+}
+
+interface Plan {
+ id: string;
+ title: string;
+ price: number;
+ description: string;
+ isArchive: boolean;
+ isRecommended: boolean;
+ deleteRequestAt: null;
+ subscriptionCloseAt: null;
+ capacity: null;
+ isSubscribing: boolean;
+ subscribersCount: number;
+ planPosts: {
+ totalCount: number;
+ nodes: {
+ post: PlanPost;
+ }[];
+ };
+}
+
+interface Followers {
+ totalCount: number;
+}
+
+interface CreatorUnitPurchaseTotalCount {
+ totalCount: number;
+}
+
+interface CreatorPostsTotalCount {
+ totalCount: number;
+}
+
+interface AllPosts {
+ totalCount: number;
+}
+
+export interface CreatorFragment {
+ displayName: string;
+ id: string;
+ messageReceive: boolean;
+ coverImageUrl: string;
+ avatarImageUrl: string;
+ identifier: string;
+ description: string;
+ snsLinks: string[];
+ isSelf: boolean;
+ following: boolean;
+ followers: Followers;
+ creatorUnitPurchaseTotalCount: CreatorUnitPurchaseTotalCount;
+ creatorPostsTotalCount: CreatorPostsTotalCount;
+ allPosts: AllPosts;
+ plans: {
+ totalCount: number;
+ nodes: Plan[];
+ };
+}
+
+interface Comments {
+ totalCount: number;
+}
+
+export interface PostReelNode {
+ id: string;
+ title: string;
+ type: 'VIDEO' | 'IMAGE';
+ price: number;
+ sampleVideoId: string | null;
+ thumbnailUrl: string;
+ description: string;
+ publishStartAt: string;
+ pinnedAt: string | null;
+ isBuyEnabled: boolean;
+ isFavorite: boolean;
+ isMine: boolean;
+ canComment: boolean;
+ creator: CreatorFragment;
+ comments: Comments;
+ planPosts: {
+ nodes: {
+ plan: Plan;
+ }[];
+ };
+ favoritesCount: number;
+ contentData: {
+ __typename: 'PostVideoType' | 'PostImageType';
+ videoUrl: string;
+ isSample: boolean;
+ noSample: boolean;
+ durationSeconds: number;
+ encrypted: boolean;
+ imageUrls: string[];
+ count: number;
+ };
+}
diff --git a/lib/routes/fantube/utils.ts b/lib/routes/fantube/utils.ts
new file mode 100644
index 00000000000000..bd303aec82b547
--- /dev/null
+++ b/lib/routes/fantube/utils.ts
@@ -0,0 +1,270 @@
+import { load } from 'cheerio';
+
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+import type { CreatorFragment, PostReelNode } from './types';
+
+export const baseUrl = 'https://www.fantube.tokyo';
+
+export const getCreatorFragment = (username: string) =>
+ cache.tryGet(`fantube:creator:${username}`, async () => {
+ const response = await ofetch(`${baseUrl}/r18/creator/${username}`, {
+ headers: {
+ cookie: 'fantube-ageVerified=1;',
+ },
+ });
+ const $ = load(response);
+
+ const selfPushString = JSON.parse(
+ $('script:contains("creatorFragment")')
+ .text()
+ .match(/^self\.__next_f\.push\((.+?)\)$/)?.[1] || '{}'
+ );
+ const selfPushData = JSON.parse(selfPushString[1].slice(2));
+ // const creatorFragment = selfPushData[3].children.find((c) => c[1] === 'div')[3].children[3].creatorFragment;
+ const creatorFragment = selfPushData
+ .find((d) => d?.hasOwnProperty('children'))
+ .children.find((child) => Object.values(child).includes('div'))
+ .find((c) => c?.hasOwnProperty('children'))
+ .children.find((c) => c?.hasOwnProperty('creatorFragment')).creatorFragment;
+
+ return creatorFragment as CreatorFragment;
+ });
+
+export const getCreatorPostReelList = (identifier: string, limit: number): Promise =>
+ cache.tryGet(`fantube:creatorPostReelList:${identifier}:${limit}`, async () => {
+ const response = await ofetch('https://api.prd.fantube.tokyo/graphql', {
+ headers: {
+ Referer: baseUrl,
+ },
+ body: JSON.stringify({
+ query: `query CreatorPostReelList($identifier: String!, $first: Int, $after: String, $last: Int, $before: String) {
+ posts(
+ where: {status: {equals: PUBLISHED}, creator: {is: {identifier: {equals: $identifier}}}}
+ orderBy: [{pinnedAt: {nulls: last, sort: desc}}, {order: asc}, {createdAt: desc}, {id: desc}]
+ first: $first
+ after: $after
+ last: $last
+ before: $before
+ ) {
+ nodes {
+ ...PostSwiper_Post
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ hasPreviousPage
+ startCursor
+ }
+ }
+}
+
+fragment PostSwiper_Post on Post {
+ id
+ title
+ isFavorite
+ favoritesCount
+ ...PostSwiperSlide_Post
+}
+
+fragment PostSwiperSlide_Post on Post {
+ id
+ type
+ title
+ price
+ creator {
+ displayName
+ }
+ ...PostVideoElement_Post
+ ...PostImageElement_Post
+}
+
+fragment PostVideoElement_Post on Post {
+ id
+ title
+ contentData {
+ ... on PostVideoType {
+ __typename
+ videoUrl
+ isSample
+ noSample
+ durationSeconds
+ }
+ }
+ isFavorite
+ sampleVideoId
+ thumbnailUrl
+ creator {
+ displayName
+ }
+ ...PostInfo_Post
+ ...VideoControlIcons_Post
+ ...PurchaseWrapper_Post
+}
+
+fragment PostInfo_Post on Post {
+ title
+ description
+ publishStartAt
+ price
+ isBuyEnabled
+ ...Profile_Post
+}
+
+fragment Profile_Post on Post {
+ id
+ creator {
+ id
+ isSelf
+ identifier
+ displayName
+ avatarImageUrl
+ following
+ }
+}
+
+fragment VideoControlIcons_Post on Post {
+ id
+ isMine
+ pinnedAt
+ favoritesCount
+ ...PostComment_Post
+}
+
+fragment PostComment_Post on Post {
+ id
+ isMine
+ canComment
+ comments(
+ where: {OR: [{parentPostComment: {is: {isDeleted: {equals: false}}}}, {parentPostCommentId: {equals: null}}], isDeleted: {equals: false}}
+ ) {
+ totalCount
+ }
+ ...PostCommentReplyDrawer_Post
+}
+
+fragment PostCommentReplyDrawer_Post on Post {
+ id
+ isMine
+ canComment
+}
+
+fragment PurchaseWrapper_Post on Post {
+ id
+ title
+ price
+ creator {
+ displayName
+ }
+ ...PostPurchaseDialog_Post
+ ...PostPurchaseSingleDialog_Post
+}
+
+fragment PostPurchaseDialog_Post on Post {
+ id
+ isBuyEnabled
+ price
+ thumbnailUrl
+ title
+ planPosts(
+ orderBy: [{plan: {deleteRequestAt: {sort: desc, nulls: first}}}, {plan: {isRecommended: desc}}, {plan: {price: asc}}]
+ ) {
+ nodes {
+ plan {
+ id
+ title
+ price
+ ...PlanSwiper_Plan
+ }
+ }
+ }
+ creator {
+ displayName
+ }
+ ...PostPurchaseSingleDialog_Post
+}
+
+fragment PlanSwiper_Plan on Plan {
+ id
+ ...PlanSwiperItem_Plan
+}
+
+fragment PlanSwiperItem_Plan on Plan {
+ id
+ title
+ price
+ isArchive
+ isRecommended
+ deleteRequestAt
+ isSubscribing
+ subscriptionCloseAt
+ capacity
+ subscribersCount
+ planPosts(
+ where: {post: {is: {status: {equals: PUBLISHED}}}}
+ first: 7
+ orderBy: [{createdAt: desc}]
+ ) {
+ nodes {
+ post {
+ id
+ thumbnailUrl
+ title
+ }
+ }
+ totalCount
+ }
+ ...PlanUnavailableNote_Plan
+}
+
+fragment PlanUnavailableNote_Plan on Plan {
+ capacity
+ subscribersCount
+ subscriptionCloseAt
+ deleteRequestAt
+}
+
+fragment PostPurchaseSingleDialog_Post on Post {
+ id
+ price
+ thumbnailUrl
+ title
+ isBuyEnabled
+}
+
+fragment PostImageElement_Post on Post {
+ id
+ title
+ contentData {
+ __typename
+ ... on PostImageType {
+ encrypted
+ imageUrls
+ count
+ }
+ }
+ isFavorite
+ creator {
+ displayName
+ }
+ ...PostInfo_Post
+ ...ImageControlIcons_Post
+ ...PurchaseWrapper_Post
+}
+
+fragment ImageControlIcons_Post on Post {
+ id
+ isMine
+ pinnedAt
+ favoritesCount
+ ...PostComment_Post
+}`,
+ variables: { identifier, first: limit, after: '' },
+ operationName: 'CreatorPostReelList',
+ }),
+ method: 'POST',
+ });
+
+ return response.data.posts.nodes as PostReelNode[];
+ });
diff --git a/lib/routes/fanxinzhui/index.ts b/lib/routes/fanxinzhui/index.ts
new file mode 100644
index 00000000000000..93b9bad32d2ed8
--- /dev/null
+++ b/lib/routes/fanxinzhui/index.ts
@@ -0,0 +1,129 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/',
+ name: '最近更新',
+ url: 'fanxinzhui.com/lastest',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/fanxinzhui',
+ categories: ['multimedia'],
+
+ radar: [
+ {
+ source: ['fanxinzhui.com/lastest'],
+ target: '/',
+ },
+ ],
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'https://www.fanxinzhui.com';
+ const currentUrl = new URL('lastest', rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ let items = $('a.la')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const season = item.find('span.season').text();
+ const name = item.find('span.name').text();
+ const link = new URL(item.prop('href'), rootUrl).href;
+
+ return {
+ title: `${season} ${name}`,
+ link,
+ guid: `${link}#${season}`,
+ pubDate: timezone(parseDate(item.find('span.time').text()), +8),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const content = load(detailResponse);
+
+ item.author = undefined;
+ item.category = [];
+
+ content('div.info ul li').each((_, el) => {
+ el = content(el);
+
+ const key = el.find('span').text().split(/:/)[0];
+ const value = el.contents().last().text().trim();
+
+ if (key === '类型') {
+ item.category = [...item.category, ...value.split(/\//)];
+ } else if (key === '首播日期') {
+ return;
+ } else {
+ item.author = `${item.author ? `${item.author}/` : ''}${value}`;
+ item.category = [...item.category, ...value.split(/\//)].filter((c) => c !== '等');
+ }
+ });
+
+ content('div.image').each((_, el) => {
+ el = content(el);
+
+ const image = el.find('img').prop('src');
+
+ el.replaceWith(
+ art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image.replace(/@\d+,\d+\.\w+$/, ''),
+ alt: content('div.resource_title h2').text(),
+ },
+ ]
+ : undefined,
+ })
+ );
+ });
+
+ content('a.password').each((_, el) => {
+ el = content(el);
+
+ el.replaceWith(el.text());
+ });
+
+ item.description = content('div.middle_box').html();
+ item.enclosure_url = content('p.way span a').prop('href');
+
+ return item;
+ })
+ )
+ );
+
+ const title = $('title').text();
+ const image = new URL($('img.logo').prop('src'), rootUrl).href;
+
+ return {
+ item: items,
+ title,
+ link: currentUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: 'zh',
+ image,
+ author: title.split(/_/).pop(),
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/fanxinzhui/latest.js b/lib/routes/fanxinzhui/latest.js
deleted file mode 100644
index 28af5948beca9b..00000000000000
--- a/lib/routes/fanxinzhui/latest.js
+++ /dev/null
@@ -1,52 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const date = require('@/utils/date');
-
-module.exports = async (ctx) => {
- const rootUrl = 'http://www.fanxinzhui.com';
- const currentUrl = `${rootUrl}/lastest`;
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = cheerio.load(response.data);
-
- const list = $('.la')
- .slice(0, 10)
- .map((_, item) => {
- item = $(item);
- const pubDate = item.find('.time');
-
- pubDate.remove();
-
- return {
- title: item.text(),
- pubDate: date(pubDate.text()),
- link: `${rootUrl}${item.attr('href')}`,
- };
- })
- .get();
-
- const items = await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const content = cheerio.load(detailResponse.data);
-
- item.description = content('.middle_box').html();
-
- return item;
- })
- )
- );
-
- ctx.state.data = {
- title: '最近更新 - 追新番',
- link: currentUrl,
- item: items,
- };
-};
diff --git a/lib/routes/fanxinzhui/namespace.ts b/lib/routes/fanxinzhui/namespace.ts
new file mode 100644
index 00000000000000..cf4a57a3af8b3e
--- /dev/null
+++ b/lib/routes/fanxinzhui/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '追新番',
+ url: 'fanxinzhui.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/fanxinzhui/templates/description.art b/lib/routes/fanxinzhui/templates/description.art
new file mode 100644
index 00000000000000..0a7f83a6f60fb1
--- /dev/null
+++ b/lib/routes/fanxinzhui/templates/description.art
@@ -0,0 +1,13 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/farcaster/namespace.ts b/lib/routes/farcaster/namespace.ts
new file mode 100644
index 00000000000000..353dbea3b61a8d
--- /dev/null
+++ b/lib/routes/farcaster/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Farcaster',
+ url: 'www.farcaster.xyz',
+ lang: 'en',
+};
diff --git a/lib/routes/farcaster/user.ts b/lib/routes/farcaster/user.ts
new file mode 100644
index 00000000000000..646edb57e16780
--- /dev/null
+++ b/lib/routes/farcaster/user.ts
@@ -0,0 +1,46 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/user/:username',
+ categories: ['social-media'],
+ example: '/farcaster/user/vitalik.eth',
+ parameters: { username: 'Farcaster username' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['warpcast.com/:username'],
+ target: '/user/:username',
+ },
+ ],
+ name: 'Farcaster User',
+ maintainers: ['DIYgod'],
+ handler,
+};
+
+async function handler(ctx) {
+ const username = ctx.req.param('username');
+
+ const user = (await got(`https://client.warpcast.com/v2/user-by-username?username=${username}`)).data.result.user;
+
+ const casts = (await got(`https://client.warpcast.com/v2/casts?fid=${user.fid}&limit=100`)).data.result.casts;
+
+ return {
+ title: `${user.displayName} on Farcaster`,
+ link: `https://warpcast.com/${username}`,
+ item: casts.map((item) => ({
+ title: item.text,
+ description: `${item.parentAuthor ? `Replying to @${item.parentAuthor.username}: ` : ''}${item.text} ${item.embeds?.urls?.map((url) => `${url.openGraph.title} `).join(' ') || ''} ${item.embeds?.images?.map((image) => ` `).join(' ') || ''}`,
+ link: `https://warpcast.com/${username}/cast/${item.hash}`,
+ pubDate: new Date(item.timestamp).toUTCString(),
+ guid: item.hash,
+ })),
+ };
+}
diff --git a/lib/routes/farmatters/index.ts b/lib/routes/farmatters/index.ts
new file mode 100644
index 00000000000000..3da082383d7716
--- /dev/null
+++ b/lib/routes/farmatters/index.ts
@@ -0,0 +1,115 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import MarkdownIt from 'markdown-it';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const md = MarkdownIt({
+ html: true,
+});
+
+const ids = {
+ 1: 0,
+ 2: 2,
+ 3: 1,
+};
+
+export const route: Route = {
+ path: ['/exclusive/:locale?', '/news/:locale?', '/:locale?', '/:type/:id/:locale?'],
+ categories: ['new-media'],
+ example: '/farmatters/exclusive',
+ parameters: { locale: 'Locale, `zh-CN` or `en-US`, `zh-CN` by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['farmatters.com/exclusive'],
+ target: '/exclusive',
+ },
+ ],
+ name: 'Exclusive',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'farmatters.com/news',
+};
+
+async function handler(ctx) {
+ const { type, id, locale = 'zh-CN' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50;
+
+ const searchParams = {
+ locale,
+ page: 0,
+ pagesize: limit,
+ };
+
+ if (type === 'wiki' && id) {
+ searchParams.subCatalogId = id;
+ } else if (type && id) {
+ searchParams[type] = id;
+ }
+
+ const rootUrl = 'https://farmatters.com';
+ const apiUrl = new URL('api/v1/doc/list', rootUrl).href;
+ const currentUrl = new URL(`${locale === 'zh-CN' ? '' : 'en/'}${type ? (type === 'wiki' ? 'wiki' : `tag/${id}`) : 'news'}`, rootUrl).href;
+
+ const { data: response } = await got(apiUrl, {
+ searchParams,
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+
+ const items = response.data.list.slice(0, limit).map((item) => ({
+ title: item.title,
+ link: new URL(`doc/${item.id}`, rootUrl).href,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ image: item.headImageUrl
+ ? {
+ src: item.headImageUrl,
+ alt: item.title,
+ }
+ : undefined,
+ description: md.render(item.content ?? item.summary),
+ }),
+ author: item.author,
+ category: [item.catalogName, item.subCatalogName ?? undefined, ...(item.tags?.map((t) => t.tagName) ?? [])].filter(Boolean),
+ guid: `farmatters-${item.id}`,
+ pubDate: timezone(parseDate(item.createdAt), +8),
+ }));
+
+ const { data: currentResponse } = await got(currentUrl, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+
+ const $ = load(currentResponse);
+
+ const subtitle = `${$('h4').first().text()}${type === 'wiki' ? ` - ${$('div.css-6f6728 div.MuiBox-root').eq(ids[id]).text()}` : ''}`;
+ const icon = new URL('favicon.ico', rootUrl).href;
+
+ return {
+ item: items,
+ title: `${$('title').text().split(/-/)[0].trim()} - ${subtitle}`,
+ link: currentUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: $('html').prop('lang'),
+ image: new URL($('img').first().prop('src'), rootUrl).href,
+ icon,
+ logo: icon,
+ subtitle,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/farmatters/namespace.ts b/lib/routes/farmatters/namespace.ts
new file mode 100644
index 00000000000000..1cdba790e989f4
--- /dev/null
+++ b/lib/routes/farmatters/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Farmatters',
+ url: 'farmatters.com',
+ categories: ['new-media'],
+ lang: 'en',
+};
diff --git a/lib/v2/farmatters/templates/description.art b/lib/routes/farmatters/templates/description.art
similarity index 100%
rename from lib/v2/farmatters/templates/description.art
rename to lib/routes/farmatters/templates/description.art
diff --git a/lib/routes/fashionnetwork/headline.js b/lib/routes/fashionnetwork/headline.js
deleted file mode 100644
index 4872f00f239745..00000000000000
--- a/lib/routes/fashionnetwork/headline.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { isValidHost } = require('@/utils/valid-host');
-
-module.exports = async (ctx) => {
- const country = ctx.params.country || 'ww';
- if (!isValidHost(country)) {
- throw Error('Invalid country');
- }
-
- const rootUrl = `https://${country}.fashionnetwork.com`;
- const response = await got({
- method: 'get',
- url: rootUrl,
- });
-
- const $ = cheerio.load(response.data);
-
- const list = $('.point-carousel__item')
- .slice(5, 10)
- .map((_, item) => {
- item = $(item);
- const a = item.find('.family-title a');
-
- return {
- title: a.text(),
- link: `${rootUrl}${a.attr('href')}`,
- pubDate: Date.parse(item.find('.time-ago').attr('data-value')),
- };
- })
- .get();
-
- const items = await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const content = cheerio.load(detailResponse.data);
-
- content('.newsTitle, .ads').remove();
-
- item.description = content('div[itemprop="text"]').html();
-
- return item;
- })
- )
- );
-
- ctx.state.data = {
- title: `${$('.category-label').eq(0).text()} - FashionNetwork`,
- link: rootUrl,
- item: items,
- };
-};
diff --git a/lib/routes/fashionnetwork/index.ts b/lib/routes/fashionnetwork/index.ts
new file mode 100644
index 00000000000000..e4d2498467fde7
--- /dev/null
+++ b/lib/routes/fashionnetwork/index.ts
@@ -0,0 +1,191 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx) => {
+ const { id = '0' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+
+ const rootUrl = 'https://fashionnetwork.cn';
+ const currentUrl = new URL(`lists/${id}`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('div.home__item')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.find('h2.family-title').text();
+
+ const src = item.find('img.item__img').first().prop('src') ?? undefined;
+ const image = src ? new URL(src, rootUrl).href : undefined;
+
+ const description = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ });
+
+ return {
+ title,
+ description,
+ link: new URL(item.find('h2.family-title a').prop('href'), rootUrl).href,
+ image,
+ banner: image,
+ language,
+ enclosure_url: image,
+ enclosure_type: image ? `image/${image.split(/\./).pop()}` : undefined,
+ enclosure_title: title,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ const title = $$('h1.newsTitle').text();
+ const description = art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('div.article-content').html(),
+ });
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = timezone(parseDate($$('span.time-ago').first().text().trim()), +8);
+ item.category = $$('div.newsTags')
+ .first()
+ .find('div.news-tag')
+ .toArray()
+ .map((c) => $$(c).text());
+ item.author = $$('div.newsLeftCol span').first().text();
+ item.content = {
+ html: description,
+ text: $$('div.article-content').text(),
+ };
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const label = $(`label[for="news_categs_${id}"]`).text()?.split(/\(/)?.[0]?.trim() ?? '';
+ const image = new URL($('div.header__fnw-logo img').prop('src'), rootUrl).href;
+
+ return {
+ title: `${label}${$('title').text()}`,
+ description: $('meta[name="description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('meta[name="author"]').prop('content'),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/cn/lists/:id?',
+ name: 'FashionNetwork 中国',
+ url: 'fashionnetwork.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/fashionnetwork/cn/lists/0',
+ parameters: { category: '分类,默认为 0,可在对应分类页 URL 中找到' },
+ description: `::: tip
+ 若订阅 [独家新闻](https://fashionnetwork.cn),网址为 \`https://fashionnetwork.cn/lists/13.html\`。截取 \`https://fashionnetwork.cn/\` 到末尾 \`.html\` 的部分 \`13\` 作为参数填入,此时路由为 [\`/fashionnetwork/cn/lists/13\`](https://rsshub.app/fashionnetwork/cn/lists/13)。
+:::
+
+| 分类 | ID |
+| ---------------------------------------------- | --------------------------------------------------- |
+| [独家](https://fashionnetwork.cn/lists/13) | [13](https://rsshub.app/fashionnetwork/cn/lists/13) |
+| [商业](https://fashionnetwork.cn/lists/1) | [1](https://rsshub.app/fashionnetwork/cn/lists/1) |
+| [人物](https://fashionnetwork.cn/lists/8) | [8](https://rsshub.app/fashionnetwork/cn/lists/8) |
+| [设计](https://fashionnetwork.cn/lists/3) | [3](https://rsshub.app/fashionnetwork/cn/lists/3) |
+| [产业](https://fashionnetwork.cn/lists/5) | [5](https://rsshub.app/fashionnetwork/cn/lists/5) |
+| [创新研究](https://fashionnetwork.cn/lists/6) | [6](https://rsshub.app/fashionnetwork/cn/lists/6) |
+| [人事变动](https://fashionnetwork.cn/lists/12) | [12](https://rsshub.app/fashionnetwork/cn/lists/12) |
+| [新闻资讯](https://fashionnetwork.cn/lists/11) | [11](https://rsshub.app/fashionnetwork/cn/lists/11) |
+ `,
+ categories: ['new-media'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['fashionnetwork.cn/lists/:id'],
+ target: (params) => {
+ const id = params.id;
+
+ return `/fashionnetwork/cn/lists${id ? `/${id}` : ''}`;
+ },
+ },
+ {
+ title: '独家',
+ source: ['fashionnetwork.cn/lists/13'],
+ target: '/cn/lists/13',
+ },
+ {
+ title: '商业',
+ source: ['fashionnetwork.cn/lists/1'],
+ target: '/cn/lists/1',
+ },
+ {
+ title: '人物',
+ source: ['fashionnetwork.cn/lists/8'],
+ target: '/cn/lists/8',
+ },
+ {
+ title: '设计',
+ source: ['fashionnetwork.cn/lists/3'],
+ target: '/cn/lists/3',
+ },
+ {
+ title: '产业',
+ source: ['fashionnetwork.cn/lists/5'],
+ target: '/cn/lists/5',
+ },
+ {
+ title: '创新研究',
+ source: ['fashionnetwork.cn/lists/6'],
+ target: '/cn/lists/6',
+ },
+ {
+ title: '人事变动',
+ source: ['fashionnetwork.cn/lists/12'],
+ target: '/cn/lists/12',
+ },
+ {
+ title: '新闻资讯',
+ source: ['fashionnetwork.cn/lists/11'],
+ target: '/cn/lists/11',
+ },
+ ],
+};
diff --git a/lib/routes/fashionnetwork/namespace.ts b/lib/routes/fashionnetwork/namespace.ts
new file mode 100644
index 00000000000000..d5d9caae9359b6
--- /dev/null
+++ b/lib/routes/fashionnetwork/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FashionNetwork',
+ url: 'fashionnetwork.cn',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/fashionnetwork/news.js b/lib/routes/fashionnetwork/news.js
deleted file mode 100644
index 211ef03a1b0503..00000000000000
--- a/lib/routes/fashionnetwork/news.js
+++ /dev/null
@@ -1,74 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { isValidHost } = require('@/utils/valid-host');
-
-module.exports = async (ctx) => {
- const country = ctx.params.country || 'ww';
- let sectors = ctx.params.sectors || '';
- let categories = ctx.params.categories || '';
-
- sectors = sectors === 'all' ? '' : sectors;
- categories = categories === 'all' ? '' : categories;
-
- const sectorsUrl = sectors ? 'sectors%5B%5D=' + sectors.split(',').join('§ors%5B%5D=') : '';
- const categoriesUrl = categories ? 'categs%5B%5D=' + categories.split(',').join('&categs%5B%5D=') : '';
-
- if (!isValidHost(country)) {
- throw Error('Invalid country');
- }
-
- const rootUrl = `https://${country}.fashionnetwork.com`;
- const currentUrl = `${rootUrl}/news/s.jsonp?${sectorsUrl}&${categoriesUrl}`;
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = cheerio.load(
- unescape(response.data.match(/"html":"(.*)","relatedUrl"/)[1].replace(/\\(u[0-9a-fA-F]{4})/gm, '%$1'))
- .replace(/\\n/g, '')
- .replace(/\\\//g, '/')
- );
-
- const list = $('.list-ui__title')
- .slice(0, 10)
- .map((_, item) => {
- item = $(item);
- return {
- title: item.text(),
- link: item.attr('href'),
- pubDate: Date.parse(item.parent().find('time').attr('datetime')),
- };
- })
- .get();
-
- const items = await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const content = cheerio.load(detailResponse.data);
-
- content('.newsTitle, .ads').remove();
-
- item.description = content('div[itemprop="text"]').html();
-
- return item;
- })
- )
- );
-
- const labels = [];
-
- $('.filter__label').each(function () {
- labels.push($(this).text());
- });
-
- ctx.state.data = {
- title: `${labels.join(',') || 'All'} - FashionNetwork`,
- link: `${rootUrl}/news/s?${sectorsUrl}&${categoriesUrl}`,
- item: items,
- };
-};
diff --git a/lib/routes/fashionnetwork/templates/description.art b/lib/routes/fashionnetwork/templates/description.art
new file mode 100644
index 00000000000000..dfab19230c1108
--- /dev/null
+++ b/lib/routes/fashionnetwork/templates/description.art
@@ -0,0 +1,17 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/fastbull/express-news.ts b/lib/routes/fastbull/express-news.ts
new file mode 100644
index 00000000000000..83354cd1549cf3
--- /dev/null
+++ b/lib/routes/fastbull/express-news.ts
@@ -0,0 +1,61 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/express-news',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/fastbull/express-news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['fastbull.com/express-news', 'fastbull.com/'],
+ },
+ ],
+ name: 'News Flash',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'fastbull.com/express-news',
+};
+
+async function handler() {
+ const rootUrl = 'https://www.fastbull.com';
+ const currentUrl = `${rootUrl}/express-news`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const items = $('.news-list')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.find('.title_name').text(),
+ pubDate: parseDate(Number.parseInt(item.attr('data-date'))),
+ link: `${rootUrl}${item.find('.title_name').attr('href')}`,
+ };
+ });
+
+ return {
+ title: '实时财经快讯 - FastBull',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/fastbull/namespace.ts b/lib/routes/fastbull/namespace.ts
new file mode 100644
index 00000000000000..f35155677fd84c
--- /dev/null
+++ b/lib/routes/fastbull/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FastBull',
+ url: 'fastbull.com',
+ lang: 'en',
+};
diff --git a/lib/routes/fastbull/news.ts b/lib/routes/fastbull/news.ts
new file mode 100644
index 00000000000000..4e53d2102f693d
--- /dev/null
+++ b/lib/routes/fastbull/news.ts
@@ -0,0 +1,87 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/fastbull/news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['fastbull.com/cn/news', 'fastbull.com/cn'],
+ },
+ ],
+ name: 'News',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'fastbull.com/news',
+};
+
+async function handler() {
+ const rootUrl = 'https://www.fastbull.com';
+ const currentUrl = `${rootUrl}/cn/news`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.trending_type')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.find('.title').text(),
+ link: `${rootUrl}${item.attr('href')}`,
+ author: item.find('.resource').text(),
+ description: item.find('.tips').text(),
+ pubDate: parseDate(Number.parseInt(item.find('.new_time').attr('data-date'))),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ tips: item.description,
+ description: content('.news-detail-content').html(),
+ });
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '财经头条、财经新闻、最新资讯 - FastBull',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/v2/fastbull/templates/description.art b/lib/routes/fastbull/templates/description.art
similarity index 100%
rename from lib/v2/fastbull/templates/description.art
rename to lib/routes/fastbull/templates/description.art
diff --git a/lib/routes/fda/cdrh.ts b/lib/routes/fda/cdrh.ts
new file mode 100644
index 00000000000000..32c1b4d1d60d8d
--- /dev/null
+++ b/lib/routes/fda/cdrh.ts
@@ -0,0 +1,79 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/cdrh/:titleOnly?',
+ radar: [
+ {
+ source: ['fda.gov/medical-devices/news-events-medical-devices/cdrhnew-news-and-updates', 'fda.gov/'],
+ target: '/cdrh/:titleOnly',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+ url: 'fda.gov/medical-devices/news-events-medical-devices/cdrhnew-news-and-updates',
+};
+
+async function handler(ctx) {
+ const titleOnly = !!(ctx.req.param('titleOnly') ?? '');
+ const rootUrl = 'https://www.fda.gov';
+ const currentUrl = `${rootUrl}/medical-devices/news-events-medical-devices/cdrhnew-news-and-updates`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('div[role="main"] a')
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 30)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const link = item.attr('href');
+
+ return {
+ title: item.text(),
+ link: link.startsWith('http') ? link : `${rootUrl}${link}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(titleOnly ? `${item.link}#${item.title}#titleOnly` : `${item.link}#${item.title}`, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.author = content('meta[property="article:publisher"]').attr('content');
+
+ try {
+ item.pubDate = parseDate(content('meta[property="article:published_time"]').attr('content').split(', ').pop(), 'MM/DD/YYYY - HH:mm');
+ } catch {
+ item.pubDate = parseDate(content('meta[property="article:published_time"]').attr('content'));
+ }
+
+ item.description = titleOnly ? null : content('div[role="main"], .doc-content-area').html();
+ item.guid = titleOnly ? `${item.link}#${item.title}#titleOnly` : `${item.link}#${item.title}`;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/fda/namespace.ts b/lib/routes/fda/namespace.ts
new file mode 100644
index 00000000000000..67d34bbfd36e30
--- /dev/null
+++ b/lib/routes/fda/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'U.S. Food and Drug Administration',
+ url: 'fda.gov',
+ lang: 'en',
+};
diff --git a/lib/routes/fdroid/apprelease.js b/lib/routes/fdroid/apprelease.js
deleted file mode 100644
index e038f156d38081..00000000000000
--- a/lib/routes/fdroid/apprelease.js
+++ /dev/null
@@ -1,33 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const response = await got.get(`https://f-droid.org/en/packages/${ctx.params.app}/`);
- const data = response.data;
- const $ = cheerio.load(data);
-
- const app_name = $('.package-title').find('h3').text().trim();
- const app_descr = $('.package-title').find('.package-summary').text();
-
- const items = [];
- $('.package-versions-list')
- .find('.package-version')
- .each(function () {
- const item = {};
- const version = $(this).find('.package-version-header').find('a').eq(0).attr('name');
- item.title = version;
- item.guid = $(this).find('.package-version-header').find('a').eq(1).attr('name');
- item.pubDate = new Date($(this).find('.package-version-header').text().split('Added on ')[1]).toUTCString();
- item.description = [$(this).find('.package-version-download').html(), $(this).find('.package-version-requirement').html(), $(this).find('.package-version-source').html()].join(' ');
- item.link = `https://f-droid.org/en/packages/${ctx.params.app}/#${version}`;
-
- items.push(item);
- });
-
- ctx.state.data = {
- title: `${app_name} releases on F-Droid`,
- discription: app_descr,
- link: `https://f-droid.org/en/packages/${ctx.params.app}/`,
- item: items,
- };
-};
diff --git a/lib/routes/fediverse/namespace.ts b/lib/routes/fediverse/namespace.ts
new file mode 100644
index 00000000000000..dd888f29f7827f
--- /dev/null
+++ b/lib/routes/fediverse/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Fediverse',
+ url: 'fediverse.observer',
+ lang: 'en',
+};
diff --git a/lib/routes/fediverse/timeline.ts b/lib/routes/fediverse/timeline.ts
new file mode 100644
index 00000000000000..210f9b3b6d068a
--- /dev/null
+++ b/lib/routes/fediverse/timeline.ts
@@ -0,0 +1,116 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import parser from '@/utils/rss-parser';
+
+export const route: Route = {
+ path: '/timeline/:account',
+ categories: ['social-media'],
+ view: ViewType.SocialMedia,
+ example: '/fediverse/timeline/Mastodon@mastodon.social',
+ parameters: { account: 'username@domain' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Timeline',
+ maintainers: ['DIYgod', 'pseudoyu'],
+ handler,
+};
+
+const allowedDomain = new Set(['mastodon.social', 'pawoo.net', config.mastodon.apiHost].filter(Boolean));
+const activityPubTypes = new Set(['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']);
+
+async function handler(ctx) {
+ const account = ctx.req.param('account');
+ const domain = account.split('@')[1];
+ const username = account.split('@')[0];
+
+ if (!domain || !username) {
+ throw new InvalidParameterError('Invalid account');
+ }
+ if (!config.feature.allow_user_supply_unsafe_domain && !allowedDomain.has(domain.toLowerCase())) {
+ throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
+ }
+
+ const requestOptions = {
+ headers: {
+ Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+ },
+ };
+
+ const acc = await ofetch(`https://${domain}/.well-known/webfinger?resource=acct:${account}`, {
+ headers: {
+ Accept: 'application/jrd+json',
+ },
+ });
+ const jsonLink = acc.links.find((link) => link.rel === 'self' && activityPubTypes.has(link.type))?.href;
+ const link = acc.links.find((link) => link.rel === 'http://webfinger.net/rel/profile-page')?.href;
+ const officialFeed = await parser.parseURL(`${link}.rss`);
+
+ if (officialFeed) {
+ return {
+ title: `${officialFeed.title} (Fediverse@${account})`,
+ description: officialFeed.description,
+ image: officialFeed.image?.url,
+ link: officialFeed.link,
+ item: officialFeed.items.map((item) => ({
+ title: item.title,
+ description: item.content,
+ link: item.link,
+ pubDate: item.pubDate ? parseDate(item.pubDate) : null,
+ guid: item.guid,
+ })),
+ };
+ }
+
+ const self = await ofetch(jsonLink, requestOptions);
+
+ // If RSS feed is not available, fallback to original method
+ const outbox = await ofetch(self.outbox, requestOptions);
+ const firstOutbox = await ofetch(outbox.first, requestOptions);
+
+ const items = firstOutbox.orderedItems;
+
+ const itemResolvers = [] as Promise[];
+
+ for (const item of items) {
+ if (!['Announce', 'Create', 'Update'].includes(item.type)) {
+ continue;
+ }
+ if (typeof item.object === 'string') {
+ itemResolvers.push(
+ (async (item) => {
+ item.object = await ofetch(item.object, requestOptions);
+ return item;
+ })(item)
+ );
+ } else {
+ itemResolvers.push(Promise.resolve(item));
+ }
+ }
+
+ const resolvedItems = await Promise.all(itemResolvers);
+
+ return {
+ title: `${self.name || self.preferredUsername} (Fediverse@${account})`,
+ description: self.summary,
+ image: self.icon?.url || self.image?.url,
+ link,
+ item: resolvedItems.map((item) => ({
+ title: item.object.content.replaceAll(/<[^<]*>/g, ''),
+ description: `${item.object.content}\n${item.object.attachment?.map((attachment) => ` `).join('\n') || ''}`,
+ link: item.object.url,
+ pubDate: parseDate(item.published),
+ guid: item.id,
+ })),
+ };
+}
diff --git a/lib/routes/feng/forum.ts b/lib/routes/feng/forum.ts
new file mode 100644
index 00000000000000..4e23c9d61f82d0
--- /dev/null
+++ b/lib/routes/feng/forum.ts
@@ -0,0 +1,72 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+import { baseUrl, getForumMeta, getThread, getThreads } from './utils';
+
+export const route: Route = {
+ path: '/forum/:id/:type?',
+ categories: ['bbs'],
+ example: '/feng/forum/1',
+ parameters: { id: '版块 ID,可在版块 URL 找到', type: '排序,见下表,默认为 `all`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['feng.com/forum/photo/:id', 'feng.com/forum/:id'],
+ target: '/forum/:id',
+ },
+ ],
+ name: '社区',
+ maintainers: ['TonyRL'],
+ handler,
+ description: `| 最新回复 | 最新发布 | 热门 | 精华 |
+| -------- | -------- | ---- | ------- |
+| newest | all | hot | essence |`,
+};
+
+async function handler(ctx) {
+ const topicId = Number(ctx.req.param('id'));
+ const { type = 'all' } = ctx.req.param();
+
+ const forumMeta = await getForumMeta(topicId);
+ const topicMeta = forumMeta.dataList.find((data) => data.topicId === topicId);
+ const threads = (await getThreads(topicId, type)).data.dataList.map((data) => ({
+ title: data.title,
+ pubDate: parseDate(data.dateline * 1000),
+ author: data.userBaseInfo.userName,
+ link: `${baseUrl}/post/${data.tid}`,
+ tid: data.tid,
+ }));
+
+ const posts = await Promise.all(
+ threads.map(async (item) => {
+ const thread = await getThread(item.tid, topicId);
+ if (thread.status.code === 0) {
+ const img = art(path.join(__dirname, 'templates/img.art'), {
+ images: thread.data.thread.fengTalkImage.length ? thread.data.thread.fengTalkImage : undefined,
+ });
+ item.description = thread.data.thread.message + img;
+ } else {
+ item.description = art(path.join(__dirname, 'templates/deleted.art'), {});
+ }
+ delete item.tid;
+ return item;
+ })
+ );
+
+ return {
+ title: `${topicMeta.topicName} - 社区 - 威锋 - 千万果粉大本营`,
+ description: topicMeta.topicDescription,
+ link: `${baseUrl}/forum/${topicId}`,
+ item: posts,
+ };
+}
diff --git a/lib/routes/feng/namespace.ts b/lib/routes/feng/namespace.ts
new file mode 100644
index 00000000000000..8fcdb303912472
--- /dev/null
+++ b/lib/routes/feng/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '威锋',
+ url: 'feng.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/feng/templates/deleted.art b/lib/routes/feng/templates/deleted.art
similarity index 100%
rename from lib/v2/feng/templates/deleted.art
rename to lib/routes/feng/templates/deleted.art
diff --git a/lib/v2/feng/templates/img.art b/lib/routes/feng/templates/img.art
similarity index 100%
rename from lib/v2/feng/templates/img.art
rename to lib/routes/feng/templates/img.art
diff --git a/lib/routes/feng/utils.ts b/lib/routes/feng/utils.ts
new file mode 100644
index 00000000000000..f640e84dcd4449
--- /dev/null
+++ b/lib/routes/feng/utils.ts
@@ -0,0 +1,78 @@
+import CryptoJS from 'crypto-js';
+
+import { config } from '@/config';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const apiUrl = 'https://api.wfdata.club';
+const baseUrl = 'https://www.feng.com';
+const KEY = '2b7e151628aed2a6';
+
+// https://juejin.cn/post/6844904066468806664
+const getXRequestId = (apiUrl) => {
+ const path = new URL(apiUrl).pathname; // split search params
+ const plainText = CryptoJS.enc.Utf8.parse('url=' + path + '$time=' + Date.now() + '000000');
+ const iv = CryptoJS.enc.Utf8.parse(KEY);
+ const encrypted = CryptoJS.AES.encrypt(plainText, iv, {
+ iv,
+ mode: CryptoJS.mode.CBC,
+ padding: CryptoJS.pad.Pkcs7,
+ }).toString();
+ return encrypted;
+};
+
+const getCategory = (topicId) => {
+ const url = `${apiUrl}/v1/topic/category`;
+ return cache.tryGet(
+ url,
+ async () => {
+ const response = await got(url, {
+ headers: {
+ Referer: `${baseUrl}/forum/${topicId}`,
+ 'X-Request-Id': getXRequestId(url),
+ },
+ });
+ return response.data;
+ },
+ config.cache.routeExpire,
+ false
+ );
+};
+
+const getForumMeta = async (topicId) => {
+ const categoryData = await getCategory(topicId);
+ return Object.values(categoryData.data.dataList).find((item) => item.dataList.find((i) => i.topicId === topicId));
+};
+
+const getThreads = (topicId, type) => {
+ const url = `${apiUrl}/v1/topic/${topicId}/thread?topicId=${topicId}&type=${type}&pageCount=50&order=${type === 'newest' ? 'replyTime' : 'postTime'}&page=1`;
+ return cache.tryGet(
+ url,
+ async () => {
+ const response = await got(url, {
+ headers: {
+ Referer: `${baseUrl}/forum/${topicId}`,
+ 'X-Request-Id': getXRequestId(url),
+ },
+ });
+ return response.data;
+ },
+ config.cache.routeExpire,
+ false
+ );
+};
+
+const getThread = (tid, topicId) => {
+ const url = `${apiUrl}/v1/thread/${tid}`;
+ return cache.tryGet(url, async () => {
+ const response = await got(url, {
+ headers: {
+ Referer: `${baseUrl}/forum/${topicId}`,
+ 'X-Request-Id': getXRequestId(url),
+ },
+ });
+ return response.data;
+ });
+};
+
+export { baseUrl, getForumMeta, getThread, getThreads };
diff --git a/lib/routes/ff14/ff14-global.ts b/lib/routes/ff14/ff14-global.ts
new file mode 100644
index 00000000000000..c7eb127b6d2608
--- /dev/null
+++ b/lib/routes/ff14/ff14-global.ts
@@ -0,0 +1,75 @@
+import path from 'node:path';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import { isValidHost } from '@/utils/valid-host';
+
+export const route: Route = {
+ path: ['/global/:lang/:type?', '/ff14_global/:lang/:type?'],
+ categories: ['game'],
+ example: '/ff14/global/na/all',
+ parameters: { lang: 'Region', type: 'Category, `all` by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'FINAL FANTASY XIV (The Lodestone)',
+ maintainers: ['kmod-midori'],
+ handler,
+ description: `Region
+
+| North Ameria | Europe | France | Germany | Japan |
+| ------------ | ------ | ------ | ------- | ----- |
+| na | eu | fr | de | jp |
+
+ Category
+
+| all | topics | notices | maintenance | updates | status | developers |
+| --- | ------ | ------- | ----------- | ------- | ------ | ---------- |`,
+};
+
+async function handler(ctx) {
+ const lang = ctx.req.param('lang');
+ const type = ctx.req.param('type') ?? 'all';
+
+ if (!isValidHost(lang)) {
+ throw new InvalidParameterError('Invalid lang');
+ }
+
+ const response = await got({
+ method: 'get',
+ url: `https://lodestonenews.com/news/${type}?locale=${lang}`,
+ });
+
+ let data;
+ if (type === 'all') {
+ data = [];
+ for (const arr of Object.values(response.data)) {
+ data = [...data, ...arr];
+ }
+ } else {
+ data = response.data;
+ }
+
+ return {
+ title: `FFXIV Lodestone updates (${type})`,
+ link: `https://${lang}.finalfantasyxiv.com/lodestone/news/`,
+ item: data.map(({ id, url, title, time, description, image }) => ({
+ title,
+ link: url,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ image,
+ description,
+ }),
+ pubDate: parseDate(time),
+ guid: id,
+ })),
+ };
+}
diff --git a/lib/routes/ff14/ff14-zh.ts b/lib/routes/ff14/ff14-zh.ts
new file mode 100644
index 00000000000000..941c86c291dcb3
--- /dev/null
+++ b/lib/routes/ff14/ff14-zh.ts
@@ -0,0 +1,73 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: ['/zh/:type?', '/ff14_zh/:type?'],
+ categories: ['game'],
+ example: '/ff14/zh/news',
+ parameters: { type: '分类名,预设为 `all`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['ff.web.sdo.com/web8/index.html'],
+ target: '/zh',
+ },
+ ],
+ name: '最终幻想 14 国服',
+ maintainers: ['Kiotlin', 'ZeroClad', '15x15G'],
+ handler,
+ url: 'ff.web.sdo.com/web8/index.html',
+ description: `| 新闻 | 公告 | 活动 | 广告 | 所有 |
+| ---- | -------- | ------ | --------- | ---- |
+| news | announce | events | advertise | all |`,
+};
+
+async function handler(ctx) {
+ const referer = 'https://ff.sdo.com/web8/index.html';
+ const type = ctx.req.param('type') ?? 'all';
+
+ const type_number = {
+ news: '5310',
+ announce: '5312',
+ events: '5311',
+ advertise: '5313',
+ all: '5310,5312,5311,5313,5309',
+ };
+
+ const response = await got({
+ method: 'get',
+ url: `http://api.act.sdo.com/UnionNews/List?gameCode=ff&category=${type_number[type]}&pageIndex=0&pageSize=50`,
+ headers: {
+ Referer: referer,
+ },
+ });
+
+ const data = response.data.Data;
+
+ return {
+ title: '最终幻想14(国服)新闻中心',
+ link: referer + '#/newstab/newslist',
+ description: '《最终幻想14》是史克威尔艾尼克斯出品的全球经典游戏品牌FINAL FANTASY系列的最新作品,IGN获得9.2高分!全球累计用户突破1600万!',
+ item: data.map(({ Title, Summary, Author, PublishDate, HomeImagePath }) => ({
+ title: Title,
+ link: Author,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ image: HomeImagePath,
+ description: Summary,
+ }),
+ pubDate: timezone(parseDate(PublishDate), +8),
+ })),
+ };
+}
diff --git a/lib/routes/ff14/namespace.ts b/lib/routes/ff14/namespace.ts
new file mode 100644
index 00000000000000..e78d1e3e20db5d
--- /dev/null
+++ b/lib/routes/ff14/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FINAL FANTASY XIV',
+ url: 'eu.finalfantasyxiv.com',
+ lang: 'en',
+};
diff --git a/lib/v2/ff14/templates/description.art b/lib/routes/ff14/templates/description.art
similarity index 100%
rename from lib/v2/ff14/templates/description.art
rename to lib/routes/ff14/templates/description.art
diff --git a/lib/routes/fffdm/manhua/manhua.ts b/lib/routes/fffdm/manhua/manhua.ts
new file mode 100644
index 00000000000000..cdd460b0abd3a3
--- /dev/null
+++ b/lib/routes/fffdm/manhua/manhua.ts
@@ -0,0 +1,78 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+const domain = 'manhua.fffdm.com';
+const host = `https://${domain}`;
+
+const get_pic = async (url) => {
+ const response = await got(url);
+ const data = response.data;
+ return {
+ comicTitle: data.mhinfo.title,
+ chapterTitle: data.title,
+ pics: data.cont,
+ pubDate: parseDate(data.mh.time),
+ };
+};
+
+export const route: Route = {
+ path: '/manhua/:id/:cdn?',
+ categories: ['anime'],
+ example: '/fffdm/manhua/93',
+ parameters: { id: '漫画ID。默认获取全部,建议使用通用参数limit获取指定数量', cdn: 'cdn加速器。默认5,当前可选1-5' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.fffdm.com/manhua/:id', 'www.fffdm.com/:id'],
+ target: '/manhua/:id',
+ },
+ ],
+ name: '在线漫画',
+ maintainers: ['zytomorrow'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const count = ctx.req.query('limit') || 99999;
+ const cdnNum = ctx.req.param('cdn') || 5;
+ const cdn = !Number.isNaN(Number.parseInt(cdnNum)) && 1 <= Number.parseInt(cdnNum) && Number.parseInt(cdnNum) <= 5 ? `https://p${cdnNum}.fzacg.com` : `https://p5.fzacg.com`;
+
+ // 获取漫画清单
+ const response = await got(`${host}/api/manhua/${id}`);
+ const data = response.data;
+
+ const chapter_detail = await Promise.all(
+ data.mhlist.splice(0, count).map((item) => {
+ const url = `${host}/api/manhua/${id}/${item.url}`;
+ return cache.tryGet(url, async () => {
+ const picContent = await get_pic(url);
+ return {
+ title: picContent.chapterTitle,
+ description: art(path.join(__dirname, '../templates/manhua.art'), { pic: picContent.pics, cdn }),
+ link: `${host}/${id}/${item.url}/`,
+ comicTitle: picContent.comicTitle,
+ pubDate: picContent.pubDate,
+ };
+ });
+ })
+ );
+ return {
+ title: '风之动漫 - ' + chapter_detail[0].comicTitle,
+ link: `${host}/${id}`,
+ description: '风之动漫',
+ item: chapter_detail,
+ };
+}
diff --git a/lib/routes/fffdm/namespace.ts b/lib/routes/fffdm/namespace.ts
new file mode 100644
index 00000000000000..5c087539b7bf8e
--- /dev/null
+++ b/lib/routes/fffdm/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '风之动漫',
+ url: 'manhua.fffdm.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/fffdm/templates/manhua.art b/lib/routes/fffdm/templates/manhua.art
similarity index 100%
rename from lib/v2/fffdm/templates/manhua.art
rename to lib/routes/fffdm/templates/manhua.art
diff --git a/lib/routes/finology/bullets.ts b/lib/routes/finology/bullets.ts
new file mode 100644
index 00000000000000..487513ece041f0
--- /dev/null
+++ b/lib/routes/finology/bullets.ts
@@ -0,0 +1,63 @@
+import { load } from 'cheerio';
+
+import type { Data, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/bullets',
+ categories: ['finance'],
+ view: ViewType.Notifications,
+ example: '/finology/bullets',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['insider.finology.in/bullets'],
+ },
+ ],
+ name: 'Bullets',
+ maintainers: ['Rjnishant530'],
+ handler,
+ url: 'insider.finology.in/bullets',
+};
+
+async function handler() {
+ const baseUrl = 'https://insider.finology.in/bullets';
+
+ const response = await ofetch(baseUrl);
+ const $ = load(response);
+
+ const listItems = $('body > div.flex.bullettext > div.w80 > div')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const time = $item.find('div.timeline-info span').text().split(', ')[1];
+ const a = $item.find('a.timeline-title');
+ const description = $item.find('div.bullet-desc').html();
+ return {
+ title: a.text(),
+ link: a.attr('href'),
+ pubDate: parseDate(time),
+ description,
+ };
+ });
+
+ return {
+ title: 'Finology Insider Bullets',
+ link: baseUrl,
+ item: listItems,
+ description: 'Your daily dose of crisp, spicy financial news in 80 words.',
+ logo: 'https://insider.finology.in/Images/favicon/favicon.ico',
+ icon: 'https://insider.finology.in/Images/favicon/favicon.ico',
+ language: 'en-us',
+ } as Data;
+}
diff --git a/lib/routes/finology/category.ts b/lib/routes/finology/category.ts
new file mode 100644
index 00000000000000..2e50180fb12800
--- /dev/null
+++ b/lib/routes/finology/category.ts
@@ -0,0 +1,63 @@
+import type { Context } from 'hono';
+
+import type { Data, Route } from '@/types';
+
+import { getItems } from './utils';
+
+export const route: Route = {
+ path: '/category/:category',
+ categories: ['finance'],
+ url: 'insider.finology.in/business',
+ example: '/finology/success-stories',
+ parameters: { category: 'Refer Table below or find in URL' },
+ radar: [
+ {
+ source: ['insider.finology.in/:category'],
+ },
+ ],
+ name: 'Category',
+ maintainers: ['Rjnishant530'],
+ handler,
+ description: `::: info Category
+| Category | Link |
+| --------------------- | ------------------ |
+| **Business** | business |
+| Big Shots | entrepreneurship |
+| Startups | startups-india |
+| Brand Games | success-stories |
+| Juicy Scams | juicy-scams |
+| **Finance** | finance |
+| Macro Moves | economy |
+| News Platter | market-news |
+| Tax Club | tax |
+| Your Money | your-money |
+| **Invest** | investing |
+| Stock Market | stock-market |
+| Financial Ratios | stock-ratios |
+| Investor's Psychology | behavioral-finance |
+| Mutual Funds | mutual-fund |
+:::`,
+};
+
+async function handler(ctx: Context) {
+ const { category } = ctx.req.param();
+ const extra = {
+ description: (topic: string) => `Articles for your research and knowledge under ${topic}`,
+ date: true,
+ selector: `div.card`,
+ };
+ return await commonHandler('https://insider.finology.in', `/${category}`, extra);
+}
+
+export async function commonHandler(baseUrl: string, route: string, extra: any): Promise {
+ const { items, topicName } = await getItems(`${baseUrl}${route}`, extra);
+ return {
+ title: `${topicName} - Finology Insider`,
+ link: `${baseUrl}${route}`,
+ item: items,
+ description: extra.description(topicName || ''),
+ logo: 'https://insider.finology.in/Images/favicon/favicon.ico',
+ icon: 'https://insider.finology.in/Images/favicon/favicon.ico',
+ language: 'en-us',
+ };
+}
diff --git a/lib/routes/finology/most-viewed.ts b/lib/routes/finology/most-viewed.ts
new file mode 100644
index 00000000000000..f89b9b4d464bf0
--- /dev/null
+++ b/lib/routes/finology/most-viewed.ts
@@ -0,0 +1,28 @@
+import type { Route } from '@/types';
+
+import { commonHandler } from './category';
+
+export const route: Route = {
+ path: '/most-viewed',
+ categories: ['finance'],
+ example: '/finology/most-viewed',
+ radar: [
+ {
+ source: ['insider.finology.in/most-viewed'],
+ target: '/most-viewed',
+ },
+ ],
+ name: 'Most Viewed',
+ maintainers: ['Rjnishant530'],
+ handler,
+ url: 'insider.finology.in/most-viewed',
+};
+
+async function handler() {
+ const extra = {
+ description: (topic: string) => `Check out the most talked-about articles among our readers! ${topic}`,
+ date: false,
+ selector: `div.card`,
+ };
+ return await commonHandler('https://insider.finology.in', '/most-viewed', extra);
+}
diff --git a/lib/routes/finology/namespace.ts b/lib/routes/finology/namespace.ts
new file mode 100644
index 00000000000000..e5c807b3bd3e37
--- /dev/null
+++ b/lib/routes/finology/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Finology Insider',
+ url: 'insider.finology.in',
+ lang: 'en',
+};
diff --git a/lib/routes/finology/tag.ts b/lib/routes/finology/tag.ts
new file mode 100644
index 00000000000000..418cb39943b59a
--- /dev/null
+++ b/lib/routes/finology/tag.ts
@@ -0,0 +1,63 @@
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
+
+import { commonHandler } from './category';
+
+export const route: Route = {
+ path: '/tag/:topic',
+ categories: ['finance'],
+ example: '/finology/tag/startups',
+ parameters: { category: 'Refer Table below or find in URL' },
+ radar: [
+ {
+ source: ['insider.finology.in/tag/:topic'],
+ },
+ ],
+ name: 'Trending Topic',
+ maintainers: ['Rjnishant530'],
+ handler,
+ url: 'insider.finology.in/tag',
+ description: `::: info Topic
+| Topic | Link |
+| ------------------------ | ------------------------ |
+| Investment Decisions | investment-decisions |
+| Investing 101 | investing-101 |
+| Stock Markets | stock-markets |
+| business news india | business-news-india |
+| Company Analysis | company-analysis |
+| Business and brand tales | business-and-brand-tales |
+| Featured | featured |
+| Fundamental Analysis | fundamental-analysis |
+| Business Story | business-story |
+| All Biz | all-biz |
+| Stock Analysis | stock-analysis |
+| Automobile Industry | automobile-industry |
+| Indian Economy | indian-economy |
+| Govt's Words | govt%27s-words |
+| Behavioral Finance | behavioral-finance |
+| Global Economy | global-economy |
+| Startups | startups |
+| GST | gst |
+| Product Review | product-review |
+| My Pocket | my-pocket |
+| Business Games | business-games |
+| Business Models | business-models |
+| Indian Indices | indian-indices |
+| Banking System | banking-system |
+| Debt | debt |
+| World News | world-news |
+| Technology | technology |
+| Regulatory Bodies | regulatory-bodies |
+:::`,
+};
+
+async function handler(ctx: Context) {
+ const { topic } = ctx.req.param();
+ const extra = {
+ description: (topic: string) => `Everything that Insider has to offer about ${topic} for you to read and learn.`,
+ date: true,
+ selector: `div.card`,
+ };
+ return await commonHandler('https://insider.finology.in', `/tag/${topic}`, extra);
+}
diff --git a/lib/routes/finology/utils.ts b/lib/routes/finology/utils.ts
new file mode 100644
index 00000000000000..746df215f36959
--- /dev/null
+++ b/lib/routes/finology/utils.ts
@@ -0,0 +1,66 @@
+import { load } from 'cheerio';
+
+import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const getItems = async (url: string, extra: { date: boolean; selector: string }) => {
+ const mainUrl = 'https://insider.finology.in';
+ const response = await ofetch(url);
+ const $ = load(response);
+ const listItems = $(extra.selector)
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const title = $item.find('p.text-m-height').text();
+ const link = $item.find('a').attr('href');
+ const pubDate = extra.date ? timezone(parseDate($item.find('div.text-light p').first().text()), 0) : '';
+ const itunes_item_image = $item.find('img').attr('src');
+ const category = [$item.find('p.pt025').text()];
+ return {
+ title,
+ link: `${mainUrl}${link}`,
+ pubDate,
+ itunes_item_image,
+ category,
+ } as DataItem;
+ });
+
+ const items = (
+ await Promise.allSettled(
+ listItems.map((item) => {
+ if (item.link === undefined) {
+ return item;
+ }
+ return cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link || '');
+ const $ = load(response);
+ const div = $('div.w60.flex.flex-wrap-badge');
+ item.author = div.find('div a p').text();
+ item.updated = extra.date ? parseDate(div.find('p:contains("Updated on") span').text()) : '';
+ item.description =
+ $('div#main-wrapper div#insiderhead')
+ .find('div.flex.flex-col.w100.align-center')
+ .children('div.m-position-r')
+ .remove()
+ .end()
+ .find('a[href="https://quest.finology.in/"]')
+ .remove()
+ .end()
+ .find('div.blur-wall-wrap')
+ .remove()
+ .end()
+ .html() ?? '';
+ return item;
+ });
+ })
+ )
+ ).map((v, index) => (v.status === 'fulfilled' ? v.value : { ...listItems[index], description: `Website did not load within Timeout Limits. Check with Website if the page is slow ` }));
+ const topicName = $('h1.font-heading.fs1875')?.text();
+ const validItems: DataItem[] = items.filter((item): item is DataItem => item !== null && typeof item !== 'string');
+ return { items: validItems, topicName };
+};
+
+export { getItems };
diff --git a/lib/routes/finviz/namespace.ts b/lib/routes/finviz/namespace.ts
new file mode 100644
index 00000000000000..f732342125c983
--- /dev/null
+++ b/lib/routes/finviz/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'finviz',
+ url: 'finviz.com',
+ lang: 'en',
+};
diff --git a/lib/routes/finviz/news.ts b/lib/routes/finviz/news.ts
new file mode 100644
index 00000000000000..52271de15b001b
--- /dev/null
+++ b/lib/routes/finviz/news.ts
@@ -0,0 +1,106 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const categories = {
+ news: 0,
+ blogs: 1,
+};
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/finviz',
+ parameters: {
+ category: {
+ description: 'Category, see below, News by default',
+ options: Object.keys(categories).map((key) => ({ value: key, label: key })),
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['finviz.com/news.ashx', 'finviz.com/'],
+ },
+ ],
+ name: 'News',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'finviz.com/news.ashx',
+ description: `| News | Blogs |
+| ---- | ---- |
+| news | blogs |`,
+};
+
+async function handler(ctx) {
+ const { category = 'News' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 200;
+
+ if (!Object.hasOwn(categories, category.toLowerCase())) {
+ throw new InvalidParameterError(`No category '${category}'.`);
+ }
+
+ const rootUrl = 'https://finviz.com';
+ const currentUrl = new URL('news.ashx', rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const items = $('table.table-fixed')
+ .eq(categories[category.toLowerCase()])
+ .find('tr')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a.nn-tab-link');
+
+ const descriptionMatches = a
+ .parent()
+ .prop('data-boxover')
+ ?.match(/(.*?)<\/td>/);
+ const authorMatches = item
+ .find('use')
+ .first()
+ .prop('href')
+ ?.match(/#(.*?)-(light|dark)/);
+
+ return {
+ title: a.text(),
+ link: a.prop('href'),
+ description: descriptionMatches ? descriptionMatches[1] : undefined,
+ author: authorMatches ? authorMatches[1].replaceAll('-', ' ') : 'finviz',
+ pubDate: timezone(parseDate(item.find('td.news_date-cell').text(), ['HH:mmA', 'MMM-DD']), -4),
+ };
+ })
+ .filter((item) => item.title);
+
+ const icon = $('link[rel="icon"]').prop('href');
+
+ return {
+ item: items,
+ title: `finviz - ${category}`,
+ link: currentUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: 'en-US',
+ image: new URL($('a.logo svg use').first().prop('href'), rootUrl).href,
+ icon,
+ logo: icon,
+ subtitle: $('title').text(),
+ };
+}
diff --git a/lib/routes/finviz/quote.ts b/lib/routes/finviz/quote.ts
new file mode 100644
index 00000000000000..6e951e19583af0
--- /dev/null
+++ b/lib/routes/finviz/quote.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news/:ticker',
+ categories: ['finance'],
+ example: '/finviz/news/AAPL',
+ parameters: { ticker: 'The stock ticker' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'US Stock News',
+ maintainers: ['HenryQW'],
+ handler,
+};
+
+async function handler(ctx) {
+ const link = `https://finviz.com/quote.ashx?t=${ctx.req.param('ticker')}`;
+ const response = await got(link);
+
+ const $ = load(response.body);
+ const data = $('table.fullview-news-outer tr');
+
+ let dateRow = '';
+ const item = await Promise.all(
+ data.toArray().map((e) => {
+ let date = $(e).find('td').first().text().trim();
+ if (date.includes('-')) {
+ dateRow = date.split(' ')[0];
+ } else {
+ date = `${dateRow} ${date}`;
+ }
+ return {
+ title: $(e).find('a').text(),
+ pubDate: parseDate(date, 'MMM-DD-YY HH:mmA'),
+ author: $(e).find('span').text(),
+ link: $(e).find('a').attr('href'),
+ };
+ })
+ );
+
+ const name = $('.fullview-title b').text();
+
+ return {
+ title: `${ctx.req.param('ticker')} ${name} News by Finviz`,
+ link,
+ description: `A collection of ${name} news aggregated by Finviz.`,
+ item,
+ };
+}
diff --git a/lib/routes/firecore/index.ts b/lib/routes/firecore/index.ts
new file mode 100644
index 00000000000000..5ea112fb7301c8
--- /dev/null
+++ b/lib/routes/firecore/index.ts
@@ -0,0 +1,57 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:os',
+ categories: ['program-update'],
+ example: '/firecore/ios',
+ parameters: { os: '`ios`,`tvos`,`macos`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Release Notes',
+ maintainers: ['NathanDai'],
+ handler,
+};
+
+async function handler(ctx) {
+ const host = 'https://firecore.com/releases';
+ const { data } = await got(host);
+ const $ = load(data);
+ const items = $(`div.tab-pane.fade#${ctx.req.param('os')}`)
+ .find('.release-date')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const title = item
+ .parent()
+ .contents()
+ .filter((_, el) => el.nodeType === 3)
+ .text();
+ const pubDate = parseDate(item.text().match(/(\d{4}-\d{2}-\d{2})/)[1]);
+
+ const next = item.parent().nextUntil('hr');
+ return {
+ title,
+ description: next
+ .toArray()
+ .map((item) => $(item).html())
+ .join(''),
+ pubDate,
+ };
+ });
+
+ return {
+ title: `Infuse Release Notes (${ctx.req.param('os')})`,
+ link: 'https://firecore.com/releases',
+ item: items,
+ };
+}
diff --git a/lib/routes/firecore/namespace.ts b/lib/routes/firecore/namespace.ts
new file mode 100644
index 00000000000000..466ad5cb823be8
--- /dev/null
+++ b/lib/routes/firecore/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Infuse',
+ url: 'firecore.com',
+ lang: 'en',
+};
diff --git a/lib/routes/firefox/addons.ts b/lib/routes/firefox/addons.ts
new file mode 100644
index 00000000000000..993cabdd8a39ed
--- /dev/null
+++ b/lib/routes/firefox/addons.ts
@@ -0,0 +1,60 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/addons/:id',
+ categories: ['program-update'],
+ example: '/firefox/addons/rsshub-radar',
+ parameters: { id: 'Add-ons id, can be found in add-ons url' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['addons.mozilla.org/:lang/firefox/addon/:id/versions', 'addons.mozilla.org/:lang/firefox/addon/:id'],
+ },
+ ],
+ name: 'Add-ons Update',
+ maintainers: ['DIYgod'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+
+ const response = await got({
+ method: 'get',
+ url: `https://addons.mozilla.org/zh-CN/firefox/addon/${id}/versions/`,
+ });
+ const data = JSON.parse(load(response.data)('#redux-store-state').text());
+ const info = data.addons.byID[data.addons.bySlug[id]];
+ const versionIds = data.versions.bySlug[id].versionIds;
+
+ return {
+ title: `${info.name} - Firefox Add-on`,
+ description: info.summary || info.description,
+ link: `https://addons.mozilla.org/zh-CN/firefox/addon/${id}/versions/`,
+ item:
+ versionIds &&
+ versionIds.map((versionId) => {
+ const versionInfo = data.versions.byId[versionId];
+ const version = 'v' + versionInfo.version;
+ return {
+ title: version,
+ description: versionInfo.releaseNotes || '',
+ link: `https://addons.mozilla.org/zh-CN/firefox/addon/${id}/versions/`,
+ pubDate: new Date(versionInfo.file.created),
+ guid: version,
+ author: info.authors.map((author) => author.name).join(', '),
+ category: info.categories,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/firefox/breaches.ts b/lib/routes/firefox/breaches.ts
new file mode 100644
index 00000000000000..5894e772e5d5ce
--- /dev/null
+++ b/lib/routes/firefox/breaches.ts
@@ -0,0 +1,64 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/breaches',
+ categories: ['other'],
+ example: '/firefox/breaches',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['monitor.firefox.com/', 'monitor.firefox.com/breaches'],
+ },
+ ],
+ name: 'Firefox Monitor',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'monitor.firefox.com/',
+};
+
+async function handler() {
+ const baseUrl = 'https://monitor.firefox.com';
+
+ const response = await got(`${baseUrl}/breaches`);
+ const $ = load(response.data);
+
+ const items = $('.breach-card')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ item.find('.breach-detail-link').remove();
+ return {
+ title: item.find('h3 span').last().text(),
+ description: item.find('.breach-main').html(),
+ link: new URL(item.attr('href'), baseUrl).href,
+ pubDate: timezone(parseDate(item.find('.breach-main div dd').first().text()), 0),
+ category: item
+ .find('.breach-main div dd')
+ .last()
+ .text()
+ .split(',')
+ .map((x) => x.trim()),
+ };
+ });
+
+ return {
+ title: $('title').text(),
+ description: $('head meta[name=description]').attr('content').trim(),
+ link: response.url,
+ item: items,
+ image: $('head meta[property=og:image]').attr('content'),
+ };
+}
diff --git a/lib/routes/firefox/namespace.ts b/lib/routes/firefox/namespace.ts
new file mode 100644
index 00000000000000..683ad1b7561865
--- /dev/null
+++ b/lib/routes/firefox/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Mozilla',
+ url: 'monitor.firefox.com',
+ lang: 'en',
+};
diff --git a/lib/routes/firefox/release.ts b/lib/routes/firefox/release.ts
new file mode 100644
index 00000000000000..96b5c14954b62b
--- /dev/null
+++ b/lib/routes/firefox/release.ts
@@ -0,0 +1,45 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const platformSlugs = {
+ desktop: 'releasenotes',
+ beta: 'beta/notes',
+ nightly: 'nightly/notes',
+ android: 'android/releasenotes',
+ ios: 'ios/notes',
+};
+
+export const route: Route = {
+ path: '/release/:platform?',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const { platform = 'desktop' } = ctx.req.param();
+ const devicePlatform = platform.replace('-', '/');
+
+ const link = ['https://www.mozilla.org/en-US/firefox', Object.hasOwn(platformSlugs, devicePlatform) ? platformSlugs[devicePlatform] : devicePlatform].filter(Boolean).join('/');
+ const response = await got.get(link);
+ const $ = load(response.data);
+ const version = $('.c-release-version').text();
+ const pubDate = parseDate($('.c-release-date').text(), 'MMMM D, YYYY');
+
+ return {
+ title: `Firefox ${platform} release notes`,
+ link,
+ item: [
+ {
+ title: `Firefox ${platform} ${version} release notes`,
+ link,
+ description: $('.c-release-notes').html(),
+ guid: `${platform} ${version}`,
+ pubDate,
+ },
+ ],
+ };
+}
diff --git a/lib/v2/firefox/templates/description.art b/lib/routes/firefox/templates/description.art
similarity index 100%
rename from lib/v2/firefox/templates/description.art
rename to lib/routes/firefox/templates/description.art
diff --git a/lib/routes/fisher-spb/namespace.ts b/lib/routes/fisher-spb/namespace.ts
new file mode 100644
index 00000000000000..be303631d6d68a
--- /dev/null
+++ b/lib/routes/fisher-spb/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Fisher Spb',
+ url: 'fisher.spb.ru',
+ lang: 'ru',
+};
diff --git a/lib/routes/fisher-spb/news.ts b/lib/routes/fisher-spb/news.ts
new file mode 100644
index 00000000000000..057aafce8f57f7
--- /dev/null
+++ b/lib/routes/fisher-spb/news.ts
@@ -0,0 +1,75 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['other'],
+ example: '/fisher-spb/news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['fisher.spb.ru/news'],
+ },
+ ],
+ name: 'News',
+ maintainers: ['denis-ya'],
+ handler,
+ url: 'fisher.spb.ru/news',
+};
+
+async function handler() {
+ const renderVideo = (link) => art(path.join(__dirname, './templates/video.art'), { link });
+ const renderImage = (href) => art(path.join(__dirname, './templates/image.art'), { href });
+
+ const rootUrl = 'https://fisher.spb.ru/news/';
+ const response = await got({
+ method: 'get',
+ url: rootUrl,
+ responseType: 'buffer',
+ });
+
+ const $ = load(response.data);
+
+ const descBuilder = (element) => {
+ const content = load(`${$('.news-message-text', element).html()}
`).root();
+ $('.news-message-media a', element).each((_, elem) => {
+ if ($(elem).hasClass('news-message-youtube')) {
+ content.append(renderVideo($(elem).attr('data-youtube')));
+ } else {
+ content.append(renderImage($(elem).attr('href')));
+ }
+ });
+ return content;
+ };
+
+ const items = $('.news-message')
+ .toArray()
+ .map((elem) => ({
+ pubDate: parseDate($('.news-message-date', elem).text().trim(), 'DD.MM.YYYY HH:mm'),
+ title: $('.news-message-location', elem).text().trim(),
+ description: descBuilder(elem).html(),
+ author: $('.news-message-user', elem).text().trim(),
+ guid: $(elem).attr('id'),
+ link: rootUrl + $('.news-message-comments-number > a', elem).attr('href'),
+ }));
+
+ return {
+ title: $('head > title').text().trim(),
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/v2/fisher-spb/templates/image.art b/lib/routes/fisher-spb/templates/image.art
similarity index 100%
rename from lib/v2/fisher-spb/templates/image.art
rename to lib/routes/fisher-spb/templates/image.art
diff --git a/lib/v2/fisher-spb/templates/video.art b/lib/routes/fisher-spb/templates/video.art
similarity index 100%
rename from lib/v2/fisher-spb/templates/video.art
rename to lib/routes/fisher-spb/templates/video.art
diff --git a/lib/routes/fishshell/index.ts b/lib/routes/fishshell/index.ts
new file mode 100644
index 00000000000000..9c6450fd3f6085
--- /dev/null
+++ b/lib/routes/fishshell/index.ts
@@ -0,0 +1,44 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/',
+ radar: [
+ {
+ source: ['fishshell.com/'],
+ target: '',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['x2cf'],
+ handler,
+ url: 'fishshell.com/',
+};
+
+async function handler() {
+ const link = 'https://fishshell.com/docs/current/relnotes.html';
+ const data = await cache.tryGet(link, async () => (await got(link)).data, config.cache.contentExpire, false);
+ const $ = load(data);
+ return {
+ link,
+ title: 'Release notes — fish-shell',
+ language: 'en',
+ item: $('#release-notes > section')
+ .toArray()
+ .map((item) => {
+ const title = $(item).find('h2').contents().first().text();
+ const date = title.match(/\(released (.+?)\)/)?.[1];
+ return {
+ title,
+ link: new URL($(item).find('a').attr('href'), link).href,
+ pubDate: date ? parseDate(date, 'MMMM D, YYYY') : undefined,
+ description: $(item).html(),
+ };
+ }),
+ };
+}
diff --git a/lib/routes/fishshell/namespace.ts b/lib/routes/fishshell/namespace.ts
new file mode 100644
index 00000000000000..91e4ee576d8466
--- /dev/null
+++ b/lib/routes/fishshell/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'fish shell',
+ url: 'fishshell.com',
+ lang: 'en',
+};
diff --git a/lib/routes/fjksbm/index.ts b/lib/routes/fjksbm/index.ts
new file mode 100644
index 00000000000000..364ed25b889198
--- /dev/null
+++ b/lib/routes/fjksbm/index.ts
@@ -0,0 +1,82 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['study'],
+ example: '/fjksbm',
+ parameters: { category: '分类,见下表,默认为网络报名进行中' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['fjksbm.com/portal/:category?', 'fjksbm.com/portal'],
+ },
+ ],
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 已发布公告 (方案),即将开始 | 网络报名进行中 | 网络报名结束等待打印准考证 | 正在打印准考证 | 考试结束,等待发布成绩 | 已发布成绩 | 新闻动态 | 政策法规 |
+| --------------------------- | -------------- | -------------------------- | -------------- | ---------------------- | ---------- | -------- | -------- |
+| 0 | 1 | 2 | 3 | 4 | 5 | news | policy |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? '0';
+
+ const id = Number.parseInt(category);
+ const isNumber = !Number.isNaN(id);
+
+ const rootUrl = 'https://fjksbm.com';
+ const currentUrl = `${rootUrl}/portal${isNumber ? '' : `/${category}`}`;
+
+ const response = await got(currentUrl);
+
+ const $ = load(response.data);
+
+ const list = (isNumber ? $('.panel-body').eq(id).find('.examName a') : $('.panel-body ul li a')).toArray().map((item) => {
+ item = $(item);
+ const link = item.attr('href');
+
+ return {
+ title: item.text(),
+ link: link.startsWith('//') ? (link.startsWith('https') ? link : `https:${link}`) : `${rootUrl}${link}/news/bulletin`,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link);
+ const content = load(detailResponse.data);
+
+ content('h3').remove();
+ content('.panel-body div').eq(0).remove();
+
+ item.description = content('.panel-body').html();
+ item.pubDate = timezone(parseDate(detailResponse.data.match(/发布时间:(\d{4}-\d{2}-\d{2} \d{2}:\d{2})/)[1]), 8);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('.panel-heading')
+ .eq(isNumber ? id : 1)
+ .text()} - 福建考试报名网`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/fjksbm/namespace.ts b/lib/routes/fjksbm/namespace.ts
new file mode 100644
index 00000000000000..6a447a6444ed03
--- /dev/null
+++ b/lib/routes/fjksbm/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '福建考试报名网',
+ url: 'fjksbm.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/flashcat/blog.ts b/lib/routes/flashcat/blog.ts
new file mode 100644
index 00000000000000..8c01e75106e9d0
--- /dev/null
+++ b/lib/routes/flashcat/blog.ts
@@ -0,0 +1,62 @@
+import { load } from 'cheerio';
+
+import type { Data, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/blog',
+ categories: ['blog'],
+ example: '/flashcat/blog',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['flashcat.cloud/blog'],
+ target: '/blog',
+ },
+ ],
+ name: '快猫星云博客',
+ maintainers: ['chesha1'],
+ handler: handlerRoute,
+};
+
+async function handlerRoute(): Promise {
+ const response = await ofetch('https://flashcat.cloud/blog/');
+ const $ = load(response);
+
+ const items = $('.post-preview')
+ .toArray()
+ .map((elem) => {
+ const $elem = $(elem);
+ return {
+ title: $elem.find('.post-title').text(),
+ description: $elem.find('.post-content-preview').text(),
+ link: $elem.find('a').attr('href'),
+ pubDate: parseDate(
+ $elem
+ .find('.post-meta')
+ .text()
+ .match(/on\s+(\w+,\s+\w+\s+\d{1,2},\s+\d{4})/)?.[1] || ''
+ ),
+ author:
+ $elem
+ .find('.post-meta')
+ .text()
+ .match(/by\s+(.+?)\s+on/)?.[1] || '',
+ };
+ });
+
+ return {
+ title: 'Flashcat 快猫星云博客',
+ link: 'https://flashcat.cloud/blog/',
+ item: items,
+ };
+}
diff --git a/lib/routes/flashcat/namespace.ts b/lib/routes/flashcat/namespace.ts
new file mode 100644
index 00000000000000..f28d31c77327cd
--- /dev/null
+++ b/lib/routes/flashcat/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Flashcat',
+ url: 'flashcat.cloud',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/flyert/creditcard.ts b/lib/routes/flyert/creditcard.ts
new file mode 100644
index 00000000000000..ea92ab34e4e015
--- /dev/null
+++ b/lib/routes/flyert/creditcard.ts
@@ -0,0 +1,138 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import util from './utils';
+
+const gbk2utf8 = (s) => iconv.decode(s, 'gbk');
+const host = 'https://www.flyert.com.cn';
+
+export const route: Route = {
+ path: '/creditcard/:bank',
+ categories: ['travel'],
+ example: '/flyert/creditcard/zhongxin',
+ parameters: { bank: '信用卡板块各银行的拼音简称' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['flyert.com.cn/'],
+ },
+ ],
+ name: '信用卡',
+ maintainers: ['nicolaszf'],
+ handler,
+ url: 'flyert.com/',
+ description: `| 信用卡模块 | bank |
+| ---------- | ------------- |
+| 国内信用卡 | creditcard |
+| 浦发银行 | pufa |
+| 招商银行 | zhaoshang |
+| 中信银行 | zhongxin |
+| 交通银行 | jiaotong |
+| 中国银行 | zhonghang |
+| 工商银行 | gongshang |
+| 广发银行 | guangfa |
+| 农业银行 | nongye |
+| 建设银行 | jianshe |
+| 汇丰银行 | huifeng |
+| 民生银行 | mingsheng |
+| 兴业银行 | xingye |
+| 花旗银行 | huaqi |
+| 上海银行 | shanghai |
+| 无卡支付 | wuka |
+| 投资理财 | 137 |
+| 网站权益汇 | 145 |
+| 境外信用卡 | intcreditcard |`,
+};
+
+async function handler(ctx) {
+ const bank = ctx.req.param('bank');
+ const target = `${host}/forum-${bank}-1.html`;
+ let bankname = '';
+
+ switch (bank) {
+ case 'creditcard':
+ bankname = '国内信用卡';
+ break;
+ case 'pufa':
+ bankname = '浦发银行';
+ break;
+ case 'zhaoshang':
+ bankname = '招商银行';
+ break;
+ case 'zhongxin':
+ bankname = '中信银行';
+ break;
+ case 'jiaotong':
+ bankname = '交通银行';
+ break;
+ case 'zhonghang':
+ bankname = '中国银行';
+ break;
+ case 'gongshang':
+ bankname = '工商银行';
+ break;
+ case 'guangfa':
+ bankname = '广发银行';
+ break;
+ case 'nongye':
+ bankname = '农业银行';
+ break;
+ case 'jianshe':
+ bankname = '建设银行';
+ break;
+ case 'huifeng':
+ bankname = '汇丰银行';
+ break;
+ case 'mingsheng':
+ bankname = '民生银行';
+ break;
+ case 'xingye':
+ bankname = '兴业银行';
+ break;
+ case 'huaqi':
+ bankname = '花旗银行';
+ break;
+ case 'shanghai':
+ bankname = '上海银行';
+ break;
+ case 'wuka':
+ bankname = '无卡支付';
+ break;
+ case '137':
+ bankname = '投资理财';
+ break;
+ case '145':
+ bankname = '网站权益汇';
+ break;
+ case 'intcreditcard':
+ bankname = '境外信用卡';
+ }
+
+ const response = await got.get(target, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(gbk2utf8(response.data));
+
+ const list = $("[id*='normalthread']").toArray();
+
+ const result = await util.ProcessFeed(list, cache);
+
+ return {
+ title: `飞客茶馆信用卡 - ${bankname}`,
+ link: 'https://www.flyert.com.cn/',
+ description: `飞客茶馆信用卡 - ${bankname}`,
+ item: result,
+ };
+}
diff --git a/lib/routes/flyert/forum.ts b/lib/routes/flyert/forum.ts
new file mode 100644
index 00000000000000..670a9d57ffcc98
--- /dev/null
+++ b/lib/routes/flyert/forum.ts
@@ -0,0 +1,100 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { parseArticle, parseArticleList, parsePost, parsePostList, rootUrl } from './util';
+
+export const handler = async (ctx) => {
+ const { params } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 5;
+
+ const decodedParams = params
+ ? decodeURIComponent(params)
+ .split(/&/)
+ .filter((p) => p.split(/=/).length === 2)
+ .join('&')
+ : undefined;
+
+ const currentUrl = new URL(`forum.php${decodedParams ? `?${decodedParams}` : ''}`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(iconv.decode(response, 'gbk'));
+
+ const language = $('meta[http-equiv="Content-Language"]').prop('content');
+
+ let items = $('table#threadlisttableid').length === 0 ? parseArticleList($, limit) : parsePostList($, limit);
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link, {
+ responseType: 'buffer',
+ });
+
+ const $$ = load(iconv.decode(detailResponse, 'gbk').replaceAll(/<\/?ignore_js_op>/g, ''));
+
+ item = $$('div.firstpost').length === 0 ? parseArticle($$, item) : parsePost($$, item);
+
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const image = `https:${$('div.wp h2 a img').prop('src')}`;
+
+ return {
+ title: `飞客 - ${$('a.forum_name, li.a, li.cur, li.xw1, div.z > a.xw1')
+ .toArray()
+ .map((a) => $(a).text())
+ .join(' - ')}`,
+ description: $('meta[name="description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('meta[name="application-name"]').prop('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/forum/:params{.+}?',
+ name: '会员说',
+ url: 'www.flyert.com.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/flyert/forum',
+ parameters: { params: '参数,默认为空,可在对应分类页 URL 中找到' },
+ description: `::: tip
+ 若订阅 [酒店集团优惠](https://www.flyert.com.cn/forum.php?mod=forumdisplay&sum=all&fid=all&catid=322&filter=sortid&sortid=144&searchsort=1&youhui_type=19),网址为 \`https://www.flyert.com.cn/forum.php?mod=forumdisplay&sum=all&fid=all&catid=322&filter=sortid&sortid=144&searchsort=1&youhui_type=19\`。截取 \`https://www.flyert.com.cn/forum.php?\` 到末尾的部分 \`mod=forumdisplay&sum=all&fid=all&catid=322&filter=sortid&sortid=144&searchsort=1&youhui_type=19\` **进行 UrlEncode 编码** 后作为参数填入,此时路由为 [\`/flyert/forum/mod%3Dforumdisplay%26sum%3Dall%26fid%3Dall%26catid%3D322%26filter%3Dsortid%26sortid%3D144%26searchsort%3D1%26youhui_type%3D226\`](https://rsshub.app/flyert/forum/mod%3Dforumdisplay%26sum%3Dall%26fid%3Dall%26catid%3D322%26filter%3Dsortid%26sortid%3D144%26searchsort%3D1%26youhui_type%3D226)。
+:::
+ `,
+ categories: ['bbs'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.flyert.com.cn/forum.php'],
+ target: (_, url) => {
+ const params = [...url.searchParams.entries()].map(([key, value]) => key + '=' + value).join('&');
+
+ return `/forum${params ? `/${encodeURIComponent(params)}` : ''}`;
+ },
+ },
+ ],
+};
diff --git a/lib/routes/flyert/namespace.ts b/lib/routes/flyert/namespace.ts
new file mode 100644
index 00000000000000..26e294156e3b89
--- /dev/null
+++ b/lib/routes/flyert/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '飞客茶馆',
+ url: 'flyert.com.cn',
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/flyert/preferential.ts b/lib/routes/flyert/preferential.ts
new file mode 100644
index 00000000000000..47578909370375
--- /dev/null
+++ b/lib/routes/flyert/preferential.ts
@@ -0,0 +1,75 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const gbk2utf8 = (s) => iconv.decode(s, 'gbk');
+const host = 'https://www.flyert.com';
+const target = `${host}/forum.php?mod=forumdisplay&sum=all&fid=all&catid=322`;
+
+export const route: Route = {
+ path: '/preferential',
+ categories: ['travel'],
+ example: '/flyert/preferential',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['flyert.com/'],
+ },
+ ],
+ name: '优惠信息',
+ maintainers: ['howel52'],
+ handler,
+ url: 'flyert.com/',
+};
+
+async function handler() {
+ const response = await got(target, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(gbk2utf8(response.data));
+ const list = $('.comiis_wzli')
+ .toArray()
+ .map((item) => ({
+ title: $(item).find('.wzbt').text(),
+ link: `${host}/${$(item).find('.wzbt a').attr('href')}`,
+ description: $(item).find('.wznr > div:first-child').text(),
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link, {
+ responseType: 'buffer',
+ });
+ const content = load(gbk2utf8(detailResponse.data));
+
+ // remove top ad
+ content('div.artical_top').remove();
+
+ item.description = content('#artMain').html();
+ item.pubDate = timezone(parseDate(content('p.xg1 > span:nth-child(1)').attr('title') || content('p.xg1').text().split('|')[0], 'YYYY-M-D HH:mm'), +8);
+ return item;
+ })
+ )
+ );
+ return {
+ title: '飞客茶馆优惠',
+ link: 'https://www.flyert.com/',
+ description: '飞客茶馆优惠',
+ item: items,
+ };
+}
diff --git a/lib/routes/flyert/templates/description.art b/lib/routes/flyert/templates/description.art
new file mode 100644
index 00000000000000..249654e7e618a4
--- /dev/null
+++ b/lib/routes/flyert/templates/description.art
@@ -0,0 +1,21 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if intro }}
+ {{ intro }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/flyert/util.ts b/lib/routes/flyert/util.ts
new file mode 100644
index 00000000000000..dea3b16a9c5b0e
--- /dev/null
+++ b/lib/routes/flyert/util.ts
@@ -0,0 +1,187 @@
+import path from 'node:path';
+
+import type { CheerioAPI } from 'cheerio';
+
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const rootUrl = 'https://www.flyert.com.cn';
+
+/**
+ * Parses a list of articles based on a CheerioAPI object and a limit.
+ * @param $ The CheerioAPI object.
+ * @param limit The maximum number of articles to parse.
+ * @returns An array of parsed article objects.
+ */
+const parseArticleList = ($: CheerioAPI, limit: number) =>
+ $('div.comiis_wzli')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.find('div.wzbt').text().trim();
+ const image = item.find('div.wzpic img').prop('src');
+ const description = art(path.join(__dirname, 'templates/description.art'), {
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: item.find('div.wznr').html(),
+ });
+ const pubDate = item.find('div.subcat span.y').contents()?.eq(2)?.text().trim() ?? undefined;
+ const link = new URL(item.find('div.wzbt a').prop('href'), rootUrl).href;
+
+ return {
+ title,
+ description,
+ pubDate: pubDate ? parseDate(pubDate) : undefined,
+ link,
+ author: item.find('div.subcat span.y a').first().text(),
+ content: {
+ html: description,
+ text: item.find('div.wznr').text(),
+ },
+ image,
+ banner: image,
+ };
+ });
+
+/**
+ * Parses a list of posts based on a CheerioAPI object and a limit.
+ * @param $ The CheerioAPI object.
+ * @param limit The maximum number of posts to parse.
+ * @returns An array of parsed post objects.
+ */
+const parsePostList = ($: CheerioAPI, limit: number) =>
+ $('div.comiis_postlist')
+ .toArray()
+ .filter((item) => {
+ item = $(item);
+
+ return item
+ .find('span.comiis_common a[data-track]')
+ .toArray()
+ .some((a) => {
+ a = $(a);
+
+ const dataTrack = a.attr('data-track') || '';
+ return dataTrack.endsWith('文章');
+ });
+ })
+ .slice(0, limit)
+ .map((item) => {
+ item = $(item);
+
+ const aEl = $(
+ item
+ .find('span.comiis_common a[data-track]')
+ .toArray()
+ .find((a) => {
+ a = $(a);
+
+ const dataTrack = a.attr('data-track') || '';
+ return dataTrack.endsWith('文章');
+ })
+ );
+
+ const pubDate = item.find('span.author_b span').prop('title') || undefined;
+
+ return {
+ title: aEl.text().trim(),
+ pubDate: pubDate ? parseDate(pubDate) : undefined,
+ link: new URL(aEl.prop('href'), rootUrl).href,
+ author: item.find('a.author_t').text().trim(),
+ };
+ });
+
+/**
+ * Parses an article based on a CheerioAPI object and an item.
+ * @param $$ The CheerioAPI object.
+ * @param item The item to parse.
+ * @returns The parsed article object.
+ */
+const parseArticle = ($$: CheerioAPI, item) => {
+ const title = $$('h1.ph').text().trim();
+ const description = art(path.join(__dirname, 'templates/description.art'), {
+ intro: $$('div.s').text() || undefined,
+ description: $$('div#artMain').html(),
+ });
+ const pubDate =
+ $$('p.xg1')
+ .contents()
+ .first()
+ .text()
+ .trim()
+ ?.match(/(\d{4}-\d{1,2}-\d{1,2}\s\d{2}:\d{2})/)?.[1] ?? undefined;
+ const guid = `flyert-${item.link.split(/=/).pop()}`;
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = pubDate ? timezone(parseDate(pubDate), +8) : item.pubDate;
+ item.author = $$('p.xg1 a').first().text();
+ item.guid = guid;
+ item.id = guid;
+ item.content = {
+ html: description,
+ text: $$('div#artMain').text(),
+ };
+
+ return item;
+};
+
+/**
+ * Parses a post based on a CheerioAPI object and an item.
+ * @param $$ The CheerioAPI object.
+ * @param item The item to parse.
+ * @returns The parsed post object.
+ */
+const parsePost = ($$: CheerioAPI, item) => {
+ $$('img.zoom').each((_, el) => {
+ el = $$(el);
+
+ el.replaceWith(
+ art(path.join(__dirname, 'templates/description.art'), {
+ images:
+ el.prop('zoomfile') || el.prop('file')
+ ? [
+ {
+ src: el.prop('zoomfile') || el.prop('file'),
+ alt: el.prop('alt') || el.prop('title'),
+ },
+ ]
+ : undefined,
+ })
+ );
+ });
+
+ $$('i.pstatus').remove();
+ $$('div.tip').remove();
+
+ const title = $$('span#thread_subject').text().trim();
+ const description = $$('div.post_message').first().html();
+ const pubDate = $$('span[title]').first().prop('title');
+
+ const tid = item.link.match(/tid=(\d+)/)?.[1] ?? undefined;
+ const guid = tid ? `flyert-${tid}` : undefined;
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = pubDate ? timezone(parseDate(pubDate), +8) : item.pubDate;
+ item.author = $$('a.kmxi2').first().text();
+ item.guid = guid;
+ item.id = guid;
+ item.content = {
+ html: description,
+ text: $$('div.post_message').first().text(),
+ };
+
+ return item;
+};
+
+export { parseArticle, parseArticleList, parsePost, parsePostList, rootUrl };
diff --git a/lib/routes/flyert/utils.ts b/lib/routes/flyert/utils.ts
new file mode 100644
index 00000000000000..8502f150e0628a
--- /dev/null
+++ b/lib/routes/flyert/utils.ts
@@ -0,0 +1,79 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+import pMap from 'p-map';
+
+import got from '@/utils/got';
+import wait from '@/utils/wait';
+
+const gbk2utf8 = (s) => iconv.decode(s, 'gbk');
+
+async function loadContent(link) {
+ // 添加随机延迟,假设延迟时间在1000毫秒到3000毫秒之间
+ const randomDelay = Math.floor(Math.random() * (3000 - 1000 + 1)) + 1000;
+ await wait(randomDelay);
+
+ const response = await got.get(link, {
+ responseType: 'buffer',
+ });
+ const $ = load(gbk2utf8(response.data));
+
+ // 去除全文末尾多余内容
+ $('.lookMore').remove();
+ $('script, style').remove();
+ $('#loginDialog').remove();
+
+ // 获取第一个帖子对象
+ const firstpost = $('.firstpost');
+
+ // 修改图片中的链接
+ firstpost.find('ignore_js_op img').each(function () {
+ $(this).attr('src', $(this).attr('file'));
+ // 移除无用属性
+ for (const attr of ['id', 'aid', 'zoomfile', 'file', 'zoomfile', 'class', 'onclick', 'title', 'inpost', 'alt', 'onmouseover']) {
+ $(this).removeAttr(attr);
+ }
+ });
+
+ // 去除全文中图片的多余标签
+ const images = firstpost.find('ignore_js_op img');
+ firstpost.find('ignore_js_op').remove();
+ firstpost.append(images);
+
+ // 提取内容
+ const description = firstpost.html();
+
+ return { description };
+}
+
+const ProcessFeed = (list, caches) => {
+ const host = 'https://www.flyert.com.cn';
+
+ return pMap(
+ list,
+ async (item) => {
+ const $ = load(item);
+
+ const $label = $(".comiis_common a[data-track='版块页主题分类']");
+ const $title = $(".comiis_common a[data-track='版块页文章']");
+ // 还原相对链接为绝对链接
+ const itemUrl = new URL($title.attr('href'), host).href;
+
+ // 列表上提取到的信息
+ const single = {
+ title: $label.text() + '-' + $title.text(),
+ link: itemUrl,
+ guid: itemUrl,
+ };
+
+ // 使用tryGet方法从缓存获取内容。
+ // 当缓存中无法获取到链接内容的时候,则使用load方法加载文章内容。
+ const other = await caches.tryGet(itemUrl, () => loadContent(itemUrl));
+
+ // 合并解析后的结果集作为该篇文章最终的输出结果
+ return Object.assign({}, single, other);
+ },
+ { concurrency: 2 }
+ ); // 设置并发请求数量为 2
+};
+
+export default { ProcessFeed };
diff --git a/lib/routes/focustaiwan/index.ts b/lib/routes/focustaiwan/index.ts
new file mode 100644
index 00000000000000..d8b8615f7f1bea
--- /dev/null
+++ b/lib/routes/focustaiwan/index.ts
@@ -0,0 +1,101 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/focustaiwan',
+ parameters: { category: '分类,见下表,默认为 news' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Category',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| Latest | Editor's Picks | Photos of the Day |
+| ------ | -------------- | ----------------- |
+| news | editorspicks | photos |
+
+| Politics | Cross-strait | Business | Society | Science & Tech | Culture | Sports |
+| -------- | ------------ | -------- | ------- | -------------- | ------- | ------ |
+| politics | cross-strait | business | society | science & tech | culture | sports |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'news';
+
+ const rootUrl = 'https://focustaiwan.tw';
+ const currentUrl = `${rootUrl}/cna2019api/cna/FTNewsList`;
+
+ const response = await got({
+ method: 'post',
+ url: currentUrl,
+ form: {
+ action: 4,
+ category,
+ pageidx: 2,
+ pagesize: 50,
+ },
+ });
+
+ const list = response.data.ResultData.Items.map((item) => ({
+ title: item.HeadLine,
+ link: item.PageUrl,
+ category: item.ClassName,
+ pubDate: timezone(parseDate(item.CreateTime), +8),
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ content('img').each(function () {
+ content(this).html(` `);
+ });
+
+ const image = content('meta[property="og:image"]').attr('content');
+ const matches = detailResponse.data.match(/var pAudio_url = "(.*)\.mp3";/);
+
+ if (matches) {
+ item.enclosure_url = matches[1];
+ item.enclosure_type = 'audio/mpeg';
+ item.itunes_item_image = image;
+ }
+
+ item.description = art(path.join(__dirname, 'templates/article.art'), {
+ image,
+ description: content('.paragraph').html(),
+ });
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: response.data.ResultData.MetaData.Title,
+ link: response.data.ResultData.MetaData.CanonicalUrl,
+ item: items,
+ itunes_author: 'Focus Taiwan',
+ image: 'https://imgcdn.cna.com.tw/Eng/website/img/default.png',
+ };
+}
diff --git a/lib/routes/focustaiwan/namespace.ts b/lib/routes/focustaiwan/namespace.ts
new file mode 100644
index 00000000000000..35b7e40e179310
--- /dev/null
+++ b/lib/routes/focustaiwan/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Focus Taiwan',
+ url: 'focustaiwan.tw',
+ lang: 'zh-TW',
+};
diff --git a/lib/v2/focustaiwan/templates/article.art b/lib/routes/focustaiwan/templates/article.art
similarity index 100%
rename from lib/v2/focustaiwan/templates/article.art
rename to lib/routes/focustaiwan/templates/article.art
diff --git a/lib/routes/follow/namespace.ts b/lib/routes/follow/namespace.ts
new file mode 100644
index 00000000000000..cdc759465159ff
--- /dev/null
+++ b/lib/routes/follow/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Follow',
+ url: 'app.follow.is',
+ lang: 'en',
+};
diff --git a/lib/routes/follow/profile.ts b/lib/routes/follow/profile.ts
new file mode 100644
index 00000000000000..e28834080ffd20
--- /dev/null
+++ b/lib/routes/follow/profile.ts
@@ -0,0 +1,122 @@
+import type { Context } from 'hono';
+import { parse } from 'tldts';
+
+import type { Data, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import type { FeedSubscription, FollowResponse, InboxSubscription, ListSubscription, Profile, Subscription } from './types';
+
+export const route: Route = {
+ name: 'User subscriptions',
+ categories: ['social-media'],
+ path: '/profile/:uid',
+ example: '/follow/profile/41279032429549568',
+ parameters: {
+ uid: 'User ID or user handle',
+ },
+ radar: [
+ {
+ source: ['app.follow.is/profile/:uid'],
+ target: '/profile/:uid',
+ },
+ ],
+ handler,
+ maintainers: ['KarasuShin', 'DIYgod', 'DFobain'],
+ features: {
+ supportRadar: true,
+ },
+ view: ViewType.Notifications,
+};
+
+const isList = (subscription: Subscription): subscription is ListSubscription => 'lists' in subscription;
+
+const isInbox = (subscription: Subscription): subscription is InboxSubscription => 'inboxId' in subscription;
+
+const isFeed = (subscription: Subscription): subscription is FeedSubscription => 'feeds' in subscription;
+
+async function handler(ctx: Context): Promise {
+ const handleOrId = ctx.req.param('uid');
+ const host = 'https://api.follow.is';
+
+ const handle = isBizId(handleOrId || '') ? handleOrId : handleOrId.startsWith('@') ? handleOrId.slice(1) : handleOrId;
+
+ const searchParams = new URLSearchParams({ handle });
+
+ if (isBizId(handle || '')) {
+ searchParams.append('id', handle);
+ }
+
+ const profile = await ofetch>(`${host}/profiles?${searchParams.toString()}`);
+ const subscriptions = await ofetch>(`${host}/subscriptions?userId=${profile.data.id}`);
+
+ return {
+ title: `${profile.data.name}'s subscriptions`,
+ item: ([]>subscriptions.data.filter((i) => !isInbox(i) && !(isFeed(i) && !!i.feeds.errorAt))).map((subscription) => {
+ if (isList(subscription)) {
+ return {
+ title: subscription.lists.title,
+ description: subscription.lists.description,
+ link: `https://app.follow.is/list/${subscription.listId}`,
+ image: subscription.lists.image,
+ };
+ }
+ return {
+ title: subscription.feeds.title,
+ description: subscription.feeds.description,
+ link: `https://app.follow.is/feed/${subscription.feedId}`,
+ image: getUrlIcon(subscription.feeds.siteUrl).src,
+ category: subscription.category ? [subscription.category] : undefined,
+ };
+ }),
+ link: `https://app.follow.is/share/users/${handleOrId}`,
+ image: profile.data.image,
+ };
+}
+
+const getUrlIcon = (url: string, fallback?: boolean | undefined) => {
+ let src: string;
+ let fallbackUrl = '';
+
+ try {
+ const { host } = new URL(url);
+ const pureDomain = parse(host).domainWithoutSuffix;
+ fallbackUrl = `https://avatar.vercel.sh/${pureDomain}.svg?text=${pureDomain?.slice(0, 2).toUpperCase()}`;
+ src = `https://unavatar.follow.is/${host}?fallback=${fallback || false}`;
+ } catch {
+ const pureDomain = parse(url).domainWithoutSuffix;
+ src = `https://avatar.vercel.sh/${pureDomain}.svg?text=${pureDomain?.slice(0, 2).toUpperCase()}`;
+ }
+ const ret = {
+ src,
+ fallbackUrl,
+ };
+
+ return ret;
+};
+
+// referenced from https://github.com/RSSNext/Follow/blob/dev/packages/utils/src/utils.ts
+const EPOCH = 1_712_546_615_000n; // follow repo created
+const MAX_TIMESTAMP_BITS = 41n; // Maximum number of bits typically used for timestamp
+export const isBizId = (id: string): boolean => {
+ if (!id || !/^\d{13,19}$/.test(id)) {
+ return false;
+ }
+
+ const snowflake = BigInt(id);
+
+ // Extract the timestamp assuming it's in the most significant bits after the sign bit
+ const timestamp = (snowflake >> (63n - MAX_TIMESTAMP_BITS)) + EPOCH;
+ const date = new Date(Number(timestamp));
+
+ // Check if the date is reasonable (between 2024 and 2050)
+ if (date.getFullYear() >= 2024 && date.getFullYear() <= 2050) {
+ // Additional validation: check if the ID is not larger than the maximum possible value
+ const maxPossibleId = (1n << 63n) - 1n; // Maximum possible 63-bit value
+ if (snowflake <= maxPossibleId) {
+ return true;
+ }
+ }
+
+ return false;
+};
diff --git a/lib/routes/follow/types.ts b/lib/routes/follow/types.ts
new file mode 100644
index 00000000000000..8ba2969d93d014
--- /dev/null
+++ b/lib/routes/follow/types.ts
@@ -0,0 +1,78 @@
+export interface FollowResponse {
+ code: number;
+ data: T;
+}
+
+export type Subscription = FeedSubscription | ListSubscription | InboxSubscription;
+
+export interface Profile {
+ id: string;
+ name: string;
+ email: string;
+ emailVerified: unknown;
+ image: string;
+ handle: unknown;
+ createdAt: string;
+}
+
+export interface BaseSubscription {
+ feedId: string;
+ isPrivate: boolean;
+ title: string | null;
+ userId: string;
+ view: number;
+}
+
+export interface FeedSubscription extends BaseSubscription {
+ category: string | null;
+ feeds: {
+ checkAt: string;
+ description: string;
+ errorAt: unknown;
+ errorMessage: unknown;
+ etagHeader: string;
+ id: string;
+ image: unknown;
+ lastModifiedHeader: string;
+ ownerUserId: string | null;
+ siteUrl: string;
+ title: string;
+ type: 'feed';
+ url: string;
+ };
+}
+
+export interface ListSubscription extends BaseSubscription {
+ lastViewedAt: string;
+ listId: string;
+ lists: {
+ description: string;
+ fee: number;
+ feedIds: string[];
+ id: string;
+ image: string;
+ owner: {
+ createdAt: string;
+ emailVerified: unknown;
+ handle: string | null;
+ id: string;
+ image: string;
+ name: string;
+ };
+ ownerUserId: string;
+ timelineUpdatedAt: string;
+ title: string;
+ type: 'list';
+ view: number;
+ };
+}
+
+export interface InboxSubscription extends BaseSubscription {
+ inboxes: {
+ type: 'inbox';
+ id: string;
+ secret: string;
+ title: string;
+ };
+ inboxId: string;
+}
diff --git a/lib/routes/followin/index.ts b/lib/routes/followin/index.ts
new file mode 100644
index 00000000000000..25ff74ab31a028
--- /dev/null
+++ b/lib/routes/followin/index.ts
@@ -0,0 +1,95 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { apiUrl, favicon, getBParam, getBuildId, getGToken, parseItem, parseList } from './utils';
+
+export const route: Route = {
+ path: '/:categoryId?/:lang?',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/followin',
+ parameters: {
+ categoryId: {
+ description: 'Category ID',
+ options: [
+ { value: '1', label: 'For You' },
+ { value: '9', label: 'Market' },
+ { value: '13', label: 'Meme' },
+ { value: '14', label: 'BRC20' },
+ { value: '3', label: 'NFT' },
+ { value: '5', label: 'Thread' },
+ { value: '6', label: 'In-depth' },
+ { value: '8', label: 'Tutorials' },
+ { value: '11', label: 'Videos' },
+ ],
+ default: '1',
+ },
+ lang: {
+ description: 'Language',
+ options: [
+ { value: 'en', label: 'English' },
+ { value: 'zh-Hans', label: '简体中文' },
+ { value: 'zh-Hant', label: '繁體中文' },
+ { value: 'vi', label: 'Tiếng Việt' },
+ ],
+ default: 'en',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Home',
+ maintainers: ['TonyRL'],
+ handler,
+ description: `Category ID
+
+| For You | Market | Meme | BRC20 | NFT | Thread | In-depth | Tutorials | Videos |
+| ------- | ------ | ---- | ----- | --- | ------ | -------- | --------- | ------ |
+| 1 | 9 | 13 | 14 | 3 | 5 | 6 | 8 | 11 |
+
+ Language
+
+| English | 简体中文 | 繁體中文 | Tiếng Việt |
+| ------- | -------- | -------- | ---------- |
+| en | zh-Hans | zh-Hant | vi |`,
+};
+
+async function handler(ctx) {
+ const { categoryId = '1', lang = 'en' } = ctx.req.param();
+ const { limit = 20 } = ctx.req.query();
+ const gToken = await getGToken(cache.tryGet);
+ const bParam = getBParam(lang);
+
+ const { data: response } = await got.post(`${apiUrl}/feed/list/recommended`, {
+ headers: {
+ 'x-bparam': JSON.stringify(bParam),
+ 'x-gtoken': gToken,
+ },
+ json: {
+ category_id: Number.parseInt(categoryId),
+ count: Number.parseInt(limit),
+ },
+ });
+ if (response.code !== 2000) {
+ throw new Error(response.msg);
+ }
+
+ const buildId = await getBuildId(cache.tryGet);
+
+ const list = parseList(response.data.list, lang, buildId);
+ const items = await Promise.all(list.map((item) => parseItem(item, cache.tryGet)));
+
+ return {
+ title: 'Followin',
+ link: 'https://followin.io',
+ image: favicon,
+ item: items,
+ };
+}
diff --git a/lib/routes/followin/kol.ts b/lib/routes/followin/kol.ts
new file mode 100644
index 00000000000000..83b5ab69f7804b
--- /dev/null
+++ b/lib/routes/followin/kol.ts
@@ -0,0 +1,51 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { baseUrl, getBuildId, parseItem, parseList } from './utils';
+
+export const route: Route = {
+ path: '/kol/:kolId/:lang?',
+ categories: ['finance'],
+ example: '/followin/kol/4075592991',
+ parameters: { kolId: 'KOL ID, can be found in URL', lang: 'Language, see table above, `en` by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['followin.io/:lang/kol/:kolId', 'followin.io/kol/:kolId'],
+ },
+ ],
+ name: 'KOL',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { kolId, lang = 'en' } = ctx.req.param();
+ const { limit = 10 } = ctx.req.query();
+
+ const buildId = await getBuildId(cache.tryGet);
+ const { data: response } = await got(`${baseUrl}/_next/data/${buildId}/${lang}/kol/${kolId}.json`);
+
+ const { queries } = response.pageProps.dehydratedState;
+ const { data: profile } = queries.find((q) => q.queryKey[0] === '/user/get_profile').state;
+
+ const list = parseList(queries.find((q) => q.queryKey[0] === '/feed/list/user').state.data.pages[0].list.slice(0, limit), lang, buildId);
+ const items = await Promise.all(list.map((item) => parseItem(item, cache.tryGet)));
+
+ return {
+ title: `${profile.nickname} - Followin`,
+ description: profile.bio,
+ link: `${baseUrl}/${lang}/kol/${kolId}`,
+ image: profile.avatar,
+ language: lang,
+ item: items,
+ };
+}
diff --git a/lib/routes/followin/namespace.ts b/lib/routes/followin/namespace.ts
new file mode 100644
index 00000000000000..776ab0f3f3bf21
--- /dev/null
+++ b/lib/routes/followin/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Followin',
+ url: 'followin.io',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/followin/news.ts b/lib/routes/followin/news.ts
new file mode 100644
index 00000000000000..4e7bd397a71b5d
--- /dev/null
+++ b/lib/routes/followin/news.ts
@@ -0,0 +1,60 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { baseUrl, favicon, getBuildId, parseItem, parseList } from './utils';
+
+export const route: Route = {
+ path: '/news/:lang?',
+ categories: ['finance'],
+ view: ViewType.Articles,
+ example: '/followin/news',
+ parameters: {
+ lang: {
+ description: 'Language',
+ options: [
+ { value: 'en', label: 'English' },
+ { value: 'zh-Hans', label: '简体中文' },
+ { value: 'zh-Hant', label: '繁體中文' },
+ { value: 'vi', label: 'Tiếng Việt' },
+ ],
+ default: 'en',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['followin.io/:lang?/news', 'followin.io/news'],
+ },
+ ],
+ name: 'News',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { lang = 'en' } = ctx.req.param();
+ const { limit = 20 } = ctx.req.query();
+
+ const buildId = await getBuildId(cache.tryGet);
+ const { data: response } = await got(`${baseUrl}/_next/data/${buildId}/${lang}/news.json`);
+
+ const list = parseList(response.pageProps.dehydratedState.queries.find((q) => q.queryKey[0] === '/feed/list/recommended/news').state.data.pages[0].list.slice(0, limit), lang, buildId);
+ const items = await Promise.all(list.map((item) => parseItem(item, cache.tryGet)));
+
+ return {
+ title: `${lang === 'en' ? 'News' : lang === 'vi' ? 'Bản tin' : '快讯'} - Followin`,
+ link: `${baseUrl}/${lang}/news`,
+ image: favicon,
+ language: lang,
+ item: items,
+ };
+}
diff --git a/lib/routes/followin/tag.ts b/lib/routes/followin/tag.ts
new file mode 100644
index 00000000000000..7d277f3f0762ef
--- /dev/null
+++ b/lib/routes/followin/tag.ts
@@ -0,0 +1,70 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { apiUrl, baseUrl, getBParam, getBuildId, getGToken, parseItem, parseList } from './utils';
+
+export const route: Route = {
+ path: '/tag/:tagId/:lang?',
+ categories: ['finance'],
+ example: '/followin/tag/177008',
+ parameters: { tagId: 'Tag ID, can be found in URL', lang: 'Language, see table above, `en` by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['followin.io/:lang/tag/:tagId', 'followin.io/tag/:tagId'],
+ },
+ ],
+ name: 'Tag',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { tagId, lang = 'en' } = ctx.req.param();
+ const { limit = 20 } = ctx.req.query();
+
+ const buildId = await getBuildId(cache.tryGet);
+ const tagInfo = await cache.tryGet(`followin:tag:${tagId}:${lang}`, async () => {
+ const { data: response } = await got(`${baseUrl}/_next/data/${buildId}/${lang}/tag/${tagId}.json`);
+ const { queries } = response.pageProps.dehydratedState;
+ const { base_info: tagInfo } = queries.find((q) => q.queryKey[0] === '/tag/info/v2').state.data;
+ return tagInfo;
+ });
+
+ const gToken = await getGToken(cache.tryGet);
+ const bParam = getBParam(lang);
+ const { data: tagResponse } = await got.post(`${apiUrl}/feed/list/tag`, {
+ headers: {
+ 'x-bparam': JSON.stringify(bParam),
+ 'x-gtoken': gToken,
+ },
+ json: {
+ count: limit,
+ id: Number.parseInt(tagId),
+ type: 'tag_discussion_feed',
+ },
+ });
+ if (tagResponse.code !== 2000) {
+ throw new Error(tagResponse.msg);
+ }
+
+ const list = parseList(tagResponse.data.list.slice(0, limit), lang, buildId);
+ const items = await Promise.all(list.map((item) => parseItem(item, cache.tryGet)));
+
+ return {
+ title: `${tagInfo.name} - Followin`,
+ description: tagInfo.description,
+ link: `${baseUrl}/${lang}/tag/${tagId}`,
+ image: tagInfo.logo,
+ language: lang,
+ item: items,
+ };
+}
diff --git a/lib/v2/followin/templates/thread.art b/lib/routes/followin/templates/thread.art
similarity index 100%
rename from lib/v2/followin/templates/thread.art
rename to lib/routes/followin/templates/thread.art
diff --git a/lib/routes/followin/topic.ts b/lib/routes/followin/topic.ts
new file mode 100644
index 00000000000000..3d3938a469a60f
--- /dev/null
+++ b/lib/routes/followin/topic.ts
@@ -0,0 +1,51 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { baseUrl, getBuildId, parseItem, parseList } from './utils';
+
+export const route: Route = {
+ path: '/topic/:topicId/:lang?',
+ categories: ['finance'],
+ example: '/followin/topic/40',
+ parameters: { topicId: 'Topic ID, can be found in URL', lang: 'Language, see table above, `en` by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['followin.io/:lang/topic/:topicId', 'followin.io/topic/:topicId'],
+ },
+ ],
+ name: 'Topic',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { topicId, lang = 'en' } = ctx.req.param();
+ const { limit = 20 } = ctx.req.query();
+
+ const buildId = await getBuildId(cache.tryGet);
+ const { data: response } = await got(`${baseUrl}/_next/data/${buildId}/${lang}/topic/${topicId}.json`);
+
+ const { queries } = response.pageProps.dehydratedState;
+ const { data: topicInfo } = queries.find((q) => q.queryKey[0] === '/topic/info').state;
+
+ const list = parseList(queries.find((q) => q.queryKey[0] === '/feed/list/topic').state.data.pages[0].list.slice(0, limit), lang, buildId);
+ const items = await Promise.all(list.map((item) => parseItem(item, cache.tryGet)));
+
+ return {
+ title: `${topicInfo.title} - Followin`,
+ description: topicInfo.desc,
+ link: `${baseUrl}/${lang}/topic/${topicId}`,
+ image: topicInfo.logo,
+ language: lang,
+ item: items,
+ };
+}
diff --git a/lib/routes/followin/utils.ts b/lib/routes/followin/utils.ts
new file mode 100644
index 00000000000000..1f210423abd320
--- /dev/null
+++ b/lib/routes/followin/utils.ts
@@ -0,0 +1,75 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+const apiUrl = 'https://api.followin.io';
+const baseUrl = 'https://followin.io';
+const favicon = `${baseUrl}/favicon.ico`;
+
+const getBParam = (lang) => ({
+ a: 'web',
+ b: '',
+ c: lang,
+ d: 0,
+ e: '',
+ f: '',
+ g: '',
+ h: '0.1.0',
+ i: 'official',
+});
+
+const getBuildId = (tryGet) =>
+ tryGet(
+ 'followin:buildId',
+ async () => {
+ const { data: pageResponse } = await got(baseUrl);
+ const $ = load(pageResponse);
+ const { buildId } = JSON.parse($('script#__NEXT_DATA__').text());
+ return buildId;
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+const getGToken = (tryGet) =>
+ tryGet('followin:gtoken', async () => {
+ const { data } = await got.post(`${apiUrl}/user/gtoken`);
+ return data.data.gtoken;
+ });
+
+const parseList = (list, lang, buildId) =>
+ list.map((item) => ({
+ title: item.translated_title || item.title,
+ description: item.translated_content || item.content,
+ link: `${baseUrl}/${lang === 'en' ? '' : `${lang}/`}feed/${item.id}`,
+ pubDate: parseDate(item.publish_time, 'x'),
+ category: item.tags.map((tag) => tag.name),
+ author: item.nickname,
+ nextData: `${baseUrl}/_next/data/${buildId}/${lang}/feed/${item.id}.json`,
+ }));
+
+const parseItem = (item, tryGet) =>
+ tryGet(item.link, async () => {
+ const { data } = await got(item.nextData);
+
+ const { queries } = data.pageProps.dehydratedState;
+ const info = queries.find((q) => q.queryKey[0] === '/feed/info').state;
+ const thread = queries.find((q) => q.queryKey[0] === '/feed/thread');
+ item.description = thread
+ ? art(path.join(__dirname, 'templates/thread.art'), {
+ list: thread.state.data.list,
+ })
+ : info.data.translated_full_content || info.data.full_content;
+
+ item.updated = parseDate(info.dataUpdatedAt, 'x');
+ item.category = [...new Set([...item.category, ...info.data.tags.map((tag) => tag.name)])];
+
+ return item;
+ });
+
+export { apiUrl, baseUrl, favicon, getBParam, getBuildId, getGToken, parseItem, parseList };
diff --git a/lib/routes/foodtalks/index.ts b/lib/routes/foodtalks/index.ts
new file mode 100644
index 00000000000000..0ea4f240e7b809
--- /dev/null
+++ b/lib/routes/foodtalks/index.ts
@@ -0,0 +1,76 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import logger from '@/utils/logger';
+import ofetch from '@/utils/ofetch';
+
+import { namespace } from './namespace';
+
+export const route: Route = {
+ path: '/',
+ categories: namespace.categories,
+ example: '/foodtalks',
+ radar: [
+ {
+ source: ['www.foodtalks.cn'],
+ },
+ ],
+ name: 'FoodTalks global food information network',
+ maintainers: ['Geraldxm'],
+ handler,
+ url: 'www.foodtalks.cn',
+};
+
+function processItems(list: any[], fullTextApi: string) {
+ return Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ const response = await ofetch(fullTextApi.replace('{id}', item.id), {
+ headers: {
+ referrer: 'https://www.foodtalks.cn/',
+ method: 'GET',
+ },
+ });
+ item.description = response.data.content;
+ return item;
+ } catch (error) {
+ logger.error(`Error fetching full text for ${item.link}:`, error);
+ return item;
+ }
+ })
+ )
+ );
+}
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') || 15;
+ const url = `https://api-we.foodtalks.cn/news/news/page?current=1&size=${limit}&isLatest=1&language=ZH`;
+ const response = await ofetch(url, {
+ headers: {
+ referrer: 'https://www.foodtalks.cn/',
+ method: 'GET',
+ },
+ });
+ const records = response.data.records;
+
+ const list = records.map((item) => ({
+ title: item.title,
+ pubDate: new Date(item.publishTime),
+ link: `https://www.foodtalks.cn/news/${item.id}`,
+ category: item.parentTagCode === 'category' ? item.tagCode : item.parentTagCode,
+ author: item.author === null ? item.sourceName : item.author,
+ id: item.id,
+ image: item.coverImg,
+ }));
+
+ const fullTextApi = 'https://api-we.foodtalks.cn/news/news/{id}?language=ZH';
+ const items = await processItems(list, fullTextApi);
+
+ return {
+ title: namespace.name,
+ description: namespace.description,
+ link: 'https://' + namespace.url,
+ item: items,
+ image: 'https://www.foodtalks.cn/static/img/news-site-logo.7aaa5463.svg',
+ };
+}
diff --git a/lib/routes/foodtalks/namespace.ts b/lib/routes/foodtalks/namespace.ts
new file mode 100644
index 00000000000000..1f98d28b8ae755
--- /dev/null
+++ b/lib/routes/foodtalks/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FoodTalks全球食品资讯网',
+ url: 'www.foodtalks.cn',
+ categories: ['new-media'],
+ lang: 'zh-CN',
+ description: 'FoodTalks全球食品资讯网是一个提供食品饮料行业新闻、资讯、分析和商业资源的领先在线平台。它涵盖行业趋势、市场动态、产品创新、投融资信息以及企业新闻,连接行业内的专业人士、企业和消费者。',
+};
diff --git a/lib/routes/foreignaffairs/namespace.ts b/lib/routes/foreignaffairs/namespace.ts
new file mode 100644
index 00000000000000..37750c7708685a
--- /dev/null
+++ b/lib/routes/foreignaffairs/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Foreign Affairs',
+ url: 'www.foreignaffairs.com',
+ lang: 'en',
+};
diff --git a/lib/routes/foreignaffairs/rss.ts b/lib/routes/foreignaffairs/rss.ts
new file mode 100644
index 00000000000000..274a8ae4923324
--- /dev/null
+++ b/lib/routes/foreignaffairs/rss.ts
@@ -0,0 +1,56 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import parser from '@/utils/rss-parser';
+
+export const route: Route = {
+ path: '/rss',
+ categories: ['traditional-media'],
+ example: '/foreignaffairs/rss',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'RSS',
+ maintainers: ['dzx-dzx'],
+ handler,
+};
+
+async function handler() {
+ const link = 'https://www.foreignaffairs.com/rss.xml';
+
+ const feed = await parser.parseURL(link);
+
+ const items = await Promise.all(
+ feed.items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const $ = load(response.data);
+
+ $('.paywall').remove();
+ $('.loading-indicator').remove();
+ item.description = $('.article-dropcap').html();
+ item.author = item.creator;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: 'Foreign Affairs - RSS',
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/foresightnews/article.ts b/lib/routes/foresightnews/article.ts
new file mode 100644
index 00000000000000..7cebf0f7d5e689
--- /dev/null
+++ b/lib/routes/foresightnews/article.ts
@@ -0,0 +1,48 @@
+import type { Route } from '@/types';
+
+import { apiRootUrl, icon, image, processItems, rootUrl } from './util';
+
+export const route: Route = {
+ path: '/article',
+ categories: ['new-media'],
+ example: '/foresightnews/article',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['foresightnews.pro/'],
+ },
+ ],
+ name: '文章',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'foresightnews.pro/',
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50;
+
+ const apiUrl = new URL('v1/articles', apiRootUrl).href;
+
+ const { items } = await processItems(apiUrl, limit);
+
+ return {
+ item: items,
+ title: 'Foresight News - 文章',
+ link: rootUrl,
+ description: '文章 - Foresight News',
+ language: 'zh-cn',
+ image,
+ icon,
+ logo: icon,
+ subtitle: '文章',
+ author: 'Foresight News',
+ };
+}
diff --git a/lib/routes/foresightnews/column.ts b/lib/routes/foresightnews/column.ts
new file mode 100644
index 00000000000000..47f68d20a6e0ce
--- /dev/null
+++ b/lib/routes/foresightnews/column.ts
@@ -0,0 +1,54 @@
+import type { Route } from '@/types';
+
+import { apiRootUrl, icon, image, processItems, rootUrl } from './util';
+
+export const route: Route = {
+ path: '/column/:id',
+ categories: ['new-media'],
+ example: '/foresightnews/column/1',
+ parameters: { id: '专栏 id, 可在对应专栏页 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['foresightnews.pro/column/detail/:id', 'foresightnews.pro/'],
+ },
+ ],
+ name: '专栏',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'foresightnews.pro/',
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50;
+
+ const apiUrl = new URL('v1/articles', apiRootUrl).href;
+ const currentUrl = new URL(`column/detail/${id}`, rootUrl).href;
+
+ const { items, info } = await processItems(apiUrl, limit, {
+ column_id: id,
+ });
+
+ const column = info.column;
+
+ return {
+ item: items,
+ title: `Foresight News - ${column}`,
+ link: currentUrl,
+ description: `${column} - Foresight News`,
+ language: 'zh-cn',
+ image,
+ icon,
+ logo: icon,
+ subtitle: column,
+ author: 'Foresight News',
+ };
+}
diff --git a/lib/routes/foresightnews/index.ts b/lib/routes/foresightnews/index.ts
new file mode 100644
index 00000000000000..3c9cbad9beb550
--- /dev/null
+++ b/lib/routes/foresightnews/index.ts
@@ -0,0 +1,40 @@
+import type { Route } from '@/types';
+
+import { apiRootUrl, icon, image, processItems, rootUrl } from './util';
+
+export const route: Route = {
+ path: '/',
+ categories: ['new-media'],
+ example: '/foresightnews',
+ radar: [
+ {
+ source: ['foresightnews.pro/'],
+ target: '',
+ },
+ ],
+ name: '精选资讯',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'foresightnews.pro/',
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50;
+
+ const apiUrl = new URL(`v2/feed`, apiRootUrl).href;
+
+ const { items } = await processItems(apiUrl, limit);
+
+ return {
+ item: items,
+ title: 'Foresight News - 精选资讯',
+ link: rootUrl,
+ description: 'FN精选 - Foresight News',
+ language: 'zh-cn',
+ image,
+ icon,
+ logo: icon,
+ subtitle: '精选资讯',
+ author: 'Foresight News',
+ };
+}
diff --git a/lib/routes/foresightnews/namespace.ts b/lib/routes/foresightnews/namespace.ts
new file mode 100644
index 00000000000000..4ff0f37076a8fb
--- /dev/null
+++ b/lib/routes/foresightnews/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Foresight News',
+ url: 'foresightnews.pro',
+ lang: 'en',
+};
diff --git a/lib/routes/foresightnews/news.ts b/lib/routes/foresightnews/news.ts
new file mode 100644
index 00000000000000..563de1150e8228
--- /dev/null
+++ b/lib/routes/foresightnews/news.ts
@@ -0,0 +1,49 @@
+import type { Route } from '@/types';
+
+import { apiRootUrl, icon, image, processItems, rootUrl } from './util';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['new-media'],
+ example: '/foresightnews/news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['foresightnews.pro/news', 'foresightnews.pro/'],
+ },
+ ],
+ name: '快讯',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'foresightnews.pro/news',
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50;
+
+ const apiUrl = new URL('v1/news', apiRootUrl).href;
+ const currentUrl = new URL('news', rootUrl).href;
+
+ const { items } = await processItems(apiUrl, limit);
+
+ return {
+ item: items,
+ title: 'Foresight News - 快讯',
+ link: currentUrl,
+ description: '快讯 - Foresight News',
+ language: 'zh-cn',
+ image,
+ icon,
+ logo: icon,
+ subtitle: '快讯',
+ author: 'Foresight News',
+ };
+}
diff --git a/lib/v2/foresightnews/templates/description.art b/lib/routes/foresightnews/templates/description.art
similarity index 100%
rename from lib/v2/foresightnews/templates/description.art
rename to lib/routes/foresightnews/templates/description.art
diff --git a/lib/routes/foresightnews/util.ts b/lib/routes/foresightnews/util.ts
new file mode 100644
index 00000000000000..17e3c19d7818a1
--- /dev/null
+++ b/lib/routes/foresightnews/util.ts
@@ -0,0 +1,86 @@
+import path from 'node:path';
+import zlib from 'node:zlib';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+const constants = {
+ labelHot: '热门',
+ labelImportant: '重要消息',
+ defaultType: 'article',
+};
+
+const params = {
+ article: 'article',
+ event: 'timeline',
+ news: 'news',
+};
+
+const rootUrl = 'https://foresightnews.pro';
+const apiRootUrl = 'https://api.foresightnews.pro';
+const imgRootUrl = 'https://img.foresightnews.pro';
+
+const icon = new URL('foresight.ico', rootUrl).href;
+const image = new URL('vertical_logo.png', imgRootUrl).href;
+
+const processItems = async (apiUrl, limit, ...parameters) => {
+ let searchParams = {
+ size: limit,
+ };
+ for (const param of parameters) {
+ searchParams = {
+ ...searchParams,
+ ...param,
+ };
+ }
+
+ const info = {
+ column: '',
+ };
+
+ const { data: response } = await got(apiUrl, {
+ searchParams,
+ });
+
+ let items = JSON.parse(String(zlib.inflateSync(Buffer.from(response.data?.list ?? response.data, 'base64'))));
+
+ items = (items?.list ?? items).slice(0, limit).map((item) => {
+ const sourceType = item.source_type ?? (item.source_link ? (item.column?.title ? 'article' : 'news') : item.event_type ? 'event' : constants.defaultType);
+
+ item = item.source_type ? item[item.source_type] : item;
+
+ const column = item.column?.title;
+ info.column = info.column || column;
+
+ const categories = [
+ column,
+ item.event_type,
+ item.is_hot ? constants.labelHot : undefined,
+ item.is_important ? (item.important_tag?.name ?? constants.labelImportant) : '',
+ item.label,
+ ...(item.tags?.map((c) => c.name) ?? []),
+ ].filter((v, index, self) => v && self.indexOf(v) === index);
+
+ const link = new URL(`${params[sourceType]}/detail/${item.id}`, rootUrl).href;
+
+ return {
+ title: item.title,
+ link,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ image: item.img.split('?')[0],
+ description: item.content ?? item.brief,
+ source: item.source_link,
+ }),
+ author: item.column?.title ?? item.author?.username ?? undefined,
+ category: categories,
+ guid: `foresightnews-${sourceType}#${item.id}`,
+ pubDate: item.published_at ? parseDate(item.published_at * 1000) : undefined,
+ updated: item.last_update_at ? parseDate(item.last_update_at * 1000) : undefined,
+ };
+ });
+
+ return { items, info };
+};
+
+export { apiRootUrl, icon, image, imgRootUrl, processItems, rootUrl };
diff --git a/lib/routes/foreverblog/feeds.ts b/lib/routes/foreverblog/feeds.ts
new file mode 100644
index 00000000000000..a3bc2918b78f77
--- /dev/null
+++ b/lib/routes/foreverblog/feeds.ts
@@ -0,0 +1,60 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/feeds',
+ categories: ['blog'],
+ example: '/foreverblog/feeds',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.foreverblog.cn/feeds.html'],
+ },
+ ],
+ name: '专题展示 - 文章',
+ maintainers: ['7Wate', 'a180285'],
+ handler,
+ url: 'www.foreverblog.cn/feeds.html',
+};
+
+async function handler() {
+ const currentUrl = 'https://www.foreverblog.cn/feeds.html';
+
+ const response = await got(currentUrl);
+
+ const $ = load(response.data);
+ const $articles = $('article[class="post post-type-normal"]');
+ const items = $articles.toArray().map((el) => {
+ const $titleDiv = $(el).find('h1[class="post-title"]');
+ const title = $titleDiv.text().trim();
+ const link = $titleDiv.find('a').eq(0).attr('href');
+ const author = $(el).find('div[class="post-author"]').text().trim();
+ const postDate = $(el).find('time').text().trim();
+ const pubDate = timezone(parseDate(postDate, 'MM-DD'), +8);
+ const description = `${author}: ${title}`;
+ return {
+ title: description,
+ description,
+ link,
+ pubDate,
+ };
+ });
+
+ return {
+ title: '十年之约——专题展示',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/foreverblog/namespace.ts b/lib/routes/foreverblog/namespace.ts
new file mode 100644
index 00000000000000..56e307f41c50ce
--- /dev/null
+++ b/lib/routes/foreverblog/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '十年之约',
+ url: 'www.foreverblog.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/forklog/index.ts b/lib/routes/forklog/index.ts
new file mode 100644
index 00000000000000..0c2c2d4d1efc1d
--- /dev/null
+++ b/lib/routes/forklog/index.ts
@@ -0,0 +1,72 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['finance'],
+ example: '/forklog/news',
+ radar: [
+ {
+ source: ['forklog.com/news'],
+ target: '/news',
+ },
+ ],
+ name: 'Новости',
+ maintainers: ['raven428'],
+ handler,
+ url: 'forklog.com/news',
+};
+
+async function handler() {
+ const response = await got('https://forklog.com/wp-content/themes/forklogv2/ajax/getPosts.php', {
+ method: 'POST',
+ headers: { 'x-requested-with': 'XMLHttpRequest' },
+ form: { action: 'getPostsByCategory', postperpage: '333' },
+ });
+ const items = JSON.parse(response.body).map((post) => {
+ const link = post.link;
+ const title = (post.title || post.text?.post_title)?.trim();
+ const description = post.text?.post_content.trim();
+ const author = post.author_name.trim();
+ let pubDate;
+ if (post.text?.post_date_gmt) {
+ pubDate = timezone(parseDate(post.text.post_date_gmt), +1);
+ } else if (post.text?.post_date) {
+ pubDate = timezone(parseDate(post.text.post_date), +4);
+ } else if (post.date) {
+ pubDate = timezone(parseDate(post.date, 'DD.MM.YYYY HH:mm'), +4);
+ }
+ const imageSrc = post.image || post.image_mobile;
+ const views = post.views;
+ return {
+ link,
+ title,
+ author,
+ pubDate,
+ description,
+ category: ['news', 'crypto', 'finance'],
+ ...(imageSrc
+ ? {
+ media: {
+ thumbnail: {
+ url: imageSrc,
+ width: 250,
+ height: 250,
+ },
+ },
+ }
+ : {}),
+ extra: {
+ views,
+ },
+ };
+ });
+ return {
+ title: 'Forklog – Новости',
+ link: 'https://forklog.com/news',
+ description: 'Последние новости из мира блокчейна и криптовалют',
+ item: items,
+ };
+}
diff --git a/lib/routes/forklog/namespace.ts b/lib/routes/forklog/namespace.ts
new file mode 100644
index 00000000000000..9416a86afcbd84
--- /dev/null
+++ b/lib/routes/forklog/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Forklog',
+ url: 'forklog.com',
+ lang: 'ru',
+};
diff --git a/lib/routes/fortnite/namespace.ts b/lib/routes/fortnite/namespace.ts
new file mode 100644
index 00000000000000..2a4a735ffa8547
--- /dev/null
+++ b/lib/routes/fortnite/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Fortnite',
+ url: 'fortnite.com',
+ lang: 'en',
+};
diff --git a/lib/routes/fortnite/news.ts b/lib/routes/fortnite/news.ts
new file mode 100644
index 00000000000000..057831f577ad2c
--- /dev/null
+++ b/lib/routes/fortnite/news.ts
@@ -0,0 +1,87 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import logger from '@/utils/logger';
+import { parseDate } from '@/utils/parse-date';
+import puppeteer from '@/utils/puppeteer';
+
+export const route: Route = {
+ path: '/news/:options?',
+ categories: ['game'],
+ example: '/fortnite/news',
+ parameters: { options: 'Params' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: true,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'News',
+ maintainers: ['lyqluis'],
+ handler,
+ description: `- \`options.lang\`, optional, language, eg. \`/fortnite/news/lang=en-US\`, common languages are listed below, more languages are available one the [official website](https://www.fortnite.com/news)
+
+| English (default) | Spanish | Japanese | French | Korean | Polish |
+| ----------------- | ------- | -------- | ------ | ------ | ------ |
+| en-US | es-ES | ja | fr | ko | pl |`,
+};
+
+async function handler(ctx) {
+ const options = ctx.req
+ .param('options')
+ ?.split('&')
+ .map((op) => op.split('='));
+
+ const rootUrl = 'https://www.fortnite.com';
+ const path = 'news';
+ const language = options?.find((op) => op[0] === 'lang')[1] ?? 'en-US';
+ const link = `${rootUrl}/${path}?lang=${language}`;
+ const apiUrl = `https://www.fortnite.com/api/blog/getPosts?category=&postsPerPage=0&offset=0&locale=${language}&rootPageSlug=blog`;
+
+ // using puppeteer instead instead of got
+ // whitch may be blocked by anti-crawling script with response code 403
+ const browser = await puppeteer();
+ const page = await browser.newPage();
+
+ // intercept all requests
+ await page.setRequestInterception(true);
+ // only document is allowed
+ page.on('request', (request) => {
+ request.resourceType() === 'document' ? request.continue() : request.abort();
+ });
+
+ // get json data in response event handler
+ let data;
+ page.on('response', async (res) => {
+ data = await res.json();
+ });
+
+ // log manually (necessary for puppeteer)
+ logger.http(`Requesting ${apiUrl}`);
+ await page.goto(apiUrl, {
+ waitUntil: 'networkidle0', // if use 'domcontentloaded', `await page.content()` is necessary
+ });
+
+ await page.close();
+ await browser.close();
+
+ const { blogList: list } = data;
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, () => ({
+ title: item.title,
+ link: `${rootUrl}/${path}/${item.slug}?lang=${language}`,
+ pubDate: parseDate(item.date),
+ author: item.author,
+ description: item.content, // includes & full text
+ }))
+ )
+ );
+
+ return {
+ title: 'Fortnite News',
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/fortunechina/index.ts b/lib/routes/fortunechina/index.ts
new file mode 100644
index 00000000000000..f5cc29699da667
--- /dev/null
+++ b/lib/routes/fortunechina/index.ts
@@ -0,0 +1,105 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { PRESETS } from '@/utils/header-generator';
+import ofetch from '@/utils/ofetch';
+import { parseDate, parseRelativeDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/fortunechina',
+ parameters: { category: '分类,见下表,默认为首页' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['fortunechina.com/:category', 'fortunechina.com/'],
+ },
+ ],
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 商业 | 领导力 | 科技 | 研究 |
+| ------- | --------- | ---- | ------ |
+| shangye | lindgaoli | keji | report |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? '';
+
+ const rootUrl = 'https://www.fortunechina.com';
+ const currentUrl = `${rootUrl}${category ? `/${category}` : ''}`;
+
+ const response = await ofetch(currentUrl);
+
+ const $ = load(response);
+
+ let items = $('.main')
+ .find(category === '' ? 'a:has(h2)' : 'h2 a')
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 15)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const link = item.attr('href');
+
+ return {
+ title: item.text(),
+ link: link.indexOf('http') === 0 ? link : `${currentUrl}/${item.attr('href')}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await ofetch(item.link, {
+ headerGeneratorOptions: PRESETS.MODERN_IOS,
+ });
+
+ const content = load(detailResponse);
+
+ const spans = content('.date').text();
+ let matches = spans.match(/(\d{4}-\d{2}-\d{2})/);
+ if (matches) {
+ item.pubDate = parseDate(matches[1]);
+ } else {
+ matches = spans.match(/(\d+小时前)/);
+ if (matches) {
+ item.pubDate = parseRelativeDate(matches[1]);
+ }
+ }
+
+ item.author = content('.author').text();
+
+ content('.mod-info, .title, .eval-zan, .eval-pic, .sae-more, .ugo-kol, .word-text .word-box .word-cn').remove();
+
+ item.description = content(item.link.includes('content') ? '.contain .text' : '.contain .top').html();
+ if (item.link.includes('jingxuan')) {
+ item.description += content('.eval-mod_ugo').html();
+ } else if (item.link.includes('events')) {
+ const eventDetails = await ofetch(`https://www.bagevent.com/event/${item.link.match(/\d+/)[0]}`);
+ const $event = load(eventDetails);
+ item.description = $event('.page_con').html();
+ } else if (item.link.includes('zhuanlan')) {
+ item.description += content('.mod-word').html();
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: category ? $('title').text() : '财富中文网',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/fortunechina/namespace.ts b/lib/routes/fortunechina/namespace.ts
new file mode 100644
index 00000000000000..93e60efd774c8d
--- /dev/null
+++ b/lib/routes/fortunechina/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '财富中文网',
+ url: 'fortunechina.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/fosshub/index.ts b/lib/routes/fosshub/index.ts
new file mode 100644
index 00000000000000..bb71f52df4c57a
--- /dev/null
+++ b/lib/routes/fosshub/index.ts
@@ -0,0 +1,70 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/:id',
+ categories: ['program-update'],
+ example: '/fosshub/qBittorrent',
+ parameters: { id: 'Software id, can be found in URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Software Update',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '';
+
+ const rootUrl = 'https://www.fosshub.com';
+ const currentUrl = `${rootUrl}/${id}.html`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ const version = $('dd[itemprop="softwareVersion"]').first().text();
+
+ const items = [
+ {
+ title: version,
+ link: `${currentUrl}#${version}`,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ links: $('.dwn-dl')
+ .toArray()
+ .map((l) =>
+ $(l)
+ .find('.w')
+ .toArray()
+ .map((w) => ({
+ dt: $(w).find('dt').text(),
+ dd: $(w).find('dd').html(),
+ }))
+ ),
+ changelog: $('div[itemprop="releaseNotes"]').html(),
+ }),
+ pubDate: parseDate($('.ma__upd .v').text(), 'MMM DD, YYYY'),
+ },
+ ];
+
+ return {
+ title: `${$('#fh-ssd__hl').text()} - FossHub`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/fosshub/namespace.ts b/lib/routes/fosshub/namespace.ts
new file mode 100644
index 00000000000000..675fda91afcef4
--- /dev/null
+++ b/lib/routes/fosshub/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FossHub',
+ url: 'fosshub.com',
+ lang: 'en',
+};
diff --git a/lib/v2/fosshub/templates/description.art b/lib/routes/fosshub/templates/description.art
similarity index 100%
rename from lib/v2/fosshub/templates/description.art
rename to lib/routes/fosshub/templates/description.art
diff --git a/lib/routes/free/namespace.ts b/lib/routes/free/namespace.ts
new file mode 100644
index 00000000000000..791c2582ee7fc2
--- /dev/null
+++ b/lib/routes/free/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '免費資源網路社群',
+ url: 'free.com.tw',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/free/rss.ts b/lib/routes/free/rss.ts
new file mode 100644
index 00000000000000..70842032e0aefd
--- /dev/null
+++ b/lib/routes/free/rss.ts
@@ -0,0 +1,35 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/',
+ categories: ['blog'],
+ example: '/free',
+ radar: [
+ {
+ source: ['free.com.tw/'],
+ },
+ ],
+ name: '最新文章',
+ maintainers: ['cnkmmk'],
+ handler,
+ url: 'free.com.tw/',
+};
+
+async function handler() {
+ const url = 'https://free.com.tw/';
+ const response = await got(`${url}/wp-json/wp/v2/posts`);
+ const list = response.data;
+ return {
+ title: '免費資源網路社群',
+ link: url,
+ description: '免費資源網路社群 - 全部文章',
+ item: list.map((item) => ({
+ title: item.title.rendered,
+ link: item.link,
+ pubDate: parseDate(item.date_gmt),
+ description: item.content.rendered,
+ })),
+ };
+}
diff --git a/lib/routes/freebuf/index.ts b/lib/routes/freebuf/index.ts
new file mode 100644
index 00000000000000..e3c0b0721d9036
--- /dev/null
+++ b/lib/routes/freebuf/index.ts
@@ -0,0 +1,68 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/articles/:type',
+ categories: ['blog'],
+ example: '/freebuf/articles/web',
+ parameters: { type: '文章类别' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['freebuf.com/articles/:type/*.html', 'freebuf.com/articles/:type'],
+ },
+ ],
+ name: '文章',
+ maintainers: ['trganda'],
+ handler,
+ description: `::: tip
+ Freebuf 的文章页面带有反爬虫机制,所以目前无法获取文章的完整内容。
+:::`,
+};
+
+async function handler(ctx) {
+ const { type = 'web' } = ctx.req.param();
+
+ const fapi = 'https://www.freebuf.com/fapi/frontend/category/list';
+ const baseUrl = 'https://www.freebuf.com';
+ const rssLink = `${baseUrl}/articles/${type}`;
+
+ const options = {
+ headers: {
+ referer: 'https://www.freebuf.com',
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
+ },
+ query: {
+ name: type,
+ page: 1,
+ limit: 20,
+ select: 0,
+ order: 0,
+ type: 'category',
+ },
+ };
+
+ const response = await ofetch(fapi, options);
+
+ const items = response.data.data_list.map((item) => ({
+ title: item.post_title,
+ link: `${baseUrl}${item.url}`,
+ description: item.content,
+ pubDate: parseDate(item.post_date),
+ author: item.nickname,
+ }));
+
+ return {
+ title: `Freebuf ${type}`,
+ link: rssLink,
+ item: items,
+ };
+}
diff --git a/lib/routes/freebuf/namespace.ts b/lib/routes/freebuf/namespace.ts
new file mode 100644
index 00000000000000..510c3219702e91
--- /dev/null
+++ b/lib/routes/freebuf/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FreeBuf',
+ url: 'freebuf.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/freecomputerbooks/index.ts b/lib/routes/freecomputerbooks/index.ts
new file mode 100644
index 00000000000000..96fb249d537f3f
--- /dev/null
+++ b/lib/routes/freecomputerbooks/index.ts
@@ -0,0 +1,115 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const baseURL = 'https://freecomputerbooks.com/';
+
+async function cheerioLoad(url) {
+ return load((await got(url)).data);
+}
+
+export const route: Route = {
+ path: '/:category?',
+ name: 'Book List',
+ url: new URL(baseURL).host,
+ maintainers: ['cubroe'],
+ handler,
+ example: '/freecomputerbooks/compscAlgorithmBooks',
+ parameters: {
+ category: 'A category id., which should be the HTML file name (but **without** the `.html` suffix) in the URL path of a book list page.',
+ },
+ categories: ['reading'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['freecomputerbooks.com/', 'freecomputerbooks.com/index.html'],
+ target: '',
+ },
+ ],
+};
+
+async function handler(ctx) {
+ const categoryId = ctx.req.param('category')?.trim();
+ const requestURL = categoryId ? new URL(`${categoryId}.html`, baseURL).href : baseURL;
+ const $ = await cheerioLoad(requestURL);
+
+ // As observation has shown that each page only has one element of the
+ // class, thus to simplify the processing the text is directly extracted.
+ // Needing more robust processing if some day more such elements show up.
+ const categoryTitle = $('.maintitlebar').text();
+
+ return {
+ title: 'Free Computer Books - ' + categoryTitle,
+ link: requestURL,
+ description: $('title').text(),
+
+ // For a "Selected New Books" page, the element's id. is
+ // `newBooksG`; for an ordinary category page, it's `newBooksL`.
+ item: await Promise.all(
+ $('ul[id^=newBooks] > li')
+ .toArray()
+ .map((elem) => buildPostItem($(elem), categoryTitle, cache))
+ ),
+ };
+}
+
+function buildPostItem(listItem, categoryTitle, cache) {
+ const $ = load(''); // the only use below doesn't care about the content
+
+ const postLink = listItem.find('a:first');
+ const postInfo = listItem.find('p:contains("Post under")');
+ const postItem = {
+ title: postLink.text(),
+ link: new URL(postLink.attr('href'), baseURL).href,
+
+ // Only a "Selected New Books" page has exclicit categorization info.
+ // for posts; an ordinary category page hasn't, then in which case the
+ // category title is used (after all, it's already a *category page*).
+ category: postInfo.length
+ ? postInfo
+ .find('a')
+ .toArray()
+ .map((elem) => $(elem).text())
+ : categoryTitle,
+ };
+
+ return cache.tryGet(postItem.link, () => insertDescriptionInto(postItem));
+}
+
+async function insertDescriptionInto(item) {
+ const $ = await cheerioLoad(item.link);
+
+ // Eliminate all comment nodes to avoid their being selected and rendered in
+ // the final output (I know this is actually unnecessary, but please forgive
+ // my mysophobia).
+ $.root()
+ .find('*')
+ .contents()
+ .filter((_, node) => node.type === 'comment')
+ .remove();
+
+ const imageURL = $('#bookdesc img[title]').attr('src');
+ const metadata = $('#booktitle ul').removeAttr('style');
+ const content = $('#bookdesccontent').removeAttr('id');
+
+ metadata.find('li:contains(Share This)').remove();
+ content.find('img[src$="/hot.gif"]').remove();
+ content.find(':contains(Similar Books)').nextAll().addBack().remove();
+
+ item.description = art(path.join(__dirname, 'templates/desc.art'), { imageURL, metadata, content });
+
+ return item;
+}
diff --git a/lib/routes/freecomputerbooks/namespace.ts b/lib/routes/freecomputerbooks/namespace.ts
new file mode 100644
index 00000000000000..7ad5e07a8ee798
--- /dev/null
+++ b/lib/routes/freecomputerbooks/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Free Computer Books',
+ url: 'freecomputerbooks.com',
+ lang: 'en',
+};
diff --git a/lib/v2/freecomputerbooks/templates/desc.art b/lib/routes/freecomputerbooks/templates/desc.art
similarity index 100%
rename from lib/v2/freecomputerbooks/templates/desc.art
rename to lib/routes/freecomputerbooks/templates/desc.art
diff --git a/lib/routes/freewechat/namespace.ts b/lib/routes/freewechat/namespace.ts
new file mode 100644
index 00000000000000..5a212f8c6647f8
--- /dev/null
+++ b/lib/routes/freewechat/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '自由微信',
+ url: 'freewechat.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/freewechat/profile.ts b/lib/routes/freewechat/profile.ts
new file mode 100644
index 00000000000000..6fb1dbdc261ff2
--- /dev/null
+++ b/lib/routes/freewechat/profile.ts
@@ -0,0 +1,96 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+import { fixArticleContent } from '@/utils/wechat-mp';
+
+const baseUrl = 'https://freewechat.com';
+
+export const route: Route = {
+ path: '/profile/:id',
+ categories: ['new-media'],
+ example: '/freewechat/profile/MzI5NTUxNzk3OA==',
+ parameters: { id: '公众号 ID,可在URL中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['freewechat.com/profile/:id'],
+ },
+ ],
+ name: '公众号',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const url = `${baseUrl}/profile/${id}`;
+ const { data: response } = await got(url, {
+ headers: {
+ 'User-Agent': config.trueUA,
+ },
+ });
+ const $ = load(response);
+ const author = $('h2').text().trim();
+
+ const list = $('.main')
+ .toArray()
+ .slice(0, -1) // last item is a template
+ .map((item) => {
+ item = $(item);
+ const a = item.find('h3 a');
+ return {
+ title: a.text().trim(),
+ author,
+ link: `${baseUrl}${a.attr('href')}`,
+ description: item.find('.preview').text(),
+ category: item.find('.classification').text().trim(),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link, {
+ headers: {
+ Referer: url,
+ 'User-Agent': config.trueUA,
+ },
+ });
+ const $ = load(response.data);
+
+ $('.js_img_placeholder').remove();
+ $('amp-img').each((_, e) => {
+ e = $(e);
+ e.replaceWith(` `);
+ });
+ $('amp-video').each((_, e) => {
+ e = $(e);
+ e.replaceWith(`${e.html()} `);
+ });
+
+ item.description = fixArticleContent($('#js_content'));
+ item.pubDate = timezone(parseDate($('#publish_time').text()), +8);
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('head title').text(),
+ link: url,
+ image: 'https://freewechat.com/favicon.ico',
+ item: items,
+ };
+}
diff --git a/lib/routes/freexcomic/book.ts b/lib/routes/freexcomic/book.ts
new file mode 100644
index 00000000000000..a6d62d83a2e91e
--- /dev/null
+++ b/lib/routes/freexcomic/book.ts
@@ -0,0 +1,88 @@
+import * as cheerio from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+const jjmhw = 'http://www.jjmhw.cc';
+
+const getLatestAddress = () =>
+ cache.tryGet('freexcomic:getLatestAddress', async () => {
+ const portalResponse = await ofetch('https://www.freexcomic.com');
+ const $portal = cheerio.load(portalResponse);
+ const portalUrl = new URL($portal('.alert-btn').attr('href')).href.replace('http:', 'https:');
+
+ const addressList = await ofetch(portalUrl);
+ const $address = cheerio.load(addressList);
+
+ return $address('p.ta-c.mb10 a')
+ .toArray()
+ .map((item) => $address(item).attr('href'));
+ });
+
+const handler = async (ctx) => {
+ const { id } = ctx.req.param();
+ const limit = Number.parseInt(ctx.req.query('limit'), 10) || 10;
+ const addresses = (await getLatestAddress()) as string[];
+ const link = `${addresses[0]}book/${id}`;
+
+ const response = await ofetch(link);
+ const $ = cheerio.load(response);
+
+ const list = $('#detail-list-select > li > a')
+ .toArray()
+ .toReversed()
+ .slice(0, limit)
+ .map((item) => {
+ const $item = $(item);
+ return {
+ title: $item.text(),
+ link: new URL($item.attr('href'), addresses[Math.floor(Math.random() * addresses.length)]).href,
+ guid: new URL($item.attr('href'), jjmhw).href,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = cheerio.load(response);
+
+ const comicpage = $('.comicpage');
+ comicpage.find('img').each((_, ele) => {
+ ele.attribs.src = ele.attribs['data-original'];
+ });
+
+ item.description = comicpage.html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `漫小肆 ${$('div.info > h1').text()}`,
+ link,
+ description: `漫小肆 ${$('div.info .content span span').text()}`,
+ image: $('.banner_detail .cover img').attr('src'),
+ item: items,
+ };
+};
+
+export const route: Route = {
+ path: '/book/:id',
+ example: '/freexcomic/book/90',
+ parameters: { id: '漫画id,漫画主页的地址栏中' },
+ radar: [
+ {
+ source: ['www.jjmhw.cc/book/:id'],
+ },
+ ],
+ name: '漫画更新',
+ maintainers: ['junfengP'],
+ handler,
+ url: 'www.jjmhw.cc',
+ features: {
+ nsfw: true,
+ },
+};
diff --git a/lib/routes/freexcomic/namespace.ts b/lib/routes/freexcomic/namespace.ts
new file mode 100644
index 00000000000000..c4a3b12f8adad6
--- /dev/null
+++ b/lib/routes/freexcomic/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '漫小肆韓漫',
+ url: 'freexcomic.com',
+ categories: ['anime'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/ft/myft.ts b/lib/routes/ft/myft.ts
new file mode 100644
index 00000000000000..236b2b8f53ebfb
--- /dev/null
+++ b/lib/routes/ft/myft.ts
@@ -0,0 +1,77 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import parser from '@/utils/rss-parser';
+
+export const route: Route = {
+ path: '/myft/:key',
+ categories: ['traditional-media'],
+ example: '/ft/myft/rss-key',
+ parameters: { key: 'the last part of myFT personal RSS address' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'myFT personal RSS',
+ maintainers: ['HenryQW'],
+ handler,
+ description: `::: tip
+ - Visit ft.com -> myFT -> Contact Preferences to enable personal RSS feed, see [help.ft.com](https://help.ft.com/faq/email-alerts-and-contact-preferences/what-is-myft-rss-feed/)
+ - Obtain the key from the personal RSS address, it looks like \`12345678-abcd-4036-82db-vdv20db024b8\`
+:::`,
+};
+
+async function handler(ctx) {
+ const ProcessFeed = (content) => {
+ // clean up the article
+ content.find('div.o-share, aside, div.o-ads').remove();
+
+ return content.html();
+ };
+
+ const link = `https://www.ft.com/myft/following/${ctx.req.param('key')}.rss`;
+
+ const feed = await parser.parseURL(link);
+
+ const items = await Promise.all(
+ feed.items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got({
+ method: 'get',
+ url: item.link,
+ headers: {
+ Referer: 'https://www.facebook.com',
+ },
+ });
+
+ const $ = load(response.data);
+
+ item.description = ProcessFeed($('article.js-article__content-body'));
+ item.category = [
+ $('.n-content-tag--with-follow').text(),
+ ...$('.article__right-bottom a.concept-list__concept')
+ .toArray()
+ .map((e) => $(e).text().trim()),
+ ];
+ item.author = $('a.n-content-tag--author')
+ .toArray()
+ .map((e) => ({ name: $(e).text() }));
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `FT.com - myFT`,
+ link,
+ description: `FT.com - myFT`,
+ item: items,
+ };
+}
diff --git a/lib/routes/ft/namespace.ts b/lib/routes/ft/namespace.ts
new file mode 100644
index 00000000000000..6ff6dcc2061669
--- /dev/null
+++ b/lib/routes/ft/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Financial Times',
+ url: 'ft.com',
+ lang: 'en',
+};
diff --git a/lib/routes/ftchinese/channel.ts b/lib/routes/ftchinese/channel.ts
new file mode 100644
index 00000000000000..e2f1a88cdc9ff1
--- /dev/null
+++ b/lib/routes/ftchinese/channel.ts
@@ -0,0 +1,39 @@
+import type { Route } from '@/types';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/:language/:channel?',
+ categories: ['traditional-media'],
+ example: '/ftchinese/simplified/hotstoryby7day',
+ parameters: { language: '语言,简体 `simplified`,繁体 `traditional`', channel: '频道,缺省为每日更新' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'FT 中文网',
+ maintainers: ['HenryQW', 'xyqfer'],
+ handler,
+ description: `::: tip
+ - 不支持付费文章。
+:::
+
+ 通过提取文章全文,以提供比官方源更佳的阅读体验。
+
+ 支持所有频道,频道名称见 [官方频道 RSS](http://www.ftchinese.com/channel/rss.html).
+
+ - 频道为单一路径,如 \`http://www.ftchinese.com/rss/news\` 则为 \`/ftchinese/simplified/news\`.
+ - 频道包含多重路径,如 \`http://www.ftchinese.com/rss/column/007000002\` 则替换 \`/\` 为 \`-\` \`/ftchinese/simplified/column-007000002\`.`,
+};
+
+async function handler(ctx) {
+ return await utils.getData({
+ site: ctx.req.param('language') === 'simplified' ? 'www' : 'big5',
+ channel: ctx.req.param('channel'),
+ ctx,
+ });
+}
diff --git a/lib/routes/ftchinese/namespace.ts b/lib/routes/ftchinese/namespace.ts
new file mode 100644
index 00000000000000..d34685b71111c7
--- /dev/null
+++ b/lib/routes/ftchinese/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FT 中文网',
+ url: 'ftchinese.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/ftchinese/utils.ts b/lib/routes/ftchinese/utils.ts
new file mode 100644
index 00000000000000..9ce5be2469c430
--- /dev/null
+++ b/lib/routes/ftchinese/utils.ts
@@ -0,0 +1,91 @@
+import { load } from 'cheerio';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import parser from '@/utils/rss-parser';
+
+const ProcessFeed = (i, $, link) => {
+ const title = $('h1').text();
+ let content = $('div.story-container').eq(i);
+
+ // 处理封面图片
+ content.find('div.story-image > figure').each((_, e) => {
+ const src = `https://thumbor.ftacademy.cn/unsafe/1340x754/${e.attribs['data-url']}`;
+
+ $(` `).insertAfter(content.find('div.story-lead')[0]);
+ });
+
+ // 付费文章跳转
+ content.find('div#subscribe-now-container').each((_, e) => {
+ $(`此文章为付费文章,会员请访问网站阅读 。
`).insertAfter(content.find('div.story-body')[0]);
+ $(e).remove();
+ });
+
+ // 获取作者
+ let author = '';
+ content.find('span.story-author > a').each((_, e) => {
+ author += `${$(e).text()} `;
+ });
+ author = author.trim();
+
+ // 去除头部主题, 头部重复标题, 冗余元数据, 植入广告, 植入 js, 社交分享按钮, 底部版权声明, 空白 DOM
+ content
+ .find(
+ 'div.story-theme, h1.story-headline, div.story-byline, div.mpu-container-instory,script, div#story-action-placeholder, div.copyrightstatement-container, div.clearfloat, div.o-ads, h2.list-title, div.allcomments, div.logincomment, div.nologincomment'
+ )
+ .each((_, e) => {
+ $(e).remove();
+ });
+ content = content.html();
+
+ return { content, author, title };
+};
+
+const getData = async ({ site = 'www', channel }) => {
+ let feed;
+
+ if (channel) {
+ channel = channel.toLowerCase();
+ channel = channel.split('-').join('/');
+
+ try {
+ feed = await parser.parseURL(`https://${site}.ftchinese.com/rss/${channel}`);
+ } catch {
+ return {
+ title: `FT 中文网 ${channel} 不存在`,
+ description: `FT 中文网 ${channel} 不存在`,
+ };
+ }
+ } else {
+ feed = await parser.parseURL(`https://${site}.ftchinese.com/rss/feed`);
+ }
+
+ const items = await Promise.all(
+ feed.items.map((item) => {
+ item.link = item.link.replace('http://', 'https://');
+ return cache.tryGet(item.link, async () => {
+ const response = await got.get(`${item.link}?full=y&archive`);
+
+ const $ = load(response.data);
+ const results = [];
+ for (let i = 0; i < $('div.story-container').length; i++) {
+ results.push(ProcessFeed(i, $, item.link));
+ }
+
+ item.title = results[0].title;
+ item.description = results.map((result) => result.content).join('');
+ item.author = results[0].author;
+ return item;
+ });
+ })
+ );
+
+ return {
+ title: feed.title,
+ link: feed.link,
+ description: feed.description,
+ item: items,
+ };
+};
+
+export default { getData };
diff --git a/lib/routes/ftm/index.ts b/lib/routes/ftm/index.ts
new file mode 100644
index 00000000000000..408131eb117bee
--- /dev/null
+++ b/lib/routes/ftm/index.ts
@@ -0,0 +1,60 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/',
+ categories: ['new-media'],
+ example: '/ftm',
+ parameters: {},
+ name: '文章',
+ maintainers: ['dzx-dzx'],
+ radar: [
+ {
+ source: ['www.ftm.eu'],
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://www.ftm.eu';
+ const currentUrl = `${rootUrl}/articles`;
+ const response = await ofetch(currentUrl);
+
+ const $ = load(response);
+
+ const list = $('.article-card')
+ .toArray()
+ .map((e) => ({ link: $(e).attr('href'), title: $(e).find('h2').text() }))
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : Infinity);
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const content = load(await ofetch(item.link));
+ const ldjson = JSON.parse(content('[type="application/ld+json"]:not([data-schema])').text());
+
+ item.pubDate = parseDate(ldjson.datePublished);
+ item.updated = parseDate(ldjson.dateModified);
+
+ item.author = content("[name='author']")
+ .toArray()
+ .map((e) => ({ name: $(e).attr('content') }));
+ item.category = content('.collection .tab').text().trim() || null;
+
+ item.description = content('.body').html();
+
+ return item;
+ })
+ )
+ );
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/ftm/namespace.ts b/lib/routes/ftm/namespace.ts
new file mode 100644
index 00000000000000..681a18f315353c
--- /dev/null
+++ b/lib/routes/ftm/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Follow The Money',
+ url: 'www.ftm.eu',
+ lang: 'en',
+};
diff --git a/lib/routes/fuliba/latest.ts b/lib/routes/fuliba/latest.ts
new file mode 100644
index 00000000000000..de117786aa2640
--- /dev/null
+++ b/lib/routes/fuliba/latest.ts
@@ -0,0 +1,50 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/latest',
+ categories: ['new-media'],
+ example: '/fuliba/latest',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['fuliba2023.net/'],
+ },
+ ],
+ name: '最新',
+ maintainers: ['shinemoon'],
+ handler,
+ url: 'fuliba2023.net/',
+};
+
+async function handler(ctx) {
+ const { data: response } = await got(`https://fuliba2023.net/wp-json/wp/v2/posts`, {
+ searchParams: {
+ per_page: ctx.req.query('limit') ?? 100,
+ _embed: 1,
+ },
+ });
+ const items = response.map((item) => ({
+ title: item.title.rendered,
+ link: item.link,
+ guid: item.guid.rendered,
+ description: item.content.rendered,
+ pubDate: parseDate(item.date_gmt),
+ author: item._embedded.author[0].name,
+ }));
+
+ return {
+ title: '福利吧',
+ link: `https://fuliba2023.net`,
+ item: items,
+ };
+}
diff --git a/lib/routes/fuliba/namespace.ts b/lib/routes/fuliba/namespace.ts
new file mode 100644
index 00000000000000..6140eac4294653
--- /dev/null
+++ b/lib/routes/fuliba/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '福利吧',
+ url: 'fuliba2023.net',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/furaffinity/art.ts b/lib/routes/furaffinity/art.ts
new file mode 100644
index 00000000000000..31c6b2d9894594
--- /dev/null
+++ b/lib/routes/furaffinity/art.ts
@@ -0,0 +1,86 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/art/:folder/:username/:mode?',
+ name: 'Gallery',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/art/gallery/fender/nsfw',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: {
+ username: 'Username, can find in userpage',
+ folder: 'Image folders, options are gallery, scraps, favorites',
+ mode: 'R18 content toggle, default value is sfw, options are sfw, nsfw',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net/gallery/:username'],
+ target: '/gallery/:username',
+ },
+ {
+ source: ['furaffinity.net/scraps/:username'],
+ target: '/scraps/:username',
+ },
+ {
+ source: ['furaffinity.net/favorites/:username'],
+ target: '/favorites/:username',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { username, folder = 'gallery', mode = 'sfw' } = ctx.req.param();
+ let url = `https://faexport.spangle.org.uk/user/${username}/${folder}.json?sfw=1&full=1`;
+ if (mode === 'nsfw') {
+ url = `https://faexport.spangle.org.uk/user/${username}/${folder}.json?full=1`;
+ }
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ let folderName;
+
+ switch (folder) {
+ case 'gallery':
+ folderName = 'Gallery';
+ break;
+ case 'scraps':
+ folderName = 'Scraps';
+ break;
+ case 'favorites':
+ folderName = 'Favorites';
+ break;
+ default:
+ folderName = 'Gallery';
+ }
+ const items = data.map((item) => ({
+ title: item.title,
+ link: item.link,
+ guid: item.id,
+ description: ` `,
+ // 由于源API未提供日期,故无pubDate
+ author: item.name,
+ }));
+
+ return {
+ allowEmpty: true,
+ title: `Fur Affinity | ${folderName} of ${username}`,
+ link: `https://www.furaffinity.net/${folder}/${username}`,
+ description: `Fur Affinity ${folderName} of ${username}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/browse.js b/lib/routes/furaffinity/browse.js
deleted file mode 100644
index 446498b8cdc406..00000000000000
--- a/lib/routes/furaffinity/browse.js
+++ /dev/null
@@ -1,44 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 传入参数
- const nsfw = String(ctx.params.nsfw);
-
- // 判断传入的参数nsfw
- let url = 'https://faexport.spangle.org.uk/browse.json?sfw=1';
- if (nsfw === '1') {
- url = 'https://faexport.spangle.org.uk/browse.json';
- }
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- const data = response.data;
-
- ctx.state.data = {
- // 源标题
- title: `Fur Affinity Browse`,
- // 源链接
- link: `https://www.furaffinity.net/browse/`,
- // 源说明
- description: `Fur Affinity Browse`,
-
- // 遍历此前获取的数据
- item: data.map((item) => ({
- // 标题
- title: item.title,
- // 正文
- description: ` `,
- // 链接
- link: item.link,
- // 作者
- author: item.name,
- // 由于源API未提供日期,故无pubDate
- })),
- };
-};
diff --git a/lib/routes/furaffinity/browse.ts b/lib/routes/furaffinity/browse.ts
new file mode 100644
index 00000000000000..8ab993012802ec
--- /dev/null
+++ b/lib/routes/furaffinity/browse.ts
@@ -0,0 +1,59 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/browse/:mode?',
+ name: 'Browse',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/browse/nsfw',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: { mode: 'R18 content toggle, default value is sfw, options are sfw, nsfw' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net'],
+ target: '/browse',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { mode = 'sfw' } = ctx.req.param();
+ let url = 'https://faexport.spangle.org.uk/browse.json?sfw=1';
+ if (mode === 'nsfw') {
+ url = 'https://faexport.spangle.org.uk/browse.json';
+ }
+
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const items = data.map((item) => ({
+ title: item.title,
+ link: item.link,
+ guid: item.id,
+ description: ` `,
+ // 由于源API未提供日期,故无pubDate
+ author: item.name,
+ }));
+
+ return {
+ title: 'Fur Affinity | Browse',
+ link: 'https://www.furaffinity.net/browse/',
+ description: `Fur Affinity Browsing Artwork`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/commissions.js b/lib/routes/furaffinity/commissions.js
deleted file mode 100644
index c2a21eecc88029..00000000000000
--- a/lib/routes/furaffinity/commissions.js
+++ /dev/null
@@ -1,39 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 传入参数
- const username = String(ctx.params.username);
-
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/user/${username}/commissions.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- const data = response.data;
-
- ctx.state.data = {
- // 源标题
- title: `${username}'s Commissions`,
- // 源链接
- link: `https://www.furaffinity.net/commissions/${username}/`,
- // 源说明
- description: `Fur Affinity ${username}'s Commissions`,
-
- // 遍历此前获取的数据
- item: data.map((item) => ({
- // 标题
- title: item.title,
- // 正文
- description: `${item.description} `,
- // 链接
- link: item.submission.link,
- // 作者
- author: username,
- // 由于源API未提供日期,故无pubDate
- })),
- };
-};
diff --git a/lib/routes/furaffinity/commissions.ts b/lib/routes/furaffinity/commissions.ts
new file mode 100644
index 00000000000000..b2f11db93f1fec
--- /dev/null
+++ b/lib/routes/furaffinity/commissions.ts
@@ -0,0 +1,56 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/commissions/:username',
+ name: 'Commissions',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/commissions/fender',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: { username: 'Username, can find in userpage' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net/commissions/:username'],
+ target: '/commissions/:username',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { username } = ctx.req.param();
+ const url = `https://faexport.spangle.org.uk/user/${username}/commissions.json?full=1`;
+
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const items = data.map((item) => ({
+ title: item.title,
+ link: item.submission.link,
+ guid: item.submission.id,
+ description: `${item.description} `,
+ author: username,
+ }));
+
+ return {
+ allowEmpty: true,
+ title: `Fur Affinity | ${username}'s Commissions`,
+ link: `https://www.furaffinity.net/commissions/${username}`,
+ description: `Fur Affinity ${username}'s Commissions`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/favorites.js b/lib/routes/furaffinity/favorites.js
deleted file mode 100644
index 0219886e5e7fb2..00000000000000
--- a/lib/routes/furaffinity/favorites.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- // 传入参数
- const nsfw = String(ctx.params.nsfw);
- const username = String(ctx.params.username);
-
- // 添加参数username以及判断传入的参数nsfw
- let url = `https://faexport.spangle.org.uk/user/${username}/favorites.rss?sfw=1`;
- if (nsfw === '1') {
- url = `https://faexport.spangle.org.uk/user/${username}/favorites.rss`;
- }
-
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- // 使用 cheerio 加载返回的 HTML
-
- const data = response.data;
- const $ = cheerio.load(data, {
- xmlMode: true,
- });
-
- const list = $('item');
-
- ctx.state.data = {
- // 源标题
- title: `${username}'s Favorites`,
- // 源链接
- link: `https://www.furaffinity.net/favorites/${username}/`,
- // 源说明
- description: `Fur Affinity ${username}'s Favorites`,
-
- // 遍历此前获取的数据
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- return {
- title: item.find('title').text(),
- description: item.find('description').text(),
- link: item.find('link').text(),
- pubDate: new Date(item.find('pubDate').text()).toUTCString(),
- };
- })
- .get(),
- };
-};
diff --git a/lib/routes/furaffinity/gallery.js b/lib/routes/furaffinity/gallery.js
deleted file mode 100644
index 57cc86f2ddb980..00000000000000
--- a/lib/routes/furaffinity/gallery.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- // 传入参数
- const nsfw = String(ctx.params.nsfw);
- const username = String(ctx.params.username);
-
- // 添加参数username以及判断传入的参数nsfw
- let url = `https://faexport.spangle.org.uk/user/${username}/gallery.rss?sfw=1`;
- if (nsfw === '1') {
- url = `https://faexport.spangle.org.uk/user/${username}/gallery.rss`;
- }
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- // 使用 cheerio 加载返回的 HTML
-
- const data = response.data;
- const $ = cheerio.load(data, {
- xmlMode: true,
- });
-
- const list = $('item');
-
- ctx.state.data = {
- // 源标题
- title: `${username}'s Gallery`,
- // 源链接
- link: `https://www.furaffinity.net/gallery/${username}/`,
- // 源说明
- description: `Fur Affinity ${username}'s Gallery`,
-
- // 遍历此前获取的数据
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- return {
- title: item.find('title').text(),
- description: item.find('description').text(),
- link: item.find('link').text(),
- pubDate: new Date(item.find('pubDate').text()).toUTCString(),
- author: username,
- };
- })
- .get(),
- };
-};
diff --git a/lib/routes/furaffinity/home.js b/lib/routes/furaffinity/home.js
deleted file mode 100644
index 62bfe7e45b0ed3..00000000000000
--- a/lib/routes/furaffinity/home.js
+++ /dev/null
@@ -1,59 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 传入参数
- const type = String(ctx.params.type);
- const nsfw = String(ctx.params.nsfw);
-
- // 判断传入的参数nsfw
- let url = 'https://faexport.spangle.org.uk/home.json?sfw=1';
- if (nsfw === '1' || type === '1') {
- url = 'https://faexport.spangle.org.uk/home.json';
- }
-
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- let data = response.data;
-
- // 判断传入的参数type,分别为:artwork、crafts、music、writing
- if (type === 'artwork') {
- data = data.artwork;
- } else if (type === 'crafts') {
- data = data.crafts;
- } else if (type === 'music') {
- data = data.music;
- } else if (type === 'writing') {
- data = data.writing;
- } else {
- data = data.artwork;
- }
-
- ctx.state.data = {
- // 源标题
- title: `Fur Affinity Home`,
- // 源链接
- link: `https://www.furaffinity.net/`,
- // 源说明
- description: `Fur Affinity Home`,
-
- // 遍历此前获取的数据
- item: data.map((item) => ({
- // 标题
- title: item.title,
- // 正文
- description: ` `,
- // 链接
- link: item.link,
- // 作者
- author: item.name,
- // 由于源API未提供日期,故无pubDate
- })),
- };
-};
diff --git a/lib/routes/furaffinity/home.ts b/lib/routes/furaffinity/home.ts
new file mode 100644
index 00000000000000..63cc0fab75bb21
--- /dev/null
+++ b/lib/routes/furaffinity/home.ts
@@ -0,0 +1,81 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/home/:category/:mode?',
+ name: 'Home',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/home/nsfw',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: {
+ category: 'Category, default value is artwork, options are artwork, writing, music, crafts',
+ mode: 'R18 content toggle, default value is sfw, options are sfw, nsfw',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net'],
+ target: '/',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { category = 'artwork', mode = 'sfw' } = ctx.req.param();
+ let url = 'https://faexport.spangle.org.uk/home.json?sfw=1';
+ if (mode === 'nsfw') {
+ url = 'https://faexport.spangle.org.uk/home.json';
+ }
+
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ let dataSelect;
+
+ switch (category) {
+ case 'artwork':
+ dataSelect = data.artwork;
+ break;
+ case 'writing':
+ dataSelect = data.writing;
+ break;
+ case 'music':
+ dataSelect = data.music;
+ break;
+ case 'crafts':
+ dataSelect = data.crafts;
+ break;
+ default:
+ dataSelect = data.artwork;
+ }
+
+ const items = dataSelect.map((item) => ({
+ title: item.title,
+ link: item.link,
+ guid: item.id,
+ description: ` `,
+ // 由于源API未提供日期,故无pubDate
+ author: item.name,
+ }));
+
+ return {
+ title: 'Fur Affinity | Home',
+ link: 'https://www.furaffinity.net/',
+ description: `Fur Affinity Index`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/journal-comments.ts b/lib/routes/furaffinity/journal-comments.ts
new file mode 100644
index 00000000000000..0fd40d71bbee01
--- /dev/null
+++ b/lib/routes/furaffinity/journal-comments.ts
@@ -0,0 +1,65 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/journal-comments/:id',
+ name: 'Journal Comments',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/journal-comments/10925112',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: { id: 'Journal ID' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net/journal/:id'],
+ target: '/journal-comments/:id',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { id } = ctx.req.param();
+ const urlJournal = `https://faexport.spangle.org.uk/journal/${id}.json`;
+ const urlComments = `https://faexport.spangle.org.uk/journal/${id}/comments.json`;
+
+ const dataJournal = await ofetch(urlJournal, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const dataComments = await ofetch(urlComments, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const items = dataComments.map((item) => ({
+ title: item.text,
+ link: `https://www.furaffinity.net/journal/${id}`,
+ guid: item.id,
+ description: ` ${item.name}: ${item.text}`,
+ pubDate: new Date(item.posted_at).toUTCString(),
+ author: item.name,
+ }));
+
+ return {
+ allowEmpty: true,
+ title: `${dataJournal.title} - ${dataJournal.name} | Journal Comments`,
+ link: `https://www.furaffinity.net/journal/${id}`,
+ description: `Fur Affinity | ${dataJournal.title} by ${dataJournal.name} - Journal Comments`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/journal_comments.js b/lib/routes/furaffinity/journal_comments.js
deleted file mode 100644
index 3a86d239e42e14..00000000000000
--- a/lib/routes/furaffinity/journal_comments.js
+++ /dev/null
@@ -1,50 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 传入参数
- const id = String(ctx.params.id);
-
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/journal/${id}/comments.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- // 发起第二个 HTTP GET 请求,用于获取该日记的标题
- const response2 = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/journal/${id}.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- const data = response.data;
- const data2 = response2.data;
-
- ctx.state.data = {
- // 源标题
- title: `${data2.title} - Journal Comments`,
- // 源链接
- link: `https://www.furaffinity.net/journal/${id}/`,
- // 源说明
- description: `Fur Affinity ${data2.title} - Journal Comments`,
-
- // 遍历此前获取的数据
- item: data.map((item) => ({
- // 标题
- title: item.text,
- // 正文
- description: ` ${item.name}: ${item.text}`,
- // 链接
- link: `https://www.furaffinity.net/journal/${id}/`,
- // 作者
- author: item.name,
- // 日期
- pubDate: new Date(item.posted_at).toUTCString(),
- })),
- };
-};
diff --git a/lib/routes/furaffinity/journals.js b/lib/routes/furaffinity/journals.js
deleted file mode 100644
index 74862de0f87b1b..00000000000000
--- a/lib/routes/furaffinity/journals.js
+++ /dev/null
@@ -1,49 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- // 传入参数
- const username = String(ctx.params.username);
-
- // 添加参数username 和 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/user/${username}/journals.rss`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- // 使用 cheerio 加载返回的 HTML
- const data = response.data;
- const $ = cheerio.load(data, {
- xmlMode: true,
- });
-
- const list = $('item');
-
- ctx.state.data = {
- // 源标题
- title: `${username}'s Journals`,
- // 源链接
- link: `https://www.furaffinity.net/journals/${username}/`,
- // 源说明
- description: `Fur Affinity ${username}'s Journals`,
-
- // 遍历此前获取的数据
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- return {
- title: item.find('title').text(),
- description: item.find('description').text(),
- link: item.find('link').text(),
- pubDate: new Date(item.find('pubDate').text()).toUTCString(),
- author: username,
- };
- })
- .get(),
- };
-};
diff --git a/lib/routes/furaffinity/journals.ts b/lib/routes/furaffinity/journals.ts
new file mode 100644
index 00000000000000..2dea98ac15fa81
--- /dev/null
+++ b/lib/routes/furaffinity/journals.ts
@@ -0,0 +1,57 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/journals/:username',
+ name: 'Journals',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/journals/fender',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: { username: 'Username, can find in userpage' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net/journals/:username'],
+ target: '/journals/:username',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { username } = ctx.req.param();
+ const url = `https://faexport.spangle.org.uk/user/${username}/journals.json?full=1`;
+
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const items = data.map((item) => ({
+ title: item.title,
+ link: item.link,
+ guid: item.id,
+ description: item.description,
+ pubDate: new Date(item.posted_at).toUTCString(),
+ author: username,
+ }));
+
+ return {
+ allowEmpty: true,
+ title: `Fur Affinity | ${username}'s Journals`,
+ link: `https://www.furaffinity.net/journals/${username}`,
+ description: `Fur Affinity ${username}'s Journals`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/namespace.ts b/lib/routes/furaffinity/namespace.ts
new file mode 100644
index 00000000000000..186165cb463b55
--- /dev/null
+++ b/lib/routes/furaffinity/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Furaffinity',
+ url: 'furaffinity.net',
+ lang: 'en',
+};
diff --git a/lib/routes/furaffinity/scraps.js b/lib/routes/furaffinity/scraps.js
deleted file mode 100644
index 0acb53fcd35875..00000000000000
--- a/lib/routes/furaffinity/scraps.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- // 传入参数
- const nsfw = String(ctx.params.nsfw);
- const username = String(ctx.params.username);
-
- // 添加参数username以及判断传入的参数nsfw
- let url = `https://faexport.spangle.org.uk/user/${username}/scraps.rss?sfw=1`;
- if (nsfw === '1') {
- url = `https://faexport.spangle.org.uk/user/${username}/scraps.rss`;
- }
-
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- // 使用 cheerio 加载返回的 HTML
- const data = response.data;
- const $ = cheerio.load(data, {
- xmlMode: true,
- });
-
- const list = $('item');
-
- ctx.state.data = {
- // 源标题
- title: `${username}'s Scraps`,
- // 源链接
- link: `https://www.furaffinity.net/scraps/${username}/`,
- // 源说明
- description: `Fur Affinity ${username}'s Scraps`,
-
- // 遍历此前获取的数据
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- return {
- title: item.find('title').text(),
- description: item.find('description').text(),
- link: item.find('link').text(),
- pubDate: new Date(item.find('pubDate').text()).toUTCString(),
- author: username,
- };
- })
- .get(),
- };
-};
diff --git a/lib/routes/furaffinity/search.js b/lib/routes/furaffinity/search.js
deleted file mode 100644
index 90066bd324a6a0..00000000000000
--- a/lib/routes/furaffinity/search.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- // 传入参数
- const nsfw = String(ctx.params.nsfw);
- const keyword = String(ctx.params.keyword);
-
- // 添加参数keyword以及判断传入的参数nsfw
- let url = `https://faexport.spangle.org.uk/search.rss?q=${keyword}&sfw=1`;
- if (nsfw === '1') {
- url = `https://faexport.spangle.org.uk/search.rss?q=${keyword}`;
- }
-
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- // 使用 cheerio 加载返回的 HTML
- const data = response.data;
- const $ = cheerio.load(data, {
- xmlMode: true,
- });
-
- const list = $('item');
-
- ctx.state.data = {
- // 源标题
- title: `FA Search for ${keyword}`,
- // 源链接
- link: `https://www.furaffinity.net/search/?q=${keyword}`,
- // 源说明
- description: `Fur Affinity Search for ${keyword}`,
-
- // 遍历此前获取的数据
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- return {
- title: item.find('title').text(),
- description: item.find('description').text(),
- link: item.find('link').text(),
- pubDate: new Date(item.find('pubDate').text()).toUTCString(),
- // 由于源API未提供作者信息,故无author
- };
- })
- .get(),
- };
-};
diff --git a/lib/routes/furaffinity/search.ts b/lib/routes/furaffinity/search.ts
new file mode 100644
index 00000000000000..2fabc24d97ef70
--- /dev/null
+++ b/lib/routes/furaffinity/search.ts
@@ -0,0 +1,73 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/search/:query/:mode?/:routeParams?',
+ name: 'Search',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/search/protogen/nsfw',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: {
+ query: 'Query value',
+ mode: 'R18 content toggle, default value is sfw, options are sfw, nsfw',
+ routeParams: 'Additional search parameters',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net'],
+ target: '/search',
+ },
+ ],
+ handler,
+ description: `Additional search parameters
+| Parameter | Description | Default | Options |
+|-----------------|----------------------|-----------|----------------------------------------------------------------|
+| order_by | Sort by | relevancy | relevancy, date, popularity |
+| order_direction | Sort order | desc | desc, asc |
+| range | Date range | all | all, 1day, 3days, 7days, 30days, 90days, 1year, 3years, 5years |
+| pattern | Query match pattern | extended | all, any, extended |
+| type | Category of artworks | all | art, flash, photo, music, story, poetry |
+`,
+};
+
+async function handler(ctx) {
+ const { query, mode = 'sfw', routeParams = 'order_by=relevancy' } = ctx.req.param();
+ let url = `https://faexport.spangle.org.uk/search.json?sfw=1&full=1&q=${query}&${routeParams}`;
+ if (mode === 'nsfw') {
+ url = `https://faexport.spangle.org.uk/search.json?full=1&q=${query}&${routeParams}`;
+ }
+
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const items = data.map((item) => ({
+ title: item.title,
+ link: item.link,
+ guid: item.id,
+ description: ` `,
+ // 由于源API未提供日期,故无pubDate
+ author: item.name,
+ }));
+
+ return {
+ allowEmpty: true,
+ title: 'Fur Affinity | Search',
+ link: `https://www.furaffinity.net/Search/?q=${query}`,
+ description: `Fur Affinity Search`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/shouts.js b/lib/routes/furaffinity/shouts.js
deleted file mode 100644
index ca3685c029ebc7..00000000000000
--- a/lib/routes/furaffinity/shouts.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 传入参数
- const username = String(ctx.params.username);
-
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/user/${username}/shouts.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- const data = response.data;
-
- ctx.state.data = {
- // 源标题
- title: `${username}'s Shouts`,
- // 源链接
- link: `https://www.furaffinity.net/user/${username}/`,
- // 源说明
- description: `Fur Affinity ${username}'s Shouts`,
-
- // 遍历此前获取的数据
- item: data.map((item) => ({
- // 标题
- title: item.text,
- // 正文
- description: ` ${item.name}: ${item.text} `,
- // 链接
- link: `https://www.furaffinity.net/user/${username}/`,
- // 作者
- author: item.name,
- // 日期
- pubDate: new Date(item.posted_at).toUTCString(),
- })),
- };
-};
diff --git a/lib/routes/furaffinity/shouts.ts b/lib/routes/furaffinity/shouts.ts
new file mode 100644
index 00000000000000..043055bc7a7ce1
--- /dev/null
+++ b/lib/routes/furaffinity/shouts.ts
@@ -0,0 +1,57 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/shouts/:username',
+ name: 'Shouts',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/shouts/fender',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: { username: 'Username, can find in userpage' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net/user/:username'],
+ target: '/shouts/:username',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { username } = ctx.req.param();
+ const url = `https://faexport.spangle.org.uk/user/${username}/shouts.json?full=1`;
+
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const items = data.map((item) => ({
+ title: `${item.name} shout at ${username}`,
+ link: `https://www.furaffinity.net/user/${username}`,
+ guid: item.id,
+ description: ` ${item.name}: ${item.text}`,
+ pubDate: new Date(item.posted_at).toUTCString(),
+ author: username,
+ }));
+
+ return {
+ allowEmpty: true,
+ title: `Fur Affinity | ${username}'s Shouts`,
+ link: `https://www.furaffinity.net/user/${username}`,
+ description: `Fur Affinity ${username}'s Shouts`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/status.js b/lib/routes/furaffinity/status.js
deleted file mode 100644
index 5feee9acba0530..00000000000000
--- a/lib/routes/furaffinity/status.js
+++ /dev/null
@@ -1,39 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/status.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- const data = response.data;
- const status = data.online;
-
- let description = '';
- if (Object.keys(data)[0] === 'online') {
- description = `Status: ${Object.keys(data)[0]} Guests: ${status.guests} Registered: ${status.registered} Other: ${status.other} Total: ${status.total} Fa Server Time: ${data.fa_server_time}`;
- } else {
- description = 'offline';
- }
- const item = [];
- item.push({
- title: `Status:${Object.keys(data)[0]}`,
- description,
- link: `https://www.furaffinity.net/`,
- });
-
- ctx.state.data = {
- // 源标题
- title: `Fur Affinity Status`,
- // 源链接
- link: `https://www.furaffinity.net/`,
- // 源说明
- description: `Fur Affinity Status`,
-
- item,
- };
-};
diff --git a/lib/routes/furaffinity/status.ts b/lib/routes/furaffinity/status.ts
new file mode 100644
index 00000000000000..e9dc5041fc65da
--- /dev/null
+++ b/lib/routes/furaffinity/status.ts
@@ -0,0 +1,59 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/status',
+ name: 'Status',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/status',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net'],
+ target: '/',
+ },
+ ],
+ handler,
+};
+
+async function handler() {
+ const url = 'https://faexport.spangle.org.uk/status.json';
+
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const description =
+ Object.keys(data)[0] === 'online'
+ ? `Status: FA Server Online Guests: ${data.online.guests} Registered: ${data.online.registered} Other: ${data.online.other} Total: ${data.online.total} FA Server Time: ${data.fa_server_time} FA Server Time at: ${data.fa_server_time_at}`
+ : 'FA Server Offline';
+
+ const items: { title: string; link: string; description: string }[] = [
+ {
+ title: `Status: ${Object.keys(data)[0]}`,
+ link: 'https://www.furaffinity.net/',
+ description,
+ },
+ ];
+
+ return {
+ title: 'Fur Affinity | Status',
+ link: 'https://www.furaffinity.net/',
+ description: `Fur Affinity Status`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/submission-comments.ts b/lib/routes/furaffinity/submission-comments.ts
new file mode 100644
index 00000000000000..985a6286423b98
--- /dev/null
+++ b/lib/routes/furaffinity/submission-comments.ts
@@ -0,0 +1,65 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/submission-comments/:id',
+ name: 'Submission Comments',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/submission-comments/24259751',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: { id: 'Submission ID' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net/view/:id'],
+ target: '/submission-comments/:id',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { id } = ctx.req.param();
+ const urlSubmission = `https://faexport.spangle.org.uk/submission/${id}.json`;
+ const urlComments = `https://faexport.spangle.org.uk/submission/${id}/comments.json`;
+
+ const dataSubmission = await ofetch(urlSubmission, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const dataComments = await ofetch(urlComments, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const items = dataComments.map((item) => ({
+ title: item.text,
+ link: `https://www.furaffinity.net/view/${id}`,
+ guid: item.id,
+ description: ` ${item.name}: ${item.text}`,
+ pubDate: new Date(item.posted_at).toUTCString(),
+ author: item.name,
+ }));
+
+ return {
+ allowEmpty: true,
+ title: `${dataSubmission.title} - ${dataSubmission.name} | Submission Comments`,
+ link: `https://www.furaffinity.net/view/${id}`,
+ description: `Fur Affinity | ${dataSubmission.title} by ${dataSubmission.name} - Submission Comments`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/submission_comments.js b/lib/routes/furaffinity/submission_comments.js
deleted file mode 100644
index e08c5ba26ec018..00000000000000
--- a/lib/routes/furaffinity/submission_comments.js
+++ /dev/null
@@ -1,50 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 传入参数
- const id = String(ctx.params.id);
-
- // 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/submission/${id}/comments.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- // 发起第二个 HTTP GET 请求,用于获取该作品的标题
- const response2 = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/submission/${id}.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- const data = response.data;
- const data2 = response2.data;
-
- ctx.state.data = {
- // 源标题
- title: `${data2.title} - Submission Comments`,
- // 源链接
- link: `https://www.furaffinity.net/view/${id}/`,
- // 源说明
- description: `Fur Affinity ${data2.title} - Submission Comments`,
-
- // 遍历此前获取的数据
- item: data.map((item) => ({
- // 标题
- title: item.text,
- // 正文
- description: ` ${item.name}: ${item.text}`,
- // 链接
- link: `https://www.furaffinity.net/view/${id}/`,
- // 作者
- author: item.name,
- // 日期
- pubDate: new Date(item.posted_at).toUTCString(),
- })),
- };
-};
diff --git a/lib/routes/furaffinity/user.js b/lib/routes/furaffinity/user.js
deleted file mode 100644
index 63dde063e8fa70..00000000000000
--- a/lib/routes/furaffinity/user.js
+++ /dev/null
@@ -1,96 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 传入参数
- const username = String(ctx.params.username);
-
- // 添加参数username 和 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/user/${username}.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- const data = response.data;
-
- // 收集传入的数据
- const name = data.name;
- const profile = data.profile;
- const account_type = data.profile;
- const avatar = ` `;
- const full_name = data.full_name;
- const artist_type = data.artist_type;
- const user_title = data.user_title;
- const registered_since = data.registered_since;
- const current_mood = data.current_mood;
- const artist_profile = data.artist_profile;
- const pageviews = data.pageviews;
- const submissions = data.submissions;
- const comments_received = data.comments_received;
- const comments_given = data.comments_given;
- const journals = data.journals;
- const favorite = data.favorites;
- const watchers_count = data.watchers.count;
- const watching_count = data.watching.count;
-
- const artist_information = data.artist_information;
- const species = artist_information.Species;
- const personal_quote = artist_information['Personal Quote'];
- const music_type_genre = artist_information['Music Type/Genre'];
- const favorites_movie = artist_information['Favorite Movie'];
- const favorites_game = artist_information['Favorite Game'];
- const favorites_game_platform = artist_information['Favorite Game Platform'];
- const favorites_artist = artist_information['Favorite Artist'];
- const favorites_animal = artist_information['Favorite Animal'];
- const favorites_website = artist_information['Favorite Website'];
- const favorites_food = artist_information['Favorite Food'];
-
- const contact_information = data.contact_information;
- let contact_result = 'none ';
-
- // 对一个或多个用户联系方式进行遍历
- if (contact_information) {
- contact_result = '';
- for (let i = 0; i < contact_information.length; i++) {
- for (const j in contact_information[i]) {
- if (j === 'title') {
- contact_result += `Title: ${contact_information[i][j]} `;
- } else if (j === 'name') {
- contact_result += `Name: ${contact_information[i][j]} `;
- } else if (j === 'link') {
- contact_result += `Link: ${contact_information[i][j]} `;
- }
- }
- contact_result += ` `;
- }
- }
-
- const description = `Name: ${name} Profile: ${profile} Account Type: ${account_type}
- Avatar: ${avatar} Full Name: ${full_name} Artist Type: ${artist_type} User Title: ${user_title}
- Registered Since: ${registered_since} Current Mood: ${current_mood} Artist Profile: ${artist_profile}
- Pageviews: ${pageviews} Submissions: ${submissions} Comments_Received: ${comments_received} Comments Given: ${comments_given}
- Journals: ${journals} Favorite: ${favorite} Artist Information: Species: ${species} Personal Quote: ${personal_quote} Music Type/Genre: ${music_type_genre}
- Favorite Movie: ${favorites_movie} Favorite Game: ${favorites_game} Favorite Game Platform: ${favorites_game_platform} Favorite Artist: ${favorites_artist}
- Favorite Animal: ${favorites_animal} Favorite Website: ${favorites_website} Favorite Food: ${favorites_food} Contact Information: ${contact_result}
- Watchers Count: ${watchers_count} Watching Count: ${watching_count} `;
-
- const item = [];
- item.push({
- title: `${data.name}'s Current Profile`,
- description,
- link: `https://www.furaffinity.net/user/${username}/`,
- });
-
- ctx.state.data = {
- // 源标题
- title: `Userpage of ${data.name}`,
- // 源链接
- link: `https://www.furaffinity.net/`,
- // 源说明
- description: `Fur Affinity Userpage Profile of ${data.name}`,
-
- item,
- };
-};
diff --git a/lib/routes/furaffinity/user.ts b/lib/routes/furaffinity/user.ts
new file mode 100644
index 00000000000000..f7ac1e981a850b
--- /dev/null
+++ b/lib/routes/furaffinity/user.ts
@@ -0,0 +1,121 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/user/:username',
+ name: 'Userpage',
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/user/fender/nsfw',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: { username: 'Username, can find in userpage' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net/user/:username'],
+ target: '/user/:username',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { username } = ctx.req.param();
+ const url = `https://faexport.spangle.org.uk/user/${username}.json`;
+
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ // 收集传入的数据
+ const name = data.name;
+ const profile = data.profile;
+ const account_type = data.profile;
+ const avatar = ` `;
+ const full_name = data.full_name;
+ const artist_type = data.artist_type;
+ const user_title = data.user_title;
+ const registered_since = data.registered_since;
+ const current_mood = data.current_mood;
+ const artist_profile = data.artist_profile;
+ const pageviews = data.pageviews;
+ const submissions = data.submissions;
+ const comments_received = data.comments_received;
+ const comments_given = data.comments_given;
+ const journals = data.journals;
+ const favorite = data.favorites;
+ const watchers_count = data.watchers.count;
+ const watching_count = data.watching.count;
+
+ const artist_information = data.artist_information;
+ const species = artist_information.Species;
+ const personal_quote = artist_information['Personal Quote'];
+ const music_type_genre = artist_information['Music Type/Genre'];
+ const favorites_movie = artist_information['Favorite Movie'];
+ const favorites_game = artist_information['Favorite Game'];
+ const favorites_game_platform = artist_information['Favorite Game Platform'];
+ const favorites_artist = artist_information['Favorite Artist'];
+ const favorites_animal = artist_information['Favorite Animal'];
+ const favorites_website = artist_information['Favorite Website'];
+ const favorites_food = artist_information['Favorite Food'];
+
+ const contact_information = data.contact_information;
+ let contact_result = 'none ';
+ // 对一个或多个用户联系方式进行遍历
+ if (contact_information) {
+ contact_result = '';
+ for (const element of contact_information) {
+ for (const x in element) {
+ switch (x) {
+ case 'title':
+ contact_result += `Title: ${element[x]} `;
+ break;
+ case 'name':
+ contact_result += `Name: ${element[x]} `;
+ break;
+ case 'link':
+ contact_result += `Link: ${element[x]} `;
+ break;
+ default:
+ throw new Error(`Unknown type: ${x}`);
+ }
+ }
+ contact_result += ` `;
+ }
+ }
+
+ const description = `Name: ${name} Profile: ${profile} Account Type: ${account_type}
+ Avatar: ${avatar} Full Name: ${full_name} Artist Type: ${artist_type} User Title: ${user_title}
+ Registered Since: ${registered_since} Current Mood: ${current_mood} Artist Profile: ${artist_profile}
+ Pageviews: ${pageviews} Submissions: ${submissions} Comments_Received: ${comments_received} Comments Given: ${comments_given}
+ Journals: ${journals} Favorite: ${favorite} Artist Information: Species: ${species} Personal Quote: ${personal_quote} Music Type/Genre: ${music_type_genre}
+ Favorite Movie: ${favorites_movie} Favorite Game: ${favorites_game} Favorite Game Platform: ${favorites_game_platform} Favorite Artist: ${favorites_artist}
+ Favorite Animal: ${favorites_animal} Favorite Website: ${favorites_website} Favorite Food: ${favorites_food} Contact Information: ${contact_result}
+ Watchers Count: ${watchers_count} Watching Count: ${watching_count} `;
+
+ const items: { title: string; link: string; description: string }[] = [
+ {
+ title: `${data.name}'s User Profile`,
+ link: `https://www.furaffinity.net/user/${username}`,
+ description,
+ },
+ ];
+
+ return {
+ title: `Fur Affinity | Userpage of ${data.name}`,
+ link: `https://www.furaffinity.net/user/${username}`,
+ description: `Fur Affinity User Profile of ${data.name}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/watchers.js b/lib/routes/furaffinity/watchers.js
deleted file mode 100644
index a02a2befeebabf..00000000000000
--- a/lib/routes/furaffinity/watchers.js
+++ /dev/null
@@ -1,47 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 传入参数
- const username = String(ctx.params.username);
-
- // 添加参数username 和 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/user/${username}/watchers.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- // 发起第二个HTTP GET请求,用于获取该用户被关注总数
- const response2 = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/user/${username}.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- const data = response.data;
- const data2 = response2.data;
-
- ctx.state.data = {
- // 源标题
- title: `${username}'s Watcher List`,
- // 源链接
- link: `https://www.furaffinity.net/watchlist/to/${username}/`,
- // 源说明
- description: `Fur Affinity ${username}'s Watcher List`,
-
- // 遍历此前获取的数据
- item: data.map((item) => ({
- // 标题
- title: item,
- // 正文
- description: `${username} was watched by ${item} Totall: ${data2.watchers.count} `,
- // 链接
- link: `https://www.furaffinity.net/user/${item}/`,
- // 由于源API未提供日期,故无pubDate
- })),
- };
-};
diff --git a/lib/routes/furaffinity/watchers.ts b/lib/routes/furaffinity/watchers.ts
new file mode 100644
index 00000000000000..cfe5ad270a1600
--- /dev/null
+++ b/lib/routes/furaffinity/watchers.ts
@@ -0,0 +1,63 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/watchers/:username',
+ name: `User's Watcher List`,
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/watchers/fender',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: { username: 'Username, can find in userpage' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net/watchlist/to/:username'],
+ target: '/watchers/:username',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { username } = ctx.req.param();
+ const url = `https://faexport.spangle.org.uk/user/${username}/watchers.json`;
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const urlUserInfo = `https://faexport.spangle.org.uk/user/${username}.json`;
+ const dataUserInfo = await ofetch(urlUserInfo, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+ const watchersCount = dataUserInfo.watchers.count;
+
+ const items = data.map((item) => ({
+ title: item,
+ link: `https://www.furaffinity.net/user/${item}`,
+ guid: item,
+ description: `${username} was watched by ${item} Total: ${watchersCount}`,
+ author: item,
+ }));
+
+ return {
+ title: `Fur Affinity | Watchers of ${username}`,
+ link: `https://www.furaffinity.net/watchlist/to/${username}/`,
+ description: `Fur Affinity Watchers of ${username}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furaffinity/watching.js b/lib/routes/furaffinity/watching.js
deleted file mode 100644
index 9532320b4fd29e..00000000000000
--- a/lib/routes/furaffinity/watching.js
+++ /dev/null
@@ -1,47 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- // 传入参数
- const username = String(ctx.params.username);
-
- // 添加参数username 和 发起 HTTP GET 请求
- const response = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/user/${username}/watching.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- // 发起第二个HTTP GET请求,用于获取该用户关注总数
- const response2 = await got({
- method: 'get',
- url: `https://faexport.spangle.org.uk/user/${username}.json`,
- headers: {
- Referer: `https://faexport.spangle.org.uk/`,
- },
- });
-
- const data = response.data;
- const data2 = response2.data;
-
- ctx.state.data = {
- // 源标题
- title: `${username}'s Watching List`,
- // 源链接
- link: `https://www.furaffinity.net/watchlist/by/${username}/`,
- // 源说明
- description: `Fur Affinity ${username}}'s Search List`,
-
- // 遍历此前获取的数据
- item: data.map((item) => ({
- // 标题
- title: item,
- // 正文
- description: `${username} watched ${item} Totall: ${data2.watching.count}`,
- // 链接
- link: `https://www.furaffinity.net/user/${item}/`,
- // 由于源API未提供日期,故无pubDate
- })),
- };
-};
diff --git a/lib/routes/furaffinity/watching.ts b/lib/routes/furaffinity/watching.ts
new file mode 100644
index 00000000000000..3c689c7b1fe966
--- /dev/null
+++ b/lib/routes/furaffinity/watching.ts
@@ -0,0 +1,63 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/watching/:username',
+ name: `User's Watching List`,
+ url: 'furaffinity.net',
+ categories: ['social-media'],
+ example: '/furaffinity/watching/fender',
+ maintainers: ['TigerCubDen', 'SkyNetX007'],
+ parameters: { username: 'Username, can find in userpage' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['furaffinity.net/watchlist/by/:username'],
+ target: '/watching/:username',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const { username } = ctx.req.param();
+ const url = `https://faexport.spangle.org.uk/user/${username}/watching.json`;
+ const data = await ofetch(url, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+
+ const urlUserInfo = `https://faexport.spangle.org.uk/user/${username}.json`;
+ const dataUserInfo = await ofetch(urlUserInfo, {
+ method: 'GET',
+ headers: {
+ Referer: 'https://faexport.spangle.org.uk/',
+ },
+ });
+ const watchingCount = dataUserInfo.watching.count;
+
+ const items = data.map((item) => ({
+ title: item,
+ link: `https://www.furaffinity.net/user/${item}`,
+ guid: item,
+ description: `${username} is watching ${item} Total: ${watchingCount}`,
+ author: item,
+ }));
+
+ return {
+ title: `Fur Affinity | Users ${username} is watching`,
+ link: `https://www.furaffinity.net/watchlist/by/${username}/`,
+ description: `Fur Affinity Users ${username} is watching`,
+ item: items,
+ };
+}
diff --git a/lib/routes/furstar/archive.ts b/lib/routes/furstar/archive.ts
new file mode 100644
index 00000000000000..17907b5642659c
--- /dev/null
+++ b/lib/routes/furstar/archive.ts
@@ -0,0 +1,54 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/archive/:lang?',
+ categories: ['shopping'],
+ example: '/furstar/archive/cn',
+ parameters: { lang: '语言, 留空为jp, 支持cn, en' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['furstar.jp/:lang/archive.php', 'furstar.jp/archive.php'],
+ target: '/archive/:lang',
+ },
+ ],
+ name: '已经出售的角色列表',
+ maintainers: ['NeverBehave'],
+ handler,
+};
+
+async function handler(ctx) {
+ const base = utils.langBase(ctx.req.param('lang'));
+ const url = `${base}/archive.php`;
+ const res = await got.get(url, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+ const info = utils.fetchAllCharacters(res.data, base);
+
+ return {
+ title: 'Furstar 已出售角色',
+ link: 'https://furstar.jp',
+ description: 'Furstar 已经出售或预订的角色列表',
+ language: ctx.req.param('lang'),
+ item: info.map((e) => ({
+ title: e.title,
+ author: e.author.name,
+ description: ` ${utils.renderAuthor(e.author)}`,
+ pubDate: parseDate(new Date().toISOString()), // No Time for now
+ link: e.detailPage,
+ })),
+ };
+}
diff --git a/lib/routes/furstar/artists.ts b/lib/routes/furstar/artists.ts
new file mode 100644
index 00000000000000..8f6821104f4c83
--- /dev/null
+++ b/lib/routes/furstar/artists.ts
@@ -0,0 +1,60 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/artists/:lang?',
+ categories: ['shopping'],
+ example: '/furstar/artists/cn',
+ parameters: { lang: '语言, 留空为jp, 支持cn, en' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['furstar.jp/'],
+ target: '/artists',
+ },
+ ],
+ name: '画师列表',
+ maintainers: ['NeverBehave'],
+ handler,
+ url: 'furstar.jp/',
+};
+
+async function handler(ctx) {
+ const base = utils.langBase(ctx.req.param('lang'));
+ const res = await got.get(base, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+ const $ = load(res.data);
+ const artists = $('.filter-item')
+ .toArray()
+ .map((e) => utils.authorDetail(e));
+ artists.shift(); // the first one is "show all"
+
+ return {
+ title: 'furstar 所有画家',
+ link: 'https://furstar.jp',
+ description: 'Furstar 所有画家列表',
+ language: ctx.req.param('lang'),
+ item: artists.map((e) => ({
+ title: e.name,
+ author: e.name,
+ description: `${e.name} `,
+ pubDate: parseDate(new Date().toISOString()), // No Time for now
+ link: `${base}/${e.link}`,
+ })),
+ };
+}
diff --git a/lib/routes/furstar/index.ts b/lib/routes/furstar/index.ts
new file mode 100644
index 00000000000000..016f469abc674f
--- /dev/null
+++ b/lib/routes/furstar/index.ts
@@ -0,0 +1,60 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/characters/:lang?',
+ categories: ['shopping'],
+ example: '/furstar/characters/cn',
+ parameters: { lang: '语言, 留空为jp, 支持cn, en' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['furstar.jp/:lang', 'furstar.jp/'],
+ target: '/characters/:lang',
+ },
+ ],
+ name: '最新售卖角色列表',
+ maintainers: ['NeverBehave'],
+ handler,
+};
+
+async function handler(ctx) {
+ const base = utils.langBase(ctx.req.param('lang'));
+ const res = await got.get(base, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+ const info = utils.fetchAllCharacters(res.data, base);
+
+ const details = await Promise.all(info.map((e) => utils.detailPage(e.detailPage, cache)));
+
+ ctx.set('json', {
+ info,
+ });
+
+ return {
+ title: 'Furstar 最新角色',
+ link: 'https://furstar.jp',
+ description: 'Furstar 最近更新的角色列表',
+ language: ctx.req.param('lang'),
+ item: info.map((e, i) => ({
+ title: e.title,
+ author: e.author.name,
+ description: utils.renderDesc(details[i].desc, details[i].pics, e.author),
+ pubDate: parseDate(new Date().toISOString()), // No Time for now
+ link: e.detailPage,
+ })),
+ };
+}
diff --git a/lib/routes/furstar/namespace.ts b/lib/routes/furstar/namespace.ts
new file mode 100644
index 00000000000000..8af12f9858deda
--- /dev/null
+++ b/lib/routes/furstar/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Furstar',
+ url: 'furstar.jp',
+ lang: 'ja',
+};
diff --git a/lib/v2/furstar/templates/author.art b/lib/routes/furstar/templates/author.art
similarity index 100%
rename from lib/v2/furstar/templates/author.art
rename to lib/routes/furstar/templates/author.art
diff --git a/lib/v2/furstar/templates/description.art b/lib/routes/furstar/templates/description.art
similarity index 100%
rename from lib/v2/furstar/templates/description.art
rename to lib/routes/furstar/templates/description.art
diff --git a/lib/routes/furstar/utils.ts b/lib/routes/furstar/utils.ts
new file mode 100644
index 00000000000000..d63e92a18c7704
--- /dev/null
+++ b/lib/routes/furstar/utils.ts
@@ -0,0 +1,96 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const base = 'https://furstar.jp';
+
+const langBase = (lang) => (lang ? `${base}/${lang}` : base); // en, cn, (none, for JP)
+
+const renderAuthor = (author) => art(path.join(__dirname, 'templates/author.art'), author);
+const renderDesc = (desc, pics, author) =>
+ art(path.join(__dirname, 'templates/description.art'), {
+ desc,
+ pics,
+ author: renderAuthor(author),
+ });
+
+const authorDetail = (el) => {
+ const $ = load(el);
+ // if there is
+ const a = $('a');
+ const result = {
+ name: null,
+ avatar: null,
+ link: null,
+ };
+
+ if (a.length > 0) {
+ const img = $('a img');
+ result.name = img.attr('alt');
+ result.avatar = img.attr('src');
+ result.link = a.attr('href');
+ } else {
+ const desc = $('img');
+ result.name = desc.attr('alt');
+ result.avatar = desc.attr('src');
+ }
+
+ return result;
+};
+
+const detailPage = (link, cache) =>
+ cache.tryGet(link, async () => {
+ const result = await got(link, {
+ https: {
+ rejectUnauthorized: false,
+ },
+ });
+ const $ = load(result.data);
+ const title = $('.row .panel-heading h2').text().trim(); // Get first title
+ const desc = $('.character-description p').text().trim();
+ const pics = $('.img-gallery .prettyPhoto')
+ .toArray()
+ .map((e) => {
+ const p = load(e);
+ const link = p('a').attr('href').trim();
+ return `${base}/${link.slice(2)}`;
+ });
+
+ return {
+ title,
+ pics,
+ desc,
+ author: authorDetail($('.character-description').html()),
+ };
+ });
+
+const fetchAllCharacters = (data, base) => {
+ // All character page
+ const $ = load(data);
+ const characters = $('.character-article');
+ const info = characters.toArray().map((e) => {
+ const c = load(e);
+ const r = {
+ title: c('.character-headline').text().trim(),
+ headImage: c('.character-images img').attr('src').trim(),
+ detailPage: `${base}/${c('.character-images a').attr('href').trim()}`,
+ author: authorDetail(c('.character-description').html()),
+ };
+ return r;
+ });
+
+ return info;
+};
+
+export default {
+ BASE: base,
+ langBase,
+ fetchAllCharacters,
+ detailPage,
+ authorDetail,
+ renderDesc,
+ renderAuthor,
+};
diff --git a/lib/routes/futunn/live.ts b/lib/routes/futunn/live.ts
new file mode 100644
index 00000000000000..fdec6cb2a5b48f
--- /dev/null
+++ b/lib/routes/futunn/live.ts
@@ -0,0 +1,101 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/live/:lang?',
+ categories: ['finance'],
+ example: '/futunn/live',
+ parameters: {
+ category: {
+ description: '通知语言',
+ default: 'Mandarin',
+ options: [
+ {
+ label: '国语',
+ value: 'Mandarin',
+ },
+ {
+ label: '粵語',
+ value: 'Cantonese',
+ },
+ {
+ label: 'English',
+ value: 'English',
+ },
+ ],
+ },
+ },
+ features: {
+ supportRadar: true,
+ },
+ radar: [
+ {
+ source: ['news.futunn.com/main/live'],
+ target: '/live',
+ },
+ {
+ source: ['news.futunn.com/hk/main/live'],
+ target: '/live/Cantonese',
+ },
+ {
+ source: ['news.futunn.com/en/main/live'],
+ target: '/live/English',
+ },
+ ],
+ name: '快讯',
+ maintainers: ['kennyfong19931'],
+ handler,
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 30;
+ const lang = ctx.req.param('lang') ?? 'Mandarin';
+
+ const rootUrl = 'https://news.futunn.com';
+ const link = `${rootUrl}/main${lang === 'Mandarin' ? '' : lang === 'Cantonese' ? '/hk' : '/en'}/live`;
+ const apiUrl = `${rootUrl}/news-site-api/main/get-flash-list?pageSize=${limit}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ headers: {
+ 'x-news-site-lang': lang === 'Mandarin' ? 0 : lang === 'Cantonese' ? 1 : 2,
+ },
+ });
+
+ const items = response.data.data.data.news.map((item) => {
+ const audio = item.audioInfos.find((audio) => audio.language === lang);
+ return {
+ title: item.title || item.content,
+ description: item.content,
+ link: item.detailUrl,
+ pubDate: parseDate(item.time * 1000),
+ category: item.quote.map((quote) => quote.name),
+ itunes_item_image: item.pic,
+ itunes_duration: audio.duration,
+ enclosure_url: audio.audioUrl,
+ enclosure_type: 'audio/mpeg',
+ media: {
+ content: {
+ url: audio.audioUrl,
+ type: 'audio/mpeg',
+ duration: audio.duration,
+ language: lang === 'Mandarin' ? 'zh-CN' : lang === 'Cantonese' ? 'zh-HK' : 'en',
+ },
+ thumbnail: {
+ url: item.pic,
+ },
+ },
+ };
+ });
+
+ return {
+ title: lang === 'Mandarin' ? '富途牛牛 - 快讯' : lang === 'Cantonese' ? '富途牛牛 - 快訊' : 'Futubull - Latest',
+ link,
+ item: items,
+ language: lang === 'Mandarin' ? 'zh-CN' : lang === 'Cantonese' ? 'zh-HK' : 'en',
+ itunes_author: lang === 'Mandarin' || lang === 'Cantonese' ? '富途牛牛' : 'Futubull',
+ itunes_category: 'News',
+ };
+}
diff --git a/lib/routes/futunn/main.ts b/lib/routes/futunn/main.ts
new file mode 100644
index 00000000000000..d61282025c469c
--- /dev/null
+++ b/lib/routes/futunn/main.ts
@@ -0,0 +1,89 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: ['/main', '/'],
+ categories: ['finance'],
+ example: '/futunn/main',
+ features: {
+ supportRadar: true,
+ },
+ radar: [
+ {
+ source: ['news.futunn.com/main', 'news.futunn.com/:lang/main'],
+ target: '/main',
+ },
+ ],
+ name: '要闻',
+ maintainers: ['Wsine', 'nczitzk', 'kennyfong19931'],
+ handler,
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 48;
+
+ const rootUrl = 'https://news.futunn.com';
+ const currentUrl = `${rootUrl}/main`;
+ const apiUrl = `${rootUrl}/news-site-api/main/get-market-list?size=${limit}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ let items = response.data.data.list.map((item) => ({
+ title: item.title,
+ link: item.url.split('?')[0],
+ author: item.source,
+ pubDate: parseDate(item.timestamp * 1000),
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ abs: item.abstract,
+ pic: item.pic,
+ }),
+ }));
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (/news\.futunn\.com/.test(item.link)) {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ content('.futu-news-time-stamp').remove();
+ content('.nnstock').each(function () {
+ content(this).replaceWith(` ${content(this).text().replaceAll('$', '')} `);
+ });
+
+ item.description = content('.origin_content').html();
+ item.category = [
+ ...content('.news__from-topic__title')
+ .toArray()
+ .map((a) => content(a).text().trim()),
+ ...content('#relatedStockWeb .stock-name')
+ .toArray()
+ .map((s) => content(s).text().trim()),
+ ];
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '富途牛牛 - 要闻',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/futunn/namespace.ts b/lib/routes/futunn/namespace.ts
new file mode 100644
index 00000000000000..7c51bd5252ecbb
--- /dev/null
+++ b/lib/routes/futunn/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Futubull 富途牛牛',
+ url: 'news.futunn.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/futunn/templates/description.art b/lib/routes/futunn/templates/description.art
similarity index 100%
rename from lib/v2/futunn/templates/description.art
rename to lib/routes/futunn/templates/description.art
diff --git a/lib/routes/futunn/topic.ts b/lib/routes/futunn/topic.ts
new file mode 100644
index 00000000000000..4f9a670ee2e0af
--- /dev/null
+++ b/lib/routes/futunn/topic.ts
@@ -0,0 +1,116 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/topic/:id',
+ categories: ['finance'],
+ example: '/futunn/topic/1267',
+ parameters: { id: 'Topic ID, can be found in URL' },
+ features: {
+ supportRadar: true,
+ },
+ radar: [
+ {
+ source: ['news.futunn.com/news-topics/:id/*', 'news.futunn.com/:lang/news-topics/:id/*'],
+ target: '/topic/:id',
+ },
+ ],
+ name: '专题',
+ maintainers: ['kennyfong19931'],
+ handler,
+};
+
+async function getTopic(rootUrl, id, seqMarkInput = '') {
+ const topicListResponse = await got({
+ method: 'get',
+ url: `${rootUrl}/news-site-api/main/get-topics-list?pageSize=48&seqMark=${seqMarkInput}`,
+ });
+ const { hasMore, seqMark, list } = topicListResponse.data.data.data;
+ const topic = list.find((item) => item.idx === id);
+ if (topic) {
+ return {
+ topicTitle: topic.title,
+ topicDescription: topic.detail,
+ };
+ } else if (hasMore === 1) {
+ return getTopic(rootUrl, id, seqMark);
+ } else {
+ return {
+ topicTitle: '',
+ topicDescription: '',
+ };
+ }
+}
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 48;
+ const id = ctx.req.param('id');
+
+ const rootUrl = 'https://news.futunn.com';
+ const link = `${rootUrl}/news-topics/${id}/`;
+ const apiUrl = `${rootUrl}/news-site-api/topic/get-topics-news-list?topicsId=${id}&pageSize=${limit}`;
+
+ const { topicTitle, topicDescription } = await cache.tryGet(link, async () => await getTopic(rootUrl, id));
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ let items = response.data.data.data.map((item) => ({
+ title: item.title,
+ link: item.url,
+ author: item.source,
+ pubDate: parseDate(item.time * 1000),
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ abs: item.abstract,
+ pic: item.pic,
+ }),
+ }));
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (/news\.futunn\.com/.test(item.link)) {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ content('.futu-news-time-stamp').remove();
+ content('.nnstock').each(function () {
+ content(this).replaceWith(`${content(this).text().replaceAll('$', '')} `);
+ });
+
+ item.description = content('.origin_content').html();
+ item.category = [
+ ...content('.news__from-topic__title')
+ .toArray()
+ .map((a) => content(a).text().trim()),
+ ...content('#relatedStockWeb .stock-name')
+ .toArray()
+ .map((s) => content(s).text().trim()),
+ ];
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `富途牛牛 - 专题 - ${topicTitle}`,
+ link,
+ description: topicDescription,
+ item: items,
+ };
+}
diff --git a/lib/routes/futunn/video.ts b/lib/routes/futunn/video.ts
new file mode 100644
index 00000000000000..87de70a71e7783
--- /dev/null
+++ b/lib/routes/futunn/video.ts
@@ -0,0 +1,53 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const route: Route = {
+ path: '/video',
+ categories: ['finance'],
+ example: '/futunn/video',
+ features: {
+ supportRadar: true,
+ },
+ radar: [
+ {
+ source: ['news.futunn.com/main/video-list', 'news.futunn.com/:lang/main/video-list'],
+ target: '/video',
+ },
+ ],
+ name: '视频',
+ maintainers: ['kennyfong19931'],
+ handler,
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
+
+ const rootUrl = 'https://news.futunn.com';
+ const link = `${rootUrl}/main/video-list`;
+ const apiUrl = `${rootUrl}/news-site-api/main/get-video-list?size=${limit}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.data.videoList.list.map((item) => ({
+ title: item.title,
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ abs: item.abstract,
+ pic: item.videoImg,
+ }),
+ link: item.targetUrl,
+ pubDate: parseDate(item.timestamp * 1000),
+ }));
+
+ return {
+ title: '富途牛牛 - 视频',
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/fx-markets/channel.ts b/lib/routes/fx-markets/channel.ts
new file mode 100644
index 00000000000000..9d8f7499416855
--- /dev/null
+++ b/lib/routes/fx-markets/channel.ts
@@ -0,0 +1,78 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:channel',
+ categories: ['finance'],
+ example: '/fx-markets/trading',
+ parameters: { channel: 'channel, can be found in the navi bar links at the home page' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Channel',
+ maintainers: [],
+ handler,
+ description: `| Trading | Infrastructure | Tech and Data | Regulation |
+| ------- | -------------- | ------------- | ---------- |
+| trading | infrastructure | tech-and-data | regulation |`,
+};
+
+async function handler(ctx) {
+ const channel = ctx.req.param('channel');
+ const link = `https://www.fx-markets.com/${channel}`;
+ const html = (await got(link)).data;
+ const $ = load(html);
+ const pageTitle = $('header.select-header > h1').text();
+ const title = `FX-Markets ${pageTitle}`;
+
+ const items = $('div#listings').children();
+ const articles = items.toArray().map((el) => {
+ const $el = $(el);
+ const $titleEl = $el.find('h5 > a');
+ const articleURL = `https://www.fx-markets.com${$titleEl.attr('href')}`;
+ const articleTitle = $titleEl.attr('title');
+ return {
+ title: articleTitle,
+ link: articleURL,
+ pubDate: parseDate($el.find('time').text()),
+ };
+ });
+
+ const result = await Promise.all(
+ articles.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const res = await got(item.link);
+ const doc = load(res.data);
+ // This script holds publish datetime info {"datePublished": "2022-05-12T08:45:04+01:00"}
+ const dateScript = doc('script[type="application/ld+json"]').toArray()[0].children[0].data;
+ const re = /"datePublished": "(?.*)"/;
+ const dateStr = re.exec(dateScript).groups.dateTimePub;
+ const pubDateTime = parseDate(dateStr, 'YYYY-MM-DDTHH:mm:ssZ');
+ // Exclude hidden print message
+ item.description = doc('div.article-page-body-content:not(.print-access-info)').html();
+ return {
+ title: item.title,
+ link: item.link,
+ description: item.description,
+ // if we fail to get accurate publish date time, show date only from article link on index page.
+ pubDate: pubDateTime ?? item.pubDate,
+ };
+ })
+ )
+ );
+
+ return {
+ title,
+ link,
+ item: result,
+ };
+}
diff --git a/lib/routes/fx-markets/namespace.ts b/lib/routes/fx-markets/namespace.ts
new file mode 100644
index 00000000000000..c93cbe9a54b33e
--- /dev/null
+++ b/lib/routes/fx-markets/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'FX Markets',
+ url: 'fx-markets.com',
+ lang: 'en',
+};
diff --git a/lib/routes/fx678/kx.ts b/lib/routes/fx678/kx.ts
new file mode 100644
index 00000000000000..153b867cccae49
--- /dev/null
+++ b/lib/routes/fx678/kx.ts
@@ -0,0 +1,73 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/kx',
+ categories: ['finance'],
+ view: ViewType.Notifications,
+ example: '/fx678/kx',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['fx678.com/kx'],
+ },
+ ],
+ name: '7x24 小时快讯',
+ maintainers: ['occupy5', 'dousha'],
+ handler,
+ url: 'fx678.com/kx',
+};
+
+async function handler() {
+ const link = 'https://www.fx678.com/kx/';
+ const res = await got.get(link);
+ const $ = load(res.data);
+ // 页面新闻消息列表
+ const list = $('.body_zb ul .body_zb_li .zb_word')
+ .find('.list_font_pic > a:first-child')
+ .toArray()
+ .slice(0, 30)
+ .map((e) => $(e).attr('href'));
+
+ const out = await Promise.all(
+ list.map((itemUrl) =>
+ cache.tryGet(itemUrl, async () => {
+ const res = await got.get(itemUrl);
+ const $ = load(res.data);
+
+ const contentPart = $('.article-main .content').html().trim();
+ const forewordPart = $('.article-main .foreword').html().trim();
+ const datetimeString = $('.article-cont .details i').text().trim();
+ const articlePubDate = timezone(parseDate(datetimeString, 'YYYY-MM-DD HH:mm:ss'), +8);
+
+ const item = {
+ title: $('.article-main .foreword').text().trim().split('——').pop(),
+ link: itemUrl,
+ description: contentPart.length > 1 ? contentPart : forewordPart,
+ pubDate: articlePubDate,
+ };
+
+ return item;
+ })
+ )
+ );
+ return {
+ title: '7x24小时快讯',
+ link,
+ item: out,
+ };
+}
diff --git a/lib/routes/fx678/namespace.ts b/lib/routes/fx678/namespace.ts
new file mode 100644
index 00000000000000..6a8bc947ca402d
--- /dev/null
+++ b/lib/routes/fx678/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '汇通网',
+ url: 'fx678.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/fxiaoke/crm.ts b/lib/routes/fxiaoke/crm.ts
new file mode 100644
index 00000000000000..ec386098df26ed
--- /dev/null
+++ b/lib/routes/fxiaoke/crm.ts
@@ -0,0 +1,88 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'https://www.fxiaoke.com/crm';
+const baseTitle = '纷享销客 CRM';
+const titleMap = new Map([
+ ['news', `全部文章 - ${baseTitle}`],
+ ['blog', `文章干货 - ${baseTitle}`],
+ ['articles', `CRM 知识 - ${baseTitle}`],
+ ['about-influence', `纷享动态 - ${baseTitle}`],
+ ['customers', `签约喜报 - ${baseTitle}`],
+]);
+
+export const route: Route = {
+ path: '/crm/:type',
+ categories: ['blog'],
+ example: '/fxiaoke/crm/news',
+ parameters: { type: '文章类型, 见下表' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '文章',
+ maintainers: ['akynazh'],
+ handler,
+ description: `| 全部文章 | 文章干货 | CRM 知识 | 纷享动态 | 签约喜报 |
+| -------- | -------- | -------- | --------------- | --------- |
+| news | blog | articles | about-influence | customers |`,
+};
+
+async function handler(ctx) {
+ const t = ctx.req.param('type');
+ const title = titleMap.get(t);
+ const url = `${baseUrl}/${t}/`;
+ const resp = await got(url);
+ const $ = load(resp.data);
+ const desc = $('.meeting').text().trim();
+ let items = $('.content-item')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const c1 = item.find('.baike-content-t1');
+ const c3 = item.find('.baike-content-t3').find('span');
+ return {
+ title: c1.text().trim(),
+ // pubDate: parseDate(c3.first().text().trim()),
+ link: item.find('a').attr('href'),
+ author: c3.last().text().trim(),
+ };
+ });
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const resp = await got(item.link);
+ const $ = load(resp.data);
+ const firstViewBox = $('.body-wrapper-article').first();
+
+ firstViewBox.find('img').each((_, img) => {
+ img = $(img);
+ if (img.attr('zoomfile')) {
+ img.attr('src', img.attr('zoomfile'));
+ img.removeAttr('zoomfile');
+ img.removeAttr('file');
+ }
+ img.removeAttr('onmouseover');
+ });
+
+ item.description = firstViewBox.html();
+ item.pubDate = parseDate($('.month-day').first().text().trim());
+ return item;
+ })
+ )
+ );
+ return {
+ title,
+ link: url,
+ description: desc,
+ item: items,
+ };
+}
diff --git a/lib/routes/fxiaoke/namespace.ts b/lib/routes/fxiaoke/namespace.ts
new file mode 100644
index 00000000000000..6edb45aba88408
--- /dev/null
+++ b/lib/routes/fxiaoke/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '纷享销客 CRM',
+ url: 'fxiaoke.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/fzmtr/announcements.ts b/lib/routes/fzmtr/announcements.ts
new file mode 100644
index 00000000000000..ebd4aaf0113c8d
--- /dev/null
+++ b/lib/routes/fzmtr/announcements.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/announcements',
+ categories: ['travel'],
+ example: '/fzmtr/announcements',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '通知公告',
+ maintainers: ['HankChow'],
+ handler,
+};
+
+async function handler() {
+ const domain = 'www.fzmtr.com';
+ const announcementsUrl = `http://${domain}/html/fzdt/tzgg/index.html`;
+ const response = await got(announcementsUrl);
+ const data = response.data;
+
+ const $ = load(data);
+ const list = $('span#resources li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const url = `http://${domain}` + item.find('a').attr('href');
+ const title = item.find('a').text();
+ const publishTime = parseDate(item.find('span').text());
+ return {
+ title,
+ link: url,
+ author: '福州地铁',
+ pubtime: publishTime,
+ };
+ });
+
+ return {
+ title: '福州地铁通知公告',
+ url: announcementsUrl,
+ description: '福州地铁通知公告',
+ item: list.map((item) => ({
+ title: item.title,
+ pubDate: item.pubtime,
+ link: item.link,
+ author: item.author,
+ })),
+ };
+}
diff --git a/lib/routes/fzmtr/namespace.ts b/lib/routes/fzmtr/namespace.ts
new file mode 100644
index 00000000000000..340de25b97edff
--- /dev/null
+++ b/lib/routes/fzmtr/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '福州地铁',
+ url: 'www.fzmtr.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/galaxylab/index.js b/lib/routes/galaxylab/index.js
deleted file mode 100644
index f727804d7442a3..00000000000000
--- a/lib/routes/galaxylab/index.js
+++ /dev/null
@@ -1,35 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- let host = 'http://galaxylab.com.cn/';
-
- host = `${host}/posts/`;
-
- const response = await got.get(host);
-
- const $ = cheerio.load(response.data);
-
- const list = $('#scroll > section > ul > li');
-
- ctx.state.data = {
- title: '平安银河实验室',
- link: 'http://galaxylab.com.cn/',
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- return {
- title: item.find('article > a[title]').attr('title'),
- link: item.find('article > a[title]').attr('href'),
- description: item.find('article > div.excerpt').text(),
- pubDate: new Date(item.find('article > div.postinfo > div.left > span.date > b').text().replace('日', '').replace(/\D/g, '-')).toUTCString(),
- author: item.find('article > div.postinfo > div.left > span.author > a[title]').attr('title'),
- };
- })
- .get(),
- };
-};
-
-// 虽然有很多分类,但是更新不勤,就不写分类了。
diff --git a/lib/routes/galxe/index.ts b/lib/routes/galxe/index.ts
new file mode 100644
index 00000000000000..90110bd75d3a2b
--- /dev/null
+++ b/lib/routes/galxe/index.ts
@@ -0,0 +1,94 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/quest/:alias',
+ name: 'Quest',
+ url: 'app.galxe.com',
+ maintainers: ['cxheng315'],
+ example: '/galxe/quest/MissionWeb3',
+ categories: ['other'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['app.galxe.com/quest/:alias'],
+ target: '/quest/:alias',
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const url = 'https://graphigo.prd.galaxy.eco/query';
+
+ const alias = ctx.req.param('alias');
+
+ const response = await ofetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: {
+ variables: {
+ alias,
+ campaignInput: {
+ first: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50,
+ excludeChildren: true,
+ listType: 'Newest',
+ },
+ },
+ query: `
+ query BrowseSpaceCampaigns($id: Int, $alias: String, $campaignInput: ListCampaignInput!) {
+ space(id: $id, alias: $alias) {
+ id
+ name
+ alias
+ info
+ campaigns(input: $campaignInput) {
+ list {
+ startTime
+ endTime
+ id
+ name
+ description
+ __typename
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
+ __typename
+ }
+ __typename
+ }
+ __typename
+ }
+ }
+ `,
+ },
+ });
+
+ const space = response.data.space;
+
+ const items = space.campaigns.list.map((campaign) => ({
+ title: campaign.name,
+ link: `https://app.galxe.com/quest/${alias}/${campaign.id}`,
+ description: campaign.description,
+ pubDate: campaign.startTime ? parseDate(campaign.startTime * 1000) : null,
+ }));
+
+ return {
+ title: space.name,
+ description: space.info,
+ link: `https://app.galxe.com/quest/${alias}`,
+ item: items,
+ author: space.alias,
+ };
+}
diff --git a/lib/routes/galxe/namespace.ts b/lib/routes/galxe/namespace.ts
new file mode 100644
index 00000000000000..e84b418b62b7de
--- /dev/null
+++ b/lib/routes/galxe/namespace.ts
@@ -0,0 +1,10 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Galxe',
+ url: 'app.galxe.com',
+ zh: {
+ name: '銀河',
+ },
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/gameapps/index.ts b/lib/routes/gameapps/index.ts
new file mode 100644
index 00000000000000..ec64f0b50d0b12
--- /dev/null
+++ b/lib/routes/gameapps/index.ts
@@ -0,0 +1,96 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import parser from '@/utils/rss-parser';
+
+export const route: Route = {
+ path: '/',
+ example: '/gameapps',
+ radar: [
+ {
+ source: ['gameapps.hk/'],
+ },
+ ],
+ name: '最新消息',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'gameapps.hk/',
+};
+
+async function handler() {
+ const baseUrl = 'https://www.gameapps.hk';
+ const feed = await parser.parseURL(`${baseUrl}/rss`);
+
+ const items = await Promise.all(
+ feed.items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link, {
+ headers: {
+ Referer: baseUrl,
+ },
+ });
+ const $ = load(response);
+
+ item.title = $('meta[property="og:title"]').attr('content') ?? $('.news-title h1').text();
+
+ const nextPages = $('.pagination li')
+ .not('.disabled')
+ .not('.active')
+ .find('a')
+ .toArray()
+ .map((a) => `${baseUrl}${a.attribs.href}`);
+
+ $('.pages').remove();
+
+ const content = $('.news-content');
+
+ // remove unwanted key value
+ delete item.content;
+ delete item.contentSnippet;
+ delete item.isoDate;
+
+ if (nextPages.length) {
+ const pages = await Promise.all(
+ nextPages.map(async (url) => {
+ const response = await ofetch(url, {
+ headers: {
+ referer: item.link,
+ },
+ });
+ const $ = load(response);
+ $('.pages').remove();
+ return $('.news-content').html();
+ })
+ );
+ content.append(pages);
+ }
+
+ item.description = art(path.join(__dirname, 'templates/description.art'), {
+ intro: $('div.introduction.media.news-intro div.media-body').html()?.trim(),
+ desc: content.html()?.trim(),
+ });
+ item.guid = item.guid.slice(0, item.link.lastIndexOf('/'));
+ item.pubDate = parseDate(item.pubDate);
+ item.enclosure_url = $('div.introduction.media.news-intro div.media-left').find('img').attr('src');
+ item.enclosure_type = 'image/jpeg';
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: feed.title,
+ link: feed.link,
+ description: feed.description,
+ image: `${baseUrl}/static/favicon/apple-touch-icon.png`,
+ item: items,
+ language: feed.language,
+ };
+}
diff --git a/lib/routes/gameapps/namespace.ts b/lib/routes/gameapps/namespace.ts
new file mode 100644
index 00000000000000..56e7e3ab4cb84a
--- /dev/null
+++ b/lib/routes/gameapps/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'GameApps.hk 香港手机游戏网',
+ url: 'gameapps.hk',
+ lang: 'zh-HK',
+};
diff --git a/lib/routes/gameapps/templates/description.art b/lib/routes/gameapps/templates/description.art
new file mode 100644
index 00000000000000..7ba352613e8e52
--- /dev/null
+++ b/lib/routes/gameapps/templates/description.art
@@ -0,0 +1,2 @@
+{{@ intro }}
+{{@ desc }}
diff --git a/lib/routes/gamebase/namespace.ts b/lib/routes/gamebase/namespace.ts
new file mode 100644
index 00000000000000..13aa6462cc69ad
--- /dev/null
+++ b/lib/routes/gamebase/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '遊戲基地 Gamebase',
+ url: 'news.gamebase.com.tw',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/gamebase/news.ts b/lib/routes/gamebase/news.ts
new file mode 100644
index 00000000000000..2f626dcb1b61b2
--- /dev/null
+++ b/lib/routes/gamebase/news.ts
@@ -0,0 +1,180 @@
+import path from 'node:path';
+
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const types = {
+ newslist: 'newsList',
+ r18list: 'newsPornList',
+};
+
+export const handler = async (ctx: Context): Promise => {
+ const { type = 'newslist', category = 'all' } = ctx.req.param();
+
+ if (!types.hasOwnProperty(type)) {
+ throw new InvalidParameterError(`Invalid type: ${type}`);
+ }
+
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl: string = 'https://news.gamebase.com.tw';
+ const targetUrl: string = new URL(`news${category === 'all' ? '' : `/newslist?type=${category}`}`, baseUrl).href;
+ const apiBaseUrl: string = 'https://api.gamebase.com.tw';
+ const apiUrl: string = new URL('api/news/getNewsList', apiBaseUrl).href;
+
+ const response = await ofetch(apiUrl, {
+ method: 'post',
+ body: {
+ GB_type: types[type],
+ category,
+ page: 1,
+ },
+ });
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'zh-TW';
+
+ const items: DataItem[] = await Promise.all(
+ response.return_msg?.list?.slice(0, limit).map((item) =>
+ cache.tryGet(`gamebase-news-${item.news_no}`, async (): Promise => {
+ const title: string = item.news_title;
+ const pubDate: number | string = item.post_time;
+ const linkUrl: string | undefined = item.news_no ? `news/detail/${item.news_no}` : undefined;
+ const categories: string[] = [item.system];
+ const authors: DataItem['author'] = item.nickname;
+ const guid: string = `gamebase-news-${item.news_no}`;
+ const image: string | undefined = item.news_img;
+ const updated: number | string = item.updated ?? pubDate;
+
+ let metaDesc = item.news_meta?.meta_des;
+
+ if (!metaDesc) {
+ const detailResponse = await ofetch(item.link);
+
+ metaDesc = (detailResponse.match(/(\\u003C.*?)","/)?.[1] ?? '').replaceAll(String.raw`\"`, '"').replaceAll(/\\u([\da-f]{4})/gi, (match, hex) => String.fromCodePoint(Number.parseInt(hex, 16)));
+ }
+
+ const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ images:
+ image && !metaDesc
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ intro: item.news_short_desc,
+ description: metaDesc,
+ });
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDate ? timezone(parseDate(pubDate), +8) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ author: authors,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: updated ? timezone(parseDate(updated), +8) : undefined,
+ language,
+ };
+
+ return processedItem;
+ })
+ ) ?? []
+ );
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: $('meta[property="og:title"]').attr('content')?.split(/\|/).pop()?.trim(),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/news/:type?/:category?',
+ name: '新聞',
+ url: 'news.gamebase.com.tw',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/gamebase/news',
+ parameters: {
+ type: '類型,見下表,預設為 newslist',
+ category: '分類,預設為 `all`,即全部,可在對應分類頁 URL 中找到',
+ },
+ description: `::: tip
+若訂閱 [手機遊戲新聞](https://news.gamebase.com.tw/news/newslist?type=mobile),網址為 \`https://news.gamebase.com.tw/news/newslist?type=mobile\`,請截取 \`https://news.gamebase.com.tw/news/\` 到末尾的部分 \`newslist\` 作為 \`type\` 參數填入,\`mobile\` 作為 \`category\` 參數填入,此時目標路由為 [\`/gamebase/news/newslist/mobile\`](https://rsshub.app/gamebase/news/newslist/mobile)。
+:::
+
+| newslist | r18list |
+| -------- | ------- |
+`,
+ categories: ['game'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['news.gamebase.com.tw/news', 'news.gamebase.com.tw/news/:type'],
+ target: (params, url) => {
+ const type: string = params.type;
+ const urlObj: URL = new URL(url);
+ const category: string | undefined = urlObj.searchParams.get('type') ?? undefined;
+
+ return `/gamebase/news${type ? `/${type}${category ? `/${category}` : ''}` : ''}`;
+ },
+ },
+ ],
+ view: ViewType.Articles,
+
+ zh: {
+ path: '/news/:type?/:category?',
+ name: '新闻',
+ url: 'news.gamebase.com.tw',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/gamebase/news',
+ parameters: {
+ type: '类型,见下表,默认为 newslist',
+ category: '分类,默认为 `all`,即全部,可在对应分类页 URL 中找到',
+ },
+ description: `::: tip
+若订阅 [手机游戏新闻](https://news.gamebase.com.tw/news/newslist?type=mobile),网址为 \`https://news.gamebase.com.tw/news/newslist?type=mobile\`,请截取 \`https://news.gamebase.com.tw/news/\` 到末尾的部分 \`newslist\` 作为 \`type\` 参数填入,\`mobile\` 作为 \`category\` 参数填入,此时目标路由为 [\`/gamebase/news/newslist/mobile\`](https://rsshub.app/gamebase/news/newslist/mobile)。
+:::
+
+| newslist | r18list |
+| -------- | ------- |
+`,
+ },
+};
diff --git a/lib/routes/gamebase/templates/description.art b/lib/routes/gamebase/templates/description.art
new file mode 100644
index 00000000000000..249654e7e618a4
--- /dev/null
+++ b/lib/routes/gamebase/templates/description.art
@@ -0,0 +1,21 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if intro }}
+ {{ intro }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/gamegene/namespace.ts b/lib/routes/gamegene/namespace.ts
new file mode 100644
index 00000000000000..57a1535b9358c3
--- /dev/null
+++ b/lib/routes/gamegene/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '游戏基因',
+ url: 'news.gamegene.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/gamegene/news.ts b/lib/routes/gamegene/news.ts
new file mode 100644
index 00000000000000..62d01b52f672f2
--- /dev/null
+++ b/lib/routes/gamegene/news.ts
@@ -0,0 +1,77 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['game'],
+ example: '/gamegene/news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['news.gamegene.cn/news'],
+ },
+ ],
+ name: '资讯',
+ maintainers: ['lone1y-51'],
+ handler,
+ url: 'news.gamegene.cn/news',
+};
+
+async function handler() {
+ const url = 'https://gamegene.cn/news';
+ const { data: response } = await got({
+ method: 'get',
+ url,
+ });
+ const $ = load(response);
+ const list = $('div.mr245')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const aEle = item.find('a').first();
+ const href = aEle.attr('href');
+ const title = aEle.find('h3').first().text();
+ const author = item.find('a.namenode').text();
+ const category = item.find('span.r').text();
+ return {
+ title,
+ link: href,
+ author,
+ category,
+ };
+ });
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: response } = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const $ = load(response);
+ const dateTime = $('div.meta').find('time').first().text();
+ item.pubDate = parseDate(dateTime);
+ item.description = $('div.content').first().html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ // 在此处输出您的 RSS
+ item: items,
+ link: url,
+ title: '游戏基因 GameGene',
+ };
+}
diff --git a/lib/routes/gamekee/namespace.ts b/lib/routes/gamekee/namespace.ts
new file mode 100644
index 00000000000000..3e78ecdff9d349
--- /dev/null
+++ b/lib/routes/gamekee/namespace.ts
@@ -0,0 +1,6 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'GameKee | 游戏百科攻略',
+ url: 'www.gamekee.com',
+};
diff --git a/lib/routes/gamekee/news.ts b/lib/routes/gamekee/news.ts
new file mode 100644
index 00000000000000..c3df4677f0ee6d
--- /dev/null
+++ b/lib/routes/gamekee/news.ts
@@ -0,0 +1,65 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['game'],
+ example: '/gamekee/news',
+ radar: [
+ {
+ source: ['gamekee.com', 'gamekee.com/news'],
+ target: '/news',
+ },
+ ],
+ name: '游戏情报',
+ maintainers: ['ueiu'],
+ handler,
+ url: 'gamekee.com/news',
+};
+
+async function handler() {
+ const rootUrl = 'https://www.gamekee.com';
+ const url = `${rootUrl}/v1/index/newsList`;
+ const { data } = await ofetch(url, {
+ headers: {
+ 'game-alias': 'www',
+ 'device-num': '1',
+ 'User-Agent': config.ua,
+ },
+ query: {
+ page_no: 1,
+ limit: 20,
+ },
+ });
+ const list = data.map((item) => {
+ const link = new URL(`${item.id}.html`, rootUrl).href;
+ const title = item.title;
+ const pubDate = parseDate(item.created_at, 'X');
+ return {
+ link,
+ title,
+ pubDate,
+ };
+ });
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ item.description = $('div.content').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ link: `${rootUrl}/news`,
+ title: '游戏情报|Gamekee',
+ item: items,
+ };
+}
diff --git a/lib/routes/gamer/ani/anime.ts b/lib/routes/gamer/ani/anime.ts
new file mode 100644
index 00000000000000..b73df201138a6f
--- /dev/null
+++ b/lib/routes/gamer/ani/anime.ts
@@ -0,0 +1,60 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/ani/anime/:sn',
+ categories: ['anime'],
+ view: ViewType.Videos,
+ example: '/gamer/ani/anime/36868',
+ parameters: { sn: '動畫 sn,在 URL 可以找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['ani.gamer.com.tw/'],
+ target: '/anime/:sn',
+ },
+ ],
+ name: '動畫瘋 - 動畫',
+ maintainers: ['maple3142', 'pseudoyu'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { sn } = ctx.req.param();
+
+ const { data: response } = await got('https://api.gamer.com.tw/mobile_app/anime/v3/video.php', {
+ searchParams: {
+ sn,
+ },
+ });
+
+ if (response.error) {
+ throw new Error(response.error.message);
+ }
+
+ const anime = response.data.anime;
+ const title = anime.title.replaceAll(/\[\d+?]$/g, '').trim();
+
+ const items = anime.volumes[0]
+ .map((item) => ({
+ title: `${title} 第 ${item.volume} 集`,
+ description: ` `,
+ link: `https://ani.gamer.com.tw/animeVideo.php?sn=${item.video_sn}`,
+ }))
+ .toReversed();
+
+ return {
+ title,
+ link: `https://ani.gamer.com.tw/animeRef.php?sn=${anime.anime_sn}`,
+ description: anime.content?.trim(),
+ item: items,
+ };
+}
diff --git a/lib/routes/gamer/ani/new-anime.ts b/lib/routes/gamer/ani/new-anime.ts
new file mode 100644
index 00000000000000..74cef3b34829d9
--- /dev/null
+++ b/lib/routes/gamer/ani/new-anime.ts
@@ -0,0 +1,49 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/ani/new_anime',
+ categories: ['anime'],
+ view: ViewType.Videos,
+ example: '/gamer/ani/new_anime',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['ani.gamer.com.tw/'],
+ target: '/new_anime',
+ },
+ ],
+ name: '動畫瘋 - 最後更新',
+ maintainers: ['maple3142', 'pseudoyu'],
+ handler,
+ url: 'ani.gamer.com.tw/',
+};
+
+async function handler() {
+ const rootUrl = 'https://ani.gamer.com.tw';
+ const { data: response } = await got('https://api.gamer.com.tw/mobile_app/anime/v3/index.php');
+
+ const items = response.data.newAnime.date.map((item) => ({
+ title: `${item.title} ${item.volume}`,
+ description: ` `,
+ link: `${rootUrl}/animeVideo.php?sn=${item.videoSn}`,
+ pubDate: timezone(parseDate(`${item.upTime} ${item.upTimeHours}`, 'MM/DD HH:mm'), +8),
+ }));
+
+ return {
+ title: '動畫瘋最後更新',
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/gamer/gnn-index.ts b/lib/routes/gamer/gnn-index.ts
new file mode 100644
index 00000000000000..fcfbe7f018b66f
--- /dev/null
+++ b/lib/routes/gamer/gnn-index.ts
@@ -0,0 +1,181 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/gnn/:category?',
+ categories: ['anime'],
+ view: ViewType.Articles,
+ example: '/gamer/gnn/1',
+ parameters: {
+ category: {
+ description: '版塊',
+ options: [
+ { value: '1', label: 'PC' },
+ { value: '3', label: 'TV 掌機' },
+ { value: '4', label: '手機遊戲' },
+ { value: '5', label: '動漫畫' },
+ { value: '9', label: '主題報導' },
+ { value: '11', label: '活動展覽' },
+ { value: '13', label: '電競' },
+ { value: 'ns', label: 'Switch' },
+ { value: 'ps5', label: 'PS5' },
+ { value: 'ps4', label: 'PS4' },
+ { value: 'xbone', label: 'XboxOne' },
+ { value: 'xbsx', label: 'XboxSX' },
+ { value: 'pc', label: 'PC 單機' },
+ { value: 'olg', label: 'PC 線上' },
+ { value: 'ios', label: 'iOS' },
+ { value: 'android', label: 'Android' },
+ { value: 'web', label: 'Web' },
+ { value: 'comic', label: '漫畫' },
+ { value: 'anime', label: '動畫' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'GNN 新聞',
+ maintainers: ['Arracc', 'ladeng07', 'pseudoyu'],
+ handler,
+ description: '缺省為首頁',
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category');
+ let url = '';
+ let categoryName = '';
+ const categoryTable = {
+ 1: 'PC',
+ 3: 'TV 掌機',
+ 4: '手機遊戲',
+ 5: '動漫畫',
+ 9: '主題報導',
+ 11: '活動展覽',
+ 13: '電競',
+ ns: 'Switch',
+ ps5: 'PS5',
+ ps4: 'PS4',
+ xbone: 'XboxOne',
+ xbsx: 'XboxSX',
+ pc: 'PC 單機',
+ olg: 'PC 線上',
+ ios: 'iOS',
+ android: 'Android',
+ web: 'Web',
+ comic: '漫畫',
+ anime: '動畫',
+ };
+ const mainCategory = ['1', '3', '4', '5', '9', '11', '13'];
+ if (!category || !Object.keys(categoryTable).includes(category)) {
+ url = 'https://gnn.gamer.com.tw/';
+ } else {
+ categoryName = '-' + categoryTable[category];
+ url = mainCategory.includes(category) ? `https://gnn.gamer.com.tw/index.php?k=${category}` : `https://acg.gamer.com.tw/news.php?p=${category}`;
+ }
+
+ const response = await got({
+ method: 'get',
+ url,
+ });
+ const data = response.data;
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
+ const $ = load(data);
+
+ const list = $('div.BH-lbox.GN-lbox2')
+ .children()
+ .not('p,a,img,span')
+ //
+ .not('[data-news-id]')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ let aLabelNode;
+ let tag;
+ // a label with div
+ if (item.find('h1').length === 0) {
+ // a label without div
+ aLabelNode = item.find('a');
+ tag = item.find('div.platform-tag_list').text();
+ } else {
+ aLabelNode = item.find('h1').find('a');
+ tag = item.find('div.platform-tag_list').text();
+ }
+
+ return {
+ title: '[' + tag + ']' + aLabelNode.text(),
+ link: aLabelNode.attr('href').replace('//', 'https://'),
+ };
+ });
+
+ const items = await pMap(
+ list,
+ async (item) => {
+ item.description = await cache.tryGet(item.link, async () => {
+ const response = await got.get(item.link);
+ let component = '';
+ const urlReg = /window\.lazySizesConfig/g;
+
+ let pubInfo;
+ let dateStr;
+ if (response.body.search(urlReg) >= 0) {
+ const $ = load(response.data);
+ if ($('span.GN-lbox3C').length > 0) {
+ // official publish 1
+ pubInfo = $('span.GN-lbox3C').text().split(')');
+ item.author = pubInfo[0].replace('(', '').replace(' 報導', '');
+ dateStr = pubInfo[1].trim();
+ } else {
+ // official publish 2
+ pubInfo = $('span.GN-lbox3CA').text().split(')');
+ item.author = pubInfo[0].replace('(', '').replace(' 報導', '');
+ dateStr = pubInfo[1].replace('原文出處', '').trim();
+ }
+ component = $('div.GN-lbox3B').html();
+ } else {
+ // url redirect
+ const _response = await got.get(item.link);
+ const _$ = load(_response.data);
+
+ if (_$('div.MSG-list8C').length > 0) {
+ // personal publish 1
+ pubInfo = _$('span.ST1').text().split('│');
+ item.author = pubInfo[0].replace('作者:', '');
+ dateStr = pubInfo[_$('span.ST1').find('a').length > 0 ? 2 : 1];
+ component = _$('div.MSG-list8C').html();
+ } else {
+ // personal publish 2
+ pubInfo = _$('div.article-intro').text().replaceAll('\n', '').split('|');
+ item.author = pubInfo[0];
+ dateStr = pubInfo[1];
+ component = _$('div.text-paragraph').html();
+ }
+ }
+ item.pubDate = timezone(parseDate(dateStr, 'YYYY-MM-DD HH:mm:ss'), +8);
+ component = component.replaceAll(/\b(data-src)\b/g, 'src');
+ return component;
+ });
+ return item;
+ },
+ { concurrency: 5 }
+ );
+
+ return {
+ title: '巴哈姆特-GNN新聞' + categoryName,
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/gamer/hot.ts b/lib/routes/gamer/hot.ts
new file mode 100644
index 00000000000000..9ccc2e4a4eca0c
--- /dev/null
+++ b/lib/routes/gamer/hot.ts
@@ -0,0 +1,79 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/hot/:bsn',
+ categories: ['anime'],
+ view: ViewType.Articles,
+ example: '/gamer/hot/47157',
+ parameters: { bsn: '板塊 id,在 URL 可以找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '本板推薦',
+ maintainers: ['nczitzk', 'TonyRL', 'kennyfong19931'],
+ handler,
+};
+
+async function handler(ctx) {
+ const rootUrl = `https://forum.gamer.com.tw/B.php?bsn=${ctx.req.param('bsn')}`;
+ const response = await got({
+ url: rootUrl,
+ headers: {
+ Referer: 'https://forum.gamer.com.tw',
+ },
+ });
+
+ const $ = load(response.data);
+ const list = $('div.popular__card-list div.popular__card-img a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ link: item.attr('href'),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ url: item.link,
+ headers: {
+ Referer: rootUrl,
+ },
+ });
+ const content = load(detailResponse.data);
+
+ content('div.c-post__body__buttonbar').remove();
+
+ item.title = content('.c-post__header__title').text();
+ item.description = content('div.c-post__body').html();
+ item.author = `${content('a.username').eq(0).text()} (${content('a.userid').eq(0).text()})`;
+ item.pubDate = timezone(parseDate(content('a.edittime').eq(0).attr('data-mtime'), +8));
+
+ return item;
+ })
+ )
+ );
+
+ const ret = {
+ title: $('title').text(),
+ link: rootUrl,
+ item: items,
+ };
+
+ ctx.set('json', ret);
+ return ret;
+}
diff --git a/lib/routes/gamer/namespace.ts b/lib/routes/gamer/namespace.ts
new file mode 100644
index 00000000000000..17e532005c2ff2
--- /dev/null
+++ b/lib/routes/gamer/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '巴哈姆特電玩資訊站',
+ url: 'acg.gamer.com.tw',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/gamer520/index.ts b/lib/routes/gamer520/index.ts
new file mode 100644
index 00000000000000..ee8c9096b4f532
--- /dev/null
+++ b/lib/routes/gamer520/index.ts
@@ -0,0 +1,86 @@
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:category?/:order?',
+ categories: ['game'],
+ example: '/gamer520/switchyouxi',
+ parameters: {
+ category: '分类,见下表',
+ order: '排序,发布日期: date; 修改日期: modified',
+ },
+ features: {
+ antiCrawler: true,
+ },
+ name: '文章',
+ maintainers: ['xzzpig'],
+ handler,
+ url: 'www.gamer520.com/',
+ description: `分类
+
+| 所有 | Switch 游戏下载 | 金手指 | 3A 巨作 | switch 主题 | PC 游戏 |
+| ---- | --------------- | ---------- | ------- | ----------- | ------- |
+| all | switchyouxi | jinshouzhi | 3ajuzuo | zhuti | pcgame |`,
+};
+
+interface Post {
+ id: number;
+ guid: { rendered: string };
+ title: { rendered: string };
+ date_gmt: string;
+ modified_gmt: string;
+ categories?: number[];
+ content: { rendered: string };
+}
+
+interface Category {
+ id: number;
+ name: string;
+ link: string;
+ slug: string;
+}
+
+async function getCategories(baseUrl: string): Promise {
+ return (await cache.tryGet('gamer520:categories', async () => {
+ const { data } = await got(`${baseUrl}/wp-json/wp/v2/categories`);
+ return data.map((category) => ({ slug: category.slug, id: category.id, name: category.name, link: category.link }));
+ })) as Category[];
+}
+
+async function handler(ctx: Context): Promise {
+ const baseUrl = 'https://www.gamer520.com';
+ const categories = await getCategories(baseUrl);
+
+ const category = ctx.req.param('category') ?? 'all';
+ const order = ctx.req.param('order');
+ const categoryId = categories.find((c) => c.slug === category)?.id;
+
+ const { data } = (await got(`${baseUrl}/wp-json/wp/v2/posts`, {
+ searchParams: {
+ categories: categoryId,
+ orderby: order,
+ per_page: ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit') as string) : undefined,
+ },
+ })) as unknown as { data: Post[] };
+
+ const items: DataItem[] = data.map((item) => ({
+ guid: `gamer520:${item.id}`,
+ title: item.title.rendered,
+ link: item.guid.rendered,
+ pubDate: parseDate(item.date_gmt),
+ updated: parseDate(item.modified_gmt),
+ category: item.categories?.map((c) => categories.find((ca) => ca.id === c)?.name ?? '').filter((c) => c !== '') ?? [],
+
+ description: item.content.rendered,
+ }));
+
+ return {
+ title: '全球游戏交流中心-' + (categories.find((c) => c.slug === category)?.name ?? '所有'),
+ link: categories.find((c) => c.slug === category)?.link ?? baseUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/gamer520/namespace.ts b/lib/routes/gamer520/namespace.ts
new file mode 100644
index 00000000000000..6b63a7b0898efe
--- /dev/null
+++ b/lib/routes/gamer520/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '全球游戏交流中心',
+ url: 'www.gamer520.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/gamersecret/index.ts b/lib/routes/gamersecret/index.ts
new file mode 100644
index 00000000000000..4fb5df94f5f7cf
--- /dev/null
+++ b/lib/routes/gamersecret/index.ts
@@ -0,0 +1,107 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:type?/:category?',
+ categories: ['game'],
+ example: '/gamersecret',
+ parameters: { type: 'Type, see below, Latest News by default', category: 'Category, see below' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['gamersecret.com/:type', 'gamersecret.com/:type/:category', 'gamersecret.com/'],
+ },
+ ],
+ name: 'Category',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| Latest News | PC | Playstation | Nintendo | Xbox | Moblie |
+| ----------- | -- | ----------- | -------- | ---- | ------ |
+| latest-news | pc | playstation | nintendo | xbox | moblie |
+
+ Or
+
+| GENERAL | GENERAL EN | MOBILE | MOBILE EN |
+| ---------------- | ------------------ | --------------- | ----------------- |
+| category/general | category/generalen | category/mobile | category/mobileen |
+
+| NINTENDO | NINTENDO EN | PC | PC EN |
+| ----------------- | ------------------- | ----------- | ------------- |
+| category/nintendo | category/nintendoen | category/pc | category/pcen |
+
+| PLAYSTATION | PLAYSTATION EN | REVIEWS |
+| -------------------- | ---------------------- | ---------------- |
+| category/playstation | category/playstationen | category/reviews |
+
+| XBOX | XBOX EN |
+| ------------- | --------------- |
+| category/xbox | category/xboxen |`,
+};
+
+async function handler(ctx) {
+ const type = ctx.req.param('type') ?? 'latest-news';
+ const category = ctx.req.param('category') ?? '';
+
+ const rootUrl = 'https://www.gamersecret.com';
+ const currentUrl = `${rootUrl}/${type}${category ? `/${category}` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.jeg_post_title a')
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 20)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text(),
+ link: item.attr('href'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ content('img').each(function () {
+ content(this).attr('src', content(this).attr('data-src'));
+ });
+
+ item.author = content('.jeg_meta_author').text().replace(/by/, '');
+ item.pubDate = timezone(parseDate(detailResponse.data.match(/datePublished":"(.*)","dateModified/)[1]), +8);
+ item.description = content('.thumbnail-container').html() + content('.elementor-text-editor, .content-inner').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/gamersecret/namespace.ts b/lib/routes/gamersecret/namespace.ts
new file mode 100644
index 00000000000000..cbb306db411723
--- /dev/null
+++ b/lib/routes/gamersecret/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Gamer Secret',
+ url: 'gamersecret.com',
+ lang: 'en',
+};
diff --git a/lib/routes/gamersky/ent.js b/lib/routes/gamersky/ent.js
deleted file mode 100644
index 442c8b0d58ad8d..00000000000000
--- a/lib/routes/gamersky/ent.js
+++ /dev/null
@@ -1,89 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-const map = new Map([
- ['qysj', { title: '趣囧时间', suffix: 'ent/qw' }],
- ['ymyy', { title: '游民影院', suffix: 'wenku/movie' }],
- ['ygtx', { title: '游观天下', suffix: 'ent/discovery' }],
- ['bztk', { title: '壁纸图库', suffix: 'ent/wp' }],
- ['ympd', { title: '游民盘点', suffix: 'wenku' }],
- ['ymfl', { title: '游民福利', suffix: 'ent/xz/' }],
-]);
-
-module.exports = async (ctx) => {
- const category = ctx.params.category;
- const suffix = map.get(category).suffix;
- const title = map.get(category).title;
-
- const url = `https://www.gamersky.com/${suffix}`;
- const response = await got({
- method: 'get',
- url,
- });
-
- const data = response.data;
- const $ = cheerio.load(data);
-
- const list = $('ul.pictxt.contentpaging li')
- .slice(0, 10)
- .map(function () {
- const info = {
- title: $(this).find('div.tit a').text(),
- link: $(this).find('div.tit a').attr('href'),
- pubDate: new Date($(this).find('.time').text()).toUTCString(),
- };
- return info;
- })
- .get();
-
- const out = await Promise.all(
- list.map(async (info) => {
- const title = info.title;
- const itemUrl = info.link.startsWith('https://') ? info.link : `https://www.gamersky.com/${info.link}`;
- const pubDate = info.pubDate;
-
- const cache = await ctx.cache.get(itemUrl);
- if (cache) {
- return Promise.resolve(JSON.parse(cache));
- }
-
- const response = await got.get(itemUrl);
- const $ = cheerio.load(response.data);
-
- let next_pages = $('div.page_css a')
- .map(function () {
- return $(this).attr('href');
- })
- .get();
-
- next_pages = next_pages.slice(0, -1);
-
- const des = await Promise.all(
- next_pages.map(async (next_page) => {
- const response = await got.get(next_page);
- const $ = cheerio.load(response.data);
- $('div.page_css').remove();
-
- return $('.Mid2L_con').html().trim();
- })
- );
-
- const description = des.join('');
-
- const single = {
- title,
- link: itemUrl,
- description,
- pubDate,
- };
- ctx.cache.set(itemUrl, JSON.stringify(single));
- return Promise.resolve(single);
- })
- );
-
- ctx.state.data = {
- title: `游民娱乐-${title}`,
- link: url,
- item: out,
- };
-};
diff --git a/lib/routes/gamersky/ent.ts b/lib/routes/gamersky/ent.ts
new file mode 100644
index 00000000000000..595370067371db
--- /dev/null
+++ b/lib/routes/gamersky/ent.ts
@@ -0,0 +1,59 @@
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
+
+import { getArticle, getArticleList, mdTableBuilder, parseArticleList } from './utils';
+
+const idNameMap = new Map([
+ ['all', { title: '热点图文', suffix: 'ent', nodeId: '20107' }],
+ ['qw', { title: '趣囧时间', suffix: 'ent/qw', nodeId: '20113' }],
+ ['movie', { title: '游民影院', suffix: 'wenku/movie', nodeId: '20111' }],
+ ['discovery', { title: '游观天下', suffix: 'ent/discovery', nodeId: '20114' }],
+ ['wp', { title: '壁纸图库', suffix: 'ent/wp', nodeId: '20117' }],
+ ['wenku', { title: '游民盘点', suffix: 'wenku', nodeId: '20106' }],
+ ['xz', { title: '游民福利', suffix: 'ent/xz', nodeId: '20119' }],
+]);
+
+export const route: Route = {
+ path: '/ent/:category?',
+ categories: ['game'],
+ example: '/gamersky/ent/xz',
+ parameters: {
+ type: '分类类型,留空为 `all`',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: Object.entries(idNameMap).map(([type, { title, suffix }]) => ({
+ title,
+ source: [`www.gamersky.com/${suffix}`],
+ target: `/ent/${type}`,
+ })),
+ name: '娱乐',
+ maintainers: ['LogicJake'],
+ description: mdTableBuilder(Object.entries(idNameMap).map(([type, { title, nodeId }]) => ({ type, name: title, nodeId }))),
+ handler,
+};
+
+async function handler(ctx: Context) {
+ const category = ctx.req.param('category') ?? 'all';
+
+ const idName = idNameMap.get(category);
+ if (!idName) {
+ throw new Error(`Invalid type: ${category}`);
+ }
+
+ const response = await getArticleList(idName.nodeId);
+ const list = parseArticleList(response);
+ const fullTextList = await Promise.all(list.map((item) => getArticle(item)));
+ return {
+ title: `${idName.title} - 游民娱乐`,
+ link: `https://www.gamersky.com/${idName.suffix}`,
+ item: fullTextList,
+ };
+}
diff --git a/lib/routes/gamersky/namespace.ts b/lib/routes/gamersky/namespace.ts
new file mode 100644
index 00000000000000..8680998d7f3441
--- /dev/null
+++ b/lib/routes/gamersky/namespace.ts
@@ -0,0 +1,11 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'GamerSky',
+ url: 'gamersky.com',
+
+ zh: {
+ name: '游民星空',
+ },
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/gamersky/news.js b/lib/routes/gamersky/news.js
deleted file mode 100644
index b0d17c330b6780..00000000000000
--- a/lib/routes/gamersky/news.js
+++ /dev/null
@@ -1,34 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const response = await got({
- method: 'get',
- url: 'https://www.gamersky.com/news/',
- headers: {
- Referer: 'https://www.gamersky.com/news/',
- },
- });
-
- const data = response.data;
- const $ = cheerio.load(data);
-
- const out = $('.Mid2L_con li')
- .slice(0, 10)
- .map(function () {
- const info = {
- title: $(this).find('.tt').text(),
- link: $(this).find('.tt').attr('href'),
- pubDate: new Date($(this).find('.time').text()).toUTCString(),
- description: $(this).find('.txt').text(),
- };
- return info;
- })
- .get();
-
- ctx.state.data = {
- title: '游民星空-今日推荐',
- link: 'https://www.gamersky.com/news/',
- item: out,
- };
-};
diff --git a/lib/routes/gamersky/news.ts b/lib/routes/gamersky/news.ts
new file mode 100644
index 00000000000000..46b0f192f6ef9c
--- /dev/null
+++ b/lib/routes/gamersky/news.ts
@@ -0,0 +1,93 @@
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
+
+import { getArticle, getArticleList, mdTableBuilder, parseArticleList } from './utils';
+
+const idNameMap = [
+ {
+ type: 'today',
+ name: '今日推荐',
+ nodeId: '11007',
+ },
+ {
+ name: '单机电玩',
+ type: 'pc',
+ nodeId: '129',
+ },
+ {
+ name: 'NS',
+ type: 'ns',
+ nodeId: '21160',
+ },
+ {
+ name: '手游',
+ type: 'mobile',
+ nodeId: '20260',
+ },
+ {
+ name: '网游',
+ type: 'web',
+ nodeId: '20225',
+ },
+ {
+ name: '业界',
+ type: 'industry',
+ nodeId: '21163',
+ },
+ {
+ name: '硬件',
+ type: 'hardware',
+ nodeId: '20070',
+ },
+ {
+ name: '科技',
+ type: 'tech',
+ nodeId: '20547',
+ },
+];
+
+export const route: Route = {
+ path: '/news/:type?',
+ categories: ['game'],
+ example: '/gamersky/news/pc',
+ parameters: {
+ type: '资讯类型,见表,默认为 `pc`',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.gamersky.com/news'],
+ target: '/news',
+ },
+ ],
+ name: '资讯',
+ maintainers: ['yy4382'],
+ description: mdTableBuilder(idNameMap),
+ handler,
+};
+
+async function handler(ctx: Context) {
+ const type = ctx.req.param('type') ?? 'pc';
+
+ const idName = idNameMap.find((item) => item.type === type);
+ if (!idName) {
+ throw new Error(`Invalid type: ${type}`);
+ }
+
+ const response = await getArticleList(idName.nodeId);
+ const list = parseArticleList(response);
+ const fullTextList = await Promise.all(list.map((item) => getArticle(item)));
+ return {
+ title: `${idName.name} - 游民星空`,
+ link: 'https://www.gamersky.com/news',
+ item: fullTextList,
+ };
+}
diff --git a/lib/routes/gamersky/review.ts b/lib/routes/gamersky/review.ts
new file mode 100644
index 00000000000000..e5c55ad89e1442
--- /dev/null
+++ b/lib/routes/gamersky/review.ts
@@ -0,0 +1,83 @@
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
+
+import { getArticle, getArticleList, mdTableBuilder, parseArticleList } from './utils';
+
+const idNameMap = [
+ {
+ type: 'pc',
+ name: '单机',
+ nodeId: '20465',
+ },
+ {
+ type: 'tv',
+ name: '电视',
+ nodeId: '20466',
+ },
+ {
+ type: 'indie',
+ name: '独立游戏',
+ nodeId: '20922',
+ },
+ {
+ type: 'web',
+ name: '网游',
+ nodeId: '20916',
+ },
+ {
+ type: 'mobile',
+ name: '手游',
+ nodeId: '20917',
+ },
+ {
+ type: 'all',
+ name: '全部评测',
+ nodeId: '20915',
+ },
+];
+
+export const route: Route = {
+ path: '/review/:type?',
+ categories: ['game'],
+ example: '/gamersky/review/pc',
+ parameters: {
+ type: '评测类型,可选值为 `pc`、`tv`、`indie`、`web`、`mobile`、`all`,默认为 `pc`',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.gamersky.com/review'],
+ target: '/review',
+ },
+ ],
+ name: '评测',
+ maintainers: ['yy4382'],
+ description: mdTableBuilder(idNameMap),
+ handler,
+};
+
+async function handler(ctx: Context) {
+ const type = ctx.req.param('type') ?? 'pc';
+
+ const idName = idNameMap.find((item) => item.type === type);
+ if (!idName) {
+ throw new Error(`Invalid type: ${type}`);
+ }
+
+ const response = await getArticleList(idName.nodeId);
+ const list = parseArticleList(response);
+ const fullTextList = await Promise.all(list.map((item) => getArticle(item)));
+ return {
+ title: `${idName.name} - 游民星空评测`,
+ link: 'https://www.gamersky.com/review',
+ item: fullTextList,
+ };
+}
diff --git a/lib/routes/gamersky/utils.ts b/lib/routes/gamersky/utils.ts
new file mode 100644
index 00000000000000..c11f631ad3af7f
--- /dev/null
+++ b/lib/routes/gamersky/utils.ts
@@ -0,0 +1,94 @@
+import { load } from 'cheerio';
+
+import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+interface idNameMap {
+ type: string;
+ name: string;
+ nodeId: string;
+ suffix?: string;
+}
+interface ArticleList {
+ status: string;
+ totalPages: number;
+ body: string;
+}
+
+export const getArticleList = async (nodeId) => {
+ const response = await ofetch(
+ `https://db2.gamersky.com/LabelJsonpAjax.aspx?${new URLSearchParams({
+ jsondata: JSON.stringify({
+ type: 'updatenodelabel',
+ isCache: true,
+ cacheTime: 60,
+ nodeId,
+ isNodeId: 'true',
+ page: 1,
+ }),
+ })}`,
+ {
+ parseResponse: (txt) => JSON.parse(txt.match(/\((.+)\);/)?.[1] ?? '{}'),
+ }
+ );
+ return response.body;
+};
+
+export const parseArticleList = (response: string) => {
+ const $ = load(response);
+ return $('li')
+ .toArray()
+ .map((item) => {
+ const ele = $(item);
+ const a = ele.find('.tt').length ? ele.find('.tt') : ele.find('a');
+ const title = a.text();
+ const link = a.attr('href');
+ const pubDate = timezone(parseDate(ele.find('.time').text()), 8);
+ const description = ele.find('.txt').text();
+ if (!link) {
+ return;
+ }
+ return {
+ title,
+ link,
+ pubDate,
+ description,
+ };
+ })
+ .filter((item) => item !== undefined) satisfies DataItem[];
+};
+
+export const getArticle = (item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ const content = $('.Mid2L_con, .MidLcon');
+ content.find('.appGameBuyCardIframe, .GSAppButton, .Mid2L_down').remove();
+ content.find('a').each((_, item) => {
+ if (item.attribs.href?.startsWith('https://www.gamersky.com/showimage/id_gamersky.shtml?')) {
+ item.attribs.href = item.attribs.href.replace('https://www.gamersky.com/showimage/id_gamersky.shtml?', '');
+ }
+ });
+ content.find('img').each((_, item) => {
+ if (item.attribs.src === 'http://image.gamersky.com/webimg13/zhuanti/common/blank.png') {
+ item.attribs.src = item.attribs['data-origin'];
+ } else if (item.attribs.src.endsWith('_S.jpg')) {
+ item.attribs.src = item.attribs.src.replace('_S.jpg', '.jpg');
+ }
+ });
+ content.find('.Slides li').each((_, item) => {
+ if (item.attribs.style === 'display: none;') {
+ item.attribs.style = 'display: list-item;';
+ }
+ });
+ item.description = content.html() || item.description;
+ return item satisfies DataItem;
+ }) as Promise;
+
+export function mdTableBuilder(data: idNameMap[]) {
+ const table = '|' + data.map((item) => `${item.type}|`).join('') + '\n|' + Array.from({ length: data.length }).fill('---|').join('') + '\n|' + data.map((item) => `${item.name}|`).join('') + '\n';
+ return table;
+}
diff --git a/lib/routes/gamme/category.ts b/lib/routes/gamme/category.ts
new file mode 100644
index 00000000000000..ad48a2eca790aa
--- /dev/null
+++ b/lib/routes/gamme/category.ts
@@ -0,0 +1,61 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import parser from '@/utils/rss-parser';
+import { isValidHost } from '@/utils/valid-host';
+
+export const route: Route = {
+ path: '/:domain/:category?',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const { domain = 'news', category } = ctx.req.param();
+ if (!isValidHost(domain)) {
+ throw new InvalidParameterError('Invalid domain');
+ }
+ const baseUrl = `https://${domain}.gamme.com.tw`;
+ const feed = await parser.parseURL(`${baseUrl + (category ? `/category/${category}` : '')}/feed`);
+
+ const items = await Promise.all(
+ feed.items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data } = await got(item.link);
+ const $ = load(data);
+
+ $('.entry img').each((_, img) => {
+ if (img.attribs['data-original'] || img.attribs['data-src']) {
+ img.attribs.src = img.attribs['data-original'] || img.attribs['data-src'];
+ delete img.attribs['data-original'];
+ delete img.attribs['data-src'];
+ }
+ });
+
+ item.author = $('.author_name').text().trim();
+ item.category = $('.tags a')
+ .toArray()
+ .map((tag) => $(tag).text());
+ $('.social_block, .tags').remove();
+ item.description = $('.entry').html();
+
+ delete item.content;
+ delete item.contentSnippet;
+ delete item.isoDate;
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: feed.title,
+ link: feed.link,
+ image: domain === 'news' ? `${baseUrl}/blogico.ico` : `${baseUrl}/favicon.ico`,
+ description: feed.description,
+ item: items,
+ };
+}
diff --git a/lib/routes/gamme/namespace.ts b/lib/routes/gamme/namespace.ts
new file mode 100644
index 00000000000000..3ada6fbfc28e3c
--- /dev/null
+++ b/lib/routes/gamme/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '卡卡洛普',
+ url: 'news.gamme.com.tw',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/gamme/tag.ts b/lib/routes/gamme/tag.ts
new file mode 100644
index 00000000000000..cd70229d307890
--- /dev/null
+++ b/lib/routes/gamme/tag.ts
@@ -0,0 +1,72 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { isValidHost } from '@/utils/valid-host';
+
+export const route: Route = {
+ path: '/:domain/tag/:tag',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const { domain = 'news', tag } = ctx.req.param();
+ if (!isValidHost(domain)) {
+ throw new InvalidParameterError('Invalid domain');
+ }
+ const baseUrl = `https://${domain}.gamme.com.tw`;
+ const pageUrl = `${baseUrl}/tag/${tag}`;
+
+ const { data } = await got(pageUrl);
+ const $ = load(data);
+
+ const list = $('#category_new li a, .List-4 h3 a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.attr('title') || item.text(),
+ link: item.attr('href'),
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data } = await got(item.link);
+ const $ = load(data);
+
+ $('.entry img').each((_, img) => {
+ if (img.attribs['data-original'] || img.attribs['data-src']) {
+ img.attribs.src = img.attribs['data-original'] || img.attribs['data-src'];
+ delete img.attribs['data-original'];
+ delete img.attribs['data-src'];
+ }
+ });
+
+ item.author = $('.author_name').text().trim();
+ item.category = $('.tags a')
+ .toArray()
+ .map((tag) => $(tag).text());
+ $('.social_block, .tags').remove();
+ item.pubDate = parseDate($('.postDate').attr('content'));
+ item.description = $('.entry').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${tag} | ${domain === 'news' ? '宅宅新聞' : '西斯新聞'}`,
+ description: $('meta[name=description]').attr('content'),
+ link: pageUrl,
+ image: domain === 'news' ? `${baseUrl}/blogico.ico` : `${baseUrl}/favicon.ico`,
+ item: items,
+ };
+}
diff --git a/lib/routes/gaoqing/utils.js b/lib/routes/gaoqing/utils.js
deleted file mode 100644
index 4477f47a7041c1..00000000000000
--- a/lib/routes/gaoqing/utils.js
+++ /dev/null
@@ -1,57 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-// 加载详情页
-async function load(link) {
- const response = await got.get(link);
- const $ = cheerio.load(response.data);
-
- // 提取标题
- const title = $('#mainrow > div:nth-child(2) > div.col-md-9 > div.row > div.col-md-12').html();
-
- // 提取图片
- const img = $('.x-m-poster').html();
-
- // 提取资料
- $('#viewfilm').find('img').remove();
- const info = $('#viewfilm').html();
-
- // 提取简介
- const intro = $('#des-ex').html() || $('#des-full').html();
-
- // 合并为描述
- const description = title + img + info + intro;
-
- return { description };
-}
-
-const ProcessFeed = (list, caches) =>
- Promise.all(
- list.map(async (item) => {
- const $ = cheerio.load(item);
-
- // 获取链接
- const $title = $('div.item-desc.pull-left > p > a');
- const itemUrl = $title.attr('href');
-
- // 获取评分
- const rate = $('div.item-desc.pull-left > p > span').text();
-
- // 列表上提取到的信息
- const single = {
- title: $title.text() + ' - ' + rate,
- link: itemUrl,
- guid: itemUrl,
- };
-
- // 缓存
- const other = await caches.tryGet(itemUrl, () => load(itemUrl));
-
- // 合并结果
- return Promise.resolve(Object.assign({}, single, other));
- })
- );
-
-module.exports = {
- ProcessFeed,
-};
diff --git a/lib/routes/gaoyu/blog.ts b/lib/routes/gaoyu/blog.ts
new file mode 100644
index 00000000000000..3a3a9c855cb79f
--- /dev/null
+++ b/lib/routes/gaoyu/blog.ts
@@ -0,0 +1,147 @@
+import path from 'node:path';
+
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '20', 10);
+
+ const baseUrl: string = 'https://www.gaoyu.me';
+ const targetUrl: string = new URL('blog', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-cn';
+
+ const authors: DataItem['author'] = [
+ {
+ name: 'Yu Gao',
+ url: baseUrl,
+ avatar: undefined,
+ },
+ ];
+
+ let items: DataItem[] = [];
+
+ items = $('a.flex-col')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('p.text-neutral-900').text();
+ const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), {
+ intro: $el.find('p.text-neutral-600').last().html(),
+ });
+ const pubDateStr: string | undefined = $el.find('p.text-neutral-600').first().text();
+ const linkUrl: string | undefined = $el.attr('href');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('h1.title').text();
+ const description: string | undefined =
+ item.description +
+ art(path.join(__dirname, 'templates/description.art'), {
+ description: $$('article.prose').html(),
+ });
+ const pubDateStr: string | undefined = $$('meta[property="article:published_time"]').attr('content');
+ const image: string | undefined = $$('meta[property="og:image"]').attr('content');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/blog',
+ name: 'Blog',
+ url: 'www.gaoyu.me',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/gaoyu/blog',
+ parameters: undefined,
+ description: undefined,
+ categories: ['blog'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.gaoyu.me/blog'],
+ target: '/blog',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/gaoyu/namespace.ts b/lib/routes/gaoyu/namespace.ts
new file mode 100644
index 00000000000000..ff5c8403e243dc
--- /dev/null
+++ b/lib/routes/gaoyu/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Yu Gao',
+ url: 'gaoyu.me',
+ categories: ['blog'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/gaoyu/templates/description.art b/lib/routes/gaoyu/templates/description.art
new file mode 100644
index 00000000000000..57498ab45a9d86
--- /dev/null
+++ b/lib/routes/gaoyu/templates/description.art
@@ -0,0 +1,7 @@
+{{ if intro }}
+ {{ intro }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/gc.ca/namespace.ts b/lib/routes/gc.ca/namespace.ts
new file mode 100644
index 00000000000000..3a6e6fa97a8381
--- /dev/null
+++ b/lib/routes/gc.ca/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Prime Minister of Canada',
+ url: 'pm.gc.ca',
+ lang: 'en',
+};
diff --git a/lib/routes/gc.ca/pm-news.ts b/lib/routes/gc.ca/pm-news.ts
new file mode 100644
index 00000000000000..364389d2a1505f
--- /dev/null
+++ b/lib/routes/gc.ca/pm-news.ts
@@ -0,0 +1,79 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/pm/:language?',
+ categories: ['government'],
+ example: '/gc.ca/pm/en',
+ parameters: { language: 'Language (en or fr)' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['pm.gc.ca', 'pm.gc.ca/:language', 'pm.gc.ca/:language/news', 'pm.gc.ca/:language/nouvelles'],
+ target: '/pm/:language',
+ },
+ ],
+ name: 'News',
+ maintainers: ['elibroftw'],
+ handler: async (ctx: Context): Promise => {
+ const { language = 'en' } = ctx.req.param();
+
+ const ajaxURL = language === 'fr' ? 'https://www.pm.gc.ca/fr/views/ajax' : 'https://www.pm.gc.ca/views/ajax';
+
+ const response = await ofetch(ajaxURL, {
+ method: 'post',
+ body: new URLSearchParams({ view_name: 'news', view_display_id: 'page_1', view_args: '', page: '0' }).toString(),
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ });
+
+ const replaceItem = response.find((item: any) => item.method === 'replaceWith');
+ if (!replaceItem) {
+ throw new Error('failed to parse AJAX response');
+ }
+
+ const $ = load(replaceItem.data);
+ const items: DataItem[] = $('.news-row')
+ .toArray()
+ .map((element) => {
+ const $element = $(element);
+ const $titleLink = $element.find('.title a');
+ const $category = $element.find('.category');
+ const $date = $element.find('.location-date time');
+
+ const title = $titleLink.text().trim();
+ const link = $titleLink.attr('href')!;
+ const category = $category.text().trim();
+ const date = $date.attr('datetime') || '';
+
+ if (title && link) {
+ return {
+ title,
+ link,
+ category: [category],
+ pubDate: date ? parseDate(date) : undefined,
+ } as DataItem;
+ }
+ return null;
+ })
+ .filter((item) => item !== null);
+
+ return {
+ title: language === 'fr' ? 'Premier ministre du Canada | Nouvelles' : 'Prime Minister of Canada | News',
+ link: `https://www.pm.gc.ca/${language}/news`,
+ item: items,
+ };
+ },
+};
diff --git a/lib/routes/gcores/articles.ts b/lib/routes/gcores/articles.ts
new file mode 100644
index 00000000000000..8322f4ea163db7
--- /dev/null
+++ b/lib/routes/gcores/articles.ts
@@ -0,0 +1,51 @@
+import type { Context } from 'hono';
+
+import type { Data, Route } from '@/types';
+import { ViewType } from '@/types';
+
+import { baseUrl, processItems } from './util';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const targetUrl: string = new URL('articles', baseUrl).href;
+ const apiUrl: string = new URL(`gapi/v1/articles`, baseUrl).href;
+
+ const query = {
+ 'page[limit]': limit,
+ sort: '-published-at',
+ include: 'category,user,media',
+ 'filter[list-all]': 1,
+ 'filter[is-news]': 0,
+ };
+
+ return await processItems(limit, query, apiUrl, targetUrl);
+};
+
+export const route: Route = {
+ path: '/articles',
+ name: '文章',
+ url: 'www.gcores.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/gcores/articles',
+ parameters: undefined,
+ description: undefined,
+ categories: ['game'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.gcores.com/articles'],
+ target: '/gcores/articles',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/gcores/categories.ts b/lib/routes/gcores/categories.ts
new file mode 100644
index 00000000000000..61785b68a62050
--- /dev/null
+++ b/lib/routes/gcores/categories.ts
@@ -0,0 +1,127 @@
+import type { Context } from 'hono';
+
+import type { Data, Route } from '@/types';
+import { ViewType } from '@/types';
+
+import { baseUrl, processItems } from './util';
+
+let viewType: ViewType = ViewType.Articles;
+
+export const handler = async (ctx: Context): Promise => {
+ const { id, tab } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const targetUrl: string = new URL(`categories/${id}${tab ? `?tab=${tab}` : ''}`, baseUrl).href;
+ const apiUrl: string = new URL(`gapi/v1/categories/${id}/${tab ?? 'originals'}`, baseUrl).href;
+
+ const query = {
+ 'page[limit]': limit,
+ sort: '-published-at',
+ include: 'category,user,media',
+ 'filter[list-all]': 1,
+ 'filter[is-news]': tab === 'news' ? 1 : 0,
+ };
+
+ if (tab === 'radios') {
+ viewType = ViewType.Audios;
+ } else if (tab === 'videos') {
+ viewType = ViewType.Videos;
+ }
+
+ return await processItems(limit, query, apiUrl, targetUrl);
+};
+
+export const route: Route = {
+ path: '/categories/:id/:tab?',
+ name: '分类',
+ url: 'www.gcores.com',
+ maintainers: ['MoguCloud', 'StevenRCE0', 'nczitzk'],
+ handler,
+ example: '/gcores/categories/1/articles',
+ parameters: {
+ id: {
+ description: '分类 ID,可在对应分类页 URL 中找到',
+ },
+ tab: {
+ description: '类型,默认为空,即全部,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '全部',
+ value: '',
+ },
+ {
+ label: '播客',
+ value: 'radios',
+ },
+ {
+ label: '文章',
+ value: 'articles',
+ },
+ {
+ label: '资讯',
+ value: 'news',
+ },
+ {
+ label: '视频',
+ value: 'videos',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+若订阅 [文章 - 文章](https://www.gcores.com/categories/1?tab=articles),网址为 \`https://www.gcores.com/categories/1?tab=articles\`,请截取 \`https://www.gcores.com/categories/\` 到末尾的部分 \`1\` 作为 \`id\` 参数填入,截取 \`articles\` 作为 \`tab\` 参数填入,此时目标路由为 [\`/gcores/categories/1/articles\`](https://rsshub.app/gcores/categories/1/articles)。
+:::
+
+| 全部 | 播客 | 文章 | 资讯 | 视频 |
+| ---- | ------ | -------- | ---- | ------ |
+| | radios | articles | news | videos |
+`,
+ categories: ['game'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.gcores.com/categories/:id'],
+ target: (params, url) => {
+ const urlObj: URL = new URL(url);
+ const id: string = params.id;
+ const tab: string | undefined = urlObj.searchParams.get('tab') ?? undefined;
+
+ return `/gcores/categories/${id}/${tab ? `/${tab}` : ''}`;
+ },
+ },
+ {
+ title: '全部',
+ source: ['www.gcores.com/categories/:id'],
+ target: '/gcores/categories/:id',
+ },
+ {
+ title: '播客',
+ source: ['www.gcores.com/categories/:id'],
+ target: '/categories/:id/radios',
+ },
+ {
+ title: '文章',
+ source: ['www.gcores.com/categories/:id'],
+ target: '/categories/:id/articles',
+ },
+ {
+ title: '资讯',
+ source: ['www.gcores.com/categories/:id'],
+ target: '/categories/:id/news',
+ },
+ {
+ title: '视频',
+ source: ['www.gcores.com/categories/:id'],
+ target: '/categories/:id/videos',
+ },
+ ],
+ view: viewType,
+};
diff --git a/lib/routes/gcores/collections.ts b/lib/routes/gcores/collections.ts
new file mode 100644
index 00000000000000..47cf12c67bf9d6
--- /dev/null
+++ b/lib/routes/gcores/collections.ts
@@ -0,0 +1,127 @@
+import type { Context } from 'hono';
+
+import type { Data, Route } from '@/types';
+import { ViewType } from '@/types';
+
+import { baseUrl, processItems } from './util';
+
+let viewType: ViewType = ViewType.Articles;
+
+export const handler = async (ctx: Context): Promise => {
+ const { id, tab } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const targetUrl: string = new URL(`collections/${id}${tab ? `?tab=${tab}` : ''}`, baseUrl).href;
+ const apiUrl: string = new URL(`gapi/v1/collections/${id}/${tab ?? 'originals'}`, baseUrl).href;
+
+ const query = {
+ 'page[limit]': limit,
+ sort: '-published-at',
+ include: 'category,user,media',
+ 'filter[list-all]': 1,
+ 'filter[is-news]': tab === 'news' ? 1 : 0,
+ };
+
+ if (tab === 'radios') {
+ viewType = ViewType.Audios;
+ } else if (tab === 'videos') {
+ viewType = ViewType.Videos;
+ }
+
+ return await processItems(limit, query, apiUrl, targetUrl);
+};
+
+export const route: Route = {
+ path: '/collections/:id/:tab?',
+ name: '专题',
+ url: 'www.gcores.com',
+ maintainers: ['kudryavka1013', 'nczitzk'],
+ handler,
+ example: '/gcores/collections/64/articles',
+ parameters: {
+ id: {
+ description: '专题 ID,可在对应专题页 URL 中找到',
+ },
+ tab: {
+ description: '类型,默认为空,即全部,可在对应专题页 URL 中找到',
+ options: [
+ {
+ label: '全部',
+ value: '',
+ },
+ {
+ label: '播客',
+ value: 'radios',
+ },
+ {
+ label: '文章',
+ value: 'articles',
+ },
+ {
+ label: '资讯',
+ value: 'news',
+ },
+ {
+ label: '视频',
+ value: 'videos',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+若订阅 [文章 - 文章](https://www.gcores.com/collections/64?tab=articles),网址为 \`https://www.gcores.com/collections/64?tab=articles\`,请截取 \`https://www.gcores.com/collections/\` 到末尾的部分 \`64\` 作为 \`id\` 参数填入,截取 \`articles\` 作为 \`tab\` 参数填入,此时目标路由为 [\`/gcores/collections/64/articles\`](https://rsshub.app/gcores/collections/64/articles)。
+:::
+
+| 全部 | 播客 | 文章 | 资讯 | 视频 |
+| ---- | ------ | -------- | ---- | ------ |
+| | radios | articles | news | videos |
+`,
+ categories: ['game'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.gcores.com/collections/:id'],
+ target: (params, url) => {
+ const urlObj: URL = new URL(url);
+ const id: string = params.id;
+ const tab: string | undefined = urlObj.searchParams.get('tab') ?? undefined;
+
+ return `/gcores/collections/${id}/${tab ? `/${tab}` : ''}`;
+ },
+ },
+ {
+ title: '全部',
+ source: ['www.gcores.com/collections/:id'],
+ target: '/collections/:id',
+ },
+ {
+ title: '播客',
+ source: ['www.gcores.com/collections/:id'],
+ target: '/collections/:id/radios',
+ },
+ {
+ title: '文章',
+ source: ['www.gcores.com/collections/:id'],
+ target: '/collections/:id/articles',
+ },
+ {
+ title: '资讯',
+ source: ['www.gcores.com/collections/:id'],
+ target: '/collections/:id/news',
+ },
+ {
+ title: '视频',
+ source: ['www.gcores.com/collections/:id'],
+ target: '/collections/:id/videos',
+ },
+ ],
+ view: viewType,
+};
diff --git a/lib/routes/gcores/namespace.ts b/lib/routes/gcores/namespace.ts
new file mode 100644
index 00000000000000..a7d59141f6950d
--- /dev/null
+++ b/lib/routes/gcores/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '机核网',
+ url: 'gcores.com',
+ lang: 'zh-CN',
+ description: '机核 GCORES',
+};
diff --git a/lib/routes/gcores/news.ts b/lib/routes/gcores/news.ts
new file mode 100644
index 00000000000000..4a3d5f94394b4a
--- /dev/null
+++ b/lib/routes/gcores/news.ts
@@ -0,0 +1,51 @@
+import type { Context } from 'hono';
+
+import type { Data, Route } from '@/types';
+import { ViewType } from '@/types';
+
+import { baseUrl, processItems } from './util';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const targetUrl: string = new URL('news', baseUrl).href;
+ const apiUrl: string = new URL('gapi/v1/articles', baseUrl).href;
+
+ const query = {
+ 'page[limit]': limit,
+ sort: '-published-at',
+ include: 'category,user,media',
+ 'filter[list-all]': 1,
+ 'filter[is-news]': 1,
+ };
+
+ return await processItems(limit, query, apiUrl, targetUrl);
+};
+
+export const route: Route = {
+ path: '/news',
+ name: '资讯',
+ url: 'www.gcores.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/gcores/news',
+ parameters: undefined,
+ description: undefined,
+ categories: ['game'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.gcores.com/news'],
+ target: '/gcores/news',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/gcores/parser.ts b/lib/routes/gcores/parser.ts
new file mode 100644
index 00000000000000..e11f4712769638
--- /dev/null
+++ b/lib/routes/gcores/parser.ts
@@ -0,0 +1,281 @@
+import path from 'node:path';
+
+import { art } from '@/utils/render';
+
+interface Style {
+ [key: string]: string;
+}
+
+interface BlockType {
+ element: string | undefined;
+ parentElement?: string;
+ aliasedElements?: string[];
+}
+
+interface InlineStyleRange {
+ offset: number;
+ length: number;
+ style: string;
+}
+
+interface EntityRange {
+ offset: number;
+ length: number;
+ key: number;
+}
+
+interface Entity {
+ type: string;
+ mutability: string;
+ data: any;
+}
+
+interface Block {
+ key: string;
+ text: string;
+ type: string;
+ depth: number;
+ inlineStyleRanges: InlineStyleRange[];
+ entityRanges: EntityRange[];
+ data: any;
+}
+
+interface Content {
+ blocks: Block[];
+ entityMap: { [key: string]: Entity };
+}
+
+const imageBaseUrl: string = 'https://image.gcores.com';
+
+const STYLES: Readonly> = {
+ BOLD: { fontWeight: 'bold' },
+ CODE: { fontFamily: 'monospace', wordWrap: 'break-word' },
+ ITALIC: { fontStyle: 'italic' },
+ STRIKETHROUGH: { textDecoration: 'line-through' },
+ UNDERLINE: { textDecoration: 'underline' },
+};
+
+const BLOCK_TYPES: Readonly> = {
+ 'header-one': { element: 'h1' },
+ 'header-two': { element: 'h2' },
+ 'header-three': { element: 'h3' },
+ 'header-four': { element: 'h4' },
+ 'header-five': { element: 'h5' },
+ 'header-six': { element: 'h6' },
+ 'unordered-list-item': { element: 'li', parentElement: 'ul' },
+ 'ordered-list-item': { element: 'li', parentElement: 'ol' },
+ blockquote: { element: 'blockquote' },
+ atomic: { element: undefined },
+ 'code-block': { element: 'pre' },
+ unstyled: { element: 'p' },
+};
+
+/**
+ * Creates a styled HTML fragment for a given text and style object.
+ * @param text - The text content.
+ * @param style - CSS styles as key-value pairs.
+ * @returns HTML string with applied styles.
+ */
+const createStyledFragment = (text: string, style: Readonly
+
+ {{@ content }}
+
+
\ No newline at end of file
diff --git a/lib/routes/trendforce/namespace.ts b/lib/routes/trendforce/namespace.ts
new file mode 100644
index 00000000000000..b52d0094a23679
--- /dev/null
+++ b/lib/routes/trendforce/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'TrendForce',
+ url: 'trendforce.com',
+ categories: ['new-media'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/trendforce/new.ts b/lib/routes/trendforce/new.ts
new file mode 100644
index 00000000000000..c2a2555fadc3a2
--- /dev/null
+++ b/lib/routes/trendforce/new.ts
@@ -0,0 +1,114 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '10', 10);
+
+ const apiSlug = 'wp-json/wp/v2';
+ const baseUrl: string = 'https://www.trendforce.com';
+ const targetUrl: string = new URL('news/', baseUrl).href;
+ const apiUrl = new URL(`${apiSlug}/posts`, targetUrl).href;
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ _embed: 'true',
+ per_page: limit,
+ },
+ });
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'en';
+
+ const items: DataItem[] = response.slice(0, limit).map((item): DataItem => {
+ const title: string = item.title?.rendered ?? item.title;
+
+ const $$: CheerioAPI = load(item.content.rendered);
+
+ $$('div.article_highlight-area-BG_wrap').remove();
+
+ const description: string | undefined = $$.html();
+ const pubDate: number | string = item.date_gmt;
+ const linkUrl: string | undefined = item.link;
+
+ const terminologies = item._embedded?.['wp:term'];
+
+ const categories: string[] = terminologies?.flat().map((c) => c.name) ?? [];
+ const authors: DataItem['author'] =
+ item._embedded?.author.map((author) => ({
+ name: author.name,
+ url: author.url,
+ avatar: undefined,
+ })) ?? [];
+ const guid: string = item.guid?.rendered ?? item.guid;
+ const image: string | undefined = item._embedded?.['wp:featuredmedia']?.[0].source_url ?? undefined;
+ const updated: number | string = item.modified_gmt ?? pubDate;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDate ? parseDate(pubDate) : undefined,
+ link: linkUrl ?? guid,
+ category: categories,
+ author: authors,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: updated ? parseDate(updated) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/news',
+ name: 'News',
+ url: 'www.trendforce.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/trendforce/news',
+ parameters: undefined,
+ description: undefined,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.trendforce.com/news/'],
+ target: '/news',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/trendingpapers/namespace.ts b/lib/routes/trendingpapers/namespace.ts
new file mode 100644
index 00000000000000..067a8442c06d33
--- /dev/null
+++ b/lib/routes/trendingpapers/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Trending Papers',
+ url: 'trendingpapers.com',
+ lang: 'en',
+};
diff --git a/lib/routes/trendingpapers/papers.ts b/lib/routes/trendingpapers/papers.ts
new file mode 100644
index 00000000000000..33d3a11cc94b01
--- /dev/null
+++ b/lib/routes/trendingpapers/papers.ts
@@ -0,0 +1,59 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/papers/:category?/:time?/:cited?',
+ categories: ['journal'],
+ example: '/trendingpapers/papers',
+ parameters: {
+ category: 'Category of papers, can be found in URL. `All categories` by default.',
+ time: 'Time like `24 hours` to specify the duration of ranking, can be found in URL. `Since beginning` by default.',
+ cited: 'Cited or uncited papers, can be found in URL. `Cited and uncited papers` by default.',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Trending Papers on arXiv',
+ maintainers: ['CookiePieWw'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { time = 'Since beginning', cited = 'Cited and uncited papers', category = 'All categories' } = ctx.req.param();
+
+ const rootUrl = 'https://trendingpapers.com';
+ const currentUrl = `${rootUrl}/api/papers?p=1&o=pagerank_growth&pd=${time}&cc=${cited}&c=${category}`;
+
+ const response = await ofetch(currentUrl);
+
+ const papers = response.data.map((_) => {
+ const title = _.title;
+ const abstract = _.abstract;
+ const url = _.url;
+ const arxivId = _.arxiv_id;
+
+ const pubDate = parseDate(_.pub_date);
+ const summaryCategories = _.summary_categories;
+
+ return {
+ title,
+ description: abstract,
+ link: url,
+ guid: arxivId,
+ pubDate,
+ category: summaryCategories,
+ };
+ });
+
+ return {
+ title: `Trending Papers on arXiv.org | ${category} | ${time} | ${cited} | `,
+ link: currentUrl,
+ item: papers,
+ };
+}
diff --git a/lib/routes/tribalfootball/latest.ts b/lib/routes/tribalfootball/latest.ts
new file mode 100644
index 00000000000000..149f87e8965701
--- /dev/null
+++ b/lib/routes/tribalfootball/latest.ts
@@ -0,0 +1,86 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+const rssUrl = 'https://www.tribalfootball.com/rss/mediafed/general/rss.xml';
+
+export const route: Route = {
+ path: '/',
+ radar: [
+ {
+ source: ['tribalfootball.com/'],
+ target: '',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['Rongronggg9'],
+ handler,
+ url: 'tribalfootball.com/',
+};
+
+async function handler() {
+ const rss = await got(rssUrl);
+ const $ = load(rss.data, { xmlMode: true });
+ const items = $('rss > channel > item')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ let link = $item.find('link').text();
+ link = new URL(link);
+ link.search = '';
+ link = link.href;
+ return {
+ title: $item.find('title').text(),
+ description: $item.find('description').text(),
+ link,
+ guid: $item.find('guid').text(),
+ pubDate: parseDate($item.find('pubDate').text()),
+ author: $item.find(String.raw`dc\:creator`).text(),
+ _header_image: $item.find('enclosure').attr('url'),
+ };
+ });
+
+ await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+
+ const title = $('head > title').text().replace(' - Tribal Football', '');
+
+ let desc = $('.articleBody');
+ desc.find('.ad').remove();
+ // AD
+ const ad = desc.find('p > br:first-child').next('i');
+ const adNextSpan = ad.next('span');
+ if (adNextSpan.length && !adNextSpan.text() && !adNextSpan.next().length) {
+ ad.parent().remove();
+ }
+ desc = desc.html();
+ desc = art(path.join(__dirname, 'templates/plus_header.art'), {
+ desc,
+ header_image: item._header_image,
+ });
+
+ item.title = title || item.title;
+ item.description = desc || item.description;
+ delete item._header_image;
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: 'Tribal Football - Latest',
+ description: 'Tribal Football - Football News, Soccer News, Transfers & Rumours',
+ link: 'https://www.tribalfootball.com/articles',
+ image: 'https://www.tribalfootball.com/images/tribal-logo-rss.png',
+ item: items,
+ };
+}
diff --git a/lib/routes/tribalfootball/namespace.ts b/lib/routes/tribalfootball/namespace.ts
new file mode 100644
index 00000000000000..586c6b26e0c6e4
--- /dev/null
+++ b/lib/routes/tribalfootball/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Tribal Football',
+ url: 'tribalfootball.com',
+ lang: 'en',
+};
diff --git a/lib/v2/tribalfootball/templates/plus_header.art b/lib/routes/tribalfootball/templates/plus_header.art
similarity index 100%
rename from lib/v2/tribalfootball/templates/plus_header.art
rename to lib/routes/tribalfootball/templates/plus_header.art
diff --git a/lib/routes/trow/namespace.ts b/lib/routes/trow/namespace.ts
new file mode 100644
index 00000000000000..bd71984531efd1
--- /dev/null
+++ b/lib/routes/trow/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'The Ring of Wonder',
+ url: 'trow.cc',
+ lang: 'en',
+};
diff --git a/lib/routes/trow/portal.ts b/lib/routes/trow/portal.ts
new file mode 100644
index 00000000000000..c814477d3026ba
--- /dev/null
+++ b/lib/routes/trow/portal.ts
@@ -0,0 +1,68 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/portal',
+ categories: ['bbs'],
+ example: '/trow/portal',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['trow.cc/'],
+ },
+ ],
+ name: '首页更新',
+ maintainers: ['shiningdracon'],
+ handler,
+ url: 'trow.cc/',
+};
+
+async function handler() {
+ let data;
+ const response = await got.extend({ followRedirect: false }).get({
+ url: `https://trow.cc`,
+ });
+ if (response.statusCode === 302) {
+ const response2 = await got.extend({ followRedirect: false }).get({
+ url: `https://trow.cc`,
+ headers: {
+ cookie: response.headers['set-cookie'],
+ },
+ });
+ data = response2.data;
+ } else {
+ data = response.data;
+ }
+
+ const $ = load(data);
+ const list = $('#portal_content .borderwrap[style="display:show"]');
+
+ return {
+ title: `The Ring of Wonder - Portal`,
+ link: `https://trow.cc`,
+ description: `The Ring of Wonder 首页更新`,
+ item: list.toArray().map((item) => {
+ item = $(item);
+ const dateraw = item.find('.postdetails').text();
+ return {
+ title: item.find('.maintitle p:nth-child(2) > a').text(),
+ description: item.find('.portal_news_content .row18').html(),
+ link: item.find('.maintitle p:nth-child(2) > a').attr('href'),
+ author: item.find('.postdetails a').text(),
+ pubDate: timezone(parseDate(dateraw.slice(3), 'YYYY-MM-DD, HH:mm'), +8),
+ };
+ }),
+ };
+}
diff --git a/lib/routes/tsdm39/bd.ts b/lib/routes/tsdm39/bd.ts
new file mode 100644
index 00000000000000..399a1db38b0698
--- /dev/null
+++ b/lib/routes/tsdm39/bd.ts
@@ -0,0 +1,102 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+// type id => display name
+type Mapping = Record;
+
+const TYPE: Mapping = {
+ '403': '720P',
+ '404': '1080P',
+ '405': 'BDMV',
+ '4130': '4K',
+ '5815': 'AV1',
+};
+
+// render into MD table
+const mkTable = (mapping: Mapping): string => {
+ const heading: string[] = [],
+ separator: string[] = [],
+ body: string[] = [];
+
+ for (const key in mapping) {
+ heading.push(mapping[key]);
+ separator.push(':--:');
+ body.push(key);
+ }
+
+ return [heading.join(' | '), separator.join(' | '), body.join(' | ')].map((s) => `| ${s} |`).join('\n');
+};
+
+const handler: Route['handler'] = async (ctx) => {
+ const { type } = ctx.req.param();
+
+ const cookie = config.tsdm39.cookie;
+ if (!cookie) {
+ throw new ConfigNotFoundError('缺少 TSDM39 用户登录后的 Cookie 值 TSDM 相关路由 ');
+ }
+
+ const html = await ofetch(`https://www.tsdm39.com/forum.php?mod=forumdisplay&fid=85${type ? `&filter=typeid&typeid=${type}` : ''}`, {
+ headers: {
+ Cookie: cookie,
+ },
+ });
+
+ const $ = load(html);
+
+ const item = $('tbody.tsdm_normalthread')
+ .toArray()
+ .map((item) => {
+ const $ = load(item);
+
+ const title = $('a.xst').text();
+ const price = $('span.xw1').last().text();
+ const link = $('a.xst').attr('href');
+ const date = $('td.by em').first().text().trim();
+
+ return {
+ title,
+ description: `价格:${price}`,
+ link,
+ pubDate: parseDate(date),
+ };
+ });
+
+ return {
+ title: '天使动漫论坛 - BD',
+ link: 'https://www.tsdm39.com/forum.php?mod=forumdisplay&fid=85',
+ language: 'zh-Hans',
+ item,
+ };
+};
+
+export const route: Route = {
+ path: '/bd/:type?',
+ name: 'BD',
+ categories: ['anime'],
+ maintainers: ['equt'],
+ example: '/tsdm39/bd',
+ parameters: {
+ type: 'BD type, checkout the table below for details',
+ },
+ features: {
+ requireConfig: [
+ {
+ name: 'TSDM39_COOKIES',
+ optional: false,
+ description: '天使动漫论坛登陆后的 cookie 值,可在浏览器控制台通过 `document.cookie` 获取。',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ handler,
+ description: [TYPE].map((el) => mkTable(el)).join('\n\n'),
+};
diff --git a/lib/routes/tsdm39/namespace.ts b/lib/routes/tsdm39/namespace.ts
new file mode 100644
index 00000000000000..f6cad579744093
--- /dev/null
+++ b/lib/routes/tsdm39/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '天使动漫论坛',
+ url: 'www.tsdm39.com',
+ categories: ['anime'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/tsinghua/lib/tzgg.ts b/lib/routes/tsinghua/lib/tzgg.ts
new file mode 100644
index 00000000000000..b78f63a7842339
--- /dev/null
+++ b/lib/routes/tsinghua/lib/tzgg.ts
@@ -0,0 +1,74 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/lib/tzgg/:category',
+ categories: ['university'],
+ example: '/tsinghua/lib/tzgg/qtkx',
+ parameters: { category: '分类,可在对应分类页 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['lib.tsinghua.edu.cn/tzgg/:category'],
+ },
+ ],
+ name: '图书馆通知公告',
+ maintainers: ['linsenwang'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { category } = ctx.req.param();
+ const host = `https://lib.tsinghua.edu.cn/tzgg/${category}.htm`;
+ const response = await ofetch(host);
+ const $ = load(response);
+
+ const feedTitle = $('.tags .on').text();
+
+ const list = $('ul.notice-list li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const title = item.find('a').first().text();
+ const time = item.find('.notice-date').first().text();
+ const a = item.find('a').first().attr('href');
+
+ const fullUrl = new URL(a, host).href;
+
+ return {
+ title,
+ link: fullUrl,
+ pubDate: time,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+
+ item.description = $('.v_news_content').first().html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ allowEmpty: true,
+ title: '图书馆通知公告 - ' + feedTitle,
+ link: host,
+ item: items,
+ };
+}
diff --git a/lib/routes/tsinghua/lib/zydt.ts b/lib/routes/tsinghua/lib/zydt.ts
new file mode 100644
index 00000000000000..37274420cf23ea
--- /dev/null
+++ b/lib/routes/tsinghua/lib/zydt.ts
@@ -0,0 +1,129 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx) => {
+ const { category } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 10;
+
+ const rootUrl = 'https://lib.tsinghua.edu.cn';
+ const currentUrl = new URL(`zydt${category ? `/${category}` : ''}.htm`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('ul.notice-list li')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.find('div.notice-list-tt a').text(),
+ pubDate: parseDate(item.find('div.notice-date').text(), 'YYYY/MM/DD'),
+ link: new URL(item.find('div.notice-list-tt a').prop('href'), rootUrl).href,
+ category: item
+ .find('div.notice-label')
+ .toArray()
+ .map((c) => $(c).text()),
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ const title = $$('h2').text();
+ const description = $$('div.v_news_content').html();
+
+ item.title = title;
+ item.description = description;
+ item.content = {
+ html: description,
+ text: $$('div.v_news_content').text(),
+ };
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const title = $('title').text();
+ const image = new URL($('div.logo a img').prop('href'), rootUrl).href;
+
+ return {
+ title,
+ description: $('META[Name="keywords"]').prop('Content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: title.split(/-/).pop(),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/lib/zydt/:category?',
+ name: '图书馆资源动态',
+ url: 'lib.tsinghua.edu.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/tsinghua/lib/zydt',
+ parameters: { category: '分类,默认为空,即全部,可在对应分类页 URL 中找到' },
+ description: `::: tip
+ 若订阅 [清华大学图书馆已购资源动态](https://lib.tsinghua.edu.cn/zydt/yg.htm),网址为 \`https://lib.tsinghua.edu.cn/zydt/yg.htm\`。截取 \`https://lib.tsinghua.edu.cn/zydt\` 到末尾 \`.htm\` 的部分 \`yg\` 作为参数填入,此时路由为 [\`/tsinghua/lib/zydt/yg\`](https://rsshub.app/tsinghua/lib/zydt/yg)。
+:::
+
+| 已购 | 试用 |
+| ---- | ---- |
+| yg | sy |
+ `,
+ categories: ['university'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['lib.tsinghua.edu.cn/zydt/:category?'],
+ target: (params) => {
+ const category = params.category?.replace(/\.htm$/, '');
+
+ return `/tsinghua/lib/zydt${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '图书馆资源动态',
+ source: ['lib.tsinghua.edu.cn/zydt'],
+ target: '/lib/zydt',
+ },
+ {
+ title: '图书馆已购资源动态',
+ source: ['lib.tsinghua.edu.cn/zydt/yg'],
+ target: '/lib/zydt/yg',
+ },
+ {
+ title: '图书馆试用资源动态',
+ source: ['lib.tsinghua.edu.cn/zydt/sy'],
+ target: '/lib/zydt/sy',
+ },
+ ],
+};
diff --git a/lib/routes/tsinghua/namespace.ts b/lib/routes/tsinghua/namespace.ts
new file mode 100644
index 00000000000000..89db20e31b0618
--- /dev/null
+++ b/lib/routes/tsinghua/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '清华大学',
+ url: 'tsinghua.edu.cn',
+ categories: ['university'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/tsinghua/news.ts b/lib/routes/tsinghua/news.ts
new file mode 100644
index 00000000000000..1f23a0f327ea41
--- /dev/null
+++ b/lib/routes/tsinghua/news.ts
@@ -0,0 +1,88 @@
+import { load } from 'cheerio';
+
+import type { 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 = {
+ path: '/news/:category?',
+ categories: ['university'],
+ example: '/tsinghua/news',
+ parameters: { category: '分类,可在对应分类页 URL 中找到,留空为 `zxdt`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '清华新闻',
+ radar: [
+ {
+ source: ['www.tsinghua.edu.cn/news/:category'],
+ target: (_, url) => `/tsinghua/news/${new URL(url).pathname.split('/').pop()?.replace('.htm', '')}`,
+ },
+ ],
+ maintainers: ['TonyRL'],
+ url: 'www.tsinghua.edu.cn/news.htm',
+ handler,
+};
+
+async function handler(ctx) {
+ const { category = 'zxdt' } = ctx.req.param();
+ const baseUrl = 'https://www.tsinghua.edu.cn';
+ const link = `${baseUrl}/news/${category}.htm`;
+ const response = await ofetch(link);
+ const $ = load(response);
+
+ const list = [
+ ...$('.left li a')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const sj = $item.find('.sj');
+
+ return {
+ title: $item.find('.bt').text().trim(),
+ link: new URL($item.attr('href'), baseUrl).href?.replace('http://', 'https://'),
+ pubDate: parseDate(`${sj.find('span').text().trim()}.${sj.find('p').text().trim()}`, 'YYYY.MM.DD'),
+ };
+ }),
+ ...($('.qhrw2_first').length
+ ? [
+ {
+ title: $('.qhrw2_first .bt').text().trim(),
+ link: $('.qhrw2_first a').attr('href')?.replace('http://', 'https://'),
+ pubDate: parseDate(`${$('.qhrw2_first .sj span').text().trim()}.${$('.qhrw2_first .sj p').text().trim()}`, 'YYYY.MM.DD'),
+ },
+ ]
+ : []),
+ ];
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (!item.link?.startsWith('https://www.tsinghua.edu.cn/info/')) {
+ return item;
+ }
+
+ const response = await ofetch(item.link);
+ const $ = load(response);
+
+ item.description = $('.v_news_content').html();
+ item.pubDate = timezone(parseDate($('.sj p').text().trim(), 'YYYY年MM月DD日 HH:mm:ss'), 8);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('head title').text(),
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/ttv/index.ts b/lib/routes/ttv/index.ts
new file mode 100644
index 00000000000000..cab774a2ae7d2e
--- /dev/null
+++ b/lib/routes/ttv/index.ts
@@ -0,0 +1,78 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['traditional-media'],
+ example: '/ttv',
+ parameters: { category: '分类' },
+ name: '分类',
+ maintainers: ['dzx-dzx'],
+ radar: [
+ {
+ source: ['news.ttv.com.tw/:category'],
+ },
+ ],
+ handler,
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://news.ttv.com.tw';
+ const category = ctx.req.param('category') ?? 'realtime';
+ const currentUrl = `${rootUrl}/${['realtime', 'focus'].includes(category) ? category : `category/${category}`}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('div.news-list li')
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 30)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ link: $(item).find('a').attr('href'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.title = content('title').text();
+ item.pubDate = timezone(parseDate(content('meta[property="article:published_time"]').attr('content')), +8);
+ item.category = content('div.article-body ul.tag')
+ .find('a')
+ .toArray()
+ .map((t) => content(t).text());
+ const section = content("meta[property='article:section']").attr('content');
+ if (!item.category.includes(section)) {
+ item.category.push(section);
+ }
+ item.description = content('#newscontent').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/ttv/namespace.ts b/lib/routes/ttv/namespace.ts
new file mode 100644
index 00000000000000..6b547ee1b772f9
--- /dev/null
+++ b/lib/routes/ttv/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '台視新聞網',
+ url: 'news.ttv.com.tw',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/tumblr/namespace.ts b/lib/routes/tumblr/namespace.ts
new file mode 100644
index 00000000000000..b1ad82188e1e38
--- /dev/null
+++ b/lib/routes/tumblr/namespace.ts
@@ -0,0 +1,17 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Tumblr',
+ url: 'tumblr.com',
+ lang: 'en',
+ description: `Register an application on \`https://www.tumblr.com/oauth/apps\`.
+
+- \`TUMBLR_CLIENT_ID\`: The key is labelled as \`OAuth consumer Key\` in the info page of the registered application.
+- \`TUMBLR_CLIENT_SECRET\`: The key is labelled as \`OAuth consumer Secret\` in the info page of the registered application.
+- \`TUMBLR_REFRESH_TOKEN\`: Navigate to \`https://www.tumblr.com/oauth2/authorize?client_id=\${CLIENT_ID}&response_type=code&scope=basic%20offline_access&state=mystate\` in your browser and login. After doing so, you'll be redirected to the URL you defined when registering the application. Look for the \`code\` parameter in the URL. You can then call \`curl -F grant_type=authorization_code -F "code=\${CODE}" -F "client_id=\${CLIENT_ID}" -F "client_secret=\${CLIENT_SECRET}" "https://api.tumblr.com/v2/oauth2/token"\`
+
+Two login methods are currently supported:
+
+- \`TUMBLR_CLIENT_ID\`: The key never expires, however blogs that are "dashboard only" cannot be accessed.
+- \`TUMBLR_CLIENT_ID\` + \`TUMBLR_CLIENT_SECRET\` + \`TUMBLR_REFRESH_TOKEN\`: The refresh token will expire and will need to be regenerated, "dashboard only" blogs can be accessed.`,
+};
diff --git a/lib/routes/tumblr/posts.ts b/lib/routes/tumblr/posts.ts
new file mode 100644
index 00000000000000..02b73bfd76590d
--- /dev/null
+++ b/lib/routes/tumblr/posts.ts
@@ -0,0 +1,76 @@
+import type { Context } from 'hono';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Data, Route } from '@/types';
+import got from '@/utils/got';
+import { fallback, queryToInteger } from '@/utils/readable-social';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/posts/:blog',
+ categories: ['blog'],
+ example: '/tumblr/posts/biketouring-nearby',
+ parameters: {
+ blog: 'Blog identifier (see `https://www.tumblr.com/docs/en/api/v2#blog-identifiers`)',
+ },
+ radar: [],
+ features: {
+ requireConfig: [
+ {
+ name: 'TUMBLR_CLIENT_ID',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TUMBLR_CLIENT_SECRET',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TUMBLR_REFRESH_TOKEN',
+ description: 'Please see above for details.',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Posts',
+ maintainers: ['Rakambda', 'PolarisStarnor'],
+ description: `::: tip
+Tumblr provides official RSS feeds for non "dashboard only" blogs, for instance [https://biketouring-nearby.tumblr.com](https://biketouring-nearby.tumblr.com/rss).
+:::`,
+ handler,
+};
+
+async function handler(ctx: Context): Promise {
+ if (!config.tumblr || !config.tumblr.clientId) {
+ throw new ConfigNotFoundError('Tumblr RSS is disabled due to the lack of relevant config ');
+ }
+
+ const blogIdentifier = ctx.req.param('blog');
+ const limit = fallback(undefined, queryToInteger(ctx.req.query('limit')), 20);
+
+ const response = await got.get(`https://api.tumblr.com/v2/blog/${blogIdentifier}/posts`, {
+ searchParams: {
+ api_key: utils.generateAuthParams(),
+ limit,
+ },
+ headers: await utils.generateAuthHeaders(),
+ });
+
+ const blog = response.data.response.blog;
+ const posts = response.data.response.posts.map((post: any) => utils.processPost(post));
+
+ return {
+ title: `Tumblr - ${blogIdentifier} - Posts`,
+ author: blog?.name,
+ link: blog?.url ?? `https://${blogIdentifier}/`,
+ item: posts,
+ allowEmpty: true,
+ image: blog?.avatar?.slice(-1)?.url,
+ description: blog?.description,
+ };
+}
diff --git a/lib/routes/tumblr/tagged.ts b/lib/routes/tumblr/tagged.ts
new file mode 100644
index 00000000000000..b2e21ce6d9238f
--- /dev/null
+++ b/lib/routes/tumblr/tagged.ts
@@ -0,0 +1,71 @@
+import type { Context } from 'hono';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Data, Route } from '@/types';
+import got from '@/utils/got';
+import { fallback, queryToInteger } from '@/utils/readable-social';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/tagged/:tag',
+ categories: ['social-media'],
+ example: '/tumblr/tagged/nature',
+ parameters: {
+ tag: 'Tag name (see `https://www.tumblr.com/docs/en/api/v2#tagged--get-posts-with-tag`)',
+ },
+ radar: [],
+ features: {
+ requireConfig: [
+ {
+ name: 'TUMBLR_CLIENT_ID',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TUMBLR_CLIENT_SECRET',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TUMBLR_REFRESH_TOKEN',
+ description: 'Please see above for details.',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Tagged Posts',
+ maintainers: ['PolarisStarnor'],
+ handler,
+};
+
+async function handler(ctx: Context): Promise {
+ if (!config.tumblr || !config.tumblr.clientId) {
+ throw new ConfigNotFoundError('Tumblr RSS is disabled due to the lack of relevant config ');
+ }
+
+ const tag = ctx.req.param('tag');
+ const limit = fallback(undefined, queryToInteger(ctx.req.query('limit')), 20);
+
+ const response = await got.get('https://api.tumblr.com/v2/tagged', {
+ searchParams: {
+ tag,
+ api_key: utils.generateAuthParams(),
+ limit,
+ },
+ headers: await utils.generateAuthHeaders(),
+ });
+
+ const posts = response.data.response.map((post: any) => utils.processPost(post));
+
+ return {
+ title: `Tumblr - ${tag}`,
+ description: `Tumblr posts tagged #${tag}`,
+ link: `https://tumblr.com/tagged/${tag}`,
+ item: posts,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/tumblr/utils.ts b/lib/routes/tumblr/utils.ts
new file mode 100644
index 00000000000000..b04dd104fd835d
--- /dev/null
+++ b/lib/routes/tumblr/utils.ts
@@ -0,0 +1,116 @@
+import { config } from '@/config';
+import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import logger from '@/utils/logger';
+import { parseDate } from '@/utils/parse-date';
+
+const getAccessToken: () => Promise = async () => {
+ let accessToken: string | null = await cache.get('tumblr:accessToken', false);
+ if (!accessToken) {
+ try {
+ const newAccessToken = await tokenRefresher();
+ if (newAccessToken) {
+ accessToken = newAccessToken;
+ }
+ } catch (error) {
+ // Return the `accessToken=null` value to indicate that the token is not available. Calls will only use the `apiKey` as a fallback to maybe hit non "dashborad only" blogs.
+ logger.error('Failed to refresh Tumblr token, using only client id as fallback', error);
+ }
+ }
+ return accessToken;
+};
+
+const generateAuthHeaders: () => Promise<{ Authorization?: string }> = async () => {
+ const accessToken = await getAccessToken();
+ if (!accessToken) {
+ return {};
+ }
+ return {
+ Authorization: `Bearer ${accessToken}`,
+ };
+};
+
+const generateAuthParams: () => string = () => config.tumblr.clientId!;
+
+const processPost: (post: any) => DataItem = (post) => {
+ let description = '';
+
+ switch (post.type) {
+ case 'text':
+ description = post.body;
+ break;
+ case 'answer':
+ description += post.asking_url === null ? `${post.asking_name} asks:
` : `${post.asking_name} asks:
`;
+ description += post.question;
+ description += ' ';
+ description += `${post.blog_name} answers:
`;
+ description += post.answer;
+ break;
+ case 'photo':
+ for (const photo of post.photos ?? []) {
+ description += ` `;
+ }
+ break;
+ case 'link':
+ description = post.url;
+ break;
+ case 'audio':
+ description = post.embed;
+ break;
+ default:
+ break;
+ }
+
+ return {
+ author: post.blog_name,
+ id: post.id_string,
+ title: post.summary ?? `New post from ${post.blog_name}`,
+ link: post.post_url,
+ pubDate: parseDate(post.timestamp * 1000),
+ category: post.tags,
+ description,
+ };
+};
+
+let tokenRefresher: () => Promise = () => Promise.resolve(null);
+if (config.tumblr && config.tumblr.clientId && config.tumblr.clientSecret && config.tumblr.refreshToken) {
+ tokenRefresher = async (): Promise => {
+ let refreshToken = config.tumblr.refreshToken;
+
+ // Restore already refreshed tokens
+ const previousRefreshTokenSerialized = await cache.get('tumblr:refreshToken', false);
+ if (previousRefreshTokenSerialized) {
+ const previousRefreshToken = JSON.parse(previousRefreshTokenSerialized);
+ if (previousRefreshToken.startToken === refreshToken) {
+ refreshToken = previousRefreshToken.currentToken;
+ }
+ }
+ const response = await got.post('https://api.tumblr.com/v2/oauth2/token', {
+ form: {
+ grant_type: 'refresh_token',
+ client_id: config.tumblr.clientId,
+ client_secret: config.tumblr.clientSecret,
+ refresh_token: refreshToken,
+ },
+ });
+ if (!response.data?.access_token || !response.data?.refresh_token) {
+ return null;
+ }
+ const accessToken = response.data.access_token;
+ const newRefreshToken = response.data.refresh_token;
+ const expiresIn = response.data.expires_in;
+
+ // Access tokens expire after 42 minutes, remove 30 seconds to renew the token before it expires (to avoid making a request right when it ends).
+ await cache.set('tumblr:accessToken', accessToken, (expiresIn ?? 2520) - 30);
+ // Store the new refresh token associated with the one that was provided first.
+ // We may be able to restore the new token if the app is restarted. This will avoid reusing the old token and have a failing request.
+ // Keep it for a year (not clear how long the refresh token lasts).
+ const cacheEntry = { startToken: config.tumblr.refreshToken, currentToken: newRefreshToken };
+ await cache.set(`tumblr:refreshToken`, JSON.stringify(cacheEntry), 31_536_000);
+
+ return accessToken;
+ };
+}
+
+export default { processPost, generateAuthParams, generateAuthHeaders };
diff --git a/lib/routes/tvb/namespace.ts b/lib/routes/tvb/namespace.ts
new file mode 100644
index 00000000000000..60a3623ca9ca06
--- /dev/null
+++ b/lib/routes/tvb/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '无线新闻',
+ url: 'tvb.com',
+ lang: 'zh-HK',
+};
diff --git a/lib/routes/tvb/news.ts b/lib/routes/tvb/news.ts
new file mode 100644
index 00000000000000..f610bea1dfa66f
--- /dev/null
+++ b/lib/routes/tvb/news.ts
@@ -0,0 +1,118 @@
+import path from 'node:path';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+const titles = {
+ focus: {
+ tc: '要聞',
+ sc: '要闻',
+ },
+ instant: {
+ tc: '快訊',
+ sc: '快讯',
+ },
+ local: {
+ tc: '港澳',
+ sc: '港澳',
+ },
+ greaterchina: {
+ tc: '兩岸',
+ sc: '两岸',
+ },
+ world: {
+ tc: '國際',
+ sc: '国际',
+ },
+ finance: {
+ tc: '財經',
+ sc: '财经',
+ },
+ sports: {
+ tc: '體育',
+ sc: '体育',
+ },
+ parliament: {
+ tc: '法庭',
+ sc: '法庭',
+ },
+ weather: {
+ tc: '天氣',
+ sc: '天气',
+ },
+};
+
+export const route: Route = {
+ path: '/news/:category?/:language?',
+ categories: ['traditional-media'],
+ example: '/tvb/news',
+ parameters: { category: '分类,见下表,默认为要聞', language: '语言,见下表' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['tvb.com/:language/:category', 'tvb.com/'],
+ },
+ ],
+ name: '新闻',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `分类
+
+| 要聞 | 快訊 | 港澳 | 兩岸 | 國際 | 財經 | 體育 | 法庭 | 天氣 |
+| ----- | ------- | ----- | ------------ | ----- | ------- | ------ | ---------- | ------- |
+| focus | instant | local | greaterchina | world | finance | sports | parliament | weather |
+
+ 语言
+
+| 繁 | 简 |
+| -- | -- |
+| tc | sc |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'focus';
+ const language = ctx.req.param('language') ?? 'tc';
+
+ const rootUrl = 'https://inews-api.tvb.com';
+ const linkRootUrl = 'https://news.tvb.com';
+ const apiUrl = `${rootUrl}/news/entry/category`;
+ const currentUrl = `${rootUrl}/${language}/${category}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ searchParams: {
+ id: category,
+ lang: language,
+ page: 1,
+ limit: ctx.req.query('limit') ?? 50,
+ country: 'HK',
+ },
+ });
+
+ const items = response.data.content.map((item) => ({
+ title: item.title,
+ link: `${linkRootUrl}/${language}/${category}/${item.id}`,
+ pubDate: parseDate(item.publish_datetime),
+ category: [...item.category.map((c) => c.title), ...item.tags],
+ description: art(path.join(__dirname, 'templates/description.art'), {
+ description: item.desc,
+ images: item.media.image?.map((i) => i.thumbnail.replace(/_\d+x\d+\./, '.')) ?? [],
+ }),
+ }));
+
+ return {
+ title: `${response.data.meta.title} - ${titles[category][language]}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/v2/tvb/templates/description.art b/lib/routes/tvb/templates/description.art
similarity index 100%
rename from lib/v2/tvb/templates/description.art
rename to lib/routes/tvb/templates/description.art
diff --git a/lib/routes/tver/namespace.ts b/lib/routes/tver/namespace.ts
new file mode 100644
index 00000000000000..46e461a64895d1
--- /dev/null
+++ b/lib/routes/tver/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'TVer',
+ url: 'tver.jp',
+ lang: 'ja',
+};
diff --git a/lib/routes/tver/series.ts b/lib/routes/tver/series.ts
new file mode 100644
index 00000000000000..18e821d564d4ec
--- /dev/null
+++ b/lib/routes/tver/series.ts
@@ -0,0 +1,99 @@
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/series/:id',
+ categories: ['traditional-media'],
+ example: '/tver/series/srx2o7o3c8',
+ parameters: {
+ id: 'Series ID (as it appears in URLs). For example, in https://tver.jp/series/srx2o7o3c8, the ID is "srx2o7o3c8".',
+ },
+ radar: [
+ {
+ source: ['tver.jp/series/:id'],
+ target: '/series/:id',
+ },
+ ],
+ name: 'Series',
+ maintainers: ['yuikisaito'],
+ handler,
+};
+
+const commonHeaders = {
+ Accept: '*/*',
+ 'Accept-Language': 'ja,en-US;q=0.7,en;q=0.3',
+ 'Cache-Control': 'no-cache',
+ Pragma: 'no-cache',
+ 'Sec-GPC': '1',
+ 'Sec-Fetch-Dest': 'empty',
+ 'Sec-Fetch-Mode': 'cors',
+ 'Sec-Fetch-Site': 'same-site',
+};
+
+async function handler(ctx: Context): Promise {
+ const { id } = ctx.req.param();
+
+ const { result: browser } = await ofetch('https://platform-api.tver.jp/v2/api/platform_users/browser/create', {
+ method: 'POST',
+ body: 'device_type=pc',
+ headers: {
+ ...commonHeaders,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ referrer: 'https://s.tver.jp/',
+ credentials: 'omit',
+ mode: 'cors',
+ });
+
+ const { platform_uid, platform_token } = browser;
+
+ const { title, description, broadcastProvider } = await ofetch(`https://statics.tver.jp/content/series/${id}.json`, {
+ method: 'GET',
+ headers: {
+ ...commonHeaders,
+ },
+ referrer: 'https://tver.jp/',
+ credentials: 'omit',
+ mode: 'cors',
+ });
+
+ const { result } = await ofetch(`https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/${id}?platform_uid=${platform_uid}&platform_token=${platform_token}`, {
+ method: 'GET',
+ headers: {
+ ...commonHeaders,
+ 'x-tver-platform-type': 'web',
+ },
+ referrer: 'https://tver.jp/',
+ credentials: 'omit',
+ mode: 'cors',
+ });
+
+ const items: DataItem[] = (result.contents?.[0]?.contents ?? [])
+ .filter((i) => i.type === 'episode')
+ .map((i) => {
+ const rawPubDate = i.content.broadcastDateLabel;
+ const cleanedPubDate = rawPubDate.replaceAll(/\(.*?\)|放送分/g, '').trim();
+ const parsedPubDate = timezone(parseDate(cleanedPubDate, 'M月D日'), +9).toDateString();
+
+ return {
+ title: i.content.title,
+ link: `https://tver.jp/episodes/${i.content.id}`,
+ image: `https://statics.tver.jp/images/content/thumbnail/episode/xlarge/${i.content.id}.jpg`,
+ pubDate: parsedPubDate,
+ };
+ });
+
+ return {
+ title: 'TVer - ' + title,
+ description,
+ author: broadcastProvider.name,
+ link: `https://tver.jp/series/${id}`,
+ image: `https://statics.tver.jp/images/content/thumbnail/series/xlarge/${id}.jpg`,
+ language: 'ja',
+ item: items,
+ };
+}
diff --git a/lib/routes/tvtropes/featured.ts b/lib/routes/tvtropes/featured.ts
new file mode 100644
index 00000000000000..9e9677f5b2c5bd
--- /dev/null
+++ b/lib/routes/tvtropes/featured.ts
@@ -0,0 +1,97 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { art } from '@/utils/render';
+
+const categories = {
+ today: 'left',
+ newest: 'right',
+};
+
+export const route: Route = {
+ path: '/featured/:category?',
+ categories: ['other'],
+ example: '/tvtropes/featured/today',
+ parameters: { category: "Category, see below, Today's Featured Trope by default" },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Featured',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| Today's Featured Trope | Newest Trope |
+| ---------------------- | ------------ |
+| today | newest |`,
+};
+
+async function handler(ctx) {
+ const { category = 'today' } = ctx.req.param();
+
+ const rootUrl = 'https://tvtropes.org';
+
+ const { data: response } = await got(rootUrl);
+
+ const $ = load(response);
+
+ const item = $(`div#featured-tropes div.${categories[category]}`);
+
+ const link = new URL(item.find('h2.entry-title a').prop('href'), rootUrl).href;
+
+ const { data: detailResponse } = await got(link);
+
+ const content = load(detailResponse);
+
+ content('div.folderlabel').remove();
+
+ content('div.lazy_load_img_box').each((_, el) => {
+ el = content(el);
+
+ const image = el.find('img');
+
+ el.replaceWith(
+ art(path.join(__dirname, 'templates/description.art'), {
+ images: [
+ {
+ src: image.prop('src'),
+ alt: image.prop('alt'),
+ width: image.prop('width'),
+ height: image.prop('height'),
+ },
+ ],
+ })
+ );
+ });
+
+ const items = [
+ {
+ title: item.find('h2.entry-title').text(),
+ link,
+ description: content('div#main-article').html(),
+ },
+ ];
+
+ const image = new URL($('img.logo-big').prop('src'), rootUrl).href;
+ const icon = $('link[rel="shortcut icon"]').prop('href');
+
+ return {
+ item: items,
+ title: `${$('title').text()} - ${item.find('span.box-title').text()}`,
+ link: rootUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: $('html').prop('lang'),
+ image,
+ icon,
+ logo: icon,
+ subtitle: $('meta[property="og:title"]').prop('content'),
+ author: $('meta[property="og:site_name"]').prop('content'),
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/tvtropes/namespace.ts b/lib/routes/tvtropes/namespace.ts
new file mode 100644
index 00000000000000..9aba076818b8fb
--- /dev/null
+++ b/lib/routes/tvtropes/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'TV Tropes',
+ url: 'tvtropes.org',
+ lang: 'en',
+};
diff --git a/lib/routes/tvtropes/templates/description.art b/lib/routes/tvtropes/templates/description.art
new file mode 100644
index 00000000000000..48df396d72fd2c
--- /dev/null
+++ b/lib/routes/tvtropes/templates/description.art
@@ -0,0 +1,23 @@
+{{ if images }}
+ {{ each images image }}
+ {{ if image?.src }}
+
+
+
+ {{ /if }}
+ {{ /each }}
+{{ /if }}
+
+{{ if description }}
+ {{@ description }}
+{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/twitch/live.ts b/lib/routes/twitch/live.ts
new file mode 100644
index 00000000000000..a137e957e3a191
--- /dev/null
+++ b/lib/routes/twitch/live.ts
@@ -0,0 +1,129 @@
+import type { DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+// https://github.com/streamlink/streamlink/blob/master/src/streamlink/plugins/twitch.py#L286
+const TWITCH_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
+
+export const route: Route = {
+ path: '/live/:login',
+ categories: ['live'],
+ view: ViewType.Notifications,
+ example: '/twitch/live/riotgames',
+ parameters: { login: 'Twitch username' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Live',
+ maintainers: ['hoilc'],
+ handler,
+};
+
+async function handler(ctx) {
+ const login = ctx.req.param('login');
+
+ const response = await got({
+ method: 'post',
+ url: 'https://gql.twitch.tv/gql',
+ headers: {
+ Referer: 'https://player.twitch.tv',
+ 'Client-ID': TWITCH_CLIENT_ID,
+ },
+ json: [
+ {
+ operationName: 'ChannelShell',
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: 'fea4573a7bf2644f5b3f2cbbdcbee0d17312e48d2e55f080589d053aad353f11',
+ },
+ },
+ variables: {
+ login,
+ lcpVideosEnabled: false,
+ },
+ },
+ {
+ operationName: 'StreamMetadata',
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: 'b57f9b910f8cd1a4659d894fe7550ccc81ec9052c01e438b290fd66a040b9b93',
+ },
+ },
+ variables: {
+ channelLogin: login,
+ includeIsDJ: true,
+ },
+ },
+ {
+ operationName: 'RealtimeStreamTagList',
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: 'a4747cac9d8e8bf6cf80969f6da6363ca1bdbd80fe136797e71504eb404313fd',
+ },
+ },
+ variables: {
+ channelLogin: login,
+ },
+ },
+ {
+ operationName: 'ChannelRoot_AboutPanel',
+ variables: {
+ channelLogin: login,
+ skipSchedule: true,
+ includeIsDJ: true,
+ },
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: '0df42c4d26990ec1216d0b815c92cc4a4a806e25b352b66ac1dd91d5a1d59b80',
+ },
+ },
+ },
+ ],
+ });
+
+ const channelShellData = response.data[0].data;
+ const streamMetadataData = response.data[1].data;
+ const realtimeStreamTagListData = response.data[2].data;
+ const channelRootAboutPanelData = response.data[3].data;
+ const { userOrError } = channelShellData;
+ const { user } = channelRootAboutPanelData;
+
+ if (!userOrError.id) {
+ throw new Error(userOrError.__typename);
+ }
+
+ const displayName = userOrError.displayName;
+
+ const liveItem: DataItem[] = [];
+
+ if (streamMetadataData.user.stream) {
+ liveItem.push({
+ title: streamMetadataData.user.lastBroadcast.title,
+ author: displayName,
+ category: realtimeStreamTagListData.user.stream.freeformTags.map((item) => item.name),
+ description: ` `,
+ pubDate: parseDate(streamMetadataData.user.stream.createdAt),
+ guid: streamMetadataData.user.stream.id,
+ link: `https://www.twitch.tv/${login}`,
+ });
+ }
+
+ return {
+ title: `Twitch - ${displayName} - Live`,
+ description: user.description,
+ link: `https://www.twitch.tv/${login}`,
+ image: user.profileImageURL,
+ item: liveItem,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/twitch/namespace.ts b/lib/routes/twitch/namespace.ts
new file mode 100644
index 00000000000000..c689b6ae6ee3b3
--- /dev/null
+++ b/lib/routes/twitch/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Twitch',
+ url: 'www.twitch.tv',
+ lang: 'en',
+};
diff --git a/lib/routes/twitch/schedule.ts b/lib/routes/twitch/schedule.ts
new file mode 100644
index 00000000000000..daad1d2f038cb9
--- /dev/null
+++ b/lib/routes/twitch/schedule.ts
@@ -0,0 +1,101 @@
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+// https://github.com/streamlink/streamlink/blob/master/src/streamlink/plugins/twitch.py#L286
+const TWITCH_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
+
+export const route: Route = {
+ path: '/schedule/:login',
+ categories: ['live'],
+ example: '/twitch/schedule/riotgames',
+ parameters: { login: 'Twitch username' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.twitch.tv/:login/schedule'],
+ },
+ ],
+ name: 'Stream Schedule',
+ maintainers: ['hoilc'],
+ handler,
+};
+
+async function handler(ctx) {
+ const login = ctx.req.param('login');
+
+ const today = new Date();
+ const oneWeekLater = new Date(today.getTime() + 86_400_000 * 7);
+ const response = await got({
+ method: 'post',
+ url: 'https://gql.twitch.tv/gql',
+ headers: {
+ Referer: 'https://player.twitch.tv',
+ 'Client-ID': TWITCH_CLIENT_ID,
+ },
+ json: [
+ {
+ operationName: 'ChannelShell',
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: 'c3ea5a669ec074a58df5c11ce3c27093fa38534c94286dc14b68a25d5adcbf55',
+ },
+ },
+ variables: {
+ login,
+ lcpVideosEnabled: false,
+ },
+ },
+ {
+ operationName: 'StreamSchedule',
+ variables: {
+ login,
+ startingWeekday: 'MONDAY',
+ utcOffsetMinutes: 480,
+ startAt: today.toISOString(),
+ endAt: oneWeekLater.toISOString(),
+ },
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: '01925339777a81111ffac469430bc4ea4773c18a3c1642f1b231e61e2278ea41',
+ },
+ },
+ },
+ ],
+ });
+
+ const channelShellData = response.data[0].data;
+ const streamScheduleData = response.data[1].data;
+
+ if (!streamScheduleData.user.id) {
+ throw new InvalidParameterError(`Username does not exist`);
+ }
+
+ const displayName = channelShellData.userOrError.displayName;
+
+ // schedule segments may be null
+ const out = streamScheduleData.user.channel.schedule.segments?.map((item) => ({
+ title: item.title,
+ guid: item.id,
+ link: `https://www.twitch.tv/${login}`,
+ author: displayName,
+ description: `StartAt: ${item.startAt}EndAt: ${item.endAt}`,
+ category: item.categories.map((item) => item.name),
+ }));
+
+ return {
+ title: `Twitch - ${displayName} - Schedule`,
+ link: `https://www.twitch.tv/${login}`,
+ item: out,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/twitch/video.ts b/lib/routes/twitch/video.ts
new file mode 100644
index 00000000000000..ef3be9eebd8ca5
--- /dev/null
+++ b/lib/routes/twitch/video.ts
@@ -0,0 +1,110 @@
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+// https://github.com/streamlink/streamlink/blob/master/src/streamlink/plugins/twitch.py#L286
+const TWITCH_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
+
+const FILTER_NODE_TYPE_MAP = {
+ archive: 'LATEST_BROADCASTS',
+ highlights: 'LATEST_NON_BROADCASTS',
+ all: 'ALL_VIDEOS',
+};
+
+export const route: Route = {
+ path: '/video/:login/:filter?',
+ categories: ['live'],
+ view: ViewType.Videos,
+ example: '/twitch/video/riotgames/highlights',
+ parameters: {
+ login: 'Twitch username',
+ filter: {
+ description: 'Video type, Default to all',
+ options: [
+ { value: 'archive', label: 'Archive' },
+ { value: 'highlights', label: 'Highlights' },
+ { value: 'all', label: 'All' },
+ ],
+ default: 'all',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.twitch.tv/:login/videos'],
+ target: '/video/:login',
+ },
+ ],
+ name: 'Channel Video',
+ maintainers: ['hoilc'],
+ handler,
+};
+
+async function handler(ctx) {
+ const login = ctx.req.param('login');
+ const filter = ctx.req.param('filter')?.toLowerCase() || 'all';
+ if (!FILTER_NODE_TYPE_MAP[filter]) {
+ throw new InvalidParameterError(`Unsupported filter type "${filter}", please choose from { ${Object.keys(FILTER_NODE_TYPE_MAP).join(', ')} }`);
+ }
+
+ const response = await got({
+ method: 'post',
+ url: 'https://gql.twitch.tv/gql',
+ headers: {
+ Referer: 'https://player.twitch.tv',
+ 'Client-ID': TWITCH_CLIENT_ID,
+ },
+ json: [
+ {
+ operationName: 'ChannelVideoShelvesQuery',
+ variables: {
+ channelLogin: login,
+ first: 5,
+ },
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: '7b31d8ae7274b79d169a504e3727baaaed0d5ede101f4a38fc44f34d76827903',
+ },
+ },
+ },
+ ],
+ });
+
+ const channelVideoShelvesQueryData = response.data[0].data;
+
+ if (!channelVideoShelvesQueryData.user.id) {
+ throw new InvalidParameterError(`Username does not exist`);
+ }
+
+ const displayName = channelVideoShelvesQueryData.user.displayName;
+
+ const videoShelvesEdge = channelVideoShelvesQueryData.user.videoShelves.edges.find((edge) => edge.node.type === FILTER_NODE_TYPE_MAP[filter]);
+ if (!videoShelvesEdge) {
+ throw new InvalidParameterError(`No video under filter type "${filter}"`);
+ }
+
+ const out = videoShelvesEdge.node.items.map((item) => ({
+ title: item.title,
+ link: `https://www.twitch.tv/videos/${item.id}`,
+ author: displayName,
+ pubDate: parseDate(item.publishedAt),
+ description: ` `,
+ category: item.game && [item.game.displayName], // item.game may be null
+ }));
+
+ return {
+ title: `Twitch - ${displayName} - ${videoShelvesEdge.node.title}`,
+ link: `https://www.twitch.tv/${login}`,
+ item: out,
+ };
+}
diff --git a/lib/routes/twitter/api/developer-api/search.ts b/lib/routes/twitter/api/developer-api/search.ts
new file mode 100644
index 00000000000000..484b93cd115b41
--- /dev/null
+++ b/lib/routes/twitter/api/developer-api/search.ts
@@ -0,0 +1,23 @@
+import utils from '../../utils';
+
+const handler = async (ctx) => {
+ const keyword = ctx.req.param('keyword');
+ const limit = ctx.req.query('limit') ?? 50;
+ const client = await utils.getAppClient();
+ const data = await client.v1.get('search/tweets.json', {
+ q: keyword,
+ count: limit,
+ tweet_mode: 'extended',
+ result_type: 'recent',
+ });
+
+ return {
+ title: `Twitter Keyword - ${keyword}`,
+ link: `https://x.com/search?q=${encodeURIComponent(keyword)}`,
+ item: utils.ProcessFeed(ctx, {
+ data: data.statuses,
+ }),
+ allowEmpty: true,
+ };
+};
+export default handler;
diff --git a/lib/routes/twitter/api/developer-api/user.ts b/lib/routes/twitter/api/developer-api/user.ts
new file mode 100644
index 00000000000000..29ba86c18b78f2
--- /dev/null
+++ b/lib/routes/twitter/api/developer-api/user.ts
@@ -0,0 +1,38 @@
+import utils from '../../utils';
+
+const handler = async (ctx) => {
+ const id = ctx.req.param('id');
+ // For compatibility
+ const { include_replies, include_rts, count } = utils.parseRouteParams(ctx.req.param('routeParams'));
+ const client = await utils.getAppClient();
+ const user_timeline_query = {
+ tweet_mode: 'extended',
+ exclude_replies: !include_replies,
+ include_rts,
+ count,
+ };
+ let screen_name;
+ if (id.startsWith('+')) {
+ user_timeline_query.user_id = +id.slice(1);
+ } else {
+ user_timeline_query.screen_name = id;
+ screen_name = id;
+ }
+ const data = await client.v1.get('statuses/user_timeline.json', user_timeline_query);
+ const userInfo = data[0].user;
+ if (!screen_name) {
+ screen_name = userInfo.screen_name;
+ }
+ const profileImageUrl = userInfo.profile_image_url || userInfo.profile_image_url_https;
+
+ return {
+ title: `Twitter @${userInfo.name}`,
+ link: `https://x.com/${screen_name}`,
+ image: profileImageUrl,
+ description: userInfo.description,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ };
+};
+export default handler;
diff --git a/lib/routes/twitter/api/index.ts b/lib/routes/twitter/api/index.ts
new file mode 100644
index 00000000000000..59536b13873d26
--- /dev/null
+++ b/lib/routes/twitter/api/index.ts
@@ -0,0 +1,48 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+
+import mobileApi from './mobile-api/api';
+import webApi from './web-api/api';
+
+const enableThirdPartyApi = config.twitter.thirdPartyApi;
+const enableMobileApi = config.twitter.username && config.twitter.password;
+const enableWebApi = config.twitter.authToken;
+
+type ApiItem = (id: string, params?: Record) => Promise> | Record | null;
+let api: {
+ init: () => void;
+ getUser: ApiItem;
+ getUserTweets: ApiItem;
+ getUserTweetsAndReplies: ApiItem;
+ getUserMedia: ApiItem;
+ getUserLikes: ApiItem;
+ getUserTweet: ApiItem;
+ getSearch: ApiItem;
+ getList: ApiItem;
+ getHomeTimeline: ApiItem;
+ getHomeLatestTimeline: ApiItem;
+} = {
+ init: () => {
+ throw new ConfigNotFoundError('Twitter API is not configured');
+ },
+ getUser: () => null,
+ getUserTweets: () => null,
+ getUserTweetsAndReplies: () => null,
+ getUserMedia: () => null,
+ getUserLikes: () => null,
+ getUserTweet: () => null,
+ getSearch: () => null,
+ getList: () => null,
+ getHomeTimeline: () => null,
+ getHomeLatestTimeline: () => null,
+};
+
+if (enableThirdPartyApi) {
+ api = webApi;
+} else if (enableWebApi) {
+ api = webApi;
+} else if (enableMobileApi) {
+ api = mobileApi;
+}
+
+export default api;
diff --git a/lib/routes/twitter/api/mobile-api/api.ts b/lib/routes/twitter/api/mobile-api/api.ts
new file mode 100644
index 00000000000000..4f3e12ce554a6a
--- /dev/null
+++ b/lib/routes/twitter/api/mobile-api/api.ts
@@ -0,0 +1,310 @@
+import CryptoJS from 'crypto-js';
+import OAuth from 'oauth-1.0a';
+import queryString from 'query-string';
+
+import { config } from '@/config';
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import cache from '@/utils/cache';
+import logger from '@/utils/logger';
+import ofetch from '@/utils/ofetch';
+
+import { baseUrl, consumerKey, consumerSecret, gqlFeatures, gqlMap } from './constants';
+import { getToken } from './token';
+
+const twitterGot = async (url, params) => {
+ const token = await getToken();
+
+ const oauth = new OAuth({
+ consumer: {
+ key: consumerKey,
+ secret: consumerSecret,
+ },
+ signature_method: 'HMAC-SHA1',
+ hash_function: (base_string, key) => CryptoJS.HmacSHA1(base_string, key).toString(CryptoJS.enc.Base64),
+ });
+
+ const requestData = {
+ url: `${url}?${queryString.stringify(params)}`,
+ method: 'GET',
+ headers: {
+ connection: 'keep-alive',
+ 'content-type': 'application/json',
+ 'x-twitter-active-user': 'yes',
+ authority: 'api.x.com',
+ 'accept-encoding': 'gzip',
+ 'accept-language': 'en-US,en;q=0.9',
+ accept: '*/*',
+ DNT: '1',
+ },
+ };
+
+ const response = await ofetch.raw(requestData.url, {
+ headers: oauth.toHeader(oauth.authorize(requestData, token)),
+ });
+ if (response.status === 401) {
+ cache.globalCache.set(token.cacheKey, '');
+ }
+
+ return response._data;
+};
+
+const paginationTweets = async (endpoint, userId, variables, path) => {
+ const { data } = await twitterGot(baseUrl + endpoint, {
+ variables: JSON.stringify({
+ ...variables,
+ rest_id: userId,
+ }),
+ features: gqlFeatures,
+ });
+
+ let instructions;
+ if (path) {
+ instructions = data;
+ for (const p of path) {
+ instructions = instructions[p];
+ }
+ instructions = instructions.instructions;
+ } else {
+ instructions = data.user_result.result.timeline_response.timeline.instructions;
+ }
+
+ return instructions.find((i) => i.__typename === 'TimelineAddEntries' || i.type === 'TimelineAddEntries').entries;
+};
+
+const timelineTweets = (userId, params = {}) =>
+ paginationTweets(gqlMap.UserWithProfileTweets, userId, {
+ ...params,
+ withQuickPromoteEligibilityTweetFields: true,
+ });
+
+const timelineTweetsAndReplies = (userId, params = {}) =>
+ paginationTweets(gqlMap.UserWithProfileTweetsAndReplies, userId, {
+ ...params,
+ count: 20,
+ });
+
+const timelineMedia = (userId, params = {}) => paginationTweets(gqlMap.MediaTimeline, userId, params);
+
+// const timelineLikes = (userId, params = {}) => paginationTweets(gqlMap.Likes, userId, params);
+
+const timelineKeywords = (keywords, params = {}) =>
+ paginationTweets(
+ gqlMap.SearchTimeline,
+ null,
+ {
+ ...params,
+ rawQuery: keywords,
+ count: 20,
+ product: 'Latest',
+ withDownvotePerspective: false,
+ withReactionsMetadata: false,
+ withReactionsPerspective: false,
+ },
+ ['search_by_raw_query', 'search_timeline', 'timeline']
+ );
+
+const tweetDetail = (userId, params) =>
+ paginationTweets(
+ gqlMap.TweetDetail,
+ userId,
+ {
+ ...params,
+ includeHasBirdwatchNotes: false,
+ includePromotedContent: false,
+ withBirdwatchNotes: false,
+ withVoice: false,
+ withV2Timeline: true,
+ },
+ ['threaded_conversation_with_injections_v2']
+ );
+
+const listTweets = (listId, params = {}) =>
+ paginationTweets(
+ gqlMap.ListTimeline,
+ listId,
+ {
+ ...params,
+ },
+ ['list', 'timeline_response', 'timeline']
+ );
+
+function gatherLegacyFromData(entries, filterNested, userId) {
+ const tweets = [];
+ const filteredEntries = [];
+ for (const entry of entries) {
+ const entryId = entry.entryId;
+ if (entryId) {
+ if (entryId.startsWith('tweet-')) {
+ filteredEntries.push(entry);
+ }
+ if (filterNested && filterNested.some((f) => entryId.startsWith(f))) {
+ filteredEntries.push(...entry.content.items);
+ }
+ }
+ }
+ for (const entry of filteredEntries) {
+ if (entry.entryId) {
+ const content = entry.content || entry.item;
+ let tweet = content?.content?.tweetResult?.result || content?.itemContent?.tweet_results?.result;
+ if (tweet && tweet.tweet) {
+ tweet = tweet.tweet;
+ }
+ if (tweet) {
+ const retweet = tweet.legacy?.retweeted_status_result?.result;
+ for (const t of [tweet, retweet]) {
+ if (!t?.legacy) {
+ continue;
+ }
+ t.legacy.user = t.core?.user_result?.result?.legacy || t.core?.user_results?.result?.legacy;
+ t.legacy.id_str = t.rest_id; // avoid falling back to conversation_id_str elsewhere
+ const quote = t.quoted_status_result?.result;
+ if (quote) {
+ t.legacy.quoted_status = quote.legacy;
+ t.legacy.quoted_status.user = quote.core.user_result?.result?.legacy || quote.core.user_results?.result?.legacy;
+ }
+ if (t.note_tweet) {
+ const tmp = t.note_tweet.note_tweet_results.result;
+ t.legacy.entities.hashtags = tmp.entity_set.hashtags;
+ t.legacy.entities.symbols = tmp.entity_set.symbols;
+ t.legacy.entities.urls = tmp.entity_set.urls;
+ t.legacy.entities.user_mentions = tmp.entity_set.user_mentions;
+ t.legacy.full_text = tmp.text;
+ }
+ }
+ const legacy = tweet.legacy;
+ if (legacy) {
+ if (retweet) {
+ legacy.retweeted_status = retweet.legacy;
+ }
+ if (userId === undefined || legacy.user_id_str === userId + '') {
+ tweets.push(legacy);
+ }
+ }
+ }
+ }
+ }
+ return tweets;
+}
+
+const getUserTweetsByID = async (id, params = {}) => gatherLegacyFromData(await timelineTweets(id, params));
+// TODO: show the whole conversation instead of just the reply tweet
+const getUserTweetsAndRepliesByID = async (id, params = {}) => gatherLegacyFromData(await timelineTweetsAndReplies(id, params), ['profile-conversation-'], id);
+const getUserMediaByID = async (id, params = {}) => gatherLegacyFromData(await timelineMedia(id, params));
+// const getUserLikesByID = async (id, params = {}) => gatherLegacyFromData(await timelineLikes(id, params));
+const getUserTweetByStatus = async (id, params = {}) => gatherLegacyFromData(await tweetDetail(id, params), ['homeConversation-', 'conversationthread-']);
+const getListById = async (id, params = {}) => gatherLegacyFromData(await listTweets(id, params));
+
+const excludeRetweet = function (tweets) {
+ const excluded = [];
+ for (const t of tweets) {
+ if (t.retweeted_status) {
+ continue;
+ }
+ excluded.push(t);
+ }
+ return excluded;
+};
+
+const userByScreenName = (screenName) =>
+ twitterGot(`${baseUrl}${gqlMap.UserResultByScreenName}`, {
+ variables: `{"screen_name":"${screenName}","withHighlightedLabel":true}`,
+ features: gqlFeatures,
+ });
+const userByRestId = (restId) =>
+ twitterGot(`${baseUrl}${gqlMap.UserByRestId}`, {
+ variables: `{"userId":"${restId}","withHighlightedLabel":true}`,
+ features: gqlFeatures,
+ });
+const userByAuto = (id) => {
+ if (id.startsWith('+')) {
+ return userByRestId(id.slice(1));
+ }
+ return userByScreenName(id);
+};
+const getUserData = (id) => cache.tryGet(`twitter-userdata-${id}`, () => userByAuto(id));
+const getUserID = async (id) => {
+ const userData = await getUserData(id);
+ return (userData.data?.user || userData.data?.user_result)?.result?.rest_id;
+};
+const getUser = async (id) => {
+ const userData = await getUserData(id);
+ return (userData.data?.user || userData.data?.user_result)?.result?.legacy;
+};
+
+const cacheTryGet = async (_id, params, func) => {
+ const id = await getUserID(_id);
+ if (id === undefined) {
+ throw new InvalidParameterError('User not found');
+ }
+ const funcName = func.name;
+ const paramsString = JSON.stringify(params);
+ return cache.tryGet(`twitter:${id}:${funcName}:${paramsString}`, () => func(id, params), config.cache.routeExpire, false);
+};
+
+// returns:
+// 1. nothing for some users
+// 2. HOT tweets for the other users, instead of the LATEST ones
+const _getUserTweets = (id, params = {}) => cacheTryGet(id, params, getUserTweetsByID);
+// workaround for the above issue:
+// 1. getUserTweetsAndReplies return LATEST tweets and replies, which requires filtering
+// a. if one replies a lot (e.g. elonmusk), there is sometimes no tweets left after filtering, caching may help
+// 2. getUserMedia return LATEST media tweets, which is a good plus
+const getUserTweets = async (id, params = {}) => {
+ let tweets = [];
+ const rest_id = await getUserID(id);
+ await Promise.all(
+ [_getUserTweets, getUserTweetsAndReplies, getUserMedia].map(async (func) => {
+ try {
+ tweets.push(...(await func(id, params)));
+ } catch (error) {
+ logger.warn(`Failed to get tweets for ${id} with ${func.name}: ${error}`);
+ }
+ })
+ );
+
+ const cacheKey = `twitter:user:tweets-cache:${rest_id}`;
+ let cacheValue = await cache.get(cacheKey);
+ if (cacheValue) {
+ cacheValue = JSON.parse(cacheValue);
+ if (cacheValue && cacheValue.length) {
+ tweets = [...cacheValue, ...tweets];
+ }
+ }
+ const idSet = new Set();
+ tweets = tweets
+ .filter(
+ (tweet) =>
+ !tweet.in_reply_to_user_id_str || // exclude replies
+ tweet.in_reply_to_user_id_str === rest_id // but include replies to self (threads)
+ )
+ .map((tweet) => {
+ const id_str = tweet.id_str || tweet.conversation_id_str;
+ return !idSet.has(id_str) && idSet.add(id_str) && tweet;
+ }) // deduplicate
+ .filter(Boolean) // remove null
+ .toSorted((a, b) => (b.id_str || b.conversation_id_str) - (a.id_str || a.conversation_id_str)) // desc
+ .slice(0, 20);
+ cache.set(cacheKey, JSON.stringify(tweets));
+ return tweets;
+};
+const getUserTweetsAndReplies = (id, params = {}) => cacheTryGet(id, params, getUserTweetsAndRepliesByID);
+const getUserMedia = (id, params = {}) => cacheTryGet(id, params, getUserMediaByID);
+// const getUserLikes = (id, params = {}) => cacheTryGet(id, params, getUserLikesByID);
+const getUserTweet = (id, params) => cacheTryGet(id, params, getUserTweetByStatus);
+
+const getSearch = async (keywords, params = {}) => gatherLegacyFromData(await timelineKeywords(keywords, params));
+
+const getList = (id, params = {}) => cache.tryGet(`twitter:${id}:getListById:${JSON.stringify(params)}`, () => getListById(id, params), config.cache.routeExpire, false);
+
+export default {
+ getUser,
+ getUserTweets,
+ getUserTweetsAndReplies,
+ getUserMedia,
+ // getUserLikes,
+ excludeRetweet,
+ getSearch,
+ getList,
+ getUserTweet,
+ init: () => void 0,
+};
diff --git a/lib/routes/twitter/api/mobile-api/constants.ts b/lib/routes/twitter/api/mobile-api/constants.ts
new file mode 100644
index 00000000000000..01af424945b80e
--- /dev/null
+++ b/lib/routes/twitter/api/mobile-api/constants.ts
@@ -0,0 +1,86 @@
+const baseUrl = 'https://api.x.com';
+
+const consumerKey = '3nVuSoBZnx6U4vzUxf5w';
+const consumerSecret = 'Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys';
+
+const graphQLEndpointsPlain = [
+ '/graphql/u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery',
+ '/graphql/oPppcargziU1uDQHAUmH-A/UserResultByIdQuery',
+ '/graphql/3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2',
+ '/graphql/8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2',
+ '/graphql/PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2',
+ '/graphql/q94uRCEn65LZThakYcPT6g/TweetDetail',
+ '/graphql/sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery',
+ '/graphql/gkjsKepM6gl_HmFWoWKfgg/SearchTimeline',
+ '/graphql/iTpgCtbdxrsJfyx0cFjHqg/ListByRestId',
+ '/graphql/-kmqNvm5Y-cVrfvBy6docg/ListBySlug',
+ '/graphql/P4NpVZDqUD_7MEM84L-8nw/ListMembers',
+ '/graphql/BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline',
+];
+
+const gqlMap = Object.fromEntries(graphQLEndpointsPlain.map((endpoint) => [endpoint.split('/')[3].replace(/V2$|Query$|QueryV2$/, ''), endpoint]));
+
+const gqlFeatures = JSON.stringify({
+ android_graphql_skip_api_media_color_palette: false,
+ blue_business_profile_image_shape_enabled: false,
+ creator_subscriptions_subscription_count_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ freedom_of_speech_not_reach_fetch_enabled: false,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: false,
+ hidden_profile_likes_enabled: false,
+ highlights_tweets_tab_ui_enabled: false,
+ interactive_text_enabled: false,
+ longform_notetweets_consumption_enabled: true,
+ longform_notetweets_inline_media_enabled: false,
+ longform_notetweets_richtext_consumption_enabled: true,
+ longform_notetweets_rich_text_read_enabled: false,
+ responsive_web_edit_tweet_api_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ responsive_web_graphql_exclude_directive_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ responsive_web_graphql_timeline_navigation_enabled: false,
+ responsive_web_media_download_video_enabled: false,
+ responsive_web_text_conversations_enabled: false,
+ responsive_web_twitter_article_tweet_consumption_enabled: false,
+ responsive_web_twitter_blue_verified_badge_is_enabled: true,
+ rweb_lists_timeline_redesign_enabled: true,
+ spaces_2022_h2_clipping: true,
+ spaces_2022_h2_spaces_communities: true,
+ standardized_nudges_misinfo: false,
+ subscriptions_verification_info_enabled: true,
+ subscriptions_verification_info_reason_enabled: true,
+ subscriptions_verification_info_verified_since_enabled: true,
+ super_follow_badge_privacy_enabled: false,
+ super_follow_exclusive_tweet_notifications_enabled: false,
+ super_follow_tweet_api_enabled: false,
+ super_follow_user_api_enabled: false,
+ tweet_awards_web_tipping_enabled: false,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: false,
+ tweetypie_unmention_optimization_enabled: false,
+ unified_cards_ad_metadata_container_dynamic_card_content_query_enabled: false,
+ verified_phone_label_enabled: false,
+ vibe_api_enabled: false,
+ view_counts_everywhere_api_enabled: false,
+});
+
+const timelineParams = {
+ include_can_media_tag: 1,
+ include_cards: 1,
+ include_entities: 1,
+ include_profile_interstitial_type: 0,
+ include_quote_count: 0,
+ include_reply_count: 0,
+ include_user_entities: 0,
+ include_ext_reply_count: 0,
+ include_ext_media_color: 0,
+ cards_platform: 'Web-13',
+ tweet_mode: 'extended',
+ send_error_codes: 1,
+ simple_quoted_tweet: 1,
+};
+
+const bearerToken = 'Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F';
+
+const guestActivateUrl = baseUrl + '/1.1/guest/activate.json';
+
+export { baseUrl, bearerToken, consumerKey, consumerSecret, gqlFeatures, gqlMap, guestActivateUrl, timelineParams };
diff --git a/lib/routes/twitter/api/mobile-api/login.ts b/lib/routes/twitter/api/mobile-api/login.ts
new file mode 100644
index 00000000000000..5544ea21024277
--- /dev/null
+++ b/lib/routes/twitter/api/mobile-api/login.ts
@@ -0,0 +1,214 @@
+// https://github.com/BANKA2017/twitter-monitor/blob/node/apps/open_account/scripts/login.mjs
+
+import crypto from 'node:crypto';
+
+import { authenticator } from 'otplib';
+import { RateLimiterMemory, RateLimiterQueue, RateLimiterRedis } from 'rate-limiter-flexible';
+import { v5 as uuidv5 } from 'uuid';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import logger from '@/utils/logger';
+import ofetch from '@/utils/ofetch';
+
+import { bearerToken, guestActivateUrl } from './constants';
+
+const ENDPOINT = 'https://api.x.com/1.1/onboarding/task.json';
+
+const NAMESPACE = 'd41d092b-b007-48f7-9129-e9538d2d8fe9';
+
+const headers = {
+ 'User-Agent': 'TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9 (OnePlus;ONEPLUS+A3010;OnePlus;OnePlus3;0;;1;2016)',
+ 'X-Twitter-API-Version': '5',
+ 'X-Twitter-Client': 'TwitterAndroid',
+ 'X-Twitter-Client-Version': '10.21.0-release.0',
+ 'OS-Version': '28',
+ 'System-User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9; ONEPLUS A3010 Build/PKQ1.181203.001)',
+ 'X-Twitter-Active-User': 'yes',
+ 'Content-Type': 'application/json',
+ Authorization: bearerToken,
+};
+
+const loginLimiter = cache.clients.redisClient
+ ? new RateLimiterRedis({
+ points: 1,
+ duration: 20,
+ execEvenly: true,
+ storeClient: cache.clients.redisClient,
+ })
+ : new RateLimiterMemory({
+ points: 1,
+ duration: 20,
+ execEvenly: true,
+ });
+
+const loginLimiterQueue = new RateLimiterQueue(loginLimiter);
+
+const postTask = async (flowToken: string, subtaskId: string, subtaskInput: Record) =>
+ await got.post(ENDPOINT, {
+ headers,
+ json: {
+ flow_token: flowToken,
+ subtask_inputs: [Object.assign({ subtask_id: subtaskId }, subtaskInput)],
+ },
+ });
+
+// In the Twitter login flow, each task successfully requested will respond with a 'subtask_id' to determine what the next task is, and the execution sequence of the tasks is non-fixed.
+// So abstract these tasks out into a map so that they can be dynamically executed during the login flow.
+// If there are missing tasks in the future, simply add the implementation of that task to it.
+const flowTasks = {
+ async LoginEnterUserIdentifier({ flowToken, username }) {
+ return await postTask(flowToken, 'LoginEnterUserIdentifier', {
+ enter_text: {
+ suggestion_id: null,
+ text: username,
+ link: 'next_link',
+ },
+ });
+ },
+ async LoginEnterPassword({ flowToken, password }) {
+ return await postTask(flowToken, 'LoginEnterPassword', {
+ enter_password: {
+ password,
+ link: 'next_link',
+ },
+ });
+ },
+ async LoginEnterAlternateIdentifierSubtask({ flowToken, phoneOrEmail }) {
+ return await postTask(flowToken, 'LoginEnterAlternateIdentifierSubtask', {
+ enter_text: {
+ suggestion_id: null,
+ text: phoneOrEmail,
+ link: 'next_link',
+ },
+ });
+ },
+ async AccountDuplicationCheck({ flowToken }) {
+ return await postTask(flowToken, 'AccountDuplicationCheck', {
+ check_logged_in_account: {
+ link: 'AccountDuplicationCheck_false',
+ },
+ });
+ },
+ async LoginTwoFactorAuthChallenge({ flowToken, authenticationSecret }) {
+ const token = authenticator.generate(authenticationSecret);
+ return await postTask(flowToken, 'LoginTwoFactorAuthChallenge', {
+ enter_text: {
+ suggestion_id: null,
+ text: token,
+ link: 'next_link',
+ },
+ });
+ },
+};
+
+async function login({ username, password, authenticationSecret, phoneOrEmail }) {
+ return (await cache.tryGet(
+ `twitter:authentication:${username}`,
+ async () => {
+ try {
+ await loginLimiterQueue.removeTokens(1);
+
+ logger.debug('Twitter login start.');
+
+ headers['X-Twitter-Client-DeviceID'] = uuidv5(username, NAMESPACE);
+
+ const ct0 = crypto.randomUUID().replaceAll('-', '');
+ const guestToken = await got(guestActivateUrl, {
+ headers: {
+ authorization: bearerToken,
+ 'x-csrf-token': ct0,
+ cookie: 'ct0=' + ct0,
+ },
+ method: 'POST',
+ });
+ logger.debug('Twitter login: guest token');
+
+ headers['x-guest-token'] = guestToken.data.guest_token;
+
+ let task = await ofetch
+ .raw(
+ ENDPOINT +
+ '?' +
+ new URLSearchParams({
+ flow_name: 'login',
+ api_version: '1',
+ known_device_token: '',
+ sim_country_code: 'us',
+ }).toString(),
+ {
+ method: 'POST',
+ headers,
+ body: {
+ flow_token: null,
+ input_flow_data: {
+ country_code: null,
+ flow_context: {
+ referrer_context: {
+ referral_details: 'utm_source=google-play&utm_medium=organic',
+ referrer_url: '',
+ },
+ start_location: {
+ location: 'deeplink',
+ },
+ },
+ requested_variant: null,
+ target_user_id: 0,
+ },
+ },
+ }
+ )
+ .then(({ headers: _headers, _data }) => {
+ headers.att = _headers.get('att');
+ return { data: _data };
+ });
+
+ logger.debug('Twitter login flow start.');
+ const runTask = async ({ data }) => {
+ const { subtask_id, open_account } = data.subtasks.shift();
+
+ // If `open_account` exists (and 'subtask_id' is `LoginSuccessSubtask`), it means the login was successful.
+ if (open_account) {
+ return open_account;
+ }
+
+ // If task does not exist in `flowTasks`, we need to implement it.
+ if (!(subtask_id in flowTasks)) {
+ logger.error(`Twitter login flow task failed: unknown subtask: ${subtask_id}`);
+ return;
+ }
+
+ task = await flowTasks[subtask_id]({
+ flowToken: data.flow_token,
+ username,
+ password,
+ authenticationSecret,
+ phoneOrEmail,
+ });
+ logger.debug(`Twitter login flow task finished: subtask: ${subtask_id}.`);
+
+ return await runTask(task);
+ };
+ const authentication = await runTask(task);
+ logger.debug('Twitter login flow finished.');
+
+ if (authentication) {
+ logger.debug('Twitter login success.', authentication);
+ } else {
+ logger.error(`Twitter login failed. ${JSON.stringify(task.data?.subtasks, null, 2)}`);
+ }
+
+ return authentication;
+ } catch (error) {
+ logger.error(`Twitter username ${username} login failed:`, error);
+ }
+ },
+ 60 * 60 * 24 * 30, // 30 days
+ false
+ )) as {
+ oauth_token: string;
+ oauth_token_secret: string;
+ } | null;
+}
+
+export default login;
diff --git a/lib/routes/twitter/api/mobile-api/token.ts b/lib/routes/twitter/api/mobile-api/token.ts
new file mode 100644
index 00000000000000..93a6069cec072f
--- /dev/null
+++ b/lib/routes/twitter/api/mobile-api/token.ts
@@ -0,0 +1,39 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+
+import login from './login';
+
+let tokenIndex = 0;
+
+async function getToken() {
+ let token;
+ if (config.twitter.username && config.twitter.password) {
+ const index = tokenIndex++ % config.twitter.username.length;
+ const username = config.twitter.username[index];
+ const password = config.twitter.password[index];
+ const authenticationSecret = config.twitter.authenticationSecret?.[index];
+ const phoneOrEmail = config.twitter.phoneOrEmail?.[index];
+ if (username && password) {
+ const authentication = await login({
+ username,
+ password,
+ authenticationSecret,
+ phoneOrEmail,
+ });
+ if (!authentication) {
+ throw new ConfigNotFoundError(`Invalid twitter configs: ${username}`);
+ }
+ token = {
+ key: authentication.oauth_token,
+ secret: authentication.oauth_token_secret,
+ cacheKey: `twitter:authentication:${username}`,
+ };
+ }
+ } else {
+ throw new ConfigNotFoundError('Invalid twitter configs');
+ }
+
+ return token;
+}
+
+export { getToken };
diff --git a/lib/routes/twitter/api/web-api/api.ts b/lib/routes/twitter/api/web-api/api.ts
new file mode 100644
index 00000000000000..be9c908cc42bd4
--- /dev/null
+++ b/lib/routes/twitter/api/web-api/api.ts
@@ -0,0 +1,220 @@
+import { config } from '@/config';
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+import { baseUrl, gqlFeatures, gqlMap } from './constants';
+import { gatherLegacyFromData, paginationTweets, twitterGot } from './utils';
+
+const getUserData = (id) =>
+ cache.tryGet(`twitter-userdata-${id}`, () => {
+ const params = {
+ variables: id.startsWith('+')
+ ? JSON.stringify({
+ userId: id.slice(1),
+ withSafetyModeUserFields: true,
+ })
+ : JSON.stringify({
+ screen_name: id,
+ withSafetyModeUserFields: true,
+ }),
+ features: JSON.stringify(id.startsWith('+') ? gqlFeatures.UserByRestId : gqlFeatures.UserByScreenName),
+ fieldToggles: JSON.stringify({
+ withAuxiliaryUserLabels: false,
+ }),
+ };
+
+ if (config.twitter.thirdPartyApi) {
+ const endpoint = id.startsWith('+') ? gqlMap.UserByRestId : gqlMap.UserByScreenName;
+
+ return ofetch(`${config.twitter.thirdPartyApi}${endpoint}`, {
+ method: 'GET',
+ params,
+ headers: {
+ 'accept-encoding': 'gzip',
+ },
+ });
+ }
+
+ return twitterGot(`${baseUrl}${id.startsWith('+') ? gqlMap.UserByRestId : gqlMap.UserByScreenName}`, params, {
+ allowNoAuth: !id.startsWith('+'),
+ });
+ });
+
+const cacheTryGet = async (_id, params, func) => {
+ const userData: any = await getUserData(_id);
+ const id = (userData.data?.user || userData.data?.user_result)?.result?.rest_id;
+ if (id === undefined) {
+ cache.set(`twitter-userdata-${_id}`, '', config.cache.contentExpire);
+ throw new InvalidParameterError('User not found');
+ }
+ const funcName = func.name;
+ const paramsString = JSON.stringify(params);
+ return cache.tryGet(`twitter:${id}:${funcName}:${paramsString}`, () => func(id, params), config.cache.routeExpire, false);
+};
+
+const getUserTweets = (id: string, params?: Record) =>
+ cacheTryGet(id, params, async (id, params = {}) =>
+ gatherLegacyFromData(
+ await paginationTweets('UserTweets', id, {
+ ...params,
+ count: 20,
+ includePromotedContent: true,
+ withQuickPromoteEligibilityTweetFields: true,
+ withVoice: true,
+ withV2Timeline: true,
+ })
+ )
+ );
+
+const getUserTweetsAndReplies = (id: string, params?: Record) =>
+ cacheTryGet(id, params, async (id, params = {}) =>
+ gatherLegacyFromData(
+ await paginationTweets('UserTweetsAndReplies', id, {
+ ...params,
+ count: 20,
+ includePromotedContent: true,
+ withCommunity: true,
+ withVoice: true,
+ withV2Timeline: true,
+ }),
+ ['profile-conversation-'],
+ id
+ )
+ );
+
+const getUserMedia = (id: string, params?: Record) =>
+ cacheTryGet(id, params, async (id, params = {}) =>
+ gatherLegacyFromData(
+ await paginationTweets('UserMedia', id, {
+ ...params,
+ count: 20,
+ includePromotedContent: false,
+ withClientEventToken: false,
+ withBirdwatchNotes: false,
+ withVoice: true,
+ withV2Timeline: true,
+ })
+ )
+ );
+
+const getUserLikes = (id: string, params?: Record) =>
+ cacheTryGet(id, params, async (id, params = {}) =>
+ gatherLegacyFromData(
+ await paginationTweets('Likes', id, {
+ ...params,
+ includeHasBirdwatchNotes: false,
+ includePromotedContent: false,
+ withBirdwatchNotes: false,
+ withVoice: false,
+ withV2Timeline: true,
+ })
+ )
+ );
+
+const getUserTweet = (id: string, params?: Record) =>
+ cacheTryGet(id, params, async (id, params = {}) =>
+ gatherLegacyFromData(
+ await paginationTweets(
+ 'TweetDetail',
+ id,
+ {
+ ...params,
+ includeHasBirdwatchNotes: false,
+ includePromotedContent: false,
+ withBirdwatchNotes: false,
+ withVoice: false,
+ withV2Timeline: true,
+ },
+ ['threaded_conversation_with_injections_v2']
+ ),
+ ['homeConversation-', 'conversationthread-']
+ )
+ );
+
+const getSearch = async (keywords: string, params?: Record) =>
+ gatherLegacyFromData(
+ await paginationTweets(
+ 'SearchTimeline',
+ undefined,
+ {
+ ...params,
+ rawQuery: keywords,
+ count: 20,
+ querySource: 'typed_query',
+ product: 'Latest',
+ },
+ ['search_by_raw_query', 'search_timeline', 'timeline']
+ )
+ );
+
+const getList = async (id: string, params?: Record) =>
+ gatherLegacyFromData(
+ await paginationTweets(
+ 'ListLatestTweetsTimeline',
+ undefined,
+ {
+ ...params,
+ listId: id,
+ count: 20,
+ },
+ ['list', 'tweets_timeline', 'timeline']
+ )
+ );
+
+const getUser = async (id: string) => {
+ const userData: any = await getUserData(id);
+ return {
+ profile_image_url: userData.data?.user?.result?.avatar?.image_url,
+ ...userData.data?.user?.result?.core,
+ ...(userData.data?.user || userData.data?.user_result)?.result?.legacy,
+ };
+};
+
+const getHomeTimeline = async (id: string, params?: Record) =>
+ gatherLegacyFromData(
+ await paginationTweets(
+ 'HomeTimeline',
+ undefined,
+ {
+ ...params,
+ count: 20,
+ includePromotedContent: true,
+ latestControlAvailable: true,
+ requestContext: 'launch',
+ withCommunity: true,
+ },
+ ['home', 'home_timeline_urt']
+ )
+ );
+
+const getHomeLatestTimeline = async (id: string, params?: Record) =>
+ gatherLegacyFromData(
+ await paginationTweets(
+ 'HomeLatestTimeline',
+ undefined,
+ {
+ ...params,
+ count: 20,
+ includePromotedContent: true,
+ latestControlAvailable: true,
+ requestContext: 'launch',
+ withCommunity: true,
+ },
+ ['home', 'home_timeline_urt']
+ )
+ );
+
+export default {
+ getUser,
+ getUserTweets,
+ getUserTweetsAndReplies,
+ getUserMedia,
+ getUserLikes,
+ getUserTweet,
+ getSearch,
+ getList,
+ getHomeTimeline,
+ getHomeLatestTimeline,
+ init: () => {},
+};
diff --git a/lib/routes/twitter/api/web-api/constants.ts b/lib/routes/twitter/api/web-api/constants.ts
new file mode 100644
index 00000000000000..b1ca1d46a1af2b
--- /dev/null
+++ b/lib/routes/twitter/api/web-api/constants.ts
@@ -0,0 +1,117 @@
+const baseUrl = 'https://x.com/i/api';
+
+const graphQLEndpointsPlain = [
+ '/graphql/E3opETHurmVJflFsUBVuUQ/UserTweets',
+ '/graphql/Yka-W8dz7RaEuQNkroPkYw/UserByScreenName',
+ '/graphql/HJFjzBgCs16TqxewQOeLNg/HomeTimeline',
+ '/graphql/DiTkXJgLqBBxCs7zaYsbtA/HomeLatestTimeline',
+ '/graphql/bt4TKuFz4T7Ckk-VvQVSow/UserTweetsAndReplies',
+ '/graphql/dexO_2tohK86JDudXXG3Yw/UserMedia',
+ '/graphql/Qw77dDjp9xCpUY-AXwt-yQ/UserByRestId',
+ '/graphql/UN1i3zUiCWa-6r-Uaho4fw/SearchTimeline',
+ '/graphql/Pa45JvqZuKcW1plybfgBlQ/ListLatestTweetsTimeline',
+ '/graphql/QuBlQ6SxNAQCt6-kBiCXCQ/TweetDetail',
+];
+
+const gqlMap = Object.fromEntries(graphQLEndpointsPlain.map((endpoint) => [endpoint.split('/')[3].replace(/V2$|Query$|QueryV2$/, ''), endpoint]));
+
+const thirdPartySupportedAPI = ['UserByScreenName', 'UserByRestId', 'UserTweets', 'UserTweetsAndReplies', 'ListLatestTweetsTimeline', 'SearchTimeline', 'UserMedia'];
+
+const gqlFeatureUser = {
+ hidden_profile_subscriptions_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ responsive_web_graphql_exclude_directive_enabled: true,
+ verified_phone_label_enabled: false,
+ subscriptions_verification_info_is_identity_verified_enabled: true,
+ subscriptions_verification_info_verified_since_enabled: true,
+ highlights_tweets_tab_ui_enabled: true,
+ responsive_web_twitter_article_notes_tab_enabled: true,
+ subscriptions_feature_can_gift_premium: true,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+};
+const gqlFeatureFeed = {
+ rweb_tipjar_consumption_enabled: true,
+ responsive_web_graphql_exclude_directive_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ rweb_video_timestamps_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_enhance_cards_enabled: false,
+};
+
+const TweetDetailFeatures = {
+ rweb_tipjar_consumption_enabled: true,
+ responsive_web_graphql_exclude_directive_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ rweb_video_timestamps_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_enhance_cards_enabled: false,
+};
+const gqlFeatures = {
+ UserByScreenName: gqlFeatureUser,
+ UserByRestId: gqlFeatureUser,
+ UserTweets: gqlFeatureFeed,
+ UserTweetsAndReplies: gqlFeatureFeed,
+ UserMedia: gqlFeatureFeed,
+ SearchTimeline: gqlFeatureFeed,
+ ListLatestTweetsTimeline: gqlFeatureFeed,
+ HomeTimeline: gqlFeatureFeed,
+ HomeLatestTimeline: TweetDetailFeatures,
+ TweetDetail: TweetDetailFeatures,
+ Likes: gqlFeatureFeed,
+};
+
+const timelineParams = {
+ include_can_media_tag: 1,
+ include_cards: 1,
+ include_entities: 1,
+ include_profile_interstitial_type: 0,
+ include_quote_count: 0,
+ include_reply_count: 0,
+ include_user_entities: 0,
+ include_ext_reply_count: 0,
+ include_ext_media_color: 0,
+ cards_platform: 'Web-13',
+ tweet_mode: 'extended',
+ send_error_codes: 1,
+ simple_quoted_tweet: 1,
+};
+
+const bearerToken = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
+
+export { baseUrl, bearerToken, gqlFeatures, gqlMap, thirdPartySupportedAPI, timelineParams };
diff --git a/lib/routes/twitter/api/web-api/login.ts b/lib/routes/twitter/api/web-api/login.ts
new file mode 100644
index 00000000000000..85f71a023de528
--- /dev/null
+++ b/lib/routes/twitter/api/web-api/login.ts
@@ -0,0 +1,74 @@
+import { authenticator } from 'otplib';
+import { RateLimiterMemory, RateLimiterQueue, RateLimiterRedis } from 'rate-limiter-flexible';
+import { CookieJar } from 'tough-cookie';
+
+import cache from '@/utils/cache';
+import logger from '@/utils/logger';
+import puppeteer from '@/utils/puppeteer';
+
+const loginLimiter = cache.clients.redisClient
+ ? new RateLimiterRedis({
+ points: 1,
+ duration: 20,
+ execEvenly: true,
+ storeClient: cache.clients.redisClient,
+ })
+ : new RateLimiterMemory({
+ points: 1,
+ duration: 20,
+ execEvenly: true,
+ });
+
+const loginLimiterQueue = new RateLimiterQueue(loginLimiter);
+
+async function login({ username, password, authenticationSecret }) {
+ if (!username || !password) {
+ return;
+ }
+ try {
+ await loginLimiterQueue.removeTokens(1);
+
+ const cookieJar = new CookieJar();
+ const browser = await puppeteer();
+ const page = await browser.newPage();
+ await page.goto('https://x.com/i/flow/login');
+ await page.waitForSelector('input[autocomplete="username"]');
+ await page.type('input[autocomplete="username"]', username);
+ const buttons = await page.$$('button');
+ await buttons[3]?.click();
+ await page.waitForSelector('input[autocomplete="current-password"]');
+ await page.type('input[autocomplete="current-password"]', password);
+ (await page.waitForSelector('button[data-testid="LoginForm_Login_Button"]'))?.click();
+ if (authenticationSecret) {
+ await page.waitForSelector('input[inputmode="numeric"]');
+ const token = authenticator.generate(authenticationSecret);
+ await page.type('input[inputmode="numeric"]', token);
+ (await page.waitForSelector('button[data-testid="ocfEnterTextNextButton"]'))?.click();
+ }
+ const waitForRequest = new Promise((resolve) => {
+ page.on('response', async (response) => {
+ if (response.url().includes('/HomeTimeline')) {
+ const data = await response.json();
+ const message = data?.data?.home?.home_timeline_urt?.instructions?.[0]?.entries?.[0]?.entryId;
+ if (message === 'messageprompt-suspended-prompt') {
+ logger.error(`twitter debug: twitter username ${username} login failed: messageprompt-suspended-prompt`);
+ resolve('');
+ }
+ const cookies = await page.cookies();
+ for (const cookie of cookies) {
+ cookieJar.setCookieSync(`${cookie.name}=${cookie.value}`, 'https://x.com');
+ }
+ logger.debug(`twitter debug: twitter username ${username} login success`);
+ resolve(JSON.stringify(cookieJar.serializeSync()));
+ }
+ });
+ });
+ const cookieString = await waitForRequest;
+ await browser.close();
+ return cookieString;
+ } catch (error) {
+ logger.error(`twitter debug: twitter username ${username} login failed:`, error);
+ }
+}
+
+export default login;
diff --git a/lib/routes/twitter/api/web-api/utils.ts b/lib/routes/twitter/api/web-api/utils.ts
new file mode 100644
index 00000000000000..becf3f74aaacb3
--- /dev/null
+++ b/lib/routes/twitter/api/web-api/utils.ts
@@ -0,0 +1,357 @@
+import { cookie as HttpCookieAgentCookie, CookieAgent } from 'http-cookie-agent/undici';
+import queryString from 'query-string';
+import { Cookie, CookieJar } from 'tough-cookie';
+import { Client, ProxyAgent } from 'undici';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import cache from '@/utils/cache';
+import logger from '@/utils/logger';
+import ofetch from '@/utils/ofetch';
+import proxy from '@/utils/proxy';
+
+import { baseUrl, bearerToken, gqlFeatures, gqlMap, thirdPartySupportedAPI } from './constants';
+import login from './login';
+
+let authTokenIndex = 0;
+
+const token2Cookie = async (token) => {
+ const c = await cache.get(`twitter:cookie:${token}`);
+ if (c) {
+ return c;
+ }
+ const jar = new CookieJar();
+ await jar.setCookie(`auth_token=${token}`, 'https://x.com');
+ try {
+ const agent = proxy.proxyUri
+ ? new ProxyAgent({
+ factory: (origin, opts) => new Client(origin as string, opts).compose(HttpCookieAgentCookie({ jar })),
+ uri: proxy.proxyUri,
+ })
+ : new CookieAgent({ cookies: { jar } });
+ if (token) {
+ await ofetch('https://x.com', {
+ dispatcher: agent,
+ });
+ } else {
+ const data = await ofetch('https://x.com/narendramodi?mx=2', {
+ dispatcher: agent,
+ });
+ const gt = data.match(/document\.cookie="gt=(\d+)/)?.[1];
+ if (gt) {
+ jar.setCookieSync(`gt=${gt}`, 'https://x.com');
+ }
+ }
+ const cookie = JSON.stringify(jar.serializeSync());
+ cache.set(`twitter:cookie:${token}`, cookie);
+ return cookie;
+ } catch {
+ // ignore
+ return '';
+ }
+};
+
+const lockPrefix = 'twitter:lock-token1:';
+
+const getAuth = async (retry: number) => {
+ if (config.twitter.authToken && retry > 0) {
+ const index = authTokenIndex++ % config.twitter.authToken.length;
+ const token = config.twitter.authToken[index];
+ const lock = await cache.get(`${lockPrefix}${token}`, false);
+ if (lock) {
+ logger.debug(`twitter debug: twitter cookie for token ${token} is locked, retry: ${retry}`);
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 500 + 500));
+ return await getAuth(retry - 1);
+ } else {
+ logger.debug(`twitter debug: lock twitter cookie for token ${token}`);
+ await cache.set(`${lockPrefix}${token}`, '1', 20);
+ return {
+ token,
+ username: config.twitter.username?.[index],
+ password: config.twitter.password?.[index],
+ authenticationSecret: config.twitter.authenticationSecret?.[index],
+ };
+ }
+ }
+};
+
+export const twitterGot = async (
+ url,
+ params,
+ options?: {
+ allowNoAuth?: boolean;
+ }
+) => {
+ const auth = await getAuth(30);
+
+ if (!auth && !options?.allowNoAuth) {
+ throw new ConfigNotFoundError('No valid Twitter token found');
+ }
+
+ const requestUrl = `${url}?${queryString.stringify(params)}`;
+
+ let cookie: string | Record | null | undefined = await token2Cookie(auth?.token);
+ if (!cookie && auth) {
+ cookie = await login({
+ username: auth.username,
+ password: auth.password,
+ authenticationSecret: auth.authenticationSecret,
+ });
+ }
+ let dispatchers:
+ | {
+ jar: CookieJar;
+ agent: CookieAgent | ProxyAgent;
+ }
+ | undefined;
+ if (cookie) {
+ logger.debug(`twitter debug: got twitter cookie for token ${auth?.token}`);
+ if (typeof cookie === 'string') {
+ cookie = JSON.parse(cookie);
+ }
+ const jar = CookieJar.deserializeSync(cookie as any);
+ const agent = proxy.proxyUri
+ ? new ProxyAgent({
+ factory: (origin, opts) => new Client(origin as string, opts).compose(HttpCookieAgentCookie({ jar })),
+ uri: proxy.proxyUri,
+ })
+ : new CookieAgent({ cookies: { jar } });
+ if (proxy.proxyUri) {
+ logger.debug(`twitter debug: Proxying request: ${requestUrl}`);
+ }
+ dispatchers = {
+ jar,
+ agent,
+ };
+ } else if (auth) {
+ throw new ConfigNotFoundError(`Twitter cookie for token ${auth?.token?.replace(/(\w{8})(\w+)/, (_, v1, v2) => v1 + '*'.repeat(v2.length))} is not valid`);
+ }
+ const jsonCookie = dispatchers
+ ? Object.fromEntries(
+ dispatchers.jar
+ .getCookieStringSync(url)
+ .split(';')
+ .map((c) => Cookie.parse(c)?.toJSON())
+ .map((c) => [c?.key, c?.value])
+ )
+ : {};
+
+ const response = await ofetch.raw(requestUrl, {
+ retry: 0,
+ headers: {
+ authority: 'x.com',
+ accept: '*/*',
+ 'accept-language': 'en-US,en;q=0.9',
+ authorization: bearerToken,
+ 'cache-control': 'no-cache',
+ 'content-type': 'application/json',
+ dnt: '1',
+ pragma: 'no-cache',
+ referer: 'https://x.com/',
+ 'x-twitter-active-user': 'yes',
+ 'x-twitter-client-language': 'en',
+ 'x-csrf-token': jsonCookie.ct0,
+ ...(auth?.token
+ ? {
+ 'x-twitter-auth-type': 'OAuth2Session',
+ }
+ : {
+ 'x-guest-token': jsonCookie.gt,
+ }),
+ },
+ dispatcher: dispatchers?.agent,
+ onResponse: async ({ response }) => {
+ const remaining = response.headers.get('x-rate-limit-remaining');
+ const remainingInt = Number.parseInt(remaining || '0');
+ const reset = response.headers.get('x-rate-limit-reset');
+ logger.debug(
+ `twitter debug: twitter rate limit remaining for token ${auth?.token} is ${remaining} and reset at ${reset}, auth: ${JSON.stringify(auth)}, status: ${response.status}, data: ${JSON.stringify(response._data?.data)}, cookie: ${JSON.stringify(dispatchers?.jar.serializeSync())}`
+ );
+ if (auth) {
+ if (remaining && remainingInt < 2 && reset) {
+ const resetTime = new Date(Number.parseInt(reset) * 1000);
+ const delay = (resetTime.getTime() - Date.now()) / 1000;
+ logger.debug(`twitter debug: twitter rate limit exceeded for token ${auth.token} with status ${response.status}, will unlock after ${delay}s`);
+ await cache.set(`${lockPrefix}${auth.token}`, '1', Math.ceil(delay) * 2);
+ } else if (response.status === 429 || JSON.stringify(response._data?.data) === '{"user":{}}') {
+ logger.debug(`twitter debug: twitter rate limit exceeded for token ${auth.token} with status ${response.status}`);
+ await cache.set(`${lockPrefix}${auth.token}`, '1', 2000);
+ } else if (response.status === 403 || response.status === 401) {
+ const newCookie = await login({
+ username: auth.username,
+ password: auth.password,
+ authenticationSecret: auth.authenticationSecret,
+ });
+ if (newCookie) {
+ logger.debug(`twitter debug: reset twitter cookie for token ${auth.token}, ${newCookie}`);
+ await cache.set(`twitter:cookie:${auth.token}`, newCookie, config.cache.contentExpire);
+ logger.debug(`twitter debug: unlock twitter cookie for token ${auth.token} with error1`);
+ await cache.set(`${lockPrefix}${auth.token}`, '', 1);
+ } else {
+ const tokenIndex = config.twitter.authToken?.indexOf(auth.token);
+ if (tokenIndex !== undefined && tokenIndex !== -1) {
+ config.twitter.authToken?.splice(tokenIndex, 1);
+ }
+ if (auth.username) {
+ const usernameIndex = config.twitter.username?.indexOf(auth.username);
+ if (usernameIndex !== undefined && usernameIndex !== -1) {
+ config.twitter.username?.splice(usernameIndex, 1);
+ }
+ }
+ if (auth.password) {
+ const passwordIndex = config.twitter.password?.indexOf(auth.password);
+ if (passwordIndex !== undefined && passwordIndex !== -1) {
+ config.twitter.password?.splice(passwordIndex, 1);
+ }
+ }
+ logger.debug(`twitter debug: delete twitter cookie for token ${auth.token} with status ${response.status}, remaining tokens: ${config.twitter.authToken?.length}`);
+ await cache.set(`${lockPrefix}${auth.token}`, '1', 3600);
+ }
+ } else {
+ logger.debug(`twitter debug: unlock twitter cookie with success for token ${auth.token}`);
+ await cache.set(`${lockPrefix}${auth.token}`, '', 1);
+ }
+ }
+ },
+ });
+
+ if (auth?.token) {
+ logger.debug(`twitter debug: update twitter cookie for token ${auth.token}`);
+ await cache.set(`twitter:cookie:${auth.token}`, JSON.stringify(dispatchers?.jar.serializeSync()), config.cache.contentExpire);
+ }
+
+ return response._data;
+};
+
+export const paginationTweets = async (endpoint: string, userId: number | undefined, variables: Record, path?: string[]) => {
+ const params = {
+ variables: JSON.stringify({ ...variables, userId }),
+ features: JSON.stringify(gqlFeatures[endpoint]),
+ };
+
+ const fetchData = async () => {
+ if (config.twitter.thirdPartyApi && thirdPartySupportedAPI.includes(endpoint)) {
+ const { data } = await ofetch(`${config.twitter.thirdPartyApi}${gqlMap[endpoint]}`, {
+ method: 'GET',
+ params,
+ headers: {
+ 'accept-encoding': 'gzip',
+ },
+ });
+ return data;
+ }
+ const { data } = await twitterGot(baseUrl + gqlMap[endpoint], params);
+ return data;
+ };
+
+ const getInstructions = (data: any) => {
+ if (path) {
+ let instructions = data;
+ for (const p of path) {
+ instructions = instructions[p];
+ }
+ return instructions.instructions;
+ }
+
+ const userResult = data?.user?.result;
+ const timeline = userResult?.timeline?.timeline || userResult?.timeline?.timeline_v2 || userResult?.timeline_v2?.timeline;
+ const instructions = timeline?.instructions;
+ if (!instructions) {
+ logger.debug(`twitter debug: instructions not found in data: ${JSON.stringify(data)}`);
+ }
+ return instructions;
+ };
+
+ const data = await fetchData();
+ const instructions = getInstructions(data);
+ if (!instructions) {
+ return [];
+ }
+
+ const moduleItems = instructions.find((i) => i.type === 'TimelineAddToModule')?.moduleItems;
+ const entries = instructions.find((i) => i.type === 'TimelineAddEntries')?.entries;
+ const gridEntries = entries.find((i) => i.entryId === 'profile-grid-0')?.content?.items;
+
+ return gridEntries || moduleItems || entries || [];
+};
+
+export function gatherLegacyFromData(entries: any[], filterNested?: string[], userId?: number | string) {
+ const tweets: any[] = [];
+ const filteredEntries: any[] = [];
+ for (const entry of entries) {
+ const entryId = entry.entryId;
+ if (entryId) {
+ if (entryId.startsWith('tweet-')) {
+ filteredEntries.push(entry);
+ } else if (entryId.startsWith('profile-grid-0-tweet-')) {
+ filteredEntries.push(entry);
+ }
+ if (filterNested && filterNested.some((f) => entryId.startsWith(f))) {
+ filteredEntries.push(...entry.content.items);
+ }
+ }
+ }
+ for (const entry of filteredEntries) {
+ if (entry.entryId) {
+ const content = entry.content || entry.item;
+ let tweet = content?.content?.tweetResult?.result || content?.itemContent?.tweet_results?.result;
+ if (tweet && tweet.tweet) {
+ tweet = tweet.tweet;
+ }
+ if (tweet) {
+ const retweet = tweet.legacy?.retweeted_status_result?.result;
+ for (const t of [tweet, retweet]) {
+ if (!t?.legacy) {
+ continue;
+ }
+ t.legacy.user = t.core?.user_result?.result?.legacy || t.core?.user_results?.result?.legacy;
+ // Add name and screen_name from core to maintain compatibility
+ if (t.legacy.user && t.core?.user_results?.result?.core) {
+ const coreUser = t.core.user_results.result.core;
+ if (coreUser.name) {
+ t.legacy.user.name = coreUser.name;
+ }
+ if (coreUser.screen_name) {
+ t.legacy.user.screen_name = coreUser.screen_name;
+ }
+ }
+ t.legacy.id_str = t.rest_id; // avoid falling back to conversation_id_str elsewhere
+ const quote = t.quoted_status_result?.result?.tweet || t.quoted_status_result?.result;
+ if (quote) {
+ t.legacy.quoted_status = quote.legacy;
+ t.legacy.quoted_status.user = quote.core.user_result?.result?.legacy || quote.core.user_results?.result?.legacy;
+ // Add name and screen_name from core for quoted status user
+ if (t.legacy.quoted_status.user && quote.core?.user_results?.result?.core) {
+ const quoteCoreUser = quote.core.user_results.result.core;
+ if (quoteCoreUser.name) {
+ t.legacy.quoted_status.user.name = quoteCoreUser.name;
+ }
+ if (quoteCoreUser.screen_name) {
+ t.legacy.quoted_status.user.screen_name = quoteCoreUser.screen_name;
+ }
+ }
+ }
+ if (t.note_tweet) {
+ const tmp = t.note_tweet.note_tweet_results.result;
+ t.legacy.entities.hashtags = tmp.entity_set.hashtags;
+ t.legacy.entities.symbols = tmp.entity_set.symbols;
+ t.legacy.entities.urls = tmp.entity_set.urls;
+ t.legacy.entities.user_mentions = tmp.entity_set.user_mentions;
+ t.legacy.full_text = tmp.text;
+ }
+ }
+ const legacy = tweet.legacy;
+ if (legacy) {
+ if (retweet) {
+ legacy.retweeted_status = retweet.legacy;
+ }
+ if (userId === undefined || legacy.user_id_str === userId + '') {
+ tweets.push(legacy);
+ }
+ }
+ }
+ }
+ }
+
+ return tweets;
+}
diff --git a/lib/routes/twitter/home-latest.ts b/lib/routes/twitter/home-latest.ts
new file mode 100644
index 00000000000000..df40889449b2c4
--- /dev/null
+++ b/lib/routes/twitter/home-latest.ts
@@ -0,0 +1,64 @@
+import type { Route } from '@/types';
+
+import api from './api';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/home_latest/:routeParams?',
+ categories: ['social-media'],
+ example: '/twitter/home_latest',
+ features: {
+ requireConfig: [
+ {
+ name: 'TWITTER_USERNAME',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_PASSWORD',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_AUTH_TOKEN',
+ description: 'Please see above for details.',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Home latest timeline',
+ maintainers: ['DIYgod', 'CaoMeiYouRen'],
+ handler,
+ radar: [
+ {
+ source: ['x.com/home'],
+ target: '/home_latest',
+ },
+ ],
+};
+
+async function handler(ctx) {
+ // For compatibility
+ const { count, include_rts, only_media } = utils.parseRouteParams(ctx.req.param('routeParams'));
+ const params = count ? { count } : {};
+
+ await api.init();
+ let data = await api.getHomeLatestTimeline('', params);
+ if (!include_rts) {
+ data = utils.excludeRetweet(data);
+ }
+ if (only_media) {
+ data = utils.keepOnlyMedia(data);
+ }
+
+ return {
+ title: `Twitter following timeline`,
+ link: `https://x.com/home`,
+ // description: userInfo?.description,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ };
+}
diff --git a/lib/routes/twitter/home.ts b/lib/routes/twitter/home.ts
new file mode 100644
index 00000000000000..64a8d103c80562
--- /dev/null
+++ b/lib/routes/twitter/home.ts
@@ -0,0 +1,64 @@
+import type { Route } from '@/types';
+
+import api from './api';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/home/:routeParams?',
+ categories: ['social-media'],
+ example: '/twitter/home',
+ features: {
+ requireConfig: [
+ {
+ name: 'TWITTER_USERNAME',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_PASSWORD',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_AUTH_TOKEN',
+ description: 'Please see above for details.',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Home timeline',
+ maintainers: ['DIYgod', 'CaoMeiYouRen'],
+ handler,
+ radar: [
+ {
+ source: ['x.com/home'],
+ target: '/home',
+ },
+ ],
+};
+
+async function handler(ctx) {
+ // For compatibility
+ const { count, include_rts, only_media } = utils.parseRouteParams(ctx.req.param('routeParams'));
+ const params = count ? { count } : {};
+
+ await api.init();
+ let data = await api.getHomeTimeline('', params);
+ if (!include_rts) {
+ data = utils.excludeRetweet(data);
+ }
+ if (only_media) {
+ data = utils.keepOnlyMedia(data);
+ }
+
+ return {
+ title: `Twitter following timeline`,
+ link: `https://x.com/home`,
+ // description: userInfo?.description,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ };
+}
diff --git a/lib/routes/twitter/keyword.ts b/lib/routes/twitter/keyword.ts
new file mode 100644
index 00000000000000..7756ee06122b84
--- /dev/null
+++ b/lib/routes/twitter/keyword.ts
@@ -0,0 +1,61 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+
+import api from './api';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/keyword/:keyword/:routeParams?',
+ categories: ['social-media'],
+ view: ViewType.SocialMedia,
+ example: '/twitter/keyword/RSSHub',
+ parameters: { keyword: 'keyword', routeParams: 'extra parameters, see the table above' },
+ features: {
+ requireConfig: [
+ {
+ name: 'TWITTER_USERNAME',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_PASSWORD',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_AUTH_TOKEN',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_THIRD_PARTY_API',
+ description: 'Please see above for details.',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Keyword',
+ maintainers: ['DIYgod', 'yindaheng98', 'Rongronggg9', 'pseudoyu'],
+ handler,
+ radar: [
+ {
+ source: ['x.com/search'],
+ },
+ ],
+};
+
+async function handler(ctx) {
+ const keyword = ctx.req.param('keyword');
+ await api.init();
+ const data = await api.getSearch(keyword);
+
+ return {
+ title: `Twitter Keyword - ${keyword}`,
+ link: `https://x.com/search?q=${encodeURIComponent(keyword)}`,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/twitter/likes.ts b/lib/routes/twitter/likes.ts
new file mode 100644
index 00000000000000..d2dde8672f1300
--- /dev/null
+++ b/lib/routes/twitter/likes.ts
@@ -0,0 +1,50 @@
+import type { Route } from '@/types';
+
+import api from './api';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/likes/:id/:routeParams?',
+ categories: ['social-media'],
+ example: '/twitter/likes/DIYgod',
+ parameters: { id: 'username', routeParams: 'extra parameters, see the table above' },
+ features: {
+ requireConfig: [
+ {
+ name: 'TWITTER_AUTH_TOKEN',
+ description: 'Please see above for details.',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'User likes',
+ maintainers: ['xyqfer'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const { count, include_rts, only_media } = utils.parseRouteParams(ctx.req.param('routeParams'));
+ const params = count ? { count } : {};
+
+ await api.init();
+ let data = await api.getUserLikes(id, params);
+ if (!include_rts) {
+ data = utils.excludeRetweet(data);
+ }
+ if (only_media) {
+ data = utils.keepOnlyMedia(data);
+ }
+
+ return {
+ title: `Twitter Likes - ${id}`,
+ link: `https://x.com/${id}/likes`,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ };
+}
diff --git a/lib/routes/twitter/list.ts b/lib/routes/twitter/list.ts
new file mode 100644
index 00000000000000..9d71f963661256
--- /dev/null
+++ b/lib/routes/twitter/list.ts
@@ -0,0 +1,60 @@
+import type { Route } from '@/types';
+
+import api from './api';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/list/:id/:routeParams?',
+ categories: ['social-media'],
+ example: '/twitter/list/1502570462752219136',
+ parameters: { id: 'list id, get from url', routeParams: 'extra parameters, see the table above' },
+ features: {
+ requireConfig: [
+ {
+ name: 'TWITTER_AUTH_TOKEN',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_THIRD_PARTY_API',
+ description: 'Please see above for details.',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'List timeline',
+ maintainers: ['DIYgod', 'xyqfer', 'pseudoyu'],
+ handler,
+ radar: [
+ {
+ source: ['x.com/i/lists/:id'],
+ target: '/list/:id',
+ },
+ ],
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const { count, include_rts, only_media } = utils.parseRouteParams(ctx.req.param('routeParams'));
+ const params = count ? { count } : {};
+
+ await api.init();
+ let data = await api.getList(id, params);
+ if (!include_rts) {
+ data = utils.excludeRetweet(data);
+ }
+ if (only_media) {
+ data = utils.keepOnlyMedia(data);
+ }
+
+ return {
+ title: `Twitter List - ${id}`,
+ link: `https://x.com/i/lists/${id}`,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ };
+}
diff --git a/lib/routes/twitter/media.ts b/lib/routes/twitter/media.ts
new file mode 100644
index 00000000000000..6c5a6569ed048e
--- /dev/null
+++ b/lib/routes/twitter/media.ts
@@ -0,0 +1,73 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import logger from '@/utils/logger';
+
+import api from './api';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/media/:id/:routeParams?',
+ categories: ['social-media'],
+ view: ViewType.Pictures,
+ example: '/twitter/media/_RSSHub',
+ parameters: { id: 'username; in particular, if starts with `+`, it will be recognized as a [unique ID](https://github.com/DIYgod/RSSHub/issues/12221), e.g. `+44196397`', routeParams: 'extra parameters, see the table above.' },
+ features: {
+ requireConfig: [
+ {
+ name: 'TWITTER_USERNAME',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_PASSWORD',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_AUTH_TOKEN',
+ description: 'Please see above for details.',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'User media',
+ maintainers: ['DIYgod', 'yindaheng98', 'Rongronggg9'],
+ handler,
+ radar: [
+ {
+ source: ['x.com/:id/media'],
+ target: '/media/:id',
+ },
+ ],
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const { count } = utils.parseRouteParams(ctx.req.param('routeParams'));
+ const params = count ? { count } : {};
+
+ await api.init();
+ const userInfo = await api.getUser(id);
+ let data;
+ try {
+ data = await api.getUserMedia(id, params);
+ } catch (error) {
+ logger.error(error);
+ }
+ const profileImageUrl = userInfo?.profile_image_url || userInfo?.profile_image_url_https;
+
+ return {
+ title: `Twitter @${userInfo?.name}`,
+ link: `https://x.com/${userInfo?.screen_name}/media`,
+ image: profileImageUrl.replace(/_normal.jpg$/, '.jpg'),
+ description: userInfo?.description,
+ item:
+ data &&
+ utils.ProcessFeed(ctx, {
+ data,
+ }),
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/twitter/namespace.ts b/lib/routes/twitter/namespace.ts
new file mode 100644
index 00000000000000..f3662fbbfd42bf
--- /dev/null
+++ b/lib/routes/twitter/namespace.ts
@@ -0,0 +1,51 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'X (Twitter)',
+ url: 'x.com',
+ description: `Specify options (in the format of query string) in parameter \`routeParams\` to control some extra features for Tweets
+
+| Key | Description | Accepts | Defaults to |
+| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------- | ----------------------------------------- |
+| \`readable\` | Enable readable layout | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`authorNameBold\` | Display author name in bold | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`showAuthorInTitle\` | Show author name in title | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` (\`true\` in \`/twitter/followings\`) |
+| \`showAuthorAsTitleOnly\` | Show only author name as title | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`showAuthorInDesc\` | Show author name in description (RSS body) | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` (\`true\` in \`/twitter/followings\`) |
+| \`showQuotedAuthorAvatarInDesc\` | Show avatar of quoted Tweet's author in description (RSS body) (Not recommended if your RSS reader extracts images from description) | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`showAuthorAvatarInDesc\` | Show avatar of author in description (RSS body) (Not recommended if your RSS reader extracts images from description) | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`showEmojiForRetweetAndReply\` | Use "🔁" instead of "RT", "↩️" & "💬" instead of "Re" | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`showSymbolForRetweetAndReply\` | Use " RT " instead of "", " Re " instead of "" | \`0\`/\`1\`/\`true\`/\`false\` | \`true\` |
+| \`showRetweetTextInTitle\` | Show quote comments in title (if \`false\`, only the retweeted tweet will be shown in the title) | \`0\`/\`1\`/\`true\`/\`false\` | \`true\` |
+| \`addLinkForPics\` | Add clickable links for Tweet pictures | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`showTimestampInDescription\` | Show timestamp in description | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`showQuotedInTitle\` | Show quoted tweet in title | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`widthOfPics\` | Width of Tweet pictures | Unspecified/Integer | Unspecified |
+| \`heightOfPics\` | Height of Tweet pictures | Unspecified/Integer | Unspecified |
+| \`sizeOfAuthorAvatar\` | Size of author's avatar | Integer | \`48\` |
+| \`sizeOfQuotedAuthorAvatar\` | Size of quoted tweet's author's avatar | Integer | \`24\` |
+| \`includeReplies\` | Include replies, only available in \`/twitter/user\` | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`includeRts\` | Include retweets, only available in \`/twitter/user\` | \`0\`/\`1\`/\`true\`/\`false\` | \`true\` |
+| \`forceWebApi\` | Force using Web API even if Developer API is configured, only available in \`/twitter/user\` and \`/twitter/keyword\` | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`count\` | \`count\` parameter passed to Twitter API, only available in \`/twitter/user\` | Unspecified/Integer | Unspecified |
+| \`onlyMedia\` | Only get tweets with a media | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+| \`mediaNumber \` | Number the medias | \`0\`/\`1\`/\`true\`/\`false\` | \`false\` |
+
+Specify different option values than default values to improve readability. The URL
+
+\`\`\`
+https://rsshub.app/twitter/user/durov/readable=1&authorNameBold=1&showAuthorInTitle=1&showAuthorInDesc=1&showQuotedAuthorAvatarInDesc=1&showAuthorAvatarInDesc=1&showEmojiForRetweetAndReply=1&showRetweetTextInTitle=0&addLinkForPics=1&showTimestampInDescription=1&showQuotedInTitle=1&heightOfPics=150
+\`\`\`
+
+generates
+
+
+
+Currently supports two authentication methods:
+
+- Using \`TWITTER_AUTH_TOKEN\` (recommended): Configure a comma-separated list of \`auth_token\` cookies of logged-in Twitter Web. RSSHub will use this information to directly access Twitter's web API to obtain data.
+
+- Using \`TWITTER_USERNAME\` \`TWITTER_PASSWORD\` and \`TWITTER_AUTHENTICATION_SECRET\`: Configure a comma-separated list of Twitter username and password. RSSHub will use this information to log in to Twitter and obtain data using the mobile API. Please note that if you have not logged in with the current IP address before, it is easy to trigger Twitter's risk control mechanism.
+`,
+ lang: 'en',
+};
diff --git a/lib/routes/twitter/trends.ts b/lib/routes/twitter/trends.ts
new file mode 100644
index 00000000000000..8b0e63c0c0e78f
--- /dev/null
+++ b/lib/routes/twitter/trends.ts
@@ -0,0 +1,45 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+
+import utils from './utils';
+
+export const route: Route = {
+ path: '/trends/:woeid?',
+ categories: ['social-media'],
+ example: '/twitter/trends/23424856',
+ parameters: { woeid: 'Yahoo! Where On Earth ID. default to woeid=1 (World Wide)' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Trends',
+ maintainers: ['sakamossan'],
+ handler,
+};
+
+async function handler(ctx) {
+ if (!config.twitter || !config.twitter.consumer_key || !config.twitter.consumer_secret) {
+ throw new ConfigNotFoundError('Twitter RSS is disabled due to the lack of relevant config ');
+ }
+ const woeid = ctx.req.param('woeid') ?? 1; // Global information is available by using 1 as the WOEID
+ const client = await utils.getAppClient();
+ const data = await client.v1.get('trends/place.json', { id: woeid });
+ const [{ trends }] = data;
+
+ return {
+ title: `Twitter Trends on ${data[0].locations[0].name}`,
+ link: `https://x.com/i/trends`,
+ item: trends
+ .filter((t) => !t.promoted_content)
+ .map((t) => ({
+ title: t.name,
+ link: t.url,
+ description: t.name + (t.tweet_volume ? ` (${t.tweet_volume})` : ''),
+ })),
+ };
+}
diff --git a/lib/routes/twitter/tweet.ts b/lib/routes/twitter/tweet.ts
new file mode 100644
index 00000000000000..02470cf5f712f4
--- /dev/null
+++ b/lib/routes/twitter/tweet.ts
@@ -0,0 +1,67 @@
+import { config } from '@/config';
+import type { Route } from '@/types';
+import { fallback, queryToBoolean } from '@/utils/readable-social';
+
+import api from './api';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/tweet/:id/status/:status/:original?',
+ categories: ['social-media'],
+ example: '/twitter/tweet/DIYgod/status/1650844643997646852',
+ parameters: {
+ id: 'username; in particular, if starts with `+`, it will be recognized as a [unique ID](https://github.com/DIYgod/RSSHub/issues/12221), e.g. `+44196397`',
+ status: 'tweet ID',
+ original: 'extra parameters, data type of return, if the value is not `0`/`false` and `config.isPackage` is `true`, return the original data of twitter',
+ },
+ features: {
+ requireConfig: [
+ {
+ name: 'TWITTER_USERNAME',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_PASSWORD',
+ description: 'Please see above for details.',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Tweet Details',
+ maintainers: ['LarchLiu', 'Rongronggg9'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const status = ctx.req.param('status');
+ const routeParams = new URLSearchParams(ctx.req.param('original'));
+ const original = fallback(undefined, queryToBoolean(routeParams.get('original')), false);
+ const params = {
+ focalTweetId: status,
+ with_rux_injections: false,
+ includePromotedContent: true,
+ withCommunity: true,
+ withQuickPromoteEligibilityTweetFields: true,
+ withBirdwatchNotes: true,
+ withVoice: true,
+ withV2Timeline: true,
+ };
+ await api.init();
+ const userInfo = await api.getUser(id);
+ const data = await api.getUserTweet(id, params);
+ const profileImageUrl = userInfo.profile_image_url || userInfo.profile_image_url_https;
+ const item = original && config.isPackage ? data : utils.ProcessFeed(ctx, { data });
+
+ return {
+ title: `Twitter @${userInfo.name}`,
+ link: `https://x.com/${userInfo.screen_name}/status/${status}`,
+ image: profileImageUrl.replace(/_normal.jpg$/, '.jpg'),
+ description: userInfo.description,
+ item,
+ };
+}
diff --git a/lib/routes/twitter/user.ts b/lib/routes/twitter/user.ts
new file mode 100644
index 00000000000000..452fa6674be3ab
--- /dev/null
+++ b/lib/routes/twitter/user.ts
@@ -0,0 +1,92 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import logger from '@/utils/logger';
+
+import api from './api';
+import utils from './utils';
+
+export const route: Route = {
+ path: '/user/:id/:routeParams?',
+ categories: ['social-media'],
+ view: ViewType.SocialMedia,
+ example: '/twitter/user/_RSSHub',
+ parameters: {
+ id: 'username; in particular, if starts with `+`, it will be recognized as a [unique ID](https://github.com/DIYgod/RSSHub/issues/12221), e.g. `+44196397`',
+ routeParams: 'extra parameters, see the table above',
+ },
+ features: {
+ requireConfig: [
+ {
+ name: 'TWITTER_USERNAME',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_PASSWORD',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_AUTHENTICATION_SECRET',
+ description: 'TOTP 2FA secret, please see above for details.',
+ optional: true,
+ },
+ {
+ name: 'TWITTER_AUTH_TOKEN',
+ description: 'Please see above for details.',
+ },
+ {
+ name: 'TWITTER_THIRD_PARTY_API',
+ description: 'Use third-party API to query twitter data',
+ optional: true,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'User timeline',
+ maintainers: ['DIYgod', 'yindaheng98', 'Rongronggg9', 'CaoMeiYouRen', 'pseudoyu'],
+ handler,
+ radar: [
+ {
+ source: ['x.com/:id'],
+ target: '/user/:id',
+ },
+ ],
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+
+ // For compatibility
+ const { count, include_replies, include_rts } = utils.parseRouteParams(ctx.req.param('routeParams'));
+ const params = count ? { count } : {};
+
+ await api.init();
+ const userInfo = await api.getUser(id);
+ let data;
+ try {
+ data = await (include_replies ? api.getUserTweetsAndReplies(id, params) : api.getUserTweets(id, params));
+ if (!include_rts) {
+ data = utils.excludeRetweet(data);
+ }
+ } catch (error) {
+ logger.error(error);
+ }
+
+ const profileImageUrl = userInfo?.profile_image_url || userInfo?.profile_image_url_https;
+
+ return {
+ title: `Twitter @${userInfo?.name}`,
+ link: `https://x.com/${userInfo?.screen_name}`,
+ image: profileImageUrl.replace(/_normal.jpg$/, '.jpg'),
+ description: userInfo?.description,
+ item:
+ data &&
+ utils.ProcessFeed(ctx, {
+ data,
+ }),
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/twitter/utils.ts b/lib/routes/twitter/utils.ts
new file mode 100644
index 00000000000000..7eb9c5481bbca5
--- /dev/null
+++ b/lib/routes/twitter/utils.ts
@@ -0,0 +1,522 @@
+import URL from 'node:url';
+
+import { TwitterApi } from 'twitter-api-v2';
+
+import { config } from '@/config';
+import { parseDate } from '@/utils/parse-date';
+import { fallback, queryToBoolean, queryToInteger } from '@/utils/readable-social';
+
+const getQueryParams = (url) => URL.parse(url, true).query;
+const getOriginalImg = (url) => {
+ // https://greasyfork.org/zh-CN/scripts/2312-resize-image-on-open-image-in-new-tab/code#n150
+ let m = null;
+ if ((m = url.match(/^(https?:\/\/\w+\.twimg\.com\/media\/[^/:]+)\.(jpg|jpeg|gif|png|bmp|webp)(:\w+)?$/i))) {
+ let format = m[2];
+ if (m[2] === 'jpeg') {
+ format = 'jpg';
+ }
+ return `${m[1]}?format=${format}&name=orig`;
+ } else if ((m = url.match(/^(https?:\/\/\w+\.twimg\.com\/.+)(\?.+)$/i))) {
+ const pars = getQueryParams(url);
+ if (!pars.format || !pars.name) {
+ return url;
+ }
+ if (pars.name === 'orig') {
+ return url;
+ }
+ return m[1] + '?format=' + pars.format + '&name=orig';
+ } else {
+ return url;
+ }
+};
+const replaceBreak = (text) => text.replaceAll(/ | /g, ' ');
+
+const formatText = (item) => {
+ let text = item.full_text;
+ const id_str = item.id_str || item.conversation_id_str;
+ const urls = item.entities.urls || [];
+ for (const url of urls) {
+ // trim link pointing to the tweet itself (usually appears when the tweet is truncated)
+ text = text.replaceAll(url.url, url.expanded_url?.endsWith(id_str) ? '' : url.expanded_url);
+ }
+ const media = item.extended_entities?.media || [];
+ for (const m of media) {
+ text = text.replaceAll(m.url, '');
+ }
+ return text.trim().replaceAll('\n', ' ');
+};
+
+const ProcessFeed = (ctx, { data = [] }, params = {}) => {
+ // undefined and strings like "exclude_rts_replies" is also safely parsed, so no if branch is needed
+ const routeParams = new URLSearchParams(ctx.req.param('routeParams'));
+
+ const mergedParams = {
+ readable: fallback(params.readable, queryToBoolean(routeParams.get('readable')), false),
+ authorNameBold: fallback(params.authorNameBold, queryToBoolean(routeParams.get('authorNameBold')), false),
+ showAuthorInTitle: fallback(params.showAuthorInTitle, queryToBoolean(routeParams.get('showAuthorInTitle')), false),
+ showAuthorAsTitleOnly: fallback(params.showAuthorAsTitleOnly, queryToBoolean(routeParams.get('showAuthorAsTitleOnly')), false),
+ showAuthorInDesc: fallback(params.showAuthorInDesc, queryToBoolean(routeParams.get('showAuthorInDesc')), false),
+ showQuotedAuthorAvatarInDesc: fallback(params.showQuotedAuthorAvatarInDesc, queryToBoolean(routeParams.get('showQuotedAuthorAvatarInDesc')), false),
+ showAuthorAvatarInDesc: fallback(params.showAuthorAvatarInDesc, queryToBoolean(routeParams.get('showAuthorAvatarInDesc')), false),
+ showEmojiForRetweetAndReply: fallback(params.showEmojiForRetweetAndReply, queryToBoolean(routeParams.get('showEmojiForRetweetAndReply')), false),
+ showSymbolForRetweetAndReply: fallback(params.showSymbolForRetweetAndReply, queryToBoolean(routeParams.get('showSymbolForRetweetAndReply')), true),
+ showRetweetTextInTitle: fallback(params.showRetweetTextInTitle, queryToBoolean(routeParams.get('showRetweetTextInTitle')), true),
+ addLinkForPics: fallback(params.addLinkForPics, queryToBoolean(routeParams.get('addLinkForPics')), false),
+ showTimestampInDescription: fallback(params.showTimestampInDescription, queryToBoolean(routeParams.get('showTimestampInDescription')), false),
+ showQuotedInTitle: fallback(params.showQuotedInTitle, queryToBoolean(routeParams.get('showQuotedInTitle')), false),
+
+ widthOfPics: fallback(params.widthOfPics, queryToInteger(routeParams.get('widthOfPics')), -1),
+ heightOfPics: fallback(params.heightOfPics, queryToInteger(routeParams.get('heightOfPics')), -1),
+ sizeOfAuthorAvatar: fallback(params.sizeOfAuthorAvatar, queryToInteger(routeParams.get('sizeOfAuthorAvatar')), 48),
+ sizeOfQuotedAuthorAvatar: fallback(params.sizeOfQuotedAuthorAvatar, queryToInteger(routeParams.get('sizeOfQuotedAuthorAvatar')), 24),
+ mediaNumber: fallback(params.mediaNumber, queryToInteger(routeParams.get('mediaNumber')), false),
+ };
+
+ params = mergedParams;
+
+ const {
+ readable,
+ authorNameBold,
+ showAuthorInTitle,
+ showAuthorAsTitleOnly,
+ showAuthorInDesc,
+ showQuotedAuthorAvatarInDesc,
+ showAuthorAvatarInDesc,
+ showEmojiForRetweetAndReply,
+ showSymbolForRetweetAndReply,
+ showRetweetTextInTitle,
+ addLinkForPics,
+ showTimestampInDescription,
+ showQuotedInTitle,
+ mediaNumber,
+ widthOfPics,
+ heightOfPics,
+ sizeOfAuthorAvatar,
+ sizeOfQuotedAuthorAvatar,
+ } = params;
+
+ const formatVideo = (media, extraAttrs = '') => {
+ let content = '';
+ let bestVideo = null;
+
+ for (const item of media.video_info.variants) {
+ if (!bestVideo || (item.bitrate || 0) > (bestVideo.bitrate || -Infinity)) {
+ bestVideo = item;
+ }
+ }
+
+ if (bestVideo && bestVideo.url) {
+ const gifAutoPlayAttr = media.type === 'animated_gif' ? `autoplay loop muted webkit-playsinline playsinline` : '';
+ if (!readable) {
+ content += ' ';
+ }
+ content += ` `;
+ }
+
+ return content;
+ };
+
+ const formatMedia = (item) => {
+ let img = '';
+ if (item.extended_entities) {
+ const mediaCount = item.extended_entities.media.length;
+ let index = 1;
+ for (const media of item.extended_entities.media) {
+ // https://developer.x.com/en/docs/tweets/data-dictionary/overview/extended-entities-object
+ let content = '';
+ let style = '';
+ let originalImg;
+ switch (media.type) {
+ case 'animated_gif':
+ case 'video':
+ content = formatVideo(media);
+ break;
+
+ case 'photo':
+ default:
+ originalImg = getOriginalImg(media.media_url_https);
+ if (!readable) {
+ content += ` `;
+ }
+ if (addLinkForPics) {
+ content += ``;
+ }
+ content += ` = 0) {
+ content += ` width="${widthOfPics}"`;
+ style += `width: ${widthOfPics}px;`;
+ }
+ if (heightOfPics > 0) {
+ content += `height="${heightOfPics}" `;
+ style += `height: ${heightOfPics}px;`;
+ }
+ if (widthOfPics <= 0 && heightOfPics <= 0) {
+ content += `width="${media.sizes.large.w}" height="${media.sizes.large.h}" `;
+ }
+ content += ` style="${style}" ` + `${readable ? 'hspace="4" vspace="8"' : ''} src="${originalImg}">`;
+ if (addLinkForPics) {
+ content += ` `;
+ }
+ break;
+ }
+
+ img += content;
+
+ if (mediaNumber) {
+ img += `${index}/${mediaCount}
`;
+ index++;
+ }
+ }
+ }
+
+ if (readable && img) {
+ img = `
` + img;
+ }
+ return img;
+ };
+
+ const generatePicsPrefix = (item) => {
+ // When author avatar is shown, generate invisible for inner images at the beginning of HTML
+ // to please some RSS readers
+ let picsPrefix = '';
+ if (item.extended_entities) {
+ for (const media of item.extended_entities.media) {
+ let content;
+ let originalImg;
+ switch (media.type) {
+ case 'video':
+ content = formatVideo(media, `width="0" height="0"`);
+ break;
+
+ case 'photo':
+ default:
+ originalImg = getOriginalImg(media.media_url_https);
+ content = ` `;
+ break;
+ }
+
+ picsPrefix += content;
+ }
+ }
+ return picsPrefix;
+ };
+
+ return data.map((item) => {
+ const originalItem = item;
+ item = item.retweeted_status || item;
+ item.full_text = item.full_text || item.text;
+ item.full_text = formatText(item);
+ const img = formatMedia(item);
+ let picsPrefix = generatePicsPrefix(item);
+ let quote = '';
+ let quoteInTitle = '';
+
+ // Make quote in description
+ if (item.is_quote_status) {
+ const quoteData = item.quoted_status;
+
+ if (quoteData) {
+ quoteData.full_text = quoteData.full_text || quoteData.text;
+ const author = quoteData.user;
+ quote += '';
+ }
+ }
+
+ // Make title
+ let title = '';
+ if (showAuthorInTitle) {
+ title += originalItem.user?.name + ': ';
+ }
+ const isRetweet = originalItem !== item;
+ const isQuote = item.is_quote_status;
+ if (!isRetweet && (!isQuote || showRetweetTextInTitle)) {
+ if (item.in_reply_to_screen_name) {
+ title += showEmojiForRetweetAndReply ? '↩️ ' : showSymbolForRetweetAndReply ? 'Re ' : '';
+ }
+ title += replaceBreak(originalItem.full_text);
+ }
+ if (isRetweet) {
+ title += showEmojiForRetweetAndReply ? '🔁 ' : showSymbolForRetweetAndReply ? 'RT ' : '';
+ title += item.user.name + ': ';
+ if (item.in_reply_to_screen_name) {
+ title += showEmojiForRetweetAndReply ? ' ↩️ ' : showSymbolForRetweetAndReply ? ' Re ' : '';
+ }
+ title += replaceBreak(item.full_text);
+ }
+
+ if (showQuotedInTitle) {
+ title += quoteInTitle;
+ }
+
+ if (showAuthorAsTitleOnly) {
+ title = originalItem.user?.name;
+ }
+
+ // Make description
+ let description = '';
+ if (showAuthorInDesc && showAuthorAvatarInDesc) {
+ description += picsPrefix;
+ }
+ if (isRetweet) {
+ if (showAuthorInDesc) {
+ if (readable) {
+ description += '';
+ description += ``;
+ }
+ if (authorNameBold) {
+ description += ``;
+ }
+ description += originalItem.user?.name;
+ if (authorNameBold) {
+ description += ` `;
+ }
+ if (readable) {
+ description += ' ';
+ }
+ description += ' ';
+ }
+ description += showEmojiForRetweetAndReply ? '🔁' : showSymbolForRetweetAndReply ? 'RT' : '';
+ if (!showAuthorInDesc) {
+ description += ' ';
+ if (readable) {
+ description += ``;
+ }
+ if (authorNameBold) {
+ description += ``;
+ }
+ description += item.user?.name;
+ if (authorNameBold) {
+ description += ` `;
+ }
+ if (readable) {
+ description += ' ';
+ }
+ }
+ if (readable) {
+ description += ' ';
+ }
+ description += ' ';
+ }
+ if (showAuthorInDesc) {
+ if (readable) {
+ description += ``;
+ }
+
+ if (showAuthorAvatarInDesc) {
+ description += ` `;
+ }
+ if (authorNameBold) {
+ description += ``;
+ }
+ description += item.user?.name;
+ if (authorNameBold) {
+ description += ` `;
+ }
+ if (readable) {
+ description += ` `;
+ }
+ description += `: `;
+ }
+ if (item.in_reply_to_screen_name) {
+ description += showEmojiForRetweetAndReply ? '↩️ ' : showSymbolForRetweetAndReply ? 'Re ' : '';
+ }
+
+ description += item.full_text;
+ // 从 description 提取 话题作为 category,放在此处是为了避免 匹配到 quote 中的 # 80808030 颜色字符
+ const category = description.match(/(\s)?(#[^\s;<]+)/g)?.map((e) => e?.match(/#([^\s<]+)/)?.[1]);
+ description += img;
+ description += quote;
+ if (readable) {
+ description += `
`;
+ }
+
+ if (showTimestampInDescription) {
+ if (readable) {
+ description += ` `;
+ }
+ description += `${parseDate(item.created_at)} `;
+ }
+
+ const link =
+ originalItem.user?.screen_name && (originalItem.id_str || originalItem.conversation_id_str)
+ ? `https://x.com/${originalItem.user?.screen_name}/status/${originalItem.id_str || originalItem.conversation_id_str}`
+ : `https://x.com/${item.user?.screen_name}/status/${item.id_str || item.conversation_id_str}`;
+ return {
+ title,
+ author: [
+ {
+ name: originalItem.user?.name,
+ url: `https://x.com/${originalItem.user?.screen_name}`,
+ avatar: originalItem.user?.profile_image_url_https,
+ },
+ ],
+ description,
+ pubDate: parseDate(item.created_at),
+ link,
+ guid: link.replace('x.com', 'twitter.com'),
+ category,
+ _extra:
+ (isRetweet && {
+ links: [
+ {
+ url: `https://x.com/${item.user?.screen_name || userScreenName}/status/${item.conversation_id_str}`,
+ type: 'repost',
+ },
+ ],
+ }) ||
+ (item.is_quote_status && {
+ links: [
+ {
+ url: `https://x.com/${item.quoted_status?.user?.screen_name}/status/${item.quoted_status?.id_str || item.quoted_status?.conversation_id_str}`,
+ type: 'quote',
+ },
+ ],
+ }) ||
+ (item.in_reply_to_screen_name &&
+ item.in_reply_to_status_id_str && {
+ links: [
+ {
+ url: `https://x.com/${item.in_reply_to_screen_name}/status/${item.in_reply_to_status_id_str}`,
+ type: 'reply',
+ },
+ ],
+ }),
+ };
+ });
+};
+
+let getAppClient = () => null;
+
+if (config.twitter.consumer_key && config.twitter.consumer_secret) {
+ const consumer_keys = config.twitter.consumer_key.split(',');
+ const consumer_secrets = config.twitter.consumer_secret.split(',');
+ const T = {};
+ let count = 0;
+ let index = -1;
+
+ for (const [i, consumer_key] of consumer_keys.entries()) {
+ const consumer_secret = consumer_secrets[i];
+ if (consumer_key && consumer_secret) {
+ T[i] = new TwitterApi({
+ appKey: consumer_key,
+ appSecret: consumer_secret,
+ }).readOnly;
+ count = i + 1;
+ }
+ }
+
+ getAppClient = () => {
+ index++;
+ return T[index % count].appLogin();
+ };
+}
+
+const parseRouteParams = (routeParams) => {
+ let count, include_replies, include_rts, only_media;
+ let force_web_api = false;
+ switch (routeParams) {
+ case 'exclude_rts_replies':
+ case 'exclude_replies_rts':
+ include_replies = false;
+ include_rts = false;
+
+ break;
+
+ case 'include_replies':
+ include_replies = true;
+ include_rts = true;
+
+ break;
+
+ case 'exclude_rts':
+ include_replies = false;
+ include_rts = false;
+
+ break;
+
+ default: {
+ const parsed = new URLSearchParams(routeParams);
+ count = fallback(undefined, queryToInteger(parsed.get('count')));
+ include_replies = fallback(undefined, queryToBoolean(parsed.get('includeReplies')), false);
+ include_rts = fallback(undefined, queryToBoolean(parsed.get('includeRts')), true);
+ force_web_api = fallback(undefined, queryToBoolean(parsed.get('forceWebApi')), false);
+ only_media = fallback(undefined, queryToBoolean(parsed.get('onlyMedia')), false);
+ }
+ }
+ return { count, include_replies, include_rts, force_web_api, only_media };
+};
+
+export const excludeRetweet = function (tweets) {
+ const excluded = [];
+ for (const t of tweets) {
+ if (t.retweeted_status) {
+ continue;
+ }
+ excluded.push(t);
+ }
+ return excluded;
+};
+
+export const keepOnlyMedia = function (tweets) {
+ const excluded = tweets.filter((t) => t.extended_entities && t.extended_entities.media);
+ return excluded;
+};
+
+export default { ProcessFeed, getAppClient, parseRouteParams, excludeRetweet, keepOnlyMedia };
diff --git a/lib/routes/twreporter/category.ts b/lib/routes/twreporter/category.ts
new file mode 100644
index 00000000000000..acce9370eb7d36
--- /dev/null
+++ b/lib/routes/twreporter/category.ts
@@ -0,0 +1,116 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+import fetch from './fetch-article';
+
+export const route: Route = {
+ path: '/category/:category',
+ categories: ['new-media'],
+ example: '/twreporter/category/world',
+ parameters: { category: 'Category' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['twreporter.org/:category'],
+ },
+ ],
+ name: '分類',
+ maintainers: ['emdoe'],
+ handler,
+ url: 'twreporter.org/',
+};
+
+// 发现其实是个开源项目 https://github.com/twreporter/go-api,所以我们能在以下两个文件找到相应的类目 ID,从 https://go-api.twreporter.org/v2/index_page 这里拿的话复杂度比较高而且少了侧栏的几个类目:
+// https://github.com/twreporter/go-api/blob/master/internal/news/category_set.go
+// https://github.com/twreporter/go-api/blob/master/internal/news/category.go
+const CATEGORIES = {
+ world: {
+ name: '國際兩岸',
+ url_name: 'world',
+ category_id: '63206383207bf7c5f871622c',
+ },
+ humanrights: {
+ name: '人權司法',
+ url_name: 'humanrights',
+ category_id: '63206383207bf7c5f8716234',
+ },
+ politics_and_society: {
+ name: '政治社會',
+ url_name: 'politics-and-society',
+ category_id: '63206383207bf7c5f871623d',
+ },
+ health: {
+ name: '醫療健康',
+ url_name: 'health',
+ category_id: '63206383207bf7c5f8716245',
+ },
+ environment: {
+ name: '環境永續',
+ url_name: 'environment',
+ category_id: '63206383207bf7c5f871624d',
+ },
+ econ: {
+ name: '經濟產業',
+ url_name: 'econ',
+ category_id: '63206383207bf7c5f8716254',
+ },
+ culture: {
+ name: '文化生活',
+ url_name: 'culture',
+ category_id: '63206383207bf7c5f8716259',
+ },
+ education: {
+ name: '教育校園',
+ url_name: 'education',
+ category_id: '63206383207bf7c5f8716260',
+ },
+ podcast: {
+ name: 'Podcast',
+ url_name: 'podcast',
+ category_id: '63206383207bf7c5f8716266',
+ },
+ opinion: {
+ name: '評論',
+ url_name: 'opinion',
+ category_id: '63206383207bf7c5f8716269',
+ },
+ photos_section: {
+ name: '影像',
+ url_name: 'photography',
+ category_id: '574d028748fa171000c45d48',
+ },
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category');
+ const url = `https://go-api.twreporter.org/v2/posts?category_id=${CATEGORIES[category].category_id}`;
+ const home = `https://www.twreporter.org/categories/${CATEGORIES[category].url_name}`;
+ const res = await ofetch(url);
+ const list = res.data.records;
+
+ const out = await Promise.all(
+ list.map((item) => {
+ const title = item.title;
+ // categoryNames = item.category_set[0].category.name;
+ return cache.tryGet(item.slug, async () => {
+ const single = await fetch(item.slug);
+ single.title = title;
+ return single;
+ });
+ })
+ );
+
+ return {
+ title: `報導者 | ${CATEGORIES[category].name}`,
+ link: home,
+ item: out,
+ };
+}
diff --git a/lib/routes/twreporter/fetch-article.ts b/lib/routes/twreporter/fetch-article.ts
new file mode 100644
index 00000000000000..fa1d40c510ae34
--- /dev/null
+++ b/lib/routes/twreporter/fetch-article.ts
@@ -0,0 +1,111 @@
+import path from 'node:path';
+
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+
+export default async function fetch(slug: string) {
+ const url = `https://go-api.twreporter.org/v2/posts/${slug}?full=true`;
+ const res = await ofetch(url);
+ const post = res.data;
+
+ const time = post.published_date;
+ // For `writers`
+ let authors = '';
+ if (post.writers) {
+ authors = post.writers.map((writer) => (writer.job_title ? writer.job_title + ' / ' + writer.name : '文字 / ' + writer.name)).join(',');
+ }
+
+ // For `photography`, if it exists
+ let photographers = '';
+ if (post.photographers) {
+ photographers = post.photographers
+ .map((photographer) => {
+ let title = '攝影 / ';
+ if (photographer.job_title) {
+ title = photographer.job_title + ' / ';
+ }
+ return title + photographer.name;
+ })
+ .join(',');
+ authors += ';' + photographers;
+ }
+
+ // Prioritize hero_image, but fall back to og_image if it's missing
+ const imageSource = post.hero_image ?? post.og_image;
+ const bannerImage = imageSource?.resized_targets.desktop.url;
+ const caption = post.leading_image_description;
+ const bannerDescription = imageSource?.description ?? '';
+ const ogDescription = post.og_description;
+ // Only render the banner if we successfully found an image URL
+ const banner = imageSource ? art(path.join(__dirname, 'templates/image.art'), { image: bannerImage, description: bannerDescription, caption }) : '';
+
+ function format(type, content) {
+ let block = '';
+ if (content !== '' && type !== 'embeddedcode') {
+ switch (type) {
+ case 'image':
+ case 'slideshow':
+ block = content.map((image) => art(path.join(__dirname, 'templates/image.art'), { image: image.desktop.url, description: image.description, caption: image.description })).join(' ');
+
+ break;
+
+ case 'blockquote':
+ block = `${content} `;
+
+ break;
+
+ case 'header-one':
+ block = `${content} `;
+
+ break;
+
+ case 'header-two':
+ block = `${content} `;
+
+ break;
+
+ case 'infobox': {
+ const box = content[0];
+ block = `${box.title} ${box.body}`;
+
+ break;
+ }
+ case 'youtube': {
+ const video = content[0].youtubeId;
+ const id = video.split('?')[0];
+ block = art(path.join(__dirname, 'templates/youtube.art'), { video: id });
+
+ break;
+ }
+ case 'quoteby': {
+ const quote = content[0];
+ block = `${quote.quote} ${quote.quoteBy}
`;
+
+ break;
+ }
+ default:
+ block = `${content} `;
+ }
+ }
+ return block;
+ }
+
+ const text = post.content.api_data
+ .map((item) => {
+ const content = item.content;
+ const type = item.type;
+ return format(type, content);
+ })
+ .filter(Boolean)
+ .join(' ');
+ const contents = [banner, ogDescription, text].filter(Boolean).join(' ');
+
+ return {
+ author: authors,
+ description: contents,
+ link: `https://www.twreporter.org/a/${slug}`,
+ guid: `https://www.twreporter.org/a/${slug}`,
+ pubDate: parseDate(time, 'YYYY-MM-DDTHH:mm:ssZ'),
+ };
+}
diff --git a/lib/routes/twreporter/namespace.ts b/lib/routes/twreporter/namespace.ts
new file mode 100644
index 00000000000000..90737c4ec6823d
--- /dev/null
+++ b/lib/routes/twreporter/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '報導者',
+ url: 'twreporter.org',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/twreporter/newest.ts b/lib/routes/twreporter/newest.ts
new file mode 100644
index 00000000000000..eb65efd6e41a07
--- /dev/null
+++ b/lib/routes/twreporter/newest.ts
@@ -0,0 +1,52 @@
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+import fetch from './fetch-article';
+
+export const route: Route = {
+ path: '/newest',
+ categories: ['new-media'],
+ example: '/twreporter/newest',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['twreporter.org/'],
+ },
+ ],
+ name: '最新',
+ maintainers: ['emdoe'],
+ handler,
+ url: 'twreporter.org/',
+};
+
+async function handler() {
+ const base = `https://www.twreporter.org`;
+ const url = `https://go-api.twreporter.org/v2/index_page`;
+ const res = await ofetch(url);
+ const list = res.data.latest_section;
+ const out = await Promise.all(
+ list.map((item) => {
+ const title = item.title;
+ return cache.tryGet(item.slug, async () => {
+ const single = await fetch(item.slug);
+ single.title = title;
+ return single;
+ });
+ })
+ );
+
+ return {
+ title: `報導者 | 最新`,
+ link: base,
+ item: out,
+ };
+}
diff --git a/lib/routes/twreporter/templates/image.art b/lib/routes/twreporter/templates/image.art
new file mode 100644
index 00000000000000..74fd8bd73288de
--- /dev/null
+++ b/lib/routes/twreporter/templates/image.art
@@ -0,0 +1,3 @@
+
+
+{{ caption }}
\ No newline at end of file
diff --git a/lib/routes/twreporter/templates/youtube.art b/lib/routes/twreporter/templates/youtube.art
new file mode 100644
index 00000000000000..5e2aefb3186d43
--- /dev/null
+++ b/lib/routes/twreporter/templates/youtube.art
@@ -0,0 +1 @@
+
diff --git a/lib/routes/txks/namespace.ts b/lib/routes/txks/namespace.ts
new file mode 100644
index 00000000000000..39ff09c37ef7b8
--- /dev/null
+++ b/lib/routes/txks/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '全国通信专业技术人员职业水平考试',
+ url: 'www.txks.org.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/txks/news.ts b/lib/routes/txks/news.ts
new file mode 100644
index 00000000000000..dae83a9ccdcd8f
--- /dev/null
+++ b/lib/routes/txks/news.ts
@@ -0,0 +1,108 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const BASE_URL = 'https://www.txks.org.cn/index/work.html';
+
+const removeFontPresetting = (html: string = ''): string => {
+ const $ = load(html);
+ $('[style]').each((_, element) => {
+ const style = $(element).attr('style') || '';
+ const cleanedStyle = style.replaceAll(/font-family:[^;]*;?/gi, '').trim();
+ $(element).attr('style', cleanedStyle || null);
+ });
+ $('style').each((_, styleElement) => {
+ const cssText = $(styleElement).html() || '';
+ const cleanedCssText = cssText.replaceAll(/font-family:[^;]*;?/gi, '');
+ $(styleElement).html(cleanedCssText);
+ });
+
+ return $.html();
+};
+
+const handler: Route['handler'] = async () => {
+ // Fetch the index page
+ const { data: listResponse } = await got(BASE_URL);
+ const $ = load(listResponse);
+
+ // Select all list items containing news information
+ const ITEM_SELECTOR = 'ul[class*="newsList"] > li';
+ const listItems = $(ITEM_SELECTOR);
+
+ // Map through each list item to extract details
+ const contentLinkList = listItems.toArray().map((element) => {
+ const date = $(element).find('label.time').text().trim().slice(1, -1);
+ const title = $(element).find('a').attr('title')!;
+ const link = $(element).find('a').attr('href')!;
+
+ const formattedDate = parseDate(date);
+ return {
+ date: formattedDate,
+ title,
+ link,
+ };
+ });
+
+ return {
+ title: '全国通信专业技术人员职业水平考试',
+ description: '全国通信专业技术人员职业水平考试网站最新动态和消息推送',
+ link: BASE_URL,
+ image: 'https://www.txks.org.cn/asset/image/logo/logo.png',
+ item: (await Promise.all(
+ contentLinkList.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const CONTENT_SELECTOR = '#contentTxt';
+ const { data: contentResponse } = await got(item.link);
+ const contentPage = load(contentResponse);
+ const content = removeFontPresetting(contentPage(CONTENT_SELECTOR).html() || '');
+ return {
+ title: item.title,
+ pubDate: item.date,
+ link: item.link,
+ description: content,
+ category: ['study'],
+ guid: item.link,
+ id: item.link,
+ image: 'https://www.txks.org.cn/asset/image/logo/logo.png',
+ content,
+ updated: item.date,
+ language: 'zh-CN',
+ };
+ })
+ )
+ )) as DataItem[],
+ allowEmpty: true,
+ language: 'zh-CN',
+ feedLink: 'https://rsshub.app/txks/news',
+ id: 'https://rsshub.app/txks/news',
+ };
+};
+
+export const route: Route = {
+ path: '/news',
+ name: '通信考试动态',
+ description: '**注意:** 官方网站限制了国外网络请求,可能需要通过部署在中国大陆内的 RSSHub 实例访问。',
+ maintainers: ['PrinOrange'],
+ handler,
+ categories: ['study'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ supportRadar: true,
+ },
+ radar: [
+ {
+ title: '全国通信专业技术人员职业水平考试动态',
+ source: ['www.txks.org.cn/index/work', 'www.txks.org.cn'],
+ target: `/news`,
+ },
+ ],
+ example: '/txks/news',
+};
diff --git a/lib/routes/txrjy/fornumtopic.ts b/lib/routes/txrjy/fornumtopic.ts
new file mode 100644
index 00000000000000..11032984010b89
--- /dev/null
+++ b/lib/routes/txrjy/fornumtopic.ts
@@ -0,0 +1,97 @@
+import path from 'node:path';
+
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { art } from '@/utils/render';
+import timezone from '@/utils/timezone';
+
+const rootUrl = 'https://www.txrjy.com';
+
+export const route: Route = {
+ path: '/fornumtopic/:channel?',
+ categories: ['bbs'],
+ example: '/txrjy/fornumtopic',
+ parameters: { channel: '频道的 id,见下表,默认为最新500个主题帖' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '论坛 频道',
+ maintainers: ['Fatpandac'],
+ handler,
+ description: `| 最新 500 个主题帖 | 最新 500 个回复帖 | 最新精华帖 | 最新精华帖 | 一周热帖 | 本月热帖 |
+| :---------------: | :---------------: | :--------: | :--------: | :------: | :------: |
+| 1 | 2 | 3 | 4 | 5 | 6 |`,
+};
+
+async function handler(ctx) {
+ const channel = ctx.req.param('channel') ?? '1';
+ const url = `${rootUrl}/c114-listnewtopic.php?typeid=${channel}`;
+
+ const response = await got(url, {
+ responseType: 'buffer',
+ });
+ const $ = load(iconv.decode(response.data, 'gbk'));
+ const title = $('div.z > a').last().text();
+ const list = $('tbody > tr')
+ .slice(0, 25)
+ .toArray()
+ .map((item) => ({
+ title: $(item).find('td.title2').text(),
+ link: new URL($(item).find('td.title2 > a').attr('href'), rootUrl).href,
+ author: $(item).find('td.author').text(),
+ pubDate: timezone(parseDate($(item).find('td.dateline').text(), 'YYYY-M-D HH:mm'), +8),
+ category: $(item).find('td.forum').text(),
+ }))
+ .filter((item) => item.title);
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link, {
+ responseType: 'buffer',
+ });
+ const content = load(iconv.decode(detailResponse.data, 'gbk'));
+
+ item.description = content('div.c_table')
+ .toArray()
+ .map((item) =>
+ art(path.join(__dirname, 'templates/fornumtopic.art'), {
+ content: content(item)
+ .find('td.t_f')
+ .find('div.a_pr')
+ .remove()
+ .end()
+ .html()
+ ?.replaceAll(/()/g, '$1$2')
+ .replaceAll(/()/g, '$1src$2'),
+ pattl: content(item)
+ .find('div.pattl')
+ .html()
+ ?.replaceAll(/()/g, '$1$2')
+ .replaceAll(/()/g, '$1src$2'),
+ author: content(item).find('a.xw1').text().trim(),
+ })
+ )
+ .join('\n');
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `通信人家园 - 论坛 ${title}`,
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/txrjy/namespace.ts b/lib/routes/txrjy/namespace.ts
new file mode 100644
index 00000000000000..8344778afd1af5
--- /dev/null
+++ b/lib/routes/txrjy/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '通信人家园',
+ url: 'txrjy.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/v2/txrjy/templates/fornumtopic.art b/lib/routes/txrjy/templates/fornumtopic.art
similarity index 100%
rename from lib/v2/txrjy/templates/fornumtopic.art
rename to lib/routes/txrjy/templates/fornumtopic.art
diff --git a/lib/routes/tynu/namespace.ts b/lib/routes/tynu/namespace.ts
new file mode 100644
index 00000000000000..644ee14062c6e9
--- /dev/null
+++ b/lib/routes/tynu/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '太原师范学院',
+ url: 'tynu.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/tynu/tynu.ts b/lib/routes/tynu/tynu.ts
new file mode 100644
index 00000000000000..99373ee6b286d0
--- /dev/null
+++ b/lib/routes/tynu/tynu.ts
@@ -0,0 +1,57 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'http://www.tynu.edu.cn';
+
+export const route: Route = {
+ path: '/',
+ radar: [
+ {
+ source: ['tynu.edu.cn/index/tzgg.htm', 'tynu.edu.cn/index.htm', 'tynu.edu.cn/'],
+ target: '',
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['2PoL'],
+ handler,
+ url: 'tynu.edu.cn/index/tzgg.htm',
+};
+
+async function handler() {
+ const link = `${baseUrl}/index/tzgg.htm`;
+ const response = await got(link);
+ const data = response.data;
+
+ const $ = load(data);
+ const list = $('.news_content_list')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.find('h3').text(),
+ link: new URL(item.find($('a')).attr('href'), baseUrl).href,
+ pubDate: parseDate(item.find('.content_list_time').text(), 'YYYYMM-DD'),
+ };
+ });
+
+ await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link);
+ const $ = load(response.data);
+ item.description = $('#vsb_content').html() + ($('.content ul').not('.btm-cate').html() || '');
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '太原师范学院通知公告',
+ link,
+ item: list,
+ };
+}
diff --git a/lib/routes/typora/changelog-dev.ts b/lib/routes/typora/changelog-dev.ts
new file mode 100644
index 00000000000000..6859758d58ad5b
--- /dev/null
+++ b/lib/routes/typora/changelog-dev.ts
@@ -0,0 +1,58 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/changelog/dev',
+ categories: ['program-update'],
+ example: '/typora/changelog/dev',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['support.typora.io/'],
+ target: '/changelog',
+ },
+ ],
+ name: 'Dev Release Changelog',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'support.typora.io/',
+};
+
+async function handler() {
+ const currentUrl = 'https://typora.io/releases/dev';
+ const response = await got(currentUrl);
+
+ const $ = load(response.data);
+
+ const items = $('h2')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.text(),
+ link: `${currentUrl}#${item.text()}`,
+ description: item
+ .nextUntil('h2')
+ .toArray()
+ .map((item) => $(item).html())
+ .join(''),
+ };
+ });
+
+ return {
+ title: `Typora Changelog - Dev`,
+ link: currentUrl,
+ description: 'Typora Changelog',
+ item: items,
+ };
+}
diff --git a/lib/routes/typora/changelog.ts b/lib/routes/typora/changelog.ts
new file mode 100644
index 00000000000000..c6f7c23dff4261
--- /dev/null
+++ b/lib/routes/typora/changelog.ts
@@ -0,0 +1,67 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/changelog',
+ categories: ['program-update'],
+ example: '/typora/changelog',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['support.typora.io/'],
+ },
+ ],
+ name: 'Changelog',
+ maintainers: ['cnzgray'],
+ handler,
+ url: 'support.typora.io/',
+};
+
+async function handler() {
+ const host = 'https://support.typora.io';
+
+ const { data } = await got(`${host}/store/`);
+
+ const list = Object.values(data)
+ .filter((i) => i.category === 'new')
+ .map((i) => ({
+ title: i.title,
+ author: i.author,
+ description: i.content,
+ link: `${host}${i.url}`,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data } = await got(item.link);
+ const $ = load(data);
+
+ item.pubDate = parseDate($('.post-meta time').text());
+ item.description = $('#post-content').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: 'Typora Changelog',
+ link: host,
+ description: 'Typora Changelog',
+ image: `${host}/assets/img/favicon-128.png`,
+ item: items,
+ };
+}
diff --git a/lib/routes/typora/namespace.ts b/lib/routes/typora/namespace.ts
new file mode 100644
index 00000000000000..23d279674f465c
--- /dev/null
+++ b/lib/routes/typora/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Typora',
+ url: 'typora.io',
+ lang: 'en',
+};
diff --git a/lib/routes/typst/namespace.ts b/lib/routes/typst/namespace.ts
new file mode 100644
index 00000000000000..a24e78df132930
--- /dev/null
+++ b/lib/routes/typst/namespace.ts
@@ -0,0 +1,14 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Typst',
+ url: 'typst.com',
+ description: `
+Compose papers faster: Focus on your text and let Typst take care of layout and formatting.
+`,
+
+ zh: {
+ name: 'Typst',
+ },
+ lang: 'en',
+};
diff --git a/lib/routes/typst/universe.ts b/lib/routes/typst/universe.ts
new file mode 100644
index 00000000000000..2ba9ae26bdd82e
--- /dev/null
+++ b/lib/routes/typst/universe.ts
@@ -0,0 +1,110 @@
+import vm from 'node:vm';
+
+import { load } from 'cheerio';
+import markdownit from 'markdown-it';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+interface Package {
+ name: string;
+ version: string;
+ entrypoint: string;
+ authors: Array;
+ license: string;
+ description: string;
+ repository: string;
+ keywords: Array;
+ compiler: string;
+ exclude: Array;
+ size: number;
+ readme: string;
+ updatedAt: number;
+ releasedAt: number;
+}
+
+interface Context {
+ an: { exports: Array };
+}
+
+const GITHUBRAW_BASE = 'https://raw.githubusercontent.com';
+const PKG_GITHUB_BASE = `${GITHUBRAW_BASE}/typst/packages/main/packages/preview`;
+
+function fixImageSrc(src: string, env: Package) {
+ if (src.includes('://')) {
+ if (src.startsWith('https://typst.app/universe/package')) {
+ src = src.replaceAll('https://typst.app/universe/package', `${PKG_GITHUB_BASE}/${env.name}/${env.version}`);
+ } else if (src.startsWith('https://github.com/') && src.match(/\.(jpeg|jpg|gif|png|bmp|webp)$/gi)?.length) {
+ src = src.replace('https://github.com/', `${GITHUBRAW_BASE}/`);
+ }
+ } else {
+ const suffix = src.startsWith('/') ? '' : '/';
+ const package_base = `${PKG_GITHUB_BASE}/${env.name}/${env.version}${suffix}`;
+ const url = new URL(src, package_base);
+ src = url.toString();
+ }
+ return src;
+}
+
+export const route: Route = {
+ path: '/universe',
+ categories: ['program-update'],
+ example: '/typst/universe',
+ radar: [
+ {
+ source: ['typst.app/universe'],
+ target: '/universe',
+ },
+ ],
+ name: 'Universe',
+ maintainers: ['HPDell'],
+ handler: async () => {
+ const targetUrl = 'https://typst.app/universe/search?kind=packages%2Ctemplates&packages=last-updated';
+ const page = await ofetch(targetUrl);
+ const $ = load(page);
+ const script = $('script')
+ .toArray()
+ .map((item) => item.attribs.src)
+ .find((item) => item && item.startsWith('/scripts/universe-search'));
+ const data: string = await ofetch(`https://typst.app${script}`, {
+ parseResponse: (txt) => txt,
+ });
+ let packages = data.match(/(an.exports=[\S\s]+);var ([$A-Z_a-z][\w$]*)=new Intl.Collator/)?.[1];
+ if (packages) {
+ packages = packages.slice(0, -2);
+ const context: Context = { an: { exports: [] } };
+ vm.createContext(context);
+ vm.runInContext(packages, context, {
+ displayErrors: true,
+ });
+ const md = markdownit('commonmark');
+ const items = context.an.exports.toSorted((a, b) => a.updatedAt - b.updatedAt);
+ const groups = new Map(items.map((it) => [it.name, it]));
+ const pkgs = [...groups.values()].map((item) => {
+ const $ = load(md.render(item.readme));
+ $('img').each((i, el) => {
+ const src = el.attribs.src;
+ el.attribs.src = fixImageSrc(src, item);
+ });
+ return {
+ title: `${item.name} (${item.version}) | ${item.description}`,
+ link: `https://typst.app/universe/package/${item.name}`,
+ description: $.html(),
+ pubDate: parseDate(item.updatedAt, 'X'),
+ };
+ });
+ return {
+ title: 'Typst universe',
+ link: targetUrl,
+ item: pkgs,
+ };
+ } else {
+ return {
+ title: 'Typst universe',
+ link: targetUrl,
+ item: [],
+ };
+ }
+ },
+};
diff --git a/lib/routes/u3c3/index.ts b/lib/routes/u3c3/index.ts
new file mode 100644
index 00000000000000..01fea524c5fc6f
--- /dev/null
+++ b/lib/routes/u3c3/index.ts
@@ -0,0 +1,94 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: ['/search/:keyword/:preview?', '/:type?/:preview?'],
+ categories: ['multimedia'],
+ example: '/u3c3/search/新片速递',
+ parameters: { keyword: 'Search keyword', preview: 'Show image preview, off by default, non empty value means on' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: true,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Search',
+ maintainers: ['storytellerF'],
+ handler,
+};
+
+async function handler(ctx) {
+ const { type, keywoard, preview } = ctx.req.param();
+ // should be:
+ // undefined
+ // U3C3
+ // Videos
+ // Photo
+ // Book
+ // Game
+ // Software
+ // Other
+ const rootURL = 'https://www.u3c3.com';
+ let currentURL;
+ let title;
+ if (keywoard) {
+ currentURL = `${rootURL}/?search=${keywoard}`;
+ title = `search ${keywoard} - u3c3`;
+ } else if (type === undefined) {
+ currentURL = rootURL;
+ title = 'home - u3c3';
+ } else {
+ currentURL = `${rootURL}/?type=${type}&p=1`;
+ title = `${type} - u3c3`;
+ }
+
+ const response = await got(currentURL);
+ const $ = load(response.data);
+
+ const list = $('body > div.container > div.table-responsive > table > tbody > tr')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const title = item.find('td:nth-of-type(2) > a ').attr('title');
+ const guid = rootURL + item.find('td:nth-of-type(2) > a').attr('href');
+ const link = guid;
+ const pubDate = item.find('td:nth-of-type(5)').text();
+ const enclosure_url = item.find('td:nth-of-type(3) > a:nth-of-type(2)').attr('href');
+ return {
+ title,
+ guid,
+ link,
+ pubDate,
+
+ enclosure_url,
+ enclosure_type: 'application/x-bittorrent',
+ };
+ });
+
+ const items = preview
+ ? await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: response } = await got(item.link);
+ const $ = load(response);
+
+ item.description = $('div.panel-footer > img:first-child').parent().html();
+
+ return item;
+ })
+ )
+ )
+ : list;
+
+ return {
+ title,
+ description: title,
+ link: currentURL,
+ item: items,
+ };
+}
diff --git a/lib/routes/u3c3/namespace.ts b/lib/routes/u3c3/namespace.ts
new file mode 100644
index 00000000000000..690e7a898f38fb
--- /dev/null
+++ b/lib/routes/u3c3/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'U3C3',
+ url: 'u3c3.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/u9a9/index.ts b/lib/routes/u9a9/index.ts
new file mode 100644
index 00000000000000..dbf2ea70b46eb7
--- /dev/null
+++ b/lib/routes/u9a9/index.ts
@@ -0,0 +1,84 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const baseUrl = 'https://u9a9.com';
+
+export const route: Route = {
+ path: ['/:preview?', '/search/:keyword/:preview?'],
+ example: '/u9a9/search/新片速递',
+ radar: [
+ {
+ source: ['u9a9.com/'],
+ target: '',
+ },
+ ],
+ name: 'Search',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'u9a9.com/',
+};
+
+async function handler(ctx) {
+ const { preview, keyword } = ctx.req.param();
+
+ let link;
+ let title;
+ if (keyword) {
+ link = `${baseUrl}/?type=2&search=${keyword}`;
+ title = `${keyword} - U9A9`;
+ } else {
+ link = baseUrl;
+ title = 'U9A9';
+ }
+
+ const { data: response } = await got(link);
+ const $ = load(response);
+
+ const list = $('table tr')
+ .slice(1) // skip thead
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('td').eq(1).find('a');
+ const { size, unit } = item
+ .find('td')
+ .eq(3)
+ .text()
+ .match(/(?