import * as cheerio from 'cheerio'; import { EXTERNAL_API_SERVICE } from '~/config'; import { extractCommentId } from '~/utils/comment'; /** * 处理文章的 html 内容 * @description 采用 cheerio 库解析并修改 html 内容 * @param rawHTML 公众号文章的原始 html * @param format 要处理的格式(默认html) * @remarks 服务端工具函数 */ export function normalizeHtml(rawHTML: string, format: 'html' | 'text' = 'html'): string { const $ = cheerio.load(rawHTML); const $jsArticleContent = $('#js_article'); // #js_content 默认是不可见的(通过js修改为可见),需要移除该样式 $jsArticleContent.find('#js_content').removeAttr('style'); // 删除无用dom元素 $jsArticleContent.find('#js_top_ad_area').remove(); $jsArticleContent.find('#js_tags_preview_toast').remove(); $jsArticleContent.find('#content_bottom_area').remove(); // 删除所有 script 标签(在 #js_article 上下文中) $jsArticleContent.find('script').remove(); $jsArticleContent.find('#js_pc_qr_code').remove(); $jsArticleContent.find('#wx_stream_article_slide_tip').remove(); // 处理图片懒加载(全局处理所有 img) $('img').each((i, el) => { const $img = $(el); const imgUrl = $img.attr('src') || $img.attr('data-src'); if (imgUrl) { $img.attr('src', imgUrl); } }); if (format === 'text') { // 获取纯文本内容 const text = $jsArticleContent.text().trim().replace(/\n+/g, '\n').replace(/ +/g, ' '); // 分割成行 const lines = text.split('\n'); // 过滤掉全空白行(^\s*$ 表示行首到行尾全是空白字符) const filteredLines = lines.filter(line => !/^\s*$/.test(line)); // 重新连接行 return filteredLines.join('\n'); } else if (format === 'html') { // 获取修改后的 HTML let bodyCls = $('body').attr('class'); const pageContentHTML = $('
').append($jsArticleContent.clone()).html(); return ` ${pageContentHTML} `; } else { throw new Error(`format not supported: ${format}`); } } /** * 验证文章的 html 内容是否下载成功,以及提取出 commentID * @param html * @return [状态,commentID/msg] 二元组 */ export function validateHTMLContent(html: string): ['Success' | 'Deleted' | 'Exception' | 'Error', string | null] { const $ = cheerio.load(html); const $jsArticle = $('#js_article'); const $weuiMsg = $('.weui-msg'); const $msgBlock = $('.mesg-block'); if ($jsArticle.length === 1) { // 成功 const commentID = extractCommentId(html); return ['Success', commentID]; } else if ($weuiMsg.length === 1) { // 失败,需要进一步判断失败类型 const msg = $('.weui-msg .weui-msg__title').text().trim().replace(/\n+/g, '').replace(/ +/g, ' '); if (msg && ['The content has been deleted by the author.', '该内容已被发布者删除'].includes(msg)) { return ['Deleted', null]; } else { return ['Exception', msg]; } } else if ($msgBlock.length === 1) { const msg = $msgBlock.text().trim().replace(/\n+/g, '').replace(/ +/g, ' '); return ['Exception', msg]; } else { return ['Error', null]; } } /** * 提取 window.cgiDataNew 所在脚本的代码 * @param html 文章的完整 html 内容 * @return 脚本代码 (纯代码,不含 `; document.body.appendChild(iframe); return new Promise((resolve, reject) => { iframe.onload = function () { // @ts-ignore const data = iframe.contentWindow.cgiDataNew; // 用完后清理 document.body.removeChild(iframe); resolve(data); }; iframe.onerror = function (e) { reject(e); }; }); } /** * 从 html 中提取 cgiDataNew 对象 * @deprecated Cloudflare 平台禁止任何动态执行脚本,故本方法在 CF 平台无效 * @param html 文章的完整 html 内容 * @return window.cgiDataNew 对象,解析失败时返回 null */ function parseCgiDataNewOnServerDeprecated(html: string): Promise { const code = extractCgiScript(html); if (!code) { return Promise.resolve(null); } // 1. 创建沙箱 const sandbox: any = { window: {}, console: { log: () => {}, error: () => {} }, // 可选:屏蔽 console // 如果脚本依赖其他全局,可在这里 mock(如 Date, Math 等已存在) }; sandbox.window = sandbox; // 关键:让 window.xxx 落入沙箱 // 2. 执行代码(new Function 比 eval 稍安全) const func = new Function('window', code); func(sandbox.window); return sandbox.cgiDataNew || sandbox.window?.cgiDataNew; } /** * 从 html 中提取 cgiDataNew 对象 * @param html 文章的完整 html 内容 * @return window.cgiDataNew 对象,解析失败时返回 null */ async function parseCgiDataNewOnServer(html: string): Promise { const code = extractCgiScript(html); if (!code) { return Promise.resolve(null); } try { const data = await fetch(`${EXTERNAL_API_SERVICE}/api/tools/eval-js-code`, { method: 'POST', body: code, }).then(res => res.json()); if (data && data.executionError === null) { return data.window.cgiDataNew; } return null; } catch (error) { console.error(error); } return null; } /** * 从 html 中提取 cgiDataNew 对象 * @param html 文章的完整 html 内容 * @return window.cgiDataNew 对象,解析失败时返回 null */ export async function parseCgiDataNew(html: string): Promise { if (process.client && typeof document === 'object') { return parseCgiDataNewOnClient(html); } else { return parseCgiDataNewOnServer(html); } }