Spaces:
Paused
Paused
File size: 7,720 Bytes
70e483f | 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 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | 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 = $('<div>').append($jsArticleContent.clone()).html();
return `<!DOCTYPE html>
<html lang="zh_CN">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0,viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<style>
#js_row_immersive_stream_wrap {
max-width: 667px;
margin: 0 auto;
}
#js_row_immersive_stream_wrap .wx_follow_avatar_pic {
display: block;
margin: 0 auto;
}
#page-content,
#js_article_bottom_bar,
.__page_content__ {
max-width: 667px;
margin: 0 auto;
}
img {
max-width: 100%;
}
.sns_opr_btn::before {
width: 16px;
height: 16px;
margin-right: 3px;
}
</style>
</head>
<body class="${bodyCls}">
${pageContentHTML}
</body>
</html>
`;
} 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 脚本代码 (纯代码,不含 <script> 标签)
* @remarks 内部使用 cheerio 库进行解析,可运行在浏览器端和服务器端。
*/
function extractCgiScript(html: string) {
const $ = cheerio.load(html);
const scriptEl = $('script[type="text/javascript"][h5only]').filter((i, el) => {
const content = $(el).html() || '';
return content.includes('window.cgiDataNew = ');
});
if (scriptEl.length !== 1) {
console.warn('未找到包含 cgiDataNew 的目标 script');
return null;
}
return scriptEl.html()?.trim() || null;
}
/**
* 从 html 中提取 cgiDataNew 对象
* @param html 文章的完整 html 内容
* @return window.cgiDataNew 对象,解析失败时返回 null
*/
function parseCgiDataNewOnClient(html: string): Promise<any> {
const code = extractCgiScript(html);
if (!code) {
return Promise.resolve(null);
}
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.srcdoc = `<script type="text/javascript">${code}</script>`;
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<any> {
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<any> {
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<any> {
if (process.client && typeof document === 'object') {
return parseCgiDataNewOnClient(html);
} else {
return parseCgiDataNewOnServer(html);
}
}
|