Compare commits
8
Commits
3dcf6930d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
218ad98519 | ||
|
|
6f5b0ecb77 | ||
|
|
96caa5cda0 | ||
|
|
7ae3264109 | ||
|
|
f45144a3f4 | ||
|
|
ed51664a09 | ||
|
|
0fbd63ba10 | ||
|
|
acb9a34520 |
@@ -0,0 +1,134 @@
|
|||||||
|
# contentsync
|
||||||
|
|
||||||
|
把源库 `91porn_video` 的**帖子 / 视频 / 短剧**增量同步到 `91fans`、`huangguo`、`tangguo` 三个库。
|
||||||
|
|
||||||
|
## 为什么不能直接整文档覆盖
|
||||||
|
|
||||||
|
对源库与目标库做过逐字段抽样比对(video_info 400 条 / 其余各 300 条),
|
||||||
|
74 个字段里只有 9 个分叉,且全部是**站点本地产生**的数据:
|
||||||
|
|
||||||
|
| 集合 | 分叉字段 | 分叉比例 |
|
||||||
|
|---|---|---|
|
||||||
|
| `video_info` | pageViewCount / playCount / fakePlayCount / likeCount / fakeLikeCount / collectCount / forwardCount / purchaseCount / updatedAt | 最高 88% |
|
||||||
|
| `media` | countBrowse / countCollect / countLike / countPurchases / hot / updateTime | 最高 39% |
|
||||||
|
| `tag` | tPlayCount / vPlayCount / playRating / videoCount / updatedAt | 最高 42% |
|
||||||
|
| `media_content` / `media_tag` / `media_tag_dimension` | 无 | 0% |
|
||||||
|
|
||||||
|
内容字段(title、cover、tags、status、newsType、coins…)**完全一致**。
|
||||||
|
|
||||||
|
所以同步必须是字段级的。本地字段分三类,都不能被源库覆盖:
|
||||||
|
|
||||||
|
1. **运行时计数器** —— 各站点自己累积的播放 / 点赞 / 收藏 / 购买数
|
||||||
|
2. **本地定时任务写入** —— `skd/job/hotvalue/video.go` 会重算并写回 `hot`
|
||||||
|
3. **后台运营编排** —— `web/service/vidser/vid.go` 写 `happinessPlazaTop`、`liaoBaTopSort`
|
||||||
|
|
||||||
|
清单见 `spec.go`,新增字段前先确认它属于「源库内容」还是「站点本地」。
|
||||||
|
|
||||||
|
## 写入语义
|
||||||
|
|
||||||
|
一条 upsert 同时处理新增和更新:
|
||||||
|
|
||||||
|
```
|
||||||
|
UpdateOne({_id}, {
|
||||||
|
$set: <内容字段>, // 每次同步都以源库为准
|
||||||
|
$setOnInsert: <本地字段>, // 只在首次插入时给初值,之后永不再碰
|
||||||
|
}, upsert: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
只更新不删除。源库下架通过 `status` 字段自然同步过去。
|
||||||
|
|
||||||
|
## 本地下架保护
|
||||||
|
|
||||||
|
每批写入前先查目标库这批 `_id` 里哪些是 `status: 0`,**整条踢出**——连内容字段
|
||||||
|
也不写。站点运营下架之后,源库无权再把它改回上架。
|
||||||
|
|
||||||
|
水位线仍会越过被跳过的文档,否则它会永久卡住水位线导致每次重扫。
|
||||||
|
|
||||||
|
没有 `status` 字段的集合(media_tag / media_tag_dimension / tag)自然查不出东西,
|
||||||
|
等于不生效。
|
||||||
|
|
||||||
|
## 软删除补扫
|
||||||
|
|
||||||
|
水位线只管新增,所以源库**事后**打上的 `isDelete: true` 永远传不过来:源站把重复
|
||||||
|
剧集软删掉了,目标库却还留着,App 剧集列表就出现「1 1 1 2 2 2」这种重复集。
|
||||||
|
2026-10-29 排查 `琼明神女录` 显示 6 集(实际 2 集)就是这个原因。
|
||||||
|
|
||||||
|
每轮同步结束后对 `media` / `media_content` / `video_info` 补扫一次:把源库
|
||||||
|
`isDelete: true` 的文档在目标库也标成已删除。`--sweep-deleted` 默认开,三个库 6 秒。
|
||||||
|
|
||||||
|
- **只做「源库删了 → 目标也删」这一个方向**,绝不把目标库已删的恢复回来,所以和
|
||||||
|
上面的本地下架保护不冲突
|
||||||
|
- 只写 `isDelete`,不碰 `updateTime`(那是站点本地字段)
|
||||||
|
- 顺带按 `web/service/mediacontentser` 的同一口径重算父级 `currentEpisode` /
|
||||||
|
`freeEpisode`——这两个字段原本只在后台编辑子集时才重算,不补的话集数会停在
|
||||||
|
含重复集的旧值
|
||||||
|
|
||||||
|
一次性修复历史欠账时实测:三个库各 261 条 `media_content` + 18 条 `media`,
|
||||||
|
涉及 28 部短剧。`freeEpisode` 只减不增(减少量正好等于被去掉的重复免费集数)。
|
||||||
|
|
||||||
|
## 媒资库重复集去重
|
||||||
|
|
||||||
|
各站点的 skd 自己也在跑媒资库同步(`common/media/syncMedia.go`,每 30 分钟),
|
||||||
|
它按 `(mediaId, hashId)` 去重、用**自己的 `_id`** 为同一集插一条;本同步再把源库
|
||||||
|
那条插进来,同一集就成了两条。两个源库的 `(mediaId, hashId)` 重复组都是 0,
|
||||||
|
只有目标站有——这是本同步引入的。
|
||||||
|
|
||||||
|
更要紧的是媒资库那条在 `SaleCoin = 0` 时 `listenPermission = 2`(免费),
|
||||||
|
**本该收费的集可以白看**。2026-10-29 的存量:
|
||||||
|
|
||||||
|
| 站点 | 重复组 | 其中免费副本 |
|
||||||
|
|---|---|---|
|
||||||
|
| tangguo / huangguo / 91fans | 各 268 | 各 241 |
|
||||||
|
| hgmj | 97 | 84 |
|
||||||
|
| 51dj | 102 | 97 |
|
||||||
|
|
||||||
|
购买记录全部挂在源库那条上(本地副本购买恒为 0,因为它免费、没人需要买),
|
||||||
|
所以**以源库那条为准**:插入时若发现本地已有同一集,把本地那条 `isActive: false`
|
||||||
|
下架(不删除,便于回查)。
|
||||||
|
|
||||||
|
根治办法是关掉各站 skd 的媒资库同步:`skd.json` 里 `switch.mediaSyncDisabled: true`
|
||||||
|
(见 `skd/job/job.go`)。**源站必须保持开启**,否则新剧集无从进库。
|
||||||
|
关掉之后新剧集改由本同步每天 04:00 从源站带过来,延迟从 30 分钟变成最多 24 小时。
|
||||||
|
|
||||||
|
## 增量水位线
|
||||||
|
|
||||||
|
每个「目标库 × 集合」各记一份水位线,存在 `state.json`:
|
||||||
|
|
||||||
|
常规同步**只取新增**:`_id > lastID`,按 `_id` 升序。ObjectId 单调递增且天然有索引,
|
||||||
|
所以每次只读上次之后产生的文档,耗时与集合总量无关(实测 1~3 秒)。
|
||||||
|
|
||||||
|
**刻意不做存量修改比对。** 源库改了已存在的文档——改标题、下架、调价、换封面——
|
||||||
|
不会同步过来。需要补齐时手动跑 `--full` 重扫全部(三个库约 17 分钟起)。
|
||||||
|
|
||||||
|
这是明确的取舍。参考量级:一次 `--full` 里 `video_info` 更新了约 7300 条
|
||||||
|
(占 273K 的 2.7%)、`media` 更新了约 5900 条(占 20K 的 29%)。
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 增量:只取新增。cron 每天北京时间 04:00 跑的就是这个,耗时数秒
|
||||||
|
/server/sync/run.sh
|
||||||
|
|
||||||
|
# 全量:忽略水位线重扫,补齐存量修改。约 17 分钟起,手动跑
|
||||||
|
/server/sync/run.sh --full
|
||||||
|
|
||||||
|
# 只跑某几个集合 / 干跑
|
||||||
|
/server/sync/contentsync --src "$SRC_URI" --dst "tangguo=$DST_TANGGUO" \
|
||||||
|
--only video_info,media --dry-run --state /server/sync/state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
服务器上无 Go / node / mongosh,所以本地交叉编译后 scp 静态二进制:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o contentsync ./scripts/contentsync/
|
||||||
|
scp contentsync root@172.104.39.32:/server/sync/
|
||||||
|
```
|
||||||
|
|
||||||
|
`/server/sync/env.sh`(600,含连接串)由 `run.sh` source。不走 Jenkins。
|
||||||
|
|
||||||
|
cron:`0 20 * * * /server/sync/run.sh`
|
||||||
|
|
||||||
|
注意机器系统时区是 **UTC**,且 Debian 系的 `cron 3.0pl1` **不支持 `CRON_TZ`**
|
||||||
|
(那是 cronie 的特性),所以只能直接写 UTC 时刻:20:00 UTC = 北京时间 04:00。
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
// contentsync 把源库(91porn_video)的帖子/视频/短剧增量同步到若干目标库。
|
||||||
|
//
|
||||||
|
// 增量策略(每个集合、每个目标库各自记水位线):
|
||||||
|
// - 新增:按 _id 升序游标,_id > lastID。ObjectId 单调递增且天然有索引。
|
||||||
|
// - 修改:按 tsField > lastTS。仅对有该字段且建了索引的集合启用。
|
||||||
|
// 没有可靠时间字段的集合(media_content/media_tag/tag)靠定期 --full 兜底。
|
||||||
|
//
|
||||||
|
// 写入语义:$set 内容字段 + $setOnInsert 站点本地字段(见 spec.go)。
|
||||||
|
// 只更新不删除;源库下架通过 status 字段自然同步。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 源库名由 --src-db 指定。两个源库 91porn_video 与 91dj_video 老数据同源但已分叉
|
||||||
|
// (最新 500 条零重叠),所以每个目标库只能配一个源,不能混。
|
||||||
|
const defaultSrcDBName = "91porn_video"
|
||||||
|
|
||||||
|
type mark struct {
|
||||||
|
LastID string `json:"lastId"`
|
||||||
|
LastTS time.Time `json:"lastTs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// state: target -> collection -> mark
|
||||||
|
type state map[string]map[string]*mark
|
||||||
|
|
||||||
|
func loadState(p string) state {
|
||||||
|
s := state{}
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(b, &s)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveState(p string, s state) error {
|
||||||
|
b, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := p + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, p) // 原子替换,避免崩溃时留下半截状态
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s state) get(target, coll string) *mark {
|
||||||
|
if s[target] == nil {
|
||||||
|
s[target] = map[string]*mark{}
|
||||||
|
}
|
||||||
|
if s[target][coll] == nil {
|
||||||
|
s[target][coll] = &mark{}
|
||||||
|
}
|
||||||
|
return s[target][coll]
|
||||||
|
}
|
||||||
|
|
||||||
|
// dstList 让 --dst 可以重复出现。MongoDB URI 自身含逗号(副本集主机列表),
|
||||||
|
// 所以不能用逗号分隔多个目标。
|
||||||
|
type dstList []string
|
||||||
|
|
||||||
|
func (d *dstList) String() string { return strings.Join(*d, " ") }
|
||||||
|
func (d *dstList) Set(v string) error {
|
||||||
|
*d = append(*d, v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type target struct {
|
||||||
|
name string
|
||||||
|
db *mongo.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
srcURI = flag.String("src", "", "源库 URI")
|
||||||
|
dsts dstList
|
||||||
|
srcDBName = flag.String("src-db", defaultSrcDBName, "源库名")
|
||||||
|
statePath = flag.String("state", "/server/sync/state.json", "水位线状态文件")
|
||||||
|
full = flag.Bool("full", false, "忽略水位线做全量扫描(用于兜底修复)")
|
||||||
|
dryRun = flag.Bool("dry-run", false, "只统计不写入")
|
||||||
|
only = flag.String("only", "", "只同步指定集合,逗号分隔")
|
||||||
|
batchSize = flag.Int("batch", 500, "每批写入条数")
|
||||||
|
sweep = flag.Bool("sweep-deleted", true, "回传源库软删除标记(清理重复剧集)")
|
||||||
|
timeout = flag.Duration("timeout", 30*time.Minute, "整体超时")
|
||||||
|
)
|
||||||
|
flag.Var(&dsts, "dst", "目标库,格式 name=uri;可重复指定多个")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *srcURI == "" || len(dsts) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "必须指定 --src 和 --dst")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
srcCli, err := mongo.Connect(ctx, options.Client().ApplyURI(*srcURI))
|
||||||
|
if err != nil {
|
||||||
|
fatal("连接源库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer srcCli.Disconnect(context.Background())
|
||||||
|
srcDB := srcCli.Database(*srcDBName)
|
||||||
|
|
||||||
|
var targets []target
|
||||||
|
for _, part := range dsts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, uri, ok := strings.Cut(part, "=")
|
||||||
|
if !ok {
|
||||||
|
fatal("--dst 格式错误,应为 name=uri: %s", part)
|
||||||
|
}
|
||||||
|
cli, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
|
||||||
|
if err != nil {
|
||||||
|
fatal("连接目标库 %s 失败: %v", name, err)
|
||||||
|
}
|
||||||
|
defer cli.Disconnect(context.Background())
|
||||||
|
targets = append(targets, target{name: name, db: cli.Database(name)})
|
||||||
|
}
|
||||||
|
|
||||||
|
wanted := map[string]bool{}
|
||||||
|
for _, n := range strings.Split(*only, ",") {
|
||||||
|
if n = strings.TrimSpace(n); n != "" {
|
||||||
|
wanted[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st := loadState(*statePath)
|
||||||
|
started := time.Now()
|
||||||
|
var totalUp, totalIns int
|
||||||
|
failed := false
|
||||||
|
|
||||||
|
for _, spec := range specs {
|
||||||
|
if len(wanted) > 0 && !wanted[spec.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
ins, up, err := syncOne(ctx, srcDB, t, spec, st, *full, *dryRun, *batchSize)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FAIL] %-20s -> %-10s %v\n", spec.Name, t.name, err)
|
||||||
|
failed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalIns += ins
|
||||||
|
totalUp += up
|
||||||
|
fmt.Printf("[ok] %-20s -> %-10s 新增 %6d 更新 %6d\n", spec.Name, t.name, ins, up)
|
||||||
|
if !*dryRun {
|
||||||
|
if err := saveState(*statePath, st); err != nil {
|
||||||
|
fmt.Printf("[WARN] 写状态文件失败: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *sweep {
|
||||||
|
for _, coll := range tombstoneColls {
|
||||||
|
if len(wanted) > 0 && !wanted[coll] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
n, fixed, err := sweepDeleted(ctx, srcDB, t, coll, *dryRun)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FAIL] 补扫软删除 %-14s -> %-10s %v\n", coll, t.name, err)
|
||||||
|
failed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("[ok] 补扫软删除 %-14s -> %-10s 标记 %5d 重算父级 %4d\n", coll, t.name, n, fixed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n合计 新增 %d 更新 %d,耗时 %s\n", totalIns, totalUp, time.Since(started).Round(time.Second))
|
||||||
|
if failed {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncOne(ctx context.Context, srcDB *mongo.Database, t target, spec CollSpec,
|
||||||
|
st state, full, dryRun bool, batchSize int) (ins, up int, err error) {
|
||||||
|
|
||||||
|
m := st.get(t.name, spec.Name)
|
||||||
|
local := map[string]bool{}
|
||||||
|
for _, f := range spec.LocalFields {
|
||||||
|
local[f] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只按 _id 水位线取新增。ObjectId 单调递增且天然有索引,
|
||||||
|
// 所以每次只读上次同步之后产生的文档,跟集合总量无关。
|
||||||
|
//
|
||||||
|
// 刻意不做存量修改比对:源库改了已存在的文档不会同步过来。
|
||||||
|
// 需要补齐时用 --full 手动重扫一次。
|
||||||
|
filter := bson.M{}
|
||||||
|
if !full && m.LastID != "" {
|
||||||
|
oid, e := primitive.ObjectIDFromHex(m.LastID)
|
||||||
|
if e == nil {
|
||||||
|
filter["_id"] = bson.M{"$gt": oid}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ins, up, maxID, err := runPass(ctx, srcDB, t, spec, local, filter,
|
||||||
|
bson.D{{Key: "_id", Value: 1}}, dryRun, batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return ins, up, err
|
||||||
|
}
|
||||||
|
// 水位线只在真跑时推进,干跑不能改状态
|
||||||
|
if !dryRun && maxID != primitive.NilObjectID {
|
||||||
|
m.LastID = maxID.Hex()
|
||||||
|
}
|
||||||
|
return ins, up, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPass(ctx context.Context, srcDB *mongo.Database, t target, spec CollSpec,
|
||||||
|
local map[string]bool, filter bson.M, sortBy bson.D, dryRun bool, batchSize int,
|
||||||
|
) (ins, up int, maxID primitive.ObjectID, err error) {
|
||||||
|
|
||||||
|
srcCur, err := srcDB.Collection(spec.Name).Find(ctx, filter,
|
||||||
|
options.Find().SetSort(sortBy).SetBatchSize(int32(batchSize)).SetNoCursorTimeout(true))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, maxID, err
|
||||||
|
}
|
||||||
|
defer srcCur.Close(ctx)
|
||||||
|
|
||||||
|
dst := t.db.Collection(spec.Name)
|
||||||
|
pending := make([]bson.M, 0, batchSize)
|
||||||
|
scanned, skipped, deduped := 0, 0, 0
|
||||||
|
lastLog := time.Now()
|
||||||
|
|
||||||
|
// blockedIDs 查出这批里哪些在目标库已被本地下架(status:0)。
|
||||||
|
// 目标库没有 status 字段的集合自然查不出东西,等于不生效。
|
||||||
|
blockedIDs := func(docs []bson.M) (map[any]bool, error) {
|
||||||
|
out := map[any]bool{}
|
||||||
|
ids := make([]any, 0, len(docs))
|
||||||
|
for _, d := range docs {
|
||||||
|
ids = append(ids, d["_id"])
|
||||||
|
}
|
||||||
|
c, e := dst.Find(ctx, bson.M{"_id": bson.M{"$in": ids}, "status": 0},
|
||||||
|
options.Find().SetProjection(bson.M{"_id": 1}))
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer c.Close(ctx)
|
||||||
|
for c.Next(ctx) {
|
||||||
|
var row bson.M
|
||||||
|
if e := c.Decode(&row); e == nil {
|
||||||
|
out[row["_id"]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, c.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// localTwins 查出这批源文档在目标库的「同一集另一条」:同属一个 mediaId,
|
||||||
|
// 且 hashId 或 videoUrl 相同,但 _id 不同。
|
||||||
|
//
|
||||||
|
// 各站点自己也在跑媒资库同步(common/media/syncMedia.go),它按
|
||||||
|
// (mediaId, hashId) 去重、用自己的 _id 为同一集插一条;本同步再把源库那条
|
||||||
|
// 插进来,同一集就变成两条,前端剧集列表显示「1 1 2 2」。
|
||||||
|
// 更要紧的是媒资库那条在 SaleCoin=0 时 listenPermission=2(免费),
|
||||||
|
// 等于用户可以白看本该收费的集。
|
||||||
|
//
|
||||||
|
// 必须同时认 videoUrl:媒资库会给同一集重新发 hashId(实测
|
||||||
|
// 「JK学姐不为人知的秘密」源库那条 hashId 6aa23652…、本地那条
|
||||||
|
// 6acaf74c…,videoUrl 却一模一样),只比 hashId 会漏掉这一类。
|
||||||
|
//
|
||||||
|
// 以源库那条为准(购买记录都挂在它上面),把本地那条下架 isActive:false,
|
||||||
|
// 不删除,便于事后回查。
|
||||||
|
localTwins := func(docs []bson.M) (map[any]any, error) {
|
||||||
|
keys := make([]any, 0, len(docs)*2) // 待查的 hashId 与 videoUrl
|
||||||
|
want := map[string]any{} // mediaId|key -> 源库 _id
|
||||||
|
add := func(mid primitive.ObjectID, v string, id any) {
|
||||||
|
if v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keys = append(keys, v)
|
||||||
|
want[mid.Hex()+"|"+v] = id
|
||||||
|
}
|
||||||
|
for _, d := range docs {
|
||||||
|
mid, ok := d["mediaId"].(primitive.ObjectID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h, _ := d["hashId"].(string)
|
||||||
|
u, _ := d["videoUrl"].(string)
|
||||||
|
add(mid, h, d["_id"])
|
||||||
|
add(mid, u, d["_id"])
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// hashId 上有索引;videoUrl 没有,所以放在同一个 $or 里一次查完
|
||||||
|
c, e := dst.Find(ctx, bson.M{"isActive": true, "$or": []bson.M{
|
||||||
|
{"hashId": bson.M{"$in": keys}},
|
||||||
|
{"videoUrl": bson.M{"$in": keys}},
|
||||||
|
}}, options.Find().SetProjection(bson.M{"_id": 1, "mediaId": 1, "hashId": 1, "videoUrl": 1}))
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer c.Close(ctx)
|
||||||
|
out := map[any]any{}
|
||||||
|
for c.Next(ctx) {
|
||||||
|
var row struct {
|
||||||
|
ID primitive.ObjectID `bson:"_id"`
|
||||||
|
MediaID primitive.ObjectID `bson:"mediaId"`
|
||||||
|
HashID string `bson:"hashId"`
|
||||||
|
VideoURL string `bson:"videoUrl"`
|
||||||
|
}
|
||||||
|
if e := c.Decode(&row); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
for _, k := range []string{row.HashID, row.VideoURL} {
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
srcID, ok := want[row.MediaID.Hex()+"|"+k]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sid, ok := srcID.(primitive.ObjectID); ok && sid == row.ID {
|
||||||
|
continue // 就是源库那条自己,不是重复
|
||||||
|
}
|
||||||
|
out[srcID] = row.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, c.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
flush := func() error {
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
docs := pending
|
||||||
|
pending = make([]bson.M, 0, batchSize)
|
||||||
|
|
||||||
|
blocked, e := blockedIDs(docs)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
twins, e := localTwins(docs)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
models := make([]mongo.WriteModel, 0, len(docs))
|
||||||
|
for _, doc := range docs {
|
||||||
|
id := doc["_id"]
|
||||||
|
// 站点运营下架之后,源库无权再改它——整条跳过,内容字段也不写。
|
||||||
|
if blocked[id] {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set := bson.M{}
|
||||||
|
onInsert := bson.M{}
|
||||||
|
for k, v := range doc {
|
||||||
|
if k == "_id" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if local[k] {
|
||||||
|
onInsert[k] = v
|
||||||
|
} else {
|
||||||
|
set[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update := bson.M{}
|
||||||
|
if len(set) > 0 {
|
||||||
|
update["$set"] = set
|
||||||
|
}
|
||||||
|
if len(onInsert) > 0 {
|
||||||
|
update["$setOnInsert"] = onInsert
|
||||||
|
}
|
||||||
|
if len(update) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
models = append(models, mongo.NewUpdateOneModel().
|
||||||
|
SetFilter(bson.M{"_id": id}).SetUpdate(update).SetUpsert(true))
|
||||||
|
// 同一集本地已有另一条(媒资库同步插的),把它下架
|
||||||
|
if twinID, ok := twins[id]; ok {
|
||||||
|
models = append(models, mongo.NewUpdateOneModel().
|
||||||
|
SetFilter(bson.M{"_id": twinID}).
|
||||||
|
SetUpdate(bson.M{"$set": bson.M{"isActive": false}}))
|
||||||
|
deduped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
up += len(models)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res, e := dst.BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false))
|
||||||
|
if e != nil {
|
||||||
|
if res != nil {
|
||||||
|
ins += int(res.UpsertedCount)
|
||||||
|
up += int(res.ModifiedCount)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
ins += int(res.UpsertedCount)
|
||||||
|
up += int(res.ModifiedCount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for srcCur.Next(ctx) {
|
||||||
|
var doc bson.M
|
||||||
|
if e := srcCur.Decode(&doc); e != nil {
|
||||||
|
return ins, up, maxID, e
|
||||||
|
}
|
||||||
|
scanned++
|
||||||
|
// 大集合动辄几十万条,cron 里长时间无输出无法判断死活
|
||||||
|
if time.Since(lastLog) >= 30*time.Second {
|
||||||
|
fmt.Printf(" %-20s -> %-10s 已扫描 %d 条…\n", spec.Name, t.name, scanned)
|
||||||
|
lastLog = time.Now()
|
||||||
|
}
|
||||||
|
// 水位线推进到扫过的最大 _id。即使这条因本地下架被跳过也要推进,
|
||||||
|
// 否则它会永久卡住水位线,导致每次都从它开始重扫。
|
||||||
|
if id, ok := doc["_id"].(primitive.ObjectID); ok && !id.IsZero() {
|
||||||
|
if maxID.IsZero() || id.Hex() > maxID.Hex() {
|
||||||
|
maxID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending = append(pending, doc)
|
||||||
|
if len(pending) >= batchSize {
|
||||||
|
if e := flush(); e != nil {
|
||||||
|
return ins, up, maxID, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := srcCur.Err(); e != nil {
|
||||||
|
return ins, up, maxID, e
|
||||||
|
}
|
||||||
|
if e := flush(); e != nil {
|
||||||
|
return ins, up, maxID, e
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
fmt.Printf(" %-20s -> %-10s 跳过 %d 条(本地已下架)\n", spec.Name, t.name, skipped)
|
||||||
|
}
|
||||||
|
if deduped > 0 {
|
||||||
|
fmt.Printf(" %-20s -> %-10s 下架 %d 条本地重复集(媒资库同步插的同一集)\n", spec.Name, t.name, deduped)
|
||||||
|
}
|
||||||
|
return ins, up, maxID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tombstoneColls 需要回传源库软删除标记的集合。
|
||||||
|
//
|
||||||
|
// 常规同步只按 _id 水位线插新增,源库事后打上的 isDelete 永远传不过来:
|
||||||
|
// 源站把重复剧集软删掉了,目标库却还留着,App 的剧集列表就会出现
|
||||||
|
// 「1 1 1 2 2 2」这种重复集。每轮补扫一次把标记补齐。
|
||||||
|
//
|
||||||
|
// 只做「源库删了 → 目标库也删」这一个方向,绝不把目标库已删的恢复回来,
|
||||||
|
// 所以和「本地下架后不再同步」不冲突。
|
||||||
|
var tombstoneColls = []string{"media", "media_content", "video_info"}
|
||||||
|
|
||||||
|
// sweepDeleted 把源库 isDelete:true 的文档在目标库也标成已删除,
|
||||||
|
// 并按 web/service/mediacontentser 的同一口径重算父级媒体的集数。
|
||||||
|
func sweepDeleted(ctx context.Context, srcDB *mongo.Database, t target, coll string, dryRun bool,
|
||||||
|
) (marked, fixed int, err error) {
|
||||||
|
|
||||||
|
cur, err := srcDB.Collection(coll).Find(ctx, bson.M{"isDelete": true},
|
||||||
|
options.Find().SetProjection(bson.M{"_id": 1}).SetNoCursorTimeout(true))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
|
||||||
|
dst := t.db.Collection(coll)
|
||||||
|
parents := map[primitive.ObjectID]bool{}
|
||||||
|
batch := make([]primitive.ObjectID, 0, 1000)
|
||||||
|
|
||||||
|
flush := func() error {
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := batch
|
||||||
|
batch = make([]primitive.ObjectID, 0, 1000)
|
||||||
|
|
||||||
|
// 先把本轮真正要改的那几条捞出来,顺带记下父级,用于之后重算集数
|
||||||
|
hit := make([]primitive.ObjectID, 0, len(ids))
|
||||||
|
c, e := dst.Find(ctx, bson.M{"_id": bson.M{"$in": ids}, "isDelete": false},
|
||||||
|
options.Find().SetProjection(bson.M{"_id": 1, "mediaId": 1}))
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer c.Close(ctx)
|
||||||
|
for c.Next(ctx) {
|
||||||
|
var row struct {
|
||||||
|
ID primitive.ObjectID `bson:"_id"`
|
||||||
|
MediaID primitive.ObjectID `bson:"mediaId"`
|
||||||
|
}
|
||||||
|
if e := c.Decode(&row); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
hit = append(hit, row.ID)
|
||||||
|
if coll == "media_content" && !row.MediaID.IsZero() {
|
||||||
|
parents[row.MediaID] = true
|
||||||
|
} else if coll == "media" {
|
||||||
|
parents[row.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := c.Err(); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if len(hit) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
marked += len(hit)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 只写 isDelete,不碰 updateTime —— 那是站点本地字段
|
||||||
|
res, e := dst.UpdateMany(ctx, bson.M{"_id": bson.M{"$in": hit}},
|
||||||
|
bson.M{"$set": bson.M{"isDelete": true}})
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
marked += int(res.ModifiedCount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for cur.Next(ctx) {
|
||||||
|
var row struct {
|
||||||
|
ID primitive.ObjectID `bson:"_id"`
|
||||||
|
}
|
||||||
|
if e := cur.Decode(&row); e != nil {
|
||||||
|
return marked, fixed, e
|
||||||
|
}
|
||||||
|
batch = append(batch, row.ID)
|
||||||
|
if len(batch) >= 1000 {
|
||||||
|
if e := flush(); e != nil {
|
||||||
|
return marked, fixed, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := cur.Err(); e != nil {
|
||||||
|
return marked, fixed, e
|
||||||
|
}
|
||||||
|
if e := flush(); e != nil {
|
||||||
|
return marked, fixed, e
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun || len(parents) == 0 {
|
||||||
|
return marked, len(parents), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 父级的 currentEpisode/freeEpisode 原本只在后台编辑子集时重算,
|
||||||
|
// 这里补上,否则集数会停留在含重复集的旧值(如 2 集的剧显示 6 集)。
|
||||||
|
mc := t.db.Collection("media_content")
|
||||||
|
media := t.db.Collection("media")
|
||||||
|
for mid := range parents {
|
||||||
|
base := bson.M{"mediaId": mid, "isActive": true, "isDelete": false}
|
||||||
|
cnt, e := mc.CountDocuments(ctx, base)
|
||||||
|
if e != nil {
|
||||||
|
return marked, fixed, e
|
||||||
|
}
|
||||||
|
free, e := mc.CountDocuments(ctx, bson.M{"mediaId": mid, "listenPermission": 2,
|
||||||
|
"isActive": true, "isDelete": false})
|
||||||
|
if e != nil {
|
||||||
|
return marked, fixed, e
|
||||||
|
}
|
||||||
|
res, e := media.UpdateOne(ctx, bson.M{"_id": mid},
|
||||||
|
bson.M{"$set": bson.M{"currentEpisode": cnt, "freeEpisode": free}})
|
||||||
|
if e != nil {
|
||||||
|
return marked, fixed, e
|
||||||
|
}
|
||||||
|
fixed += int(res.ModifiedCount)
|
||||||
|
}
|
||||||
|
return marked, fixed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(f string, a ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, f+"\n", a...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// CollSpec 描述一个集合怎么同步。
|
||||||
|
//
|
||||||
|
// localFields 是「站点本地字段」:目标库自己产生、源库无权覆盖的字段。
|
||||||
|
// 判定依据有三类,改动前请先确认属于哪一类:
|
||||||
|
// 1. 运行时计数器 —— 各站点自己累积的播放/点赞/收藏/购买数
|
||||||
|
// 2. 本地定时任务写入 —— 如 skd/job/hotvalue 会重算并写回 hot
|
||||||
|
// 3. 后台运营编排 —— 如 web/service/vidser 写的 happinessPlazaTop、liaoBaTopSort
|
||||||
|
//
|
||||||
|
// 这些字段走 $setOnInsert:新文档插入时给初值,之后永不再覆盖。
|
||||||
|
// 其余字段走 $set。
|
||||||
|
//
|
||||||
|
// 注意:常规同步只按 _id 水位线取新增,不比对存量修改,所以 $set 实际只在
|
||||||
|
// 新文档插入、或手动 --full 重扫时才起作用。
|
||||||
|
type CollSpec struct {
|
||||||
|
Name string
|
||||||
|
// LocalFields 走 $setOnInsert,只在首次插入时写入,之后永不覆盖。
|
||||||
|
LocalFields []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各集合的本地字段清单,来自对源库与目标库 400/300 条抽样的逐字段比对,
|
||||||
|
// 并交叉核对了 web/ 与 skd/ 中的写入点。
|
||||||
|
var specs = []CollSpec{
|
||||||
|
{
|
||||||
|
Name: "video_info",
|
||||||
|
LocalFields: []string{
|
||||||
|
// 运行时计数器
|
||||||
|
"playCount", "fakePlayCount", "effectivePlayCount", "pageViewCount",
|
||||||
|
"likeCount", "fakeLikeCount", "collectCount", "forwardCount",
|
||||||
|
"shareCount", "fakeShareCount", "commentCount", "fakeCommentCount",
|
||||||
|
"validCommentCount", "purchaseCount", "rewarded", "fakeRewarded",
|
||||||
|
// 本地 skd 重算:skd/job/hotvalue/video.go
|
||||||
|
"hot", "hotCommentExposureUntil", "lastHotTriggerAt",
|
||||||
|
// 后台运营编排:web/service/vidser/vid.go
|
||||||
|
"happinessPlazaTop", "liaoBaTop", "liaoBaTopSort",
|
||||||
|
"isTopping", "isRecommend", "chosen", "chosenDate",
|
||||||
|
"sortCode", "tagSort", "recoWeight", "upTag", "shareSort", "worksSort",
|
||||||
|
// 本地修改时间戳
|
||||||
|
"updatedAt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "media",
|
||||||
|
LocalFields: []string{
|
||||||
|
"countBrowse", "countCollect", "countComment", "countContentPurchases",
|
||||||
|
"countDisLike", "countLike", "countPurchases", "countView",
|
||||||
|
"hot",
|
||||||
|
// 短剧在各站点的编排位
|
||||||
|
"choice", "choiceSort", "general", "generalSort", "sectionSort",
|
||||||
|
"updateTime", "updatedAt", "updatedAct", "updatedDesc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "media_content",
|
||||||
|
LocalFields: []string{
|
||||||
|
"countBrowse", "countLike", "countPurchases", "fakeCountBrowse",
|
||||||
|
"updateTime", "updatedAt", "updatedAct",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "media_tag",
|
||||||
|
LocalFields: []string{"updatedAt"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "media_tag_dimension",
|
||||||
|
LocalFields: []string{"updatedAt"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tag",
|
||||||
|
LocalFields: []string{
|
||||||
|
"collCount", "fakeCollCount", "fakePlayCount", "likeCount",
|
||||||
|
"playRating", "tPlayCount", "vPlayCount", "videoCount", "hotMark",
|
||||||
|
"updatedAt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "go.mongodb.org/mongo-driver/bson"
|
||||||
|
|
||||||
|
// SeedSpec 一个要播种的集合。Filter 为 nil 表示整表搬。
|
||||||
|
type SeedSpec struct {
|
||||||
|
Name string
|
||||||
|
Filter bson.M
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSpecs 是 91dj_video -> hgmj 的播种清单。
|
||||||
|
//
|
||||||
|
// 只搬「内容」与「配置」,外加用户表的一个子集。刻意排除的几类:
|
||||||
|
// - 交易与资产:transaction / wallet / recharge_order / product_history /
|
||||||
|
// media_buy_record / pay_video_log / advance_order / backpack / video_coupon /
|
||||||
|
// fund_transfer_log / withdraw_order
|
||||||
|
// - 埋点与事件:vip_card_analytics_event(496万) / event(270万) / user_act /
|
||||||
|
// operation_log / ip_block / user_watch_record(474万)
|
||||||
|
// - IM 与 AI 生成内容:im_user(82万) / im_group / chat_message / ai_plaza(15.7万) /
|
||||||
|
// ai_un_dress / ai_change_face / ai_change_face_img / ai_text_to_* / ai_image_to_video
|
||||||
|
// - 用户行为与 UGC:comment(33.7万) / like(17.6万) / collect(9.8万) /
|
||||||
|
// media_bookshelf(8万) / user_follow / user_checkin / invitation / user_feedback
|
||||||
|
// - 运行时任务:task / export_task
|
||||||
|
//
|
||||||
|
// 排除它们的原因:用户表只搬 205 个 uid,这些按 uid 关联的表搬过去会产生大量
|
||||||
|
// 指向不存在用户的孤儿记录;埋点和事件是源站历史,对新站报表无意义。
|
||||||
|
var seedSpecs = []SeedSpec{
|
||||||
|
// ---- 内容 ----
|
||||||
|
{Name: "video_info"}, // 帖子与视频(newsType 区分 SP/COVER/SHORT/PIC/SEED_LINK)
|
||||||
|
{Name: "section_video"}, // 模块下的视频编排
|
||||||
|
{Name: "section"}, // 模块/专题
|
||||||
|
{Name: "media"}, // 短剧主表
|
||||||
|
{Name: "media_content"}, // 剧集内容
|
||||||
|
{Name: "media_tag"}, // 短剧标签
|
||||||
|
{Name: "media_tag_dimension"}, // 标签维度
|
||||||
|
{Name: "tag"}, // 视频标签
|
||||||
|
{Name: "ai_change_face_video"}, // AI 换脸视频模版(模版属于内容,不是用户生成结果)
|
||||||
|
|
||||||
|
// ---- 配置 ----
|
||||||
|
{Name: "sys_conf"}, // 系统通用配置
|
||||||
|
{Name: "module_conf"}, // 模块配置
|
||||||
|
{Name: "product"}, // 商品(会员卡)
|
||||||
|
{Name: "product_benefit"}, // 商品权益
|
||||||
|
{Name: "product_position"}, // 商品位
|
||||||
|
{Name: "vip_card_experiment"}, // 会员卡 A/B 实验配置(源库有 5 条,会在新站生效)
|
||||||
|
{Name: "currency"}, // 币种
|
||||||
|
{Name: "jingang"}, // 金刚区
|
||||||
|
{Name: "banner_jump"}, // Banner 跳转
|
||||||
|
{Name: "official_config"}, // 官方配置
|
||||||
|
{Name: "daily_task"}, // 每日任务
|
||||||
|
{Name: "once_task"}, // 一次性任务
|
||||||
|
{Name: "task_config"}, // 任务配置
|
||||||
|
{Name: "sign_task_config"}, // 签到任务配置
|
||||||
|
{Name: "checkin_config"}, // 签到配置
|
||||||
|
{Name: "checkin_prize"}, // 签到奖品
|
||||||
|
{Name: "integral_config"}, // 积分配置
|
||||||
|
{Name: "video_gold_coin_ser"}, // 金币视频配置
|
||||||
|
{Name: "activity_prize"}, // 活动奖品
|
||||||
|
{Name: "transaction_act"}, // 交易活动配置
|
||||||
|
{Name: "withdraw_channel"}, // 提现渠道
|
||||||
|
{Name: "withdraw_tariff"}, // 提现费率
|
||||||
|
{Name: "source"}, // 资源管理
|
||||||
|
{Name: "filter"}, // 敏感词
|
||||||
|
{Name: "reject_template"}, // 驳回模板
|
||||||
|
{Name: "location"}, // 地区
|
||||||
|
{Name: "announce"}, // 公告
|
||||||
|
{Name: "quick_search"}, // 快捷搜索
|
||||||
|
{Name: "ip_white"}, // IP 白名单(ip_block 是运行时封禁,不搬)
|
||||||
|
{Name: "admin"}, // 后台账号
|
||||||
|
{Name: "authority"}, // 权限
|
||||||
|
{Name: "auto_id"}, // 自增序列:必须搬,否则新站发号可能与已搬内容的 ID 冲撞
|
||||||
|
|
||||||
|
// ---- 用户子集 ----
|
||||||
|
// uid 在源库只连续分配到 205,之后直接跳到 100 万以上,
|
||||||
|
// 所以 <30000 实际就是这 205 个早期账号。
|
||||||
|
{Name: "user", Filter: bson.M{"uid": bson.M{"$lt": 30000}}},
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// dbseed 把源库的一批集合播种到目标库,用于开新站。
|
||||||
|
//
|
||||||
|
// 与 scripts/contentsync 的区别:contentsync 是每天跑的增量同步,只认 _id 水位线;
|
||||||
|
// dbseed 是一次性播种,可带过滤条件,按 _id 升序分批 upsert,中断后重跑幂等。
|
||||||
|
//
|
||||||
|
// 集合清单与过滤条件写在 collections.go,改范围改那里。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
srcURI = flag.String("src", "", "源 URI")
|
||||||
|
srcDB = flag.String("src-db", "", "源库名")
|
||||||
|
dstURI = flag.String("dst", "", "目标 URI")
|
||||||
|
dstDB = flag.String("dst-db", "", "目标库名")
|
||||||
|
only = flag.String("only", "", "只跑指定集合,逗号分隔")
|
||||||
|
dryRun = flag.Bool("dry-run", false, "只统计不写入")
|
||||||
|
batchSize = flag.Int("batch", 500, "每批条数")
|
||||||
|
timeout = flag.Duration("timeout", 6*time.Hour, "整体超时")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
if *srcURI == "" || *srcDB == "" || *dstURI == "" || *dstDB == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "必须指定 --src --src-db --dst --dst-db")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
srcCli, err := mongo.Connect(ctx, options.Client().ApplyURI(*srcURI))
|
||||||
|
if err != nil {
|
||||||
|
fatal("连接源库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer srcCli.Disconnect(context.Background())
|
||||||
|
dstCli, err := mongo.Connect(ctx, options.Client().ApplyURI(*dstURI))
|
||||||
|
if err != nil {
|
||||||
|
fatal("连接目标库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer dstCli.Disconnect(context.Background())
|
||||||
|
|
||||||
|
src := srcCli.Database(*srcDB)
|
||||||
|
dst := dstCli.Database(*dstDB)
|
||||||
|
|
||||||
|
wanted := map[string]bool{}
|
||||||
|
for _, n := range strings.Split(*only, ",") {
|
||||||
|
if n = strings.TrimSpace(n); n != "" {
|
||||||
|
wanted[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
var total int
|
||||||
|
failed := false
|
||||||
|
for _, spec := range seedSpecs {
|
||||||
|
if len(wanted) > 0 && !wanted[spec.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := seedOne(ctx, src, dst, spec, *dryRun, *batchSize)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FAIL] %-26s %v\n", spec.Name, err)
|
||||||
|
failed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
note := ""
|
||||||
|
if spec.Filter != nil {
|
||||||
|
note = " (带过滤)"
|
||||||
|
}
|
||||||
|
fmt.Printf("[ok] %-26s 写入 %7d%s\n", spec.Name, n, note)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n合计写入 %d 条,耗时 %s\n", total, time.Since(started).Round(time.Second))
|
||||||
|
if failed {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedOne(ctx context.Context, src, dst *mongo.Database, spec SeedSpec,
|
||||||
|
dryRun bool, batchSize int) (written int, err error) {
|
||||||
|
|
||||||
|
filter := bson.M{}
|
||||||
|
if spec.Filter != nil {
|
||||||
|
filter = spec.Filter
|
||||||
|
}
|
||||||
|
cur, err := src.Collection(spec.Name).Find(ctx, filter,
|
||||||
|
options.Find().SetSort(bson.D{{Key: "_id", Value: 1}}).
|
||||||
|
SetBatchSize(int32(batchSize)).SetNoCursorTimeout(true))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
|
||||||
|
target := dst.Collection(spec.Name)
|
||||||
|
buf := make([]mongo.WriteModel, 0, batchSize)
|
||||||
|
scanned := 0
|
||||||
|
lastLog := time.Now()
|
||||||
|
|
||||||
|
flush := func() error {
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
models := buf
|
||||||
|
buf = make([]mongo.WriteModel, 0, batchSize)
|
||||||
|
if dryRun {
|
||||||
|
written += len(models)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res, e := target.BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false))
|
||||||
|
if res != nil {
|
||||||
|
written += int(res.UpsertedCount) + int(res.ModifiedCount)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
for cur.Next(ctx) {
|
||||||
|
var doc bson.M
|
||||||
|
if e := cur.Decode(&doc); e != nil {
|
||||||
|
return written, e
|
||||||
|
}
|
||||||
|
scanned++
|
||||||
|
if time.Since(lastLog) >= 30*time.Second {
|
||||||
|
fmt.Printf(" %-26s 已扫描 %d 条…\n", spec.Name, scanned)
|
||||||
|
lastLog = time.Now()
|
||||||
|
}
|
||||||
|
id := doc["_id"]
|
||||||
|
delete(doc, "_id")
|
||||||
|
if len(doc) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 整条替换:目标是新库,没有需要保留的本地字段。
|
||||||
|
// upsert 保证中断重跑幂等。
|
||||||
|
buf = append(buf, mongo.NewUpdateOneModel().
|
||||||
|
SetFilter(bson.M{"_id": id}).
|
||||||
|
SetUpdate(bson.M{"$set": doc}).
|
||||||
|
SetUpsert(true))
|
||||||
|
if len(buf) >= batchSize {
|
||||||
|
if e := flush(); e != nil {
|
||||||
|
return written, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := cur.Err(); e != nil {
|
||||||
|
return written, e
|
||||||
|
}
|
||||||
|
return written, flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = primitive.NilObjectID
|
||||||
|
|
||||||
|
func fatal(f string, a ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, f+"\n", a...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# dramacmt
|
||||||
|
|
||||||
|
给在线短剧铺机器人评论。一次性运营工具,不进 cron。
|
||||||
|
|
||||||
|
## 为什么不用后台已有的评论导入
|
||||||
|
|
||||||
|
`web/api/commentctrl.AddContentExcel` 有三个限制:
|
||||||
|
|
||||||
|
1. 只认视频(按 `vidmod.GetVideoInfo` 查对象),短剧的 `objID` 是 `media._id`,走不通
|
||||||
|
2. 要上传 Excel
|
||||||
|
3. 它的 `userID` 取自 `static.json` 的 `sysUser`——**那些 uid 在 `user` 表里并不存在**
|
||||||
|
|
||||||
|
第 3 点是实际踩到的坑:评论列表填充用户信息时(`app/service/commentser/cmtcoverter.go`)
|
||||||
|
查不到用户**不会丢弃评论,只是把昵称和头像留空**。线上已经有 5962 条这样的
|
||||||
|
「无名无头像」评论(抽查 400 个引用 uid,0 命中)。
|
||||||
|
|
||||||
|
所以本工具自己建一批机器人用户,评论挂在它们身上。
|
||||||
|
|
||||||
|
## 机器人用户
|
||||||
|
|
||||||
|
- uid 从 **1001** 开始往上发,`--users` 控制池子大小。该区间原本完全空闲
|
||||||
|
(`uid 1~17` 是库里已有的 17 个机器人,1000 以下整段不碰)
|
||||||
|
- **池子要够大**:同一部剧内用户不重复,所以 `--users` 必须大于 `--max`;
|
||||||
|
而且池子太小的话同一个昵称头像会在整个评论区刷屏。实测取值:
|
||||||
|
tangguo 5,000(和 `scripts/vidcmt` 共用),其余四站 3,000
|
||||||
|
- uid 必须 `<= 111999`(`common/constant.RobotUIDLimit`)——
|
||||||
|
`commentser.getTrackInfo` 靠这个阈值判定 `isRobot`,后台可按机器人筛选和撤回
|
||||||
|
- uid 池远低于 `auto_id` 的 uid 计数器(当时是 3111989,只增不减),
|
||||||
|
不会和后续真实注册撞号
|
||||||
|
- 昵称按「姓 + 名」随机拼,风格对齐库里现有那 17 个机器人(郏哲、溪焱这种)
|
||||||
|
- 头像**从库里现有用户采样**,不写死路径(各站 `static.json` 里是同一批 90 张)
|
||||||
|
|
||||||
|
`user` 表的唯一索引要当心:
|
||||||
|
|
||||||
|
| 索引 | 类型 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| `devID` | **非 sparse 唯一** | 必须给唯一非空值,用 `ROBOT%07d` |
|
||||||
|
| `token` / `promotionCode` / `mobile` | 部分唯一(`{$gt: ""}`) | 留空串即豁免 |
|
||||||
|
|
||||||
|
已存在的 uid **不覆盖**(昵称头像可能被运营改过),所以可以重复跑。
|
||||||
|
|
||||||
|
## 评论
|
||||||
|
|
||||||
|
要能在 App 里显示,必须满足 `models/v/cmtmod.GetParentCmtList` 的全部条件:
|
||||||
|
|
||||||
|
```
|
||||||
|
level = 1 status = 1 isGodComment = false
|
||||||
|
isDelete = false isAdvertiser 非 true
|
||||||
|
createdAt <= now
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `status`:App 发评论走的 `getTrackInfo` 写的是 `status: 2`(未审核),
|
||||||
|
而列表只认 `1`。所以铺数据必须直接写 `1`,否则一条都不显示。
|
||||||
|
|
||||||
|
其余字段整体对齐线上真实短剧评论的形状,两个容易写错的地方:
|
||||||
|
|
||||||
|
- `type` 是 `"VIDEO"`,不是 `"drama"`——`objType` 才是 `"drama"`。
|
||||||
|
`getTrackInfo` 对所有 objType 都传 `cmtmod.CmtTypeVideo`
|
||||||
|
- `tousername` 落库键名是**小写**的:结构体那个字段只有 `form`/`json` tag,
|
||||||
|
没写 `bson` tag,driver 默认把字段名小写
|
||||||
|
- `city` 必须写 `"-"`:发评论路径里 `GetLocationByIP` 的调用是注释掉的
|
||||||
|
(`app/service/commentser/cmtsend.go:114-116`),线上真实评论**没有一条**带
|
||||||
|
归属地。`City` 会原样出参给 App(`cmtcoverter.go:46`),给真城市等于在评论区
|
||||||
|
挂个「我是机器人」的标。第一版写了随机城市,已改掉,线上那 19,697 条也已回刷
|
||||||
|
|
||||||
|
**`media.countComment` 必须同步 `$inc`**。App 的「总评论条数」在第一页直接读这个
|
||||||
|
字段(`app/api/commentctrl`),不补的话前端显示 0 条、但列表里有评论。
|
||||||
|
|
||||||
|
### 点赞数与时间
|
||||||
|
|
||||||
|
- `createdAt` 随机铺在该剧 `latestPublishedAt`(没有就退 `createdAt`)到现在之间,
|
||||||
|
再按时间排序写入
|
||||||
|
- `likeCount` 走长尾:55% 落 0~5、30% 落 6~30、12% 落 31~120、3% 落 121~400。
|
||||||
|
热门评论区要求 `likeCount > 0` 且按它倒序,所以不能全给 0
|
||||||
|
- 再按文案长短压上限:≤4 字的短评最多 30 赞,≤8 字最多 120。
|
||||||
|
不压的话会出现「可以」两个字拿 164 赞这种明显假的数据
|
||||||
|
- **不写 `like` 集合的记录**。`likeCount` 是展示值,`isLike` 只反映当前请求用户,
|
||||||
|
不建 like 文档不影响功能,也省掉几万条垃圾数据
|
||||||
|
|
||||||
|
文案池在 `content.go`,**637 条**互不重复。单剧内文案和用户都不重复,
|
||||||
|
所以 `--max` 不能超过池子容量(`main.go` 里有硬校验)。
|
||||||
|
|
||||||
|
容量还要留足余量,不能刚好够:铺 200 条时池子只有 220 条的话,每部剧都会抽到
|
||||||
|
九成文案,几千部剧的评论区长得一模一样。现在抽 200 条约占池子三成。
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o /tmp/dramacmt ./scripts/dramacmt/
|
||||||
|
|
||||||
|
# 干跑,看会写多少
|
||||||
|
/tmp/dramacmt --dst "$DST_TANGGUO" --db tangguo --dry-run
|
||||||
|
|
||||||
|
# 只给评论数为 0 的剧铺,每部 20~50 条(默认)
|
||||||
|
/tmp/dramacmt --dst "$DST_TANGGUO" --db tangguo
|
||||||
|
|
||||||
|
# 所有在线短剧都加铺 50~200 条(其余四站 2026-10-30 实际用的参数)
|
||||||
|
/tmp/dramacmt --dst "$DST_HUANGGUO" --db huangguo --users 3000 \
|
||||||
|
--min 50 --max 200 --only-empty=false
|
||||||
|
|
||||||
|
# 补齐模式:把每部剧补到 50~200 条总量(tangguo 2026-10-30 实际用的参数)
|
||||||
|
/tmp/dramacmt --dst "$DST_TANGGUO" --db tangguo --users 5000 \
|
||||||
|
--min 50 --max 200 --only-empty=false --top-up
|
||||||
|
|
||||||
|
# 只重算 countComment,不铺评论(每次 contentsync 带进新剧之后都要跑)
|
||||||
|
/tmp/dramacmt --dst "$DST_TANGGUO" --db tangguo --recount
|
||||||
|
|
||||||
|
# 调试:只跑 3 部,固定种子便于复现
|
||||||
|
/tmp/dramacmt --dst "$DST_TANGGUO" --db tangguo --limit 3 --seed 42
|
||||||
|
```
|
||||||
|
|
||||||
|
`--db` 必须显式传:URI 里的 `authSource` 通常是 `admin`,推不出业务库名。
|
||||||
|
|
||||||
|
### `--top-up`
|
||||||
|
|
||||||
|
默认模式下 `--min~--max` 是**新增**条数,`--top-up` 把它当成**目标总条数**,
|
||||||
|
按差额补,已经够的跳过。
|
||||||
|
|
||||||
|
区别在于底子不空的站:tangguo 的短剧先前已经铺过 20~50 条,照新增算会冲到
|
||||||
|
250 条,和其余四站的 50~200 对不上。代价是每部剧多一次 `CountDocuments`
|
||||||
|
往返(773 部约两分钟,远端副本集的网络延迟占大头)。
|
||||||
|
|
||||||
|
现有条数按 App 列表第 1 页的口径数,不读 `countComment`——那个字段可能是飘的。
|
||||||
|
|
||||||
|
### `--recount`
|
||||||
|
|
||||||
|
把每部短剧的 `countComment` 重算成 App 列表口径的实际可见条数。铺完会自动跑一次,
|
||||||
|
`--no-recount` 可以跳过,`--recount` 则是只重算不铺。
|
||||||
|
|
||||||
|
**这个字段只会飘,不会自己回来**,有两个来源:
|
||||||
|
|
||||||
|
1. 发评论时 `incCommentCount` 无条件 `+1`(`app/service/commentser/cmtsend.go:224`),
|
||||||
|
审核不通过和删除都不回减。后端唯一的重算函数
|
||||||
|
`web/service/commentser.rebuildSingleObjCommentCount` **明确跳过 drama**
|
||||||
|
(`commentAgent.go:268` 只认 video 和 cartoon),所以线上没有任何一条路会修它
|
||||||
|
2. `contentsync` 用 `$setOnInsert` 给新文档的本地字段赋初值,`countComment` 在
|
||||||
|
那份清单里(`scripts/contentsync/spec.go`),于是**源库新剧带着源站的评论数
|
||||||
|
进来,本地却一条评论都没有**
|
||||||
|
|
||||||
|
第 2 条意味着每次 contentsync 带进新短剧之后都要重算一次,否则新剧在 App 里显示
|
||||||
|
「N 条评论」但列表是空的。2026-11-03 实测:一次增量同步带进 16 部新短剧,
|
||||||
|
全都带着源站的 `countComment`。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
tangguo 的连接串在本仓库 `app/config/app.json` 的 `mongo.videoDbUrl`,本地直接跑。
|
||||||
|
**其余四站的连接串只在同步服务器上**(`/server/sync/env.sh`、`hgmj_env.sh`,权限 600),
|
||||||
|
所以照 `scripts/contentsync` 的办法交叉编译静态二进制 scp 过去,让它用服务器自己的
|
||||||
|
连接串,凭据不出那台机器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dramacmt ./scripts/dramacmt/
|
||||||
|
scp dramacmt root@172.104.39.32:/server/sync/
|
||||||
|
```
|
||||||
|
|
||||||
|
服务器上的 `/server/sync/run_cmt.sh` 负责 source 连接串并按站点循环,日志
|
||||||
|
`/var/log/dramacmt.log`。**不进 cron**,手动跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/server/sync/run_cmt.sh # 默认 huangguo 91fans hgmj 51dj
|
||||||
|
SITES="hgmj 51dj" /server/sync/run_cmt.sh # 指定站点
|
||||||
|
DRY=--dry-run /server/sync/run_cmt.sh # 干跑
|
||||||
|
```
|
||||||
|
|
||||||
|
`--top-up` 把 50~200 当成目标总量、已达标的不动,所以可以反复跑。**每次
|
||||||
|
contentsync 带进新短剧之后跑一遍**:新剧既没有评论,又带着源站的 `countComment`,
|
||||||
|
不补的话在 App 里显示「有 N 条评论但列表是空的」。
|
||||||
|
|
||||||
|
2026-11-03 实测一次增量同步之后:四站各有 43 / 43 / 39 / 39 部新剧待补,
|
||||||
|
共写入 19,300 条,重算修正 229 部,耗时 9 秒。
|
||||||
|
|
||||||
|
## 撤回
|
||||||
|
|
||||||
|
机器人用户和评论都带标记,撤回是两条命令:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 撤回评论(软删,保守做法)。uid 上限按当站实际的 --users 取
|
||||||
|
db.comment.updateMany({isRobot: true, objType: "drama", userID: {$gte: 1001, $lte: 5000}},
|
||||||
|
{$set: {isDelete: true}})
|
||||||
|
```
|
||||||
|
|
||||||
|
硬删同理,把 `$set` 换成 `deleteMany`。两种都做完后 `countComment` 要重算,
|
||||||
|
口径和 App 列表第 1 页保持一致(`app/service/commentser.GetTotalComments`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
countDocuments({objID, objType: "drama", level: 1, status: 1,
|
||||||
|
isDelete: false, isAdvertiser: {$in: [null, false]}})
|
||||||
|
```
|
||||||
|
|
||||||
|
短剧和影视不一样:**第 1 页的总条数直接读 `media.countComment`**,第 2 页之后才
|
||||||
|
实时查,所以这个字段不重算前端就会出现「显示 N 条但列表是空的」。
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 评论文案池。短剧观众的常见反应:催更、剧情、演员、画质、追剧感想。
|
||||||
|
//
|
||||||
|
// 每部剧从池子里**不重复**地抽 --min~--max 条,所以池子容量必须远大于 --max,
|
||||||
|
// 否则同一部剧下会出现重复文案(main.go 里有硬校验)。
|
||||||
|
//
|
||||||
|
// 容量还要留足余量:铺 200 条时,池子只有 220 条的话每部剧都会抽到九成文案,
|
||||||
|
// 几千部剧的评论区长得一模一样,一眼假。现在 contents 条数见文件末尾的统计,
|
||||||
|
// 抽 200 条约占池子三成。
|
||||||
|
//
|
||||||
|
// 三类内容故意不收:约炮求联系留地区的、涉及年龄的、非自愿表述的——
|
||||||
|
// 这些是审核该删的东西,不该批量生产。
|
||||||
|
var contents = []string{
|
||||||
|
// 催更
|
||||||
|
"什么时候更新下一集啊", "等更新等得好辛苦", "一天一集太少了吧", "求加更!",
|
||||||
|
"这才几集就没了", "更新太慢了受不了", "蹲下一集", "已经刷完了,等更",
|
||||||
|
"导演快点更啊", "每天来看一眼更没更", "这个点蹲更新的有没有",
|
||||||
|
"追到最新一集了,难受", "坐等下集", "一口气看完,现在开始等更",
|
||||||
|
"更新日历求一个", "周更还是日更啊", "催更催更催更", "别断更就行",
|
||||||
|
"看完这集整个人都空了", "下一集什么时候来",
|
||||||
|
// 剧情
|
||||||
|
"剧情节奏挺紧凑的", "开头就抓人", "这反转有点意思", "编剧有点东西",
|
||||||
|
"前两集平平,后面越来越好看", "伏笔埋得不错", "这个设定挺新鲜",
|
||||||
|
"剧情有点俗但是好看", "看得停不下来", "一集一个高潮,顶不住",
|
||||||
|
"这段看了三遍", "结尾留这么大一个钩子", "男主这操作我没看懂",
|
||||||
|
"女主演技还行", "配角比主角抢戏", "这台词写得挺自然",
|
||||||
|
"节奏快是优点也是缺点", "几句话就把人物立起来了", "这段转折有点生硬",
|
||||||
|
"没想到是这个走向", "跟我猜的完全不一样", "细节经得起推敲",
|
||||||
|
"看到一半就猜到结局了,但还是想看完", "情绪拉得很到位",
|
||||||
|
"第三集那段我笑死", "中间有点拖,后面补回来了", "这条线收得干净",
|
||||||
|
"人物动机讲清楚了,难得", "一集二十分钟信息量不小",
|
||||||
|
// 演员 / 制作
|
||||||
|
"选角选得好", "这个女主我认识,演过别的", "男主挺有观众缘",
|
||||||
|
"妆造在线", "服装道具用心了", "打光比同类好太多", "画质清晰舒服",
|
||||||
|
"配乐加分", "音效做得可以", "剪辑干净利落", "运镜有想法",
|
||||||
|
"布景不廉价,能看出预算", "后期调色挺高级", "台词配音自然",
|
||||||
|
"演员台词功底不错", "表情特写很到位", "这个滤镜有点重",
|
||||||
|
"画面稳,不晃", "收音清楚", "整体制作水准在线",
|
||||||
|
// 平台 / 体验
|
||||||
|
"终于等到上线了", "收藏了慢慢看", "加入追剧列表", "推荐给朋友了",
|
||||||
|
"一直在找这部", "搜了好久才找到", "这部值得推上首页", "希望多上这类剧",
|
||||||
|
"片源很流畅不卡", "加载很快", "画质选项挺全", "倍速看也不违和",
|
||||||
|
"希望出合集", "能不能一次性放完", "等全集再看", "先屏蔽剧透",
|
||||||
|
"这部必须三连", "点赞收藏转发", "期待第二季", "希望有续集",
|
||||||
|
// 追剧感想
|
||||||
|
"熬夜看完了", "本来想看一集结果通宵", "上班偷偷看完的", "通勤路上刷完",
|
||||||
|
"周末就靠这个了", "下饭神剧", "睡前看一集刚好", "停不下来,明天还要上班",
|
||||||
|
"看完睡不着", "算是今年看过比较好的", "比预期好很多", "完全超出预期",
|
||||||
|
"没想到这么上头", "入坑了", "二刷中", "三刷依然好看",
|
||||||
|
"回头又看了一遍开头", "看第二遍发现好多细节", "这部我能反复看",
|
||||||
|
"推给了家里人一起看",
|
||||||
|
// 讨论 / 互动
|
||||||
|
"有没有人和我一样在等更新的", "评论区都是催更的", "来看看大家怎么说",
|
||||||
|
"看完来评论区冒个泡", "第一次在这留评", "有同感的举手",
|
||||||
|
"求推荐类似的", "看完这部不知道看什么了", "有没有同款推荐",
|
||||||
|
"大家觉得结局会怎样", "赌一包烟是开放结局", "我站男主这条线",
|
||||||
|
"我更喜欢配角那条线", "希望别烂尾", "前面这么好,千万别烂尾",
|
||||||
|
"留个记号,更新了回来看", "打卡第一集", "打卡追剧中", "签到",
|
||||||
|
"又来重温了",
|
||||||
|
// 短评
|
||||||
|
"好看", "不错", "可以", "挺香的", "顶", "牛", "质量很高", "喜欢",
|
||||||
|
"有被惊艳到", "值得一看", "强烈推荐", "必看", "够味", "带劲",
|
||||||
|
"上头", "绝了", "太香了", "爱了", "服了", "有点东西",
|
||||||
|
"看得很爽", "过瘾", "真不错", "超出预期", "名副其实",
|
||||||
|
"口碑不是吹的", "确实好看", "不虚此名", "看完直呼过瘾", "舒服了",
|
||||||
|
// 稍长一点的
|
||||||
|
"刷到这部算是赚了,比最近看的几部都强",
|
||||||
|
"冲着封面点进来的,没想到内容也撑得住",
|
||||||
|
"一开始没抱期待,看完回来补个评论",
|
||||||
|
"这种题材拍成这样已经很不容易了",
|
||||||
|
"同类里算上游水平,推荐",
|
||||||
|
"剧情和制作都在线,难得",
|
||||||
|
"看完顺手把演员都搜了一遍",
|
||||||
|
"这个时长刚好,不注水",
|
||||||
|
"不拖沓是最大的优点",
|
||||||
|
"每集结尾都卡在关键处,太会了",
|
||||||
|
"连着看了几集,眼睛都不想眨",
|
||||||
|
"剧情不复杂但拍得很稳",
|
||||||
|
"演员选得贴脸,代入感强",
|
||||||
|
"看完才发现已经凌晨两点",
|
||||||
|
"最近唯一追完的一部",
|
||||||
|
"朋友推荐的,果然没踩雷",
|
||||||
|
"比我想的好看,已经推荐出去了",
|
||||||
|
"更新频率再快点就完美了",
|
||||||
|
"希望这个班底多出点作品",
|
||||||
|
"这部的完成度明显比平均水平高",
|
||||||
|
|
||||||
|
// 催更(续)
|
||||||
|
"今天也没更新", "怎么还不更", "更新了吗更新了吗", "蹲一个更新预告",
|
||||||
|
"这周还更不更了", "断更第三天", "等得我都快忘了剧情了",
|
||||||
|
"求导演行行好", "一集不够塞牙缝", "能不能两集两集地放",
|
||||||
|
"我愿意多等几天换一集长的", "刚追上就断更", "更到哪了有人知道吗",
|
||||||
|
"评论区有人知道更新时间吗", "设了提醒还是怕漏", "开了通知就等它响",
|
||||||
|
"每天固定时间来刷", "看完上一集就在这等了", "再不更我就要去看别的了",
|
||||||
|
"等更的日子不好过", "更新速度跟不上我的手速", "一更我就来",
|
||||||
|
"没更新先来点个赞", "占个位置等更新", "更新了记得回来找我",
|
||||||
|
"这个坑我跳了,等着被填", "追更第一天打卡", "追更第十天了",
|
||||||
|
"从第一集等到现在", "我已经把前面都重看一遍了", "先看别的垫一垫,等更",
|
||||||
|
"求个更新时间表", "是不是要等下周", "放假前能更完吗",
|
||||||
|
"希望别拖到明年", "一次性更完我给你打钱", "愿意为加更付费",
|
||||||
|
"加更一集吧求你了", "导演你听得到吗", "催更大队集合",
|
||||||
|
"今天来晚了,更新了没",
|
||||||
|
|
||||||
|
// 剧情 / 编剧(续)
|
||||||
|
"这集信息量有点大", "剧情推进得很干脆", "不拖泥带水",
|
||||||
|
"每集都有新进展", "没有一集是废的", "该交代的都交代了",
|
||||||
|
"这个悬念吊得人难受", "刚解开一个又来一个", "线索给得挺克制",
|
||||||
|
"看第二遍才发现第一集就埋了", "回头看开头全是暗示",
|
||||||
|
"这个巧合有点刻意", "剧情逻辑基本站得住", "没有明显的硬伤",
|
||||||
|
"情绪转折处理得不错", "矛盾爆发那段很带劲", "冲突写得真实",
|
||||||
|
"人物关系不复杂,看得清楚", "群像写得不错", "每个人都有自己的算盘",
|
||||||
|
"反派终于有脑子了", "反派不蠢是加分项", "坏人坏得有理由",
|
||||||
|
"主角不圣母,这点好", "主角终于会还手了", "主角这波操作解气",
|
||||||
|
"看得我拳头都硬了", "这段太解压了", "憋了几集终于爆发",
|
||||||
|
"前期憋屈后期爽", "欲扬先抑用得好", "铺垫没白铺",
|
||||||
|
"结尾收得有点急", "结局有点仓促", "差一个交代就完美了",
|
||||||
|
"留白留得恰好", "开放结局挺好", "不想要强行圆满的结局",
|
||||||
|
"希望别为了续集硬留悬念", "这个钩子我咬了", "下一季该怎么接",
|
||||||
|
"要是能拍成长剧也不错", "这个题材可以深挖", "设定其实很有潜力",
|
||||||
|
"世界观交代得挺清楚", "规则讲明白了,看得不累",
|
||||||
|
"时间线有点乱", "闪回太多了容易晕", "插叙用得还算克制",
|
||||||
|
"叙事视角切换得流畅", "多线并进处理得不错", "两条线终于合上了",
|
||||||
|
"这条支线有点多余", "支线比主线好看", "希望支线也给个交代",
|
||||||
|
"台词有金句", "有几句台词挺有嚼头", "这句台词我记下了",
|
||||||
|
"对白不说教,难得", "没有强行煽情", "该克制的地方克制住了",
|
||||||
|
"该狠的地方够狠", "尺度把握得刚好", "分寸感不错",
|
||||||
|
"笑点踩在点上", "不尴尬的幽默", "该严肃的时候立得住",
|
||||||
|
"情绪没有断层", "看完有点意犹未尽", "想知道后面怎么样了",
|
||||||
|
|
||||||
|
// 角色 / CP
|
||||||
|
"女主这个性格我喜欢", "女主不恋爱脑真好", "女主终于不装了",
|
||||||
|
"女主这段哭戏到位", "男主人设立得住", "男主有点东西",
|
||||||
|
"男主前期有点闷,后面好了", "男二比男主有意思",
|
||||||
|
"女二这条线我站", "配角个个有名有姓", "配角撑起半部剧",
|
||||||
|
"这个配角我记住了", "闺蜜这个角色太真实了", "兄弟情写得不错",
|
||||||
|
"反派的戏最好看", "反派一出来我就精神", "坏人演得真坏",
|
||||||
|
"这对我站", "这对有化学反应", "这俩站一起就来电",
|
||||||
|
"CP感很强", "磕到了", "这糖我吃", "终于有进展了",
|
||||||
|
"这进度条我满意", "这段互动好自然", "眼神戏比台词管用",
|
||||||
|
"两个人之间的拉扯挺细腻", "别虐了求放过", "虐得我有点上气不接下气",
|
||||||
|
"希望他们好好的", "给个好结局行吗", "别搞误会那一套",
|
||||||
|
"误会桥段能不能少点", "没有狗血误会就很好", "成年人之间直接说话真好",
|
||||||
|
"角色都挺讨人喜欢", "没有让人想快进的角色", "没有讨人厌的工具人",
|
||||||
|
"每个人的选择都能理解", "没有一个人是纯坏的", "人物都有灰度",
|
||||||
|
"这个角色挺立体", "角色成长看得见", "主角有变化,不是一成不变",
|
||||||
|
|
||||||
|
// 演员 / 演技(续)
|
||||||
|
"这几个演员都挺生面孔,但不出戏", "新人演得比预期好",
|
||||||
|
"女主眼睛会说话", "男主哭戏有层次", "这段无台词的戏演得好",
|
||||||
|
"演员状态在线", "演员之间很有默契", "对手戏火花四射",
|
||||||
|
"台词念得清楚,不含糊", "原声出演加分", "不是后期配的,真好",
|
||||||
|
"口型对得上,舒服", "方言用得挺地道", "口音有点出戏",
|
||||||
|
"演技撑得住特写", "特写镜头经得起看", "微表情处理得细",
|
||||||
|
"情绪收放自如", "不用力过猛", "没有夸张的表演",
|
||||||
|
"反派演员气场很足", "这个演员适合演这种角色", "选角很贴脸",
|
||||||
|
"配角的演技反而更稳", "老演员一出场就不一样",
|
||||||
|
"几个年轻演员都在进步", "希望他们都能火", "顺手关注了女主",
|
||||||
|
"去搜了演员的其他作品", "这个班底值得追",
|
||||||
|
|
||||||
|
// 制作 / 画质 / 配乐(续)
|
||||||
|
"开头曲挺好听", "主题曲求歌名", "配乐用得克制,不抢戏",
|
||||||
|
"这段BGM上来我鸡皮疙瘩", "音乐起得恰到好处", "没有滥用煽情音乐",
|
||||||
|
"音效细节到位", "环境音做得真实", "收音干净",
|
||||||
|
"混音舒服,不用调音量", "人声和音乐比例合适",
|
||||||
|
"画面构图讲究", "镜头语言有想法", "转场设计得不错",
|
||||||
|
"长镜头用得漂亮", "手持镜头不晕", "打光把人拍好看了",
|
||||||
|
"夜景拍得清楚", "暗部细节还在", "色调统一,看着舒服",
|
||||||
|
"调色不油腻", "没有滥用滤镜", "美术质感在线",
|
||||||
|
"置景细节经得起看", "道具没有穿帮", "服装符合人物身份",
|
||||||
|
"妆造没有脱离剧情", "发型换得有道理", "造型没有悬浮感",
|
||||||
|
"剪辑节奏舒服", "没有多余的空镜", "该快的快该慢的慢",
|
||||||
|
"字幕排版清楚", "字幕没有错别字", "分辨率够用了",
|
||||||
|
"竖屏拍成这样不容易", "竖屏构图没浪费", "适配手机看刚好",
|
||||||
|
|
||||||
|
// 吐槽 / 批评
|
||||||
|
"中间几集明显在水", "有两集可以删掉", "这段注水了",
|
||||||
|
"重复镜头有点多", "回忆片段太长", "片头片尾加起来太占时长",
|
||||||
|
"广告有点多", "插播位置很烦", "能不能别在高潮处插广告",
|
||||||
|
"这集结尾有点敷衍", "转折太生硬", "为了反转而反转",
|
||||||
|
"后期有点崩", "前紧后松", "开头惊艳结尾普通",
|
||||||
|
"期待值被自己拉太高了", "没有宣传得那么好", "标题有点夸张",
|
||||||
|
"封面和内容风格不太一样", "简介写得有点误导",
|
||||||
|
"配角消失得莫名其妙", "这条线没交代就断了", "伏笔忘了收",
|
||||||
|
"有个bug没人发现吗", "穿帮了一处", "这里前后矛盾",
|
||||||
|
"时间线对不上", "第五集和第七集说法不一致",
|
||||||
|
"台词有点书面", "念起来不太像人话", "有几句很尬",
|
||||||
|
"音乐有点抢戏", "配乐太满了,喘不过气", "滤镜重了点",
|
||||||
|
"有些镜头有点糊", "夜戏看不清", "声音忽大忽小",
|
||||||
|
"节奏有点慢,建议1.25倍速", "前三集需要耐心", "过了前三集就好了",
|
||||||
|
"不是我的口味但能看出用心", "题材不新但拍得认真",
|
||||||
|
"套路还是那些套路", "能猜到走向,但看着不难受",
|
||||||
|
|
||||||
|
// 平台 / 体验 / 求资源(续)
|
||||||
|
"终于在这找到全集了", "别的地方都是片段", "这里的片源最清楚",
|
||||||
|
"求个下载", "能不能支持缓存", "想离线看",
|
||||||
|
"投屏看更爽", "大屏看质感更好", "耳机听音效完全不一样",
|
||||||
|
"建议戴耳机", "半夜看记得关声音", "音量先调小",
|
||||||
|
"收藏夹又多一部", "加到稍后再看了", "分享给群里了",
|
||||||
|
"推荐给对象一起看", "和家人一起看的", "闺蜜推荐来的",
|
||||||
|
"评论区看到推荐才点进来的", "首页推给我的,没失望",
|
||||||
|
"算法这次推得准", "希望多推这种", "同类的都给我来一打",
|
||||||
|
"求推荐同题材的", "看完想找类似的", "有没有同一个班底的作品",
|
||||||
|
"这个系列前作也好看", "先看这部再去补前作", "顺序看不影响理解",
|
||||||
|
"单集独立,随便从哪看都行", "建议从第一集看", "别跳着看",
|
||||||
|
|
||||||
|
// 追剧场景(续)
|
||||||
|
"午休看了两集", "等车的时候刷完一集", "地铁上看完的",
|
||||||
|
"在被窝里看完的", "饭都忘了吃", "外卖凉了才想起来",
|
||||||
|
"一边加班一边偷看", "摸鱼看完的", "开会时候偷看了一集",
|
||||||
|
"周末一口气刷完", "下雨天在家刷剧最舒服", "假期就靠它了",
|
||||||
|
"睡前看一集结果看到三点", "本来定了闹钟,结果没睡", "眼睛熬红了",
|
||||||
|
"第二天起不来也值", "看完直接精神了", "看完还在回味",
|
||||||
|
"关了屏幕还在想剧情", "脑子里一直循环那段", "走路都在想后续",
|
||||||
|
"今天第三遍了", "二刷比一刷好看", "三刷发现了新细节",
|
||||||
|
"每年都会重看一遍", "翻出来重温", "又来回味名场面",
|
||||||
|
"专门回来看第七集那段", "那段我单独存下来了", "截了好几张图",
|
||||||
|
"做了壁纸", "剪了个片段", "推荐语都想好了",
|
||||||
|
|
||||||
|
// 讨论 / 互动 / 打卡(续)
|
||||||
|
"有人看到这条吗", "来晚了,坐最后一排", "前排占座",
|
||||||
|
"考古的举个手", "两年后还有人在看吗", "翻评论区比看剧还有意思",
|
||||||
|
"评论区全是神评", "笑点都在评论区", "来评论区取经",
|
||||||
|
"看完回来对答案", "果然大家都注意到了", "跟我想的一样",
|
||||||
|
"只有我觉得男二更好吗", "有没有人和我站同一边",
|
||||||
|
"讨论一下结局", "大家猜猜下一集", "我押反转",
|
||||||
|
"打赌下集有人要走", "猜中了来找我", "我先立个贴等反转",
|
||||||
|
"留个脚印", "签到第一天", "每日打卡", "打卡第二遍",
|
||||||
|
"日常来看一眼", "顺手留个评", "第一次留言给这部",
|
||||||
|
"必须留个评论支持", "给个好评不过分", "值得一条五星",
|
||||||
|
"已经推荐三个人了", "拉人入坑成功", "安利出去了",
|
||||||
|
|
||||||
|
// 短评 / 弹幕式(续)
|
||||||
|
"香", "顶一个", "妙", "稳", "可", "真顶", "有料", "够劲",
|
||||||
|
"太对了", "就是这个感觉", "对味了", "正合口味", "拿下",
|
||||||
|
"收下了", "入库", "存了", "顶上去", "必须顶", "帮顶一下",
|
||||||
|
"顶到首页", "不火说不过去", "凭什么没人看", "宝藏",
|
||||||
|
"挖到宝了", "捡到了", "赚了", "值", "回本了", "不亏",
|
||||||
|
"有被治愈", "看完心情好了", "解压", "很上瘾", "沦陷了",
|
||||||
|
"栽了", "一发入魂", "直接命中", "正中靶心", "我的审美被拿捏了",
|
||||||
|
"懂我", "这个我熟", "对上了", "好评", "满分", "五星",
|
||||||
|
"九分起步", "值得高分", "名列前茅", "口碑之作", "良心作品",
|
||||||
|
"用心了", "看得出诚意", "不糊弄观众", "没有敷衍",
|
||||||
|
"难得的好东西", "少见的用心", "这种质量多来点",
|
||||||
|
|
||||||
|
// 稍长一点的(续)
|
||||||
|
"从第一集追到现在,质量一直稳得住,没有中途垮掉",
|
||||||
|
"难得遇到不靠狗血冲突推进剧情的短剧,看着不累",
|
||||||
|
"同类型里我看了不少,这部在剧本上明显多花了功夫",
|
||||||
|
"二十分钟一集能把人物和冲突都立起来,编剧功力不差",
|
||||||
|
"没有强行拉长,也没有赶进度,节奏感很舒服",
|
||||||
|
"第一集还有点生涩,从第三集开始整个水准上了一个台阶",
|
||||||
|
"演员不是熟脸,反而没有偶像剧那种悬浮感",
|
||||||
|
"布景和服装都不糊弄,一看就知道预算花在了该花的地方",
|
||||||
|
"主角做的每个选择都有前因,不是编剧硬塞给他的",
|
||||||
|
"以为是老套路,结果中段来了个我完全没想到的转向",
|
||||||
|
"前半段的铺垫在后半段全都用上了,这才叫埋伏笔",
|
||||||
|
"看完回去重看第一集,发现开场那几句台词全是暗示",
|
||||||
|
"最喜欢的是它没有把观众当傻子,该留白的地方留白了",
|
||||||
|
"情感戏处理得克制,没有大段哭诉,反而更打人",
|
||||||
|
"反派不是为了恶而恶,动机写清楚了,这点就比很多剧强",
|
||||||
|
"结局不算圆满,但是合理,比硬凑一个大团圆好",
|
||||||
|
"唯一的遗憾是时长,再多三集能把支线交代得更完整",
|
||||||
|
"一口气看完才发现天都亮了,这种体验很久没有过了",
|
||||||
|
"推荐给了身边三个人,没有一个说不好看的",
|
||||||
|
"如果有第二季,原班人马回来我肯定第一时间追",
|
||||||
|
"这部的完成度放在长剧里也不算差,只是时长短",
|
||||||
|
"短剧能拍到这个水准,说明问题从来不是形式",
|
||||||
|
"看惯了注水的,再看这种干净利落的叙事特别舒服",
|
||||||
|
"每集结尾都停在最想知道后面的地方,太会勾人了",
|
||||||
|
"镜头、配乐、表演三样都没掉链子,难得的整体感",
|
||||||
|
"没有一处让我想按快进,这在短剧里已经很稀有了",
|
||||||
|
"题材不算新鲜,但处理方式让它和同类拉开了差距",
|
||||||
|
"最打动我的是细节,很多一闪而过的东西都站得住",
|
||||||
|
"重看第二遍反而更喜欢,第一遍光顾着看剧情了",
|
||||||
|
"算是今年追过的里面最省心的一部,没有中途失望",
|
||||||
|
"从头到尾没有降智情节,角色都在正常思考",
|
||||||
|
"该交代的都交代清楚了,看完没有留下一堆疑问",
|
||||||
|
"情绪给得满但不腻,收得住,这个火候不好掌握",
|
||||||
|
"值得一个认真的长评,可惜我写不出更好的话",
|
||||||
|
"看完去搜了班底,打算把他们的作品都补一遍",
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "math/rand"
|
||||||
|
|
||||||
|
// 机器人用户的昵称按「姓 + 名」随机拼,和库里现有那 17 个机器人用户的风格一致
|
||||||
|
// (郏哲、溪焱、书锦素这种)。姓 103 个 × 名 120 个,重名概率够低。
|
||||||
|
var surnames = []string{
|
||||||
|
"赵", "钱", "孙", "李", "周", "吴", "郑", "王", "冯", "陈",
|
||||||
|
"褚", "卫", "蒋", "沈", "韩", "杨", "朱", "秦", "尤", "许",
|
||||||
|
"何", "吕", "施", "张", "孔", "曹", "严", "华", "金", "魏",
|
||||||
|
"陶", "姜", "戚", "谢", "邹", "喻", "柏", "水", "窦", "章",
|
||||||
|
"云", "苏", "潘", "葛", "奚", "范", "彭", "郎", "鲁", "韦",
|
||||||
|
"昌", "马", "苗", "凤", "花", "方", "俞", "任", "袁", "柳",
|
||||||
|
"酆", "鲍", "史", "唐", "费", "廉", "岑", "薛", "雷", "贺",
|
||||||
|
"倪", "汤", "滕", "殷", "罗", "毕", "郝", "邬", "安", "常",
|
||||||
|
"乐", "于", "时", "傅", "皮", "齐", "康", "伍", "余", "元",
|
||||||
|
"卜", "顾", "孟", "平", "黄", "和", "穆", "萧", "尹", "姚",
|
||||||
|
"邵", "湛", "汪",
|
||||||
|
}
|
||||||
|
|
||||||
|
var givens = []string{
|
||||||
|
"哲", "焱", "锦素", "彤", "水生", "嘉聪", "理源", "霖", "钧莎", "香",
|
||||||
|
"亦旭", "朋翰", "寒健", "沁", "旭", "怡言", "鹏阳", "子轩", "思远", "若曦",
|
||||||
|
"曼青", "语桐", "宇辰", "泽楷", "家豪", "静怡", "雨萱", "梓涵", "一诺", "欣然",
|
||||||
|
"书瑶", "明轩", "睿杰", "佳怡", "子墨", "艺涵", "念慈", "向晚", "星河", "听澜",
|
||||||
|
"知微", "清和", "望舒", "照邻", "渐鸿", "慕白", "青山", "行舟", "无眠", "半夏",
|
||||||
|
"长风", "衔月", "疏影", "烟岚", "问荆", "怀瑾", "映雪", "拾音", "观鱼", "枕书",
|
||||||
|
"伯言", "仲卿", "叔夜", "季同", "元朗", "子期", "牧之", "介甫", "东篱", "南山",
|
||||||
|
"西岭", "北辰", "承安", "守拙", "明诚", "致远", "思齐", "见深", "存厚", "知行",
|
||||||
|
"宸", "珩", "珏", "琛", "璟", "瑜", "琰", "瑄", "璨", "瑾",
|
||||||
|
"岚", "屿", "洲", "沅", "湘", "淮", "汀", "泠", "泓", "澈",
|
||||||
|
"晞", "暄", "昀", "晗", "旸", "曜", "昭", "晟", "晏", "晔",
|
||||||
|
"桐", "梧", "榕", "楠", "柘", "槿", "樾", "檀", "蓁", "荔",
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomName(rnd *rand.Rand) string {
|
||||||
|
return surnames[rnd.Intn(len(surnames))] + givens[rnd.Intn(len(givens))]
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# vidcmt
|
||||||
|
|
||||||
|
给在线影视(`video_info` 里 `newsType=SP` 的长视频)铺机器人评论,并按后端自己的
|
||||||
|
口径重算 `commentCount` / `fakeCommentCount`。一次性运营工具,不进 cron。
|
||||||
|
|
||||||
|
`scripts/dramacmt` 是同一件事的短剧版,两边的坑大部分重合,差异见下。
|
||||||
|
|
||||||
|
## 为什么不是统一 20~50 条
|
||||||
|
|
||||||
|
影视和短剧差两个数量级,而且**真实评论区本来就很稀**。线上 tangguo 实测:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 在线影视(`newsType=SP, status=1`) | 105,671 个 |
|
||||||
|
| 其中有可见一级评论的 | 4,437 个 |
|
||||||
|
| 真实可见影视评论总量 | 79,028 条 |
|
||||||
|
| 有评论的对象里只有 1 条的 | 9,291 个 |
|
||||||
|
|
||||||
|
照短剧那样每个视频 20~50 条,就是 ~310 万条 `comment` 文档(集合原本一共
|
||||||
|
35 万条),而且长尾视频评论数比播放量还多,一眼假。
|
||||||
|
|
||||||
|
所以按播放量(`fakePlayCount`)分层,让评论数和热度正相关:
|
||||||
|
|
||||||
|
| 播放量 | 每个视频 | tangguo 实际 |
|
||||||
|
|---|---|---|
|
||||||
|
| ≥ 200,000 | 20~50 条 | ~61 个视频 |
|
||||||
|
| 50,000 ~ 200,000 | 8~20 条 | ~890 个 |
|
||||||
|
| 10,000 ~ 50,000 | 3~8 条 | ~13,300 个 |
|
||||||
|
| 1,000 ~ 10,000 | 1~3 条 | ~58,000 个 |
|
||||||
|
| < 1,000 | 1 条 | ~29,100 个 |
|
||||||
|
|
||||||
|
合计 ~23 万条,分层表在 `main.go` 的 `tiers`。
|
||||||
|
|
||||||
|
## 两个计数字段
|
||||||
|
|
||||||
|
影视的评论数和短剧不是一套机制:
|
||||||
|
|
||||||
|
- 评论列表第 1 页的「总条数」是**实时查**的(`commentser.GetTotalComments`),
|
||||||
|
不读计数器——和短剧第 1 页直接读 `media.countComment` 不同
|
||||||
|
- 卡片、搜索结果、「最多评论」榜用的是 **`fakeCommentCount`**
|
||||||
|
(`app/service/searcher/video.go:203`、`vidmod.VIDListSortByFakeCommentCount`)
|
||||||
|
- 真实发评论走 `vidmod.IncCommentCount`,两个字段一起 `$inc`
|
||||||
|
(`models/v/vidmod/vid.go:1996`)
|
||||||
|
|
||||||
|
本工具铺评论时同样两个字段一起 `$inc`,跑完再做一趟重算,口径取自后端自己的
|
||||||
|
修复函数 `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` 字段。
|
||||||
|
|
||||||
|
重算同时修掉两类历史脏数据:
|
||||||
|
|
||||||
|
- **`fakeCommentCount` 为负数**:`web/vidhelp/redis.go:87` 删评论时 `--`,
|
||||||
|
减穿了。线上实测 3,966 个在线影视的卡片显示负数评论数
|
||||||
|
- **有计数没评论**:36,549 个在线影视 `commentCount>0`,但只有 4,437 个真有
|
||||||
|
可见评论——剩下的是克隆库带过来的、或评论被硬删了
|
||||||
|
|
||||||
|
## 和 dramacmt 的字段差异
|
||||||
|
|
||||||
|
抽样线上真实影视评论对出来的,三处和短剧不一样:
|
||||||
|
|
||||||
|
| 字段 | 影视 | 短剧 |
|
||||||
|
|---|---|---|
|
||||||
|
| `publisherID` | **视频的 `publisherID`** | 0 |
|
||||||
|
| `title` | 留空 | 剧名 |
|
||||||
|
| `city` | **`"-"`** | (dramacmt 写了真城市) |
|
||||||
|
|
||||||
|
`city` 这条要特别注意:发评论路径里 `GetLocationByIP` 的调用是**注释掉的**
|
||||||
|
(`app/service/commentser/cmtsend.go:114-116`),所以线上 79,063 条可见影视评论
|
||||||
|
**全部**是 `"-"`,真实短剧评论也是。`City` 会原样出参给 App
|
||||||
|
(`cmtcoverter.go:46`),给了真城市等于在评论区挂个「我是机器人」的标。
|
||||||
|
|
||||||
|
`likeCount` 分布也比短剧平得多——真实影视评论抽样 70 条,0 赞占八成:
|
||||||
|
|
||||||
|
```
|
||||||
|
72% -> 0 16% -> 1~3 8% -> 4~20 3.4% -> 21~80 0.6% -> 81~300
|
||||||
|
```
|
||||||
|
|
||||||
|
再按文案长短压上限(≤4 字最多 20 赞,≤8 字最多 80)。
|
||||||
|
|
||||||
|
其余坑和 dramacmt 完全一致,不重复写:`status` 必须直接写 `1`(App 发评论写的是
|
||||||
|
`2`,列表只认 `1`)、`type` 是 `"VIDEO"` 而 `objType` 才是 `"video"`、
|
||||||
|
`tousername` 落库键名是小写的、`user` 表 `devID` 上是非 sparse 唯一索引。
|
||||||
|
|
||||||
|
## 文案
|
||||||
|
|
||||||
|
`content.go` 里 224 条,互不重复,以 2~14 字的短句为主(对齐真实评论的语感)。
|
||||||
|
|
||||||
|
真实评论里占比不小的三类内容**故意没有收进来**:约炮求联系留地区的、涉及年龄的、
|
||||||
|
非自愿表述的。这三类是审核该删的东西,不该由我们批量生产。
|
||||||
|
|
||||||
|
## 机器人用户
|
||||||
|
|
||||||
|
沿用 dramacmt 的 uid 池并往上扩到 **1001~6000**(5,000 个)。23 万条评论摊到
|
||||||
|
5,000 个用户是每人 ~46 条;只用 dramacmt 那 400 个的话每人 580 条,同一个头像
|
||||||
|
昵称在评论区刷屏。已存在的 uid 不覆盖,可以重复跑。
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o /tmp/vidcmt ./scripts/vidcmt/
|
||||||
|
|
||||||
|
# 干跑,看分层后会写多少
|
||||||
|
/tmp/vidcmt --dst "$DST_TANGGUO" --db tangguo --dry-run
|
||||||
|
|
||||||
|
# 正式跑(跑完自动重算计数)
|
||||||
|
/tmp/vidcmt --dst "$DST_TANGGUO" --db tangguo
|
||||||
|
|
||||||
|
# 只重算计数,不铺评论
|
||||||
|
/tmp/vidcmt --dst "$DST_TANGGUO" --db tangguo --recount
|
||||||
|
|
||||||
|
# 调试:只铺 5 个视频,固定种子便于复现,跳过重算
|
||||||
|
/tmp/vidcmt --dst "$DST_TANGGUO" --db tangguo --limit 5 --seed 7 --no-recount
|
||||||
|
```
|
||||||
|
|
||||||
|
默认只给**没有任何可见评论**的视频铺(`--only-empty`),所以中断了直接重跑,
|
||||||
|
已经铺过的会被跳过。`--db` 必须显式传:URI 里的 `authSource` 通常是 `admin`。
|
||||||
|
|
||||||
|
## 撤回
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 软删这批评论
|
||||||
|
db.comment.updateMany({isRobot: true, objType: "video", userID: {$gte: 1001, $lte: 6000}},
|
||||||
|
{$set: {isDelete: true}})
|
||||||
|
```
|
||||||
|
|
||||||
|
硬删把 `$set` 换成 `deleteMany`。两种都做完后跑一次 `--recount`,
|
||||||
|
计数会自己对齐,不用手工回减。
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 影视(video_info 里 newsType=SP 的长视频)评论文案池。
|
||||||
|
//
|
||||||
|
// 语感对齐线上真实评论:抽样 70 条真实可见评论,绝大多数在 2~14 字,
|
||||||
|
// 直白、反应式、很少有完整句子,点赞基本是 0。所以这里也以短句为主。
|
||||||
|
//
|
||||||
|
// 真实数据里占比不小的三类内容故意没有收进来:
|
||||||
|
// 1. 约炮/求联系/留地区("怎么约"、"私我"、"XX 的有没有")——铺这种等于
|
||||||
|
// 平台自己在引导站外交易
|
||||||
|
// 2. 涉及年龄的(真实评论里出现过未成年表述)
|
||||||
|
// 3. 非自愿、强迫类表述
|
||||||
|
//
|
||||||
|
// 这三类是审核要删的东西,不该由我们批量生产。
|
||||||
|
//
|
||||||
|
// 共 contents 条,同一个视频内不重复,所以单视频条数上限受池子容量限制。
|
||||||
|
var contents = []string{
|
||||||
|
// 短感叹——出现频率最高的一类
|
||||||
|
"好", "可以", "不错", "真顶", "牛啊", "厉害", "太猛了", "受不了", "顶不住",
|
||||||
|
"绝了", "好看", "爱看", "喜欢", "真香", "上头", "过瘾", "爽", "真爽", "好爽",
|
||||||
|
"舒服", "带劲", "太带感了", "起猛了", "破防了", "血脉觉醒", "汗流浃背了",
|
||||||
|
"我直接好家伙", "笑死", "来对了", "没白点进来",
|
||||||
|
|
||||||
|
// 身材外貌
|
||||||
|
"身材真不错", "身材绝了", "这身材太顶了", "腰细腿长", "腿真好看", "这腰真细",
|
||||||
|
"皮肤好白", "白到发光", "这颜值能打", "颜值身材双绝", "长得真清秀",
|
||||||
|
"笑起来好甜", "一看就是我的理想型", "这身材是练过的吧", "这比例太好了",
|
||||||
|
"背影就很迷人", "侧脸绝杀", "这小腰我能看一天", "腿控狂喜", "锁骨太好看了",
|
||||||
|
"黑丝加分太多了", "丝袜控表示满意", "高跟配丝袜是绝配", "这身材衣服都挡不住",
|
||||||
|
"头发披着比扎起来好看", "素颜也能打",
|
||||||
|
|
||||||
|
// 镜头、表演
|
||||||
|
"镜头给得太到位了", "这个角度拍得好", "运镜不错", "灯光打得挺专业",
|
||||||
|
"摄影师辛苦了", "摄影师是懂的", "这机位选得好", "全程高能", "前戏太长了",
|
||||||
|
"三分钟入正题,好评", "节奏刚好", "节奏有点拖", "后半段才是精华",
|
||||||
|
"最后那段最好", "开头平淡后面炸裂", "一气呵成", "一镜到底厉害",
|
||||||
|
"配乐有点出戏", "音效太假了", "表情到位", "眼神会说话", "演得挺投入",
|
||||||
|
"不像演的", "这反应太真实了", "声音好听", "声音太好听了", "听声音就够了",
|
||||||
|
"台词有点多", "不用台词也行", "互动感很好",
|
||||||
|
|
||||||
|
// 画质、技术
|
||||||
|
"画质可以", "画质真清楚", "4K 就是不一样", "这画质给满分", "画质糊了",
|
||||||
|
"糊成一团", "能不能上个高清", "打码太重了", "码得什么都看不见",
|
||||||
|
"马赛克太大了", "希望出个高清的", "加载有点慢", "卡了好几次",
|
||||||
|
"音画不同步", "字幕能加一个吗", "没有字幕差点意思", "竖屏看着不舒服",
|
||||||
|
"建议横屏重拍", "时长太短了", "就这么点", "太短了不够看", "希望长一点",
|
||||||
|
"半小时起步才够", "下载下来慢慢看",
|
||||||
|
|
||||||
|
// 求资源、求续集
|
||||||
|
"求名字", "女主叫什么", "这位叫什么名字", "有没有同系列的", "还有别的吗",
|
||||||
|
"求同款", "求全集", "后面还有吗", "蹲一个续集", "等更新", "更新快点",
|
||||||
|
"催更", "坐等下一部", "这个系列都不错", "系列里这部最好", "第一部更好看",
|
||||||
|
"把前面几部也补上吧", "希望别太监了",
|
||||||
|
|
||||||
|
// 收藏、重复看
|
||||||
|
"收藏了", "已收藏", "收藏夹吃灰系列", "看了三遍", "来回看了好几遍",
|
||||||
|
"二刷了", "三刷打卡", "每天来看一遍", "放收藏夹里天天看", "存下来了",
|
||||||
|
"加入豪华午餐", "这个得留着",
|
||||||
|
|
||||||
|
// 整体评价
|
||||||
|
"这才是我想看的质量", "终于刷到一个能看的了", "质量比前面那些高一截",
|
||||||
|
"首页推荐里最好的一个", "这个比热榜上那些强多了", "刷了半小时就这个最值",
|
||||||
|
"不虚此行", "值得一个赞", "值得收藏", "点赞收藏一条龙", "这种质量多来点",
|
||||||
|
"多上点这种资源", "希望多出这类的", "这类型太少了", "可惜太少了",
|
||||||
|
"一部顶十部", "这个必须顶上去", "顶上去让更多人看到", "帮顶", "已三连",
|
||||||
|
"质量稳定", "这个up靠得住", "每次都不让人失望",
|
||||||
|
|
||||||
|
// 吐槽、对比
|
||||||
|
"标题党", "标题夸张了", "标题和内容不符", "封面骗我进来的", "封面比内容好看",
|
||||||
|
"就这", "有点失望", "不如上一部", "上一部更好", "期待太高了", "还行吧一般",
|
||||||
|
"中规中矩", "凑合看", "看个开头就够了", "前面还行后面划水", "水时长",
|
||||||
|
"重复镜头太多", "剪辑有点乱", "剪得太碎了", "转场太突然", "广告有点多",
|
||||||
|
"开头那段能剪掉",
|
||||||
|
|
||||||
|
// 反应(露骨但不涉联系方式)
|
||||||
|
"太会了", "这腰力惊人", "腰力太好了", "体力真好", "这体力我不行",
|
||||||
|
"我是真不行", "跟不上节奏", "太猛了受不了", "看得我腿软", "手机都拿不稳了",
|
||||||
|
"心跳加速", "血压上来了", "坐不住了", "一发入魂", "正中我的点",
|
||||||
|
"这就是我的 XP", "拿下", "收下了", "这下真忍不住了", "白天不敢看",
|
||||||
|
"戴耳机看的", "声音开小了点", "被窝里偷偷看", "差点被发现",
|
||||||
|
|
||||||
|
// 稍长一点的评价,撑起热评位
|
||||||
|
"这个质量放在首页都算上游,可惜刷到的人不多",
|
||||||
|
"画质、身材、节奏都在线,少见的三样都没掉链子",
|
||||||
|
"看了这么多就这个让我回头又点了一遍",
|
||||||
|
"比那些标题吹得天花乱坠的强太多,老老实实拍反而好看",
|
||||||
|
"镜头稳、光够亮、该给的都给了,这才叫用心",
|
||||||
|
"节奏控制得真好,不拖也不赶,一口气看完",
|
||||||
|
"这种片子的精髓就是真实感,这部拿住了",
|
||||||
|
"看完去翻了一下同系列,整体水准都不低",
|
||||||
|
"评论区居然没几条,这么好的东西被埋了",
|
||||||
|
"建议直接从中间开始看,前面有点铺垫过多",
|
||||||
|
"反复看了好几遍,每次都能发现没注意到的细节",
|
||||||
|
"同类里排得上前几,缺点是时长实在太短",
|
||||||
|
"这个画质放两年前想都不敢想,技术进步真快",
|
||||||
|
"难得遇到不打码还清楚的,收藏了",
|
||||||
|
"看表情就知道不是演的,这点最难得",
|
||||||
|
}
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "math/rand"
|
||||||
|
|
||||||
|
// 昵称表与 scripts/dramacmt/names.go 相同(scripts 下每个工具各自独立成包,
|
||||||
|
// 不互相 import,这里是有意重复的数据表)。
|
||||||
|
var surnames = []string{
|
||||||
|
"赵", "钱", "孙", "李", "周", "吴", "郑", "王", "冯", "陈",
|
||||||
|
"褚", "卫", "蒋", "沈", "韩", "杨", "朱", "秦", "尤", "许",
|
||||||
|
"何", "吕", "施", "张", "孔", "曹", "严", "华", "金", "魏",
|
||||||
|
"陶", "姜", "戚", "谢", "邹", "喻", "柏", "水", "窦", "章",
|
||||||
|
"云", "苏", "潘", "葛", "奚", "范", "彭", "郎", "鲁", "韦",
|
||||||
|
"昌", "马", "苗", "凤", "花", "方", "俞", "任", "袁", "柳",
|
||||||
|
"酆", "鲍", "史", "唐", "费", "廉", "岑", "薛", "雷", "贺",
|
||||||
|
"倪", "汤", "滕", "殷", "罗", "毕", "郝", "邬", "安", "常",
|
||||||
|
"乐", "于", "时", "傅", "皮", "齐", "康", "伍", "余", "元",
|
||||||
|
"卜", "顾", "孟", "平", "黄", "和", "穆", "萧", "尹", "姚",
|
||||||
|
"邵", "湛", "汪",
|
||||||
|
}
|
||||||
|
|
||||||
|
var givens = []string{
|
||||||
|
"哲", "焱", "锦素", "彤", "水生", "嘉聪", "理源", "霖", "钧莎", "香",
|
||||||
|
"亦旭", "朋翰", "寒健", "沁", "旭", "怡言", "鹏阳", "子轩", "思远", "若曦",
|
||||||
|
"曼青", "语桐", "宇辰", "泽楷", "家豪", "静怡", "雨萱", "梓涵", "一诺", "欣然",
|
||||||
|
"书瑶", "明轩", "睿杰", "佳怡", "子墨", "艺涵", "念慈", "向晚", "星河", "听澜",
|
||||||
|
"知微", "清和", "望舒", "照邻", "渐鸿", "慕白", "青山", "行舟", "无眠", "半夏",
|
||||||
|
"长风", "衔月", "疏影", "烟岚", "问荆", "怀瑾", "映雪", "拾音", "观鱼", "枕书",
|
||||||
|
"伯言", "仲卿", "叔夜", "季同", "元朗", "子期", "牧之", "介甫", "东篱", "南山",
|
||||||
|
"西岭", "北辰", "承安", "守拙", "明诚", "致远", "思齐", "见深", "存厚", "知行",
|
||||||
|
"宸", "珩", "珏", "琛", "璟", "瑜", "琰", "瑄", "璨", "瑾",
|
||||||
|
"岚", "屿", "洲", "沅", "湘", "淮", "汀", "泠", "泓", "澈",
|
||||||
|
"晞", "暄", "昀", "晗", "旸", "曜", "昭", "晟", "晏", "晔",
|
||||||
|
"桐", "梧", "榕", "楠", "柘", "槿", "樾", "檀", "蓁", "荔",
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomName(rnd *rand.Rand) string {
|
||||||
|
return surnames[rnd.Intn(len(surnames))] + givens[rnd.Intn(len(givens))]
|
||||||
|
}
|
||||||
+12
-8
@@ -93,14 +93,18 @@ func start() {
|
|||||||
RunPendingSyncIMUsers()
|
RunPendingSyncIMUsers()
|
||||||
})
|
})
|
||||||
// 同步acg订阅的动漫
|
// 同步acg订阅的动漫
|
||||||
_, _ = statCron.AddFunc("0 0/30 * * * ?", func() { //每30分钟执行一次
|
if skdg.Conf.Switch.MediaSyncDisabled {
|
||||||
ms := mediaService.NewMediaSync(skdg.Redis, mediaCenter.NewMediaCenterService(mediaCenter.Config{
|
log.Info("【ACG同步任务】已按配置关闭(switch.mediaSyncDisabled),内容改由 contentsync 从源站同步")
|
||||||
Url: skdg.Conf.MediaResourcesCfg.UploadMediaUrl,
|
} else {
|
||||||
ProId: skdg.Conf.MediaResourcesCfg.ProId,
|
_, _ = statCron.AddFunc("0 0/30 * * * ?", func() { //每30分钟执行一次
|
||||||
Secret: skdg.Conf.MediaResourcesCfg.AppSecret,
|
ms := mediaService.NewMediaSync(skdg.Redis, mediaCenter.NewMediaCenterService(mediaCenter.Config{
|
||||||
}))
|
Url: skdg.Conf.MediaResourcesCfg.UploadMediaUrl,
|
||||||
ms.SyncAll("系统")
|
ProId: skdg.Conf.MediaResourcesCfg.ProId,
|
||||||
})
|
Secret: skdg.Conf.MediaResourcesCfg.AppSecret,
|
||||||
|
}))
|
||||||
|
ms.SyncAll("系统")
|
||||||
|
})
|
||||||
|
}
|
||||||
common.Go(func() {
|
common.Go(func() {
|
||||||
err := aiService.NewAiService(
|
err := aiService.NewAiService(
|
||||||
aiService.AppId(int(commod.KFK_APPID)),
|
aiService.AppId(int(commod.KFK_APPID)),
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ type GlobalConfig struct {
|
|||||||
} `json:"url"`
|
} `json:"url"`
|
||||||
Switch struct {
|
Switch struct {
|
||||||
NotifyExpire bool `json:"notifyExpire"`
|
NotifyExpire bool `json:"notifyExpire"`
|
||||||
|
// MediaSyncDisabled 关掉 skd 每 30 分钟的媒资库同步。
|
||||||
|
//
|
||||||
|
// 本站内容由 scripts/contentsync 从源站整体同步过来,源站自己会跑媒资库
|
||||||
|
// 同步;本站再跑一遍会为同一集(mediaId+hashId 相同)另插一条自己的 _id,
|
||||||
|
// 剧集列表就出现「1 1 2 2」,而且媒资库那条在 SaleCoin=0 时
|
||||||
|
// listenPermission=2(免费),等于本该收费的集可以白看。
|
||||||
|
//
|
||||||
|
// 留空/false 表示照旧开启——源站必须保持开启,否则新剧集无从进库。
|
||||||
|
MediaSyncDisabled bool `json:"mediaSyncDisabled"`
|
||||||
} `json:"switch"`
|
} `json:"switch"`
|
||||||
StartDate string `json:"startDate"`
|
StartDate string `json:"startDate"`
|
||||||
MediaResourcesCfg struct {
|
MediaResourcesCfg struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user