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
|
@@ -6,7 +6,7 @@ module Html2rss
|
|
|
6
6
|
#
|
|
7
7
|
# Owned by {FeedPipeline}; invoked only after {StrategyPlan} resolves +:auto+.
|
|
8
8
|
# Hosted by the pipeline instance: session + extract call back into FeedPipeline.
|
|
9
|
-
class AutoFallback
|
|
9
|
+
class AutoFallback # rubocop:disable Metrics/ClassLength -- attempt state + logging stay co-located
|
|
10
10
|
# Ordered list of concrete request strategies attempted by the :auto plan.
|
|
11
11
|
CHAIN = %i[faraday botasaurus].freeze
|
|
12
12
|
|
|
@@ -15,7 +15,6 @@ module Html2rss
|
|
|
15
15
|
RequestService::UnknownStrategy,
|
|
16
16
|
RequestService::InvalidUrl,
|
|
17
17
|
RequestService::UnsupportedUrlScheme,
|
|
18
|
-
RequestService::UnsupportedResponseContentType,
|
|
19
18
|
RequestService::RequestBudgetExceeded,
|
|
20
19
|
RequestService::PrivateNetworkDenied,
|
|
21
20
|
RequestService::CrossOriginFollowUpDenied,
|
|
@@ -64,17 +63,22 @@ module Html2rss
|
|
|
64
63
|
# @param dedup_dropped [Integer] articles removed by deduplication
|
|
65
64
|
# @param selected_strategy [Symbol] concrete strategy that produced items
|
|
66
65
|
# @param attempt_count [Integer] number of attempts recorded for this chain
|
|
66
|
+
# @param admission_drops [Hash{String => Integer}] Cleanup drop tallies
|
|
67
67
|
# @return [void]
|
|
68
|
-
|
|
68
|
+
# rubocop:disable Metrics/ParameterLists -- PipelineOutcome kwargs stay co-located
|
|
69
|
+
def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
70
|
+
admission_drops: {})
|
|
69
71
|
@result = PipelineOutcome.new(
|
|
70
72
|
response:,
|
|
71
73
|
articles:,
|
|
72
74
|
dedup_dropped:,
|
|
73
75
|
selected_strategy:,
|
|
74
76
|
attempt_count:,
|
|
75
|
-
strategy_attempts: attempts
|
|
77
|
+
strategy_attempts: attempts,
|
|
78
|
+
admission_drops:
|
|
76
79
|
)
|
|
77
80
|
end
|
|
81
|
+
# rubocop:enable Metrics/ParameterLists
|
|
78
82
|
end
|
|
79
83
|
|
|
80
84
|
##
|
|
@@ -120,6 +124,9 @@ module Html2rss
|
|
|
120
124
|
return unless response
|
|
121
125
|
|
|
122
126
|
process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
127
|
+
rescue RequestService::UnsupportedResponseContentType => error
|
|
128
|
+
state.record_error(strategy:, error:)
|
|
129
|
+
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
|
|
123
130
|
end
|
|
124
131
|
|
|
125
132
|
def fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
@@ -132,15 +139,18 @@ module Html2rss
|
|
|
132
139
|
nil
|
|
133
140
|
end
|
|
134
141
|
|
|
135
|
-
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
142
|
+
def process_response(response:, strategy:, next_strategy:, request_session:, state:) # rubocop:disable Metrics/MethodLength -- extract + success path
|
|
143
|
+
articles, dedup_dropped, admission_drops = articles_for(response:, request_session:)
|
|
136
144
|
state.remember_response(response)
|
|
137
|
-
articles, dedup_dropped = articles_for(response:, request_session:)
|
|
138
145
|
items_count = articles.size
|
|
139
146
|
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
140
147
|
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
141
148
|
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
142
149
|
"budget_remaining=#{budget_remaining_label}")
|
|
143
|
-
|
|
150
|
+
if items_count.positive?
|
|
151
|
+
return record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:,
|
|
152
|
+
state:)
|
|
153
|
+
end
|
|
144
154
|
|
|
145
155
|
log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
|
|
146
156
|
end
|
|
@@ -149,15 +159,18 @@ module Html2rss
|
|
|
149
159
|
pipeline.deduplicated_articles(config:, response:, request_session:)
|
|
150
160
|
end
|
|
151
161
|
|
|
152
|
-
|
|
162
|
+
# rubocop:disable Metrics/ParameterLists -- success kwargs match PipelineOutcome
|
|
163
|
+
def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
|
|
153
164
|
attempt_count = state.attempts.size
|
|
154
|
-
state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy,
|
|
165
|
+
state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy,
|
|
166
|
+
attempt_count:, admission_drops:)
|
|
155
167
|
return unless attempt_count > 1
|
|
156
168
|
|
|
157
169
|
Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
|
|
158
170
|
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
159
171
|
"budget_remaining=#{budget_remaining_label}")
|
|
160
172
|
end
|
|
173
|
+
# rubocop:enable Metrics/ParameterLists
|
|
161
174
|
|
|
162
175
|
def finalize_failure(attempts:, response:)
|
|
163
176
|
surface_category = response && AutoSource::Scraper.classify_no_scraper_surface(
|
|
@@ -41,6 +41,17 @@ module Html2rss
|
|
|
41
41
|
Concrete.new(strategy: normalized)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
##
|
|
45
|
+
# Cheap single-request diagnostic default: +:auto+ → +:faraday+.
|
|
46
|
+
# Does not run {AutoFallback}. Prefer {resolve} for scrape/capture feeds.
|
|
47
|
+
#
|
|
48
|
+
# @param name [Symbol, String, nil] plan name (+nil+ → +:auto+)
|
|
49
|
+
# @return [Symbol] concrete {RequestService} strategy
|
|
50
|
+
def concrete_for_diagnostic(name = AUTO_NAME)
|
|
51
|
+
plan = resolve(name || AUTO_NAME)
|
|
52
|
+
plan.is_a?(Auto) ? RequestService.default_strategy_name : plan.strategy
|
|
53
|
+
end
|
|
54
|
+
|
|
44
55
|
##
|
|
45
56
|
# @param name [Symbol, String, nil] candidate plan name
|
|
46
57
|
# @return [Boolean] whether +name+ is a valid feed-level strategy plan
|
|
@@ -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,11 +94,12 @@ 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:)
|
|
97
|
+
articles, dedup_dropped, admission_drops = deduplicated_articles(config:, response:, request_session:)
|
|
84
98
|
raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
|
|
85
99
|
|
|
86
100
|
PipelineOutcome.new(
|
|
87
|
-
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:
|
|
88
103
|
)
|
|
89
104
|
end
|
|
90
105
|
|
|
@@ -108,8 +123,9 @@ module Html2rss
|
|
|
108
123
|
end
|
|
109
124
|
|
|
110
125
|
def collect_articles(config:, response:, request_session:)
|
|
111
|
-
selector_articles(config:, response:, request_session:)
|
|
112
|
-
|
|
126
|
+
selector = selector_articles(config:, response:, request_session:)
|
|
127
|
+
auto, admission_drops = auto_source_articles(config:, response:, request_session:)
|
|
128
|
+
[selector + auto, admission_drops]
|
|
113
129
|
end
|
|
114
130
|
|
|
115
131
|
# rubocop:disable Metrics/MethodLength
|
|
@@ -132,10 +148,12 @@ module Html2rss
|
|
|
132
148
|
end
|
|
133
149
|
# rubocop:enable Metrics/MethodLength
|
|
134
150
|
|
|
151
|
+
# @return [Array(Array<Html2rss::Article>, Hash{String => Integer})]
|
|
135
152
|
def auto_source_articles(config:, response:, request_session:)
|
|
136
|
-
return [] unless (auto_source = config.auto_source)
|
|
153
|
+
return [[], {}] unless (auto_source = config.auto_source)
|
|
137
154
|
|
|
138
|
-
AutoSource.new(response, auto_source, request_session:)
|
|
155
|
+
source = AutoSource.new(response, auto_source, request_session:)
|
|
156
|
+
[source.articles, source.admission_drops]
|
|
139
157
|
end
|
|
140
158
|
end
|
|
141
159
|
end
|
|
@@ -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
|