html2rss 0.24.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/mcp/server.rb +577 -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 +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- 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 +23 -1
- 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 +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- 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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module ArticleRules
|
|
6
|
+
##
|
|
7
|
+
# DOM-agnostic image URL selection (srcset sizing, CSS background urls).
|
|
8
|
+
#
|
|
9
|
+
# Uses string scans instead of Regexp: CodeQL rb/polynomial-redos flags the
|
|
10
|
+
# prior patterns on uncontrolled HTML attributes, and length guards alone
|
|
11
|
+
# do not clear the query.
|
|
12
|
+
module Image
|
|
13
|
+
# Skip absurdly large attributes before scanning.
|
|
14
|
+
MAX_ATTR_CHARS = 4_096
|
|
15
|
+
# Optional quoting around a CSS `url(...)` value.
|
|
16
|
+
QUOTES = ['"', "'"].freeze
|
|
17
|
+
# Srcset width/height descriptor suffixes.
|
|
18
|
+
UNITS = %w[w h].freeze
|
|
19
|
+
# HTML/CSS whitespace treated as srcset token separators.
|
|
20
|
+
WS = [' ', "\t", "\n", "\r", "\f"].freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
##
|
|
24
|
+
# @param srcset_strings [Array<String>] raw srcset attribute values
|
|
25
|
+
# @return [String, nil] URL of the largest width/height candidate
|
|
26
|
+
def largest_from_srcsets(srcset_strings)
|
|
27
|
+
by_size = {}
|
|
28
|
+
Array(srcset_strings).each { |srcset| merge_srcset!(by_size, srcset) }
|
|
29
|
+
by_size[by_size.keys.max]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# @param style_strings [Array<String>] CSS style attribute values
|
|
34
|
+
# @return [String, nil] longest non-data background url
|
|
35
|
+
def best_from_styles(style_strings)
|
|
36
|
+
Array(style_strings)
|
|
37
|
+
.filter_map { |style| style_url(style) }
|
|
38
|
+
.reject { |src| src.start_with?('data:') }
|
|
39
|
+
.max_by(&:size)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def merge_srcset!(by_size, srcset)
|
|
45
|
+
s = srcset.to_s
|
|
46
|
+
return if s.length > MAX_ATTR_CHARS
|
|
47
|
+
|
|
48
|
+
each_srcset_pair(s) do |url, size|
|
|
49
|
+
next if url.start_with?('data:')
|
|
50
|
+
|
|
51
|
+
by_size[size] = url
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# URL token + `Nw`/`Nh` pairs. Commas may appear inside URLs; a candidate
|
|
56
|
+
# boundary is whitespace before a width/height descriptor.
|
|
57
|
+
def each_srcset_pair(string) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
58
|
+
i = 0
|
|
59
|
+
len = string.length
|
|
60
|
+
while i < len
|
|
61
|
+
i += 1 while i < len && sep?(string[i])
|
|
62
|
+
break if i >= len
|
|
63
|
+
|
|
64
|
+
start = i
|
|
65
|
+
i += 1 while i < len && !ws?(string[i])
|
|
66
|
+
url = string[start, i - start]
|
|
67
|
+
|
|
68
|
+
i += 1 while i < len && ws?(string[i])
|
|
69
|
+
size_start = i
|
|
70
|
+
i += 1 while i < len && string[i].between?('0', '9')
|
|
71
|
+
next unless i > size_start && i < len && UNITS.include?(string[i])
|
|
72
|
+
|
|
73
|
+
yield url, string[size_start, i - size_start].to_i
|
|
74
|
+
i += 1
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def style_url(style)
|
|
79
|
+
s = style.to_s
|
|
80
|
+
return if s.length > MAX_ATTR_CHARS
|
|
81
|
+
|
|
82
|
+
pos = s.index('url(')
|
|
83
|
+
return unless pos
|
|
84
|
+
|
|
85
|
+
extract_url_body(s, pos + 4)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def extract_url_body(string, index)
|
|
89
|
+
quote = string[index]
|
|
90
|
+
if QUOTES.include?(quote)
|
|
91
|
+
slice_until(string, index + 1, quote)
|
|
92
|
+
else
|
|
93
|
+
slice_until(string, index, ')')
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def slice_until(string, start, closer)
|
|
98
|
+
close = string.index(closer, start)
|
|
99
|
+
string[start, close - start] if close
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sep?(char) = ws?(char) || char == ','
|
|
103
|
+
|
|
104
|
+
def ws?(char) = WS.include?(char)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'cgi'
|
|
4
|
-
|
|
5
3
|
module Html2rss
|
|
6
4
|
module Html
|
|
7
5
|
module Rendering
|
|
8
6
|
# Renders an HTML <audio> tag from a URL and type.
|
|
9
7
|
class AudioRenderer
|
|
8
|
+
include EscapedAttributes
|
|
9
|
+
|
|
10
10
|
# @param url [String, Html2rss::Url] media URL for the audio source
|
|
11
11
|
# @param type [String] MIME type for the audio source
|
|
12
12
|
def initialize(url:, type:)
|
|
@@ -22,16 +22,6 @@ module Html2rss
|
|
|
22
22
|
'</audio>'
|
|
23
23
|
].join
|
|
24
24
|
end
|
|
25
|
-
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
def escaped_url
|
|
29
|
-
CGI.escapeHTML(@url.to_s)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def escaped_type
|
|
33
|
-
CGI.escapeHTML(@type.to_s)
|
|
34
|
-
end
|
|
35
25
|
end
|
|
36
26
|
end
|
|
37
27
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
module Rendering
|
|
8
|
+
##
|
|
9
|
+
# Shared HTML attribute escaping for media renderers.
|
|
10
|
+
module EscapedAttributes
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def escaped_url
|
|
14
|
+
CGI.escapeHTML(@url.to_s)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def escaped_type
|
|
18
|
+
CGI.escapeHTML(@type.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def escaped_title
|
|
22
|
+
CGI.escapeHTML(@title.to_s)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'cgi'
|
|
4
|
-
|
|
5
3
|
module Html2rss
|
|
6
4
|
module Html
|
|
7
5
|
module Rendering
|
|
8
6
|
# Renders an HTML <img> tag from a URL and title.
|
|
9
7
|
class ImageRenderer
|
|
8
|
+
include EscapedAttributes
|
|
9
|
+
|
|
10
10
|
# @param url [String, Html2rss::Url] image URL for the src attribute
|
|
11
11
|
# @param title [String, nil] title/alt text for the image
|
|
12
12
|
def initialize(url:, title:)
|
|
@@ -27,16 +27,6 @@ module Html2rss
|
|
|
27
27
|
]
|
|
28
28
|
"<img #{attributes.join(' ')}>"
|
|
29
29
|
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def escaped_url
|
|
34
|
-
CGI.escapeHTML(@url.to_s)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def escaped_title
|
|
38
|
-
CGI.escapeHTML(@title.to_s)
|
|
39
|
-
end
|
|
40
30
|
end
|
|
41
31
|
end
|
|
42
32
|
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'cgi'
|
|
4
|
-
|
|
5
3
|
module Html2rss
|
|
6
4
|
module Html
|
|
7
5
|
module Rendering
|
|
8
6
|
# Renders an HTML <iframe> for PDF documents.
|
|
9
7
|
class PdfRenderer
|
|
8
|
+
include EscapedAttributes
|
|
9
|
+
|
|
10
10
|
# @param url [String, Html2rss::Url] PDF URL rendered in the iframe
|
|
11
11
|
def initialize(url:)
|
|
12
12
|
@url = url
|
|
@@ -24,12 +24,6 @@ module Html2rss
|
|
|
24
24
|
]
|
|
25
25
|
"<iframe #{attributes.join(' ')}></iframe>"
|
|
26
26
|
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def escaped_url
|
|
31
|
-
CGI.escapeHTML(@url.to_s)
|
|
32
|
-
end
|
|
33
27
|
end
|
|
34
28
|
end
|
|
35
29
|
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'cgi'
|
|
4
|
-
|
|
5
3
|
module Html2rss
|
|
6
4
|
module Html
|
|
7
5
|
module Rendering
|
|
8
6
|
# Renders an HTML <video> tag from a URL and type.
|
|
9
7
|
class VideoRenderer
|
|
8
|
+
include EscapedAttributes
|
|
9
|
+
|
|
10
10
|
# @param url [String, Html2rss::Url] media URL for the video source
|
|
11
11
|
# @param type [String] MIME type for the video source
|
|
12
12
|
def initialize(url:, type:)
|
|
@@ -22,16 +22,6 @@ module Html2rss
|
|
|
22
22
|
'</video>'
|
|
23
23
|
].join
|
|
24
24
|
end
|
|
25
|
-
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
def escaped_url
|
|
29
|
-
CGI.escapeHTML(@url.to_s)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def escaped_type
|
|
33
|
-
CGI.escapeHTML(@type.to_s)
|
|
34
|
-
end
|
|
35
25
|
end
|
|
36
26
|
end
|
|
37
27
|
end
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
##
|
|
8
|
+
# Builds an {Html2rss::Article} from an SST segment / ranked segment.
|
|
9
|
+
# Port of Html::ArticleExtractor field logic onto SST::Node.
|
|
10
|
+
# rubocop:disable Metrics/ClassLength -- SST field extractors colocated for parity with ArticleExtractor
|
|
11
|
+
class SstArticleExtractor
|
|
12
|
+
# CSS class tokens that mark kicker / eyebrow text (excluded from titles).
|
|
13
|
+
KICKER_CLASS_PATTERN = /kicker|eyebrow|pre-title|pretitle|overline/i
|
|
14
|
+
# Inline emphasis tags used as title fallbacks when no heading exists.
|
|
15
|
+
FALLBACK_HEADING_NAMES = %i[strong b].freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
##
|
|
19
|
+
# @param ranked_or_segment [Scoring::RankedSegment, AutoSource::Segment]
|
|
20
|
+
# @param base_url [String, Html2rss::Url]
|
|
21
|
+
# @param scraper [Class, nil]
|
|
22
|
+
# @param fallback_anchorless [Boolean]
|
|
23
|
+
# @return [Html2rss::Article, nil]
|
|
24
|
+
def call(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false)
|
|
25
|
+
new(ranked_or_segment, base_url:, scraper:, fallback_anchorless:).call
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param ranked_or_segment [Scoring::RankedSegment, AutoSource::Segment]
|
|
30
|
+
# @param base_url [String, Html2rss::Url]
|
|
31
|
+
# @param scraper [Class, nil]
|
|
32
|
+
# @param fallback_anchorless [Boolean]
|
|
33
|
+
def initialize(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false)
|
|
34
|
+
segment = ranked_or_segment.is_a?(Scoring::RankedSegment) ? ranked_or_segment.segment : ranked_or_segment
|
|
35
|
+
raise ArgumentError, 'segment is required' unless segment.is_a?(AutoSource::Segment)
|
|
36
|
+
|
|
37
|
+
@segment = segment
|
|
38
|
+
@root = segment.root_node
|
|
39
|
+
@selected_anchor = segment.primary_link
|
|
40
|
+
@base_url = base_url
|
|
41
|
+
@scraper = scraper
|
|
42
|
+
@fallback_anchorless = fallback_anchorless
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @return [Html2rss::Article, nil]
|
|
47
|
+
def call # rubocop:disable Metrics/MethodLength
|
|
48
|
+
attrs = {
|
|
49
|
+
title: extract_title,
|
|
50
|
+
url: extract_url,
|
|
51
|
+
image: extract_image,
|
|
52
|
+
description: extract_description,
|
|
53
|
+
id: generate_id,
|
|
54
|
+
published_at: extract_published_at,
|
|
55
|
+
enclosures: extract_enclosures,
|
|
56
|
+
categories: extract_categories,
|
|
57
|
+
scraper: @scraper
|
|
58
|
+
}
|
|
59
|
+
article = Article.new(**attrs)
|
|
60
|
+
article.valid? ? article : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def extract_url
|
|
66
|
+
@extract_url ||= begin
|
|
67
|
+
href = @selected_anchor&.attrs&.href.to_s
|
|
68
|
+
if href.empty?
|
|
69
|
+
anchorless_url_fallback
|
|
70
|
+
else
|
|
71
|
+
Url.from_relative(href.split('#').first.strip, @base_url)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def anchorless_url_fallback
|
|
77
|
+
return unless @fallback_anchorless
|
|
78
|
+
|
|
79
|
+
id = generate_id
|
|
80
|
+
Url.from_relative("##{id}", @base_url) if id
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_title
|
|
84
|
+
title_text = title_from_in_card_sources
|
|
85
|
+
return unless title_text
|
|
86
|
+
|
|
87
|
+
kicker = kicker_node&.visible_text.to_s.strip
|
|
88
|
+
kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Prefer heading over credit-shaped visible text; never emit agency-only titles.
|
|
92
|
+
#
|
|
93
|
+
# @return [String, nil]
|
|
94
|
+
def title_from_in_card_sources
|
|
95
|
+
title_candidates.find { |text| !credit_shaped_title?(text) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Array<String>] ordered in-card title candidates (heading → anchor → fallback)
|
|
99
|
+
def title_candidates
|
|
100
|
+
[].tap do |candidates|
|
|
101
|
+
add_title_candidate!(candidates, heading&.visible_text)
|
|
102
|
+
add_title_candidate!(candidates, @selected_anchor&.visible_text)
|
|
103
|
+
next unless candidates.empty? && @fallback_anchorless && @selected_anchor.nil?
|
|
104
|
+
|
|
105
|
+
add_title_candidate!(candidates, first_nonempty_own_text)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param candidates [Array<String>]
|
|
110
|
+
# @param text [String, nil]
|
|
111
|
+
# @return [void]
|
|
112
|
+
def add_title_candidate!(candidates, text)
|
|
113
|
+
stripped = text.to_s.strip
|
|
114
|
+
return if stripped.empty? || candidates.include?(stripped)
|
|
115
|
+
|
|
116
|
+
candidates << stripped
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param text [String]
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def credit_shaped_title?(text)
|
|
122
|
+
AutoSource::Cleanup::CREDIT_TITLE.match?(text)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def heading
|
|
126
|
+
@heading ||= begin
|
|
127
|
+
tags = @root.find_all(&:heading?)
|
|
128
|
+
if tags.any?
|
|
129
|
+
select_best_heading(tags)
|
|
130
|
+
elsif @fallback_anchorless && @selected_anchor.nil?
|
|
131
|
+
fallback_heading
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def select_best_heading(tags)
|
|
137
|
+
min_name = tags.map { |t| t.name.to_s }.min
|
|
138
|
+
best = nil
|
|
139
|
+
max_size = -1
|
|
140
|
+
tags.each do |tag|
|
|
141
|
+
next unless tag.name.to_s == min_name
|
|
142
|
+
|
|
143
|
+
size = tag.visible_text.to_s.size
|
|
144
|
+
(best = tag) && (max_size = size) if size > max_size
|
|
145
|
+
end
|
|
146
|
+
best
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def fallback_heading
|
|
150
|
+
@root.find do |n|
|
|
151
|
+
next true if FALLBACK_HEADING_NAMES.include?(n.name)
|
|
152
|
+
|
|
153
|
+
n.attrs.class_names.any? { |c| c.match?(/title|font-bold|font-semibold/) } &&
|
|
154
|
+
!n.visible_text.to_s.strip.empty?
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def kicker_node # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
159
|
+
@kicker_node ||= begin
|
|
160
|
+
node = @root.find do |n|
|
|
161
|
+
n.attrs.raw.key?('data-tb-kicker') ||
|
|
162
|
+
n.attrs.class_names.any? { |c| c.match?(KICKER_CLASS_PATTERN) }
|
|
163
|
+
end
|
|
164
|
+
node && heading && (node.equal?(heading) || descendant_of?(node, heading)) ? nil : node
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def descendant_of?(child, ancestor)
|
|
169
|
+
# Walk via root children since Extractor may not hold Index; use tag walk on tree.
|
|
170
|
+
return true if child.equal?(ancestor)
|
|
171
|
+
|
|
172
|
+
ancestor.descendants.any? { |d| d.equal?(child) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def extract_description
|
|
176
|
+
exclude = [heading, @selected_anchor, kicker_node].compact
|
|
177
|
+
description = @root.visible_text(exclude:)
|
|
178
|
+
return if description.nil?
|
|
179
|
+
|
|
180
|
+
desc = description.strip
|
|
181
|
+
desc.empty? ? nil : desc
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def generate_id # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
185
|
+
@generate_id ||= begin
|
|
186
|
+
id_from_dom = parse_id_from_dom
|
|
187
|
+
if id_from_dom
|
|
188
|
+
id_from_dom
|
|
189
|
+
else
|
|
190
|
+
heading_text = heading&.visible_text
|
|
191
|
+
heading_text = first_nonempty_own_text if heading_text.to_s.strip.empty? && @fallback_anchorless
|
|
192
|
+
if heading_text && !heading_text.strip.empty?
|
|
193
|
+
generate_slug(heading_text)
|
|
194
|
+
elsif @fallback_anchorless
|
|
195
|
+
text = @root.visible_text.to_s.strip
|
|
196
|
+
Zlib.crc32(text).to_s(36) unless text.empty?
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def parse_id_from_dom
|
|
203
|
+
candidates = [@root.attrs.id]
|
|
204
|
+
nested = @root.find { |n| n.attrs.id }
|
|
205
|
+
candidates << nested.attrs.id if nested
|
|
206
|
+
if @selected_anchor
|
|
207
|
+
url = extract_url
|
|
208
|
+
candidates += [url&.path, url&.query]
|
|
209
|
+
end
|
|
210
|
+
candidates.compact.reject(&:empty?).first
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def generate_slug(text)
|
|
214
|
+
slug = text.downcase.gsub(/[^a-z0-9]+/, '-')
|
|
215
|
+
slug = slug[1..] if slug.start_with?('-')
|
|
216
|
+
slug = slug[0..-2] if slug.end_with?('-')
|
|
217
|
+
slug unless slug.empty?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def first_nonempty_own_text
|
|
221
|
+
@root.find { |n| !n.own_text.to_s.strip.empty? }&.own_text&.strip
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def extract_image
|
|
225
|
+
img_src = image_from_srcset || image_from_img || image_from_style
|
|
226
|
+
Url.from_relative(img_src, @base_url) if img_src
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def image_from_img
|
|
230
|
+
@root.find { |n| n.image? && n.attrs.src && !n.attrs.src.start_with?('data:') }&.attrs&.src
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def image_from_srcset
|
|
234
|
+
srcsets = @root.find_all { |n| n.attrs.srcset }.map { |n| n.attrs.srcset }
|
|
235
|
+
ArticleRules::Image.largest_from_srcsets(srcsets)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def image_from_style
|
|
239
|
+
styles = @root.find_all { |n| n.attrs.style&.include?('url') }.map { |n| n.attrs.style }
|
|
240
|
+
ArticleRules::Image.best_from_styles(styles)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def extract_published_at
|
|
244
|
+
datetimes = @root.find_all { |n| n.attrs.datetime }.map { |n| n.attrs.datetime }
|
|
245
|
+
ArticleRules::Date.earliest(datetimes)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def extract_enclosures
|
|
249
|
+
@root.find_all { |n| enclosure_node?(n) }.filter_map { |element| enclosure_from(element) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def enclosure_node?(node)
|
|
253
|
+
case node.name
|
|
254
|
+
when :img then node.attrs.src && !node.attrs.src.start_with?('data:')
|
|
255
|
+
when :source, :audio, :video, :iframe then node.attrs.src
|
|
256
|
+
when :a then ArticleRules::Enclosure.archive_href?(node.attrs.href)
|
|
257
|
+
else false
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def enclosure_from(element)
|
|
262
|
+
case element.name
|
|
263
|
+
when :img
|
|
264
|
+
ArticleRules::Enclosure.from_image(element.attrs.src, @base_url)
|
|
265
|
+
when :video, :audio, :source
|
|
266
|
+
ArticleRules::Enclosure.from_media(element.attrs.src, element.attrs.type, @base_url)
|
|
267
|
+
when :iframe
|
|
268
|
+
ArticleRules::Enclosure.from_iframe(element.attrs.src, @base_url)
|
|
269
|
+
when :a
|
|
270
|
+
ArticleRules::Enclosure.from_anchor(element.attrs.href, @base_url)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def extract_categories
|
|
275
|
+
Set.new.tap do |categories|
|
|
276
|
+
@root.find_all { |n| category_candidate?(n) }.each do |element|
|
|
277
|
+
add_category_text!(categories, element) if ArticleRules::Category.class_match?(element.attrs.class_attr)
|
|
278
|
+
element.attrs.raw.each do |name, value|
|
|
279
|
+
next unless ArticleRules::Category.attr_name_match?(name)
|
|
280
|
+
|
|
281
|
+
ArticleRules::Category.add_text!(categories, value)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end.to_a
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def category_candidate?(node)
|
|
288
|
+
ArticleRules::Category.class_match?(node.attrs.class_attr) ||
|
|
289
|
+
node.attrs.raw.keys.any? { |k| ArticleRules::Category.attr_name_match?(k) }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def add_category_text!(categories, element)
|
|
293
|
+
if element.link?
|
|
294
|
+
ArticleRules::Category.add_text!(categories, element.visible_text)
|
|
295
|
+
return
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
anchors = element.find_all(&:link?)
|
|
299
|
+
if anchors.any?
|
|
300
|
+
anchors.each { |a| ArticleRules::Category.add_text!(categories, a.visible_text) }
|
|
301
|
+
else
|
|
302
|
+
ArticleRules::Category.add_split_text!(categories, element.visible_text)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
# rubocop:enable Metrics/ClassLength
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
# Normalized URL plus reusable route-classification facts for one link.
|
|
6
|
+
DestinationFacts = Data.define(
|
|
7
|
+
:url,
|
|
8
|
+
:destination,
|
|
9
|
+
:segments,
|
|
10
|
+
:content_path,
|
|
11
|
+
:utility_path,
|
|
12
|
+
:taxonomy_path,
|
|
13
|
+
:vanity_path,
|
|
14
|
+
:shallow,
|
|
15
|
+
:strong_post_suffix,
|
|
16
|
+
:high_confidence_junk_path,
|
|
17
|
+
:high_confidence_utility_destination
|
|
18
|
+
) do
|
|
19
|
+
# @param url [Html2rss::Url] normalized destination URL
|
|
20
|
+
# @return [DestinationFacts] route facts for downstream link scoring
|
|
21
|
+
def self.build(url) # rubocop:disable Metrics/MethodLength
|
|
22
|
+
classifier = PathClassifier.new(url.path_segments)
|
|
23
|
+
|
|
24
|
+
new(
|
|
25
|
+
url:,
|
|
26
|
+
destination: url.to_s,
|
|
27
|
+
segments: classifier.segments,
|
|
28
|
+
strong_post_suffix: classifier.strong_post_suffix?,
|
|
29
|
+
content_path: classifier.content_path?,
|
|
30
|
+
utility_path: classifier.utility_path?,
|
|
31
|
+
taxonomy_path: classifier.taxonomy_path?,
|
|
32
|
+
vanity_path: classifier.vanity_path?,
|
|
33
|
+
shallow: classifier.shallow?,
|
|
34
|
+
high_confidence_junk_path: classifier.junk_path?,
|
|
35
|
+
high_confidence_utility_destination: classifier.utility_destination?
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
##
|
|
6
|
+
# Noise / junk chrome eligibility for SST anchors (port of LinkHeuristics#noise_anchor?).
|
|
7
|
+
class NoisePolicy
|
|
8
|
+
# Child tag names that mark icon-only anchors as noise.
|
|
9
|
+
ICON_NAMES = %i[img svg].freeze
|
|
10
|
+
|
|
11
|
+
# @param link_resolver [LinkResolver]
|
|
12
|
+
def initialize(link_resolver:)
|
|
13
|
+
@link_resolver = link_resolver
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# @param text [String]
|
|
18
|
+
# @param destination_facts [LinkDestination::DestinationFacts, nil]
|
|
19
|
+
# @param anchor [SST::Node, nil]
|
|
20
|
+
# @param container [SST::Node, nil]
|
|
21
|
+
# @param heading_anchor [Boolean]
|
|
22
|
+
# @param utility_landmark_ancestor [Boolean] pre-computed by Segmenter tree walk
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
|
|
25
|
+
def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil,
|
|
26
|
+
heading_anchor: false, utility_landmark_ancestor: false)
|
|
27
|
+
return true unless destination_facts
|
|
28
|
+
|
|
29
|
+
destination_facts.taxonomy_path ||
|
|
30
|
+
short_utility_label?(text, destination_facts) ||
|
|
31
|
+
recommended_chrome?(text, destination_facts, heading_anchor:) ||
|
|
32
|
+
(@link_resolver.utility_prefix_text?(text) && destination_facts.high_confidence_utility_destination) ||
|
|
33
|
+
(@link_resolver.utility_text?(text) && destination_facts.vanity_path) ||
|
|
34
|
+
utility_text_chrome?(text, destination_facts, heading_anchor:) ||
|
|
35
|
+
icon_only_anchor?(anchor, text) ||
|
|
36
|
+
utility_landmark_ancestor
|
|
37
|
+
end
|
|
38
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def short_utility_label?(text, destination_facts)
|
|
43
|
+
destination_facts.utility_path &&
|
|
44
|
+
!destination_facts.content_path &&
|
|
45
|
+
!destination_facts.strong_post_suffix &&
|
|
46
|
+
text.to_s.scan(/\p{Alnum}+/).size <= 3
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def recommended_chrome?(text, destination_facts, heading_anchor:)
|
|
50
|
+
!heading_anchor && @link_resolver.recommended_text?(text) && destination_facts.shallow
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def utility_text_chrome?(text, destination_facts, heading_anchor:)
|
|
54
|
+
return false if destination_facts.content_path
|
|
55
|
+
return false unless @link_resolver.utility_text?(text)
|
|
56
|
+
|
|
57
|
+
!heading_anchor && !destination_facts.strong_post_suffix
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def icon_only_anchor?(anchor, text)
|
|
61
|
+
return false unless anchor
|
|
62
|
+
return false if text.to_s.match?(/\p{Alnum}/)
|
|
63
|
+
|
|
64
|
+
!!anchor.find { |n| ICON_NAMES.include?(n.name) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|