Files
tangguo_server/scripts/dramacmt/main.go
T
rootandClaude Opus 5 f45144a3f4 [AI] feat: 新增 dramacmt 短剧评论铺量工具
后台已有的评论导入只认视频、要上传 Excel,而且 userID 取自 static.json
的 sysUser —— 那些 uid 在 user 表里不存在,前端渲染出来昵称头像都是空的
(线上已有 5962 条这样的评论)。

本工具自建机器人用户池(uid 1001 起,落在 constant.RobotUIDLimit 以内,
devID 唯一索引是非 sparse 的,给 ROBOT%07d),评论挂在它们身上,
昵称头像可正常显示,后台能按 isRobot 筛选和撤回。

几个容易写错的点记在 README:status 必须直接写 1(App 发评论走的是 2,
而列表只认 1)、type 是 VIDEO 而 objType 才是 drama、tousername 落库键名
是小写、media.countComment 必须同步 $inc 否则前端总数显示 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-29 21:13:16 +08:00

396 lines
12 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.
// dramacmt 给在线短剧铺机器人评论。
//
// 为什么要单独做一个工具:后台已有的评论导入(web/api/commentctrl 的
// AddContentExcel)只认视频,要上传 Excel,而且它写的 userID 取自
// static.json 的 sysUser —— 那些 uid 在 user 表里并不存在,前端渲染出来
// 昵称和头像都是空的(线上已有 5962 条这样的评论)。
//
// 所以这里自己建一批机器人用户(uid <= constant.RobotUIDLimit),
// 评论挂在它们身上,昵称头像都能正常显示,后台也能按 isRobot 一键筛出来。
//
// 用法见 README.md。
package main
import (
"context"
"flag"
"fmt"
"math/rand"
"os"
"sort"
"time"
"unicode/utf8"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// robotUIDLimit 与 common/constant.RobotUIDLimit 一致。
// app/service/commentser.getTrackInfo 靠 uid < 该值判定 isRobot,
// 机器人用户的 uid 必须落在这个区间里。
const robotUIDLimit = 111999
// 机器人用户 uid 从这里开始往上发。1~17 已被占用,1000 以下整段留空不碰。
const robotUIDStart = 1001
func main() {
var (
dstURI = flag.String("dst", "", "目标库 URI(库名取 URI 里的 path)")
dbName = flag.String("db", "", "库名,留空则取 URI 里的")
userCount = flag.Int("users", 400, "机器人用户池大小")
minCmt = flag.Int("min", 20, "每部剧最少评论数")
maxCmt = flag.Int("max", 50, "每部剧最多评论数")
onlyEmpty = flag.Bool("only-empty", true, "只给 countComment=0 的剧铺")
limit = flag.Int("limit", 0, "最多处理多少部剧,0 为不限(调试用)")
dryRun = flag.Bool("dry-run", false, "只统计不写入")
seed = flag.Int64("seed", 0, "随机种子,0 用当前时间")
timeout = flag.Duration("timeout", 30*time.Minute, "整体超时")
)
flag.Parse()
if *dstURI == "" {
fmt.Fprintln(os.Stderr, "必须指定 --dst")
os.Exit(2)
}
if *minCmt < 1 || *maxCmt < *minCmt {
fatal("--min/--max 不合法: %d~%d", *minCmt, *maxCmt)
}
if *maxCmt > len(contents) {
fatal("--max %d 超过文案池容量 %d,同一部剧下会出现重复文案", *maxCmt, len(contents))
}
if *seed == 0 {
*seed = time.Now().UnixNano()
}
rnd := rand.New(rand.NewSource(*seed))
fmt.Printf("随机种子 %d(复现同一批数据可用 --seed)\n", *seed)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
cli, err := mongo.Connect(ctx, options.Client().ApplyURI(*dstURI))
if err != nil {
fatal("连接目标库失败: %v", err)
}
defer cli.Disconnect(context.Background())
// URI 里的 authSource 通常是 admin,推不出业务库名,所以要求显式传 --db
if *dbName == "" {
fatal("必须用 --db 指定库名")
}
db := cli.Database(*dbName)
portraits, err := samplePortraits(ctx, db)
if err != nil {
fatal("取头像素材失败: %v", err)
}
if len(portraits) == 0 {
fatal("库里没有可用头像素材")
}
fmt.Printf("头像素材 %d 张\n", len(portraits))
uids, err := ensureRobotUsers(ctx, db, *userCount, portraits, rnd, *dryRun)
if err != nil {
fatal("准备机器人用户失败: %v", err)
}
fmt.Printf("机器人用户池 %d 个(uid %d~%d)\n", len(uids), uids[0], uids[len(uids)-1])
dramas, err := pickDramas(ctx, db, *onlyEmpty, *limit)
if err != nil {
fatal("取短剧列表失败: %v", err)
}
fmt.Printf("待铺评论的在线短剧 %d 部\n\n", len(dramas))
total, done := 0, 0
for _, d := range dramas {
n := *minCmt + rnd.Intn(*maxCmt-*minCmt+1)
cmts := buildComments(d, n, uids, rnd)
total += len(cmts)
if *dryRun {
done++
continue
}
if err := insertComments(ctx, db, d.ID, cmts); err != nil {
fmt.Printf("[FAIL] %s %v\n", d.Title, err)
continue
}
done++
if done%50 == 0 {
fmt.Printf(" 已完成 %d/%d 部,累计 %d 条\n", done, len(dramas), total)
}
}
if *dryRun {
fmt.Printf("\nDRY-RUN:%d 部剧,将写入 %d 条评论(未写入)\n", done, total)
return
}
fmt.Printf("\n完成:%d 部剧,共写入 %d 条评论\n", done, total)
}
// samplePortraits 从库里现有用户取头像路径,避免把素材路径写死在代码里。
func samplePortraits(ctx context.Context, db *mongo.Database) ([]string, error) {
cur, err := db.Collection("user").Aggregate(ctx, []bson.M{
{"$match": bson.M{"portrait": bson.M{"$ne": ""}}},
{"$limit": 5000},
{"$group": bson.M{"_id": "$portrait"}},
})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var out []string
for cur.Next(ctx) {
var row struct {
ID string `bson:"_id"`
}
if err := cur.Decode(&row); err != nil {
return nil, err
}
out = append(out, row.ID)
}
sort.Strings(out)
return out, cur.Err()
}
// ensureRobotUsers 保证 uid 池里的机器人用户都存在,返回全部 uid。
// 已存在的不动——昵称头像可能被运营改过。
func ensureRobotUsers(ctx context.Context, db *mongo.Database, want int,
portraits []string, rnd *rand.Rand, dryRun bool) ([]int64, error) {
if robotUIDStart+want-1 > robotUIDLimit {
return nil, fmt.Errorf("uid 池越界:%d 个会超过 robotUIDLimit %d", want, robotUIDLimit)
}
uids := make([]int64, 0, want)
for i := 0; i < want; i++ {
uids = append(uids, int64(robotUIDStart+i))
}
coll := db.Collection("user")
cur, err := coll.Find(ctx, bson.M{"uid": bson.M{"$in": uids}},
options.Find().SetProjection(bson.M{"uid": 1}))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
exists := map[int64]bool{}
for cur.Next(ctx) {
var row struct {
UID int64 `bson:"uid"`
}
if err := cur.Decode(&row); err != nil {
return nil, err
}
exists[row.UID] = true
}
if err := cur.Err(); err != nil {
return nil, err
}
var docs []any
for _, uid := range uids {
if exists[uid] {
continue
}
// devID 上是非 sparse 的唯一索引,必须给一个唯一的非空值。
// token / promotionCode / mobile 的唯一索引是 {$gt: ""} 的部分索引,
// 留空串即可豁免。
docs = append(docs, bson.M{
"uid": uid,
"devID": fmt.Sprintf("ROBOT%07d", uid),
"name": randomName(rnd),
"portrait": portraits[rnd.Intn(len(portraits))],
"mobile": "",
"gender": "",
"token": "",
"promotionCode": "",
"vipLevel": 0,
"hasLocked": false,
"hasBanned": false,
// 注册时间铺在过去一年里,免得一批用户同一秒注册
"createdAt": time.Now().Add(-time.Duration(rnd.Intn(365*24)) * time.Hour),
})
}
if len(docs) == 0 {
fmt.Printf("机器人用户已齐备,无需新建\n")
return uids, nil
}
if dryRun {
fmt.Printf("DRY-RUN:将新建 %d 个机器人用户\n", len(docs))
return uids, nil
}
res, err := coll.InsertMany(ctx, docs, options.InsertMany().SetOrdered(false))
if err != nil {
return nil, err
}
fmt.Printf("新建机器人用户 %d 个\n", len(res.InsertedIDs))
return uids, nil
}
type drama struct {
ID primitive.ObjectID `bson:"_id"`
Title string `bson:"title"`
CreatedAt time.Time `bson:"createdAt"`
PublishedAt time.Time `bson:"latestPublishedAt"`
}
// since 返回评论时间的起点:优先用最新上架时间,没有就用创建时间;
// 都没有就退到一年前,免得算出零值把评论写到公元 1 年。
func (d drama) since() time.Time {
if !d.PublishedAt.IsZero() {
return d.PublishedAt
}
if !d.CreatedAt.IsZero() {
return d.CreatedAt
}
return time.Now().AddDate(-1, 0, 0)
}
func pickDramas(ctx context.Context, db *mongo.Database, onlyEmpty bool, limit int) ([]drama, error) {
filter := bson.M{"mediaType": "drama", "status": 1, "isDelete": false}
if onlyEmpty {
filter["$or"] = []bson.M{
{"countComment": bson.M{"$lte": 0}},
{"countComment": bson.M{"$exists": false}},
}
}
opts := options.Find().SetProjection(bson.M{
"_id": 1, "title": 1, "createdAt": 1, "latestPublishedAt": 1,
})
if limit > 0 {
opts.SetLimit(int64(limit))
}
cur, err := db.Collection("media").Find(ctx, filter, opts)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var out []drama
for cur.Next(ctx) {
var d drama
if err := cur.Decode(&d); err != nil {
return nil, err
}
out = append(out, d)
}
return out, cur.Err()
}
// buildComments 为一部剧造 n 条一级评论。
//
// 要能在 App 里显示出来,必须满足 models/v/cmtmod.GetParentCmtList 的条件:
// level=1、status=1、isGodComment=false、isDelete=false、isAdvertiser 非 true、
// createdAt <= 当前时间。字段整体对齐线上真实短剧评论的形状。
func buildComments(d drama, n int, uids []int64, rnd *rand.Rand) []any {
start := d.since()
now := time.Now()
span := now.Sub(start)
if span <= 0 {
span = 24 * time.Hour
start = now.Add(-span)
}
// 文案与用户都在同一部剧内不重复
ci := rnd.Perm(len(contents))[:n]
ui := rnd.Perm(len(uids))
if n > len(ui) {
n = len(ui)
ci = ci[:n]
}
ts := make([]time.Time, n)
for i := range ts {
ts[i] = start.Add(time.Duration(rnd.Int63n(int64(span))))
}
sort.Slice(ts, func(i, j int) bool { return ts[i].Before(ts[j]) })
out := make([]any, 0, n)
for i := 0; i < n; i++ {
text := contents[ci[i]]
out = append(out, bson.M{
"objID": d.ID,
"objType": "drama",
"title": d.Title,
"type": "VIDEO", // 真实短剧评论也是 VIDEO,见 getTrackInfo 的调用
"publisherID": int64(0),
"authorMark": false,
"userID": uids[ui[i]],
"tousername": "", // 结构体没写 bson tag,落库键名是小写的
"likeCount": likeCount(rnd, text),
"commentCount": 0,
"isGodComment": false,
"isAuthor": false,
"isRobot": true,
"quoteType": "",
"quoteID": primitive.NilObjectID,
"quoteImg": "",
"quoteTitle": "",
"content": text,
"orgContent": text,
"image": "",
"level": 1,
"ipAddr": randomIP(rnd),
"status": 1, // 1=已通过审核,列表只认这个值
"reason": "",
"desc": "",
"city": cities[rnd.Intn(len(cities))],
"linkStr": "",
"isAdvertiser": false,
"isDelete": false,
"createdAt": ts[i],
"deletedAt": time.Time{},
})
}
return out
}
// likeCount 造长尾分布:大多数评论没人点赞,少数几条很热。
// 热门评论区按 likeCount 倒序且要求 >0,所以不能全给 0。
//
// 再按文案长短压一下上限——「可以」这种两个字的短评顶上几百赞太假,
// 热评通常是有内容的长评。
func likeCount(rnd *rand.Rand, text string) int {
n := 0
switch r := rnd.Intn(100); {
case r < 55:
n = rnd.Intn(6) // 0~5
case r < 85:
n = 6 + rnd.Intn(25) // 6~30
case r < 97:
n = 31 + rnd.Intn(90) // 31~120
default:
n = 121 + rnd.Intn(280) // 121~400
}
switch runes := utf8.RuneCountInString(text); {
case runes <= 4 && n > 30:
n = 1 + rnd.Intn(30)
case runes <= 8 && n > 120:
n = 6 + rnd.Intn(115)
}
return n
}
func insertComments(ctx context.Context, db *mongo.Database, mediaID primitive.ObjectID, docs []any) error {
if len(docs) == 0 {
return nil
}
if _, err := db.Collection("comment").InsertMany(ctx, docs,
options.InsertMany().SetOrdered(false)); err != nil {
return err
}
// App 的「总评论条数」直接读 media.countComment(见 app/api/commentctrl),
// 不补这一步前端会显示 0 条。
_, err := db.Collection("media").UpdateByID(ctx, mediaID,
bson.M{"$inc": bson.M{"countComment": int64(len(docs))}})
return err
}
func randomIP(rnd *rand.Rand) string {
return fmt.Sprintf("%d.%d.%d.%d", 1+rnd.Intn(222), rnd.Intn(256), rnd.Intn(256), 1+rnd.Intn(254))
}
func fatal(f string, a ...any) {
fmt.Fprintf(os.Stderr, f+"\n", a...)
os.Exit(1)
}