// 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/common/utils" "videoweb/pub/models/playletmod" "videoweb/pub/models/seopushmod" "videoweb/pub/models/videoinformationmod" "videoweb/pub/pubdef" "videoweb/pub/seo/googleindex" 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" ) const ( KindVideo = "video" KindPlaylet = "playlet" ) // 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, } func kindPrefix(kind string) string { switch kind { case KindVideo: return "/video-detail" case KindPlaylet: return "/drama-detail" default: return "" } } // DetailURL 拼装详情页完整地址,路径与 sitemap 一致:/{prefix}/{slug},无 slug 回退 ID。 func DetailURL(webAddr, prefix, slug string, id int64) string { seg := utils.PathSegment(slug, id) if seg == "" { return "" } return utils.JoinUrlPathKeepSlash(webAddr, strings.TrimRight(prefix, "/")+"/"+seg) } // PageVisiblePlaylet 判断短剧详情页当前是否对外可访问。 func PageVisiblePlaylet(status playletmod.Status, onlineAt time.Time) bool { if status != playletmod.StatusUp { return false } if onlineAt.IsZero() { return true } return !onlineAt.After(time.Now()) } // PageVisibleInfo 判断视频详情页是否已上架。 func PageVisibleInfo(status pubdef.InformationStatus) bool { return status == pubdef.InformationStatusVerifyOK } // 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 } // Enabled 业务开关是否打开(不含环境判断)。 func Enabled() bool { return pubdef.Enabled() } func canPushExternally() bool { return pubdef.Enabled() && webg.Conf.RunMod == "prod" } // EnqueueUpdated 内容新上架后登记并立即推送,仅在页面已可访问时生效 func EnqueueUpdated(ctx context.Context, kind string, id int64, visible bool) { if !pubdef.Enabled() || !visible { return } enqueue(ctx, kind, id, seopushmod.ActionUpdated, true) } // RegisterUpdated 只登记不立即推送,供批量补推使用 func RegisterUpdated(ctx context.Context, kind string, id int64, visible bool) { if !pubdef.Enabled() || !visible { return } enqueue(ctx, kind, id, seopushmod.ActionUpdated, false) } // EnqueueDeleted 内容下架/删除后登记并立即推送 func EnqueueDeleted(ctx context.Context, kind string, id int64) { if !pubdef.Enabled() { return } enqueue(ctx, kind, id, seopushmod.ActionDeleted, true) } func enqueue(ctx context.Context, kind string, id int64, action string, pushNow bool) { webAddr := SEOBaseURL(ctx) if webAddr == "" { return } prefix := kindPrefix(kind) if prefix == "" { return } pageURL := DetailURL(webAddr, prefix, loadSlug(ctx, kind, id), id) if pageURL == "" { return } now := time.Now() task := &seopushmod.SeoPushTask{ Url: pageURL, VideoId: id, 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 { markQueued(ctx, kind, id) } if !pushNow { return } godef.Go(func() { PushTask(context.Background(), webAddr, task) }) } func loadSlug(ctx context.Context, kind string, id int64) string { if id <= 0 { return "" } switch kind { case KindVideo: item, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: id}, options.FindOne().SetProjection(bson.D{{Key: videoinformationmod.FN.VideoInformation.Slug, Value: 1}})) if err != nil || item == nil { return "" } return strings.TrimSpace(item.Slug) case KindPlaylet: item, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: id}, options.FindOne().SetProjection(bson.D{{Key: playletmod.FN.Playlet.Slug, Value: 1}})) if err != nil || item == nil { return "" } return strings.TrimSpace(item.Slug) default: return "" } } func markQueued(ctx context.Context, kind string, id int64) { var serr error switch kind { case KindVideo: serr = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: id}, bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.IndexNow: int64(1)}}) case KindPlaylet: serr = playletmod.UpdateOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: id}, bson.M{"$set": bson.M{playletmod.FN.Playlet.IndexNow: int64(1)}}) } if serr != nil { log.Warn("seopush: mark queued failed", log.Any("kind", kind), log.Any("id", id), log.E(serr)) } } // PushTask 推送任务中尚未成功的通道 func PushTask(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) { if task == nil || !pubdef.Enabled() { return } if strings.Contains(task.Url, "?") { log.Warn("seopush: skip query-url task", log.Any("url", task.Url), log.Any("action", task.Action)) 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=quota_exceeded", log.Any("url", task.Url), log.Any("resp", err.Error())) return } if errors.Is(err, googleindex.ErrNotEnabled) { log.Warn("seopush=google=not_ready", 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, } }