[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>
This commit is contained in:
2026-10-29 21:13:16 +08:00
co-authored by Claude Opus 5
parent ed51664a09
commit f45144a3f4
4 changed files with 632 additions and 0 deletions
+395
View File
@@ -0,0 +1,395 @@
// 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)
}