deadfinder 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98d859458bc87d32005ad05d4e3f90103aab430037b4b439951911e760eea968
4
- data.tar.gz: 347a37265add728676f196a8d313dfda37a54b48eb45030f6ff01dba15b152d6
3
+ metadata.gz: 4d72852a66ecdb612a93361fa42c367d9c2505966016bf4ca0a76a099e6ab235
4
+ data.tar.gz: 19116106505f9c3060dce40406b8bc478f4ce01e49eccfddaa192fc981adcef2
5
5
  SHA512:
6
- metadata.gz: dc059649e3a8cfd4e644651db38e1082b4b1060624cd2d1d6d43064c43c87c836583912c6430c00f0799ee77526917d2357286cd2129c4e6176e62457c6dc007
7
- data.tar.gz: 17d62439486da8d4f2efe8ce957744c8fca8ee23a5670fb7aa128d9b688e4b7dcf7c8689355553431a171ac14e2c2a6d58515a9ec9a2c4d4ea1f5bfc40c482f2
6
+ metadata.gz: 5b00c09cbe273376dd5cc60fb964d1a74146e6d98e5a21656d91202b792b6bcac3576ba825b3fc3d144f58f2e76a34ad08658f925d0469824b420fac1e2ea153
7
+ data.tar.gz: 4ae07f18471a1aa71018533259e14d99af53799e1f65479a6d953f44d7d5f182c97393747cb502a910878311ff70ec85a6e007c63aea20c8c58b78798f03b3cc
@@ -15,7 +15,7 @@ module DeadFinder
15
15
  class_option :headers, aliases: :H, default: [], type: :array,
16
16
  desc: 'Custom HTTP headers to send with initial request'
17
17
  class_option :worker_headers, default: [], type: :array, desc: 'Custom HTTP headers to send with worker requests'
18
- class_option :user_agent, default: 'Mozilla/5.0 (compatible; DeadFinder/1.7.1;)', type: :string,
18
+ class_option :user_agent, default: 'Mozilla/5.0 (compatible; DeadFinder/1.9.0;)', type: :string,
19
19
  desc: 'User-Agent string to use for requests'
20
20
  class_option :proxy, aliases: :p, default: '', type: :string, desc: 'Proxy server to use for requests'
21
21
  class_option :proxy_auth, default: '', type: :string, desc: 'Proxy server authentication credentials'
@@ -24,6 +24,9 @@ module DeadFinder
24
24
  class_option :silent, aliases: :s, default: false, type: :boolean, desc: 'Silent mode'
25
25
  class_option :verbose, aliases: :v, default: false, type: :boolean, desc: 'Verbose mode'
26
26
  class_option :debug, default: false, type: :boolean, desc: 'Debug mode'
27
+ class_option :limit, default: 0, type: :numeric, desc: 'Limit the number of URLs to scan'
28
+ class_option :coverage, default: false, type: :boolean, desc: 'Enable coverage tracking and reporting'
29
+ class_option :visualize, default: '', type: :string, desc: 'Generate a visualization of the scan results (e.g., report.png)'
27
30
 
28
31
  def self.exit_on_failure?
29
32
  true
@@ -31,22 +34,30 @@ module DeadFinder
31
34
 
32
35
  desc 'pipe', 'Scan the URLs from STDIN. (e.g., cat urls.txt | deadfinder pipe)'
33
36
  def pipe
34
- DeadFinder.run_pipe options
37
+ opts = options.dup
38
+ opts['coverage'] = true if opts['visualize'] && !opts['visualize'].empty?
39
+ DeadFinder.run_pipe opts
35
40
  end
36
41
 
37
42
  desc 'file <FILE>', 'Scan the URLs from File. (e.g., deadfinder file urls.txt)'
38
43
  def file(filename)
39
- DeadFinder.run_file filename, options
44
+ opts = options.dup
45
+ opts['coverage'] = true if opts['visualize'] && !opts['visualize'].empty?
46
+ DeadFinder.run_file filename, opts
40
47
  end
41
48
 
42
49
  desc 'url <URL>', 'Scan the Single URL.'
43
50
  def url(url)
44
- DeadFinder.run_url url, options
51
+ opts = options.dup
52
+ opts['coverage'] = true if opts['visualize'] && !opts['visualize'].empty?
53
+ DeadFinder.run_url url, opts
45
54
  end
46
55
 
47
56
  desc 'sitemap <SITEMAP-URL>', 'Scan the URLs from sitemap.'
48
57
  def sitemap(sitemap)
49
- DeadFinder.run_sitemap sitemap, options
58
+ opts = options.dup
59
+ opts['coverage'] = true if opts['visualize'] && !opts['visualize'].empty?
60
+ DeadFinder.run_sitemap sitemap, opts
50
61
  end
51
62
 
52
63
  desc 'completion <SHELL>', 'Generate completion script for shell.'
@@ -83,6 +83,15 @@ module DeadFinder
83
83
  jobs.close
84
84
 
85
85
  (1..jobs_size).each { ~results }
86
+
87
+ # Log coverage summary if tracking was enabled
88
+ if options['coverage'] && DeadFinder.coverage_data[target] && DeadFinder.coverage_data[target][:total] > 0
89
+ total = DeadFinder.coverage_data[target][:total]
90
+ dead = DeadFinder.coverage_data[target][:dead]
91
+ percentage = ((dead.to_f / total) * 100).round(2)
92
+ DeadFinder::Logger.sub_info "Coverage: #{dead}/#{total} URLs are dead links (#{percentage}%)"
93
+ end
94
+
86
95
  DeadFinder::Logger.sub_complete 'Task completed'
87
96
  rescue StandardError => e
88
97
  DeadFinder::Logger.error "[#{e}] #{target}"
@@ -94,6 +103,12 @@ module DeadFinder
94
103
  # Skip if already cached
95
104
  else
96
105
  CACHE_SET[j] = true
106
+ # Track total URLs tested for coverage calculation (only if coverage flag is enabled)
107
+ if options['coverage']
108
+ DeadFinder.coverage_data[target] ||= { total: 0, dead: 0, status_counts: Hash.new(0) }
109
+ DeadFinder.coverage_data[target][:total] += 1
110
+ end
111
+
97
112
  begin
98
113
  CACHE_QUE[j] = true
99
114
  uri = URI.parse(j)
@@ -114,11 +129,23 @@ module DeadFinder
114
129
  CACHE_QUE[j] = false
115
130
  DeadFinder.output[target] ||= []
116
131
  DeadFinder.output[target] << j
132
+ # Track dead URLs for coverage calculation (only if coverage flag is enabled)
133
+ if options['coverage']
134
+ DeadFinder.coverage_data[target][:dead] += 1
135
+ DeadFinder.coverage_data[target][:status_counts][status_code] += 1
136
+ end
117
137
  else
118
138
  DeadFinder::Logger.verbose_ok "[#{status_code}] #{j}" if options['verbose']
139
+ # Track status for successful URLs
140
+ DeadFinder.coverage_data[target][:status_counts][status_code] += 1 if options['coverage']
119
141
  end
120
142
  rescue StandardError => e
121
143
  DeadFinder::Logger.verbose "[#{e}] #{j}" if options['verbose']
144
+ # Consider errored URLs as dead for coverage calculation (only if coverage flag is enabled)
145
+ if options['coverage']
146
+ DeadFinder.coverage_data[target][:dead] += 1
147
+ DeadFinder.coverage_data[target][:status_counts]['error'] += 1
148
+ end
122
149
  end
123
150
  end
124
151
  results << j
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadFinder
4
- VERSION = '1.7.1'
4
+ VERSION = '1.9.0'
5
5
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chunky_png'
4
+
5
+ module DeadFinder
6
+ # Visualizer module for generating images from scan results
7
+ module Visualizer
8
+ def self.generate(data, output_path)
9
+ # Extract summary data
10
+ summary = data[:summary]
11
+ return if summary.nil?
12
+
13
+ total_tested = summary[:total_tested]
14
+ return if total_tested.nil? || total_tested.zero?
15
+
16
+ # Create a new image with transparent background
17
+ png = ChunkyPNG::Image.new(500, 300, ChunkyPNG::Color::TRANSPARENT)
18
+
19
+ # Draw stacked bar chart for status code distribution
20
+ status_counts = data[:summary][:overall_status_counts] || {}
21
+ bar_height = 70
22
+ current_y = 110
23
+
24
+ # Sort statuses by count descending
25
+ sorted_statuses = status_counts.sort_by { |_, v| -v }
26
+
27
+ sorted_statuses.each do |status, count|
28
+ height = (count.to_f / total_tested * bar_height).to_i
29
+ next if height.zero?
30
+
31
+ color = case status.to_s
32
+ when '200' then ChunkyPNG::Color.rgb(0, 255, 0) # Green for 200
33
+ when /^3\d{2}$/ then ChunkyPNG::Color.rgb(255, 165, 0) # Orange for 3xx
34
+ when /^4\d{2}$/ then ChunkyPNG::Color.rgb(255, 0, 0) # Red for 4xx
35
+ when /^5\d{2}$/ then ChunkyPNG::Color.rgb(128, 0, 128) # Purple for 5xx
36
+ else ChunkyPNG::Color.rgb(128, 128, 128) # Gray for others/error
37
+ end
38
+
39
+ (current_y..(current_y + height - 1)).each do |y|
40
+ (20..480).each do |x|
41
+ png[x, y] = color
42
+ end
43
+ end
44
+ current_y += height
45
+ end
46
+
47
+ # Draw rounded outline around the bar area
48
+ r = 10
49
+ x1 = 10
50
+ y1 = 100
51
+ x2 = 490
52
+ y2 = 190
53
+
54
+ # Top line
55
+ ((x1 + r)..(x2 - r)).each do |x|
56
+ png[x, y1] = ChunkyPNG::Color.rgba(0, 0, 0, 128)
57
+ # Bottom line
58
+ png[x, y2] = ChunkyPNG::Color.rgba(0, 0, 0, 128)
59
+ end
60
+ # Left line
61
+ ((y1 + r)..(y2 - r)).each do |y|
62
+ png[x1, y] = ChunkyPNG::Color.rgba(0, 0, 0, 128)
63
+ # Right line
64
+ png[x2, y] = ChunkyPNG::Color.rgba(0, 0, 0, 128)
65
+ end
66
+
67
+ # Corners: quarter circles
68
+ # Top-left
69
+ (0..90).each do |angle|
70
+ rad = angle * Math::PI / 180
71
+ cx = x1 + r
72
+ cy = y1 + r
73
+ px = cx + (r * Math.cos(rad))
74
+ py = cy + (r * Math.sin(rad))
75
+ png[px.to_i, py.to_i] = ChunkyPNG::Color.rgba(0, 0, 0, 128) if px >= x1 && py >= y1
76
+ end
77
+ # Top-right
78
+ (90..180).each do |angle|
79
+ rad = angle * Math::PI / 180
80
+ cx = x2 - r
81
+ cy = y1 + r
82
+ px = cx + (r * Math.cos(rad))
83
+ py = cy + (r * Math.sin(rad))
84
+ png[px.to_i, py.to_i] = ChunkyPNG::Color.rgba(0, 0, 0, 128) if px <= x2 && py >= y1
85
+ end
86
+ # Bottom-left
87
+ (270..360).each do |angle|
88
+ rad = angle * Math::PI / 180
89
+ cx = x1 + r
90
+ cy = y2 - r
91
+ px = cx + (r * Math.cos(rad))
92
+ py = cy + (r * Math.sin(rad))
93
+ png[px.to_i, py.to_i] = ChunkyPNG::Color.rgba(0, 0, 0, 128) if px >= x1 && py <= y2
94
+ end
95
+ # Bottom-right
96
+ (180..270).each do |angle|
97
+ rad = angle * Math::PI / 180
98
+ cx = x2 - r
99
+ cy = y2 - r
100
+ px = cx + (r * Math.cos(rad))
101
+ py = cy + (r * Math.sin(rad))
102
+ png[px.to_i, py.to_i] = ChunkyPNG::Color.rgba(0, 0, 0, 128) if px <= x2 && py <= y2
103
+ end
104
+
105
+ # Save the image
106
+ png.save(output_path, :fast_rgba)
107
+ end
108
+ end
109
+ end
data/lib/deadfinder.rb CHANGED
@@ -7,6 +7,7 @@ require 'nokogiri'
7
7
  require 'deadfinder/utils'
8
8
  require 'deadfinder/logger'
9
9
  require 'deadfinder/runner'
10
+ require 'deadfinder/visualizer'
10
11
  require 'deadfinder/cli'
11
12
  require 'deadfinder/version'
12
13
  require 'concurrent-edge'
@@ -29,6 +30,15 @@ module DeadFinder
29
30
  @output = val
30
31
  end
31
32
 
33
+ @coverage_data = {}
34
+ def self.coverage_data
35
+ @coverage_data
36
+ end
37
+
38
+ def self.coverage_data=(val)
39
+ @coverage_data = val
40
+ end
41
+
32
42
  def self.run_pipe(options)
33
43
  run_with_input(options) { $stdin.gets&.chomp }
34
44
  end
@@ -48,8 +58,10 @@ module DeadFinder
48
58
  app = Runner.new
49
59
  base_uri = URI(sitemap_url)
50
60
  sitemap = SitemapParser.new(sitemap_url, recurse: true)
51
- DeadFinder::Logger.info "Found #{sitemap.to_a.size} URLs from #{sitemap_url}"
52
- sitemap.to_a.each do |url|
61
+ urls = sitemap.to_a
62
+ urls = urls.first(options['limit']) if options['limit'].positive?
63
+ DeadFinder::Logger.info "Found #{urls.size} URLs from #{sitemap_url}"
64
+ urls.each do |url|
53
65
  turl = generate_url(url, base_uri)
54
66
  run_with_target(turl, options, app)
55
67
  end
@@ -60,7 +72,9 @@ module DeadFinder
60
72
  DeadFinder::Logger.apply_options(options)
61
73
  DeadFinder::Logger.info 'Reading input'
62
74
  app = Runner.new
63
- Array(yield).each do |target|
75
+ targets = Array(yield)
76
+ targets = targets.first(options['limit']) if options['limit'].positive?
77
+ targets.each do |target|
64
78
  run_with_target(target, options, app)
65
79
  end
66
80
  gen_output(options)
@@ -71,30 +85,89 @@ module DeadFinder
71
85
  app.run(target, options)
72
86
  end
73
87
 
74
- def self.gen_output(options)
75
- return if options['output'].empty?
88
+ def self.calculate_coverage
89
+ coverage_summary = {}
90
+ total_all_tested = 0
91
+ total_all_dead = 0
92
+ overall_status_counts = Hash.new(0)
93
+
94
+ coverage_data.each do |target, data|
95
+ total = data[:total]
96
+ dead = data[:dead]
97
+ status_counts = data[:status_counts] || {}
98
+ coverage_percentage = total.positive? ? ((dead.to_f / total) * 100).round(2) : 0.0
99
+
100
+ coverage_summary[target] = {
101
+ total_tested: total,
102
+ dead_links: dead,
103
+ coverage_percentage: coverage_percentage,
104
+ status_counts: status_counts
105
+ }
106
+
107
+ total_all_tested += total
108
+ total_all_dead += dead
109
+ status_counts.each { |code, count| overall_status_counts[code] += count }
110
+ end
76
111
 
112
+ overall_coverage = total_all_tested.positive? ? ((total_all_dead.to_f / total_all_tested) * 100).round(2) : 0.0
113
+
114
+ {
115
+ targets: coverage_summary,
116
+ summary: {
117
+ total_tested: total_all_tested,
118
+ total_dead: total_all_dead,
119
+ overall_coverage_percentage: overall_coverage,
120
+ overall_status_counts: overall_status_counts
121
+ }
122
+ }
123
+ end
124
+
125
+ def self.gen_output(options)
77
126
  output_data = DeadFinder.output.to_h
78
127
  format = options['output_format'].to_s.downcase
128
+ # Include coverage data only if coverage flag is enabled and data exists
129
+ coverage_info = calculate_coverage if options['coverage'] && coverage_data.any? && coverage_data.values.any? { |v| v[:total].positive? }
79
130
 
80
- content = case format
81
- when 'yaml', 'yml'
82
- output_data.to_yaml
83
- when 'csv'
84
- generate_csv(output_data)
85
- else
86
- JSON.pretty_generate(output_data)
87
- end
131
+ unless options['output'].to_s.empty?
132
+ content = case format
133
+ when 'yaml', 'yml'
134
+ output_with_coverage = coverage_info ? { 'dead_links' => output_data, 'coverage' => coverage_info } : output_data
135
+ output_with_coverage.to_yaml
136
+ when 'csv'
137
+ generate_csv(output_data, coverage_info)
138
+ else
139
+ output_with_coverage = coverage_info ? { 'dead_links' => output_data, 'coverage' => coverage_info } : output_data
140
+ JSON.pretty_generate(output_with_coverage)
141
+ end
142
+ File.write(options['output'], content)
143
+ end
144
+
145
+ return unless options['visualize'] && !options['visualize'].empty? && coverage_info
88
146
 
89
- File.write(options['output'], content)
147
+ DeadFinder::Visualizer.generate(coverage_info, options['visualize'])
90
148
  end
91
149
 
92
- def self.generate_csv(output_data)
150
+ def self.generate_csv(output_data, coverage_info = nil)
93
151
  CSV.generate do |csv|
94
152
  csv << %w[target url]
95
153
  output_data.each do |target, urls|
96
154
  Array(urls).each { |url| csv << [target, url] }
97
155
  end
156
+
157
+ # Add coverage information as additional rows if available
158
+ if coverage_info
159
+ csv << [] # Empty row separator
160
+ csv << ['Coverage Report']
161
+ csv << %w[target total_tested dead_links coverage_percentage]
162
+ coverage_info[:targets].each do |target, data|
163
+ csv << [target, data[:total_tested], data[:dead_links], "#{data[:coverage_percentage]}%"]
164
+ end
165
+ csv << [] # Empty row separator
166
+ csv << ['Overall Summary']
167
+ csv << %w[total_tested total_dead overall_coverage_percentage]
168
+ summary = coverage_info[:summary]
169
+ csv << [summary[:total_tested], summary[:total_dead], "#{summary[:overall_coverage_percentage]}%"]
170
+ end
98
171
  end
99
172
  end
100
173
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deadfinder
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.1
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hahwul
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-25 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: colorize
@@ -151,38 +151,72 @@ dependencies:
151
151
  version: 0.5.0
152
152
  - !ruby/object:Gem::Dependency
153
153
  name: thor
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '1.2'
159
+ - - "<"
160
+ - !ruby/object:Gem::Version
161
+ version: '1.5'
162
+ type: :runtime
163
+ prerelease: false
164
+ version_requirements: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '1.2'
169
+ - - "<"
170
+ - !ruby/object:Gem::Version
171
+ version: '1.5'
172
+ - !ruby/object:Gem::Dependency
173
+ name: chunky_png
154
174
  requirement: !ruby/object:Gem::Requirement
155
175
  requirements:
156
176
  - - "~>"
157
177
  - !ruby/object:Gem::Version
158
- version: 1.2.0
178
+ version: 1.4.0
159
179
  - - ">="
160
180
  - !ruby/object:Gem::Version
161
- version: 1.2.0
181
+ version: 1.4.0
162
182
  type: :runtime
163
183
  prerelease: false
164
184
  version_requirements: !ruby/object:Gem::Requirement
165
185
  requirements:
166
186
  - - "~>"
167
187
  - !ruby/object:Gem::Version
168
- version: 1.2.0
188
+ version: 1.4.0
169
189
  - - ">="
170
190
  - !ruby/object:Gem::Version
171
- version: 1.2.0
191
+ version: 1.4.0
172
192
  - !ruby/object:Gem::Dependency
173
193
  name: rspec
174
194
  requirement: !ruby/object:Gem::Requirement
175
195
  requirements:
176
- - - ">="
196
+ - - "~>"
177
197
  - !ruby/object:Gem::Version
178
- version: '0'
198
+ version: '3.12'
179
199
  type: :development
180
200
  prerelease: false
181
201
  version_requirements: !ruby/object:Gem::Requirement
182
202
  requirements:
183
- - - ">="
203
+ - - "~>"
204
+ - !ruby/object:Gem::Version
205
+ version: '3.12'
206
+ - !ruby/object:Gem::Dependency
207
+ name: cyclonedx-ruby
208
+ requirement: !ruby/object:Gem::Requirement
209
+ requirements:
210
+ - - "~>"
211
+ - !ruby/object:Gem::Version
212
+ version: '1.4'
213
+ type: :development
214
+ prerelease: false
215
+ version_requirements: !ruby/object:Gem::Requirement
216
+ requirements:
217
+ - - "~>"
184
218
  - !ruby/object:Gem::Version
185
- version: '0'
219
+ version: '1.4'
186
220
  description: Find dead-links (broken links). Dead link (broken link) means a link
187
221
  within a web page that cannot be connected. These links can have a negative impact
188
222
  to SEO and Security. This tool makes it easy to identify and modify.
@@ -202,6 +236,7 @@ files:
202
236
  - lib/deadfinder/url_pattern_matcher.rb
203
237
  - lib/deadfinder/utils.rb
204
238
  - lib/deadfinder/version.rb
239
+ - lib/deadfinder/visualizer.rb
205
240
  homepage: https://www.hahwul.com/projects/deadfinder/
206
241
  licenses:
207
242
  - MIT
@@ -222,7 +257,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
222
257
  - !ruby/object:Gem::Version
223
258
  version: '0'
224
259
  requirements: []
225
- rubygems_version: 3.6.3
260
+ rubygems_version: 3.6.9
226
261
  specification_version: 4
227
262
  summary: Find dead-links (broken links)
228
263
  test_files: []