// Package srv_seopush 负责短剧新上架/下架时向搜索引擎推送页面地址。 // 业务回调里即时推送一次,失败的任务由 web/skd/job/seopush 兜底重试。 package srv_seopush import ( "context" "errors" "strings" "time" "videoweb/pub/common/godef" "videoweb/pub/common/log" "videoweb/pub/models/playletmod" "videoweb/pub/models/seopushmod" "videoweb/pub/pubdef" "videoweb/pub/seo/googleindex" "videoweb/pub/seo/h5path" seoindexnow "videoweb/pub/seo/indexnow" "videoweb/web/service/srv_cfg" "videoweb/web/webg" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" ) // channelFields 把两个推送通道的字段名抽象出来,共用同一套状态更新逻辑 type channelFields struct { name string status string retry string errMsg string at string } var googleChannel = channelFields{ name: "google", status: seopushmod.FN.SeoPushTask.GoogleStatus, retry: seopushmod.FN.SeoPushTask.GoogleRetry, errMsg: seopushmod.FN.SeoPushTask.GoogleErr, at: seopushmod.FN.SeoPushTask.GoogleAt, } var indexNowChannel = channelFields{ name: "indexnow", status: seopushmod.FN.SeoPushTask.IndexNowStatus, retry: seopushmod.FN.SeoPushTask.IndexNowRetry, errMsg: seopushmod.FN.SeoPushTask.IndexNowErr, at: seopushmod.FN.SeoPushTask.IndexNowAt, } // PlayletDetailURL 拼装短剧播放页完整地址,路径与 H5 / sitemap 一致:/play/{slug},无 slug 回退 ID。 func PlayletDetailURL(webAddr, slug string, playletId int64) string { path := h5path.Playlet(slug, playletId) if path == "" { return "" } return h5path.Abs(webAddr, path) } // PageVisible 判断详情页当前是否对外可访问。 func PageVisible(status playletmod.Status, onlineAt time.Time) bool { if status != playletmod.StatusUp { return false } if onlineAt.IsZero() { return true } return !onlineAt.After(time.Now()) } // SEOBaseURL 取站点对外地址 func SEOBaseURL(ctx context.Context) string { sysCfg, serr := srv_cfg.GetSystemConfig(ctx) if serr != nil || sysCfg == nil { log.Warn("seopush: get system config failed", log.E(serr)) return "" } base := sysCfg.SEOBaseURL() if base == "" { log.Warn("seopush: SEO base URL is empty, skip push") } return base } func canPushExternally() bool { return pubdef.Enabled() && webg.Conf.RunMod == "prod" } // EnqueuePlayletUpdated 短剧新上架后登记并立即推送,仅在页面已可访问时生效 func EnqueuePlayletUpdated(ctx context.Context, playletId int64, status playletmod.Status, onlineAt time.Time) { if !pubdef.Enabled() || !PageVisible(status, onlineAt) { return } enqueue(ctx, playletId, seopushmod.ActionUpdated, true) } // RegisterPlayletUpdated 只登记不立即推送,供批量补推使用 func RegisterPlayletUpdated(ctx context.Context, playletId int64, status playletmod.Status, onlineAt time.Time) { if !pubdef.Enabled() || !PageVisible(status, onlineAt) { return } enqueue(ctx, playletId, seopushmod.ActionUpdated, false) } // EnqueuePlayletDeleted 短剧下架/删除后登记并立即推送 func EnqueuePlayletDeleted(ctx context.Context, playletId int64) { if !pubdef.Enabled() { return } enqueue(ctx, playletId, seopushmod.ActionDeleted, true) } func enqueue(ctx context.Context, playletId int64, action string, pushNow bool) { webAddr := SEOBaseURL(ctx) if webAddr == "" { return } pageURL := PlayletDetailURL(webAddr, loadPlayletSlug(ctx, playletId), playletId) if pageURL == "" { return } now := time.Now() task := &seopushmod.SeoPushTask{ Url: pageURL, VideoId: playletId, Action: action, CreatedAt: now, UpdatedAt: now, } if action == seopushmod.ActionDeleted { _, _ = seopushmod.DelManySeoPushTask(ctx, bson.M{ seopushmod.FN.SeoPushTask.Url: pageURL, seopushmod.FN.SeoPushTask.Action: seopushmod.ActionUpdated, }) } if serr := seopushmod.UpsertPendingSeoPushTask(ctx, task); serr != nil { log.Warn("seopush: enqueue task failed", log.Any("url", pageURL), log.E(serr)) return } if action == seopushmod.ActionUpdated { markPlayletQueued(ctx, playletId) } if !pushNow { return } godef.Go(func() { PushTask(context.Background(), webAddr, task) }) } func loadPlayletSlug(ctx context.Context, playletId int64) string { if playletId <= 0 { return "" } item, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId}, options.FindOne().SetProjection(bson.D{{Key: playletmod.FN.Playlet.Slug, Value: 1}})) if err != nil || item == nil { return "" } return strings.TrimSpace(item.Slug) } func isObsoletePushURL(raw string) bool { raw = strings.TrimSpace(raw) if raw == "" { return true } if strings.Contains(raw, "?") { return true } if strings.Contains(raw, "/detail/") || strings.HasSuffix(raw, "/detail") { return true } if strings.Contains(raw, "/lieyan/av/detail") { return true } return false } func markPlayletQueued(ctx context.Context, playletId int64) { if serr := playletmod.UpdateOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId}, bson.M{"$set": bson.M{playletmod.FN.Playlet.IndexNow: int64(1)}}); serr != nil { log.Warn("seopush: mark playlet queued failed", log.Any("playletId", playletId), log.E(serr)) } } // PushTask 推送任务中尚未成功的通道 func PushTask(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) { if task == nil || !pubdef.Enabled() { return } // 旧站路径不再推送:查询参数、/detail/{slug}、/lieyan/av/detail。 if isObsoletePushURL(task.Url) { log.Warn("seopush: drop obsolete-url task", log.Any("url", task.Url), log.Any("action", task.Action)) _, _ = seopushmod.DelManySeoPushTask(ctx, taskFilter(task)) return } if !canPushExternally() { dryRun(ctx, task) return } if task.GoogleStatus != seopushmod.PushStatusSuccess { pushGoogle(ctx, task) } if task.IndexNowStatus != seopushmod.PushStatusSuccess { pushIndexNow(ctx, webAddr, task) } } func dryRun(ctx context.Context, task *seopushmod.SeoPushTask) { log.Info("seopush: dry-run, external push skipped in non-prod", log.Any("url", task.Url), log.Any("action", task.Action)) if task.GoogleStatus != seopushmod.PushStatusSuccess { markSuccess(ctx, task, googleChannel) } if task.IndexNowStatus != seopushmod.PushStatusSuccess { markSuccess(ctx, task, indexNowChannel) } } func pushGoogle(ctx context.Context, task *seopushmod.SeoPushTask) { notifyType := googleindex.TypeUpdated if task.Action == seopushmod.ActionDeleted { notifyType = googleindex.TypeDeleted } resp, err := googleindex.Publish(ctx, task.Url, notifyType) if err == nil { markSuccess(ctx, task, googleChannel) log.Info("seopush: google pushed", log.Any("url", task.Url), log.Any("type", notifyType), log.Any("resp", resp)) return } if errors.Is(err, googleindex.ErrQuotaExceeded) { log.Warn("seopush: google daily quota exceeded, keep pending", log.Any("url", task.Url), log.Any("resp", err.Error())) return } if errors.Is(err, googleindex.ErrNotEnabled) { log.Warn("seopush: google client not ready, keep pending", log.Any("url", task.Url)) return } markFailed(ctx, task, googleChannel, err) } func pushIndexNow(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) { if strings.TrimSpace(webAddr) == "" { return } config, err := seoindexnow.BuildConfig(webAddr) if err != nil { log.Warn("seopush: build indexnow config failed", log.Any("webAddr", webAddr), log.E(err)) return } result := seoindexnow.NewBatchIndexNowSubmitter(config).SubmitBatch([]string{task.Url}) if result.SuccessCount > 0 { markSuccess(ctx, task, indexNowChannel) log.Info("seopush: indexnow pushed", log.Any("url", task.Url), log.Any("statusCode", result.StatusCodes)) return } markFailed(ctx, task, indexNowChannel, errors.New(strings.Join(result.Errors, "; "))) } func markSuccess(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields) { now := time.Now() if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{ "$set": bson.M{ ch.status: seopushmod.PushStatusSuccess, ch.errMsg: "", ch.at: now, seopushmod.FN.SeoPushTask.UpdatedAt: now, }, }); serr != nil { log.Warn("seopush: mark success failed", log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr)) } } func markFailed(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields, pushErr error) { now := time.Now() errText := "" if pushErr != nil { errText = pushErr.Error() } log.Warn("seopush: push failed", log.Any("channel", ch.name), log.Any("url", task.Url), log.Any("err", errText)) if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{ "$set": bson.M{ ch.errMsg: errText, ch.at: now, seopushmod.FN.SeoPushTask.UpdatedAt: now, }, "$inc": bson.M{ch.retry: 1}, }); serr != nil { log.Warn("seopush: mark failed failed", log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr)) } } func taskFilter(task *seopushmod.SeoPushTask) bson.M { return bson.M{ seopushmod.FN.SeoPushTask.Url: task.Url, seopushmod.FN.SeoPushTask.Action: task.Action, } }