跳到主要内容

制作路由

创建命名空间​

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

提示

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

一旦您为 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
});
};

这两个代码片段都执行相同的操作。第一个使用对象解构将 user 和 repo 变量赋值,而第二个使用传统赋值和空值合并运算符在请求 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 实例的负载情况。