html2rss 0.24.0 → 0.25.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/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 +0 -379
- 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.rb +81 -44
- 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 +101 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +66 -0
- data/lib/html2rss/auto_source.rb +74 -21
- data/lib/html2rss/cli.rb +8 -13
- data/lib/html2rss/config/auto_source_contract.rb +2 -0
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/validator.rb +15 -8
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline.rb +4 -0
- data/lib/html2rss/feed_result.rb +1 -1
- 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 +279 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +79 -0
- data/lib/html2rss/link_destination/path_classifier.rb +205 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/scoring/anchor_score.rb +34 -0
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +83 -0
- data/lib/html2rss/scoring/engine.rb +101 -0
- data/lib/html2rss/scoring/link_resolver.rb +72 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +45 -0
- data/lib/html2rss/scoring/score.rb +30 -0
- data/lib/html2rss/scoring.rb +33 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
- 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/version.rb +1 -1
- data/lib/html2rss.rb +26 -26
- data/schema/html2rss-config.schema.json +20 -0
- metadata +42 -18
- 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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
# Classifies normalized destination path segments for scoring.
|
|
6
|
+
class PathClassifier # rubocop:disable Metrics/ClassLength
|
|
7
|
+
attr_reader :segments
|
|
8
|
+
|
|
9
|
+
# Soft utility segments excluded from high-confidence junk (still utility_path).
|
|
10
|
+
SOFT_UTILITY = %w[
|
|
11
|
+
for-you join member members membership newsletter newsletters
|
|
12
|
+
plans plus premium pricing recommended
|
|
13
|
+
].to_set.freeze
|
|
14
|
+
|
|
15
|
+
# Segment groups used to classify article, taxonomy, utility, and vanity routes.
|
|
16
|
+
SEGMENT_SETS = begin
|
|
17
|
+
content = %w[
|
|
18
|
+
article articles blog blogs changelog changelogs insight insights
|
|
19
|
+
launch launches news post posts release releases story stories update updates
|
|
20
|
+
artikel beitrag beitraege nachrichten neuigkeiten aktuelles
|
|
21
|
+
articulo articulos noticia noticias entrada entradas publicacion publicaciones
|
|
22
|
+
actualite actualites nouvelle nouvelles teaser teasers card cards
|
|
23
|
+
].to_set.freeze
|
|
24
|
+
taxonomy = %w[
|
|
25
|
+
category categories tag tags topic topics
|
|
26
|
+
kategorie kategorien schlagwort schlagworte thema themen
|
|
27
|
+
categoria categorias etiqueta etiquetas tema temas
|
|
28
|
+
categorie etiquette etiquettes sujet sujets theme themes
|
|
29
|
+
].to_set.freeze
|
|
30
|
+
vanity = %w[
|
|
31
|
+
join membership plus premium pricing plans subscribe signup
|
|
32
|
+
abonnieren abo suscribirse boletin s-abonner saboner
|
|
33
|
+
].to_set.freeze
|
|
34
|
+
utility = (
|
|
35
|
+
taxonomy.to_a + %w[
|
|
36
|
+
about account archive archives author authors comment comments
|
|
37
|
+
contact feedback help login logout notification notifications
|
|
38
|
+
preference preferences profile register search settings share signup subscribe
|
|
39
|
+
feed feeds comment-feed comments-feed privacy terms cookie cookies user users
|
|
40
|
+
kategorie kategorien schlagwort schlagworte thema themen autor autoren archiv
|
|
41
|
+
ueber-uns ueber ueberuns profil kontakt impressum suche hilfe anmelden registrieren
|
|
42
|
+
konto registrierung anmeldung abonnieren abo datenschutz nutzungsbedingungen agb
|
|
43
|
+
categoria categorias etiqueta etiquetas tema temas autores archivos
|
|
44
|
+
sobre-nosotros sobre quienes-somos buscar busqueda ayuda entrar ingresar
|
|
45
|
+
registrarse registro cuenta suscribirse boletin privacidad condiciones
|
|
46
|
+
categorie etiquette etiquettes sujet sujets theme themes auteur auteurs
|
|
47
|
+
a-propos apropos recherche rechercher aide connexion s-inscrire
|
|
48
|
+
sinscrire inscription compte s-abonner saboner lettre-information confidentialite
|
|
49
|
+
mentions-legales cgu menu sidebar widget social modal popup banner promo ad ads
|
|
50
|
+
related recommendation recommendations pagination pager
|
|
51
|
+
] + SOFT_UTILITY.to_a
|
|
52
|
+
).to_set.freeze
|
|
53
|
+
{
|
|
54
|
+
content:,
|
|
55
|
+
utility:,
|
|
56
|
+
high_confidence_junk: (utility - SOFT_UTILITY).freeze,
|
|
57
|
+
taxonomy:,
|
|
58
|
+
vanity:,
|
|
59
|
+
deep_post_context: %w[press newsroom presse pressemitteilungen prensa].to_set.freeze
|
|
60
|
+
}.freeze
|
|
61
|
+
end
|
|
62
|
+
# Path segment that begins with a year-like publishing marker.
|
|
63
|
+
YEARISH_SEGMENT = /\A\d{4,}[\w-]*\z/
|
|
64
|
+
# Hyphenated slug shape common to article permalinks.
|
|
65
|
+
POST_SLUG_SEGMENT = /\A[a-z0-9]+(?:-[a-z0-9]+){2,}\z/i
|
|
66
|
+
|
|
67
|
+
# @param segments [Array<String>] normalized URL path segments
|
|
68
|
+
def initialize(segments)
|
|
69
|
+
@segments = segments
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] true when the route has article-like path evidence
|
|
73
|
+
def content_path?
|
|
74
|
+
@content_path ||= SEGMENT_SETS[:content].intersect?(segments) ||
|
|
75
|
+
yearish_content_context?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Boolean] true when the route includes utility/navigation evidence
|
|
79
|
+
def utility_path?
|
|
80
|
+
@utility_path ||= SEGMENT_SETS[:utility].intersect?(segments)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean] true when the route points at conversion or account chrome
|
|
84
|
+
def vanity_path?
|
|
85
|
+
@vanity_path ||= SEGMENT_SETS[:vanity].intersect?(segments)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Boolean] true when the route points at taxonomy/listing chrome
|
|
89
|
+
def taxonomy_path?
|
|
90
|
+
@taxonomy_path ||= SEGMENT_SETS[:taxonomy].intersect?(segments)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Boolean] true when the route is too shallow to strongly indicate an article
|
|
94
|
+
def shallow?
|
|
95
|
+
segment_count = segments.size
|
|
96
|
+
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
97
|
+
|
|
98
|
+
segment_count <= 1 || (segment_count == 2 && junk_segments.include?(segments.last))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Boolean] true when the final path segment looks like a post slug
|
|
102
|
+
def strong_post_suffix?
|
|
103
|
+
@strong_post_suffix ||= segments.any? &&
|
|
104
|
+
included_last_segment? &&
|
|
105
|
+
trusted_post_context?(segments.size - 1)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Boolean] true when every path segment is utility chrome
|
|
109
|
+
def utility_only_route?
|
|
110
|
+
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
111
|
+
|
|
112
|
+
segments.all? { |segment| junk_segments.include?(segment) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Boolean] true when the route is shallow and contains high-confidence noise
|
|
116
|
+
def shallow_high_confidence_route?
|
|
117
|
+
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
118
|
+
vanity_segments = SEGMENT_SETS.fetch(:vanity)
|
|
119
|
+
|
|
120
|
+
shallow? && segments.any? do |segment|
|
|
121
|
+
junk_segments.include?(segment) || vanity_segments.include?(segment)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Boolean] true when the leading segments are all utility chrome
|
|
126
|
+
def deep_utility_context_route?
|
|
127
|
+
all_junk?(segments.size - 1)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [Boolean] true when the route is shallow and contains high-confidence noise
|
|
131
|
+
def junk_path?
|
|
132
|
+
return false if excluded_content_route?
|
|
133
|
+
|
|
134
|
+
taxonomy_path? ||
|
|
135
|
+
utility_only_route? ||
|
|
136
|
+
deep_utility_context_route? ||
|
|
137
|
+
shallow_high_confidence_route?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] true when the route points at conversion or account chrome
|
|
141
|
+
def utility_destination?
|
|
142
|
+
return false if excluded_content_route?
|
|
143
|
+
|
|
144
|
+
vanity_path? || utility_route?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def yearish_content_context?
|
|
150
|
+
segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
|
|
151
|
+
(strong_post_suffix? || trusted_post_context?(segments.size - 1))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def excluded_content_route?
|
|
155
|
+
segments.empty? || content_path? || strong_post_suffix?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def utility_route?
|
|
159
|
+
taxonomy_path? ||
|
|
160
|
+
utility_only_route? ||
|
|
161
|
+
deep_utility_context_route? ||
|
|
162
|
+
shallow_utility_route?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def shallow_utility_route?
|
|
166
|
+
shallow? && utility_path?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def all_junk?(limit)
|
|
170
|
+
return false if limit <= 0
|
|
171
|
+
|
|
172
|
+
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
173
|
+
(0...limit).all? { |i| junk_segments.include?(segments[i]) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def trusted_post_context?(limit)
|
|
177
|
+
return false if limit <= 0
|
|
178
|
+
|
|
179
|
+
content_segments = SEGMENT_SETS.fetch(:content)
|
|
180
|
+
context_segments = SEGMENT_SETS.fetch(:deep_post_context)
|
|
181
|
+
|
|
182
|
+
(0...limit).any? do |i|
|
|
183
|
+
segment = segments[i]
|
|
184
|
+
content_segments.include?(segment) ||
|
|
185
|
+
segment.match?(PathClassifier::YEARISH_SEGMENT) ||
|
|
186
|
+
context_segments.include?(segment)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def included_last_segment?
|
|
191
|
+
!excluded_last_segment? && slug_last_segment?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def excluded_last_segment?
|
|
195
|
+
last = segments.last
|
|
196
|
+
[SEGMENT_SETS[:high_confidence_junk], SEGMENT_SETS[:vanity]].any? { |set| set.include?(last) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def slug_last_segment?
|
|
200
|
+
last = segments.last
|
|
201
|
+
last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
# Classifies visible anchor text for utility and recommendation chrome.
|
|
6
|
+
class TextClassifier
|
|
7
|
+
# Prefix labels that usually identify navigation or subscription links.
|
|
8
|
+
UTILITY_PREFIX_PATTERN = /
|
|
9
|
+
\A\s*(
|
|
10
|
+
# English
|
|
11
|
+
view\s+all|see\s+all|all\s+news|subscribe|newsletter|comment\s+feed|comments\s+feed|join|premium|plus|
|
|
12
|
+
# German
|
|
13
|
+
alle\s+anzeigen|alle\s+news|abonnieren|newsletter|kommentar\s+feed|mitmachen|
|
|
14
|
+
# Spanish
|
|
15
|
+
ver\s+todos|ver\s+todo|todas\s+las\s+noticias|suscribirse|bolet(i|í)n|comentarios\s+feed|unirse|
|
|
16
|
+
# French
|
|
17
|
+
voir\s+tout|voir\s+tous|toutes\s+les\s+nouvelles|s['’]abonner|flux\s+de\s+commentaires|rejoindre
|
|
18
|
+
)\b
|
|
19
|
+
/ix
|
|
20
|
+
# Short labels that usually identify non-article navigation links.
|
|
21
|
+
UTILITY_PATTERN = /
|
|
22
|
+
\A\s*(
|
|
23
|
+
# English
|
|
24
|
+
about|contact|comments?|join|log\s+in|login|member(ship)?|
|
|
25
|
+
plus|premium|pricing|recommended(\s+for\s+you)?|
|
|
26
|
+
see\s+all|share|sign\s+up|signup|subscribe|view\s+all|
|
|
27
|
+
# German
|
|
28
|
+
(ue|ü)ber(\s+uns)?|kontakt|kommentare?|mitmachen|anmelden|login|
|
|
29
|
+
mitglied(schaft)?|empfohlen(\s+f(ue|ü)r\s+dich)?|alle\s+anzeigen|
|
|
30
|
+
teilen|registrieren|abonnieren|newsletter|
|
|
31
|
+
# Spanish
|
|
32
|
+
sobre(\s+nosotros)?|contacto|comentarios?|unirse|iniciar\s+sesion|
|
|
33
|
+
login|miembro|membres(i|í)a|recomendado(\s+para\s+ti)?|ver\s+todo|
|
|
34
|
+
compartir|registrarse|suscribirse|bolet(i|í)n|
|
|
35
|
+
# French
|
|
36
|
+
(a|à)\s+propos|(a|à)propos|contact|commentaires?|rejoindre|
|
|
37
|
+
se\s+connecter|login|membre|abonnement|recommand(e|é)(\s+pour\s+vous)?|
|
|
38
|
+
voir\s+tout|partager|s['’]inscrire|s['’]abonner|newsletter
|
|
39
|
+
)\b
|
|
40
|
+
/ix
|
|
41
|
+
# Labels for recommendation chrome rather than source articles.
|
|
42
|
+
RECOMMENDED_PATTERN = /
|
|
43
|
+
\A\s*(
|
|
44
|
+
recommended(\s+for\s+you)?|
|
|
45
|
+
empfohlen(\s+f(ue|ü)r\s+dich)?|
|
|
46
|
+
recomendado(\s+para\s+ti)?|
|
|
47
|
+
recommand(e|é)(\s+pour\s+vous)?
|
|
48
|
+
)\b
|
|
49
|
+
/ix
|
|
50
|
+
|
|
51
|
+
# @param text [String, #to_s] visible anchor text
|
|
52
|
+
# @return [Boolean] true when text matches a utility label
|
|
53
|
+
def utility?(text) = text.to_s.match?(UTILITY_PATTERN)
|
|
54
|
+
|
|
55
|
+
# @param text [String, #to_s] visible anchor text
|
|
56
|
+
# @return [Boolean] true when text begins with a utility label
|
|
57
|
+
def utility_prefix?(text) = text.to_s.match?(UTILITY_PREFIX_PATTERN)
|
|
58
|
+
|
|
59
|
+
# @param text [String, #to_s] visible anchor text
|
|
60
|
+
# @return [Boolean] true when text identifies recommendation chrome
|
|
61
|
+
def recommended?(text) = text.to_s.match?(RECOMMENDED_PATTERN)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -66,14 +66,55 @@ module Html2rss
|
|
|
66
66
|
faraday_request(response_guard, deadline:, streaming_buffer: false, consume_budget: false)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
##
|
|
70
|
+
# Validates the remote socket peer IP during Net::HTTP start.
|
|
71
|
+
module PeerIpValidator
|
|
72
|
+
module_function
|
|
73
|
+
|
|
74
|
+
# @param http [Net::HTTP] connection to configure
|
|
75
|
+
# @param policy [Policy] request policy
|
|
76
|
+
# @return [void]
|
|
77
|
+
def install!(http, policy:)
|
|
78
|
+
orig_start = http.method(:start)
|
|
79
|
+
http.define_singleton_method(:start) do |&block|
|
|
80
|
+
orig_start.call do |opened_http|
|
|
81
|
+
PeerIpValidator.validate!(opened_http, policy:)
|
|
82
|
+
block.call(opened_http)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param opened_http [Net::HTTP] active connection
|
|
88
|
+
# @param policy [Policy] request policy
|
|
89
|
+
# @return [void]
|
|
90
|
+
def validate!(opened_http, policy:)
|
|
91
|
+
scheme = opened_http.use_ssl? ? 'https' : 'http'
|
|
92
|
+
url = Html2rss::Url.from_absolute("#{scheme}://#{opened_http.address}:#{opened_http.port}")
|
|
93
|
+
policy.validate_remote_ip!(ip: peer_ip_for(opened_http), url:)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param opened_http [Net::HTTP]
|
|
97
|
+
# @return [String, nil]
|
|
98
|
+
def peer_ip_for(opened_http)
|
|
99
|
+
sock = opened_http.instance_variable_get(:@socket)
|
|
100
|
+
io = sock.respond_to?(:io) ? sock.io : sock
|
|
101
|
+
peeraddr = io.respond_to?(:peeraddr) ? io.peeraddr : nil
|
|
102
|
+
peeraddr&.[](3) || peeraddr&.[](2)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# rubocop:disable Metrics/AbcSize
|
|
69
107
|
def client
|
|
70
108
|
@client ||= Faraday.new(url: ctx.url.to_s, headers: ctx.headers) do |faraday|
|
|
71
109
|
faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
|
|
72
110
|
faraday.request :gzip
|
|
73
111
|
faraday.use StreamingBodyMiddleware
|
|
74
|
-
faraday.adapter Faraday.default_adapter
|
|
112
|
+
faraday.adapter Faraday.default_adapter do |http|
|
|
113
|
+
PeerIpValidator.install!(http, policy: ctx.policy)
|
|
114
|
+
end
|
|
75
115
|
end
|
|
76
116
|
end
|
|
117
|
+
# rubocop:enable Metrics/AbcSize
|
|
77
118
|
|
|
78
119
|
def apply_timeouts(request, deadline:)
|
|
79
120
|
remaining_timeout = remaining_timeout_seconds(deadline)
|
|
@@ -48,7 +48,10 @@ module Html2rss
|
|
|
48
48
|
def enforce_public_network!(url)
|
|
49
49
|
host = url.host
|
|
50
50
|
return if allow_private_networks?
|
|
51
|
-
|
|
51
|
+
raise PrivateNetworkDenied, "Private network target denied for #{url}" if blocked_host?(host)
|
|
52
|
+
|
|
53
|
+
addresses = resolved_ip_addresses(host)
|
|
54
|
+
return unless addresses.empty? || addresses.any? { |address| blocked_ip?(address) }
|
|
52
55
|
|
|
53
56
|
raise PrivateNetworkDenied, "Private network target denied for #{url}"
|
|
54
57
|
end
|
|
@@ -62,9 +65,8 @@ module Html2rss
|
|
|
62
65
|
# @raise [PrivateNetworkDenied] if the response came from a blocked address
|
|
63
66
|
def validate_remote_ip!(ip:, url:)
|
|
64
67
|
return if allow_private_networks?
|
|
65
|
-
return if ip.nil? || ip.empty?
|
|
66
68
|
|
|
67
|
-
parsed_ip = parse_ip(ip)
|
|
69
|
+
parsed_ip = ip && !ip.to_s.empty? ? parse_ip(ip) : nil
|
|
68
70
|
raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
|
|
69
71
|
return unless blocked_ip?(parsed_ip)
|
|
70
72
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Primary-link ranking weights (sole home; FeatureId-aligned).
|
|
7
|
+
module AnchorScore
|
|
8
|
+
# Signal → integer weight map for primary-link ranking.
|
|
9
|
+
WEIGHTS = {
|
|
10
|
+
heading_anchor: 100,
|
|
11
|
+
heading_text_match: 20,
|
|
12
|
+
meaningful_text: 10,
|
|
13
|
+
content_like_destination: 10
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# @param heading_anchor [Boolean]
|
|
20
|
+
# @param heading_text_match [Boolean]
|
|
21
|
+
# @param meaningful_text [Boolean]
|
|
22
|
+
# @param content_like_destination [Boolean]
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
def score(heading_anchor:, heading_text_match:, meaningful_text:, content_like_destination:)
|
|
25
|
+
total = 0
|
|
26
|
+
total += WEIGHTS[:heading_anchor] if heading_anchor
|
|
27
|
+
total += WEIGHTS[:heading_text_match] if heading_text_match
|
|
28
|
+
total += WEIGHTS[:meaningful_text] if meaningful_text
|
|
29
|
+
total += WEIGHTS[:content_like_destination] if content_like_destination
|
|
30
|
+
total
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Scores DomClustering-style node groups (heading/time/date/word density).
|
|
7
|
+
# Sole home for cluster-group ranking weights.
|
|
8
|
+
class ClusterScorer
|
|
9
|
+
# Minimum average visible words before a group is eligible.
|
|
10
|
+
MIN_AVG_WORDS = 5
|
|
11
|
+
# Bonus when any node has a heading or bold title class.
|
|
12
|
+
HEADING_BONUS = 20
|
|
13
|
+
# Bonus when any node has a time/datetime marker.
|
|
14
|
+
TIME_BONUS = 20
|
|
15
|
+
# Bonus when any node has date-like visible text.
|
|
16
|
+
DATE_BONUS = 40
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@text_words = {}.compare_by_identity
|
|
20
|
+
@has_date = {}.compare_by_identity
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @param groups [Hash{String => Array<SST::Node>}]
|
|
25
|
+
# @return [Array<SST::Node>]
|
|
26
|
+
def select_best_group(groups)
|
|
27
|
+
best_nodes = []
|
|
28
|
+
best_score = -1
|
|
29
|
+
|
|
30
|
+
groups.each_value do |nodes|
|
|
31
|
+
score = score_group(nodes)
|
|
32
|
+
next if score.negative?
|
|
33
|
+
|
|
34
|
+
(best_nodes = nodes) && (best_score = score) if score > best_score
|
|
35
|
+
end
|
|
36
|
+
best_nodes
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @param nodes [Array<SST::Node>]
|
|
41
|
+
# @return [Float]
|
|
42
|
+
def avg_words(nodes)
|
|
43
|
+
nodes.sum { |n| text_words(n) } / nodes.size.to_f
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def score_group(nodes)
|
|
49
|
+
avg_w = avg_words(nodes)
|
|
50
|
+
return -1 if avg_w < MIN_AVG_WORDS
|
|
51
|
+
|
|
52
|
+
score = nodes.size + (avg_w / 5.0)
|
|
53
|
+
score += HEADING_BONUS if nodes_heading?(nodes)
|
|
54
|
+
score += TIME_BONUS if nodes_time?(nodes)
|
|
55
|
+
score += DATE_BONUS if nodes_date?(nodes)
|
|
56
|
+
score
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def nodes_heading?(nodes)
|
|
60
|
+
nodes.any? do |n|
|
|
61
|
+
n.find(&:heading?) ||
|
|
62
|
+
n.attrs.class_names.any? { |c| c.match?(/font-bold|font-semibold/) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def nodes_time?(nodes)
|
|
67
|
+
nodes.any? { |n| n.find { |d| d.name == :time || d.attrs.datetime } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def nodes_date?(nodes)
|
|
71
|
+
nodes.any? { |n| date?(n) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def text_words(node)
|
|
75
|
+
@text_words[node] ||= node.word_count
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def date?(node)
|
|
79
|
+
@has_date[node] ||= begin
|
|
80
|
+
text = node.visible_text.to_s
|
|
81
|
+
text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
|
|
82
|
+
text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Observes an SST container and builds typed {Observation} score inputs.
|
|
7
|
+
class ContainerAssessor
|
|
8
|
+
# Microdata itemprop values treated as publish/update markers.
|
|
9
|
+
PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
|
|
10
|
+
|
|
11
|
+
# Matches content-like tokens in class/id strings.
|
|
12
|
+
CONTENT_TOKEN_REGEXP = begin
|
|
13
|
+
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
|
|
14
|
+
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
15
|
+
end.freeze
|
|
16
|
+
|
|
17
|
+
# Matches utility/junk tokens in class/id strings.
|
|
18
|
+
JUNK_TOKEN_REGEXP = begin
|
|
19
|
+
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
|
|
20
|
+
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
21
|
+
end.freeze
|
|
22
|
+
|
|
23
|
+
# @param text_classifier [LinkDestination::TextClassifier]
|
|
24
|
+
def initialize(text_classifier: LinkDestination::TextClassifier.new)
|
|
25
|
+
@text_classifier = text_classifier
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @param container [SST::Node]
|
|
30
|
+
# @param selected_anchor [SST::Node, nil]
|
|
31
|
+
# @param destination_facts [LinkDestination::DestinationFacts, nil]
|
|
32
|
+
# @return [Observation]
|
|
33
|
+
def call(container, selected_anchor, destination_facts:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
34
|
+
title = entry_title(container, selected_anchor)
|
|
35
|
+
tokens = "#{container.attrs.class_attr} #{container.attrs.id}"
|
|
36
|
+
|
|
37
|
+
Observation.new(
|
|
38
|
+
title_word_count: word_count(title),
|
|
39
|
+
path_length: destination_facts&.url&.path.to_s.length,
|
|
40
|
+
content_path: destination_facts&.content_path,
|
|
41
|
+
publish_marker: publish_marker?(container),
|
|
42
|
+
descriptive_context: descriptive_context?(container.visible_text, title),
|
|
43
|
+
article_container: container.name == :article,
|
|
44
|
+
content_tokens: tokens.match?(CONTENT_TOKEN_REGEXP),
|
|
45
|
+
junk_tokens: tokens.match?(JUNK_TOKEN_REGEXP),
|
|
46
|
+
utility_prefix_title: @text_classifier.utility_prefix?(title),
|
|
47
|
+
recommended_title: @text_classifier.recommended?(title),
|
|
48
|
+
utility_path: destination_facts&.utility_path,
|
|
49
|
+
strong_post_suffix: destination_facts&.strong_post_suffix,
|
|
50
|
+
shallow: destination_facts&.shallow,
|
|
51
|
+
high_confidence_junk_path: destination_facts&.high_confidence_junk_path,
|
|
52
|
+
high_confidence_utility_destination: destination_facts&.high_confidence_utility_destination,
|
|
53
|
+
selected_anchor_present: !selected_anchor.nil?
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def publish_marker?(container)
|
|
60
|
+
!!container.find do |n|
|
|
61
|
+
n.name == :time ||
|
|
62
|
+
n.attrs.datetime ||
|
|
63
|
+
PUBLISH_ITEMPROPS.include?(n.attrs.itemprop.to_s)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def descriptive_context?(container_text, title)
|
|
68
|
+
snippet = container_text.to_s.sub(/\A#{Regexp.escape(title.to_s)}/i, '')
|
|
69
|
+
snippet.length > 30 && word_count(snippet) >= 8
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def entry_title(container, selected_anchor)
|
|
73
|
+
heading = container.find(&:heading?)
|
|
74
|
+
source = heading || selected_anchor
|
|
75
|
+
source ? source.visible_text.to_s.strip : ''
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def word_count(text)
|
|
79
|
+
text.to_s.scan(/\p{Alnum}+/).size
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Ranks AutoSource::Segment instances with an explicit feature registry (no send).
|
|
7
|
+
class Engine
|
|
8
|
+
# Soft cap after score-floor filtering (lazy extraction guard; breadth > blind top-50).
|
|
9
|
+
TOP_K = 99
|
|
10
|
+
# Minimum composite score retained by {#rank_top} (precision floor).
|
|
11
|
+
SCORE_FLOOR = 0.0
|
|
12
|
+
|
|
13
|
+
# Quality feature triples: [FeatureId, predicate, weight].
|
|
14
|
+
QUALITY_RULES = [
|
|
15
|
+
[:title_word_count_ge3, ->(o) { o.title_word_count >= 3 }, 40],
|
|
16
|
+
[:title_word_count_ge7, ->(o) { o.title_word_count >= 7 }, 15],
|
|
17
|
+
[:path_length_gt6, ->(o) { o.path_length > 6 }, 20],
|
|
18
|
+
[:content_path, lambda(&:content_path), 15],
|
|
19
|
+
[:publish_marker, lambda(&:publish_marker), 15],
|
|
20
|
+
[:descriptive_context, lambda(&:descriptive_context), 10],
|
|
21
|
+
[:article_container, lambda(&:article_container), 10],
|
|
22
|
+
[:content_tokens, lambda(&:content_tokens), 10]
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# Junk feature triples: [FeatureId, predicate, weight].
|
|
26
|
+
JUNK_RULES = [
|
|
27
|
+
[:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
|
|
28
|
+
[:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_word_count <= 6 }, 15],
|
|
29
|
+
[:shallow, lambda(&:shallow), 10],
|
|
30
|
+
[:weak_container, ->(o) { !o.publish_marker && !o.descriptive_context }, 10],
|
|
31
|
+
[:recommended_title_non_content, ->(o) { o.recommended_title && !o.content_path }, 10],
|
|
32
|
+
[:high_confidence_junk_path, lambda(&:high_confidence_junk_path), 5],
|
|
33
|
+
[:junk_tokens, lambda(&:junk_tokens), 15]
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
(QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| FeatureId.assert!(feature_id) }
|
|
37
|
+
|
|
38
|
+
# @param link_resolver [LinkResolver]
|
|
39
|
+
def initialize(link_resolver:)
|
|
40
|
+
@link_resolver = link_resolver
|
|
41
|
+
@assessor = ContainerAssessor.new(text_classifier: link_resolver.text_classifier)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# @param segments [Array<Html2rss::AutoSource::Segment>]
|
|
46
|
+
# @return [Array<RankedSegment>] sorted by composite desc, position asc; hard-junk dropped
|
|
47
|
+
def rank(segments)
|
|
48
|
+
segments.filter_map { |segment| rank_one(segment) }
|
|
49
|
+
.sort_by { |ranked| [-ranked.final_score, ranked.position] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# Filters hard-junk but preserves discovery order (list/cluster scrapers).
|
|
54
|
+
#
|
|
55
|
+
# @param segments [Array<Html2rss::AutoSource::Segment>]
|
|
56
|
+
# @param limit [Integer]
|
|
57
|
+
# @return [Array<RankedSegment>]
|
|
58
|
+
def select_eligible(segments, limit: TOP_K)
|
|
59
|
+
segments.filter_map { |segment| rank_one(segment, hard_junk_mode: :lenient) }
|
|
60
|
+
.sort_by(&:position)
|
|
61
|
+
.first(limit)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# @param segments [Array<Html2rss::AutoSource::Segment>]
|
|
66
|
+
# @param limit [Integer]
|
|
67
|
+
# @return [Array<RankedSegment>] score-floor filtered, then capped
|
|
68
|
+
def rank_top(segments, limit: TOP_K)
|
|
69
|
+
rank(segments).select { |ranked| ranked.final_score >= SCORE_FLOOR }.first(limit)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/MethodLength
|
|
75
|
+
facts = segment.primary_link ? @link_resolver.destination_facts(segment.primary_link) : nil
|
|
76
|
+
obs = @assessor.call(segment.root_node, segment.primary_link, destination_facts: facts)
|
|
77
|
+
return if obs.hard_junk?(mode: hard_junk_mode)
|
|
78
|
+
|
|
79
|
+
quality, quality_parts = apply_rules(QUALITY_RULES, obs)
|
|
80
|
+
junk, junk_parts = apply_rules(JUNK_RULES, obs)
|
|
81
|
+
RankedSegment.build(
|
|
82
|
+
segment:,
|
|
83
|
+
score: Score.build(
|
|
84
|
+
composite: quality - junk,
|
|
85
|
+
quality:,
|
|
86
|
+
junk:,
|
|
87
|
+
breakdown: quality_parts.merge(junk_parts)
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def apply_rules(rules, obs)
|
|
93
|
+
parts = {}
|
|
94
|
+
rules.each do |feature_id, predicate, weight|
|
|
95
|
+
parts[feature_id] = weight if predicate.call(obs)
|
|
96
|
+
end
|
|
97
|
+
[parts.values.sum, parts]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|