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.
- checksums.yaml +4 -4
- data/README.md +41 -18
- data/html2rss.gemspec +1 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +121 -56
- data/lib/html2rss/auto_source/scraper.rb +13 -0
- data/lib/html2rss/auto_source.rb +34 -8
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +120 -117
- data/lib/html2rss/cli.rb +35 -17
- data/lib/html2rss/config/schema.rb +12 -0
- data/lib/html2rss/config/validator.rb +31 -8
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +24 -6
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +30 -12
- data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
- data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
- data/lib/html2rss/html/article_extractor.rb +95 -17
- data/lib/html2rss/html/article_rules/category.rb +28 -11
- data/lib/html2rss/html/article_rules/date.rb +60 -6
- data/lib/html2rss/html/article_rules/description.rb +122 -0
- data/lib/html2rss/html/card_walk.rb +42 -0
- data/lib/html2rss/html/feed_link.rb +34 -0
- data/lib/html2rss/html/navigator.rb +18 -0
- data/lib/html2rss/html/sst_article_extractor.rb +119 -32
- data/lib/html2rss/link_destination/path_classifier.rb +49 -35
- data/lib/html2rss/mcp/config_argument.rb +42 -0
- data/lib/html2rss/mcp/contract.rb +173 -0
- data/lib/html2rss/mcp/inspect.rb +241 -0
- data/lib/html2rss/mcp/outcome.rb +188 -0
- data/lib/html2rss/mcp/server.rb +253 -409
- data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
- data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +57 -6
- data/lib/html2rss/request_service.rb +6 -1
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +20 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +30 -6
- data/schema/html2rss-config.schema.json +26 -15
- 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
58
|
+
title:,
|
|
50
59
|
url: extract_url,
|
|
51
60
|
image: extract_image,
|
|
52
|
-
description:
|
|
61
|
+
description: ArticleRules::Description.from_lines(lines, title:),
|
|
53
62
|
id: generate_id,
|
|
54
|
-
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
|
|
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
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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 =
|
|
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
|
-
|
|
277
|
-
add_category_text!(categories, element) if
|
|
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
|
|
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
|