twikoo-func 1.7.12 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twikoo-func",
3
- "version": "1.7.12",
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
- // 兼容 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/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