Files
web_hgdjc_server/web/service/srv_seopush/push.go
T
2026-11-07 10:46:18 +08:00

425 lines
12 KiB
Go

// 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,
}
}