twikoo-func 1.7.13 → 1.7.15

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/index.js CHANGED
@@ -38,6 +38,13 @@ const {
38
38
  checkCommentOwnership,
39
39
  isValidEmail
40
40
  } = require('./utils')
41
+ const {
42
+ createCap,
43
+ tcbStorage,
44
+ createChallenge,
45
+ redeemChallenge,
46
+ isBuiltinCap
47
+ } = require('./utils/cap')
41
48
  const {
42
49
  jsonParse,
43
50
  commentImportValine,
@@ -151,6 +158,12 @@ exports.main = async (event, context) => {
151
158
  case 'COMMENT_DELETE_FOR_USER':
152
159
  res = await commentDeleteForUser(event)
153
160
  break
161
+ case 'CAP_CHALLENGE': // embedded Cap.js
162
+ res = await capChallenge()
163
+ break
164
+ case 'CAP_REDEEM':
165
+ res = await capRedeem(event)
166
+ break
154
167
  default:
155
168
  if (event.event) {
156
169
  res.code = RES_CODE.EVENT_NOT_EXIST
@@ -760,6 +773,14 @@ async function checkCaptcha (comment) {
760
773
  geeTestPassToken: comment.geeTestPassToken,
761
774
  geeTestGenTime: comment.geeTestGenTime
762
775
  })
776
+ } else if (provider === 'Cap' && isBuiltinCap(config)) {
777
+ if (!comment.capToken) {
778
+ throw new Error('验证码 token 缺失,请刷新页面重试')
779
+ }
780
+ await checkCapCaptcha({
781
+ capToken: comment.capToken,
782
+ cap: createCap(tcbStorage(db))
783
+ })
763
784
  } else if (provider === 'Cap' && config.CAP_API_ENDPOINT && config.CAP_SECRET_KEY) {
764
785
  if (!comment.capToken) {
765
786
  throw new Error('验证码 token 缺失,请刷新页面重试')
@@ -770,7 +791,7 @@ async function checkCaptcha (comment) {
770
791
  capApiEndpoint: config.CAP_API_ENDPOINT
771
792
  })
772
793
  } else if (provider === 'Cap') {
773
- throw new Error('Cap 验证码配置不完整,请联系管理员')
794
+ throw new Error('Cap 验证码配置不完整:内嵌模式无需额外配置,外部模式需填写 CAP_API_ENDPOINT 与 CAP_SECRET_KEY')
774
795
  } else if (provider) {
775
796
  throw new Error(`不支持的验证码类型: ${provider}`)
776
797
  }
@@ -1027,9 +1048,27 @@ function isRecursion (context) {
1027
1048
  return envObj.TCB_SOURCE.substr(-3, 3) === 'scf'
1028
1049
  }
1029
1050
 
1051
+ async function capChallenge () {
1052
+ if (!isBuiltinCap(config)) {
1053
+ return { code: RES_CODE.FAIL, message: '内嵌 Cap 未启用' }
1054
+ }
1055
+ const cap = createCap(tcbStorage(db))
1056
+ const data = await createChallenge(cap)
1057
+ return { code: RES_CODE.SUCCESS, ...data }
1058
+ }
1059
+
1060
+ async function capRedeem (event) {
1061
+ if (!isBuiltinCap(config)) {
1062
+ return { code: RES_CODE.FAIL, message: '内嵌 Cap 未启用' }
1063
+ }
1064
+ const cap = createCap(tcbStorage(db))
1065
+ const data = await redeemChallenge(cap, event)
1066
+ return { code: RES_CODE.SUCCESS, ...data }
1067
+ }
1068
+
1030
1069
  // 建立数据库 collections
1031
1070
  async function createCollections () {
1032
- const collections = ['comment', 'config', 'counter']
1071
+ const collections = ['comment', 'config', 'counter', 'cap_challenges', 'cap_tokens']
1033
1072
  const res = {}
1034
1073
  for (const collection of collections) {
1035
1074
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twikoo-func",
3
- "version": "1.7.13",
3
+ "version": "1.7.15",
4
4
  "description": "A simple comment system.",
5
5
  "author": "imaegoo <hello@imaegoo.com> (https://github.com/imaegoo)",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "homepage": "https://twikoo.js.org",
13
13
  "dependencies": {
14
+ "@cap.js/server": "^4.0.5",
14
15
  "@cloudbase/manager-node": "^3.9.0",
15
16
  "@cloudbase/node-sdk": "^2.5.0",
16
17
  "@imaegoo/node-ip2region": "^2.1.1",
@@ -27,6 +28,7 @@
27
28
  "nodemailer": "^7.0.11",
28
29
  "pushoo": "latest",
29
30
  "tencentcloud-sdk-nodejs": "^4.0.65",
30
- "xml2js": "^0.6.0"
31
+ "xml2js": "^0.6.0",
32
+ "xsai": "^0.2.2"
31
33
  }
32
34
  }
package/utils/cap.js ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Embedded Cap.js (https://capjs.js.org) — no external Cap Standalone required.
3
+ * Storage adapters follow the same hooks as @cap.js/server / ink-battles.
4
+ */
5
+
6
+ const Cap = require('@cap.js/server')
7
+ const logger = require('./logger')
8
+
9
+ const CHALLENGE_OPTS = {
10
+ challengeCount: 50,
11
+ challengeSize: 32,
12
+ challengeDifficulty: 4,
13
+ expiresMs: 600000 // 10 min
14
+ }
15
+
16
+ function memoryStorage () {
17
+ const challenges = new Map()
18
+ const tokens = new Map()
19
+ return {
20
+ challenges: {
21
+ store: async (token, data) => { challenges.set(token, data) },
22
+ read: async (token) => challenges.get(token) || null,
23
+ delete: async (token) => { challenges.delete(token) },
24
+ deleteExpired: async () => {
25
+ const now = Date.now()
26
+ for (const [k, v] of challenges) {
27
+ if (!v || v.expires < now) challenges.delete(k)
28
+ }
29
+ }
30
+ },
31
+ tokens: {
32
+ store: async (key, expires) => { tokens.set(key, expires) },
33
+ get: async (key) => {
34
+ const exp = tokens.get(key)
35
+ if (!exp) return null
36
+ if (exp < Date.now()) {
37
+ tokens.delete(key)
38
+ return null
39
+ }
40
+ return exp
41
+ },
42
+ delete: async (key) => { tokens.delete(key) },
43
+ deleteExpired: async () => {
44
+ const now = Date.now()
45
+ for (const [k, v] of tokens) {
46
+ if (v < now) tokens.delete(k)
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ /** MongoDB (native driver) — Vercel / self-hosted mongo */
54
+ function mongoStorage (db) {
55
+ const challenges = () => db.collection('cap_challenges')
56
+ const tokens = () => db.collection('cap_tokens')
57
+ let indexed = false
58
+ const ensureIndex = () => {
59
+ if (indexed) return
60
+ indexed = true
61
+ challenges().createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(() => {})
62
+ tokens().createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(() => {})
63
+ }
64
+ return {
65
+ challenges: {
66
+ store: async (token, data) => {
67
+ ensureIndex()
68
+ await challenges().updateOne(
69
+ { token },
70
+ { $set: { token, challenge: data.challenge, expiresAt: new Date(data.expires) } },
71
+ { upsert: true }
72
+ )
73
+ },
74
+ read: async (token) => {
75
+ const doc = await challenges().findOne({ token, expiresAt: { $gt: new Date() } })
76
+ return doc ? { challenge: doc.challenge, expires: doc.expiresAt.getTime() } : null
77
+ },
78
+ delete: async (token) => { await challenges().deleteOne({ token }) },
79
+ deleteExpired: async () => { await challenges().deleteMany({ expiresAt: { $lte: new Date() } }) }
80
+ },
81
+ tokens: {
82
+ store: async (key, expires) => {
83
+ ensureIndex()
84
+ await tokens().updateOne(
85
+ { key },
86
+ { $set: { key, expiresAt: new Date(expires) } },
87
+ { upsert: true }
88
+ )
89
+ },
90
+ get: async (key) => {
91
+ const doc = await tokens().findOne({ key, expiresAt: { $gt: new Date() } })
92
+ return doc ? doc.expiresAt.getTime() : null
93
+ },
94
+ delete: async (key) => { await tokens().deleteOne({ key }) },
95
+ deleteExpired: async () => { await tokens().deleteMany({ expiresAt: { $lte: new Date() } }) }
96
+ }
97
+ }
98
+ }
99
+
100
+ /** LokiJS — self-hosted default */
101
+ function lokiStorage (db) {
102
+ const getCol = (name) => {
103
+ let col = db.getCollection(name)
104
+ if (!col) col = db.addCollection(name, { unique: ['key'] })
105
+ return col
106
+ }
107
+ // challenges use token field; tokens use key field
108
+ const getChallengeCol = () => {
109
+ let col = db.getCollection('cap_challenges')
110
+ if (!col) col = db.addCollection('cap_challenges', { unique: ['token'] })
111
+ return col
112
+ }
113
+ const getTokenCol = () => getCol('cap_tokens')
114
+ return {
115
+ challenges: {
116
+ store: async (token, data) => {
117
+ const col = getChallengeCol()
118
+ const existing = col.findOne({ token })
119
+ const row = { token, challenge: data.challenge, expires: data.expires }
120
+ if (existing) {
121
+ Object.assign(existing, row)
122
+ col.update(existing)
123
+ } else col.insert(row)
124
+ },
125
+ read: async (token) => {
126
+ const doc = getChallengeCol().findOne({ token })
127
+ if (!doc || doc.expires < Date.now()) return null
128
+ return { challenge: doc.challenge, expires: doc.expires }
129
+ },
130
+ delete: async (token) => { getChallengeCol().findAndRemove({ token }) },
131
+ deleteExpired: async () => {
132
+ getChallengeCol().findAndRemove({ expires: { $lte: Date.now() } })
133
+ }
134
+ },
135
+ tokens: {
136
+ store: async (key, expires) => {
137
+ const col = getTokenCol()
138
+ const existing = col.findOne({ key })
139
+ const row = { key, expires }
140
+ if (existing) {
141
+ Object.assign(existing, row)
142
+ col.update(existing)
143
+ } else col.insert(row)
144
+ },
145
+ get: async (key) => {
146
+ const doc = getTokenCol().findOne({ key })
147
+ if (!doc || doc.expires < Date.now()) return null
148
+ return doc.expires
149
+ },
150
+ delete: async (key) => { getTokenCol().findAndRemove({ key }) },
151
+ deleteExpired: async () => {
152
+ getTokenCol().findAndRemove({ expires: { $lte: Date.now() } })
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ /** CloudBase / TCB database */
159
+ function tcbStorage (db) {
160
+ const _ = db.command
161
+ return {
162
+ challenges: {
163
+ store: async (token, data) => {
164
+ const col = db.collection('cap_challenges')
165
+ try {
166
+ await col.doc(token).set({
167
+ token,
168
+ challenge: data.challenge,
169
+ expires: data.expires
170
+ })
171
+ } catch (e) {
172
+ // doc may exist
173
+ await col.doc(token).update({
174
+ challenge: data.challenge,
175
+ expires: data.expires
176
+ })
177
+ }
178
+ },
179
+ read: async (token) => {
180
+ try {
181
+ const res = await db.collection('cap_challenges').doc(token).get()
182
+ const doc = res.data && res.data[0]
183
+ if (!doc || doc.expires < Date.now()) return null
184
+ return { challenge: doc.challenge, expires: doc.expires }
185
+ } catch (e) {
186
+ return null
187
+ }
188
+ },
189
+ delete: async (token) => {
190
+ try { await db.collection('cap_challenges').doc(token).remove() } catch (e) {}
191
+ },
192
+ deleteExpired: async () => {
193
+ try {
194
+ await db.collection('cap_challenges').where({ expires: _.lte(Date.now()) }).remove()
195
+ } catch (e) {}
196
+ }
197
+ },
198
+ tokens: {
199
+ store: async (key, expires) => {
200
+ // CloudBase doc id cannot contain ':'
201
+ const id = key.replace(/:/g, '_')
202
+ try {
203
+ await db.collection('cap_tokens').doc(id).set({ key, expires })
204
+ } catch (e) {
205
+ await db.collection('cap_tokens').doc(id).update({ key, expires })
206
+ }
207
+ },
208
+ get: async (key) => {
209
+ try {
210
+ const id = key.replace(/:/g, '_')
211
+ const res = await db.collection('cap_tokens').doc(id).get()
212
+ const doc = res.data && res.data[0]
213
+ if (!doc || doc.expires < Date.now()) return null
214
+ return doc.expires
215
+ } catch (e) {
216
+ return null
217
+ }
218
+ },
219
+ delete: async (key) => {
220
+ try {
221
+ const id = key.replace(/:/g, '_')
222
+ await db.collection('cap_tokens').doc(id).remove()
223
+ } catch (e) {}
224
+ },
225
+ deleteExpired: async () => {
226
+ try {
227
+ await db.collection('cap_tokens').where({ expires: _.lte(Date.now()) }).remove()
228
+ } catch (e) {}
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * EdgeOne Blob-style store: expects { get(key), set(key, value), del(key) }
236
+ * value is JSON-serializable.
237
+ */
238
+ function kvStorage (kv) {
239
+ return {
240
+ challenges: {
241
+ store: async (token, data) => { await kv.set(`cap:c:${token}`, data) },
242
+ read: async (token) => {
243
+ const data = await kv.get(`cap:c:${token}`)
244
+ if (!data || data.expires < Date.now()) return null
245
+ return data
246
+ },
247
+ delete: async (token) => { await kv.del(`cap:c:${token}`) },
248
+ deleteExpired: async () => {}
249
+ },
250
+ tokens: {
251
+ store: async (key, expires) => { await kv.set(`cap:t:${key}`, { expires }) },
252
+ get: async (key) => {
253
+ const data = await kv.get(`cap:t:${key}`)
254
+ if (!data || data.expires < Date.now()) return null
255
+ return data.expires
256
+ },
257
+ delete: async (key) => { await kv.del(`cap:t:${key}`) },
258
+ deleteExpired: async () => {}
259
+ }
260
+ }
261
+ }
262
+
263
+ function createCap (storage) {
264
+ return new Cap({
265
+ noFSState: true,
266
+ storage: storage || memoryStorage()
267
+ })
268
+ }
269
+
270
+ async function createChallenge (cap) {
271
+ return cap.createChallenge(CHALLENGE_OPTS)
272
+ }
273
+
274
+ async function redeemChallenge (cap, body) {
275
+ const token = body && body.token
276
+ const solutions = body && body.solutions
277
+ if (!token || !solutions || !Array.isArray(solutions)) {
278
+ return { success: false, error: 'Missing token or solutions' }
279
+ }
280
+ return cap.redeemChallenge({ token, solutions })
281
+ }
282
+
283
+ async function validateToken (cap, token) {
284
+ if (!token) return false
285
+ try {
286
+ const { success } = await cap.validateToken(token)
287
+ return !!success
288
+ } catch (e) {
289
+ logger.error('Cap validateToken failed:', e)
290
+ return false
291
+ }
292
+ }
293
+
294
+ /** CAPTCHA_PROVIDER=Cap and no CAP_API_ENDPOINT → use embedded Cap */
295
+ function isBuiltinCap (config) {
296
+ return config && config.CAPTCHA_PROVIDER === 'Cap' && !config.CAP_API_ENDPOINT
297
+ }
298
+
299
+ module.exports = {
300
+ createCap,
301
+ memoryStorage,
302
+ mongoStorage,
303
+ lokiStorage,
304
+ tcbStorage,
305
+ kvStorage,
306
+ createChallenge,
307
+ redeemChallenge,
308
+ validateToken,
309
+ isBuiltinCap,
310
+ CHALLENGE_OPTS
311
+ }
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
- // 兼容 R2
248
- endpoint = `${config.S3_ENDPOINT.replace(/\/$/, '')}/${config.S3_BUCKET}/${key}`
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
- endpoint = `https://${config.S3_BUCKET}.s3.${region}.amazonaws.com/${key}`
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 = `https://${config.S3_BUCKET}.s3.${region}.amazonaws.com/${key}`
314
+ fileUrl = `${s3Base}/${key}`
312
315
  }
313
316
  res.data = { url: fileUrl }
314
317
  },
package/utils/index.js CHANGED
@@ -268,7 +268,7 @@ const fn = {
268
268
  if (qqApiKey) {
269
269
  headers.Authorization = `Bearer ${qqApiKey}`
270
270
  }
271
- const result = await axios.get(`https://v1.nsuuu.com/api/qqname?qq=${qqNum}`, { headers })
271
+ const result = await axios.get(`https://v1.tqq.me/v1/qqname?qq=${qqNum}`, { headers })
272
272
  if (result.data?.code === 200 && result.data?.data?.nick) {
273
273
  return result.data.data.nick
274
274
  }
@@ -366,11 +366,17 @@ const fn = {
366
366
  throw new Error('极验验证码检测失败: ' + e.message)
367
367
  }
368
368
  },
369
- async checkCapCaptcha ({ capToken, capSecretKey, capApiEndpoint }) {
369
+ async checkCapCaptcha ({ capToken, capSecretKey, capApiEndpoint, cap }) {
370
370
  try {
371
- // 移除末尾的斜杠,避免双斜杠
371
+ // 内嵌 Cap:直接 validateToken,无需外部 Standalone
372
+ if (cap) {
373
+ const { validateToken } = require('./cap')
374
+ const ok = await validateToken(cap, capToken)
375
+ if (!ok) throw new Error('验证码错误')
376
+ return
377
+ }
378
+ // 外部 Cap Standalone:HTTP siteverify
372
379
  const endpoint = capApiEndpoint.replace(/\/$/, '')
373
- // Cap 的 siteverify 端点
374
380
  const url = `${endpoint}/siteverify`
375
381
  logger.log('Cap验证码验证URL:', url)
376
382
  logger.log('Cap验证码验证参数:', { secret: capSecretKey ? '***' : undefined, response: capToken.substring(0, 20) + '...' })
@@ -426,9 +432,13 @@ const fn = {
426
432
  baseConfig.GEETEST_CAPTCHA_ID = config.GEETEST_CAPTCHA_ID
427
433
  }
428
434
 
429
- // 仅在明确指定使用 Cap 时下发 Cap api endpoint
435
+ // Cap:有外部 endpoint 则下发;否则标记 builtin,前端走 twikoo 事件代理
430
436
  if (config.CAPTCHA_PROVIDER === 'Cap') {
431
- baseConfig.CAP_API_ENDPOINT = config.CAP_API_ENDPOINT
437
+ if (config.CAP_API_ENDPOINT) {
438
+ baseConfig.CAP_API_ENDPOINT = config.CAP_API_ENDPOINT
439
+ } else {
440
+ baseConfig.CAP_BUILTIN = true
441
+ }
432
442
  }
433
443
 
434
444
  return {
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 { createXSClient } = require('xsai') // xsai SDK
77
+ const openaiClient = createXSClient({
78
+ apiKey: config.LLM_API_KEY,
79
+ baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1'
80
+ })
81
+ return openaiClient
74
82
  }
75
83
  }
package/utils/spam.js CHANGED
@@ -24,6 +24,139 @@ function getTencentCloud () {
24
24
  return tencentcloud
25
25
  }
26
26
 
27
+ // 提取json结构的函数
28
+ function extractJson (rawText) {
29
+ if (!rawText) return ''
30
+ const trimmed = rawText.trim()
31
+ const match = trimmed.match(/\{[\s\S]*\}/)
32
+ return match ? match[0] : trimmed
33
+ }
34
+
35
+ // 移除多余的字符
36
+ function repairJson (jsonStr) {
37
+ if (!jsonStr) return ''
38
+ let cleaned = jsonStr.trim()
39
+ cleaned = cleaned.replace(/,\s*([}\]])/g, '$1')
40
+ return cleaned
41
+ }
42
+
43
+ // validateJson 返回的 error 信息会被拼接进 prompt
44
+ function validateJson (jsonStr) {
45
+ try {
46
+ const obj = JSON.parse(jsonStr)
47
+ if (typeof obj !== 'object' || obj === null) {
48
+ return { valid: false, error: 'Parsed JSON is not an object' }
49
+ }
50
+ if (!('spam' in obj)) {
51
+ return { valid: false, error: 'Missing required key "spam"' }
52
+ }
53
+ if (typeof obj.spam !== 'boolean') {
54
+ return { valid: false, error: 'Key "spam" must be a boolean value' }
55
+ }
56
+ return { valid: true, data: obj }
57
+ } catch (err) {
58
+ return { valid: false, error: `JSON Parse failed: ${err.message}` }
59
+ }
60
+ }
61
+
62
+ // 生成提示词的函数: system 为管理员指令,user 为待审核的评论数据
63
+ // errorMsg 用于重试,customPrompt 替代默认的 system 指令
64
+ function buildMessages (commentData, errorMsg = '', customPrompt = '') {
65
+ // 1. system 指令(管理员自定义或内置默认)
66
+ const systemContent = customPrompt || `You are a blog comment moderation assistant. Analyze ALL fields below and determine if this submission is spam or ham.
67
+
68
+ Spam includes ANY of the following in ANY field:
69
+ - Commercial ads, promotions, or buying/selling offers (e.g., "代开发票", "加微信", "兼职", "办证", "AI中转站").
70
+ - 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.
71
+ - Meaningless gibberish or spammy repetition (e.g., "顶顶顶", "111111", "asdfgh", "好" repeated).
72
+ - Abusive language, insults, or offensive Chinese slang.
73
+ - Suspicious links or SEO spam in the website field.
74
+ - Bot-like automated greetings.
75
+
76
+ Ham includes:
77
+ - Genuine questions, constructive feedback, technical discussions, or normal greetings in Chinese/English.
78
+
79
+ Strictly follow these rules:
80
+ 1. If ANY field contains spam content, output exactly {"spam": true}.
81
+ 2. If ALL fields are legitimate, output exactly {"spam": false}.
82
+ 3. Do not include any explanations, introduction, punctuation, or extra spaces. Output only the JSON object.
83
+
84
+ Your response MUST be a single valid JSON object, like:
85
+ {"spam": true} or {"spam": false}`
86
+
87
+ // 2. user 数据(始终是待审核的评论内容)
88
+ let userContent = `Comment: ${commentData.comment}
89
+ Nickname: ${commentData.nick || ''}
90
+ Website: ${commentData.link || ''}`
91
+
92
+ // 3. 如果有错误信息,追加到用户消息末尾
93
+ if (errorMsg) {
94
+ 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.`
95
+ }
96
+
97
+ // 4. 返回 messages 数组
98
+ const finalPrompt = [
99
+ { role: 'system', content: systemContent },
100
+ { role: 'user', content: userContent }
101
+ ]
102
+ logger.log('提示词是:', finalPrompt)
103
+ return finalPrompt
104
+ }
105
+
106
+ async function checkByLLM (comment, config) {
107
+ const maxRetries = Number(config.LLM_MAX_RETRIES) || 3
108
+ let lastError = ''
109
+
110
+ // 网络/Provider 异常或者格式校验不通过会进入重试逻辑
111
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
112
+ if (attempt > 1) {
113
+ await new Promise(resolve => setTimeout(resolve, 1000))
114
+ }
115
+ try {
116
+ let messages
117
+ // 自定义提示词和内置提示词的 message 构建逻辑全交给 buildMessages 函数
118
+ if (config.LLM_SPAM_PROMPT) {
119
+ messages = buildMessages(comment, lastError, config.LLM_SPAM_PROMPT)
120
+ } else {
121
+ messages = buildMessages(comment, lastError)
122
+ }
123
+
124
+ const { generateText } = require('xsai')
125
+ const chatCompletion = await generateText({
126
+ model: config.LLM_MODEL || 'deepseek-chat',
127
+ responseFormat: { type: 'json_object' },
128
+ messages
129
+ })
130
+
131
+ const rawText = chatCompletion.text || ''
132
+
133
+ const extracted = extractJson(rawText)
134
+ const repaired = repairJson(extracted)
135
+ const validation = validateJson(repaired)
136
+
137
+ // 校验通过返回,不通过继续循环
138
+ if (validation.valid) {
139
+ const isSpam = validation.data.spam
140
+ if (isSpam) {
141
+ logger.info(`LLM 判定为 SPAM (尝试第 ${attempt} 次): id="${comment.id}" nick="${comment.nick}"`)
142
+ } else {
143
+ logger.log(`LLM 判定为 HAM (尝试第 ${attempt} 次): id="${comment.id}" nick="${comment.nick}"`)
144
+ }
145
+ return isSpam
146
+ } else {
147
+ lastError = validation.error
148
+ logger.warn(`LLM 返回校验失败 (尝试第 ${attempt}/${maxRetries} 次), 错误: ${lastError}. 原始返回: "${rawText}"`)
149
+ }
150
+ } catch (error) {
151
+ lastError = error.message
152
+ logger.error(`LLM 请求异常 (尝试第 ${attempt}/${maxRetries} 次), 错误: ${lastError}`)
153
+ }
154
+ }
155
+
156
+ logger.error(`LLM 垃圾评论检测历经 ${maxRetries} 次尝试均失败,执行终极放行兜底(返回 false)`)
157
+ return false
158
+ }
159
+
27
160
  const fn = {
28
161
  // 后垃圾评论检测
29
162
  async postCheckSpam (comment, config) {
@@ -77,6 +210,9 @@ const fn = {
77
210
  comment_author_url: comment.link,
78
211
  comment_content: comment.comment
79
212
  })
213
+ } else if (config.LLM_API_KEY) {
214
+ // 大语言模型检测
215
+ isSpam = await checkByLLM(comment, config)
80
216
  }
81
217
  logger.log('垃圾评论检测结果:', isSpam)
82
218
  return isSpam