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.
- checksums.yaml +4 -4
- data/README.md +99 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +140 -49
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +47 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +41 -10
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +318 -0
- data/lib/html2rss/cli.rb +94 -17
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +79 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +46 -32
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +31 -1
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +40 -15
- data/lib/html2rss/hash_util.rb +17 -0
- 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 +147 -31
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +49 -32
- 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 +421 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
- data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
- data/lib/html2rss/request_service/policy.rb +2 -2
- data/lib/html2rss/request_service/response.rb +72 -7
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +10 -10
- data/lib/html2rss/scoring/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +31 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +62 -7
- data/schema/html2rss-config.schema.json +428 -94
- metadata +65 -12
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
- 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
|
-
|
|
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)
|
|
@@ -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
|
|
84
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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 =
|
|
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
|
-
|
|
248
|
-
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|