ruby-link-checker 0.3.0 → 0.5.1
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/.github/workflows/coverage.yml +24 -0
- data/.github/workflows/danger-comment.yml +17 -0
- data/.github/workflows/danger.yml +12 -0
- data/.github/workflows/lint.yml +20 -0
- data/.github/workflows/release.yml +14 -0
- data/.github/workflows/test.yml +25 -0
- data/.gitignore +6 -0
- data/.rspec +2 -0
- data/.rubocop.yml +26 -0
- data/.rubocop_todo.yml +332 -0
- data/AGENTS.md +23 -0
- data/CHANGELOG.md +27 -0
- data/CONTRIBUTING.md +4 -0
- data/Dangerfile +2 -0
- data/Gemfile +5 -1
- data/README.md +151 -7
- data/RELEASING.md +17 -40
- data/Rakefile +8 -0
- data/benchmarks/check.rb +27 -20
- data/lib/ruby-link-checker/async/http/checker.rb +118 -0
- data/lib/ruby-link-checker/async/http/config.rb +39 -0
- data/lib/ruby-link-checker/async/http/result.rb +85 -0
- data/lib/ruby-link-checker/async/http.rb +3 -0
- data/lib/ruby-link-checker/cache.rb +101 -0
- data/lib/ruby-link-checker/callbacks.rb +2 -2
- data/lib/ruby-link-checker/checker.rb +76 -4
- data/lib/ruby-link-checker/config.rb +64 -0
- data/lib/ruby-link-checker/errors/connection_error.rb +23 -0
- data/lib/ruby-link-checker/errors/redirect_limit_error.rb +15 -0
- data/lib/ruby-link-checker/errors.rb +2 -0
- data/lib/ruby-link-checker/net/http/checker.rb +4 -1
- data/lib/ruby-link-checker/net/http/result.rb +4 -0
- data/lib/ruby-link-checker/result.rb +25 -0
- data/lib/ruby-link-checker/task.rb +2 -0
- data/lib/ruby-link-checker/tasks.rb +43 -7
- data/lib/ruby-link-checker/typhoeus/hydra/checker.rb +29 -6
- data/lib/ruby-link-checker/typhoeus/hydra/result.rb +4 -0
- data/lib/ruby-link-checker/version.rb +1 -1
- data/lib/ruby-link-checker.rb +2 -0
- data/ruby-link-checker.gemspec +3 -2
- data/spec/ruby-link-checker/async/http/checker_spec.rb +184 -0
- data/spec/ruby-link-checker/callbacks_spec.rb +33 -0
- data/spec/ruby-link-checker/checker_spec.rb +89 -2
- data/spec/ruby-link-checker/config_spec.rb +6 -0
- data/spec/ruby-link-checker/errors/redirect_loop_error_spec.rb +18 -0
- data/spec/ruby-link-checker/net/http/checker_spec.rb +32 -0
- data/spec/ruby-link-checker/result_error_spec.rb +34 -0
- data/spec/ruby-link-checker/result_spec.rb +41 -0
- data/spec/ruby-link-checker/task_spec.rb +20 -0
- data/spec/ruby-link-checker/tasks_spec.rb +91 -0
- data/spec/ruby-link-checker/typhoeus/hydra/checker_spec.rb +116 -3
- data/spec/spec_helper.rb +3 -1
- data/spec/support/link_checker.rb +267 -3
- metadata +42 -35
- data/Gemfile.lock +0 -215
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_asc.png +0 -0
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_asc_disabled.png +0 -0
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_both.png +0 -0
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_desc.png +0 -0
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_desc_disabled.png +0 -0
- data/coverage/assets/0.13.1/application.css +0 -1
- data/coverage/assets/0.13.1/application.js +0 -7
- data/coverage/assets/0.13.1/colorbox/border.png +0 -0
- data/coverage/assets/0.13.1/colorbox/controls.png +0 -0
- data/coverage/assets/0.13.1/colorbox/loading.gif +0 -0
- data/coverage/assets/0.13.1/colorbox/loading_background.png +0 -0
- data/coverage/assets/0.13.1/favicon_green.png +0 -0
- data/coverage/assets/0.13.1/favicon_red.png +0 -0
- data/coverage/assets/0.13.1/favicon_yellow.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_flat_0_aaaaaa_40x100.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_flat_75_ffffff_40x100.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_55_fbf9ee_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_75_dadada_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_75_e6e6e6_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_95_fef1ec_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_highlight-soft_75_cccccc_1x100.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_222222_256x240.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_2e83ff_256x240.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_454545_256x240.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_888888_256x240.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_cd0a0a_256x240.png +0 -0
- data/coverage/assets/0.13.1/loading.gif +0 -0
- data/coverage/assets/0.13.1/magnify.png +0 -0
- data/coverage/index.html +0 -15014
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'tempfile'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
module LinkChecker
|
|
10
|
+
class CachedResult < Result
|
|
11
|
+
attr_reader :code
|
|
12
|
+
|
|
13
|
+
def initialize(data, uri)
|
|
14
|
+
@code = data.fetch('code')
|
|
15
|
+
super(URI(data.fetch('result_uri')), data.fetch('method'), URI(uri.to_s))
|
|
16
|
+
@cached = true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def success?
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class Cache
|
|
25
|
+
VERSION = 1
|
|
26
|
+
|
|
27
|
+
def initialize(options, logger:)
|
|
28
|
+
unless options.is_a?(Hash) && options[:file].is_a?(String) && !options[:file].strip.empty?
|
|
29
|
+
raise ArgumentError, 'Cache requires a file path'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@ttl = options[:ttl]
|
|
33
|
+
unless @ttl.is_a?(Numeric) && @ttl.real? && @ttl.finite? && @ttl.positive?
|
|
34
|
+
raise ArgumentError, 'Cache requires a positive, finite TTL in seconds'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@file = File.expand_path(options[:file])
|
|
38
|
+
@logger = logger
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fetch(key, uri)
|
|
42
|
+
data = read_entries[key]
|
|
43
|
+
return unless fresh?(data) && data['code'].is_a?(Integer) && data['code'].between?(200, 299)
|
|
44
|
+
return unless data['method'].is_a?(String) && data['result_uri'].is_a?(String)
|
|
45
|
+
|
|
46
|
+
parsed = URI(data['result_uri'])
|
|
47
|
+
return unless %w[http https].include?(parsed.scheme) && parsed.host && !parsed.userinfo
|
|
48
|
+
|
|
49
|
+
CachedResult.new(data, uri)
|
|
50
|
+
rescue URI::InvalidURIError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def store(key, result)
|
|
55
|
+
return unless result.success?
|
|
56
|
+
return if URI(result.result_uri.to_s).userinfo
|
|
57
|
+
|
|
58
|
+
directory = File.dirname(@file)
|
|
59
|
+
FileUtils.mkdir_p(directory)
|
|
60
|
+
File.open("#{@file}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
61
|
+
lock.flock(File::LOCK_EX)
|
|
62
|
+
entries = read_entries.select { |_entry_key, data| fresh?(data) }
|
|
63
|
+
entries[key] = {
|
|
64
|
+
'checked_at' => Time.now.to_f,
|
|
65
|
+
'method' => result.method,
|
|
66
|
+
'code' => result.code,
|
|
67
|
+
'result_uri' => result.result_uri.to_s
|
|
68
|
+
}
|
|
69
|
+
Tempfile.create(['link-checker-', '.json'], directory) do |file|
|
|
70
|
+
file.write(JSON.generate('version' => VERSION, 'entries' => entries))
|
|
71
|
+
file.flush
|
|
72
|
+
File.rename(file.path, @file)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
rescue IOError, SystemCallError, JSON::GeneratorError
|
|
76
|
+
@logger.warn("Unable to write link cache #{@file}; result was not cached")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def fresh?(data)
|
|
82
|
+
return false unless data.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
timestamp = data['checked_at']
|
|
85
|
+
now = Time.now.to_f
|
|
86
|
+
timestamp.is_a?(Numeric) && timestamp.finite? && timestamp <= now && now - timestamp < @ttl
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def read_entries
|
|
90
|
+
data = JSON.parse(File.read(@file))
|
|
91
|
+
return {} unless data.is_a?(Hash) && data['version'] == VERSION && data['entries'].is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
data['entries']
|
|
94
|
+
rescue Errno::ENOENT
|
|
95
|
+
{}
|
|
96
|
+
rescue IOError, SystemCallError, JSON::ParserError
|
|
97
|
+
@logger.warn("Unable to read link cache #{@file}; checking links normally")
|
|
98
|
+
{}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -6,15 +6,17 @@ module LinkChecker
|
|
|
6
6
|
include LinkChecker::Callbacks
|
|
7
7
|
|
|
8
8
|
attr_reader :results
|
|
9
|
-
attr_accessor(*Config::ATTRIBUTES)
|
|
10
9
|
|
|
11
10
|
def initialize(options = {})
|
|
12
11
|
LinkChecker::Config::ATTRIBUTES.each do |key|
|
|
13
|
-
send("#{key}=", options
|
|
12
|
+
send("#{key}=", options.fetch(key) { LinkChecker.config.send(key) })
|
|
14
13
|
end
|
|
15
14
|
raise ArgumentError, "Missing methods." if methods&.none?
|
|
16
15
|
@logger ||= options[:logger] || LinkChecker::Config.logger || LinkChecker::Logger.default
|
|
17
16
|
@results = { error: [], failure: [], success: [] } unless options.key?(:results) && !options[:results]
|
|
17
|
+
@pending = {}
|
|
18
|
+
@successful = {}
|
|
19
|
+
@cache_store = Cache.new(cache, logger: @logger) if cache
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def task_klass
|
|
@@ -25,6 +27,37 @@ module LinkChecker
|
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def check(uri, options = {})
|
|
30
|
+
return execute_check(uri, options) unless deduplicate || @cache_store
|
|
31
|
+
|
|
32
|
+
key = request_key(uri)
|
|
33
|
+
return execute_check(uri, options) unless key
|
|
34
|
+
|
|
35
|
+
subscriber = [uri.to_s.dup, options]
|
|
36
|
+
if deduplicate && (entry = @pending[key])
|
|
37
|
+
entry[:subscribers] << subscriber
|
|
38
|
+
entry[:events].each { |event, result| publish(event, copy_result(result, subscriber)) }
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
cached_result = @successful[key] if deduplicate
|
|
43
|
+
cached_result ||= @cache_store&.fetch(key, uri)
|
|
44
|
+
if cached_result
|
|
45
|
+
@successful[key] ||= cached_result if deduplicate
|
|
46
|
+
result = copy_result(cached_result, subscriber)
|
|
47
|
+
result.cached = true
|
|
48
|
+
publish(:result, result)
|
|
49
|
+
publish(:success, result)
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
entry = { subscribers: [subscriber], events: [] }
|
|
54
|
+
@pending[key] = entry if deduplicate
|
|
55
|
+
execute_check(uri, options) { |event, result| handle_shared_result(key, entry, uri, event, result) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def execute_check(uri, options, &handler)
|
|
28
61
|
tasks = Tasks.new(
|
|
29
62
|
self,
|
|
30
63
|
task_klass,
|
|
@@ -33,10 +66,49 @@ module LinkChecker
|
|
|
33
66
|
options
|
|
34
67
|
)
|
|
35
68
|
tasks.on do |event, *args|
|
|
36
|
-
|
|
37
|
-
callback event, *args
|
|
69
|
+
handler ? handler.call(event, *args) : publish(event, args.first)
|
|
38
70
|
end
|
|
39
71
|
tasks.execute!
|
|
40
72
|
end
|
|
73
|
+
|
|
74
|
+
def publish(event, result)
|
|
75
|
+
result.checker = self
|
|
76
|
+
results[event] << result if @results && %i[error failure success].include?(event)
|
|
77
|
+
callback(event, result)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle_shared_result(key, entry, uri, event, result)
|
|
81
|
+
entry[:events] << [event, result]
|
|
82
|
+
subscribers = entry[:subscribers].dup
|
|
83
|
+
if %i[error failure success].include?(event)
|
|
84
|
+
@pending.delete(key) if @pending[key].equal?(entry)
|
|
85
|
+
if event == :success && request_key(uri) == key
|
|
86
|
+
@successful[key] = result if deduplicate
|
|
87
|
+
@cache_store&.store(key, result)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
subscribers.each { |subscriber| publish(event, copy_result(result, subscriber)) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def copy_result(result, subscriber)
|
|
94
|
+
result.dup.tap do |copy|
|
|
95
|
+
copy.uri = URI(subscriber.first)
|
|
96
|
+
copy.result_uri = result.result_uri.dup
|
|
97
|
+
copy.options = subscriber.last
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def request_key(uri)
|
|
102
|
+
parsed = URI(uri.to_s)
|
|
103
|
+
return unless %w[http https].include?(parsed.scheme) && parsed.host && !parsed.userinfo
|
|
104
|
+
|
|
105
|
+
attributes = Config::ATTRIBUTES - %i[logger cache deduplicate]
|
|
106
|
+
attributes += %i[timeout connecttimeout read_timeout open_timeout protocol].select { |key| respond_to?(key) }
|
|
107
|
+
settings = attributes.to_h { |key| [key, send(key)] }
|
|
108
|
+
settings[:headers] = headers.sort_by { |key, _value| key.to_s }
|
|
109
|
+
Digest::SHA256.hexdigest(JSON.generate([task_klass.name, parsed.to_s, settings]))
|
|
110
|
+
rescue URI::InvalidURIError, JSON::GeneratorError, TypeError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
41
113
|
end
|
|
42
114
|
end
|
|
@@ -7,8 +7,18 @@ module LinkChecker
|
|
|
7
7
|
ATTRIBUTES = %i[
|
|
8
8
|
methods
|
|
9
9
|
user_agent
|
|
10
|
+
headers
|
|
10
11
|
logger
|
|
11
12
|
retries
|
|
13
|
+
max_redirects
|
|
14
|
+
retry_status_codes
|
|
15
|
+
retry_after
|
|
16
|
+
retry_backoff
|
|
17
|
+
retry_jitter
|
|
18
|
+
max_retry_delay
|
|
19
|
+
respect_retry_after
|
|
20
|
+
deduplicate
|
|
21
|
+
cache
|
|
12
22
|
].freeze
|
|
13
23
|
|
|
14
24
|
attr_accessor(*Config::ATTRIBUTES)
|
|
@@ -16,8 +26,18 @@ module LinkChecker
|
|
|
16
26
|
def reset
|
|
17
27
|
self.methods = %w[HEAD GET]
|
|
18
28
|
self.user_agent = "Ruby Link Checker/#{LinkChecker::VERSION}"
|
|
29
|
+
self.headers = {}
|
|
19
30
|
self.logger = nil
|
|
20
31
|
self.retries = 0
|
|
32
|
+
self.max_redirects = 20
|
|
33
|
+
self.retry_status_codes = [408, 425, 429, 500, 502, 503, 504]
|
|
34
|
+
self.retry_after = 0
|
|
35
|
+
self.retry_backoff = 1
|
|
36
|
+
self.retry_jitter = 0
|
|
37
|
+
self.max_retry_delay = 60
|
|
38
|
+
self.respect_retry_after = false
|
|
39
|
+
self.deduplicate = false
|
|
40
|
+
self.cache = nil
|
|
21
41
|
end
|
|
22
42
|
|
|
23
43
|
def retries=(value)
|
|
@@ -25,6 +45,50 @@ module LinkChecker
|
|
|
25
45
|
|
|
26
46
|
@retries = value
|
|
27
47
|
end
|
|
48
|
+
|
|
49
|
+
def max_redirects=(value)
|
|
50
|
+
raise ArgumentError, "Invalid redirect limit: #{value}" unless value.is_a?(Integer) && value >= 0
|
|
51
|
+
|
|
52
|
+
@max_redirects = value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def retry_status_codes=(value)
|
|
56
|
+
unless value.nil? || (value.is_a?(Array) && value.all? { |code| code.is_a?(Integer) && code.between?(100, 599) })
|
|
57
|
+
raise ArgumentError, "Invalid retry status codes: #{value}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@retry_status_codes = value&.dup
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def retry_after=(value)
|
|
64
|
+
validate_number!(:retry_after, value, 0)
|
|
65
|
+
@retry_after = value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def retry_backoff=(value)
|
|
69
|
+
validate_number!(:retry_backoff, value, 1)
|
|
70
|
+
@retry_backoff = value
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def retry_jitter=(value)
|
|
74
|
+
validate_number!(:retry_jitter, value, 0)
|
|
75
|
+
raise ArgumentError, "Invalid retry_jitter: #{value}" if value > 1
|
|
76
|
+
|
|
77
|
+
@retry_jitter = value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def max_retry_delay=(value)
|
|
81
|
+
validate_number!(:max_retry_delay, value, 0)
|
|
82
|
+
@max_retry_delay = value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def validate_number!(name, value, minimum)
|
|
88
|
+
return if value.is_a?(Numeric) && value.real? && value.finite? && value >= minimum
|
|
89
|
+
|
|
90
|
+
raise ArgumentError, "Invalid #{name}: #{value}"
|
|
91
|
+
end
|
|
28
92
|
end
|
|
29
93
|
|
|
30
94
|
class << self
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkChecker
|
|
4
|
+
module Errors
|
|
5
|
+
class ConnectionError < BaseError
|
|
6
|
+
TRANSIENT_CODES = %i[
|
|
7
|
+
couldnt_resolve_proxy couldnt_resolve_host couldnt_connect
|
|
8
|
+
partial_file got_nothing send_error recv_error http2 http2_stream
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :return_code
|
|
12
|
+
|
|
13
|
+
def initialize(return_code, message)
|
|
14
|
+
@return_code = return_code
|
|
15
|
+
super(message)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def error_type
|
|
19
|
+
TRANSIENT_CODES.include?(return_code) ? :connection : :other
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkChecker
|
|
4
|
+
module Errors
|
|
5
|
+
class RedirectLimitError < BaseError
|
|
6
|
+
attr_reader :limit, :urls
|
|
7
|
+
|
|
8
|
+
def initialize(limit, urls)
|
|
9
|
+
@limit = limit
|
|
10
|
+
@urls = urls
|
|
11
|
+
super("Redirect limit of #{limit} exceeded: #{urls.join(' -> ')}.")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -3,11 +3,13 @@ module LinkChecker
|
|
|
3
3
|
module HTTP
|
|
4
4
|
class Task < ::LinkChecker::Task
|
|
5
5
|
def run!
|
|
6
|
-
|
|
6
|
+
sleep(delay) if delay.positive?
|
|
7
|
+
::Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
|
7
8
|
http.read_timeout = checker.read_timeout if checker.read_timeout
|
|
8
9
|
http.open_timeout = checker.open_timeout if checker.open_timeout
|
|
9
10
|
request = ::Net::HTTPGenericRequest.new(method, false, true, uri)
|
|
10
11
|
request['User-Agent'] = checker.user_agent
|
|
12
|
+
checker.headers.each { |key, value| request[key] = value }
|
|
11
13
|
response = http.request(request)
|
|
12
14
|
result! Result.new(uri, method, original_uri, request, response, options)
|
|
13
15
|
end
|
|
@@ -16,6 +18,7 @@ module LinkChecker
|
|
|
16
18
|
|
|
17
19
|
class Checker < LinkChecker::Checker
|
|
18
20
|
extend ::LinkChecker::Net::HTTP::Config
|
|
21
|
+
|
|
19
22
|
attr_accessor(*LinkChecker::Net::HTTP::Config::ATTRIBUTES)
|
|
20
23
|
|
|
21
24
|
def initialize(options = {})
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
require 'timeout'
|
|
2
|
+
require 'socket'
|
|
3
|
+
|
|
1
4
|
module LinkChecker
|
|
2
5
|
class Result
|
|
3
6
|
attr_accessor :uri, :result_uri, :method, :options, :checker
|
|
7
|
+
attr_writer :cached
|
|
4
8
|
|
|
5
9
|
def initialize(current_uri, method, original_uri, options = {})
|
|
6
10
|
@uri = original_uri
|
|
@@ -13,6 +17,10 @@ module LinkChecker
|
|
|
13
17
|
false
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
def cached?
|
|
21
|
+
!!@cached
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
def failure?
|
|
17
25
|
false
|
|
18
26
|
end
|
|
@@ -33,6 +41,14 @@ module LinkChecker
|
|
|
33
41
|
{}
|
|
34
42
|
end
|
|
35
43
|
|
|
44
|
+
def response_headers
|
|
45
|
+
{}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def error_type
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
36
52
|
def code
|
|
37
53
|
nil
|
|
38
54
|
end
|
|
@@ -70,5 +86,14 @@ module LinkChecker
|
|
|
70
86
|
def code
|
|
71
87
|
error.class.name
|
|
72
88
|
end
|
|
89
|
+
|
|
90
|
+
def error_type
|
|
91
|
+
return :timeout if error.is_a?(Timeout::Error)
|
|
92
|
+
return :timeout if defined?(::Async::TimeoutError) && error.is_a?(::Async::TimeoutError)
|
|
93
|
+
return error.error_type if error.is_a?(LinkChecker::Errors::ConnectionError)
|
|
94
|
+
return :connection if [IOError, SystemCallError, SocketError].any? { |klass| error.is_a?(klass) }
|
|
95
|
+
|
|
96
|
+
:other
|
|
97
|
+
end
|
|
73
98
|
end
|
|
74
99
|
end
|
|
@@ -3,6 +3,7 @@ module LinkChecker
|
|
|
3
3
|
include LinkChecker::Callbacks
|
|
4
4
|
|
|
5
5
|
attr_reader :uri, :original_uri, :method, :logger, :options, :checker
|
|
6
|
+
attr_accessor :delay
|
|
6
7
|
|
|
7
8
|
def initialize(checker, uri, method, original_uri, options = {})
|
|
8
9
|
@checker = checker
|
|
@@ -11,6 +12,7 @@ module LinkChecker
|
|
|
11
12
|
@original_uri = original_uri || @uri
|
|
12
13
|
@method = method
|
|
13
14
|
@options = options
|
|
15
|
+
@delay = 0
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def run!
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
|
|
1
3
|
module LinkChecker
|
|
2
4
|
class Tasks
|
|
3
5
|
include LinkChecker::Callbacks
|
|
@@ -7,6 +9,7 @@ module LinkChecker
|
|
|
7
9
|
def initialize(checker, task_klass, uri, methods, options = {})
|
|
8
10
|
@uri = uri
|
|
9
11
|
@retries_left = checker.retries
|
|
12
|
+
@retry_count = 0
|
|
10
13
|
@methods_left = methods.dup
|
|
11
14
|
@methods = methods.dup
|
|
12
15
|
@task_klass = task_klass
|
|
@@ -24,11 +27,13 @@ module LinkChecker
|
|
|
24
27
|
def execute!
|
|
25
28
|
if retry?
|
|
26
29
|
@retries_left -= 1
|
|
30
|
+
@retry_count += 1
|
|
27
31
|
retry! @result
|
|
28
|
-
_queue_task(uri, method, original_uri || uri, options)
|
|
32
|
+
_queue_task(uri, method, original_uri || uri, options, delay: _retry_delay)
|
|
29
33
|
elsif methods_left.any?
|
|
30
34
|
@method = methods_left.shift
|
|
31
35
|
@uri = URI(@uri) unless @uri.is_a?(URI)
|
|
36
|
+
@redirects = [@uri] if @result.nil?
|
|
32
37
|
_queue_task(uri, method, original_uri || uri, options)
|
|
33
38
|
elsif @result && result.error?
|
|
34
39
|
error! @result
|
|
@@ -57,11 +62,37 @@ module LinkChecker
|
|
|
57
62
|
end
|
|
58
63
|
|
|
59
64
|
def retry?
|
|
60
|
-
!first_time? && retries_left > 0
|
|
65
|
+
!first_time? && retries_left > 0 && retryable?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def retryable?
|
|
69
|
+
return false unless result
|
|
70
|
+
return result.error? || result.failure? if checker.retry_status_codes.nil?
|
|
71
|
+
return %i[timeout connection].include?(result.error_type) if result.error?
|
|
72
|
+
|
|
73
|
+
result.failure? && checker.retry_status_codes.include?(result.code)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def _retry_delay
|
|
77
|
+
delay = checker.retry_after.zero? ? 0 : checker.retry_after * (checker.retry_backoff**(@retry_count - 1))
|
|
78
|
+
delay = [delay, _server_retry_delay].max if checker.respect_retry_after
|
|
79
|
+
delay *= 1 + (rand * checker.retry_jitter) if checker.retry_jitter.positive?
|
|
80
|
+
[delay, checker.max_retry_delay].min
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def _server_retry_delay
|
|
84
|
+
headers = result.response_headers
|
|
85
|
+
value = Array(headers['Retry-After'] || headers['retry-after']).first.to_s.strip
|
|
86
|
+
return value.to_i if /\A\d+\z/.match?(value)
|
|
87
|
+
|
|
88
|
+
[Time.httpdate(value) - Time.now, 0].max
|
|
89
|
+
rescue ArgumentError
|
|
90
|
+
0
|
|
61
91
|
end
|
|
62
92
|
|
|
63
|
-
def _queue_task(uri, method, original_uri, options = {})
|
|
93
|
+
def _queue_task(uri, method, original_uri, options = {}, delay: 0)
|
|
64
94
|
task = new_task(uri, method, original_uri, options)
|
|
95
|
+
task.delay = delay
|
|
65
96
|
task.on :result do |result|
|
|
66
97
|
_handle_result result
|
|
67
98
|
end
|
|
@@ -88,14 +119,17 @@ module LinkChecker
|
|
|
88
119
|
result! result
|
|
89
120
|
if result.redirect?
|
|
90
121
|
redirect! result
|
|
91
|
-
redirected_to_uri = URI.join(
|
|
122
|
+
redirected_to_uri = URI.join(result.result_uri, result.redirect_to)
|
|
92
123
|
if redirects.include?(redirected_to_uri)
|
|
93
124
|
raise LinkChecker::Errors::RedirectLoopError,
|
|
94
125
|
redirects.push(redirected_to_uri)
|
|
95
126
|
end
|
|
127
|
+
if redirects.length - 1 >= checker.max_redirects
|
|
128
|
+
raise LinkChecker::Errors::RedirectLimitError.new(checker.max_redirects, redirects + [redirected_to_uri])
|
|
129
|
+
end
|
|
96
130
|
|
|
97
131
|
redirects << redirected_to_uri
|
|
98
|
-
_queue_task(redirected_to_uri, result.method, uri, options)
|
|
132
|
+
_queue_task(redirected_to_uri, result.method, result.uri, options)
|
|
99
133
|
elsif result.success?
|
|
100
134
|
success! result
|
|
101
135
|
else
|
|
@@ -103,8 +137,10 @@ module LinkChecker
|
|
|
103
137
|
execute!
|
|
104
138
|
end
|
|
105
139
|
rescue StandardError => e
|
|
106
|
-
logger.error("#{self}##{__method__}") { e }
|
|
107
|
-
|
|
140
|
+
logger.error("#{self}##{__method__}") { e } rescue nil
|
|
141
|
+
error_result = ResultError.new(result.result_uri, result.method, result.uri, e, options)
|
|
142
|
+
result! error_result
|
|
143
|
+
error! error_result
|
|
108
144
|
end
|
|
109
145
|
end
|
|
110
146
|
end
|
|
@@ -5,28 +5,32 @@ module LinkChecker
|
|
|
5
5
|
def run!
|
|
6
6
|
request = ::Typhoeus::Request.new(
|
|
7
7
|
uri, {
|
|
8
|
-
method: method,
|
|
8
|
+
method: method.downcase.to_sym,
|
|
9
9
|
followlocation: false,
|
|
10
10
|
timeout: checker.timeout,
|
|
11
11
|
connecttimeout: checker.connecttimeout,
|
|
12
12
|
headers: {
|
|
13
13
|
'User-Agent' => checker.user_agent
|
|
14
|
-
}
|
|
14
|
+
}.merge(checker.headers)
|
|
15
15
|
}
|
|
16
16
|
)
|
|
17
17
|
request.on_complete do |response|
|
|
18
18
|
if response.timed_out?
|
|
19
19
|
result! ResultError.new(uri, method, original_uri, Timeout::Error.new, options)
|
|
20
|
+
elsif response.code.zero?
|
|
21
|
+
error = LinkChecker::Errors::ConnectionError.new(response.return_code, response.return_message)
|
|
22
|
+
result! ResultError.new(uri, method, original_uri, error, options)
|
|
20
23
|
else
|
|
21
24
|
result! Result.new(uri, method, original_uri, request, response, options)
|
|
22
25
|
end
|
|
23
26
|
end
|
|
24
|
-
checker._queue(request)
|
|
27
|
+
checker._queue(request, delay: delay)
|
|
25
28
|
end
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
class Checker < LinkChecker::Checker
|
|
29
32
|
extend ::LinkChecker::Typhoeus::Hydra::Config
|
|
33
|
+
|
|
30
34
|
attr_accessor(*LinkChecker::Typhoeus::Hydra::Config::ATTRIBUTES)
|
|
31
35
|
|
|
32
36
|
def initialize(options = {})
|
|
@@ -34,15 +38,34 @@ module LinkChecker
|
|
|
34
38
|
send("#{key}=", options[key] || LinkChecker::Typhoeus::Hydra::Config.send(key))
|
|
35
39
|
end
|
|
36
40
|
@hydra = ::Typhoeus::Hydra.new(options[:hydra] || { max_concurrency: 10 })
|
|
41
|
+
@delayed = []
|
|
37
42
|
super options
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
def run
|
|
41
|
-
|
|
46
|
+
loop do
|
|
47
|
+
@hydra.run
|
|
48
|
+
break if @delayed.empty?
|
|
49
|
+
|
|
50
|
+
deadline = @delayed.map(&:first).min
|
|
51
|
+
sleep([deadline - monotonic_time, 0].max)
|
|
52
|
+
ready, @delayed = @delayed.partition { |time, _request| time <= monotonic_time }
|
|
53
|
+
ready.each { |entry| @hydra.queue(entry.last) }
|
|
54
|
+
end
|
|
42
55
|
end
|
|
43
56
|
|
|
44
|
-
def _queue(request)
|
|
45
|
-
|
|
57
|
+
def _queue(request, delay: 0)
|
|
58
|
+
if delay.positive?
|
|
59
|
+
@delayed << [monotonic_time + delay, request]
|
|
60
|
+
else
|
|
61
|
+
@hydra.queue(request)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def monotonic_time
|
|
68
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
69
|
end
|
|
47
70
|
end
|
|
48
71
|
end
|
data/lib/ruby-link-checker.rb
CHANGED
|
@@ -9,5 +9,7 @@ require_relative 'ruby-link-checker/task'
|
|
|
9
9
|
require_relative 'ruby-link-checker/tasks'
|
|
10
10
|
require_relative 'ruby-link-checker/checker'
|
|
11
11
|
require_relative 'ruby-link-checker/result'
|
|
12
|
+
require_relative 'ruby-link-checker/cache'
|
|
12
13
|
require_relative 'ruby-link-checker/net/http'
|
|
13
14
|
require_relative 'ruby-link-checker/typhoeus/hydra'
|
|
15
|
+
require_relative 'ruby-link-checker/async/http'
|
data/ruby-link-checker.gemspec
CHANGED
|
@@ -10,12 +10,13 @@ Gem::Specification.new do |s|
|
|
|
10
10
|
s.email = 'dblock@dblock.org'
|
|
11
11
|
s.platform = Gem::Platform::RUBY
|
|
12
12
|
s.required_rubygems_version = '>= 1.3.6'
|
|
13
|
-
s.required_ruby_version = '>=
|
|
14
|
-
s.files =
|
|
13
|
+
s.required_ruby_version = '>= 3.3'
|
|
14
|
+
s.files = IO.popen(%w[git ls-files -z], &:read).split("\0")
|
|
15
15
|
s.require_paths = ['lib']
|
|
16
16
|
s.homepage = 'http://github.com/dblock/ruby-link-checker'
|
|
17
17
|
s.licenses = ['MIT']
|
|
18
18
|
s.summary = 'Fast ruby link checker.'
|
|
19
19
|
s.metadata['rubygems_mfa_required'] = 'true'
|
|
20
|
+
s.add_development_dependency 'async-http'
|
|
20
21
|
s.add_development_dependency 'typhoeus'
|
|
21
22
|
end
|