// 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" "sync" "time" "github.com/aliyun/alibaba-cloud-sdk-go/services/green" "github.com/pkg/errors" ) type AliyunTextCensor struct { accessKey, accessKeySecret string } func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor { return &AliyunTextCensor{ accessKey: accessKey, accessKeySecret: accessKeySecret, } } // 包级复用的 green.Client:审核请求高频且密钥在启动后不变, // 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。 var ( aliyunGreenOnce sync.Once aliyunGreenClient *green.Client aliyunGreenErr error ) func (c *AliyunTextCensor) getClient() (*green.Client, error) { aliyunGreenOnce.Do(func() { aliyunGreenClient, aliyunGreenErr = green.NewClientWithAccessKey("cn-shanghai", c.accessKey, c.accessKeySecret) if aliyunGreenErr == nil { aliyunGreenClient.SetConnectTimeout(5 * time.Second) aliyunGreenClient.SetReadTimeout(10 * time.Second) } }) return aliyunGreenClient, aliyunGreenErr } type AliyunTextCensorResponse struct { Code int `json:"code"` Data []struct { Code int `json:"code"` Content string `json:"content"` FilteredContent string `json:"filteredContent"` Msg string `json:"msg"` Results []struct { Details []struct { Contexts []struct { Context string `json:"context"` Positions []struct { EndPos int `json:"endPos"` StartPos int `json:"startPos"` } `json:"positions"` } `json:"contexts"` Label string `json:"label"` } `json:"details"` Label string `json:"label"` Rate float64 `json:"rate"` Scene string `json:"scene"` Suggestion string `json:"suggestion"` } `json:"results"` TaskId string `json:"taskId"` } `json:"data"` Msg string `json:"msg"` RequestId string `json:"requestId"` } func (r *AliyunTextCensorResponse) IsPass() bool { if len(r.Data) == 0 || len(r.Data[0].Results) == 0 { return false } // ⚠️ Right now, we allow `review` and `pass` to pass the censor. // 任一 scene 判定为 block 即不通过(此前只检查首个 result,存在漏审)。 for _, result := range r.Data[0].Results { if result.Suggestion == "block" { return false } } return true } // Censor censors text with Aliyun API. // https://developer.qiniu.com/censor/7260/api-text-censor func (c *AliyunTextCensor) Censor(_ context.Context, text string) (*TextCensorResponse, error) { client, err := c.getClient() if err != nil { return nil, errors.Wrap(err, "new client with access key") } content, err := json.Marshal( map[string]interface{}{ "scenes": []string{"antispam"}, "tasks": []map[string]interface{}{ {"content": text}, }, }, ) if err != nil { return nil, errors.Wrap(err, "marshal content") } req := green.CreateTextScanRequest() req.SetContent(content) resp, err := client.TextScan(req) if err != nil { return nil, errors.Wrap(err, "text scan") } if !resp.IsSuccess() { return nil, errors.New("response is not success") } return AliyunTextCensorParser(resp.GetHttpContentBytes()) } func (*AliyunTextCensor) String() string { return "aliyun" } func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) { var responseJSON AliyunTextCensorResponse if err := json.Unmarshal(raw, &responseJSON); err != nil { return nil, errors.Wrap(err, "unmarshal response") } if len(responseJSON.Data) == 0 { return nil, errors.New("response data is empty") } var hint string var label string var confidence float64 for _, result := range responseJSON.Data[0].Results { if result.Label == "normal" { continue } // Get the first context as the hint, forbidden type, confidence. for _, detail := range result.Details { for _, context := range detail.Contexts { hint = context.Context } } label = result.Label confidence = result.Rate break } return &TextCensorResponse{ SourceName: "aliyun", Pass: responseJSON.IsPass(), ForbiddenType: formatAliyunForbiddenType(label), Hint: hint, Confidence: confidence, RawResponse: raw, }, nil } func formatAliyunForbiddenType(typ string) ForbiddenType { switch typ { case "spam": return ForbiddenTypeSpam case "ad": return ForbiddenTypeAd case "politics": return ForbiddenTypePolitics case "terrorism": return ForbiddenTypeTerrorism case "abuse": return ForbiddenTypeAbuse case "porn": return ForbiddenTypePorn case "flood": return ForbiddenTypeFlood case "contraband": return ForbiddenTypeContraband case "meaningless": return ForbiddenTypeMeaningless case "harmful": return ForbiddenTypeHarmful default: return "" } }