mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-16 16:44:53 +08:00
chore: integrate upstream before publishing route recovery
This commit is contained in:
@@ -23,6 +23,24 @@ jobs:
|
||||
- run: pnpm i
|
||||
- run: npm run format:description
|
||||
- run: npm run format
|
||||
# oxlint applies fixes in a single pass, so a fix can leave problems that only the next pass fixes or reports
|
||||
# Repeat on the changed files until they stop changing, and fail instead of committing what lint rejects
|
||||
# https://github.com/oxc-project/oxc/issues/16118
|
||||
- name: Apply chained fixes
|
||||
run: |
|
||||
files=()
|
||||
while IFS= read -r file; do files+=("$file"); done < <(git diff --name-only -- '*.js' '*.mjs' '*.ts' '*.tsx')
|
||||
if [ ${#files[@]} -gt 0 ]; then
|
||||
for _ in 1 2 3 4 5; do
|
||||
before=$(git diff | git hash-object --stdin)
|
||||
pnpm exec oxlint --type-aware --fix "${files[@]}" || true
|
||||
pnpm exec oxfmt "${files[@]}"
|
||||
if [ "$(git diff | git hash-object --stdin)" = "$before" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
pnpm exec oxlint --type-aware "${files[@]}"
|
||||
fi
|
||||
- name: Commit files
|
||||
run: |
|
||||
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
Generated
+6
-6
@@ -64,11 +64,11 @@
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1791549372,
|
||||
"narHash": "sha256-5Vu2oKeImB5wQWLFSEcWXsF0Oajm4/5MV7UlnOdelmg=",
|
||||
"lastModified": 1791785848,
|
||||
"narHash": "sha256-8hOkttkgaVPCKwLHK5T2XbfBIj1k4G6xO1r4UVYm5aA=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "a73c5b84c9527760f009232988466763068f9f61",
|
||||
"rev": "a60ff7da8843686d06b4c6646846024d97138da8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -278,11 +278,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1791456040,
|
||||
"narHash": "sha256-H83yz/do+hjOO0uujHyH8RgtIQP2v7Mw+cLwcZK/xKc=",
|
||||
"lastModified": 1791594108,
|
||||
"narHash": "sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e7439b6b14ad3cc35d05608ebca9bce01a25f5f8",
|
||||
"rev": "6510408d147d8c5e6d76043e899e9251756f6a75",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { load } from 'cheerio';
|
||||
import pMap from 'p-map';
|
||||
|
||||
import InvalidParameterError from '@/errors/types/invalid-parameter';
|
||||
import type { Route } from '@/types';
|
||||
import got from '@/utils/got';
|
||||
import { getRouteLimit } from '@/utils/route-limit';
|
||||
|
||||
import { getRelease } from './utils';
|
||||
|
||||
export const route: Route = {
|
||||
path: '/artist/:artist',
|
||||
categories: ['multimedia'],
|
||||
example: '/bandcamp/artist/radiohead',
|
||||
parameters: { artist: 'Subdomain of the artist or label, such as `radiohead` in `https://radiohead.bandcamp.com`' },
|
||||
features: {
|
||||
requireConfig: false,
|
||||
requirePuppeteer: false,
|
||||
antiCrawler: false,
|
||||
supportBT: false,
|
||||
supportPodcast: false,
|
||||
supportScihub: false,
|
||||
},
|
||||
name: 'Artist Releases',
|
||||
maintainers: ['pseudoyu'],
|
||||
handler,
|
||||
description:
|
||||
'Releases on the music page of an artist or label, in the order the page shows them (newest first unless the artist reorders them). Returns the latest 10 by default, use `limit` for more. Artists on a custom domain still have a bandcamp.com subdomain.',
|
||||
};
|
||||
|
||||
async function handler(ctx) {
|
||||
const artist = ctx.req.param('artist');
|
||||
if (!/^[\da-z-]+$/i.test(artist)) {
|
||||
throw new InvalidParameterError('Use the artist subdomain, such as radiohead in https://radiohead.bandcamp.com.');
|
||||
}
|
||||
const limit = getRouteLimit(ctx, 10);
|
||||
const link = `https://${artist}.bandcamp.com/music`;
|
||||
const response = await got(link);
|
||||
const $ = load(response.data);
|
||||
|
||||
// The grid renders the first releases, and the page adds the rest from data-client-items
|
||||
const clientItems: Array<{ page_url: string }> = JSON.parse($('#music-grid').attr('data-client-items') ?? '[]');
|
||||
const urls = [
|
||||
...$('#music-grid li a')
|
||||
.toArray()
|
||||
.map((element) => $(element).attr('href')!),
|
||||
...clientItems.map((item) => item.page_url),
|
||||
];
|
||||
|
||||
const items = await pMap(
|
||||
urls.slice(0, limit),
|
||||
async (url) => {
|
||||
const itemLink = new URL(url, link).href;
|
||||
return { ...(await getRelease(itemLink)), link: itemLink };
|
||||
},
|
||||
{ concurrency: 3 }
|
||||
);
|
||||
|
||||
return {
|
||||
title: `${JSON.parse($('[data-band]').attr('data-band') ?? '{}').name ?? artist} - Bandcamp`,
|
||||
link,
|
||||
item: items,
|
||||
allowEmpty: true,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import { load } from 'cheerio';
|
||||
import { raw } from 'hono/html';
|
||||
import { renderToString } from 'hono/jsx/dom/server';
|
||||
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 { getRouteLimit } from '@/utils/route-limit';
|
||||
|
||||
import { getRelease } from './utils';
|
||||
|
||||
export const route: Route = {
|
||||
path: '/tag/:tag?',
|
||||
categories: ['multimedia'],
|
||||
@@ -63,40 +60,7 @@ async function handler(ctx) {
|
||||
return { title: item.title, link: link.href, author: item.album_artist || item.band_name };
|
||||
});
|
||||
|
||||
const items = await pMap(
|
||||
list,
|
||||
(item) =>
|
||||
cache.tryGet(item.link, async () => {
|
||||
const detailResponse = await got(item.link);
|
||||
const content = load(detailResponse.data);
|
||||
const data = JSON.parse(content('[data-tralbum]').attr('data-tralbum')!);
|
||||
const image = content('#tralbumArt img').attr('src');
|
||||
const about = content('.tralbum-about').html() ?? '';
|
||||
const tracks = data.trackinfo as Array<{ title: string; title_link?: string }>;
|
||||
|
||||
return {
|
||||
...item,
|
||||
image,
|
||||
pubDate: data.current.publish_date ? parseDate(data.current.publish_date) : undefined,
|
||||
category: content('.tralbum-tags a.tag')
|
||||
.toArray()
|
||||
.map((tag) => content(tag).text()),
|
||||
description: renderToString(
|
||||
<>
|
||||
{image ? <img src={image} /> : null}
|
||||
<div>{raw(about)}</div>
|
||||
{data.current.credits ? <p style="white-space: pre-line">{data.current.credits}</p> : null}
|
||||
<ol>
|
||||
{tracks.map((track) => (
|
||||
<li>{track.title_link ? <a href={new URL(track.title_link, item.link).href}>{track.title}</a> : track.title}</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}),
|
||||
{ concurrency: 3 }
|
||||
);
|
||||
const items = await pMap(list, async (item) => ({ ...(await getRelease(item.link)), ...item }), { concurrency: 3 });
|
||||
|
||||
return {
|
||||
title: `Bandcamp${tag ? ` - ${tag}` : ''}`,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { load } from 'cheerio';
|
||||
import { raw } from 'hono/html';
|
||||
import { renderToString } from 'hono/jsx/dom/server';
|
||||
|
||||
import type { DataItem } from '@/types';
|
||||
import cache from '@/utils/cache';
|
||||
import got from '@/utils/got';
|
||||
import { parseDate } from '@/utils/parse-date';
|
||||
|
||||
export const getRelease = (link: string) =>
|
||||
cache.tryGet(link, async (): Promise<DataItem> => {
|
||||
const detailResponse = await got(link);
|
||||
const content = load(detailResponse.data);
|
||||
const data = JSON.parse(content('[data-tralbum]').attr('data-tralbum')!);
|
||||
const image = content('#tralbumArt img').attr('src');
|
||||
const about = content('.tralbum-about').html() ?? '';
|
||||
const tracks = data.trackinfo as Array<{ title: string; title_link?: string }>;
|
||||
|
||||
return {
|
||||
title: data.current.title,
|
||||
author: data.artist,
|
||||
image,
|
||||
pubDate: data.current.publish_date ? parseDate(data.current.publish_date) : undefined,
|
||||
category: content('.tralbum-tags a.tag')
|
||||
.toArray()
|
||||
.map((tag) => content(tag).text()),
|
||||
description: renderToString(
|
||||
<>
|
||||
{image ? <img src={image} /> : null}
|
||||
<div>{raw(about)}</div>
|
||||
{data.current.credits ? <p style="white-space: pre-line">{data.current.credits}</p> : null}
|
||||
<ol>
|
||||
{tracks.map((track) => (
|
||||
<li>{track.title_link ? <a href={new URL(track.title_link, link).href}>{track.title}</a> : track.title}</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CheerioAPI } from 'cheerio';
|
||||
import { load } from 'cheerio';
|
||||
|
||||
import { config } from '@/config';
|
||||
@@ -27,12 +28,29 @@ export const route: Route = {
|
||||
},
|
||||
features: { antiCrawler: true, requireConfig: [{ name: 'DOUBAN_COOKIE', optional: true, description: '需要登录才能查看的帖子请配置本人豆瓣 Cookie。' }] },
|
||||
radar: [{ source: ['www.douban.com/group/topic/:id'], target: '/group/topic/:id' }],
|
||||
description:
|
||||
'订阅主帖正文和源页面首屏回帖。主帖标题或正文改变时产生新 GUID,以供阅读器识别更新。只看楼主使用源站的 author=1 页面。源站回帖按从早到晚排序,因此长帖尾页的新回复尚不在本路由范围内。日期保留源站创建时间,不冒充最后更新时间。',
|
||||
description: '订阅主帖正文和最新一页回帖。源站回帖按从早到晚排序,多页长帖取尾页的回帖。主帖标题或正文改变时产生新 GUID,以供阅读器识别更新。只看楼主使用源站的 author=1 页面。日期保留源站创建时间,不冒充最后更新时间。',
|
||||
handler,
|
||||
};
|
||||
|
||||
export function parseGroupTopic(html: string, link: string): { title: string; item: DataItem[] } {
|
||||
function parseReplies($: CheerioAPI, link: string, pageUrl = link): DataItem[] {
|
||||
return $('#comments > .comment-item[id]')
|
||||
.toArray()
|
||||
.map((element): DataItem => {
|
||||
const $reply = $(element);
|
||||
const pubtime = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?:\s+(\S.*))?$/.exec($reply.find('.pubtime').first().text());
|
||||
return {
|
||||
title: $reply.find('.reply-content').text(),
|
||||
description: $reply.find('.reply-content').html(),
|
||||
link: `${pageUrl}#${$reply.attr('id')}`,
|
||||
guid: `${link}#${$reply.attr('id')}`,
|
||||
author: $reply.find('h4 > a').first().text(),
|
||||
pubDate: pubtime ? timezone(parseDate(pubtime[1], 'YYYY-MM-DD HH:mm:ss'), 8) : undefined,
|
||||
category: pubtime?.[2] ? [`IP属地:${pubtime[2]}`] : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseGroupTopic(html: string, link: string): { title: string; item: DataItem[]; lastPageStart?: string } {
|
||||
const $ = load(html);
|
||||
const title = $('h1').text();
|
||||
const content = $('.topic-content .rich-content').html();
|
||||
@@ -50,21 +68,10 @@ export function parseGroupTopic(html: string, link: string): { title: string; it
|
||||
category: ipLocation ? [`IP属地:${ipLocation}`] : undefined,
|
||||
guid: `${link}#content-${md5(`${title}\n${content}`)}`,
|
||||
};
|
||||
const replies = $('#comments > .comment-item[id]')
|
||||
.toArray()
|
||||
.map((element): DataItem => {
|
||||
const $reply = $(element);
|
||||
const pubtime = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?:\s+(\S.*))?$/.exec($reply.find('.pubtime').first().text());
|
||||
return {
|
||||
title: $reply.find('.reply-content').text(),
|
||||
description: $reply.find('.reply-content').html(),
|
||||
link: `${link}#${$reply.attr('id')}`,
|
||||
author: $reply.find('h4 > a').first().text(),
|
||||
pubDate: pubtime ? timezone(parseDate(pubtime[1], 'YYYY-MM-DD HH:mm:ss'), 8) : undefined,
|
||||
category: pubtime?.[2] ? [`IP属地:${pubtime[2]}`] : undefined,
|
||||
};
|
||||
});
|
||||
return { title, item: [post, ...replies] };
|
||||
// The last direct link of the paginator points to the last page
|
||||
const lastPage = $('.paginator > a').last().attr('href');
|
||||
const lastPageStart = lastPage ? (new URL(lastPage, link).searchParams.get('start') ?? undefined) : undefined;
|
||||
return { title, item: [post, ...parseReplies($, link)], lastPageStart };
|
||||
}
|
||||
|
||||
async function handler(ctx) {
|
||||
@@ -74,7 +81,16 @@ async function handler(ctx) {
|
||||
throw new InvalidParameterError('Use a numeric topic ID and all or author for the reply scope.');
|
||||
}
|
||||
const link = `https://www.douban.com/group/topic/${id}/`;
|
||||
const { data } = await got(link, { headers: { Cookie: config.douban.cookie }, searchParams: author === 'author' ? { author: 1 } : undefined });
|
||||
const headers = { Cookie: config.douban.cookie };
|
||||
const searchParams = author === 'author' ? { author: '1' } : undefined;
|
||||
const { data } = await got(link, { headers, searchParams });
|
||||
const result = parseGroupTopic(data, link);
|
||||
return { title: `${result.title} - 豆瓣小组帖子`, link, item: result.item };
|
||||
let item = result.item;
|
||||
if (result.lastPageStart) {
|
||||
// Keep the post and replace the oldest replies with those on the last page
|
||||
const pageUrl = `${link}?${new URLSearchParams({ ...searchParams, start: result.lastPageStart })}`;
|
||||
const { data: lastPage } = await got(pageUrl, { headers });
|
||||
item = [item[0], ...parseReplies(load(lastPage), link, pageUrl)];
|
||||
}
|
||||
return { title: `${result.title} - 豆瓣小组帖子`, link, item };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
import type { DataItem, Route } from '@/types';
|
||||
import { ViewType } from '@/types';
|
||||
import ofetch from '@/utils/ofetch';
|
||||
import { parseDate } from '@/utils/parse-date';
|
||||
|
||||
const md = MarkdownIt({ breaks: true, linkify: true });
|
||||
|
||||
interface SocialPost {
|
||||
rawContent: string;
|
||||
url: string;
|
||||
publishedAt: string;
|
||||
author: {
|
||||
name: string;
|
||||
fullname?: string;
|
||||
};
|
||||
attachments?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
type: string;
|
||||
socialPost?: SocialPost;
|
||||
}
|
||||
|
||||
export const route: Route = {
|
||||
path: '/activity/:user/posts',
|
||||
categories: ['programming'],
|
||||
view: ViewType.SocialMedia,
|
||||
example: '/huggingface/activity/AbstractPhil/posts',
|
||||
parameters: {
|
||||
user: 'Hugging Face username',
|
||||
},
|
||||
features: {
|
||||
requireConfig: false,
|
||||
requirePuppeteer: false,
|
||||
antiCrawler: false,
|
||||
supportBT: false,
|
||||
supportPodcast: false,
|
||||
supportScihub: false,
|
||||
},
|
||||
radar: [
|
||||
{
|
||||
source: ['huggingface.co/:user/activity/posts'],
|
||||
target: '/activity/:user/posts',
|
||||
},
|
||||
],
|
||||
name: 'User Posts Activity',
|
||||
maintainers: ['qqc20043', 'pseudoyu'],
|
||||
handler,
|
||||
url: 'huggingface.co',
|
||||
};
|
||||
|
||||
async function handler(ctx) {
|
||||
const { user } = ctx.req.param();
|
||||
const link = `https://huggingface.co/${user}/activity/posts`;
|
||||
|
||||
const { recentActivity } = await ofetch<{ recentActivity: Activity[] }>('https://huggingface.co/api/recent-activity', {
|
||||
query: {
|
||||
feedType: 'user',
|
||||
entity: user,
|
||||
activityType: 'post',
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
|
||||
// The post activity also lists the user's comments and reactions on other posts
|
||||
const items: DataItem[] = recentActivity
|
||||
.flatMap((activity) => (activity.type === 'social-post' && activity.socialPost ? [activity.socialPost] : []))
|
||||
.map((post) => ({
|
||||
title: post.rawContent.trim().split('\n', 1)[0],
|
||||
description:
|
||||
md.render(post.rawContent) +
|
||||
(post.attachments ?? [])
|
||||
.filter((attachment) => attachment.type === 'image' || attachment.type === 'video')
|
||||
.map((attachment) => (attachment.type === 'video' ? `<video controls src="${attachment.url}"></video>` : `<img src="${attachment.url}">`))
|
||||
.join(''),
|
||||
link: `https://huggingface.co${post.url}`,
|
||||
pubDate: parseDate(post.publishedAt),
|
||||
author: post.author.fullname || post.author.name,
|
||||
}));
|
||||
|
||||
return {
|
||||
title: `${user} - Posts Activity`,
|
||||
link,
|
||||
item: items,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Namespace } from '@/types';
|
||||
|
||||
export const namespace: Namespace = {
|
||||
name: 'Premium Bandai',
|
||||
url: 'p-bandai.jp',
|
||||
lang: 'ja',
|
||||
|
||||
ja: {
|
||||
name: 'プレミアムバンダイ',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { load } from 'cheerio';
|
||||
import { renderToString } from 'hono/jsx/dom/server';
|
||||
|
||||
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';
|
||||
import timezone from '@/utils/timezone';
|
||||
|
||||
const link = 'https://p-bandai.jp/new_itemlist/';
|
||||
|
||||
export const route: Route = {
|
||||
path: '/new',
|
||||
categories: ['shopping'],
|
||||
example: '/p-bandai/new',
|
||||
parameters: {},
|
||||
features: {
|
||||
requireConfig: false,
|
||||
requirePuppeteer: false,
|
||||
antiCrawler: false,
|
||||
supportBT: false,
|
||||
supportPodcast: false,
|
||||
supportScihub: false,
|
||||
},
|
||||
radar: [
|
||||
{
|
||||
source: ['p-bandai.jp/new_itemlist'],
|
||||
target: '/new',
|
||||
},
|
||||
],
|
||||
name: 'New Items',
|
||||
maintainers: ['pseudoyu'],
|
||||
handler,
|
||||
url: 'p-bandai.jp/new_itemlist/',
|
||||
description: `Items added in the last 10 days, with the image, the price and the start of sales or reservations.
|
||||
|
||||
::: warning
|
||||
Premium Bandai redirects visitors outside Japan to its international site, so the instance needs a Japanese IP. You can send only these requests through a Japanese proxy with \`PROXY_URI\` and \`PROXY_URL_REGEX=p-bandai\\.jp\`.
|
||||
|
||||
The list shortens long item names, and the item pages are behind a browser check, so full names and delivery dates are not included.
|
||||
:::`,
|
||||
};
|
||||
|
||||
const parseStart = (year: number, month: string, day: string, hour: string) => timezone(parseDate(`${year}-${month}-${day} ${hour}`, 'YYYY-M-D H'), 9);
|
||||
|
||||
async function handler() {
|
||||
const response = await ofetch.raw<ArrayBuffer, 'arrayBuffer'>(link, { responseType: 'arrayBuffer' });
|
||||
if (new URL(response.url).pathname !== '/new_itemlist/') {
|
||||
throw new ConfigNotFoundError('Premium Bandai redirected the request to its international site. Send p-bandai.jp requests through a Japanese proxy with PROXY_URI and PROXY_URL_REGEX.');
|
||||
}
|
||||
const $ = load(new TextDecoder('shift_jis').decode(response._data));
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const items = $('.item_wrap > li')
|
||||
.toArray()
|
||||
.map((element): DataItem => {
|
||||
const $item = $(element);
|
||||
const start = $item.find('.start').contents().first().text().trim();
|
||||
const price = $item.find('.price').text();
|
||||
const [, month, day, hour] = /(\d{1,2})\/(\d{1,2}) (\d{1,2})時/.exec(start) ?? [];
|
||||
// The list gives no year and only covers recent days, so a date ahead of now is from last year
|
||||
let pubDate = month ? parseStart(year, month, day, hour) : undefined;
|
||||
if (pubDate && pubDate > new Date()) {
|
||||
pubDate = parseStart(year - 1, month, day, hour);
|
||||
}
|
||||
// The `b` size of the same image is 560px instead of the 200px thumbnail
|
||||
const image = $item.find('img').attr('src')?.replace('/model/m/', '/model/b/');
|
||||
return {
|
||||
title: $item.find('.itemslist_name span').first().text(),
|
||||
description: renderToString(
|
||||
<>
|
||||
{image ? <img src={image} /> : null}
|
||||
<p>{price}</p>
|
||||
<p>{start}</p>
|
||||
</>
|
||||
),
|
||||
link: new URL($item.find('a').first().attr('href')!, link).href,
|
||||
pubDate,
|
||||
image,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: 'プレミアムバンダイ 新着商品',
|
||||
link,
|
||||
language: 'ja' as const,
|
||||
item: items,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { load } from 'cheerio';
|
||||
import { renderToString } from 'hono/jsx/dom/server';
|
||||
|
||||
import InvalidParameterError from '@/errors/types/invalid-parameter';
|
||||
import NotFoundError from '@/errors/types/not-found';
|
||||
import type { DataItem, Route } from '@/types';
|
||||
import { ViewType } from '@/types';
|
||||
import ofetch from '@/utils/ofetch';
|
||||
import { parseDate } from '@/utils/parse-date';
|
||||
|
||||
interface Price {
|
||||
basePrice: string;
|
||||
discountedPrice: string;
|
||||
endTime: string | null;
|
||||
}
|
||||
|
||||
// The fields this route reads from the store's Apollo cache entries
|
||||
interface CacheEntry {
|
||||
name?: string;
|
||||
products?: Array<{ __ref: string }>;
|
||||
edition?: { name: string } | null;
|
||||
platforms?: string[];
|
||||
skus?: Array<{ __ref: string }>;
|
||||
media?: Array<{ role: string; url: string }>;
|
||||
action?: { param: Array<{ name: string; value: string }> };
|
||||
meta?: { upSellService: string };
|
||||
price?: Price;
|
||||
local?: { discountBadgeText?: string; lowestRecentPrice?: string };
|
||||
}
|
||||
|
||||
export const route: Route = {
|
||||
path: '/price/:id/:locale?',
|
||||
categories: ['game'],
|
||||
view: ViewType.Notifications,
|
||||
example: '/ps/price/10001538',
|
||||
parameters: {
|
||||
id: 'Concept or product ID from the store URL, such as `10001538` in `https://store.playstation.com/en-us/concept/10001538`',
|
||||
locale: 'Store locale from the URL, `en-us` by default',
|
||||
},
|
||||
features: {
|
||||
requireConfig: false,
|
||||
requirePuppeteer: false,
|
||||
antiCrawler: false,
|
||||
supportBT: false,
|
||||
supportPodcast: false,
|
||||
supportScihub: false,
|
||||
},
|
||||
radar: [
|
||||
{
|
||||
source: ['store.playstation.com/:locale/concept/:id', 'store.playstation.com/:locale/product/:id'],
|
||||
target: '/price/:id/:locale',
|
||||
},
|
||||
],
|
||||
name: 'Game Price',
|
||||
maintainers: ['pseudoyu'],
|
||||
handler,
|
||||
url: 'store.playstation.com',
|
||||
description: 'A concept ID lists every edition of the game, and a product ID lists only that product. A new item appears whenever the price or the end of the offer changes. PlayStation Plus prices and trials are not included.',
|
||||
};
|
||||
|
||||
const renderDescription = (product: CacheEntry, price: Price, local: CacheEntry['local'], image?: string) =>
|
||||
renderToString(
|
||||
<>
|
||||
{image ? <img src={image} /> : null}
|
||||
<p>{[product.edition?.name, product.platforms?.join(' / ')].filter(Boolean).join(' · ')}</p>
|
||||
<p>
|
||||
{price.discountedPrice}
|
||||
{price.basePrice === price.discountedPrice ? null : (
|
||||
<>
|
||||
{' '}
|
||||
<s>{price.basePrice}</s> {local?.discountBadgeText}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{price.endTime ? <p>Offer ends {parseDate(Number(price.endTime)).toUTCString()}</p> : null}
|
||||
{local?.lowestRecentPrice ? <p>{local.lowestRecentPrice}</p> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
async function handler(ctx) {
|
||||
const { id, locale = 'en-us' } = ctx.req.param();
|
||||
if (!/^[\w-]+$/.test(id) || !/^[a-z]{2}(?:-[a-z]{2,4}){1,2}$/.test(locale)) {
|
||||
throw new InvalidParameterError('Use the concept or product ID and the locale, such as en-us, from a PlayStation Store URL.');
|
||||
}
|
||||
const type = /^\d+$/.test(id) ? 'concept' : 'product';
|
||||
const link = `https://store.playstation.com/${locale}/${type}/${id}`;
|
||||
const html = await ofetch<string>(link);
|
||||
const $ = load(html);
|
||||
|
||||
// The page embeds several Apollo cache snapshots, each holding part of every entity
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
for (const element of $('script[id^="env:"]').toArray()) {
|
||||
const snapshot = Object.entries<CacheEntry>(JSON.parse($(element).text()).cache ?? {});
|
||||
for (const [key, value] of snapshot) {
|
||||
cache.set(key, { ...cache.get(key), ...value });
|
||||
}
|
||||
}
|
||||
const target = cache.get(type === 'concept' ? `Concept:${id}` : `Product:${id}`);
|
||||
if (!target?.name) {
|
||||
throw new NotFoundError(`The PlayStation Store has no ${type} ${id} in ${locale}.`);
|
||||
}
|
||||
|
||||
const ctas = [...cache].filter(([key]) => key.startsWith('GameCTA:')).map(([, value]) => value);
|
||||
const keys = type === 'concept' ? (target.products ?? []).map((ref) => ref.__ref) : [`Product:${id}`];
|
||||
const items = keys.flatMap((key): DataItem[] => {
|
||||
const product = cache.get(key);
|
||||
const skus = new Set(product?.skus?.map((ref) => ref.__ref.replace('Sku:', '')));
|
||||
// PlayStation Plus upsells such as catalog access or trials are not a purchase price
|
||||
const cta = ctas.find((value) => value.meta?.upSellService === 'NONE' && skus.has(value.action?.param.find((param) => param.name === 'skuId')?.value ?? ''));
|
||||
if (!product?.name || !cta?.price) {
|
||||
return [];
|
||||
}
|
||||
const price = cta.price;
|
||||
const productLink = `https://store.playstation.com/${locale}/product/${key.replace('Product:', '')}`;
|
||||
const image = (product.media?.find((media) => media.role === 'EDITION_KEY_ART') ?? product.media?.find((media) => media.role === 'MASTER'))?.url;
|
||||
return [
|
||||
{
|
||||
title: `${product.name} ${price.discountedPrice}`,
|
||||
description: renderDescription(product, price, cta.local, image),
|
||||
link: productLink,
|
||||
guid: `${productLink}#${price.basePrice}-${price.discountedPrice}-${price.endTime ?? ''}`,
|
||||
image,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
title: `${target.name} - PlayStation Store`,
|
||||
link,
|
||||
item: items,
|
||||
};
|
||||
}
|
||||
@@ -73,31 +73,32 @@ function catalogJson<T>(script: string, name: string): T {
|
||||
} else if (character === '"') {
|
||||
quoted = false;
|
||||
}
|
||||
} else
|
||||
} else {
|
||||
switch (character) {
|
||||
case '"': {
|
||||
case '"':
|
||||
quoted = true;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case '{':
|
||||
case '[': {
|
||||
case '[':
|
||||
depth++;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case '}':
|
||||
case ']': {
|
||||
case ']':
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return JSON.parse(script.slice(start, index + 1));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`The admissions catalog contains incomplete ${name} data.`);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import querystring from 'node:querystring';
|
||||
import { load } from 'cheerio';
|
||||
import { FetchError } from 'ofetch';
|
||||
import pMap from 'p-map';
|
||||
import { errors } from 'teleproto';
|
||||
|
||||
import { config } from '@/config';
|
||||
import type { DataItem, Route } from '@/types';
|
||||
import { ViewType } from '@/types';
|
||||
import cache from '@/utils/cache';
|
||||
import logger from '@/utils/logger';
|
||||
import ofetch from '@/utils/ofetch';
|
||||
import { parseDate } from '@/utils/parse-date';
|
||||
import { fallback, queryToBoolean } from '@/utils/readable-social';
|
||||
@@ -159,7 +161,15 @@ When a reverse proxy removes a public URL prefix (for example, \`/rss\`), set it
|
||||
async function handler(ctx) {
|
||||
const useWeb = ctx.req.param('routeParams') || !config.telegram.session;
|
||||
if (!useWeb) {
|
||||
return tglibchannel(ctx);
|
||||
try {
|
||||
return await tglibchannel(ctx);
|
||||
} catch (error) {
|
||||
// A flood wait blocks the account for minutes to hours, while the public web preview still works
|
||||
if (!(error instanceof errors.FloodWaitError)) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn(`Telegram MTProto requires a wait of ${error.seconds} seconds, falling back to the web preview`);
|
||||
}
|
||||
}
|
||||
|
||||
const username = ctx.req.param('username');
|
||||
|
||||
@@ -148,7 +148,8 @@ export default async function handler(ctx: Context) {
|
||||
if (!peerCache) {
|
||||
const p = await client.getInputEntity(username!);
|
||||
peerCache = JSON.stringify(p.toJSON());
|
||||
await cache.set(`telegram:inputEntity:${username}`, peerCache);
|
||||
// The channel ID and access hash do not change, and contacts.ResolveUsername is heavily rate limited
|
||||
await cache.set(`telegram:inputEntity:${username}`, peerCache, 30 * 24 * 60 * 60);
|
||||
}
|
||||
const peerData = JSON.parse(peerCache, (k, v) => (k === 'channelId' || k === 'accessHash' ? returnBigInt(v) : v));
|
||||
const peer = new Api.InputPeerChannel(peerData);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { cookie as HttpCookieAgentCookie, CookieAgent } from 'http-cookie-agent/undici';
|
||||
import queryString from 'query-string';
|
||||
import { Cookie, CookieJar } from 'tough-cookie';
|
||||
import undici, { ProxyAgent } from 'undici';
|
||||
import type { ProxyAgent } from 'undici';
|
||||
import undici from 'undici';
|
||||
|
||||
import { config } from '@/config';
|
||||
import ConfigNotFoundError from '@/errors/types/config-not-found';
|
||||
@@ -26,11 +27,7 @@ const token2Cookie = async (token) => {
|
||||
const jar = new CookieJar();
|
||||
await jar.setCookie(`auth_token=${token}`, 'https://x.com');
|
||||
try {
|
||||
const agent = proxy.proxyUri
|
||||
? new ProxyAgent({
|
||||
uri: proxy.proxyUri,
|
||||
}).compose(HttpCookieAgentCookie({ jar }))
|
||||
: new CookieAgent({ cookies: { jar } });
|
||||
const agent = proxy.dispatcher ? proxy.dispatcher.compose(HttpCookieAgentCookie({ jar })) : new CookieAgent({ cookies: { jar } });
|
||||
if (token) {
|
||||
await ofetch('https://x.com', {
|
||||
dispatcher: agent,
|
||||
@@ -109,12 +106,8 @@ export const twitterGot = async (
|
||||
if (cookie) {
|
||||
logger.debug('twitter debug: got twitter cookie');
|
||||
const jar = CookieJar.deserializeSync(JSON.parse(cookie));
|
||||
const agent = proxy.proxyUri
|
||||
? new ProxyAgent({
|
||||
uri: proxy.proxyUri,
|
||||
}).compose(HttpCookieAgentCookie({ jar }))
|
||||
: new CookieAgent({ cookies: { jar } });
|
||||
if (proxy.proxyUri) {
|
||||
const agent = proxy.dispatcher ? proxy.dispatcher.compose(HttpCookieAgentCookie({ jar })) : new CookieAgent({ cookies: { jar } });
|
||||
if (proxy.dispatcher) {
|
||||
logger.debug(`twitter debug: Proxying request: ${requestUrl}`);
|
||||
}
|
||||
dispatchers = {
|
||||
|
||||
@@ -166,21 +166,21 @@ const bakeSiteSearchParams = (filterPairs: Record<string, Filter[] | string[]>):
|
||||
continue;
|
||||
}
|
||||
switch (key) {
|
||||
case 'category': {
|
||||
case 'category':
|
||||
searchParams.set('cat', (value as Filter[]).map((pair) => pair.id).join(','));
|
||||
|
||||
break;
|
||||
}
|
||||
case 'tag': {
|
||||
|
||||
case 'tag':
|
||||
searchParams.set('tag', (value as Filter[]).map((pair) => pair.slug).join(','));
|
||||
|
||||
break;
|
||||
}
|
||||
case 'search': {
|
||||
|
||||
case 'search':
|
||||
searchParams.set('s', value.join(','));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
@@ -36,9 +36,14 @@ async function handler(ctx) {
|
||||
url: `https://api.zhihu.com/pins/${id}/moments?limit=10&offset=0`,
|
||||
});
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new TypeError('Zhihu pins API returned an invalid data list');
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${data[0].target.author.name}的知乎想法`,
|
||||
title: `${data.length ? data[0].target.author.name : id}的知乎想法`,
|
||||
link: `https://www.zhihu.com/people/${id}/pins`,
|
||||
item: generateData(data),
|
||||
allowEmpty: data.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +80,30 @@ describe('proxy', () => {
|
||||
expect(requestTlsOf(proxy.dispatcher!).ALPNProtocols).toEqual(['h2', 'http/1.1']);
|
||||
});
|
||||
|
||||
it('creates a socks5 dispatcher for socks5h proxies', async () => {
|
||||
const proxy = await loadProxy({
|
||||
PROXY_URI: 'socks5h://proxy.local:1080',
|
||||
PROXY_URIS: '',
|
||||
PAC_URI: '',
|
||||
});
|
||||
|
||||
expect(proxy.agent).toBeInstanceOf(SocksProxyAgent);
|
||||
expect(proxy.dispatcher).toBeInstanceOf(ProxyAgent);
|
||||
expect(proxy.getCurrentProxy()?.uri).toBe('socks5h://proxy.local:1080');
|
||||
});
|
||||
|
||||
it('disables socks4 proxies instead of failing to start', async () => {
|
||||
const proxy = await loadProxy({
|
||||
PROXY_URI: 'socks4://proxy.local:1080',
|
||||
PROXY_URIS: '',
|
||||
PAC_URI: '',
|
||||
});
|
||||
|
||||
expect(proxy.agent).toBeNull();
|
||||
expect(proxy.dispatcher).toBeNull();
|
||||
expect(proxy.getCurrentProxy()).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers h2 on the http proxy dispatcher', async () => {
|
||||
const proxy = await loadProxy({
|
||||
PROXY_URI: 'http://proxy.local:8080',
|
||||
|
||||
@@ -62,7 +62,8 @@ const createDispatcherForProxy = (uri: string, proxyObj: Config['proxy'], minVer
|
||||
}
|
||||
if (uri.startsWith('socks')) {
|
||||
return new ProxyAgent({
|
||||
uri,
|
||||
// undici only accepts socks5: and socks:, and its SOCKS5 client already lets the proxy resolve host names
|
||||
uri: uri.replace(/^socks5h:/, 'socks5:'),
|
||||
requestTls: {
|
||||
ALPNProtocols: ['h2', 'http/1.1'],
|
||||
minVersion,
|
||||
|
||||
@@ -37,6 +37,10 @@ describe('unify-proxy', () => {
|
||||
it('proxy-obj invalid protocol', () => {
|
||||
nullExpect(unifyProxy('', { ...emptyProxyObj, protocol: 'ftp', host: 'rsshub.proxy', port: '2333' }));
|
||||
});
|
||||
it('proxy-uri socks4 unsupported', () => {
|
||||
nullExpect(unifyProxy('socks4://rsshub.proxy:2333', emptyProxyObj));
|
||||
nullExpect(unifyProxy('socks4a://rsshub.proxy:2333', emptyProxyObj));
|
||||
});
|
||||
|
||||
const httpNoPortUri = 'http://rsshub.proxy';
|
||||
const httpNoPortObj = { ...emptyProxyObj, protocol: 'http', host: 'rsshub.proxy' };
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { Config } from '@/config';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const defaultProtocol = 'http';
|
||||
const possibleProtocol = ['http', 'https', 'socks', 'socks4', 'socks4a', 'socks5', 'socks5h'];
|
||||
// SOCKS4 is left out because the undici dispatcher behind fetch only speaks SOCKS5
|
||||
const possibleProtocol = ['http', 'https', 'socks', 'socks5', 'socks5h'];
|
||||
|
||||
const unifyProxy = (proxyUri: Config['proxyUri'] | string, proxyObj: Config['proxy']) => {
|
||||
const [oriProxyUri, oriProxyObj] = [proxyUri, proxyObj];
|
||||
|
||||
+8
-8
@@ -99,7 +99,7 @@
|
||||
"http-cookie-agent": "8.0.0",
|
||||
"https-proxy-agent": "9.1.0",
|
||||
"iconv-lite": "0.7.3",
|
||||
"imapflow": "2.3.0",
|
||||
"imapflow": "2.4.0",
|
||||
"ioredis": "6.0.0",
|
||||
"ip-regex": "5.0.0",
|
||||
"jsdom": "30.1.2",
|
||||
@@ -115,7 +115,7 @@
|
||||
"ofetch": "1.5.1",
|
||||
"otplib": "13.5.0",
|
||||
"oxc-parser": "0.153.0",
|
||||
"p-map": "7.1.0",
|
||||
"p-map": "7.1.1",
|
||||
"pac-proxy-agent": "9.1.0",
|
||||
"patchright": "1.63.0",
|
||||
"postal-mime": "4.0.5",
|
||||
@@ -138,14 +138,14 @@
|
||||
"twitter-api-v2": "1.29.1",
|
||||
"ufo": "1.6.4",
|
||||
"undici": "8.11.2",
|
||||
"uuid": "14.0.2",
|
||||
"uuid": "14.0.3",
|
||||
"vm2": "3.12.2",
|
||||
"winston": "3.19.0",
|
||||
"x-client-transaction-id": "0.3.2",
|
||||
"xxhash-wasm": "1.1.0",
|
||||
"youtube-caption-extractor": "1.10.2",
|
||||
"youtubei.js": "18.1.0",
|
||||
"zod": "4.6.5"
|
||||
"zod": "4.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/core": "3.0.1",
|
||||
@@ -154,7 +154,7 @@
|
||||
"@cloudflare/containers": "0.3.7",
|
||||
"@cloudflare/playwright": "1.3.6",
|
||||
"@cloudflare/vitest-plugin": "1.4.0",
|
||||
"@cloudflare/workers-types": "5.20261009.1",
|
||||
"@cloudflare/workers-types": "5.20261012.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@j178/prek": "0.5.5",
|
||||
"@mswjs/interceptors": "0.45.7",
|
||||
@@ -190,18 +190,18 @@
|
||||
"globals": "17.13.0",
|
||||
"js-beautify": "2.0.3",
|
||||
"mockdate": "3.0.5",
|
||||
"msw": "3.0.2",
|
||||
"msw": "3.0.3",
|
||||
"node-network-devtools": "1.0.30",
|
||||
"oxfmt": "0.72.0",
|
||||
"oxlint": "1.87.0",
|
||||
"oxlint-plugin-eslint": "1.87.0",
|
||||
"oxlint-tsgolint": "7.0.2003",
|
||||
"oxlint-tsgolint": "7.0.2004",
|
||||
"remark": "15.0.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-pangu": "2.2.0",
|
||||
"remark-parse": "11.0.0",
|
||||
"rolldown": "1.2.13",
|
||||
"tsdown": "0.23.0",
|
||||
"tsdown": "0.23.1",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2",
|
||||
"typescript-7": "npm:typescript@7.0.2",
|
||||
"unified": "11.0.5",
|
||||
|
||||
Generated
+351
-464
File diff suppressed because it is too large
Load Diff
@@ -40,25 +40,10 @@ const allRoutePaths = new Set<string>();
|
||||
// Use all namespaces for both regular and Worker builds.
|
||||
for (const namespace in namespaces) {
|
||||
const namespaceData = namespaces[namespace];
|
||||
let defaultCategory = namespaceData.categories?.[0];
|
||||
if (!defaultCategory) {
|
||||
for (const path in namespaceData.routes) {
|
||||
if (!namespaceData.routes[path].categories) {
|
||||
continue;
|
||||
}
|
||||
|
||||
defaultCategory = namespaceData.routes[path].categories[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!defaultCategory) {
|
||||
defaultCategory = 'other';
|
||||
}
|
||||
for (const path in namespaceData.routes) {
|
||||
const realPath = `/${namespace}${path}`;
|
||||
allRoutePaths.add(realPath);
|
||||
const data = namespaceData.routes[path];
|
||||
const categories = data.categories || namespaceData.categories || [defaultCategory];
|
||||
// maintainers
|
||||
if (data.maintainers) {
|
||||
maintainers[realPath] = data.maintainers;
|
||||
@@ -82,7 +67,7 @@ for (const namespace in namespaces) {
|
||||
}
|
||||
radar[domain][subdomain].push({
|
||||
title: radarItem.title || data.name,
|
||||
docs: `https://docs.rsshub.app/routes/${categories[0]}`,
|
||||
docs: `https://docs.rsshub.app/routes/${namespace}`,
|
||||
source: radarItem.source.map((source) => {
|
||||
const sourceURL = new URL('https://' + source);
|
||||
return sourceURL.pathname + sourceURL.search + sourceURL.hash;
|
||||
|
||||
Reference in New Issue
Block a user