chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// 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 ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
var (
|
||||
censorCacheNoMoreThan = 31 * 24 * time.Hour // 1 month
|
||||
)
|
||||
|
||||
// Text checks the text for sensitive content.
|
||||
// It will save the censor log to the database and invoke the callback function.
|
||||
func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
|
||||
if !conf.Security.EnableTextCensor {
|
||||
return &TextCensorResponse{Pass: true}, nil
|
||||
}
|
||||
|
||||
var responses []*TextCensorResponse
|
||||
|
||||
for _, censor := range []TextCensor{
|
||||
//No, we need to use qiniu's API.
|
||||
NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret),
|
||||
NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret),
|
||||
} {
|
||||
sourceName := censor.String()
|
||||
|
||||
// Try to get the censor from the database log.
|
||||
censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, text, time.Now().Add(-censorCacheNoMoreThan))
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrCensorLogsNotFound) {
|
||||
logrus.WithContext(ctx).WithError(err).Error("Failed to get censor log")
|
||||
}
|
||||
} else {
|
||||
raw := censorLog.RawResponse
|
||||
// We got the previous censor log cache, seems like we saved money for the API call.
|
||||
switch censorLog.SourceName {
|
||||
case "qiniu":
|
||||
// HACK: Qiniu's API response is not accurate, so we need to check the text again with aliyun API.
|
||||
response, err := QiniuTextCensorParser(raw)
|
||||
if err == nil {
|
||||
if response.Pass {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
responses = append(responses, response) // If the text is not passed, we need to check it with aliyun API.
|
||||
continue
|
||||
}
|
||||
|
||||
case "aliyun":
|
||||
return AliyunTextCensorParser(raw)
|
||||
|
||||
default:
|
||||
logrus.WithContext(ctx).WithField("censor_source", censorLog.SourceName).Error("Unknown censor source")
|
||||
}
|
||||
}
|
||||
|
||||
// response, err := censor.Censor(ctx, text)
|
||||
censorText := RemoveTrustedURL(text)
|
||||
response, err := censor.Censor(ctx, censorText)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).Error("Failed to censor text")
|
||||
continue
|
||||
}
|
||||
responses = append(responses, response)
|
||||
|
||||
// Save the censor log to the database.
|
||||
if err := db.CensorLogs.Create(ctx, db.CreateCensorLogOptions{
|
||||
SourceName: sourceName,
|
||||
Input: text,
|
||||
Pass: response.Pass,
|
||||
RawResponse: response.RawResponse,
|
||||
}); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Error("Failed to save censor log")
|
||||
}
|
||||
|
||||
if response.Pass {
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(responses) == 0 {
|
||||
return nil, errors.New("no censor source available")
|
||||
}
|
||||
return responses[len(responses)-1], nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/qiniu/go-sdk/v7/auth"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type QiniuTextCensor struct {
|
||||
accessKey, accessSecret string
|
||||
}
|
||||
|
||||
func NewQiniuTextCensor(accessKey, accessSecret string) *QiniuTextCensor {
|
||||
return &QiniuTextCensor{
|
||||
accessKey: accessKey,
|
||||
accessSecret: accessSecret,
|
||||
}
|
||||
}
|
||||
|
||||
type QiniuTextCensorResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Result struct {
|
||||
Suggestion string `json:"suggestion"`
|
||||
Scenes struct {
|
||||
Antispam struct {
|
||||
Suggestion string `json:"suggestion"`
|
||||
Details []struct {
|
||||
Label string `json:"label"`
|
||||
Score float64 `json:"score"`
|
||||
Contexts []struct {
|
||||
Context string `json:"context"`
|
||||
Positions []struct {
|
||||
StartPos int `json:"startPos"`
|
||||
EndPos int `json:"endPos"`
|
||||
} `json:"positions"`
|
||||
} `json:"contexts"`
|
||||
} `json:"details"`
|
||||
} `json:"antispam"`
|
||||
} `json:"scenes"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
func (r *QiniuTextCensorResponse) IsPass() bool {
|
||||
for _, detail := range r.Result.Scenes.Antispam.Details {
|
||||
label := detail.Label
|
||||
|
||||
// These are absolutely not allowed to display to users.
|
||||
blockedLabels := []string{"politics", "terrorism", "porn"}
|
||||
if lo.Contains(blockedLabels, label) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ Right now, we allow `review` and `pass` to pass the censor.
|
||||
return r.Result.Scenes.Antispam.Suggestion != "block"
|
||||
}
|
||||
|
||||
// Censor censors text with Qiniu API.
|
||||
// https://developer.qiniu.com/censor/7260/api-text-censor
|
||||
func (c *QiniuTextCensor) Censor(ctx context.Context, text string) (*TextCensorResponse, error) {
|
||||
var bodyBuffer bytes.Buffer
|
||||
if err := json.NewEncoder(&bodyBuffer).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"text": text,
|
||||
},
|
||||
"params": map[string]interface{}{
|
||||
"scenes": []string{"antispam"},
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, errors.Wrap(err, "encode request body")
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, "https://ai.qiniuapi.com/v3/text/censor", &bodyBuffer)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "new request")
|
||||
}
|
||||
request = request.WithContext(ctx)
|
||||
|
||||
credentials := auth.New(c.accessKey, c.accessSecret)
|
||||
token, err := credentials.SignRequestV2(request)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "sign request")
|
||||
}
|
||||
request.Header.Set("Authorization", "Qiniu "+token)
|
||||
|
||||
// 带超时:避免七牛审核服务无响应时请求永久挂起,拖垮提问/回答流程
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "do request")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "read body")
|
||||
}
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, errors.Errorf("unexpected error code: %d, response body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return QiniuTextCensorParser(bodyBytes)
|
||||
}
|
||||
|
||||
func (*QiniuTextCensor) String() string {
|
||||
return "qiniu"
|
||||
}
|
||||
|
||||
func QiniuTextCensorParser(raw []byte) (*TextCensorResponse, error) {
|
||||
var responseJSON QiniuTextCensorResponse
|
||||
if err := json.Unmarshal(raw, &responseJSON); err != nil {
|
||||
return nil, errors.Wrap(err, "decode response body")
|
||||
}
|
||||
|
||||
var hint string
|
||||
var detailKey string
|
||||
var confidence float64
|
||||
for _, detail := range responseJSON.Result.Scenes.Antispam.Details {
|
||||
if detail.Label == "normal" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the first context as the hint, forbidden type, confidence.
|
||||
for _, context := range detail.Contexts {
|
||||
hint = context.Context
|
||||
}
|
||||
detailKey = detail.Label
|
||||
confidence = detail.Score
|
||||
break
|
||||
}
|
||||
|
||||
return &TextCensorResponse{
|
||||
SourceName: "qiniu",
|
||||
Pass: responseJSON.IsPass(),
|
||||
ForbiddenType: formatQiniuForbiddenType(detailKey),
|
||||
Hint: hint,
|
||||
Confidence: confidence,
|
||||
RawResponse: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatQiniuForbiddenType(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
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func (f ForbiddenType) String() string {
|
||||
return map[ForbiddenType]string{
|
||||
ForbiddenTypeSpam: "含垃圾信息",
|
||||
ForbiddenTypeAd: "广告",
|
||||
ForbiddenTypePolitics: "涉政",
|
||||
ForbiddenTypeTerrorism: "暴恐",
|
||||
ForbiddenTypeAbuse: "辱骂",
|
||||
ForbiddenTypePorn: "色情",
|
||||
ForbiddenTypeFlood: "灌水",
|
||||
ForbiddenTypeContraband: "违禁",
|
||||
ForbiddenTypeMeaningless: "无意义",
|
||||
ForbiddenTypeHarmful: "不良场景",
|
||||
}[f]
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (r *TextCensorResponse) ToJSON() []byte {
|
||||
jsonBytes, _ := json.Marshal(r)
|
||||
return jsonBytes
|
||||
}
|
||||
|
||||
func (r *TextCensorResponse) ErrorMessage() string {
|
||||
errorMessage := "内容安全检查不通过"
|
||||
if r.ForbiddenType != "" {
|
||||
errorMessage += fmt.Sprintf(" [%s]", r.ForbiddenType.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
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2025 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRemoveTrustedURL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{name: "empty string", text: "", want: ""},
|
||||
{name: "normal", text: "你喜欢看什么书?", want: "你喜欢看什么书?"},
|
||||
{name: "xiaohongshu", text: `59 茄酱发布了一篇小红书笔记,快来看吧! 😆 jk14SBf2G75FTW2 😆 http://xhslink.com/a/WYaQ6Tjn86Hab,复制本条信息,打开【小红书】App查看精彩内容!`, want: `59 茄酱发布了一篇小红书笔记,快来看吧! 😆 jk14SBf2G75FTW2 😆 /a/WYaQ6Tjn86Hab,复制本条信息,打开【小红书】App查看精彩内容!`},
|
||||
{name: "weibo", text: `TK 发微博啦~ https://weibo.com/1401527553/5144129806795225 真好看!`, want: `TK 发微博啦~ /1401527553/5144129806795225 真好看!`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := RemoveTrustedURL(tc.text)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user