deadfinder 1.8.0 → 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: 5c4f184329cd4314f65259f1f4b316c50ad3953bd52eeb81fbac45907eca25ab
4
- data.tar.gz: d6e046f05ca1088d3a9a593fce99cc52dc582687e9982b68b1b604d3e1573918
3
+ metadata.gz: 4d72852a66ecdb612a93361fa42c367d9c2505966016bf4ca0a76a099e6ab235
4
+ data.tar.gz: 19116106505f9c3060dce40406b8bc478f4ce01e49eccfddaa192fc981adcef2
5
5
  SHA512:
6
- metadata.gz: 9c8dd4b7c1047808c8ac57228f9b1ab7ea02b948e8f42c37766a189058b7b44cb50822b4d4632faf45b27046364bdb08b0cc679c7ed2c8a95f2dacef818085aa
7
- data.tar.gz: af74057d1c8e03e69e47253b07f685796f3978e9c5da75a33efd33ae7d8f004f147555a03c529c33bd871fd7830b887cc27c1a9e851f93d8725daa24036cefef
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.8.0;)', 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'
@@ -26,6 +26,7 @@ module DeadFinder
26
26
  class_option :debug, default: false, type: :boolean, desc: 'Debug mode'
27
27
  class_option :limit, default: 0, type: :numeric, desc: 'Limit the number of URLs to scan'
28
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)'
29
30
 
30
31
  def self.exit_on_failure?
31
32
  true
@@ -33,22 +34,30 @@ module DeadFinder
33
34
 
34
35
  desc 'pipe', 'Scan the URLs from STDIN. (e.g., cat urls.txt | deadfinder pipe)'
35
36
  def pipe
36
- DeadFinder.run_pipe options
37
+ opts = options.dup
38
+ opts['coverage'] = true if opts['visualize'] && !opts['visualize'].empty?
39
+ DeadFinder.run_pipe opts
37
40
  end
38
41
 
39
42
  desc 'file <FILE>', 'Scan the URLs from File. (e.g., deadfinder file urls.txt)'
40
43
  def file(filename)
41
- 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
42
47
  end
43
48
 
44
49
  desc 'url <URL>', 'Scan the Single URL.'
45
50
  def url(url)
46
- 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
47
54
  end
48
55
 
49
56
  desc 'sitemap <SITEMAP-URL>', 'Scan the URLs from sitemap.'
50
57
  def sitemap(sitemap)
51
- 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
52
61
  end
53
62
 
54
63
  desc 'completion <SHELL>', 'Generate completion script for shell.'
@@ -83,7 +83,7 @@ module DeadFinder
83
83
  jobs.close
84
84
 
85
85
  (1..jobs_size).each { ~results }
86
-
86
+
87
87
  # Log coverage summary if tracking was enabled
88
88
  if options['coverage'] && DeadFinder.coverage_data[target] && DeadFinder.coverage_data[target][:total] > 0
89
89
  total = DeadFinder.coverage_data[target][:total]
@@ -91,7 +91,7 @@ module DeadFinder
91
91
  percentage = ((dead.to_f / total) * 100).round(2)
92
92
  DeadFinder::Logger.sub_info "Coverage: #{dead}/#{total} URLs are dead links (#{percentage}%)"
93
93
  end
94
-
94
+
95
95
  DeadFinder::Logger.sub_complete 'Task completed'
96
96
  rescue StandardError => e
97
97
  DeadFinder::Logger.error "[#{e}] #{target}"
@@ -105,10 +105,10 @@ module DeadFinder
105
105
  CACHE_SET[j] = true
106
106
  # Track total URLs tested for coverage calculation (only if coverage flag is enabled)
107
107
  if options['coverage']
108
- DeadFinder.coverage_data[target] ||= { total: 0, dead: 0 }
108
+ DeadFinder.coverage_data[target] ||= { total: 0, dead: 0, status_counts: Hash.new(0) }
109
109
  DeadFinder.coverage_data[target][:total] += 1
110
110
  end
111
-
111
+
112
112
  begin
113
113
  CACHE_QUE[j] = true
114
114
  uri = URI.parse(j)
@@ -130,14 +130,22 @@ module DeadFinder
130
130
  DeadFinder.output[target] ||= []
131
131
  DeadFinder.output[target] << j
132
132
  # Track dead URLs for coverage calculation (only if coverage flag is enabled)
133
- DeadFinder.coverage_data[target][:dead] += 1 if options['coverage']
133
+ if options['coverage']
134
+ DeadFinder.coverage_data[target][:dead] += 1
135
+ DeadFinder.coverage_data[target][:status_counts][status_code] += 1
136
+ end
134
137
  else
135
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']
136
141
  end
137
142
  rescue StandardError => e
138
143
  DeadFinder::Logger.verbose "[#{e}] #{j}" if options['verbose']
139
144
  # Consider errored URLs as dead for coverage calculation (only if coverage flag is enabled)
140
- DeadFinder.coverage_data[target][:dead] += 1 if options['coverage']
145
+ if options['coverage']
146
+ DeadFinder.coverage_data[target][:dead] += 1
147
+ DeadFinder.coverage_data[target][:status_counts]['error'] += 1
148
+ end
141
149
  end
142
150
  end
143
151
  results << j
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadFinder
4
- VERSION = '1.8.0'
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'
@@ -88,20 +89,24 @@ module DeadFinder
88
89
  coverage_summary = {}
89
90
  total_all_tested = 0
90
91
  total_all_dead = 0
92
+ overall_status_counts = Hash.new(0)
91
93
 
92
94
  coverage_data.each do |target, data|
93
95
  total = data[:total]
94
96
  dead = data[:dead]
97
+ status_counts = data[:status_counts] || {}
95
98
  coverage_percentage = total.positive? ? ((dead.to_f / total) * 100).round(2) : 0.0
96
99
 
97
100
  coverage_summary[target] = {
98
101
  total_tested: total,
99
102
  dead_links: dead,
100
- coverage_percentage: coverage_percentage
103
+ coverage_percentage: coverage_percentage,
104
+ status_counts: status_counts
101
105
  }
102
106
 
103
107
  total_all_tested += total
104
108
  total_all_dead += dead
109
+ status_counts.each { |code, count| overall_status_counts[code] += count }
105
110
  end
106
111
 
107
112
  overall_coverage = total_all_tested.positive? ? ((total_all_dead.to_f / total_all_tested) * 100).round(2) : 0.0
@@ -111,32 +116,35 @@ module DeadFinder
111
116
  summary: {
112
117
  total_tested: total_all_tested,
113
118
  total_dead: total_all_dead,
114
- overall_coverage_percentage: overall_coverage
119
+ overall_coverage_percentage: overall_coverage,
120
+ overall_status_counts: overall_status_counts
115
121
  }
116
122
  }
117
123
  end
118
124
 
119
125
  def self.gen_output(options)
120
- return if options['output'].empty?
121
-
122
126
  output_data = DeadFinder.output.to_h
123
127
  format = options['output_format'].to_s.downcase
124
-
125
128
  # Include coverage data only if coverage flag is enabled and data exists
126
129
  coverage_info = calculate_coverage if options['coverage'] && coverage_data.any? && coverage_data.values.any? { |v| v[:total].positive? }
127
130
 
128
- content = case format
129
- when 'yaml', 'yml'
130
- output_with_coverage = coverage_info ? { 'dead_links' => output_data, 'coverage' => coverage_info } : output_data
131
- output_with_coverage.to_yaml
132
- when 'csv'
133
- generate_csv(output_data, coverage_info)
134
- else
135
- output_with_coverage = coverage_info ? { 'dead_links' => output_data, 'coverage' => coverage_info } : output_data
136
- JSON.pretty_generate(output_with_coverage)
137
- end
138
-
139
- File.write(options['output'], content)
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
146
+
147
+ DeadFinder::Visualizer.generate(coverage_info, options['visualize'])
140
148
  end
141
149
 
142
150
  def self.generate_csv(output_data, coverage_info = nil)
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.8.0
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-08-23 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
@@ -170,19 +170,53 @@ dependencies:
170
170
  - !ruby/object:Gem::Version
171
171
  version: '1.5'
172
172
  - !ruby/object:Gem::Dependency
173
- name: rspec
173
+ name: chunky_png
174
174
  requirement: !ruby/object:Gem::Requirement
175
175
  requirements:
176
+ - - "~>"
177
+ - !ruby/object:Gem::Version
178
+ version: 1.4.0
176
179
  - - ">="
177
180
  - !ruby/object:Gem::Version
178
- version: '0'
179
- type: :development
181
+ version: 1.4.0
182
+ type: :runtime
180
183
  prerelease: false
181
184
  version_requirements: !ruby/object:Gem::Requirement
182
185
  requirements:
186
+ - - "~>"
187
+ - !ruby/object:Gem::Version
188
+ version: 1.4.0
183
189
  - - ">="
184
190
  - !ruby/object:Gem::Version
185
- version: '0'
191
+ version: 1.4.0
192
+ - !ruby/object:Gem::Dependency
193
+ name: rspec
194
+ requirement: !ruby/object:Gem::Requirement
195
+ requirements:
196
+ - - "~>"
197
+ - !ruby/object:Gem::Version
198
+ version: '3.12'
199
+ type: :development
200
+ prerelease: false
201
+ version_requirements: !ruby/object:Gem::Requirement
202
+ requirements:
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
+ - - "~>"
218
+ - !ruby/object:Gem::Version
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: []