跳到主要内容

制作路由

创建命名空间

制作新的 RSS 路由的第一步是创建命名空间。命名空间原则上应该与您制作 RSS 源的主要网站的二级域名相同。例如,如果您正在为 https://github.com/DIYgod/RSSHub/issues 制作 RSS 源,第二级域名是 github。因此,您应该在 lib/routes 下创建名为 github 的文件夹,作为您的 RSS 路由的命名空间。

提示

在创建命名空间时,避免为同一命名空间的创建多个变体。例如,如果您为 yahoo.co.jpyahoo.com 制作 RSS 源,则应该使用单个命名空间 yahoo,而不是创建多个命名空间如 yahoo-jpyahoojpyahoo.jpjp.yahooyahoocojp 等。

一旦您为 RSS 路由创建了命名空间,下一步就是创建文件 namespace.ts 来定义命名空间。

文件应该通过 namespace 返回一个符合 Namespace 类型的对象。Namespace 的定义在 /lib/types.ts

  • name:供人类阅读的命名空间的名称,它会被用作文档的标题
  • url:对应网站的不包含 protocol 的网址
  • description:可选,对使用此命名空间用户的提示和额外说明,它会被插入到文档中
  • zh, zh-TW, ja: 可选,英文以外的多语言支持,它会被用作生成多语言文档

一个完整的例子是:

import type { Namespace } from '@/types';

export const namespace: Namespace = {
name: 'GitHub',
url: 'github.com',
description: `
:::tip
GitHub provides some official RSS feeds:

- Repo releases: \`https://github.com/:owner/:repo/releases.atom\`
- Repo commits: \`https://github.com/:owner/:repo/commits.atom\`
- User activities: \`https://github.com/:user.atom\`
- Private feed: \`https://github.com/:user.private.atom?token=:secret\` (You can find **Subscribe to your news feed** in [dashboard](https://github.com) page after login)
- Wiki history: \`https://github.com/:owner/:repo/wiki.atom\`
:::`,

zh: {
name: '给他哈不',
},
};

创建路由

一旦您为路由创建了命名空间,下一步创建一个路由文件注册路由。

例如,如果您为 GitHub 仓库 Issues 制作 RSS 源,并且假设您希望用户输入 GitHub 用户名和仓库名,如果他们没有输入仓库名,则返回到 RSSHub,您可以在 /lib/routes/github/issue.ts 中注册您的新 RSS 路由,文件需要通过 route 返回一个符合 Route 类型的对象。Route 的定义在 /lib/types.ts

  • path: 路由路径,使用 Hono 路由 语法
  • name: 供人类阅读的路由名称,它会被用作文档的标题
  • url: 对应网站的不包含 protocol 的网址
  • maintainers: 负责维护此路由的人员的 GitHub handle
  • example: 路由的一个示例 URL
  • parameters: 路由的参数说明
  • description: 可选,对使用此路由用户的提示和额外说明,它会被插入到文档中
  • categories: 路由的分类,它会被写入到对应分类的文档中
  • features: 路由的一些特性,比如依赖哪些配置项,是否反爬严格,是否支持某种功能等
  • radar: 可以帮助用户在使用 RSSHub Radar 或其他兼容其格式的软件时订阅您的新 RSS 路由,我们将在后面的部分更多介绍
  • handler: 路由的处理函数,我们将在后面的部分更多介绍

一个完整例子是:

import { Route } from '@/types';

export const route: Route = {
path: '/issue/:user/:repo/:state?/:labels?',
categories: ['programming'],
example: '/github/issue/vuejs/core/all/wontfix',
parameters: { user: 'GitHub username', repo: 'GitHub repo name', state: 'the state of the issues. Can be either `open`, `closed`, or `all`. Default: `open`.', labels: 'a list of comma separated label names' },
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
radar: {
source: ['github.com/:user/:repo/issues', 'github.com/:user/:repo/issues/:id', 'github.com/:user/:repo'],
target: '/issue/:user/:repo',
},
name: 'Repo Issues',
maintainers: ['HenryQW', 'AndreyMZ'],
handler,
};

在上面的示例中,issue 是一个精确匹配,:user 是一个必需参数,:repo? 是一个可选参数。?:repo 之后表示该参数是可选的

编写路由处理函数

处理函数会被传入一个参数 ctx,函数结束后需要返回一个包含 RSS 所需信息的对象

ctx 可以使用的 API 可以在 Hono context 文档中查看

返回值的类型在这里定义:/lib/types.ts#L37

如前所述,我们以 GitHub 仓库 Issues 为例制作 RSS 源。我们将展示前面提到的四种数据获取方法:

  1. 通过 API
  2. 通过 got 从 HTML 获取数据
  3. 使用通用配置路由
  4. 使用 puppeteer
注意

以下示例代码为旧版标准,区别为

  • 处理函数之前会被整体返回,现在只作为 route 对象的一部分返回
  • 处理函数之前会把 RSS 信息保存在 ctx.set('data') 中且没有返回值,现在需要把 RSS 信息作为处理函数的返回值

通过 API

查看 API 文档

不同的站点有不同的 API。您可以查看要为其制作 RSS 源的站点的 API 文档。在本例中,我们将使用 GitHub Issues API

创建主文件

打开您的代码编辑器并创建一个新文件。由于我们要为 GitHub 仓库 Issues 制作 RSS 源,因此建议将文件命名为 issue.ts

以下是让您开始的基本代码:

// 导入所需模组
import got from '@/utils/got'; // 自订的 got
import { parseDate } from '@/utils/parse-date';

export default async (ctx) => {
// 在此处编写您的逻辑

ctx.set('data', {
// 在此处输出您的 RSS
});
};

获取用户输入

如前所述,我们需要从用户输入中获取 GitHub 用户名和仓库名称。如果请求 URL 中未提供仓库名称,则应默认为 RSSHub。您可以使用以下代码实现:

export default async (ctx) => {
const { user, repo = 'RSSHub' } = ctx.req.param();

ctx.set('data', {
// 在此处输出您的 RSS
});
};

这两个代码片段都执行相同的操作。第一个使用对象解构将 userrepo 变量赋值,而第二个使用传统赋值和空值合并运算符在请求 URL 中未提供它的情况下将 repo 变量分配默认值 RSSHub

从 API 获取数据

在获取用户输入后,我们可以使用它向 API 发送请求。大多数情况下,您需要使用 @/utils/got 中的 got(一个自订的 got 包装函数)发送 HTTP 请求。有关更多信息,请参阅 got 文档

export default async (ctx) => {
const { user, repo = 'RSSHub' } = ctx.req.param();
// 发送 HTTP GET 请求到 API 并解构返回的数据对象
const { data } = await got(`https://api.github.com/repos/${user}/${repo}/issues`, {
headers: {
// 为简单起见,此示例使用 HTML 而不是推荐的 'application/vnd.github+json',
// 因后者返回 Markdown 并需要进一步处理
accept: 'application/vnd.github.html+json',
},
searchParams: {
// 这允许用户设置条数限制
per_page: ctx.req.query('limit') ? parseInt(ctx.req.query('limit'), 10) : 30,
},
});

ctx.set('data', {
// 在此处输出您的 RSS
});
};

生成 RSS 源

一旦我们从 API 获取到数据,我们需要进一步处理它以生成符合 RSS 规范的 RSS 源。具体来说,我们需要提取源标题、源链接、文章标题、文章链接、文章正文和文章发布日期。

为此,我们可以将相关数据传给 ctx.set('data', obj),RSSHub 的中间件将处理其余部分。

以下是应有的最终代码:

import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';

export default async (ctx) => {
const { user, repo = 'RSSHub' } = ctx.req.param();

const { data } = await got(`https://api.github.com/repos/${user}/${repo}/issues`, {
headers: {
accept: 'application/vnd.github.html+json',
},
searchParams: {
per_page: ctx.req.query('limit') ? parseInt(ctx.req.query('limit'), 10) : 30,
},
});

// 从 API 响应中提取相关数据
const items = data.map((item) => ({
// 文章标题
title: item.title,
// 文章链接
link: item.html_url,
// 文章正文
description: item.body_html,
// 文章发布日期
pubDate: parseDate(item.created_at),
// 如果有的话,文章作者
author: item.user.login,
// 如果有的话,文章分类
category: item.labels.map((label) => label.name),
}));

ctx.set('data', {
// 源标题
title: `${user}/${repo} issues`,
// 源链接
link: `https://github.com/${user}/${repo}/issues`,
// 源文章
item: items,
});
};

通过 got 从 HTML 获取数据

创建主文件

打开您的代码编辑器并创建一个新文件。由于我们要为 GitHub 仓库 Issues 制作 RSS 源,因此建议将文件命名为 issue.ts

以下是让您开始的基本代码:

// 导入必要的模组
import got from '@/utils/got'; // 自订的 got
import { load } from 'cheerio'; // 可以使用类似 jQuery 的 API HTML 解析器
import { parseDate } from '@/utils/parse-date';

export default async (ctx) => {
// 在此处编写您的逻辑

ctx.set('data', {
// 在此处输出您的 RSS
});
};

parseDate 函数是 RSSHub 提供的一个工具函数,在代码的后面我们会用到它来解析日期。

您需要添加自己的代码来从 HTML 文档中提取数据、处理数据并以 RSS 格式输出。在下一步中,我们将详细介绍此过程的细节。

获取用户输入

如前所述,我们需要从用户输入中获取 GitHub 用户名和仓库名称。如果请求 URL 中未提供仓库名称,则应默认为 RSSHub。您可以使用以下代码实现:

export default async (ctx) => {
// 从 URL 参数中获取用户名和仓库名称
const { user, repo = 'RSSHub' } = ctx.req.param();

ctx.set('data', {
// 在此处输出您的 RSS
});
};

在这段代码中,user 将被设置为 user 参数的值,如果存在 repo 参数,则 repo 将被设置为该参数的值,否则为 RSSHub

从网页获取数据

在获取了用户输入之后,我们需要向网页发起请求,以检索所需的信息。在大多数情况下,我们将使用 @/utils/got 中的 got(一个自订的 got 包装函数)发送 HTTP 请求。您可以在 got 文档 中找到有关如何使用 got 的更多信息。

首先,我们将向 API 发送 HTTP GET 请求,并将 HTML 响应加载到 Cheerio 中,Cheerio 是一个帮助我们解析和操作 HTML 的库。

    const baseUrl = 'https://github.com';
const { user, repo = 'RSSHub' } = ctx.req.param();

// 注意,".data" 属性包含了请求返回的目标页面的完整 HTML 源代码
const { data: response } = await got(`${baseUrl}/${user}/${repo}/issues`);
const $ = load(response);

接下来,我们将使用 Cheerio 选择器选择相关的 HTML 元素,解析我们需要的数据,并将其转换为数组。

    // 我们使用 Cheerio 选择器选择所有带类名“js-navigation-container”的“div”元素,
// 其中包含带类名“flex-auto”的子元素。
const items = $('div.js-navigation-container .flex-auto')
// 使用“toArray()”方法将选择的所有 DOM 元素以数组的形式返回。
.toArray()
// 使用“map()”方法遍历数组,并从每个元素中解析需要的数据。
.map((item) => {
item = $(item);
const a = item.find('a').first();
return {
title: a.text(),
// `link` 需要一个绝对 URL,但 `a.attr('href')` 返回一个相对 URL。
link: `${baseUrl}${a.attr('href')}`,
pubDate: parseDate(item.find('relative-time').attr('datetime')),
author: item.find('.opened-by a').text(),
category: item
.find('a[id^=label]')
.toArray()
.map((item) => $(item).text()),
};
});

ctx.set('data', {
// 在此处输出您的 RSS
});

生成 RSS 源

一旦我们从 API 获取到数据,我们需要进一步处理它以生成符合 RSS 规范的 RSS 源。具体来说,我们需要提取源标题、源链接、文章标题、文章链接、文章正文和文章发布日期。

为此,我们可以将相关数据传给 ctx.set('data', obj),RSSHub 的中间件将处理其余部分。

以下是应有的最终代码:

import got from '@/utils/got';
import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';

export default async (ctx) => {
const baseUrl = 'https://github.com';
const { user, repo = 'RSSHub' } = ctx.req.param();

const { data: response } = await got(`${baseUrl}/${user}/${repo}/issues`);
const $ = load(response);

const items = $('div.js-navigation-container .flex-auto')
.toArray()
.map((item) => {
item = $(item);
const a = item.find('a').first();
return {
title: a.text(),
link: `${baseUrl}${a.attr('href')}`,
pubDate: parseDate(item.find('relative-time').attr('datetime')),
author: item.find('.opened-by a').text(),
category: item
.find('a[id^=label]')
.toArray()
.map((item) => $(item).text()),
};
});

ctx.set('data', {
// 源标题
title: `${user}/${repo} issues`,
// 源链接
link: `${baseUrl}/${user}/${repo}/issues`,
// 源文章
item: items,
});
};

更好的阅读体验

上述的代码仅针对每个订阅项提供部分信息。为了提供更好的阅读体验,我们可以在每个订阅项中添加完整的文章,例如每个 GitHub Issue 的正文。

以下是更新后的代码:

import got from '@/utils/got';
import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';

export default async (ctx) => {
const baseUrl = 'https://github.com';
const { user, repo = 'RSSHub' } = ctx.req.param();

const { data: response } = await got(`${baseUrl}/${user}/${repo}/issues`);
const $ = load(response);

const list = $('div.js-navigation-container .flex-auto')
.toArray()
.map((item) => {
item = $(item);
const a = item.find('a').first();
return {
title: a.text(),
link: `${baseUrl}${a.attr('href')}`,
pubDate: parseDate(item.find('relative-time').attr('datetime')),
author: item.find('.opened-by a').text(),
category: item
.find('a[id^=label]')
.toArray()
.map((item) => $(item).text()),
};
});

const items = await Promise.all(
list.map((item) =>
cache.tryGet(item.link, async () => {
const { data: response } = await got(item.link);
const $ = load(response);

// 选择类名为“comment-body”的第一个元素
item.description = $('.comment-body').first().html();

// 上面每个列表项的每个属性都在此重用,
// 并增加了一个新属性“description”
return item;
})
)
);

ctx.set('data', {
title: `${user}/${repo} issues`,
link: `https://github.com/${user}/${repo}/issues`,
item: items,
});
};

现在,这个 RSS 源将具有类似于原始网站的阅读体验。

提示

请注意,在先前的部分中,我们仅需向 API 发送一个 HTTP 请求即可获得所需的所有数据。然而,在此部分中,我们需要发送 1 + n 个 HTTP 请求,其中 n 是从第一个请求获取的文章列表中的数量。

部分网站可能不喜欢在短时间内接收大量请求,并返回类似于“429 Too Many Requests”的错误。

使用通用配置路由

创建主文件

首先,我们需要一些数据:

  1. RSS 来源链接
  2. 数据来源链接
  3. RSS 订阅标题(不是每个文章的标题)

打开您的代码编辑器并创建一个新文件。由于我们要为 GitHub 仓库 Issues 制作 RSS 源,因此建议将文件命名为 issue.ts

这是一些基础代码,你可以从这里开始:

// 导入所需模组
import buildData from '@/utils/common-config';

export default async (ctx) => {
ctx.set('data', await buildData({
link: '', // RSS 来源链接
url: '', // 数据来源链接
// 此处可以使用变量
// 如 %xxx% 会被解析为 **params** 中同名变量的值
title: '%title%',
params: {
title: '', // 标题变量
},
}));
};

我们的 RSS 订阅源目前缺少内容。必须设置 item 才能添加内容。以下是一个示例:

import buildData from '@/utils/common-config';

export default async (ctx) => {
const { user, repo = 'RSSHub' } = ctx.req.param();
const link = `https://github.com/${user}/${repo}/issues`;

ctx.set('data', await buildData({
link,
url: link,
title: `${user}/${repo} issues`, // 也可以使用 $('head title').text()
params: {
title: `${user}/${repo} issues`,
baseUrl: 'https://github.com',
},
item: {
item: 'div.js-navigation-container .flex-auto',
// 如果要使用变量,必须使用模板字符串
title: `$('a').first().text() + ' - %title%'`, // 仅支持像 $().xxx() 这样的 js 语句
link: `'%baseUrl%' + $('a').first().attr('href')`, // .text() 为获取元素的文本
// description: ..., 目前没有文章正文
pubDate: `parseDate($('relative-time').attr('datetime'))`,
},
}));
};

你会发现,此代码与上面的 从网页获取数据 部分相似。但是,这个 RSS 订阅源不包含 GitHub Issue 的正文。

获取完整文章

要获取每个 GitHub Issue 的正文,你需要添加一些代码。以下是一个示例:

import buildData from '@/utils/common-config';
import got from '@/utils/got';
import { load } from 'cheerio';

export default async (ctx) => {
const { user, repo = 'RSSHub' } = ctx.req.param();
const link = `https://github.com/${user}/${repo}/issues`;

const data = await buildData({
link,
url: link,
title: `${user}/${repo} issues`,
params: {
title: `${user}/${repo} issues`,
baseUrl: 'https://github.com',
},
item: {
item: 'div.js-navigation-container .flex-auto',
title: `$('a').first().text() + ' - %title%'`,
link: `'%baseUrl%' + $('a').first().attr('href')`,
pubDate: `parseDate($('relative-time').attr('datetime'))`,
},
});

await Promise.all(
data.item.map((item) =>
cache.tryGet(item.link, async () => {
const { data: resonse } = await got(item.link);
const $ = load(resonse);
item.description = $('.comment-body').first().html();
return item;
})
)
);

ctx.set('data', data);
};

你可以看到,上面的代码与 前一节 非常相似,通过添加一些代码它获取了完整文章。建议你尽可能使用 前一节 中的方法,因为它比使用 @/utils/common-config 更加灵活。

使用 puppeteer

使用 Puppeteer 是从网站获取数据的另一种方法。不过,建议您首先尝试 上述方法。还建议您先阅读 通过 got 从 HTML 获取数据,因为本节是前一节的扩展,不会解释一些基本概念。

创建主文件

创建一个新文件并使用适当的名称保存,例如 issue.ts。然后,导入所需模组并设置函数的基本结构:

// 导入所需模组
import { load } from 'cheerio'; // 可以使用类似 jQuery 的 API HTML 解析器
import { parseDate } from '@/utils/parse-date';
import logger from '@/utils/logger';

export default async (ctx) => {
// 在此处编写您的逻辑

ctx.set('data', {
// 在此处输出您的 RSS
});
};

将 got 替换为 puppeteer

现在,我们将使用 puppeteer 代替 got 来从网页获取数据。

import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import logger from '@/utils/logger';
import puppeteer from '@/utils/puppeteer';

export default async (ctx) => {
const baseUrl = 'https://github.com';
const { user, repo = 'RSSHub' } = ctx.req.param();

// 导入 puppeteer 工具类并初始化浏览器实例
const browser = await puppeteer();
// 打开一个新标签页
const page = await browser.newPage();
// 拦截所有请求
await page.setRequestInterception(true);
// 仅允许某些类型的请求
page.on('request', (request) => {
// 在这次例子,我们只允许 HTML 请求
request.resourceType() === 'document' ? request.continue() : request.abort();
});
// 访问目标链接
const link = `${baseUrl}/${user}/${repo}/issues`;
// got 请求会被自动记录,
// 但 puppeteer 请求不会
// 所以我们需要手动记录它们
logger.http(`Requesting ${link}`);
await page.goto(link, {
// 指定页面等待载入的时间
waitUntil: 'domcontentloaded',
});
// 获取页面的 HTML 内容
const response = await page.content();
// 关闭标签页
page.close();

const $ = load(response);

// const item = ...;

// 不要忘记关闭浏览器实例
browser.close();

ctx.set('data', {
// 在此处输出您的 RSS
});
}

获取完整文章

使用浏览器新标签页获取每个 GitHub Issue 的正文,类似于 上一节。我们可以使用以下代码:

import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import logger from '@/utils/logger';
import puppeteer from '@/utils/puppeteer';

export default async (ctx) => {
const baseUrl = 'https://github.com';
const { user, repo = 'RSSHub' } = ctx.req.param();

const browser = await puppeteer();
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', (request) => {
request.resourceType() === 'document' ? request.continue() : request.abort();
});

const link = `${baseUrl}/${user}/${repo}/issues`;
logger.http(`Requesting ${link}`);
await page.goto(link, {
waitUntil: 'domcontentloaded',
});
const response = await page.content();
page.close();

const $ = load(response);

const list = $('div.js-navigation-container .flex-auto')
.toArray()
.map((item) => {
item = $(item);
const a = item.find('a').first();
return {
title: a.text(),
link: `${baseUrl}${a.attr('href')}`,
pubDate: parseDate(item.find('relative-time').attr('datetime')),
author: item.find('.opened-by a').text(),
category: item
.find('a[id^=label]')
.toArray()
.map((item) => $(item).text()),
};
});

const items = await Promise.all(
list.map((item) =>
cache.tryGet(item.link, async () => {
// 重用浏览器实例并打开新标签页
const page = await browser.newPage();
// 设置请求拦截,仅允许 HTML 请求
await page.setRequestInterception(true);
page.on('request', (request) => {
request.resourceType() === 'document' ? request.continue() : request.abort();
});

logger.http(`Requesting ${item.link}`);
await page.goto(item.link, {
waitUntil: 'domcontentloaded',
});
const response = await page.content();
// 获取 HTML 内容后关闭标签页
page.close();

const $ = load(response);

item.description = $('.comment-body').first().html();

return item;
})
)
);

// 所有请求完成后关闭浏览器实例
browser.close();

ctx.set('data', {
title: `${user}/${repo} issues`,
link: `https://github.com/${user}/${repo}/issues`,
item: items,
});
};

额外资源

这里有一些您可以使用的资源来了解 puppeteer:

拦截请求

在爬取网页时,您可能会遇到您不需要的图像、字体和其他资源。这些资源会减慢页面加载速度并消耗宝贵的 CPU 和内存资源。为了避免这种情况,您可以在 puppeteer 中启用请求拦截。

这是如何实现的:

await page.setRequestInterception(true);
page.on('request', (request) => {
request.resourceType() === 'document' ? request.continue() : request.abort();
});
// 这两条语句必须放在 page.goto() 之前

您可以在 这里 找到 request.resourceType() 的所有可能值。在代码中使用这些值时,请确保使用小写字母。

Wait Until

在上面的代码中,您将看到在 page.goto() 函数中使用了 waitUntil: 'domcontentloaded'。这是 puppeteer 的一个选项,它告诉它在何时认为导航成功。您可以在 这里 找到所有可能的值及其含义。

需要注意的是,domcontentloaded 的等待时间较短,而 networkidle0 可能不适用于始终发送后台遥测或获取数据的网站。

此外,重要的是避免等待特定的超时时间,而是等待选择器出现。等待超时是不准确的,因为它取决于 puppeteer 实例的负载情况。