Files
rootandClaude Opus 5 6f5b0ecb77 [AI] feat: dramacmt 加补齐与重算两个模式
--top-up:--min~--max 当成目标总条数按差额补,已经落在区间内的不动。
拿每次新摇的目标值比对会让重复跑把条数一路往上限顶,所以按 min 判定。

--recount:把 countComment 重算成 App 列表口径的实际可见条数,铺完自动跑一次。
这个字段只会飘不会自己回来,两个来源:发评论时无条件 +1 而审核不通过和删除都
不回减(后端唯一的重算函数明确跳过 drama);contentsync 用 $setOnInsert 赋初值
时 countComment 在清单里,源库新剧带着源站的评论数进来而本地一条评论都没有。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-11-03 11:36:29 +08:00

547 lines
17 KiB
Go
Raw Permalink 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 的剧铺")
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)
}