UrlCategorise 0.1.7 → 0.1.11

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.
@@ -1,5 +1,6 @@
1
- require 'set'
2
- require 'digest'
1
+ require "set"
2
+ require "digest"
3
+ require "etc"
3
4
 
4
5
  module UrlCategorise
5
6
  class Client < ApiPattern::Client
@@ -7,17 +8,23 @@ module UrlCategorise
7
8
  include ActiveAttr::Model
8
9
 
9
10
  def self.compatible_api_version
10
- 'v2'
11
+ "v2"
11
12
  end
12
13
 
13
14
  def self.api_version
14
- 'v2 2025-08-23'
15
+ "v2 2025-08-23"
16
+ end
17
+
18
+ def self.ractor_available?
19
+ defined?(Ractor) && Ractor.respond_to?(:new)
20
+ rescue
21
+ false
15
22
  end
16
23
 
17
24
  attribute :host_urls, default: -> { DEFAULT_HOST_URLS }
18
25
  attribute :cache_dir
19
26
  attribute :force_download, type: Boolean, default: false
20
- attribute :dns_servers, default: ['1.1.1.1', '1.0.0.1']
27
+ attribute :dns_servers, default: [ "1.1.1.1", "1.0.0.1" ]
21
28
  attribute :request_timeout, type: Integer, default: 10
22
29
  attribute :iab_compliance_enabled, type: Boolean, default: false
23
30
  attribute :iab_version, default: :v3
@@ -26,18 +33,22 @@ module UrlCategorise
26
33
  attribute :smart_rules, default: -> { {} }
27
34
  attribute :regex_categorization_enabled, type: Boolean, default: false
28
35
  attribute :regex_patterns_file, default: -> { VIDEO_URL_PATTERNS_FILE }
36
+ attribute :debug_enabled, type: Boolean, default: false
37
+ attribute :parallel_loading_enabled, type: Boolean, default: -> { self.class.ractor_available? }
38
+ attribute :max_threads, type: Integer, default: 8
39
+ attribute :max_ractor_workers, type: Integer, default: -> { [ 4, Etc.nprocessors ].max }
29
40
 
30
41
  attr_reader :hosts, :metadata, :dataset_processor, :dataset_categories, :regex_patterns
31
42
 
32
43
  def initialize(**kwargs)
33
44
  # Extract dataset_config for later use
34
45
  dataset_config = kwargs.fetch(:dataset_config, {})
35
-
46
+
36
47
  # Set ActiveAttr attributes - preserve explicitly passed values including nil
37
48
  self.host_urls = kwargs.key?(:host_urls) ? kwargs[:host_urls] : DEFAULT_HOST_URLS
38
49
  self.cache_dir = kwargs[:cache_dir] # will be nil if not provided or explicitly nil
39
50
  self.force_download = kwargs.key?(:force_download) ? kwargs[:force_download] : false
40
- self.dns_servers = kwargs.key?(:dns_servers) ? kwargs[:dns_servers] : ['1.1.1.1', '1.0.0.1']
51
+ self.dns_servers = kwargs.key?(:dns_servers) ? kwargs[:dns_servers] : [ "1.1.1.1", "1.0.0.1" ]
41
52
  self.request_timeout = kwargs.key?(:request_timeout) ? kwargs[:request_timeout] : 10
42
53
  self.iab_compliance_enabled = kwargs.key?(:iab_compliance) ? kwargs[:iab_compliance] : false
43
54
  self.iab_version = kwargs.key?(:iab_version) ? kwargs[:iab_version] : :v3
@@ -46,6 +57,10 @@ module UrlCategorise
46
57
  self.smart_rules = initialize_smart_rules(kwargs.key?(:smart_rules) ? kwargs[:smart_rules] : {})
47
58
  self.regex_categorization_enabled = kwargs.key?(:regex_categorization) ? kwargs[:regex_categorization] : false
48
59
  self.regex_patterns_file = kwargs.key?(:regex_patterns_file) ? kwargs[:regex_patterns_file] : VIDEO_URL_PATTERNS_FILE
60
+ self.debug_enabled = kwargs.key?(:debug) ? kwargs[:debug] : false
61
+ self.parallel_loading_enabled = kwargs.key?(:parallel_loading) ? kwargs[:parallel_loading] : self.class.ractor_available?
62
+ self.max_threads = kwargs.key?(:max_threads) ? kwargs[:max_threads] : 8
63
+ self.max_ractor_workers = kwargs.key?(:max_ractor_workers) ? kwargs[:max_ractor_workers] : [ 4, Etc.nprocessors ].max
49
64
 
50
65
  @metadata = {}
51
66
  @dataset_categories = Set.new # Track which categories come from datasets
@@ -54,35 +69,86 @@ module UrlCategorise
54
69
  # Initialize dataset processor if config provided
55
70
  @dataset_processor = initialize_dataset_processor(dataset_config) unless dataset_config.empty?
56
71
 
57
- @hosts = fetch_and_build_host_lists
72
+ debug_log("Initializing UrlCategorise Client with debug enabled")
73
+
74
+ initialization_start_time = Time.now
75
+
76
+ debug_log("Loading host lists from #{(host_urls || {}).keys.size} categories")
77
+
78
+ @hosts = debug_time("Host lists loading") do
79
+ fetch_and_build_host_lists
80
+ end
58
81
 
59
82
  # Load regex patterns if enabled
60
- load_regex_patterns if regex_categorization_enabled
83
+ if regex_categorization_enabled
84
+ debug_log("Regex categorization enabled, loading patterns from #{regex_patterns_file}")
85
+ debug_time("Regex patterns loading") { load_regex_patterns }
86
+ end
61
87
 
62
88
  # Auto-load datasets from constants if enabled
63
- load_datasets_from_constants if auto_load_datasets && @dataset_processor
89
+ if auto_load_datasets && @dataset_processor
90
+ debug_log("Auto-loading datasets from constants")
91
+ debug_time("Datasets auto-loading") { load_datasets_from_constants }
92
+ end
93
+
94
+ # Calculate and log total initialization time
95
+ if debug_enabled
96
+ total_time = ((Time.now - initialization_start_time) * 1000).round(2)
97
+ debug_log("🎯 Client initialization completed in #{total_time}ms")
98
+ else
99
+ debug_log("Client initialization completed")
100
+ end
64
101
  end
65
102
 
66
103
  def categorise(url)
67
- host = (URI.parse(url).host || url).downcase
68
- host = host.gsub('www.', '')
104
+ return [] if url.nil? || (url.respond_to?(:strip) && url.strip.empty?)
69
105
 
70
- categories = @hosts.keys.select do |category|
71
- @hosts[category].any? do |blocked_host|
72
- host == blocked_host || host.end_with?(".#{blocked_host}")
106
+ debug_time("Categorizing '#{url}'") do
107
+ debug_log("🔍 Starting categorization for URL: #{url}")
108
+
109
+ host = begin
110
+ (URI.parse(url).host || url).downcase
111
+ rescue URI::InvalidURIError
112
+ url.to_s.downcase
73
113
  end
74
- end
114
+ host = host.gsub("www.", "")
115
+ debug_log("📌 Extracted host: #{host}")
75
116
 
76
- # Apply smart categorization if enabled
77
- categories = apply_smart_categorization(url, categories) if smart_categorization_enabled
117
+ categories = @hosts.keys.select do |category|
118
+ @hosts[category].any? do |blocked_host|
119
+ host == blocked_host || host.end_with?(".#{blocked_host}")
120
+ end
121
+ end
122
+ debug_log("📋 Basic categorization matches: #{categories}")
78
123
 
79
- # Apply regex categorization if enabled
80
- categories = apply_regex_categorization(url, categories) if regex_categorization_enabled
124
+ # Apply smart categorisation if enabled
125
+ if smart_categorization_enabled
126
+ debug_log("🧠 Applying smart categorization")
127
+ categories = apply_smart_categorisation(url, categories)
128
+ debug_log("📋 After smart categorization: #{categories}")
129
+ end
81
130
 
82
- if iab_compliance_enabled
83
- IabCompliance.get_iab_categories(categories, iab_version)
84
- else
85
- categories
131
+ # Apply regex categorisation if enabled
132
+ if regex_categorization_enabled
133
+ debug_log("🔗 Applying regex categorization")
134
+ categories = apply_regex_categorisation(url, categories)
135
+ debug_log("📋 After regex categorization: #{categories}")
136
+ end
137
+
138
+ # Check if blog
139
+ categories += [:blog] if blog_url?(url)
140
+
141
+ final_categories = if iab_compliance_enabled
142
+ debug_log("🏢 Applying IAB compliance mapping")
143
+ iab_categories = IabCompliance.get_iab_categories(categories, iab_version)
144
+ debug_log("📋 Final IAB categories: #{iab_categories}")
145
+ iab_categories
146
+ else
147
+ debug_log("✅ Final categories: #{categories}")
148
+ categories
149
+ end
150
+
151
+ final_categories
86
152
  end
87
153
  end
88
154
 
@@ -121,8 +187,8 @@ module UrlCategorise
121
187
 
122
188
  # First check if it's from a video hosting domain
123
189
  categories = categorise(url)
124
- video_hosting_categories = categories & [:video, :video_hosting, :youtube, :vimeo, :tiktok, :dailymotion, :twitch]
125
-
190
+ video_hosting_categories = categories & %i[video_hosting youtube vimeo tiktok dailymotion twitch]
191
+
126
192
  return false unless video_hosting_categories.any?
127
193
 
128
194
  # Then check if it matches video content patterns
@@ -138,6 +204,163 @@ module UrlCategorise
138
204
  false
139
205
  end
140
206
 
207
+ def shorts_url?(url)
208
+ return false unless url && !url.empty?
209
+ return false unless regex_categorization_enabled && @regex_patterns.any?
210
+
211
+ # Check for video hosting domain
212
+ categories = categorise(url)
213
+ return false unless (categories & %i[video_hosting youtube vimeo tiktok dailymotion twitch]).any?
214
+
215
+ # Check for shorts patterns
216
+ shorts_patterns = [
217
+ %r{https?://(?:www\.)?youtube\.com/shorts/[a-zA-Z0-9_-]{11}}i,
218
+ %r{https?://(?:www\.)?tiktok\.com/@[^/]+/video/\d+}i,
219
+ %r{https?://(?:www\.)?instagram\.com/(?:reel|reels)/[a-zA-Z0-9_-]+}i,
220
+ %r{https?://(?:www\.)?facebook\.com/.*/videos/\d+}i
221
+ ]
222
+
223
+ shorts_patterns.any? { |pattern| url.match?(pattern) }
224
+ rescue StandardError
225
+ false
226
+ end
227
+
228
+ def playlist_url?(url)
229
+ return false unless url && !url.empty?
230
+ return false unless regex_categorization_enabled && @regex_patterns.any?
231
+
232
+ # Check for video hosting domain
233
+ categories = categorise(url)
234
+ return false unless (categories & %i[video_hosting youtube vimeo tiktok dailymotion twitch]).any?
235
+
236
+ # Check for playlist patterns
237
+ playlist_patterns = [
238
+ %r{https?://(?:www\.)?youtube\.com/(?:playlist\?list=|watch\?.*[&?]list=)[a-zA-Z0-9_-]+}i,
239
+ %r{https?://(?:www\.)?vimeo\.com/(?:album|showcase)/\d+}i,
240
+ %r{https?://(?:www\.)?dailymotion\.com/playlist/[a-zA-Z0-9_-]+}i,
241
+ %r{https?://(?:www\.)?twitch\.tv/collections/[a-zA-Z0-9_-]+}i
242
+ ]
243
+
244
+ playlist_patterns.any? { |pattern| url.match?(pattern) }
245
+ rescue StandardError
246
+ false
247
+ end
248
+
249
+ def music_url?(url)
250
+ return false unless url && !url.empty?
251
+
252
+ # Check for dedicated music platforms
253
+ music_categories = categorise(url)
254
+ music_domains = music_categories & %i[music spotify soundcloud apple_music youtube_music]
255
+ return true if music_domains.any?
256
+
257
+ # Check for music-specific patterns on video platforms
258
+ return false unless regex_categorization_enabled && @regex_patterns.any?
259
+
260
+ video_categories = music_categories & %i[video_hosting youtube vimeo]
261
+ return false unless video_categories.any?
262
+
263
+ # Check for music patterns in URLs
264
+ music_patterns = [
265
+ %r{https?://(?:www\.)?youtube\.com/watch\?.*v=[a-zA-Z0-9_-]{11}.*[&?]list=(?:PL|RD|UU)[a-zA-Z0-9_-]+}i, # YouTube music playlists
266
+ %r{https?://music\.youtube\.com}i,
267
+ %r{https?://(?:www\.)?youtube\.com/(?:c/|channel/|user/)?.*music}i,
268
+ %r{https?://(?:www\.)?vimeo\.com/channels/music}i,
269
+ %r{https?://(?:www\.)?dailymotion\.com/.*music}i,
270
+ %r{/music[/_-]|music[/_-]|\bmusic\b}i # Generic music indicators in path
271
+ ]
272
+
273
+ music_patterns.any? { |pattern| url.match?(pattern) }
274
+ rescue StandardError
275
+ false
276
+ end
277
+
278
+ def channel_url?(url)
279
+ return false unless url && !url.empty?
280
+ return false unless regex_categorization_enabled && @regex_patterns.any?
281
+
282
+ # Check for video hosting domain
283
+ categories = categorise(url)
284
+ return false unless (categories & %i[video_hosting youtube vimeo tiktok dailymotion twitch]).any?
285
+
286
+ # Check for channel/profile patterns
287
+ channel_patterns = [
288
+ %r{https?://(?:www\.)?youtube\.com/(?:@[a-zA-Z0-9_-]+|c/[a-zA-Z0-9_-]+|channel/[a-zA-Z0-9_-]+|user/[a-zA-Z0-9_-]+)(?:/|$)}i,
289
+ %r{https?://(?:www\.)?tiktok\.com/@[a-zA-Z0-9_.-]+$}i, # TikTok channels - must end after username
290
+ %r{https?://(?:www\.)?twitch\.tv/[a-zA-Z0-9_-]+(?:/|$)}i,
291
+ %r{https?://(?:www\.)?vimeo\.com/user\d+}i,
292
+ %r{https?://(?:www\.)?dailymotion\.com/[a-zA-Z0-9_-]+(?:/|$)}i
293
+ ]
294
+
295
+ channel_patterns.any? { |pattern| url.match?(pattern) }
296
+ rescue StandardError
297
+ false
298
+ end
299
+
300
+ def live_stream_url?(url)
301
+ return false unless url && !url.empty?
302
+ return false unless regex_categorization_enabled && @regex_patterns.any?
303
+
304
+ # Check for video hosting domain
305
+ categories = categorise(url)
306
+ return false unless (categories & %i[video_hosting youtube vimeo tiktok dailymotion twitch]).any?
307
+
308
+ # Check for live stream patterns
309
+ live_patterns = [
310
+ %r{https?://(?:www\.)?youtube\.com/watch\?.*v=[a-zA-Z0-9_-]{11}.*[&?]live=1}i,
311
+ %r{https?://(?:www\.)?youtube\.com/live/[a-zA-Z0-9_-]+}i,
312
+ %r{https?://(?:www\.)?twitch\.tv/[a-zA-Z0-9_-]+(?:/|$)}i, # Twitch channels are typically live
313
+ %r{https?://(?:www\.)?facebook\.com/.*/live}i,
314
+ %r{https?://(?:www\.)?instagram\.com/[^/]+/live}i,
315
+ %r{/live[/_-]|live[/_-]|\blive\b}i # Generic live indicators
316
+ ]
317
+
318
+ live_patterns.any? { |pattern| url.match?(pattern) }
319
+ rescue StandardError
320
+ false
321
+ end
322
+
323
+ def blog_url?(url)
324
+ return false unless url && !url.empty?
325
+
326
+ # Simple string matching for blog-related keywords
327
+ blog_indicators = [
328
+ /\/blog[\/\?]?/i, # /blog/ or /blog? in path
329
+ /\/blogs[\/\?]?/i, # /blogs/ or /blogs? in path
330
+ /blog\./i, # blog.domain.com subdomain
331
+ /blog-/i, # blog-something in domain or path
332
+ /wordpress/i, # WordPress blogs
333
+ /blogspot/i, # Blogspot blogs
334
+ /medium\.com/i, # Medium articles
335
+ /substack\.com/i, # Substack newsletters/blogs
336
+ /post[\/\?]/i, # /post/ in path (common blog pattern)
337
+ /posts[\/\?]/i, # /posts/ in path (common blog pattern)
338
+ /article[\/\?]/i, # /article/ in path
339
+ /articles[\/\?]/i, # /articles/ in path
340
+ /diary/i, # Personal diary/blog
341
+ /journal/i, # Journal entries
342
+ /\bblog\b/i # The word "blog" as whole word
343
+ ]
344
+
345
+ # Exclude search engines and common false positives
346
+ search_exclusions = [
347
+ /google\.com/i,
348
+ /bing\.com/i,
349
+ /yahoo\.com/i,
350
+ /duckduckgo\.com/i,
351
+ /search\..*\?.*q=/i, # Generic search queries
352
+ /\?.*q=.*blog/i # Search queries containing "blog"
353
+ ]
354
+
355
+ # Return false if it matches search exclusions
356
+ return false if search_exclusions.any? { |pattern| url.match?(pattern) }
357
+
358
+ # Check for blog indicators
359
+ blog_indicators.any? { |pattern| url.match?(pattern) }
360
+ rescue StandardError
361
+ false
362
+ end
363
+
141
364
  def count_of_hosts
142
365
  @hosts.keys.map do |category|
143
366
  @hosts[category].size
@@ -209,7 +432,7 @@ module UrlCategorise
209
432
  end
210
433
 
211
434
  def check_all_lists
212
- puts 'Checking all lists in constants...'
435
+ puts "Checking all lists in constants..."
213
436
 
214
437
  unreachable_lists = {}
215
438
  missing_categories = []
@@ -220,7 +443,7 @@ module UrlCategorise
220
443
 
221
444
  if urls.empty?
222
445
  missing_categories << category
223
- puts ' ❌ No URLs defined for category'
446
+ puts " ❌ No URLs defined for category"
224
447
  next
225
448
  end
226
449
 
@@ -235,7 +458,7 @@ module UrlCategorise
235
458
  end
236
459
 
237
460
  unless url_valid?(url)
238
- unreachable_lists[category] << { url: url, error: 'Invalid URL format' }
461
+ unreachable_lists[category] << { url: url, error: "Invalid URL format" }
239
462
  puts " ❌ Invalid URL format: #{url}"
240
463
  next
241
464
  end
@@ -247,18 +470,18 @@ module UrlCategorise
247
470
 
248
471
  case response.code
249
472
  when 200
250
- puts '✅ OK'
473
+ puts "✅ OK"
251
474
  successful_lists[category] << url
252
475
  when 301, 302, 307, 308
253
476
  puts "↗️ Redirect (#{response.code})"
254
- puts " Redirects to: #{response.headers['location']}" if response.headers['location']
477
+ puts " Redirects to: #{response.headers['location']}" if response.headers["location"]
255
478
  successful_lists[category] << url
256
479
  when 404
257
- puts '❌ Not Found (404)'
258
- unreachable_lists[category] << { url: url, error: '404 Not Found' }
480
+ puts "❌ Not Found (404)"
481
+ unreachable_lists[category] << { url: url, error: "404 Not Found" }
259
482
  when 403
260
- puts '❌ Forbidden (403)'
261
- unreachable_lists[category] << { url: url, error: '403 Forbidden' }
483
+ puts "❌ Forbidden (403)"
484
+ unreachable_lists[category] << { url: url, error: "403 Forbidden" }
262
485
  when 500..599
263
486
  puts "❌ Server Error (#{response.code})"
264
487
  unreachable_lists[category] << { url: url, error: "Server Error #{response.code}" }
@@ -267,13 +490,13 @@ module UrlCategorise
267
490
  unreachable_lists[category] << { url: url, error: "HTTP #{response.code}" }
268
491
  end
269
492
  rescue Timeout::Error
270
- puts '❌ Timeout'
271
- unreachable_lists[category] << { url: url, error: 'Request timeout' }
493
+ puts "❌ Timeout"
494
+ unreachable_lists[category] << { url: url, error: "Request timeout" }
272
495
  rescue SocketError => e
273
- puts '❌ DNS/Network Error'
496
+ puts "❌ DNS/Network Error"
274
497
  unreachable_lists[category] << { url: url, error: "DNS/Network: #{e.message}" }
275
498
  rescue HTTParty::Error, Net::HTTPError => e
276
- puts '❌ HTTP Error'
499
+ puts "❌ HTTP Error"
277
500
  unreachable_lists[category] << { url: url, error: "HTTP Error: #{e.message}" }
278
501
  rescue StandardError => e
279
502
  puts "❌ Error: #{e.class}"
@@ -290,9 +513,9 @@ module UrlCategorise
290
513
  end
291
514
 
292
515
  # Generate summary report
293
- puts "\n" + '=' * 80
294
- puts 'LIST HEALTH REPORT'
295
- puts '=' * 80
516
+ puts "\n" + "=" * 80
517
+ puts "LIST HEALTH REPORT"
518
+ puts "=" * 80
296
519
 
297
520
  puts "\n📊 SUMMARY:"
298
521
  total_categories = (host_urls || {}).keys.length
@@ -327,7 +550,7 @@ module UrlCategorise
327
550
  puts " - #{category} (#{url_count} URL#{'s' if url_count != 1})"
328
551
  end
329
552
 
330
- puts "\n" + '=' * 80
553
+ puts "\n" + "=" * 80
331
554
 
332
555
  # Return structured data for programmatic use
333
556
  {
@@ -343,31 +566,57 @@ module UrlCategorise
343
566
  end
344
567
 
345
568
  def load_kaggle_dataset(dataset_owner, dataset_name, options = {})
346
- raise Error, 'Dataset processor not configured' unless @dataset_processor
569
+ raise Error, "Dataset processor not configured" unless @dataset_processor
347
570
 
571
+ debug_log("Loading Kaggle dataset: #{dataset_owner}/#{dataset_name}")
348
572
  default_options = { use_cache: true, integrate_data: true }
349
573
  merged_options = default_options.merge(options)
350
574
 
351
- dataset = @dataset_processor.process_kaggle_dataset(dataset_owner, dataset_name, merged_options)
575
+ dataset = debug_time("Kaggle dataset processing: #{dataset_owner}/#{dataset_name}") do
576
+ @dataset_processor.process_kaggle_dataset(dataset_owner, dataset_name, merged_options)
577
+ end
352
578
 
353
579
  if merged_options[:integrate_data]
354
- integrate_dataset(dataset, merged_options[:category_mappings] || {})
580
+ debug_log("Integrating Kaggle dataset into categorization")
581
+ result = debug_time("Dataset integration") do
582
+ integrate_dataset(dataset, merged_options[:category_mappings] || {})
583
+ end
584
+ if result
585
+ debug_log("✅ Successfully integrated Kaggle dataset #{dataset_owner}/#{dataset_name}")
586
+ else
587
+ debug_log("⚠️ Integration returned nil for Kaggle dataset #{dataset_owner}/#{dataset_name}")
588
+ end
589
+ result
355
590
  else
591
+ debug_log("Returning raw Kaggle dataset (not integrated)")
356
592
  dataset
357
593
  end
358
594
  end
359
595
 
360
596
  def load_csv_dataset(url, options = {})
361
- raise Error, 'Dataset processor not configured' unless @dataset_processor
597
+ raise Error, "Dataset processor not configured" unless @dataset_processor
362
598
 
599
+ debug_log("Loading CSV dataset: #{url}")
363
600
  default_options = { use_cache: true, integrate_data: true }
364
601
  merged_options = default_options.merge(options)
365
602
 
366
- dataset = @dataset_processor.process_csv_dataset(url, merged_options)
603
+ dataset = debug_time("CSV dataset processing: #{url}") do
604
+ @dataset_processor.process_csv_dataset(url, merged_options)
605
+ end
367
606
 
368
607
  if merged_options[:integrate_data]
369
- integrate_dataset(dataset, merged_options[:category_mappings] || {})
608
+ debug_log("Integrating CSV dataset into categorization")
609
+ result = debug_time("Dataset integration") do
610
+ integrate_dataset(dataset, merged_options[:category_mappings] || {})
611
+ end
612
+ if result
613
+ debug_log("✅ Successfully integrated CSV dataset #{url}")
614
+ else
615
+ debug_log("⚠️ Integration returned nil for CSV dataset #{url}")
616
+ end
617
+ result
370
618
  else
619
+ debug_log("Returning raw CSV dataset (not integrated)")
371
620
  dataset
372
621
  end
373
622
  end
@@ -402,39 +651,44 @@ module UrlCategorise
402
651
  end
403
652
 
404
653
  def export_hosts_files(output_path = nil)
405
- export_dir = output_path || (cache_dir ? File.join(cache_dir, 'exports', 'hosts') : File.join(Dir.pwd, 'exports', 'hosts'))
406
-
654
+ export_dir = output_path || (if cache_dir
655
+ File.join(cache_dir, "exports",
656
+ "hosts")
657
+ else
658
+ File.join(Dir.pwd, "exports", "hosts")
659
+ end)
660
+
407
661
  FileUtils.mkdir_p(export_dir) unless Dir.exist?(export_dir)
408
-
662
+
409
663
  exported_files = {}
410
-
664
+
411
665
  @hosts.each do |category, domains|
412
666
  next if domains.empty?
413
-
667
+
414
668
  filename = "#{category}.hosts"
415
669
  file_path = File.join(export_dir, filename)
416
-
417
- File.open(file_path, 'w') do |file|
670
+
671
+ File.open(file_path, "w") do |file|
418
672
  file.puts "# #{category.to_s.gsub('_', ' ').split.map(&:capitalize).join(' ')} - Generated by UrlCategorise"
419
673
  file.puts "# Generated at: #{Time.now}"
420
674
  file.puts "# Total entries: #{domains.length}"
421
675
  file.puts ""
422
-
676
+
423
677
  domains.sort.each do |domain|
424
678
  file.puts "0.0.0.0 #{domain}"
425
679
  end
426
680
  end
427
-
681
+
428
682
  exported_files[category] = {
429
683
  path: file_path,
430
684
  filename: filename,
431
685
  count: domains.length
432
686
  }
433
687
  end
434
-
688
+
435
689
  # Create summary file
436
- summary_path = File.join(export_dir, '_export_summary.txt')
437
- File.open(summary_path, 'w') do |file|
690
+ summary_path = File.join(export_dir, "_export_summary.txt")
691
+ File.open(summary_path, "w") do |file|
438
692
  file.puts "UrlCategorise Hosts Export Summary"
439
693
  file.puts "=================================="
440
694
  file.puts "Generated at: #{Time.now}"
@@ -443,56 +697,61 @@ module UrlCategorise
443
697
  file.puts "Total domains: #{@hosts.values.map(&:length).sum}"
444
698
  file.puts ""
445
699
  file.puts "Files created:"
446
-
447
- exported_files.each do |category, info|
700
+
701
+ exported_files.each do |_category, info|
448
702
  file.puts " #{info[:filename]} - #{info[:count]} domains"
449
703
  end
450
704
  end
451
-
705
+
452
706
  exported_files[:_summary] = {
453
707
  path: summary_path,
454
708
  total_categories: exported_files.keys.length,
455
709
  total_domains: @hosts.values.map(&:length).sum,
456
710
  export_directory: export_dir
457
711
  }
458
-
712
+
459
713
  exported_files
460
714
  end
461
715
 
462
716
  def export_csv_data(output_path = nil)
463
- require 'csv'
464
-
465
- export_dir = output_path || (cache_dir ? File.join(cache_dir, 'exports', 'csv') : File.join(Dir.pwd, 'exports', 'csv'))
466
-
717
+ require "csv"
718
+
719
+ export_dir = output_path || (if cache_dir
720
+ File.join(cache_dir, "exports",
721
+ "csv")
722
+ else
723
+ File.join(Dir.pwd, "exports", "csv")
724
+ end)
725
+
467
726
  FileUtils.mkdir_p(export_dir) unless Dir.exist?(export_dir)
468
-
727
+
469
728
  # Create single comprehensive CSV with ALL data
470
- timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
729
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
471
730
  filename = "url_categorise_comprehensive_export_#{timestamp}.csv"
472
731
  file_path = File.join(export_dir, filename)
473
-
732
+
474
733
  # Collect all available data
475
734
  all_data = collect_all_export_data
476
-
735
+
477
736
  # Create CSV with dynamic headers
478
737
  headers = determine_comprehensive_headers(all_data)
479
-
480
- CSV.open(file_path, 'w', headers: true) do |csv|
738
+
739
+ CSV.open(file_path, "w", headers: true) do |csv|
481
740
  csv << headers
482
-
741
+
483
742
  all_data.each do |entry|
484
- row = headers.map { |header| entry[header] || entry[header.to_sym] || '' }
743
+ row = headers.map { |header| entry[header] || entry[header.to_sym] || "" }
485
744
  csv << row
486
745
  end
487
746
  end
488
-
747
+
489
748
  # Create summary file
490
749
  summary_filename = "export_summary_#{timestamp}.json"
491
750
  summary_file_path = File.join(export_dir, summary_filename)
492
-
751
+
493
752
  summary = create_comprehensive_export_summary(file_path, all_data, export_dir)
494
753
  File.write(summary_file_path, JSON.pretty_generate(summary))
495
-
754
+
496
755
  {
497
756
  csv_file: file_path,
498
757
  summary_file: summary_file_path,
@@ -504,6 +763,45 @@ module UrlCategorise
504
763
 
505
764
  private
506
765
 
766
+ def debug_log(message)
767
+ puts "[UrlCategorise DEBUG] #{message}" if debug_enabled
768
+ end
769
+
770
+ def debug_time(description)
771
+ return yield unless debug_enabled
772
+
773
+ start_time = Time.now
774
+ result = yield
775
+ end_time = Time.now
776
+ elapsed = ((end_time - start_time) * 1000).round(2)
777
+ debug_log("#{description} completed in #{elapsed}ms")
778
+ result
779
+ end
780
+
781
+ def test_environment?
782
+ # Check environment variables
783
+ return true if ENV["RAILS_ENV"] == "test" || ENV["RACK_ENV"] == "test"
784
+
785
+ # Check if MiniTest is defined (after it's loaded)
786
+ return true if defined?(Minitest) || defined?(MiniTest)
787
+
788
+ # Check caller stack for test-related files
789
+ caller_locations = caller_locations(1, 10) || []
790
+ test_patterns = [ /test\//, /_test\.rb$/, /minitest/, /rake.*test/ ]
791
+ return true if caller_locations.any? { |loc|
792
+ test_patterns.any? { |pattern| loc.path.match?(pattern) }
793
+ }
794
+
795
+ # Check if we're running under rake test
796
+ return true if defined?(Rake) && ARGV.include?("test")
797
+
798
+ # Check for common test runners
799
+ return true if $PROGRAM_NAME.include?("rake") && ARGV.include?("test")
800
+ return true if $PROGRAM_NAME.include?("minitest")
801
+
802
+ false
803
+ end
804
+
507
805
  def load_regex_patterns
508
806
  return unless regex_patterns_file
509
807
 
@@ -519,9 +817,9 @@ module UrlCategorise
519
817
 
520
818
  # Check if this line is a source comment
521
819
  if line.match(/^# Source: (.+)$/)
522
- current_category = $1.downcase
820
+ current_category = ::Regexp.last_match(1).downcase
523
821
  @regex_patterns[current_category] = [] unless @regex_patterns[current_category]
524
- elsif current_category && !line.start_with?('#') && !line.empty?
822
+ elsif current_category && !line.start_with?("#") && !line.empty?
525
823
  # This is a regex pattern
526
824
  begin
527
825
  regex = Regexp.new(line)
@@ -535,11 +833,13 @@ module UrlCategorise
535
833
  end
536
834
  end
537
835
 
538
- puts "Loaded #{@regex_patterns.values.flatten.size} regex patterns from #{@regex_patterns.keys.size} categories" if @regex_patterns.any?
836
+ return unless @regex_patterns.any?
837
+
838
+ debug_log("Loaded #{@regex_patterns.values.flatten.size} regex patterns from #{@regex_patterns.keys.size} categories")
539
839
  end
540
840
 
541
841
  def fetch_regex_patterns_content
542
- if regex_patterns_file.start_with?('http://', 'https://')
842
+ if regex_patterns_file.start_with?("http://", "https://")
543
843
  # Remote URL
544
844
  begin
545
845
  response = HTTParty.get(regex_patterns_file, timeout: request_timeout)
@@ -548,9 +848,9 @@ module UrlCategorise
548
848
  puts "Warning: Failed to fetch regex patterns from #{regex_patterns_file}: #{e.message}"
549
849
  return nil
550
850
  end
551
- elsif regex_patterns_file.start_with?('file://')
851
+ elsif regex_patterns_file.start_with?("file://")
552
852
  # Local file URL
553
- file_path = regex_patterns_file.sub('file://', '')
853
+ file_path = regex_patterns_file.sub("file://", "")
554
854
  return File.read(file_path) if File.exist?(file_path)
555
855
  elsif File.exist?(regex_patterns_file)
556
856
  # Direct file path
@@ -561,21 +861,23 @@ module UrlCategorise
561
861
  nil
562
862
  end
563
863
 
564
- def apply_regex_categorization(url, existing_categories)
864
+ def apply_regex_categorisation(url, existing_categories)
565
865
  return existing_categories unless @regex_patterns.any?
566
866
 
567
867
  # If we have existing categories that match domains, check if the URL matches video patterns
568
- video_categories = existing_categories & [:video, :video_hosting, :youtube, :vimeo, :tiktok]
569
-
868
+ video_categories = existing_categories & %i[video_hosting youtube vimeo tiktok]
869
+
570
870
  if video_categories.any?
571
871
  # Check if this URL matches any video patterns
572
- @regex_patterns.each do |category, patterns|
872
+ @regex_patterns.each do |_category, patterns|
573
873
  patterns.each do |pattern_info|
574
- if url.match?(pattern_info[:pattern])
575
- # This is a video content URL, add a more specific categorization
576
- existing_categories << "#{video_categories.first}_content".to_sym unless existing_categories.include?("#{video_categories.first}_content".to_sym)
577
- break
874
+ next unless url.match?(pattern_info[:pattern])
875
+
876
+ # This is a video content URL, add a more specific categorisation
877
+ unless existing_categories.include?("#{video_categories.first}_content".to_sym)
878
+ existing_categories << "#{video_categories.first}_content".to_sym
578
879
  end
880
+ break
579
881
  end
580
882
  end
581
883
  end
@@ -585,13 +887,13 @@ module UrlCategorise
585
887
 
586
888
  def collect_all_export_data
587
889
  all_data = []
588
-
890
+
589
891
  # 1. Add all processed domain/category mappings
590
892
  @hosts.each do |category, domains|
591
893
  domains.each do |domain|
592
- source_type = @dataset_categories.include?(category) ? 'dataset' : 'blocklist'
894
+ source_type = @dataset_categories.include?(category) ? "dataset" : "blocklist"
593
895
  is_dataset_category = @dataset_categories.include?(category)
594
-
896
+
595
897
  # Get IAB mappings if compliance is enabled
596
898
  iab_v2 = nil
597
899
  iab_v3 = nil
@@ -599,36 +901,36 @@ module UrlCategorise
599
901
  iab_v2 = IabCompliance.map_category_to_iab(category, :v2)
600
902
  iab_v3 = IabCompliance.map_category_to_iab(category, :v3)
601
903
  end
602
-
904
+
603
905
  entry = {
604
- 'data_type' => 'domain_categorization',
605
- 'domain' => domain,
606
- 'url' => domain, # For compatibility
607
- 'category' => category.to_s,
608
- 'source_type' => source_type,
609
- 'is_dataset_category' => is_dataset_category,
610
- 'iab_category_v2' => iab_v2,
611
- 'iab_category_v3' => iab_v3,
612
- 'export_timestamp' => Time.now.iso8601,
613
- 'smart_categorization_enabled' => smart_categorization_enabled
906
+ "data_type" => "domain_categorization",
907
+ "domain" => domain,
908
+ "url" => domain, # For compatibility
909
+ "category" => category.to_s,
910
+ "source_type" => source_type,
911
+ "is_dataset_category" => is_dataset_category,
912
+ "iab_category_v2" => iab_v2,
913
+ "iab_category_v3" => iab_v3,
914
+ "export_timestamp" => Time.now.iso8601,
915
+ "smart_categorization_enabled" => smart_categorization_enabled
614
916
  }
615
-
917
+
616
918
  all_data << entry
617
919
  end
618
920
  end
619
-
921
+
620
922
  # 2. Add raw dataset content from cache
621
923
  collect_cached_dataset_content.each do |entry|
622
- entry['data_type'] = 'raw_dataset_content'
924
+ entry["data_type"] = "raw_dataset_content"
623
925
  all_data << entry
624
926
  end
625
-
927
+
626
928
  # 3. Try to collect currently loaded dataset data if available
627
929
  collect_current_dataset_content.each do |entry|
628
- entry['data_type'] = 'current_dataset_content'
930
+ entry["data_type"] = "current_dataset_content"
629
931
  all_data << entry
630
932
  end
631
-
933
+
632
934
  all_data
633
935
  end
634
936
 
@@ -638,25 +940,27 @@ module UrlCategorise
638
940
 
639
941
  # Collect from cached datasets if available
640
942
  (@dataset_metadata || {}).each do |data_hash, metadata|
641
- cache_key = @dataset_processor.send(:generate_cache_key, metadata[:source_identifier] || data_hash, metadata[:source_type]&.to_sym || :unknown)
943
+ cache_key = @dataset_processor.send(:generate_cache_key, metadata[:source_identifier] || data_hash,
944
+ metadata[:source_type]&.to_sym || :unknown)
642
945
  cached_result = @dataset_processor.send(:load_from_cache, cache_key)
643
-
644
- if cached_result && cached_result.is_a?(Hash) && cached_result['raw_content']
645
- cached_result['raw_content'].each do |entry|
946
+
947
+ if cached_result && cached_result.is_a?(Hash) && cached_result["raw_content"]
948
+ cached_result["raw_content"].each do |entry|
646
949
  enhanced_entry = entry.dup
647
- enhanced_entry['dataset_source'] = metadata[:source_identifier] || 'unknown'
648
- enhanced_entry['dataset_type'] = metadata[:source_type] || 'unknown'
649
- enhanced_entry['processed_at'] = metadata[:processed_at]
950
+ enhanced_entry["dataset_source"] = metadata[:source_identifier] || "unknown"
951
+ enhanced_entry["dataset_type"] = metadata[:source_type] || "unknown"
952
+ enhanced_entry["processed_at"] = metadata[:processed_at]
650
953
  cached_data << enhanced_entry
651
954
  end
652
955
  elsif cached_result.is_a?(Array)
653
956
  # Legacy format - array of entries
654
957
  cached_result.each do |entry|
655
958
  next unless entry.is_a?(Hash)
959
+
656
960
  enhanced_entry = entry.dup
657
- enhanced_entry['dataset_source'] = metadata[:source_identifier] || 'unknown'
658
- enhanced_entry['dataset_type'] = metadata[:source_type] || 'unknown'
659
- enhanced_entry['processed_at'] = metadata[:processed_at]
961
+ enhanced_entry["dataset_source"] = metadata[:source_identifier] || "unknown"
962
+ enhanced_entry["dataset_type"] = metadata[:source_type] || "unknown"
963
+ enhanced_entry["processed_at"] = metadata[:processed_at]
660
964
  cached_data << enhanced_entry
661
965
  end
662
966
  end
@@ -666,7 +970,7 @@ module UrlCategorise
666
970
  end
667
971
 
668
972
  def collect_current_dataset_content
669
- # This is a placeholder - in practice, the original dataset content
973
+ # This is a placeholder - in practice, the original dataset content
670
974
  # is processed and only domain mappings are kept in @hosts.
671
975
  # The raw content should come from cache, but if we want to be more
672
976
  # aggressive, we could re-process datasets here or store them differently.
@@ -683,36 +987,36 @@ module UrlCategorise
683
987
 
684
988
  # Core headers that should appear first
685
989
  core_headers = %w[data_type domain url category]
686
-
990
+
687
991
  # Standard categorization headers
688
992
  categorization_headers = %w[source_type is_dataset_category iab_category_v2 iab_category_v3]
689
-
993
+
690
994
  # Dataset content headers
691
995
  content_headers = %w[title description text content summary body]
692
-
996
+
693
997
  # Metadata headers
694
998
  metadata_headers = %w[dataset_source dataset_type processed_at export_timestamp smart_categorization_enabled]
695
-
999
+
696
1000
  # Build final header order
697
1001
  ordered_headers = []
698
1002
  ordered_headers += (core_headers & all_keys_array)
699
1003
  ordered_headers += (categorization_headers & all_keys_array)
700
1004
  ordered_headers += (content_headers & all_keys_array)
701
-
1005
+
702
1006
  # Add any remaining headers (alphabetically sorted)
703
1007
  remaining_headers = (all_keys_array - ordered_headers - metadata_headers).sort
704
1008
  ordered_headers += remaining_headers
705
-
1009
+
706
1010
  # Add metadata headers at the end
707
1011
  ordered_headers += (metadata_headers & all_keys_array)
708
-
1012
+
709
1013
  ordered_headers
710
1014
  end
711
1015
 
712
1016
  def create_comprehensive_export_summary(file_path, all_data, export_dir)
713
- domain_entries = all_data.select { |entry| entry['data_type'] == 'domain_categorization' }
714
- dataset_entries = all_data.select { |entry| entry['data_type']&.include?('dataset') }
715
-
1017
+ domain_entries = all_data.select { |entry| entry["data_type"] == "domain_categorization" }
1018
+ dataset_entries = all_data.select { |entry| entry["data_type"]&.include?("dataset") }
1019
+
716
1020
  {
717
1021
  export_info: {
718
1022
  timestamp: Time.now.iso8601,
@@ -741,13 +1045,10 @@ module UrlCategorise
741
1045
  }
742
1046
  end
743
1047
 
744
-
745
- private
746
-
747
1048
  def initialize_dataset_processor(config)
748
1049
  processor_config = {
749
- download_path: config[:download_path] || cache_dir&.+(File::SEPARATOR + 'downloads'),
750
- cache_path: config[:cache_path] || cache_dir&.+(File::SEPARATOR + 'datasets'),
1050
+ download_path: config[:download_path] || cache_dir&.+(File::SEPARATOR + "downloads"),
1051
+ cache_path: config[:cache_path] || cache_dir&.+(File::SEPARATOR + "datasets"),
751
1052
  timeout: config[:timeout] || request_timeout,
752
1053
  enable_kaggle: config.fetch(:enable_kaggle, true) # Default to true for backwards compatibility
753
1054
  }
@@ -765,7 +1066,7 @@ module UrlCategorise
765
1066
  DatasetProcessor.new(**processor_config)
766
1067
  rescue Error => e
767
1068
  # Dataset processor failed to initialize, but client can still work without it
768
- puts "Warning: Dataset processor initialization failed: #{e.message}" if ENV['DEBUG']
1069
+ puts "Warning: Dataset processor initialization failed: #{e.message}" if ENV["DEBUG"]
769
1070
  nil
770
1071
  end
771
1072
 
@@ -773,37 +1074,51 @@ module UrlCategorise
773
1074
  return dataset unless @dataset_processor
774
1075
  return nil unless dataset # Handle nil datasets gracefully
775
1076
 
1077
+ debug_log("Processing dataset for integration...")
776
1078
  processed_result = @dataset_processor.integrate_dataset_into_categorization(dataset, category_mappings)
777
1079
 
778
1080
  # Handle new data structure with categories and raw_content
779
- if processed_result.is_a?(Hash) && processed_result['categories']
780
- categorized_data = processed_result['categories']
781
- metadata = processed_result['_metadata']
1081
+ if processed_result.is_a?(Hash) && processed_result["categories"]
1082
+ categorised_data = processed_result["categories"]
1083
+ metadata = processed_result["_metadata"]
1084
+ debug_log("Received new-format dataset with #{categorised_data.keys.length} categories")
782
1085
  else
783
- # Legacy format - assume the whole result is categorized data
784
- categorized_data = processed_result
785
- metadata = categorized_data[:_metadata] if categorized_data.respond_to?(:delete)
1086
+ # Legacy format - assume the whole result is categorised data
1087
+ categorised_data = processed_result
1088
+ metadata = categorised_data[:_metadata] if categorised_data.respond_to?(:delete)
1089
+ debug_log("Received legacy-format dataset with #{categorised_data.keys.length} categories")
786
1090
  end
787
1091
 
788
1092
  # Store metadata
789
1093
  if metadata
790
1094
  @dataset_metadata ||= {}
791
1095
  @dataset_metadata[metadata[:data_hash]] = metadata
1096
+ debug_log("Stored dataset metadata with hash: #{metadata[:data_hash]}")
792
1097
  end
793
1098
 
794
1099
  # Merge with existing host data
795
- categorized_data.each do |category, domains|
796
- next if category.to_s.start_with?('_') # Skip internal keys
1100
+ total_domains_added = 0
1101
+ categories_processed = 0
1102
+ categorised_data.each do |category, domains|
1103
+ next if category.to_s.start_with?("_") # Skip internal keys
797
1104
 
798
1105
  # Convert category to symbol for consistency
799
1106
  category_sym = category.to_sym
800
1107
  @hosts[category_sym] ||= []
1108
+ before_size = @hosts[category_sym].size
801
1109
  @hosts[category_sym].concat(domains).uniq!
1110
+ after_size = @hosts[category_sym].size
1111
+ domains_added = after_size - before_size
1112
+
1113
+ debug_log(" Category #{category_sym}: added #{domains_added} new domains (#{domains.size} total in dataset)")
1114
+ total_domains_added += domains_added
1115
+ categories_processed += 1
802
1116
 
803
1117
  # Track this as a dataset category
804
1118
  @dataset_categories.add(category_sym)
805
1119
  end
806
1120
 
1121
+ debug_log("✅ Dataset integration completed: #{categories_processed} categories, #{total_domains_added} new domains added")
807
1122
  dataset
808
1123
  end
809
1124
 
@@ -811,78 +1126,90 @@ module UrlCategorise
811
1126
  return unless defined?(CATEGORIY_DATABASES) && CATEGORIY_DATABASES.is_a?(Array)
812
1127
  return unless @dataset_processor
813
1128
 
814
- puts "Loading #{CATEGORIY_DATABASES.length} datasets from constants..." if ENV['DEBUG']
1129
+ debug_log("Loading #{CATEGORIY_DATABASES.length} datasets from constants...")
815
1130
  loaded_count = 0
816
1131
 
817
1132
  CATEGORIY_DATABASES.each do |dataset_config|
818
- begin
819
- case dataset_config[:type]
820
- when :kaggle
821
- # Parse the kaggle path to get owner and dataset name
822
- path_parts = dataset_config[:path].split('/')
823
- next unless path_parts.length == 2
824
-
825
- dataset_owner, dataset_name = path_parts
826
-
827
- # Check if dataset is already cached before attempting to load
828
- cache_key = @dataset_processor.send(:generate_cache_key, "#{dataset_owner}/#{dataset_name}", :kaggle)
829
- cache_file = File.join(@dataset_processor.cache_path, cache_key)
830
-
831
- if File.exist?(cache_file)
832
- puts "Loading cached Kaggle dataset: #{dataset_owner}/#{dataset_name}" if ENV['DEBUG']
1133
+ case dataset_config[:type]
1134
+ when :kaggle
1135
+ # Parse the kaggle path to get owner and dataset name
1136
+ path_parts = dataset_config[:path].split("/")
1137
+ next unless path_parts.length == 2
1138
+
1139
+ dataset_owner, dataset_name = path_parts
1140
+ dataset_identifier = "#{dataset_owner}/#{dataset_name}"
1141
+
1142
+ # Check if dataset is already cached before attempting to load
1143
+ cache_key = @dataset_processor.send(:generate_cache_key, dataset_identifier, :kaggle)
1144
+ cache_file = File.join(@dataset_processor.cache_path, cache_key)
1145
+
1146
+ if File.exist?(cache_file)
1147
+ debug_log("✅ Dataset cache HIT for Kaggle dataset: #{dataset_identifier}")
1148
+ debug_time("Loading cached Kaggle dataset: #{dataset_identifier}") do
833
1149
  load_kaggle_dataset(dataset_owner, dataset_name, {
834
1150
  use_cache: true,
835
1151
  integrate_data: true
836
1152
  })
837
- loaded_count += 1
838
- else
839
- puts "Attempting to download missing Kaggle dataset: #{dataset_owner}/#{dataset_name}" if ENV['DEBUG']
840
- begin
1153
+ end
1154
+ loaded_count += 1
1155
+ else
1156
+ debug_log("❌ Dataset cache MISS for Kaggle dataset: #{dataset_identifier}")
1157
+ debug_log("Attempting to download missing Kaggle dataset: #{dataset_identifier}")
1158
+ begin
1159
+ debug_time("Downloading Kaggle dataset: #{dataset_identifier}") do
841
1160
  load_kaggle_dataset(dataset_owner, dataset_name, {
842
1161
  use_cache: true,
843
1162
  integrate_data: true
844
1163
  })
845
- loaded_count += 1
846
- rescue Error => e
847
- puts "Warning: Failed to download Kaggle dataset #{dataset_owner}/#{dataset_name}: #{e.message}" if ENV['DEBUG']
848
1164
  end
1165
+ debug_log("💾 Successfully downloaded and cached Kaggle dataset: #{dataset_identifier}")
1166
+ loaded_count += 1
1167
+ rescue Error => e
1168
+ debug_log("⚠️ Failed to download Kaggle dataset #{dataset_identifier}: #{e.message}")
849
1169
  end
1170
+ end
850
1171
 
851
- when :csv
852
- # Check if CSV dataset is cached
853
- cache_key = @dataset_processor.send(:generate_cache_key, dataset_config[:path], :csv)
854
- cache_file = File.join(@dataset_processor.cache_path, cache_key)
855
-
856
- if File.exist?(cache_file)
857
- puts "Loading cached CSV dataset: #{dataset_config[:path]}" if ENV['DEBUG']
858
- load_csv_dataset(dataset_config[:path], {
1172
+ when :csv
1173
+ # Check if CSV dataset is cached
1174
+ dataset_path = dataset_config[:path]
1175
+ cache_key = @dataset_processor.send(:generate_cache_key, dataset_path, :csv)
1176
+ cache_file = File.join(@dataset_processor.cache_path, cache_key)
1177
+
1178
+ if File.exist?(cache_file)
1179
+ debug_log("✅ Dataset cache HIT for CSV dataset: #{dataset_path}")
1180
+ debug_time("Loading cached CSV dataset: #{dataset_path}") do
1181
+ load_csv_dataset(dataset_path, {
859
1182
  use_cache: true,
860
1183
  integrate_data: true
861
1184
  })
862
- loaded_count += 1
863
- else
864
- puts "Attempting to download missing CSV dataset: #{dataset_config[:path]}" if ENV['DEBUG']
865
- begin
866
- load_csv_dataset(dataset_config[:path], {
1185
+ end
1186
+ loaded_count += 1
1187
+ else
1188
+ debug_log("❌ Dataset cache MISS for CSV dataset: #{dataset_path}")
1189
+ debug_log("Attempting to download missing CSV dataset: #{dataset_path}")
1190
+ begin
1191
+ debug_time("Downloading CSV dataset: #{dataset_path}") do
1192
+ load_csv_dataset(dataset_path, {
867
1193
  use_cache: true,
868
1194
  integrate_data: true
869
1195
  })
870
- loaded_count += 1
871
- rescue Error => e
872
- puts "Warning: Failed to download CSV dataset #{dataset_config[:path]}: #{e.message}" if ENV['DEBUG']
873
1196
  end
1197
+ debug_log("💾 Successfully downloaded and cached CSV dataset: #{dataset_path}")
1198
+ loaded_count += 1
1199
+ rescue Error => e
1200
+ debug_log("⚠️ Failed to download CSV dataset #{dataset_path}: #{e.message}")
874
1201
  end
875
1202
  end
876
- rescue Error => e
877
- puts "Warning: Failed to load dataset #{dataset_config[:path]}: #{e.message}" if ENV['DEBUG']
878
- # Continue loading other datasets even if one fails
879
- rescue StandardError => e
880
- puts "Warning: Unexpected error loading dataset #{dataset_config[:path]}: #{e.message}" if ENV['DEBUG']
881
- # Continue loading other datasets even if one fails
882
1203
  end
1204
+ rescue Error => e
1205
+ debug_log("⚠️ Failed to load dataset #{dataset_config[:path]}: #{e.message}")
1206
+ # Continue loading other datasets even if one fails
1207
+ rescue StandardError => e
1208
+ debug_log("⚠️ Unexpected error loading dataset #{dataset_config[:path]}: #{e.message}")
1209
+ # Continue loading other datasets even if one fails
883
1210
  end
884
1211
 
885
- puts "Finished loading datasets from constants (#{loaded_count}/#{CATEGORIY_DATABASES.length} loaded)" if ENV['DEBUG']
1212
+ debug_log("Finished loading datasets from constants (#{loaded_count}/#{CATEGORIY_DATABASES.length} loaded)")
886
1213
  end
887
1214
 
888
1215
  def hash_size_in_mb(hash)
@@ -903,8 +1230,16 @@ module UrlCategorise
903
1230
  def fetch_and_build_host_lists
904
1231
  @hosts = {}
905
1232
 
906
- (host_urls || {}).keys.each do |category|
907
- @hosts[category] = build_host_data((host_urls || {})[category])
1233
+ # Disable parallel loading for tests to avoid freezing issues
1234
+ if test_environment?
1235
+ debug_log("📥 Using sequential loading (test environment detected)")
1236
+ fetch_and_build_host_lists_sequential
1237
+ elsif parallel_loading_enabled && self.class.ractor_available? && (host_urls || {}).size > 1
1238
+ debug_log("🚀 Using parallel loading with Ractors for #{(host_urls || {}).keys.size} categories")
1239
+ fetch_and_build_host_lists_parallel
1240
+ else
1241
+ debug_log("📥 Using sequential loading for #{(host_urls || {}).keys.size} categories")
1242
+ fetch_and_build_host_lists_sequential
908
1243
  end
909
1244
 
910
1245
  sub_category_values = categories_with_keys
@@ -922,6 +1257,276 @@ module UrlCategorise
922
1257
  @hosts
923
1258
  end
924
1259
 
1260
+ def fetch_and_build_host_lists_sequential
1261
+ (host_urls || {}).keys.each do |category|
1262
+ @hosts[category] = build_host_data((host_urls || {})[category])
1263
+ end
1264
+ end
1265
+
1266
+ def fetch_and_build_host_lists_parallel
1267
+ # Hybrid approach: HTTP requests serially, file processing with Ractors in parallel
1268
+ # First, download all content serially to avoid Ractor HTTP issues
1269
+ debug_log("📡 Phase 1: Downloading content serially")
1270
+
1271
+ all_download_tasks = []
1272
+ (host_urls || {}).each do |category, urls|
1273
+ urls.each do |url|
1274
+ next unless url_valid?(url)
1275
+ all_download_tasks << { category: category, url: url }
1276
+ end
1277
+ end
1278
+
1279
+ # Download all content serially
1280
+ downloaded_content = {}
1281
+ all_download_tasks.each do |task|
1282
+ url = task[:url]
1283
+ category = task[:category]
1284
+
1285
+ debug_log("📥 Downloading #{url}")
1286
+ content = nil
1287
+
1288
+ # Try cache first
1289
+ if cache_dir && !force_download
1290
+ cached_data = debug_time("Cache lookup for #{url}") do
1291
+ read_from_cache(url)
1292
+ end
1293
+ if cached_data && !cached_data.empty?
1294
+ debug_log("✅ Cache HIT for #{url} - loaded #{cached_data.size} hosts")
1295
+ downloaded_content["#{category}:#{url}"] = { hosts: cached_data, from_cache: true }
1296
+ next
1297
+ else
1298
+ debug_log("❌ Cache MISS for #{url}")
1299
+ end
1300
+ else
1301
+ debug_log("Cache disabled (cache_dir: #{cache_dir.inspect}, force_download: #{force_download})")
1302
+ end
1303
+
1304
+ # Download content
1305
+ if url.start_with?("file://")
1306
+ file_path = url.sub("file://", "")
1307
+ if File.exist?(file_path)
1308
+ content = File.read(file_path)
1309
+ end
1310
+ else
1311
+ begin
1312
+ raw_data = HTTParty.get(url, timeout: request_timeout)
1313
+ content = raw_data&.body
1314
+ rescue => e
1315
+ debug_log("⚠️ Download failed for #{url}: #{e.message}")
1316
+ next
1317
+ end
1318
+ end
1319
+
1320
+ downloaded_content["#{category}:#{url}"] = { content: content, from_cache: false }
1321
+ debug_log("📥 Downloaded #{content&.length || 0} bytes from #{url}")
1322
+ end
1323
+
1324
+ # Phase 2: Process content in parallel using Ractors (if available) or threads
1325
+ debug_log("⚡ Phase 2: Processing content in parallel")
1326
+
1327
+ if self.class.ractor_available? && !test_environment?
1328
+ process_content_with_ractors(downloaded_content)
1329
+ else
1330
+ process_content_with_threads(downloaded_content)
1331
+ end
1332
+ end
1333
+
1334
+ def process_content_with_ractors(downloaded_content)
1335
+ debug_time("Parallel content processing with Ractors") do
1336
+ # Create a work queue
1337
+ # Suppress Ractor experimental warnings
1338
+ original_verbose = $VERBOSE
1339
+ $VERBOSE = nil
1340
+ work_queue = Ractor.new do
1341
+ # :nocov:
1342
+ tasks = []
1343
+ no_more_tasks = false
1344
+
1345
+ while (task = Ractor.receive) != :done
1346
+ if task == :get_task
1347
+ if tasks.empty? && no_more_tasks
1348
+ Ractor.yield(nil) # No more tasks available
1349
+ else
1350
+ Ractor.yield(tasks.shift) # May return nil if empty, but more tasks might come
1351
+ end
1352
+ elsif task == :no_more_tasks
1353
+ no_more_tasks = true
1354
+ else
1355
+ tasks << task
1356
+ end
1357
+ end
1358
+ # :nocov:
1359
+ end
1360
+
1361
+ # Add all tasks to queue
1362
+ downloaded_content.each do |key, data|
1363
+ work_queue.send([ key, data ])
1364
+ end
1365
+
1366
+ # Signal that no more tasks will be added
1367
+ work_queue.send(:no_more_tasks)
1368
+
1369
+ # Create worker Ractors (limited by max_ractor_workers)
1370
+ num_workers = [ max_ractor_workers, downloaded_content.size ].min
1371
+ debug_log("🔧 Creating #{num_workers} Ractor workers")
1372
+
1373
+ workers = num_workers.times.map do
1374
+ Ractor.new(work_queue, cache_dir) do |queue, cache_path|
1375
+ # :nocov:
1376
+ require "digest"
1377
+ require "fileutils"
1378
+
1379
+ results = []
1380
+ loop do
1381
+ queue.send(:get_task)
1382
+ task = queue.take
1383
+ break if task.nil?
1384
+
1385
+ key, data = task
1386
+ category, url = key.split(":", 2)
1387
+
1388
+ if data[:from_cache]
1389
+ # Already processed, just return
1390
+ results << [ category, data[:hosts] ]
1391
+ else
1392
+ # Parse the content
1393
+ content = data[:content]
1394
+ if content.nil? || content.empty?
1395
+ results << [ category, [] ]
1396
+ else
1397
+ # Parse content (simplified version for Ractor)
1398
+ lines = content.split("\n").reject { |line| line.empty? || line.strip.start_with?("#") }
1399
+ sample_lines = lines.first(20)
1400
+
1401
+ parsed_hosts = if sample_lines.any? { |line| line.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\s+/) }
1402
+ # hosts format
1403
+ lines.map do |line|
1404
+ parts = line.split(" ")
1405
+ parts.length >= 2 ? parts[1].strip : nil
1406
+ end.compact.reject(&:empty?)
1407
+ elsif sample_lines.any? { |line| line.include?("address=/") }
1408
+ # dnsmasq format
1409
+ lines.map do |line|
1410
+ match = line.match(%r{address=/(.+?)/})
1411
+ match ? match[1] : nil
1412
+ end.compact
1413
+ elsif sample_lines.any? { |line| line.match(/^\|\|/) }
1414
+ # ublock format
1415
+ lines.map { |line| line.gsub(/^\|\|/, "").gsub(/[$\^].*$/, "").strip }.reject(&:empty?)
1416
+ else
1417
+ # plain format
1418
+ lines.map(&:strip)
1419
+ end.compact.sort.uniq
1420
+
1421
+ # Cache the results if cache is enabled
1422
+ if cache_path && !parsed_hosts.empty?
1423
+ begin
1424
+ FileUtils.mkdir_p(cache_path) unless Dir.exist?(cache_path)
1425
+ filename = Digest::MD5.hexdigest(url) + ".cache"
1426
+ cache_file = File.join(cache_path, filename)
1427
+
1428
+ cache_data = {
1429
+ hosts: parsed_hosts,
1430
+ cached_at: Time.now
1431
+ }
1432
+
1433
+ File.write(cache_file, Marshal.dump(cache_data))
1434
+ rescue
1435
+ # Cache save failed, continue
1436
+ end
1437
+ end
1438
+
1439
+ results << [ category, parsed_hosts ]
1440
+ end
1441
+ end
1442
+ end
1443
+
1444
+ results
1445
+ # :nocov:
1446
+ end
1447
+ end
1448
+
1449
+ # Collect all results from workers
1450
+ all_results = workers.flat_map(&:take)
1451
+ work_queue.send(:done)
1452
+
1453
+ # Merge results into @hosts
1454
+ all_results.each do |category, hosts|
1455
+ category_sym = category.to_sym
1456
+ @hosts[category_sym] ||= []
1457
+ @hosts[category_sym].concat(hosts)
1458
+ @hosts[category_sym].uniq!
1459
+ end
1460
+
1461
+ # Final consolidation
1462
+ @hosts.each do |category, hosts|
1463
+ debug_log("🏁 Category #{category}: loaded #{hosts.size} hosts with Ractor processing")
1464
+ end
1465
+
1466
+ # Restore original verbose setting
1467
+ $VERBOSE = original_verbose
1468
+ end
1469
+ end
1470
+
1471
+ def process_content_with_threads(downloaded_content)
1472
+ debug_time("Parallel content processing with Threads") do
1473
+ require "thread"
1474
+
1475
+ # Create a work queue
1476
+ work_queue = Queue.new
1477
+ downloaded_content.each { |key, data| work_queue << [ key, data ] }
1478
+
1479
+ # Create a limited number of worker threads
1480
+ num_workers = [ max_threads, downloaded_content.size ].min
1481
+ debug_log("🔧 Creating #{num_workers} thread workers")
1482
+
1483
+ threads = []
1484
+ results_mutex = Mutex.new
1485
+
1486
+ num_workers.times do
1487
+ thread = Thread.new do
1488
+ while (task = work_queue.pop(true) rescue nil)
1489
+ key, data = task
1490
+ category, url = key.split(":", 2)
1491
+
1492
+ if data[:from_cache]
1493
+ # Already processed
1494
+ parsed_hosts = data[:hosts]
1495
+ else
1496
+ # Parse the content
1497
+ content = data[:content]
1498
+ next if content.nil? || content.empty?
1499
+
1500
+ parsed_hosts = parse_list_content(content, detect_list_format(content))
1501
+
1502
+ # Save to cache
1503
+ if cache_dir && parsed_hosts && !parsed_hosts.empty?
1504
+ save_to_cache(url, parsed_hosts)
1505
+ end
1506
+ end
1507
+
1508
+ # Thread-safe result storage
1509
+ results_mutex.synchronize do
1510
+ category_sym = category.to_sym
1511
+ @hosts[category_sym] ||= []
1512
+ @hosts[category_sym].concat(parsed_hosts || [])
1513
+ @hosts[category_sym].uniq!
1514
+ end
1515
+ end
1516
+ end
1517
+
1518
+ threads << thread
1519
+ end
1520
+
1521
+ # Wait for all threads to complete
1522
+ threads.each(&:join)
1523
+
1524
+ @hosts.each do |category, hosts|
1525
+ debug_log("🏁 Category #{category}: loaded #{hosts.size} hosts with Thread processing")
1526
+ end
1527
+ end
1528
+ end
1529
+
925
1530
  def initialize_smart_rules(custom_rules)
926
1531
  custom_rules = {} if custom_rules.nil?
927
1532
  default_rules = {
@@ -950,7 +1555,7 @@ module UrlCategorise
950
1555
  default_rules.merge(custom_rules)
951
1556
  end
952
1557
 
953
- def apply_smart_categorization(url, categories)
1558
+ def apply_smart_categorisation(url, categories)
954
1559
  return categories unless smart_categorization_enabled
955
1560
 
956
1561
  host = extract_host(url)
@@ -990,9 +1595,11 @@ module UrlCategorise
990
1595
  end
991
1596
 
992
1597
  def extract_host(url)
993
- (URI.parse(url).host || url).downcase.gsub('www.', '')
1598
+ return nil if url.nil? || (url.respond_to?(:strip) && url.strip.empty?)
1599
+
1600
+ (URI.parse(url).host || url).downcase.gsub("www.", "")
994
1601
  rescue URI::InvalidURIError
995
- url.downcase.gsub('www.', '')
1602
+ url.to_s.downcase.gsub("www.", "")
996
1603
  end
997
1604
 
998
1605
  def build_host_data(urls)
@@ -1001,52 +1608,75 @@ module UrlCategorise
1001
1608
  urls.each do |url|
1002
1609
  next unless url_valid?(url)
1003
1610
 
1611
+ debug_log("Processing host list: #{url}")
1004
1612
  hosts_data = nil
1005
1613
 
1006
- hosts_data = read_from_cache(url) if cache_dir && !force_download
1614
+ if cache_dir && !force_download
1615
+ debug_log("Attempting to load from cache: #{url}")
1616
+ hosts_data = debug_time("Cache lookup for #{url}") do
1617
+ read_from_cache(url)
1618
+ end
1619
+ if hosts_data
1620
+ debug_log("✅ Cache HIT for #{url} - loaded #{hosts_data.size} hosts")
1621
+ else
1622
+ debug_log("❌ Cache MISS for #{url}")
1623
+ end
1624
+ else
1625
+ debug_log("Cache disabled (cache_dir: #{cache_dir.inspect}, force_download: #{force_download})")
1626
+ end
1007
1627
 
1008
1628
  if hosts_data.nil?
1009
- hosts_data = download_and_parse_list(url)
1010
- save_to_cache(url, hosts_data) if cache_dir
1629
+ debug_time("Downloading and parsing #{url}") do
1630
+ hosts_data = download_and_parse_list(url)
1631
+ debug_log("Downloaded #{hosts_data&.size || 0} hosts from #{url}")
1632
+ end
1633
+ if cache_dir && hosts_data
1634
+ debug_time("Saving to cache for #{url}") do
1635
+ save_to_cache(url, hosts_data)
1636
+ end
1637
+ debug_log("💾 Saved #{hosts_data.size} hosts to cache for #{url}")
1638
+ end
1011
1639
  end
1012
1640
 
1013
1641
  all_hosts.concat(hosts_data) if hosts_data
1014
1642
  end
1015
1643
 
1016
- all_hosts.compact.sort.uniq
1644
+ result = all_hosts.compact.sort.uniq
1645
+ debug_log("Total unique hosts collected: #{result.size}")
1646
+ result
1017
1647
  end
1018
1648
 
1019
1649
  def download_and_parse_list(url)
1020
- if url.start_with?('file://')
1650
+ if url.start_with?("file://")
1021
1651
  # Handle local file URLs
1022
- file_path = url.sub('file://', '')
1652
+ file_path = url.sub("file://", "")
1023
1653
  return [] unless File.exist?(file_path)
1024
-
1654
+
1025
1655
  content = File.read(file_path)
1026
1656
  return [] if content.nil? || content.empty?
1027
-
1657
+
1028
1658
  # Store metadata
1029
1659
  @metadata[url] = {
1030
1660
  last_updated: Time.now,
1031
1661
  content_hash: Digest::SHA256.hexdigest(content),
1032
- status: 'success'
1662
+ status: "success"
1033
1663
  }
1034
-
1664
+
1035
1665
  return parse_list_content(content, detect_list_format(content))
1036
1666
  end
1037
-
1667
+
1038
1668
  raw_data = HTTParty.get(url, timeout: request_timeout)
1039
1669
  return [] if raw_data.body.nil? || raw_data.body.empty?
1040
1670
 
1041
1671
  # Store metadata
1042
- etag = raw_data.headers['etag']
1043
- last_modified = raw_data.headers['last-modified']
1672
+ etag = raw_data.headers["etag"]
1673
+ last_modified = raw_data.headers["last-modified"]
1044
1674
  @metadata[url] = {
1045
1675
  last_updated: Time.now,
1046
1676
  etag: etag,
1047
1677
  last_modified: last_modified,
1048
1678
  content_hash: Digest::SHA256.hexdigest(raw_data.body),
1049
- status: 'success'
1679
+ status: "success"
1050
1680
  }
1051
1681
 
1052
1682
  parse_list_content(raw_data.body, detect_list_format(raw_data.body))
@@ -1055,18 +1685,18 @@ module UrlCategorise
1055
1685
  @metadata[url] = {
1056
1686
  last_updated: Time.now,
1057
1687
  error: e.message,
1058
- status: 'failed'
1688
+ status: "failed"
1059
1689
  }
1060
1690
  []
1061
1691
  end
1062
1692
 
1063
1693
  def parse_list_content(content, format)
1064
- lines = content.split("\n").reject { |line| line.empty? || line.strip.start_with?('#') }
1694
+ lines = content.split("\n").reject { |line| line.empty? || line.strip.start_with?("#") }
1065
1695
 
1066
1696
  case format
1067
1697
  when :hosts
1068
1698
  lines.map do |line|
1069
- parts = line.split(' ')
1699
+ parts = line.split(" ")
1070
1700
  # Extract domain from hosts format: "0.0.0.0 domain.com" -> "domain.com"
1071
1701
  parts.length >= 2 ? parts[1].strip : nil
1072
1702
  end.compact.reject(&:empty?)
@@ -1078,7 +1708,7 @@ module UrlCategorise
1078
1708
  match ? match[1] : nil
1079
1709
  end.compact
1080
1710
  when :ublock
1081
- lines.map { |line| line.gsub(/^\|\|/, '').gsub(/[$\^].*$/, '').strip }.reject(&:empty?)
1711
+ lines.map { |line| line.gsub(/^\|\|/, "").gsub(/[$\^].*$/, "").strip }.reject(&:empty?)
1082
1712
  else
1083
1713
  lines.map(&:strip)
1084
1714
  end
@@ -1087,11 +1717,11 @@ module UrlCategorise
1087
1717
  def detect_list_format(content)
1088
1718
  # Skip comments and empty lines, then look at first 20 non-comment lines
1089
1719
  sample_lines = content.split("\n")
1090
- .reject { |line| line.empty? || line.strip.start_with?('#') }
1720
+ .reject { |line| line.empty? || line.strip.start_with?("#") }
1091
1721
  .first(20)
1092
1722
 
1093
1723
  return :hosts if sample_lines.any? { |line| line.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\s+/) }
1094
- return :dnsmasq if sample_lines.any? { |line| line.include?('address=/') }
1724
+ return :dnsmasq if sample_lines.any? { |line| line.include?("address=/") }
1095
1725
  return :ublock if sample_lines.any? { |line| line.match(/^\|\|/) }
1096
1726
 
1097
1727
  :plain
@@ -1101,7 +1731,7 @@ module UrlCategorise
1101
1731
  return nil unless cache_dir
1102
1732
 
1103
1733
  FileUtils.mkdir_p(cache_dir) unless Dir.exist?(cache_dir)
1104
- filename = Digest::MD5.hexdigest(url) + '.cache'
1734
+ filename = Digest::MD5.hexdigest(url) + ".cache"
1105
1735
  File.join(cache_dir, filename)
1106
1736
  end
1107
1737
 
@@ -1145,8 +1775,8 @@ module UrlCategorise
1145
1775
  # Check if remote content has changed
1146
1776
  begin
1147
1777
  head_response = HTTParty.head(url, timeout: request_timeout)
1148
- remote_etag = head_response.headers['etag']
1149
- remote_last_modified = head_response.headers['last-modified']
1778
+ remote_etag = head_response.headers["etag"]
1779
+ remote_last_modified = head_response.headers["last-modified"]
1150
1780
 
1151
1781
  cached_metadata = cache_data[:metadata]
1152
1782
 
@@ -1182,10 +1812,10 @@ module UrlCategorise
1182
1812
 
1183
1813
  def url_valid?(url)
1184
1814
  return false if url.nil? || url.empty?
1185
- return true if url.start_with?('file://')
1186
-
1815
+ return true if url.start_with?("file://")
1816
+
1187
1817
  uri = URI.parse(url)
1188
- uri.is_a?(URI::HTTP) && !uri.host.nil?
1818
+ uri.is_a?(URI::HTTP) && !uri.host.nil? && !uri.host.empty?
1189
1819
  rescue URI::InvalidURIError
1190
1820
  false
1191
1821
  end