diff --git a/docs/new-media.md b/docs/new-media.md index bfc496e43..6a0d3c7d4 100644 --- a/docs/new-media.md +++ b/docs/new-media.md @@ -1855,6 +1855,32 @@ area 分区选项 +## 公众号 360 + +### 公众号 + + + +### 分类 + + + +| `id` | 分类 | | `id` | 分类 | +| -------------------------- | --- | - | -------------------------- | -- | +| | 首页 | | `5d357ae6e2eb992114a3d592` | 育儿 | +| `5d357964e2eb992114a3d588` | 热门 | | `5d357b00e2eb992114a3d593` | 旅游 | +| `5d3579a2e2eb992114a3d589` | 搞笑 | | `5d357b17e2eb992114a3d594` | 职场 | +| `5d3579b0e2eb992114a3d58a` | 健康 | | `5d357b34e2eb992114a3d595` | 美食 | +| `5d3579bae2eb992114a3d58b` | 私房话 | | `5d357b4ae2eb992114a3d596` | 历史 | +| `5d357a10e2eb992114a3d58c` | 八卦精 | | `5d357b60e2eb992114a3d597` | 教育 | +| `5d357a4ae2eb992114a3d58d` | 科技咖 | | `5d357b76e2eb992114a3d598` | 星座 | +| `5d357a72e2eb992114a3d58e` | 财经迷 | | `5d357b8de2eb992114a3d599` | 体育 | +| `5d357a8be2eb992114a3d58f` | 汽车控 | | `5d357b9be2eb992114a3d59a` | 军事 | +| `5d357aa1e2eb992114a3d590` | 生活家 | | `5d357bc2e2eb992114a3d59b` | 游戏 | +| `5d357ab6e2eb992114a3d591` | 时尚圈 | | `5d357bd4e2eb992114a3d59c` | 萌宠 | + + + ## 谷歌新闻 ### 新闻 @@ -3372,6 +3398,10 @@ column 为 third 时可选的 category: +### 公众号 (公众号 360 来源) + +见 [#公众号 360](#gong-zhong-hao-360) + ### 公众号栏目 (非推送 & 历史消息) diff --git a/lib/v2/gzh360/category.js b/lib/v2/gzh360/category.js new file mode 100644 index 000000000..ded39c8b2 --- /dev/null +++ b/lib/v2/gzh360/category.js @@ -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); +}; diff --git a/lib/v2/gzh360/gzh.js b/lib/v2/gzh360/gzh.js new file mode 100644 index 000000000..b82692420 --- /dev/null +++ b/lib/v2/gzh360/gzh.js @@ -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); +}; diff --git a/lib/v2/gzh360/maintainer.js b/lib/v2/gzh360/maintainer.js new file mode 100644 index 000000000..e9c4b613c --- /dev/null +++ b/lib/v2/gzh360/maintainer.js @@ -0,0 +1,4 @@ +module.exports = { + '/category/:id?': ['Rongronggg9'], + '/gzh/:name?': ['Rongronggg9'], +}; diff --git a/lib/v2/gzh360/radar.js b/lib/v2/gzh360/radar.js new file mode 100644 index 000000000..8af5d2f79 --- /dev/null +++ b/lib/v2/gzh360/radar.js @@ -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') ?? ''}`, + }, + ], + }, +}; diff --git a/lib/v2/gzh360/router.js b/lib/v2/gzh360/router.js new file mode 100644 index 000000000..358481fbe --- /dev/null +++ b/lib/v2/gzh360/router.js @@ -0,0 +1,4 @@ +module.exports = function (router) { + router.get('/category/:id?', require('./category')); + router.get('/gzh/:name', require('./gzh')); +}; diff --git a/lib/v2/gzh360/universal.js b/lib/v2/gzh360/universal.js new file mode 100644 index 000000000..c8bd7998b --- /dev/null +++ b/lib/v2/gzh360/universal.js @@ -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), + }; +}; diff --git a/lib/v2/gzh360/utils.js b/lib/v2/gzh360/utils.js new file mode 100644 index 000000000..03f304bd5 --- /dev/null +++ b/lib/v2/gzh360/utils.js @@ -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, +};