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.
- checksums.yaml +4 -4
- data/.claude/settings.local.json +9 -2
- data/.gitignore +1 -0
- data/.rubocop.yml +56 -0
- data/.sublime-project +14 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +89 -41
- data/README.md +167 -9
- data/Rakefile +1 -1
- data/bin/check_lists +124 -22
- data/bin/export_csv +45 -36
- data/bin/export_hosts +34 -24
- data/bin/generate_categorised_lists +533 -0
- data/bin/generate_social_media_lists +398 -0
- data/bin/generate_video_lists +303 -124
- data/bin/generate_video_lists_focused +381 -0
- data/correct_usage_example.rb +18 -18
- data/lib/url_categorise/active_record_client.rb +11 -11
- data/lib/url_categorise/client.rb +856 -226
- data/lib/url_categorise/constants.rb +148 -70
- data/lib/url_categorise/dataset_processor.rb +68 -64
- data/lib/url_categorise/iab_compliance.rb +111 -99
- data/lib/url_categorise/models.rb +15 -15
- data/lib/url_categorise/version.rb +1 -1
- data/lib/url_categorise.rb +14 -14
- data/lists/blogging_domains.hosts +62 -0
- data/lists/crypto_domains.hosts +46 -0
- data/lists/developer_domains.hosts +108 -0
- data/lists/forum_domains.hosts +65 -0
- data/lists/gaming_domains.hosts +99 -0
- data/lists/messaging_domains.hosts +96 -0
- data/lists/music_domains.hosts +62 -0
- data/lists/security_domains.hosts +48 -0
- data/lists/social_media_domains.hosts +443 -0
- data/lists/streaming_domains.hosts +41 -0
- data/lists/video_hosting_domains.hosts +203 -175
- data/lists/video_url_patterns.txt +15 -15
- data/url_categorise.gemspec +11 -9
- metadata +69 -20
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
require
|
|
2
|
-
require
|
|
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
|
-
|
|
11
|
+
"v2"
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def self.api_version
|
|
14
|
-
|
|
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: [
|
|
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] : [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
host = host.gsub('www.', '')
|
|
104
|
+
return [] if url.nil? || (url.respond_to?(:strip) && url.strip.empty?)
|
|
69
105
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
114
|
+
host = host.gsub("www.", "")
|
|
115
|
+
debug_log("📌 Extracted host: #{host}")
|
|
75
116
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 & [
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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[
|
|
477
|
+
puts " Redirects to: #{response.headers['location']}" if response.headers["location"]
|
|
255
478
|
successful_lists[category] << url
|
|
256
479
|
when 404
|
|
257
|
-
puts
|
|
258
|
-
unreachable_lists[category] << { url: url, error:
|
|
480
|
+
puts "❌ Not Found (404)"
|
|
481
|
+
unreachable_lists[category] << { url: url, error: "404 Not Found" }
|
|
259
482
|
when 403
|
|
260
|
-
puts
|
|
261
|
-
unreachable_lists[category] << { url: url, error:
|
|
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
|
|
271
|
-
unreachable_lists[category] << { url: url, error:
|
|
493
|
+
puts "❌ Timeout"
|
|
494
|
+
unreachable_lists[category] << { url: url, error: "Request timeout" }
|
|
272
495
|
rescue SocketError => e
|
|
273
|
-
puts
|
|
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
|
|
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" +
|
|
294
|
-
puts
|
|
295
|
-
puts
|
|
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" +
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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 || (
|
|
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,
|
|
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,
|
|
437
|
-
File.open(summary_path,
|
|
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 |
|
|
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
|
|
464
|
-
|
|
465
|
-
export_dir = output_path || (
|
|
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(
|
|
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,
|
|
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 =
|
|
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?(
|
|
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
|
-
|
|
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?(
|
|
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?(
|
|
851
|
+
elsif regex_patterns_file.start_with?("file://")
|
|
552
852
|
# Local file URL
|
|
553
|
-
file_path = regex_patterns_file.sub(
|
|
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
|
|
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 & [
|
|
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 |
|
|
872
|
+
@regex_patterns.each do |_category, patterns|
|
|
573
873
|
patterns.each do |pattern_info|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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) ?
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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[
|
|
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[
|
|
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,
|
|
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[
|
|
645
|
-
cached_result[
|
|
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[
|
|
648
|
-
enhanced_entry[
|
|
649
|
-
enhanced_entry[
|
|
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[
|
|
658
|
-
enhanced_entry[
|
|
659
|
-
enhanced_entry[
|
|
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[
|
|
714
|
-
dataset_entries = all_data.select { |entry| entry[
|
|
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 +
|
|
750
|
-
cache_path: config[:cache_path] || cache_dir&.+(File::SEPARATOR +
|
|
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[
|
|
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[
|
|
780
|
-
|
|
781
|
-
metadata = processed_result[
|
|
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
|
|
784
|
-
|
|
785
|
-
metadata =
|
|
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
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
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
|
-
|
|
907
|
-
|
|
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
|
|
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
|
-
(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
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?(
|
|
1650
|
+
if url.start_with?("file://")
|
|
1021
1651
|
# Handle local file URLs
|
|
1022
|
-
file_path = url.sub(
|
|
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:
|
|
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[
|
|
1043
|
-
last_modified = raw_data.headers[
|
|
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:
|
|
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:
|
|
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(/^\|\|/,
|
|
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?(
|
|
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) +
|
|
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[
|
|
1149
|
-
remote_last_modified = head_response.headers[
|
|
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?(
|
|
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
|