// 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/models/videoinformationmod" "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 拼装短剧详情页完整地址,与 sitemap 一致:/detail/{slug}/,无 slug 回退 ID。 func PlayletDetailURL(webAddr, slug string, playletId int64) string { if h5path.Segment(slug, playletId) == "" { return "" } return h5path.SitemapAbs(webAddr, h5path.Entity("/detail", slug, playletId)) } // VideoPlayURL 拼装视频播放页完整地址,路径与 H5 / sitemap 一致:/series-play/{slug}。 func VideoPlayURL(webAddr, slug string, videoId int64) string { path := h5path.VideoPlay(slug, videoId) 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) } // VideoPageVisible 判断视频播放页当前是否对外可访问。 func VideoPageVisible(status videoinformationmod.InformationSatus) bool { return status == videoinformationmod.InformationSatusVerifyOK } // EnqueueVideoUpdated 视频上架后登记并立即推送。 func EnqueueVideoUpdated(ctx context.Context, videoId int64, status videoinformationmod.InformationSatus) { if !pubdef.Enabled() || !VideoPageVisible(status) { return } enqueueVideo(ctx, videoId, seopushmod.ActionUpdated, true) } // EnqueueVideoDeleted 视频下架/删除后登记并立即推送。 func EnqueueVideoDeleted(ctx context.Context, videoId int64) { if !pubdef.Enabled() { return } enqueueVideo(ctx, videoId, 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 enqueueVideo(ctx context.Context, videoId int64, action string, pushNow bool) { webAddr := SEOBaseURL(ctx) if webAddr == "" { return } pageURL := VideoPlayURL(webAddr, loadVideoSlug(ctx, videoId), videoId) if pageURL == "" { return } now := time.Now() task := &seopushmod.SeoPushTask{ Url: pageURL, VideoId: videoId, 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 video task failed", log.Any("url", pageURL), log.E(serr)) return } if action == seopushmod.ActionUpdated { markVideoQueued(ctx, videoId) } 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 loadVideoSlug(ctx context.Context, videoId int64) string { if videoId <= 0 { return "" } item, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: videoId}, options.FindOne().SetProjection(bson.D{{Key: videoinformationmod.FN.VideoInformation.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, "/lieyan/av/detail") { return true } // 旧推送路径 /play/{slug}。/series-play 是视频页,/detail/{slug}/ 是当前详情页。 path := requestPath(raw) return path == "/play" || strings.HasPrefix(path, "/play/") } func requestPath(raw string) string { raw = strings.TrimSpace(raw) if i := strings.IndexAny(raw, "?#"); i >= 0 { raw = raw[:i] } if i := strings.Index(raw, "://"); i >= 0 { raw = raw[i+3:] if j := strings.Index(raw, "/"); j >= 0 { return raw[j:] } return "/" } if raw == "" { return "/" } if !strings.HasPrefix(raw, "/") { return "/" + raw } return raw } 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)) } } func markVideoQueued(ctx context.Context, videoId int64) { if serr := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: videoId}, bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.IndexNow: int64(1)}}); serr != nil { log.Warn("seopush: mark video queued failed", log.Any("videoId", videoId), log.E(serr)) } } // PushTask 推送任务中尚未成功的通道 func PushTask(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) { if task == nil || !pubdef.Enabled() { return } // 旧路径不再推送:查询参数、/play/{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, } }