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,148 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RequestService
5
- class PuppetCommander
6
- ##
7
- # Owns Puppeteer request interception, deferred navigation errors, main-frame
8
- # filtering, and redirect-chain validation via {Policy}.
9
- #
10
- # Does not re-own Policy rules — only calls +validate_*!+ on +ctx.policy+.
11
- class NavigationGuards
12
- class << self
13
- ##
14
- # Resolves the final response URL for Response building and IP validation.
15
- #
16
- # @param navigation_response [Puppeteer::HTTPResponse, nil]
17
- # @param fallback_url [String, Html2rss::Url]
18
- # @return [Html2rss::Url]
19
- def response_url(navigation_response, fallback_url)
20
- raw_url = navigation_response&.url || fallback_url.to_s
21
- Html2rss::Url.from_absolute(raw_url)
22
- end
23
- end
24
-
25
- ##
26
- # @param ctx [Context] request context providing policy and origin
27
- # @param skip_request_resources [Set<String>] resource types to abort
28
- def initialize(ctx:, skip_request_resources:)
29
- @ctx = ctx
30
- @skip_request_resources = skip_request_resources
31
- @navigation_error = nil
32
- @latest_navigation_response = nil
33
- @main_frame = nil
34
- end
35
-
36
- ##
37
- # Captures the main frame and wires request/response interceptors.
38
- #
39
- # @param page [Puppeteer::Page] browser page
40
- # @return [void]
41
- def install!(page)
42
- @main_frame = page.main_frame if page.respond_to?(:main_frame)
43
- page.request_interception = true
44
- page.on('request') { |request| handle_request(request) }
45
- page.on('response') { |response| handle_response(response) }
46
- end
47
-
48
- ##
49
- # Clears deferred error and latest navigation response before goto.
50
- #
51
- # @return [void]
52
- def begin_navigation!
53
- @navigation_error = nil
54
- @latest_navigation_response = nil
55
- end
56
-
57
- ##
58
- # Re-raises a deferred navigation error when one was captured.
59
- #
60
- # @return [void]
61
- # @raise [Html2rss::Error] when a navigation request or response validation failed
62
- def raise_deferred_error!
63
- raise @navigation_error if @navigation_error
64
- end
65
-
66
- ##
67
- # @return [Puppeteer::HTTPResponse, nil] latest main-frame navigation response
68
- attr_reader :latest_navigation_response
69
-
70
- ##
71
- # Validates the remote IP of a navigation response via Policy.
72
- #
73
- # @param navigation_response [Puppeteer::HTTPResponse, nil]
74
- # @return [void]
75
- def validate_final!(navigation_response)
76
- final_url = self.class.response_url(navigation_response, ctx.url)
77
- ctx.policy.validate_remote_ip!(ip: remote_ip(navigation_response), url: final_url)
78
- end
79
-
80
- private
81
-
82
- attr_reader :ctx, :skip_request_resources, :main_frame
83
-
84
- def handle_request(request)
85
- validate_request!(request)
86
-
87
- skip_request_resources.member?(request.resource_type) ? request.abort : request.continue
88
- rescue Html2rss::Error => error
89
- store_navigation_error(error, navigation_request: request.navigation_request?)
90
- request.abort
91
- end
92
-
93
- def handle_response(response)
94
- @latest_navigation_response = response if main_frame_navigation_response?(response)
95
- validate_final!(response)
96
- rescue Html2rss::Error => error
97
- store_navigation_error(error, navigation_request: response.request.navigation_request?)
98
- end
99
-
100
- def validate_request!(request)
101
- validate_navigation_redirect_chain!(request)
102
- validate_navigation_target!(request)
103
- end
104
-
105
- def main_frame_navigation_response?(response)
106
- request = response.request
107
- return false unless request.navigation_request?
108
- return true unless request.respond_to?(:frame)
109
-
110
- frame = request.frame
111
- return true if frame.nil?
112
- return frame == main_frame unless main_frame.nil?
113
- return true unless frame.respond_to?(:parent_frame)
114
-
115
- frame.parent_frame.nil?
116
- end
117
-
118
- def remote_ip(navigation_response)
119
- navigation_response.remote_address&.ip
120
- end
121
-
122
- def request_chain(request)
123
- (request.redirect_chain + [request]).map { |entry| request_url(entry) }
124
- end
125
-
126
- def request_url(request)
127
- Html2rss::Url.from_absolute(request.url)
128
- end
129
-
130
- def validate_navigation_redirect_chain!(request)
131
- request_chain(request).each_cons(2) do |from_url, to_url|
132
- ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
133
- end
134
- end
135
-
136
- def validate_navigation_target!(request)
137
- ctx.policy.validate_request!(url: request_url(request), origin_url: ctx.origin_url, relation: ctx.relation)
138
- end
139
-
140
- def store_navigation_error(error, navigation_request:)
141
- return unless navigation_request
142
-
143
- @navigation_error = error if @navigation_error.nil?
144
- end
145
- end
146
- end
147
- end
148
- end
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RequestService
5
- class PuppetCommander
6
- ##
7
- # Runs Browserless preload choreography: wait, click, and scroll against
8
- # the interaction budget on {Context#budget}.
9
- class PreloadRunner
10
- ##
11
- # @param ctx [Context] request context providing preload config and budget
12
- def initialize(ctx:)
13
- @ctx = ctx
14
- end
15
-
16
- ##
17
- # Executes configured preload actions on the page, if any.
18
- #
19
- # @param page [Puppeteer::Page] browser page after initial navigation
20
- # @return [void]
21
- def call(page)
22
- preload_config = ctx.browserless_preload
23
- return unless preload_config
24
-
25
- wait_after(page, preload_config[:wait_after_ms])
26
- click_selectors(page, preload_config[:click_selectors]) if preload_config[:click_selectors]
27
- scroll_down(page, preload_config[:scroll_down]) if preload_config[:scroll_down]
28
- wait_after(page, preload_config[:wait_after_ms])
29
- end
30
-
31
- private
32
-
33
- attr_reader :ctx
34
-
35
- def wait_after(page, timeout_ms)
36
- return unless timeout_ms
37
-
38
- ctx.budget.consume_interaction!
39
- page.wait_for_timeout(timeout_ms)
40
- end
41
-
42
- def click_selectors(page, selectors)
43
- selectors.each { |selector_config| click_selector(page, selector_config) }
44
- end
45
-
46
- def scroll_down(page, config)
47
- iterations = config.fetch(:iterations, 1)
48
- wait_after_ms = config[:wait_after_ms]
49
- previous_height = nil
50
-
51
- iterations.times do
52
- updated_height = perform_scroll_iteration(page, wait_after_ms, previous_height)
53
- break unless updated_height
54
-
55
- previous_height = updated_height
56
- end
57
- end
58
-
59
- def click_selector(page, config)
60
- selector = config.fetch(:selector)
61
- max_clicks = config.fetch(:max_clicks, 1)
62
- wait_after_ms = config[:wait_after_ms]
63
-
64
- max_clicks.times do
65
- break unless (element = page.query_selector(selector))
66
-
67
- ctx.budget.consume_interaction!
68
- element.click
69
- wait_after(page, wait_after_ms)
70
- end
71
- end
72
-
73
- def perform_scroll_iteration(page, wait_after_ms, previous_height)
74
- ctx.budget.consume_interaction!
75
- page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)')
76
- wait_after(page, wait_after_ms)
77
-
78
- current_height = page.evaluate('() => document.body.scrollHeight')
79
- return if previous_height && current_height <= previous_height
80
-
81
- current_height
82
- end
83
- end
84
- end
85
- end
86
- end
@@ -1,95 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RequestService
5
- ##
6
- # Commands the Puppeteer Browser to the website and builds the Response.
7
- #
8
- # Public interface is {#call}; page setup, navigation, and body collection
9
- # are private orchestration steps.
10
- class PuppetCommander
11
- # Request header names that must not be forwarded to the browser session.
12
- BROWSER_UNSAFE_HEADERS = %w[
13
- host connection content-length transfer-encoding
14
- sec-fetch-dest sec-fetch-mode sec-fetch-site sec-fetch-user
15
- upgrade-insecure-requests
16
- ].to_set.freeze
17
-
18
- # @param ctx [Context]
19
- # @param browser [Puppeteer::Browser]
20
- # @param skip_request_resources [Set<String>] the resource types not to request
21
- # @param referer [String] the referer to use for the request
22
- def initialize(ctx,
23
- browser,
24
- skip_request_resources: %w[stylesheet image media font].to_set,
25
- referer: [ctx.url.scheme, ctx.url.host].join('://'))
26
- @ctx = ctx
27
- @browser = browser
28
- @referer = referer
29
- @navigation_guards = NavigationGuards.new(ctx:, skip_request_resources:)
30
- @preload_runner = PreloadRunner.new(ctx:)
31
- end
32
-
33
- ##
34
- # Visits the request URL and normalizes the page into a response object.
35
- #
36
- # @return [Response] rendered page response
37
- def call
38
- page = new_page
39
- navigation_response = navigate_to_destination(page, ctx.url)
40
- preload_runner.call(page)
41
- navigation_guards.raise_deferred_error!
42
- final_navigation_response = navigation_guards.latest_navigation_response || navigation_response
43
- navigation_guards.validate_final!(final_navigation_response)
44
- build_response(page, final_navigation_response)
45
- ensure
46
- page&.close
47
- end
48
-
49
- private
50
-
51
- attr_reader :ctx, :browser, :referer, :navigation_guards, :preload_runner
52
-
53
- def new_page
54
- page = browser.new_page
55
- configure_page(page)
56
- navigation_guards.install!(page)
57
- page
58
- end
59
-
60
- def configure_page(page)
61
- page.extra_http_headers = browser_headers
62
- page.default_navigation_timeout = navigation_timeout_ms
63
- page.default_timeout = navigation_timeout_ms
64
- end
65
-
66
- def navigate_to_destination(page, url)
67
- navigation_guards.begin_navigation!
68
- page.goto(url, wait_until: 'networkidle0', referer:, timeout: navigation_timeout_ms).tap do
69
- navigation_guards.raise_deferred_error!
70
- end
71
- rescue StandardError
72
- navigation_guards.raise_deferred_error!
73
-
74
- raise
75
- end
76
-
77
- def navigation_timeout_ms
78
- ctx.budget.effective_timeout_ms(fallback: ctx.policy.total_timeout_seconds)
79
- end
80
-
81
- def browser_headers
82
- ctx.headers.reject { |key, _| BROWSER_UNSAFE_HEADERS.include?(key.to_s.downcase) }
83
- end
84
-
85
- def build_response(page, navigation_response)
86
- Response.new(
87
- body: page.content,
88
- headers: navigation_response&.headers || {},
89
- url: NavigationGuards.response_url(navigation_response, ctx.url),
90
- status: navigation_response&.status
91
- )
92
- end
93
- end
94
- end
95
- end