koishi-plugin-ban-qrcode 1.2.0 → 1.3.0

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.
package/lib/detect.js CHANGED
@@ -51,9 +51,17 @@ const SHARE_TYPES = new Set([
51
51
  'share',
52
52
  'ark',
53
53
  'lightapp',
54
+ 'miniapp',
54
55
  'onebot:json',
55
56
  'onebot:xml',
56
57
  'onebot:share',
58
+ 'onebot:ark',
59
+ 'onebot:lightapp',
60
+ 'onebot:miniapp',
61
+ 'weapp',
62
+ 'wxapp',
63
+ 'miniprogram',
64
+ 'onebot:weapp',
57
65
  ]);
58
66
  const CONTACT_TYPES = new Set([
59
67
  'contact',
@@ -98,8 +106,8 @@ function collectMessageParts(nodes, extra = '') {
98
106
  if (typeof content === 'string' && content)
99
107
  texts.push(content);
100
108
  }
101
- if (SHARE_TYPES.has(node.type)) {
102
- addShare(unwrapShareData(node.attrs?.data ?? node.attrs?.content ?? node.attrs?.value));
109
+ if (SHARE_TYPES.has(node.type) || looksLikeShareAttrs(node.attrs)) {
110
+ addShare(sharePayloadFromAttrs(node.attrs));
103
111
  }
104
112
  if (CONTACT_TYPES.has(node.type)) {
105
113
  addShare(node.attrs);
@@ -198,6 +206,32 @@ function unwrapShareData(raw) {
198
206
  }
199
207
  return raw;
200
208
  }
209
+ function looksLikeShareAttrs(attrs) {
210
+ if (!attrs)
211
+ return false;
212
+ if (typeof attrs.app === 'string')
213
+ return true;
214
+ if (attrs.meta && (attrs.prompt || attrs.view || attrs.bizsrc))
215
+ return true;
216
+ const wrapped = attrs.data ?? attrs.content ?? attrs.value;
217
+ if (typeof wrapped === 'string' && /"app"\s*:/.test(wrapped))
218
+ return true;
219
+ if (wrapped && typeof wrapped === 'object' && !Array.isArray(wrapped)) {
220
+ return typeof wrapped.app === 'string';
221
+ }
222
+ return false;
223
+ }
224
+ function sharePayloadFromAttrs(attrs) {
225
+ if (!attrs)
226
+ return undefined;
227
+ const wrapped = attrs.data ?? attrs.content ?? attrs.value;
228
+ if (wrapped !== undefined && wrapped !== null && wrapped !== '') {
229
+ return unwrapShareData(wrapped);
230
+ }
231
+ if (typeof attrs.app === 'string' || attrs.meta)
232
+ return attrs;
233
+ return undefined;
234
+ }
201
235
  function isDownloadableSrc(src) {
202
236
  return /^(https?:|data:|file:)/i.test(src);
203
237
  }
package/lib/document.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare const DEFAULT_MAX_OFFICE_BYTES: number;
1
2
  export interface DocContent {
2
3
  title: string;
3
4
  text: string;
@@ -15,7 +16,8 @@ export declare function parseTencentDocUrl(url: string): {
15
16
  pageUrl: string;
16
17
  } | null;
17
18
  export declare function collectTencentDocUrls(text: string): string[];
19
+ export declare function extractTencentDocUrlsFromShare(payload: string): string[];
18
20
  export declare function parseOpendocBody(body: string): DocContent | null;
19
21
  export declare function fetchTencentDoc(http: DocHttp, url: string): Promise<DocContent | null>;
20
- export declare function extractOfficeText(buffer: Buffer, name: string): DocContent | null;
22
+ export declare function extractOfficeText(buffer: Buffer, name: string, maxBytes?: number): DocContent | null;
21
23
  export declare function extractReadableText(input: string | Buffer): string;
package/lib/document.js CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_MAX_OFFICE_BYTES = void 0;
3
4
  exports.parseTencentDocUrl = parseTencentDocUrl;
4
5
  exports.collectTencentDocUrls = collectTencentDocUrls;
6
+ exports.extractTencentDocUrlsFromShare = extractTencentDocUrlsFromShare;
5
7
  exports.parseOpendocBody = parseOpendocBody;
6
8
  exports.fetchTencentDoc = fetchTencentDoc;
7
9
  exports.extractOfficeText = extractOfficeText;
@@ -9,32 +11,81 @@ exports.extractReadableText = extractReadableText;
9
11
  const node_zlib_1 = require("node:zlib");
10
12
  const detect_1 = require("./detect");
11
13
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
12
- const DOC_RE = /https?:\/\/(?:docs\.qq\.com|doc\.weixin\.qq\.com)\/(doc|sheet|slide|pdf|form|mind)\/([A-Za-z0-9]+)/i;
14
+ const DOC_HOST = '(?:docs\\.qq\\.com|doc\\.weixin\\.qq\\.com)';
15
+ const DOC_KIND = 'doc|sheet|slide|pdf|form|mind|smartsheet|smartpage';
16
+ const DOC_ID = '[A-Za-z0-9][A-Za-z0-9_-]{5,}';
17
+ const DOC_RE = new RegExp(`(?:https?:\\/\\/)?(?:\\/\\/)?${DOC_HOST}\\/(${DOC_KIND})(?:\\/page)?\\/(${DOC_ID})`, 'i');
13
18
  const ZIP_LOCAL = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
14
19
  const IMAGE_RE = /https?:\/\/docimg\d+\.docs\.qq\.com\/image\/[A-Za-z0-9_-]+(?:\.(?:jpe?g|png|webp))?/gi;
20
+ exports.DEFAULT_MAX_OFFICE_BYTES = 5 * 1024 * 1024;
15
21
  function parseTencentDocUrl(url) {
16
- const match = DOC_RE.exec(url);
22
+ const match = DOC_RE.exec(decodePercents((0, detect_1.unescapePayload)(url)));
17
23
  if (!match)
18
24
  return null;
25
+ const kind = match[1].toLowerCase();
26
+ const id = match[2];
27
+ const path = kind === 'form' ? `form/page/${id}` : `${kind}/${id}`;
19
28
  return {
20
- kind: match[1],
21
- id: match[2],
22
- pageUrl: `https://docs.qq.com/${match[1]}/${match[2]}`,
29
+ kind,
30
+ id,
31
+ pageUrl: `https://docs.qq.com/${path}`,
23
32
  };
24
33
  }
25
34
  function collectTencentDocUrls(text) {
26
35
  const urls = [];
27
36
  const seen = new Set();
28
- const re = new RegExp(DOC_RE.source, 'gi');
29
- for (const match of (0, detect_1.unescapePayload)(text).matchAll(re)) {
30
- const parsed = parseTencentDocUrl(match[0]);
31
- if (!parsed || seen.has(parsed.pageUrl))
32
- continue;
33
- seen.add(parsed.pageUrl);
34
- urls.push(parsed.pageUrl);
37
+ const add = (value) => {
38
+ const decoded = decodePercents((0, detect_1.unescapePayload)(value));
39
+ const re = new RegExp(DOC_RE.source, 'gi');
40
+ for (const match of decoded.matchAll(re)) {
41
+ const parsed = parseTencentDocUrl(match[0]);
42
+ if (!parsed || seen.has(parsed.pageUrl))
43
+ continue;
44
+ seen.add(parsed.pageUrl);
45
+ urls.push(parsed.pageUrl);
46
+ }
47
+ };
48
+ add(text);
49
+ return urls;
50
+ }
51
+ function extractTencentDocUrlsFromShare(payload) {
52
+ const urls = collectTencentDocUrls(payload);
53
+ const seen = new Set(urls);
54
+ const add = (value) => {
55
+ for (const url of collectTencentDocUrls(value)) {
56
+ if (seen.has(url))
57
+ continue;
58
+ seen.add(url);
59
+ urls.push(url);
60
+ }
61
+ };
62
+ const raw = (0, detect_1.unescapePayload)(payload);
63
+ try {
64
+ walkDocCandidates(JSON.parse(decodePercents(raw)), add);
65
+ }
66
+ catch {
67
+ // not json; still scan xml tags below
68
+ }
69
+ for (const match of raw.matchAll(/<(?:url|qqdocurl|pagepath|jumpurl|pcjumpurl)[^>]*>([\s\S]*?)<\//gi)) {
70
+ add(match[1]);
35
71
  }
36
72
  return urls;
37
73
  }
74
+ function walkDocCandidates(value, add) {
75
+ if (typeof value === 'string') {
76
+ add(value);
77
+ return;
78
+ }
79
+ if (Array.isArray(value)) {
80
+ for (const item of value)
81
+ walkDocCandidates(item, add);
82
+ return;
83
+ }
84
+ if (value && typeof value === 'object') {
85
+ for (const item of Object.values(value))
86
+ walkDocCandidates(item, add);
87
+ }
88
+ }
38
89
  function parseOpendocBody(body) {
39
90
  const data = parseJsonp(body);
40
91
  if (!data)
@@ -68,14 +119,37 @@ async function fetchTencentDoc(http, url) {
68
119
  const parsed = parseTencentDocUrl(url);
69
120
  if (!parsed)
70
121
  return null;
71
- const page = await http.text(parsed.pageUrl, {
122
+ const pageUrls = [parsed.pageUrl];
123
+ if (/doc\.weixin\.qq\.com/i.test(url) || parsed.id.includes('_')) {
124
+ const weixinPath = parsed.kind === 'form' ? `form/page/${parsed.id}` : `${parsed.kind}/${parsed.id}`;
125
+ const weixinUrl = `https://doc.weixin.qq.com/${weixinPath}`;
126
+ if (!pageUrls.includes(weixinUrl))
127
+ pageUrls.push(weixinUrl);
128
+ }
129
+ let lastError;
130
+ for (const pageUrl of pageUrls) {
131
+ try {
132
+ const doc = await fetchOpendocFromPage(http, parsed.id, pageUrl);
133
+ if (doc)
134
+ return doc;
135
+ }
136
+ catch (error) {
137
+ lastError = error;
138
+ }
139
+ }
140
+ if (lastError)
141
+ throw lastError;
142
+ return null;
143
+ }
144
+ async function fetchOpendocFromPage(http, id, pageUrl) {
145
+ const page = await http.text(pageUrl, {
72
146
  'user-agent': UA,
73
147
  accept: 'text/html',
74
148
  });
75
- const opendocUrl = extractOpendocUrl(page.body, parsed.id) ?? defaultOpendocUrl(parsed.id);
149
+ const opendocUrl = extractOpendocUrl(page.body, id) ?? defaultOpendocUrl(id);
76
150
  const headers = {
77
151
  'user-agent': UA,
78
- referer: parsed.pageUrl,
152
+ referer: pageUrl,
79
153
  accept: '*/*',
80
154
  };
81
155
  if (page.cookies)
@@ -83,14 +157,16 @@ async function fetchTencentDoc(http, url) {
83
157
  const opendoc = await http.text(opendocUrl, headers);
84
158
  return parseOpendocBody(opendoc.body);
85
159
  }
86
- function extractOfficeText(buffer, name) {
160
+ function extractOfficeText(buffer, name, maxBytes = exports.DEFAULT_MAX_OFFICE_BYTES) {
161
+ if (buffer.length > maxBytes)
162
+ return null;
87
163
  const lower = name.toLowerCase();
88
164
  if (lower.endsWith('.txt')) {
89
165
  const text = buffer.toString('utf8').trim();
90
166
  return text ? { title: name, text, images: [] } : null;
91
167
  }
92
168
  if (lower.endsWith('.docx')) {
93
- const xml = readZipEntry(buffer, 'word/document.xml');
169
+ const xml = readZipEntry(buffer, 'word/document.xml', maxBytes);
94
170
  if (!xml)
95
171
  return null;
96
172
  const text = xml
@@ -114,7 +190,7 @@ function defaultOpendocUrl(id) {
114
190
  return `https://docs.qq.com/dop-api/opendoc?u=&id=${id}&normal=1&outformat=1&noEscape=1&commandsFormat=1&doc_chunk_version=3&preview_token=&doc_chunk_flag=1&callback=clientVarsCallback`;
115
191
  }
116
192
  function extractOpendocUrl(html, id) {
117
- const match = html.match(/\/\/docs\.qq\.com\/dop-api\/opendoc\?[^"'<\s]+/);
193
+ const match = html.match(/\/\/(?:docs\.qq\.com|doc\.weixin\.qq\.com)\/dop-api\/opendoc\?[^"'<\s]+/);
118
194
  if (!match)
119
195
  return undefined;
120
196
  const url = `https:${match[0]}`;
@@ -165,7 +241,7 @@ function extractUtf16LeText(buffer) {
165
241
  chars.push(run);
166
242
  return chars.join('\n');
167
243
  }
168
- function readZipEntry(buffer, suffix) {
244
+ function readZipEntry(buffer, suffix, maxBytes) {
169
245
  let offset = 0;
170
246
  while (offset < buffer.length) {
171
247
  const found = buffer.indexOf(ZIP_LOCAL, offset);
@@ -175,6 +251,7 @@ function readZipEntry(buffer, suffix) {
175
251
  return null;
176
252
  const method = buffer.readUInt16LE(found + 8);
177
253
  const compSize = buffer.readUInt32LE(found + 18);
254
+ const uncompSize = buffer.readUInt32LE(found + 22);
178
255
  const nameLen = buffer.readUInt16LE(found + 26);
179
256
  const extraLen = buffer.readUInt16LE(found + 28);
180
257
  const nameStart = found + 30;
@@ -183,16 +260,41 @@ function readZipEntry(buffer, suffix) {
183
260
  return null;
184
261
  const name = buffer.subarray(nameStart, nameStart + nameLen).toString('utf8');
185
262
  if (name === suffix || name.endsWith(`/${suffix}`)) {
263
+ if (uncompSize > 0 && uncompSize <= 0xffff_fffe && uncompSize > maxBytes)
264
+ return null;
186
265
  const data = buffer.subarray(dataStart, dataStart + compSize);
187
266
  if (method === 0)
188
- return data.toString('utf8');
189
- if (method === 8)
190
- return (0, node_zlib_1.inflateRawSync)(data).toString('utf8');
267
+ return data.length > maxBytes ? null : data.toString('utf8');
268
+ if (method === 8) {
269
+ try {
270
+ return (0, node_zlib_1.inflateRawSync)(data, { maxOutputLength: maxBytes }).toString('utf8');
271
+ }
272
+ catch {
273
+ return null;
274
+ }
275
+ }
191
276
  }
192
277
  offset = dataStart + Math.max(compSize, 1);
193
278
  }
194
279
  return null;
195
280
  }
281
+ function decodePercents(text, times = 3) {
282
+ let current = text;
283
+ for (let i = 0; i < times; i++) {
284
+ if (!/%[0-9A-Fa-f]{2}/.test(current))
285
+ break;
286
+ try {
287
+ const next = decodeURIComponent(current);
288
+ if (next === current)
289
+ break;
290
+ current = next;
291
+ }
292
+ catch {
293
+ break;
294
+ }
295
+ }
296
+ return current;
297
+ }
196
298
  function firstString(...values) {
197
299
  for (const value of values) {
198
300
  if (typeof value === 'string' && value.trim())
package/lib/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface Config {
14
14
  scanGroupInvite: boolean;
15
15
  scanDocs: boolean;
16
16
  adKeywords: string[];
17
+ maxOfficeMb: number;
17
18
  debug: boolean;
18
19
  }
19
20
  export declare const Config: Schema<Config>;
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ exports.Config = koishi_1.Schema.object({
29
29
  scanGroupInvite: koishi_1.Schema.boolean().default(true).description('拦截邀请 / 推荐群聊 / 群名片分享卡。'),
30
30
  scanDocs: koishi_1.Schema.boolean().default(true).description('检查腾讯文档和 Word / 文本附件里的广告。'),
31
31
  adKeywords: koishi_1.Schema.array(koishi_1.Schema.string()).role('table').default([]).description('额外广告关键词。命中即撤回。'),
32
+ maxOfficeMb: koishi_1.Schema.number().min(1).default(5).description('解析 Word / 文本附件的大小上限(MB)。超出则跳过,防止解压占用过多内存。'),
32
33
  debug: koishi_1.Schema.boolean().default(true).description('输出调试日志:跳过原因、消息结构、下载/扫码/文档结果。'),
33
34
  });
34
35
  function apply(ctx, config) {
@@ -56,7 +57,10 @@ function apply(ctx, config) {
56
57
  return;
57
58
  }
58
59
  const docUrls = config.scanDocs
59
- ? (0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n'))
60
+ ? uniqueStrings([
61
+ ...(0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n')),
62
+ ...parts.shares.flatMap(document_1.extractTencentDocUrlsFromShare),
63
+ ])
60
64
  : [];
61
65
  const officeFiles = config.scanDocs
62
66
  ? parts.files.filter(file => (0, detect_1.isOfficeFile)(file.name) || (0, detect_1.isOfficeFile)(file.src))
@@ -146,7 +150,7 @@ function apply(ctx, config) {
146
150
  }
147
151
  for (const file of officeFiles) {
148
152
  try {
149
- const doc = (0, document_1.extractOfficeText)(await (0, qrcode_1.downloadFile)(ctx.http, { ...file, groupId: guildId }, resolveFile), file.name);
153
+ const doc = (0, document_1.extractOfficeText)(await (0, qrcode_1.downloadFile)(ctx.http, { ...file, groupId: guildId }, resolveFile), file.name, Math.floor(config.maxOfficeMb * 1024 * 1024));
150
154
  const ad = doc ? (0, ad_1.detectAdContent)(doc.text, doc.title || file.name, config.adKeywords) : null;
151
155
  if (config.debug)
152
156
  logger.info('office %s ad=%s', file.name, Boolean(ad));
@@ -210,6 +214,7 @@ function looksRelevant(parts, config) {
210
214
  return true;
211
215
  if (config.scanDocs && (parts.files.some(file => (0, detect_1.isOfficeFile)(file.name))
212
216
  || (0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n')).length
217
+ || parts.shares.some(share => (0, document_1.extractTencentDocUrlsFromShare)(share).length > 0)
213
218
  || parts.shares.some(share_1.isTencentDocCard))) {
214
219
  return true;
215
220
  }
@@ -233,6 +238,17 @@ function createDocHttp(http) {
233
238
  function errorMessage(error) {
234
239
  return error instanceof Error && error.message ? error.message : 'unknown';
235
240
  }
241
+ function uniqueStrings(values) {
242
+ const seen = new Set();
243
+ const result = [];
244
+ for (const value of values) {
245
+ if (!value || seen.has(value))
246
+ continue;
247
+ seen.add(value);
248
+ result.push(value);
249
+ }
250
+ return result;
251
+ }
236
252
  function cookiesFromHeaders(headers) {
237
253
  const parts = typeof headers.getSetCookie === 'function'
238
254
  ? headers.getSetCookie()
package/lib/share.js CHANGED
@@ -5,11 +5,16 @@ exports.isGroupInviteCard = isGroupInviteCard;
5
5
  exports.isTencentDocCard = isTencentDocCard;
6
6
  exports.extractShareCardText = extractShareCardText;
7
7
  const detect_1 = require("./detect");
8
+ const document_1 = require("./document");
8
9
  const INVITE_APPS = new Set([
9
10
  'com.tencent.qun.invite',
10
11
  'com.tencent.troopsharecard',
11
12
  ]);
12
13
  const GROUP_PROMPT = /群名片|\[QQ名片\]群|推荐群聊|邀请你加入群聊|邀请加入群聊/;
14
+ const TENCENT_DOC_APPIDS = new Set([
15
+ 'wxd45c635d754dbf59',
16
+ '1108338344',
17
+ ]);
13
18
  function normalizeShare(payload) {
14
19
  let text = payload.trim();
15
20
  const wrapped = /^\[(?:CQ:)?json(?:,data=|:data=)/i.exec(text);
@@ -58,7 +63,12 @@ function isGroupInviteCard(payload) {
58
63
  return false;
59
64
  }
60
65
  function isTencentDocCard(payload) {
61
- return /腾讯文档|docs\.qq\.com|doc\.weixin\.qq\.com/i.test(normalizeShare(payload));
66
+ const raw = normalizeShare(payload);
67
+ if (/腾讯文档|docs\.qq\.com|doc\.weixin\.qq\.com|qqdocurl/i.test(raw))
68
+ return true;
69
+ if (TENCENT_DOC_APPIDS.has(findShareAppId(readShareJson(raw))))
70
+ return true;
71
+ return (0, document_1.collectTencentDocUrls)(raw).length > 0;
62
72
  }
63
73
  function extractShareCardText(payload) {
64
74
  const raw = normalizeShare(payload);
@@ -87,6 +97,8 @@ function extractShareCardText(payload) {
87
97
  add(item.tag);
88
98
  add(item.summary);
89
99
  add(item.brief);
100
+ add(item.appname);
101
+ add(item.appName);
90
102
  }
91
103
  }
92
104
  return chunks.join('\n');
@@ -139,6 +151,24 @@ function tryParseJson(text) {
139
151
  return null;
140
152
  }
141
153
  }
154
+ function findShareAppId(value) {
155
+ if (!isRecord(value))
156
+ return '';
157
+ for (const key of ['appid', 'appId', 'appID']) {
158
+ const id = value[key];
159
+ if (id === undefined || id === null)
160
+ continue;
161
+ const text = String(id);
162
+ if (TENCENT_DOC_APPIDS.has(text))
163
+ return text;
164
+ }
165
+ for (const item of Object.values(value)) {
166
+ const found = findShareAppId(item);
167
+ if (found)
168
+ return found;
169
+ }
170
+ return '';
171
+ }
142
172
  function isRecord(value) {
143
173
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
144
174
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-ban-qrcode",
3
3
  "description": "检测到群内二维码、拉群卡片或文档广告后自动撤回并禁言",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
package/readme.md CHANGED
@@ -14,7 +14,7 @@ Recall QR-code images, group-invite share cards, and freshman-list document ads,
14
14
 
15
15
  - 🔍 **扫图识码**:下载消息里的图片,解码是否含二维码
16
16
  - 📎 **拉群卡片**:识别 QQ 邀请加群 / 推荐群聊 / 群名片分享卡
17
- - 📄 **文档广告**:拉取腾讯文档或 Word / 文本附件正文,识别夹带的床品推销等广告
17
+ - 📄 **文档广告**:拉取腾讯文档(含微信 / QQ 小程序卡片)或 Word / 文本附件正文,识别夹带的床品推销等广告
18
18
  - 🗑️ **自动撤回**:命中后立刻撤回原消息
19
19
  - 🔇 **自动禁言**:默认禁言 60 秒,秒数可配
20
20
  - 🛡️ **白名单**:默认跳过群主 / 管理员,也可按用户 ID、群 ID 过滤
@@ -74,7 +74,7 @@ npm run build
74
74
  1. 只处理群聊,忽略私聊和机器人自己的消息
75
75
  2. 图片二维码:收集 `img` / `image`,下载后解码
76
76
  3. 拉群卡片:识别 `json` / `xml` / `contact` 分享卡(`com.tencent.qun.invite`、群名片、`[推荐群]` 等)
77
- 4. 文档广告:从文本、分享卡里取出 `docs.qq.com` 链接,或下载 `.doc` / `.docx` / `.txt`,识别夹带的推销文案(例如校园床品「送货到寝」)
77
+ 4. 文档广告:从文本、新闻卡、微信 / QQ 小程序腾讯文档卡里取出 `docs.qq.com` / `doc.weixin.qq.com` 链接(含编码过的小程序 path、`qqdocurl`),或下载 `.doc` / `.docx` / `.txt`,识别夹带的推销文案(例如校园床品「送货到寝」)
78
78
  5. 文档里的图也会再扫一遍二维码
79
79
 
80
80
  不会把解码出的文本或广告原文发回群里。纯物品清单(只写「被子、枕头」而没有推销)不会误杀。
@@ -94,6 +94,7 @@ npm run build
94
94
  | `scanGroupInvite` | `boolean` | `true` | 拦截邀请 / 推荐群聊分享卡 |
95
95
  | `scanDocs` | `boolean` | `true` | 检查腾讯文档和 Word / 文本附件 |
96
96
  | `adKeywords` | `string[]` | `[]` | 额外广告关键词,命中即撤回 |
97
+ | `maxOfficeMb` | `number` | `5` | Word / 文本附件的大小上限(MB),超出则跳过 |
97
98
  | `debug` | `boolean` | `true` | 输出调试日志:跳过原因、消息结构、下载 / 扫码 / 文档结果 |
98
99
 
99
100
  内置词在 `src/ad-keywords.txt`,一行一条。`[strong]` 命中任意一条即撤回;`[commerce]` 要同时出现床品类用词且至少两条。控制台 `adKeywords` 按强匹配叠加。
@@ -278,7 +279,7 @@ Actions 会 `npm ci` → `npm test` → `npm run build` → `npm publish --acces
278
279
 
279
280
  ## Changelog
280
281
 
281
- 当前版本 **1.2.0**:内置广告词改到 `ad-keywords.txt` 维护,控制台 `adKeywords` 仍作额外强匹配。1.1.3 补拦 QQ 群名片。1.1.2 修复 OneBot `getImage` 未绑定导致的生产崩溃。1.1.1 补齐跳过原因日志。1.1.0 起拦截拉群分享卡和腾讯文档 / Word 广告。
282
+ 当前版本 **1.3.0**:识别微信 / QQ 小程序腾讯文档卡,还原 `docs.qq.com` 链接后再按原逻辑拉正文。1.2.1:Word / 文本附件默认限制 5MB(`maxOfficeMb`)。1.2.0 起内置广告词改到 `ad-keywords.txt` 维护。1.1.3 补拦 QQ 群名片。1.1.2 修复 OneBot `getImage` 未绑定导致的生产崩溃。1.1.1 补齐跳过原因日志。1.1.0 起拦截拉群分享卡和腾讯文档 / Word 广告。
282
283
 
283
284
  ## License
284
285