// vidcmt 给在线影视(video_info 里 newsType=SP 的长视频)铺机器人评论, // 并按后端自己的口径重算 commentCount / fakeCommentCount。 // // 和 scripts/dramacmt 的区别: // - 对象是 video_info 而不是 media,objType 是 "video" // - 条数按播放量分层,不是统一 20~50。影视有十万量级,真实评论区本来就稀 // (线上 105671 个在线影视只有 16240 个有可见评论,其中 9291 个只有 1 条), // 统一铺 20~50 既是 300 万条文档,又一眼假 // - 评论计数有两个字段,而且卡片上显示的是 fakeCommentCount // // 用法见 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。 const robotUIDLimit = 111999 // 机器人用户 uid 从这里开始往上发。1~17 是库里原有的机器人,1000 以下不碰。 // dramacmt 已经占了 1001~1400,这里继续往上发,复用同一批用户。 const robotUIDStart = 1001 // tiers 按播放量决定每个视频铺几条,从高到低匹配第一个命中的档。 // 分层而不是一刀切,是为了让评论数和播放量正相关——长尾视频挂着 // 几十条评论比没有评论更假。 var tiers = []struct { minPlay int64 min, max int }{ {200000, 20, 50}, {50000, 8, 20}, {10000, 3, 8}, {1000, 1, 3}, {0, 1, 1}, } func tierCount(play int64, rnd *rand.Rand) int { for _, t := range tiers { if play >= t.minPlay { if t.max <= t.min { return t.min } return t.min + rnd.Intn(t.max-t.min+1) } } return 1 } func main() { var ( dstURI = flag.String("dst", "", "目标库 URI") dbName = flag.String("db", "", "库名(URI 里的 authSource 通常是 admin,推不出来,必须显式传)") userCount = flag.Int("users", 5000, "机器人用户池大小") onlyEmpty = flag.Bool("only-empty", true, "只给没有任何可见评论的视频铺") limit = flag.Int("limit", 0, "最多处理多少个视频,0 为不限(调试用)") batchSize = flag.Int("batch", 2000, "每批写入的评论条数") recount = flag.Bool("recount", false, "只重算评论计数,不铺评论") noRecount = flag.Bool("no-recount", false, "铺完后不重算评论计数") dryRun = flag.Bool("dry-run", false, "只统计不写入") seed = flag.Int64("seed", 0, "随机种子,0 用当前时间") timeout = flag.Duration("timeout", 2*time.Hour, "整体超时") ) flag.Parse() if *dstURI == "" { fatal("必须指定 --dst") } if *dbName == "" { fatal("必须用 --db 指定库名") } for _, t := range tiers { if t.max > len(contents) { fatal("分层上限 %d 超过文案池容量 %d,同一个视频下会出现重复文案", t.max, 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()) db := cli.Database(*dbName) if *recount { if err := recountAll(ctx, db, *dryRun); err != nil { fatal("重算评论计数失败: %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]) var skip map[primitive.ObjectID]struct{} if *onlyEmpty { if skip, err = objIDsWithVisibleComments(ctx, db); err != nil { fatal("取已有评论的视频失败: %v", err) } fmt.Printf("已有可见评论的对象 %d 个,跳过\n", len(skip)) } if err := seedAll(ctx, db, uids, skip, *limit, *batchSize, rnd, *dryRun); err != nil { fatal("铺评论失败: %v", err) } if *dryRun || *noRecount { return } fmt.Printf("\n") if err := recountAll(ctx, db, false); err != nil { fatal("重算评论计数失败: %v", err) } } // 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。 // 已存在的不动——dramacmt 建的那 400 个昵称头像可能被运营改过。 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(2*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 } // 分批插,5000 个一次性 InsertMany 会撞 16MB 限制 const chunk = 1000 made := 0 for i := 0; i < len(docs); i += chunk { end := i + chunk if end > len(docs) { end = len(docs) } res, err := coll.InsertMany(ctx, docs[i:end], options.InsertMany().SetOrdered(false)) if err != nil { return nil, err } made += len(res.InsertedIDs) } fmt.Printf("新建机器人用户 %d 个\n", made) return uids, nil } // objIDsWithVisibleComments 返回所有已经有可见一级评论的对象 id。 // 口径和 models/v/cmtmod.GetParentCmtList 一致——只要列表里能看见一条, // 这个视频就不用铺了。 func objIDsWithVisibleComments(ctx context.Context, db *mongo.Database) (map[primitive.ObjectID]struct{}, error) { cur, err := db.Collection("comment").Aggregate(ctx, []bson.M{ {"$match": bson.M{"objType": "video", "level": 1, "status": 1, "isDelete": false}}, {"$group": bson.M{"_id": "$objID"}}, }, options.Aggregate().SetAllowDiskUse(true)) if err != nil { return nil, err } defer cur.Close(ctx) out := map[primitive.ObjectID]struct{}{} for cur.Next(ctx) { var row struct { ID primitive.ObjectID `bson:"_id"` } if err := cur.Decode(&row); err != nil { return nil, err } out[row.ID] = struct{}{} } return out, cur.Err() } type video struct { ID primitive.ObjectID `bson:"_id"` PublisherID int64 `bson:"publisherID"` CreatedAt time.Time `bson:"createdAt"` ReviewAt time.Time `bson:"reviewAt"` PlayCount int64 `bson:"fakePlayCount"` } // since 返回评论时间的起点:优先用过审时间,没有就用创建时间; // 都没有就退到一年前,免得算出零值把评论写到公元 1 年。 func (v video) since() time.Time { if !v.ReviewAt.IsZero() { return v.ReviewAt } if !v.CreatedAt.IsZero() { return v.CreatedAt } return time.Now().AddDate(-1, 0, 0) } // seedAll 流式遍历在线影视,攒够一批再写,避免十万次往返。 func seedAll(ctx context.Context, db *mongo.Database, uids []int64, skip map[primitive.ObjectID]struct{}, limit, batchSize int, rnd *rand.Rand, dryRun bool) error { opts := options.Find().SetProjection(bson.M{ "_id": 1, "publisherID": 1, "createdAt": 1, "reviewAt": 1, "fakePlayCount": 1, }).SetBatchSize(2000) cur, err := db.Collection("video_info").Find(ctx, bson.M{"newsType": "SP", "status": 1}, opts) if err != nil { return err } defer cur.Close(ctx) var ( docs []any incs []mongo.WriteModel scanned int seeded int total int perTier = make([]int, len(tiers)) ) flush := func() error { if dryRun || len(docs) == 0 { docs, incs = nil, nil return nil } if _, err := db.Collection("comment").InsertMany(ctx, docs, options.InsertMany().SetOrdered(false)); err != nil { return err } // 卡片上显示的是 fakeCommentCount,列表总数是实时查的。 // 两个字段一起 $inc,和 vidmod.IncCommentCount 的做法一致。 if _, err := db.Collection("video_info").BulkWrite(ctx, incs, options.BulkWrite().SetOrdered(false)); err != nil { return err } docs, incs = nil, nil return nil } for cur.Next(ctx) { var v video if err := cur.Decode(&v); err != nil { return err } scanned++ if _, ok := skip[v.ID]; ok { continue } ti := tierIndex(v.PlayCount) n := tierCount(v.PlayCount, rnd) cmts := buildComments(v, n, uids, rnd) if len(cmts) == 0 { continue } docs = append(docs, cmts...) incs = append(incs, mongo.NewUpdateOneModel(). SetFilter(bson.M{"_id": v.ID}). SetUpdate(bson.M{"$inc": bson.M{ "commentCount": int64(len(cmts)), "fakeCommentCount": int64(len(cmts)), }})) seeded++ total += len(cmts) perTier[ti] += len(cmts) if len(docs) >= batchSize { if err := flush(); err != nil { return err } fmt.Printf(" 已扫 %d 个,铺了 %d 个视频 / %d 条评论\n", scanned, seeded, total) } if limit > 0 && seeded >= limit { break } } if err := cur.Err(); err != nil { return err } if err := flush(); err != nil { return err } tag := "完成" if dryRun { tag = "DRY-RUN(未写入)" } fmt.Printf("\n%s:扫 %d 个在线影视,铺 %d 个视频、%d 条评论\n", tag, scanned, seeded, total) for i, t := range tiers { fmt.Printf(" 播放 >=%-7d 每个 %2d~%-2d 条:%d 条\n", t.minPlay, t.min, t.max, perTier[i]) } return nil } func tierIndex(play int64) int { for i, t := range tiers { if play >= t.minPlay { return i } } return len(tiers) - 1 } // buildComments 为一个视频造 n 条一级评论。 // // 要能在 App 里显示出来,必须满足 models/v/cmtmod.GetParentCmtList 的条件: // level=1、status=1、isGodComment=false、isDelete=false、isAdvertiser 非 true、 // createdAt <= 当前时间。其余字段对齐线上真实影视评论的形状: // - publisherID 取视频的 publisherID(不是 0) // - title 留空:App 发评论那条路不写 title // - city 固定 "-":发评论的 GetLocationByIP 调用是注释掉的, // 线上 79063 条可见影视评论全都是 "-",给了真城市反而一眼认出是机器人 func buildComments(v video, n int, uids []int64, rnd *rand.Rand) []any { start := v.since() now := time.Now() span := now.Sub(start) if span <= 0 { span = 24 * time.Hour start = now.Add(-span) } // 文案与用户都在同一个视频内不重复 if n > len(uids) { n = len(uids) } ci := rnd.Perm(len(contents))[:n] ui := rnd.Perm(len(uids))[:n] ts := make([]time.Time, n) for i := range ts { // 平方偏置:评论往上架后不久堆,和真实热度曲线一致 u := rnd.Float64() ts[i] = start.Add(time.Duration(u * u * float64(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": v.ID, "objType": "video", "title": "", "type": "VIDEO", "publisherID": v.PublisherID, "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 比 dramacmt 平得多:线上影视评论抽样 70 条,0 赞占到八成, // 偶尔一两个,上双的是个别。热门评论区要求 likeCount>0 并按它倒序, // 所以不能全给 0。 func likeCount(rnd *rand.Rand, text string) int { n := 0 switch r := rnd.Intn(1000); { case r < 720: n = 0 case r < 880: n = 1 + rnd.Intn(3) // 1~3 case r < 960: n = 4 + rnd.Intn(17) // 4~20 case r < 994: n = 21 + rnd.Intn(60) // 21~80 default: n = 81 + rnd.Intn(220) // 81~300 } // 按文案长短压上限——"可以"两个字顶上几百赞太假 switch runes := utf8.RuneCountInString(text); { case runes <= 4 && n > 20: n = 1 + rnd.Intn(20) case runes <= 8 && n > 80: n = 4 + rnd.Intn(77) } return n } // recountAll 按后端自己的口径重算影视的评论计数。 // // 口径取自 web/service/commentser.rebuildSingleObjCommentCount 的 video 分支: // // count = count({objType:"video", objID, status:1, isAdvertiser:false, isDelete:false}) // fakeDelta = max(0, fakeCommentCount - commentCount) // 保留运营手动加的假量 // commentCount = count;fakeCommentCount = count + fakeDelta // // 用后端的口径而不是列表的口径,是为了后台万一触发那条重算不会把数字改回去。 // 两者在影视上没有差别:线上 objType=video 的评论没有一条缺 isAdvertiser 字段。 func recountAll(ctx context.Context, db *mongo.Database, dryRun bool) error { cur, err := db.Collection("comment").Aggregate(ctx, []bson.M{ {"$match": bson.M{"objType": "video", "status": 1, "isAdvertiser": false, "isDelete": 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 } vcur, err := db.Collection("video_info").Find(ctx, bson.M{"newsType": "SP"}, options.Find().SetProjection(bson.M{ "_id": 1, "commentCount": 1, "fakeCommentCount": 1, }).SetBatchSize(2000)) if err != nil { return err } defer vcur.Close(ctx) var ( models []mongo.WriteModel scanned int fixed int negFixed int sumOld int64 sumNew int64 ) flush := func() error { if dryRun || len(models) == 0 { models = nil return nil } _, err := db.Collection("video_info").BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false)) models = nil return err } for vcur.Next(ctx) { var row struct { ID primitive.ObjectID `bson:"_id"` Count int64 `bson:"commentCount"` Fake int64 `bson:"fakeCommentCount"` } if err := vcur.Decode(&row); err != nil { return err } scanned++ want := real[row.ID] delta := row.Fake - row.Count if delta < 0 { delta = 0 } wantFake := want + delta if row.Count == want && row.Fake == wantFake { continue } if row.Fake < 0 { negFixed++ } fixed++ sumOld += row.Fake sumNew += wantFake models = append(models, mongo.NewUpdateOneModel(). SetFilter(bson.M{"_id": row.ID}). SetUpdate(bson.M{"$set": bson.M{ "commentCount": want, "fakeCommentCount": wantFake, }})) if len(models) >= 2000 { if err := flush(); err != nil { return err } } } if err := vcur.Err(); err != nil { return err } if err := flush(); err != nil { return err } tag := "重算评论计数" if dryRun { tag = "重算评论计数 DRY-RUN(未写入)" } fmt.Printf("%s:扫 %d 个影视,修正 %d 个(其中 fakeCommentCount 为负的 %d 个)\n", tag, scanned, fixed, negFixed) fmt.Printf(" 这些视频的 fakeCommentCount 合计 %d -> %d\n", sumOld, sumNew) return nil } 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) }