burly 0.3.1 → 0.4.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: 4ee29f60f553f2beda4f2904c9451620e3f51629b3ca3a0f32889ac07e9623be
4
- data.tar.gz: 1711b71044722dfc61876645ba51c6e66fe65817e59927ce02a3d26e9f1a5ecc
3
+ metadata.gz: 0f2cd8eabc523f6e281042e03a815b8e71738e8befa93ec0e33805803cc3f90a
4
+ data.tar.gz: 68a5db717b7d48c863aaaeccf89885ecf87fb7ec34d43682d3926e4015f45a02
5
5
  SHA512:
6
- metadata.gz: f4d6527105e193e904fcfb027b0399971a968db52b7ef974f273d1056b8f06a2a343eb6cb0ed10ce3cf577c124f1fdb35975c1e6b9f607c86286470052606800
7
- data.tar.gz: a6acfe754967fcd61635c7fc8d7d10950d5d4a951d092ca556156fe3d3e21e8477cf2431f3f76d7bbf1226c3c50b2217a96a67a3a22e8b01ec15fc9b15221278
6
+ metadata.gz: 32401d0e85a4c205ef96bf54e21cda6f1c3ce09353bd8c18d983b618e0dfef33869cf97e2386b934ebf730188e4bb088033d0f3abf9ab79645198652c8acdc57
7
+ data.tar.gz: e07cd49d7f9439af7334def57a8b49c5c0023ef10f742c3879e25de8c06f96f251ae5889cab06ac0a58db1cf8e27c5ffe6c81335e9b2cd2b5ea30a1e49b5d3d5
data/README.md CHANGED
@@ -8,9 +8,9 @@ A Ruby gem for extracting URLs from HTML, JSON, and plaintext documents.
8
8
 
9
9
  ## Getting Started
10
10
 
11
- Before installing and using Burly, you'll want to have [Ruby](https://www.ruby-lang.org) 2.6 (or newer) installed. Using a Ruby version managment tool like [rbenv](https://github.com/rbenv/rbenv), [chruby](https://github.com/postmodern/chruby), or [rvm](https://github.com/rvm/rvm) is recommended.
11
+ Before installing and using Burly, you'll want to have [Ruby](https://www.ruby-lang.org) 2.6 (or newer) installed. Using a Ruby version managment tool like [rv](https://github.com/spinel-coop/rv) or [rbenv](https://github.com/rbenv/rbenv) is recommended.
12
12
 
13
- Burly is developed using Ruby 3.4 and is tested against additional Ruby versions using [Forgejo Actions](https://codeberg.org/jgarber/burly/actions).
13
+ Burly is developed using Ruby 4.0 and is tested against additional Ruby versions using [Forgejo Actions](https://codeberg.org/jgarber/burly/actions).
14
14
 
15
15
  ## Installation
16
16
 
@@ -72,6 +72,28 @@ In all cases, Burly will search for nodes matching the provided selector(s) and
72
72
  > Burly.parse(response, context: [".h-entry .e-content", ".h-entry", "body"], mime_type: "text/html")
73
73
  > ```
74
74
 
75
+ ## Extending Burly
76
+
77
+ New parsers may be added to Burly by subclassing `Burly::Parsers::Base` and implementing an instance method named `parse` that returns an Array of Strings.
78
+
79
+ ```ruby
80
+ require "burly"
81
+ require "csv"
82
+
83
+ class CsvParser < Burly::Parsers::Base
84
+ @mime_types = ["text/csv"]
85
+
86
+ def parse
87
+ doc = CSV.parse(@document, headers: true)
88
+
89
+ doc.flat_map(&:fields).grep(uri_regexp)
90
+ end
91
+ end
92
+ ```
93
+
94
+ > [!TIP]
95
+ > The private `uri_regexp` method available to `Burly::Parsers::Base` subclasses memoizes the regular expression provided by [`URI::RFC2396_Parser#make_regexp`](https://docs.ruby-lang.org/en/master/URI/RFC2396_Parser.html#method-i-make_regexp).
96
+
75
97
  ## License
76
98
 
77
99
  This project is freely available under the [MIT License](https://opensource.org/license/MIT).
data/burly.gemspec CHANGED
@@ -4,7 +4,7 @@ Gem::Specification.new do |spec|
4
4
  spec.required_ruby_version = ">= 2.6"
5
5
 
6
6
  spec.name = "burly"
7
- spec.version = "0.3.1"
7
+ spec.version = "0.4.0"
8
8
  spec.authors = ["Jason Garber"]
9
9
  spec.email = ["jason@sixtwothree.org"]
10
10
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Burly
4
+ module Parsers
5
+ # @api private
6
+ class Base
7
+ class << self
8
+ # @return [Array<String>]
9
+ attr_reader :mime_types
10
+ end
11
+
12
+ # @return [Array<Class>]
13
+ def self.descendants
14
+ ObjectSpace.each_object(Class).select { |klass| klass < self }
15
+ end
16
+
17
+ # @param document (see Burly.parse)
18
+ def initialize(document, **_kwargs)
19
+ @document = document.to_s
20
+ end
21
+
22
+ # @raise [NotImplementedError]
23
+ def parse
24
+ raise NotImplementedError
25
+ end
26
+
27
+ private
28
+
29
+ def uri_parser
30
+ @uri_parser ||= URI::RFC2396_Parser.new
31
+ end
32
+
33
+ def uri_regexp
34
+ @uri_regexp ||= uri_parser.make_regexp(["http", "https"])
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Burly
4
+ module Parsers
5
+ # @api private
6
+ class Html < Base
7
+ @mime_types = ["text/html"]
8
+
9
+ # An array of HTML +srcset+ attributes.
10
+ #
11
+ # @see https://html.spec.whatwg.org/#srcset-attributes HTML Standard § 4.8.4.2.1 Srcset attributes
12
+ SRCSET_ATTRIBUTES = ["imagesrcset", "srcset"].freeze
13
+
14
+ # A map of HTML URL attributes and their associated element names.
15
+ #
16
+ # @see https://html.spec.whatwg.org/#attributes-3 HTML Standard § Attributes
17
+ #
18
+ # rubocop:disable-next Layout/HashAlignment
19
+ URL_ATTRIBUTES_MAP = {
20
+ "action" => ["form"],
21
+ "cite" => ["blockquote", "del", "ins", "q"],
22
+ "data" => ["object"],
23
+ "formaction" => ["button", "input"],
24
+ "href" => ["a", "area", "base", "link"],
25
+ "imagesrcset" => ["link"],
26
+ "ping" => ["a", "area"],
27
+ "poster" => ["video"],
28
+ "src" => ["audio", "embed", "iframe", "img", "input", "script", "source", "track", "video"],
29
+ "srcset" => ["img", "source"],
30
+ }.freeze
31
+
32
+ # @param document (see Burly.parse)
33
+ # @param context [String, Array<String>]
34
+ def initialize(document, context: nil)
35
+ @context = context
36
+
37
+ super
38
+ end
39
+
40
+ # Parse an HTML document for absolute or relative URLs.
41
+ #
42
+ # @return [Array<String>]
43
+ def parse
44
+ context_node
45
+ .xpath(*xpaths_from_url_attributes_map)
46
+ .lazy
47
+ .flat_map { |node| parse_attribute_node(node) }
48
+ .to_a
49
+ end
50
+
51
+ private
52
+
53
+ # @return [Nokogiri::HTML5::Document, Nokogiri::XML::Element]
54
+ def context_node
55
+ @context_node ||= doc.search(*Array(@context)).first || doc
56
+ end
57
+
58
+ # @return [Nokogiri::HTML5::Document]
59
+ def doc
60
+ @doc ||= Nokogiri.HTML5(@document)
61
+ end
62
+
63
+ # @return [String, Array<String>]
64
+ def parse_attribute_node(node)
65
+ return node.value.strip unless SRCSET_ATTRIBUTES.include?(node.name)
66
+
67
+ node.value.split(/\s*,\s*/).lazy.flat_map { |value| value.strip.split(/\s|$/)[0] }
68
+ end
69
+
70
+ # @return [Array<String>]
71
+ def xpaths_from_url_attributes_map
72
+ URL_ATTRIBUTES_MAP.each_with_object([]) do |(attribute, names), xpaths|
73
+ xpaths.concat(names.map { |name| ".//#{name}/@#{attribute}" })
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Burly
4
+ module Parsers
5
+ # @api private
6
+ class Json < Base
7
+ @mime_types = ["application/json"]
8
+
9
+ # Parse a JSON document for string values whose content are absolute URLs.
10
+ #
11
+ # @return [Array<String>]
12
+ def parse
13
+ recursive_parse(JSON.parse(@document)).to_a.compact
14
+ end
15
+
16
+ private
17
+
18
+ def recursive_parse(*objs)
19
+ objs.lazy.flat_map do |obj|
20
+ case obj
21
+ when Array then recursive_parse(*obj)
22
+ when Hash then recursive_parse(obj.values)
23
+ when String then obj if obj.match?(uri_regexp)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -3,16 +3,14 @@
3
3
  module Burly
4
4
  module Parsers
5
5
  # @api private
6
- class PlaintextParser < Parser
6
+ class Plaintext < Base
7
7
  @mime_types = ["text/plain"]
8
8
 
9
- Burly.register_parser(self)
10
-
11
9
  # Parse a plaintext document for absolute URLs.
12
10
  #
13
11
  # @return [Array<String>]
14
12
  def parse
15
- URI_PARSER.extract(@document, ["http", "https"])
13
+ uri_parser.extract(@document, ["http", "https"])
16
14
  end
17
15
  end
18
16
  end
data/lib/burly.rb CHANGED
@@ -5,16 +5,13 @@ require "uri"
5
5
 
6
6
  require "nokogiri"
7
7
 
8
- require_relative "burly/exceptions"
9
- require_relative "burly/parser"
8
+ require_relative "burly/parsers/base"
9
+ require_relative "burly/parsers/html"
10
+ require_relative "burly/parsers/json"
11
+ require_relative "burly/parsers/plaintext"
10
12
 
11
13
  module Burly
12
- @registered_parsers = {}
13
-
14
- class << self
15
- # @api private
16
- attr_reader :registered_parsers
17
- end
14
+ class UnsupportedMimeType < StandardError; end
18
15
 
19
16
  # Parse a document for URLs.
20
17
  #
@@ -34,19 +31,10 @@ module Burly
34
31
  #
35
32
  # @return [Array<String>]
36
33
  def self.parse(document, mime_type: "text/plain", **options)
37
- parser = registered_parsers[mime_type]
34
+ parser = Parsers::Base.descendants.find { |parser| parser.mime_types.include?(mime_type) }
38
35
 
39
36
  raise UnsupportedMimeType unless parser
40
37
 
41
38
  parser.new(document, **options).parse
42
39
  end
43
-
44
- # @api private
45
- def self.register_parser(klass)
46
- klass.mime_types.each { |mime_type| @registered_parsers[mime_type] = klass }
47
- end
48
40
  end
49
-
50
- require_relative "burly/parsers/html_parser"
51
- require_relative "burly/parsers/json_parser"
52
- require_relative "burly/parsers/plaintext_parser"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: burly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Garber
@@ -34,21 +34,20 @@ files:
34
34
  - README.md
35
35
  - burly.gemspec
36
36
  - lib/burly.rb
37
- - lib/burly/exceptions.rb
38
- - lib/burly/parser.rb
39
- - lib/burly/parsers/html_parser.rb
40
- - lib/burly/parsers/json_parser.rb
41
- - lib/burly/parsers/plaintext_parser.rb
37
+ - lib/burly/parsers/base.rb
38
+ - lib/burly/parsers/html.rb
39
+ - lib/burly/parsers/json.rb
40
+ - lib/burly/parsers/plaintext.rb
42
41
  homepage: https://codeberg.org/jgarber/burly
43
42
  licenses:
44
43
  - MIT
45
44
  metadata:
46
45
  bug_tracker_uri: https://codeberg.org/jgarber/burly/issues
47
- changelog_uri: https://codeberg.org/jgarber/burly/releases/tag/v0.3.1
48
- documentation_uri: https://rubydoc.info/gems/burly/0.3.1
46
+ changelog_uri: https://codeberg.org/jgarber/burly/releases/tag/v0.4.0
47
+ documentation_uri: https://rubydoc.info/gems/burly/0.4.0
49
48
  homepage_uri: https://codeberg.org/jgarber/burly
50
49
  rubygems_mfa_required: 'true'
51
- source_code_uri: https://codeberg.org/jgarber/burly/src/tag/v0.3.1
50
+ source_code_uri: https://codeberg.org/jgarber/burly/src/tag/v0.4.0
52
51
  rdoc_options: []
53
52
  require_paths:
54
53
  - lib
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Burly
4
- class UnsupportedMimeType < StandardError; end
5
- end
data/lib/burly/parser.rb DELETED
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Burly
4
- # @api private
5
- class Parser
6
- URI_PARSER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::Generic::DEFAULT_PARSER
7
-
8
- URI_REGEXP = URI_PARSER.make_regexp(["http", "https"])
9
-
10
- class << self
11
- # @return [Array<String>]
12
- attr_reader :mime_types
13
- end
14
-
15
- # @param document (see Burly.parse)
16
- def initialize(document, **_kwargs)
17
- @document = document.to_s
18
- end
19
-
20
- # @raise [NotImplementedError]
21
- def parse
22
- raise NotImplementedError
23
- end
24
- end
25
- end
@@ -1,82 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Burly
4
- module Parsers
5
- # @api private
6
- class HtmlParser < Parser
7
- @mime_types = ["text/html"]
8
-
9
- Burly.register_parser(self)
10
-
11
- # A map of HTML +srcset+ attributes and their associated element names.
12
- #
13
- # @see https://html.spec.whatwg.org/#srcset-attributes
14
- # @see https://html.spec.whatwg.org/#attributes-3
15
- SRCSET_ATTRIBUTES_MAP = {
16
- "imagesrcset" => ["link"],
17
- "srcset" => ["img", "source"],
18
- }.freeze
19
-
20
- # A map of HTML URL attributes and their associated element names.
21
- #
22
- # @see https://html.spec.whatwg.org/#attributes-3
23
- URL_ATTRIBUTES_MAP = {
24
- "action" => ["form"],
25
- "cite" => ["blockquote", "del", "ins", "q"],
26
- "data" => ["object"],
27
- "formaction" => ["button", "input"],
28
- "href" => ["a", "area", "base", "link"],
29
- "ping" => ["a", "area"],
30
- "poster" => ["video"],
31
- "src" => ["audio", "embed", "iframe", "img", "input", "script", "source", "track", "video"],
32
- }.freeze
33
-
34
- ATTRIBUTES_XPATHS =
35
- URL_ATTRIBUTES_MAP.merge(SRCSET_ATTRIBUTES_MAP).flat_map do |attribute, names|
36
- names.map { |name| ".//#{name} / @#{attribute}" }
37
- end
38
-
39
- # @param document (see Burly.parse)
40
- # @param context [String, Array<String>]
41
- def initialize(document, context: nil)
42
- @context = context
43
-
44
- super
45
- end
46
-
47
- # Parse an HTML document for absolute or relative URLs.
48
- #
49
- # @return [Array<String>]
50
- def parse
51
- attr_nodes.flat_map do |attr_node|
52
- if SRCSET_ATTRIBUTES_MAP.key?(attr_node.name)
53
- urls_from_candidate_strings(attr_node.value.split(/\s*,\s*/))
54
- else
55
- attr_node.value.strip
56
- end
57
- end
58
- end
59
-
60
- private
61
-
62
- # @return [Nokogiri::XML::NodeSet]
63
- def attr_nodes
64
- @attr_nodes ||= context_node.xpath(*ATTRIBUTES_XPATHS)
65
- end
66
-
67
- # @return [Nokogiri::HTML5::Document, Nokogiri::XML::Element]
68
- def context_node
69
- @context_node ||= doc.search(*Array(@context)).first || doc
70
- end
71
-
72
- # @return [Nokogiri::HTML5::Document]
73
- def doc
74
- @doc ||= Nokogiri.HTML5(@document)
75
- end
76
-
77
- def urls_from_candidate_strings(candidate_strings)
78
- candidate_strings.flat_map { |candidate_string| candidate_string.strip.split(/\s|$/)[0] }
79
- end
80
- end
81
- end
82
- end
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Burly
4
- module Parsers
5
- # @api private
6
- class JsonParser < Parser
7
- @mime_types = ["application/json"]
8
-
9
- Burly.register_parser(self)
10
-
11
- # Parse a JSON document for string values whose contents are absolute
12
- # URLs.
13
- #
14
- # @return [Array<String>]
15
- def parse
16
- recursive_parse(JSON.parse(@document))
17
- end
18
-
19
- private
20
-
21
- def recursive_parse(*objs)
22
- objs.flat_map do |obj|
23
- if obj.is_a?(Array)
24
- obj.map! { |value| recursive_parse(value) }
25
- obj.flatten!
26
- obj.compact!
27
-
28
- return obj
29
- end
30
-
31
- return recursive_parse(obj.values) if obj.is_a?(Hash)
32
-
33
- obj if obj.is_a?(String) && obj.match?(URI_REGEXP)
34
- end
35
- end
36
- end
37
- end
38
- end