fetch_util 0.5.2 → 0.6.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +3 -1
  4. data/Rakefile +0 -2
  5. data/lib/fetch_util/assets/extract.js +1 -1
  6. data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +12 -17
  7. data/lib/fetch_util/browser/interaction_helpers/timing_helpers.rb +42 -15
  8. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_commit_details_script.rb +48 -0
  9. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_product_state_script.rb +80 -0
  10. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_helpers_script.rb +53 -0
  11. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_state_script.rb +118 -0
  12. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_stabilization.rb +54 -0
  13. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_state.rb +27 -0
  14. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_product_state.rb +55 -0
  15. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_request_state.rb +100 -0
  16. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_state.rb +59 -0
  17. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_modules.rb +26 -0
  18. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_state.rb +93 -0
  19. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resources.rb +73 -0
  20. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_thread_state.rb +92 -0
  21. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_threads.rb +71 -0
  22. data/lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb +9 -10
  23. data/lib/fetch_util/browser/site_stabilization/facebook_stabilization.rb +56 -0
  24. data/lib/fetch_util/browser/site_stabilization/gerrit_change_product_state_script.rb +97 -0
  25. data/lib/fetch_util/browser/site_stabilization/gerrit_change_request_state_script.rb +75 -0
  26. data/lib/fetch_util/browser/site_stabilization/gerrit_change_stabilization.rb +37 -0
  27. data/lib/fetch_util/browser/site_stabilization/gerrit_change_state.rb +39 -0
  28. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_fetch_state_script.rb +31 -0
  29. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_product_state_script.rb +76 -0
  30. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_request_state_script.rb +111 -0
  31. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_route_state_script.rb +71 -0
  32. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_stabilization.rb +37 -0
  33. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_state.rb +42 -0
  34. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_validation_state_script.rb +49 -0
  35. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_product_state.rb +88 -0
  36. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_state.rb +109 -0
  37. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resources.rb +30 -0
  38. data/lib/fetch_util/browser/site_stabilization/gitea_family_stabilization.rb +60 -0
  39. data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_product_state.rb +74 -0
  40. data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_timeline_state.rb +69 -0
  41. data/lib/fetch_util/browser/site_stabilization/gitea_family_threads.rb +28 -0
  42. data/lib/fetch_util/browser/site_stabilization/gitea_family_visibility_script.rb +28 -0
  43. data/lib/fetch_util/browser/site_stabilization/github_pull_resource_stabilization.rb +31 -0
  44. data/lib/fetch_util/browser/site_stabilization/github_pull_resources.rb +105 -0
  45. data/lib/fetch_util/browser/site_stabilization/github_threads.rb +82 -0
  46. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state.rb +17 -0
  47. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state_script.rb +98 -0
  48. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_visibility_script.rb +43 -0
  49. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resources.rb +28 -0
  50. data/lib/fetch_util/browser/site_stabilization/gitlab_repo.rb +3 -11
  51. data/lib/fetch_util/browser/site_stabilization/gitlab_stabilization.rb +33 -0
  52. data/lib/fetch_util/browser/site_stabilization/gitlab_threads.rb +92 -0
  53. data/lib/fetch_util/browser/site_stabilization/social_platforms.rb +19 -38
  54. data/lib/fetch_util/browser/site_stabilization/travel_and_lodging.rb +3 -3
  55. data/lib/fetch_util/browser/site_stabilization.rb +89 -0
  56. data/lib/fetch_util/browser/stabilization/anubis.rb +4 -5
  57. data/lib/fetch_util/browser/stabilization/page_flow.rb +71 -27
  58. data/lib/fetch_util/browser/stabilization/spa_hydration.rb +7 -5
  59. data/lib/fetch_util/browser.rb +88 -17
  60. data/lib/fetch_util/cli.rb +69 -23
  61. data/lib/fetch_util/extractor.rb +8 -2
  62. data/lib/fetch_util/fetcher.rb +144 -43
  63. data/lib/fetch_util/http_redirect_client.rb +161 -0
  64. data/lib/fetch_util/parallel_fetcher.rb +125 -28
  65. data/lib/fetch_util/raw_docs_fallback.rb +158 -21
  66. data/lib/fetch_util/regulatory/cache_store.rb +47 -14
  67. data/lib/fetch_util/regulatory/fetch_records.rb +10 -7
  68. data/lib/fetch_util/regulatory/headers.rb +8 -1
  69. data/lib/fetch_util/regulatory/http_client.rb +2 -97
  70. data/lib/fetch_util/regulatory/orchestration.rb +10 -6
  71. data/lib/fetch_util/regulatory/page.rb +8 -11
  72. data/lib/fetch_util/regulatory/robots.rb +2 -10
  73. data/lib/fetch_util/regulatory/source_selection.rb +13 -3
  74. data/lib/fetch_util/regulatory/tdm_page.rb +9 -4
  75. data/lib/fetch_util/regulatory/tdm_policy.rb +16 -9
  76. data/lib/fetch_util/regulatory/tdm_rep.rb +4 -3
  77. data/lib/fetch_util/regulatory/tdm_support.rb +10 -6
  78. data/lib/fetch_util/regulatory.rb +5 -1
  79. data/lib/fetch_util/request_log.rb +24 -3
  80. data/lib/fetch_util/result.rb +37 -89
  81. data/lib/fetch_util/search_transport.rb +102 -35
  82. data/lib/fetch_util/searcher.rb +15 -13
  83. data/lib/fetch_util/version.rb +1 -1
  84. data/lib/fetch_util.rb +4 -1
  85. metadata +66 -2
@@ -3,13 +3,102 @@
3
3
  module FetchUtil
4
4
  class Browser
5
5
  module SiteStabilization
6
+ autoload :AzureDevopsPrProductStateScript,
7
+ "fetch_util/browser/site_stabilization/azure_devops_pr_product_state_script"
8
+ autoload :AzureDevopsPrCommitDetailsScript,
9
+ "fetch_util/browser/site_stabilization/azure_devops_pr_commit_details_script"
10
+ autoload :AzureDevopsPrRequestHelpersScript,
11
+ "fetch_util/browser/site_stabilization/azure_devops_pr_request_helpers_script"
12
+ autoload :AzureDevopsPrRequestStateScript,
13
+ "fetch_util/browser/site_stabilization/azure_devops_pr_request_state_script"
14
+ autoload :AzureDevopsPrState, "fetch_util/browser/site_stabilization/azure_devops_pr_state"
15
+ autoload :AzureDevopsPrStabilization,
16
+ "fetch_util/browser/site_stabilization/azure_devops_pr_stabilization"
17
+ autoload :BitbucketCloudThreadState,
18
+ "fetch_util/browser/site_stabilization/bitbucket_cloud_thread_state"
19
+ autoload :BitbucketCloudThreads, "fetch_util/browser/site_stabilization/bitbucket_cloud_threads"
20
+ autoload :BitbucketCloudPullResourceModules,
21
+ "fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_modules"
6
22
  autoload :CommunityAndMarketplace, "fetch_util/browser/site_stabilization/community_and_marketplace"
23
+ autoload :FacebookStabilization, "fetch_util/browser/site_stabilization/facebook_stabilization"
24
+ autoload :GerritChangeProductStateScript,
25
+ "fetch_util/browser/site_stabilization/gerrit_change_product_state_script"
26
+ autoload :GerritChangeRequestStateScript,
27
+ "fetch_util/browser/site_stabilization/gerrit_change_request_state_script"
28
+ autoload :GerritChangeState, "fetch_util/browser/site_stabilization/gerrit_change_state"
29
+ autoload :GerritChangeStabilization,
30
+ "fetch_util/browser/site_stabilization/gerrit_change_stabilization"
31
+ autoload :GerritFileResourceProductStateScript,
32
+ "fetch_util/browser/site_stabilization/gerrit_file_resource_product_state_script"
33
+ autoload :GerritFileResourceRouteStateScript,
34
+ "fetch_util/browser/site_stabilization/gerrit_file_resource_route_state_script"
35
+ autoload :GerritFileResourceFetchStateScript,
36
+ "fetch_util/browser/site_stabilization/gerrit_file_resource_fetch_state_script"
37
+ autoload :GerritFileResourceValidationStateScript,
38
+ "fetch_util/browser/site_stabilization/gerrit_file_resource_validation_state_script"
39
+ autoload :GerritFileResourceRequestStateScript,
40
+ "fetch_util/browser/site_stabilization/gerrit_file_resource_request_state_script"
41
+ autoload :GerritFileResourceState,
42
+ "fetch_util/browser/site_stabilization/gerrit_file_resource_state"
43
+ autoload :GerritFileResourceStabilization,
44
+ "fetch_util/browser/site_stabilization/gerrit_file_resource_stabilization"
45
+ autoload :GithubPullResources, "fetch_util/browser/site_stabilization/github_pull_resources"
46
+ autoload :GithubPullResourceStabilization,
47
+ "fetch_util/browser/site_stabilization/github_pull_resource_stabilization"
48
+ autoload :GithubThreads, "fetch_util/browser/site_stabilization/github_threads"
49
+ autoload :GiteaFamilyThreadProductState,
50
+ "fetch_util/browser/site_stabilization/gitea_family_thread_product_state"
51
+ autoload :GiteaFamilyThreadTimelineState,
52
+ "fetch_util/browser/site_stabilization/gitea_family_thread_timeline_state"
53
+ autoload :GiteaFamilyStabilization,
54
+ "fetch_util/browser/site_stabilization/gitea_family_stabilization"
55
+ autoload :GiteaFamilyVisibilityScript,
56
+ "fetch_util/browser/site_stabilization/gitea_family_visibility_script"
57
+ autoload :GiteaFamilyThreads, "fetch_util/browser/site_stabilization/gitea_family_threads"
58
+ autoload :GiteaFamilyPullResourceProductState,
59
+ "fetch_util/browser/site_stabilization/gitea_family_pull_resource_product_state"
60
+ autoload :GiteaFamilyPullResourceState,
61
+ "fetch_util/browser/site_stabilization/gitea_family_pull_resource_state"
62
+ autoload :GiteaFamilyPullResources, "fetch_util/browser/site_stabilization/gitea_family_pull_resources"
7
63
  autoload :GitlabRepo, "fetch_util/browser/site_stabilization/gitlab_repo"
64
+ autoload :GitlabMergeRequestResourceVisibilityScript,
65
+ "fetch_util/browser/site_stabilization/gitlab_merge_request_resource_visibility_script"
66
+ autoload :GitlabMergeRequestResourceStateScript,
67
+ "fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state_script"
68
+ autoload :GitlabMergeRequestResourceState,
69
+ "fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state"
70
+ autoload :GitlabMergeRequestResources, "fetch_util/browser/site_stabilization/gitlab_merge_request_resources"
71
+ autoload :GitlabStabilization, "fetch_util/browser/site_stabilization/gitlab_stabilization"
72
+ autoload :GitlabThreads, "fetch_util/browser/site_stabilization/gitlab_threads"
8
73
  autoload :SocialPlatforms, "fetch_util/browser/site_stabilization/social_platforms"
9
74
  autoload :TravelAndLodging, "fetch_util/browser/site_stabilization/travel_and_lodging"
10
75
 
76
+ include AzureDevopsPrState
77
+ include AzureDevopsPrStabilization
78
+ include BitbucketCloudThreadState
79
+ include BitbucketCloudThreads
80
+ include BitbucketCloudPullResourceModules
11
81
  include CommunityAndMarketplace
82
+ include FacebookStabilization
83
+ include GerritChangeState
84
+ include GerritChangeStabilization
85
+ include GerritFileResourceState
86
+ include GerritFileResourceStabilization
87
+ include GithubPullResources
88
+ include GithubPullResourceStabilization
89
+ include GithubThreads
90
+ include GiteaFamilyVisibilityScript
91
+ include GiteaFamilyThreadProductState
92
+ include GiteaFamilyThreadTimelineState
93
+ include GiteaFamilyStabilization
94
+ include GiteaFamilyThreads
95
+ include GiteaFamilyPullResourceProductState
96
+ include GiteaFamilyPullResourceState
97
+ include GiteaFamilyPullResources
12
98
  include GitlabRepo
99
+ include GitlabStabilization
100
+ include GitlabMergeRequestResources
101
+ include GitlabThreads
13
102
  include SocialPlatforms
14
103
  include TravelAndLodging
15
104
  end
@@ -8,18 +8,17 @@ module FetchUtil
8
8
 
9
9
  private
10
10
 
11
- def wait_for_anubis_challenge(page)
12
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
11
+ def wait_for_anubis_challenge(page, deadline: stabilization_deadline)
13
12
  state = anubis_page_state(page)
14
- if !valid_anubis_state?(state) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
13
+ if !valid_anubis_state?(state) && monotonic_now < deadline
15
14
  state = anubis_page_state(page)
16
15
  end
17
16
  return false unless valid_anubis_state?(state) && state["challenge"] == true
18
17
 
19
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ remaining = capped_timeout(@timeout, deadline: deadline)
20
19
  return false unless remaining.positive?
21
20
 
22
- retry_until_timeout(remaining, interval: ANUBIS_POLL) do
21
+ retry_until_timeout(remaining, interval: ANUBIS_POLL, deadline: deadline) do
23
22
  state = anubis_page_state(page)
24
23
  next false unless valid_anubis_state?(state)
25
24
  next false unless state["challenge"] == false
@@ -10,6 +10,17 @@ module FetchUtil
10
10
  SiteStabilization::SocialPlatforms::SOCIAL_PLATFORM_STABILIZATION_PROFILES[:stabilize_instagram],
11
11
  SiteStabilization::SocialPlatforms::SOCIAL_PLATFORM_STABILIZATION_PROFILES[:stabilize_facebook],
12
12
  SiteStabilization::CommunityAndMarketplace::COMMUNITY_MARKETPLACE_STABILIZATION_PROFILES[:stabilize_ebay_search],
13
+ SiteStabilization::GithubThreads::GITHUB_THREAD_STABILIZATION_PROFILE,
14
+ SiteStabilization::GithubPullResources::GITHUB_PULL_RESOURCE_STABILIZATION_PROFILE,
15
+ SiteStabilization::GitlabThreads::GITLAB_THREAD_STABILIZATION_PROFILE,
16
+ SiteStabilization::GitlabMergeRequestResources::GITLAB_MERGE_REQUEST_RESOURCE_STABILIZATION_PROFILE,
17
+ SiteStabilization::GiteaFamilyPullResources::GITEA_FAMILY_PULL_RESOURCE_STABILIZATION_PROFILE,
18
+ SiteStabilization::GiteaFamilyThreads::GITEA_FAMILY_THREAD_STABILIZATION_PROFILE,
19
+ SiteStabilization::BitbucketCloudPullResources::BITBUCKET_CLOUD_PULL_RESOURCE_STABILIZATION_PROFILE,
20
+ SiteStabilization::BitbucketCloudThreads::BITBUCKET_CLOUD_THREAD_STABILIZATION_PROFILE,
21
+ SiteStabilization::AzureDevopsPrStabilization::AZURE_DEVOPS_PR_STABILIZATION_PROFILE,
22
+ SiteStabilization::GerritFileResourceStabilization::GERRIT_FILE_RESOURCE_STABILIZATION_PROFILE,
23
+ SiteStabilization::GerritChangeStabilization::GERRIT_CHANGE_STABILIZATION_PROFILE,
13
24
  SiteStabilization::TravelAndLodging::TRAVEL_LODGING_STABILIZATION_PROFILES[:stabilize_lodging_detail],
14
25
  { host: "t.me", path_query: ->(uri) { uri.path.match?(%r{\A/s/[^/]+/\d+/?\z}) },
15
26
  strategy: :wait_for_telegram_message, notes: "Wait for the requested public Telegram preview message.",
@@ -38,31 +49,48 @@ module FetchUtil
38
49
  private
39
50
 
40
51
  def stabilize_page(page, url)
41
- wait_for_anubis_challenge(page)
52
+ deadline = stabilization_deadline
53
+ wait_for_anubis_challenge(page, deadline: deadline)
54
+ return false unless stabilization_time_remaining?(deadline)
42
55
 
43
56
  if (profile = matching_stabilization_profile(url, PAGE_FLOW_STABILIZATION_PROFILES))
44
- return send(profile.fetch(:strategy), page)
57
+ handled = send(profile.fetch(:strategy), page, deadline: deadline)
58
+ return handled unless profile[:fallthrough] && !handled
59
+ return false unless stabilization_time_remaining?(deadline)
45
60
  end
46
61
 
47
- reached_idle = !@wait_for_idle || wait_for_idle_or_content(page)
62
+ reached_idle = !@wait_for_idle || wait_for_idle_or_content(page, deadline: deadline)
63
+ return false unless stabilization_time_remaining?(deadline)
64
+
48
65
  preserve_consent = preserve_consent_wall?(page, url)
66
+ return false unless stabilization_time_remaining?(deadline)
67
+
49
68
  accepted_cookies = preserve_consent ? false : dismiss_cookie_overlays(page)
69
+ return false unless stabilization_time_remaining?(deadline)
70
+
50
71
  if @wait.positive? && (!@wait_for_idle || accepted_cookies)
51
- sleep @wait
72
+ sleep_before_deadline(@wait, deadline: deadline)
73
+ return false unless stabilization_time_remaining?(deadline)
74
+
52
75
  accepted_cookies = dismiss_cookie_overlays(page) || accepted_cookies unless preserve_consent
76
+ return false unless stabilization_time_remaining?(deadline)
53
77
  end
54
78
 
55
- wait_for_spa_hydration(page) if @wait_for_idle && reached_idle
79
+ wait_for_spa_hydration(page, deadline: deadline) if @wait_for_idle && reached_idle
80
+ return false unless stabilization_time_remaining?(deadline)
81
+
56
82
  if accepted_cookies
57
83
  accepted_cookies = dismiss_cookie_overlays(page) || accepted_cookies
84
+ return false unless stabilization_time_remaining?(deadline)
58
85
  end
59
86
  if (profile = matching_stabilization_profile(url, POST_GENERIC_STABILIZATION_PROFILES))
60
- send(profile.fetch(:strategy), page, url)
87
+ send(profile.fetch(:strategy), page, url, deadline: deadline)
88
+ return false unless stabilization_time_remaining?(deadline)
61
89
  end
62
90
 
63
91
  return unless accepted_cookies && @wait_for_idle && reached_idle
64
92
 
65
- wait_for_network_idle(page)
93
+ wait_for_network_idle(page, deadline: deadline)
66
94
  end
67
95
 
68
96
  def matching_stabilization_profile(url, profiles)
@@ -75,13 +103,18 @@ module FetchUtil
75
103
 
76
104
  def profile_match?(profile, uri, host) = stabilization_host_matches?(profile.fetch(:host), host) && profile.fetch(:path_query, ->(_) { true }).call(uri)
77
105
 
78
- def stabilization_host_matches?(matcher, host) = Array(matcher).any? { |candidate| host == candidate || host.end_with?(".#{candidate}") }
106
+ def stabilization_host_matches?(matcher, host)
107
+ return true if matcher == true
79
108
 
80
- def wait_for_idle_or_content(page)
109
+ Array(matcher).any? { |candidate| host == candidate || host.end_with?(".#{candidate}") }
110
+ end
111
+
112
+ def wait_for_idle_or_content(page, deadline: stabilization_deadline)
81
113
  content_seen_at = nil
82
114
 
83
- retry_until_timeout(@timeout, interval: @idle_duration) do
84
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
+ retry_until_timeout(capped_timeout(@timeout, deadline: deadline),
116
+ interval: @idle_duration, deadline: deadline) do
117
+ now = monotonic_now
85
118
  next true if page.network.idle?
86
119
  if content_seen_at.nil? && page_has_content?(page)
87
120
  content_seen_at = now
@@ -93,11 +126,19 @@ module FetchUtil
93
126
  false
94
127
  end
95
128
 
96
- def wait_for_network_idle(page)
97
- page.network.wait_for_idle(duration: @idle_duration, timeout: POST_CONSENT_IDLE_TIMEOUT)
98
- true
99
- rescue Ferrum::TimeoutError
100
- false
129
+ def wait_for_network_idle(page, deadline: stabilization_deadline)
130
+ idle_since = nil
131
+ timeout = capped_timeout(POST_CONSENT_IDLE_TIMEOUT, deadline: deadline)
132
+ retry_until_timeout(timeout, interval: @idle_duration, deadline: deadline) do
133
+ now = monotonic_now
134
+ if page.network.idle?
135
+ idle_since ||= now
136
+ now >= idle_since + @idle_duration
137
+ else
138
+ idle_since = nil
139
+ false
140
+ end
141
+ end
101
142
  rescue Ferrum::Error => e
102
143
  raise if retryable_pending_connections_error?(e)
103
144
 
@@ -135,8 +176,8 @@ module FetchUtil
135
176
  false
136
177
  end
137
178
 
138
- def wait_for_agora_article(page, _url)
139
- retry_until_timeout(7.0, interval: 0.25) do
179
+ def wait_for_agora_article(page, _url, deadline: stabilization_deadline)
180
+ retry_until_timeout(capped_timeout(7.0, deadline: deadline), interval: 0.25, deadline: deadline) do
140
181
  page.evaluate(<<~JS)
141
182
  (() => {
142
183
  const selectors = ['.mrf-article-body', '.article_body', 'div.articleBody', '.art_content', '.article-inner', 'section.article', '[itemprop=articleBody]'];
@@ -150,8 +191,8 @@ module FetchUtil
150
191
  false
151
192
  end
152
193
 
153
- def wait_for_france24_article(page, _url)
154
- retry_until_timeout(15.0, interval: 0.25) do
194
+ def wait_for_france24_article(page, _url, deadline: stabilization_deadline)
195
+ retry_until_timeout(capped_timeout(15.0, deadline: deadline), interval: 0.25, deadline: deadline) do
155
196
  page.evaluate(<<~JS)
156
197
  (() => {
157
198
  const body = document.querySelector('.t-content__body') || document.querySelector('.t-content--article');
@@ -165,18 +206,21 @@ module FetchUtil
165
206
  false
166
207
  end
167
208
 
168
- def wait_for_onet_homepage(page)
169
- wait_for_structural_readiness(page, "section[class*='Feed_']", "article[class*='Card_']")
209
+ def wait_for_onet_homepage(page, deadline: stabilization_deadline)
210
+ wait_for_structural_readiness(page, "section[class*='Feed_']", "article[class*='Card_']",
211
+ deadline: deadline)
170
212
  end
171
213
 
172
- def wait_for_wp_homepage(page)
173
- wait_for_structural_readiness(page, ".wp-section-grid", ".wp-teaser-tile, .wp-teaser-regular")
214
+ def wait_for_wp_homepage(page, deadline: stabilization_deadline)
215
+ wait_for_structural_readiness(page, ".wp-section-grid", ".wp-teaser-tile, .wp-teaser-regular",
216
+ deadline: deadline)
174
217
  end
175
218
 
176
- def wait_for_telegram_message(page)
219
+ def wait_for_telegram_message(page, deadline: stabilization_deadline)
177
220
  target = URI.parse(page.current_url).path.delete_prefix("/s/")
178
221
 
179
- ready = retry_until_timeout(capped_timeout(5.0), interval: 0.25) do
222
+ ready = retry_until_timeout(capped_timeout(5.0, deadline: deadline),
223
+ interval: 0.25, deadline: deadline) do
180
224
  page.evaluate(<<~JS)
181
225
  (() => {
182
226
  const card = document.querySelector('.tgme_widget_message[data-post="#{target}"]');
@@ -185,7 +229,7 @@ module FetchUtil
185
229
  })()
186
230
  JS
187
231
  end
188
- sleep PRE_EXTRACTION_SETTLE_WAIT if ready
232
+ sleep_before_deadline(PRE_EXTRACTION_SETTLE_WAIT, deadline: deadline) if ready
189
233
  ready
190
234
  rescue URI::InvalidURIError, Ferrum::JavaScriptError, Ferrum::TimeoutError
191
235
  false
@@ -7,21 +7,23 @@ module FetchUtil
7
7
  module SpaHydration
8
8
  private
9
9
 
10
- def wait_for_spa_hydration(page)
10
+ def wait_for_spa_hydration(page, deadline: stabilization_deadline)
11
11
  framework = detect_spa_framework(page)
12
12
  return unless framework
13
13
 
14
- retry_until_timeout(SPA_HYDRATION_TIMEOUT, interval: SPA_HYDRATION_POLL) do
14
+ retry_until_timeout(capped_timeout(SPA_HYDRATION_TIMEOUT, deadline: deadline),
15
+ interval: SPA_HYDRATION_POLL, deadline: deadline) do
15
16
  spa_hydration_complete?(page, framework)
16
17
  end
17
18
 
18
- sleep SPA_HYDRATION_POLL
19
+ sleep_before_deadline(SPA_HYDRATION_POLL, deadline: deadline)
19
20
  rescue Ferrum::JavaScriptError, Ferrum::TimeoutError
20
21
  end
21
22
 
22
- def wait_for_structural_readiness(page, region_selector, card_selector)
23
+ def wait_for_structural_readiness(page, region_selector, card_selector, deadline: stabilization_deadline)
23
24
  previous_count = nil
24
- retry_until_timeout(SPA_HYDRATION_TIMEOUT, interval: SPA_HYDRATION_POLL) do
25
+ retry_until_timeout(capped_timeout(SPA_HYDRATION_TIMEOUT, deadline: deadline),
26
+ interval: SPA_HYDRATION_POLL, deadline: deadline) do
25
27
  counts = page.evaluate(<<~JS)
26
28
  (() => ({
27
29
  regions: document.querySelectorAll(#{region_selector.to_json}).length,
@@ -45,18 +45,19 @@ module FetchUtil
45
45
  viewport: DEFAULT_VIEWPORT, user_agent: DEFAULT_USER_AGENT,
46
46
  accept_language: DEFAULT_ACCEPT_LANGUAGE, browser_path: nil,
47
47
  browser_options: nil)
48
- @timeout = timeout.to_f
49
- @wait = wait.to_f
48
+ @timeout = positive_timeout(timeout)
49
+ @wait = nonnegative_duration(wait, "wait")
50
50
  @wait_for_idle = wait_for_idle
51
- @idle_duration = idle_duration.to_f
51
+ @idle_duration = nonnegative_duration(idle_duration, "idle_duration")
52
52
  @viewport = DEFAULT_VIEWPORT.merge(symbolize_hash(viewport || {}))
53
- @user_agent = user_agent
54
- @accept_language = accept_language
53
+ @user_agent = user_agent.to_s.dup.freeze
54
+ @accept_language = accept_language.to_s.dup.freeze
55
55
  @default_headers = {
56
56
  "User-Agent" => @user_agent,
57
57
  "Accept-Language" => @accept_language
58
58
  }.freeze
59
- @browser_path = browser_path || ENV["BROWSER_PATH"] || BROWSER_CANDIDATES.find { |path| File.executable?(path) }
59
+ selected_browser_path = browser_path || ENV["BROWSER_PATH"] || BROWSER_CANDIDATES.find { |path| File.executable?(path) }
60
+ @browser_path = selected_browser_path&.dup&.freeze
60
61
  @full_browser = @browser_path && !@browser_path.include?("headless_shell")
61
62
  default_opts = { "no-sandbox": nil }
62
63
  # Use newer headless mode with the full browser binary for closer runtime
@@ -67,9 +68,10 @@ module FetchUtil
67
68
  default_opts["headless"] = "new"
68
69
  default_opts["enable-automation"] = false # override Ferrum default
69
70
  end
70
- @browser_options = default_opts.merge(browser_options || {})
71
+ @browser_options = immutable_browser_option(default_opts.merge(browser_options || {}))
71
72
  @navigator_patch = build_navigator_patch
72
73
  @ferrum = nil
74
+ @pending_shutdown = nil
73
75
  @mutex = Mutex.new
74
76
  end
75
77
 
@@ -82,12 +84,12 @@ module FetchUtil
82
84
 
83
85
  normalized_url = FetchUtil.normalize_url(url)
84
86
  page = load_page_with_retry(ensure_browser, normalized_url)
85
- sleep PRE_EXTRACTION_SETTLE_WAIT if heavy_script_page?(page, normalized_url)
87
+ sleep PRE_EXTRACTION_SETTLE_WAIT if heavy_script_page?(page, loaded_page_url(page, normalized_url))
86
88
  yield page
87
89
  rescue Ferrum::Error => e
88
90
  raise BrowserError, e.message
89
91
  ensure
90
- page&.close
92
+ close_page(page)
91
93
  end
92
94
 
93
95
  # Shut down the underlying Chromium process. Safe to call multiple times or
@@ -95,33 +97,92 @@ module FetchUtil
95
97
  # +with_page+ call will transparently launch a new process.
96
98
  def quit
97
99
  @mutex.synchronize do
98
- @ferrum&.quit
100
+ browser = @ferrum || @pending_shutdown
99
101
  @ferrum = nil
102
+ @pending_shutdown = browser
103
+ shutdown_pending_browser
100
104
  end
101
105
  end
102
106
 
103
107
  private
104
108
 
109
+ def positive_timeout(value)
110
+ timeout = Float(value)
111
+ return timeout if timeout.positive? && timeout.finite?
112
+
113
+ raise ArgumentError
114
+ rescue ArgumentError, TypeError
115
+ raise InputError, "timeout must be positive"
116
+ end
117
+
118
+ def nonnegative_duration(value, name)
119
+ duration = Float(value)
120
+ return duration if duration >= 0 && duration.finite?
121
+
122
+ raise ArgumentError
123
+ rescue ArgumentError, TypeError
124
+ raise InputError, "#{name} must be nonnegative"
125
+ end
126
+
127
+ def immutable_browser_option(value, memo = {}.compare_by_identity)
128
+ return memo[value] if memo.key?(value)
129
+
130
+ case value
131
+ when String
132
+ memo[value] = value.dup.freeze
133
+ when Array
134
+ owned = []
135
+ memo[value] = owned
136
+ value.each { |item| owned << immutable_browser_option(item, memo) }
137
+ owned.freeze
138
+ when Hash
139
+ owned = {}
140
+ memo[value] = owned
141
+ value.each do |key, item|
142
+ owned[immutable_browser_option(key, memo)] = immutable_browser_option(item, memo)
143
+ end
144
+ owned.freeze
145
+ else
146
+ value
147
+ end
148
+ end
149
+
105
150
  # Lazily start the shared Chromium process on first use. The
106
151
  # +evaluate_on_new_document+ call registers the navigator patch once;
107
152
  # Ferrum automatically applies it to every new page/context created
108
153
  # afterwards.
109
154
  def ensure_browser
110
155
  @mutex.synchronize do
156
+ shutdown_pending_browser
111
157
  return @ferrum if @ferrum
112
158
 
113
- @ferrum = Ferrum::Browser.new(
159
+ browser = Ferrum::Browser.new(
114
160
  headless: true,
115
161
  browser_path: @browser_path,
116
162
  timeout: @timeout,
117
163
  window_size: [@viewport.fetch(:width), @viewport.fetch(:height)],
118
164
  browser_options: @browser_options
119
165
  )
120
- @ferrum.evaluate_on_new_document(navigator_patch)
121
- @ferrum
166
+ browser.evaluate_on_new_document(navigator_patch)
167
+ @ferrum = browser
168
+ rescue Ferrum::Error
169
+ @pending_shutdown = browser
170
+ begin
171
+ shutdown_pending_browser
172
+ rescue Ferrum::Error
173
+ nil
174
+ end
175
+ raise
122
176
  end
123
177
  end
124
178
 
179
+ def shutdown_pending_browser
180
+ return unless @pending_shutdown
181
+
182
+ @pending_shutdown.quit
183
+ @pending_shutdown = nil
184
+ end
185
+
125
186
  def host_matches?(url, host)
126
187
  normalized_host = FetchUtil.strip_www_host(url)
127
188
  normalized_host == host || normalized_host.end_with?(".#{host}")
@@ -132,8 +193,7 @@ module FetchUtil
132
193
  end
133
194
 
134
195
  def retryable_navigation_error?(error)
135
- error.is_a?(Ferrum::PendingConnectionsError) || error.is_a?(Ferrum::TimeoutError) ||
136
- error.message.to_s.match?(/pending connections/i)
196
+ error.is_a?(Ferrum::TimeoutError) || retryable_pending_connections_error?(error)
137
197
  end
138
198
 
139
199
  def heavy_script_page?(page, url)
@@ -146,6 +206,17 @@ module FetchUtil
146
206
  false
147
207
  end
148
208
 
209
+ def close_page(page)
210
+ page&.close
211
+ rescue Ferrum::Error
212
+ nil
213
+ end
214
+
215
+ def loaded_page_url(page, fallback)
216
+ current_url = page.current_url
217
+ current_url.to_s.empty? ? fallback : current_url
218
+ end
219
+
149
220
  def load_page_with_retry(ferrum, url)
150
221
  retries = 0
151
222
 
@@ -158,10 +229,10 @@ module FetchUtil
158
229
  rescue Ferrum::TimeoutError
159
230
  raise unless page_loaded_enough?(page)
160
231
  end
161
- stabilize_page(page, url)
232
+ stabilize_page(page, loaded_page_url(page, url))
162
233
  page
163
234
  rescue Ferrum::PendingConnectionsError, Ferrum::TimeoutError, Ferrum::Error => e
164
- page&.close
235
+ close_page(page)
165
236
  raise unless retryable_navigation_error?(e)
166
237
  raise if retries >= NAVIGATION_MAX_RETRIES
167
238
 
@@ -6,6 +6,31 @@ require "thor"
6
6
 
7
7
  module FetchUtil
8
8
  class CLI < Thor
9
+ class << self
10
+ def start(given_args = ARGV, config = {})
11
+ command = given_args.first
12
+ arguments = given_args.drop(1)
13
+ help_command = command_for_local_help(command, arguments)
14
+ return super(["help", help_command], config) if help_command
15
+
16
+ super
17
+ end
18
+
19
+ def exit_on_failure? = true
20
+
21
+ private
22
+
23
+ def command_for_local_help(command, arguments)
24
+ normalized_command = normalize_command_name(command)
25
+ return unless all_commands.key?(normalized_command)
26
+
27
+ help_requested = arguments.take_while { |argument| argument != "--" }.any? do |argument|
28
+ Thor::HELP_MAPPINGS.include?(argument)
29
+ end
30
+ normalized_command if help_requested
31
+ end
32
+ end
33
+
9
34
  DEFAULT_FETCH_FIELDS = %i[
10
35
  title
11
36
  byline
@@ -55,13 +80,15 @@ module FetchUtil
55
80
 
56
81
  desc "fetch URL [URL...]", "Fetch one or more URLs"
57
82
  def fetch(*urls)
58
- raise ArgumentError, "at least one URL is required" if urls.empty?
83
+ raise Thor::Error, "at least one URL is required" if urls.empty?
59
84
 
60
- results = if urls.length == 1
61
- [FetchUtil.fetch(urls.first, **fetch_options, request_log: request_log)]
62
- else
63
- FetchUtil.fetch_many(urls, **fetch_options, request_log: request_log, concurrency: options[:concurrency])
64
- end
85
+ results = translate_input_errors do
86
+ if urls.length == 1
87
+ [FetchUtil.fetch(urls.first, **fetch_options, request_log: request_log)]
88
+ else
89
+ FetchUtil.fetch_many(urls, **fetch_options, request_log: request_log, concurrency: options[:concurrency])
90
+ end
91
+ end
65
92
 
66
93
  if options[:format] == "markdown"
67
94
  puts results.map { |result| front_matter_document(result) }.join("\n\n")
@@ -76,15 +103,18 @@ module FetchUtil
76
103
  option :verbose_search, type: :boolean, default: false, desc: "Include per-result search provenance"
77
104
  def search(*terms)
78
105
  query = terms.join(" ").strip
79
- raise ArgumentError, "query is required" if query.empty?
80
-
81
- payload = Searcher.new(
82
- request_log: request_log,
83
- sources: options[:source],
84
- limit: options[:limit],
85
- verbose: options[:verbose_search],
86
- timeout: options[:timeout]
87
- ).search(query)
106
+ raise Thor::Error, "query is required" if query.empty?
107
+
108
+ searcher = translate_input_errors do
109
+ Searcher.new(
110
+ request_log: request_log,
111
+ sources: options[:source],
112
+ limit: options[:limit],
113
+ verbose: options[:verbose_search],
114
+ timeout: options[:timeout]
115
+ )
116
+ end
117
+ payload = translate_input_errors { searcher.search(query) }
88
118
 
89
119
  emit(payload)
90
120
  end
@@ -93,22 +123,38 @@ module FetchUtil
93
123
  option :sources, type: :string, default: "machine", desc: "Comma-separated source selectors, e.g. machine,-robotstxt or human,machine,-human"
94
124
  option :cache_path, type: :string, desc: "Structured regulatory cache directory"
95
125
  def regulatory(url)
96
- raise ArgumentError, "url is required" if url.to_s.strip.empty?
126
+ raise Thor::Error, "url is required" if url.to_s.strip.empty?
97
127
 
98
128
  request_log.append("regulatory://#{url}?sources=#{options[:sources]}")
99
- payload = FetchUtil.regulatory(
100
- url,
101
- cache_path: options[:cache_path],
102
- sources: options[:sources],
103
- timeout: options[:timeout]
104
- )
129
+ payload = translate_input_errors do
130
+ FetchUtil.regulatory(
131
+ url,
132
+ cache_path: options[:cache_path],
133
+ sources: options[:sources],
134
+ timeout: options[:timeout]
135
+ )
136
+ end
105
137
 
106
138
  emit(payload)
107
139
  end
108
140
 
109
141
  no_commands do
110
142
  def request_log
111
- @request_log ||= RequestLog.new(path: options[:log_path] || ENV.fetch("FETCH_UTIL_REQUEST_LOG", RequestLog::DEFAULT_PATH))
143
+ @request_log ||= options[:log_path] ? RequestLog.new(path: options[:log_path]) : RequestLog.new
144
+ end
145
+
146
+ def translate_input_errors
147
+ yield
148
+ rescue ParallelFetcher::ParallelFetchError => e
149
+ raise unless e.errors.all? { |error| input_error?(error) }
150
+
151
+ raise Thor::Error, e.message
152
+ rescue URI::InvalidURIError, InputError => e
153
+ raise Thor::Error, e.message
154
+ end
155
+
156
+ def input_error?(error)
157
+ error.is_a?(URI::InvalidURIError) || error.is_a?(InputError)
112
158
  end
113
159
 
114
160
  def fetch_options