webmention 0.1.6 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,190 +1,47 @@
1
1
  module Webmention
2
2
  class Client
3
- # Public: Returns a URI of the url initialized with.
4
- attr_reader :url
3
+ def initialize(source)
4
+ raise ArgumentError, "source must be a String (given #{source.class.name})" unless source.is_a?(String)
5
5
 
6
- # Public: Returns an array of links contained within the url.
7
- attr_reader :links
6
+ @source = source
8
7
 
9
- # Public: Create a new client
10
- #
11
- # url - The url you want us to crawl.
12
- def initialize(url)
13
- @url = URI.parse(url)
14
- @links ||= Set.new
15
-
16
- unless Webmention::Client.valid_http_url? @url
17
- raise ArgumentError.new "#{@url} is not a valid HTTP or HTTPS URI."
18
- end
19
- end
20
-
21
- # Public: Crawl the url this client was initialized with.
22
- #
23
- # Returns the number of links found.
24
- def crawl
25
- @links ||= Set.new
26
- if @url.nil?
27
- raise ArgumentError.new "url is nil."
28
- end
29
-
30
- Nokogiri::HTML(open(self.url)).css('.h-entry a').each do |link|
31
- link = link.attribute('href').to_s
32
- if Webmention::Client.valid_http_url? link
33
- @links.add link
34
- end
35
- end
36
-
37
- return @links.count
8
+ raise ArgumentError, 'source must be an absolute URL (e.g. https://example.com)' unless source_uri.absolute?
38
9
  end
39
10
 
40
- # Public: Sends mentions to each of the links found in the page.
41
- #
42
- # Returns the number of links mentioned.
43
- def send_mentions
44
- if self.links.nil? or self.links.empty?
45
- self.crawl
46
- end
47
-
48
- cnt = 0
49
- self.links.each do |link|
50
- endpoint = Webmention::Client.supports_webmention? link
51
- if endpoint
52
- cnt += 1 if Webmention::Client.send_mention endpoint, self.url, link
53
- end
54
- end
55
-
56
- return cnt
11
+ def send_all_mentions
12
+ mentioned_urls.each_with_object({}) { |url, hash| hash[url] = send_mention(url) }
57
13
  end
58
14
 
59
- # Public: Send a mention to an endoint about a link from a link.
60
- #
61
- # endpoint - URL to send mention to.
62
- # source - Source of mention (your page).
63
- # target - The link that was mentioned in the source page.
64
- #
65
- # Returns a boolean.
66
- def self.send_mention endpoint, source, target, full_response=false
67
- data = {
68
- :source => source,
69
- :target => target,
70
- }
71
-
72
- # Ensure the endpoint is an absolute URL
73
- endpoint = absolute_endpoint endpoint, target
15
+ def mentioned_urls
16
+ raise UnsupportedMimeTypeError, "Unsupported MIME Type: #{source_response.mime_type}" unless parser_for_mime_type
74
17
 
75
- begin
76
- response = HTTParty.post(endpoint, {
77
- :body => data
78
- })
79
-
80
- if full_response
81
- return response
82
- else
83
- return response.code == 200 || response.code == 202
84
- end
85
- rescue
86
- return false
87
- end
18
+ @mentioned_urls ||= parser_for_mime_type.new(source_response).results
88
19
  end
89
20
 
90
- # Public: Fetch a url and check if it supports webmention
91
- #
92
- # url - URL to check
93
- #
94
- # Returns false if does not support webmention, returns string
95
- # of url to ping if it does.
96
- def self.supports_webmention? url
97
- return false if !Webmention::Client.valid_http_url? url
98
-
99
- doc = nil
100
-
101
- begin
102
- response = HTTParty.get(url, {
103
- :timeout => 3,
104
- :headers => {
105
- 'User-Agent' => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.57 Safari/537.36 (https://rubygems.org/gems/webmention)",
106
- 'Accept' => '*/*'
107
- }
108
- })
109
-
110
- # First check the HTTP Headers
111
- if !response.headers['Link'].nil?
112
- endpoint = self.discover_webmention_endpoint_from_header response.headers['Link']
113
- return endpoint if endpoint
114
- end
21
+ def send_mention(target)
22
+ endpoint = IndieWeb::Endpoints.get(target).webmention
115
23
 
116
- # Do we support webmention?
117
- endpoint = self.discover_webmention_endpoint_from_html response.body.to_s
118
- return endpoint if endpoint
24
+ return unless endpoint
119
25
 
120
- # TODO: Move to supports_pingback? method
121
- # Last chance, do we support Pingback?
122
- # if !doc.css('link[rel="pingback"]').empty?
123
- # return doc.css('link[rel="pingback"]').attribute("href").value
124
- # end
125
-
126
- rescue EOFError
127
- rescue Errno::ECONNRESET
128
- end
129
-
130
- return false
26
+ HttpRequest.post(Addressable::URI.parse(endpoint), source: @source, target: target)
27
+ rescue IndieWeb::Endpoints::IndieWebEndpointsError => exception
28
+ raise Webmention.const_get(exception.class.name.split('::').last), exception
131
29
  end
132
30
 
133
- def self.discover_webmention_endpoint_from_html html
134
- doc = Nokogiri::HTML(html)
135
- if !doc.css('[rel~="webmention"]').css('[href]').empty?
136
- doc.css('[rel~="webmention"]').css('[href]').attribute("href").value
137
- elsif !doc.css('[rel="http://webmention.org/"]').css('[href]').empty?
138
- doc.css('[rel="http://webmention.org/"]').css('[href]').attribute("href").value
139
- elsif !doc.css('[rel="http://webmention.org"]').css('[href]').empty?
140
- doc.css('[rel="http://webmention.org"]').css('[href]').attribute("href").value
141
- else
142
- false
143
- end
144
- end
31
+ private
145
32
 
146
- def self.discover_webmention_endpoint_from_header header
147
- if matches = header.match(%r{<([^>]+)>; rel="[^"]*\s?webmention\s?[^"]*"})
148
- return matches[1]
149
- elsif matches = header.match(%r{<([^>]+)>; rel=webmention})
150
- return matches[1]
151
- elsif matches = header.match(%r{rel="[^"]*\s?webmention\s?[^"]*"; <([^>]+)>})
152
- return matches[1]
153
- elsif matches = header.match(%r{rel=webmention; <([^>]+)>})
154
- return matches[1]
155
- elsif matches = header.match(%r{<([^>]+)>; rel="http://webmention\.org/?"})
156
- return matches[1]
157
- elsif matches = header.match(%r{rel="http://webmention\.org/?"; <([^>]+)>})
158
- return matches[1]
159
- end
160
- return false
33
+ def parser_for_mime_type
34
+ @parser_for_mime_type ||= Parsers.registered[source_response.mime_type]
161
35
  end
162
36
 
163
- # Public: Use URI to parse a url and check if it is HTTP or HTTPS.
164
- #
165
- # url - URL to check
166
- #
167
- # Returns a boolean.
168
- def self.valid_http_url? url
169
- if url.is_a? String
170
- url = URI.parse(url)
171
- end
172
-
173
- return (url.is_a? URI::HTTP or url.is_a? URI::HTTPS)
37
+ def source_response
38
+ @source_response ||= HttpRequest.get(source_uri)
174
39
  end
175
40
 
176
- # Public: Takes an endpoint and ensures an absolute URL is returned
177
- #
178
- # endpoint - Endpoint which may be an absolute or relative URL
179
- # url - URL of the webmention
180
- #
181
- # Returns original endpoint if it is already an absolute URL; constructs
182
- # new absolute URL using relative endpoint if not
183
- def self.absolute_endpoint endpoint, url
184
- unless Webmention::Client.valid_http_url? endpoint
185
- endpoint = URI.join(url, endpoint).to_s
186
- end
187
- endpoint
41
+ def source_uri
42
+ @source_uri ||= Addressable::URI.parse(@source)
43
+ rescue Addressable::URI::InvalidURIError => exception
44
+ raise InvalidURIError, exception
188
45
  end
189
46
  end
190
47
  end
@@ -0,0 +1,15 @@
1
+ module Webmention
2
+ class WebmentionClientError < StandardError; end
3
+
4
+ class ArgumentError < WebmentionClientError; end
5
+
6
+ class ConnectionError < WebmentionClientError; end
7
+
8
+ class InvalidURIError < WebmentionClientError; end
9
+
10
+ class TimeoutError < WebmentionClientError; end
11
+
12
+ class TooManyRedirectsError < WebmentionClientError; end
13
+
14
+ class UnsupportedMimeTypeError < WebmentionClientError; end
15
+ end
@@ -0,0 +1,41 @@
1
+ module Webmention
2
+ class HttpRequest
3
+ # Defaults derived from Webmention specification examples
4
+ # https://www.w3.org/TR/webmention/#limits-on-get-requests
5
+ # rubocop:disable Layout/AlignHash
6
+ HTTP_CLIENT_OPTS = {
7
+ follow: {
8
+ max_hops: 20
9
+ },
10
+ headers: {
11
+ accept: '*/*',
12
+ user_agent: 'Webmention Client (https://rubygems.org/gems/webmention)'
13
+ },
14
+ timeout_options: {
15
+ connect_timeout: 5,
16
+ read_timeout: 5
17
+ }
18
+ }.freeze
19
+ # rubocop:enable Layout/AlignHash
20
+
21
+ class << self
22
+ def get(uri)
23
+ request(:get, uri)
24
+ end
25
+
26
+ def post(uri, **options)
27
+ request(:post, uri, form: options)
28
+ end
29
+
30
+ private
31
+
32
+ def request(method, uri, **options)
33
+ HTTP::Client.new(HTTP_CLIENT_OPTS).request(method, uri, options)
34
+ rescue HTTP::ConnectionError,
35
+ HTTP::TimeoutError,
36
+ HTTP::Redirector::TooManyRedirectsError => exception
37
+ raise Webmention.const_get(exception.class.name.split('::').last), exception
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ module Webmention
2
+ module Parsers
3
+ extend Registerable
4
+
5
+ class BaseParser
6
+ def initialize(response)
7
+ raise ArgumentError, "response must be an HTTP::Response (given #{response.class.name})" unless response.is_a?(HTTP::Response)
8
+
9
+ @response = response
10
+
11
+ raise UnsupportedMimeTypeError, "Unsupported MIME Type: #{response.mime_type}" unless self.class.mime_types.include?(response.mime_type)
12
+ end
13
+
14
+ def results
15
+ @results ||= parse_response_body
16
+ end
17
+
18
+ private
19
+
20
+ def response_body
21
+ @response_body ||= @response.body.to_s
22
+ end
23
+
24
+ def response_url
25
+ @response_url ||= @response.uri.to_s
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,73 @@
1
+ module Webmention
2
+ module Parsers
3
+ class HtmlParser < BaseParser
4
+ def self.mime_types
5
+ ['text/html']
6
+ end
7
+
8
+ Parsers.register(self)
9
+
10
+ HTML_ATTRIBUTE_MAP = {
11
+ cite: %w[blockquote del ins q],
12
+ data: %w[object],
13
+ href: %w[a area],
14
+ poster: %w[video],
15
+ src: %w[audio embed img source track video],
16
+ srcset: %w[img source]
17
+ }.freeze
18
+
19
+ CSS_SELECTORS_MAP = HTML_ATTRIBUTE_MAP.each_with_object({}) do |(attribute, elements), hash|
20
+ hash[attribute] = elements.map { |element| "#{element}[#{attribute}]" }
21
+ end
22
+
23
+ private
24
+
25
+ def doc
26
+ @doc ||= Nokogiri::HTML(response_body)
27
+ end
28
+
29
+ # Parse an HTML string for URLs
30
+ #
31
+ # @return [Array] the URLs
32
+ def parse_response_body
33
+ CSS_SELECTORS_MAP
34
+ .each_with_object([]) { |(*args), array| array << search_node(*args) }
35
+ .flatten
36
+ .map { |url| Absolutely.to_abs(base: response_url, relative: url) }
37
+ .uniq
38
+ end
39
+
40
+ def root_node
41
+ @root_node ||= doc.css('.h-entry .e-content').first || doc.css('.h-entry').first || doc.css('body')
42
+ end
43
+
44
+ def search_node(attribute, selectors)
45
+ NodeParser.nodes_from(root_node, selectors).map { |node| NodeParser.values_from(node, attribute) }.reject(&:empty?)
46
+ end
47
+
48
+ module NodeParser
49
+ class << self
50
+ # Search a node for matching elements
51
+ #
52
+ # @param node [Nokogiri::XML::Element]
53
+ # @param selectors [Array]
54
+ # @return [Nokogiri::XML::NodeSet]
55
+ def nodes_from(node, selectors)
56
+ node.css(*selectors)
57
+ end
58
+
59
+ # Derive attribute values from a single node
60
+ #
61
+ # @param node [Nokogiri::XML::Element]
62
+ # @param attribute [Symbol]
63
+ # @return [Array] the HTML attribute values
64
+ def values_from(node, attribute)
65
+ return Array(node[attribute]) unless attribute == :srcset
66
+
67
+ node[attribute].split(',').map { |value| value.strip.match(/^\S+/).to_s }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,11 @@
1
+ module Webmention
2
+ module Registerable
3
+ def register(klass)
4
+ klass.mime_types.each { |mime_type| registered[mime_type] = klass }
5
+ end
6
+
7
+ def registered
8
+ @registered ||= {}
9
+ end
10
+ end
11
+ end
@@ -1,3 +1,3 @@
1
1
  module Webmention
2
- VERSION = "0.1.6"
2
+ VERSION = '1.0.0'.freeze
3
3
  end
@@ -1,34 +1,43 @@
1
- lib = File.expand_path('../lib', __FILE__)
1
+ lib = File.expand_path('lib', __dir__)
2
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+
3
4
  require 'webmention/version'
4
5
 
5
- Gem::Specification.new do |s|
6
- s.name = 'webmention'
7
- s.version = Webmention::VERSION
8
- s.date = '2017-06-27'
9
- s.homepage = 'https://github.com/indieweb/mention-client-ruby'
10
- s.summary = 'A gem for sending webmention (and pingback) notifications'
11
- s.authors = [
12
- 'Aaron Parecki',
13
- 'Nat Welch'
14
- ]
15
-
16
- s.email = 'aaron@parecki.com'
17
-
18
- s.files = `git ls-files`.split($/)
19
- s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
20
- s.test_files = s.files.grep(%r{^(test|spec|features)/})
21
- s.require_paths = ['lib']
22
-
23
- s.required_ruby_version = '>= 1.9.3'
24
-
25
- s.add_dependency 'json'
26
- s.add_dependency 'nokogiri'
27
- s.add_dependency 'httparty', '~> 0.15.5'
28
- s.add_dependency 'link_header', '~> 0.0.8'
29
-
30
- s.add_development_dependency 'bundler'
31
- s.add_development_dependency 'rake'
32
- s.add_development_dependency 'minitest'
33
- s.add_development_dependency 'webmock'
6
+ Gem::Specification.new do |spec|
7
+ spec.required_ruby_version = ['>= 2.4', '< 2.7']
8
+
9
+ spec.name = 'webmention'
10
+ spec.version = Webmention::VERSION
11
+ spec.authors = ['Aaron Parecki', 'Nat Welch']
12
+ spec.email = ['aaron@parecki.com']
13
+
14
+ spec.summary = 'Webmention notification client'
15
+ spec.description = 'A Ruby gem for sending webmention notifications.'
16
+ spec.homepage = 'https://github.com/indieweb/webmention-client-ruby'
17
+ spec.license = 'Apache-2.0'
18
+
19
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(bin|test)/}) }
20
+
21
+ spec.require_paths = ['lib']
22
+
23
+ spec.metadata = {
24
+ 'bug_tracker_uri' => "#{spec.homepage}/issues",
25
+ 'changelog_uri' => "#{spec.homepage}/blob/v#{spec.version}/CHANGELOG.md"
26
+ }
27
+
28
+ spec.add_development_dependency 'minitest', '~> 5.11'
29
+ spec.add_development_dependency 'minitest-reporters', '~> 1.3'
30
+ spec.add_development_dependency 'rake', '~> 12.3'
31
+ spec.add_development_dependency 'reek', '~> 5.4'
32
+ spec.add_development_dependency 'rubocop', '~> 0.72.0'
33
+ spec.add_development_dependency 'rubocop-performance', '~> 1.4'
34
+ spec.add_development_dependency 'simplecov', '~> 0.17.0'
35
+ spec.add_development_dependency 'simplecov-console', '~> 0.5.0'
36
+ spec.add_development_dependency 'webmock', '~> 3.6'
37
+
38
+ spec.add_runtime_dependency 'absolutely', '~> 3.0'
39
+ spec.add_runtime_dependency 'addressable', '~> 2.6'
40
+ spec.add_runtime_dependency 'http', '~> 5.0.0.pre'
41
+ spec.add_runtime_dependency 'indieweb-endpoints', '~> 0.6.0'
42
+ spec.add_runtime_dependency 'nokogiri', '~> 1.10'
34
43
  end