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
@@ -0,0 +1,208 @@
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
+ dating jobs job career careers deals deal shopping shop trading broker
52
+ versicherung tierversicherung insurance vergleich comparison
53
+ partnerboerse singleboerse krypto crypto
54
+ ] + SOFT_UTILITY.to_a
55
+ ).to_set.freeze
56
+ {
57
+ content:,
58
+ utility:,
59
+ high_confidence_junk: (utility - SOFT_UTILITY).freeze,
60
+ taxonomy:,
61
+ vanity:,
62
+ deep_post_context: %w[press newsroom presse pressemitteilungen prensa].to_set.freeze
63
+ }.freeze
64
+ end
65
+ # Path segment that begins with a year-like publishing marker.
66
+ YEARISH_SEGMENT = /\A\d{4,}[\w-]*\z/
67
+ # Hyphenated slug shape common to article permalinks.
68
+ POST_SLUG_SEGMENT = /\A[a-z0-9]+(?:-[a-z0-9]+){2,}\z/i
69
+
70
+ # @param segments [Array<String>] normalized URL path segments
71
+ def initialize(segments)
72
+ @segments = segments
73
+ end
74
+
75
+ # @return [Boolean] true when the route has article-like path evidence
76
+ def content_path?
77
+ @content_path ||= SEGMENT_SETS[:content].intersect?(segments) ||
78
+ yearish_content_context?
79
+ end
80
+
81
+ # @return [Boolean] true when the route includes utility/navigation evidence
82
+ def utility_path?
83
+ @utility_path ||= SEGMENT_SETS[:utility].intersect?(segments)
84
+ end
85
+
86
+ # @return [Boolean] true when the route points at conversion or account chrome
87
+ def vanity_path?
88
+ @vanity_path ||= SEGMENT_SETS[:vanity].intersect?(segments)
89
+ end
90
+
91
+ # @return [Boolean] true when the route points at taxonomy/listing chrome
92
+ def taxonomy_path?
93
+ @taxonomy_path ||= SEGMENT_SETS[:taxonomy].intersect?(segments)
94
+ end
95
+
96
+ # @return [Boolean] true when the route is too shallow to strongly indicate an article
97
+ def shallow?
98
+ segment_count = segments.size
99
+ junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
100
+
101
+ segment_count <= 1 || (segment_count == 2 && junk_segments.include?(segments.last))
102
+ end
103
+
104
+ # @return [Boolean] true when the final path segment looks like a post slug
105
+ def strong_post_suffix?
106
+ @strong_post_suffix ||= segments.any? &&
107
+ included_last_segment? &&
108
+ trusted_post_context?(segments.size - 1)
109
+ end
110
+
111
+ # @return [Boolean] true when every path segment is utility chrome
112
+ def utility_only_route?
113
+ junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
114
+
115
+ segments.all? { |segment| junk_segments.include?(segment) }
116
+ end
117
+
118
+ # @return [Boolean] true when the route is shallow and contains high-confidence noise
119
+ def shallow_high_confidence_route?
120
+ junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
121
+ vanity_segments = SEGMENT_SETS.fetch(:vanity)
122
+
123
+ shallow? && segments.any? do |segment|
124
+ junk_segments.include?(segment) || vanity_segments.include?(segment)
125
+ end
126
+ end
127
+
128
+ # @return [Boolean] true when the leading segments are all utility chrome
129
+ def deep_utility_context_route?
130
+ all_junk?(segments.size - 1)
131
+ end
132
+
133
+ # @return [Boolean] true when the route is shallow and contains high-confidence noise
134
+ def junk_path?
135
+ return false if excluded_content_route?
136
+
137
+ taxonomy_path? ||
138
+ utility_only_route? ||
139
+ deep_utility_context_route? ||
140
+ shallow_high_confidence_route?
141
+ end
142
+
143
+ # @return [Boolean] true when the route points at conversion or account chrome
144
+ def utility_destination?
145
+ return false if excluded_content_route?
146
+
147
+ vanity_path? || utility_route?
148
+ end
149
+
150
+ private
151
+
152
+ def yearish_content_context?
153
+ segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
154
+ (strong_post_suffix? || trusted_post_context?(segments.size - 1))
155
+ end
156
+
157
+ def excluded_content_route?
158
+ segments.empty? || content_path? || strong_post_suffix?
159
+ end
160
+
161
+ def utility_route?
162
+ taxonomy_path? ||
163
+ utility_only_route? ||
164
+ deep_utility_context_route? ||
165
+ shallow_utility_route?
166
+ end
167
+
168
+ def shallow_utility_route?
169
+ shallow? && utility_path?
170
+ end
171
+
172
+ def all_junk?(limit)
173
+ return false if limit <= 0
174
+
175
+ junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
176
+ (0...limit).all? { |i| junk_segments.include?(segments[i]) }
177
+ end
178
+
179
+ def trusted_post_context?(limit)
180
+ return false if limit <= 0
181
+
182
+ content_segments = SEGMENT_SETS.fetch(:content)
183
+ context_segments = SEGMENT_SETS.fetch(:deep_post_context)
184
+
185
+ (0...limit).any? do |i|
186
+ segment = segments[i]
187
+ content_segments.include?(segment) ||
188
+ segment.match?(PathClassifier::YEARISH_SEGMENT) ||
189
+ context_segments.include?(segment)
190
+ end
191
+ end
192
+
193
+ def included_last_segment?
194
+ !excluded_last_segment? && slug_last_segment?
195
+ end
196
+
197
+ def excluded_last_segment?
198
+ last = segments.last
199
+ [SEGMENT_SETS[:high_confidence_junk], SEGMENT_SETS[:vanity]].any? { |set| set.include?(last) }
200
+ end
201
+
202
+ def slug_last_segment?
203
+ last = segments.last
204
+ last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
205
+ end
206
+ end
207
+ end
208
+ 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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Destination URL / anchor-text classification shared by Scoring.
6
+ module LinkDestination
7
+ end
8
+ end