Files
web_hgdjb_server/pub/seo/googleindex/googleindex.go
T
2026-11-07 10:47:32 +08:00

188 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package googleindex 对接 Google Indexing API,在文章发布/更新/删除时推送具体页面 URL。
// 需要在 Google Cloud Console 创建服务账号并开启 Indexing API,
// 同时在 Search Console 中把该服务账号邮箱添加为站点「所有者」。
package googleindex
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
"videoweb/pub/common/log"
"google.golang.org/api/googleapi"
"google.golang.org/api/indexing/v3"
"google.golang.org/api/option"
)
// 推送动作类型
const (
TypeUpdated = "URL_UPDATED" // 新增或更新
TypeDeleted = "URL_DELETED" // 已删除
)
// defaultDailyQuota Google Indexing API 默认每天 200 个 URL 请求
const defaultDailyQuota = int64(200)
// ErrQuotaExceeded 配额已用尽,可能来自本地计数拦截,也可能是 Google 返回的 429。
// 两者都只代表「现在不能推」,调用方应保留任务等下次,而不是计为失败重试。
var ErrQuotaExceeded = errors.New("googleindex: daily quota exceeded")
// ErrNotEnabled 未启用或未初始化
var ErrNotEnabled = errors.New("googleindex: not enabled")
// Client Indexing API 客户端,内置每日配额保护
type Client struct {
svc *indexing.Service
quota *quotaGuard
}
var (
defaultClient *Client
defaultMu sync.RWMutex
)
// Init 初始化全局客户端,未启用时不报错,仅记录日志
func Init(ctx context.Context) {
client, err := NewClient(ctx)
if err != nil {
log.Error("googleindex: init failed", log.E(err))
return
}
defaultMu.Lock()
defaultClient = client
defaultMu.Unlock()
log.Info("googleindex: ready", log.Any("dailyQuota", client.quota.limit))
}
// NewClient 依据配置创建客户端
func NewClient(ctx context.Context) (*Client, error) {
credentials, err := loadCredentials()
if err != nil {
return nil, err
}
svc, err := indexing.NewService(ctx, option.WithCredentialsJSON(credentials))
if err != nil {
return nil, fmt.Errorf("create indexing service failed: %w", err)
}
limit := defaultDailyQuota
return &Client{svc: svc, quota: &quotaGuard{limit: limit}}, nil
}
func loadCredentials() ([]byte, error) {
credentialsFile := "composed-anvil-470512-r8-fd674ac0d7dc.json"
b, err := os.ReadFile(credentialsFile)
if err != nil {
return nil, fmt.Errorf("read credentials file %s failed: %w", credentialsFile, err)
}
return b, nil
}
// Publish 使用全局客户端推送单个 URL
func Publish(ctx context.Context, pageURL, notifyType string) (*indexing.PublishUrlNotificationResponse, error) {
defaultMu.RLock()
client := defaultClient
defaultMu.RUnlock()
if client == nil {
return nil, ErrNotEnabled
}
return client.Publish(ctx, pageURL, notifyType)
}
// Publish 推送单个 URL,notifyType 取 TypeUpdated / TypeDeleted
func (c *Client) Publish(ctx context.Context, pageURL, notifyType string) (*indexing.PublishUrlNotificationResponse, error) {
if strings.TrimSpace(pageURL) == "" {
return nil, errors.New("googleindex: empty url")
}
if notifyType != TypeUpdated && notifyType != TypeDeleted {
return nil, fmt.Errorf("googleindex: invalid notify type %s", notifyType)
}
if !c.quota.acquire() {
return nil, ErrQuotaExceeded
}
resp, err := c.svc.UrlNotifications.Publish(&indexing.UrlNotification{
Url: pageURL,
Type: notifyType,
}).Context(ctx).Do()
if err != nil {
// Google 已明确告知配额用尽,说明本地计数比实际用量宽松:
// 本地日界按服务器时区计算、且计数在进程重启后归零,都可能早于 Google 放行。
// 此时把本地计数拉满,避免后续请求继续无效地打过去。
if isQuotaError(err) {
c.quota.exhaust()
return resp, fmt.Errorf("%w: %v", ErrQuotaExceeded, err)
}
// 请求已消耗配额但失败,退回本地计数,避免本地计数与实际用量偏差过大
c.quota.release()
return resp, err
}
return resp, nil
}
// isQuotaError 判断错误是否为配额或频率限制。
// 这类错误只代表「现在不能推」,调用方应保留任务等下次,而不是计为失败重试。
// 注意 403 还用于站点所有权不足等需要人工介入的场景,因此只在 reason 明确是配额时才算。
func isQuotaError(err error) bool {
var gerr *googleapi.Error
if !errors.As(err, &gerr) {
return false
}
if gerr.Code == http.StatusTooManyRequests {
return true
}
for _, item := range gerr.Errors {
switch item.Reason {
case "rateLimitExceeded", "quotaExceeded", "userRateLimitExceeded":
return true
}
}
return false
}
// quotaGuard 按自然日计数的配额保护。
// 计数仅存于内存,进程重启会归零;真正超配额时 Google 会返回 429,由调用方按失败重试处理。
type quotaGuard struct {
mu sync.Mutex
limit int64
day string
used int64
}
func (q *quotaGuard) acquire() bool {
q.mu.Lock()
defer q.mu.Unlock()
today := time.Now().Format("2006-01-02")
if q.day != today {
q.day = today
q.used = 0
}
if q.used >= q.limit {
return false
}
q.used++
return true
}
func (q *quotaGuard) release() {
q.mu.Lock()
defer q.mu.Unlock()
if q.used > 0 {
q.used--
}
}
// exhaust 把当天计数置满,用于 Google 已返回配额超限时同步本地状态。
// 代价是本地要等到下一个自然日才放行,可能晚于 Google 实际的配额重置时刻;
// 换来的是不再持续发送必定失败的请求。
func (q *quotaGuard) exhaust() {
q.mu.Lock()
defer q.mu.Unlock()
q.day = time.Now().Format("2006-01-02")
q.used = q.limit
}