html2rss 0.33.0 → 0.34.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 +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -21
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +126 -50
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +49 -6
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +4 -0
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +100 -9
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +0 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +6 -6
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors.rb +53 -12
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -4,44 +4,25 @@ module Html2rss
|
|
|
4
4
|
module Html
|
|
5
5
|
class ArticleExtractor
|
|
6
6
|
##
|
|
7
|
-
#
|
|
7
|
+
# Adapts Nokogiri elements to {ArticleRules::Enclosure}, which owns which elements become enclosures.
|
|
8
8
|
class EnclosureExtractor
|
|
9
|
-
# CSS union
|
|
10
|
-
SELECTOR =
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
'audio source[src]',
|
|
14
|
-
'audio[src]',
|
|
15
|
-
'a[href$=".pdf"]',
|
|
16
|
-
'iframe[src]',
|
|
17
|
-
'a[href$=".zip"]',
|
|
18
|
-
'a[href$=".tar.gz"]',
|
|
19
|
-
'a[href$=".tgz"]'
|
|
20
|
-
].join(',').freeze
|
|
9
|
+
# CSS union of the element names {ArticleRules::Enclosure} may turn into an enclosure.
|
|
10
|
+
SELECTOR = ArticleRules::Enclosure::ELEMENT_NAMES.join(',').freeze
|
|
11
|
+
# CSS union of the players a +source+ element must sit inside.
|
|
12
|
+
PLAYERS = ArticleRules::Enclosure::MEDIA_ELEMENTS.join(',').freeze
|
|
21
13
|
|
|
22
14
|
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
23
15
|
# @param base_url [String, Html2rss::Url] base URL for relative enclosure links
|
|
24
|
-
# @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
|
|
16
|
+
# @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes in document order, one per URL
|
|
25
17
|
def self.call(article_tag, base_url)
|
|
26
|
-
article_tag.css(SELECTOR).filter_map do |element|
|
|
27
|
-
|
|
18
|
+
found = article_tag.css(SELECTOR).filter_map do |element|
|
|
19
|
+
attrs = { src: element['src'], href: element['href'], type: element['type'] }
|
|
20
|
+
ArticleRules::Enclosure.from_element(element.name, base_url, **attrs) do
|
|
21
|
+
element.ancestors(PLAYERS).first&.name
|
|
22
|
+
end
|
|
28
23
|
end
|
|
24
|
+
ArticleRules::Enclosure.unique(found)
|
|
29
25
|
end
|
|
30
|
-
|
|
31
|
-
def self.extract_from_element(element, base_url)
|
|
32
|
-
case element.name
|
|
33
|
-
when 'img'
|
|
34
|
-
ArticleRules::Enclosure.from_image(element['src'], base_url)
|
|
35
|
-
when 'audio', 'source'
|
|
36
|
-
ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
|
|
37
|
-
when 'iframe'
|
|
38
|
-
ArticleRules::Enclosure.from_iframe(element['src'], base_url)
|
|
39
|
-
when 'a'
|
|
40
|
-
ArticleRules::Enclosure.from_anchor(element['href'], base_url)
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
private_class_method :extract_from_element
|
|
45
26
|
end
|
|
46
27
|
end
|
|
47
28
|
end
|
|
@@ -12,10 +12,9 @@ module Html2rss
|
|
|
12
12
|
class << self
|
|
13
13
|
##
|
|
14
14
|
# @param article_tag [Nokogiri::XML::Element] container node
|
|
15
|
-
# @param fallback_anchorless [Boolean] whether to use fallback search
|
|
16
15
|
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
17
16
|
# @return [Nokogiri::XML::Node, nil] the heading node, if found
|
|
18
|
-
def call(article_tag,
|
|
17
|
+
def call(article_tag, selected_anchor:)
|
|
19
18
|
tags = article_tag.css(HEADING_TAGS.join(','))
|
|
20
19
|
if tags.any?
|
|
21
20
|
select_best_heading(tags)
|
|
@@ -23,7 +22,7 @@ module Html2rss
|
|
|
23
22
|
labeled = heading_from_aria_or_title(article_tag)
|
|
24
23
|
return labeled if labeled
|
|
25
24
|
|
|
26
|
-
fallback_heading(article_tag) if
|
|
25
|
+
fallback_heading(article_tag) if selected_anchor.nil?
|
|
27
26
|
end
|
|
28
27
|
end
|
|
29
28
|
|
|
@@ -14,16 +14,15 @@ module Html2rss
|
|
|
14
14
|
# @param heading [Nokogiri::XML::Node, nil] heading node
|
|
15
15
|
# @param url [Html2rss::Url, nil] absolute article URL
|
|
16
16
|
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
17
|
-
# @param fallback_anchorless [Boolean] whether to use fallback hashing
|
|
18
17
|
# @return [String, nil] the generated ID, if any
|
|
19
|
-
def call(article_tag, heading:, url:, selected_anchor
|
|
18
|
+
def call(article_tag, heading:, url:, selected_anchor:)
|
|
20
19
|
id_from_dom = parse_id_from_dom(article_tag, url, selected_anchor)
|
|
21
20
|
return id_from_dom if id_from_dom
|
|
22
21
|
|
|
23
|
-
heading_text = resolve_heading_text(article_tag, heading
|
|
22
|
+
heading_text = resolve_heading_text(article_tag, heading)
|
|
24
23
|
if heading_text && !heading_text.strip.empty?
|
|
25
24
|
generate_slug(heading_text)
|
|
26
|
-
|
|
25
|
+
else
|
|
27
26
|
generate_content_hash(article_tag)
|
|
28
27
|
end
|
|
29
28
|
end
|
|
@@ -36,18 +35,16 @@ module Html2rss
|
|
|
36
35
|
candidates.compact.reject(&:empty?).first
|
|
37
36
|
end
|
|
38
37
|
|
|
39
|
-
def resolve_heading_text(article_tag, heading
|
|
38
|
+
def resolve_heading_text(article_tag, heading)
|
|
40
39
|
text = heading ? Navigator::TextExtractor.call(heading) : nil
|
|
41
40
|
if text.nil? || text.strip.empty?
|
|
42
|
-
fallback_text_node_content(article_tag
|
|
41
|
+
fallback_text_node_content(article_tag)
|
|
43
42
|
else
|
|
44
43
|
text
|
|
45
44
|
end
|
|
46
45
|
end
|
|
47
46
|
|
|
48
|
-
def fallback_text_node_content(article_tag
|
|
49
|
-
return unless fallback_anchorless
|
|
50
|
-
|
|
47
|
+
def fallback_text_node_content(article_tag)
|
|
51
48
|
article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }&.text&.strip
|
|
52
49
|
end
|
|
53
50
|
|
|
@@ -12,7 +12,8 @@ module Html2rss
|
|
|
12
12
|
def self.call(article_tag, base_url:)
|
|
13
13
|
img_src = from_source(article_tag) ||
|
|
14
14
|
from_img(article_tag) ||
|
|
15
|
-
from_style(article_tag)
|
|
15
|
+
from_style(article_tag) ||
|
|
16
|
+
from_poster(article_tag)
|
|
16
17
|
|
|
17
18
|
Url.from_relative(img_src, base_url) if img_src
|
|
18
19
|
end
|
|
@@ -43,6 +44,13 @@ module Html2rss
|
|
|
43
44
|
styles = article_tag.css('[style*="url"]').map { |tag| tag['style'] }
|
|
44
45
|
ArticleRules::Image.best_from_styles(styles)
|
|
45
46
|
end
|
|
47
|
+
|
|
48
|
+
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
49
|
+
# @return [String, nil] first usable video poster frame: the last resort, when the card has no other image
|
|
50
|
+
def self.from_poster(article_tag)
|
|
51
|
+
posters = article_tag.css('video[poster]').map { |video| video['poster'] }
|
|
52
|
+
posters.find { |src| ArticleRules::Image.remote_src?(src) }
|
|
53
|
+
end
|
|
46
54
|
end
|
|
47
55
|
end
|
|
48
56
|
end
|
|
@@ -14,11 +14,10 @@ module Html2rss
|
|
|
14
14
|
# @param article_tag [Nokogiri::XML::Node] article-like container to extract from
|
|
15
15
|
# @param base_url [String, Html2rss::Url] base url used to resolve relative links
|
|
16
16
|
# @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
|
|
17
|
-
# @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
|
|
18
17
|
# @param time_zone [String] channel time zone for naive leftover dates
|
|
19
18
|
# @return [Hash{Symbol => Object}] extracted article attributes
|
|
20
|
-
def call(article_tag, base_url:, selected_anchor: nil,
|
|
21
|
-
new(article_tag, base_url:, selected_anchor:,
|
|
19
|
+
def call(article_tag, base_url:, selected_anchor: nil, time_zone: 'UTC')
|
|
20
|
+
new(article_tag, base_url:, selected_anchor:, time_zone:).call
|
|
22
21
|
end
|
|
23
22
|
end
|
|
24
23
|
|
|
@@ -26,26 +25,26 @@ module Html2rss
|
|
|
26
25
|
# @param article_tag [Nokogiri::XML::Node] article-like container to extract from
|
|
27
26
|
# @param base_url [String, Html2rss::Url] base url used to resolve relative links
|
|
28
27
|
# @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
|
|
29
|
-
# @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
|
|
30
28
|
# @param time_zone [String] channel time zone for naive leftover dates
|
|
31
|
-
def initialize(article_tag, base_url:, selected_anchor: nil,
|
|
32
|
-
raise
|
|
29
|
+
def initialize(article_tag, base_url:, selected_anchor: nil, time_zone: 'UTC')
|
|
30
|
+
raise InvalidError, 'article_tag is required' unless article_tag
|
|
33
31
|
|
|
34
32
|
@article_tag = article_tag
|
|
35
33
|
@base_url = base_url
|
|
36
34
|
@selected_anchor = selected_anchor
|
|
37
|
-
@fallback_anchorless = fallback_anchorless
|
|
38
35
|
@time_zone = time_zone
|
|
39
36
|
end
|
|
40
37
|
|
|
41
38
|
# @return [Hash{Symbol => Object}] extracted article attributes
|
|
42
39
|
def call # rubocop:disable Metrics/MethodLength
|
|
43
|
-
title =
|
|
40
|
+
title = extract_headline
|
|
44
41
|
lines = leftover_lines
|
|
45
42
|
published_at = extract_published_at(lines)
|
|
46
43
|
source, lines, published_at = parent_card_fields(title, lines, published_at)
|
|
47
44
|
{
|
|
48
|
-
|
|
45
|
+
# Composed here, not passed as a +kicker:+ key: Selectors' enhance merges keys one by one,
|
|
46
|
+
# so a separate kicker would prefix an explicitly selected title.
|
|
47
|
+
title: Article::Title.compose(title, kicker: title_pieces.kicker_text),
|
|
49
48
|
url: extract_url,
|
|
50
49
|
image: extract_image,
|
|
51
50
|
description: ArticleRules::Description.from_lines(lines, title:),
|
|
@@ -63,55 +62,29 @@ module Html2rss
|
|
|
63
62
|
def extract_url
|
|
64
63
|
@extract_url ||= begin
|
|
65
64
|
href = selected_anchor&.[]('href').to_s
|
|
66
|
-
|
|
67
|
-
if href.empty?
|
|
68
|
-
anchorless_url_fallback
|
|
69
|
-
else
|
|
70
|
-
Url.from_relative(href.split('#').first.strip, base_url)
|
|
71
|
-
end
|
|
65
|
+
Url.from_relative(href.split('#').first, base_url) unless href.empty?
|
|
72
66
|
end
|
|
73
67
|
end
|
|
74
68
|
|
|
75
|
-
def
|
|
76
|
-
return unless
|
|
69
|
+
def extract_headline
|
|
70
|
+
return first_text_title unless headline_source
|
|
77
71
|
|
|
78
|
-
|
|
79
|
-
Url.from_relative("##{id}", base_url) if id
|
|
72
|
+
title_pieces.headline_text(headline_source)
|
|
80
73
|
end
|
|
81
74
|
|
|
82
|
-
#
|
|
83
|
-
def
|
|
84
|
-
source = heading || selected_anchor
|
|
85
|
-
title_text = source ? Navigator.extract_visible_text(source) : fallback_anchorless_title
|
|
86
|
-
return unless title_text
|
|
87
|
-
|
|
88
|
-
kicker = kicker_node ? Navigator.extract_visible_text(kicker_node).to_s.strip : nil
|
|
89
|
-
kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def fallback_anchorless_title
|
|
93
|
-
return unless @fallback_anchorless && selected_anchor.nil?
|
|
75
|
+
# Kicker and headline text ({TitlePieces}) of this card.
|
|
76
|
+
def title_pieces = @title_pieces ||= TitlePieces.new(article_tag, headline_source, anchor: selected_anchor)
|
|
94
77
|
|
|
78
|
+
def first_text_title
|
|
95
79
|
text_node = article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }
|
|
96
80
|
text_node&.text&.strip
|
|
97
81
|
end
|
|
98
82
|
|
|
99
83
|
def heading
|
|
100
|
-
@heading ||= HeadingExtractor.call(
|
|
101
|
-
article_tag,
|
|
102
|
-
fallback_anchorless: @fallback_anchorless,
|
|
103
|
-
selected_anchor:
|
|
104
|
-
)
|
|
84
|
+
@heading ||= HeadingExtractor.call(article_tag, selected_anchor:)
|
|
105
85
|
end
|
|
106
86
|
|
|
107
|
-
def
|
|
108
|
-
@kicker_node ||= begin
|
|
109
|
-
selector = '[data-tb-kicker], [class*="kicker"], [class*="eyebrow"], ' \
|
|
110
|
-
'[class*="pre-title"], [class*="pretitle"], [class*="overline"]'
|
|
111
|
-
node = article_tag.at_css(selector)
|
|
112
|
-
node && heading && (node == heading || Navigator.descendant_of?(node, heading)) ? nil : node
|
|
113
|
-
end
|
|
114
|
-
end
|
|
87
|
+
def headline_source = heading || selected_anchor
|
|
115
88
|
|
|
116
89
|
def leftover_lines
|
|
117
90
|
leftover_lines_from(article_tag)
|
|
@@ -124,7 +97,7 @@ module Html2rss
|
|
|
124
97
|
end
|
|
125
98
|
|
|
126
99
|
def leftover_exclude_nodes_for(node)
|
|
127
|
-
[heading, selected_anchor,
|
|
100
|
+
[heading, selected_anchor, title_pieces.kicker, *node.css('time')].compact
|
|
128
101
|
end
|
|
129
102
|
|
|
130
103
|
def heading_or_anchor_item?
|
|
@@ -186,8 +159,7 @@ module Html2rss
|
|
|
186
159
|
article_tag,
|
|
187
160
|
heading:,
|
|
188
161
|
url: (selected_anchor ? extract_url : nil),
|
|
189
|
-
selected_anchor
|
|
190
|
-
fallback_anchorless: @fallback_anchorless
|
|
162
|
+
selected_anchor:
|
|
191
163
|
)
|
|
192
164
|
end
|
|
193
165
|
|
|
@@ -27,10 +27,10 @@ module Html2rss
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
##
|
|
30
|
-
# @param
|
|
30
|
+
# @param class_names [Array<String>] individual class tokens
|
|
31
31
|
# @return [Boolean]
|
|
32
|
-
def class_match?(
|
|
33
|
-
|
|
32
|
+
def class_match?(class_names)
|
|
33
|
+
class_names.any? { |token| class_token_match?(token) }
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
##
|
|
@@ -7,24 +7,15 @@ module Html2rss
|
|
|
7
7
|
# Leftover visible-text keep/drop for article descriptions.
|
|
8
8
|
# Split once on block newlines; Date and Category consume the same keepers.
|
|
9
9
|
module Description
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
TYPE_CHIPS = Set['news article', 'press release', 'news'].freeze
|
|
10
|
+
# Owner of the read-more vocabulary; a whole line that is one is chrome, not a dek.
|
|
11
|
+
TEXT_CLASSIFIER = LinkDestination::TextClassifier.new
|
|
12
|
+
private_constant :TEXT_CLASSIFIER
|
|
14
13
|
# Listing section names that are chrome, not a dek.
|
|
15
14
|
SECTION_NAMES = Set['press releases'].freeze
|
|
16
15
|
# Skip date detection / DateTime.parse above this length.
|
|
17
16
|
MAX_DATE_CHARS = 128
|
|
18
17
|
# Optional type-chip / clock separators (ASCII/en/em dash, pipe, bullet).
|
|
19
18
|
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
19
|
# Whole-line date shapes after normalize (ISO, numeric, day-month, month-day).
|
|
29
20
|
DATE_SHAPES = [
|
|
30
21
|
/\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,
|
|
@@ -100,13 +91,13 @@ module Html2rss
|
|
|
100
91
|
nil
|
|
101
92
|
end
|
|
102
93
|
|
|
103
|
-
def type_chip?(text) =
|
|
94
|
+
def type_chip?(text) = Article::Title::TYPE_MARKERS.include?(text.downcase)
|
|
104
95
|
|
|
105
|
-
def clock_zone?(text) =
|
|
96
|
+
def clock_zone?(text) = Article::Title.clock_piece?(text)
|
|
106
97
|
|
|
107
98
|
def chrome?(normalized, title:, type_chips:)
|
|
108
99
|
key = normalized.downcase
|
|
109
|
-
|
|
100
|
+
TEXT_CLASSIFIER.read_more?(normalized) || (type_chips && Article::Title::TYPE_MARKERS.include?(key)) ||
|
|
110
101
|
SECTION_NAMES.include?(key) || normalized.end_with?(':') ||
|
|
111
102
|
date_shaped?(normalized) || title_echo?(key, title)
|
|
112
103
|
end
|
|
@@ -4,66 +4,94 @@ module Html2rss
|
|
|
4
4
|
module Html
|
|
5
5
|
module ArticleRules
|
|
6
6
|
##
|
|
7
|
-
#
|
|
7
|
+
# The one rule for which HTML elements become enclosures, and their URL/type normalization.
|
|
8
|
+
#
|
|
9
|
+
# DOM-agnostic: {ArticleExtractor::EnclosureExtractor} (Nokogiri) and {SstArticleExtractor} (SST)
|
|
10
|
+
# only visit {ELEMENT_NAMES}, adapt their node API to {.from_element} and pass the result through
|
|
11
|
+
# {.unique}. Images (+img[src]+), players (+audio[src]+, +video[src]+), a player's +source[src]+ with
|
|
12
|
+
# its +type+, and file links ({FILE_LINK}) qualify; an +iframe+ (embed player page) never does,
|
|
13
|
+
# and only an http(s) URL is one (no +data:+, +blob:+, +javascript:+ or +mailto:+).
|
|
8
14
|
module Enclosure
|
|
9
|
-
#
|
|
10
|
-
|
|
15
|
+
# A link to a file: its path (before any query or fragment) ends, in any case, in a downloadable
|
|
16
|
+
# document or archive extension (+.pdf+, +.zip+, +.tar.gz+, +.tgz+) or an audio or video one
|
|
17
|
+
# ({Article::Enclosure::MEDIA_TYPES}, which also types it), such as an episode's download link.
|
|
18
|
+
FILE_LINK = /
|
|
19
|
+
\A[^?\#]*\.
|
|
20
|
+
(?:pdf|zip|tar\.gz|tgz|#{Regexp.union(Article::Enclosure::MEDIA_TYPES.keys).source})
|
|
21
|
+
(?:[?\#]|\z)
|
|
22
|
+
/ix
|
|
23
|
+
# Element names that may carry an enclosure.
|
|
24
|
+
ELEMENT_NAMES = %w[img audio video source a].freeze
|
|
25
|
+
# Players whose own +src+ and inner +source+ elements name a media file.
|
|
26
|
+
MEDIA_ELEMENTS = %w[audio video].freeze
|
|
11
27
|
|
|
12
28
|
class << self
|
|
13
29
|
##
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
##
|
|
21
|
-
# @param src [String]
|
|
30
|
+
# The enclosure an HTML element stands for, if any. A player's file carries the player as its
|
|
31
|
+
# kind, so an untyped file without a media extension is still audio or video
|
|
32
|
+
# ({Article::Enclosure::KIND_TYPES}).
|
|
33
|
+
#
|
|
34
|
+
# @param name [String] element name (lower case)
|
|
22
35
|
# @param base_url [String, Html2rss::Url]
|
|
36
|
+
# @param src [String, nil] the element's +src+ attribute
|
|
37
|
+
# @param href [String, nil] the element's +href+ attribute
|
|
38
|
+
# @param type [String, nil] the element's +type+ attribute
|
|
39
|
+
# @yieldreturn [String, nil] the name of the nearest {MEDIA_ELEMENTS} player around the element (an
|
|
40
|
+
# ancestor, not only the parent: the HTML4 parser nests sibling +source+ elements), or nil; asked
|
|
41
|
+
# only for a +source+
|
|
23
42
|
# @return [Hash{Symbol => Object}, nil]
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
43
|
+
def from_element(name, base_url, src:, href:, type:)
|
|
44
|
+
case name
|
|
45
|
+
when 'img' then from_image(src, base_url)
|
|
46
|
+
when 'audio', 'video' then from_media(src, type, base_url, kind: name.to_sym)
|
|
47
|
+
when 'source' then from_source(src, type, base_url, yield)
|
|
48
|
+
when 'a' then from_anchor(href, base_url)
|
|
49
|
+
end
|
|
29
50
|
end
|
|
30
51
|
|
|
31
52
|
##
|
|
32
|
-
# @param
|
|
33
|
-
# @
|
|
34
|
-
|
|
35
|
-
# @return [Hash{Symbol => Object}, nil]
|
|
36
|
-
def from_media(src, type, base_url)
|
|
37
|
-
return if src.to_s.empty?
|
|
53
|
+
# @param href [String, nil] an anchor's +href+
|
|
54
|
+
# @return [Boolean] whether it names a file {.from_element} turns into an enclosure ({FILE_LINK})
|
|
55
|
+
def file_link?(href) = href.to_s.match?(FILE_LINK)
|
|
38
56
|
|
|
39
|
-
|
|
57
|
+
##
|
|
58
|
+
# @param src [String, nil]
|
|
59
|
+
# @param type [String, nil] declared MIME type; nil lets {Article::Enclosure#type} guess it
|
|
60
|
+
# @param base_url [String, Html2rss::Url, nil]
|
|
61
|
+
# @param kind [Symbol, nil] +:audio+ or +:video+ when the source proves the file's kind
|
|
62
|
+
# @return [Hash{Symbol => Object}, nil] nil unless +src+ resolves to an http(s) URL ({Url.resolve_web})
|
|
63
|
+
def from_media(src, type, base_url, kind: nil)
|
|
64
|
+
return unless (url = Url.resolve_web(src, base_url))
|
|
65
|
+
|
|
66
|
+
{ url:, type:, kind: }.compact
|
|
40
67
|
end
|
|
41
68
|
|
|
42
69
|
##
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
70
|
+
# One enclosure per URL, at its first position in document order; a duplicate declaring a type
|
|
71
|
+
# wins over an untyped one (+<video src=x><source src=x type=video/webm>+).
|
|
72
|
+
#
|
|
73
|
+
# @param enclosures [Array<Hash{Symbol => Object}>] {.from_element} results in document order
|
|
74
|
+
# @return [Array<Hash{Symbol => Object}>]
|
|
75
|
+
def unique(enclosures)
|
|
76
|
+
enclosures.group_by { _1[:url] }.map { |_url, same| same.find { _1[:type] } || same.first }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def from_image(src, base_url)
|
|
82
|
+
return unless (url = Url.resolve_web(src, base_url))
|
|
48
83
|
|
|
49
|
-
|
|
50
|
-
|
|
84
|
+
{ url:, type: Article::Enclosure.guess_content_type_from_url(url, default: 'image/jpeg') }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def from_source(src, type, base_url, player)
|
|
88
|
+
from_media(src, type, base_url, kind: player.to_sym) if player
|
|
51
89
|
end
|
|
52
90
|
|
|
53
|
-
##
|
|
54
|
-
# @param href [String]
|
|
55
|
-
# @param base_url [String, Html2rss::Url]
|
|
56
|
-
# @return [Hash{Symbol => Object}, nil]
|
|
57
91
|
def from_anchor(href, base_url)
|
|
58
|
-
return
|
|
92
|
+
return unless file_link?(href) && (url = Url.resolve_web(href, base_url))
|
|
59
93
|
|
|
60
|
-
|
|
61
|
-
type = if href.end_with?('.pdf')
|
|
62
|
-
Article::Enclosure.guess_content_type_from_url(abs)
|
|
63
|
-
else
|
|
64
|
-
'application/zip'
|
|
65
|
-
end
|
|
66
|
-
{ url: abs, type: }
|
|
94
|
+
{ url: }
|
|
67
95
|
end
|
|
68
96
|
end
|
|
69
97
|
end
|
|
@@ -14,10 +14,16 @@ module Html2rss
|
|
|
14
14
|
MAX_ATTR_CHARS = 4_096
|
|
15
15
|
# Optional quoting around a CSS `url(...)` value.
|
|
16
16
|
QUOTES = ['"', "'"].freeze
|
|
17
|
-
# Srcset width/height descriptor
|
|
18
|
-
|
|
19
|
-
# HTML/CSS whitespace treated as srcset token separators.
|
|
20
|
-
|
|
17
|
+
# Srcset width/height descriptor suffix bytes (`w`, `h`).
|
|
18
|
+
UNIT_BYTES = [119, 104].freeze
|
|
19
|
+
# HTML/CSS whitespace bytes (space, tab, LF, CR, FF) treated as srcset token separators.
|
|
20
|
+
WS_BYTES = [32, 9, 10, 13, 12].freeze
|
|
21
|
+
# ASCII `,`.
|
|
22
|
+
COMMA = 44
|
|
23
|
+
# ASCII `0`.
|
|
24
|
+
ZERO = 48
|
|
25
|
+
# ASCII `9`.
|
|
26
|
+
NINE = 57
|
|
21
27
|
|
|
22
28
|
class << self
|
|
23
29
|
##
|
|
@@ -29,6 +35,11 @@ module Html2rss
|
|
|
29
35
|
by_size[by_size.keys.max]
|
|
30
36
|
end
|
|
31
37
|
|
|
38
|
+
##
|
|
39
|
+
# @param src [String, nil] an image URL attribute, e.g. a video +poster+
|
|
40
|
+
# @return [Boolean] whether it names a fetchable image: not blank, not an inline +data:+ URI
|
|
41
|
+
def remote_src?(src) = src.to_s.match?(/\S/) && !src.start_with?('data:')
|
|
42
|
+
|
|
32
43
|
##
|
|
33
44
|
# @param style_strings [Array<String>] CSS style attribute values
|
|
34
45
|
# @return [String, nil] longest non-data background url
|
|
@@ -54,23 +65,24 @@ module Html2rss
|
|
|
54
65
|
|
|
55
66
|
# URL token + `Nw`/`Nh` pairs. Commas may appear inside URLs; a candidate
|
|
56
67
|
# boundary is whitespace before a width/height descriptor.
|
|
68
|
+
# Scans bytes: every separator, digit and unit is ASCII, and UTF-8
|
|
69
|
+
# continuation bytes (0x80+) never equal an ASCII byte, so byte offsets are safe.
|
|
70
|
+
# `getbyte` returns nil past the end, which every predicate treats as no match.
|
|
57
71
|
def each_srcset_pair(string) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
58
72
|
i = 0
|
|
59
|
-
len = string.
|
|
73
|
+
len = string.bytesize
|
|
60
74
|
while i < len
|
|
61
|
-
i += 1 while
|
|
62
|
-
break if i >= len
|
|
63
|
-
|
|
75
|
+
i += 1 while sep?(string.getbyte(i))
|
|
64
76
|
start = i
|
|
65
|
-
i += 1
|
|
66
|
-
url = string
|
|
77
|
+
i += 1 until ws?(string.getbyte(i)) || i >= len
|
|
78
|
+
url = string.byteslice(start, i - start)
|
|
67
79
|
|
|
68
|
-
i += 1 while
|
|
80
|
+
i += 1 while ws?(string.getbyte(i))
|
|
69
81
|
size_start = i
|
|
70
|
-
i += 1 while
|
|
71
|
-
next unless i > size_start &&
|
|
82
|
+
i += 1 while digit?(string.getbyte(i))
|
|
83
|
+
next unless i > size_start && UNIT_BYTES.include?(string.getbyte(i))
|
|
72
84
|
|
|
73
|
-
yield url, string
|
|
85
|
+
yield url, string.byteslice(size_start, i - size_start).to_i
|
|
74
86
|
i += 1
|
|
75
87
|
end
|
|
76
88
|
end
|
|
@@ -99,9 +111,11 @@ module Html2rss
|
|
|
99
111
|
string[start, close - start] if close
|
|
100
112
|
end
|
|
101
113
|
|
|
102
|
-
def sep?(
|
|
114
|
+
def sep?(byte) = ws?(byte) || byte == COMMA
|
|
115
|
+
|
|
116
|
+
def ws?(byte) = WS_BYTES.include?(byte)
|
|
103
117
|
|
|
104
|
-
def
|
|
118
|
+
def digit?(byte) = byte&.between?(ZERO, NINE)
|
|
105
119
|
end
|
|
106
120
|
end
|
|
107
121
|
end
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module Html
|
|
5
|
-
FeedLink = Data.define(:href, :mime_type)
|
|
5
|
+
FeedLink = Data.define(:href, :mime_type, :title)
|
|
6
6
|
|
|
7
|
-
# A native RSS/Atom hint from a document head +link[rel=alternate]+.
|
|
7
|
+
# A native RSS/Atom/RDF hint from a document head +link[rel=alternate]+.
|
|
8
8
|
#
|
|
9
9
|
# Does not guess +/feed+ or +/rss.xml+ paths — that stays in configs +probe_rss+.
|
|
10
|
+
# +title+ is the link's stripped +title+ attribute (empty when absent).
|
|
10
11
|
class FeedLink
|
|
11
12
|
class << self
|
|
12
13
|
##
|
|
13
|
-
# Collects RSS/Atom +rel=alternate+ links from +doc+'s +head+.
|
|
14
|
+
# Collects RSS/Atom/RDF +rel=alternate+ links from +doc+'s +head+.
|
|
14
15
|
#
|
|
15
16
|
# @param doc [Nokogiri::XML::Node] parsed HTML document
|
|
16
17
|
# @return [Array<FeedLink>] native feed hints, omitting blanks and non-feed types
|
|
@@ -21,16 +22,13 @@ module Html2rss
|
|
|
21
22
|
private
|
|
22
23
|
|
|
23
24
|
def from_node(node)
|
|
24
|
-
href = node['href']
|
|
25
|
+
href = node['href'].strip # the head selector guarantees an href attribute
|
|
25
26
|
mime_type = feed_mime_type(node['type'])
|
|
26
|
-
new(href:, mime_type:) unless href.
|
|
27
|
+
new(href:, mime_type:, title: node['title'].to_s.strip) unless href.empty? || mime_type.nil?
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def feed_mime_type(type)
|
|
30
|
-
|
|
31
|
-
return Probe::APPLICATION_ATOM_XML if Probe.mime_match?(type, Probe::APPLICATION_ATOM_XML)
|
|
32
|
-
|
|
33
|
-
nil
|
|
31
|
+
Probe::FEED_MIME_TYPES.find { |mime| Probe.mime_match?(type, mime) }
|
|
34
32
|
end
|
|
35
33
|
end
|
|
36
34
|
end
|