html2rss 0.22.2 → 0.23.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 +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +206 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +21 -2
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +118 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
- data/lib/html2rss/feed_builder/json_feed.rb +61 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +23 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +52 -65
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
- data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +90 -16
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +102 -6
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +25 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +63 -65
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +19 -19
- data/schema/html2rss-config.schema.json +109 -9
- metadata +68 -42
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
|
@@ -1,401 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
module Scraper
|
|
6
|
-
##
|
|
7
|
-
# Shared link-level heuristics used by scraper-local selection and
|
|
8
|
-
# scoring. This keeps normalization and route/text classification
|
|
9
|
-
# consistent without moving scraper policy into higher orchestration.
|
|
10
|
-
class LinkHeuristics
|
|
11
|
-
# Normalized URL plus reusable route-classification facts for one link.
|
|
12
|
-
DestinationFacts = Data.define(
|
|
13
|
-
:url,
|
|
14
|
-
:destination,
|
|
15
|
-
:segments,
|
|
16
|
-
:content_path,
|
|
17
|
-
:utility_path,
|
|
18
|
-
:taxonomy_path,
|
|
19
|
-
:vanity_path,
|
|
20
|
-
:shallow,
|
|
21
|
-
:strong_post_suffix,
|
|
22
|
-
:high_confidence_junk_path,
|
|
23
|
-
:high_confidence_utility_destination
|
|
24
|
-
) do
|
|
25
|
-
# @param url [Html2rss::Url] normalized destination URL
|
|
26
|
-
# @return [DestinationFacts] route facts for downstream link scoring
|
|
27
|
-
def self.build(url) # rubocop:disable Metrics/MethodLength
|
|
28
|
-
classifier = PathClassifier.new(url.path_segments)
|
|
29
|
-
|
|
30
|
-
new(
|
|
31
|
-
url:,
|
|
32
|
-
destination: url.to_s,
|
|
33
|
-
segments: classifier.segments,
|
|
34
|
-
strong_post_suffix: classifier.strong_post_suffix?,
|
|
35
|
-
content_path: classifier.content_path?,
|
|
36
|
-
utility_path: classifier.utility_path?,
|
|
37
|
-
taxonomy_path: classifier.taxonomy_path?,
|
|
38
|
-
vanity_path: classifier.vanity_path?,
|
|
39
|
-
shallow: classifier.shallow?,
|
|
40
|
-
high_confidence_junk_path: classifier.junk_path?,
|
|
41
|
-
high_confidence_utility_destination: classifier.utility_destination?
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Extracts a normalized href from a Nokogiri anchor or raw href value.
|
|
47
|
-
class HrefExtractor
|
|
48
|
-
# Regexp to capture everything before the first '#'
|
|
49
|
-
HREF_BASE_PATTERN = /\A([^#]*)/
|
|
50
|
-
|
|
51
|
-
# @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
|
|
52
|
-
# @return [String, nil] href without fragment, or nil when blank
|
|
53
|
-
def self.call(anchor_or_href) = new(anchor_or_href).call
|
|
54
|
-
|
|
55
|
-
# @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
|
|
56
|
-
def initialize(anchor_or_href)
|
|
57
|
-
@anchor_or_href = anchor_or_href
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# @return [String, nil] href without fragment, or nil when blank
|
|
61
|
-
def call
|
|
62
|
-
href = case @anchor_or_href
|
|
63
|
-
when Nokogiri::XML::Node
|
|
64
|
-
@anchor_or_href['href']
|
|
65
|
-
else
|
|
66
|
-
@anchor_or_href
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
return unless href
|
|
70
|
-
|
|
71
|
-
# Extract base part before # and strip whitespace
|
|
72
|
-
base = href.to_s[HREF_BASE_PATTERN, 1].strip
|
|
73
|
-
base unless base.empty?
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Classifies visible anchor text for utility and recommendation chrome.
|
|
78
|
-
class TextClassifier
|
|
79
|
-
# Prefix labels that usually identify navigation or subscription links.
|
|
80
|
-
UTILITY_PREFIX_PATTERN = /
|
|
81
|
-
\A\s*(
|
|
82
|
-
# English
|
|
83
|
-
view\s+all|see\s+all|all\s+news|subscribe|newsletter|comment\s+feed|comments\s+feed|join|premium|plus|
|
|
84
|
-
# German
|
|
85
|
-
alle\s+anzeigen|alle\s+news|abonnieren|newsletter|kommentar\s+feed|mitmachen|
|
|
86
|
-
# Spanish
|
|
87
|
-
ver\s+todos|ver\s+todo|todas\s+las\s+noticias|suscribirse|bolet(i|í)n|comentarios\s+feed|unirse|
|
|
88
|
-
# French
|
|
89
|
-
voir\s+tout|voir\s+tous|toutes\s+les\s+nouvelles|s['’]abonner|flux\s+de\s+commentaires|rejoindre
|
|
90
|
-
)\b
|
|
91
|
-
/ix
|
|
92
|
-
# Short labels that usually identify non-article navigation links.
|
|
93
|
-
UTILITY_PATTERN = /
|
|
94
|
-
\A\s*(
|
|
95
|
-
# English
|
|
96
|
-
about|contact|comments?|join|log\s+in|login|member(ship)?|
|
|
97
|
-
plus|premium|pricing|recommended(\s+for\s+you)?|
|
|
98
|
-
see\s+all|share|sign\s+up|signup|subscribe|view\s+all|
|
|
99
|
-
# German
|
|
100
|
-
(ue|ü)ber(\s+uns)?|kontakt|kommentare?|mitmachen|anmelden|login|
|
|
101
|
-
mitglied(schaft)?|empfohlen(\s+f(ue|ü)r\s+dich)?|alle\s+anzeigen|
|
|
102
|
-
teilen|registrieren|abonnieren|newsletter|
|
|
103
|
-
# Spanish
|
|
104
|
-
sobre(\s+nosotros)?|contacto|comentarios?|unirse|iniciar\s+sesion|
|
|
105
|
-
login|miembro|membres(i|í)a|recomendado(\s+para\s+ti)?|ver\s+todo|
|
|
106
|
-
compartir|registrarse|suscribirse|bolet(i|í)n|
|
|
107
|
-
# French
|
|
108
|
-
(a|à)\s+propos|(a|à)propos|contact|commentaires?|rejoindre|
|
|
109
|
-
se\s+connecter|login|membre|abonnement|recommand(e|é)(\s+pour\s+vous)?|
|
|
110
|
-
voir\s+tout|partager|s['’]inscrire|s['’]abonner|newsletter
|
|
111
|
-
)\b
|
|
112
|
-
/ix
|
|
113
|
-
# Labels for recommendation chrome rather than source articles.
|
|
114
|
-
RECOMMENDED_PATTERN = /
|
|
115
|
-
\A\s*(
|
|
116
|
-
recommended(\s+for\s+you)?|
|
|
117
|
-
empfohlen(\s+f(ue|ü)r\s+dich)?|
|
|
118
|
-
recomendado(\s+para\s+ti)?|
|
|
119
|
-
recommand(e|é)(\s+pour\s+vous)?
|
|
120
|
-
)\b
|
|
121
|
-
/ix
|
|
122
|
-
|
|
123
|
-
# @param text [String, #to_s] visible anchor text
|
|
124
|
-
# @return [Boolean] true when text matches a utility label
|
|
125
|
-
def utility?(text) = text.to_s.match?(UTILITY_PATTERN)
|
|
126
|
-
|
|
127
|
-
# @param text [String, #to_s] visible anchor text
|
|
128
|
-
# @return [Boolean] true when text begins with a utility label
|
|
129
|
-
def utility_prefix?(text) = text.to_s.match?(UTILITY_PREFIX_PATTERN)
|
|
130
|
-
|
|
131
|
-
# @param text [String, #to_s] visible anchor text
|
|
132
|
-
# @return [Boolean] true when text identifies recommendation chrome
|
|
133
|
-
def recommended?(text) = text.to_s.match?(RECOMMENDED_PATTERN)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# Classifies normalized destination path segments for scoring.
|
|
137
|
-
class PathClassifier # rubocop:disable Metrics/ClassLength
|
|
138
|
-
attr_reader :segments
|
|
139
|
-
|
|
140
|
-
# Segment groups used to classify article, taxonomy, utility, and vanity routes.
|
|
141
|
-
SEGMENT_SETS = {
|
|
142
|
-
content: %w[
|
|
143
|
-
article articles blog blogs changelog changelogs insight insights
|
|
144
|
-
launch launches news post posts release releases story stories update updates
|
|
145
|
-
artikel beitrag beitraege nachrichten neuigkeiten aktuelles
|
|
146
|
-
articulo articulos noticia noticias entrada entradas publicacion publicaciones
|
|
147
|
-
actualite actualites nouvelle nouvelles
|
|
148
|
-
teaser teasers card cards
|
|
149
|
-
].to_set.freeze,
|
|
150
|
-
utility: %w[
|
|
151
|
-
about account archive archives author authors category categories comment comments
|
|
152
|
-
contact feedback help login logout newsletter newsletters notification notifications
|
|
153
|
-
preference preferences profile register search settings share signup subscribe
|
|
154
|
-
tag tags topic topics
|
|
155
|
-
feed feeds comment-feed comments-feed
|
|
156
|
-
recommended
|
|
157
|
-
for-you
|
|
158
|
-
privacy terms cookie cookies
|
|
159
|
-
join member members membership plus premium plans pricing user users
|
|
160
|
-
kategorie kategorien schlagwort schlagworte thema themen autor autoren archiv
|
|
161
|
-
ueber-uns ueber ueberuns profil kontakt impressum suche hilfe anmelden registrieren
|
|
162
|
-
konto registrierung anmeldung abonnieren abo datenschutz nutzungsbedingungen agb
|
|
163
|
-
categoria categorias etiqueta etiquetas tema temas autores archivos
|
|
164
|
-
sobre-nosotros sobre quienes-somos buscar busqueda ayuda entrar ingresar
|
|
165
|
-
registrarse registro cuenta suscribirse boletin privacidad condiciones
|
|
166
|
-
categorie etiquette etiquettes sujet sujets theme themes auteur auteurs
|
|
167
|
-
a-propos apropos recherche rechercher aide connexion s-inscrire
|
|
168
|
-
sinscrire inscription compte s-abonner saboner lettre-information confidentialite mentions-legales cgu
|
|
169
|
-
menu sidebar widget social modal popup banner promo ad ads
|
|
170
|
-
related recommendation recommendations pagination pager
|
|
171
|
-
].to_set.freeze,
|
|
172
|
-
high_confidence_junk: %w[
|
|
173
|
-
about account archive archives author authors category categories comment comments
|
|
174
|
-
contact cookie cookies feedback feed feeds help login logout notification notifications
|
|
175
|
-
preference preferences privacy profile register search settings share signup subscribe
|
|
176
|
-
tag tags terms topic topics comment-feed comments-feed user users
|
|
177
|
-
kategorie kategorien schlagwort schlagworte thema themen autor autoren archiv
|
|
178
|
-
ueber-uns ueber ueberuns profil kontakt impressum suche hilfe anmelden registrieren
|
|
179
|
-
konto registrierung anmeldung abonnieren abo datenschutz nutzungsbedingungen agb
|
|
180
|
-
categoria categorias etiqueta etiquetas tema temas autores archivos
|
|
181
|
-
sobre-nosotros sobre quienes-somos buscar busqueda ayuda entrar ingresar
|
|
182
|
-
registrarse registro cuenta suscribirse boletin privacidad condiciones
|
|
183
|
-
categorie etiquette etiquettes sujet sujets theme themes auteur auteurs
|
|
184
|
-
a-propos apropos recherche rechercher aide connexion s-inscrire
|
|
185
|
-
sinscrire inscription compte s-abonner saboner lettre-information confidentialite mentions-legales cgu
|
|
186
|
-
menu sidebar widget social modal popup banner promo ad ads
|
|
187
|
-
related recommendation recommendations pagination pager
|
|
188
|
-
].to_set.freeze,
|
|
189
|
-
taxonomy: %w[
|
|
190
|
-
category categories tag tags topic topics
|
|
191
|
-
kategorie kategorien schlagwort schlagworte thema themen
|
|
192
|
-
categoria categorias etiqueta etiquetas tema temas
|
|
193
|
-
categorie etiquette etiquettes sujet sujets theme themes
|
|
194
|
-
].to_set.freeze,
|
|
195
|
-
vanity: %w[
|
|
196
|
-
join membership plus premium pricing plans subscribe signup
|
|
197
|
-
abonnieren abo
|
|
198
|
-
suscribirse boletin
|
|
199
|
-
s-abonner saboner
|
|
200
|
-
].to_set.freeze,
|
|
201
|
-
deep_post_context: %w[
|
|
202
|
-
press newsroom
|
|
203
|
-
presse pressemitteilungen
|
|
204
|
-
prensa
|
|
205
|
-
].to_set.freeze
|
|
206
|
-
}.freeze
|
|
207
|
-
# Path segment that begins with a year-like publishing marker.
|
|
208
|
-
YEARISH_SEGMENT = /\A\d{4,}[\w-]*\z/
|
|
209
|
-
# Hyphenated slug shape common to article permalinks.
|
|
210
|
-
POST_SLUG_SEGMENT = /\A[a-z0-9]+(?:-[a-z0-9]+){2,}\z/i
|
|
211
|
-
|
|
212
|
-
# @param segments [Array<String>] normalized URL path segments
|
|
213
|
-
def initialize(segments)
|
|
214
|
-
@segments = segments
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
# @return [Boolean] true when the route has article-like path evidence
|
|
218
|
-
def content_path?
|
|
219
|
-
@content_path ||= segments.any? { |s| SEGMENT_SETS[:content].include?(s) } ||
|
|
220
|
-
yearish_content_context?
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# @return [Boolean] true when the route includes utility/navigation evidence
|
|
224
|
-
def utility_path?
|
|
225
|
-
@utility_path ||= segments.any? { |s| SEGMENT_SETS[:utility].include?(s) }
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
# @return [Boolean] true when the route points at conversion or account chrome
|
|
229
|
-
def vanity_path?
|
|
230
|
-
@vanity_path ||= segments.any? { |s| SEGMENT_SETS[:vanity].include?(s) }
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# @return [Boolean] true when the route points at taxonomy/listing chrome
|
|
234
|
-
def taxonomy_path?
|
|
235
|
-
@taxonomy_path ||= segments.any? { |s| SEGMENT_SETS[:taxonomy].include?(s) }
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
# @return [Boolean] true when the route is too shallow to strongly indicate an article
|
|
239
|
-
def shallow?
|
|
240
|
-
segment_count = segments.size
|
|
241
|
-
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
242
|
-
|
|
243
|
-
segment_count <= 1 || (segment_count == 2 && junk_segments.include?(segments.last))
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
# @return [Boolean] true when the final path segment looks like a post slug
|
|
247
|
-
def strong_post_suffix?
|
|
248
|
-
@strong_post_suffix ||= segments.any? &&
|
|
249
|
-
included_last_segment? &&
|
|
250
|
-
trusted_post_context?(segments.size - 1)
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
# @return [Boolean] true when every path segment is utility chrome
|
|
254
|
-
def utility_only_route?
|
|
255
|
-
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
256
|
-
|
|
257
|
-
segments.all? { |segment| junk_segments.include?(segment) }
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
# @return [Boolean] true when the route is shallow and contains high-confidence noise
|
|
261
|
-
def shallow_high_confidence_route?
|
|
262
|
-
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
263
|
-
vanity_segments = SEGMENT_SETS.fetch(:vanity)
|
|
264
|
-
|
|
265
|
-
shallow? && segments.any? do |segment|
|
|
266
|
-
junk_segments.include?(segment) || vanity_segments.include?(segment)
|
|
267
|
-
end
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
# @return [Boolean] true when the leading segments are all utility chrome
|
|
271
|
-
def deep_utility_context_route?
|
|
272
|
-
all_junk?(segments.size - 1)
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
# @return [Boolean] true when the route is shallow and contains high-confidence noise
|
|
276
|
-
def junk_path?
|
|
277
|
-
return false if excluded_content_route?
|
|
278
|
-
|
|
279
|
-
taxonomy_path? ||
|
|
280
|
-
utility_only_route? ||
|
|
281
|
-
deep_utility_context_route? ||
|
|
282
|
-
shallow_high_confidence_route?
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
# @return [Boolean] true when the route points at conversion or account chrome
|
|
286
|
-
def utility_destination?
|
|
287
|
-
return false if excluded_content_route?
|
|
288
|
-
|
|
289
|
-
vanity_path? || utility_route?
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
private
|
|
293
|
-
|
|
294
|
-
def yearish_content_context?
|
|
295
|
-
segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
|
|
296
|
-
(strong_post_suffix? || trusted_post_context?(segments.size - 1))
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
def excluded_content_route?
|
|
300
|
-
segments.empty? || content_path? || strong_post_suffix?
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def utility_route?
|
|
304
|
-
taxonomy_path? ||
|
|
305
|
-
utility_only_route? ||
|
|
306
|
-
deep_utility_context_route? ||
|
|
307
|
-
shallow_utility_route?
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
def shallow_utility_route?
|
|
311
|
-
shallow? && utility_path?
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
def all_junk?(limit)
|
|
315
|
-
return false if limit <= 0
|
|
316
|
-
|
|
317
|
-
junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
|
|
318
|
-
(0...limit).all? { |i| junk_segments.include?(segments[i]) }
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def trusted_post_context?(limit)
|
|
322
|
-
return false if limit <= 0
|
|
323
|
-
|
|
324
|
-
content_segments = SEGMENT_SETS.fetch(:content)
|
|
325
|
-
context_segments = SEGMENT_SETS.fetch(:deep_post_context)
|
|
326
|
-
|
|
327
|
-
(0...limit).any? do |i|
|
|
328
|
-
segment = segments[i]
|
|
329
|
-
content_segments.include?(segment) ||
|
|
330
|
-
segment.match?(PathClassifier::YEARISH_SEGMENT) ||
|
|
331
|
-
context_segments.include?(segment)
|
|
332
|
-
end
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
def included_last_segment?
|
|
336
|
-
!excluded_last_segment? && slug_last_segment?
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
def excluded_last_segment?
|
|
340
|
-
last = segments.last
|
|
341
|
-
[SEGMENT_SETS[:high_confidence_junk], SEGMENT_SETS[:vanity]].any? { |set| set.include?(last) }
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
def slug_last_segment?
|
|
345
|
-
last = segments.last
|
|
346
|
-
last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
|
|
347
|
-
end
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
# @param base_url [String, Html2rss::Url] page URL used to resolve relative hrefs
|
|
351
|
-
def initialize(base_url)
|
|
352
|
-
@base_url = base_url
|
|
353
|
-
@text_classifier = TextClassifier.new
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
# Builds normalized destination facts for an anchor element or href string.
|
|
357
|
-
#
|
|
358
|
-
# @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
|
|
359
|
-
# @return [DestinationFacts, nil] normalized destination facts, or nil for blank/invalid URLs
|
|
360
|
-
def destination_facts(anchor_or_href)
|
|
361
|
-
return node_facts[anchor_or_href] if node_facts.key?(anchor_or_href)
|
|
362
|
-
|
|
363
|
-
href = HrefExtractor.call(anchor_or_href)
|
|
364
|
-
return unless href
|
|
365
|
-
|
|
366
|
-
res = memoized_destination_facts(href)
|
|
367
|
-
|
|
368
|
-
node_facts[anchor_or_href] = res if anchor_or_href.is_a?(Nokogiri::XML::Node)
|
|
369
|
-
res
|
|
370
|
-
rescue ArgumentError
|
|
371
|
-
nil
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
# @param text [String, #to_s] visible anchor text
|
|
375
|
-
# @return [Boolean] true when text matches a utility label
|
|
376
|
-
def utility_text?(text) = @text_classifier.utility?(text)
|
|
377
|
-
|
|
378
|
-
# @param text [String, #to_s] visible anchor text
|
|
379
|
-
# @return [Boolean] true when text begins with a utility label
|
|
380
|
-
def utility_prefix_text?(text) = @text_classifier.utility_prefix?(text)
|
|
381
|
-
|
|
382
|
-
# @param text [String, #to_s] visible anchor text
|
|
383
|
-
# @return [Boolean] true when text identifies recommendation chrome
|
|
384
|
-
def recommended_text?(text) = @text_classifier.recommended?(text)
|
|
385
|
-
|
|
386
|
-
private
|
|
387
|
-
|
|
388
|
-
def node_facts
|
|
389
|
-
@node_facts ||= {}.compare_by_identity
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
def memoized_destination_facts(href)
|
|
393
|
-
(@destination_facts ||= {})[href] ||= begin
|
|
394
|
-
url = Html2rss::Url.from_relative(href, @base_url)
|
|
395
|
-
DestinationFacts.build(url)
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
end
|
|
399
|
-
end
|
|
400
|
-
end
|
|
401
|
-
end
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
module Scraper
|
|
6
|
-
class SemanticHtml
|
|
7
|
-
##
|
|
8
|
-
# Selects the best content-like anchor from a semantic container.
|
|
9
|
-
#
|
|
10
|
-
# The selector turns raw DOM anchors into ranked facts so semantic
|
|
11
|
-
# scraping can reason about link intent instead of DOM order. It favors
|
|
12
|
-
# heading-aligned article links and suppresses utility links, duplicate
|
|
13
|
-
# destinations, and weak textless affordances.
|
|
14
|
-
class AnchorSelector
|
|
15
|
-
# Comma-separated heading selector used for heading/anchor matching.
|
|
16
|
-
HEADING_SELECTOR = HtmlExtractor::HEADING_TAGS.join(',').freeze
|
|
17
|
-
|
|
18
|
-
# @param base_url [String, Html2rss::Url] page URL used to normalize href destinations
|
|
19
|
-
def initialize(base_url)
|
|
20
|
-
@link_heuristics = LinkHeuristics.new(base_url)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
##
|
|
24
|
-
# Chooses the single anchor that best represents the story contained
|
|
25
|
-
# in a semantic block.
|
|
26
|
-
#
|
|
27
|
-
# Ranking is scoped to one container at a time. That keeps the logic
|
|
28
|
-
# local, makes duplicate links to the same destination collapse into
|
|
29
|
-
# one candidate, and avoids page-wide heuristics leaking across cards.
|
|
30
|
-
#
|
|
31
|
-
# @param container [Nokogiri::XML::Element] semantic container being evaluated
|
|
32
|
-
# @return [Nokogiri::XML::Element, nil] selected primary anchor or nil when none qualify
|
|
33
|
-
def primary_anchor_for(container)
|
|
34
|
-
facts_for(container).max_by(&:score)&.anchor
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
def facts_for(container)
|
|
40
|
-
HtmlExtractor::SemanticAnchorCandidates.new(
|
|
41
|
-
container,
|
|
42
|
-
link_heuristics: @link_heuristics
|
|
43
|
-
).to_a
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
##
|
|
5
|
-
# Shared anti-bot/interstitial signatures used by request and auto-source flows.
|
|
6
|
-
#
|
|
7
|
-
# This module centralizes signature matching so request-time guards and
|
|
8
|
-
# auto-source surface classification stay consistent.
|
|
9
|
-
module BlockedSurface
|
|
10
|
-
# Known interstitial fingerprints used to detect blocked or anti-bot surfaces.
|
|
11
|
-
INTERSTITIAL_SIGNATURES = [
|
|
12
|
-
{
|
|
13
|
-
key: :cloudflare_interstitial,
|
|
14
|
-
min_matches: 2,
|
|
15
|
-
patterns: [
|
|
16
|
-
%r{<title>\s*just a moment\.\.\.\s*</title>}i,
|
|
17
|
-
/checking your browser before accessing/i,
|
|
18
|
-
/please (?:enable|turn on) javascript and cookies/i,
|
|
19
|
-
%r{cdn-cgi/challenge-platform}i,
|
|
20
|
-
/cloudflare ray id/i
|
|
21
|
-
],
|
|
22
|
-
message: 'Blocked surface detected: Cloudflare anti-bot interstitial page. ' \
|
|
23
|
-
'Retry with --strategy browserless, try a more specific public listing URL, ' \
|
|
24
|
-
'or run from an environment that can complete anti-bot checks.'
|
|
25
|
-
}
|
|
26
|
-
].freeze
|
|
27
|
-
|
|
28
|
-
##
|
|
29
|
-
# Returns the first matching interstitial signature for the provided body.
|
|
30
|
-
#
|
|
31
|
-
# @param body [String, nil] response body candidate
|
|
32
|
-
# @return [Hash, nil] signature hash when matched, otherwise nil
|
|
33
|
-
def self.interstitial_signature_for(body)
|
|
34
|
-
normalized_body = normalize_body(body)
|
|
35
|
-
INTERSTITIAL_SIGNATURES.find { |signature| interstitial_signature_match?(normalized_body, signature) }
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
##
|
|
39
|
-
# @param body [String, nil] response body candidate
|
|
40
|
-
# @return [Boolean] true when body matches a known interstitial signature
|
|
41
|
-
def self.interstitial?(body)
|
|
42
|
-
!interstitial_signature_for(body).nil?
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def self.interstitial_signature_match?(body, signature)
|
|
46
|
-
min_matches = signature.fetch(:min_matches, 1)
|
|
47
|
-
matches = 0
|
|
48
|
-
|
|
49
|
-
signature.fetch(:patterns).each do |pattern|
|
|
50
|
-
matches += 1 if pattern.match?(body)
|
|
51
|
-
return true if matches >= min_matches
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
false
|
|
55
|
-
end
|
|
56
|
-
private_class_method :interstitial_signature_match?
|
|
57
|
-
|
|
58
|
-
def self.normalize_body(body)
|
|
59
|
-
body.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
|
|
60
|
-
rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
61
|
-
body.to_s.force_encoding(Encoding::UTF_8).scrub
|
|
62
|
-
end
|
|
63
|
-
private_class_method :normalize_body
|
|
64
|
-
end
|
|
65
|
-
end
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
##
|
|
5
|
-
# CategoryExtractor is responsible for extracting categories from HTML elements
|
|
6
|
-
# by looking for CSS class names containing common category-related terms.
|
|
7
|
-
class CategoryExtractor
|
|
8
|
-
# Common category-related terms to look for in class names
|
|
9
|
-
CATEGORY_TERMS = %w[category tag topic section label theme subject].freeze
|
|
10
|
-
|
|
11
|
-
# CSS selectors to find elements with category-related class names or data attributes
|
|
12
|
-
CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
|
|
13
|
-
["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
|
|
14
|
-
end.freeze
|
|
15
|
-
|
|
16
|
-
# Regex pattern for matching category-related attribute names
|
|
17
|
-
CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
|
|
18
|
-
|
|
19
|
-
##
|
|
20
|
-
# Extracts categories from the given article tag by looking for elements
|
|
21
|
-
# with class names containing common category-related terms.
|
|
22
|
-
#
|
|
23
|
-
# @param article_tag [Nokogiri::XML::Element] The article element to extract categories from
|
|
24
|
-
# @return [Array<String>] Array of category strings, empty if none found
|
|
25
|
-
def self.call(article_tag)
|
|
26
|
-
return [] unless article_tag
|
|
27
|
-
|
|
28
|
-
# Single optimized traversal that extracts all category types
|
|
29
|
-
extract_all_categories(article_tag)
|
|
30
|
-
.map(&:strip)
|
|
31
|
-
.reject(&:empty?)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
##
|
|
35
|
-
# Optimized single DOM traversal that extracts all category types.
|
|
36
|
-
#
|
|
37
|
-
# @param article_tag [Nokogiri::XML::Element] The article element
|
|
38
|
-
# @return [Set<String>] Set of category strings
|
|
39
|
-
def self.extract_all_categories(article_tag)
|
|
40
|
-
Set.new.tap do |categories|
|
|
41
|
-
article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
|
|
42
|
-
# Extract text categories from elements with category-related class names
|
|
43
|
-
extract_text_categories!(categories, element) if element['class']&.match?(CATEGORY_ATTR_PATTERN)
|
|
44
|
-
|
|
45
|
-
# Extract data categories from all elements
|
|
46
|
-
extract_element_data_categories!(categories, element)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
##
|
|
52
|
-
# Extracts categories from data attributes of a single element.
|
|
53
|
-
#
|
|
54
|
-
# @param categories [Set<String>] Accumulator set
|
|
55
|
-
# @param element [Nokogiri::XML::Element] metadata element that may contain category links
|
|
56
|
-
# @return [void]
|
|
57
|
-
def self.extract_element_data_categories!(categories, element)
|
|
58
|
-
element.attributes.each_value do |attr|
|
|
59
|
-
next unless attr.name.match?(CATEGORY_ATTR_PATTERN)
|
|
60
|
-
|
|
61
|
-
value = attr.value&.strip
|
|
62
|
-
categories.add(value) if value && !value.empty?
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
##
|
|
67
|
-
# Extracts text-based categories from elements, splitting content into discrete values.
|
|
68
|
-
#
|
|
69
|
-
# @param categories [Set<String>] Accumulator set
|
|
70
|
-
# @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
|
|
71
|
-
# @return [void]
|
|
72
|
-
def self.extract_text_categories!(categories, element)
|
|
73
|
-
if element.name == 'a'
|
|
74
|
-
add_text_to_categories!(categories, element)
|
|
75
|
-
return
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
anchors = element.css('a')
|
|
79
|
-
|
|
80
|
-
if anchors.any?
|
|
81
|
-
anchors.each { |node| add_text_to_categories!(categories, node) }
|
|
82
|
-
else
|
|
83
|
-
extract_split_text_categories!(categories, element)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
##
|
|
88
|
-
# Adds the visible text of the given element to the categories set.
|
|
89
|
-
#
|
|
90
|
-
# @param categories [Set<String>] Accumulator set
|
|
91
|
-
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
92
|
-
# @return [void]
|
|
93
|
-
def self.add_text_to_categories!(categories, element)
|
|
94
|
-
text = HtmlExtractor.extract_visible_text(element)
|
|
95
|
-
categories.add(text) if text && !text.empty?
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
##
|
|
99
|
-
# Extracts categories from the element's text by splitting on newlines.
|
|
100
|
-
#
|
|
101
|
-
# @param categories [Set<String>] Accumulator set
|
|
102
|
-
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
103
|
-
# @return [void]
|
|
104
|
-
def self.extract_split_text_categories!(categories, element)
|
|
105
|
-
text = HtmlExtractor.extract_visible_text(element)
|
|
106
|
-
return unless text
|
|
107
|
-
|
|
108
|
-
text.split(/\n+/).each do |line|
|
|
109
|
-
line = line.strip
|
|
110
|
-
categories.add(line) unless line.empty?
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
private_class_method :add_text_to_categories!, :extract_split_text_categories!
|
|
115
|
-
end
|
|
116
|
-
end
|