@zhin.js/plugin-content-moderation 1.0.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/README.md +173 -0
- package/lib/bypass.d.ts +10 -0
- package/lib/bypass.d.ts.map +1 -0
- package/lib/bypass.js +27 -0
- package/lib/bypass.js.map +1 -0
- package/lib/config.d.ts +5 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +184 -0
- package/lib/config.js.map +1 -0
- package/lib/engine.d.ts +40 -0
- package/lib/engine.d.ts.map +1 -0
- package/lib/engine.js +170 -0
- package/lib/engine.js.map +1 -0
- package/lib/extract.d.ts +18 -0
- package/lib/extract.d.ts.map +1 -0
- package/lib/extract.js +121 -0
- package/lib/extract.js.map +1 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/lib/providers/builtin-lexicon.d.ts +15 -0
- package/lib/providers/builtin-lexicon.d.ts.map +1 -0
- package/lib/providers/builtin-lexicon.js +133 -0
- package/lib/providers/builtin-lexicon.js.map +1 -0
- package/lib/providers/http.d.ts +14 -0
- package/lib/providers/http.d.ts.map +1 -0
- package/lib/providers/http.js +191 -0
- package/lib/providers/http.js.map +1 -0
- package/lib/providers/local-lexicon.d.ts +28 -0
- package/lib/providers/local-lexicon.d.ts.map +1 -0
- package/lib/providers/local-lexicon.js +129 -0
- package/lib/providers/local-lexicon.js.map +1 -0
- package/lib/providers/registry.d.ts +8 -0
- package/lib/providers/registry.d.ts.map +1 -0
- package/lib/providers/registry.js +24 -0
- package/lib/providers/registry.js.map +1 -0
- package/lib/providers/types.d.ts +6 -0
- package/lib/providers/types.d.ts.map +1 -0
- package/lib/providers/types.js +2 -0
- package/lib/providers/types.js.map +1 -0
- package/lib/redact.d.ts +18 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/redact.js +102 -0
- package/lib/redact.js.map +1 -0
- package/lib/types.d.ts +105 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +26 -0
- package/lib/types.js.map +1 -0
- package/middlewares/inbound.js +97 -0
- package/middlewares/inbound.ts +112 -0
- package/middlewares/outbound.js +53 -0
- package/middlewares/outbound.ts +66 -0
- package/package.json +81 -0
- package/plugin.js +8 -0
- package/schema.json +240 -0
- package/src/bypass.ts +38 -0
- package/src/config.ts +203 -0
- package/src/engine.ts +220 -0
- package/src/extract.ts +155 -0
- package/src/index.ts +49 -0
- package/src/providers/builtin-lexicon.ts +149 -0
- package/src/providers/http.ts +225 -0
- package/src/providers/local-lexicon.ts +148 -0
- package/src/providers/registry.ts +37 -0
- package/src/providers/types.ts +6 -0
- package/src/redact.ts +141 -0
- package/src/types.ts +143 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Severity } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export type LexiconSeverity = Exclude<Severity, 'pass'>;
|
|
4
|
+
|
|
5
|
+
export interface LexiconEntry {
|
|
6
|
+
readonly word: string;
|
|
7
|
+
readonly severity: LexiconSeverity;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Built-in graded lexicon for IM bots (starter set).
|
|
12
|
+
* Users can extend / override via `words` / `wordFiles`; same word keeps the
|
|
13
|
+
* higher severity when merging.
|
|
14
|
+
*/
|
|
15
|
+
export const BUILTIN_LEXICON: readonly LexiconEntry[] = Object.freeze([
|
|
16
|
+
// —— low:轻度不当 / 骚扰语气 ——
|
|
17
|
+
entry('脑残', 'low'),
|
|
18
|
+
entry('智障', 'low'),
|
|
19
|
+
entry('废物', 'low'),
|
|
20
|
+
entry('垃圾人', 'low'),
|
|
21
|
+
entry('滚蛋', 'low'),
|
|
22
|
+
entry('去死', 'low'),
|
|
23
|
+
entry('找死', 'low'),
|
|
24
|
+
entry('闭嘴', 'low'),
|
|
25
|
+
entry('恶心', 'low'),
|
|
26
|
+
entry('变态', 'low'),
|
|
27
|
+
|
|
28
|
+
// —— medium:辱骂 / 粗口 ——
|
|
29
|
+
entry('傻逼', 'medium'),
|
|
30
|
+
entry('傻B', 'medium'),
|
|
31
|
+
entry('傻b', 'medium'),
|
|
32
|
+
entry('傻叉', 'medium'),
|
|
33
|
+
entry('操逼', 'medium'),
|
|
34
|
+
entry('草泥马', 'medium'),
|
|
35
|
+
entry('妈的', 'medium'),
|
|
36
|
+
entry('他妈的', 'medium'),
|
|
37
|
+
entry('你妈的', 'medium'),
|
|
38
|
+
entry('卧槽', 'medium'),
|
|
39
|
+
entry('日你', 'medium'),
|
|
40
|
+
entry('操你', 'medium'),
|
|
41
|
+
entry('操他', 'medium'),
|
|
42
|
+
entry('狗日的', 'medium'),
|
|
43
|
+
entry('混蛋', 'medium'),
|
|
44
|
+
entry('王八蛋', 'medium'),
|
|
45
|
+
entry('白痴', 'medium'),
|
|
46
|
+
entry('去死吧', 'medium'),
|
|
47
|
+
|
|
48
|
+
// —— high:色情 / 引流诈骗常见话术 ——
|
|
49
|
+
entry('约炮', 'high'),
|
|
50
|
+
entry('裸聊', 'high'),
|
|
51
|
+
entry('色情', 'high'),
|
|
52
|
+
entry('黄色网站', 'high'),
|
|
53
|
+
entry('看片加', 'high'),
|
|
54
|
+
entry('援交', 'high'),
|
|
55
|
+
entry('包养', 'high'),
|
|
56
|
+
entry('上门服务', 'high'),
|
|
57
|
+
entry('加微信领', 'high'),
|
|
58
|
+
entry('刷单返利', 'high'),
|
|
59
|
+
entry('刷单赚钱', 'high'),
|
|
60
|
+
entry('日赚千元', 'high'),
|
|
61
|
+
entry('稳赚不赔', 'high'),
|
|
62
|
+
entry('内幕消息', 'high'),
|
|
63
|
+
entry('代开发票', 'high'),
|
|
64
|
+
entry('办假证', 'high'),
|
|
65
|
+
entry('假钞', 'high'),
|
|
66
|
+
entry('色情服务', 'high'),
|
|
67
|
+
entry('一夜情', 'high'),
|
|
68
|
+
|
|
69
|
+
// —— high:中国政治敏感(常见审查词,可按业务在配置中覆盖/关闭内置)——
|
|
70
|
+
entry('台独', 'high'),
|
|
71
|
+
entry('藏独', 'high'),
|
|
72
|
+
entry('疆独', 'high'),
|
|
73
|
+
entry('港独', 'high'),
|
|
74
|
+
entry('分裂国家', 'high'),
|
|
75
|
+
entry('民族分裂', 'high'),
|
|
76
|
+
entry('法轮功', 'high'),
|
|
77
|
+
entry('法轮大法', 'high'),
|
|
78
|
+
entry('六四事件', 'high'),
|
|
79
|
+
entry('六四屠城', 'high'),
|
|
80
|
+
entry('天安门事件', 'high'),
|
|
81
|
+
entry('坦克人', 'high'),
|
|
82
|
+
entry('学潮动乱', 'high'),
|
|
83
|
+
entry('政治风波', 'high'),
|
|
84
|
+
entry('打倒共产党', 'high'),
|
|
85
|
+
entry('推翻共产党', 'high'),
|
|
86
|
+
entry('颠覆国家政权', 'high'),
|
|
87
|
+
entry('占中运动', 'high'),
|
|
88
|
+
entry('反送中', 'high'),
|
|
89
|
+
entry('香港独立', 'high'),
|
|
90
|
+
entry('台湾独立', 'high'),
|
|
91
|
+
entry('西藏独立', 'high'),
|
|
92
|
+
entry('新疆独立', 'high'),
|
|
93
|
+
entry('东突厥斯坦', 'high'),
|
|
94
|
+
entry('民运人士', 'high'),
|
|
95
|
+
entry('零八宪章', 'high'),
|
|
96
|
+
entry('煽动颠覆', 'high'),
|
|
97
|
+
entry('颜色革命', 'high'),
|
|
98
|
+
entry('大跃进饿死', 'high'),
|
|
99
|
+
entry('文革迫害', 'high'),
|
|
100
|
+
|
|
101
|
+
// —— critical:毒品 / 极端违法 / 煽动暴力颠覆 ——
|
|
102
|
+
entry('冰毒', 'critical'),
|
|
103
|
+
entry('海洛因', 'critical'),
|
|
104
|
+
entry('可卡因', 'critical'),
|
|
105
|
+
entry('大麻走私', 'critical'),
|
|
106
|
+
entry('售卖枪支', 'critical'),
|
|
107
|
+
entry('买卖枪支', 'critical'),
|
|
108
|
+
entry('制毒', 'critical'),
|
|
109
|
+
entry('贩毒', 'critical'),
|
|
110
|
+
entry('炸药制作', 'critical'),
|
|
111
|
+
entry('儿童色情', 'critical'),
|
|
112
|
+
entry('幼女援交', 'critical'),
|
|
113
|
+
entry('武装暴动', 'critical'),
|
|
114
|
+
entry('暴力推翻政权', 'critical'),
|
|
115
|
+
entry('刺杀国家领导人', 'critical'),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
function entry(word: string, severity: LexiconSeverity): LexiconEntry {
|
|
119
|
+
return Object.freeze({ word, severity });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Merge entries; duplicate words keep the higher severity. */
|
|
123
|
+
export function mergeLexiconEntries(
|
|
124
|
+
...lists: Array<readonly LexiconEntry[] | undefined>
|
|
125
|
+
): readonly LexiconEntry[] {
|
|
126
|
+
const map = new Map<string, LexiconSeverity>();
|
|
127
|
+
const rank: Record<LexiconSeverity, number> = {
|
|
128
|
+
low: 1,
|
|
129
|
+
medium: 2,
|
|
130
|
+
high: 3,
|
|
131
|
+
critical: 4,
|
|
132
|
+
};
|
|
133
|
+
for (const list of lists) {
|
|
134
|
+
if (!list) continue;
|
|
135
|
+
for (const item of list) {
|
|
136
|
+
const word = item.word.trim();
|
|
137
|
+
if (!word) continue;
|
|
138
|
+
const prev = map.get(word);
|
|
139
|
+
if (!prev || rank[item.severity] > rank[prev]) {
|
|
140
|
+
map.set(word, item.severity);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return Object.freeze(
|
|
145
|
+
[...map.entries()]
|
|
146
|
+
.map(([word, severity]) => Object.freeze({ word, severity }))
|
|
147
|
+
.sort((a, b) => b.word.length - a.word.length || a.word.localeCompare(b.word)),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type {
|
|
3
|
+
ExtractedImage,
|
|
4
|
+
HttpSourceConfig,
|
|
5
|
+
ProviderResult,
|
|
6
|
+
ScanInput,
|
|
7
|
+
Severity,
|
|
8
|
+
TextMatch,
|
|
9
|
+
} from '../types.js';
|
|
10
|
+
import { isSeverity } from '../types.js';
|
|
11
|
+
import type { ModerationProvider } from './types.js';
|
|
12
|
+
|
|
13
|
+
export interface HttpProviderDeps {
|
|
14
|
+
readonly fetch?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class HttpModerationProvider implements ModerationProvider {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly #config: HttpSourceConfig;
|
|
20
|
+
readonly #fetch: typeof fetch;
|
|
21
|
+
|
|
22
|
+
constructor(config: HttpSourceConfig, deps: HttpProviderDeps = {}) {
|
|
23
|
+
this.id = config.id;
|
|
24
|
+
this.#config = config;
|
|
25
|
+
this.#fetch = deps.fetch ?? globalThis.fetch.bind(globalThis);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async scan(input: ScanInput): Promise<ProviderResult> {
|
|
29
|
+
try {
|
|
30
|
+
const images = await prepareImages(input.images, this.#config.forceUpload, this.#fetch);
|
|
31
|
+
const useMultipart = images.some((img) => img.mode === 'upload');
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), this.#config.timeoutMs);
|
|
34
|
+
try {
|
|
35
|
+
const response = useMultipart
|
|
36
|
+
? await this.#postMultipart(input, images, controller.signal)
|
|
37
|
+
: await this.#postJson(input, images, controller.signal);
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error(`HTTP ${response.status}`);
|
|
40
|
+
}
|
|
41
|
+
const body = await response.json() as unknown;
|
|
42
|
+
return parseHttpResult(this.id, body);
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
sourceId: this.id,
|
|
49
|
+
severity: this.#config.onError === 'closed' ? 'critical' as const : 'pass' as const,
|
|
50
|
+
error: true,
|
|
51
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async #postJson(
|
|
57
|
+
input: ScanInput,
|
|
58
|
+
images: readonly PreparedImage[],
|
|
59
|
+
signal: AbortSignal,
|
|
60
|
+
): Promise<Response> {
|
|
61
|
+
const body = {
|
|
62
|
+
text: input.text,
|
|
63
|
+
images: images.map((img) => (
|
|
64
|
+
img.mode === 'url'
|
|
65
|
+
? { url: img.url }
|
|
66
|
+
: { base64: img.base64, mime: img.mime }
|
|
67
|
+
)),
|
|
68
|
+
direction: input.direction,
|
|
69
|
+
context: input.context,
|
|
70
|
+
};
|
|
71
|
+
return this.#fetch(this.#config.url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'content-type': 'application/json',
|
|
75
|
+
...this.#config.headers,
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
signal,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async #postMultipart(
|
|
83
|
+
input: ScanInput,
|
|
84
|
+
images: readonly PreparedImage[],
|
|
85
|
+
signal: AbortSignal,
|
|
86
|
+
): Promise<Response> {
|
|
87
|
+
const form = new FormData();
|
|
88
|
+
form.set('text', input.text);
|
|
89
|
+
form.set('direction', input.direction);
|
|
90
|
+
form.set('context', JSON.stringify(input.context));
|
|
91
|
+
for (const [i, img] of images.entries()) {
|
|
92
|
+
if (img.mode === 'url') {
|
|
93
|
+
form.append('images', JSON.stringify({ index: i, url: img.url }));
|
|
94
|
+
} else {
|
|
95
|
+
const bytes = Uint8Array.from(Buffer.from(img.base64, 'base64'));
|
|
96
|
+
const mime = img.mime || 'application/octet-stream';
|
|
97
|
+
form.append(
|
|
98
|
+
'images',
|
|
99
|
+
new Blob([bytes], { type: mime }),
|
|
100
|
+
`image-${i}.${extensionForMime(mime)}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return this.#fetch(this.#config.url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { ...this.#config.headers },
|
|
107
|
+
body: form,
|
|
108
|
+
signal,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type PreparedImage =
|
|
114
|
+
| { readonly mode: 'url'; readonly url: string }
|
|
115
|
+
| { readonly mode: 'upload'; readonly base64: string; readonly mime?: string };
|
|
116
|
+
|
|
117
|
+
async function prepareImages(
|
|
118
|
+
images: readonly ExtractedImage[],
|
|
119
|
+
forceUpload: boolean,
|
|
120
|
+
fetchImpl: typeof fetch,
|
|
121
|
+
): Promise<PreparedImage[]> {
|
|
122
|
+
const out: PreparedImage[] = [];
|
|
123
|
+
for (const image of images) {
|
|
124
|
+
if (!forceUpload && image.url && isPublicHttpUrl(image.url)) {
|
|
125
|
+
out.push({ mode: 'url', url: image.url });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (image.base64) {
|
|
129
|
+
out.push({ mode: 'upload', base64: image.base64, mime: image.mime });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (image.path) {
|
|
133
|
+
const buf = readFileSync(image.path);
|
|
134
|
+
out.push({
|
|
135
|
+
mode: 'upload',
|
|
136
|
+
base64: buf.toString('base64'),
|
|
137
|
+
mime: image.mime,
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (image.url) {
|
|
142
|
+
const downloaded = await downloadAsBase64(image.url, fetchImpl);
|
|
143
|
+
out.push({ mode: 'upload', base64: downloaded.base64, mime: downloaded.mime ?? image.mime });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// Opaque file refs: skip (cannot fetch without platform API)
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function downloadAsBase64(
|
|
152
|
+
url: string,
|
|
153
|
+
fetchImpl: typeof fetch,
|
|
154
|
+
): Promise<{ base64: string; mime?: string }> {
|
|
155
|
+
const response = await fetchImpl(url);
|
|
156
|
+
if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
|
|
157
|
+
const mime = response.headers.get('content-type') ?? undefined;
|
|
158
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
159
|
+
return { base64: buf.toString('base64'), mime };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function isPublicHttpUrl(url: string): boolean {
|
|
163
|
+
try {
|
|
164
|
+
const parsed = new URL(url);
|
|
165
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function parseHttpResult(sourceId: string, body: unknown): ProviderResult {
|
|
172
|
+
if (!body || typeof body !== 'object') {
|
|
173
|
+
throw new Error('invalid moderation response');
|
|
174
|
+
}
|
|
175
|
+
const record = body as Record<string, unknown>;
|
|
176
|
+
if (!isSeverity(record.severity)) {
|
|
177
|
+
throw new Error('invalid severity in moderation response');
|
|
178
|
+
}
|
|
179
|
+
const severity = record.severity as Severity;
|
|
180
|
+
const matches = parseMatches(record.matches);
|
|
181
|
+
const flaggedImageIndexes = parseFlaggedImages(record.images);
|
|
182
|
+
return Object.freeze({
|
|
183
|
+
sourceId,
|
|
184
|
+
severity,
|
|
185
|
+
...(matches.length ? { matches } : {}),
|
|
186
|
+
...(flaggedImageIndexes.length ? { flaggedImageIndexes } : {}),
|
|
187
|
+
...(typeof record.reason === 'string' ? { reason: record.reason } : {}),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseMatches(raw: unknown): readonly TextMatch[] {
|
|
192
|
+
if (!Array.isArray(raw)) return Object.freeze([]);
|
|
193
|
+
const out: TextMatch[] = [];
|
|
194
|
+
for (const item of raw) {
|
|
195
|
+
if (!item || typeof item !== 'object') continue;
|
|
196
|
+
const start = (item as { start?: unknown }).start;
|
|
197
|
+
const end = (item as { end?: unknown }).end;
|
|
198
|
+
if (typeof start !== 'number' || typeof end !== 'number') continue;
|
|
199
|
+
if (!(end > start) || start < 0) continue;
|
|
200
|
+
out.push({ start: Math.trunc(start), end: Math.trunc(end) });
|
|
201
|
+
}
|
|
202
|
+
return Object.freeze(out);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseFlaggedImages(raw: unknown): readonly number[] {
|
|
206
|
+
if (!Array.isArray(raw)) return Object.freeze([]);
|
|
207
|
+
const out: number[] = [];
|
|
208
|
+
for (const item of raw) {
|
|
209
|
+
if (!item || typeof item !== 'object') continue;
|
|
210
|
+
const index = (item as { index?: unknown }).index;
|
|
211
|
+
const flagged = (item as { flagged?: unknown }).flagged;
|
|
212
|
+
if (typeof index === 'number' && Number.isFinite(index) && flagged === true) {
|
|
213
|
+
out.push(Math.trunc(index));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return Object.freeze(out);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function extensionForMime(mime: string): string {
|
|
220
|
+
if (mime.includes('png')) return 'png';
|
|
221
|
+
if (mime.includes('jpeg') || mime.includes('jpg')) return 'jpg';
|
|
222
|
+
if (mime.includes('gif')) return 'gif';
|
|
223
|
+
if (mime.includes('webp')) return 'webp';
|
|
224
|
+
return 'bin';
|
|
225
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { maxSeverity, type LocalSourceConfig, type ProviderResult, type ScanInput, type TextMatch } from '../types.js';
|
|
4
|
+
import type { LexiconEntry, LexiconSeverity } from './builtin-lexicon.js';
|
|
5
|
+
import { BUILTIN_LEXICON, mergeLexiconEntries } from './builtin-lexicon.js';
|
|
6
|
+
import type { ModerationProvider } from './types.js';
|
|
7
|
+
|
|
8
|
+
export interface GradedMatch extends TextMatch {
|
|
9
|
+
readonly word: string;
|
|
10
|
+
readonly severity: LexiconSeverity;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class LocalLexiconProvider implements ModerationProvider {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly #entries: readonly LexiconEntry[];
|
|
16
|
+
readonly #onError: LocalSourceConfig['onError'];
|
|
17
|
+
|
|
18
|
+
constructor(config: LocalSourceConfig, cwd: string = process.cwd()) {
|
|
19
|
+
this.id = config.id;
|
|
20
|
+
this.#onError = config.onError;
|
|
21
|
+
this.#entries = Object.freeze(loadLexiconEntries(config, cwd));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async scan(input: ScanInput): Promise<ProviderResult> {
|
|
25
|
+
try {
|
|
26
|
+
const matches = findGradedMatches(input.text, this.#entries);
|
|
27
|
+
if (matches.length === 0) {
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
sourceId: this.id,
|
|
30
|
+
severity: 'pass' as const,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
let severity: LexiconSeverity = matches[0]!.severity;
|
|
34
|
+
for (const match of matches) {
|
|
35
|
+
severity = maxSeverity(severity, match.severity) as LexiconSeverity;
|
|
36
|
+
}
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
sourceId: this.id,
|
|
39
|
+
severity,
|
|
40
|
+
matches: Object.freeze(matches.map(({ start, end }) => ({ start, end }))),
|
|
41
|
+
reason: `lexicon hit (${matches.length}, max=${severity})`,
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
sourceId: this.id,
|
|
46
|
+
severity: this.#onError === 'closed' ? 'critical' as const : 'pass' as const,
|
|
47
|
+
error: true,
|
|
48
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadLexiconEntries(config: LocalSourceConfig, cwd: string): readonly LexiconEntry[] {
|
|
55
|
+
const custom: LexiconEntry[] = [];
|
|
56
|
+
for (const item of config.words) {
|
|
57
|
+
custom.push({ word: item.word, severity: item.severity });
|
|
58
|
+
}
|
|
59
|
+
for (const file of config.wordFiles) {
|
|
60
|
+
custom.push(...parseWordFile(readFileSync(resolve(cwd, file), 'utf8'), config.defaultSeverity));
|
|
61
|
+
}
|
|
62
|
+
const builtin = config.includeBuiltin ? BUILTIN_LEXICON : undefined;
|
|
63
|
+
return mergeLexiconEntries(builtin, custom);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @deprecated Use loadLexiconEntries; kept for tests that only need bare words. */
|
|
67
|
+
export function loadWords(config: LocalSourceConfig, cwd: string): string[] {
|
|
68
|
+
return loadLexiconEntries(config, cwd).map((e) => e.word);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function findMatches(text: string, words: readonly string[]): readonly TextMatch[] {
|
|
72
|
+
const entries = words.map((word) => ({ word, severity: 'high' as const }));
|
|
73
|
+
return findGradedMatches(text, entries).map(({ start, end }) => ({ start, end }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function findGradedMatches(
|
|
77
|
+
text: string,
|
|
78
|
+
entries: readonly LexiconEntry[],
|
|
79
|
+
): readonly GradedMatch[] {
|
|
80
|
+
if (!text || entries.length === 0) return Object.freeze([]);
|
|
81
|
+
const matches: GradedMatch[] = [];
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.word) continue;
|
|
84
|
+
let from = 0;
|
|
85
|
+
while (from < text.length) {
|
|
86
|
+
const idx = text.indexOf(entry.word, from);
|
|
87
|
+
if (idx < 0) break;
|
|
88
|
+
matches.push({
|
|
89
|
+
start: idx,
|
|
90
|
+
end: idx + entry.word.length,
|
|
91
|
+
word: entry.word,
|
|
92
|
+
severity: entry.severity,
|
|
93
|
+
});
|
|
94
|
+
from = idx + entry.word.length;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
matches.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
98
|
+
return Object.freeze(matches);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Word-file lines:
|
|
103
|
+
* - `# comment`
|
|
104
|
+
* - `word` → defaultSeverity
|
|
105
|
+
* - `severity:word` → graded(low|medium|high|critical)
|
|
106
|
+
* - `word|severity` → graded
|
|
107
|
+
*/
|
|
108
|
+
export function parseWordFile(
|
|
109
|
+
content: string,
|
|
110
|
+
defaultSeverity: LexiconSeverity,
|
|
111
|
+
): LexiconEntry[] {
|
|
112
|
+
const out: LexiconEntry[] = [];
|
|
113
|
+
for (const line of content.split(/\r?\n/u)) {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
116
|
+
const parsed = parseWordLine(trimmed, defaultSeverity);
|
|
117
|
+
if (parsed) out.push(parsed);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parseWordLine(
|
|
123
|
+
line: string,
|
|
124
|
+
defaultSeverity: LexiconSeverity,
|
|
125
|
+
): LexiconEntry | null {
|
|
126
|
+
const pipe = line.indexOf('|');
|
|
127
|
+
if (pipe > 0) {
|
|
128
|
+
const word = line.slice(0, pipe).trim();
|
|
129
|
+
const severity = parseSeverityToken(line.slice(pipe + 1).trim()) ?? defaultSeverity;
|
|
130
|
+
return word ? { word, severity } : null;
|
|
131
|
+
}
|
|
132
|
+
const colon = line.indexOf(':');
|
|
133
|
+
if (colon > 0) {
|
|
134
|
+
const maybeSeverity = parseSeverityToken(line.slice(0, colon).trim());
|
|
135
|
+
if (maybeSeverity) {
|
|
136
|
+
const word = line.slice(colon + 1).trim();
|
|
137
|
+
return word ? { word, severity: maybeSeverity } : null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { word: line, severity: defaultSeverity };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseSeverityToken(value: string): LexiconSeverity | null {
|
|
144
|
+
if (value === 'low' || value === 'medium' || value === 'high' || value === 'critical') {
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ModerationConfig, SourceConfig } from '../types.js';
|
|
2
|
+
import { HttpModerationProvider } from './http.js';
|
|
3
|
+
import { LocalLexiconProvider } from './local-lexicon.js';
|
|
4
|
+
import type { ModerationProvider } from './types.js';
|
|
5
|
+
|
|
6
|
+
export interface CreateProvidersOptions {
|
|
7
|
+
readonly cwd?: string;
|
|
8
|
+
readonly fetch?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createProviders(
|
|
12
|
+
config: ModerationConfig,
|
|
13
|
+
options: CreateProvidersOptions = {},
|
|
14
|
+
): readonly ModerationProvider[] {
|
|
15
|
+
const cwd = options.cwd ?? process.cwd();
|
|
16
|
+
const providers: ModerationProvider[] = [];
|
|
17
|
+
for (const source of config.sources) {
|
|
18
|
+
if (!source.enabled) continue;
|
|
19
|
+
const provider = createProvider(source, cwd, options.fetch);
|
|
20
|
+
if (provider) providers.push(provider);
|
|
21
|
+
}
|
|
22
|
+
return Object.freeze(providers);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createProvider(
|
|
26
|
+
source: SourceConfig,
|
|
27
|
+
cwd: string,
|
|
28
|
+
fetchImpl?: typeof fetch,
|
|
29
|
+
): ModerationProvider | null {
|
|
30
|
+
if (source.type === 'local') {
|
|
31
|
+
return new LocalLexiconProvider(source, cwd);
|
|
32
|
+
}
|
|
33
|
+
if (source.type === 'http') {
|
|
34
|
+
return new HttpModerationProvider(source, { fetch: fetchImpl });
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|