readme-score 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.travis.yml +3 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +68 -0
- data/LICENSE.txt +22 -0
- data/README.md +87 -0
- data/Rakefile +133 -0
- data/data/seed.json +58 -0
- data/lib/readme-score.rb +47 -0
- data/lib/readme-score/document.rb +37 -0
- data/lib/readme-score/document/filter.rb +70 -0
- data/lib/readme-score/document/loader.rb +88 -0
- data/lib/readme-score/document/loader/github_readme_finder.rb +39 -0
- data/lib/readme-score/document/metrics.rb +125 -0
- data/lib/readme-score/document/parser.rb +24 -0
- data/lib/readme-score/document/score.rb +104 -0
- data/lib/readme-score/util.rb +14 -0
- data/lib/readme-score/version.rb +3 -0
- data/readme-score.gemspec +30 -0
- data/spec/document/filter_spec.rb +111 -0
- data/spec/document/loader_spec.rb +139 -0
- data/spec/document/metrics_spec.rb +95 -0
- data/spec/readme-score_spec.rb +34 -0
- data/spec/spec_helper.rb +17 -0
- metadata +199 -0
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'readme-score/document/filter'
|
2
|
+
require 'readme-score/document/metrics'
|
3
|
+
require 'readme-score/document/loader'
|
4
|
+
require 'readme-score/document/parser'
|
5
|
+
require 'readme-score/document/score'
|
6
|
+
|
7
|
+
module ReadmeScore
|
8
|
+
class Document
|
9
|
+
attr_accessor :html, :filter, :metrics
|
10
|
+
|
11
|
+
def self.load(url)
|
12
|
+
loader = Loader.new(url)
|
13
|
+
loader.load!
|
14
|
+
new(loader.html)
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(html)
|
18
|
+
@html = html
|
19
|
+
@noko = Nokogiri::HTML.fragment(@html)
|
20
|
+
@metrics = Document::Metrics.new(html_for_analysis)
|
21
|
+
end
|
22
|
+
|
23
|
+
# @return [String] HTML string ready for analysis
|
24
|
+
def html_for_analysis
|
25
|
+
@filter ||= Document::Filter.new(@noko)
|
26
|
+
@filter.filtered_html!
|
27
|
+
end
|
28
|
+
|
29
|
+
def score
|
30
|
+
@score ||= Score.new(metrics)
|
31
|
+
end
|
32
|
+
|
33
|
+
def inspect
|
34
|
+
"#<#{self.class}>"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module ReadmeScore
|
2
|
+
class Document
|
3
|
+
class Filter
|
4
|
+
SERVICES = ["travis-ci.org", "codeclimate.com", "gemnasium.com", "cocoadocs.org", "readme-score-api.herokuapp.com"]
|
5
|
+
|
6
|
+
def initialize(noko_or_html)
|
7
|
+
@noko = Util.to_noko(noko_or_html, true)
|
8
|
+
end
|
9
|
+
|
10
|
+
def filtered_html!
|
11
|
+
remove_license!
|
12
|
+
remove_contact!
|
13
|
+
remove_service_images!
|
14
|
+
|
15
|
+
@noko.to_s
|
16
|
+
end
|
17
|
+
|
18
|
+
def remove_license!
|
19
|
+
remove_heading_sections_named("license")
|
20
|
+
remove_heading_sections_named("licensing")
|
21
|
+
remove_heading_sections_named("copyright")
|
22
|
+
end
|
23
|
+
|
24
|
+
def remove_contact!
|
25
|
+
remove_heading_sections_named("contact")
|
26
|
+
remove_heading_sections_named("author")
|
27
|
+
remove_heading_sections_named("credits")
|
28
|
+
end
|
29
|
+
|
30
|
+
def remove_service_images!
|
31
|
+
SERVICES.each {|service|
|
32
|
+
remove_anchor_images_containing_url(service)
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
def remove_heading_sections_named(prefix)
|
38
|
+
any_hit = false
|
39
|
+
selectors = (1..5).map {|i| "h#{i}"}
|
40
|
+
selectors.each { |h|
|
41
|
+
@noko.search(h).each { |heading|
|
42
|
+
if heading.content.downcase == prefix
|
43
|
+
# hit - remove everything until the next heading
|
44
|
+
while sibling = heading.next_sibling
|
45
|
+
if sibling.name.downcase.start_with?(heading.name)
|
46
|
+
break
|
47
|
+
else
|
48
|
+
sibling.remove
|
49
|
+
end
|
50
|
+
end
|
51
|
+
heading.remove
|
52
|
+
any_hit = true
|
53
|
+
break
|
54
|
+
end
|
55
|
+
}
|
56
|
+
}
|
57
|
+
any_hit
|
58
|
+
end
|
59
|
+
|
60
|
+
def remove_anchor_images_containing_url(url_fragment)
|
61
|
+
@noko.search('a').each {|a|
|
62
|
+
href = a['href']
|
63
|
+
if href && href.downcase.include?(url_fragment.downcase)
|
64
|
+
a.remove unless a.search('img').empty?
|
65
|
+
end
|
66
|
+
}
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'uri/http'
|
2
|
+
|
3
|
+
require 'readme-score/document/loader/github_readme_finder'
|
4
|
+
|
5
|
+
module ReadmeScore
|
6
|
+
class Document
|
7
|
+
|
8
|
+
class Loader
|
9
|
+
MARKDOWN_EXTENSIONS = %w{md mdown markdown}
|
10
|
+
|
11
|
+
attr_accessor :request, :markdown
|
12
|
+
|
13
|
+
def self.github_repo_name(url)
|
14
|
+
uri = URI.parse(url)
|
15
|
+
return nil unless ["github.com", "www.github.com"].include?(uri.host)
|
16
|
+
path_components = uri.path.split("/")
|
17
|
+
return nil if path_components.reject(&:empty?).count != 2
|
18
|
+
path_components[-2..-1].join("/")
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.is_github_repo_slug?(possible_repo)
|
22
|
+
!!(/^(\w|-)+\/(\w|-|\.)+$/.match(possible_repo))
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.is_url?(possible_url)
|
26
|
+
!!(/https?:\/\//.match(possible_url))
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.markdown_url?(url)
|
30
|
+
MARKDOWN_EXTENSIONS.select {|ext| url.downcase.end_with?(".#{ext}")}.any?
|
31
|
+
end
|
32
|
+
|
33
|
+
attr_accessor :response
|
34
|
+
|
35
|
+
def initialize(url)
|
36
|
+
@url = url
|
37
|
+
end
|
38
|
+
|
39
|
+
def github_repo_name
|
40
|
+
Loader.github_repo_name(@url)
|
41
|
+
end
|
42
|
+
|
43
|
+
def load!
|
44
|
+
if github_repo_name
|
45
|
+
if ReadmeScore.use_github_api?
|
46
|
+
load_via_github_api!
|
47
|
+
else
|
48
|
+
# take a guess at the raw file name
|
49
|
+
load_via_github_approximation!
|
50
|
+
end
|
51
|
+
else
|
52
|
+
@markdown = Loader.markdown_url?(@url)
|
53
|
+
@response ||= Unirest.get @url
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def load_via_github_api!
|
58
|
+
@markdown = false
|
59
|
+
@response ||= OpenStruct.new.tap {|o|
|
60
|
+
@@client ||= Octokit::Client.new(access_token: ReadmeScore.github_api_token)
|
61
|
+
o.body = @@client.readme(github_repo_name, :accept => 'application/vnd.github.html').force_encoding("UTF-8")
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
def load_via_github_approximation!
|
66
|
+
@github_approximation_url ||= GithubReadmeFinder.new(url).find_url
|
67
|
+
@markdown = Loader.markdown_url?(@github_approximation_url)
|
68
|
+
@response ||= Unirest.get @github_approximation_url
|
69
|
+
end
|
70
|
+
|
71
|
+
def html
|
72
|
+
if markdown?
|
73
|
+
parse_markdown(@response.body)
|
74
|
+
else
|
75
|
+
@response.body
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def parse_markdown(markdown)
|
80
|
+
Parser.new(@response.body).to_html
|
81
|
+
end
|
82
|
+
|
83
|
+
def markdown?
|
84
|
+
@markdown == true
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
|
3
|
+
module ReadmeScore
|
4
|
+
class Document
|
5
|
+
class Loader
|
6
|
+
class GithubReadmeFinder
|
7
|
+
POSSIBLE_README_FILES = %w{README.md readme.md README readme ReadMe ReadMe.md}
|
8
|
+
|
9
|
+
def initialize(github_repo_url)
|
10
|
+
@repo_url = github_repo_url
|
11
|
+
end
|
12
|
+
|
13
|
+
def find_url
|
14
|
+
uri = URI.parse(@repo_url)
|
15
|
+
uri.scheme = "https"
|
16
|
+
uri.host = "raw.githubusercontent.com"
|
17
|
+
original_path = uri.path
|
18
|
+
readme_url = nil
|
19
|
+
POSSIBLE_README_FILES.each {|f|
|
20
|
+
uri.path = File.join(original_path, "master/#{f}")
|
21
|
+
readme_url = uri.to_s if reachable?(uri.to_s)
|
22
|
+
break if readme_url
|
23
|
+
}
|
24
|
+
readme_url
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
def reachable?(url)
|
29
|
+
begin
|
30
|
+
RestClient::Request.execute(method: :head, url: url)
|
31
|
+
true
|
32
|
+
rescue RestClient::Exception
|
33
|
+
false
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
module ReadmeScore
|
2
|
+
class Document
|
3
|
+
class Metrics
|
4
|
+
EQUATION_METRICS = [
|
5
|
+
:cumulative_code_block_length
|
6
|
+
]
|
7
|
+
def initialize(noko_or_html)
|
8
|
+
@noko = Util.to_noko(noko_or_html)
|
9
|
+
end
|
10
|
+
|
11
|
+
def cumulative_code_block_length
|
12
|
+
all_code_blocks.inner_html.length
|
13
|
+
end
|
14
|
+
|
15
|
+
def number_of_links
|
16
|
+
all_links.length
|
17
|
+
end
|
18
|
+
|
19
|
+
def number_of_code_blocks
|
20
|
+
all_code_blocks.length
|
21
|
+
end
|
22
|
+
|
23
|
+
def number_of_paragraphs
|
24
|
+
all_paragraphs.length
|
25
|
+
end
|
26
|
+
|
27
|
+
def number_of_non_code_sections
|
28
|
+
(all_paragraphs + all_lists).length
|
29
|
+
end
|
30
|
+
|
31
|
+
def code_block_to_paragraph_ratio
|
32
|
+
if number_of_paragraphs.to_f == 0.0
|
33
|
+
return 0
|
34
|
+
end
|
35
|
+
number_of_code_blocks.to_f / number_of_paragraphs.to_f
|
36
|
+
end
|
37
|
+
|
38
|
+
def number_of_internal_links
|
39
|
+
all_links.select {|a|
|
40
|
+
internal_link?(a)
|
41
|
+
}.count
|
42
|
+
end
|
43
|
+
|
44
|
+
def number_of_external_links
|
45
|
+
all_links.reject {|a|
|
46
|
+
internal_link?(a)
|
47
|
+
}.count
|
48
|
+
end
|
49
|
+
|
50
|
+
def has_lists?
|
51
|
+
all_lists.length > 0
|
52
|
+
end
|
53
|
+
|
54
|
+
def has_images?
|
55
|
+
number_of_images > 0
|
56
|
+
end
|
57
|
+
|
58
|
+
def number_of_images
|
59
|
+
all_images.count
|
60
|
+
end
|
61
|
+
|
62
|
+
def has_gifs?
|
63
|
+
number_of_gifs > 0
|
64
|
+
end
|
65
|
+
|
66
|
+
def number_of_gifs
|
67
|
+
all_gifs.length
|
68
|
+
end
|
69
|
+
|
70
|
+
def has_tables?
|
71
|
+
!all_tables.empty?
|
72
|
+
end
|
73
|
+
|
74
|
+
def inspect
|
75
|
+
"#<#{self.class}>"
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
def all_links
|
80
|
+
@noko.search('a')
|
81
|
+
end
|
82
|
+
|
83
|
+
def all_code_blocks
|
84
|
+
@noko.search('pre')
|
85
|
+
end
|
86
|
+
|
87
|
+
def all_paragraphs
|
88
|
+
@noko.search('p')
|
89
|
+
end
|
90
|
+
|
91
|
+
def all_lists
|
92
|
+
@noko.search('ol') + @noko.search('ul')
|
93
|
+
end
|
94
|
+
|
95
|
+
def all_images
|
96
|
+
@noko.search('img')
|
97
|
+
end
|
98
|
+
|
99
|
+
def all_gifs
|
100
|
+
all_images.select {|a|
|
101
|
+
source_attributes = ['src', 'data-canonical-src']
|
102
|
+
source_attributes.map {|_attr|
|
103
|
+
a[_attr] && a[_attr].downcase.include?(".gif")
|
104
|
+
}.any?
|
105
|
+
}
|
106
|
+
end
|
107
|
+
|
108
|
+
def all_tables
|
109
|
+
@noko.search('table')
|
110
|
+
end
|
111
|
+
|
112
|
+
def internal_link?(a)
|
113
|
+
external_prefixes = %w{http}
|
114
|
+
href = a['href'].downcase
|
115
|
+
|
116
|
+
return true if href.include?("://github") || href.include?("github.io")
|
117
|
+
|
118
|
+
external_prefixes.select {|prefix|
|
119
|
+
href.start_with?(prefix)
|
120
|
+
}.any?
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module ReadmeScore
|
2
|
+
class Document
|
3
|
+
class Parser
|
4
|
+
def initialize(markdown)
|
5
|
+
@markdown = markdown
|
6
|
+
end
|
7
|
+
|
8
|
+
def to_html
|
9
|
+
parser.render(@markdown)
|
10
|
+
end
|
11
|
+
|
12
|
+
def parser
|
13
|
+
@@parser ||= Redcarpet::Markdown.new(
|
14
|
+
Redcarpet::Render::HTML,
|
15
|
+
no_intra_emphasis: true,
|
16
|
+
autolink: true,
|
17
|
+
fenced_code_blocks: true,
|
18
|
+
tables: true,
|
19
|
+
strikethrough: true
|
20
|
+
)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module ReadmeScore
|
2
|
+
class Document
|
3
|
+
class Score
|
4
|
+
|
5
|
+
SCORE_METRICS = [
|
6
|
+
{
|
7
|
+
metric: :number_of_code_blocks,
|
8
|
+
description: "Number of code blocks",
|
9
|
+
value_per: 5,
|
10
|
+
max: 40
|
11
|
+
},
|
12
|
+
{
|
13
|
+
metric: :number_of_non_code_sections,
|
14
|
+
description: "Number of non-code sections",
|
15
|
+
value_per: 5,
|
16
|
+
max: 30
|
17
|
+
},
|
18
|
+
{
|
19
|
+
metric: :has_lists?,
|
20
|
+
description: "Has any lists?",
|
21
|
+
value: 10
|
22
|
+
},
|
23
|
+
{
|
24
|
+
metric: :number_of_images,
|
25
|
+
description: "Number of images",
|
26
|
+
value_per: 5,
|
27
|
+
max: 15
|
28
|
+
},
|
29
|
+
{
|
30
|
+
metric: :number_of_gifs,
|
31
|
+
description: "Number of GIFs",
|
32
|
+
value_per: 5,
|
33
|
+
max: 15
|
34
|
+
},
|
35
|
+
{
|
36
|
+
metric: :cumulative_code_block_length,
|
37
|
+
description: "Amount of code",
|
38
|
+
value_per: 0.0009475244447271192,
|
39
|
+
max: 10
|
40
|
+
},
|
41
|
+
{
|
42
|
+
metric_name: :low_code_block_penalty,
|
43
|
+
description: "Penalty for lack of code blocks",
|
44
|
+
metric: :number_of_code_blocks,
|
45
|
+
if_less_than: 3,
|
46
|
+
value: -10
|
47
|
+
}
|
48
|
+
]
|
49
|
+
|
50
|
+
attr_accessor :metrics
|
51
|
+
|
52
|
+
def initialize(metrics)
|
53
|
+
@metrics = metrics
|
54
|
+
end
|
55
|
+
|
56
|
+
def score_breakdown(as_description = false)
|
57
|
+
breakdown = {}
|
58
|
+
SCORE_METRICS.each { |h|
|
59
|
+
metric_option = OpenStruct.new(h)
|
60
|
+
metric_name = metric_option.metric_name || metric_option.metric
|
61
|
+
metric_score_value = 0
|
62
|
+
# points for each occurance
|
63
|
+
if metric_option.value_per
|
64
|
+
metric_score_value = [metrics.send(metric_option.metric) * metric_option.value_per, metric_option.max].min
|
65
|
+
elsif metric_option.if_less_than
|
66
|
+
if metrics.send(metric_option.metric) < metric_option.if_less_than
|
67
|
+
metric_score_value = metric_option.value
|
68
|
+
end
|
69
|
+
else
|
70
|
+
metric_score_value = metrics.send(metric_option.metric) ? metric_option.value : 0
|
71
|
+
end
|
72
|
+
if as_description
|
73
|
+
breakdown[metric_option.description] = [metric_score_value, metric_option.max || metric_option.value]
|
74
|
+
else
|
75
|
+
breakdown[metric_name] = metric_score_value
|
76
|
+
end
|
77
|
+
}
|
78
|
+
breakdown
|
79
|
+
end
|
80
|
+
alias_method :breakdown, :score_breakdown
|
81
|
+
|
82
|
+
def human_breakdown
|
83
|
+
score_breakdown(true)
|
84
|
+
end
|
85
|
+
|
86
|
+
def total_score
|
87
|
+
score = 0
|
88
|
+
score_breakdown.each {|metric, points|
|
89
|
+
score += points.to_i
|
90
|
+
}
|
91
|
+
[[score, 100].min, 0].max
|
92
|
+
end
|
93
|
+
alias_method :to_i, :total_score
|
94
|
+
|
95
|
+
def to_f
|
96
|
+
to_i.to_f
|
97
|
+
end
|
98
|
+
|
99
|
+
def inspect
|
100
|
+
"#<#{self.class} - #{total_score}>"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|