koishi-plugin-ban-qrcode 1.1.3 → 1.2.1

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,24 @@
1
+ # 一行一个关键词。空行和 # 注释会被忽略。
2
+ # [strong] 命中任意一条即撤回
3
+ # [commerce] 需同时出现床品类用词,且至少命中两条
4
+
5
+ [strong]
6
+ 校园麦芽
7
+ 送货到寝
8
+ 配送到寝
9
+ 送寝
10
+ 名额有限
11
+ 营业执照
12
+ 联系本校的负责人
13
+ 联系本校负责人
14
+ 整套四五百
15
+
16
+ [commerce]
17
+ 提前预订
18
+ 提前预定
19
+ 买被子
20
+ 购买链接
21
+ 质保四年
22
+ 厂家直销
23
+ 真空包装
24
+ 代发到寝
package/lib/ad.d.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  export interface AdHit {
2
2
  matches: string[];
3
3
  }
4
+ export interface AdKeywordLists {
5
+ strong: string[];
6
+ commerce: string[];
7
+ }
8
+ export declare function parseAdKeywords(source: string): AdKeywordLists;
4
9
  export declare function detectAdContent(text: string, title?: string, extraKeywords?: readonly string[]): AdHit | null;
package/lib/ad.js CHANGED
@@ -1,27 +1,30 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseAdKeywords = parseAdKeywords;
3
4
  exports.detectAdContent = detectAdContent;
4
- const VERY_STRONG = [
5
- '校园麦芽',
6
- '送货到寝',
7
- '配送到寝',
8
- '送寝',
9
- '名额有限',
10
- '营业执照',
11
- '联系本校的负责人',
12
- '联系本校负责人',
13
- '整套四五百',
14
- ];
15
- const COMMERCE = [
16
- '提前预订',
17
- '提前预定',
18
- '买被子',
19
- '购买链接',
20
- '质保四年',
21
- '厂家直销',
22
- '真空包装',
23
- '代发到寝',
24
- ];
5
+ const node_fs_1 = require("node:fs");
6
+ const node_path_1 = require("node:path");
7
+ const SECTION = /^\[(strong|commerce)\]$/i;
8
+ function parseAdKeywords(source) {
9
+ const lists = { strong: [], commerce: [] };
10
+ let section = 'strong';
11
+ for (const raw of source.replace(/^\uFEFF/, '').split(/\r?\n/)) {
12
+ const line = raw.trim();
13
+ if (!line || line.startsWith('#'))
14
+ continue;
15
+ const marker = line.match(SECTION);
16
+ if (marker) {
17
+ section = marker[1].toLowerCase();
18
+ continue;
19
+ }
20
+ lists[section].push(line);
21
+ }
22
+ return lists;
23
+ }
24
+ function loadAdKeywords() {
25
+ return parseAdKeywords((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, 'ad-keywords.txt'), 'utf8'));
26
+ }
27
+ const { strong: VERY_STRONG, commerce: COMMERCE } = loadAdKeywords();
25
28
  const PRODUCT = /被子|床品|四件套|六件套|九件套|被芯|床垫/;
26
29
  const SELL = /购买|预订|预定|下单|代购|团购|包邮|厂家|品牌|优惠|联系.{0,8}负责/;
27
30
  const CONTACT = /微信|加v|加微|vx[::]|v信/i;
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;
@@ -17,5 +18,5 @@ export declare function parseTencentDocUrl(url: string): {
17
18
  export declare function collectTencentDocUrls(text: string): string[];
18
19
  export declare function parseOpendocBody(body: string): DocContent | null;
19
20
  export declare function fetchTencentDoc(http: DocHttp, url: string): Promise<DocContent | null>;
20
- export declare function extractOfficeText(buffer: Buffer, name: string): DocContent | null;
21
+ export declare function extractOfficeText(buffer: Buffer, name: string, maxBytes?: number): DocContent | null;
21
22
  export declare function extractReadableText(input: string | Buffer): string;
package/lib/document.js CHANGED
@@ -1,5 +1,6 @@
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;
5
6
  exports.parseOpendocBody = parseOpendocBody;
@@ -12,6 +13,7 @@ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
12
13
  const DOC_RE = /https?:\/\/(?:docs\.qq\.com|doc\.weixin\.qq\.com)\/(doc|sheet|slide|pdf|form|mind)\/([A-Za-z0-9]+)/i;
13
14
  const ZIP_LOCAL = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
14
15
  const IMAGE_RE = /https?:\/\/docimg\d+\.docs\.qq\.com\/image\/[A-Za-z0-9_-]+(?:\.(?:jpe?g|png|webp))?/gi;
16
+ exports.DEFAULT_MAX_OFFICE_BYTES = 5 * 1024 * 1024;
15
17
  function parseTencentDocUrl(url) {
16
18
  const match = DOC_RE.exec(url);
17
19
  if (!match)
@@ -83,14 +85,16 @@ async function fetchTencentDoc(http, url) {
83
85
  const opendoc = await http.text(opendocUrl, headers);
84
86
  return parseOpendocBody(opendoc.body);
85
87
  }
86
- function extractOfficeText(buffer, name) {
88
+ function extractOfficeText(buffer, name, maxBytes = exports.DEFAULT_MAX_OFFICE_BYTES) {
89
+ if (buffer.length > maxBytes)
90
+ return null;
87
91
  const lower = name.toLowerCase();
88
92
  if (lower.endsWith('.txt')) {
89
93
  const text = buffer.toString('utf8').trim();
90
94
  return text ? { title: name, text, images: [] } : null;
91
95
  }
92
96
  if (lower.endsWith('.docx')) {
93
- const xml = readZipEntry(buffer, 'word/document.xml');
97
+ const xml = readZipEntry(buffer, 'word/document.xml', maxBytes);
94
98
  if (!xml)
95
99
  return null;
96
100
  const text = xml
@@ -165,7 +169,7 @@ function extractUtf16LeText(buffer) {
165
169
  chars.push(run);
166
170
  return chars.join('\n');
167
171
  }
168
- function readZipEntry(buffer, suffix) {
172
+ function readZipEntry(buffer, suffix, maxBytes) {
169
173
  let offset = 0;
170
174
  while (offset < buffer.length) {
171
175
  const found = buffer.indexOf(ZIP_LOCAL, offset);
@@ -175,6 +179,7 @@ function readZipEntry(buffer, suffix) {
175
179
  return null;
176
180
  const method = buffer.readUInt16LE(found + 8);
177
181
  const compSize = buffer.readUInt32LE(found + 18);
182
+ const uncompSize = buffer.readUInt32LE(found + 22);
178
183
  const nameLen = buffer.readUInt16LE(found + 26);
179
184
  const extraLen = buffer.readUInt16LE(found + 28);
180
185
  const nameStart = found + 30;
@@ -183,11 +188,19 @@ function readZipEntry(buffer, suffix) {
183
188
  return null;
184
189
  const name = buffer.subarray(nameStart, nameStart + nameLen).toString('utf8');
185
190
  if (name === suffix || name.endsWith(`/${suffix}`)) {
191
+ if (uncompSize > 0 && uncompSize <= 0xffff_fffe && uncompSize > maxBytes)
192
+ return null;
186
193
  const data = buffer.subarray(dataStart, dataStart + compSize);
187
194
  if (method === 0)
188
- return data.toString('utf8');
189
- if (method === 8)
190
- return (0, node_zlib_1.inflateRawSync)(data).toString('utf8');
195
+ return data.length > maxBytes ? null : data.toString('utf8');
196
+ if (method === 8) {
197
+ try {
198
+ return (0, node_zlib_1.inflateRawSync)(data, { maxOutputLength: maxBytes }).toString('utf8');
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ }
191
204
  }
192
205
  offset = dataStart + Math.max(compSize, 1);
193
206
  }
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) {
@@ -146,7 +147,7 @@ function apply(ctx, config) {
146
147
  }
147
148
  for (const file of officeFiles) {
148
149
  try {
149
- const doc = (0, document_1.extractOfficeText)(await (0, qrcode_1.downloadFile)(ctx.http, { ...file, groupId: guildId }, resolveFile), file.name);
150
+ 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
151
  const ad = doc ? (0, ad_1.detectAdContent)(doc.text, doc.title || file.name, config.adKeywords) : null;
151
152
  if (config.debug)
152
153
  logger.info('office %s ad=%s', file.name, Boolean(ad));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-ban-qrcode",
3
3
  "description": "检测到群内二维码、拉群卡片或文档广告后自动撤回并禁言",
4
- "version": "1.1.3",
4
+ "version": "1.2.1",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -21,7 +21,7 @@
21
21
  "access": "public"
22
22
  },
23
23
  "scripts": {
24
- "build": "tsc -b",
24
+ "build": "tsc -b && node -e \"require('fs').copyFileSync('src/ad-keywords.txt', 'lib/ad-keywords.txt')\"",
25
25
  "test": "tsx --test tests/*.spec.ts"
26
26
  },
27
27
  "keywords": [
package/readme.md CHANGED
@@ -94,13 +94,16 @@ 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
 
100
+ 内置词在 `src/ad-keywords.txt`,一行一条。`[strong]` 命中任意一条即撤回;`[commerce]` 要同时出现床品类用词且至少两条。控制台 `adKeywords` 按强匹配叠加。
101
+
99
102
  默认提示按原因变化,例如:`检测到拉群卡片,已撤回并禁言 60 秒。` / `检测到文档广告,已撤回并禁言 60 秒。`
100
103
 
101
104
  ## API Reference
102
105
 
103
- 插件入口导出 `name`、`inject`、`Config`、`apply`。判定与扫码编排在 `src/detect.ts`,拉群卡在 `src/share.ts`,文档广告在 `src/ad.ts` / `src/document.ts`,下载和解码在 `src/qrcode.ts`。
106
+ 插件入口导出 `name`、`inject`、`Config`、`apply`。判定与扫码编排在 `src/detect.ts`,拉群卡在 `src/share.ts`,文档广告在 `src/ad.ts` / `src/ad-keywords.txt` / `src/document.ts`,下载和解码在 `src/qrcode.ts`。内置广告词在 `ad-keywords.txt` 里一行一条;控制台 `adKeywords` 仍是额外覆盖。
104
107
 
105
108
  ### `collectImageSrcs(nodes)`
106
109
 
@@ -122,12 +125,13 @@ npm run build
122
125
 
123
126
  ```
124
127
  src/
125
- ├── index.ts # 插件入口:监听群消息,撤回 / 禁言 / 提示
126
- ├── detect.ts # 收集图片 / 卡片 / 文件、是否处理、提示语
127
- ├── share.ts # 识别 QQ 拉群分享卡
128
- ├── ad.ts # 识别文档里的卖货文案
129
- ├── document.ts # 拉腾讯文档、抽出 Word / 文本
130
- └── qrcode.ts # 下载图片、解码二维码
128
+ ├── index.ts # 插件入口:监听群消息,撤回 / 禁言 / 提示
129
+ ├── detect.ts # 收集图片 / 卡片 / 文件、是否处理、提示语
130
+ ├── share.ts # 识别 QQ 拉群分享卡
131
+ ├── ad.ts # 识别文档里的卖货文案
132
+ ├── ad-keywords.txt # 内置广告关键词,一行一条
133
+ ├── document.ts # 拉腾讯文档、抽出 Word / 文本
134
+ └── qrcode.ts # 下载图片、解码二维码
131
135
  tests/
132
136
  ├── detect.spec.ts
133
137
  ├── share.spec.ts
@@ -275,7 +279,7 @@ Actions 会 `npm ci` → `npm test` → `npm run build` → `npm publish --acces
275
279
 
276
280
  ## Changelog
277
281
 
278
- 当前版本 **1.1.3**:补拦 QQ 群名片(`source=sharecard` 的 contact.lua 卡、OneBot `contact` 段)。1.1.2 修复 OneBot `getImage` 未绑定导致的生产崩溃,扫码失败不再跳过腾讯文档检测。1.1.1 补齐跳过原因日志,并用 `http.file` / OneBot `get_image` 与微信扫码器提高检出率。1.1.0 起拦截拉群分享卡和腾讯文档 / Word 广告。
282
+ 当前版本 **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 广告。
279
283
 
280
284
  ## License
281
285