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
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
##
|
|
5
5
|
# Builds feeds from validated config through request, extraction, and rendering stages.
|
|
6
|
-
|
|
6
|
+
#
|
|
7
|
+
# {include:file:lib/html2rss/feed_pipeline/README.md}
|
|
8
|
+
class FeedPipeline # rubocop:disable Metrics/ClassLength -- outcome + collect seams stay co-located
|
|
7
9
|
# Scrape-finished facts after request + extraction + dedup (before Channel/Status materialize).
|
|
8
10
|
# selected_strategy: set on :auto success; nil otherwise.
|
|
9
11
|
# attempt_count: auto attempts attempted; 0 outside :auto.
|
|
10
12
|
# strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
|
|
11
13
|
PipelineOutcome = Data.define(
|
|
12
|
-
:response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
|
|
14
|
+
:response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts,
|
|
15
|
+
:admission_drops
|
|
13
16
|
)
|
|
14
17
|
|
|
15
18
|
##
|
|
@@ -18,6 +21,16 @@ module Html2rss
|
|
|
18
21
|
@raw_config = raw_config
|
|
19
22
|
end
|
|
20
23
|
|
|
24
|
+
##
|
|
25
|
+
# Runs the pipeline once and returns scrape-finished outcome (before Channel/Status).
|
|
26
|
+
# Used by {Capture} which needs the response body for SST selector derivation.
|
|
27
|
+
#
|
|
28
|
+
# @return [PipelineOutcome]
|
|
29
|
+
def to_outcome
|
|
30
|
+
config = Config.from_hash(raw_config, params: raw_config[:params])
|
|
31
|
+
pipeline_outcome_for(config)
|
|
32
|
+
end
|
|
33
|
+
|
|
21
34
|
##
|
|
22
35
|
# Runs the pipeline once and returns an opaque, Marshal-cacheable result.
|
|
23
36
|
#
|
|
@@ -32,7 +45,8 @@ module Html2rss
|
|
|
32
45
|
dedup_dropped: outcome.dedup_dropped,
|
|
33
46
|
selected_strategy: outcome.selected_strategy,
|
|
34
47
|
attempt_count: outcome.attempt_count,
|
|
35
|
-
strategy_attempts: outcome.strategy_attempts
|
|
48
|
+
strategy_attempts: outcome.strategy_attempts,
|
|
49
|
+
admission_drops: outcome.admission_drops
|
|
36
50
|
)
|
|
37
51
|
FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
|
|
38
52
|
end
|
|
@@ -56,11 +70,11 @@ module Html2rss
|
|
|
56
70
|
# @param config [Html2rss::Config]
|
|
57
71
|
# @param response [Html2rss::RequestService::Response]
|
|
58
72
|
# @param request_session [Html2rss::RequestSession]
|
|
59
|
-
# @return [Array(Array<Html2rss::Article>, Integer)] unique articles
|
|
73
|
+
# @return [Array(Array<Html2rss::Article>, Integer, Hash)] unique articles, dedup drops, admission drops
|
|
60
74
|
def deduplicated_articles(config:, response:, request_session:)
|
|
61
|
-
collected = collect_articles(config:, response:, request_session:)
|
|
75
|
+
collected, admission_drops = collect_articles(config:, response:, request_session:)
|
|
62
76
|
unique = Article::Deduplicator.new(collected).call
|
|
63
|
-
[unique, collected.size - unique.size]
|
|
77
|
+
[unique, collected.size - unique.size, admission_drops]
|
|
64
78
|
end
|
|
65
79
|
|
|
66
80
|
private
|
|
@@ -80,13 +94,21 @@ module Html2rss
|
|
|
80
94
|
def run_pipeline_for_strategy(config, strategy:, resources:)
|
|
81
95
|
request_session = request_session_for(config, strategy:, resources:)
|
|
82
96
|
response = request_session.fetch_initial_response
|
|
83
|
-
articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
|
|
84
|
-
if config.auto_source && articles.empty?
|
|
85
|
-
raise NoFeedItemsExtracted.new(attempts: [{ strategy:, items_count: 0, error_class: nil }])
|
|
86
|
-
end
|
|
97
|
+
articles, dedup_dropped, admission_drops = deduplicated_articles(config:, response:, request_session:)
|
|
98
|
+
raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
|
|
87
99
|
|
|
88
100
|
PipelineOutcome.new(
|
|
89
|
-
response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
101
|
+
response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
102
|
+
strategy_attempts: [], admission_drops:
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def raise_empty_auto_source!(strategy:, response:)
|
|
107
|
+
raise NoFeedItemsExtracted.new(
|
|
108
|
+
attempts: [{ strategy:, items_count: 0, error_class: nil }],
|
|
109
|
+
surface_category: AutoSource::Scraper.classify_no_scraper_surface(
|
|
110
|
+
response.parsed_body, body: response.body
|
|
111
|
+
)
|
|
90
112
|
)
|
|
91
113
|
end
|
|
92
114
|
|
|
@@ -101,8 +123,9 @@ module Html2rss
|
|
|
101
123
|
end
|
|
102
124
|
|
|
103
125
|
def collect_articles(config:, response:, request_session:)
|
|
104
|
-
selector_articles(config:, response:, request_session:)
|
|
105
|
-
|
|
126
|
+
selector = selector_articles(config:, response:, request_session:)
|
|
127
|
+
auto, admission_drops = auto_source_articles(config:, response:, request_session:)
|
|
128
|
+
[selector + auto, admission_drops]
|
|
106
129
|
end
|
|
107
130
|
|
|
108
131
|
# rubocop:disable Metrics/MethodLength
|
|
@@ -125,10 +148,12 @@ module Html2rss
|
|
|
125
148
|
end
|
|
126
149
|
# rubocop:enable Metrics/MethodLength
|
|
127
150
|
|
|
151
|
+
# @return [Array(Array<Html2rss::Article>, Hash{String => Integer})]
|
|
128
152
|
def auto_source_articles(config:, response:, request_session:)
|
|
129
|
-
return [] unless (auto_source = config.auto_source)
|
|
153
|
+
return [[], {}] unless (auto_source = config.auto_source)
|
|
130
154
|
|
|
131
|
-
AutoSource.new(response, auto_source, request_session:)
|
|
155
|
+
source = AutoSource.new(response, auto_source, request_session:)
|
|
156
|
+
[source.articles, source.admission_drops]
|
|
132
157
|
end
|
|
133
158
|
end
|
|
134
159
|
end
|
data/lib/html2rss/hash_util.rb
CHANGED
|
@@ -54,6 +54,23 @@ module Html2rss
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Converts hash keys (and Symbol values) to strings recursively.
|
|
58
|
+
#
|
|
59
|
+
# @param object [Object] value to normalize
|
|
60
|
+
# @return [Object] normalized value with string keys
|
|
61
|
+
def deep_stringify_keys(object)
|
|
62
|
+
case object
|
|
63
|
+
when Hash
|
|
64
|
+
object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
|
|
65
|
+
when Array
|
|
66
|
+
object.map { deep_stringify_keys(_1) }
|
|
67
|
+
when Symbol
|
|
68
|
+
object.to_s
|
|
69
|
+
else
|
|
70
|
+
object
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
57
74
|
# Validates that hash keys are symbols.
|
|
58
75
|
#
|
|
59
76
|
# @param value [Object] candidate hash container whose keys must be symbols
|
|
@@ -9,24 +9,26 @@ module Html2rss
|
|
|
9
9
|
class CategoryExtractor
|
|
10
10
|
# Shared category vocabulary (owned by {ArticleRules::Category}).
|
|
11
11
|
CATEGORY_TERMS = ArticleRules::Category::CATEGORY_TERMS
|
|
12
|
-
# Shared category attribute/class pattern (owned by {ArticleRules::Category}).
|
|
13
|
-
CATEGORY_ATTR_PATTERN = ArticleRules::Category::CATEGORY_ATTR_PATTERN
|
|
14
12
|
|
|
15
13
|
# CSS selectors to find elements with category-related class names or data attributes
|
|
16
14
|
CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
|
|
17
15
|
["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
|
|
18
16
|
end.freeze
|
|
19
17
|
|
|
18
|
+
# Nested blocks that mean a "category" node is actually a content container.
|
|
19
|
+
CONTAINER_CHILD_SELECTOR = 'p, article, section, h1, h2, h3, h4, h5, h6'
|
|
20
|
+
|
|
20
21
|
##
|
|
21
22
|
# Extracts categories from the given article tag by looking for elements
|
|
22
23
|
# with class names containing common category-related terms.
|
|
23
24
|
#
|
|
24
25
|
# @param article_tag [Nokogiri::XML::Element] The article element to extract categories from
|
|
26
|
+
# @param title [String, nil] article title used to reject title-echo values
|
|
25
27
|
# @return [Array<String>] Array of category strings, empty if none found
|
|
26
|
-
def self.call(article_tag)
|
|
28
|
+
def self.call(article_tag, title: nil)
|
|
27
29
|
return [] unless article_tag
|
|
28
30
|
|
|
29
|
-
extract_all_categories(article_tag)
|
|
31
|
+
extract_all_categories(article_tag, title:)
|
|
30
32
|
.map(&:strip)
|
|
31
33
|
.reject(&:empty?)
|
|
32
34
|
end
|
|
@@ -35,12 +37,18 @@ module Html2rss
|
|
|
35
37
|
# Optimized single DOM traversal that extracts all category types.
|
|
36
38
|
#
|
|
37
39
|
# @param article_tag [Nokogiri::XML::Element] The article element
|
|
40
|
+
# @param title [String, nil]
|
|
38
41
|
# @return [Set<String>] Set of category strings
|
|
39
|
-
def self.extract_all_categories(article_tag)
|
|
42
|
+
def self.extract_all_categories(article_tag, title: nil)
|
|
40
43
|
Set.new.tap do |categories|
|
|
44
|
+
extract_element_data_categories!(categories, article_tag, title:)
|
|
41
45
|
article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
next if element == article_tag
|
|
47
|
+
|
|
48
|
+
if ArticleRules::Category.class_match?(element['class'])
|
|
49
|
+
extract_text_categories!(categories, element, title:)
|
|
50
|
+
end
|
|
51
|
+
extract_element_data_categories!(categories, element, title:)
|
|
44
52
|
end
|
|
45
53
|
end
|
|
46
54
|
end
|
|
@@ -50,12 +58,13 @@ module Html2rss
|
|
|
50
58
|
#
|
|
51
59
|
# @param categories [Set<String>] Accumulator set
|
|
52
60
|
# @param element [Nokogiri::XML::Element] metadata element that may contain category links
|
|
61
|
+
# @param title [String, nil]
|
|
53
62
|
# @return [void]
|
|
54
|
-
def self.extract_element_data_categories!(categories, element)
|
|
63
|
+
def self.extract_element_data_categories!(categories, element, title: nil)
|
|
55
64
|
element.attributes.each_value do |attr|
|
|
56
65
|
next unless ArticleRules::Category.attr_name_match?(attr.name)
|
|
57
66
|
|
|
58
|
-
ArticleRules::Category.add_text!(categories, attr.value)
|
|
67
|
+
ArticleRules::Category.add_text!(categories, attr.value, title:)
|
|
59
68
|
end
|
|
60
69
|
end
|
|
61
70
|
|
|
@@ -64,20 +73,20 @@ module Html2rss
|
|
|
64
73
|
#
|
|
65
74
|
# @param categories [Set<String>] Accumulator set
|
|
66
75
|
# @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
|
|
76
|
+
# @param title [String, nil]
|
|
67
77
|
# @return [void]
|
|
68
|
-
def self.extract_text_categories!(categories, element)
|
|
69
|
-
if element
|
|
70
|
-
add_text_to_categories!(categories, element)
|
|
71
|
-
return
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
anchors = element.css('a')
|
|
78
|
+
def self.extract_text_categories!(categories, element, title: nil)
|
|
79
|
+
return if category_container?(element)
|
|
75
80
|
|
|
81
|
+
anchors = element.name == 'a' ? [element] : element.css('a').to_a
|
|
76
82
|
if anchors.any?
|
|
77
|
-
anchors.each { |node| add_text_to_categories!(categories, node) }
|
|
78
|
-
|
|
79
|
-
ArticleRules::Category.add_split_text!(categories, element.text)
|
|
83
|
+
anchors.each { |node| add_text_to_categories!(categories, node, title:) }
|
|
84
|
+
return
|
|
80
85
|
end
|
|
86
|
+
|
|
87
|
+
ArticleRules::Category.add_split_text!(
|
|
88
|
+
categories, Navigator.extract_visible_text(element), title:
|
|
89
|
+
)
|
|
81
90
|
end
|
|
82
91
|
|
|
83
92
|
##
|
|
@@ -85,12 +94,17 @@ module Html2rss
|
|
|
85
94
|
#
|
|
86
95
|
# @param categories [Set<String>] Accumulator set
|
|
87
96
|
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
97
|
+
# @param title [String, nil]
|
|
88
98
|
# @return [void]
|
|
89
|
-
def self.add_text_to_categories!(categories, element)
|
|
90
|
-
ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element))
|
|
99
|
+
def self.add_text_to_categories!(categories, element, title: nil)
|
|
100
|
+
ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element), title:)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.category_container?(element)
|
|
104
|
+
element.at_css(CONTAINER_CHILD_SELECTOR)
|
|
91
105
|
end
|
|
92
106
|
|
|
93
|
-
private_class_method :add_text_to_categories
|
|
107
|
+
private_class_method :add_text_to_categories!, :category_container?
|
|
94
108
|
end
|
|
95
109
|
end
|
|
96
110
|
end
|
|
@@ -6,10 +6,12 @@ module Html2rss
|
|
|
6
6
|
# Extracts the earliest date from an article_tag.
|
|
7
7
|
class DateExtractor
|
|
8
8
|
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
9
|
+
# @param leftover_lines [Array<String>] leftover visible lines (already split)
|
|
10
|
+
# @param time_zone [String] channel time zone for naive values
|
|
9
11
|
# @return [DateTime, nil]
|
|
10
|
-
def self.call(article_tag)
|
|
11
|
-
datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
|
|
12
|
-
ArticleRules::Date.earliest(datetimes)
|
|
12
|
+
def self.call(article_tag, leftover_lines: [], time_zone: 'UTC')
|
|
13
|
+
datetimes = article_tag.respond_to?(:css) ? article_tag.css('[datetime]').map { |tag| tag['datetime'] } : []
|
|
14
|
+
ArticleRules::Date.earliest(datetimes, leftover_lines:, time_zone:)
|
|
13
15
|
end
|
|
14
16
|
end
|
|
15
17
|
end
|
|
@@ -5,6 +5,7 @@ module Html2rss
|
|
|
5
5
|
##
|
|
6
6
|
# ArticleExtractor is responsible for extracting details (headline, url, images, etc.)
|
|
7
7
|
# from an article_tag DOM node. DOM chrome helpers live on {Navigator}.
|
|
8
|
+
# rubocop:disable Metrics/ClassLength -- leftover re-extract stays with field extractors
|
|
8
9
|
class ArticleExtractor
|
|
9
10
|
class << self
|
|
10
11
|
##
|
|
@@ -14,9 +15,10 @@ module Html2rss
|
|
|
14
15
|
# @param base_url [String, Html2rss::Url] base url used to resolve relative links
|
|
15
16
|
# @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
|
|
16
17
|
# @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
|
|
18
|
+
# @param time_zone [String] channel time zone for naive leftover dates
|
|
17
19
|
# @return [Hash{Symbol => Object}] extracted article attributes
|
|
18
|
-
def call(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false)
|
|
19
|
-
new(article_tag, base_url:, selected_anchor:, fallback_anchorless:).call
|
|
20
|
+
def call(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false, time_zone: 'UTC')
|
|
21
|
+
new(article_tag, base_url:, selected_anchor:, fallback_anchorless:, time_zone:).call
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
|
|
@@ -25,32 +27,38 @@ module Html2rss
|
|
|
25
27
|
# @param base_url [String, Html2rss::Url] base url used to resolve relative links
|
|
26
28
|
# @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
|
|
27
29
|
# @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
|
|
28
|
-
|
|
30
|
+
# @param time_zone [String] channel time zone for naive leftover dates
|
|
31
|
+
def initialize(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false, time_zone: 'UTC')
|
|
29
32
|
raise ArgumentError, 'article_tag is required' unless article_tag
|
|
30
33
|
|
|
31
34
|
@article_tag = article_tag
|
|
32
35
|
@base_url = base_url
|
|
33
36
|
@selected_anchor = selected_anchor
|
|
34
37
|
@fallback_anchorless = fallback_anchorless
|
|
38
|
+
@time_zone = time_zone
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
# @return [Hash{Symbol => Object}] extracted article attributes
|
|
38
|
-
def call
|
|
42
|
+
def call # rubocop:disable Metrics/MethodLength
|
|
43
|
+
title = extract_title
|
|
44
|
+
lines = leftover_lines
|
|
45
|
+
published_at = extract_published_at(lines)
|
|
46
|
+
source, lines, published_at = parent_card_fields(title, lines, published_at)
|
|
39
47
|
{
|
|
40
|
-
title
|
|
48
|
+
title:,
|
|
41
49
|
url: extract_url,
|
|
42
50
|
image: extract_image,
|
|
43
|
-
description:
|
|
51
|
+
description: ArticleRules::Description.from_lines(lines, title:),
|
|
44
52
|
id: generate_id,
|
|
45
|
-
published_at
|
|
53
|
+
published_at:,
|
|
46
54
|
enclosures: extract_enclosures,
|
|
47
|
-
categories:
|
|
55
|
+
categories: CategoryExtractor.call(source, title:)
|
|
48
56
|
}
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
private
|
|
52
60
|
|
|
53
|
-
attr_reader :article_tag, :base_url, :selected_anchor
|
|
61
|
+
attr_reader :article_tag, :base_url, :selected_anchor, :time_zone
|
|
54
62
|
|
|
55
63
|
def extract_url
|
|
56
64
|
@extract_url ||= begin
|
|
@@ -106,13 +114,77 @@ module Html2rss
|
|
|
106
114
|
end
|
|
107
115
|
end
|
|
108
116
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
def leftover_lines
|
|
118
|
+
leftover_lines_from(article_tag)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def leftover_lines_from(node)
|
|
122
|
+
ArticleRules::Description.lines_from(
|
|
123
|
+
Navigator.extract_visible_text(node, exclude_nodes: leftover_exclude_nodes_for(node))
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def leftover_exclude_nodes_for(node)
|
|
128
|
+
times = node.respond_to?(:css) ? node.css('time') : []
|
|
129
|
+
[heading, selected_anchor, kicker_node, *times].compact
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def heading_or_anchor_item?
|
|
133
|
+
heading_item? || article_tag.name.to_s == 'a'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def heading_item?
|
|
137
|
+
Navigator::HEADING_TAGS.include?(article_tag.name.to_s)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def heading_or_anchor_miss?(title, lines, published_at)
|
|
141
|
+
CardWalk.miss?(
|
|
142
|
+
heading_or_anchor_item: heading_or_anchor_item?,
|
|
143
|
+
published_at:,
|
|
144
|
+
description: ArticleRules::Description.from_lines(lines, title:)
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parent_card_fields(title, lines, published_at)
|
|
149
|
+
return article_tag, lines, published_at unless heading_or_anchor_miss?(title, lines, published_at)
|
|
150
|
+
|
|
151
|
+
parent = immediate_card_parent
|
|
152
|
+
if !parent || crowded_parent?(parent)
|
|
153
|
+
Log.debug { "parent-walk abort at #{article_tag.parent&.name}" }
|
|
154
|
+
return article_tag, lines, published_at
|
|
155
|
+
end
|
|
113
156
|
|
|
114
|
-
|
|
115
|
-
|
|
157
|
+
new_lines = leftover_lines_from(parent)
|
|
158
|
+
new_date = extract_published_at_from(parent, new_lines)
|
|
159
|
+
[parent, new_lines, new_date || published_at]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def immediate_card_parent
|
|
163
|
+
parent = article_tag.respond_to?(:parent) ? article_tag.parent : nil
|
|
164
|
+
Navigator.parent_until_condition(parent, method(:usable_walk_parent?))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def usable_walk_parent?(node)
|
|
168
|
+
Navigator.usable_card_parent?(node) && !thin_heading_wrapper?(node)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def thin_heading_wrapper?(node)
|
|
172
|
+
CardWalk.thin_wrapper?(
|
|
173
|
+
children: node.element_children,
|
|
174
|
+
item: article_tag,
|
|
175
|
+
descendant_of: ->(item, child) { Navigator.descendant_of?(item, child) }
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def crowded_parent?(node)
|
|
180
|
+
CardWalk.crowded?(
|
|
181
|
+
heading_count: node.css(Navigator::HEADING_TAGS.join(',')).size,
|
|
182
|
+
distinct_main_hrefs: distinct_main_hrefs(node)
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def distinct_main_hrefs(node)
|
|
187
|
+
node.css(Navigator::MAIN_ANCHOR_SELECTOR).map { |anchor| anchor['href'] }.uniq.size
|
|
116
188
|
end
|
|
117
189
|
|
|
118
190
|
def generate_id
|
|
@@ -126,9 +198,15 @@ module Html2rss
|
|
|
126
198
|
end
|
|
127
199
|
|
|
128
200
|
def extract_image = ImageExtractor.call(article_tag, base_url:)
|
|
129
|
-
|
|
201
|
+
|
|
202
|
+
def extract_published_at(lines) = extract_published_at_from(article_tag, lines)
|
|
203
|
+
|
|
204
|
+
def extract_published_at_from(node, lines)
|
|
205
|
+
DateExtractor.call(node, leftover_lines: lines, time_zone:)
|
|
206
|
+
end
|
|
207
|
+
|
|
130
208
|
def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
|
|
131
|
-
def extract_categories = CategoryExtractor.call(article_tag)
|
|
132
209
|
end
|
|
210
|
+
# rubocop:enable Metrics/ClassLength
|
|
133
211
|
end
|
|
134
212
|
end
|
|
@@ -6,47 +6,64 @@ module Html2rss
|
|
|
6
6
|
##
|
|
7
7
|
# Shared category term vocabulary and text accumulation (DOM walk stays in adapters).
|
|
8
8
|
module Category
|
|
9
|
-
# Class/data attribute tokens that mark category metadata.
|
|
9
|
+
# Class/data attribute tokens that mark category metadata (not layout words).
|
|
10
10
|
CATEGORY_TERMS = %w[
|
|
11
|
-
category categories tag tags topic topics
|
|
12
|
-
|
|
11
|
+
category categories tag tags topic topics
|
|
12
|
+
theme themes subject subjects
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
|
-
#
|
|
16
|
-
|
|
15
|
+
# Frozen set for token membership checks.
|
|
16
|
+
TERM_SET = CATEGORY_TERMS.to_set.freeze
|
|
17
|
+
|
|
18
|
+
# Hyphen/underscore/BEM separators inside a single CSS class token.
|
|
19
|
+
CLASS_TOKEN_SPLIT = /[-_]+/
|
|
17
20
|
|
|
18
21
|
class << self
|
|
19
22
|
##
|
|
20
23
|
# @param name [String]
|
|
21
24
|
# @return [Boolean]
|
|
22
25
|
def attr_name_match?(name)
|
|
23
|
-
|
|
26
|
+
TERM_SET.include?(normalize_attr_name(name))
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
##
|
|
27
30
|
# @param class_attr [String, nil]
|
|
28
31
|
# @return [Boolean]
|
|
29
32
|
def class_match?(class_attr)
|
|
30
|
-
class_attr.to_s.
|
|
33
|
+
class_attr.to_s.split(/\s+/).any? { |token| class_token_match?(token) }
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
##
|
|
34
37
|
# @param categories [Set<String>]
|
|
35
38
|
# @param text [String, nil]
|
|
39
|
+
# @param title [String, nil]
|
|
36
40
|
# @return [void]
|
|
37
|
-
def add_text!(categories, text)
|
|
41
|
+
def add_text!(categories, text, title: nil)
|
|
38
42
|
value = text.to_s.strip
|
|
39
|
-
|
|
43
|
+
return if value.empty? || !Description.keep?(value, title:, type_chips: false)
|
|
44
|
+
|
|
45
|
+
categories.add(value)
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
##
|
|
43
49
|
# @param categories [Set<String>]
|
|
44
50
|
# @param text [String, nil]
|
|
51
|
+
# @param title [String, nil]
|
|
45
52
|
# @return [void]
|
|
46
|
-
def add_split_text!(categories, text)
|
|
53
|
+
def add_split_text!(categories, text, title: nil)
|
|
47
54
|
return unless text
|
|
48
55
|
|
|
49
|
-
text.split(/\n+/).each { |line| add_text!(categories, line) }
|
|
56
|
+
text.split(/\n+/).each { |line| add_text!(categories, line, title:) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def class_token_match?(token)
|
|
62
|
+
token.downcase.split(CLASS_TOKEN_SPLIT).intersect?(CATEGORY_TERMS)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_attr_name(name)
|
|
66
|
+
name.to_s.downcase.delete_prefix('data-')
|
|
50
67
|
end
|
|
51
68
|
end
|
|
52
69
|
end
|
|
@@ -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
|