// Package googleindex 对接 Google Indexing API,在文章发布/更新/删除时推送具体页面 URL。 // 需要在 Google Cloud Console 创建服务账号并开启 Indexing API, // 同时在 Search Console 中把该服务账号邮箱添加为站点「所有者」。 package googleindex import ( "context" "errors" "fmt" "hkdj-web-server/pub/common/log" "net/http" "os" "strings" "sync" "time" "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 }