@nebulaaa/auditor 1.0.0 → 1.0.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.
- package/dist/auditor.cjs +38 -13
- package/dist/auditor.d.ts +20 -15
- package/dist/auditor.min.cjs +1 -1
- package/dist/auditor.min.mjs +1 -1
- package/dist/auditor.mjs +38 -13
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/auditor.cjs
CHANGED
|
@@ -16,6 +16,7 @@ class ACAutomaton {
|
|
|
16
16
|
this._built = false;
|
|
17
17
|
this._interferenceWords = new Set();
|
|
18
18
|
this._ignoreCase = ignoreCase;
|
|
19
|
+
this._wordChars = new Map();
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
21
22
|
* 大小写归一(仅 ASCII 字母,逐码点等长替换,保证匹配位置与原始文本索引一致)
|
|
@@ -54,11 +55,13 @@ class ACAutomaton {
|
|
|
54
55
|
}
|
|
55
56
|
current = child;
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// 归一后同一路径只保留首词:IgnoreCase 下 "abc"/"ABC" 共享路径,
|
|
59
|
+
// 变体重复入表会让 Match 对同一区间输出多条重复结果(下游命中计数虚高)
|
|
60
|
+
if (current.Words.length === 0) {
|
|
59
61
|
current.Words.push(word);
|
|
62
|
+
this._wordChars.set(word, chars);
|
|
63
|
+
this._built = false; // 标记需要重新构建 fail 指针
|
|
60
64
|
}
|
|
61
|
-
this._built = false; // 标记需要重新构建 fail 指针
|
|
62
65
|
}
|
|
63
66
|
/**
|
|
64
67
|
* 构建 fail 指针(使用 BFS)
|
|
@@ -121,8 +124,8 @@ class ACAutomaton {
|
|
|
121
124
|
for (const word of current.Output) {
|
|
122
125
|
// 计算在原始文本中的起始位置(跳过干扰字符)
|
|
123
126
|
let j = i;
|
|
124
|
-
//
|
|
125
|
-
const wordChars =
|
|
127
|
+
// 从当前位置向前查找匹配的起始位置(AddWord 已缓存归一后的码点数组)
|
|
128
|
+
const wordChars = this._wordChars.get(word);
|
|
126
129
|
let wordIndex = wordChars.length - 1;
|
|
127
130
|
// 从后向前匹配,找到原始文本中的位置
|
|
128
131
|
while (j >= 0 && wordIndex >= 0) {
|
|
@@ -135,6 +138,11 @@ class ACAutomaton {
|
|
|
135
138
|
}
|
|
136
139
|
j--;
|
|
137
140
|
}
|
|
141
|
+
// 回扫必须完整命中:前向 AC 已保证该词是当前位置的后缀(两侧对称跳过干扰字符),
|
|
142
|
+
// wordIndex >= 0 说明不变量被破坏(理论不可达),防御性跳过而非产出错误区间
|
|
143
|
+
if (wordIndex >= 0) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
138
146
|
results.push({
|
|
139
147
|
Word: word,
|
|
140
148
|
Start: j + 1,
|
|
@@ -184,25 +192,29 @@ class ACAutomaton {
|
|
|
184
192
|
Words: [],
|
|
185
193
|
Output: [],
|
|
186
194
|
};
|
|
195
|
+
this._wordChars.clear();
|
|
187
196
|
this._built = false;
|
|
188
197
|
}
|
|
189
198
|
/**
|
|
190
199
|
* 注入干扰词(仅单字符生效:匹配按单字符查表)
|
|
191
200
|
* @param words 干扰词列表
|
|
201
|
+
* @returns 被忽略的多字符词(由调用方决定告警方式,保持算法类无日志副作用)
|
|
192
202
|
*/
|
|
193
203
|
AddInterferenceWords(words) {
|
|
204
|
+
const ignored = [];
|
|
194
205
|
for (const word of words) {
|
|
195
206
|
if (!word || word.length === 0) {
|
|
196
207
|
continue;
|
|
197
208
|
}
|
|
198
209
|
const chars = Array.from(this.normalize(word));
|
|
199
210
|
if (chars.length !== 1) {
|
|
200
|
-
//
|
|
201
|
-
|
|
211
|
+
// 多字符干扰词在按字符匹配的查表中永不命中——返回给调用方显式告警而非静默失效
|
|
212
|
+
ignored.push(word);
|
|
202
213
|
continue;
|
|
203
214
|
}
|
|
204
215
|
this._interferenceWords.add(chars[0]);
|
|
205
216
|
}
|
|
217
|
+
return ignored;
|
|
206
218
|
}
|
|
207
219
|
/**
|
|
208
220
|
* 清空干扰词
|
|
@@ -229,14 +241,14 @@ class AuditorPlugin extends plugin.Plugin {
|
|
|
229
241
|
CacheSize: 1000,
|
|
230
242
|
});
|
|
231
243
|
super(deps, cfg);
|
|
244
|
+
// 缓存容量须为正整数:无论是否启用缓存都在构造期校验,配置错误不留到运行期
|
|
245
|
+
if (!Number.isInteger(cfg.CacheSize) || cfg.CacheSize <= 0) {
|
|
246
|
+
throw new Error(`AuditorPlugin CacheSize 须为正整数 >>> ${cfg.CacheSize}`);
|
|
247
|
+
}
|
|
232
248
|
this._acAutomaton = new ACAutomaton(cfg.IgnoreCase);
|
|
233
249
|
this._sensitiveWords = new Set();
|
|
234
250
|
this._acAutomaton.AddInterferenceWords(' \t\r\n'.split(''));
|
|
235
251
|
if (cfg.EnableCache) {
|
|
236
|
-
// 缓存容量须为正整数:非法配置构造期显式失败,错误信息带插件上下文便于定位
|
|
237
|
-
if (!Number.isInteger(cfg.CacheSize) || cfg.CacheSize <= 0) {
|
|
238
|
-
throw new Error(`AuditorPlugin CacheSize 须为正整数 >>> ${cfg.CacheSize}`);
|
|
239
|
-
}
|
|
240
252
|
this._testCache = new lru.LRU(cfg.CacheSize);
|
|
241
253
|
this._findCache = new lru.LRU(cfg.CacheSize);
|
|
242
254
|
this._replaceCache = new lru.LRU(cfg.CacheSize);
|
|
@@ -353,16 +365,29 @@ class AuditorPlugin extends plugin.Plugin {
|
|
|
353
365
|
return replacedText;
|
|
354
366
|
}
|
|
355
367
|
InjectSensitiveWords(words) {
|
|
368
|
+
let added = false;
|
|
356
369
|
for (const word of words) {
|
|
357
|
-
if (word && word.length > 0) {
|
|
370
|
+
if (word && word.length > 0 && !this._sensitiveWords.has(word)) {
|
|
358
371
|
this._sensitiveWords.add(word);
|
|
372
|
+
added = true;
|
|
359
373
|
}
|
|
360
374
|
}
|
|
375
|
+
// 无新增词时词库未变:跳过自动机重建与缓存清理,重复注入不打断既有缓存
|
|
376
|
+
if (!added) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
361
379
|
this.buildAutomaton();
|
|
362
380
|
this.clearCache();
|
|
363
381
|
}
|
|
364
382
|
InjectInterferenceWords(words) {
|
|
365
|
-
this._acAutomaton.AddInterferenceWords(words);
|
|
383
|
+
const ignored = this._acAutomaton.AddInterferenceWords(words);
|
|
384
|
+
// 多字符干扰词永不命中:经插件日志显式告警(词库内容不落 console)
|
|
385
|
+
if (ignored.length > 0) {
|
|
386
|
+
const logger = this.Logger;
|
|
387
|
+
for (const word of ignored) {
|
|
388
|
+
logger.Warn(`干扰词须为单个字符,已忽略 >>> ${word}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
366
391
|
this.clearCache();
|
|
367
392
|
}
|
|
368
393
|
GetSensitiveWordCount() {
|
package/dist/auditor.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ interface AuditorCfg extends PluginCfg {
|
|
|
18
18
|
/**
|
|
19
19
|
* 缓存大小
|
|
20
20
|
* @default 1000
|
|
21
|
-
* @tip
|
|
21
|
+
* @tip 须为正整数,非法值构造期显式报错(无论是否启用缓存均校验)
|
|
22
22
|
*/
|
|
23
23
|
readonly CacheSize?: number;
|
|
24
24
|
/**
|
|
@@ -33,9 +33,15 @@ interface AuditorCfg extends PluginCfg {
|
|
|
33
33
|
interface IAuditorMatchPosition {
|
|
34
34
|
/** 匹配到的敏感词 */
|
|
35
35
|
Word: string;
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* 起始位置(**码点索引**,`Array.from` 语义)
|
|
38
|
+
* @tip 非 UTF-16 下标:文本含 emoji 等代理对时,直接 `str.slice(Start, End)` 会切错位置
|
|
39
|
+
*/
|
|
37
40
|
Start: number;
|
|
38
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* 结束位置(码点索引,不包含)
|
|
43
|
+
* @tip 同上,切分须按码点进行(如 `Array.from(str).slice(Start, End)`)
|
|
44
|
+
*/
|
|
39
45
|
End: number;
|
|
40
46
|
}
|
|
41
47
|
/** 匹配结果 */
|
|
@@ -58,15 +64,8 @@ interface IACNode {
|
|
|
58
64
|
/** 匹配输出(自身词 + fail 链词,构建时由 words 重新生成) */
|
|
59
65
|
Output: string[];
|
|
60
66
|
}
|
|
61
|
-
/** AC
|
|
62
|
-
|
|
63
|
-
/** 匹配到的敏感词 */
|
|
64
|
-
Word: string;
|
|
65
|
-
/** 起始位置 */
|
|
66
|
-
Start: number;
|
|
67
|
-
/** 结束位置 */
|
|
68
|
-
End: number;
|
|
69
|
-
}
|
|
67
|
+
/** AC 自动机匹配结果(与外部匹配位置结构一致,复用同一形状避免重复定义) */
|
|
68
|
+
type IACMatchResult = IAuditorMatchPosition;
|
|
70
69
|
/** 敏感词审计员插件接口 */
|
|
71
70
|
interface IAuditorPlugin extends IPlugin<AuditorDeps, AuditorCfg> {
|
|
72
71
|
/**
|
|
@@ -78,7 +77,7 @@ interface IAuditorPlugin extends IPlugin<AuditorDeps, AuditorCfg> {
|
|
|
78
77
|
/**
|
|
79
78
|
* 查找文本中的所有敏感词
|
|
80
79
|
* @param text 待查找的文本
|
|
81
|
-
* @returns
|
|
80
|
+
* @returns 匹配结果(匹配位置为码点索引)
|
|
82
81
|
*/
|
|
83
82
|
Find(text: string): IAuditorMatchResult;
|
|
84
83
|
/**
|
|
@@ -94,6 +93,7 @@ interface IAuditorPlugin extends IPlugin<AuditorDeps, AuditorCfg> {
|
|
|
94
93
|
Replace(text: string, replacement?: string): string;
|
|
95
94
|
/**
|
|
96
95
|
* 注入敏感词
|
|
96
|
+
* @tip 重复注入已存在的词为幂等操作,不触发自动机重建与缓存清理
|
|
97
97
|
* @param words 敏感词列表
|
|
98
98
|
*/
|
|
99
99
|
InjectSensitiveWords(words: string[]): void;
|
|
@@ -102,6 +102,8 @@ interface IAuditorPlugin extends IPlugin<AuditorDeps, AuditorCfg> {
|
|
|
102
102
|
*
|
|
103
103
|
* - 干扰词在匹配时被跳过(如 "敏 感 词" 仍命中),仅单字符
|
|
104
104
|
* 生效,多字符词将被忽略并告警
|
|
105
|
+
* - 构造期默认注入空白字符(空格/制表符/回车/换行),故全新实例的
|
|
106
|
+
* GetInterferenceWordCount 返回 4
|
|
105
107
|
* - 敏感词本身包含干扰字符时该词将无法命中,注入时须避开词库字符
|
|
106
108
|
* @param words 干扰词列表
|
|
107
109
|
*/
|
|
@@ -113,12 +115,15 @@ interface IAuditorPlugin extends IPlugin<AuditorDeps, AuditorCfg> {
|
|
|
113
115
|
GetSensitiveWordCount(): number;
|
|
114
116
|
/**
|
|
115
117
|
* 获取当前干扰词数量
|
|
116
|
-
* @returns
|
|
118
|
+
* @returns 干扰词数量(含构造期默认注入的空白字符)
|
|
117
119
|
*/
|
|
118
120
|
GetInterferenceWordCount(): number;
|
|
119
121
|
/** 清空所有敏感词 */
|
|
120
122
|
ClearSensitiveWords(): void;
|
|
121
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* 清空所有干扰词(含构造期默认注入的空白字符)
|
|
125
|
+
* @tip 清空后 "敏 感 词" 这类夹空白的变体不再命中
|
|
126
|
+
*/
|
|
122
127
|
ClearInterferenceWords(): void;
|
|
123
128
|
}
|
|
124
129
|
|
package/dist/auditor.min.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var t=require("@nebulaaa/dict"),e=require("@nebulaaa/lru"),r=require("@nebulaaa/plugin");class
|
|
1
|
+
"use strict";var t=require("@nebulaaa/dict"),e=require("@nebulaaa/lru"),r=require("@nebulaaa/plugin");class s{constructor(t=!1){this._root={Children:new Map,Fail:null,Words:[],Output:[]},this._built=!1,this._interferenceWords=new Set,this._ignoreCase=t,this._wordChars=new Map}normalize(t){if(!this._ignoreCase)return t;let e="";for(const r of t){const t=r.codePointAt(0);e+=t>=65&&t<=90?String.fromCharCode(t+32):r}return e}AddWord(t){if(!t||0===t.length)return;let e=this._root;const r=Array.from(this.normalize(t));for(const t of r){let r=e.Children.get(t);r||(r={Children:new Map,Fail:null,Words:[],Output:[]},e.Children.set(t,r)),e=r}0===e.Words.length&&(e.Words.push(t),this._wordChars.set(t,r),this._built=!1)}Build(){if(this._built)return;const t=[];for(const e of this._root.Children.values())e.Fail=this._root,e.Output=[...e.Words],t.push(e);let e=0;for(;e<t.length;){const r=t[e++];for(const[e,s]of r.Children){t.push(s);let n=r.Fail;for(;n&&!n.Children.has(e);)n=n.Fail;s.Fail=n&&n.Children.get(e)||this._root,s.Output=[...s.Words,...s.Fail.Output]}}this._built=!0}Match(t){this._built||this.Build();const e=[];let r=this._root;const s=Array.from(this.normalize(t));for(let t=0;t<s.length;t++){const n=s[t];if(!this._interferenceWords.has(n)){for(;r!==this._root&&!r.Children.has(n);)r=r.Fail;if(r.Children.has(n)){r=r.Children.get(n);for(const n of r.Output){let r=t;const i=this._wordChars.get(n);let o=i.length-1;for(;r>=0&&o>=0;)this._interferenceWords.has(s[r])||s[r]===i[o]&&o--,r--;o>=0||e.push({Word:n,Start:r+1,End:t+1})}}}}return e}Test(t){this._built||this.Build();let e=this._root;const r=Array.from(this.normalize(t));for(let t=0;t<r.length;t++){const s=r[t];if(!this._interferenceWords.has(s)){for(;e!==this._root&&!e.Children.has(s);)e=e.Fail;if(e.Children.has(s)&&(e=e.Children.get(s),e.Output.length>0))return!0}}return!1}Clear(){this._root={Children:new Map,Fail:null,Words:[],Output:[]},this._wordChars.clear(),this._built=!1}AddInterferenceWords(t){const e=[];for(const r of t){if(!r||0===r.length)continue;const t=Array.from(this.normalize(r));1===t.length?this._interferenceWords.add(t[0]):e.push(r)}return e}ClearInterferenceWords(){this._interferenceWords.clear()}GetInterferenceWordCount(){return this._interferenceWords.size}}class n extends r.Plugin{constructor(r,n){if(t.Override(n,{Replacement:"█",IgnoreCase:!1,CacheSize:1e3}),super(r,n),!Number.isInteger(n.CacheSize)||n.CacheSize<=0)throw new Error(`AuditorPlugin CacheSize 须为正整数 >>> ${n.CacheSize}`);this._acAutomaton=new s(n.IgnoreCase),this._sensitiveWords=new Set,this._acAutomaton.AddInterferenceWords(" \t\r\n".split("")),n.EnableCache?(this._testCache=new e.LRU(n.CacheSize),this._findCache=new e.LRU(n.CacheSize),this._replaceCache=new e.LRU(n.CacheSize)):(this._testCache=null,this._findCache=null,this._replaceCache=null),this.buildAutomaton()}Test(t){if(!t||0===t.length)return!1;if(this._testCache){const e=this._testCache.Get(t);if(null!==e)return e}const e=this._acAutomaton.Test(t);return this._testCache&&this._testCache.Set(t,e),e}Find(t){if(!t||0===t.length)return{Matched:!1,Words:[],Matches:[]};if(this._findCache){const e=this._findCache.Get(t);if(null!==e)return{Matched:e.Matched,Words:[...e.Words],Matches:e.Matches.map(t=>Object.assign({},t))}}const e=this._acAutomaton.Match(t),r=new Set;for(const t of e)r.add(t.Word);const s={Matched:e.length>0,Words:Array.from(r),Matches:e};return this._findCache&&this._findCache.Set(t,{Matched:s.Matched,Words:[...s.Words],Matches:s.Matches.map(t=>Object.assign({},t))}),s}Replace(t,e){var r;if(!t||0===t.length)return t;const s=JSON.stringify([t,null!=e?e:this.Cfg.Replacement]);if(this._replaceCache){const t=this._replaceCache.Get(s);if(null!==t)return t}const n=this.Find(t);if(!n.Matched)return null===(r=this._replaceCache)||void 0===r||r.Set(s,t),t;const i=Array.from(t),o=[...n.Matches].sort((t,e)=>t.Start-e.Start),h=[];for(const t of o){const e=h[h.length-1];e&&t.Start<e.end?e.end=Math.max(e.end,t.End):h.push({start:t.Start,end:t.End})}const a=null!=e?e:this.Cfg.Replacement;for(let t=h.length-1;t>=0;t--){const{start:e,end:r}=h[t];i.splice(e,r-e,a)}const c=i.join("");return this._replaceCache&&this._replaceCache.Set(s,c),c}InjectSensitiveWords(t){let e=!1;for(const r of t)r&&r.length>0&&!this._sensitiveWords.has(r)&&(this._sensitiveWords.add(r),e=!0);e&&(this.buildAutomaton(),this.clearCache())}InjectInterferenceWords(t){const e=this._acAutomaton.AddInterferenceWords(t);if(e.length>0){const t=this.Logger;for(const r of e)t.Warn(`干扰词须为单个字符,已忽略 >>> ${r}`)}this.clearCache()}GetSensitiveWordCount(){return this._sensitiveWords.size}GetInterferenceWordCount(){return this._acAutomaton.GetInterferenceWordCount()}ClearSensitiveWords(){this._sensitiveWords.clear(),this.buildAutomaton(),this.clearCache()}ClearInterferenceWords(){this._acAutomaton.ClearInterferenceWords(),this.clearCache()}buildAutomaton(){this._acAutomaton.Clear();const t=this._sensitiveWords;for(const e of t)this._acAutomaton.AddWord(e);this._acAutomaton.Build()}clearCache(){var t,e,r;null===(t=this._testCache)||void 0===t||t.Clear(),null===(e=this._findCache)||void 0===e||e.Clear(),null===(r=this._replaceCache)||void 0===r||r.Clear()}}exports.AuditorPlugin=n;
|
package/dist/auditor.min.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Override as t}from"@nebulaaa/dict";import{LRU as e}from"@nebulaaa/lru";import{Plugin as r}from"@nebulaaa/plugin";class
|
|
1
|
+
import{Override as t}from"@nebulaaa/dict";import{LRU as e}from"@nebulaaa/lru";import{Plugin as r}from"@nebulaaa/plugin";class s{constructor(t=!1){this._root={Children:new Map,Fail:null,Words:[],Output:[]},this._built=!1,this._interferenceWords=new Set,this._ignoreCase=t,this._wordChars=new Map}normalize(t){if(!this._ignoreCase)return t;let e="";for(const r of t){const t=r.codePointAt(0);e+=t>=65&&t<=90?String.fromCharCode(t+32):r}return e}AddWord(t){if(!t||0===t.length)return;let e=this._root;const r=Array.from(this.normalize(t));for(const t of r){let r=e.Children.get(t);r||(r={Children:new Map,Fail:null,Words:[],Output:[]},e.Children.set(t,r)),e=r}0===e.Words.length&&(e.Words.push(t),this._wordChars.set(t,r),this._built=!1)}Build(){if(this._built)return;const t=[];for(const e of this._root.Children.values())e.Fail=this._root,e.Output=[...e.Words],t.push(e);let e=0;for(;e<t.length;){const r=t[e++];for(const[e,s]of r.Children){t.push(s);let n=r.Fail;for(;n&&!n.Children.has(e);)n=n.Fail;s.Fail=n&&n.Children.get(e)||this._root,s.Output=[...s.Words,...s.Fail.Output]}}this._built=!0}Match(t){this._built||this.Build();const e=[];let r=this._root;const s=Array.from(this.normalize(t));for(let t=0;t<s.length;t++){const n=s[t];if(!this._interferenceWords.has(n)){for(;r!==this._root&&!r.Children.has(n);)r=r.Fail;if(r.Children.has(n)){r=r.Children.get(n);for(const n of r.Output){let r=t;const i=this._wordChars.get(n);let o=i.length-1;for(;r>=0&&o>=0;)this._interferenceWords.has(s[r])||s[r]===i[o]&&o--,r--;o>=0||e.push({Word:n,Start:r+1,End:t+1})}}}}return e}Test(t){this._built||this.Build();let e=this._root;const r=Array.from(this.normalize(t));for(let t=0;t<r.length;t++){const s=r[t];if(!this._interferenceWords.has(s)){for(;e!==this._root&&!e.Children.has(s);)e=e.Fail;if(e.Children.has(s)&&(e=e.Children.get(s),e.Output.length>0))return!0}}return!1}Clear(){this._root={Children:new Map,Fail:null,Words:[],Output:[]},this._wordChars.clear(),this._built=!1}AddInterferenceWords(t){const e=[];for(const r of t){if(!r||0===r.length)continue;const t=Array.from(this.normalize(r));1===t.length?this._interferenceWords.add(t[0]):e.push(r)}return e}ClearInterferenceWords(){this._interferenceWords.clear()}GetInterferenceWordCount(){return this._interferenceWords.size}}class n extends r{constructor(r,n){if(t(n,{Replacement:"█",IgnoreCase:!1,CacheSize:1e3}),super(r,n),!Number.isInteger(n.CacheSize)||n.CacheSize<=0)throw new Error(`AuditorPlugin CacheSize 须为正整数 >>> ${n.CacheSize}`);this._acAutomaton=new s(n.IgnoreCase),this._sensitiveWords=new Set,this._acAutomaton.AddInterferenceWords(" \t\r\n".split("")),n.EnableCache?(this._testCache=new e(n.CacheSize),this._findCache=new e(n.CacheSize),this._replaceCache=new e(n.CacheSize)):(this._testCache=null,this._findCache=null,this._replaceCache=null),this.buildAutomaton()}Test(t){if(!t||0===t.length)return!1;if(this._testCache){const e=this._testCache.Get(t);if(null!==e)return e}const e=this._acAutomaton.Test(t);return this._testCache&&this._testCache.Set(t,e),e}Find(t){if(!t||0===t.length)return{Matched:!1,Words:[],Matches:[]};if(this._findCache){const e=this._findCache.Get(t);if(null!==e)return{Matched:e.Matched,Words:[...e.Words],Matches:e.Matches.map(t=>Object.assign({},t))}}const e=this._acAutomaton.Match(t),r=new Set;for(const t of e)r.add(t.Word);const s={Matched:e.length>0,Words:Array.from(r),Matches:e};return this._findCache&&this._findCache.Set(t,{Matched:s.Matched,Words:[...s.Words],Matches:s.Matches.map(t=>Object.assign({},t))}),s}Replace(t,e){var r;if(!t||0===t.length)return t;const s=JSON.stringify([t,null!=e?e:this.Cfg.Replacement]);if(this._replaceCache){const t=this._replaceCache.Get(s);if(null!==t)return t}const n=this.Find(t);if(!n.Matched)return null===(r=this._replaceCache)||void 0===r||r.Set(s,t),t;const i=Array.from(t),o=[...n.Matches].sort((t,e)=>t.Start-e.Start),h=[];for(const t of o){const e=h[h.length-1];e&&t.Start<e.end?e.end=Math.max(e.end,t.End):h.push({start:t.Start,end:t.End})}const a=null!=e?e:this.Cfg.Replacement;for(let t=h.length-1;t>=0;t--){const{start:e,end:r}=h[t];i.splice(e,r-e,a)}const c=i.join("");return this._replaceCache&&this._replaceCache.Set(s,c),c}InjectSensitiveWords(t){let e=!1;for(const r of t)r&&r.length>0&&!this._sensitiveWords.has(r)&&(this._sensitiveWords.add(r),e=!0);e&&(this.buildAutomaton(),this.clearCache())}InjectInterferenceWords(t){const e=this._acAutomaton.AddInterferenceWords(t);if(e.length>0){const t=this.Logger;for(const r of e)t.Warn(`干扰词须为单个字符,已忽略 >>> ${r}`)}this.clearCache()}GetSensitiveWordCount(){return this._sensitiveWords.size}GetInterferenceWordCount(){return this._acAutomaton.GetInterferenceWordCount()}ClearSensitiveWords(){this._sensitiveWords.clear(),this.buildAutomaton(),this.clearCache()}ClearInterferenceWords(){this._acAutomaton.ClearInterferenceWords(),this.clearCache()}buildAutomaton(){this._acAutomaton.Clear();const t=this._sensitiveWords;for(const e of t)this._acAutomaton.AddWord(e);this._acAutomaton.Build()}clearCache(){var t,e,r;null===(t=this._testCache)||void 0===t||t.Clear(),null===(e=this._findCache)||void 0===e||e.Clear(),null===(r=this._replaceCache)||void 0===r||r.Clear()}}export{n as AuditorPlugin};
|
package/dist/auditor.mjs
CHANGED
|
@@ -14,6 +14,7 @@ class ACAutomaton {
|
|
|
14
14
|
this._built = false;
|
|
15
15
|
this._interferenceWords = new Set();
|
|
16
16
|
this._ignoreCase = ignoreCase;
|
|
17
|
+
this._wordChars = new Map();
|
|
17
18
|
}
|
|
18
19
|
/**
|
|
19
20
|
* 大小写归一(仅 ASCII 字母,逐码点等长替换,保证匹配位置与原始文本索引一致)
|
|
@@ -52,11 +53,13 @@ class ACAutomaton {
|
|
|
52
53
|
}
|
|
53
54
|
current = child;
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
// 归一后同一路径只保留首词:IgnoreCase 下 "abc"/"ABC" 共享路径,
|
|
57
|
+
// 变体重复入表会让 Match 对同一区间输出多条重复结果(下游命中计数虚高)
|
|
58
|
+
if (current.Words.length === 0) {
|
|
57
59
|
current.Words.push(word);
|
|
60
|
+
this._wordChars.set(word, chars);
|
|
61
|
+
this._built = false; // 标记需要重新构建 fail 指针
|
|
58
62
|
}
|
|
59
|
-
this._built = false; // 标记需要重新构建 fail 指针
|
|
60
63
|
}
|
|
61
64
|
/**
|
|
62
65
|
* 构建 fail 指针(使用 BFS)
|
|
@@ -119,8 +122,8 @@ class ACAutomaton {
|
|
|
119
122
|
for (const word of current.Output) {
|
|
120
123
|
// 计算在原始文本中的起始位置(跳过干扰字符)
|
|
121
124
|
let j = i;
|
|
122
|
-
//
|
|
123
|
-
const wordChars =
|
|
125
|
+
// 从当前位置向前查找匹配的起始位置(AddWord 已缓存归一后的码点数组)
|
|
126
|
+
const wordChars = this._wordChars.get(word);
|
|
124
127
|
let wordIndex = wordChars.length - 1;
|
|
125
128
|
// 从后向前匹配,找到原始文本中的位置
|
|
126
129
|
while (j >= 0 && wordIndex >= 0) {
|
|
@@ -133,6 +136,11 @@ class ACAutomaton {
|
|
|
133
136
|
}
|
|
134
137
|
j--;
|
|
135
138
|
}
|
|
139
|
+
// 回扫必须完整命中:前向 AC 已保证该词是当前位置的后缀(两侧对称跳过干扰字符),
|
|
140
|
+
// wordIndex >= 0 说明不变量被破坏(理论不可达),防御性跳过而非产出错误区间
|
|
141
|
+
if (wordIndex >= 0) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
136
144
|
results.push({
|
|
137
145
|
Word: word,
|
|
138
146
|
Start: j + 1,
|
|
@@ -182,25 +190,29 @@ class ACAutomaton {
|
|
|
182
190
|
Words: [],
|
|
183
191
|
Output: [],
|
|
184
192
|
};
|
|
193
|
+
this._wordChars.clear();
|
|
185
194
|
this._built = false;
|
|
186
195
|
}
|
|
187
196
|
/**
|
|
188
197
|
* 注入干扰词(仅单字符生效:匹配按单字符查表)
|
|
189
198
|
* @param words 干扰词列表
|
|
199
|
+
* @returns 被忽略的多字符词(由调用方决定告警方式,保持算法类无日志副作用)
|
|
190
200
|
*/
|
|
191
201
|
AddInterferenceWords(words) {
|
|
202
|
+
const ignored = [];
|
|
192
203
|
for (const word of words) {
|
|
193
204
|
if (!word || word.length === 0) {
|
|
194
205
|
continue;
|
|
195
206
|
}
|
|
196
207
|
const chars = Array.from(this.normalize(word));
|
|
197
208
|
if (chars.length !== 1) {
|
|
198
|
-
//
|
|
199
|
-
|
|
209
|
+
// 多字符干扰词在按字符匹配的查表中永不命中——返回给调用方显式告警而非静默失效
|
|
210
|
+
ignored.push(word);
|
|
200
211
|
continue;
|
|
201
212
|
}
|
|
202
213
|
this._interferenceWords.add(chars[0]);
|
|
203
214
|
}
|
|
215
|
+
return ignored;
|
|
204
216
|
}
|
|
205
217
|
/**
|
|
206
218
|
* 清空干扰词
|
|
@@ -227,14 +239,14 @@ class AuditorPlugin extends Plugin {
|
|
|
227
239
|
CacheSize: 1000,
|
|
228
240
|
});
|
|
229
241
|
super(deps, cfg);
|
|
242
|
+
// 缓存容量须为正整数:无论是否启用缓存都在构造期校验,配置错误不留到运行期
|
|
243
|
+
if (!Number.isInteger(cfg.CacheSize) || cfg.CacheSize <= 0) {
|
|
244
|
+
throw new Error(`AuditorPlugin CacheSize 须为正整数 >>> ${cfg.CacheSize}`);
|
|
245
|
+
}
|
|
230
246
|
this._acAutomaton = new ACAutomaton(cfg.IgnoreCase);
|
|
231
247
|
this._sensitiveWords = new Set();
|
|
232
248
|
this._acAutomaton.AddInterferenceWords(' \t\r\n'.split(''));
|
|
233
249
|
if (cfg.EnableCache) {
|
|
234
|
-
// 缓存容量须为正整数:非法配置构造期显式失败,错误信息带插件上下文便于定位
|
|
235
|
-
if (!Number.isInteger(cfg.CacheSize) || cfg.CacheSize <= 0) {
|
|
236
|
-
throw new Error(`AuditorPlugin CacheSize 须为正整数 >>> ${cfg.CacheSize}`);
|
|
237
|
-
}
|
|
238
250
|
this._testCache = new LRU(cfg.CacheSize);
|
|
239
251
|
this._findCache = new LRU(cfg.CacheSize);
|
|
240
252
|
this._replaceCache = new LRU(cfg.CacheSize);
|
|
@@ -351,16 +363,29 @@ class AuditorPlugin extends Plugin {
|
|
|
351
363
|
return replacedText;
|
|
352
364
|
}
|
|
353
365
|
InjectSensitiveWords(words) {
|
|
366
|
+
let added = false;
|
|
354
367
|
for (const word of words) {
|
|
355
|
-
if (word && word.length > 0) {
|
|
368
|
+
if (word && word.length > 0 && !this._sensitiveWords.has(word)) {
|
|
356
369
|
this._sensitiveWords.add(word);
|
|
370
|
+
added = true;
|
|
357
371
|
}
|
|
358
372
|
}
|
|
373
|
+
// 无新增词时词库未变:跳过自动机重建与缓存清理,重复注入不打断既有缓存
|
|
374
|
+
if (!added) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
359
377
|
this.buildAutomaton();
|
|
360
378
|
this.clearCache();
|
|
361
379
|
}
|
|
362
380
|
InjectInterferenceWords(words) {
|
|
363
|
-
this._acAutomaton.AddInterferenceWords(words);
|
|
381
|
+
const ignored = this._acAutomaton.AddInterferenceWords(words);
|
|
382
|
+
// 多字符干扰词永不命中:经插件日志显式告警(词库内容不落 console)
|
|
383
|
+
if (ignored.length > 0) {
|
|
384
|
+
const logger = this.Logger;
|
|
385
|
+
for (const word of ignored) {
|
|
386
|
+
logger.Warn(`干扰词须为单个字符,已忽略 >>> ${word}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
364
389
|
this.clearCache();
|
|
365
390
|
}
|
|
366
391
|
GetSensitiveWordCount() {
|
package/dist/package.json
CHANGED