188 lines
5.4 KiB
Go
188 lines
5.4 KiB
Go
// 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: "aGuard{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
|
||
}
|