gsc-cli 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +205 -0
  3. data/FUNDING.md +120 -0
  4. data/README.md +463 -299
  5. data/bin/gsc +29158 -4921
  6. data/dist/gsc +29158 -4921
  7. data/lib/gsc/aio_hunter.rb +343 -0
  8. data/lib/gsc/answer_synthesizer.rb +157 -0
  9. data/lib/gsc/api.rb +53 -1
  10. data/lib/gsc/auth.rb +26 -0
  11. data/lib/gsc/backlinks_manager.rb +96 -0
  12. data/lib/gsc/brand_segmenter.rb +140 -0
  13. data/lib/gsc/cache_manager.rb +806 -0
  14. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  15. data/lib/gsc/canonical_chains.rb +367 -0
  16. data/lib/gsc/citation_simulator.rb +339 -0
  17. data/lib/gsc/cli/aio_hunter.rb +154 -0
  18. data/lib/gsc/cli/analytics.rb +788 -0
  19. data/lib/gsc/cli/audit.rb +1976 -0
  20. data/lib/gsc/cli/base.rb +384 -0
  21. data/lib/gsc/cli/cache.rb +266 -0
  22. data/lib/gsc/cli/canonical.rb +223 -0
  23. data/lib/gsc/cli/citation_simulator.rb +152 -0
  24. data/lib/gsc/cli/dashboard.rb +354 -0
  25. data/lib/gsc/cli/doctor.rb +129 -0
  26. data/lib/gsc/cli/eeat.rb +125 -0
  27. data/lib/gsc/cli/ga4.rb +852 -0
  28. data/lib/gsc/cli/growth.rb +650 -0
  29. data/lib/gsc/cli/hreflang.rb +164 -0
  30. data/lib/gsc/cli/image_seo.rb +162 -0
  31. data/lib/gsc/cli/indexing.rb +458 -0
  32. data/lib/gsc/cli/intent_shift.rb +125 -0
  33. data/lib/gsc/cli/keyword_value.rb +134 -0
  34. data/lib/gsc/cli/keywords.rb +795 -0
  35. data/lib/gsc/cli/landing_roi.rb +308 -0
  36. data/lib/gsc/cli/low_ctr.rb +213 -0
  37. data/lib/gsc/cli/mobile_parity.rb +150 -0
  38. data/lib/gsc/cli/report.rb +100 -0
  39. data/lib/gsc/cli/rich_results.rb +172 -0
  40. data/lib/gsc/cli/schema_generate.rb +149 -0
  41. data/lib/gsc/cli/seasonal.rb +232 -0
  42. data/lib/gsc/cli/security.rb +153 -0
  43. data/lib/gsc/cli/setup.rb +1291 -0
  44. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  45. data/lib/gsc/cli/skill_pack.rb +62 -0
  46. data/lib/gsc/cli/soft_404.rb +199 -0
  47. data/lib/gsc/cli/sparkline.rb +227 -0
  48. data/lib/gsc/cli/watchdog.rb +150 -0
  49. data/lib/gsc/cli/zombie_purger.rb +208 -0
  50. data/lib/gsc/cli.rb +706 -5111
  51. data/lib/gsc/cli_advanced.rb +1513 -0
  52. data/lib/gsc/client.rb +17 -2
  53. data/lib/gsc/color.rb +16 -1
  54. data/lib/gsc/command_registry.rb +47 -9
  55. data/lib/gsc/config.rb +11 -2
  56. data/lib/gsc/content_gap.rb +112 -0
  57. data/lib/gsc/ctr_curve.rb +115 -0
  58. data/lib/gsc/decay_predictor.rb +322 -0
  59. data/lib/gsc/doctor.rb +434 -0
  60. data/lib/gsc/eeat_auditor.rb +428 -0
  61. data/lib/gsc/entity_auditor.rb +229 -0
  62. data/lib/gsc/firewall_scanner.rb +733 -0
  63. data/lib/gsc/geo_auditor.rb +368 -0
  64. data/lib/gsc/google_suggest.rb +109 -0
  65. data/lib/gsc/google_trends.rb +8 -1
  66. data/lib/gsc/heading_validator.rb +283 -0
  67. data/lib/gsc/hreflang_validator.rb +412 -0
  68. data/lib/gsc/image_seo.rb +286 -0
  69. data/lib/gsc/indexing_queue.rb +179 -0
  70. data/lib/gsc/indexnow.rb +93 -0
  71. data/lib/gsc/intent_shift.rb +188 -0
  72. data/lib/gsc/internal_links.rb +249 -0
  73. data/lib/gsc/keyword_value.rb +191 -0
  74. data/lib/gsc/landing_roi.rb +195 -0
  75. data/lib/gsc/llms_generator.rb +425 -0
  76. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  77. data/lib/gsc/mobile_parity.rb +222 -0
  78. data/lib/gsc/network_tracer.rb +93 -0
  79. data/lib/gsc/open_page_rank.rb +72 -0
  80. data/lib/gsc/page_analyzer.rb +47 -7
  81. data/lib/gsc/page_comparator.rb +108 -0
  82. data/lib/gsc/page_speed.rb +110 -0
  83. data/lib/gsc/prompts.rb +38 -29
  84. data/lib/gsc/questions_harvester.rb +178 -0
  85. data/lib/gsc/report_generator.rb +461 -0
  86. data/lib/gsc/rich_results.rb +388 -0
  87. data/lib/gsc/robots_checker.rb +114 -0
  88. data/lib/gsc/schema_generator.rb +788 -0
  89. data/lib/gsc/schema_validator.rb +120 -0
  90. data/lib/gsc/seasonal_predictor.rb +381 -0
  91. data/lib/gsc/security_scanner.rb +496 -0
  92. data/lib/gsc/serp_feature_detector.rb +359 -0
  93. data/lib/gsc/serp_preview.rb +152 -0
  94. data/lib/gsc/site_crawler.rb +113 -21
  95. data/lib/gsc/sitemap_loader.rb +15 -4
  96. data/lib/gsc/sitemap_tree.rb +301 -0
  97. data/lib/gsc/skill_pack.rb +195 -0
  98. data/lib/gsc/soft_404_analyzer.rb +385 -0
  99. data/lib/gsc/sparkline.rb +171 -0
  100. data/lib/gsc/speed_correlator.rb +416 -0
  101. data/lib/gsc/striking_playbook.rb +190 -0
  102. data/lib/gsc/title_optimizer.rb +420 -0
  103. data/lib/gsc/vault.rb +260 -0
  104. data/lib/gsc/version.rb +1 -1
  105. data/lib/gsc/watchdog.rb +235 -0
  106. data/lib/gsc/zombie_purger.rb +366 -0
  107. data/lib/gsc.rb +144 -0
  108. metadata +91 -2
@@ -0,0 +1,806 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+ require 'date'
7
+
8
+ module GSC
9
+ class CacheManager
10
+ DEFAULT_CONFIG_DIR = File.expand_path('~/.config/gsc')
11
+
12
+ attr_reader :db_path, :engine, :config_dir
13
+
14
+ def initialize(db_path = nil)
15
+ @config_dir = ENV['GSC_CONFIG_DIR'] || DEFAULT_CONFIG_DIR
16
+ FileUtils.mkdir_p(@config_dir) unless Dir.exist?(@config_dir)
17
+
18
+ @db_path = db_path || ENV['GSC_CACHE_PATH'] || File.join(@config_dir, 'cache.db')
19
+ @json_path = File.join(@config_dir, 'cache.json')
20
+
21
+ init_engine!
22
+ init_schema!
23
+ end
24
+
25
+ # Return summary status of cache
26
+ def status
27
+ size_bytes = 0
28
+ if @engine == :sqlite_gem || @engine == :sqlite_cli
29
+ size_bytes = File.size(@db_path) if File.exist?(@db_path)
30
+ else
31
+ size_bytes = File.size(@json_path) if File.exist?(@json_path)
32
+ end
33
+
34
+ stats = fetch_stats
35
+ {
36
+ engine: @engine,
37
+ path: @engine == :json ? @json_path : @db_path,
38
+ size_bytes: size_bytes,
39
+ formatted_size: format_bytes(size_bytes),
40
+ total_queries: stats[:total_queries],
41
+ total_pages: stats[:total_pages],
42
+ domains: stats[:domains],
43
+ snapshots: stats[:snapshots],
44
+ last_updated: stats[:last_updated]
45
+ }
46
+ end
47
+
48
+ # Warm cache with Search Console data
49
+ def warm(domain, api, days: 30, limit: 5000)
50
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+ norm_domain = normalize_domain(domain)
52
+ snapshot_date = Date.today.iso8601
53
+
54
+ # 1. Fetch Queries from GSC
55
+ q_res = api.query_analytics(norm_domain, days: days, dimensions: ['query'], row_limit: limit)
56
+ query_rows = q_res ? (q_res['rows'] || q_res.dig(:data, 'rows') || q_res.dig('data', 'rows') || []) : []
57
+
58
+ # 2. Fetch Pages from GSC
59
+ p_res = api.query_analytics(norm_domain, days: days, dimensions: ['page'], row_limit: limit)
60
+ page_rows = p_res ? (p_res['rows'] || p_res.dig(:data, 'rows') || p_res.dig('data', 'rows') || []) : []
61
+
62
+ # 3. Fetch Query + Page pairs if possible for deep correlation (capped at 2500)
63
+ qp_res = api.query_analytics(norm_domain, days: days, dimensions: %w[query page], row_limit: [limit, 2500].min)
64
+ qp_rows = qp_res ? (qp_res['rows'] || qp_res.dig(:data, 'rows') || qp_res.dig('data', 'rows') || []) : []
65
+
66
+ saved_queries = save_queries(norm_domain, query_rows, qp_rows, days, snapshot_date)
67
+ saved_pages = save_pages(norm_domain, page_rows, days, snapshot_date)
68
+
69
+ record_meta(norm_domain, snapshot_date, days, saved_queries, saved_pages)
70
+
71
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
72
+
73
+ {
74
+ domain: norm_domain,
75
+ snapshot_date: snapshot_date,
76
+ days: days,
77
+ queries_saved: saved_queries,
78
+ pages_saved: saved_pages,
79
+ elapsed_ms: elapsed
80
+ }
81
+ end
82
+
83
+ # Save query records directly (useful for testing or direct population)
84
+ def save_queries_direct(domain, rows, days = 30, snapshot_date = Date.today.iso8601)
85
+ save_queries(normalize_domain(domain), rows, [], days, snapshot_date)
86
+ end
87
+
88
+ # Save page records directly
89
+ def save_pages_direct(domain, rows, days = 30, snapshot_date = Date.today.iso8601)
90
+ save_pages(normalize_domain(domain), rows, days, snapshot_date)
91
+ end
92
+
93
+ # Instant sub-10ms offline query search
94
+ def query(domain: nil, search: nil, min_clicks: 0, min_impressions: 0, sort: 'clicks', order: 'DESC', limit: 50)
95
+ norm_domain = domain ? normalize_domain(domain) : nil
96
+ safe_sort = %w[clicks impressions ctr position query].include?(sort.to_s.downcase) ? sort.to_s.downcase : 'clicks'
97
+ safe_order = order.to_s.upcase == 'ASC' ? 'ASC' : 'DESC'
98
+ limit_num = [limit.to_i, 1].max
99
+
100
+ if @engine == :sqlite_gem
101
+ query_sqlite_gem(norm_domain, search, min_clicks, min_impressions, safe_sort, safe_order, limit_num)
102
+ elsif @engine == :sqlite_cli
103
+ query_sqlite_cli(norm_domain, search, min_clicks, min_impressions, safe_sort, safe_order, limit_num)
104
+ else
105
+ query_json(norm_domain, search, min_clicks, min_impressions, safe_sort, safe_order, limit_num)
106
+ end
107
+ end
108
+
109
+ # Instant sub-10ms offline page search
110
+ def pages(domain: nil, search: nil, min_clicks: 0, min_impressions: 0, sort: 'clicks', order: 'DESC', limit: 50)
111
+ norm_domain = domain ? normalize_domain(domain) : nil
112
+ safe_sort = %w[clicks impressions ctr position page].include?(sort.to_s.downcase) ? sort.to_s.downcase : 'clicks'
113
+ safe_order = order.to_s.upcase == 'ASC' ? 'ASC' : 'DESC'
114
+ limit_num = [limit.to_i, 1].max
115
+
116
+ if @engine == :sqlite_gem
117
+ pages_sqlite_gem(norm_domain, search, min_clicks, min_impressions, safe_sort, safe_order, limit_num)
118
+ elsif @engine == :sqlite_cli
119
+ pages_sqlite_cli(norm_domain, search, min_clicks, min_impressions, safe_sort, safe_order, limit_num)
120
+ else
121
+ pages_json(norm_domain, search, min_clicks, min_impressions, safe_sort, safe_order, limit_num)
122
+ end
123
+ end
124
+
125
+ # Offline diff between two snapshot dates
126
+ def diff(domain, old_date, new_date)
127
+ norm_domain = normalize_domain(domain)
128
+
129
+ old_rows = query(domain: norm_domain, limit: 10000).select { |r| r[:snapshot_date] == old_date }
130
+ new_rows = query(domain: norm_domain, limit: 10000).select { |r| r[:snapshot_date] == new_date }
131
+
132
+ # Fallback to nearest dates if exact date match is empty
133
+ if old_rows.empty? || new_rows.empty?
134
+ all_snapshots = fetch_stats[:snapshots].select { |s| s[:domain] == norm_domain }.map { |s| s[:snapshot_date] }.uniq.sort
135
+ if all_snapshots.size >= 2
136
+ old_date ||= all_snapshots[-2]
137
+ new_date ||= all_snapshots[-1]
138
+ old_rows = query(domain: norm_domain, limit: 10000).select { |r| r[:snapshot_date] == old_date }
139
+ new_rows = query(domain: norm_domain, limit: 10000).select { |r| r[:snapshot_date] == new_date }
140
+ end
141
+ end
142
+
143
+ old_map = {}
144
+ old_rows.each { |r| old_map[r[:query]] = r }
145
+
146
+ new_map = {}
147
+ new_rows.each { |r| new_map[r[:query]] = r }
148
+
149
+ winners = []
150
+ losers = []
151
+ new_queries = []
152
+ dropped_queries = []
153
+
154
+ new_map.each do |q, new_r|
155
+ if old_map.key?(q)
156
+ old_r = old_map[q]
157
+ click_delta = new_r[:clicks] - old_r[:clicks]
158
+ imp_delta = new_r[:impressions] - old_r[:impressions]
159
+ pos_delta = (old_r[:position] - new_r[:position]).round(1) # positive means improved rank
160
+
161
+ item = {
162
+ query: q,
163
+ old_clicks: old_r[:clicks],
164
+ new_clicks: new_r[:clicks],
165
+ click_delta: click_delta,
166
+ old_impressions: old_r[:impressions],
167
+ new_impressions: new_r[:impressions],
168
+ imp_delta: imp_delta,
169
+ old_position: old_r[:position],
170
+ new_position: new_r[:position],
171
+ pos_delta: pos_delta
172
+ }
173
+
174
+ if click_delta > 0 || (click_delta == 0 && imp_delta > 0)
175
+ winners << item
176
+ elsif click_delta < 0 || (click_delta == 0 && imp_delta < 0)
177
+ losers << item
178
+ end
179
+ else
180
+ new_queries << {
181
+ query: q,
182
+ clicks: new_r[:clicks],
183
+ impressions: new_r[:impressions],
184
+ position: new_r[:position]
185
+ }
186
+ end
187
+ end
188
+
189
+ old_map.each do |q, old_r|
190
+ unless new_map.key?(q)
191
+ dropped_queries << {
192
+ query: q,
193
+ clicks: old_r[:clicks],
194
+ impressions: old_r[:impressions],
195
+ position: old_r[:position]
196
+ }
197
+ end
198
+ end
199
+
200
+ winners.sort_by! { |w| -w[:click_delta] }
201
+ losers.sort_by! { |l| l[:click_delta] }
202
+ new_queries.sort_by! { |n| -n[:impressions] }
203
+ dropped_queries.sort_by! { |d| -d[:impressions] }
204
+
205
+ {
206
+ domain: norm_domain,
207
+ old_date: old_date,
208
+ new_date: new_date,
209
+ summary: {
210
+ winners_count: winners.size,
211
+ losers_count: losers.size,
212
+ new_queries_count: new_queries.size,
213
+ dropped_queries_count: dropped_queries.size
214
+ },
215
+ winners: winners.first(20),
216
+ losers: losers.first(20),
217
+ new_queries: new_queries.first(20),
218
+ dropped_queries: dropped_queries.first(20)
219
+ }
220
+ end
221
+
222
+ # Clear cache data
223
+ def clear(domain: nil, all: false)
224
+ if all || domain.nil?
225
+ if @engine == :sqlite_gem
226
+ @sqlite_db.execute("DELETE FROM query_snapshots;")
227
+ @sqlite_db.execute("DELETE FROM page_snapshots;")
228
+ @sqlite_db.execute("DELETE FROM cache_meta;")
229
+ @sqlite_db.execute("VACUUM;")
230
+ elsif @engine == :sqlite_cli
231
+ exec_cli_sql("DELETE FROM query_snapshots; DELETE FROM page_snapshots; DELETE FROM cache_meta; VACUUM;")
232
+ else
233
+ File.write(@json_path, JSON.generate({ meta: [], queries: [], pages: [] }))
234
+ end
235
+ { status: 'cleared_all' }
236
+ else
237
+ norm_domain = normalize_domain(domain)
238
+ if @engine == :sqlite_gem
239
+ @sqlite_db.execute("DELETE FROM query_snapshots WHERE domain = ?;", [norm_domain])
240
+ @sqlite_db.execute("DELETE FROM page_snapshots WHERE domain = ?;", [norm_domain])
241
+ @sqlite_db.execute("DELETE FROM cache_meta WHERE key LIKE ?;", ["%#{norm_domain}%"])
242
+ @sqlite_db.execute("VACUUM;")
243
+ elsif @engine == :sqlite_cli
244
+ escaped = norm_domain.gsub("'", "''")
245
+ exec_cli_sql("DELETE FROM query_snapshots WHERE domain = '#{escaped}'; DELETE FROM page_snapshots WHERE domain = '#{escaped}'; DELETE FROM cache_meta WHERE key LIKE '%#{escaped}%'; VACUUM;")
246
+ else
247
+ data = read_json_data
248
+ data['queries'].reject! { |q| q['domain'] == norm_domain }
249
+ data['pages'].reject! { |p| p['domain'] == norm_domain }
250
+ data['meta'].reject! { |m| m['key'].to_s.include?(norm_domain) }
251
+ File.write(@json_path, JSON.generate(data))
252
+ end
253
+ { status: 'cleared_domain', domain: norm_domain }
254
+ end
255
+ end
256
+
257
+ private
258
+
259
+ def init_engine!
260
+ begin
261
+ require 'sqlite3'
262
+ @sqlite_db = SQLite3::Database.new(@db_path)
263
+ @sqlite_db.results_as_hash = true
264
+ @engine = :sqlite_gem
265
+ return
266
+ rescue LoadError
267
+ # Gem not loaded, try CLI
268
+ end
269
+
270
+ if sqlite3_cli_available?
271
+ @engine = :sqlite_cli
272
+ return
273
+ end
274
+
275
+ @engine = :json
276
+ end
277
+
278
+ def sqlite3_cli_available?
279
+ system('which sqlite3 >/dev/null 2>&1')
280
+ end
281
+
282
+ def init_schema!
283
+ case @engine
284
+ when :sqlite_gem
285
+ @sqlite_db.execute_batch(<<~SQL)
286
+ CREATE TABLE IF NOT EXISTS cache_meta (
287
+ key TEXT PRIMARY KEY,
288
+ value TEXT,
289
+ updated_at TEXT
290
+ );
291
+
292
+ CREATE TABLE IF NOT EXISTS query_snapshots (
293
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
294
+ domain TEXT NOT NULL,
295
+ query TEXT NOT NULL,
296
+ page TEXT,
297
+ clicks INTEGER DEFAULT 0,
298
+ impressions INTEGER DEFAULT 0,
299
+ ctr REAL DEFAULT 0.0,
300
+ position REAL DEFAULT 0.0,
301
+ days INTEGER DEFAULT 30,
302
+ snapshot_date TEXT NOT NULL,
303
+ created_at TEXT NOT NULL
304
+ );
305
+
306
+ CREATE TABLE IF NOT EXISTS page_snapshots (
307
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
308
+ domain TEXT NOT NULL,
309
+ page TEXT NOT NULL,
310
+ clicks INTEGER DEFAULT 0,
311
+ impressions INTEGER DEFAULT 0,
312
+ ctr REAL DEFAULT 0.0,
313
+ position REAL DEFAULT 0.0,
314
+ days INTEGER DEFAULT 30,
315
+ snapshot_date TEXT NOT NULL,
316
+ created_at TEXT NOT NULL
317
+ );
318
+
319
+ CREATE INDEX IF NOT EXISTS idx_query_domain ON query_snapshots(domain);
320
+ CREATE INDEX IF NOT EXISTS idx_query_text ON query_snapshots(query);
321
+ CREATE INDEX IF NOT EXISTS idx_query_clicks ON query_snapshots(clicks);
322
+ CREATE INDEX IF NOT EXISTS idx_query_impressions ON query_snapshots(impressions);
323
+ CREATE INDEX IF NOT EXISTS idx_query_date ON query_snapshots(snapshot_date);
324
+ CREATE INDEX IF NOT EXISTS idx_page_domain ON page_snapshots(domain);
325
+ CREATE INDEX IF NOT EXISTS idx_page_url ON page_snapshots(page);
326
+ CREATE INDEX IF NOT EXISTS idx_page_date ON page_snapshots(snapshot_date);
327
+ SQL
328
+ when :sqlite_cli
329
+ sql = <<~SQL
330
+ CREATE TABLE IF NOT EXISTS cache_meta (key TEXT PRIMARY KEY, value TEXT, updated_at TEXT);
331
+ CREATE TABLE IF NOT EXISTS query_snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL, query TEXT NOT NULL, page TEXT, clicks INTEGER DEFAULT 0, impressions INTEGER DEFAULT 0, ctr REAL DEFAULT 0.0, position REAL DEFAULT 0.0, days INTEGER DEFAULT 30, snapshot_date TEXT NOT NULL, created_at TEXT NOT NULL);
332
+ CREATE TABLE IF NOT EXISTS page_snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL, page TEXT NOT NULL, clicks INTEGER DEFAULT 0, impressions INTEGER DEFAULT 0, ctr REAL DEFAULT 0.0, position REAL DEFAULT 0.0, days INTEGER DEFAULT 30, snapshot_date TEXT NOT NULL, created_at TEXT NOT NULL);
333
+ CREATE INDEX IF NOT EXISTS idx_query_domain ON query_snapshots(domain);
334
+ CREATE INDEX IF NOT EXISTS idx_query_text ON query_snapshots(query);
335
+ CREATE INDEX IF NOT EXISTS idx_page_domain ON page_snapshots(domain);
336
+ SQL
337
+ exec_cli_sql(sql)
338
+ when :json
339
+ unless File.exist?(@json_path)
340
+ File.write(@json_path, JSON.generate({ meta: [], queries: [], pages: [] }))
341
+ end
342
+ end
343
+ end
344
+
345
+ def exec_cli_sql(sql)
346
+ cmd = ['sqlite3', @db_path, sql]
347
+ IO.popen(cmd, 'r') { |io| io.read }
348
+ rescue => _e
349
+ ''
350
+ end
351
+
352
+ def save_queries(domain, query_rows, qp_rows, days, snapshot_date)
353
+ # Build query -> primary page mapping if qp_rows is provided
354
+ query_to_page = {}
355
+ qp_rows.each do |row|
356
+ keys = row['keys'] || []
357
+ q = keys[0].to_s.strip
358
+ p = keys[1].to_s.strip
359
+ query_to_page[q] ||= p unless q.empty? || p.empty?
360
+ end
361
+
362
+ created_at = Time.now.utc.iso8601
363
+ count = 0
364
+
365
+ case @engine
366
+ when :sqlite_gem
367
+ @sqlite_db.transaction do
368
+ query_rows.each do |row|
369
+ keys = row['keys'] || []
370
+ q_text = keys[0].to_s.strip
371
+ next if q_text.empty?
372
+
373
+ clicks = (row['clicks'] || 0).to_i
374
+ impressions = (row['impressions'] || 0).to_i
375
+ ctr = (row['ctr'] || 0.0).to_f.round(4)
376
+ pos = (row['position'] || 0.0).to_f.round(1)
377
+ page = query_to_page[q_text] || ''
378
+
379
+ @sqlite_db.execute(
380
+ "INSERT INTO query_snapshots (domain, query, page, clicks, impressions, ctr, position, days, snapshot_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
381
+ [domain, q_text, page, clicks, impressions, ctr, pos, days, snapshot_date, created_at]
382
+ )
383
+ count += 1
384
+ end
385
+ end
386
+ when :sqlite_cli
387
+ sql_statements = ["BEGIN TRANSACTION;"]
388
+ query_rows.each do |row|
389
+ keys = row['keys'] || []
390
+ q_text = keys[0].to_s.strip.gsub("'", "''")
391
+ next if q_text.empty?
392
+
393
+ clicks = (row['clicks'] || 0).to_i
394
+ impressions = (row['impressions'] || 0).to_i
395
+ ctr = (row['ctr'] || 0.0).to_f.round(4)
396
+ pos = (row['position'] || 0.0).to_f.round(1)
397
+ page = (query_to_page[keys[0].to_s.strip] || '').gsub("'", "''")
398
+
399
+ sql_statements << "INSERT INTO query_snapshots (domain, query, page, clicks, impressions, ctr, position, days, snapshot_date, created_at) VALUES ('#{domain}', '#{q_text}', '#{page}', #{clicks}, #{impressions}, #{ctr}, #{pos}, #{days}, '#{snapshot_date}', '#{created_at}');"
400
+ count += 1
401
+ end
402
+ sql_statements << "COMMIT;"
403
+ exec_cli_sql(sql_statements.join("\n"))
404
+ when :json
405
+ data = read_json_data
406
+ query_rows.each do |row|
407
+ keys = row['keys'] || []
408
+ q_text = keys[0].to_s.strip
409
+ next if q_text.empty?
410
+
411
+ data['queries'] << {
412
+ 'domain' => domain,
413
+ 'query' => q_text,
414
+ 'page' => query_to_page[q_text] || '',
415
+ 'clicks' => (row['clicks'] || 0).to_i,
416
+ 'impressions' => (row['impressions'] || 0).to_i,
417
+ 'ctr' => (row['ctr'] || 0.0).to_f.round(4),
418
+ 'position' => (row['position'] || 0.0).to_f.round(1),
419
+ 'days' => days,
420
+ 'snapshot_date' => snapshot_date,
421
+ 'created_at' => created_at
422
+ }
423
+ count += 1
424
+ end
425
+ File.write(@json_path, JSON.generate(data))
426
+ end
427
+ count
428
+ end
429
+
430
+ def save_pages(domain, page_rows, days, snapshot_date)
431
+ created_at = Time.now.utc.iso8601
432
+ count = 0
433
+
434
+ case @engine
435
+ when :sqlite_gem
436
+ @sqlite_db.transaction do
437
+ page_rows.each do |row|
438
+ keys = row['keys'] || []
439
+ page_url = keys[0].to_s.strip
440
+ next if page_url.empty?
441
+
442
+ clicks = (row['clicks'] || 0).to_i
443
+ impressions = (row['impressions'] || 0).to_i
444
+ ctr = (row['ctr'] || 0.0).to_f.round(4)
445
+ pos = (row['position'] || 0.0).to_f.round(1)
446
+
447
+ @sqlite_db.execute(
448
+ "INSERT INTO page_snapshots (domain, page, clicks, impressions, ctr, position, days, snapshot_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
449
+ [domain, page_url, clicks, impressions, ctr, pos, days, snapshot_date, created_at]
450
+ )
451
+ count += 1
452
+ end
453
+ end
454
+ when :sqlite_cli
455
+ sql_statements = ["BEGIN TRANSACTION;"]
456
+ page_rows.each do |row|
457
+ keys = row['keys'] || []
458
+ page_url = keys[0].to_s.strip.gsub("'", "''")
459
+ next if page_url.empty?
460
+
461
+ clicks = (row['clicks'] || 0).to_i
462
+ impressions = (row['impressions'] || 0).to_i
463
+ ctr = (row['ctr'] || 0.0).to_f.round(4)
464
+ pos = (row['position'] || 0.0).to_f.round(1)
465
+
466
+ sql_statements << "INSERT INTO page_snapshots (domain, page, clicks, impressions, ctr, position, days, snapshot_date, created_at) VALUES ('#{domain}', '#{page_url}', #{clicks}, #{impressions}, #{ctr}, #{pos}, #{days}, '#{snapshot_date}', '#{created_at}');"
467
+ count += 1
468
+ end
469
+ sql_statements << "COMMIT;"
470
+ exec_cli_sql(sql_statements.join("\n"))
471
+ when :json
472
+ data = read_json_data
473
+ page_rows.each do |row|
474
+ keys = row['keys'] || []
475
+ page_url = keys[0].to_s.strip
476
+ next if page_url.empty?
477
+
478
+ data['pages'] << {
479
+ 'domain' => domain,
480
+ 'page' => page_url,
481
+ 'clicks' => (row['clicks'] || 0).to_i,
482
+ 'impressions' => (row['impressions'] || 0).to_i,
483
+ 'ctr' => (row['ctr'] || 0.0).to_f.round(4),
484
+ 'position' => (row['position'] || 0.0).to_f.round(1),
485
+ 'days' => days,
486
+ 'snapshot_date' => snapshot_date,
487
+ 'created_at' => created_at
488
+ }
489
+ count += 1
490
+ end
491
+ File.write(@json_path, JSON.generate(data))
492
+ end
493
+ count
494
+ end
495
+
496
+ def record_meta(domain, snapshot_date, days, query_count, page_count)
497
+ key = "sync:#{domain}:#{snapshot_date}"
498
+ val = JSON.generate({
499
+ domain: domain,
500
+ snapshot_date: snapshot_date,
501
+ days: days,
502
+ queries: query_count,
503
+ pages: page_count,
504
+ updated_at: Time.now.utc.iso8601
505
+ })
506
+
507
+ case @engine
508
+ when :sqlite_gem
509
+ @sqlite_db.execute("INSERT OR REPLACE INTO cache_meta (key, value, updated_at) VALUES (?, ?, ?);", [key, val, Time.now.utc.iso8601])
510
+ when :sqlite_cli
511
+ escaped_key = key.gsub("'", "''")
512
+ escaped_val = val.gsub("'", "''")
513
+ exec_cli_sql("INSERT OR REPLACE INTO cache_meta (key, value, updated_at) VALUES ('#{escaped_key}', '#{escaped_val}', '#{Time.now.utc.iso8601}');")
514
+ when :json
515
+ data = read_json_data
516
+ data['meta'].reject! { |m| m['key'] == key }
517
+ data['meta'] << { 'key' => key, 'value' => val, 'updated_at' => Time.now.utc.iso8601 }
518
+ File.write(@json_path, JSON.generate(data))
519
+ end
520
+ end
521
+
522
+ def query_sqlite_gem(domain, search, min_clicks, min_impressions, sort, order, limit)
523
+ sql = +"SELECT domain, query, page, clicks, impressions, ctr, position, days, snapshot_date, created_at FROM query_snapshots WHERE 1=1"
524
+ params = []
525
+
526
+ if domain
527
+ sql << " AND domain = ?"
528
+ params << domain
529
+ end
530
+
531
+ if search && !search.strip.empty?
532
+ sql << " AND query LIKE ?"
533
+ params << "%#{search.strip}%"
534
+ end
535
+
536
+ if min_clicks.to_i > 0
537
+ sql << " AND clicks >= ?"
538
+ params << min_clicks.to_i
539
+ end
540
+
541
+ if min_impressions.to_i > 0
542
+ sql << " AND impressions >= ?"
543
+ params << min_impressions.to_i
544
+ end
545
+
546
+ sql << " ORDER BY #{sort} #{order} LIMIT ?"
547
+ params << limit
548
+
549
+ rows = @sqlite_db.execute(sql, params)
550
+ rows.map do |r|
551
+ {
552
+ domain: r['domain'],
553
+ query: r['query'],
554
+ page: r['page'],
555
+ clicks: r['clicks'].to_i,
556
+ impressions: r['impressions'].to_i,
557
+ ctr: r['ctr'].to_f,
558
+ position: r['position'].to_f,
559
+ days: r['days'].to_i,
560
+ snapshot_date: r['snapshot_date'],
561
+ created_at: r['created_at']
562
+ }
563
+ end
564
+ end
565
+
566
+ def query_sqlite_cli(domain, search, min_clicks, min_impressions, sort, order, limit)
567
+ conditions = ["1=1"]
568
+ conditions << "domain = '#{domain.gsub("'", "''")}'" if domain
569
+ conditions << "query LIKE '%#{search.strip.gsub("'", "''")}%'" if search && !search.strip.empty?
570
+ conditions << "clicks >= #{min_clicks.to_i}" if min_clicks.to_i > 0
571
+ conditions << "impressions >= #{min_impressions.to_i}" if min_impressions.to_i > 0
572
+
573
+ sql = "SELECT domain, query, page, clicks, impressions, ctr, position, days, snapshot_date, created_at FROM query_snapshots WHERE #{conditions.join(' AND ')} ORDER BY #{sort} #{order} LIMIT #{limit};"
574
+ out = IO.popen(['sqlite3', '-json', @db_path, sql], 'r') { |io| io.read } rescue '[]'
575
+ parsed = JSON.parse(out) rescue []
576
+ parsed.map do |r|
577
+ {
578
+ domain: r['domain'],
579
+ query: r['query'],
580
+ page: r['page'],
581
+ clicks: r['clicks'].to_i,
582
+ impressions: r['impressions'].to_i,
583
+ ctr: r['ctr'].to_f,
584
+ position: r['position'].to_f,
585
+ days: r['days'].to_i,
586
+ snapshot_date: r['snapshot_date'],
587
+ created_at: r['created_at']
588
+ }
589
+ end
590
+ end
591
+
592
+ def query_json(domain, search, min_clicks, min_impressions, sort, order, limit)
593
+ data = read_json_data
594
+ rows = data['queries'] || []
595
+
596
+ filtered = rows.select do |r|
597
+ next false if domain && r['domain'] != domain
598
+ next false if search && !search.strip.empty? && !r['query'].to_s.downcase.include?(search.strip.downcase)
599
+ next false if min_clicks.to_i > 0 && r['clicks'].to_i < min_clicks.to_i
600
+ next false if min_impressions.to_i > 0 && r['impressions'].to_i < min_impressions.to_i
601
+ true
602
+ end
603
+
604
+ sorted = filtered.sort_by do |r|
605
+ val = r[sort] || 0
606
+ sort == 'query' ? val.to_s.downcase : val.to_f
607
+ end
608
+
609
+ sorted.reverse! if order == 'DESC'
610
+ sorted.first(limit).map do |r|
611
+ {
612
+ domain: r['domain'],
613
+ query: r['query'],
614
+ page: r['page'],
615
+ clicks: r['clicks'].to_i,
616
+ impressions: r['impressions'].to_i,
617
+ ctr: r['ctr'].to_f,
618
+ position: r['position'].to_f,
619
+ days: r['days'].to_i,
620
+ snapshot_date: r['snapshot_date'],
621
+ created_at: r['created_at']
622
+ }
623
+ end
624
+ end
625
+
626
+ def pages_sqlite_gem(domain, search, min_clicks, min_impressions, sort, order, limit)
627
+ sql = +"SELECT domain, page, clicks, impressions, ctr, position, days, snapshot_date, created_at FROM page_snapshots WHERE 1=1"
628
+ params = []
629
+
630
+ if domain
631
+ sql << " AND domain = ?"
632
+ params << domain
633
+ end
634
+
635
+ if search && !search.strip.empty?
636
+ sql << " AND page LIKE ?"
637
+ params << "%#{search.strip}%"
638
+ end
639
+
640
+ if min_clicks.to_i > 0
641
+ sql << " AND clicks >= ?"
642
+ params << min_clicks.to_i
643
+ end
644
+
645
+ if min_impressions.to_i > 0
646
+ sql << " AND impressions >= ?"
647
+ params << min_impressions.to_i
648
+ end
649
+
650
+ sql << " ORDER BY #{sort} #{order} LIMIT ?"
651
+ params << limit
652
+
653
+ rows = @sqlite_db.execute(sql, params)
654
+ rows.map do |r|
655
+ {
656
+ domain: r['domain'],
657
+ page: r['page'],
658
+ clicks: r['clicks'].to_i,
659
+ impressions: r['impressions'].to_i,
660
+ ctr: r['ctr'].to_f,
661
+ position: r['position'].to_f,
662
+ days: r['days'].to_i,
663
+ snapshot_date: r['snapshot_date'],
664
+ created_at: r['created_at']
665
+ }
666
+ end
667
+ end
668
+
669
+ def pages_sqlite_cli(domain, search, min_clicks, min_impressions, sort, order, limit)
670
+ conditions = ["1=1"]
671
+ conditions << "domain = '#{domain.gsub("'", "''")}'" if domain
672
+ conditions << "page LIKE '%#{search.strip.gsub("'", "''")}%'" if search && !search.strip.empty?
673
+ conditions << "clicks >= #{min_clicks.to_i}" if min_clicks.to_i > 0
674
+ conditions << "impressions >= #{min_impressions.to_i}" if min_impressions.to_i > 0
675
+
676
+ sql = "SELECT domain, page, clicks, impressions, ctr, position, days, snapshot_date, created_at FROM page_snapshots WHERE #{conditions.join(' AND ')} ORDER BY #{sort} #{order} LIMIT #{limit};"
677
+ out = IO.popen(['sqlite3', '-json', @db_path, sql], 'r') { |io| io.read } rescue '[]'
678
+ parsed = JSON.parse(out) rescue []
679
+ parsed.map do |r|
680
+ {
681
+ domain: r['domain'],
682
+ page: r['page'],
683
+ clicks: r['clicks'].to_i,
684
+ impressions: r['impressions'].to_i,
685
+ ctr: r['ctr'].to_f,
686
+ position: r['position'].to_f,
687
+ days: r['days'].to_i,
688
+ snapshot_date: r['snapshot_date'],
689
+ created_at: r['created_at']
690
+ }
691
+ end
692
+ end
693
+
694
+ def pages_json(domain, search, min_clicks, min_impressions, sort, order, limit)
695
+ data = read_json_data
696
+ rows = data['pages'] || []
697
+
698
+ filtered = rows.select do |r|
699
+ next false if domain && r['domain'] != domain
700
+ next false if search && !search.strip.empty? && !r['page'].to_s.downcase.include?(search.strip.downcase)
701
+ next false if min_clicks.to_i > 0 && r['clicks'].to_i < min_clicks.to_i
702
+ next false if min_impressions.to_i > 0 && r['impressions'].to_i < min_impressions.to_i
703
+ true
704
+ end
705
+
706
+ sorted = filtered.sort_by do |r|
707
+ val = r[sort] || 0
708
+ sort == 'page' ? val.to_s.downcase : val.to_f
709
+ end
710
+
711
+ sorted.reverse! if order == 'DESC'
712
+ sorted.first(limit).map do |r|
713
+ {
714
+ domain: r['domain'],
715
+ page: r['page'],
716
+ clicks: r['clicks'].to_i,
717
+ impressions: r['impressions'].to_i,
718
+ ctr: r['ctr'].to_f,
719
+ position: r['position'].to_f,
720
+ days: r['days'].to_i,
721
+ snapshot_date: r['snapshot_date'],
722
+ created_at: r['created_at']
723
+ }
724
+ end
725
+ end
726
+
727
+ def fetch_stats
728
+ case @engine
729
+ when :sqlite_gem
730
+ q_count = @sqlite_db.get_first_value("SELECT COUNT(*) FROM query_snapshots;").to_i rescue 0
731
+ p_count = @sqlite_db.get_first_value("SELECT COUNT(*) FROM page_snapshots;").to_i rescue 0
732
+ domains = @sqlite_db.execute("SELECT DISTINCT domain FROM query_snapshots UNION SELECT DISTINCT domain FROM page_snapshots;").map { |r| r['domain'] }.compact rescue []
733
+
734
+ meta_rows = @sqlite_db.execute("SELECT value, updated_at FROM cache_meta ORDER BY updated_at DESC;") rescue []
735
+ snapshots = meta_rows.map { |r| JSON.parse(r['value']) rescue nil }.compact
736
+ last_updated = meta_rows.first ? meta_rows.first['updated_at'] : nil
737
+
738
+ {
739
+ total_queries: q_count,
740
+ total_pages: p_count,
741
+ domains: domains,
742
+ snapshots: snapshots,
743
+ last_updated: last_updated
744
+ }
745
+ when :sqlite_cli
746
+ q_count = exec_cli_sql("SELECT COUNT(*) FROM query_snapshots;").to_i rescue 0
747
+ p_count = exec_cli_sql("SELECT COUNT(*) FROM page_snapshots;").to_i rescue 0
748
+ domains_out = exec_cli_sql("SELECT DISTINCT domain FROM query_snapshots UNION SELECT DISTINCT domain FROM page_snapshots;") rescue ''
749
+ domains = domains_out.split("\n").map(&:strip).reject(&:empty?)
750
+
751
+ meta_out = IO.popen(['sqlite3', '-json', @db_path, "SELECT value, updated_at FROM cache_meta ORDER BY updated_at DESC;"], 'r') { |io| io.read } rescue '[]'
752
+ meta_rows = JSON.parse(meta_out) rescue []
753
+ snapshots = meta_rows.map { |r| JSON.parse(r['value']) rescue nil }.compact
754
+ last_updated = meta_rows.first ? meta_rows.first['updated_at'] : nil
755
+
756
+ {
757
+ total_queries: q_count,
758
+ total_pages: p_count,
759
+ domains: domains,
760
+ snapshots: snapshots,
761
+ last_updated: last_updated
762
+ }
763
+ when :json
764
+ data = read_json_data
765
+ queries = data['queries'] || []
766
+ pages = data['pages'] || []
767
+ domains = (queries.map { |q| q['domain'] } + pages.map { |p| p['domain'] }).uniq.compact
768
+ meta_rows = data['meta'] || []
769
+ snapshots = meta_rows.map { |m| JSON.parse(m['value']) rescue nil }.compact
770
+
771
+ {
772
+ total_queries: queries.size,
773
+ total_pages: pages.size,
774
+ domains: domains,
775
+ snapshots: snapshots,
776
+ last_updated: meta_rows.last ? meta_rows.last['updated_at'] : nil
777
+ }
778
+ end
779
+ end
780
+
781
+ def read_json_data
782
+ return { 'meta' => [], 'queries' => [], 'pages' => [] } unless File.exist?(@json_path)
783
+
784
+ begin
785
+ JSON.parse(File.read(@json_path))
786
+ rescue => _e
787
+ { 'meta' => [], 'queries' => [], 'pages' => [] }
788
+ end
789
+ end
790
+
791
+ def normalize_domain(domain)
792
+ d = domain.to_s.strip.downcase
793
+ d = d.sub(%r{^https?://}, '').sub(%r{^sc-domain:}, '')
794
+ d.split('/').first || 'unknown.com'
795
+ end
796
+
797
+ def format_bytes(bytes)
798
+ return '0 B' if bytes.nil? || bytes.zero?
799
+
800
+ units = %w[B KB MB GB]
801
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
802
+ exp = units.length - 1 if exp >= units.length
803
+ format('%.2f %s', bytes.to_f / (1024**exp), units[exp])
804
+ end
805
+ end
806
+ end