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.
- checksums.yaml +4 -4
- data/AUTH.md +205 -0
- data/FUNDING.md +120 -0
- data/README.md +463 -299
- data/bin/gsc +29158 -4921
- data/dist/gsc +29158 -4921
- data/lib/gsc/aio_hunter.rb +343 -0
- data/lib/gsc/answer_synthesizer.rb +157 -0
- data/lib/gsc/api.rb +53 -1
- data/lib/gsc/auth.rb +26 -0
- data/lib/gsc/backlinks_manager.rb +96 -0
- data/lib/gsc/brand_segmenter.rb +140 -0
- data/lib/gsc/cache_manager.rb +806 -0
- data/lib/gsc/cannibalization_analyzer.rb +141 -0
- data/lib/gsc/canonical_chains.rb +367 -0
- data/lib/gsc/citation_simulator.rb +339 -0
- data/lib/gsc/cli/aio_hunter.rb +154 -0
- data/lib/gsc/cli/analytics.rb +788 -0
- data/lib/gsc/cli/audit.rb +1976 -0
- data/lib/gsc/cli/base.rb +384 -0
- data/lib/gsc/cli/cache.rb +266 -0
- data/lib/gsc/cli/canonical.rb +223 -0
- data/lib/gsc/cli/citation_simulator.rb +152 -0
- data/lib/gsc/cli/dashboard.rb +354 -0
- data/lib/gsc/cli/doctor.rb +129 -0
- data/lib/gsc/cli/eeat.rb +125 -0
- data/lib/gsc/cli/ga4.rb +852 -0
- data/lib/gsc/cli/growth.rb +650 -0
- data/lib/gsc/cli/hreflang.rb +164 -0
- data/lib/gsc/cli/image_seo.rb +162 -0
- data/lib/gsc/cli/indexing.rb +458 -0
- data/lib/gsc/cli/intent_shift.rb +125 -0
- data/lib/gsc/cli/keyword_value.rb +134 -0
- data/lib/gsc/cli/keywords.rb +795 -0
- data/lib/gsc/cli/landing_roi.rb +308 -0
- data/lib/gsc/cli/low_ctr.rb +213 -0
- data/lib/gsc/cli/mobile_parity.rb +150 -0
- data/lib/gsc/cli/report.rb +100 -0
- data/lib/gsc/cli/rich_results.rb +172 -0
- data/lib/gsc/cli/schema_generate.rb +149 -0
- data/lib/gsc/cli/seasonal.rb +232 -0
- data/lib/gsc/cli/security.rb +153 -0
- data/lib/gsc/cli/setup.rb +1291 -0
- data/lib/gsc/cli/sitemap_tree.rb +143 -0
- data/lib/gsc/cli/skill_pack.rb +62 -0
- data/lib/gsc/cli/soft_404.rb +199 -0
- data/lib/gsc/cli/sparkline.rb +227 -0
- data/lib/gsc/cli/watchdog.rb +150 -0
- data/lib/gsc/cli/zombie_purger.rb +208 -0
- data/lib/gsc/cli.rb +706 -5111
- data/lib/gsc/cli_advanced.rb +1513 -0
- data/lib/gsc/client.rb +17 -2
- data/lib/gsc/color.rb +16 -1
- data/lib/gsc/command_registry.rb +47 -9
- data/lib/gsc/config.rb +11 -2
- data/lib/gsc/content_gap.rb +112 -0
- data/lib/gsc/ctr_curve.rb +115 -0
- data/lib/gsc/decay_predictor.rb +322 -0
- data/lib/gsc/doctor.rb +434 -0
- data/lib/gsc/eeat_auditor.rb +428 -0
- data/lib/gsc/entity_auditor.rb +229 -0
- data/lib/gsc/firewall_scanner.rb +733 -0
- data/lib/gsc/geo_auditor.rb +368 -0
- data/lib/gsc/google_suggest.rb +109 -0
- data/lib/gsc/google_trends.rb +8 -1
- data/lib/gsc/heading_validator.rb +283 -0
- data/lib/gsc/hreflang_validator.rb +412 -0
- data/lib/gsc/image_seo.rb +286 -0
- data/lib/gsc/indexing_queue.rb +179 -0
- data/lib/gsc/indexnow.rb +93 -0
- data/lib/gsc/intent_shift.rb +188 -0
- data/lib/gsc/internal_links.rb +249 -0
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +425 -0
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +93 -0
- data/lib/gsc/open_page_rank.rb +72 -0
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/page_comparator.rb +108 -0
- data/lib/gsc/page_speed.rb +110 -0
- data/lib/gsc/prompts.rb +38 -29
- data/lib/gsc/questions_harvester.rb +178 -0
- data/lib/gsc/report_generator.rb +461 -0
- data/lib/gsc/rich_results.rb +388 -0
- data/lib/gsc/robots_checker.rb +114 -0
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +120 -0
- data/lib/gsc/seasonal_predictor.rb +381 -0
- data/lib/gsc/security_scanner.rb +496 -0
- data/lib/gsc/serp_feature_detector.rb +359 -0
- data/lib/gsc/serp_preview.rb +152 -0
- data/lib/gsc/site_crawler.rb +113 -21
- data/lib/gsc/sitemap_loader.rb +15 -4
- data/lib/gsc/sitemap_tree.rb +301 -0
- data/lib/gsc/skill_pack.rb +195 -0
- data/lib/gsc/soft_404_analyzer.rb +385 -0
- data/lib/gsc/sparkline.rb +171 -0
- data/lib/gsc/speed_correlator.rb +416 -0
- data/lib/gsc/striking_playbook.rb +190 -0
- data/lib/gsc/title_optimizer.rb +420 -0
- data/lib/gsc/vault.rb +260 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc/watchdog.rb +235 -0
- data/lib/gsc/zombie_purger.rb +366 -0
- data/lib/gsc.rb +144 -0
- 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
|