html2rss 0.25.0 → 0.27.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +140 -49
  6. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  7. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  8. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  9. data/lib/html2rss/auto_source/scraper.rb +47 -15
  10. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  11. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  12. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  13. data/lib/html2rss/auto_source.rb +41 -10
  14. data/lib/html2rss/capture/README.md +61 -0
  15. data/lib/html2rss/capture.rb +318 -0
  16. data/lib/html2rss/cli.rb +94 -17
  17. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  18. data/lib/html2rss/config/schema.rb +79 -26
  19. data/lib/html2rss/config/selectors_validator.rb +60 -21
  20. data/lib/html2rss/config/validator.rb +46 -32
  21. data/lib/html2rss/config.rb +28 -0
  22. data/lib/html2rss/error.rb +31 -1
  23. data/lib/html2rss/feed_pipeline/README.md +42 -0
  24. data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
  25. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  26. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  27. data/lib/html2rss/feed_pipeline.rb +40 -15
  28. data/lib/html2rss/hash_util.rb +17 -0
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  31. data/lib/html2rss/html/article_extractor.rb +95 -17
  32. data/lib/html2rss/html/article_rules/category.rb +28 -11
  33. data/lib/html2rss/html/article_rules/date.rb +60 -6
  34. data/lib/html2rss/html/article_rules/description.rb +122 -0
  35. data/lib/html2rss/html/card_walk.rb +42 -0
  36. data/lib/html2rss/html/feed_link.rb +34 -0
  37. data/lib/html2rss/html/navigator.rb +18 -0
  38. data/lib/html2rss/html/sst_article_extractor.rb +147 -31
  39. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  40. data/lib/html2rss/link_destination/path_classifier.rb +49 -32
  41. data/lib/html2rss/mcp/config_argument.rb +42 -0
  42. data/lib/html2rss/mcp/contract.rb +173 -0
  43. data/lib/html2rss/mcp/inspect.rb +241 -0
  44. data/lib/html2rss/mcp/outcome.rb +188 -0
  45. data/lib/html2rss/mcp/server.rb +421 -0
  46. data/lib/html2rss/mcp.rb +21 -0
  47. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  48. data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
  49. data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
  50. data/lib/html2rss/request_service/budget.rb +7 -35
  51. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  52. data/lib/html2rss/request_service/context.rb +0 -6
  53. data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
  54. data/lib/html2rss/request_service/policy.rb +2 -2
  55. data/lib/html2rss/request_service/response.rb +72 -7
  56. data/lib/html2rss/request_service/strategy.rb +1 -2
  57. data/lib/html2rss/request_service.rb +10 -10
  58. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  59. data/lib/html2rss/scoring/engine.rb +49 -5
  60. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  61. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  62. data/lib/html2rss/scoring/score.rb +1 -20
  63. data/lib/html2rss/scoring.rb +0 -25
  64. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  65. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  66. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  67. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  68. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  69. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  70. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  71. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  72. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  73. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  75. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  76. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  77. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  78. data/lib/html2rss/selectors.rb +2 -1
  79. data/lib/html2rss/status.rb +27 -11
  80. data/lib/html2rss/url.rb +31 -7
  81. data/lib/html2rss/version.rb +1 -1
  82. data/lib/html2rss.rb +62 -7
  83. data/schema/html2rss-config.schema.json +428 -94
  84. metadata +65 -12
  85. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  88. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  89. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # Leftover visible-text keep/drop for article descriptions.
8
+ # Split once on block newlines; Date and Category consume the same keepers.
9
+ module Description
10
+ # CTA lines dropped by whole-line equality (case-insensitive).
11
+ CTA = Set['read more', 'learn more'].freeze
12
+ # Type-chip lines dropped when the whole leftover line is one of these.
13
+ TYPE_CHIPS = Set['news article', 'press release', 'news'].freeze
14
+ # Listing section names that are chrome, not a dek.
15
+ SECTION_NAMES = Set['press releases'].freeze
16
+ # Skip date detection / DateTime.parse above this length.
17
+ MAX_DATE_CHARS = 128
18
+ # Optional type-chip / clock separators (ASCII/en/em dash, pipe, bullet).
19
+ CHIP_SEPARATORS = [' - ', ' – ', ' — ', ' | ', ' • '].freeze
20
+ # Trailing clock plus optional AM/PM and one timezone/offset token (IANA, GMT/UTC offset, or abbrev).
21
+ CLOCK_ZONE = %r{
22
+ \A
23
+ \d{1,2}:\d{2}(?::\d{2})?
24
+ (?:\s*(?:AM|PM))?
25
+ (?:\s+(?:[A-Za-z]+(?:/[A-Za-z_]+)+|(?:GMT|UTC)[+-]\d{1,2}(?::\d{2})?|[A-Za-z]{2,5}))?
26
+ \z
27
+ }ix
28
+ # Whole-line date shapes after normalize (ISO, numeric, day-month, month-day).
29
+ DATE_SHAPES = [
30
+ /\A\d{4}-\d{1,2}-\d{1,2}(?:[T ]\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/i,
31
+ %r{\A\d{1,2}[./-]\d{1,2}[./-]\d{2,4}(?: \d{1,2}:\d{2}(?::\d{2})?)?\z},
32
+ /\A\d{1,2}\.? \p{L}{3,9}\.?\s*,?\s*\d{4}(?: \d{1,2}:\d{2}(?::\d{2})?)?\z/i,
33
+ /\A\p{L}{3,9}\.? \d{1,2}\s*,?\s*\d{4}(?: \d{1,2}:\d{2}(?::\d{2})?)?\z/i
34
+ ].freeze
35
+
36
+ class << self
37
+ ##
38
+ # @param text [String, nil]
39
+ # @return [Array<String>]
40
+ def lines_from(text)
41
+ text.to_s.split(/\n+/).map { |line| normalize(line) }.reject(&:empty?)
42
+ end
43
+
44
+ ##
45
+ # @param lines [Array<String>, nil]
46
+ # @param title [String, nil]
47
+ # @return [String, nil]
48
+ def from_lines(lines, title: nil)
49
+ kept = Array(lines).select { |line| keep?(line, title:) }
50
+ kept.empty? ? nil : kept.join("\n")
51
+ end
52
+
53
+ ##
54
+ # @param line [String, nil]
55
+ # @param title [String, nil]
56
+ # @param type_chips [Boolean] whether whole-line type chips are leftover chrome
57
+ # @return [Boolean]
58
+ def keep?(line, title: nil, type_chips: true)
59
+ normalized = normalize(line)
60
+ return false if normalized.empty?
61
+
62
+ !chrome?(normalized, title:, type_chips:)
63
+ end
64
+
65
+ ##
66
+ # @param line [String, nil]
67
+ # @return [Boolean]
68
+ def date_shaped?(line)
69
+ core = date_core(line)
70
+ return false if core.empty? || core.length > MAX_DATE_CHARS
71
+
72
+ DATE_SHAPES.any? { |shape| core.match?(shape) }
73
+ end
74
+
75
+ ##
76
+ # @param line [String, nil]
77
+ # @return [String]
78
+ def date_core(line)
79
+ normalized = normalize(line)
80
+ CHIP_SEPARATORS.each do |sep|
81
+ peeled = peel_separator(normalized, sep)
82
+ return peeled if peeled
83
+ end
84
+ normalized
85
+ end
86
+
87
+ private
88
+
89
+ def normalize(line) = line.to_s.gsub(/[[:space:]]+/, ' ').strip
90
+
91
+ def peel_separator(normalized, sep)
92
+ idx = normalized.rindex(sep)
93
+ return unless idx
94
+
95
+ left = normalized[0, idx]
96
+ right = normalized[(idx + sep.length)..]
97
+ return left if type_chip?(right) || clock_zone?(right)
98
+ return right if type_chip?(left)
99
+
100
+ nil
101
+ end
102
+
103
+ def type_chip?(text) = TYPE_CHIPS.include?(text.downcase)
104
+
105
+ def clock_zone?(text) = text.match?(CLOCK_ZONE)
106
+
107
+ def chrome?(normalized, title:, type_chips:)
108
+ key = normalized.downcase
109
+ CTA.include?(key) || (type_chips && TYPE_CHIPS.include?(key)) ||
110
+ SECTION_NAMES.include?(key) || normalized.end_with?(':') ||
111
+ date_shaped?(normalized) || title_echo?(key, title)
112
+ end
113
+
114
+ def title_echo?(key, title)
115
+ t = title.to_s.strip.downcase
116
+ !t.empty? && key == t
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ ##
6
+ # Leftover parent-card walk policy shared by Nokogiri and SST extractors.
7
+ # Adapters traverse; this module decides miss / thin wrapper / crowded abort.
8
+ # Keep/drop stays on {ArticleRules::Description}. Capture's selector lift is a
9
+ # separate job (set-aware abort) and is not encoded here.
10
+ module CardWalk
11
+ class << self
12
+ ##
13
+ # @param heading_or_anchor_item [Boolean] true when the extract root is a heading or wrapping +a+
14
+ # @param published_at [Object, nil]
15
+ # @param description [String, nil] leftover description after Description keep/drop
16
+ # @return [Boolean] whether to climb to a parent card
17
+ def miss?(heading_or_anchor_item:, published_at:, description:)
18
+ heading_or_anchor_item && published_at.nil? && description.nil?
19
+ end
20
+
21
+ ##
22
+ # @param children [Enumerable] immediate children of the candidate parent
23
+ # @param item [Object] heading/anchor being extracted
24
+ # @param descendant_of [Proc] +(item, child)+ — whether +item+ sits inside +child+
25
+ # @return [Boolean] true when the parent only wraps the item (no sibling content)
26
+ def thin_wrapper?(children:, item:, descendant_of:)
27
+ children.none? do |child|
28
+ !child.equal?(item) && !descendant_of.call(item, child)
29
+ end
30
+ end
31
+
32
+ ##
33
+ # @param heading_count [Integer] headings inside the candidate parent
34
+ # @param distinct_main_hrefs [Integer] unique eligible main-article hrefs
35
+ # @return [Boolean] true when the parent looks like a listing, not one card
36
+ def crowded?(heading_count:, distinct_main_hrefs:)
37
+ heading_count > 1 || distinct_main_hrefs > 1
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ # A native RSS/Atom hint from a document head +link[rel=alternate]+.
6
+ #
7
+ # Does not guess +/feed+ or +/rss.xml+ paths — that stays in configs +probe_rss+.
8
+ FeedLink = Data.define(:href, :mime_type) do
9
+ class << self
10
+ ##
11
+ # Collects RSS/Atom +rel=alternate+ links from +doc+'s +head+.
12
+ #
13
+ # @param doc [Nokogiri::XML::Node] parsed HTML document
14
+ # @return [Array<FeedLink>] native feed hints, omitting blanks and non-feed types
15
+ def from_document(doc)
16
+ doc.css('head link[rel~="alternate"][href]').filter_map { |node| from_node(node) }
17
+ end
18
+
19
+ private
20
+
21
+ def from_node(node)
22
+ href = node['href']&.strip
23
+ mime_type = feed_mime_type(node['type'])
24
+ new(href:, mime_type:) unless href.nil? || href.empty? || mime_type.nil?
25
+ end
26
+
27
+ def feed_mime_type(type)
28
+ media_type = type.to_s.split(';', 2).first.strip.downcase
29
+ media_type if media_type in 'application/rss+xml' | 'application/atom+xml'
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -17,6 +17,12 @@ module Html2rss
17
17
  # Ancestor tags that usually indicate navigation/utility regions inside a content container.
18
18
  UTILITY_LANDMARK_TAGS = %w[nav aside footer menu].to_set.freeze
19
19
 
20
+ # Immediate parent walk stops here — not a usable article card.
21
+ CARD_WALK_STOP_TAGS = (UTILITY_LANDMARK_TAGS | IGNORED_CONTAINER_TAGS | %w[html body]).freeze
22
+
23
+ # Inner tags that mean a wrapping <a> is a card, not a span-styled list link.
24
+ WRAPPING_ANCHOR_CHILD_TAGS = (HEADING_TAGS + %w[p]).freeze
25
+
20
26
  # Anchor selector used to identify the canonical article link element.
21
27
  MAIN_ANCHOR_SELECTOR = begin
22
28
  buf = +'a[href]:not([href=""])'
@@ -94,6 +100,18 @@ module Html2rss
94
100
  end
95
101
  end
96
102
 
103
+ ##
104
+ # Immediate parent is a usable article card (not html/body/landmark chrome).
105
+ #
106
+ # @param node [Nokogiri::XML::Node, SST::Node, nil]
107
+ # @return [Boolean]
108
+ def usable_card_parent?(node)
109
+ return false unless node
110
+ return false if node.respond_to?(:document?) && node.document?
111
+
112
+ !CARD_WALK_STOP_TAGS.include?(node.name.to_s)
113
+ end
114
+
97
115
  ##
98
116
  # Think of it as `css_upwards` method.
99
117
  # It searches for the closest parent that matches the given selector.
@@ -13,6 +13,8 @@ module Html2rss
13
13
  KICKER_CLASS_PATTERN = /kicker|eyebrow|pre-title|pretitle|overline/i
14
14
  # Inline emphasis tags used as title fallbacks when no heading exists.
15
15
  FALLBACK_HEADING_NAMES = %i[strong b].freeze
16
+ # Nested blocks that mean a category node is actually a content container.
17
+ CATEGORY_CONTAINER_NAMES = %i[p article section].to_set.freeze
16
18
 
17
19
  class << self
18
20
  ##
@@ -20,9 +22,10 @@ module Html2rss
20
22
  # @param base_url [String, Html2rss::Url]
21
23
  # @param scraper [Class, nil]
22
24
  # @param fallback_anchorless [Boolean]
25
+ # @param time_zone [String] channel time zone for naive leftover dates
23
26
  # @return [Html2rss::Article, nil]
24
- def call(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false)
25
- new(ranked_or_segment, base_url:, scraper:, fallback_anchorless:).call
27
+ def call(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false, time_zone: 'UTC')
28
+ new(ranked_or_segment, base_url:, scraper:, fallback_anchorless:, time_zone:).call
26
29
  end
27
30
  end
28
31
 
@@ -30,7 +33,8 @@ module Html2rss
30
33
  # @param base_url [String, Html2rss::Url]
31
34
  # @param scraper [Class, nil]
32
35
  # @param fallback_anchorless [Boolean]
33
- def initialize(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false)
36
+ # @param time_zone [String] channel time zone for naive leftover dates
37
+ def initialize(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false, time_zone: 'UTC')
34
38
  segment = ranked_or_segment.is_a?(Scoring::RankedSegment) ? ranked_or_segment.segment : ranked_or_segment
35
39
  raise ArgumentError, 'segment is required' unless segment.is_a?(AutoSource::Segment)
36
40
 
@@ -40,20 +44,25 @@ module Html2rss
40
44
  @base_url = base_url
41
45
  @scraper = scraper
42
46
  @fallback_anchorless = fallback_anchorless
47
+ @time_zone = time_zone
43
48
  end
44
49
 
45
50
  ##
46
51
  # @return [Html2rss::Article, nil]
47
52
  def call # rubocop:disable Metrics/MethodLength
53
+ title = extract_title
54
+ lines = leftover_lines
55
+ published_at = extract_published_at(lines)
56
+ root, lines, published_at = parent_card_fields(title, lines, published_at)
48
57
  attrs = {
49
- title: extract_title,
58
+ title:,
50
59
  url: extract_url,
51
60
  image: extract_image,
52
- description: extract_description,
61
+ description: ArticleRules::Description.from_lines(lines, title:),
53
62
  id: generate_id,
54
- published_at: extract_published_at,
63
+ published_at:,
55
64
  enclosures: extract_enclosures,
56
- categories: extract_categories,
65
+ categories: extract_categories(title, root:),
57
66
  scraper: @scraper
58
67
  }
59
68
  article = Article.new(**attrs)
@@ -80,19 +89,48 @@ module Html2rss
80
89
  Url.from_relative("##{id}", @base_url) if id
81
90
  end
82
91
 
83
- def extract_title # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
84
- source = heading || @selected_anchor
85
- title_text = if source
86
- source.visible_text
87
- elsif @fallback_anchorless && @selected_anchor.nil?
88
- first_nonempty_own_text
89
- end
92
+ def extract_title
93
+ title_text = title_from_in_card_sources
90
94
  return unless title_text
91
95
 
92
96
  kicker = kicker_node&.visible_text.to_s.strip
93
97
  kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
94
98
  end
95
99
 
100
+ # Prefer a non-junk title. Credit-only cards may blank to nil (description can carry
101
+ # the item). Other junk shapes must stay present so Cleanup can hard-reject them —
102
+ # blanking slug/CMS/template text would admit titleless cards.
103
+ #
104
+ # @return [String, nil]
105
+ def title_from_in_card_sources
106
+ candidates = title_candidates
107
+ clean = candidates.find { |text| AutoSource::Cleanup.junk_reason(text).nil? }
108
+ return clean if clean
109
+
110
+ candidates.find { |text| AutoSource::Cleanup.junk_reason(text) != :credit }
111
+ end
112
+
113
+ # @return [Array<String>] ordered in-card title candidates (heading → anchor → fallback)
114
+ def title_candidates
115
+ [].tap do |candidates|
116
+ add_title_candidate!(candidates, heading&.visible_text)
117
+ add_title_candidate!(candidates, @selected_anchor&.visible_text)
118
+ next unless candidates.empty? && @fallback_anchorless && @selected_anchor.nil?
119
+
120
+ add_title_candidate!(candidates, first_nonempty_own_text)
121
+ end
122
+ end
123
+
124
+ # @param candidates [Array<String>]
125
+ # @param text [String, nil]
126
+ # @return [void]
127
+ def add_title_candidate!(candidates, text)
128
+ stripped = text.to_s.strip
129
+ return if stripped.empty? || candidates.include?(stripped)
130
+
131
+ candidates << stripped
132
+ end
133
+
96
134
  def heading
97
135
  @heading ||= begin
98
136
  tags = @root.find_all(&:heading?)
@@ -143,13 +181,73 @@ module Html2rss
143
181
  ancestor.descendants.any? { |d| d.equal?(child) }
144
182
  end
145
183
 
146
- def extract_description
147
- exclude = [heading, @selected_anchor, kicker_node].compact
148
- description = @root.visible_text(exclude:)
149
- return if description.nil?
184
+ def leftover_lines
185
+ leftover_lines_from(@root)
186
+ end
187
+
188
+ def leftover_lines_from(node)
189
+ times = node.find_all { |n| n.name == :time }
190
+ exclude = [heading, @selected_anchor, kicker_node, *times].compact
191
+ ArticleRules::Description.lines_from(node.visible_text(exclude:))
192
+ end
193
+
194
+ def heading_or_anchor_item?
195
+ @root.heading? || @root.name == :a
196
+ end
197
+
198
+ def heading_or_anchor_miss?(title, lines, published_at)
199
+ CardWalk.miss?(
200
+ heading_or_anchor_item: heading_or_anchor_item?,
201
+ published_at:,
202
+ description: ArticleRules::Description.from_lines(lines, title:)
203
+ )
204
+ end
205
+
206
+ def parent_card_fields(title, lines, published_at)
207
+ return @root, lines, published_at unless heading_or_anchor_miss?(title, lines, published_at)
208
+
209
+ parent = immediate_card_parent
210
+ if !parent || crowded_parent?(parent)
211
+ Log.debug { "parent-walk abort at #{sst_parent&.name}" }
212
+ return @root, lines, published_at
213
+ end
214
+
215
+ new_lines = leftover_lines_from(parent)
216
+ new_date = extract_published_at(new_lines, root: parent)
217
+ [parent, new_lines, new_date || published_at]
218
+ end
219
+
220
+ def immediate_card_parent
221
+ parent = sst_parent
222
+ index = SST::Index.for_node(@root)
223
+ index&.parent_until(parent, method(:usable_walk_parent?))
224
+ end
225
+
226
+ def usable_walk_parent?(node)
227
+ Navigator.usable_card_parent?(node) && !thin_heading_wrapper?(node)
228
+ end
229
+
230
+ def thin_heading_wrapper?(node)
231
+ CardWalk.thin_wrapper?(
232
+ children: node.children,
233
+ item: @root,
234
+ descendant_of: ->(item, child) { descendant_of?(item, child) }
235
+ )
236
+ end
237
+
238
+ def crowded_parent?(node)
239
+ CardWalk.crowded?(
240
+ heading_count: node.each_node.count(&:heading?),
241
+ distinct_main_hrefs: distinct_main_hrefs(node)
242
+ )
243
+ end
244
+
245
+ def distinct_main_hrefs(node)
246
+ node.each_node.filter_map { |candidate| candidate.attrs.href if candidate.link? }.uniq.size
247
+ end
150
248
 
151
- desc = description.strip
152
- desc.empty? ? nil : desc
249
+ def sst_parent
250
+ SST::Index.for_node(@root)&.parent_of(@root)
153
251
  end
154
252
 
155
253
  def generate_id # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -211,9 +309,9 @@ module Html2rss
211
309
  ArticleRules::Image.best_from_styles(styles)
212
310
  end
213
311
 
214
- def extract_published_at
215
- datetimes = @root.find_all { |n| n.attrs.datetime }.map { |n| n.attrs.datetime }
216
- ArticleRules::Date.earliest(datetimes)
312
+ def extract_published_at(lines, root: @root)
313
+ datetimes = root.find_all { |n| n.attrs.datetime }.map { |n| n.attrs.datetime }
314
+ ArticleRules::Date.earliest(datetimes, leftover_lines: lines, time_zone: @time_zone)
217
315
  end
218
316
 
219
317
  def extract_enclosures
@@ -242,14 +340,14 @@ module Html2rss
242
340
  end
243
341
  end
244
342
 
245
- def extract_categories
343
+ def extract_categories(title, root: @root)
246
344
  Set.new.tap do |categories|
247
- @root.find_all { |n| category_candidate?(n) }.each do |element|
248
- add_category_text!(categories, element) if ArticleRules::Category.class_match?(element.attrs.class_attr)
345
+ root.find_all { |n| category_candidate?(n) }.each do |element|
346
+ add_category_text!(categories, element, title:) if category_text_node?(element, root)
249
347
  element.attrs.raw.each do |name, value|
250
348
  next unless ArticleRules::Category.attr_name_match?(name)
251
349
 
252
- ArticleRules::Category.add_text!(categories, value)
350
+ ArticleRules::Category.add_text!(categories, value, title:)
253
351
  end
254
352
  end
255
353
  end.to_a
@@ -260,17 +358,35 @@ module Html2rss
260
358
  node.attrs.raw.keys.any? { |k| ArticleRules::Category.attr_name_match?(k) }
261
359
  end
262
360
 
263
- def add_category_text!(categories, element)
361
+ def category_text_node?(element, root)
362
+ !element.equal?(root) && ArticleRules::Category.class_match?(element.attrs.class_attr)
363
+ end
364
+
365
+ def add_category_text!(categories, element, title:)
366
+ return if category_text_container?(element)
367
+
264
368
  if element.link?
265
- ArticleRules::Category.add_text!(categories, element.visible_text)
369
+ ArticleRules::Category.add_text!(categories, element.visible_text, title:)
266
370
  return
267
371
  end
268
372
 
373
+ add_descendant_or_visible_category!(categories, element, title:)
374
+ end
375
+
376
+ def add_descendant_or_visible_category!(categories, element, title:)
269
377
  anchors = element.find_all(&:link?)
270
378
  if anchors.any?
271
- anchors.each { |a| ArticleRules::Category.add_text!(categories, a.visible_text) }
379
+ anchors.each { |a| ArticleRules::Category.add_text!(categories, a.visible_text, title:) }
272
380
  else
273
- ArticleRules::Category.add_split_text!(categories, element.visible_text)
381
+ ArticleRules::Category.add_split_text!(categories, element.visible_text, title:)
382
+ end
383
+ end
384
+
385
+ def category_text_container?(element)
386
+ element.find do |node|
387
+ next if node.equal?(element)
388
+
389
+ node.heading? || CATEGORY_CONTAINER_NAMES.include?(node.name)
274
390
  end
275
391
  end
276
392
  end
@@ -9,10 +9,8 @@ module Html2rss
9
9
  ICON_NAMES = %i[img svg].freeze
10
10
 
11
11
  # @param link_resolver [LinkResolver]
12
- # @param index [SST::Index]
13
- def initialize(link_resolver:, index:)
12
+ def initialize(link_resolver:)
14
13
  @link_resolver = link_resolver
15
- @index = index
16
14
  end
17
15
 
18
16
  ##
@@ -21,8 +19,11 @@ module Html2rss
21
19
  # @param anchor [SST::Node, nil]
22
20
  # @param container [SST::Node, nil]
23
21
  # @param heading_anchor [Boolean]
22
+ # @param utility_landmark_ancestor [Boolean] pre-computed by Segmenter tree walk
24
23
  # @return [Boolean]
25
- def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil, heading_anchor: false) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
24
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
25
+ def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil,
26
+ heading_anchor: false, utility_landmark_ancestor: false)
26
27
  return true unless destination_facts
27
28
 
28
29
  destination_facts.taxonomy_path ||
@@ -32,8 +33,9 @@ module Html2rss
32
33
  (@link_resolver.utility_text?(text) && destination_facts.vanity_path) ||
33
34
  utility_text_chrome?(text, destination_facts, heading_anchor:) ||
34
35
  icon_only_anchor?(anchor, text) ||
35
- utility_landmark_ancestor?(anchor, container)
36
+ utility_landmark_ancestor
36
37
  end
38
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
37
39
 
38
40
  private
39
41
 
@@ -61,19 +63,6 @@ module Html2rss
61
63
 
62
64
  !!anchor.find { |n| ICON_NAMES.include?(n.name) }
63
65
  end
64
-
65
- def utility_landmark_ancestor?(anchor, container) # rubocop:disable Metrics/CyclomaticComplexity
66
- return false unless anchor && container
67
-
68
- curr = @index.parent_of(anchor)
69
- while curr && curr.name != :html
70
- return true if curr != container && curr.utility_landmark?
71
- return false if curr.equal?(container)
72
-
73
- curr = @index.parent_of(curr)
74
- end
75
- false
76
- end
77
66
  end
78
67
  end
79
68
  end