来源:hgdjnet-server 9bfbafe10d417deea959bd53117bc88a10e60a83 项目身份 HKDJ / JHA-216,独立官网仓库,Jenkins 守卫键 hkdjnet 测试目录 /server/hkdj_net,App/Web API 30331/30332 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
406 lines
12 KiB
Go
406 lines
12 KiB
Go
// Package srv_seopush 负责短剧新上架/下架时向搜索引擎推送页面地址。
|
|
// 业务回调里即时推送一次,失败的任务由 web/skd/job/seopush 兜底重试。
|
|
package srv_seopush
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"hkdj-web-server/pub/common/godef"
|
|
"hkdj-web-server/pub/common/log"
|
|
"hkdj-web-server/pub/models/playletmod"
|
|
"hkdj-web-server/pub/models/seopushmod"
|
|
"hkdj-web-server/pub/models/videoinformationmod"
|
|
"hkdj-web-server/pub/pubdef"
|
|
"hkdj-web-server/pub/seo/googleindex"
|
|
"hkdj-web-server/pub/seo/h5path"
|
|
seoindexnow "hkdj-web-server/pub/seo/indexnow"
|
|
"hkdj-web-server/web/service/srv_cfg"
|
|
"hkdj-web-server/web/webg"
|
|
"strings"
|
|
"time"
|
|
|
|
"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 一致:/drama/{slug},无 slug 回退 ID。
|
|
func PlayletDetailURL(webAddr, slug string, playletId int64) string {
|
|
path := h5path.Playlet(slug, playletId)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
return h5path.Abs(webAddr, path)
|
|
}
|
|
|
|
// VideoPlayURL 拼装视频播放页完整地址,路径与 H5 / sitemap 一致:/series/watch/{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, "/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))
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
// 旧站路径不再推送:查询参数、/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, webg.Conf.IndexNow.Key)
|
|
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,
|
|
}
|
|
}
|