feat(route): add 公众号 360 (#9457)

Signed-off-by: Rongrong <15956627+Rongronggg9@users.noreply.github.com>
This commit is contained in:
Rongrong 2022-04-05 20:05:45 +08:00 committed by GitHub
parent d887fd755a
commit 26fbfbfa8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 197 additions and 0 deletions

View File

@ -1855,6 +1855,32 @@ area 分区选项
<Route author="luyuhuang" example="/ofweek/news" path="/ofweek/news"/>
## 公众号 360
### 公众号
<Route author="Rongronggg9" example="/gzh360/gzh/北京青年报" path="/gzh360/gzh/:name" :paramsDesc="['公众号名,也可以是公众号 360 的内部 id']" radar="1" />
### 分类
<Route author="Rongronggg9" example="/gzh360/category/5d357964e2eb992114a3d588" path="/gzh360/category/:id?" :paramsDesc="['分类 id见下表']" radar="1">
| `id` | 分类 | | `id` | 分类 |
| -------------------------- | --- | - | -------------------------- | -- |
| | 首页 | | `5d357ae6e2eb992114a3d592` | 育儿 |
| `5d357964e2eb992114a3d588` | 热门 | | `5d357b00e2eb992114a3d593` | 旅游 |
| `5d3579a2e2eb992114a3d589` | 搞笑 | | `5d357b17e2eb992114a3d594` | 职场 |
| `5d3579b0e2eb992114a3d58a` | 健康 | | `5d357b34e2eb992114a3d595` | 美食 |
| `5d3579bae2eb992114a3d58b` | 私房话 | | `5d357b4ae2eb992114a3d596` | 历史 |
| `5d357a10e2eb992114a3d58c` | 八卦精 | | `5d357b60e2eb992114a3d597` | 教育 |
| `5d357a4ae2eb992114a3d58d` | 科技咖 | | `5d357b76e2eb992114a3d598` | 星座 |
| `5d357a72e2eb992114a3d58e` | 财经迷 | | `5d357b8de2eb992114a3d599` | 体育 |
| `5d357a8be2eb992114a3d58f` | 汽车控 | | `5d357b9be2eb992114a3d59a` | 军事 |
| `5d357aa1e2eb992114a3d590` | 生活家 | | `5d357bc2e2eb992114a3d59b` | 游戏 |
| `5d357ab6e2eb992114a3d591` | 时尚圈 | | `5d357bd4e2eb992114a3d59c` | 萌宠 |
</Route>
## 谷歌新闻
### 新闻
@ -3372,6 +3398,10 @@ column 为 third 时可选的 category:
<Route author="TonyRL Rongronggg9" example="/wechat/feeddd/6131e1441269c358aa0e2141" path="/wechat/feeddd/:id" :paramsDesc="['公众号 id, 打开 `https://feeddd.org/feeds``https://cdn.jsdelivr.net/gh/feeddd/feeds/feeds_all_rss.txt`, 在 URL 中找到 id; 注意不是公众号页的 id, 而是订阅的 id']"/>
### 公众号 (公众号 360 来源)
见 [#公众号 360](#gong-zhong-hao-360)
### 公众号栏目 (非推送 & 历史消息)
<Route author="MisteryMonster" example="/wechat/mp/homepage/MzA3MDM3NjE5NQ==/16" path="/wechat/mp/homepage/:biz/:hid/:cid?" :paramsDesc="['公众号id', '分页id', '页内栏目']" radar="1" rssbud="1" anticrawler="1">

View File

@ -0,0 +1,8 @@
const universal = require('./universal');
module.exports = async (ctx) => {
const path = 'category';
const id = ctx.params.id ?? '';
const titleHeader = '公众号 360 - ';
await universal(ctx, path, id, titleHeader);
};

18
lib/v2/gzh360/gzh.js Normal file
View File

@ -0,0 +1,18 @@
const { mpIdEncode, mpIdDecode } = require('./utils');
const universal = require('./universal');
module.exports = async (ctx) => {
const path = 'gzh_articles';
let name = ctx.params.name;
let id = name;
// in order to support RSSHub Radar, we need to accept both name and id
try {
name = mpIdDecode(name);
} catch {
id = mpIdEncode(name);
}
await universal(ctx, path, id, '', name, true, true);
};

View File

@ -0,0 +1,4 @@
module.exports = {
'/category/:id?': ['Rongronggg9'],
'/gzh/:name?': ['Rongronggg9'],
};

19
lib/v2/gzh360/radar.js Normal file
View File

@ -0,0 +1,19 @@
module.exports = {
'gzh360.com': {
_name: '公众号360',
web: [
{
title: '公众号',
docs: 'https://docs.rsshub.app/new-media.html#gong-zhong-hao-360',
source: ['/gzh_articles', '/gzh', '/'],
target: (params, url) => `/gzh360/gzh/${new URL(url).searchParams.get('id') ?? ''}`,
},
{
title: '分类',
docs: 'https://docs.rsshub.app/new-media.html#gong-zhong-hao-360',
source: ['/category', '/'],
target: (params, url) => `/gzh360/category/${new URL(url).searchParams.get('id') ?? ''}`,
},
],
},
};

4
lib/v2/gzh360/router.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = function (router) {
router.get('/category/:id?', require('./category'));
router.get('/gzh/:name', require('./gzh'));
};

View File

@ -0,0 +1,53 @@
const got = require('@/utils/got');
const cheerio = require('cheerio');
const { parseDate } = require('@/utils/parse-date');
const { finishArticleItem } = require('./utils');
const config = require('@/config').value;
const getInitEntry = async (url) =>
await got(url)
.then((_r) => _r.data)
.catch((err) => {
if (err.response.statusCode === 404) {
throw new Error('This category / WeChat Official Account is not found on GZH360.');
}
throw err;
});
module.exports = async (ctx, path, id, titleHeader = '', custom_title = null, skipAuthor = false, cacheInitEntry = false) => {
const rootUrl = 'http://web.gzh360.com';
const currentUrl = `${rootUrl}/${id ? `${path}?id=${id}` : ''}`;
const respData = cacheInitEntry ? await ctx.cache.tryGet(currentUrl, async () => await getInitEntry(currentUrl), config.cache.routeExpire, false) : await getInitEntry(currentUrl);
const $ = cheerio.load(respData);
const title = id ? $('head > title').text().split(' - ', 1)[0] : '首页';
let items = $('div.content div.news_desc')
.map((_, item) => {
item = $(item);
const link = item.find('h3 > a');
const href = link.attr('href');
const title = link.text();
const pubDate = item.find('span.datecss > span').attr('data-timestamp');
let author;
if (!skipAuthor) {
author = item.find('span.fromcss > a').text(); // only some pages have this
}
return {
link: `${href.startsWith('http') ? '' : rootUrl}${href}`,
title,
pubDate: parseDate(pubDate),
author,
};
})
.get();
items = await Promise.all(items.map((item) => finishArticleItem(ctx, item, skipAuthor)));
ctx.state.data = {
title: `${titleHeader}${custom_title ?? title}`,
link: currentUrl,
item: items.filter((item) => item),
};
};

61
lib/v2/gzh360/utils.js Normal file
View File

@ -0,0 +1,61 @@
const got = require('@/utils/got');
const cheerio = require('cheerio');
const { parseDate } = require('@/utils/parse-date');
const invalidIdError = new RangeError('Invalid id');
const mpIdEncode = (name) => {
const onePassed = Buffer.from(name).toString('base64'); // one-pass uses the standard base64 alphabet
// two-passed base64 is always url-safe (`7f+/` never appear), you can prove it by yourself
return Buffer.from(onePassed).toString('base64');
};
const mpIdDecode = (id) => {
// verify that the decoded name can be a valid WeChat mp name: https://kf.qq.com/faq/120911VrYVrA141110r2MRJV.html
// "公众号名称/昵称可设置4-30个字符1个汉字算2字符": 4 ASCII characters => 12, 15 Chinese characters => 80
// and the id is a valid two-passed base64 string (`7f+/` never appear)
if (id.length < 12 || id.length > 80 || id.length % 4 !== 0 || !/^[a-eg-zA-Z0-68-9]+={0,2}$/.test(id)) {
throw invalidIdError;
}
const deSecondPassed = Buffer.from(id, 'base64').toString();
// verify that it is a valid base64 string using standard base64 alphabet
if (deSecondPassed.length % 4 !== 0 || !/^[a-zA-Z0-9+/]+={0,2}$/.test(deSecondPassed)) {
throw invalidIdError;
}
const deFirstPassed = Buffer.from(deSecondPassed, 'base64').toString();
// "空格不可在最前或者最后,且空格不可连续"
if (deFirstPassed.length < 2 || deFirstPassed.startsWith(' ') || deFirstPassed.endsWith(' ') || deFirstPassed.includes(' ')) {
throw invalidIdError;
}
return deFirstPassed;
};
const finishArticleItem = async (ctx, item, skipAuthor = false) => {
// the website is slow and unstable, so we need to ignore errors to avoid breaking the whole route
// we can't cache the item instead of the webpage because we need to retry when the last request failed
const article = await ctx.cache.tryGet(
item.link,
async () =>
await got(item.link)
.then((_r) => _r.data)
.catch(() => null) // it is safe do that in tryGet because a false value always lead to a cache miss
);
if (article) {
const $ = cheerio.load(article);
if (!skipAuthor) {
item.author = item.author || $('[id=webbdgzh]+a').text();
}
item.title = item.title || $('div.desc > h1').text();
item.pubDate = item.pubDate || parseDate($('div.desc span[data-timestamp]').attr('data-timestamp'));
item.description = $('div.rich_media_content').html(); // sometimes it is an empty string due to the website's fault
}
return item;
};
module.exports = {
mpIdEncode,
mpIdDecode,
finishArticleItem,
};