制作自己的 RSSHub 路由

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

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

# 通过 API

# 查看 API 文档

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

# 创建主文件

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

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

# 获取用户输入

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

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

# 从 API 获取数据

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

# 生成 RSS 源

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

为此,我们可以将相关数据赋值给 ctx.state.data 对象,RSSHub 的中间件将处理其余部分。

以下是应有的最终代码:

# 通过 got 从 HTML 获取数据

# 创建主文件

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

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

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

module.exports = async (ctx) => {
    // 在此处编写您的逻辑

    ctx.state.data = {
        // 在此处输出您的 RSS
    };
};
1
2
3
4
5
6
7
8
9
10
11
12

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

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

# 获取用户输入

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


 
 






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

    ctx.state.data = {
        // 在此处输出您的 RSS
    };
};
1
2
3
4
5
6
7
8

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

# 从网页获取数据

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

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





 
 

    const baseUrl = 'https://github.com';
    const { user, repo = 'RSSHub' } = ctx.params;

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

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



 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 





    // 我们使用 Cheerio 选择器选择所有带类名“js-navigation-container”的“div”元素,
    // 其中包含带类名“flex-auto”的子元素。
    const item = $('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.state.data = {
        // 在此处输出您的 RSS
    };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 生成 RSS 源

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

为此,我们可以将相关数据赋值给 ctx.state.data 对象,RSSHub 的中间件将处理其余部分。

以下是应有的最终代码:





























 
 
 
 
 
 
 
 


const got = require('@/utils/got');
const cheerio = require('cheerio');
const { parseDate } = require('@/utils/parse-date');

module.exports = async (ctx) => {
    const baseUrl = 'https://github.com';
    const { user, repo = 'RSSHub' } = ctx.params;

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

    const item = $('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.state.data = {
        // 源标题
        title: `${user}/${repo} issues`,
        // 源链接
        link: `${baseUrl}/${user}/${repo}/issues`,
        // 源文章
        item: items,
    };
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

# 更好的阅读体验

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

以下是更新后的代码:












 
















 
 
 
 
 
 
 
 
 
 
 
 
 
 
 




 




const got = require('@/utils/got');
const cheerio = require('cheerio');
const { parseDate } = require('@/utils/parse-date');

module.exports = async (ctx) => {
    const baseUrl = 'https://github.com';
    const { user, repo = 'RSSHub' } = ctx.params;

    const { data: response } = await got(`${baseUrl}/${user}/${repo}/issues`);
    const $ = cheerio.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) =>
            ctx.cache.tryGet(item.link, async () => {
                const { data: response } = await got(item.link);
                const $ = cheerio.load(response);

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

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

    ctx.state.data = {
        title: `${user}/${repo} issues`,
        link: `https://github.com/${user}/${repo}/issues`,
        item: items,
    };
};

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

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

提示

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

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

# 使用通用配置路由

# 创建主文件

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

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

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

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

// 导入所需模组
const buildData = require('@/utils/common-config');

module.exports = async (ctx) => {
    ctx.state.data = await buildData({
        link: '', // RSS 来源链接
        url: '', // 数据来源链接
        // 此处可以使用变量
        // 如 %xxx% 会被解析为 **params** 中同名变量的值
        title: '%title%', 
        params: {
            title: '', // 标题变量
        },
    });
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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















 
 
 
 
 
 
 
 



const buildData = require('@/utils/common-config');

module.exports = async (ctx) => {
    const { user, repo = 'RSSHub' } = ctx.params;
    const link = `https://github.com/${user}/${repo}/issues`;

    ctx.state.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'))`,
        },
    });
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

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

# 获取完整文章

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


 
 





















 
 
 
 
 
 
 
 
 
 


const buildData = require('@/utils/common-config');
const got = require('@/utils/got');
const cheerio = require('cheerio');

module.exports = async (ctx) => {
    const { user, repo = 'RSSHub' } = ctx.params;
    const link = `https://github.com/${user}/${repo}/issues`;

    ctx.state.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(
        ctx.state.data.item.map((item) =>
            ctx.cache.tryGet(item.link, async () => {
                const { data: resonse } = await got(item.link);
                const $ = cheerio.load(resonse);
                item.description = $('.comment-body').first().html();
                return item;
            })
        )
    );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

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

# 使用 puppeteer

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

# 创建主文件

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

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

module.exports = async (ctx) => {
    // 在此处编写您的逻辑

    ctx.state.data = {
        // 在此处输出您的 RSS
    };
};
1
2
3
4
5
6
7
8
9
10
11
12

# 将 got 替换为 puppeteer

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

# 获取完整文章

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














































 
 
 
 
 
 
 
 
 
 
 
 
 
 
 










 
 








const cheerio = require('cheerio');
const { parseDate } = require('@/utils/parse-date');
const logger = require('@/utils/logger');

module.exports = async (ctx) => {
    const baseUrl = 'https://github.com';
    const { user, repo = 'RSSHub' } = ctx.params;

    const browser = await require('@/utils/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.debug(`Requesting ${link}`);
    await page.goto(link, {
        waitUntil: 'domcontentloaded',
    });
    const response = await page.content();
    page.close();

    const $ = cheerio.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) =>
            ctx.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.debug(`Requesting ${item.link}`);
                await page.goto(item.link, {
                    waitUntil: 'domcontentloaded',
                });
                const response = await page.content();
                // 获取 HTML 内容后关闭标签页
                page.close();

                const $ = cheerio.load(response);

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

                return item;
            })
        )
    );

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

    ctx.state.data = {
        title: `${user}/${repo} issues`,
        link: `https://github.com/${user}/${repo}/issues`,
        item: items,
    };
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

# 额外资源

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

# 拦截请求

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

这是如何实现的:

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

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

# Wait Until

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

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

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