Class: Seo::KeywordSyncService

Inherits:
BaseService show all
Defined in:
app/services/seo/keyword_sync_service.rb

Overview

Syncs organic keywords for a single SiteMap page.

Data source priority:

  1. Google Search Console (page_queries) — actual clicks/positions, no API quota per page
  2. Ahrefs (organic_keywords) — fallback when GSC returns nothing (new/unindexed pages)

Search volume enrichment: Google Keyword Planner (authoritative, already in stack).

After saving keywords, updates seo_report['cannibalization'] so the AI batch
analysis always has current cannibalization context without a separate pass.

Examples:

Sync keywords for a page

Seo::KeywordSyncService.new(site_map: site_map).process

Constant Summary collapse

MAX_KEYWORDS =
100
GOOGLE_BATCH_SIZE =
20
GSC_LOOKBACK_DAYS =
90

Instance Method Summary collapse

Methods inherited from BaseService

#log_debug, #log_error, #log_info, #log_warning, #logger, #options, #tagged_logger

Constructor Details

#initialize(options = {}) ⇒ KeywordSyncService

Returns a new instance of KeywordSyncService.

Raises:

  • (ArgumentError)


23
24
25
26
27
28
29
30
# File 'app/services/seo/keyword_sync_service.rb', line 23

def initialize(options = {})
  super
  @site_map = options[:site_map]
  @limit = options[:limit] || MAX_KEYWORDS
  @skip_google = options[:skip_google] || false
  @skip_ahrefs_fallback = options[:skip_ahrefs_fallback] || false
  raise ArgumentError, 'site_map is required' unless @site_map
end

Instance Method Details

#processObject



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'app/services/seo/keyword_sync_service.rb', line 32

def process
  @logger.info "[KeywordSyncService] Syncing #{@site_map.id}: #{@site_map.path}"

  # Step 1: Try GSC first (preferred — actual rankings, no per-page quota)
  keywords_data = fetch_keywords_from_gsc

  source = if keywords_data.present?
             @logger.info "[KeywordSyncService] GSC returned #{keywords_data.size} queries"
             'gsc'
           elsif @skip_ahrefs_fallback
             @logger.info "[KeywordSyncService] GSC returned nothing, Ahrefs fallback disabled"
             return { keywords_synced: 0, source: 'none' }
           else
             @logger.info "[KeywordSyncService] GSC empty — falling back to Ahrefs"
             keywords_data = fetch_keywords_from_ahrefs
             if keywords_data.blank?
               @logger.warn "[KeywordSyncService] No keywords from Ahrefs either"
               return { keywords_synced: 0, error: 'No keywords from GSC or Ahrefs' }
             end
             @logger.info "[KeywordSyncService] Ahrefs returned #{keywords_data.size} keywords"
             'ahrefs'
           end

  # Step 2: Enrich with authoritative search volume from Google Keyword Planner
  keywords_data = enrich_with_google_data(keywords_data) unless @skip_google

  # Step 3: Save to seo_page_keywords
  synced_count = save_keywords(keywords_data, source: source)

  # Step 3b: Enrich with Ahrefs SERP feature data (AI Overview, snippets, etc.)
  enrich_with_serp_features(source: source)

  # Step 4: Refresh cannibalization data in seo_report now that keywords are current
  update_cannibalization_in_report

  @site_map.update!(seo_synced_at: Time.current)

  @logger.info "[KeywordSyncService] Synced #{synced_count} keywords (#{source}) for #{@site_map.path}"
  { keywords_synced: synced_count, source: source }
rescue Seo::AhrefsMcpClient::Error => e
  @logger.error "[KeywordSyncService] Ahrefs error: #{e.message}"
  { keywords_synced: 0, error: e.message }
rescue StandardError => e
  @logger.error "[KeywordSyncService] Error: #{e.message}"
  { keywords_synced: 0, error: e.message }
end