@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 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
- if (!current.Words.includes(word)) {
58
- // 同词重复添加幂等:自身词独立存放,output 由 build 重新生成
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 = Array.from(this.normalize(word));
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
- console.warn(`addInterferenceWords: 干扰词须为单个字符,已忽略 "${word}"`);
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
- interface IACMatchResult {
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
 
@@ -1 +1 @@
1
- "use strict";var t=require("@nebulaaa/dict"),e=require("@nebulaaa/lru"),r=require("@nebulaaa/plugin");class n{constructor(t=!1){this._root={Children:new Map,Fail:null,Words:[],Output:[]},this._built=!1,this._interferenceWords=new Set,this._ignoreCase=t}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}e.Words.includes(t)||e.Words.push(t),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,n]of r.Children){t.push(n);let s=r.Fail;for(;s&&!s.Children.has(e);)s=s.Fail;n.Fail=s&&s.Children.get(e)||this._root,n.Output=[...n.Words,...n.Fail.Output]}}this._built=!0}Match(t){this._built||this.Build();const e=[];let r=this._root;const n=Array.from(this.normalize(t));for(let t=0;t<n.length;t++){const s=n[t];if(!this._interferenceWords.has(s)){for(;r!==this._root&&!r.Children.has(s);)r=r.Fail;if(r.Children.has(s)){r=r.Children.get(s);for(const s of r.Output){let r=t;const i=Array.from(this.normalize(s));let o=i.length-1;for(;r>=0&&o>=0;)this._interferenceWords.has(n[r])||n[r]===i[o]&&o--,r--;e.push({Word:s,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 n=r[t];if(!this._interferenceWords.has(n)){for(;e!==this._root&&!e.Children.has(n);)e=e.Fail;if(e.Children.has(n)&&(e=e.Children.get(n),e.Output.length>0))return!0}}return!1}Clear(){this._root={Children:new Map,Fail:null,Words:[],Output:[]},this._built=!1}AddInterferenceWords(t){for(const e of t){if(!e||0===e.length)continue;const t=Array.from(this.normalize(e));1===t.length?this._interferenceWords.add(t[0]):console.warn(`addInterferenceWords: 干扰词须为单个字符,已忽略 "${e}"`)}}ClearInterferenceWords(){this._interferenceWords.clear()}GetInterferenceWordCount(){return this._interferenceWords.size}}class s extends r.Plugin{constructor(r,s){if(t.Override(s,{Replacement:"█",IgnoreCase:!1,CacheSize:1e3}),super(r,s),this._acAutomaton=new n(s.IgnoreCase),this._sensitiveWords=new Set,this._acAutomaton.AddInterferenceWords(" \t\r\n".split("")),s.EnableCache){if(!Number.isInteger(s.CacheSize)||s.CacheSize<=0)throw new Error(`AuditorPlugin CacheSize 须为正整数 >>> ${s.CacheSize}`);this._testCache=new e.LRU(s.CacheSize),this._findCache=new e.LRU(s.CacheSize),this._replaceCache=new e.LRU(s.CacheSize)}else 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 n={Matched:e.length>0,Words:Array.from(r),Matches:e};return this._findCache&&this._findCache.Set(t,{Matched:n.Matched,Words:[...n.Words],Matches:n.Matches.map(t=>Object.assign({},t))}),n}Replace(t,e){var r;if(!t||0===t.length)return t;const n=JSON.stringify([t,null!=e?e:this.Cfg.Replacement]);if(this._replaceCache){const t=this._replaceCache.Get(n);if(null!==t)return t}const s=this.Find(t);if(!s.Matched)return null===(r=this._replaceCache)||void 0===r||r.Set(n,t),t;const i=Array.from(t),o=[...s.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(n,c),c}InjectSensitiveWords(t){for(const e of t)e&&e.length>0&&this._sensitiveWords.add(e);this.buildAutomaton(),this.clearCache()}InjectInterferenceWords(t){this._acAutomaton.AddInterferenceWords(t),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=s;
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;
@@ -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 n{constructor(t=!1){this._root={Children:new Map,Fail:null,Words:[],Output:[]},this._built=!1,this._interferenceWords=new Set,this._ignoreCase=t}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}e.Words.includes(t)||e.Words.push(t),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,n]of r.Children){t.push(n);let s=r.Fail;for(;s&&!s.Children.has(e);)s=s.Fail;n.Fail=s&&s.Children.get(e)||this._root,n.Output=[...n.Words,...n.Fail.Output]}}this._built=!0}Match(t){this._built||this.Build();const e=[];let r=this._root;const n=Array.from(this.normalize(t));for(let t=0;t<n.length;t++){const s=n[t];if(!this._interferenceWords.has(s)){for(;r!==this._root&&!r.Children.has(s);)r=r.Fail;if(r.Children.has(s)){r=r.Children.get(s);for(const s of r.Output){let r=t;const i=Array.from(this.normalize(s));let o=i.length-1;for(;r>=0&&o>=0;)this._interferenceWords.has(n[r])||n[r]===i[o]&&o--,r--;e.push({Word:s,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 n=r[t];if(!this._interferenceWords.has(n)){for(;e!==this._root&&!e.Children.has(n);)e=e.Fail;if(e.Children.has(n)&&(e=e.Children.get(n),e.Output.length>0))return!0}}return!1}Clear(){this._root={Children:new Map,Fail:null,Words:[],Output:[]},this._built=!1}AddInterferenceWords(t){for(const e of t){if(!e||0===e.length)continue;const t=Array.from(this.normalize(e));1===t.length?this._interferenceWords.add(t[0]):console.warn(`addInterferenceWords: 干扰词须为单个字符,已忽略 "${e}"`)}}ClearInterferenceWords(){this._interferenceWords.clear()}GetInterferenceWordCount(){return this._interferenceWords.size}}class s extends r{constructor(r,s){if(t(s,{Replacement:"█",IgnoreCase:!1,CacheSize:1e3}),super(r,s),this._acAutomaton=new n(s.IgnoreCase),this._sensitiveWords=new Set,this._acAutomaton.AddInterferenceWords(" \t\r\n".split("")),s.EnableCache){if(!Number.isInteger(s.CacheSize)||s.CacheSize<=0)throw new Error(`AuditorPlugin CacheSize 须为正整数 >>> ${s.CacheSize}`);this._testCache=new e(s.CacheSize),this._findCache=new e(s.CacheSize),this._replaceCache=new e(s.CacheSize)}else 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 n={Matched:e.length>0,Words:Array.from(r),Matches:e};return this._findCache&&this._findCache.Set(t,{Matched:n.Matched,Words:[...n.Words],Matches:n.Matches.map(t=>Object.assign({},t))}),n}Replace(t,e){var r;if(!t||0===t.length)return t;const n=JSON.stringify([t,null!=e?e:this.Cfg.Replacement]);if(this._replaceCache){const t=this._replaceCache.Get(n);if(null!==t)return t}const s=this.Find(t);if(!s.Matched)return null===(r=this._replaceCache)||void 0===r||r.Set(n,t),t;const i=Array.from(t),o=[...s.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(n,c),c}InjectSensitiveWords(t){for(const e of t)e&&e.length>0&&this._sensitiveWords.add(e);this.buildAutomaton(),this.clearCache()}InjectInterferenceWords(t){this._acAutomaton.AddInterferenceWords(t),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{s as AuditorPlugin};
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
- if (!current.Words.includes(word)) {
56
- // 同词重复添加幂等:自身词独立存放,output 由 build 重新生成
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 = Array.from(this.normalize(word));
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
- console.warn(`addInterferenceWords: 干扰词须为单个字符,已忽略 "${word}"`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebulaaa/auditor",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "敏感词审计员插件",
5
5
  "author": "doooreyn",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebulaaa/auditor",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "敏感词审计员插件",
5
5
  "main": "./dist/auditor.cjs",
6
6
  "module": "./dist/auditor.mjs",