mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-13 08:48:59 +08:00
128 lines
8.1 KiB
JavaScript
128 lines
8.1 KiB
JavaScript
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { createInterface } from 'node:readline/promises';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { parseArgs } from 'node:util';
|
|
|
|
const fields = ['namespace', 'route', 'name', 'url', 'maintainer', 'category', 'template'];
|
|
const templates = new Set(['api', 'html', 'browser']);
|
|
|
|
async function checkSource(url, fetcher) {
|
|
const response = await fetcher(url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
|
|
if (response.status === 404 || response.status >= 500) {
|
|
throw new Error(`Source URL returned HTTP ${response.status}: ${url}`);
|
|
}
|
|
}
|
|
|
|
function renderRoute(options) {
|
|
const imports = ["import type { Route } from '@/types';", "import { parseDate } from '@/utils/parse-date';"];
|
|
let body;
|
|
if (options.template === 'api') {
|
|
imports.push("import ofetch from '@/utils/ofetch';");
|
|
body = ` // Replace this endpoint and field names with the site's first-page API.\n const response = await ofetch<{ items: { title: string; url: string; content: string; publishedAt?: string }[] }>(new URL('/api/articles', link).href);\n const items = response.items.map((article) => ({\n title: article.title,\n link: new URL(article.url, link).href,\n description: article.content,\n pubDate: article.publishedAt ? parseDate(article.publishedAt) : undefined,\n }));`;
|
|
} else {
|
|
imports.unshift("import { load } from 'cheerio';", '');
|
|
const parse = ` // Replace these selectors with the site's actual article list.\n const $ = load(response);\n const items = $('.article').toArray().map((element) => {\n const article = $(element);\n const anchor = article.find('h2 a');\n const date = article.find('time').attr('datetime');\n return {\n title: anchor.text(),\n link: new URL(anchor.attr('href')!, link).href,\n description: article.find('.summary').html() || '',\n pubDate: date ? parseDate(date) : undefined,\n };\n });`;
|
|
if (options.template === 'html') {
|
|
imports.push("import ofetch from '@/utils/ofetch';");
|
|
body = ` const response = await ofetch(link);\n${parse.replaceAll(/^ {4}/gm, '')}`;
|
|
} else {
|
|
imports.push("import { getPlaywrightPage } from '@/utils/playwright';");
|
|
body = ` const { page, destroy } = await getPlaywrightPage(link, {\n closeTimeout: 0,\n onBeforeLoad: async (page) => {\n await page.route('**/*', (request) => ['document', 'script', 'xhr', 'fetch'].includes(request.request().resourceType()) ? request.continue() : request.abort());\n },\n });\n try {\n await page.waitForSelector('.article');\n const response = await page.content();\n${parse}\n return { title: ${JSON.stringify(options.name)}, link, item: items };\n } finally {\n await destroy();\n }`;
|
|
}
|
|
}
|
|
const routePath = options.path || `/${options.route}`;
|
|
const example = options.example || `/${options.namespace}${routePath}`;
|
|
return `${imports.join('\n')}\n\nexport const route: Route = {\n path: ${JSON.stringify(routePath)},\n name: ${JSON.stringify(options.name)},\n example: ${JSON.stringify(example)},\n categories: [${JSON.stringify(options.category)}],\n maintainers: [${JSON.stringify(options.maintainer)}],\n features: { requirePuppeteer: ${options.template === 'browser'} },\n handler,\n};\n\nasync function handler() {\n const link = ${JSON.stringify(options.url)};\n${body}\n${options.template === 'browser' ? '' : ` return { title: ${JSON.stringify(options.name)}, link, item: items };\n`}}\n`;
|
|
}
|
|
|
|
export async function generateRoute(options, fetcher = fetch) {
|
|
for (const field of fields) {
|
|
if (!Object.hasOwn(options, field) || options[field] === '') {
|
|
throw new Error(`Missing --${field}.`);
|
|
}
|
|
}
|
|
if (!/^[a-z][a-z\d-]*$/.test(options.namespace) || !/^[a-z][a-z\d-]*$/.test(options.route) || options.route === 'namespace') {
|
|
throw new Error('Namespace and route filenames must use lowercase letters, numbers and hyphens.');
|
|
}
|
|
if (!templates.has(options.template)) {
|
|
throw new Error('Template must be api, html or browser.');
|
|
}
|
|
const types = await readFile(new URL('../lib/types.ts', import.meta.url), 'utf8');
|
|
const categories =
|
|
types
|
|
.match(/export type Category =([\s\S]*?);/)?.[1]
|
|
.matchAll(/'([^']+)'/g)
|
|
.toArray()
|
|
.map((match) => match[1]) || [];
|
|
if (!categories.includes(options.category)) {
|
|
throw new Error(`Unknown category. Choose one of: ${categories.join(', ')}.`);
|
|
}
|
|
const url = new URL(options.url);
|
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
throw new Error('Source URL must be HTTP(S) without credentials.');
|
|
}
|
|
if (options.path && (!options.path.startsWith('/') || /\?(?!\/|$)/.test(options.path))) {
|
|
throw new Error('--path must start with / and use path parameters instead of a query string.');
|
|
}
|
|
if (!options.example && (options.path || '').includes(':')) {
|
|
throw new Error('Parameterized paths require an explicit --example.');
|
|
}
|
|
if (options.example && !options.example.startsWith(`/${options.namespace}/`)) {
|
|
throw new Error('--example must be a route path under the selected namespace.');
|
|
}
|
|
const directory = path.resolve(options.directory || 'lib/routes', options.namespace);
|
|
const routeFile = path.join(directory, `${options.route}.ts`);
|
|
try {
|
|
await access(routeFile);
|
|
throw new Error(`Route already exists: ${routeFile}`);
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
const maintainer = await fetcher(`https://api.github.com/users/${encodeURIComponent(options.maintainer)}`, { signal: AbortSignal.timeout(15000) });
|
|
if (!maintainer.ok || (await maintainer.json()).login?.toLowerCase() !== options.maintainer.toLowerCase()) {
|
|
throw new Error(`GitHub could not verify maintainer ${options.maintainer}.`);
|
|
}
|
|
await checkSource(url.href, fetcher);
|
|
await mkdir(directory, { recursive: true });
|
|
const namespaceFile = path.join(directory, 'namespace.ts');
|
|
try {
|
|
await writeFile(namespaceFile, `import type { Namespace } from '@/types';\n\nexport const namespace: Namespace = { name: ${JSON.stringify(options.namespace)}, url: ${JSON.stringify(url.host)} };\n`, { flag: 'wx' });
|
|
} catch (error) {
|
|
if (error.code !== 'EEXIST') {
|
|
throw error;
|
|
}
|
|
}
|
|
await writeFile(routeFile, renderRoute(options), { flag: 'wx' });
|
|
return routeFile;
|
|
}
|
|
|
|
async function main() {
|
|
const { values } = parseArgs({ options: Object.fromEntries([...[...fields, 'directory', 'path', 'example'].map((field) => [field, { type: 'string' }]), ['help', { type: 'boolean' }]]) });
|
|
if (values.help) {
|
|
process.stdout.write(
|
|
'pnpm new:route --namespace example --route news --name News --url https://example.com --maintainer YOUR_GITHUB_ID --category new-media --template api|html|browser [--path /news/:id --example /example/news/123]\n'
|
|
);
|
|
return;
|
|
}
|
|
if (process.stdin.isTTY && fields.some((field) => !Object.hasOwn(values, field))) {
|
|
const prompts = createInterface({ input: process.stdin, output: process.stdout });
|
|
try {
|
|
for (const field of fields) {
|
|
// Prompts must be sequential because they share a terminal input stream.
|
|
// eslint-disable-next-line no-await-in-loop
|
|
values[field] ||= await prompts.question(`${field}${field === 'template' ? ' (api/html/browser)' : ''}: `);
|
|
}
|
|
} finally {
|
|
prompts.close();
|
|
}
|
|
}
|
|
process.stdout.write(`Created ${await generateRoute(values)}. Adapt the endpoint or selectors, then run pnpm format and verify the example against the live site.\n`);
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
await main();
|
|
}
|