// 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 的剧铺") topUp = flag.Bool("top-up", false, "补齐模式:--min~--max 当成目标总条数,按差额补,已经够的跳过") recount = flag.Bool("recount", false, "只重算 countComment,不铺评论") noRecount = flag.Bool("no-recount", false, "铺完后不重算 countComment") 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)) 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) if *recount { if err := recountAll(ctx, db, *dryRun); err != nil { fatal("重算 countComment 失败: %v", err) } return } fmt.Printf("随机种子 %d(复现同一批数据可用 --seed)\n", *seed) 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, skipped := 0, 0, 0 for _, d := range dramas { n := *minCmt + rnd.Intn(*maxCmt-*minCmt+1) if *topUp { // 补齐模式下 --min~--max 是目标总条数,不是新增条数。 // 糖果的短剧已经有 20~50 条,照新增算会冲到 250 条,和其余站对不上。 cur, err := visibleCount(ctx, db, d.ID) if err != nil { fmt.Printf("[FAIL] %s 查现有条数: %v\n", d.Title, err) continue } // 已经落在 [min, max] 区间内的一律不动。 // 拿每次新摇的目标值去比对会让重复跑把条数一路往 max 顶 // (已有 60 条的剧遇到目标 180 还会再补 120),这样跑几次就全挤到上限了。 if int(cur) >= *minCmt { skipped++ continue } n -= int(cur) } 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) } } tail := "" if *topUp { tail = fmt.Sprintf(",另有 %d 部已经够了跳过", skipped) } if *dryRun { fmt.Printf("\nDRY-RUN:%d 部剧,将写入 %d 条评论(未写入)%s\n", done, total, tail) return } fmt.Printf("\n完成:%d 部剧,共写入 %d 条评论%s\n", done, total, tail) if *noRecount { return } fmt.Println() if err := recountAll(ctx, db, false); err != nil { fatal("重算 countComment 失败: %v", err) } } // recountAll 把每部短剧的 countComment 重算成 App 列表口径的实际可见条数。 // // 两个地方会让这个字段飘,所以铺完必须重算一次: // - 发评论时 incCommentCount 无条件 +1(app/service/commentser/cmtsend.go), // 审核不通过和删除都不回减,后端也没有任何一条路会重算短剧的这个字段 // - contentsync 用 $setOnInsert 给新文档的本地字段赋初值,countComment 在列表里, // 所以源库新剧带着源站的评论数进来,本地却一条评论都没有 // // 口径与 app/service/commentser.GetTotalComments 一致——App 评论列表第 2 页之后 // 就是用它实时算总数的,第 1 页读 countComment,两者必须对得上。 func recountAll(ctx context.Context, db *mongo.Database, dryRun bool) error { cur, err := db.Collection("comment").Aggregate(ctx, []bson.M{ {"$match": bson.M{"objType": "drama", "level": 1, "status": 1, "isDelete": false, "isAdvertiser": bson.M{"$in": []any{nil, false}}}}, {"$group": bson.M{"_id": "$objID", "n": bson.M{"$sum": 1}}}, }, options.Aggregate().SetAllowDiskUse(true)) if err != nil { return err } real := map[primitive.ObjectID]int64{} for cur.Next(ctx) { var row struct { ID primitive.ObjectID `bson:"_id"` N int64 `bson:"n"` } if err := cur.Decode(&row); err != nil { cur.Close(ctx) return err } real[row.ID] = row.N } cur.Close(ctx) if err := cur.Err(); err != nil { return err } mcur, err := db.Collection("media").Find(ctx, bson.M{"mediaType": "drama"}, options.Find().SetProjection(bson.M{"_id": 1, "countComment": 1})) if err != nil { return err } defer mcur.Close(ctx) var ( models []mongo.WriteModel scanned, up, down int sumOld, sumNew int64 ) for mcur.Next(ctx) { var row struct { ID primitive.ObjectID `bson:"_id"` Count int64 `bson:"countComment"` } if err := mcur.Decode(&row); err != nil { return err } scanned++ want := real[row.ID] if row.Count == want { continue } if want > row.Count { up++ } else { down++ } sumOld += row.Count sumNew += want models = append(models, mongo.NewUpdateOneModel(). SetFilter(bson.M{"_id": row.ID}). SetUpdate(bson.M{"$set": bson.M{"countComment": want}})) } if err := mcur.Err(); err != nil { return err } fmt.Printf("重算 countComment:扫 %d 部短剧,不一致 %d 部(调高 %d,调低 %d),合计 %d -> %d\n", scanned, len(models), up, down, sumOld, sumNew) if dryRun { fmt.Println(" [dry-run] 未写入") return nil } if len(models) == 0 { return nil } res, err := db.Collection("media").BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false)) if err != nil { return err } fmt.Printf(" 已写入:matched=%d modified=%d\n", res.MatchedCount, res.ModifiedCount) return nil } // visibleCount 返回一部剧当前在 App 评论列表里能看到的一级评论条数。 // 口径与 app/service/commentser.GetTotalComments 一致。 func visibleCount(ctx context.Context, db *mongo.Database, mediaID primitive.ObjectID) (int64, error) { return db.Collection("comment").CountDocuments(ctx, bson.M{ "objID": mediaID, "objType": "drama", "level": 1, "status": 1, "isDelete": false, "isAdvertiser": bson.M{"$in": []any{nil, false}}, }) } // 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 <= 当前时间。字段整体对齐线上真实短剧评论的形状。 // // city 固定 "-":发评论路径里 GetLocationByIP 的调用是注释掉的 // (app/service/commentser/cmtsend.go:114-116),线上真实评论没有一条带 // 归属地。City 会原样出参给 App(cmtcoverter.go:46),给真城市等于在 // 评论区挂个「我是机器人」的标。 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": "-", "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) }