Initial commit: web_hghl_server
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
package srv_seopush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/pubdef"
|
||||
"videoweb/pub/seo/sitemapdirty"
|
||||
)
|
||||
|
||||
// MarkSitemapDirty 通知 app 进程重新生成 sitemap。
|
||||
func MarkSitemapDirty(ctx context.Context) {
|
||||
sitemapdirty.Mark(ctx)
|
||||
}
|
||||
|
||||
// NotifyPlayletAfterWrite 短剧写入后:刷新 sitemap 标记。
|
||||
// 搜索引擎只在新上架(含新增即上架)或从已上架变为下架时推送,普通改内容不推。
|
||||
func NotifyPlayletAfterWrite(ctx context.Context, playletId int64, prevStatus *playletmod.Status, status playletmod.Status, onlineAt time.Time) {
|
||||
MarkSitemapDirty(ctx)
|
||||
wasUp := prevStatus != nil && *prevStatus == playletmod.StatusUp
|
||||
if status == playletmod.StatusUp {
|
||||
if !wasUp {
|
||||
EnqueueUpdated(ctx, KindPlaylet, playletId, PageVisiblePlaylet(status, onlineAt))
|
||||
}
|
||||
return
|
||||
}
|
||||
if wasUp {
|
||||
EnqueueDeleted(ctx, KindPlaylet, playletId)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyPlayletAfterStatus 批量上下架后的 SEO 通知
|
||||
func NotifyPlayletAfterStatus(ctx context.Context, ids []int64, status playletmod.Status, onlineAt time.Time) {
|
||||
MarkSitemapDirty(ctx)
|
||||
if status == playletmod.StatusUp {
|
||||
for _, id := range ids {
|
||||
EnqueueUpdated(ctx, KindPlaylet, id, PageVisiblePlaylet(status, onlineAt))
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
EnqueueDeleted(ctx, KindPlaylet, id)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyInfoAfterWrite 视频写入后的 SEO 通知。
|
||||
// 搜索引擎只在新上架或从已上架变为下架时推送,普通改内容不推。
|
||||
func NotifyInfoAfterWrite(ctx context.Context, kind string, id int64, prevStatus, status pubdef.InformationStatus) {
|
||||
MarkSitemapDirty(ctx)
|
||||
wasUp := prevStatus == pubdef.InformationStatusVerifyOK
|
||||
if PageVisibleInfo(status) {
|
||||
if !wasUp {
|
||||
EnqueueUpdated(ctx, kind, id, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if wasUp {
|
||||
EnqueueDeleted(ctx, kind, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user