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 +41 -2
- package/package.json +4 -2
- package/utils/cap.js +311 -0
- package/utils/image.js +9 -6
- package/utils/index.js +16 -6
- package/utils/lib.js +8 -0
- package/utils/spam.js +136 -0
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.
|
|
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
|
-
//
|
|
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/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.
|
|
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
|
-
//
|
|
435
|
+
// Cap:有外部 endpoint 则下发;否则标记 builtin,前端走 twikoo 事件代理
|
|
430
436
|
if (config.CAPTCHA_PROVIDER === 'Cap') {
|
|
431
|
-
|
|
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
|