Class: Seo::GscApiClient

Inherits:
Object
  • Object
show all
Defined in:
app/services/seo/gsc_api_client.rb

Overview

Client for Google Search Console API using Service Account authentication.
Fetches search analytics data (clicks, impressions, CTR, position) for pages.

Examples:

Basic usage

client = Seo::GscApiClient.new
data = client.search_analytics(
  start_date: 28.days.ago.to_date,
  end_date: Date.yesterday
)

Get data by page and query

data = client.search_analytics(
  start_date: 28.days.ago.to_date,
  end_date: Date.yesterday,
  dimensions: %w[page query]
)

Constant Summary collapse

SITE_URL =

URL-prefix property for Search Analytics queries (scoped to en-US content)

'https://www.warmlyyours.com/en-US/'
INSPECTION_PROPERTY =

Property used for URL Inspection. It must CONTAIN the inspected URL, so
this is the root URL-prefix property rather than the en-US one above.

NOT sc-domain:warmlyyours.com: the domain property is not shared with
our service account, so inspecting against it returns
403 PERMISSION_DENIED "You do not own this site, or the inspected URL is not part of this property." The service account can see exactly three
URL-prefix properties (all siteRestrictedUser) — this one, /en-US/,
and /en-CA/ — and this root prefix covers every locale under www, which
is what the domain property was reached for in the first place.
Verified live 2026-07-20; see .agents/skills/google-search-console/SKILL.md.

'https://www.warmlyyours.com/'
URL_INSPECTION_ENDPOINT =

Url inspection endpoint.

'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect'

Instance Method Summary collapse

Constructor Details

#initializeGscApiClient

Returns a new instance of GscApiClient.



40
41
42
43
44
45
# File 'app/services/seo/gsc_api_client.rb', line 40

def initialize
  require 'google/apis/searchconsole_v1'
  require 'googleauth'
  @service = Google::Apis::SearchconsoleV1::SearchConsoleService.new
  @service.authorization = 
end

Instance Method Details

#all_pages(start_date:, end_date:, row_limit: 5000) ⇒ Array<Hash>

Get all pages with their metrics

Parameters:

  • start_date (Date)

    Start of date range

  • end_date (Date)

    End of date range

  • row_limit (Integer) (defaults to: 5000)

    Max pages to return

Returns:

  • (Array<Hash>)

    Array of { page:, clicks:, impressions:, ctr:, position: }



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'app/services/seo/gsc_api_client.rb', line 140

def all_pages(start_date:, end_date:, row_limit: 5000)
  response = search_analytics(
    start_date: start_date,
    end_date: end_date,
    dimensions: ['page'],
    row_limit: row_limit
  )

  return [] unless response.rows

  response.rows.map do |row|
    {
      page: row.keys.first,
      clicks: row.clicks.to_i,
      impressions: row.impressions.to_i,
      ctr: (row.ctr * 100).round(2),
      position: row.position.round(2)
    }
  end
end

#inspect_url(inspection_url, site_url: INSPECTION_PROPERTY) ⇒ Hash?

Inspect a URL's indexing status via the URL Inspection API.
The Ruby gem doesn't include generated methods for this endpoint,
so we call the REST API directly using the same service account auth.

Rate limits: 600/min, 2000/day per property.

Parameters:

  • inspection_url (String)

    Full URL to inspect

  • site_url (String) (defaults to: INSPECTION_PROPERTY)

    GSC property that CONTAINS inspection_url
    (defaults to INSPECTION_PROPERTY)

Returns:

  • (Hash, nil)

    Inspection result with :last_crawl_time, :coverage_state,
    :indexing_state, :page_fetch_state, :crawled_as, :google_canonical, :robots_txt_state



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'app/services/seo/gsc_api_client.rb', line 172

def inspect_url(inspection_url, site_url: INSPECTION_PROPERTY)
  auth = @service.authorization
  auth.fetch_access_token! if auth.access_token.nil? || auth.expired?

  conn = Faraday.new(
    url: URL_INSPECTION_ENDPOINT,
    headers: {
      'Authorization' => "Bearer #{auth.access_token}",
      'Content-Type'  => 'application/json'
    },
    request: { open_timeout: 10, timeout: 15 }
  ) { |f| f.adapter Faraday.default_adapter }

  response = conn.post('') do |req|
    req.body = { inspectionUrl: inspection_url, siteUrl: site_url }.to_json
  end

  unless response.success?
    # A 403 is a misconfiguration, not a per-URL condition: the property is
    # not shared with the service account, or it does not contain the URL.
    # Every call will fail identically, so don't let it read as "no data".
    if response.status == 403
      Rails.logger.error "[GscApiClient] URL Inspection PERMISSION_DENIED for property #{site_url.inspect}" \
                         'the service account cannot inspect this property (it owns only URL-prefix ' \
                         "properties under #{INSPECTION_PROPERTY.inspect}). This fails for every URL, not just " \
                         "this one: #{response.body.to_s.truncate(200)}"
    else
      Rails.logger.warn "[GscApiClient] URL Inspection failed (#{response.status}): #{response.body.to_s.truncate(200)}"
    end
    return nil
  end

  body = JSON.parse(response.body)
  index_result = body.dig('inspectionResult', 'indexStatusResult')
  return nil unless index_result

  {
    last_crawl_time: index_result['lastCrawlTime'],
    coverage_state: index_result['coverageState'],
    indexing_state: index_result['indexingState'],
    page_fetch_state: index_result['pageFetchState'],
    crawled_as: index_result['crawledAs'],
    google_canonical: index_result['googleCanonical'],
    user_canonical: index_result['userCanonical'],
    robots_txt_state: index_result['robotsTxtState'],
    verdict: index_result['verdict'],
    referring_urls: index_result['referringUrls']
  }
rescue StandardError => e
  Rails.logger.error "[GscApiClient] URL Inspection error: #{e.message}"
  nil
end

#page_metrics(page_url:, start_date:, end_date:) ⇒ Hash

Get aggregated metrics for a specific page

Parameters:

  • page_url (String)

    Full URL of the page

  • start_date (Date)

    Start of date range

  • end_date (Date)

    End of date range

Returns:

  • (Hash)

    Aggregated metrics { clicks:, impressions:, ctr:, position: }



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'app/services/seo/gsc_api_client.rb', line 86

def page_metrics(page_url:, start_date:, end_date:)
  response = search_analytics(
    start_date: start_date,
    end_date: end_date,
    dimensions: ['page'],
    filters: [{ dimension: 'page', operator: 'equals', expression: page_url }]
  )

  row = response.rows&.first
  return nil unless row

  {
    clicks: row.clicks.to_i,
    impressions: row.impressions.to_i,
    ctr: (row.ctr * 100).round(2),
    position: row.position.round(2)
  }
end

#page_queries(page_url:, start_date:, end_date:, limit: 50) ⇒ Array<Hash>

Get top queries for a specific page

Parameters:

  • page_url (String)

    Full URL of the page

  • start_date (Date)

    Start of date range

  • end_date (Date)

    End of date range

  • limit (Integer) (defaults to: 50)

    Max queries to return

Returns:

  • (Array<Hash>)

    Array of { query:, clicks:, impressions:, ctr:, position: }



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'app/services/seo/gsc_api_client.rb', line 112

def page_queries(page_url:, start_date:, end_date:, limit: 50)
  response = search_analytics(
    start_date: start_date,
    end_date: end_date,
    dimensions: %w[page query],
    row_limit: limit,
    filters: [{ dimension: 'page', operator: 'equals', expression: page_url }]
  )

  return [] unless response.rows

  response.rows.map do |row|
    {
      query: row.keys[1], # Second dimension is query
      clicks: row.clicks.to_i,
      impressions: row.impressions.to_i,
      ctr: (row.ctr * 100).round(2),
      position: row.position.round(2)
    }
  end
end

#search_analytics(start_date:, end_date:, dimensions: ['page'], row_limit: 1000, filters: nil) ⇒ Google::Apis::SearchconsoleV1::SearchAnalyticsQueryResponse

Query search analytics data from Google Search Console

Parameters:

  • start_date (Date)

    Start of date range

  • end_date (Date)

    End of date range

  • dimensions (Array<String>) (defaults to: ['page'])

    Dimensions to group by: 'page', 'query', 'country', 'device', 'date'

  • row_limit (Integer) (defaults to: 1000)

    Max rows to return (default 1000, max 25000)

  • filters (Array<Hash>) (defaults to: nil)

    Optional filters, e.g., [{ dimension: 'page', operator: 'contains', expression: '/posts/' }]

Returns:

  • (Google::Apis::SearchconsoleV1::SearchAnalyticsQueryResponse)


55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'app/services/seo/gsc_api_client.rb', line 55

def search_analytics(start_date:, end_date:, dimensions: ['page'], row_limit: 1000, filters: nil)
  request = Google::Apis::SearchconsoleV1::SearchAnalyticsQueryRequest.new(
    start_date: start_date.to_s,
    end_date: end_date.to_s,
    dimensions: dimensions,
    row_limit: [row_limit, 25_000].min
  )

  if filters.present?
    request.dimension_filter_groups = [
      Google::Apis::SearchconsoleV1::ApiDimensionFilterGroup.new(
        filters: filters.map do |f|
          Google::Apis::SearchconsoleV1::ApiDimensionFilter.new(
            dimension: f[:dimension],
            operator: f[:operator] || 'contains',
            expression: f[:expression]
          )
        end
      )
    ]
  end

  @service.query_searchanalytic(SITE_URL, request)
end

#test_connectionBoolean

Test the connection

Returns:

  • (Boolean)

    true if connection is successful



227
228
229
230
231
232
233
234
235
236
237
# File 'app/services/seo/gsc_api_client.rb', line 227

def test_connection
  # Try to list sites to verify authentication works
  @service.list_sites
  true
rescue Google::Apis::AuthorizationError => e
  Rails.logger.error "[GscApiClient] Authorization failed: #{e.message}"
  false
rescue StandardError => e
  Rails.logger.error "[GscApiClient] Connection test failed: #{e.message}"
  false
end