154 lines
5.9 KiB
Go
154 lines
5.9 KiB
Go
// 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
|
||
}
|