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.
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'fileutils'
8
+ require 'time'
9
+ require 'set'
10
+
11
+ module VideoListGenerator
12
+ class FocusedExtractorParser
13
+ # Focus on the most popular video hosting platforms first
14
+ PRIORITY_EXTRACTORS = [
15
+ 'youtube.py',
16
+ 'vimeo.py',
17
+ 'dailymotion.py',
18
+ 'twitch.py',
19
+ 'tiktok.py',
20
+ 'instagram.py',
21
+ 'facebook.py',
22
+ 'twitter.py',
23
+ 'reddit.py',
24
+ 'streamable.py',
25
+ 'wistia.py',
26
+ 'brightcove.py',
27
+ 'jwplatform.py',
28
+ 'kaltura.py',
29
+ 'vidyard.py',
30
+ 'vzaar.py',
31
+ 'soundcloud.py',
32
+ 'mixcloud.py',
33
+ 'bandcamp.py',
34
+ 'peertube.py',
35
+ 'rumble.py',
36
+ 'odysee.py',
37
+ 'bitchute.py'
38
+ ].freeze
39
+
40
+ YT_DLP_API_URL = 'https://api.github.com/repos/yt-dlp/yt-dlp/contents/yt_dlp/extractor'
41
+
42
+ def initialize
43
+ @video_domains = Set.new
44
+ @video_regexes = []
45
+ @manual_domains = get_manual_video_domains
46
+ end
47
+
48
+ def generate_lists
49
+ puts "🎬 Generating focused video hosting lists..."
50
+
51
+ puts "📝 Adding manually curated video hosting domains..."
52
+ @manual_domains.each { |domain| @video_domains.add(domain) }
53
+
54
+ puts "📡 Fetching priority yt-dlp extractors..."
55
+ fetch_priority_extractors
56
+
57
+ create_lists_directory
58
+ generate_hosts_file
59
+ generate_regex_file
60
+
61
+ puts "✅ Video hosting lists generated successfully!"
62
+ puts "📁 Files created in lists/ directory:"
63
+ puts " - video_hosting_domains.hosts (PiHole compatible)"
64
+ puts " - video_url_patterns.txt (Regex patterns for video detection)"
65
+ end
66
+
67
+ private
68
+
69
+ def get_manual_video_domains
70
+ # Manually curated list of major video hosting domains
71
+ [
72
+ 'youtube.com', 'youtu.be', 'youtube-nocookie.com',
73
+ 'vimeo.com', 'player.vimeo.com',
74
+ 'dailymotion.com', 'dai.ly',
75
+ 'twitch.tv', 'clips.twitch.tv',
76
+ 'tiktok.com', 'vm.tiktok.com',
77
+ 'instagram.com', 'instagr.am',
78
+ 'facebook.com', 'fb.com', 'fb.watch',
79
+ 'twitter.com', 'x.com', 't.co',
80
+ 'reddit.com', 'redd.it', 'v.redd.it',
81
+ 'streamable.com',
82
+ 'wistia.com', 'fast.wistia.com', 'fast.wistia.net',
83
+ 'brightcove.com', 'bcove.video',
84
+ 'jwplatform.com', 'jwpcdn.com',
85
+ 'kaltura.com',
86
+ 'vidyard.com',
87
+ 'vzaar.com',
88
+ 'soundcloud.com', 'snd.sc',
89
+ 'mixcloud.com',
90
+ 'bandcamp.com',
91
+ 'rumble.com',
92
+ 'odysee.com', 'lbry.tv',
93
+ 'bitchute.com',
94
+ 'peertube.tv',
95
+ 'archive.org',
96
+ 'vevo.com',
97
+ 'liveleak.com',
98
+ 'metacafe.com',
99
+ 'veoh.com',
100
+ 'break.com',
101
+ 'crunchyroll.com',
102
+ 'funimation.com',
103
+ 'vrv.co',
104
+ 'roosterteeth.com',
105
+ 'newgrounds.com',
106
+ 'coub.com',
107
+ 'vidme.com',
108
+ 'vid.me',
109
+ 'gfycat.com',
110
+ 'imgur.com',
111
+ 'giphy.com',
112
+ 'tenor.com'
113
+ ]
114
+ end
115
+
116
+ def fetch_priority_extractors
117
+ uri = URI(YT_DLP_API_URL)
118
+ response = Net::HTTP.get_response(uri)
119
+
120
+ unless response.code == '200'
121
+ puts "⚠️ Failed to fetch extractor list from GitHub API, using manual domains only"
122
+ return
123
+ end
124
+
125
+ files = JSON.parse(response.body)
126
+
127
+ # Filter to priority extractor files
128
+ priority_files = files.select { |file| PRIORITY_EXTRACTORS.include?(file['name']) }
129
+
130
+ puts "📝 Processing #{priority_files.length} priority extractor files..."
131
+
132
+ priority_files.each_with_index do |file, index|
133
+ puts "Processing #{index + 1}/#{priority_files.length}: #{file['name']}"
134
+ process_extractor_file(file)
135
+ end
136
+
137
+ puts "🎯 Found #{@video_domains.size} unique video hosting domains"
138
+ puts "🔍 Extracted #{@video_regexes.size} URL pattern regexes"
139
+ end
140
+
141
+ def process_extractor_file(file_info)
142
+ begin
143
+ response = Net::HTTP.get_response(URI(file_info['download_url']))
144
+ return unless response.code == '200'
145
+
146
+ content = response.body
147
+
148
+ extract_domains_from_content(content, file_info['name'])
149
+ extract_regexes_from_content(content, file_info['name'])
150
+ rescue StandardError => e
151
+ puts "⚠️ Error processing #{file_info['name']}: #{e.message}"
152
+ end
153
+ end
154
+
155
+ def extract_domains_from_content(content, filename)
156
+ # Look for _VALID_URL patterns
157
+ valid_url_patterns = content.scan(/_VALID_URL\s*=\s*r?['"]([^'"]+)['"]/m)
158
+
159
+ valid_url_patterns.each do |pattern|
160
+ pattern = pattern[0] if pattern.is_a?(Array)
161
+
162
+ domains = extract_domains_from_regex(pattern)
163
+ domains.each { |domain| @video_domains.add(domain) }
164
+ end
165
+
166
+ # Also look for explicit domain lists or IE_NAME patterns
167
+ ie_name_patterns = content.scan(/IE_NAME\s*=\s*['"]([^'"]+)['"]/m)
168
+ ie_name_patterns.each do |name|
169
+ name = name[0] if name.is_a?(Array)
170
+ next unless name
171
+
172
+ # Many IE_NAME values correspond to domain names
173
+ if name.include?('.') && valid_domain?(name)
174
+ @video_domains.add(name.downcase)
175
+ end
176
+ end
177
+ end
178
+
179
+ def extract_regexes_from_content(content, filename)
180
+ # Extract _VALID_URL patterns for video detection
181
+ valid_url_patterns = content.scan(/_VALID_URL\s*=\s*r?['"]([^'"]+)['"]/m)
182
+
183
+ valid_url_patterns.each do |pattern|
184
+ pattern = pattern[0] if pattern.is_a?(Array)
185
+
186
+ cleaned_pattern = clean_regex_pattern(pattern)
187
+ if cleaned_pattern && !cleaned_pattern.empty?
188
+ @video_regexes << {
189
+ source: filename.gsub('.py', ''),
190
+ pattern: cleaned_pattern,
191
+ original: pattern,
192
+ description: generate_pattern_description(pattern, filename)
193
+ }
194
+ end
195
+ end
196
+ end
197
+
198
+ def extract_domains_from_regex(pattern)
199
+ domains = Set.new
200
+
201
+ # Various patterns to extract domains from regex
202
+ # Pattern: https?://(?:www\.)?domain\.com
203
+ domain_matches = pattern.scan(/https?:\/\/[^\/]*?([a-zA-Z0-9-]+\.[a-zA-Z]{2,})/i)
204
+ domain_matches.each do |match|
205
+ domain = match[0] if match.is_a?(Array)
206
+ domain = clean_domain(domain) if domain
207
+ domains.add(domain.downcase) if domain && valid_domain?(domain)
208
+ end
209
+
210
+ # Pattern: (?:www\.)?domain\.com
211
+ simple_matches = pattern.scan(/(?:\(\?\:www\\\.\)\?)?([a-zA-Z0-9-]+\.[a-zA-Z]{2,})/i)
212
+ simple_matches.each do |match|
213
+ domain = match[0] if match.is_a?(Array)
214
+ domain = clean_domain(domain) if domain
215
+ domains.add(domain.downcase) if domain && valid_domain?(domain)
216
+ end
217
+
218
+ domains.to_a
219
+ end
220
+
221
+ def clean_domain(domain)
222
+ return nil unless domain
223
+
224
+ # Remove common regex escapes
225
+ cleaned = domain.gsub(/\\\./, '.').gsub(/[\(\)\?\:\|\\]/, '')
226
+ cleaned = cleaned.strip
227
+
228
+ # Remove any trailing regex syntax
229
+ cleaned = cleaned.split(/[\(\)\[\]]/)[0] if cleaned.include?('(') || cleaned.include?('[')
230
+
231
+ cleaned
232
+ end
233
+
234
+ def clean_regex_pattern(pattern)
235
+ return nil unless pattern
236
+
237
+ # Simplify common patterns for practical use
238
+ cleaned = pattern.dup
239
+
240
+ # Remove complex lookaheads/lookbehinds
241
+ cleaned = cleaned.gsub(/\(\?\![^)]*\)/, '')
242
+ cleaned = cleaned.gsub(/\(\?\<\![^)]*\)/, '')
243
+
244
+ # Simplify common patterns
245
+ cleaned = cleaned.gsub(/\(\?\:www\\\.\)\?/, '(?:www\.)?')
246
+ cleaned = cleaned.gsub(/\\\./, '\.')
247
+
248
+ # Validate that it's a reasonable regex
249
+ begin
250
+ Regexp.new(cleaned)
251
+ cleaned
252
+ rescue RegexpError
253
+ # Return a simplified version if the complex regex fails
254
+ extract_simple_pattern(pattern)
255
+ end
256
+ end
257
+
258
+ def extract_simple_pattern(pattern)
259
+ # Extract a simple domain pattern from complex regex
260
+ if pattern.include?('https?://')
261
+ # Extract the basic domain part
262
+ match = pattern.match(/https?:\/\/[^\/]*?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i)
263
+ if match
264
+ return "https?://[^/]*#{Regexp.escape(match[1])}"
265
+ end
266
+ end
267
+
268
+ nil
269
+ end
270
+
271
+ def generate_pattern_description(pattern, filename)
272
+ service = filename.gsub('.py', '').capitalize
273
+
274
+ case filename
275
+ when 'youtube.py'
276
+ 'YouTube video, playlist, and channel URLs'
277
+ when 'vimeo.py'
278
+ 'Vimeo video and album URLs'
279
+ when 'twitch.py'
280
+ 'Twitch streams, clips, and VODs'
281
+ when 'tiktok.py'
282
+ 'TikTok video URLs'
283
+ else
284
+ "#{service} video content URLs"
285
+ end
286
+ end
287
+
288
+ def valid_domain?(domain)
289
+ return false unless domain
290
+ return false if domain.length < 4
291
+ return false unless domain.match?(/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
292
+ return false if domain.include?('example.') || domain.include?('test.')
293
+ return false if domain.include?('localhost')
294
+ true
295
+ end
296
+
297
+ def create_lists_directory
298
+ FileUtils.mkdir_p('lists')
299
+ end
300
+
301
+ def generate_hosts_file
302
+ hosts_file_path = 'lists/video_hosting_domains.hosts'
303
+
304
+ File.open(hosts_file_path, 'w') do |file|
305
+ write_hosts_header(file)
306
+
307
+ @video_domains.sort.each do |domain|
308
+ file.puts "0.0.0.0 #{domain}"
309
+ file.puts "0.0.0.0 www.#{domain}" unless domain.start_with?('www.')
310
+ end
311
+ end
312
+
313
+ puts "📝 Generated hosts file: #{hosts_file_path} (#{@video_domains.size} domains)"
314
+ end
315
+
316
+ def generate_regex_file
317
+ regex_file_path = 'lists/video_url_patterns.txt'
318
+
319
+ File.open(regex_file_path, 'w') do |file|
320
+ write_regex_header(file)
321
+
322
+ @video_regexes.each do |regex_info|
323
+ file.puts "# Source: #{regex_info[:source]}"
324
+ file.puts "# Description: #{regex_info[:description]}"
325
+ file.puts "# Pattern: #{regex_info[:pattern]}"
326
+ file.puts "# Original: #{regex_info[:original]}"
327
+ file.puts regex_info[:pattern]
328
+ file.puts ""
329
+ end
330
+ end
331
+
332
+ puts "📝 Generated regex file: #{regex_file_path} (#{@video_regexes.size} patterns)"
333
+ end
334
+
335
+ def write_hosts_header(file)
336
+ file.puts "# Video Hosting Domains - PiHole Compatible Hosts File"
337
+ file.puts "# Generated on: #{Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')}"
338
+ file.puts "# Source: yt-dlp extractors + manual curation"
339
+ file.puts "# Purpose: Block or categorize video hosting websites"
340
+ file.puts "# Format: 0.0.0.0 domain.com"
341
+ file.puts "#"
342
+ file.puts "# This file contains domains from popular video hosting platforms"
343
+ file.puts "# extracted from yt-dlp video extractors and manually curated lists."
344
+ file.puts "# Use this for URL categorization or content filtering."
345
+ file.puts "#"
346
+ file.puts "# Generated by UrlCategorise gem: https://github.com/trex22/url_categorise"
347
+ file.puts "#"
348
+ file.puts ""
349
+ end
350
+
351
+ def write_regex_header(file)
352
+ file.puts "# Video URL Detection Patterns"
353
+ file.puts "# Generated on: #{Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')}"
354
+ file.puts "# Source: yt-dlp extractors + manual curation"
355
+ file.puts "# Purpose: Regex patterns to detect video URLs vs other content"
356
+ file.puts "#"
357
+ file.puts "# These patterns help distinguish between:"
358
+ file.puts "# - Direct video content URLs (matches these patterns)"
359
+ file.puts "# - Homepage, playlist, user profile, community content URLs"
360
+ file.puts "#"
361
+ file.puts "# Usage: Use these patterns to categorize URLs from video hosting domains"
362
+ file.puts "# to determine if they contain actual video content or other resources"
363
+ file.puts "#"
364
+ file.puts "# Each pattern includes:"
365
+ file.puts "# - Source extractor name"
366
+ file.puts "# - Human-readable description"
367
+ file.puts "# - Simplified regex pattern"
368
+ file.puts "# - Original complex pattern (for reference)"
369
+ file.puts "#"
370
+ file.puts "# Generated by UrlCategorise gem: https://github.com/trex22/url_categorise"
371
+ file.puts "#"
372
+ file.puts ""
373
+ end
374
+ end
375
+ end
376
+
377
+ # Run the generator if this file is executed directly
378
+ if __FILE__ == $0
379
+ generator = VideoListGenerator::FocusedExtractorParser.new
380
+ generator.generate_lists
381
+ end
@@ -3,11 +3,11 @@
3
3
  require 'bundler/setup'
4
4
  require './lib/url_categorise'
5
5
 
6
- puts "=== Large Dataset Loading Example ==="
6
+ puts '=== Large Dataset Loading Example ==='
7
7
 
8
8
  # Configuration for handling large datasets (300+ MB)
9
9
  # First test with cache-only mode
10
- puts "Creating client with cached datasets only..."
10
+ puts 'Creating client with cached datasets only...'
11
11
  client = UrlCategorise::Client.new(
12
12
  cache_dir: './url_cache',
13
13
  auto_load_datasets: true,
@@ -19,46 +19,46 @@ client = UrlCategorise::Client.new(
19
19
  }
20
20
  )
21
21
 
22
- puts "Client created successfully!"
23
- puts ""
24
- puts "Dataset Statistics:"
22
+ puts 'Client created successfully!'
23
+ puts ''
24
+ puts 'Dataset Statistics:'
25
25
  puts " Total categories: #{client.count_of_categories}"
26
26
  puts " Dataset categories: #{client.count_of_dataset_categories}"
27
27
  puts " Blocklist categories: #{client.count_of_categories - client.count_of_dataset_categories}"
28
- puts ""
28
+ puts ''
29
29
  puts " Total hosts: #{client.count_of_hosts.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
30
30
  puts " Dataset hosts: #{client.count_of_dataset_hosts.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
31
- puts ""
31
+ puts ''
32
32
  puts " Total data size: #{client.size_of_data.round(1)} MB"
33
- puts " Dataset data size: #{client.size_of_dataset_data.round(1)} MB"
33
+ puts " Dataset data size: #{client.size_of_dataset_data.round(1)} MB"
34
34
  puts " Blocklist data size: #{client.size_of_blocklist_data.round(1)} MB"
35
35
 
36
- puts ""
37
- puts "Dataset-specific Statistics:"
36
+ puts ''
37
+ puts 'Dataset-specific Statistics:'
38
38
  # Get dataset metadata if available
39
39
  metadata = client.dataset_metadata
40
40
  if metadata && !metadata.empty?
41
41
  puts " Datasets loaded: #{metadata.size}"
42
-
42
+
43
43
  # Calculate size for each dataset by finding its categories and domains
44
- dataset_categories = client.instance_variable_get(:@dataset_categories)
44
+ client.instance_variable_get(:@dataset_categories)
45
45
  total_dataset_size = 0
46
-
46
+
47
47
  metadata.each_with_index do |(hash, data), index|
48
48
  # Estimate size contribution of this dataset
49
49
  dataset_portion = data[:total_entries].to_f / metadata.values.sum { |d| d[:total_entries] }
50
50
  dataset_size_mb = (client.size_of_dataset_data * dataset_portion).round(2)
51
51
  total_dataset_size += dataset_size_mb
52
-
52
+
53
53
  puts " Dataset #{index + 1}:"
54
54
  puts " Processed at: #{data[:processed_at]}"
55
55
  puts " Total entries: #{data[:total_entries].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
56
56
  puts " Estimated size: #{dataset_size_mb} MB"
57
57
  puts " Data hash: #{hash[0..12]}..."
58
58
  end
59
-
60
- puts ""
59
+
60
+ puts ''
61
61
  puts " Total dataset size: #{total_dataset_size.round(2)} MB (#{client.size_of_dataset_data.round(1)} MB actual)"
62
62
  else
63
- puts " No dataset metadata available"
64
- end
63
+ puts ' No dataset metadata available'
64
+ end
@@ -1,9 +1,9 @@
1
- require_relative 'models'
1
+ require_relative "models"
2
2
 
3
3
  module UrlCategorise
4
4
  class ActiveRecordClient < Client
5
5
  def initialize(**kwargs)
6
- raise 'ActiveRecord not available' unless UrlCategorise::Models.available?
6
+ raise "ActiveRecord not available" unless UrlCategorise::Models.available?
7
7
 
8
8
  @use_database = kwargs.delete(:use_database) { true }
9
9
  super(**kwargs)
@@ -14,7 +14,7 @@ module UrlCategorise
14
14
  def categorise(url)
15
15
  return super(url) unless @use_database && UrlCategorise::Models.available?
16
16
 
17
- host = (URI.parse(url).host || url).downcase.gsub('www.', '')
17
+ host = (URI.parse(url).host || url).downcase.gsub("www.", "")
18
18
 
19
19
  # Try database first
20
20
  categories = UrlCategorise::Models::Domain.categorise(host)
@@ -59,7 +59,7 @@ module UrlCategorise
59
59
  # Store dataset metadata in database if enabled
60
60
  if @use_database && UrlCategorise::Models.available? && @dataset_metadata
61
61
  store_dataset_metadata_in_db(
62
- source_type: 'kaggle',
62
+ source_type: "kaggle",
63
63
  identifier: "#{dataset_owner}/#{dataset_name}",
64
64
  metadata: @dataset_metadata.values.last,
65
65
  category_mappings: options[:category_mappings],
@@ -79,7 +79,7 @@ module UrlCategorise
79
79
  # Store dataset metadata in database if enabled
80
80
  if @use_database && UrlCategorise::Models.available? && @dataset_metadata
81
81
  store_dataset_metadata_in_db(
82
- source_type: 'csv',
82
+ source_type: "csv",
83
83
  identifier: url,
84
84
  metadata: @dataset_metadata.values.last,
85
85
  category_mappings: options[:category_mappings],
@@ -125,7 +125,7 @@ module UrlCategorise
125
125
  metadata = @metadata[url] || {}
126
126
  UrlCategorise::Models::ListMetadata.find_or_create_by(url: url) do |record|
127
127
  record.name = category.to_s
128
- record.categories = [category.to_s]
128
+ record.categories = [ category.to_s ]
129
129
  record.file_hash = metadata[:content_hash]
130
130
  record.fetched_at = metadata[:last_updated]
131
131
  end
@@ -140,12 +140,12 @@ module UrlCategorise
140
140
  existing = UrlCategorise::Models::Domain.find_by(domain: domain)
141
141
  if existing
142
142
  # Add category if not already present
143
- categories = existing.categories | [category.to_s]
143
+ categories = existing.categories | [ category.to_s ]
144
144
  existing.update(categories: categories) if categories != existing.categories
145
145
  else
146
146
  UrlCategorise::Models::Domain.create!(
147
147
  domain: domain,
148
- categories: [category.to_s]
148
+ categories: [ category.to_s ]
149
149
  )
150
150
  end
151
151
  end
@@ -163,12 +163,12 @@ module UrlCategorise
163
163
 
164
164
  existing = UrlCategorise::Models::IpAddress.find_by(ip_address: ip)
165
165
  if existing
166
- categories = existing.categories | [category.to_s]
166
+ categories = existing.categories | [ category.to_s ]
167
167
  existing.update(categories: categories) if categories != existing.categories
168
168
  else
169
169
  UrlCategorise::Models::IpAddress.create!(
170
170
  ip_address: ip,
171
- categories: [category.to_s]
171
+ categories: [ category.to_s ]
172
172
  )
173
173
  end
174
174
  end
@@ -189,7 +189,7 @@ module UrlCategorise
189
189
  end
190
190
  rescue ActiveRecord::RecordInvalid => e
191
191
  # Dataset metadata already exists or validation failed
192
- puts "Warning: Failed to store dataset metadata: #{e.message}" if ENV['DEBUG']
192
+ puts "Warning: Failed to store dataset metadata: #{e.message}" if ENV["DEBUG"]
193
193
  end
194
194
  end
195
195
  end