twikoo-func 1.7.24 → 2.0.0-beta.2
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 +41 -4
- package/dist/index.d.mts +104 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +215 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +210 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +40 -22
- package/index.js +0 -1178
- package/utils/cap.js +0 -311
- package/utils/constants.js +0 -20
- package/utils/image.js +0 -376
- package/utils/import.js +0 -290
- package/utils/index.js +0 -512
- package/utils/lib.js +0 -79
- package/utils/logger.js +0 -22
- package/utils/notify.js +0 -309
- package/utils/spam.js +0 -241
package/utils/notify.js
DELETED
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
const { equalsMail, getAvatar, isValidEmail } = require('.')
|
|
2
|
-
const {
|
|
3
|
-
getHtmlToText,
|
|
4
|
-
getNodemailer,
|
|
5
|
-
getPushoo
|
|
6
|
-
} = require('./lib')
|
|
7
|
-
const htmlToText = getHtmlToText()
|
|
8
|
-
const pushoo = getPushoo()
|
|
9
|
-
const { RES_CODE } = require('./constants')
|
|
10
|
-
const logger = require('./logger')
|
|
11
|
-
|
|
12
|
-
// HTML 实体转义,防止用户可控字段(昵称、邮箱等)在邮件 HTML 模板中造成存储型 XSS
|
|
13
|
-
// 仅在渲染邮件时转义,不修改数据库的存储内容
|
|
14
|
-
function escapeHtml (str) {
|
|
15
|
-
if (typeof str !== 'string') return str
|
|
16
|
-
return str
|
|
17
|
-
.replace(/&/g, '&')
|
|
18
|
-
.replace(/</g, '<')
|
|
19
|
-
.replace(/>/g, '>')
|
|
20
|
-
.replace(/"/g, '"')
|
|
21
|
-
.replace(/'/g, ''')
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
let nodemailer
|
|
25
|
-
|
|
26
|
-
function lazilyGetNodemailer () {
|
|
27
|
-
return nodemailer ?? (nodemailer = getNodemailer())
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
let transporter
|
|
31
|
-
|
|
32
|
-
const fn = {
|
|
33
|
-
// 发送通知
|
|
34
|
-
async sendNotice (comment, config, getParentComment) {
|
|
35
|
-
if (comment.isSpam && config.NOTIFY_SPAM === 'false') return
|
|
36
|
-
await Promise.all([
|
|
37
|
-
fn.noticeMaster(comment, config),
|
|
38
|
-
fn.noticeReply(comment, config, getParentComment),
|
|
39
|
-
fn.noticePushoo(comment, config)
|
|
40
|
-
]).catch(err => {
|
|
41
|
-
logger.error('通知异常:', err)
|
|
42
|
-
})
|
|
43
|
-
},
|
|
44
|
-
// 初始化邮件插件
|
|
45
|
-
async initMailer ({ config, throwErr = false } = {}) {
|
|
46
|
-
try {
|
|
47
|
-
if (!config || !config.SMTP_USER || !config.SMTP_PASS) {
|
|
48
|
-
throw new Error('数据库配置不存在')
|
|
49
|
-
}
|
|
50
|
-
const transportConfig = {
|
|
51
|
-
auth: {
|
|
52
|
-
user: config.SMTP_USER,
|
|
53
|
-
pass: config.SMTP_PASS
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
if (config.SMTP_SERVICE) {
|
|
57
|
-
transportConfig.service = config.SMTP_SERVICE
|
|
58
|
-
} else if (config.SMTP_HOST) {
|
|
59
|
-
transportConfig.host = config.SMTP_HOST
|
|
60
|
-
transportConfig.port = parseInt(config.SMTP_PORT)
|
|
61
|
-
transportConfig.secure = config.SMTP_SECURE === 'true'
|
|
62
|
-
} else {
|
|
63
|
-
throw new Error('SMTP 服务器没有配置')
|
|
64
|
-
}
|
|
65
|
-
transporter = lazilyGetNodemailer().createTransport(transportConfig)
|
|
66
|
-
try {
|
|
67
|
-
const success = await transporter.verify()
|
|
68
|
-
if (success) logger.info('SMTP 邮箱配置正常')
|
|
69
|
-
} catch (error) {
|
|
70
|
-
throw new Error('SMTP 邮箱配置异常:' + error.message)
|
|
71
|
-
}
|
|
72
|
-
return true
|
|
73
|
-
} catch (e) {
|
|
74
|
-
if (throwErr) {
|
|
75
|
-
logger.error('邮件初始化异常:', e.message)
|
|
76
|
-
throw e
|
|
77
|
-
} else {
|
|
78
|
-
logger.warn('邮件初始化异常:', e.message)
|
|
79
|
-
}
|
|
80
|
-
return false
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
// 博主通知
|
|
84
|
-
async noticeMaster (comment, config) {
|
|
85
|
-
if (!transporter && !await fn.initMailer({ config })) {
|
|
86
|
-
logger.info('未配置邮箱或邮箱配置有误,不通知')
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
if (equalsMail(config.BLOGGER_EMAIL, comment.mail)) {
|
|
90
|
-
logger.info('博主本人评论,不发送通知给博主')
|
|
91
|
-
return
|
|
92
|
-
}
|
|
93
|
-
// 判断是否存在即时消息推送配置
|
|
94
|
-
const hasIMPushConfig = config.PUSHOO_CHANNEL && config.PUSHOO_TOKEN
|
|
95
|
-
if (hasIMPushConfig && config.SC_MAIL_NOTIFY !== 'true') {
|
|
96
|
-
logger.info('存在即时消息推送配置,默认不发送邮件给博主,您可以在管理面板修改此行为')
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
const SITE_NAME = config.SITE_NAME
|
|
100
|
-
const NICK = escapeHtml(comment.nick)
|
|
101
|
-
const IMG = getAvatar(comment, config)
|
|
102
|
-
const IP = comment.ip
|
|
103
|
-
const MAIL = escapeHtml(comment.mail)
|
|
104
|
-
const COMMENT = comment.comment
|
|
105
|
-
const SITE_URL = config.SITE_URL
|
|
106
|
-
const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
|
|
107
|
-
const emailSubject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}上有新评论了`
|
|
108
|
-
let emailContent
|
|
109
|
-
if (config.MAIL_TEMPLATE_ADMIN) {
|
|
110
|
-
emailContent = config.MAIL_TEMPLATE_ADMIN
|
|
111
|
-
.replace(/\${SITE_URL}/g, SITE_URL)
|
|
112
|
-
.replace(/\${SITE_NAME}/g, SITE_NAME)
|
|
113
|
-
.replace(/\${NICK}/g, NICK)
|
|
114
|
-
.replace(/\${IMG}/g, IMG)
|
|
115
|
-
.replace(/\${IP}/g, IP)
|
|
116
|
-
.replace(/\${MAIL}/g, MAIL)
|
|
117
|
-
.replace(/\${COMMENT}/g, COMMENT)
|
|
118
|
-
.replace(/\${POST_URL}/g, POST_URL)
|
|
119
|
-
} else {
|
|
120
|
-
emailContent = `
|
|
121
|
-
<div style="border-top:2px solid #12addb;box-shadow:0 1px 3px #aaaaaa;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;">
|
|
122
|
-
<h2 style="border-bottom:1px solid #dddddd;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;">
|
|
123
|
-
您在<a style="text-decoration:none;color: #12addb;" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>上的文章有了新的评论
|
|
124
|
-
</h2>
|
|
125
|
-
<p><strong>${NICK}</strong>回复说:</p>
|
|
126
|
-
<div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${COMMENT}</div>
|
|
127
|
-
<p>您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a><br></p>
|
|
128
|
-
</div>`
|
|
129
|
-
}
|
|
130
|
-
const toMail = config.BLOGGER_EMAIL || config.SENDER_EMAIL
|
|
131
|
-
if (!isValidEmail(toMail)) {
|
|
132
|
-
logger.warn('博主邮箱格式不合法,跳过发送博主通知:', toMail)
|
|
133
|
-
return
|
|
134
|
-
}
|
|
135
|
-
let sendResult
|
|
136
|
-
try {
|
|
137
|
-
sendResult = await transporter.sendMail({
|
|
138
|
-
from: `"${config.SENDER_NAME}" <${config.SENDER_EMAIL}>`,
|
|
139
|
-
to: toMail,
|
|
140
|
-
subject: emailSubject,
|
|
141
|
-
html: emailContent
|
|
142
|
-
})
|
|
143
|
-
} catch (e) {
|
|
144
|
-
sendResult = e
|
|
145
|
-
}
|
|
146
|
-
logger.log('博主通知结果:', sendResult)
|
|
147
|
-
return sendResult
|
|
148
|
-
},
|
|
149
|
-
// 即时消息通知
|
|
150
|
-
async noticePushoo (comment, config) {
|
|
151
|
-
if (!config.PUSHOO_CHANNEL || !config.PUSHOO_TOKEN) {
|
|
152
|
-
logger.info('没有配置 pushoo,放弃即时消息通知')
|
|
153
|
-
return
|
|
154
|
-
}
|
|
155
|
-
if (equalsMail(config.BLOGGER_EMAIL, comment.mail)) {
|
|
156
|
-
logger.info('博主本人评论,不发送通知给博主')
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
const pushContent = fn.getIMPushContent(comment, config)
|
|
160
|
-
const sendResult = await pushoo(config.PUSHOO_CHANNEL, {
|
|
161
|
-
token: config.PUSHOO_TOKEN,
|
|
162
|
-
title: pushContent.subject,
|
|
163
|
-
content: pushContent.content,
|
|
164
|
-
options: {
|
|
165
|
-
bark: {
|
|
166
|
-
url: pushContent.url
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
})
|
|
170
|
-
logger.info('即时消息通知结果:', sendResult)
|
|
171
|
-
},
|
|
172
|
-
// 即时消息推送内容获取
|
|
173
|
-
getIMPushContent (comment, config) {
|
|
174
|
-
const SITE_NAME = config.SITE_NAME
|
|
175
|
-
const NICK = escapeHtml(comment.nick)
|
|
176
|
-
const MAIL = escapeHtml(comment.mail)
|
|
177
|
-
const IP = comment.ip
|
|
178
|
-
const COMMENT = htmlToText(comment.comment)
|
|
179
|
-
const SITE_URL = config.SITE_URL
|
|
180
|
-
const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
|
|
181
|
-
const subject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}有新评论了`
|
|
182
|
-
const content = `评论人:${NICK} ([${MAIL}](mailto:${MAIL}))
|
|
183
|
-
|
|
184
|
-
评论人IP:${IP}
|
|
185
|
-
|
|
186
|
-
评论内容:${COMMENT}
|
|
187
|
-
|
|
188
|
-
原文链接:[${POST_URL}](${POST_URL})`
|
|
189
|
-
return {
|
|
190
|
-
subject,
|
|
191
|
-
content,
|
|
192
|
-
url: POST_URL
|
|
193
|
-
}
|
|
194
|
-
},
|
|
195
|
-
// 回复通知
|
|
196
|
-
async noticeReply (currentComment, config, getParentComment) {
|
|
197
|
-
if (!currentComment.pid) {
|
|
198
|
-
logger.info('无父级评论,不通知')
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
if (!transporter && !await fn.initMailer({ config })) {
|
|
202
|
-
logger.info('未配置邮箱或邮箱配置有误,不通知')
|
|
203
|
-
return
|
|
204
|
-
}
|
|
205
|
-
const parentComment = await getParentComment(currentComment)
|
|
206
|
-
if (equalsMail(config.BLOGGER_EMAIL, parentComment.mail)) {
|
|
207
|
-
logger.info('回复给博主,因为会发博主通知邮件,所以不再重复通知')
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
|
-
if (equalsMail(currentComment.mail, parentComment.mail)) {
|
|
211
|
-
logger.info('回复自己的评论,不邮件通知')
|
|
212
|
-
return
|
|
213
|
-
}
|
|
214
|
-
const PARENT_NICK = escapeHtml(parentComment.nick)
|
|
215
|
-
const IMG = getAvatar(currentComment, config)
|
|
216
|
-
const PARENT_IMG = getAvatar(parentComment, config)
|
|
217
|
-
const SITE_NAME = config.SITE_NAME
|
|
218
|
-
const NICK = escapeHtml(currentComment.nick)
|
|
219
|
-
const COMMENT = currentComment.comment
|
|
220
|
-
const PARENT_COMMENT = parentComment.comment
|
|
221
|
-
const POST_URL = escapeHtml(fn.appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id))
|
|
222
|
-
const SITE_URL = config.SITE_URL
|
|
223
|
-
const emailSubject = config.MAIL_SUBJECT || `${PARENT_NICK},您在『${SITE_NAME}』上的评论收到了回复`
|
|
224
|
-
let emailContent
|
|
225
|
-
if (config.MAIL_TEMPLATE) {
|
|
226
|
-
emailContent = config.MAIL_TEMPLATE
|
|
227
|
-
.replace(/\${IMG}/g, IMG)
|
|
228
|
-
.replace(/\${PARENT_IMG}/g, PARENT_IMG)
|
|
229
|
-
.replace(/\${SITE_URL}/g, SITE_URL)
|
|
230
|
-
.replace(/\${SITE_NAME}/g, SITE_NAME)
|
|
231
|
-
.replace(/\${PARENT_NICK}/g, PARENT_NICK)
|
|
232
|
-
.replace(/\${PARENT_COMMENT}/g, PARENT_COMMENT)
|
|
233
|
-
.replace(/\${NICK}/g, NICK)
|
|
234
|
-
.replace(/\${COMMENT}/g, COMMENT)
|
|
235
|
-
.replace(/\${POST_URL}/g, POST_URL)
|
|
236
|
-
} else {
|
|
237
|
-
emailContent = `
|
|
238
|
-
<div style="border-top:2px solid #12ADDB;box-shadow:0 1px 3px #AAAAAA;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;">
|
|
239
|
-
<h2 style="border-bottom:1px solid #dddddd;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;">
|
|
240
|
-
您在<a style="text-decoration:none;color: #12ADDB;" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>上的评论有了新的回复
|
|
241
|
-
</h2>
|
|
242
|
-
${PARENT_NICK} 同学,您曾发表评论:
|
|
243
|
-
<div style="padding:0 12px 0 12px;margin-top:18px">
|
|
244
|
-
<div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${PARENT_COMMENT}</div>
|
|
245
|
-
<p><strong>${NICK}</strong>回复说:</p>
|
|
246
|
-
<div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${COMMENT}</div>
|
|
247
|
-
<p>
|
|
248
|
-
您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a>,
|
|
249
|
-
欢迎再次光临<a style="text-decoration:none; color:#12addb" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>。<br>
|
|
250
|
-
</p>
|
|
251
|
-
</div>
|
|
252
|
-
</div>`
|
|
253
|
-
}
|
|
254
|
-
if (!isValidEmail(parentComment.mail)) {
|
|
255
|
-
logger.warn('回复通知邮箱格式不合法,跳过发送回复通知:', parentComment.mail)
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
let sendResult
|
|
259
|
-
try {
|
|
260
|
-
sendResult = await transporter.sendMail({
|
|
261
|
-
from: `"${config.SENDER_NAME}" <${config.SENDER_EMAIL}>`,
|
|
262
|
-
to: parentComment.mail,
|
|
263
|
-
subject: emailSubject,
|
|
264
|
-
html: emailContent
|
|
265
|
-
})
|
|
266
|
-
} catch (e) {
|
|
267
|
-
sendResult = e
|
|
268
|
-
}
|
|
269
|
-
logger.log('回复通知结果:', sendResult)
|
|
270
|
-
return sendResult
|
|
271
|
-
},
|
|
272
|
-
appendHashToUrl (url, hash) {
|
|
273
|
-
if (url.indexOf('#') === -1) {
|
|
274
|
-
return `${url}#${hash}`
|
|
275
|
-
} else {
|
|
276
|
-
return `${url.substring(0, url.indexOf('#'))}#${hash}`
|
|
277
|
-
}
|
|
278
|
-
},
|
|
279
|
-
async emailTest (event, config, isAdminUser) {
|
|
280
|
-
const res = {}
|
|
281
|
-
if (isAdminUser) {
|
|
282
|
-
try {
|
|
283
|
-
// 邮件测试前清除 transporter,保证读取的是最新的配置
|
|
284
|
-
transporter = null
|
|
285
|
-
await fn.initMailer({ config, throwErr: true })
|
|
286
|
-
const toMail = event.mail || config.BLOGGER_EMAIL || config.SENDER_EMAIL
|
|
287
|
-
if (!isValidEmail(toMail)) {
|
|
288
|
-
res.message = '邮箱格式不合法'
|
|
289
|
-
return res
|
|
290
|
-
}
|
|
291
|
-
const sendResult = await transporter.sendMail({
|
|
292
|
-
from: config.SENDER_EMAIL,
|
|
293
|
-
to: toMail,
|
|
294
|
-
subject: 'Twikoo 邮件通知测试邮件',
|
|
295
|
-
html: '如果您收到这封邮件,说明 Twikoo 邮件功能配置正确'
|
|
296
|
-
})
|
|
297
|
-
res.result = sendResult
|
|
298
|
-
} catch (e) {
|
|
299
|
-
res.message = e.message
|
|
300
|
-
}
|
|
301
|
-
} else {
|
|
302
|
-
res.code = RES_CODE.NEED_LOGIN
|
|
303
|
-
res.message = '请先登录'
|
|
304
|
-
}
|
|
305
|
-
return res
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
module.exports = fn
|
package/utils/spam.js
DELETED
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
const {
|
|
2
|
-
getAkismetClient,
|
|
3
|
-
getTencentcloudTms
|
|
4
|
-
} = require('./lib')
|
|
5
|
-
const {
|
|
6
|
-
equalsMail
|
|
7
|
-
} = require('.')
|
|
8
|
-
const AkismetClient = getAkismetClient()
|
|
9
|
-
|
|
10
|
-
const logger = require('./logger')
|
|
11
|
-
|
|
12
|
-
let tencentcloudTms
|
|
13
|
-
let generateTextPromise
|
|
14
|
-
|
|
15
|
-
function getTencentCloudTms () {
|
|
16
|
-
if (!tencentcloudTms) {
|
|
17
|
-
try {
|
|
18
|
-
tencentcloudTms = getTencentcloudTms()
|
|
19
|
-
} catch (e) {
|
|
20
|
-
logger.warn('加载 "tencentcloud-sdk-nodejs-tms" 失败', e)
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return tencentcloudTms
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function getGenerateText () {
|
|
27
|
-
if (!generateTextPromise) {
|
|
28
|
-
generateTextPromise = import('@xsai/generate-text')
|
|
29
|
-
.then(({ generateText }) => generateText)
|
|
30
|
-
.catch((error) => {
|
|
31
|
-
generateTextPromise = null
|
|
32
|
-
throw error
|
|
33
|
-
})
|
|
34
|
-
}
|
|
35
|
-
return generateTextPromise
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 提取json结构的函数
|
|
39
|
-
function extractJson (rawText) {
|
|
40
|
-
if (!rawText) return ''
|
|
41
|
-
const trimmed = rawText.trim()
|
|
42
|
-
const match = trimmed.match(/\{[\s\S]*\}/)
|
|
43
|
-
return match ? match[0] : trimmed
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// 移除多余的字符
|
|
47
|
-
function repairJson (jsonStr) {
|
|
48
|
-
if (!jsonStr) return ''
|
|
49
|
-
let cleaned = jsonStr.trim()
|
|
50
|
-
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1')
|
|
51
|
-
return cleaned
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// validateJson 返回的 error 信息会被拼接进 prompt
|
|
55
|
-
function validateJson (jsonStr) {
|
|
56
|
-
try {
|
|
57
|
-
const obj = JSON.parse(jsonStr)
|
|
58
|
-
if (typeof obj !== 'object' || obj === null) {
|
|
59
|
-
return { valid: false, error: 'Parsed JSON is not an object' }
|
|
60
|
-
}
|
|
61
|
-
if (!('spam' in obj)) {
|
|
62
|
-
return { valid: false, error: 'Missing required key "spam"' }
|
|
63
|
-
}
|
|
64
|
-
if (typeof obj.spam !== 'boolean') {
|
|
65
|
-
return { valid: false, error: 'Key "spam" must be a boolean value' }
|
|
66
|
-
}
|
|
67
|
-
return { valid: true, data: obj }
|
|
68
|
-
} catch (err) {
|
|
69
|
-
return { valid: false, error: `JSON Parse failed: ${err.message}` }
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 生成提示词的函数: system 为管理员指令,user 为待审核的评论数据
|
|
74
|
-
// errorMsg 用于重试,customPrompt 替代默认的 system 指令
|
|
75
|
-
function buildMessages (commentData, errorMsg = '', customPrompt = '') {
|
|
76
|
-
// 1. system 指令(管理员自定义或内置默认)
|
|
77
|
-
const systemContent = customPrompt || `You are a blog comment moderation assistant. Analyze ALL fields below and determine if this submission is spam or ham.
|
|
78
|
-
|
|
79
|
-
Spam includes ANY of the following in ANY field:
|
|
80
|
-
- Commercial ads, promotions, or buying/selling offers (e.g., "代开发票", "加微信", "兼职", "办证", "AI中转站").
|
|
81
|
-
- 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.
|
|
82
|
-
- Meaningless gibberish or spammy repetition (e.g., "顶顶顶", "111111", "asdfgh", "好" repeated).
|
|
83
|
-
- Abusive language, insults, or offensive Chinese slang.
|
|
84
|
-
- Suspicious links or SEO spam in the website field.
|
|
85
|
-
- Bot-like automated greetings.
|
|
86
|
-
|
|
87
|
-
Ham includes:
|
|
88
|
-
- Genuine questions, constructive feedback, technical discussions, or normal greetings in Chinese/English.
|
|
89
|
-
|
|
90
|
-
Strictly follow these rules:
|
|
91
|
-
1. If ANY field contains spam content, output exactly {"spam": true}.
|
|
92
|
-
2. If ALL fields are legitimate, output exactly {"spam": false}.
|
|
93
|
-
3. Do not include any explanations, introduction, punctuation, or extra spaces. Output only the JSON object.
|
|
94
|
-
|
|
95
|
-
Your response MUST be a single valid JSON object, like:
|
|
96
|
-
{"spam": true} or {"spam": false}`
|
|
97
|
-
|
|
98
|
-
// 2. user 数据(始终是待审核的评论内容)
|
|
99
|
-
let userContent = `Comment: ${commentData.comment}
|
|
100
|
-
Nickname: ${commentData.nick || ''}
|
|
101
|
-
Website: ${commentData.link || ''}`
|
|
102
|
-
|
|
103
|
-
// 3. 如果有错误信息,追加到用户消息末尾
|
|
104
|
-
if (errorMsg) {
|
|
105
|
-
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.`
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// 4. 返回 messages 数组
|
|
109
|
-
const finalPrompt = [
|
|
110
|
-
{ role: 'system', content: systemContent },
|
|
111
|
-
{ role: 'user', content: userContent }
|
|
112
|
-
]
|
|
113
|
-
logger.log('提示词是:', finalPrompt)
|
|
114
|
-
return finalPrompt
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function checkByLLM (comment, config) {
|
|
118
|
-
const maxRetries = Number(config.LLM_MAX_RETRIES) || 3
|
|
119
|
-
let lastError = ''
|
|
120
|
-
|
|
121
|
-
// 网络/Provider 异常或者格式校验不通过会进入重试逻辑
|
|
122
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
123
|
-
if (attempt > 1) {
|
|
124
|
-
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
125
|
-
}
|
|
126
|
-
try {
|
|
127
|
-
let messages
|
|
128
|
-
// 自定义提示词和内置提示词的 message 构建逻辑全交给 buildMessages 函数
|
|
129
|
-
if (config.LLM_SPAM_PROMPT) {
|
|
130
|
-
messages = buildMessages(comment, lastError, config.LLM_SPAM_PROMPT)
|
|
131
|
-
} else {
|
|
132
|
-
messages = buildMessages(comment, lastError)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const generateText = await getGenerateText()
|
|
136
|
-
const chatCompletion = await generateText({
|
|
137
|
-
apiKey: config.LLM_API_KEY,
|
|
138
|
-
baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1',
|
|
139
|
-
model: config.LLM_MODEL || 'deepseek-chat',
|
|
140
|
-
responseFormat: { type: 'json_object' },
|
|
141
|
-
messages
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
const rawText = chatCompletion.text || ''
|
|
145
|
-
|
|
146
|
-
const extracted = extractJson(rawText)
|
|
147
|
-
const repaired = repairJson(extracted)
|
|
148
|
-
const validation = validateJson(repaired)
|
|
149
|
-
|
|
150
|
-
// 校验通过返回,不通过继续循环
|
|
151
|
-
if (validation.valid) {
|
|
152
|
-
const isSpam = validation.data.spam
|
|
153
|
-
if (isSpam) {
|
|
154
|
-
logger.info(`LLM 判定为 SPAM (尝试第 ${attempt} 次): id="${comment.id}" nick="${comment.nick}"`)
|
|
155
|
-
} else {
|
|
156
|
-
logger.log(`LLM 判定为 HAM (尝试第 ${attempt} 次): id="${comment.id}" nick="${comment.nick}"`)
|
|
157
|
-
}
|
|
158
|
-
return isSpam
|
|
159
|
-
} else {
|
|
160
|
-
lastError = validation.error
|
|
161
|
-
logger.warn(`LLM 返回校验失败 (尝试第 ${attempt}/${maxRetries} 次), 错误: ${lastError}. 原始返回: "${rawText}"`)
|
|
162
|
-
}
|
|
163
|
-
} catch (error) {
|
|
164
|
-
lastError = error.message
|
|
165
|
-
logger.error(`LLM 请求异常 (尝试第 ${attempt}/${maxRetries} 次), 错误: ${lastError}`)
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
logger.error(`LLM 垃圾评论检测历经 ${maxRetries} 次尝试均失败,执行终极放行兜底(返回 false)`)
|
|
170
|
-
return false
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const fn = {
|
|
174
|
-
// 后垃圾评论检测
|
|
175
|
-
async postCheckSpam (comment, config) {
|
|
176
|
-
try {
|
|
177
|
-
let isSpam
|
|
178
|
-
if (comment.isSpam) {
|
|
179
|
-
// 预检测没过的,就不再检测了
|
|
180
|
-
isSpam = true
|
|
181
|
-
} else if (equalsMail(config.BLOGGER_EMAIL, comment.mail)) {
|
|
182
|
-
// 博主本人评论,不再检测了
|
|
183
|
-
isSpam = false
|
|
184
|
-
} else if (config.QCLOUD_SECRET_ID && config.QCLOUD_SECRET_KEY) {
|
|
185
|
-
// 腾讯云内容安全
|
|
186
|
-
const client = new (getTencentCloudTms().tms.v20201229.Client)({
|
|
187
|
-
credential: { secretId: config.QCLOUD_SECRET_ID, secretKey: config.QCLOUD_SECRET_KEY },
|
|
188
|
-
region: 'ap-shanghai',
|
|
189
|
-
profile: { httpProfile: { endpoint: 'tms.tencentcloudapi.com' } }
|
|
190
|
-
})
|
|
191
|
-
const textModerationParams = {
|
|
192
|
-
// 文档: https://cloud.tencent.com/document/api/1124/51860
|
|
193
|
-
Content: Buffer.from(comment.comment, 'utf8').toString('base64'),
|
|
194
|
-
DataId: comment.id,
|
|
195
|
-
User: { Nickname: comment.nick },
|
|
196
|
-
Device: { IP: comment.ip }
|
|
197
|
-
}
|
|
198
|
-
if (config.QCLOUD_CMS_BIZTYPE) {
|
|
199
|
-
textModerationParams.BizType = config.QCLOUD_CMS_BIZTYPE
|
|
200
|
-
}
|
|
201
|
-
logger.log('腾讯云请求参数:', textModerationParams)
|
|
202
|
-
const checkResult = await client.TextModeration(textModerationParams)
|
|
203
|
-
logger.log('腾讯云返回结果:', checkResult)
|
|
204
|
-
isSpam = checkResult.Suggestion !== 'Pass'
|
|
205
|
-
if (isSpam) {
|
|
206
|
-
logger.warn(`腾讯云判定不通过: id="${comment.id}" nick="${comment.nick}" suggestion="${checkResult.Suggestion}" label="${checkResult.Label}" subLabel="${checkResult.SubLabel}"`)
|
|
207
|
-
}
|
|
208
|
-
} else if (config.AKISMET_KEY) {
|
|
209
|
-
// Akismet
|
|
210
|
-
const akismetClient = new AkismetClient({
|
|
211
|
-
key: config.AKISMET_KEY,
|
|
212
|
-
blog: config.SITE_URL
|
|
213
|
-
})
|
|
214
|
-
const isValid = await akismetClient.verifyKey()
|
|
215
|
-
if (!isValid) {
|
|
216
|
-
logger.warn('Akismet key 不可用:', config.AKISMET_KEY)
|
|
217
|
-
return
|
|
218
|
-
}
|
|
219
|
-
isSpam = await akismetClient.checkSpam({
|
|
220
|
-
user_ip: comment.ip,
|
|
221
|
-
user_agent: comment.ua,
|
|
222
|
-
permalink: comment.href,
|
|
223
|
-
comment_type: comment.rid ? 'reply' : 'comment',
|
|
224
|
-
comment_author: comment.nick,
|
|
225
|
-
comment_author_email: comment.mail,
|
|
226
|
-
comment_author_url: comment.link,
|
|
227
|
-
comment_content: comment.comment
|
|
228
|
-
})
|
|
229
|
-
} else if (config.LLM_API_KEY) {
|
|
230
|
-
// 大语言模型检测
|
|
231
|
-
isSpam = await checkByLLM(comment, config)
|
|
232
|
-
}
|
|
233
|
-
logger.log('垃圾评论检测结果:', isSpam)
|
|
234
|
-
return isSpam
|
|
235
|
-
} catch (err) {
|
|
236
|
-
logger.error('垃圾评论检测异常:', err)
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
module.exports = fn
|