html2rss 0.32.4 → 0.33.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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +7 -3
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  48. data/lib/html2rss/feed_resolution/options.rb +3 -7
  49. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  50. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  51. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  52. data/lib/html2rss/feed_resolution.rb +5 -9
  53. data/lib/html2rss/feed_result.rb +4 -3
  54. data/lib/html2rss/hash_util.rb +91 -90
  55. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  56. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  57. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  59. data/lib/html2rss/html/probe.rb +51 -51
  60. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  61. data/lib/html2rss/mcp/config_argument.rb +9 -2
  62. data/lib/html2rss/mcp/contract.rb +7 -5
  63. data/lib/html2rss/mcp/outcome.rb +4 -5
  64. data/lib/html2rss/mcp/runtime.rb +17 -24
  65. data/lib/html2rss/mcp/server/tools.rb +9 -9
  66. data/lib/html2rss/option.rb +48 -0
  67. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  68. data/lib/html2rss/page_recon.rb +0 -4
  69. data/lib/html2rss/recon.rb +10 -12
  70. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  71. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  72. data/lib/html2rss/request_service/budget.rb +3 -24
  73. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  74. data/lib/html2rss/request_service/context.rb +1 -3
  75. data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
  76. data/lib/html2rss/request_service/network_guard.rb +3 -2
  77. data/lib/html2rss/request_service/policy.rb +8 -26
  78. data/lib/html2rss/request_service/response.rb +3 -6
  79. data/lib/html2rss/request_session/pager/base.rb +3 -3
  80. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  81. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  82. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  83. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  84. data/lib/html2rss/scoring/engine.rb +4 -32
  85. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  86. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  87. data/lib/html2rss/selectors/json_xml.rb +33 -34
  88. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  89. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  90. data/lib/html2rss/selectors/schema_export.rb +60 -60
  91. data/lib/html2rss/selectors.rb +14 -18
  92. data/lib/html2rss/sst/node.rb +9 -3
  93. data/lib/html2rss/sst/normalizer.rb +25 -24
  94. data/lib/html2rss/sst/text.rb +53 -57
  95. data/lib/html2rss/status.rb +23 -30
  96. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  97. data/lib/html2rss/syndication/discovery.rb +117 -187
  98. data/lib/html2rss/syndication/parser.rb +92 -115
  99. data/lib/html2rss/test/enhance_audit.rb +13 -15
  100. data/lib/html2rss/test/policy.rb +9 -11
  101. data/lib/html2rss/test.rb +29 -48
  102. data/lib/html2rss/url.rb +53 -53
  103. data/lib/html2rss/version.rb +1 -1
  104. data/lib/html2rss.rb +2 -1
  105. metadata +4 -2
@@ -26,149 +26,149 @@ module Html2rss
26
26
  # Combined regex for faster matching of global assignments.
27
27
  GLOBAL_ASSIGNMENT_REGEXP = Regexp.union(GLOBAL_ASSIGNMENT_PATTERNS).freeze
28
28
 
29
- module_function
30
-
31
- # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
32
- # @return [Array<Hash, Array>] parsed JSON documents discovered in scripts
33
- def json_documents(parsed_body)
34
- # Use identity-based cache to avoid double-parsing of the same document.
35
- # WeakMap allows the Nokogiri Document (key) to be garbage collected.
36
- # rubocop:disable-next ThreadSafety/ClassInstanceVariable
37
- (@cache ||= ObjectSpace::WeakMap.new)[parsed_body] ||=
38
- script_documents(parsed_body) + assignment_documents(parsed_body)
39
- end
29
+ class << self
30
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
31
+ # @return [Array<Hash, Array>] parsed JSON documents discovered in scripts
32
+ def json_documents(parsed_body)
33
+ # Use identity-based cache to avoid double-parsing of the same document.
34
+ # WeakMap allows the Nokogiri Document (key) to be garbage collected.
35
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
36
+ (@cache ||= ObjectSpace::WeakMap.new)[parsed_body] ||=
37
+ script_documents(parsed_body) + assignment_documents(parsed_body)
38
+ end
40
39
 
41
- # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
42
- # @return [Array<Hash, Array>] JSON documents extracted from JSON script tags
43
- def script_documents(parsed_body)
44
- ::Html2rss::Html::Probe.scripts(parsed_body, ::Html2rss::Html::Probe::APPLICATION_JSON)
45
- .filter_map { parse_json(_1.text) }
46
- end
40
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
41
+ # @return [Array<Hash, Array>] JSON documents extracted from JSON script tags
42
+ def script_documents(parsed_body)
43
+ ::Html2rss::Html::Probe.scripts(parsed_body, ::Html2rss::Html::Probe::APPLICATION_JSON)
44
+ .filter_map { parse_json(_1.text) }
45
+ end
47
46
 
48
- # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
49
- # @return [Array<Hash, Array>] JSON documents extracted from global assignments
50
- def assignment_documents(parsed_body)
51
- parsed_body.css('script').filter_map { parse_assignment(_1.text) }
52
- end
47
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
48
+ # @return [Array<Hash, Array>] JSON documents extracted from global assignments
49
+ def assignment_documents(parsed_body)
50
+ parsed_body.css('script').filter_map { parse_assignment(_1.text) }
51
+ end
53
52
 
54
- # @param text [String] script text that may contain a global assignment
55
- # @return [Hash, Array, nil] parsed assignment payload when available
56
- def parse_assignment(text)
57
- payload = assignment_payload(text)
58
- parse_json(payload) if payload
59
- end
53
+ # @param text [String] script text that may contain a global assignment
54
+ # @return [Hash, Array, nil] parsed assignment payload when available
55
+ def parse_assignment(text)
56
+ payload = assignment_payload(text)
57
+ parse_json(payload) if payload
58
+ end
60
59
 
61
- # @param text [String] script text to inspect for known assignment patterns
62
- # @return [String, nil] extracted JSON-like assignment payload
63
- def assignment_payload(text)
64
- trimmed = text.to_s.strip
65
- return if trimmed.empty?
66
- return unless trimmed.match?(GLOBAL_ASSIGNMENT_REGEXP)
60
+ # @param text [String] script text to inspect for known assignment patterns
61
+ # @return [String, nil] extracted JSON-like assignment payload
62
+ def assignment_payload(text)
63
+ trimmed = text.to_s.strip
64
+ return if trimmed.empty?
65
+ return unless trimmed.match?(GLOBAL_ASSIGNMENT_REGEXP)
67
66
 
68
- payload = trimmed.sub(GLOBAL_ASSIGNMENT_REGEXP, '')
69
- extract_assignment_payload(payload)
70
- end
67
+ payload = trimmed.sub(GLOBAL_ASSIGNMENT_REGEXP, '')
68
+ extract_assignment_payload(payload)
69
+ end
71
70
 
72
- # @param text [String] text potentially containing JSON-like payloads
73
- # @return [String, nil] normalized assignment payload
74
- def extract_assignment_payload(text)
75
- extract_json_block(text) || text
76
- end
71
+ # @param text [String] text potentially containing JSON-like payloads
72
+ # @return [String, nil] normalized assignment payload
73
+ def extract_assignment_payload(text)
74
+ extract_json_block(text) || text
75
+ end
77
76
 
78
- # @param text [String] text potentially containing JSON blocks
79
- # @return [String, nil] extracted JSON block spanning balanced brackets
80
- def extract_json_block(text)
81
- start_index = text.index(/[\[{]/)
82
- return unless start_index
77
+ # @param text [String] text potentially containing JSON blocks
78
+ # @return [String, nil] extracted JSON block spanning balanced brackets
79
+ def extract_json_block(text)
80
+ start_index = text.index(/[\[{]/)
81
+ return unless start_index
83
82
 
84
- stop_index = scan_for_json_end(text, start_index)
85
- text[start_index..stop_index] if stop_index
86
- end
83
+ stop_index = scan_for_json_end(text, start_index)
84
+ text[start_index..stop_index] if stop_index
85
+ end
87
86
 
88
- # @param text [String] text starting with a JSON object/array opening token
89
- # @param start_index [Integer] index where JSON-like content starts
90
- # @return [Integer, nil] index where the balanced JSON payload ends
91
- def scan_for_json_end(text, start_index) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
92
- stack = []
93
- in_string = false
94
- escape = false
95
-
96
- i = start_index
97
- len = text.length
98
- while i < len
99
- char = text[i]
100
-
101
- if in_string
102
- if escape
103
- escape = false
104
- elsif char == '\\'
105
- escape = true
106
- elsif char == '"'
107
- in_string = false
108
- end
109
- else
110
- case char
111
- when '"' then in_string = true
112
- when '{' then stack << '}'
113
- when '[' then stack << ']'
114
- when '}', ']'
115
- expected = stack.pop
116
- return i if expected == char && stack.empty?
87
+ # @param text [String] text starting with a JSON object/array opening token
88
+ # @param start_index [Integer] index where JSON-like content starts
89
+ # @return [Integer, nil] index where the balanced JSON payload ends
90
+ def scan_for_json_end(text, start_index) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
91
+ stack = []
92
+ in_string = false
93
+ escape = false
94
+
95
+ i = start_index
96
+ len = text.length
97
+ while i < len
98
+ char = text[i]
99
+
100
+ if in_string
101
+ if escape
102
+ escape = false
103
+ elsif char == '\\'
104
+ escape = true
105
+ elsif char == '"'
106
+ in_string = false
107
+ end
108
+ else
109
+ case char
110
+ when '"' then in_string = true
111
+ when '{' then stack << '}'
112
+ when '[' then stack << ']'
113
+ when '}', ']'
114
+ expected = stack.pop
115
+ return i if expected == char && stack.empty?
116
+ end
117
117
  end
118
+ i += 1
118
119
  end
119
- i += 1
120
- end
121
120
 
122
- nil
123
- end
121
+ nil
122
+ end
124
123
 
125
- # @param payload [String, nil] JSON payload to parse
126
- # @return [Hash, Array, nil] parsed payload or nil when parsing fails
127
- def parse_json(payload)
128
- return unless payload
124
+ # @param payload [String, nil] JSON payload to parse
125
+ # @return [Hash, Array, nil] parsed payload or nil when parsing fails
126
+ def parse_json(payload)
127
+ return unless payload
129
128
 
130
- JSON.parse(payload, symbolize_names: true)
131
- rescue JSON::ParserError => error
132
- parse_js_object(payload, error)
133
- end
129
+ JSON.parse(payload, symbolize_names: true)
130
+ rescue JSON::ParserError => error
131
+ parse_js_object(payload, error)
132
+ end
134
133
 
135
- # @param payload [String] JavaScript object-literal payload
136
- # @param _original_error [JSON::ParserError] original JSON parse error
137
- # @return [Hash, Array, nil] parsed payload after JavaScript coercion
138
- def parse_js_object(payload, _original_error)
139
- coerced = coerce_javascript_object(payload)
140
- return unless coerced
141
-
142
- # Some sites emit JavaScript object literals (unquoted keys, trailing commas).
143
- # Coerce those payloads into valid JSON so we keep the same parsing pipeline.
144
- JSON.parse(coerced, symbolize_names: true)
145
- rescue JSON::ParserError => error
146
- Html2rss::Log.debug("JsonState: failed to parse coerced JavaScript object (#{error.message})")
147
- nil
148
- end
134
+ # @param payload [String] JavaScript object-literal payload
135
+ # @param _original_error [JSON::ParserError] original JSON parse error
136
+ # @return [Hash, Array, nil] parsed payload after JavaScript coercion
137
+ def parse_js_object(payload, _original_error)
138
+ coerced = coerce_javascript_object(payload)
139
+ return unless coerced
140
+
141
+ # Some sites emit JavaScript object literals (unquoted keys, trailing commas).
142
+ # Coerce those payloads into valid JSON so we keep the same parsing pipeline.
143
+ JSON.parse(coerced, symbolize_names: true)
144
+ rescue JSON::ParserError => error
145
+ Html2rss::Log.debug("JsonState: failed to parse coerced JavaScript object (#{error.message})")
146
+ nil
147
+ end
149
148
 
150
- # @param payload [String] JavaScript object-literal payload
151
- # @return [String] JSON-compatible payload string
152
- def coerce_javascript_object(payload)
153
- string = payload.dup
149
+ # @param payload [String] JavaScript object-literal payload
150
+ # @return [String] JSON-compatible payload string
151
+ def coerce_javascript_object(payload)
152
+ string = payload.dup
154
153
 
155
- # KISS approach: mutate common JS literal quirks instead of a full parser.
156
- strip_trailing_commas(quote_unquoted_keys(string))
157
- end
154
+ # KISS approach: mutate common JS literal quirks instead of a full parser.
155
+ strip_trailing_commas(quote_unquoted_keys(string))
156
+ end
158
157
 
159
- # @param jsonish [String] JSON-like string with potentially unquoted keys
160
- # @return [String] payload with unquoted object keys quoted
161
- def quote_unquoted_keys(jsonish)
162
- jsonish.gsub(/(?<prefix>\A\s*|[{,\[]\s*)(?<key>[A-Za-z_]\w*)(?<suffix>\s*:)/) do
163
- captures = Regexp.last_match.named_captures(symbolize_names: true)
164
- "#{captures[:prefix]}\"#{captures[:key]}\"#{captures[:suffix]}"
158
+ # @param jsonish [String] JSON-like string with potentially unquoted keys
159
+ # @return [String] payload with unquoted object keys quoted
160
+ def quote_unquoted_keys(jsonish)
161
+ jsonish.gsub(/(?<prefix>\A\s*|[{,\[]\s*)(?<key>[A-Za-z_]\w*)(?<suffix>\s*:)/) do
162
+ captures = Regexp.last_match.named_captures(symbolize_names: true)
163
+ "#{captures[:prefix]}\"#{captures[:key]}\"#{captures[:suffix]}"
164
+ end
165
165
  end
166
- end
167
166
 
168
- # @param jsonish [String] JSON-like string with potential trailing commas
169
- # @return [String] payload without trailing commas before closing tokens
170
- def strip_trailing_commas(jsonish)
171
- jsonish.gsub(/,(\s*[\]}])/, '\1')
167
+ # @param jsonish [String] JSON-like string with potential trailing commas
168
+ # @return [String] payload without trailing commas before closing tokens
169
+ def strip_trailing_commas(jsonish)
170
+ jsonish.gsub(/,(\s*[\]}])/, '\1')
171
+ end
172
172
  end
173
173
  end
174
174
  end
@@ -6,47 +6,47 @@ module Html2rss
6
6
  class JsonState
7
7
  # Retrieves values from heterogeneous objects by probing multiple keys.
8
8
  module ValueFinder
9
- module_function
10
-
11
- # @param object [Hash, Array] candidate container traversed during key lookup
12
- # @param keys [Array<Symbol>] keys to probe in order
13
- # @return [Object, nil] first matching value
14
- def fetch(object, keys)
15
- case object
16
- when Hash then fetch_from_hash(object, keys)
17
- when Array then fetch_from_array(object, keys)
9
+ class << self
10
+ # @param object [Hash, Array] candidate container traversed during key lookup
11
+ # @param keys [Array<Symbol>] keys to probe in order
12
+ # @return [Object, nil] first matching value
13
+ def fetch(object, keys)
14
+ case object
15
+ when Hash then fetch_from_hash(object, keys)
16
+ when Array then fetch_from_array(object, keys)
17
+ end
18
18
  end
19
- end
20
19
 
21
- # @param hash [Hash] hash candidate traversed during key lookup
22
- # @param keys [Array<Symbol>] keys to probe in order
23
- # @return [Object, nil] first matching value from hash or nested metadata
24
- def fetch_from_hash(hash, keys)
25
- keys.each do |key|
26
- return hash[key] if hash.key?(key)
20
+ # @param hash [Hash] hash candidate traversed during key lookup
21
+ # @param keys [Array<Symbol>] keys to probe in order
22
+ # @return [Object, nil] first matching value from hash or nested metadata
23
+ def fetch_from_hash(hash, keys)
24
+ keys.each do |key|
25
+ return hash[key] if hash.key?(key)
26
+ end
27
+
28
+ fetch_nested(hash[:attributes], keys) ||
29
+ fetch_nested(hash[:data], keys)
27
30
  end
28
31
 
29
- fetch_nested(hash[:attributes], keys) ||
30
- fetch_nested(hash[:data], keys)
31
- end
32
+ # @param array [Array] array whose entries may contain target keys
33
+ # @param keys [Array<Symbol>] keys to probe in order
34
+ # @return [Object, nil] first matching value from array entries
35
+ def fetch_from_array(array, keys)
36
+ array.each do |entry|
37
+ result = fetch(entry, keys)
38
+ return result if result
39
+ end
32
40
 
33
- # @param array [Array] array whose entries may contain target keys
34
- # @param keys [Array<Symbol>] keys to probe in order
35
- # @return [Object, nil] first matching value from array entries
36
- def fetch_from_array(array, keys)
37
- array.each do |entry|
38
- result = fetch(entry, keys)
39
- return result if result
41
+ nil
40
42
  end
41
43
 
42
- nil
43
- end
44
-
45
- # @param value [Hash, Array, nil] nested value to recurse into
46
- # @param keys [Array<Symbol>] keys to probe in order
47
- # @return [Object, nil] matching nested value
48
- def fetch_nested(value, keys)
49
- fetch(value, keys) if value
44
+ # @param value [Hash, Array, nil] nested value to recurse into
45
+ # @param keys [Array<Symbol>] keys to probe in order
46
+ # @return [Object, nil] matching nested value
47
+ def fetch_nested(value, keys)
48
+ fetch(value, keys) if value
49
+ end
50
50
  end
51
51
  end
52
52
  end
@@ -43,7 +43,7 @@ module Html2rss
43
43
  def discover_articles(document, base_url:, &block)
44
44
  case document
45
45
  when Array then handle_array(document, base_url:, &block)
46
- when Hash then document.each_value { discover_articles(_1, base_url:, &block) if traversable?(_1) }
46
+ when Hash then document.each_value { discover_articles(_1, base_url:, &block) }
47
47
  end
48
48
  end
49
49
 
@@ -55,13 +55,9 @@ module Html2rss
55
55
  yield(ArticleNormalizer.normalise(entry, base_url:))
56
56
  end
57
57
  else
58
- array.each { discover_articles(_1, base_url:, &block) if traversable?(_1) }
58
+ array.each { discover_articles(_1, base_url:, &block) }
59
59
  end
60
60
  end
61
-
62
- def traversable?(value)
63
- value.is_a?(Array) || value.is_a?(Hash)
64
- end
65
61
  end
66
62
 
67
63
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
@@ -23,7 +23,15 @@ module Html2rss
23
23
  'og:image' => :image,
24
24
  'twitter:image' => :image,
25
25
  'article:published_time' => :published_at,
26
- 'article:author' => :author
26
+ 'article:author' => :author,
27
+ 'og:video' => :video,
28
+ 'og:video:url' => :video,
29
+ 'og:video:secure_url' => :video,
30
+ 'og:video:type' => :video_type,
31
+ 'og:audio' => :audio,
32
+ 'og:audio:url' => :audio,
33
+ 'og:audio:secure_url' => :audio,
34
+ 'og:audio:type' => :audio_type
27
35
  }.freeze
28
36
  private_constant :META_MAP
29
37
 
@@ -167,10 +175,32 @@ module Html2rss
167
175
  description: meta_data[:description] || oembed_data[:html],
168
176
  author: oembed_data[:author] || meta_data[:author],
169
177
  published_at: meta_data[:published_at],
170
- image: oembed_data[:thumbnail] || meta_data[:image]
178
+ image: oembed_data[:thumbnail] || meta_data[:image],
179
+ enclosures: media_enclosures(meta_data)
171
180
  }.compact
172
181
  end
173
182
 
183
+ # @param meta_data [Hash{Symbol => Object}] extracted OpenGraph meta tags
184
+ # @return [Array<Hash{Symbol => Object}>, nil] og:video / og:audio enclosures (page-type embeds skipped)
185
+ def media_enclosures(meta_data)
186
+ found = %i[video audio].filter_map do |kind|
187
+ type = meta_data[:"#{kind}_type"]
188
+ next if type && !type.start_with?("#{kind}/")
189
+
190
+ media_enclosure(meta_data[kind], type)
191
+ end
192
+ found unless found.empty?
193
+ end
194
+
195
+ # @param src [String, nil] raw media URL
196
+ # @param type [String, nil] declared MIME type
197
+ # @return [Hash{Symbol => Object}, nil] enclosure attributes
198
+ def media_enclosure(src, type)
199
+ ::Html2rss::Html::ArticleRules::Enclosure.from_media(src, type, url)
200
+ rescue Html2rss::Url::InvalidUrlError
201
+ nil
202
+ end
203
+
174
204
  # @param raw_url [String, nil] raw relative or absolute URL string
175
205
  # @return [Html2rss::Url, nil] resolved absolute URL or nil
176
206
  def resolve_url(raw_url)