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.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  12. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  14. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  15. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  16. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  17. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  18. data/lib/html2rss/auto_source/scraper.rb +109 -53
  19. data/lib/html2rss/auto_source/segment.rb +29 -0
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  21. data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
  22. data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
  23. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  24. data/lib/html2rss/auto_source/segmenter.rb +95 -0
  25. data/lib/html2rss/auto_source.rb +79 -21
  26. data/lib/html2rss/capture.rb +315 -0
  27. data/lib/html2rss/cli.rb +72 -18
  28. data/lib/html2rss/config/auto_source_contract.rb +5 -1
  29. data/lib/html2rss/config/request_controls.rb +33 -0
  30. data/lib/html2rss/config/schema.rb +67 -26
  31. data/lib/html2rss/config/selectors_validator.rb +60 -21
  32. data/lib/html2rss/config/validator.rb +33 -35
  33. data/lib/html2rss/config.rb +6 -2
  34. data/lib/html2rss/error.rb +15 -3
  35. data/lib/html2rss/feed_builder/rss.rb +17 -5
  36. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  37. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  38. data/lib/html2rss/feed_pipeline.rb +11 -0
  39. data/lib/html2rss/feed_result.rb +1 -1
  40. data/lib/html2rss/hash_util.rb +17 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +308 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +68 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +208 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/mcp/server.rb +577 -0
  62. data/lib/html2rss/mcp.rb +21 -0
  63. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  64. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  65. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  66. data/lib/html2rss/request_service/budget.rb +7 -35
  67. data/lib/html2rss/request_service/context.rb +0 -6
  68. data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
  69. data/lib/html2rss/request_service/network_guard.rb +5 -3
  70. data/lib/html2rss/request_service/policy.rb +1 -1
  71. data/lib/html2rss/request_service/response.rb +15 -1
  72. data/lib/html2rss/request_service/strategy.rb +1 -2
  73. data/lib/html2rss/request_service.rb +4 -9
  74. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  75. data/lib/html2rss/scoring/container_assessor.rb +77 -0
  76. data/lib/html2rss/scoring/engine.rb +145 -0
  77. data/lib/html2rss/scoring/link_resolver.rb +84 -0
  78. data/lib/html2rss/scoring/observation.rb +53 -0
  79. data/lib/html2rss/scoring/ranked_segment.rb +33 -0
  80. data/lib/html2rss/scoring/score.rb +11 -0
  81. data/lib/html2rss/scoring.rb +8 -0
  82. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  83. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  84. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  85. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  86. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  87. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  88. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  89. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  90. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  91. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  92. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
  93. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  94. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  95. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  96. data/lib/html2rss/selectors.rb +0 -20
  97. data/lib/html2rss/sst/attrs.rb +91 -0
  98. data/lib/html2rss/sst/document.rb +23 -0
  99. data/lib/html2rss/sst/index.rb +112 -0
  100. data/lib/html2rss/sst/node.rb +147 -0
  101. data/lib/html2rss/sst/normalizer.rb +171 -0
  102. data/lib/html2rss/sst/tags.rb +26 -0
  103. data/lib/html2rss/sst/text.rb +81 -0
  104. data/lib/html2rss/sst.rb +8 -0
  105. data/lib/html2rss/url.rb +11 -7
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +57 -26
  108. data/schema/html2rss-config.schema.json +428 -85
  109. metadata +93 -27
  110. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  111. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  112. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  113. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  114. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  115. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  116. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  117. data/lib/html2rss/auto_source/discovery.rb +0 -14
  118. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  119. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  120. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  121. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  122. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  123. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  124. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  125. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  126. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  127. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  128. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  129. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- class LinkHeuristics
6
- # Normalized URL plus reusable route-classification facts for one link.
7
- DestinationFacts = Data.define(
8
- :url,
9
- :destination,
10
- :segments,
11
- :content_path,
12
- :utility_path,
13
- :taxonomy_path,
14
- :vanity_path,
15
- :shallow,
16
- :strong_post_suffix,
17
- :high_confidence_junk_path,
18
- :high_confidence_utility_destination
19
- ) do
20
- # @param url [Html2rss::Url] normalized destination URL
21
- # @return [DestinationFacts] route facts for downstream link scoring
22
- def self.build(url) # rubocop:disable Metrics/MethodLength
23
- classifier = PathClassifier.new(url.path_segments)
24
-
25
- new(
26
- url:,
27
- destination: url.to_s,
28
- segments: classifier.segments,
29
- strong_post_suffix: classifier.strong_post_suffix?,
30
- content_path: classifier.content_path?,
31
- utility_path: classifier.utility_path?,
32
- taxonomy_path: classifier.taxonomy_path?,
33
- vanity_path: classifier.vanity_path?,
34
- shallow: classifier.shallow?,
35
- high_confidence_junk_path: classifier.junk_path?,
36
- high_confidence_utility_destination: classifier.utility_destination?
37
- )
38
- end
39
- end
40
- end
41
- end
42
- end
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- class LinkHeuristics
6
- # Extracts a normalized href from a Nokogiri anchor or raw href value.
7
- class HrefExtractor
8
- # Regexp to capture everything before the first '#'
9
- HREF_BASE_PATTERN = /\A([^#]*)/
10
-
11
- # @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
12
- # @return [String, nil] href without fragment, or nil when blank
13
- def self.call(anchor_or_href) = new(anchor_or_href).call
14
-
15
- # @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
16
- def initialize(anchor_or_href)
17
- @anchor_or_href = anchor_or_href
18
- end
19
-
20
- # @return [String, nil] href without fragment, or nil when blank
21
- def call
22
- href = case @anchor_or_href
23
- when Nokogiri::XML::Node
24
- @anchor_or_href['href']
25
- else
26
- @anchor_or_href
27
- end
28
-
29
- return unless href
30
-
31
- # Extract base part before # and strip whitespace
32
- base = href.to_s[HREF_BASE_PATTERN, 1].strip
33
- base unless base.empty?
34
- end
35
- end
36
- end
37
- end
38
- end
@@ -1,221 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- class LinkHeuristics
6
- # Classifies normalized destination path segments for scoring.
7
- class PathClassifier # rubocop:disable Metrics/ClassLength
8
- attr_reader :segments
9
-
10
- # Segment groups used to classify article, taxonomy, utility, and vanity routes.
11
- SEGMENT_SETS = {
12
- content: %w[
13
- article articles blog blogs changelog changelogs insight insights
14
- launch launches news post posts release releases story stories update updates
15
- artikel beitrag beitraege nachrichten neuigkeiten aktuelles
16
- articulo articulos noticia noticias entrada entradas publicacion publicaciones
17
- actualite actualites nouvelle nouvelles
18
- teaser teasers card cards
19
- ].to_set.freeze,
20
- utility: %w[
21
- about account archive archives author authors category categories comment comments
22
- contact feedback help login logout newsletter newsletters notification notifications
23
- preference preferences profile register search settings share signup subscribe
24
- tag tags topic topics
25
- feed feeds comment-feed comments-feed
26
- recommended
27
- for-you
28
- privacy terms cookie cookies
29
- join member members membership plus premium plans pricing user users
30
- kategorie kategorien schlagwort schlagworte thema themen autor autoren archiv
31
- ueber-uns ueber ueberuns profil kontakt impressum suche hilfe anmelden registrieren
32
- konto registrierung anmeldung abonnieren abo datenschutz nutzungsbedingungen agb
33
- categoria categorias etiqueta etiquetas tema temas autores archivos
34
- sobre-nosotros sobre quienes-somos buscar busqueda ayuda entrar ingresar
35
- registrarse registro cuenta suscribirse boletin privacidad condiciones
36
- categorie etiquette etiquettes sujet sujets theme themes auteur auteurs
37
- a-propos apropos recherche rechercher aide connexion s-inscrire
38
- sinscrire inscription compte s-abonner saboner lettre-information confidentialite mentions-legales cgu
39
- menu sidebar widget social modal popup banner promo ad ads
40
- related recommendation recommendations pagination pager
41
- ].to_set.freeze,
42
- high_confidence_junk: %w[
43
- about account archive archives author authors category categories comment comments
44
- contact cookie cookies feedback feed feeds help login logout notification notifications
45
- preference preferences privacy profile register search settings share signup subscribe
46
- tag tags terms topic topics comment-feed comments-feed user users
47
- kategorie kategorien schlagwort schlagworte thema themen autor autoren archiv
48
- ueber-uns ueber ueberuns profil kontakt impressum suche hilfe anmelden registrieren
49
- konto registrierung anmeldung abonnieren abo datenschutz nutzungsbedingungen agb
50
- categoria categorias etiqueta etiquetas tema temas autores archivos
51
- sobre-nosotros sobre quienes-somos buscar busqueda ayuda entrar ingresar
52
- registrarse registro cuenta suscribirse boletin privacidad condiciones
53
- categorie etiquette etiquettes sujet sujets theme themes auteur auteurs
54
- a-propos apropos recherche rechercher aide connexion s-inscrire
55
- sinscrire inscription compte s-abonner saboner lettre-information confidentialite mentions-legales cgu
56
- menu sidebar widget social modal popup banner promo ad ads
57
- related recommendation recommendations pagination pager
58
- ].to_set.freeze,
59
- taxonomy: %w[
60
- category categories tag tags topic topics
61
- kategorie kategorien schlagwort schlagworte thema themen
62
- categoria categorias etiqueta etiquetas tema temas
63
- categorie etiquette etiquettes sujet sujets theme themes
64
- ].to_set.freeze,
65
- vanity: %w[
66
- join membership plus premium pricing plans subscribe signup
67
- abonnieren abo
68
- suscribirse boletin
69
- s-abonner saboner
70
- ].to_set.freeze,
71
- deep_post_context: %w[
72
- press newsroom
73
- presse pressemitteilungen
74
- prensa
75
- ].to_set.freeze
76
- }.freeze
77
- # Path segment that begins with a year-like publishing marker.
78
- YEARISH_SEGMENT = /\A\d{4,}[\w-]*\z/
79
- # Hyphenated slug shape common to article permalinks.
80
- POST_SLUG_SEGMENT = /\A[a-z0-9]+(?:-[a-z0-9]+){2,}\z/i
81
-
82
- # @param segments [Array<String>] normalized URL path segments
83
- def initialize(segments)
84
- @segments = segments
85
- end
86
-
87
- # @return [Boolean] true when the route has article-like path evidence
88
- def content_path?
89
- @content_path ||= SEGMENT_SETS[:content].intersect?(segments) ||
90
- yearish_content_context?
91
- end
92
-
93
- # @return [Boolean] true when the route includes utility/navigation evidence
94
- def utility_path?
95
- @utility_path ||= SEGMENT_SETS[:utility].intersect?(segments)
96
- end
97
-
98
- # @return [Boolean] true when the route points at conversion or account chrome
99
- def vanity_path?
100
- @vanity_path ||= SEGMENT_SETS[:vanity].intersect?(segments)
101
- end
102
-
103
- # @return [Boolean] true when the route points at taxonomy/listing chrome
104
- def taxonomy_path?
105
- @taxonomy_path ||= SEGMENT_SETS[:taxonomy].intersect?(segments)
106
- end
107
-
108
- # @return [Boolean] true when the route is too shallow to strongly indicate an article
109
- def shallow?
110
- segment_count = segments.size
111
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
112
-
113
- segment_count <= 1 || (segment_count == 2 && junk_segments.include?(segments.last))
114
- end
115
-
116
- # @return [Boolean] true when the final path segment looks like a post slug
117
- def strong_post_suffix?
118
- @strong_post_suffix ||= segments.any? &&
119
- included_last_segment? &&
120
- trusted_post_context?(segments.size - 1)
121
- end
122
-
123
- # @return [Boolean] true when every path segment is utility chrome
124
- def utility_only_route?
125
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
126
-
127
- segments.all? { |segment| junk_segments.include?(segment) }
128
- end
129
-
130
- # @return [Boolean] true when the route is shallow and contains high-confidence noise
131
- def shallow_high_confidence_route?
132
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
133
- vanity_segments = SEGMENT_SETS.fetch(:vanity)
134
-
135
- shallow? && segments.any? do |segment|
136
- junk_segments.include?(segment) || vanity_segments.include?(segment)
137
- end
138
- end
139
-
140
- # @return [Boolean] true when the leading segments are all utility chrome
141
- def deep_utility_context_route?
142
- all_junk?(segments.size - 1)
143
- end
144
-
145
- # @return [Boolean] true when the route is shallow and contains high-confidence noise
146
- def junk_path?
147
- return false if excluded_content_route?
148
-
149
- taxonomy_path? ||
150
- utility_only_route? ||
151
- deep_utility_context_route? ||
152
- shallow_high_confidence_route?
153
- end
154
-
155
- # @return [Boolean] true when the route points at conversion or account chrome
156
- def utility_destination?
157
- return false if excluded_content_route?
158
-
159
- vanity_path? || utility_route?
160
- end
161
-
162
- private
163
-
164
- def yearish_content_context?
165
- segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
166
- (strong_post_suffix? || trusted_post_context?(segments.size - 1))
167
- end
168
-
169
- def excluded_content_route?
170
- segments.empty? || content_path? || strong_post_suffix?
171
- end
172
-
173
- def utility_route?
174
- taxonomy_path? ||
175
- utility_only_route? ||
176
- deep_utility_context_route? ||
177
- shallow_utility_route?
178
- end
179
-
180
- def shallow_utility_route?
181
- shallow? && utility_path?
182
- end
183
-
184
- def all_junk?(limit)
185
- return false if limit <= 0
186
-
187
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
188
- (0...limit).all? { |i| junk_segments.include?(segments[i]) }
189
- end
190
-
191
- def trusted_post_context?(limit)
192
- return false if limit <= 0
193
-
194
- content_segments = SEGMENT_SETS.fetch(:content)
195
- context_segments = SEGMENT_SETS.fetch(:deep_post_context)
196
-
197
- (0...limit).any? do |i|
198
- segment = segments[i]
199
- content_segments.include?(segment) ||
200
- segment.match?(PathClassifier::YEARISH_SEGMENT) ||
201
- context_segments.include?(segment)
202
- end
203
- end
204
-
205
- def included_last_segment?
206
- !excluded_last_segment? && slug_last_segment?
207
- end
208
-
209
- def excluded_last_segment?
210
- last = segments.last
211
- [SEGMENT_SETS[:high_confidence_junk], SEGMENT_SETS[:vanity]].any? { |set| set.include?(last) }
212
- end
213
-
214
- def slug_last_segment?
215
- last = segments.last
216
- last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
217
- end
218
- end
219
- end
220
- end
221
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- class LinkHeuristics
6
- # Classifies visible anchor text for utility and recommendation chrome.
7
- class TextClassifier
8
- # Prefix labels that usually identify navigation or subscription links.
9
- UTILITY_PREFIX_PATTERN = /
10
- \A\s*(
11
- # English
12
- view\s+all|see\s+all|all\s+news|subscribe|newsletter|comment\s+feed|comments\s+feed|join|premium|plus|
13
- # German
14
- alle\s+anzeigen|alle\s+news|abonnieren|newsletter|kommentar\s+feed|mitmachen|
15
- # Spanish
16
- ver\s+todos|ver\s+todo|todas\s+las\s+noticias|suscribirse|bolet(i|í)n|comentarios\s+feed|unirse|
17
- # French
18
- voir\s+tout|voir\s+tous|toutes\s+les\s+nouvelles|s['’]abonner|flux\s+de\s+commentaires|rejoindre
19
- )\b
20
- /ix
21
- # Short labels that usually identify non-article navigation links.
22
- UTILITY_PATTERN = /
23
- \A\s*(
24
- # English
25
- about|contact|comments?|join|log\s+in|login|member(ship)?|
26
- plus|premium|pricing|recommended(\s+for\s+you)?|
27
- see\s+all|share|sign\s+up|signup|subscribe|view\s+all|
28
- # German
29
- (ue|ü)ber(\s+uns)?|kontakt|kommentare?|mitmachen|anmelden|login|
30
- mitglied(schaft)?|empfohlen(\s+f(ue|ü)r\s+dich)?|alle\s+anzeigen|
31
- teilen|registrieren|abonnieren|newsletter|
32
- # Spanish
33
- sobre(\s+nosotros)?|contacto|comentarios?|unirse|iniciar\s+sesion|
34
- login|miembro|membres(i|í)a|recomendado(\s+para\s+ti)?|ver\s+todo|
35
- compartir|registrarse|suscribirse|bolet(i|í)n|
36
- # French
37
- (a|à)\s+propos|(a|à)propos|contact|commentaires?|rejoindre|
38
- se\s+connecter|login|membre|abonnement|recommand(e|é)(\s+pour\s+vous)?|
39
- voir\s+tout|partager|s['’]inscrire|s['’]abonner|newsletter
40
- )\b
41
- /ix
42
- # Labels for recommendation chrome rather than source articles.
43
- RECOMMENDED_PATTERN = /
44
- \A\s*(
45
- recommended(\s+for\s+you)?|
46
- empfohlen(\s+f(ue|ü)r\s+dich)?|
47
- recomendado(\s+para\s+ti)?|
48
- recommand(e|é)(\s+pour\s+vous)?
49
- )\b
50
- /ix
51
-
52
- # @param text [String, #to_s] visible anchor text
53
- # @return [Boolean] true when text matches a utility label
54
- def utility?(text) = text.to_s.match?(UTILITY_PATTERN)
55
-
56
- # @param text [String, #to_s] visible anchor text
57
- # @return [Boolean] true when text begins with a utility label
58
- def utility_prefix?(text) = text.to_s.match?(UTILITY_PREFIX_PATTERN)
59
-
60
- # @param text [String, #to_s] visible anchor text
61
- # @return [Boolean] true when text identifies recommendation chrome
62
- def recommended?(text) = text.to_s.match?(RECOMMENDED_PATTERN)
63
- end
64
- end
65
- end
66
- end
@@ -1,139 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- ##
6
- # Shared link eligibility and scoring policy for AutoSource scrapers.
7
- #
8
- # Scrapers collect DOM observations; this module owns junk/noise rules and
9
- # numeric weights so eligibility policy stays in one place.
10
- class LinkHeuristics
11
- # @param base_url [String, Html2rss::Url] page URL used to resolve relative hrefs
12
- def initialize(base_url)
13
- @base_url = base_url
14
- @text_classifier = TextClassifier.new
15
- @container_assessor = ContainerAssessor.new(text_classifier: @text_classifier)
16
- end
17
-
18
- # Builds normalized destination facts for an anchor element or href string.
19
- #
20
- # @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
21
- # @return [DestinationFacts, nil] normalized destination facts, or nil for blank/invalid URLs
22
- def destination_facts(anchor_or_href)
23
- return node_facts[anchor_or_href] if node_facts.key?(anchor_or_href)
24
-
25
- href = HrefExtractor.call(anchor_or_href)
26
- return unless href
27
-
28
- res = memoized_destination_facts(href)
29
-
30
- node_facts[anchor_or_href] = res if anchor_or_href.is_a?(Nokogiri::XML::Node)
31
- res
32
- rescue ArgumentError
33
- nil
34
- end
35
-
36
- # @param text [String, #to_s] visible anchor text
37
- # @return [Boolean] true when text matches a utility label
38
- def utility_text?(text) = @text_classifier.utility?(text)
39
-
40
- # @param text [String, #to_s] visible anchor text
41
- # @return [Boolean] true when text begins with a utility label
42
- def utility_prefix_text?(text) = @text_classifier.utility_prefix?(text)
43
-
44
- # @param text [String, #to_s] visible anchor text
45
- # @return [Boolean] true when text identifies recommendation chrome
46
- def recommended_text?(text) = @text_classifier.recommended?(text)
47
-
48
- ##
49
- # Whether an anchor is junk chrome rather than a content permalink.
50
- #
51
- # One eligibility home for Html and SemanticHtml: taxonomy/utility text
52
- # rules plus optional DOM checks (icon-only, utility landmarks).
53
- #
54
- # @param text [String, #to_s] visible anchor text
55
- # @param destination_facts [DestinationFacts, nil] route facts for the href
56
- # @param anchor [Nokogiri::XML::Node, nil] anchor node for icon/landmark checks
57
- # @param container [Nokogiri::XML::Node, nil] content container bounding landmark walks
58
- # @param heading_anchor [Boolean] whether the anchor is the container heading link
59
- # @return [Boolean] true when the anchor should be ignored
60
- def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil, heading_anchor: false) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
61
- return true unless destination_facts
62
-
63
- destination_facts.taxonomy_path ||
64
- short_utility_label?(text, destination_facts) ||
65
- recommended_chrome?(text, destination_facts, heading_anchor:) ||
66
- (utility_prefix_text?(text) && destination_facts.high_confidence_utility_destination) ||
67
- (utility_text?(text) && destination_facts.vanity_path) ||
68
- utility_text_chrome?(text, destination_facts, heading_anchor:) ||
69
- icon_only_anchor?(anchor, text) ||
70
- utility_landmark_ancestor?(anchor, container)
71
- end
72
-
73
- ##
74
- # Observes a container and builds ranking signals, including hard-junk.
75
- #
76
- # Delegates DOM observation to ContainerAssessor so SemanticHtml only
77
- # orchestrates candidates and extraction, while ContainerSignals keeps
78
- # scoring policy.
79
- #
80
- # @param container [Nokogiri::XML::Node] semantic container node
81
- # @param selected_anchor [Nokogiri::XML::Node, nil] primary anchor for the container
82
- # @param destination_facts [DestinationFacts, nil] route facts for the selected anchor
83
- # @return [ContainerSignals] observation + scoring signals for the container
84
- def assess_container(container, selected_anchor, destination_facts:)
85
- @container_assessor.call(container, selected_anchor, destination_facts:)
86
- end
87
-
88
- private
89
-
90
- def short_utility_label?(text, destination_facts)
91
- destination_facts.utility_path &&
92
- !destination_facts.content_path &&
93
- !destination_facts.strong_post_suffix &&
94
- text.to_s.scan(/\p{Alnum}+/).size <= 3
95
- end
96
-
97
- def recommended_chrome?(text, destination_facts, heading_anchor:)
98
- # Heading-linked titles may start with "Recommended …" while still being
99
- # real posts; container hard_junk? owns that call with publish markers.
100
- !heading_anchor && recommended_text?(text) && destination_facts.shallow
101
- end
102
-
103
- def utility_text_chrome?(text, destination_facts, heading_anchor:)
104
- return false if destination_facts.content_path
105
- return false unless utility_text?(text)
106
-
107
- !heading_anchor && !destination_facts.strong_post_suffix
108
- end
109
-
110
- def icon_only_anchor?(anchor, text)
111
- return false unless anchor
112
-
113
- !text.to_s.match?(/\p{Alnum}/) && !anchor.at_css('img, svg').nil?
114
- end
115
-
116
- def utility_landmark_ancestor?(anchor, container)
117
- return false unless anchor && container
118
-
119
- condition = lambda { |node|
120
- node == container || Html2rss::Html::Navigator::UTILITY_LANDMARK_TAGS.include?(node.name)
121
- }
122
- landmark = Html2rss::Html::Navigator.parent_until_condition(anchor.parent, condition)
123
-
124
- !landmark.nil? && landmark != container
125
- end
126
-
127
- def node_facts
128
- @node_facts ||= {}.compare_by_identity
129
- end
130
-
131
- def memoized_destination_facts(href)
132
- (@destination_facts ||= {})[href] ||= begin
133
- url = Html2rss::Url.from_relative(href, @base_url)
134
- DestinationFacts.build(url)
135
- end
136
- end
137
- end
138
- end
139
- end
@@ -1,132 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'puppeteer'
4
-
5
- module Html2rss
6
- class RequestService
7
- ##
8
- # Browserless.io strategy to request websites.
9
- #
10
- # Provide the WebSocket URL and your API token via environment variables:
11
- # - BROWSERLESS_IO_WEBSOCKET_URL
12
- # - BROWSERLESS_IO_API_TOKEN
13
- #
14
- # To use this strategy, you need to have a Browserless.io account or run a
15
- # local Browserless.io instance.
16
- #
17
- # @see https://www.browserless.io/
18
- #
19
- # To run a local Browserless.io instance, you can use the following Docker command:
20
- #
21
- # ```sh
22
- # docker run \
23
- # --rm \
24
- # -p 3000:3000 \
25
- # -e "CONCURRENT=10" \
26
- # -e "TOKEN=6R0W53R135510" \
27
- # ghcr.io/browserless/chromium
28
- # ```
29
- #
30
- # When running locally, you can skip setting the environment variables, as above commands
31
- # are aligned with the default values.
32
- # @see https://github.com/browserless/browserless/pkgs/container/chromium
33
- class BrowserlessStrategy < Strategy
34
- ##
35
- # @return [String] the Browserless websocket endpoint with token query param
36
- # @raise [ArgumentError] if a custom endpoint is configured without an API token
37
- def browser_ws_endpoint
38
- @browser_ws_endpoint ||= begin
39
- ws_url = ENV.fetch('BROWSERLESS_IO_WEBSOCKET_URL', 'ws://127.0.0.1:3000')
40
- api_token = browserless_api_token(ws_url)
41
-
42
- "#{ws_url}?token=#{api_token}"
43
- end
44
- end
45
-
46
- private
47
-
48
- def fetch
49
- connect_with_timeout_support do |browser|
50
- PuppetCommander.new(ctx, browser).call
51
- ensure
52
- browser.disconnect
53
- end
54
- end
55
-
56
- def protocol_timeout_ms
57
- ctx.budget.effective_timeout_ms(fallback: ctx.policy.total_timeout_seconds)
58
- end
59
-
60
- def connect_with_timeout_support(&)
61
- connect_browserless(protocol_timeout: protocol_timeout_ms, &)
62
- rescue ArgumentError => error
63
- raise unless unsupported_protocol_timeout?(error)
64
-
65
- connect_browserless(&)
66
- end
67
-
68
- def unsupported_protocol_timeout?(error)
69
- error.message.include?('unknown keyword: :protocol_timeout')
70
- end
71
-
72
- def connect_browserless(protocol_timeout: nil, &)
73
- connected = false
74
-
75
- Puppeteer.connect(**browserless_connect_options(protocol_timeout)) do |browser|
76
- connected = true
77
- yield browser
78
- end
79
- rescue ArgumentError => error
80
- handle_connection_error(error, connected:, protocol_timeout:)
81
- rescue StandardError => error
82
- handle_connection_error(error, connected:)
83
- end
84
-
85
- def browserless_connect_options(protocol_timeout)
86
- { browser_ws_endpoint:, protocol_timeout: }.compact
87
- end
88
-
89
- def handle_connection_error(error, connected:, protocol_timeout: nil)
90
- raise if connected || compatibility_timeout_error?(error, protocol_timeout:)
91
-
92
- raise BrowserlessConnectionFailed, browserless_connection_message(error), cause: error
93
- end
94
-
95
- def compatibility_timeout_error?(error, protocol_timeout:)
96
- protocol_timeout && unsupported_protocol_timeout?(error)
97
- end
98
-
99
- def browserless_connection_message(error)
100
- base = "Browserless connection failed (#{error.class}: #{error.message})."
101
- endpoint_hint = "Check BROWSERLESS_IO_WEBSOCKET_URL (currently #{browserless_websocket_url})."
102
- token_hint = 'Check BROWSERLESS_IO_API_TOKEN and ensure it matches your Browserless TOKEN.'
103
- local_hint = 'For local Browserless, confirm the service is running and reachable.'
104
-
105
- if likely_authentication_error?(error)
106
- "#{base} #{token_hint} #{endpoint_hint}"
107
- else
108
- "#{base} #{endpoint_hint} #{token_hint} #{local_hint}"
109
- end
110
- end
111
-
112
- def likely_authentication_error?(error)
113
- message = error.message.downcase
114
- message.include?('unauthorized') || message.include?('forbidden') || message.include?('401')
115
- end
116
-
117
- def browserless_websocket_url
118
- ENV.fetch('BROWSERLESS_IO_WEBSOCKET_URL', 'ws://127.0.0.1:3000')
119
- end
120
-
121
- def browserless_api_token(ws_url)
122
- ENV.fetch('BROWSERLESS_IO_API_TOKEN') do
123
- return '6R0W53R135510' if ws_url == 'ws://127.0.0.1:3000'
124
-
125
- raise BrowserlessConfigurationError,
126
- 'BROWSERLESS_IO_API_TOKEN is required for custom Browserless endpoints. ' \
127
- 'Set BROWSERLESS_IO_API_TOKEN or use ws://127.0.0.1:3000 for local defaults.'
128
- end
129
- end
130
- end
131
- end
132
- end