html2rss 0.23.0 → 0.25.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article.rb +42 -35
  4. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  5. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  6. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  9. data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
  10. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  12. data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
  13. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  14. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  16. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  17. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  18. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  19. data/lib/html2rss/auto_source/scraper.rb +81 -44
  20. data/lib/html2rss/auto_source/segment.rb +29 -0
  21. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  22. data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
  23. data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
  24. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  25. data/lib/html2rss/auto_source/segmenter.rb +66 -0
  26. data/lib/html2rss/auto_source.rb +74 -21
  27. data/lib/html2rss/channel.rb +159 -79
  28. data/lib/html2rss/cli.rb +8 -13
  29. data/lib/html2rss/config/auto_source_contract.rb +2 -0
  30. data/lib/html2rss/config/request_controls.rb +33 -0
  31. data/lib/html2rss/config/validator.rb +15 -8
  32. data/lib/html2rss/config.rb +6 -2
  33. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  34. data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
  35. data/lib/html2rss/feed_builder/json_feed.rb +22 -2
  36. data/lib/html2rss/feed_builder/rss.rb +36 -24
  37. data/lib/html2rss/feed_builder.rb +9 -17
  38. data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
  39. data/lib/html2rss/feed_pipeline.rb +66 -55
  40. data/lib/html2rss/feed_result.rb +92 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +279 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +79 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +205 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
  62. data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
  63. data/lib/html2rss/request_service/budget.rb +7 -2
  64. data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
  65. data/lib/html2rss/request_service/network_guard.rb +5 -3
  66. data/lib/html2rss/request_service/response.rb +9 -1
  67. data/lib/html2rss/request_service/strategy.rb +22 -0
  68. data/lib/html2rss/request_session.rb +4 -0
  69. data/lib/html2rss/scoring/anchor_score.rb +34 -0
  70. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  71. data/lib/html2rss/scoring/container_assessor.rb +83 -0
  72. data/lib/html2rss/scoring/engine.rb +101 -0
  73. data/lib/html2rss/scoring/link_resolver.rb +72 -0
  74. data/lib/html2rss/scoring/observation.rb +53 -0
  75. data/lib/html2rss/scoring/ranked_segment.rb +45 -0
  76. data/lib/html2rss/scoring/score.rb +30 -0
  77. data/lib/html2rss/scoring.rb +33 -0
  78. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
  79. data/lib/html2rss/selectors.rb +26 -24
  80. data/lib/html2rss/sst/attrs.rb +91 -0
  81. data/lib/html2rss/sst/document.rb +23 -0
  82. data/lib/html2rss/sst/index.rb +112 -0
  83. data/lib/html2rss/sst/node.rb +147 -0
  84. data/lib/html2rss/sst/normalizer.rb +171 -0
  85. data/lib/html2rss/sst/tags.rb +26 -0
  86. data/lib/html2rss/sst/text.rb +81 -0
  87. data/lib/html2rss/sst.rb +8 -0
  88. data/lib/html2rss/status.rb +132 -0
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +41 -29
  91. data/schema/html2rss-config.schema.json +20 -0
  92. metadata +45 -19
  93. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  94. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  95. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  96. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  97. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  98. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  99. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  100. data/lib/html2rss/auto_source/discovery.rb +0 -14
  101. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  102. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  103. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  104. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  105. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  106. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  107. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  108. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  109. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
@@ -32,27 +32,16 @@ module Html2rss
32
32
  # @see <https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images>
33
33
  # @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#srcset>
34
34
  # @see <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture>
35
- def self.from_source(article_tag) # rubocop:disable Metrics/AbcSize
36
- hash = article_tag.css('img[srcset], picture > source[srcset]').flat_map do |source|
37
- source['srcset'].to_s.scan(/(\S+)\s+(\d+w|\d+h)[\s,]?/).map do |url, width|
38
- next if url.nil? || url.start_with?('data:')
39
-
40
- width_value = width.to_i.zero? ? 0 : width.scan(/\d+/).first.to_i
41
-
42
- [width_value, url.strip]
43
- end
44
- end.compact.to_h
45
-
46
- hash[hash.keys.max]
35
+ def self.from_source(article_tag)
36
+ srcsets = article_tag.css('img[srcset], picture > source[srcset]').map { |source| source['srcset'] }
37
+ ArticleRules::Image.largest_from_srcsets(srcsets)
47
38
  end
48
39
 
49
40
  # @param article_tag [Nokogiri::XML::Element] article container node
50
41
  # @return [String, nil] best style-based background image URL
51
42
  def self.from_style(article_tag)
52
- article_tag.css('[style*="url"]')
53
- .filter_map { |tag| tag['style'][/url\(['"]?(.*?)['"]?\)/, 1] }
54
- .reject { |src| src.start_with?('data:') }
55
- .max_by(&:size)
43
+ styles = article_tag.css('[style*="url"]').map { |tag| tag['style'] }
44
+ ArticleRules::Image.best_from_styles(styles)
56
45
  end
57
46
  end
58
47
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # Shared category term vocabulary and text accumulation (DOM walk stays in adapters).
8
+ module Category
9
+ # Class/data attribute tokens that mark category metadata.
10
+ CATEGORY_TERMS = %w[
11
+ category categories tag tags topic topics section sections
12
+ label labels theme themes subject subjects
13
+ ].freeze
14
+
15
+ # Regex matching category-related attribute or class names.
16
+ CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
17
+
18
+ class << self
19
+ ##
20
+ # @param name [String]
21
+ # @return [Boolean]
22
+ def attr_name_match?(name)
23
+ name.to_s.match?(CATEGORY_ATTR_PATTERN)
24
+ end
25
+
26
+ ##
27
+ # @param class_attr [String, nil]
28
+ # @return [Boolean]
29
+ def class_match?(class_attr)
30
+ class_attr.to_s.match?(CATEGORY_ATTR_PATTERN)
31
+ end
32
+
33
+ ##
34
+ # @param categories [Set<String>]
35
+ # @param text [String, nil]
36
+ # @return [void]
37
+ def add_text!(categories, text)
38
+ value = text.to_s.strip
39
+ categories.add(value) unless value.empty?
40
+ end
41
+
42
+ ##
43
+ # @param categories [Set<String>]
44
+ # @param text [String, nil]
45
+ # @return [void]
46
+ def add_split_text!(categories, text)
47
+ return unless text
48
+
49
+ text.split(/\n+/).each { |line| add_text!(categories, line) }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # DOM-agnostic datetime aggregation for article published_at.
8
+ module Date
9
+ class << self
10
+ ##
11
+ # @param datetime_strings [Array<String, nil>] raw datetime attribute values
12
+ # @return [DateTime, nil] earliest successfully parsed instant
13
+ def earliest(datetime_strings)
14
+ times = Array(datetime_strings).filter_map do |value|
15
+ DateTime.parse(value.to_s)
16
+ rescue ArgumentError, TypeError
17
+ nil
18
+ end
19
+ times.min
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # DOM-agnostic enclosure URL/type normalization.
8
+ module Enclosure
9
+ # Href path suffixes treated as download/archive enclosure links.
10
+ ARCHIVE_HREF_SUFFIXES = %w[.pdf .zip .tar.gz .tgz].freeze
11
+
12
+ class << self
13
+ ##
14
+ # @param href [String]
15
+ # @return [Boolean]
16
+ def archive_href?(href)
17
+ ARCHIVE_HREF_SUFFIXES.any? { |suffix| href.to_s.end_with?(suffix) }
18
+ end
19
+
20
+ ##
21
+ # @param src [String]
22
+ # @param base_url [String, Html2rss::Url]
23
+ # @return [Hash{Symbol => Object}, nil]
24
+ def from_image(src, base_url)
25
+ return if src.to_s.empty?
26
+
27
+ abs = Url.from_relative(src, base_url)
28
+ { url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'image/jpeg') }
29
+ end
30
+
31
+ ##
32
+ # @param src [String]
33
+ # @param type [String, nil]
34
+ # @param base_url [String, Html2rss::Url]
35
+ # @return [Hash{Symbol => Object}, nil]
36
+ def from_media(src, type, base_url)
37
+ return if src.to_s.empty?
38
+
39
+ { url: Url.from_relative(src, base_url), type: }
40
+ end
41
+
42
+ ##
43
+ # @param src [String]
44
+ # @param base_url [String, Html2rss::Url]
45
+ # @return [Hash{Symbol => Object}, nil]
46
+ def from_iframe(src, base_url)
47
+ return if src.to_s.empty?
48
+
49
+ abs = Url.from_relative(src, base_url)
50
+ { url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'text/html') }
51
+ end
52
+
53
+ ##
54
+ # @param href [String]
55
+ # @param base_url [String, Html2rss::Url]
56
+ # @return [Hash{Symbol => Object}, nil]
57
+ def from_anchor(href, base_url)
58
+ return if href.to_s.empty?
59
+
60
+ abs = Url.from_relative(href, base_url)
61
+ type = if href.end_with?('.pdf')
62
+ Article::Enclosure.guess_content_type_from_url(abs)
63
+ else
64
+ 'application/zip'
65
+ end
66
+ { url: abs, type: }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # DOM-agnostic image URL selection (srcset sizing, CSS background urls).
8
+ #
9
+ # Uses string scans instead of Regexp: CodeQL rb/polynomial-redos flags the
10
+ # prior patterns on uncontrolled HTML attributes, and length guards alone
11
+ # do not clear the query.
12
+ module Image
13
+ # Skip absurdly large attributes before scanning.
14
+ MAX_ATTR_CHARS = 4_096
15
+ # Optional quoting around a CSS `url(...)` value.
16
+ QUOTES = ['"', "'"].freeze
17
+ # Srcset width/height descriptor suffixes.
18
+ UNITS = %w[w h].freeze
19
+ # HTML/CSS whitespace treated as srcset token separators.
20
+ WS = [' ', "\t", "\n", "\r", "\f"].freeze
21
+
22
+ class << self
23
+ ##
24
+ # @param srcset_strings [Array<String>] raw srcset attribute values
25
+ # @return [String, nil] URL of the largest width/height candidate
26
+ def largest_from_srcsets(srcset_strings)
27
+ by_size = {}
28
+ Array(srcset_strings).each { |srcset| merge_srcset!(by_size, srcset) }
29
+ by_size[by_size.keys.max]
30
+ end
31
+
32
+ ##
33
+ # @param style_strings [Array<String>] CSS style attribute values
34
+ # @return [String, nil] longest non-data background url
35
+ def best_from_styles(style_strings)
36
+ Array(style_strings)
37
+ .filter_map { |style| style_url(style) }
38
+ .reject { |src| src.start_with?('data:') }
39
+ .max_by(&:size)
40
+ end
41
+
42
+ private
43
+
44
+ def merge_srcset!(by_size, srcset)
45
+ s = srcset.to_s
46
+ return if s.length > MAX_ATTR_CHARS
47
+
48
+ each_srcset_pair(s) do |url, size|
49
+ next if url.start_with?('data:')
50
+
51
+ by_size[size] = url
52
+ end
53
+ end
54
+
55
+ # URL token + `Nw`/`Nh` pairs. Commas may appear inside URLs; a candidate
56
+ # boundary is whitespace before a width/height descriptor.
57
+ def each_srcset_pair(string) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
58
+ i = 0
59
+ len = string.length
60
+ while i < len
61
+ i += 1 while i < len && sep?(string[i])
62
+ break if i >= len
63
+
64
+ start = i
65
+ i += 1 while i < len && !ws?(string[i])
66
+ url = string[start, i - start]
67
+
68
+ i += 1 while i < len && ws?(string[i])
69
+ size_start = i
70
+ i += 1 while i < len && string[i].between?('0', '9')
71
+ next unless i > size_start && i < len && UNITS.include?(string[i])
72
+
73
+ yield url, string[size_start, i - size_start].to_i
74
+ i += 1
75
+ end
76
+ end
77
+
78
+ def style_url(style)
79
+ s = style.to_s
80
+ return if s.length > MAX_ATTR_CHARS
81
+
82
+ pos = s.index('url(')
83
+ return unless pos
84
+
85
+ extract_url_body(s, pos + 4)
86
+ end
87
+
88
+ def extract_url_body(string, index)
89
+ quote = string[index]
90
+ if QUOTES.include?(quote)
91
+ slice_until(string, index + 1, quote)
92
+ else
93
+ slice_until(string, index, ')')
94
+ end
95
+ end
96
+
97
+ def slice_until(string, start, closer)
98
+ close = string.index(closer, start)
99
+ string[start, close - start] if close
100
+ end
101
+
102
+ def sep?(char) = ws?(char) || char == ','
103
+
104
+ def ws?(char) = WS.include?(char)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ ##
6
+ # DOM-agnostic article field rules shared by Nokogiri and SST adapters.
7
+ module ArticleRules
8
+ end
9
+ end
10
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <audio> tag from a URL and type.
9
7
  class AudioRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] media URL for the audio source
11
11
  # @param type [String] MIME type for the audio source
12
12
  def initialize(url:, type:)
@@ -22,16 +22,6 @@ module Html2rss
22
22
  '</audio>'
23
23
  ].join
24
24
  end
25
-
26
- private
27
-
28
- def escaped_url
29
- CGI.escapeHTML(@url.to_s)
30
- end
31
-
32
- def escaped_type
33
- CGI.escapeHTML(@type.to_s)
34
- end
35
25
  end
36
26
  end
37
27
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ ##
9
+ # Shared HTML attribute escaping for media renderers.
10
+ module EscapedAttributes
11
+ private
12
+
13
+ def escaped_url
14
+ CGI.escapeHTML(@url.to_s)
15
+ end
16
+
17
+ def escaped_type
18
+ CGI.escapeHTML(@type.to_s)
19
+ end
20
+
21
+ def escaped_title
22
+ CGI.escapeHTML(@title.to_s)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <img> tag from a URL and title.
9
7
  class ImageRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] image URL for the src attribute
11
11
  # @param title [String, nil] title/alt text for the image
12
12
  def initialize(url:, title:)
@@ -27,16 +27,6 @@ module Html2rss
27
27
  ]
28
28
  "<img #{attributes.join(' ')}>"
29
29
  end
30
-
31
- private
32
-
33
- def escaped_url
34
- CGI.escapeHTML(@url.to_s)
35
- end
36
-
37
- def escaped_title
38
- CGI.escapeHTML(@title.to_s)
39
- end
40
30
  end
41
31
  end
42
32
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <iframe> for PDF documents.
9
7
  class PdfRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] PDF URL rendered in the iframe
11
11
  def initialize(url:)
12
12
  @url = url
@@ -24,12 +24,6 @@ module Html2rss
24
24
  ]
25
25
  "<iframe #{attributes.join(' ')}></iframe>"
26
26
  end
27
-
28
- private
29
-
30
- def escaped_url
31
- CGI.escapeHTML(@url.to_s)
32
- end
33
27
  end
34
28
  end
35
29
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <video> tag from a URL and type.
9
7
  class VideoRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] media URL for the video source
11
11
  # @param type [String] MIME type for the video source
12
12
  def initialize(url:, type:)
@@ -22,16 +22,6 @@ module Html2rss
22
22
  '</video>'
23
23
  ].join
24
24
  end
25
-
26
- private
27
-
28
- def escaped_url
29
- CGI.escapeHTML(@url.to_s)
30
- end
31
-
32
- def escaped_type
33
- CGI.escapeHTML(@type.to_s)
34
- end
35
25
  end
36
26
  end
37
27
  end