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.
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
+