twikoo-func 1.7.13 → 1.7.14
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/package.json +2 -1
- package/utils/image.js +9 -6
- package/utils/lib.js +8 -0
- package/utils/spam.js +157 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twikoo-func",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.14",
|
|
4
4
|
"description": "A simple comment system.",
|
|
5
5
|
"author": "imaegoo <hello@imaegoo.com> (https://github.com/imaegoo)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"jsdom": "^16.4.0",
|
|
26
26
|
"marked": "^4.0.12",
|
|
27
27
|
"nodemailer": "^7.0.11",
|
|
28
|
+
"openai": "^6.45.0",
|
|
28
29
|
"pushoo": "latest",
|
|
29
30
|
"tencentcloud-sdk-nodejs": "^4.0.65",
|
|
30
31
|
"xml2js": "^0.6.0"
|
package/utils/image.js
CHANGED
|
@@ -242,13 +242,18 @@ const fn = {
|
|
|
242
242
|
// 构建对象 key
|
|
243
243
|
const prefix = config.S3_PATH_PREFIX ? config.S3_PATH_PREFIX.replace(/\/$/, '') + '/' : ''
|
|
244
244
|
const key = `${prefix}${Date.now()}-${fileName}`
|
|
245
|
+
const forcePathStyle = String(config.S3_FORCE_PATH_STYLE).trim().toLowerCase() !== 'false'
|
|
245
246
|
let endpoint
|
|
247
|
+
let s3Base
|
|
246
248
|
if (config.S3_ENDPOINT) {
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
+
// 自定义 S3 Endpoint
|
|
250
|
+
const endpointBase = config.S3_ENDPOINT.replace(/\/$/, '')
|
|
251
|
+
s3Base = forcePathStyle ? `${endpointBase}/${config.S3_BUCKET}` : endpointBase
|
|
252
|
+
endpoint = `${s3Base}/${key}`
|
|
249
253
|
} else {
|
|
250
254
|
// 标准 AWS S3:virtual-hosted-style URL
|
|
251
|
-
|
|
255
|
+
s3Base = `https://${config.S3_BUCKET}.s3.${region}.amazonaws.com`
|
|
256
|
+
endpoint = `${s3Base}/${key}`
|
|
252
257
|
}
|
|
253
258
|
const endpointUrl = new URL(endpoint)
|
|
254
259
|
const host = endpointUrl.host
|
|
@@ -305,10 +310,8 @@ const fn = {
|
|
|
305
310
|
let fileUrl
|
|
306
311
|
if (config.S3_CDN_URL) {
|
|
307
312
|
fileUrl = `${config.S3_CDN_URL.replace(/\/$/, '')}/${key}`
|
|
308
|
-
} else if (config.S3_ENDPOINT) {
|
|
309
|
-
fileUrl = `${config.S3_ENDPOINT.replace(/\/$/, '')}/${config.S3_BUCKET}/${key}`
|
|
310
313
|
} else {
|
|
311
|
-
fileUrl =
|
|
314
|
+
fileUrl = `${s3Base}/${key}`
|
|
312
315
|
}
|
|
313
316
|
res.data = { url: fileUrl }
|
|
314
317
|
},
|
package/utils/lib.js
CHANGED
|
@@ -71,5 +71,13 @@ module.exports = {
|
|
|
71
71
|
getXml2js () {
|
|
72
72
|
const xml2js = require('xml2js') // XML 解析
|
|
73
73
|
return xml2js
|
|
74
|
+
},
|
|
75
|
+
getOpenAIClient (config) {
|
|
76
|
+
const OpenAI = require('openai') // OpenAI 的 SDK,用于反垃圾
|
|
77
|
+
const openaiClient = new OpenAI({
|
|
78
|
+
apiKey: config.LLM_API_KEY,
|
|
79
|
+
baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com'
|
|
80
|
+
})
|
|
81
|
+
return openaiClient
|
|
74
82
|
}
|
|
75
83
|
}
|
package/utils/spam.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const {
|
|
2
2
|
getAkismetClient,
|
|
3
3
|
getCryptoJS,
|
|
4
|
-
getTencentcloud
|
|
4
|
+
getTencentcloud,
|
|
5
|
+
getOpenAIClient
|
|
5
6
|
} = require('./lib')
|
|
6
7
|
const {
|
|
7
8
|
equalsMail
|
|
@@ -12,6 +13,9 @@ const CryptoJS = getCryptoJS()
|
|
|
12
13
|
const logger = require('./logger')
|
|
13
14
|
|
|
14
15
|
let tencentcloud
|
|
16
|
+
let openai
|
|
17
|
+
let _openaiApiKey
|
|
18
|
+
let _openaiEndpoint
|
|
15
19
|
|
|
16
20
|
function getTencentCloud () {
|
|
17
21
|
if (!tencentcloud) {
|
|
@@ -24,6 +28,155 @@ function getTencentCloud () {
|
|
|
24
28
|
return tencentcloud
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
function getOpenAI (config) {
|
|
32
|
+
if (isConfigChanged(config)) {
|
|
33
|
+
_openaiApiKey = config.LLM_API_KEY || ''
|
|
34
|
+
_openaiEndpoint = config.LLM_API_ENDPOINT || ''
|
|
35
|
+
openai = getOpenAIClient(config)
|
|
36
|
+
}
|
|
37
|
+
return openai
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isConfigChanged (config) {
|
|
41
|
+
return !openai ||
|
|
42
|
+
_openaiApiKey !== (config.LLM_API_KEY || '') ||
|
|
43
|
+
_openaiEndpoint !== (config.LLM_API_ENDPOINT || '')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 提取json结构的函数
|
|
47
|
+
function extractJson (rawText) {
|
|
48
|
+
if (!rawText) return ''
|
|
49
|
+
const trimmed = rawText.trim()
|
|
50
|
+
const match = trimmed.match(/\{[\s\S]*\}/)
|
|
51
|
+
return match ? match[0] : trimmed
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 移除多余的字符
|
|
55
|
+
function repairJson (jsonStr) {
|
|
56
|
+
if (!jsonStr) return ''
|
|
57
|
+
let cleaned = jsonStr.trim()
|
|
58
|
+
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1')
|
|
59
|
+
return cleaned
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// validateJson 返回的 error 信息会被拼接进 prompt
|
|
63
|
+
function validateJson (jsonStr) {
|
|
64
|
+
try {
|
|
65
|
+
const obj = JSON.parse(jsonStr)
|
|
66
|
+
if (typeof obj !== 'object' || obj === null) {
|
|
67
|
+
return { valid: false, error: 'Parsed JSON is not an object' }
|
|
68
|
+
}
|
|
69
|
+
if (!('spam' in obj)) {
|
|
70
|
+
return { valid: false, error: 'Missing required key "spam"' }
|
|
71
|
+
}
|
|
72
|
+
if (typeof obj.spam !== 'boolean') {
|
|
73
|
+
return { valid: false, error: 'Key "spam" must be a boolean value' }
|
|
74
|
+
}
|
|
75
|
+
return { valid: true, data: obj }
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { valid: false, error: `JSON Parse failed: ${err.message}` }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 生成提示词的函数: system 为管理员指令,user 为待审核的评论数据
|
|
82
|
+
// errorMsg 用于重试,customPrompt 替代默认的 system 指令
|
|
83
|
+
function buildMessages (commentData, errorMsg = '', customPrompt = '') {
|
|
84
|
+
// 1. system 指令(管理员自定义或内置默认)
|
|
85
|
+
const systemContent = customPrompt || `You are a blog comment moderation assistant. Analyze ALL fields below and determine if this submission is spam or ham.
|
|
86
|
+
|
|
87
|
+
Spam includes ANY of the following in ANY field:
|
|
88
|
+
- Commercial ads, promotions, or buying/selling offers (e.g., "代开发票", "加微信", "兼职", "办证", "AI中转站").
|
|
89
|
+
- Special case: If the comment text is harmless, but the nickname is suspicious (e.g., contains ads or promotions) AND a website link is provided, treat it as SPAM.
|
|
90
|
+
- Meaningless gibberish or spammy repetition (e.g., "顶顶顶", "111111", "asdfgh", "好" repeated).
|
|
91
|
+
- Abusive language, insults, or offensive Chinese slang.
|
|
92
|
+
- Suspicious links or SEO spam in the website field.
|
|
93
|
+
- Bot-like automated greetings.
|
|
94
|
+
|
|
95
|
+
Ham includes:
|
|
96
|
+
- Genuine questions, constructive feedback, technical discussions, or normal greetings in Chinese/English.
|
|
97
|
+
|
|
98
|
+
Strictly follow these rules:
|
|
99
|
+
1. If ANY field contains spam content, output exactly {"spam": true}.
|
|
100
|
+
2. If ALL fields are legitimate, output exactly {"spam": false}.
|
|
101
|
+
3. Do not include any explanations, introduction, punctuation, or extra spaces. Output only the JSON object.
|
|
102
|
+
|
|
103
|
+
Your response MUST be a single valid JSON object, like:
|
|
104
|
+
{"spam": true} or {"spam": false}`
|
|
105
|
+
|
|
106
|
+
// 2. user 数据(始终是待审核的评论内容)
|
|
107
|
+
let userContent = `Comment: ${commentData.comment}
|
|
108
|
+
Nickname: ${commentData.nick || ''}
|
|
109
|
+
Website: ${commentData.link || ''}`
|
|
110
|
+
|
|
111
|
+
// 3. 如果有错误信息,追加到用户消息末尾
|
|
112
|
+
if (errorMsg) {
|
|
113
|
+
userContent += `\n\n[ERROR FROM PREVIOUS ATTEMPT]: Your last response failed verification with error: "${errorMsg}". Please correct your output format and make sure to return exactly valid JSON.`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. 返回 messages 数组
|
|
117
|
+
const finalPrompt = [
|
|
118
|
+
{ role: 'system', content: systemContent },
|
|
119
|
+
{ role: 'user', content: userContent }
|
|
120
|
+
]
|
|
121
|
+
logger.log('提示词是:', finalPrompt)
|
|
122
|
+
return finalPrompt
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function checkByLLM (comment, config) {
|
|
126
|
+
const maxRetries = Number(config.LLM_MAX_RETRIES) || 3
|
|
127
|
+
let lastError = ''
|
|
128
|
+
|
|
129
|
+
const openai = getOpenAI(config)
|
|
130
|
+
|
|
131
|
+
// 网络/Provider 异常或者格式校验不通过会进入重试逻辑
|
|
132
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
133
|
+
if (attempt > 1) {
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
let messages
|
|
138
|
+
// 自定义提示词和内置提示词的 message 构建逻辑全交给 buildMessages 函数
|
|
139
|
+
if (config.LLM_SPAM_PROMPT) {
|
|
140
|
+
messages = buildMessages(comment, lastError, config.LLM_SPAM_PROMPT)
|
|
141
|
+
} else {
|
|
142
|
+
messages = buildMessages(comment, lastError)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const chatCompletion = await openai.chat.completions.create({
|
|
146
|
+
model: config.LLM_MODEL || 'deepseek-v4-pro',
|
|
147
|
+
response_format: { type: 'json_object' },
|
|
148
|
+
messages
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const rawText = chatCompletion.choices[0].message.content || ''
|
|
152
|
+
|
|
153
|
+
const extracted = extractJson(rawText)
|
|
154
|
+
const repaired = repairJson(extracted)
|
|
155
|
+
const validation = validateJson(repaired)
|
|
156
|
+
|
|
157
|
+
// 校验通过返回,不通过继续循环
|
|
158
|
+
if (validation.valid) {
|
|
159
|
+
const isSpam = validation.data.spam
|
|
160
|
+
if (isSpam) {
|
|
161
|
+
logger.info(`LLM 判定为 SPAM (尝试第 ${attempt} 次): id="${comment.id}" nick="${comment.nick}"`)
|
|
162
|
+
} else {
|
|
163
|
+
logger.log(`LLM 判定为 HAM (尝试第 ${attempt} 次): id="${comment.id}" nick="${comment.nick}"`)
|
|
164
|
+
}
|
|
165
|
+
return isSpam
|
|
166
|
+
} else {
|
|
167
|
+
lastError = validation.error
|
|
168
|
+
logger.warn(`LLM 返回校验失败 (尝试第 ${attempt}/${maxRetries} 次), 错误: ${lastError}. 原始返回: "${rawText}"`)
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
lastError = error.message
|
|
172
|
+
logger.error(`LLM 请求异常 (尝试第 ${attempt}/${maxRetries} 次), 错误: ${lastError}`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
logger.error(`LLM 垃圾评论检测历经 ${maxRetries} 次尝试均失败,执行终极放行兜底(返回 false)`)
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
|
|
27
180
|
const fn = {
|
|
28
181
|
// 后垃圾评论检测
|
|
29
182
|
async postCheckSpam (comment, config) {
|
|
@@ -77,6 +230,9 @@ const fn = {
|
|
|
77
230
|
comment_author_url: comment.link,
|
|
78
231
|
comment_content: comment.comment
|
|
79
232
|
})
|
|
233
|
+
} else if (config.LLM_API_KEY) {
|
|
234
|
+
// 大语言模型检测
|
|
235
|
+
isSpam = await checkByLLM(comment, config)
|
|
80
236
|
}
|
|
81
237
|
logger.log('垃圾评论检测结果:', isSpam)
|
|
82
238
|
return isSpam
|