webmention 0.1.6 → 1.0.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.
@@ -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