Files

464 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { getImagePath } from './upload.js'
/**
* 内容审核服务 - 支持多厂商API适配
*/
// 厂商默认配置(统一定义,其他地方引用)
export const MODERATION_PROVIDERS = {
nsfwdet: {
name: 'NSFW Detector',
apiUrl: 'https://nsfwdet.com/api/v1/detect-nsfw',
apiKey: 'nsfw_2f7ab4f1d743d69ee242eec932b19671', // 厂商开放的默认 API Key
threshold: 0.5
},
elysiatools: {
name: 'Elysia Tools',
uploadUrl: 'https://elysiatools.com/upload/nsfw-image-detector',
apiUrl: 'https://elysiatools.com/zh/api/tools/nsfw-image-detector'
// 不需要 API Key 和阈值配置,使用 isSafe 字段判断
},
'nsfw_detector': {
name: 'nsfw_detector',
apiUrl: '', // 需在设置中自行配置
apiKey: '', // 如需认证需自行配置
threshold: 0.8 // 违规阈值,默认80%,nsfw值超过此阈值判定为违规
}
}
// 获取默认的内容安全配置
export function getDefaultContentSafetyConfig() {
return {
enabled: false,
provider: 'elysiatools', // 默认使用免费的 elysiatools
autoBlacklistIp: false, // 是否自动将违规 IP 加入黑名单
providers: Object.fromEntries(
Object.entries(MODERATION_PROVIDERS).map(([key, config]) => [
key,
{ ...config }
])
)
}
}
// 厂商适配器注册表
const providerAdapters = {
/**
* nsfwdet.com 适配器
* API: https://nsfwdet.com/api/v1/detect-nsfw
* Request: multipart/form-data, image file
* Response: { result: { normal: 0.96, nsfw: 0.04 }, code: 0 }
*/
nsfwdet: {
name: MODERATION_PROVIDERS.nsfwdet.name,
defaultApiUrl: MODERATION_PROVIDERS.nsfwdet.apiUrl,
defaultApiKey: MODERATION_PROVIDERS.nsfwdet.apiKey,
defaultThreshold: MODERATION_PROVIDERS.nsfwdet.threshold,
/**
* 调用API进行检测
* @param {Buffer} imageBuffer - 图片二进制数据
* @param {string} filename - 文件名
* @param {object} config - 厂商配置
* @returns {Promise<object>} - { success, isNsfw, score, rawResult, error }
*/
async detect(imageBuffer, filename, config) {
try {
const apiUrl = config.apiUrl || this.defaultApiUrl
const apiKey = config.apiKey || this.defaultApiKey
// 构建 FormData(使用 Node.js 原生支持的方式)
const FormData = (await import('form-data')).default
const formData = new FormData()
formData.append('image', imageBuffer, {
filename: filename,
contentType: getMimeType(filename)
})
// 构建请求头
const headers = {
...formData.getHeaders()
}
// 添加 API Key 请求头
if (apiKey) {
headers['X-API-Key'] = apiKey
}
// 使用 undici 或 node-fetch 兼容的方式发送请求
// form-data 库需要转换为 Buffer 才能正确与 fetch 配合使用
const formBuffer = formData.getBuffer()
// 发送请求
const response = await fetch(apiUrl, {
method: 'POST',
body: formBuffer,
headers: headers
})
if (!response.ok) {
return {
success: false,
error: `API请求失败: ${response.status} ${response.statusText}`
}
}
const result = await response.json()
// 解析响应
if (result.code !== 0) {
return {
success: false,
error: `API返回错误: code=${result.code}`
}
}
const nsfwScore = result.result?.nsfw || 0
const threshold = config.threshold || this.defaultThreshold
return {
success: true,
isNsfw: nsfwScore >= threshold,
score: nsfwScore,
threshold: threshold,
rawResult: result
}
} catch (error) {
return {
success: false,
error: error.message || '检测请求失败'
}
}
}
},
/**
* elysiatools.com 适配器
* 流程:1. 先上传图片到 upload 接口 2. 用返回的 filePath 调用检测接口
* Upload: https://elysiatools.com/upload/nsfw-image-detector
* API: https://elysiatools.com/zh/api/tools/nsfw-image-detector
* Response: { data: { isSafe: true/false, ... } }
*/
elysiatools: {
name: MODERATION_PROVIDERS.elysiatools.name,
defaultUploadUrl: MODERATION_PROVIDERS.elysiatools.uploadUrl,
defaultApiUrl: MODERATION_PROVIDERS.elysiatools.apiUrl,
/**
* 调用API进行检测
* @param {Buffer} imageBuffer - 图片二进制数据
* @param {string} filename - 文件名
* @param {object} config - 厂商配置
* @returns {Promise<object>} - { success, isNsfw, score, rawResult, error }
*/
async detect(imageBuffer, filename, config) {
try {
const uploadUrl = config.uploadUrl || this.defaultUploadUrl
const apiUrl = config.apiUrl || this.defaultApiUrl
const FormData = (await import('form-data')).default
// 第一步:上传图片
const uploadFormData = new FormData()
uploadFormData.append('file', imageBuffer, {
filename: filename,
contentType: getMimeType(filename)
})
const uploadHeaders = {
...uploadFormData.getHeaders()
}
const uploadBuffer = uploadFormData.getBuffer()
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: uploadBuffer,
headers: uploadHeaders
})
if (!uploadResponse.ok) {
return {
success: false,
error: `上传失败: ${uploadResponse.status} ${uploadResponse.statusText}`
}
}
const uploadResult = await uploadResponse.json()
if (!uploadResult.filePath) {
return {
success: false,
error: '上传响应中缺少 filePath'
}
}
// 第二步:调用检测接口(使用 JSON 格式)
const detectResponse = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
imageFile: uploadResult.filePath,
sensitivity: 0.5,
analysisMode: 'auto'
})
})
if (!detectResponse.ok) {
return {
success: false,
error: `检测失败: ${detectResponse.status} ${detectResponse.statusText}`
}
}
const result = await detectResponse.json()
// 解析响应 - 使用 isSafe 字段判断
if (!result.data) {
return {
success: false,
error: 'API返回数据格式错误'
}
}
const isSafe = result.data.data.isSafe === true
const confidence = result.data.confidence || 0
return {
success: true,
isNsfw: !isSafe,
score: isSafe ? 0 : (100 - confidence) / 100, // 转换为 0-1 的分数
rawResult: result
}
} catch (error) {
return {
success: false,
error: error.message || '检测请求失败'
}
}
}
},
/**
* nsfw_detector 适配器(自建服务)
* API: 需在设置中自行配置
* Request: multipart/form-data, file 字段
* Response: { code: 200, msg: "success", data: { sfw: 0.0014, nsfw: 0.9986, is_nsfw: true } }
*/
'nsfw_detector': {
name: MODERATION_PROVIDERS['nsfw_detector'].name,
defaultApiUrl: MODERATION_PROVIDERS['nsfw_detector'].apiUrl,
defaultApiKey: MODERATION_PROVIDERS['nsfw_detector'].apiKey,
/**
* 调用API进行检测
* @param {Buffer} imageBuffer - 图片二进制数据
* @param {string} filename - 文件名
* @param {object} config - 厂商配置
* @returns {Promise<object>} - { success, isNsfw, score, rawResult, error }
*/
async detect(imageBuffer, filename, config) {
try {
const apiUrl = config.apiUrl || this.defaultApiUrl
const apiKey = config.apiKey || this.defaultApiKey
// 检查 API URL 是否已配置
if (!apiUrl) {
return {
success: false,
error: 'nsfw_detector 服务 API 地址未配置,请在设置中配置'
}
}
// 构建 FormData
const FormData = (await import('form-data')).default
const formData = new FormData()
formData.append('file', imageBuffer, {
filename: filename,
contentType: getMimeType(filename)
})
// 构建请求头
const headers = {
...formData.getHeaders(),
'Accept': 'application/json, text/plain, */*'
}
// 添加 Bearer Token 认证(如果配置了 API Key)
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
// 发送请求
const formBuffer = formData.getBuffer()
const response = await fetch(apiUrl, {
method: 'POST',
body: formBuffer,
headers: headers
})
if (!response.ok) {
return {
success: false,
error: `API请求失败: ${response.status} ${response.statusText}`
}
}
const result = await response.json()
// 解析响应
if (result.status !== 'success') {
return {
success: false,
error: `API返回错误: ${JSON.stringify(result) || 'unknown error'}`
}
}
// 使用配置的阈值判断是否违规(默认80%)
const nsfwScore = result.result?.nsfw || 0
const threshold = config.threshold !== undefined ? config.threshold : 0.8
const isNsfw = nsfwScore >= threshold
return {
success: true,
isNsfw: isNsfw,
score: nsfwScore,
threshold: threshold,
rawResult: result
}
} catch (error) {
return {
success: false,
error: error.message || '检测请求失败'
}
}
}
}
}
/**
* 根据文件名获取 MIME 类型
*/
function getMimeType(filename) {
const ext = filename.split('.').pop()?.toLowerCase()
const mimeTypes = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'avif': 'image/avif',
'svg': 'image/svg+xml',
'bmp': 'image/bmp',
'ico': 'image/x-icon',
'tiff': 'image/tiff',
'tif': 'image/tiff'
}
return mimeTypes[ext] || 'application/octet-stream'
}
/**
* 执行内容审核
* @param {string} imageId - 图片ID
* @param {string} filename - 文件名
* @param {object} contentSafetyConfig - 内容安全配置
* @returns {Promise<object>} - 审核结果
*/
export async function moderateImage(imageId, filename, contentSafetyConfig) {
if (!contentSafetyConfig?.enabled) {
return {
success: true,
skipped: true,
reason: '内容安全检测未启用'
}
}
const providerKey = contentSafetyConfig.provider
// 优先使用用户配置,如果不存在则使用默认配置
let providerConfig = contentSafetyConfig.providers?.[providerKey]
// 如果用户配置不存在,使用默认配置
if (!providerConfig) {
const defaultConfig = getDefaultContentSafetyConfig()
providerConfig = defaultConfig.providers?.[providerKey]
}
if (!providerConfig) {
return {
success: false,
error: `检测服务 ${providerKey} 配置不存在`
}
}
const adapter = providerAdapters[providerKey]
if (!adapter) {
return {
success: false,
error: `不支持的检测服务: ${providerKey}`
}
}
// 读取图片文件
const filePath = getImagePath(filename)
if (!existsSync(filePath)) {
return {
success: false,
error: '图片文件不存在'
}
}
try {
const imageBuffer = await readFile(filePath)
const result = await adapter.detect(imageBuffer, filename, providerConfig)
return {
...result,
provider: providerKey,
imageId: imageId
}
} catch (error) {
return {
success: false,
error: error.message || '读取图片文件失败'
}
}
}
/**
* 获取支持的厂商列表
*/
export function getSupportedProviders() {
return Object.entries(providerAdapters).map(([key, adapter]) => ({
key,
name: adapter.name,
defaultApiUrl: adapter.defaultApiUrl,
defaultThreshold: adapter.defaultThreshold
}))
}
/**
* 验证厂商配置是否有效
*/
export function validateProviderConfig(providerKey, config) {
const adapter = providerAdapters[providerKey]
if (!adapter) {
return { valid: false, error: `不支持的检测服务: ${providerKey}` }
}
if (config.threshold !== undefined) {
if (typeof config.threshold !== 'number' || config.threshold < 0 || config.threshold > 1) {
return { valid: false, error: '阈值必须是 0-1 之间的数字' }
}
}
return { valid: true }
}
export default {
moderateImage,
getSupportedProviders,
validateProviderConfig,
getDefaultContentSafetyConfig,
MODERATION_PROVIDERS
}