@qcplay/cli 1.0.14 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,807 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { createHash } from "crypto";
5
+ import { inflateRawSync } from "zlib";
6
+
7
+ import * as cheerio from "cheerio";
8
+
9
+ const RULE_VERSION = 1;
10
+ const RULE_ACTIONS = new Set([
11
+ "remove_section_until_next_heading",
12
+ "remove_section_to_end",
13
+ "remove_section_through_match",
14
+ "remove_text_block",
15
+ "replace_text",
16
+ "remove_image",
17
+ "replace_image",
18
+ "remove_image_caption"
19
+ ]);
20
+ const MATCH_OPERATORS = new Set(["contains", "equals", "starts_with", "ends_with"]);
21
+ const RULE_PHASES = new Set(["import", "publish", "*"]);
22
+ const BLOCK_SELECTOR = "p,li,blockquote,figcaption,h1,h2,h3,h4,h5,h6,div,section,aside,footer";
23
+ const PLATFORM_ALIASES = new Map([
24
+ ["官网", "website"],
25
+ ["website", "website"],
26
+ ["tap", "taptap"],
27
+ ["taptap", "taptap"],
28
+ ["b站", "bilibili"],
29
+ ["哔哩哔哩", "bilibili"],
30
+ ["bilibili", "bilibili"],
31
+ ["微博", "weibo"],
32
+ ["weibo", "weibo"],
33
+ ["好游快爆", "haoyou"],
34
+ ["haoyou", "haoyou"],
35
+ ["小红书", "xiaohongshu"],
36
+ ["xiaohongshu", "xiaohongshu"],
37
+ ["xhs", "xiaohongshu"],
38
+ ["discord", "discord"]
39
+ ]);
40
+
41
+ export const DEFAULT_CONTENT_RULES_FILE = path.join(os.homedir(), ".qcplay", "platform-rules.json");
42
+
43
+ export const CONTENT_RULES_TEMPLATE = {
44
+ version: RULE_VERSION,
45
+ rules: [
46
+ {
47
+ name: "公众号删除抽奖章节",
48
+ enabled: true,
49
+ source: "wechat",
50
+ project: "*",
51
+ platform: "*",
52
+ actions: [
53
+ {
54
+ type: "remove_section_until_next_heading",
55
+ match: { text: ["回复有奖", "评论抽奖"], operator: "contains" }
56
+ }
57
+ ]
58
+ },
59
+ {
60
+ name: "B站删除官充章节",
61
+ enabled: true,
62
+ source: "wechat",
63
+ project: "*",
64
+ platform: "bilibili",
65
+ actions: [
66
+ {
67
+ type: "remove_section_until_next_heading",
68
+ match: { text: "官充", operator: "contains" }
69
+ }
70
+ ]
71
+ },
72
+ {
73
+ name: "替换指定图片和文案",
74
+ enabled: false,
75
+ source: "*",
76
+ project: "示例游戏",
77
+ platform: "*",
78
+ actions: [
79
+ {
80
+ type: "replace_text",
81
+ match: { text: "旧文案", operator: "contains" },
82
+ replacement: "新文案"
83
+ },
84
+ {
85
+ type: "replace_image",
86
+ match: { src: "old-image.png", operator: "contains" },
87
+ replacement: { src: "https://example.com/new-image.png", alt: "新图片" }
88
+ }
89
+ ]
90
+ },
91
+ {
92
+ name: "公众号拉取时删除引导图片和文案",
93
+ enabled: false,
94
+ phase: "import",
95
+ source: "wechat",
96
+ project: "*",
97
+ platform: "*",
98
+ actions: [
99
+ {
100
+ type: "remove_image",
101
+ match: { nearby_text: ["长按识别", "关注公众号"], operator: "contains" }
102
+ },
103
+ {
104
+ type: "remove_text_block",
105
+ match: { text: ["长按识别", "关注公众号"], operator: "contains" }
106
+ }
107
+ ]
108
+ }
109
+ ]
110
+ };
111
+
112
+ function normalizeText(value) {
113
+ return String(value ?? "").trim();
114
+ }
115
+
116
+ function normalizeKey(value) {
117
+ return normalizeText(value).toLowerCase().replace(/\s+/g, "");
118
+ }
119
+
120
+ function canonicalPlatform(value) {
121
+ const key = normalizeKey(value);
122
+ return PLATFORM_ALIASES.get(key) || key;
123
+ }
124
+
125
+ function arrayValue(value) {
126
+ return Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
127
+ }
128
+
129
+ function ruleError(location, message) {
130
+ throw new Error(`内容规则 ${location}: ${message}`);
131
+ }
132
+
133
+ function validateScopeValue(value, location) {
134
+ if (value === undefined) return;
135
+ const values = arrayValue(value);
136
+ if (values.length === 0 || values.some(item => typeof item !== "string" || !item.trim())) {
137
+ ruleError(location, "必须是非空字符串或非空字符串数组");
138
+ }
139
+ }
140
+
141
+ function validateMatch(match, location, allowedFields) {
142
+ if (!match || typeof match !== "object" || Array.isArray(match)) {
143
+ ruleError(location, "match 必须是对象");
144
+ }
145
+ const fields = Object.keys(match).filter(field => field !== "operator" && field !== "case_sensitive");
146
+ if (fields.some(field => !allowedFields.has(field))) {
147
+ ruleError(location, `不支持的匹配字段: ${fields.filter(field => !allowedFields.has(field)).join("、")}`);
148
+ }
149
+ if (fields.some(field => arrayValue(match[field]).some(value => typeof value !== "string" || !value))) {
150
+ ruleError(location, "匹配值必须是非空字符串或非空字符串数组");
151
+ }
152
+ const operator = match.operator || "contains";
153
+ if (!MATCH_OPERATORS.has(operator)) {
154
+ ruleError(location, `operator 仅支持 ${[...MATCH_OPERATORS].join("、")}`);
155
+ }
156
+ if (match.case_sensitive !== undefined && typeof match.case_sensitive !== "boolean") {
157
+ ruleError(location, "case_sensitive 必须是布尔值");
158
+ }
159
+ }
160
+
161
+ function validateAction(action, location) {
162
+ if (!action || typeof action !== "object" || Array.isArray(action)) {
163
+ ruleError(location, "动作必须是对象");
164
+ }
165
+ if (!RULE_ACTIONS.has(action.type)) {
166
+ ruleError(location, `不支持的 type: ${action.type || "空"}`);
167
+ }
168
+ const imageFields = new Set(["src", "alt", "title", "caption", "nearby_text"]);
169
+ const removableImageFields = new Set([...imageFields, "sha256"]);
170
+ const textFields = new Set(["text"]);
171
+ if (action.type === "remove_image_caption") {
172
+ validateMatch(action.match || {}, `${location}.match`, imageFields);
173
+ } else if (action.type === "remove_image" || action.type === "replace_image") {
174
+ validateMatch(action.match, `${location}.match`, action.type === "remove_image" ? removableImageFields : imageFields);
175
+ if (![...removableImageFields].some(field => action.match[field] !== undefined)) {
176
+ ruleError(`${location}.match`, "删除或替换图片时至少需要一个图片匹配字段");
177
+ }
178
+ if (action.match.sha256 !== undefined) {
179
+ if (action.type !== "remove_image") {
180
+ ruleError(`${location}.match`, "sha256 仅支持 remove_image");
181
+ }
182
+ if (Object.keys(action.match).some(field => !["sha256", "operator", "case_sensitive"].includes(field))) {
183
+ ruleError(`${location}.match`, "sha256 不能与其他图片匹配字段同时使用");
184
+ }
185
+ if (action.match.operator !== undefined && action.match.operator !== "equals") {
186
+ ruleError(`${location}.match`, "sha256 的 operator 仅支持 equals");
187
+ }
188
+ if (arrayValue(action.match.sha256).some(value => !/^[a-f\d]{64}$/i.test(value))) {
189
+ ruleError(`${location}.match`, "sha256 必须是 64 位十六进制摘要");
190
+ }
191
+ }
192
+ } else {
193
+ validateMatch(action.match, `${location}.match`, textFields);
194
+ if (action.match.text === undefined) {
195
+ ruleError(`${location}.match`, "必须提供 text");
196
+ }
197
+ if (action.type === "remove_section_through_match") {
198
+ validateMatch(action.end_match, `${location}.end_match`, textFields);
199
+ if (action.end_match.text === undefined) {
200
+ ruleError(`${location}.end_match`, "必须提供 text");
201
+ }
202
+ }
203
+ }
204
+ if (action.type === "replace_text" && typeof action.replacement !== "string") {
205
+ ruleError(location, "replace_text 的 replacement 必须是字符串");
206
+ }
207
+ if (action.type === "replace_image") {
208
+ const replacement = action.replacement;
209
+ if (!replacement || typeof replacement !== "object" || Array.isArray(replacement)) {
210
+ ruleError(location, "replace_image 的 replacement 必须是对象");
211
+ }
212
+ const fields = Object.keys(replacement);
213
+ if (fields.length === 0 || fields.some(field => !new Set(["src", "alt", "title"]).has(field))) {
214
+ ruleError(location, "replace_image 的 replacement 仅支持 src、alt、title");
215
+ }
216
+ if (fields.some(field => typeof replacement[field] !== "string")) {
217
+ ruleError(location, "replace_image 的 replacement 字段必须是字符串");
218
+ }
219
+ if (/^(?:javascript|vbscript):/i.test(replacement.src || "")) {
220
+ ruleError(location, "replace_image 的 src 不允许脚本协议");
221
+ }
222
+ }
223
+ }
224
+
225
+ export function validateContentRules(value) {
226
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
227
+ ruleError("根节点", "必须是对象");
228
+ }
229
+ if (value.version !== RULE_VERSION) {
230
+ ruleError("version", `仅支持 ${RULE_VERSION}`);
231
+ }
232
+ if (!Array.isArray(value.rules)) {
233
+ ruleError("rules", "必须是数组");
234
+ }
235
+ const names = new Set();
236
+ value.rules.forEach((rule, ruleIndex) => {
237
+ const location = `rules[${ruleIndex}]`;
238
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) ruleError(location, "必须是对象");
239
+ if (typeof rule.name !== "string" || !rule.name.trim()) ruleError(`${location}.name`, "不能为空");
240
+ if (names.has(rule.name.trim())) ruleError(`${location}.name`, "规则名称不能重复");
241
+ names.add(rule.name.trim());
242
+ if (rule.enabled !== undefined && typeof rule.enabled !== "boolean") {
243
+ ruleError(`${location}.enabled`, "必须是布尔值");
244
+ }
245
+ for (const field of ["source", "project", "region", "platform"]) {
246
+ validateScopeValue(rule[field], `${location}.${field}`);
247
+ }
248
+ if (rule.phase !== undefined) {
249
+ validateScopeValue(rule.phase, `${location}.phase`);
250
+ if (arrayValue(rule.phase).some(phase => !RULE_PHASES.has(normalizeKey(phase)))) {
251
+ ruleError(`${location}.phase`, "仅支持 import、publish 或 *");
252
+ }
253
+ }
254
+ if (!Array.isArray(rule.actions) || rule.actions.length === 0) {
255
+ ruleError(`${location}.actions`, "至少需要一个动作");
256
+ }
257
+ rule.actions.forEach((action, actionIndex) => validateAction(action, `${location}.actions[${actionIndex}]`));
258
+ });
259
+ return value;
260
+ }
261
+
262
+ function decodeXmlText(value) {
263
+ return String(value || "")
264
+ .replace(/&/g, "&")
265
+ .replace(/&lt;/g, "<")
266
+ .replace(/&gt;/g, ">")
267
+ .replace(/&quot;/g, '"')
268
+ .replace(/&#(?:x([\da-f]+)|(\d+));/gi, (_, hex, decimal) => String.fromCodePoint(Number.parseInt(hex || decimal, hex ? 16 : 10)));
269
+ }
270
+
271
+ function readDocxEntries(buffer, entryPredicate) {
272
+ const minimumSize = 22;
273
+ const searchStart = Math.max(0, buffer.length - 0xffff - minimumSize);
274
+ let endOfCentralDirectory = -1;
275
+ for (let offset = buffer.length - minimumSize; offset >= searchStart; offset -= 1) {
276
+ if (buffer.readUInt32LE(offset) === 0x06054b50) {
277
+ endOfCentralDirectory = offset;
278
+ break;
279
+ }
280
+ }
281
+ if (endOfCentralDirectory === -1) throw new Error("DOCX 文件结构无效");
282
+
283
+ const entryCount = buffer.readUInt16LE(endOfCentralDirectory + 10);
284
+ let offset = buffer.readUInt32LE(endOfCentralDirectory + 16);
285
+ for (let index = 0; index < entryCount; index += 1) {
286
+ if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error("DOCX 文件目录无效");
287
+ const compression = buffer.readUInt16LE(offset + 10);
288
+ const compressedSize = buffer.readUInt32LE(offset + 20);
289
+ const fileNameLength = buffer.readUInt16LE(offset + 28);
290
+ const extraLength = buffer.readUInt16LE(offset + 30);
291
+ const commentLength = buffer.readUInt16LE(offset + 32);
292
+ const localHeaderOffset = buffer.readUInt32LE(offset + 42);
293
+ const fileName = buffer.subarray(offset + 46, offset + 46 + fileNameLength).toString("utf8");
294
+ if (entryPredicate(fileName)) {
295
+ if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) throw new Error("DOCX 条目无效");
296
+ const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26);
297
+ const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28);
298
+ const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength;
299
+ const data = buffer.subarray(dataStart, dataStart + compressedSize);
300
+ if (compression === 0) return [{ name: fileName, data }];
301
+ if (compression === 8) return [{ name: fileName, data: inflateRawSync(data) }];
302
+ throw new Error(`DOCX 使用了不支持的压缩方式: ${compression}`);
303
+ }
304
+ offset += 46 + fileNameLength + extraLength + commentLength;
305
+ }
306
+ return [];
307
+ }
308
+
309
+ function docxText(buffer) {
310
+ const entry = readDocxEntries(buffer, name => name === "word/document.xml")[0];
311
+ if (!entry) throw new Error("DOCX 中缺少正文内容");
312
+ const xml = entry.data.toString("utf8");
313
+ const paragraphs = xml.match(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/g) || [];
314
+ return paragraphs
315
+ .map(paragraph => {
316
+ const text = [...paragraph.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map(match => decodeXmlText(match[1])).join("");
317
+ return text.trim();
318
+ })
319
+ .filter(Boolean)
320
+ .join("\n");
321
+ }
322
+
323
+ function docxImageHashes(buffer) {
324
+ const images = [];
325
+ const minimumSize = 22;
326
+ const searchStart = Math.max(0, buffer.length - 0xffff - minimumSize);
327
+ let endOfCentralDirectory = -1;
328
+ for (let offset = buffer.length - minimumSize; offset >= searchStart; offset -= 1) {
329
+ if (buffer.readUInt32LE(offset) === 0x06054b50) {
330
+ endOfCentralDirectory = offset;
331
+ break;
332
+ }
333
+ }
334
+ if (endOfCentralDirectory === -1) throw new Error("DOCX 文件结构无效");
335
+ const entryCount = buffer.readUInt16LE(endOfCentralDirectory + 10);
336
+ let offset = buffer.readUInt32LE(endOfCentralDirectory + 16);
337
+ for (let index = 0; index < entryCount; index += 1) {
338
+ if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error("DOCX 文件目录无效");
339
+ const compression = buffer.readUInt16LE(offset + 10);
340
+ const compressedSize = buffer.readUInt32LE(offset + 20);
341
+ const fileNameLength = buffer.readUInt16LE(offset + 28);
342
+ const extraLength = buffer.readUInt16LE(offset + 30);
343
+ const commentLength = buffer.readUInt16LE(offset + 32);
344
+ const localHeaderOffset = buffer.readUInt32LE(offset + 42);
345
+ const fileName = buffer.subarray(offset + 46, offset + 46 + fileNameLength).toString("utf8");
346
+ if (/^word\/media\//i.test(fileName)) {
347
+ if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) throw new Error("DOCX 条目无效");
348
+ const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26);
349
+ const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28);
350
+ const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength;
351
+ const data = buffer.subarray(dataStart, dataStart + compressedSize);
352
+ const image = compression === 0 ? data : compression === 8 ? inflateRawSync(data) : null;
353
+ if (!image) throw new Error(`DOCX 使用了不支持的压缩方式: ${compression}`);
354
+ images.push(createHash("sha256").update(image).digest("hex"));
355
+ }
356
+ offset += 46 + fileNameLength + extraLength + commentLength;
357
+ }
358
+ return images;
359
+ }
360
+
361
+ function parseRuleText(text, file, imageHashes = []) {
362
+ const trimmed = String(text || "").replace(/^\uFEFF/, "").trim();
363
+ if (!trimmed) throw new Error(`内容规则文件为空: ${file}`);
364
+ if (trimmed.startsWith("{")) return JSON.parse(trimmed);
365
+
366
+ const rules = [];
367
+ const rule = { name: path.basename(file, path.extname(file)) || "导入规则", enabled: true, actions: [] };
368
+ const fields = { "规则名称": "name", "项目": "project", "地区": "region", "平台": "platform", "来源": "source", "阶段": "phase" };
369
+ const ignored = /^(?:#|\/\/|说明[::]?|其他要求[::]?)\s*/;
370
+ const unsupported = [];
371
+ let hasImageHashRule = false;
372
+ for (const rawLine of trimmed.split(/\r?\n/)) {
373
+ const line = rawLine.trim().replace(/^(?:[-*•]\s*|\d+[.、]\s*)/, "");
374
+ if (!line || ignored.test(line) || /(?:样式全部保留|样式.*保留不变|区分.*b站.*非b站|可以有两篇文章|^文章.*规则(?:修改)?$)/i.test(line)) continue;
375
+ if (line === "最强蜗牛") {
376
+ rule.project = line;
377
+ continue;
378
+ }
379
+ const scoped = line.match(/^([^::]+)[::]\s*(.+)$/);
380
+ if (scoped && fields[scoped[1].trim()]) {
381
+ rule[fields[scoped[1].trim()]] = scoped[2].trim();
382
+ continue;
383
+ }
384
+ const section = line.match(/^(?:删除章节|删除小节)[::]\s*(.+)$/);
385
+ if (section) {
386
+ rule.actions.push({ type: "remove_section_until_next_heading", match: { text: section[1].trim(), operator: "contains" } });
387
+ continue;
388
+ }
389
+ const removeRange = line.match(/^(?:(?:涉及|删除段落|删除文本|删除文案)\s*[::]?\s*)?《\s*(.+?)\s*》\s*(?:后到|至)\s*《\s*(.+?)\s*》$/);
390
+ if (removeRange) {
391
+ rule.actions.push({
392
+ type: "remove_section_through_match",
393
+ match: { text: removeRange[1].trim(), operator: "contains" },
394
+ end_match: { text: removeRange[2].trim(), operator: "contains" }
395
+ });
396
+ continue;
397
+ }
398
+ const removeAfter = line.match(/^(?:(?:涉及|删除段落|删除文本|删除文案)\s*[::]?\s*)?《?\s*(.+?)\s*》?\s*(?:后的?|以后)(?:的)?全部删除/);
399
+ if (removeAfter) {
400
+ rule.actions.push({ type: "remove_section_to_end", match: { text: removeAfter[1].trim(), operator: "contains" } });
401
+ continue;
402
+ }
403
+ const block = line.match(/^(?:删除段落|删除文本|删除文案)[::]\s*(.+)$/);
404
+ if (block) {
405
+ rule.actions.push({ type: "remove_text_block", match: { text: block[1].trim(), operator: "contains" } });
406
+ continue;
407
+ }
408
+ const replacement = line.match(/^(?:替换文本|替换文案)[::]\s*(.+?)\s*(?:=>|→|替换为)\s*(.+)$/);
409
+ if (replacement) {
410
+ rule.actions.push({ type: "replace_text", match: { text: replacement[1].trim(), operator: "contains" }, replacement: replacement[2].trim() });
411
+ continue;
412
+ }
413
+ const imageSection = /^(?:b站的内容要删除如下图片|删除图片)\s*[::]?\s*$/i.test(line);
414
+ if (imageSection) {
415
+ if (imageHashes.length > 0 && !hasImageHashRule) {
416
+ rules.push({
417
+ name: `${rule.name}-B站图片`,
418
+ enabled: true,
419
+ platform: "bilibili",
420
+ phase: "import",
421
+ actions: imageHashes.map(sha256 => ({ type: "remove_image", match: { sha256 } }))
422
+ });
423
+ hasImageHashRule = true;
424
+ }
425
+ continue;
426
+ }
427
+ const image = line.match(/^删除图片[::]\s*(.+)$/);
428
+ if (image) {
429
+ rule.actions.push({ type: "remove_image", match: { nearby_text: image[1].trim(), operator: "contains" } });
430
+ continue;
431
+ }
432
+ unsupported.push(rawLine.trim());
433
+ }
434
+ if (unsupported.length > 0) {
435
+ throw new Error(`无法识别内容规则: ${unsupported.join(";")}。请使用“删除章节:关键词”“删除段落:关键词”“替换文本:旧文本 => 新文本”或 JSON。`);
436
+ }
437
+ if (rule.actions.length === 0 && rules.length === 0) {
438
+ throw new Error(`内容规则文件没有可执行规则: ${file}`);
439
+ }
440
+ if (rule.actions.length > 0) rules.push(rule);
441
+ return { version: RULE_VERSION, rules };
442
+ }
443
+
444
+ export async function loadContentRules(file = DEFAULT_CONTENT_RULES_FILE, options = {}) {
445
+ const resolved = path.resolve(file);
446
+ try {
447
+ const extension = path.extname(resolved).toLowerCase();
448
+ if (!new Set([".json", ".txt", ".text", ".docx"]).has(extension)) {
449
+ throw new Error(`内容规则文件仅支持 .json、.txt、.text 或 .docx: ${resolved}`);
450
+ }
451
+ const raw = await fs.promises.readFile(resolved);
452
+ const value = extension === ".docx"
453
+ ? parseRuleText(docxText(raw), resolved, docxImageHashes(raw))
454
+ : parseRuleText(raw.toString("utf8"), resolved);
455
+ return { ...validateContentRules(value), file: resolved };
456
+ } catch (error) {
457
+ if (error?.code === "ENOENT" && options.optional) {
458
+ return { file: resolved, version: RULE_VERSION, rules: [] };
459
+ }
460
+ if (error instanceof SyntaxError && path.extname(resolved).toLowerCase() === ".json") {
461
+ throw new Error(`内容规则文件不是有效 JSON: ${resolved} (${error.message})`);
462
+ }
463
+ throw error;
464
+ }
465
+ }
466
+
467
+ function contextSource(article) {
468
+ const source = normalizeText(article.meta?.source_url || article.payload?.source_url);
469
+ try {
470
+ const hostname = new URL(source).hostname.toLowerCase();
471
+ if (hostname === "mp.weixin.qq.com") return "wechat";
472
+ if (/feishu\.cn$|larksuite\.com$|doubao\.com$/.test(hostname)) return "lark";
473
+ } catch {
474
+ // Non-URL sources are treated as generic article content.
475
+ }
476
+ return "article";
477
+ }
478
+
479
+ function scopeMatches(configured, actual, normalizer = normalizeKey) {
480
+ if (configured === undefined) return true;
481
+ const expected = arrayValue(configured).map(normalizer);
482
+ return expected.includes("*") || expected.includes(normalizer(actual));
483
+ }
484
+
485
+ export function matchingContentRules(config, article, entry = {}, options = {}) {
486
+ const platform = entry.platformKey || canonicalPlatform(entry.platform);
487
+ const phase = options.phase || entry.phase || "publish";
488
+ return (config?.rules || []).filter(rule => {
489
+ if (rule.enabled === false) return false;
490
+ return (
491
+ scopeMatches(rule.source, contextSource(article)) &&
492
+ scopeMatches(rule.project, entry.project) &&
493
+ scopeMatches(rule.region, entry.region) &&
494
+ scopeMatches(rule.platform, platform, canonicalPlatform) &&
495
+ scopeMatches(rule.phase, phase)
496
+ );
497
+ });
498
+ }
499
+
500
+ function matchesValue(value, expected, match = {}) {
501
+ const caseSensitive = match.case_sensitive === true;
502
+ const normalizeForMatch = input => String(input ?? "").normalize("NFKC").replace(/\u00a0/g, " ");
503
+ const actualValue = normalizeForMatch(value);
504
+ const actual = caseSensitive ? actualValue : actualValue.toLowerCase();
505
+ const operator = match.operator || "contains";
506
+ return arrayValue(expected).some(candidateValue => {
507
+ const candidateValueNormalized = normalizeForMatch(candidateValue);
508
+ const candidate = caseSensitive ? candidateValueNormalized : candidateValueNormalized.toLowerCase();
509
+ if (operator === "equals") return actual === candidate;
510
+ if (operator === "starts_with") return actual.startsWith(candidate);
511
+ if (operator === "ends_with") return actual.endsWith(candidate);
512
+ return actual.includes(candidate);
513
+ });
514
+ }
515
+
516
+ function blockIsHeading($, node) {
517
+ const tag = String(node?.name || node?.tagName || "").toLowerCase();
518
+ if (/^h[1-6]$/.test(tag)) return true;
519
+ const element = $(node);
520
+ const styleText = [element.attr("style") || "", ...element.find("[style]").map((_, child) => $(child).attr("style") || "").get()]
521
+ .join(";");
522
+ const sizes = [...styleText.matchAll(/font-size\s*:\s*([\d.]+)px/gi)].map(match => Number(match[1]));
523
+ const bold = /font-weight\s*:\s*(?:bold|[6-9]00)/i.test(styleText) || element.find("strong,b").length > 0;
524
+ return sizes.some(size => size >= 16) && bold;
525
+ }
526
+
527
+ function leafMatchingBlocks($, match, options = {}) {
528
+ return $(BLOCK_SELECTOR)
529
+ .toArray()
530
+ .filter(node => !options.headingOnly || blockIsHeading($, node))
531
+ .filter(node => match.text === undefined || matchesValue($(node).text().trim(), match.text, match))
532
+ .filter(node => {
533
+ return !$(node)
534
+ .find(BLOCK_SELECTOR)
535
+ .toArray()
536
+ .some(child => {
537
+ if (options.headingOnly && !blockIsHeading($, child)) return false;
538
+ return match.text === undefined || matchesValue($(child).text().trim(), match.text, match);
539
+ });
540
+ });
541
+ }
542
+
543
+ function removeSectionUntilNextHeading($, match) {
544
+ const startNode = leafMatchingBlocks($, match, { headingOnly: true })[0];
545
+ if (!startNode) return false;
546
+ const intervals = new Map();
547
+ let position = 0;
548
+ const indexNode = node => {
549
+ const start = position++;
550
+ for (const child of node.children || []) indexNode(child);
551
+ intervals.set(node, { start, end: position });
552
+ };
553
+ for (const node of $.root().contents().toArray()) indexNode(node);
554
+ const startPosition = intervals.get(startNode)?.start;
555
+ const endNode = $(BLOCK_SELECTOR)
556
+ .toArray()
557
+ .find(node => {
558
+ const interval = intervals.get(node);
559
+ return interval?.start > startPosition && !$(node).find(BLOCK_SELECTOR).length && blockIsHeading($, node);
560
+ });
561
+ const endPosition = endNode ? intervals.get(endNode).start : position;
562
+ const inRange = node => {
563
+ const interval = intervals.get(node);
564
+ return interval && interval.start >= startPosition && interval.end <= endPosition;
565
+ };
566
+ const roots = [...intervals.keys()].filter(node => inRange(node) && (!node.parent || !inRange(node.parent)));
567
+ roots.forEach(node => $(node).remove());
568
+ return true;
569
+ }
570
+
571
+ function removeSectionToEnd($, match) {
572
+ const startNode = leafMatchingBlocks($, match, { headingOnly: true })[0] || leafMatchingBlocks($, match)[0];
573
+ if (!startNode) return false;
574
+
575
+ const hasMeaningfulContent = node => {
576
+ if (node.type === "text") return Boolean(String(node.data || "").trim());
577
+ return Boolean($(node).text().trim() || $(node).find("img,video,audio,table,hr").length);
578
+ };
579
+ let boundary = startNode;
580
+ while (boundary.parent && boundary.parent.type !== "root" && String(boundary.parent.name || "").toLowerCase() !== "body") {
581
+ const siblings = (boundary.parent.children || []).filter(hasMeaningfulContent);
582
+ if (siblings.findIndex(node => node === boundary) > 0) break;
583
+ boundary = boundary.parent;
584
+ }
585
+
586
+ const siblings = (boundary.parent?.children || []).filter(hasMeaningfulContent);
587
+ const startIndex = siblings.findIndex(node => node === boundary);
588
+ if (startIndex === -1) {
589
+ $(startNode).remove();
590
+ return true;
591
+ }
592
+ siblings.slice(startIndex).forEach(node => $(node).remove());
593
+ return true;
594
+ }
595
+
596
+ function removeSectionThroughMatch($, match, endMatch) {
597
+ const blocks = $(BLOCK_SELECTOR).toArray().filter(node => !$(node).find(BLOCK_SELECTOR).length);
598
+ const startIndex = blocks.findIndex(node => matchesValue($(node).text().trim(), match.text, match));
599
+ if (startIndex === -1) return false;
600
+ const endIndex = blocks.findIndex((node, index) => index >= startIndex && matchesValue($(node).text().trim(), endMatch.text, endMatch));
601
+ if (endIndex === -1) return false;
602
+
603
+ const intervals = new Map();
604
+ let position = 0;
605
+ const indexNode = node => {
606
+ const start = position++;
607
+ for (const child of node.children || []) indexNode(child);
608
+ intervals.set(node, { start, end: position });
609
+ };
610
+ for (const node of $.root().contents().toArray()) indexNode(node);
611
+ const startPosition = intervals.get(blocks[startIndex])?.start;
612
+ const endPosition = intervals.get(blocks[endIndex])?.end;
613
+ if (startPosition === undefined || endPosition === undefined) return false;
614
+ const inRange = node => {
615
+ const interval = intervals.get(node);
616
+ return interval && interval.start >= startPosition && interval.end <= endPosition;
617
+ };
618
+ const roots = [...intervals.keys()].filter(node => inRange(node) && (!node.parent || !inRange(node.parent)));
619
+ roots.forEach(node => $(node).remove());
620
+ return roots.length > 0;
621
+ }
622
+
623
+ function imageCaption($, image) {
624
+ const figureCaption = image.closest("figure").find("figcaption").first().text();
625
+ return figureCaption || image.next("figcaption").first().text();
626
+ }
627
+
628
+ function imageNearbyText($, image) {
629
+ const blockSelector = "p,li,blockquote,figure,figcaption,h1,h2,h3,h4,h5,h6,div,section,aside,footer";
630
+ const block = image.closest(blockSelector).first();
631
+ if (!block.length) return "";
632
+ const nearby = [block, block.prev(blockSelector).first(), block.next(blockSelector).first()];
633
+ return nearby
634
+ .filter(element => element?.length)
635
+ .map(element => element.text().trim())
636
+ .filter(Boolean)
637
+ .join(" ");
638
+ }
639
+
640
+ function imageMatches($, image, match) {
641
+ const values = {
642
+ src: image.attr("data-src") || image.attr("data-original") || image.attr("src") || "",
643
+ alt: image.attr("alt") || "",
644
+ title: image.attr("title") || "",
645
+ caption: imageCaption($, image),
646
+ nearby_text: imageNearbyText($, image)
647
+ };
648
+ if (match.sha256 !== undefined) return false;
649
+ const fields = ["src", "alt", "title", "caption", "nearby_text"].filter(field => match[field] !== undefined);
650
+ return fields.length === 0 || fields.every(field => matchesValue(values[field], match[field], match));
651
+ }
652
+
653
+ function removeImage($, image) {
654
+ const container = image.closest("p,figure,div,section").first();
655
+ image.closest("figure").find("figcaption").remove();
656
+ image.next("figcaption").remove();
657
+ if (
658
+ container.length &&
659
+ !container.text().trim() &&
660
+ container.find("img").length === 1 &&
661
+ container.find("video,audio,iframe,table").length === 0
662
+ ) {
663
+ container.remove();
664
+ } else {
665
+ image.remove();
666
+ }
667
+ }
668
+
669
+ function replaceTextNodes($, match, replacement) {
670
+ let changed = false;
671
+ const visit = node => {
672
+ if (node.type === "text" && matchesValue(node.data, match.text, match)) {
673
+ const values = arrayValue(match.text);
674
+ let value = String(node.data || "");
675
+ for (const candidate of values) {
676
+ const operator = match.operator || "contains";
677
+ if (operator === "equals") {
678
+ value = replacement;
679
+ break;
680
+ }
681
+ const flags = match.case_sensitive === true ? "g" : "gi";
682
+ const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
683
+ const pattern = operator === "starts_with" ? `^${escaped}` : operator === "ends_with" ? `${escaped}$` : escaped;
684
+ value = value.replace(new RegExp(pattern, flags), replacement);
685
+ }
686
+ node.data = value;
687
+ changed = true;
688
+ return;
689
+ }
690
+ for (const child of node.children || []) visit(child);
691
+ };
692
+ for (const node of $.root().contents().toArray()) visit(node);
693
+ return changed;
694
+ }
695
+
696
+ function applyAction($, action) {
697
+ if (action.type === "remove_section_until_next_heading") {
698
+ let changed = false;
699
+ while (removeSectionUntilNextHeading($, action.match)) changed = true;
700
+ return changed;
701
+ }
702
+ if (action.type === "remove_section_to_end") {
703
+ return removeSectionToEnd($, action.match);
704
+ }
705
+ if (action.type === "remove_section_through_match") {
706
+ return removeSectionThroughMatch($, action.match, action.end_match);
707
+ }
708
+ if (action.type === "remove_text_block") {
709
+ const blocks = leafMatchingBlocks($, action.match);
710
+ blocks.forEach(node => $(node).remove());
711
+ return blocks.length > 0;
712
+ }
713
+ if (action.type === "replace_text") {
714
+ return replaceTextNodes($, action.match, action.replacement);
715
+ }
716
+ const images = $("img").toArray().filter(node => imageMatches($, $(node), action.match || {}));
717
+ if (action.type === "remove_image") {
718
+ images.forEach(node => removeImage($, $(node)));
719
+ return images.length > 0;
720
+ }
721
+ if (action.type === "replace_image") {
722
+ images.forEach(node => {
723
+ const image = $(node);
724
+ for (const field of ["src", "alt", "title"]) {
725
+ if (action.replacement[field] === undefined) continue;
726
+ image.attr(field, action.replacement[field]);
727
+ if (field === "src") {
728
+ if (image.attr("data-src") !== undefined) image.attr("data-src", action.replacement.src);
729
+ if (image.attr("data-original") !== undefined) image.attr("data-original", action.replacement.src);
730
+ }
731
+ }
732
+ });
733
+ return images.length > 0;
734
+ }
735
+ if (action.type === "remove_image_caption") {
736
+ images.forEach(node => {
737
+ const image = $(node);
738
+ image.removeAttr("alt").removeAttr("title").removeAttr("aria-label");
739
+ image.closest("figure").find("figcaption").remove();
740
+ image.next("figcaption").remove();
741
+ });
742
+ return images.length > 0;
743
+ }
744
+ return false;
745
+ }
746
+
747
+ export function applyContentRules(article, entry = {}, config = entry.contentRules, options = {}) {
748
+ const rules = matchingContentRules(config, article, entry, options);
749
+ if (rules.length === 0) return article;
750
+ const $ = cheerio.load(String(article.html || ""), null, false);
751
+ const appliedRules = [];
752
+ const importActions = new Set([
753
+ "remove_section_until_next_heading",
754
+ "remove_section_to_end",
755
+ "remove_section_through_match",
756
+ "remove_text_block",
757
+ "replace_text",
758
+ "remove_image",
759
+ "replace_image",
760
+ "remove_image_caption"
761
+ ]);
762
+ const queuedActions = rules.flatMap((rule, ruleIndex) =>
763
+ rule.actions
764
+ .filter(action => options.phase !== "import" || importActions.has(action.type))
765
+ .map((action, actionIndex) => ({ rule, ruleIndex, action, actionIndex }))
766
+ );
767
+ if (options.phase === "import") {
768
+ const priority = {
769
+ remove_image: 0,
770
+ replace_image: 0,
771
+ remove_image_caption: 0,
772
+ remove_section_until_next_heading: 1,
773
+ remove_section_to_end: 1,
774
+ remove_section_through_match: 1,
775
+ remove_text_block: 2,
776
+ replace_text: 3
777
+ };
778
+ queuedActions.sort(
779
+ (left, right) =>
780
+ priority[left.action.type] - priority[right.action.type] ||
781
+ left.ruleIndex - right.ruleIndex ||
782
+ left.actionIndex - right.actionIndex
783
+ );
784
+ }
785
+ const changedRules = new Set();
786
+ for (const { rule, action } of queuedActions) {
787
+ if (action.match?.sha256 !== undefined) continue;
788
+ if (applyAction($, action)) changedRules.add(rule.name);
789
+ }
790
+ rules.forEach(rule => {
791
+ if (changedRules.has(rule.name)) appliedRules.push(rule.name);
792
+ });
793
+ return appliedRules.length === 0
794
+ ? article
795
+ : { ...article, html: $.root().html(), appliedContentRules: appliedRules };
796
+ }
797
+
798
+ export function matchingDownloadedImageRules(image, article, entry = {}, config = entry.contentRules, options = {}) {
799
+ if (!Buffer.isBuffer(image?.buffer)) return [];
800
+ const digest = createHash("sha256").update(image.buffer).digest("hex");
801
+ return matchingContentRules(config, article, entry, { ...options, phase: options.phase || "import" }).filter(rule =>
802
+ rule.actions.some(action => {
803
+ if (action.type !== "remove_image" || action.match?.sha256 === undefined) return false;
804
+ return matchesValue(digest, action.match.sha256, { ...action.match, operator: "equals" });
805
+ })
806
+ );
807
+ }