// 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 }