html2rss 0.26.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -18
  3. data/html2rss.gemspec +1 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +121 -56
  6. data/lib/html2rss/auto_source/scraper.rb +13 -0
  7. data/lib/html2rss/auto_source.rb +34 -8
  8. data/lib/html2rss/capture/README.md +61 -0
  9. data/lib/html2rss/capture.rb +120 -117
  10. data/lib/html2rss/cli.rb +35 -17
  11. data/lib/html2rss/config/schema.rb +12 -0
  12. data/lib/html2rss/config/validator.rb +31 -8
  13. data/lib/html2rss/config.rb +28 -0
  14. data/lib/html2rss/error.rb +24 -6
  15. data/lib/html2rss/feed_pipeline/README.md +42 -0
  16. data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
  17. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  18. data/lib/html2rss/feed_pipeline.rb +30 -12
  19. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  20. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  21. data/lib/html2rss/html/article_extractor.rb +95 -17
  22. data/lib/html2rss/html/article_rules/category.rb +28 -11
  23. data/lib/html2rss/html/article_rules/date.rb +60 -6
  24. data/lib/html2rss/html/article_rules/description.rb +122 -0
  25. data/lib/html2rss/html/card_walk.rb +42 -0
  26. data/lib/html2rss/html/feed_link.rb +34 -0
  27. data/lib/html2rss/html/navigator.rb +18 -0
  28. data/lib/html2rss/html/sst_article_extractor.rb +119 -32
  29. data/lib/html2rss/link_destination/path_classifier.rb +49 -35
  30. data/lib/html2rss/mcp/config_argument.rb +42 -0
  31. data/lib/html2rss/mcp/contract.rb +173 -0
  32. data/lib/html2rss/mcp/inspect.rb +241 -0
  33. data/lib/html2rss/mcp/outcome.rb +188 -0
  34. data/lib/html2rss/mcp/server.rb +253 -409
  35. data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
  36. data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
  37. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  38. data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
  39. data/lib/html2rss/request_service/policy.rb +1 -1
  40. data/lib/html2rss/request_service/response.rb +57 -6
  41. data/lib/html2rss/request_service.rb +6 -1
  42. data/lib/html2rss/selectors.rb +2 -1
  43. data/lib/html2rss/status.rb +27 -11
  44. data/lib/html2rss/url.rb +20 -0
  45. data/lib/html2rss/version.rb +1 -1
  46. data/lib/html2rss.rb +30 -6
  47. data/schema/html2rss-config.schema.json +26 -15
  48. metadata +15 -4
@@ -1,22 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
4
+ require 'tzinfo'
5
+
3
6
  module Html2rss
4
7
  module Html
5
8
  module ArticleRules
6
9
  ##
7
10
  # DOM-agnostic datetime aggregation for article published_at.
8
11
  module Date
12
+ # Skip DateTime.parse above this length (Ruby parse is not a validator).
13
+ MAX_DATE_CHARS = Description::MAX_DATE_CHARS
14
+ # Trailing offset / Zulu marker — present means the string already has a zone.
15
+ OFFSET = /(?:Z|[+-]\d{2}:?\d{2})\z/i
16
+ # Prefer standard time when a local clock time occurs twice (DST fall-back).
17
+ AMBIGUOUS_DST = false
18
+
9
19
  class << self
10
20
  ##
11
21
  # @param datetime_strings [Array<String, nil>] raw datetime attribute values
22
+ # @param leftover_lines [Array<String>] leftover visible lines (already split)
23
+ # @param time_zone [String] channel time zone for naive values
12
24
  # @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
25
+ def earliest(datetime_strings, leftover_lines: [], time_zone: 'UTC')
26
+ values = Array(datetime_strings).filter_map { |value| parse_attr(value, time_zone:) }
27
+ Array(leftover_lines).each do |line|
28
+ parsed = parse_line(line, time_zone:)
29
+ values << parsed if parsed
18
30
  end
19
- times.min
31
+ values.min
32
+ end
33
+
34
+ private
35
+
36
+ def parse_attr(value, time_zone:)
37
+ parse_datetime(value.to_s.strip, time_zone:)
38
+ end
39
+
40
+ def parse_line(line, time_zone:)
41
+ return unless Description.date_shaped?(line)
42
+
43
+ parse_datetime(Description.date_core(line), time_zone:)
44
+ end
45
+
46
+ def parse_datetime(string, time_zone:)
47
+ return if string.empty? || string.length > MAX_DATE_CHARS
48
+
49
+ parsed = DateTime.parse(string, limit: MAX_DATE_CHARS)
50
+ naive?(string) ? localize(parsed, time_zone) : parsed
51
+ rescue ArgumentError, TypeError
52
+ nil
53
+ end
54
+
55
+ def naive?(string) = !string.match?(OFFSET)
56
+
57
+ def localize(datetime, time_zone)
58
+ identifier = time_zone.to_s
59
+ identifier = 'UTC' if identifier.empty?
60
+ at_local(timezone_for(identifier), datetime)
61
+ rescue TZInfo::PeriodNotFound
62
+ at_local(timezone_for('UTC'), datetime)
63
+ end
64
+
65
+ def at_local(timezone, datetime)
66
+ timezone.local_datetime(datetime.year, datetime.month, datetime.day,
67
+ datetime.hour, datetime.min, datetime.sec, 0, AMBIGUOUS_DST, &:first)
68
+ end
69
+
70
+ def timezone_for(identifier)
71
+ TZInfo::Timezone.get(identifier)
72
+ rescue TZInfo::InvalidTimezoneIdentifier
73
+ TZInfo::Timezone.get('UTC')
20
74
  end
21
75
  end
22
76
  end
@@ -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)
@@ -88,11 +97,17 @@ module Html2rss
88
97
  kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
89
98
  end
90
99
 
91
- # Prefer heading over credit-shaped visible text; never emit agency-only titles.
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.
92
103
  #
93
104
  # @return [String, nil]
94
105
  def title_from_in_card_sources
95
- title_candidates.find { |text| !credit_shaped_title?(text) }
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 }
96
111
  end
97
112
 
98
113
  # @return [Array<String>] ordered in-card title candidates (heading → anchor → fallback)
@@ -116,12 +131,6 @@ module Html2rss
116
131
  candidates << stripped
117
132
  end
118
133
 
119
- # @param text [String]
120
- # @return [Boolean]
121
- def credit_shaped_title?(text)
122
- AutoSource::Cleanup::CREDIT_TITLE.match?(text)
123
- end
124
-
125
134
  def heading
126
135
  @heading ||= begin
127
136
  tags = @root.find_all(&:heading?)
@@ -172,13 +181,73 @@ module Html2rss
172
181
  ancestor.descendants.any? { |d| d.equal?(child) }
173
182
  end
174
183
 
175
- def extract_description
176
- exclude = [heading, @selected_anchor, kicker_node].compact
177
- description = @root.visible_text(exclude:)
178
- 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
179
229
 
180
- desc = description.strip
181
- desc.empty? ? nil : desc
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
248
+
249
+ def sst_parent
250
+ SST::Index.for_node(@root)&.parent_of(@root)
182
251
  end
183
252
 
184
253
  def generate_id # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -240,9 +309,9 @@ module Html2rss
240
309
  ArticleRules::Image.best_from_styles(styles)
241
310
  end
242
311
 
243
- def extract_published_at
244
- datetimes = @root.find_all { |n| n.attrs.datetime }.map { |n| n.attrs.datetime }
245
- 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)
246
315
  end
247
316
 
248
317
  def extract_enclosures
@@ -271,14 +340,14 @@ module Html2rss
271
340
  end
272
341
  end
273
342
 
274
- def extract_categories
343
+ def extract_categories(title, root: @root)
275
344
  Set.new.tap do |categories|
276
- @root.find_all { |n| category_candidate?(n) }.each do |element|
277
- 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)
278
347
  element.attrs.raw.each do |name, value|
279
348
  next unless ArticleRules::Category.attr_name_match?(name)
280
349
 
281
- ArticleRules::Category.add_text!(categories, value)
350
+ ArticleRules::Category.add_text!(categories, value, title:)
282
351
  end
283
352
  end
284
353
  end.to_a
@@ -289,17 +358,35 @@ module Html2rss
289
358
  node.attrs.raw.keys.any? { |k| ArticleRules::Category.attr_name_match?(k) }
290
359
  end
291
360
 
292
- 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
+
293
368
  if element.link?
294
- ArticleRules::Category.add_text!(categories, element.visible_text)
369
+ ArticleRules::Category.add_text!(categories, element.visible_text, title:)
295
370
  return
296
371
  end
297
372
 
373
+ add_descendant_or_visible_category!(categories, element, title:)
374
+ end
375
+
376
+ def add_descendant_or_visible_category!(categories, element, title:)
298
377
  anchors = element.find_all(&:link?)
299
378
  if anchors.any?
300
- anchors.each { |a| ArticleRules::Category.add_text!(categories, a.visible_text) }
379
+ anchors.each { |a| ArticleRules::Category.add_text!(categories, a.visible_text, title:) }
301
380
  else
302
- 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)
303
390
  end
304
391
  end
305
392
  end