ligamagic-scraper 0.6.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 +7 -0
- data/CHANGELOG.md +318 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +614 -0
- data/Rakefile +121 -0
- data/bin/ligamagic-scraper +28 -0
- data/lib/ligamagic_scraper/alerts/alert_system.rb +218 -0
- data/lib/ligamagic_scraper/alerts/base_alert.rb +75 -0
- data/lib/ligamagic_scraper/alerts/file_alert.rb +56 -0
- data/lib/ligamagic_scraper/alerts/telegram_alert.rb +36 -0
- data/lib/ligamagic_scraper/cli.rb +152 -0
- data/lib/ligamagic_scraper/loggable.rb +43 -0
- data/lib/ligamagic_scraper/scrapers/base_scraper.rb +126 -0
- data/lib/ligamagic_scraper/scrapers/global_scraper.rb +240 -0
- data/lib/ligamagic_scraper/scrapers/store_scraper.rb +392 -0
- data/lib/ligamagic_scraper/version.rb +4 -0
- data/lib/ligamagic_scraper.rb +18 -0
- metadata +134 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require "bundler/gem_tasks"
|
|
2
|
+
require "rspec/core/rake_task"
|
|
3
|
+
require_relative "lib/ligamagic_scraper/version"
|
|
4
|
+
|
|
5
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
6
|
+
|
|
7
|
+
desc "Show current version"
|
|
8
|
+
task :version do
|
|
9
|
+
puts "Current version: #{LigaMagicScraper::VERSION}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
desc "Bump patch version (0.1.0 -> 0.1.1)"
|
|
13
|
+
task :bump_patch do
|
|
14
|
+
bump_version(:patch)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "Bump minor version (0.1.0 -> 0.2.0)"
|
|
18
|
+
task :bump_minor do
|
|
19
|
+
bump_version(:minor)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc "Bump major version (0.1.0 -> 1.0.0)"
|
|
23
|
+
task :bump_major do
|
|
24
|
+
bump_version(:major)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc "Build and install gem locally"
|
|
28
|
+
task :install_local do
|
|
29
|
+
Rake::Task["clean"].invoke
|
|
30
|
+
|
|
31
|
+
puts "๐จ Building gem..."
|
|
32
|
+
system("gem build ligamagic-scraper.gemspec")
|
|
33
|
+
|
|
34
|
+
gem_file = Dir["ligamagic-scraper-*.gem"].first
|
|
35
|
+
|
|
36
|
+
if gem_file
|
|
37
|
+
puts "๐ฆ Installing #{gem_file}..."
|
|
38
|
+
system("gem install #{gem_file}")
|
|
39
|
+
puts "โ
Gem installed successfully!"
|
|
40
|
+
puts " You can now use: ligamagic-scraper -s 'search term'"
|
|
41
|
+
else
|
|
42
|
+
puts "โ Failed to build gem"
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
desc "Clean built gems"
|
|
48
|
+
task :clean do
|
|
49
|
+
puts "๐งน Cleaning old gem files..."
|
|
50
|
+
Dir["*.gem"].each do |gem_file|
|
|
51
|
+
File.delete(gem_file)
|
|
52
|
+
puts " Deleted: #{gem_file}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc "Build gem without installing"
|
|
57
|
+
task :build do
|
|
58
|
+
Rake::Task["clean"].invoke
|
|
59
|
+
puts "๐จ Building gem..."
|
|
60
|
+
system("gem build ligamagic-scraper.gemspec")
|
|
61
|
+
gem_file = Dir["ligamagic-scraper-*.gem"].first
|
|
62
|
+
puts "โ
Built: #{gem_file}" if gem_file
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
desc "Release new version (bump patch, build, install)"
|
|
66
|
+
task :release_patch do
|
|
67
|
+
bump_version(:patch)
|
|
68
|
+
Rake::Task["install_local"].invoke
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
desc "Release new minor version (bump minor, build, install)"
|
|
72
|
+
task :release_minor do
|
|
73
|
+
bump_version(:minor)
|
|
74
|
+
Rake::Task["install_local"].invoke
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
desc "Release new major version (bump major, build, install)"
|
|
78
|
+
task :release_major do
|
|
79
|
+
bump_version(:major)
|
|
80
|
+
Rake::Task["install_local"].invoke
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
desc "Uninstall the gem"
|
|
84
|
+
task :uninstall do
|
|
85
|
+
puts "๐๏ธ Uninstalling ligamagic-scraper..."
|
|
86
|
+
system("gem uninstall ligamagic-scraper -x")
|
|
87
|
+
puts "โ
Gem uninstalled"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def bump_version(type)
|
|
91
|
+
version_file = "lib/ligamagic_scraper/version.rb"
|
|
92
|
+
content = File.read(version_file)
|
|
93
|
+
|
|
94
|
+
current_version = LigaMagicScraper::VERSION
|
|
95
|
+
parts = current_version.split('.').map(&:to_i)
|
|
96
|
+
|
|
97
|
+
case type
|
|
98
|
+
when :patch
|
|
99
|
+
parts[2] += 1
|
|
100
|
+
when :minor
|
|
101
|
+
parts[1] += 1
|
|
102
|
+
parts[2] = 0
|
|
103
|
+
when :major
|
|
104
|
+
parts[0] += 1
|
|
105
|
+
parts[1] = 0
|
|
106
|
+
parts[2] = 0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
new_version = parts.join('.')
|
|
110
|
+
|
|
111
|
+
new_content = content.gsub(/VERSION = "#{Regexp.escape(current_version)}"/,
|
|
112
|
+
"VERSION = \"#{new_version}\"")
|
|
113
|
+
|
|
114
|
+
File.write(version_file, new_content)
|
|
115
|
+
|
|
116
|
+
puts "๐ Version bumped: #{current_version} -> #{new_version}"
|
|
117
|
+
new_version
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
task default: :spec
|
|
121
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require_relative '../lib/ligamagic_scraper'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
cli = LigaMagicScraper::CLI.new.parse
|
|
7
|
+
scraper = cli.create_scraper
|
|
8
|
+
|
|
9
|
+
results = scraper.scrape
|
|
10
|
+
scraper.save_to_json(results)
|
|
11
|
+
|
|
12
|
+
scraper.formatted_logs.each { |msg| puts msg }
|
|
13
|
+
|
|
14
|
+
if scraper.alert_system
|
|
15
|
+
scraper.alert_system.formatted_logs.each { |msg| puts msg }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
puts "โ
Scraping completed successfully!"
|
|
19
|
+
puts "๐ Exiting..."
|
|
20
|
+
rescue Interrupt
|
|
21
|
+
puts "โ ๏ธ Interrupted by user (Ctrl+C)"
|
|
22
|
+
puts "๐ Cleaning up..."
|
|
23
|
+
ensure
|
|
24
|
+
Capybara.reset_sessions!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
exit(0)
|
|
28
|
+
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require_relative 'base_alert'
|
|
3
|
+
require_relative 'file_alert'
|
|
4
|
+
require_relative 'telegram_alert'
|
|
5
|
+
|
|
6
|
+
module LigaMagicScraper
|
|
7
|
+
class AlertSystem
|
|
8
|
+
include Loggable
|
|
9
|
+
|
|
10
|
+
attr_reader :config, :alerts
|
|
11
|
+
|
|
12
|
+
def initialize(config = {})
|
|
13
|
+
initialize_logs
|
|
14
|
+
@config = {
|
|
15
|
+
enabled: config[:enabled] || false,
|
|
16
|
+
alert_types: config[:alert_types] || [:file],
|
|
17
|
+
compare_previous: config[:compare_previous] != false
|
|
18
|
+
}
|
|
19
|
+
@alerts = []
|
|
20
|
+
|
|
21
|
+
setup_alerts if @config[:enabled]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def process(current_data:, previous_file: nil)
|
|
25
|
+
return unless @config[:enabled]
|
|
26
|
+
|
|
27
|
+
log_info("๐ Alert system enabled")
|
|
28
|
+
|
|
29
|
+
previous_data = load_previous_data(previous_file) if @config[:compare_previous]
|
|
30
|
+
|
|
31
|
+
if previous_data
|
|
32
|
+
log_info("๐ Comparing with previous scrape...")
|
|
33
|
+
changes = detect_changes(previous_data, current_data)
|
|
34
|
+
|
|
35
|
+
if changes[:has_changes]
|
|
36
|
+
log_info("โจ Changes detected!")
|
|
37
|
+
notify_all(changes)
|
|
38
|
+
else
|
|
39
|
+
log_info("โ
No changes detected")
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
log_info("โน๏ธ No previous data to compare")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def detect_changes(previous_data, current_data)
|
|
47
|
+
changes = {
|
|
48
|
+
has_changes: false,
|
|
49
|
+
timestamp: Time.now.iso8601,
|
|
50
|
+
search_type: current_data[:search_type],
|
|
51
|
+
search_term: current_data[:search_term] || current_data[:store_domain],
|
|
52
|
+
new_products: [],
|
|
53
|
+
removed_products: [],
|
|
54
|
+
price_changes: [],
|
|
55
|
+
quantity_changes: [],
|
|
56
|
+
availability_changes: []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
prev_products = index_products(previous_data[:products])
|
|
60
|
+
curr_products = index_products(current_data[:products])
|
|
61
|
+
|
|
62
|
+
# Detect new products
|
|
63
|
+
curr_products.each do |id, product|
|
|
64
|
+
unless prev_products.key?(id)
|
|
65
|
+
changes[:new_products] << product
|
|
66
|
+
changes[:has_changes] = true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Detect removed products
|
|
71
|
+
prev_products.each do |id, product|
|
|
72
|
+
unless curr_products.key?(id)
|
|
73
|
+
changes[:removed_products] << product
|
|
74
|
+
changes[:has_changes] = true
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Detect price changes
|
|
79
|
+
curr_products.each do |id, curr_product|
|
|
80
|
+
if prev_products.key?(id)
|
|
81
|
+
prev_product = prev_products[id]
|
|
82
|
+
|
|
83
|
+
# Check price changes (works for both global and store scrapers)
|
|
84
|
+
price_change = detect_price_change(prev_product, curr_product)
|
|
85
|
+
if price_change
|
|
86
|
+
changes[:price_changes] << price_change
|
|
87
|
+
changes[:has_changes] = true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check quantity changes (store scraper)
|
|
91
|
+
if curr_product.key?(:qtd) && prev_product.key?(:qtd)
|
|
92
|
+
prev_qtd = prev_product[:qtd]
|
|
93
|
+
curr_qtd = curr_product[:qtd]
|
|
94
|
+
|
|
95
|
+
if prev_qtd != curr_qtd
|
|
96
|
+
changes[:quantity_changes] << {
|
|
97
|
+
id:,
|
|
98
|
+
name: curr_product[:name],
|
|
99
|
+
previous_qtd: prev_qtd,
|
|
100
|
+
current_qtd: curr_qtd,
|
|
101
|
+
change: curr_qtd.to_i - prev_qtd.to_i
|
|
102
|
+
}
|
|
103
|
+
changes[:has_changes] = true
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check availability changes (store scraper)
|
|
108
|
+
if curr_product.key?(:available) && prev_product.key?(:available)
|
|
109
|
+
if curr_product[:available] != prev_product[:available]
|
|
110
|
+
changes[:availability_changes] << {
|
|
111
|
+
id:,
|
|
112
|
+
name: curr_product[:name],
|
|
113
|
+
previous: prev_product[:available],
|
|
114
|
+
current: curr_product[:available]
|
|
115
|
+
}
|
|
116
|
+
changes[:has_changes] = true
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
changes
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def setup_alerts
|
|
128
|
+
@config[:alert_types].each do |type|
|
|
129
|
+
alert = create_alert(type)
|
|
130
|
+
@alerts << alert if alert
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
log_info("๐ข Configured alerts: #{@alerts.map(&:class).map(&:name).join(', ')}")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def create_alert(type)
|
|
137
|
+
case type
|
|
138
|
+
when :file
|
|
139
|
+
Alerts::FileAlert.new
|
|
140
|
+
when :telegram
|
|
141
|
+
Alerts::TelegramAlert.new
|
|
142
|
+
else
|
|
143
|
+
log_warning("โ ๏ธ Unknown alert type: #{type}")
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def load_previous_data(filename)
|
|
149
|
+
return nil unless filename && File.exist?(filename)
|
|
150
|
+
|
|
151
|
+
JSON.parse(File.read(filename), symbolize_names: true)
|
|
152
|
+
rescue => e
|
|
153
|
+
log_error("โ ๏ธ Error loading previous data: #{e.message}")
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def index_products(products)
|
|
158
|
+
indexed = {}
|
|
159
|
+
products.each do |product|
|
|
160
|
+
# Use card_id if available (store scraper), otherwise use id (global scraper)
|
|
161
|
+
id = product[:card_id] || product[:id] || product[:slug]
|
|
162
|
+
indexed[id] = product if id
|
|
163
|
+
end
|
|
164
|
+
indexed
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def detect_price_change(prev_product, curr_product)
|
|
168
|
+
# For global scraper (has min/avg/max prices)
|
|
169
|
+
if curr_product.key?(:min_price)
|
|
170
|
+
prev_min = prev_product[:min_price]
|
|
171
|
+
curr_min = curr_product[:min_price]
|
|
172
|
+
|
|
173
|
+
if prev_min && curr_min && prev_min != curr_min
|
|
174
|
+
return {
|
|
175
|
+
id: curr_product[:id],
|
|
176
|
+
name: curr_product[:name],
|
|
177
|
+
previous_min: prev_min,
|
|
178
|
+
current_min: curr_min,
|
|
179
|
+
change: curr_min - prev_min,
|
|
180
|
+
change_percent: ((curr_min - prev_min) / prev_min * 100).round(2)
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# For store scraper (has single price)
|
|
186
|
+
if curr_product.key?(:price)
|
|
187
|
+
prev_price = prev_product[:price]
|
|
188
|
+
curr_price = curr_product[:price]
|
|
189
|
+
|
|
190
|
+
if prev_price && curr_price && prev_price != curr_price
|
|
191
|
+
return {
|
|
192
|
+
id: curr_product[:card_id],
|
|
193
|
+
name: curr_product[:name],
|
|
194
|
+
previous_price: prev_price,
|
|
195
|
+
current_price: curr_price,
|
|
196
|
+
change: curr_price - prev_price,
|
|
197
|
+
change_percent: ((curr_price - prev_price) / prev_price * 100).round(2)
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def notify_all(changes)
|
|
206
|
+
@alerts.each do |alert|
|
|
207
|
+
begin
|
|
208
|
+
alert.notify(changes)
|
|
209
|
+
# Merge alert logs into system logs
|
|
210
|
+
@logs.concat(alert.logs) if alert.respond_to?(:logs)
|
|
211
|
+
rescue => e
|
|
212
|
+
log_error("โ Error sending alert via #{alert.class.name}: #{e.message}")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module LigaMagicScraper
|
|
2
|
+
module Alerts
|
|
3
|
+
class BaseAlert
|
|
4
|
+
def notify(changes)
|
|
5
|
+
raise NotImplementedError, "Subclasses must implement the notify method"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def format_changes(changes)
|
|
11
|
+
summary = []
|
|
12
|
+
|
|
13
|
+
summary << "๐ Changes detected at #{changes[:timestamp]}"
|
|
14
|
+
summary << "๐ Search: #{changes[:search_term]} (#{changes[:search_type]})"
|
|
15
|
+
summary << ""
|
|
16
|
+
|
|
17
|
+
if changes[:new_products].any?
|
|
18
|
+
summary << "๐ New products (#{changes[:new_products].count}):"
|
|
19
|
+
changes[:new_products].first(5).each do |product|
|
|
20
|
+
summary << " - #{product[:name]}"
|
|
21
|
+
end
|
|
22
|
+
summary << " ... and #{changes[:new_products].count - 5} more" if changes[:new_products].count > 5
|
|
23
|
+
summary << ""
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if changes[:removed_products].any?
|
|
27
|
+
summary << "โ Removed products (#{changes[:removed_products].count}):"
|
|
28
|
+
changes[:removed_products].first(5).each do |product|
|
|
29
|
+
summary << " - #{product[:name]}"
|
|
30
|
+
end
|
|
31
|
+
summary << " ... and #{changes[:removed_products].count - 5} more" if changes[:removed_products].count > 5
|
|
32
|
+
summary << ""
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if changes[:price_changes].any?
|
|
36
|
+
summary << "๐ฐ Price changes (#{changes[:price_changes].count}):"
|
|
37
|
+
changes[:price_changes].first(5).each do |change|
|
|
38
|
+
arrow = change[:change] > 0 ? "๐" : "๐"
|
|
39
|
+
summary << " #{arrow} #{change[:name]}: R$ #{format_price(change[:previous_price] || change[:previous_min])} โ R$ #{format_price(change[:current_price] || change[:current_min])} (#{change[:change_percent]}%)"
|
|
40
|
+
end
|
|
41
|
+
summary << " ... and #{changes[:price_changes].count - 5} more" if changes[:price_changes].count > 5
|
|
42
|
+
summary << ""
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if changes[:quantity_changes].any?
|
|
46
|
+
summary << "๐ Quantity changes (#{changes[:quantity_changes].count}):"
|
|
47
|
+
changes[:quantity_changes].first(5).each do |change|
|
|
48
|
+
arrow = change[:change] > 0 ? "๐" : "๐"
|
|
49
|
+
summary << " #{arrow} #{change[:name]}: #{change[:previous_qtd]} โ #{change[:current_qtd]} (#{change[:change] > 0 ? '+' : ''}#{change[:change]})"
|
|
50
|
+
end
|
|
51
|
+
summary << " ... and #{changes[:quantity_changes].count - 5} more" if changes[:quantity_changes].count > 5
|
|
52
|
+
summary << ""
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if changes[:availability_changes].any?
|
|
56
|
+
summary << "๐ฆ Availability changes (#{changes[:availability_changes].count}):"
|
|
57
|
+
changes[:availability_changes].first(5).each do |change|
|
|
58
|
+
status = change[:current] ? "โ
Available" : "โ Unavailable"
|
|
59
|
+
summary << " #{status}: #{change[:name]}"
|
|
60
|
+
end
|
|
61
|
+
summary << " ... and #{changes[:availability_changes].count - 5} more" if changes[:availability_changes].count > 5
|
|
62
|
+
summary << ""
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
summary.join("\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def format_price(price)
|
|
69
|
+
return "N/A" unless price
|
|
70
|
+
"%.2f" % price
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require_relative 'base_alert'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module LigaMagicScraper
|
|
6
|
+
module Alerts
|
|
7
|
+
class FileAlert < BaseAlert
|
|
8
|
+
include Loggable
|
|
9
|
+
|
|
10
|
+
ALERTS_BASE_DIR = 'alerts_json'
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
super
|
|
14
|
+
initialize_logs
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def notify(changes)
|
|
18
|
+
log_info("๐ FileAlert: Saving changes to JSON file...")
|
|
19
|
+
|
|
20
|
+
subdir = if changes[:search_type] == 'store'
|
|
21
|
+
search_identifier = sanitize_filename(changes[:search_term] || 'unknown')
|
|
22
|
+
File.join(ALERTS_BASE_DIR, 'stores', search_identifier)
|
|
23
|
+
else
|
|
24
|
+
File.join(ALERTS_BASE_DIR, 'global')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
FileUtils.mkdir_p(subdir) unless Dir.exist?(subdir)
|
|
28
|
+
|
|
29
|
+
filename = generate_alert_filename(changes)
|
|
30
|
+
filepath = File.join(subdir, filename)
|
|
31
|
+
|
|
32
|
+
File.write(filepath, JSON.pretty_generate(changes))
|
|
33
|
+
|
|
34
|
+
log_info(" โ
Changes saved to: #{filepath}")
|
|
35
|
+
log_info(" ๐ Summary:")
|
|
36
|
+
log_info(" ๐ New products: #{changes[:new_products].count}")
|
|
37
|
+
log_info(" โ Removed products: #{changes[:removed_products].count}")
|
|
38
|
+
log_info(" ๐ฐ Price changes: #{changes[:price_changes].count}")
|
|
39
|
+
log_info(" ๐ Quantity changes: #{changes[:quantity_changes].count}") if changes[:quantity_changes]
|
|
40
|
+
log_info(" ๐ฆ Availability changes: #{changes[:availability_changes].count}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def generate_alert_filename(changes)
|
|
46
|
+
datetime_str = Time.now.strftime('%Y%m%d_%H%M%S')
|
|
47
|
+
"#{datetime_str}.json"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def sanitize_filename(name)
|
|
51
|
+
name.to_s.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_+|_+$/, '')
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require_relative 'base_alert'
|
|
2
|
+
|
|
3
|
+
module LigaMagicScraper
|
|
4
|
+
module Alerts
|
|
5
|
+
class TelegramAlert < BaseAlert
|
|
6
|
+
include Loggable
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
super
|
|
10
|
+
initialize_logs
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def notify(changes)
|
|
14
|
+
log_info("๐ฑ TelegramAlert: Preparing to send Telegram message...")
|
|
15
|
+
|
|
16
|
+
# TODO: Implement Telegram alert logic
|
|
17
|
+
# Ideas:
|
|
18
|
+
# - Use Telegram Bot API
|
|
19
|
+
# - Configure bot token and chat ID
|
|
20
|
+
# - Send formatted messages with emojis
|
|
21
|
+
# - Support for inline keyboards
|
|
22
|
+
# - Send photos of cards (if available)
|
|
23
|
+
# - Rate limiting for multiple changes
|
|
24
|
+
|
|
25
|
+
log_debug(" Would send to Telegram:")
|
|
26
|
+
log_debug(" Bot Token: [Configure bot token]")
|
|
27
|
+
log_debug(" Chat ID: [Configure chat ID]")
|
|
28
|
+
log_debug(" Message preview:")
|
|
29
|
+
format_changes(changes).split("\n").each { |line| log_debug(" #{line}") }
|
|
30
|
+
|
|
31
|
+
log_warning(" โ ๏ธ Telegram alert action not implemented yet")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
require 'optparse'
|
|
2
|
+
|
|
3
|
+
module LigaMagicScraper
|
|
4
|
+
class CLI
|
|
5
|
+
attr_reader :options
|
|
6
|
+
|
|
7
|
+
def initialize(argv = ARGV)
|
|
8
|
+
@argv = argv
|
|
9
|
+
@options = default_options
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def parse
|
|
13
|
+
option_parser.parse!(@argv)
|
|
14
|
+
validate_options
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_scraper
|
|
19
|
+
alert_config = build_alert_config
|
|
20
|
+
|
|
21
|
+
if store_search?
|
|
22
|
+
StoreScraper.new(
|
|
23
|
+
store_domain: options[:store_domain],
|
|
24
|
+
search_term: options[:search],
|
|
25
|
+
max_pages: options[:max_pages],
|
|
26
|
+
browser_mode: options[:browser_mode],
|
|
27
|
+
alert_config: alert_config
|
|
28
|
+
)
|
|
29
|
+
else
|
|
30
|
+
GlobalScraper.new(
|
|
31
|
+
search_term: options[:search],
|
|
32
|
+
browser_mode: options[:browser_mode],
|
|
33
|
+
alert_config: alert_config
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def default_options
|
|
41
|
+
{
|
|
42
|
+
search: nil,
|
|
43
|
+
store_domain: nil,
|
|
44
|
+
max_pages: nil,
|
|
45
|
+
browser_mode: 'headed',
|
|
46
|
+
search_type: 'global',
|
|
47
|
+
alerts_enabled: false,
|
|
48
|
+
alert_types: [:file]
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def option_parser
|
|
53
|
+
@option_parser ||= OptionParser.new do |opts|
|
|
54
|
+
opts.banner = "Usage: ligamagic-scraper [options]"
|
|
55
|
+
opts.separator ""
|
|
56
|
+
opts.separator "Search Options (choose one):"
|
|
57
|
+
|
|
58
|
+
opts.on("-s", "--search SEARCH", "Search term (global search or store search with -u)") do |search|
|
|
59
|
+
options[:search] = search
|
|
60
|
+
# Don't set search_type here - will be determined by presence of -u flag
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
opts.on("-u", "--store DOMAIN", "Store domain (e.g., 'test-store')") do |domain|
|
|
64
|
+
options[:store_domain] = domain
|
|
65
|
+
options[:search_type] = 'store'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
opts.on("-p", "--pages N", Integer, "Pages to scrape (max: 5, required with -u if no -s)") do |pages|
|
|
69
|
+
# Cap at 5 pages maximum
|
|
70
|
+
options[:max_pages] = [pages, 5].min
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
opts.separator ""
|
|
74
|
+
opts.separator "Options:"
|
|
75
|
+
|
|
76
|
+
opts.on("-g", "--global", "Use global Liga Magic search (default)") do
|
|
77
|
+
options[:search_type] = 'global'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
opts.on("-b", "--browser-mode MODE", "Browser mode: 'headed' (default) or 'headless'") do |mode|
|
|
81
|
+
options[:browser_mode] = mode
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
opts.on("-a", "--alerts", "Enable alert system (detects changes from previous scrapes)") do
|
|
85
|
+
options[:alerts_enabled] = true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
opts.on("--alert-types TYPES", Array, "Alert types: file, email, telegram, webhook (comma-separated)") do |types|
|
|
89
|
+
options[:alert_types] = types.map(&:to_sym)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
opts.on("-h", "--help", "Show this help message") do
|
|
93
|
+
puts opts
|
|
94
|
+
exit
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
opts.on("--version", "Show version") do
|
|
98
|
+
puts "LigaMagic Scraper v#{VERSION}"
|
|
99
|
+
exit
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_options
|
|
105
|
+
# At least one search parameter required
|
|
106
|
+
if options[:search].nil? && options[:store_domain].nil?
|
|
107
|
+
puts "โ Error: Either search term (-s) or store domain (-u) is required"
|
|
108
|
+
puts ""
|
|
109
|
+
puts "Usage examples:"
|
|
110
|
+
puts " ligamagic-scraper -s SEARCH # Global search"
|
|
111
|
+
puts " ligamagic-scraper -u test-store -p 3 # List store products (max 5 pages)"
|
|
112
|
+
puts " ligamagic-scraper -u test-store -s TERM # Search within store"
|
|
113
|
+
puts ""
|
|
114
|
+
puts "Try 'ligamagic-scraper -h' for more information"
|
|
115
|
+
exit(1)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate: max_pages required when using -u without -s
|
|
119
|
+
if options[:store_domain] && options[:search].nil? && options[:max_pages].nil?
|
|
120
|
+
puts "โ Error: -p (--pages) is required when listing store products without search term"
|
|
121
|
+
puts ""
|
|
122
|
+
puts "Example: ligamagic-scraper -u test-store -p 3"
|
|
123
|
+
puts ""
|
|
124
|
+
puts "Note: Maximum 5 pages allowed. Use -s flag for unlimited pagination."
|
|
125
|
+
exit(1)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Validate: -p only makes sense with -u
|
|
129
|
+
if options[:max_pages] && options[:store_domain].nil?
|
|
130
|
+
puts "โ Error: -p (--pages) can only be used with -u (--store)"
|
|
131
|
+
puts ""
|
|
132
|
+
puts "Usage: ligamagic-scraper -u STORE -p N"
|
|
133
|
+
exit(1)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_alert_config
|
|
138
|
+
return nil unless options[:alerts_enabled]
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
enabled: true,
|
|
142
|
+
alert_types: options[:alert_types],
|
|
143
|
+
compare_previous: true
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def store_search?
|
|
148
|
+
options[:search_type] == 'store'
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|