Files
tamabox/internal/security/censor/text.go
T
2026-09-10 18:04:19 +08:00

154 lines
5.9 KiB
Go
Raw 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.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package censor
import (
"context"
"encoding/json"
"fmt"
"strings"
)
type ForbiddenType string
const (
ForbiddenTypeSpam ForbiddenType = "spam"
ForbiddenTypeAd ForbiddenType = "ad"
ForbiddenTypePolitics ForbiddenType = "politics"
ForbiddenTypeTerrorism ForbiddenType = "terrorism"
ForbiddenTypeAbuse ForbiddenType = "abuse"
ForbiddenTypePorn ForbiddenType = "porn"
ForbiddenTypeFlood ForbiddenType = "flood"
ForbiddenTypeContraband ForbiddenType = "contraband"
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
ForbiddenTypeHarmful ForbiddenType = "harmful"
// ForbiddenTypeCustomized 命中审核服务侧的自定义词库(阿里云控制台配置,
// 七牛不支持服务端词库因此不会返回该维度)。属运营方显式配置的规则,
// 在 policy.go 中被归入底线集合,任何档位都不放行。
ForbiddenTypeCustomized ForbiddenType = "customized"
)
func (f ForbiddenType) String() string {
return map[ForbiddenType]string{
ForbiddenTypeSpam: "含垃圾信息",
ForbiddenTypeAd: "广告",
ForbiddenTypePolitics: "涉政",
ForbiddenTypeTerrorism: "暴恐",
ForbiddenTypeAbuse: "辱骂",
ForbiddenTypePorn: "色情",
ForbiddenTypeFlood: "灌水",
ForbiddenTypeContraband: "违禁",
ForbiddenTypeMeaningless: "无意义",
ForbiddenTypeHarmful: "不良场景",
ForbiddenTypeCustomized: "自定义词库",
}[f]
}
// LabelScore 审核服务针对单个维度(label)给出的判定与置信度。
//
// 各服务商都会返回「多维度 + 每个维度一个分值」的结果,过去只取首个非 normal 维度,
// 丢掉了其余维度的信息,导致无法按维度做差异化处置(例如只拦高置信度的辱骂)。
//
// ⚠️ Score 已统一归一化到 0~1,与提供方无关:七牛本身即 0~1。
// 阿里云内容安全 2.0 不返回逐标签置信度,只返回整条内容的 riskLevel,
// 由 AliyunTextCensorParser 折算为 0~1(high=1 / medium=0.6 / low=0.3)后由该响应的
// 所有标签共用 —— 这是有损的近似,详见 AliyunTextCensorParser 与 aliyunRiskLevelScore 的注释。
// policy.go 的阈值因此对两家服务商都成立(对阿里云的语义为「按风险级别分级」)。
type LabelScore struct {
Label ForbiddenType `json:"label"`
Score float64 `json:"score"`
}
type TextCensorResponse struct {
SourceName string `json:"source_name"`
Pass bool `json:"pass"`
ForbiddenType ForbiddenType `json:"forbidden_type"`
Hint string `json:"hint"`
Confidence float64 `json:"confidence"`
RawResponse json.RawMessage `json:"raw_response"`
// Details 全部非 normal 维度的判定明细,供严格度策略按维度裁决。
// 仅在回放缓存结果时为 nil(见 censor.go),此时 Pass 已足以表达判定。
Details []LabelScore `json:"details,omitempty"`
// ProviderSuggestion 审核服务自身给出的管控建议(block / review / pass)。
// 它不携带具体维度,无法做置信度收敛,因此只在严格档被采信(见 policy.go)。
ProviderSuggestion string `json:"provider_suggestion,omitempty"`
}
func (r *TextCensorResponse) ToJSON() []byte {
jsonBytes, _ := json.Marshal(r)
return jsonBytes
}
// ErrorMessageFor 生成针对指定命中维度的拒绝提示。
//
// 与已移除的 ErrorMessage 的区别:后者固定使用首个非 normal 维度,而实际触发拦截的
// 可能是另一个维度(策略层按档位裁决),两者不一致时用户会看到对不上的原因。
// 因此拦截时统一用本方法,按真正命中的维度出文案。
// detail 为 nil(即由审核服务的整体 block 建议触发)时退化为不带维度的基础文案。
func (r *TextCensorResponse) ErrorMessageFor(detail *LabelScore) string {
errorMessage := "内容安全检查不通过"
if detail != nil && detail.Label != "" {
errorMessage += fmt.Sprintf(" [%s]", detail.Label.String())
}
if r.Hint != "" {
errorMessage += fmt.Sprintf(",相关标签:%q", r.Hint)
}
return errorMessage
}
type TextCensor interface {
Censor(ctx context.Context, text string) (*TextCensorResponse, error)
String() string
}
var TrustedHosts = []string{
"xhslink.com", // 小红书
"weibo.com", // 微博
"b23.tv", // Bilibili
"acfun.cn",
"aixifan.com",
"github.com",
// 自有域名:旧域 tama.guru 保留至 2026-12 到期前的过渡期结束,
// 期间用户分享的旧链接仍需视为可信(不被判为广告);新域 shiroko.one 为主域。
"tama.guru",
"shiroko.one",
"337845818.best",
"x.com",
"bilibili.com",
}
// RemoveTrustedURL removes the trusted URL from the text,
// in order to bypass the URL censoring.
// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于字符串结尾时才视为可信,
// 避免 github.com.evil.tld 被朴素替换误判为 github.com 之子域而漏审。
func RemoveTrustedURL(str string) string {
for _, host := range TrustedHosts {
for _, protocol := range []string{"http://", "https://"} {
needle := protocol + host
var b strings.Builder
rest := str
for {
idx := strings.Index(rest, needle)
if idx < 0 {
b.WriteString(rest)
break
}
b.WriteString(rest[:idx])
after := rest[idx+len(needle):]
if len(after) == 0 || after[0] == '/' || after[0] == '?' || after[0] == '#' {
// 可信域名边界:跳过该前缀,从域名之后继续(域名本身被移除,不进入审核)。
rest = after
} else {
// 非可信域名边界(如 github.com.evil):保留前缀,从其后继续扫描。
b.WriteString(needle)
rest = after
}
}
str = b.String()
}
}
return str
}