ghostcrawl 2.3.5

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 (333) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +218 -0
  4. data/_generated/ghostcrawl.rb +0 -0
  5. data/_generated/ghostcrawl_client.rb +27 -0
  6. data/_generated/models/agent_request.rb +203 -0
  7. data/_generated/models/agent_request_mode.rb +8 -0
  8. data/_generated/models/agent_request_task.rb +164 -0
  9. data/_generated/models/agent_request_task_steps.rb +122 -0
  10. data/_generated/models/agent_request_task_steps_schema.rb +64 -0
  11. data/_generated/models/agent_request_task_steps_type.rb +9 -0
  12. data/_generated/models/auth_context.rb +204 -0
  13. data/_generated/models/batch_scrape_request.rb +644 -0
  14. data/_generated/models/batch_scrape_request_behavior_actions_member1.rb +62 -0
  15. data/_generated/models/batch_scrape_request_behavior_actions_member2.rb +62 -0
  16. data/_generated/models/batch_scrape_request_engine.rb +10 -0
  17. data/_generated/models/batch_scrape_request_extraction_strategy_member1.rb +62 -0
  18. data/_generated/models/batch_scrape_request_identity_country_member1.rb +62 -0
  19. data/_generated/models/batch_scrape_request_language_member1.rb +62 -0
  20. data/_generated/models/batch_scrape_request_output_format.rb +9 -0
  21. data/_generated/models/batch_scrape_request_profile_member1.rb +62 -0
  22. data/_generated/models/cdp_frame_request.rb +103 -0
  23. data/_generated/models/cdp_url_request.rb +82 -0
  24. data/_generated/models/content_request.rb +581 -0
  25. data/_generated/models/content_request_engine.rb +12 -0
  26. data/_generated/models/content_request_format.rb +10 -0
  27. data/_generated/models/content_request_identity_country_member1.rb +62 -0
  28. data/_generated/models/content_request_identity_member1.rb +62 -0
  29. data/_generated/models/content_request_language_member1.rb +62 -0
  30. data/_generated/models/content_request_profile_member1.rb +62 -0
  31. data/_generated/models/content_request_proxy_member1.rb +62 -0
  32. data/_generated/models/cookie_dict.rb +739 -0
  33. data/_generated/models/cookie_dict_domain_member1.rb +62 -0
  34. data/_generated/models/cookie_dict_expires_member1.rb +62 -0
  35. data/_generated/models/cookie_dict_http_only_member1.rb +62 -0
  36. data/_generated/models/cookie_dict_path_member1.rb +62 -0
  37. data/_generated/models/cookie_dict_same_site_member1.rb +62 -0
  38. data/_generated/models/cookie_dict_secure_member1.rb +62 -0
  39. data/_generated/models/cookie_dict_url_member1.rb +62 -0
  40. data/_generated/models/cookies_delete_body.rb +355 -0
  41. data/_generated/models/cookies_delete_body_domain_member1.rb +62 -0
  42. data/_generated/models/cookies_delete_body_name_member1.rb +62 -0
  43. data/_generated/models/cookies_delete_body_path_member1.rb +62 -0
  44. data/_generated/models/cookies_set_body.rb +102 -0
  45. data/_generated/models/deep_crawl_body.rb +542 -0
  46. data/_generated/models/deep_crawl_body_routing_mode.rb +11 -0
  47. data/_generated/models/deep_crawl_body_timeout_s_member1.rb +62 -0
  48. data/_generated/models/deep_crawl_body_wait_until_member1.rb +62 -0
  49. data/_generated/models/deep_crawl_body_webhook_url_member1.rb +62 -0
  50. data/_generated/models/dom_snapshot_body.rb +82 -0
  51. data/_generated/models/download_body.rb +102 -0
  52. data/_generated/models/error_code.rb +31 -0
  53. data/_generated/models/error_envelope.rb +264 -0
  54. data/_generated/models/error_envelope_limits.rb +162 -0
  55. data/_generated/models/eval_body.rb +102 -0
  56. data/_generated/models/extend_body.rb +155 -0
  57. data/_generated/models/extend_body_ttl_seconds_member1.rb +62 -0
  58. data/_generated/models/extract_request.rb +631 -0
  59. data/_generated/models/extract_request_behavior_actions_member1.rb +62 -0
  60. data/_generated/models/extract_request_behavior_actions_member2.rb +62 -0
  61. data/_generated/models/extract_request_engine.rb +12 -0
  62. data/_generated/models/extract_request_identity_country_member1.rb +62 -0
  63. data/_generated/models/extract_request_language_member1.rb +62 -0
  64. data/_generated/models/extract_request_prompt_member1.rb +62 -0
  65. data/_generated/models/extract_request_url_member1.rb +62 -0
  66. data/_generated/models/extract_request_urls_member1.rb +62 -0
  67. data/_generated/models/filter_spec.rb +62 -0
  68. data/_generated/models/har_body.rb +82 -0
  69. data/_generated/models/map_body.rb +238 -0
  70. data/_generated/models/map_body_search_member1.rb +62 -0
  71. data/_generated/models/map_response.rb +195 -0
  72. data/_generated/models/map_response_truncated_to_server_max_member1.rb +62 -0
  73. data/_generated/models/models.rb +152 -0
  74. data/_generated/models/pdf_request.rb +560 -0
  75. data/_generated/models/pdf_request_engine.rb +12 -0
  76. data/_generated/models/pdf_request_identity_country_member1.rb +62 -0
  77. data/_generated/models/pdf_request_identity_member1.rb +62 -0
  78. data/_generated/models/pdf_request_language_member1.rb +62 -0
  79. data/_generated/models/pdf_request_profile_member1.rb +62 -0
  80. data/_generated/models/pdf_request_proxy_member1.rb +62 -0
  81. data/_generated/models/problem_details.rb +226 -0
  82. data/_generated/models/profile_create_request.rb +175 -0
  83. data/_generated/models/profile_create_request_storage_state_id_member1.rb +62 -0
  84. data/_generated/models/profile_update_request.rb +246 -0
  85. data/_generated/models/profile_update_request_name_member1.rb +62 -0
  86. data/_generated/models/profile_update_request_storage_state_id_member1.rb +62 -0
  87. data/_generated/models/proxy_config.rb +116 -0
  88. data/_generated/models/retry_delivery_response.rb +145 -0
  89. data/_generated/models/rotate_secret_response.rb +145 -0
  90. data/_generated/models/schedule_create_request.rb +256 -0
  91. data/_generated/models/schedule_create_request_job_params.rb +64 -0
  92. data/_generated/models/schedule_create_request_notify_webhook_member1.rb +62 -0
  93. data/_generated/models/scorer_spec.rb +102 -0
  94. data/_generated/models/scrape_page_result.rb +62 -0
  95. data/_generated/models/scrape_request.rb +1254 -0
  96. data/_generated/models/scrape_request_batch_identity_mode.rb +10 -0
  97. data/_generated/models/scrape_request_behavior_actions_member1.rb +62 -0
  98. data/_generated/models/scrape_request_behavior_actions_member2.rb +62 -0
  99. data/_generated/models/scrape_request_engine.rb +12 -0
  100. data/_generated/models/scrape_request_format.rb +11 -0
  101. data/_generated/models/scrape_request_identity_country_member1.rb +62 -0
  102. data/_generated/models/scrape_request_identity_member1.rb +62 -0
  103. data/_generated/models/scrape_request_language_member1.rb +62 -0
  104. data/_generated/models/scrape_request_profile_member1.rb +62 -0
  105. data/_generated/models/scrape_request_proxy_member1.rb +62 -0
  106. data/_generated/models/scrape_request_routing_mode.rb +11 -0
  107. data/_generated/models/scrape_request_screenshot_selector_member1.rb +62 -0
  108. data/_generated/models/scrape_request_session_member1.rb +62 -0
  109. data/_generated/models/scrape_request_timeout_ms_member1.rb +62 -0
  110. data/_generated/models/scrape_request_url_member1.rb +62 -0
  111. data/_generated/models/scrape_request_urls_member1.rb +62 -0
  112. data/_generated/models/scrape_result.rb +630 -0
  113. data/_generated/models/scrape_result_format_member1.rb +62 -0
  114. data/_generated/models/scrape_result_identity_id_member1.rb +62 -0
  115. data/_generated/models/scrape_result_markdown_member1.rb +62 -0
  116. data/_generated/models/scrape_result_status_member1.rb +62 -0
  117. data/_generated/models/scrape_result_token_estimate_member1.rb +62 -0
  118. data/_generated/models/scrape_result_warnings_member1.rb +62 -0
  119. data/_generated/models/screenshot_request.rb +672 -0
  120. data/_generated/models/screenshot_request_engine.rb +12 -0
  121. data/_generated/models/screenshot_request_identity_country_member1.rb +62 -0
  122. data/_generated/models/screenshot_request_identity_member1.rb +62 -0
  123. data/_generated/models/screenshot_request_language_member1.rb +62 -0
  124. data/_generated/models/screenshot_request_profile_member1.rb +62 -0
  125. data/_generated/models/screenshot_request_proxy_member1.rb +62 -0
  126. data/_generated/models/screenshot_request_screenshot_selector_member1.rb +62 -0
  127. data/_generated/models/scroll_body.rb +284 -0
  128. data/_generated/models/scroll_body_direction.rb +11 -0
  129. data/_generated/models/scroll_body_distance_px_member1.rb +62 -0
  130. data/_generated/models/scroll_body_selector_member1.rb +62 -0
  131. data/_generated/models/search_request.rb +469 -0
  132. data/_generated/models/search_request_country_member1.rb +62 -0
  133. data/_generated/models/search_request_engine_member1.rb +62 -0
  134. data/_generated/models/search_request_freshness_member1.rb +62 -0
  135. data/_generated/models/search_request_vertical_member1.rb +62 -0
  136. data/_generated/models/session_create_request.rb +174 -0
  137. data/_generated/models/session_create_request_engine.rb +10 -0
  138. data/_generated/models/session_create_request_profile_member1.rb +62 -0
  139. data/_generated/models/storage_state_attach_request.rb +58 -0
  140. data/_generated/models/storage_state_detach_request.rb +58 -0
  141. data/_generated/models/takeover_action_body.rb +82 -0
  142. data/_generated/models/upload_body.rb +162 -0
  143. data/_generated/models/wait_body.rb +194 -0
  144. data/_generated/models/wait_body_selector_member1.rb +62 -0
  145. data/_generated/models/webhook_create_request.rb +125 -0
  146. data/_generated/models/webhook_create_response.rb +104 -0
  147. data/_generated/models/webhook_delivery_list_response.rb +195 -0
  148. data/_generated/models/webhook_delivery_list_response_next_cursor_member1.rb +62 -0
  149. data/_generated/models/webhook_delivery_public.rb +680 -0
  150. data/_generated/models/webhook_delivery_public_delivered_at_member1.rb +62 -0
  151. data/_generated/models/webhook_delivery_public_error_class_member1.rb +62 -0
  152. data/_generated/models/webhook_delivery_public_replay_of_member1.rb +62 -0
  153. data/_generated/models/webhook_delivery_public_response_body_preview_member1.rb +62 -0
  154. data/_generated/models/webhook_delivery_public_response_status_member1.rb +62 -0
  155. data/_generated/models/webhook_list_response.rb +195 -0
  156. data/_generated/models/webhook_list_response_next_cursor_member1.rb +62 -0
  157. data/_generated/models/webhook_public.rb +316 -0
  158. data/_generated/models/webhook_public_secret_rotated_at_member1.rb +62 -0
  159. data/_generated/v1/agent/agent.rb +1 -0
  160. data/_generated/v1/agent/agent404_error.rb +87 -0
  161. data/_generated/v1/agent/agent_request_builder.rb +73 -0
  162. data/_generated/v1/binary/binary.rb +9 -0
  163. data/_generated/v1/cdp/cdp.rb +0 -0
  164. data/_generated/v1/cdp/cdp_request_builder.rb +37 -0
  165. data/_generated/v1/cdp/frame/frame.rb +0 -0
  166. data/_generated/v1/cdp/frame/frame_request_builder.rb +74 -0
  167. data/_generated/v1/cdp/url/url.rb +0 -0
  168. data/_generated/v1/cdp/url/url_request_builder.rb +74 -0
  169. data/_generated/v1/content/content.rb +0 -0
  170. data/_generated/v1/content/content_request_builder.rb +71 -0
  171. data/_generated/v1/crawl/crawl.rb +0 -0
  172. data/_generated/v1/crawl/crawl_request_builder.rb +31 -0
  173. data/_generated/v1/crawl/deep/deep.rb +0 -0
  174. data/_generated/v1/crawl/deep/deep_request_builder.rb +86 -0
  175. data/_generated/v1/crawl/deep/item/item.rb +0 -0
  176. data/_generated/v1/crawl/deep/item/with_run_item_request_builder.rb +71 -0
  177. data/_generated/v1/crawl_runs/crawl_runs.rb +1 -0
  178. data/_generated/v1/crawl_runs/crawl_runs_post_request_body.rb +65 -0
  179. data/_generated/v1/crawl_runs/crawl_runs_request_builder.rb +131 -0
  180. data/_generated/v1/crawl_runs/item/cancel/cancel.rb +0 -0
  181. data/_generated/v1/crawl_runs/item/cancel/cancel_request_builder.rb +71 -0
  182. data/_generated/v1/crawl_runs/item/item.rb +0 -0
  183. data/_generated/v1/crawl_runs/item/resume/resume.rb +0 -0
  184. data/_generated/v1/crawl_runs/item/resume/resume_request_builder.rb +71 -0
  185. data/_generated/v1/crawl_runs/item/with_run_item_request_builder.rb +96 -0
  186. data/_generated/v1/datasets/datasets.rb +1 -0
  187. data/_generated/v1/datasets/datasets_post_request_body.rb +65 -0
  188. data/_generated/v1/datasets/datasets_request_builder.rb +124 -0
  189. data/_generated/v1/datasets/item/item.rb +0 -0
  190. data/_generated/v1/datasets/item/rows/append/append.rb +1 -0
  191. data/_generated/v1/datasets/item/rows/append/append_post_request_body.rb +74 -0
  192. data/_generated/v1/datasets/item/rows/append/append_request_builder.rb +79 -0
  193. data/_generated/v1/datasets/item/rows/rows.rb +0 -0
  194. data/_generated/v1/datasets/item/rows/rows_request_builder.rb +86 -0
  195. data/_generated/v1/datasets/item/with_name_item_request_builder.rb +104 -0
  196. data/_generated/v1/downloads/downloads.rb +0 -0
  197. data/_generated/v1/downloads/downloads_request_builder.rb +76 -0
  198. data/_generated/v1/downloads/item/item.rb +0 -0
  199. data/_generated/v1/downloads/item/with_download_item_request_builder.rb +97 -0
  200. data/_generated/v1/extract/extract.rb +1 -0
  201. data/_generated/v1/extract/extract_post_response.rb +65 -0
  202. data/_generated/v1/extract/extract_request_builder.rb +78 -0
  203. data/_generated/v1/kv/item/item.rb +1 -0
  204. data/_generated/v1/kv/item/with_key_item_request_builder.rb +133 -0
  205. data/_generated/v1/kv/item/with_key_put_request_body.rb +68 -0
  206. data/_generated/v1/kv/kv.rb +0 -0
  207. data/_generated/v1/kv/kv_request_builder.rb +37 -0
  208. data/_generated/v1/map/map.rb +0 -0
  209. data/_generated/v1/map/map_request_builder.rb +72 -0
  210. data/_generated/v1/page/cookies/cookies.rb +0 -0
  211. data/_generated/v1/page/cookies/cookies_request_builder.rb +150 -0
  212. data/_generated/v1/page/dom_snapshot/dom_snapshot.rb +0 -0
  213. data/_generated/v1/page/dom_snapshot/dom_snapshot_request_builder.rb +74 -0
  214. data/_generated/v1/page/download/download.rb +0 -0
  215. data/_generated/v1/page/download/download_request_builder.rb +74 -0
  216. data/_generated/v1/page/eval/eval.rb +0 -0
  217. data/_generated/v1/page/eval/eval_request_builder.rb +74 -0
  218. data/_generated/v1/page/har/har.rb +0 -0
  219. data/_generated/v1/page/har/har_request_builder.rb +74 -0
  220. data/_generated/v1/page/page.rb +0 -0
  221. data/_generated/v1/page/page_request_builder.rb +73 -0
  222. data/_generated/v1/page/scroll/scroll.rb +0 -0
  223. data/_generated/v1/page/scroll/scroll_request_builder.rb +74 -0
  224. data/_generated/v1/page/upload/upload.rb +0 -0
  225. data/_generated/v1/page/upload/upload_request_builder.rb +74 -0
  226. data/_generated/v1/page/wait/wait.rb +0 -0
  227. data/_generated/v1/page/wait/wait_request_builder.rb +74 -0
  228. data/_generated/v1/pdf/pdf.rb +0 -0
  229. data/_generated/v1/pdf/pdf_request_builder.rb +71 -0
  230. data/_generated/v1/profiles/item/item.rb +0 -0
  231. data/_generated/v1/profiles/item/with_name_item_request_builder.rb +134 -0
  232. data/_generated/v1/profiles/profiles.rb +0 -0
  233. data/_generated/v1/profiles/profiles_request_builder.rb +125 -0
  234. data/_generated/v1/recordings/item/item.rb +0 -0
  235. data/_generated/v1/recordings/item/recording_item_request_builder.rb +102 -0
  236. data/_generated/v1/recordings/item/visual/frames/frames.rb +0 -0
  237. data/_generated/v1/recordings/item/visual/frames/frames_request_builder.rb +93 -0
  238. data/_generated/v1/recordings/item/visual/start/start.rb +0 -0
  239. data/_generated/v1/recordings/item/visual/start/start_request_builder.rb +80 -0
  240. data/_generated/v1/recordings/item/visual/stop/stop.rb +0 -0
  241. data/_generated/v1/recordings/item/visual/stop/stop_request_builder.rb +80 -0
  242. data/_generated/v1/recordings/item/visual/visual.rb +0 -0
  243. data/_generated/v1/recordings/item/visual/visual_request_builder.rb +95 -0
  244. data/_generated/v1/recordings/recordings.rb +0 -0
  245. data/_generated/v1/recordings/recordings_request_builder.rb +89 -0
  246. data/_generated/v1/schedules/item/item.rb +0 -0
  247. data/_generated/v1/schedules/item/runs/runs.rb +0 -0
  248. data/_generated/v1/schedules/item/runs/runs_request_builder.rb +71 -0
  249. data/_generated/v1/schedules/item/with_schedule_item_request_builder.rb +103 -0
  250. data/_generated/v1/schedules/schedules.rb +0 -0
  251. data/_generated/v1/schedules/schedules_request_builder.rb +113 -0
  252. data/_generated/v1/scrape/batch/batch.rb +0 -0
  253. data/_generated/v1/scrape/batch/batch_request_builder.rb +81 -0
  254. data/_generated/v1/scrape/scrape.rb +0 -0
  255. data/_generated/v1/scrape/scrape_request_builder.rb +86 -0
  256. data/_generated/v1/screenshot/screenshot.rb +0 -0
  257. data/_generated/v1/screenshot/screenshot_request_builder.rb +71 -0
  258. data/_generated/v1/screenshot_blobs/item/item.rb +0 -0
  259. data/_generated/v1/screenshot_blobs/item/with_ref_item_request_builder.rb +68 -0
  260. data/_generated/v1/screenshot_blobs/screenshot_blobs.rb +0 -0
  261. data/_generated/v1/screenshot_blobs/screenshot_blobs_request_builder.rb +37 -0
  262. data/_generated/v1/search/jobs/item/item.rb +0 -0
  263. data/_generated/v1/search/jobs/item/with_job_item_request_builder.rb +71 -0
  264. data/_generated/v1/search/jobs/jobs.rb +0 -0
  265. data/_generated/v1/search/jobs/jobs_request_builder.rb +40 -0
  266. data/_generated/v1/search/search.rb +0 -0
  267. data/_generated/v1/search/search_request_builder.rb +84 -0
  268. data/_generated/v1/selfhost/byo/behavior/behavior.rb +1 -0
  269. data/_generated/v1/selfhost/byo/behavior/behavior_put_request_body.rb +71 -0
  270. data/_generated/v1/selfhost/byo/behavior/behavior_request_builder.rb +76 -0
  271. data/_generated/v1/selfhost/byo/byo.rb +0 -0
  272. data/_generated/v1/selfhost/byo/byo_request_builder.rb +46 -0
  273. data/_generated/v1/selfhost/byo/identity/identity.rb +1 -0
  274. data/_generated/v1/selfhost/byo/identity/identity_put_request_body.rb +71 -0
  275. data/_generated/v1/selfhost/byo/identity/identity_request_builder.rb +76 -0
  276. data/_generated/v1/selfhost/byo/proxy/proxy.rb +1 -0
  277. data/_generated/v1/selfhost/byo/proxy/proxy_put_request_body.rb +71 -0
  278. data/_generated/v1/selfhost/byo/proxy/proxy_request_builder.rb +76 -0
  279. data/_generated/v1/selfhost/selfhost.rb +0 -0
  280. data/_generated/v1/selfhost/selfhost_request_builder.rb +31 -0
  281. data/_generated/v1/sessions/create/create.rb +0 -0
  282. data/_generated/v1/sessions/create/create_request_builder.rb +74 -0
  283. data/_generated/v1/sessions/item/extend/extend.rb +0 -0
  284. data/_generated/v1/sessions/item/extend/extend_request_builder.rb +77 -0
  285. data/_generated/v1/sessions/item/item.rb +0 -0
  286. data/_generated/v1/sessions/item/pin/pin.rb +0 -0
  287. data/_generated/v1/sessions/item/pin/pin_request_builder.rb +71 -0
  288. data/_generated/v1/sessions/item/profile_item_request_builder.rb +70 -0
  289. data/_generated/v1/sessions/item/recording/recording.rb +0 -0
  290. data/_generated/v1/sessions/item/recording/recording_request_builder.rb +43 -0
  291. data/_generated/v1/sessions/item/recording/start/start.rb +0 -0
  292. data/_generated/v1/sessions/item/recording/start/start_request_builder.rb +74 -0
  293. data/_generated/v1/sessions/item/recording/stop/stop.rb +0 -0
  294. data/_generated/v1/sessions/item/recording/stop/stop_request_builder.rb +74 -0
  295. data/_generated/v1/sessions/item/release/release.rb +0 -0
  296. data/_generated/v1/sessions/item/release/release_request_builder.rb +71 -0
  297. data/_generated/v1/sessions/item/takeover/takeover.rb +0 -0
  298. data/_generated/v1/sessions/item/takeover/takeover_request_builder.rb +77 -0
  299. data/_generated/v1/sessions/item/takeover_release/takeover_release.rb +0 -0
  300. data/_generated/v1/sessions/item/takeover_release/takeover_release_request_builder.rb +71 -0
  301. data/_generated/v1/sessions/item/takeover_token/takeover_token.rb +0 -0
  302. data/_generated/v1/sessions/item/takeover_token/takeover_token_request_builder.rb +71 -0
  303. data/_generated/v1/sessions/sessions.rb +0 -0
  304. data/_generated/v1/sessions/sessions_request_builder.rb +95 -0
  305. data/_generated/v1/storage_states/detach/detach.rb +0 -0
  306. data/_generated/v1/storage_states/detach/detach_request_builder.rb +74 -0
  307. data/_generated/v1/storage_states/item/attach/attach.rb +0 -0
  308. data/_generated/v1/storage_states/item/attach/attach_request_builder.rb +77 -0
  309. data/_generated/v1/storage_states/item/item.rb +0 -0
  310. data/_generated/v1/storage_states/item/with_id_or_name_item_request_builder.rb +104 -0
  311. data/_generated/v1/storage_states/storage_states.rb +1 -0
  312. data/_generated/v1/storage_states/storage_states_post_request_body.rb +65 -0
  313. data/_generated/v1/storage_states/storage_states_request_builder.rb +127 -0
  314. data/_generated/v1/v1.rb +0 -0
  315. data/_generated/v1/v1_request_builder.rb +161 -0
  316. data/_generated/v1/webhooks/item/deliveries/deliveries.rb +0 -0
  317. data/_generated/v1/webhooks/item/deliveries/deliveries_request_builder.rb +96 -0
  318. data/_generated/v1/webhooks/item/deliveries/item/item.rb +0 -0
  319. data/_generated/v1/webhooks/item/deliveries/item/retry_escaped/retry_escaped.rb +0 -0
  320. data/_generated/v1/webhooks/item/deliveries/item/retry_escaped/retry_request_builder.rb +78 -0
  321. data/_generated/v1/webhooks/item/deliveries/item/with_delivery_item_request_builder.rb +40 -0
  322. data/_generated/v1/webhooks/item/item.rb +0 -0
  323. data/_generated/v1/webhooks/item/rotate_secret/rotate_secret.rb +0 -0
  324. data/_generated/v1/webhooks/item/rotate_secret/rotate_secret_request_builder.rb +72 -0
  325. data/_generated/v1/webhooks/item/with_webhook_item_request_builder.rb +111 -0
  326. data/_generated/v1/webhooks/webhooks.rb +0 -0
  327. data/_generated/v1/webhooks/webhooks_request_builder.rb +127 -0
  328. data/lib/ghostcrawl/client.rb +1365 -0
  329. data/lib/ghostcrawl/error_codes.rb +111 -0
  330. data/lib/ghostcrawl/errors.rb +137 -0
  331. data/lib/ghostcrawl/version.rb +5 -0
  332. data/lib/ghostcrawl.rb +17 -0
  333. metadata +422 -0
@@ -0,0 +1,1365 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GhostCrawl Ruby SDK — idiomatic facade over the Kiota-generated core.
4
+ #
5
+ # Architecture:
6
+ #
7
+ # _generated/ Kiota core — spec-faithful 98-op request-builder (models, transport, auth)
8
+ # This FACADE — thin idiomatic layer delegating to the generated builders
9
+ #
10
+ # All HTTP transport, URL routing, serialization, and auth come from the generated core.
11
+ # The facade maps idiomatic calls (client.scrape) to generated builders via
12
+ # BaseBearerTokenAuthenticationProvider + FaradayRequestAdapter.
13
+ #
14
+ # Usage:
15
+ # require "ghostcrawl"
16
+ # client = GhostCrawl::Client.new(token: "gck_live_YOUR_KEY")
17
+ # result = client.scrape(url: "https://example.com")
18
+
19
+ require "json"
20
+ require "microsoft_kiota_abstractions"
21
+ require "microsoft_kiota_faraday"
22
+ require "microsoft_kiota_serialization_json"
23
+ require_relative "../../_generated/ghostcrawl_client"
24
+ require_relative "../../_generated/models/scrape_request"
25
+ require_relative "../../_generated/models/search_request"
26
+ require_relative "../../_generated/models/extract_request"
27
+ require_relative "../../_generated/models/deep_crawl_body"
28
+ require_relative "../../_generated/models/map_body"
29
+ require_relative "../../_generated/models/session_create_request"
30
+ require_relative "../../_generated/models/extend_body"
31
+ require_relative "../../_generated/models/profile_create_request"
32
+ require_relative "../../_generated/models/profile_update_request"
33
+ require_relative "../../_generated/models/webhook_create_request"
34
+ require_relative "../../_generated/models/schedule_create_request"
35
+ require_relative "errors"
36
+
37
+ module GhostCrawl
38
+ # ---------------------------------------------------------------------------
39
+ # Fix: microsoft_kiota_serialization_json 0.9.2 has a bug where
40
+ # write_object_value(nil, body) creates a temp writer, serializes body into
41
+ # temp, then RETURNS temp without merging temp's content into self.@writer.
42
+ # set_content_from_parsable calls write_object_value(nil, body) and discards
43
+ # the return value, so @content = {}.to_json = "{}".
44
+ #
45
+ # Fix: monkey-patch write_object_value so that when key is nil, it serializes
46
+ # body into self directly (not a temp writer) — same as what the non-nil branch
47
+ # does, but without a key.
48
+ # ---------------------------------------------------------------------------
49
+
50
+ # @api private
51
+ module KiotaWriterFix
52
+ def write_object_value(key, value)
53
+ return unless value
54
+ if key.nil?
55
+ # Fix: serialize into self, not a temp. Merges all fields into @writer.
56
+ value.serialize(self)
57
+ else
58
+ super
59
+ end
60
+ end
61
+
62
+ # Fix 1b: the pinned Kiota writer's write_collection_of_object_values calls
63
+ # `self.write_object_value(nil, v).writer` on each element — it relies on
64
+ # write_object_value(nil, ...) returning a *writer object* that responds to
65
+ # `.writer`. Our write_object_value override above (Fix 1) changes that
66
+ # nil-key contract to serialize-into-self and return the serialize result
67
+ # (a Hash / nil), so the upstream `.writer` call raises
68
+ # `NoMethodError: undefined method 'writer' for {}:Hash`. That aborts
69
+ # serialize for ANY response model carrying a collection of typed objects
70
+ # (WebhookListResponse#items, etc.), which ResponseHelper.serialize_parsable
71
+ # then swallows -> the facade returns {} and silently drops the whole list.
72
+ #
73
+ # Fix: serialize each element into its OWN fresh writer and collect the raw
74
+ # per-element hashes directly, never depending on the write_object_value
75
+ # return contract. Mirrors the non-nil upstream branch (line 154) without the
76
+ # broken `.writer` deref.
77
+ def write_collection_of_object_values(key, values)
78
+ return unless values
79
+ hashes = values.map do |v|
80
+ temp = MicrosoftKiotaSerializationJson::JsonSerializationWriter.new
81
+ v.serialize(temp)
82
+ temp.writer
83
+ end
84
+ if key.nil?
85
+ hashes
86
+ else
87
+ writer[key] = hashes
88
+ end
89
+ end
90
+
91
+ # Fix 1c: the pinned Kiota writer's write_any_value (used by
92
+ # write_additional_data, i.e. EVERY field AdditionalDataBody sends) has no
93
+ # case for a plain Hash and mis-handles an Array of Hashes:
94
+ # * a Hash hits the `value.is_a? Object` branch -> `return value.to_s`,
95
+ # which returns a string but NEVER assigns @writer[key] -> the whole
96
+ # nested object is SILENTLY DROPPED from the request body.
97
+ # * an Array is sent to write_collection_of_primitive_values, which mangles
98
+ # any non-primitive (Hash) element.
99
+ # This drops/mangles every nested field the facade sends: scrape(extract_schema),
100
+ # extract(schema), crawl/crawl_runs opts, datasets.append(rows: [ {..} ]),
101
+ # schedules.create(job_params: {..}) — the last of which the API REQUIRES,
102
+ # so the request 422s ("job_params required") even though the caller supplied it.
103
+ #
104
+ # Fix: intercept Hash and Array here and write a fully-recursed, JSON-native
105
+ # structure straight into @writer[key]. The writer emits @writer.to_json at the
106
+ # end, so plain nested Hash/Array values serialize correctly. Everything else
107
+ # falls through to the upstream primitive handling.
108
+ def write_any_value(key, value)
109
+ if value.is_a?(Hash) || value.is_a?(Array)
110
+ return super unless key # nil-key arrays keep upstream behavior
111
+ writer[key] = KiotaWriterFix.jsonable(value)
112
+ return
113
+ end
114
+ super
115
+ end
116
+
117
+ # Recursively converts a value into a JSON-native structure (Hash/Array of
118
+ # plain scalars), so it round-trips through @writer.to_json intact.
119
+ # @api private
120
+ def self.jsonable(value)
121
+ case value
122
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = jsonable(v) }
123
+ when Array then value.map { |v| jsonable(v) }
124
+ else value
125
+ end
126
+ end
127
+ end
128
+
129
+ # Patch the writer class once, idempotently
130
+ MicrosoftKiotaSerializationJson::JsonSerializationWriter.prepend(KiotaWriterFix) \
131
+ unless MicrosoftKiotaSerializationJson::JsonSerializationWriter.ancestors.include?(KiotaWriterFix)
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Fix 2: Some generated request builders pass a Module (e.g. `Binary`) as
135
+ # the factory to `send_async`. The parse node's `get_object_value(factory)`
136
+ # calls `factory.call(self)`, which fails for a Module.
137
+ #
138
+ # Fix: when factory doesn't respond to `call`, fall back to parsing the
139
+ # response body as JSON and returning the raw hash. This gives us plain
140
+ # Ruby hashes (which ResponseHelper.to_hash handles correctly) without
141
+ # requiring generated response models.
142
+ # ---------------------------------------------------------------------------
143
+
144
+ # @api private
145
+ module KiotaParseNodeFix
146
+ def get_object_value(_factory)
147
+ # The ghostcrawl SDK returns plain Ruby hashes and deliberately does NOT
148
+ # rely on the generated typed response models (see the module comment and
149
+ # ResponseHelper below). Several 200 schemas — notably scrape_result — model
150
+ # their fields as composed anyOf members (identity_id, results, status,
151
+ # format, markdown, ...). Kiota's Ruby json deserializer either raises
152
+ # NoMethodError on those composed members (surfaced as the opaque
153
+ # "Error during deserialization") OR the subsequent typed->serialize
154
+ # round-trip in ResponseHelper drops the raw-hash field values, returning a
155
+ # gutted body (e.g. scrape returning only {routing_mode, request_class} with
156
+ # the whole `results`/`html` payload lost).
157
+ #
158
+ # @current_node is already the fully-parsed Ruby Hash/Array/primitive for
159
+ # this node, so return it directly for every factory (callable typed models
160
+ # included). ResponseHelper.to_hash then recurses it into a plain nested
161
+ # Hash. Error responses are unaffected: Kiota applies the builder's
162
+ # error_mapping (raising a typed ApiError) BEFORE the 2xx success factory
163
+ # ever reaches this method.
164
+ @current_node
165
+ rescue StandardError
166
+ super
167
+ end
168
+ end
169
+
170
+ MicrosoftKiotaSerializationJson::JsonParseNode.prepend(KiotaParseNodeFix) \
171
+ unless MicrosoftKiotaSerializationJson::JsonParseNode.ancestors.include?(KiotaParseNodeFix)
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # AdditionalDataBody — a minimal Parsable that wraps an arbitrary Hash.
175
+ #
176
+ # Used for all POST bodies. The generated models set typed defaults in their
177
+ # constructors that would be serialized as null/empty-enum values and cause
178
+ # 422 validation errors. AdditionalDataBody only serializes the fields we
179
+ # explicitly pass, producing clean JSON.
180
+ #
181
+ # Depends on KiotaWriterFix above to correctly handle the nil-key
182
+ # write_object_value(nil, body) call from set_content_from_parsable.
183
+ # ---------------------------------------------------------------------------
184
+
185
+ # @api private
186
+ class AdditionalDataBody
187
+ include MicrosoftKiotaAbstractions::Parsable
188
+
189
+ def initialize(data = {})
190
+ @data = data.transform_keys(&:to_s)
191
+ end
192
+
193
+ def get_field_deserializers
194
+ {}
195
+ end
196
+
197
+ def serialize(writer)
198
+ writer.write_additional_data(@data)
199
+ end
200
+
201
+ def additional_data
202
+ @data
203
+ end
204
+
205
+ def additional_data=(hash)
206
+ @data = hash.transform_keys(&:to_s)
207
+ end
208
+
209
+ def self.create_from_discriminator_value(_parse_node)
210
+ AdditionalDataBody.new
211
+ end
212
+ end
213
+ end
214
+
215
+ module GhostCrawl
216
+ DEFAULT_BASE_URL = "https://api.ghostcrawl.io"
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Static bearer token provider — implements AccessTokenProvider
220
+ # ---------------------------------------------------------------------------
221
+
222
+ # @api private
223
+ class StaticTokenProvider
224
+ include MicrosoftKiotaAbstractions::AccessTokenProvider
225
+
226
+ def initialize(token)
227
+ @token = token
228
+ end
229
+
230
+ # The Kiota Faraday adapter calls .resume on the return value of this method,
231
+ # expecting a Fiber. Wrap the token in a Fiber to satisfy the contract.
232
+ def get_authorization_token(_uri, _additional_authentication_context = nil)
233
+ token = @token
234
+ Fiber.new { token }
235
+ end
236
+
237
+ def get_allowed_hosts_validator
238
+ MicrosoftKiotaAbstractions::AllowedHostsValidator.new([])
239
+ end
240
+ end
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Response helpers — convert Kiota Parsable/Hash responses to plain Hash
244
+ # ---------------------------------------------------------------------------
245
+
246
+ # @api private
247
+ module ResponseHelper
248
+ # Converts any Kiota response value to a plain Hash or Array.
249
+ # The Kiota Faraday adapter returns Fibers for async responses; call .resume
250
+ # to execute the request synchronously and get the actual response value.
251
+ def self.to_hash(value)
252
+ # Resolve Fibers (the Kiota Faraday adapter returns a Fiber for every
253
+ # request; .resume is what actually executes the HTTP call). Any non-2xx
254
+ # surfaces here as a raw transport exception — translate it into a typed
255
+ # GhostCrawl error so the documented rescue contract works.
256
+ if value.is_a?(Fiber)
257
+ begin
258
+ value = value.resume
259
+ rescue GhostCrawl::GhostCrawlError
260
+ raise
261
+ rescue StandardError => e
262
+ GhostCrawl.raise_translated(e)
263
+ end
264
+ end
265
+
266
+ case value
267
+ when Hash
268
+ value.transform_values { |v| to_hash(v) }
269
+ when Array
270
+ value.map { |v| to_hash(v) }
271
+ when NilClass
272
+ {}
273
+ else
274
+ # Typed Parsable (Kiota-generated model). Kiota models keep their spec
275
+ # fields in typed instance variables, NOT in +additional_data+ (which is
276
+ # only the overflow bucket for unmapped keys). Reading +additional_data+
277
+ # alone therefore returns +{}+ for a fully-typed response (e.g.
278
+ # MapResponse{@links,@success}, WebhookListResponse{@items,@total}),
279
+ # silently dropping the real payload.
280
+ #
281
+ # Recover the typed fields by round-tripping the model through its own
282
+ # +serialize+ (the Parsable contract) into a JSON writer and parsing the
283
+ # result back to a plain Hash. Then overlay any +additional_data+ (unmapped
284
+ # keys the server sent that the model didn't declare). Degrade gracefully:
285
+ # a model whose +serialize+ raises (e.g. an unresolved composed-type member)
286
+ # falls back to the prior additional_data/to_h behavior so nothing regresses.
287
+ parsable_to_hash(value)
288
+ end
289
+ end
290
+
291
+ # Converts a typed Kiota Parsable into a plain Hash by serializing it via its
292
+ # own +serialize+ and parsing the emitted JSON, then merging +additional_data+.
293
+ # Falls back to +additional_data+ / +to_h+ / the value itself on any failure.
294
+ # @api private
295
+ def self.parsable_to_hash(value)
296
+ serialized = serialize_parsable(value)
297
+ if serialized.is_a?(Hash)
298
+ extra = (value.additional_data if value.respond_to?(:additional_data)) || {}
299
+ merged = serialized.merge(extra.transform_keys(&:to_s))
300
+ return merged.transform_values { |v| to_hash(v) }
301
+ end
302
+
303
+ if value.respond_to?(:additional_data) && value.additional_data && !value.additional_data.empty?
304
+ value.additional_data.transform_values { |v| to_hash(v) }
305
+ elsif value.respond_to?(:to_h)
306
+ value.to_h.transform_values { |v| to_hash(v) }
307
+ elsif value.respond_to?(:additional_data) && value.additional_data
308
+ value.additional_data.transform_values { |v| to_hash(v) }
309
+ else
310
+ value
311
+ end
312
+ end
313
+
314
+ # Serializes a Parsable to a Ruby Hash using the pinned Kiota JSON writer.
315
+ # Returns the parsed Hash, or +nil+ when the model isn't a serializable
316
+ # Parsable or serialization/parse fails (caller then falls back).
317
+ # @api private
318
+ def self.serialize_parsable(value)
319
+ return nil unless value.respond_to?(:serialize)
320
+
321
+ writer = MicrosoftKiotaSerializationJson::JsonSerializationWriter.new
322
+ value.serialize(writer)
323
+ content = writer.get_serialized_content
324
+ json = content.is_a?(String) ? content : content.read
325
+ return nil if json.nil? || json.empty?
326
+
327
+ parsed = JSON.parse(json)
328
+ parsed.is_a?(Hash) ? parsed : nil
329
+ rescue StandardError
330
+ nil
331
+ end
332
+
333
+ # Executes a request that returns NO response body (an HTTP 204, or any DELETE
334
+ # whose spec maps the success response to +void+) and returns a plain Hash.
335
+ #
336
+ # Why this exists — two upstream defects in the pinned Kiota Ruby runtime that
337
+ # make the generated void-DELETE builders unusable:
338
+ #
339
+ # 1. The generated +delete+ for a void response calls
340
+ # +send_async(request_info, nil, ...)+, and the Faraday adapter hard-raises
341
+ # +"factory cannot be null"+ BEFORE the request is ever sent — so the DELETE
342
+ # never reaches the server and the caller gets a bare +StandardError+
343
+ # (not a {GhostCrawlError}).
344
+ # 2. For void DELETEs the adapter DOES send maps to +Binary+, the response is an
345
+ # empty 204 body, and +get_root_parse_node+ then feeds +""+ to the JSON parser,
346
+ # raising +JSON::ParserError+ on a perfectly successful delete.
347
+ #
348
+ # This helper reuses the adapter's OWN public request pipeline
349
+ # (+convert_to_native_request_async+ applies base-url + auth, +run_request+
350
+ # sends it) but skips body deserialization entirely: a 2xx returns +{}+ (or the
351
+ # decoded JSON when the server did send a body, e.g. +{"deleted":true}+), and a
352
+ # >=400 is translated into the documented typed {GhostCrawlError} hierarchy.
353
+ #
354
+ # @param adapter [MicrosoftKiotaFaraday::FaradayRequestAdapter]
355
+ # @param request_info [MicrosoftKiotaAbstractions::RequestInformation]
356
+ # @return [Hash] +{}+ on an empty 2xx, or the decoded body when one is present
357
+ # @api private
358
+ def self.void_request!(adapter, request_info)
359
+ # Replicate the first half of the adapter's send_async by hand — apply the
360
+ # base URL, run the bearer-auth Fiber (which mutates request_info's headers),
361
+ # then build the native Faraday request. We deliberately do NOT call the
362
+ # adapter's convert_to_native_request_async helper: its body uses a bare
363
+ # +return+ inside a Fiber, which raises LocalJumpError("unexpected return")
364
+ # when resumed under Ruby 3.x.
365
+ request_info.path_parameters["baseurl"] = adapter.get_base_url
366
+ # authenticate_request returns a Fiber only when the Authorization header is
367
+ # not already present; it returns nil otherwise. Guard the resume.
368
+ auth_fiber = adapter.authentication_provider.authenticate_request(request_info)
369
+ auth_fiber.resume if auth_fiber.respond_to?(:resume)
370
+ request = adapter.get_request_from_request_info(request_info)
371
+ response = adapter.client.run_request(
372
+ request.http_method, request.path, request.body, request.headers
373
+ )
374
+
375
+ status = response.status
376
+ if status >= 400
377
+ # Reuse the typed-error translation: synthesize a message carrying the
378
+ # ":<status>" token raise_translated keys off, plus any body text.
379
+ body = response.body.to_s
380
+ err = MicrosoftKiotaAbstractions::ApiError.new(
381
+ "The server returned an unexpected status code:#{status}" \
382
+ "#{body.empty? ? '' : " #{body}"}"
383
+ )
384
+ GhostCrawl.raise_translated(err)
385
+ end
386
+
387
+ body = response.body.to_s
388
+ return {} if body.strip.empty?
389
+
390
+ parsed = (JSON.parse(body) rescue nil)
391
+ parsed.is_a?(Hash) ? parsed.transform_values { |v| to_hash(v) } : {}
392
+ rescue GhostCrawl::GhostCrawlError
393
+ raise
394
+ rescue StandardError => e
395
+ GhostCrawl.raise_translated(e)
396
+ end
397
+
398
+ # Executes a request whose success response is RAW BINARY (e.g. the
399
+ # +application/pdf+ body from +POST /v1/pdf+) and returns the bytes verbatim.
400
+ #
401
+ # Mirrors {void_request!}: it reuses the adapter's own request pipeline
402
+ # (base URL + bearer auth via +authenticate_request+, then +run_request+) but
403
+ # deliberately skips Kiota body deserialization — the response is not JSON, so
404
+ # there is no parse node to build. A 2xx returns the raw response body (an
405
+ # ASCII-8BIT String of bytes, ready to write to a file); a >=400 is translated
406
+ # into the documented typed {GhostCrawlError} hierarchy (the problem+json
407
+ # +detail+ rides along in the message).
408
+ #
409
+ # @param adapter [MicrosoftKiotaFaraday::FaradayRequestAdapter]
410
+ # @param request_info [MicrosoftKiotaAbstractions::RequestInformation]
411
+ # @return [String] the raw response bytes (ASCII-8BIT)
412
+ # @api private
413
+ def self.binary_request!(adapter, request_info)
414
+ request_info.path_parameters["baseurl"] = adapter.get_base_url
415
+ auth_fiber = adapter.authentication_provider.authenticate_request(request_info)
416
+ auth_fiber.resume if auth_fiber.respond_to?(:resume)
417
+ request = adapter.get_request_from_request_info(request_info)
418
+ response = adapter.client.run_request(
419
+ request.http_method, request.path, request.body, request.headers
420
+ )
421
+
422
+ status = response.status
423
+ if status >= 400
424
+ body = response.body.to_s
425
+ err = MicrosoftKiotaAbstractions::ApiError.new(
426
+ "The server returned an unexpected status code:#{status}" \
427
+ "#{body.empty? ? '' : " #{body}"}"
428
+ )
429
+ GhostCrawl.raise_translated(err)
430
+ end
431
+
432
+ response.body.to_s.b
433
+ rescue GhostCrawl::GhostCrawlError
434
+ raise
435
+ rescue StandardError => e
436
+ GhostCrawl.raise_translated(e)
437
+ end
438
+
439
+ # Inspects a decoded HTTP-200 response hash for a RESULT-channel failure (the
440
+ # target page could not be scraped) and raises {GhostCrawl::ScrapeError} when
441
+ # one is present. This is the reliable, highest-value error path: the body is
442
+ # always available here (unlike the dropped problem+json body on non-2xx).
443
+ #
444
+ # A failure is signalled by any of:
445
+ # * a +result_error+ Hash carrying a +code+
446
+ # * +ok+ explicitly +false+ (always a failure, even with no code)
447
+ # * a top-level +code+ that is a known RESULT-channel code
448
+ #
449
+ # A genuinely OK hash (ok: true, no +ok+ key, or no error code) is returned
450
+ # untouched and never raises.
451
+ #
452
+ # @param hash [Hash] the decoded response
453
+ # @return [Hash] the same hash, when it is not a failure
454
+ # @api private
455
+ def self.raise_on_result_error!(hash)
456
+ return hash unless hash.is_a?(Hash)
457
+
458
+ # Descend into a `results` envelope (scrape/extract wrap per-URL results) —
459
+ # the target failure lives on the INNER result, not the envelope top level.
460
+ inner = hash["results"]
461
+ if inner.is_a?(Array)
462
+ inner.each { |item| raise_on_result_error!(item) }
463
+ return hash
464
+ end
465
+
466
+ result_error = hash["result_error"]
467
+ result_error = nil unless result_error.is_a?(Hash)
468
+ top_code = hash["code"]
469
+ ok_false = hash["ok"] == false
470
+
471
+ # Pull the code: result_error wins, then a top-level RESULT-channel code.
472
+ code = nil
473
+ code = result_error["code"] if result_error
474
+ code ||= top_code if GhostCrawl::ErrorCodes.result_channel?(top_code)
475
+
476
+ # The flat markdown-build envelope reports a target failure ONLY via
477
+ # status="failed" (no ok/result_error) — don't count it as a success.
478
+ status_failed = hash["status"] == "failed"
479
+ code ||= top_code if status_failed && top_code.is_a?(String)
480
+
481
+ # Only raise when there is a concrete result-channel failure signal.
482
+ return hash unless code || ok_false || status_failed
483
+
484
+ # ok: false with no usable code -> treat as empty/unusable content.
485
+ code ||= GhostCrawl::ErrorCodes::EMPTY_CONTENT
486
+
487
+ retryable =
488
+ if result_error && result_error.key?("retryable")
489
+ result_error["retryable"]
490
+ else
491
+ GhostCrawl::ErrorCodes::RETRYABLE.fetch(code, false)
492
+ end
493
+
494
+ target_status = nil
495
+ target_status = result_error["target_status"] if result_error
496
+ target_status ||= hash["target_status"] # flat markdown-envelope path
497
+ reason = result_error && result_error["reason"]
498
+
499
+ msg = "scrape failed (#{code})"
500
+ msg += ": #{reason}" if reason && !reason.to_s.empty?
501
+ msg += " (target HTTP #{target_status})" if target_status
502
+
503
+ raise GhostCrawl::ScrapeError.new(
504
+ msg,
505
+ status_code: 200,
506
+ body: nil,
507
+ code: code,
508
+ retryable: retryable,
509
+ request_id: hash["request_id"],
510
+ target_status: target_status
511
+ )
512
+ end
513
+ end
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # Sub-clients — each delegates to the generated v1 request builders
517
+ # ---------------------------------------------------------------------------
518
+
519
+ # Manage crawl runs — /v1/crawl-runs.
520
+ class CrawlRunsClient
521
+ # Terminal run states — a crawl run in any of these will never change again,
522
+ # so a wait can stop. Both spellings of "cancelled" are accepted.
523
+ TERMINAL_STATUSES = %w[completed failed cancelled canceled].freeze
524
+
525
+ # Per-request server-block window (seconds) for the long-poll wait. Each
526
+ # +GET ...?wait=true&timeout_s=WAIT_WINDOW_S+ makes the SERVER block for up
527
+ # to this long, so the client never sleeps between checks. Kept well under
528
+ # the client read timeout ({Client::DEFAULT_TIMEOUT}) so a single blocking
529
+ # request can never trip the transport timeout.
530
+ WAIT_WINDOW_S = 30
531
+
532
+ def initialize(v1, adapter = nil)
533
+ @v1 = v1
534
+ @adapter = adapter
535
+ end
536
+
537
+ # Start a new crawl run from a seed URL.
538
+ # Delegates to POST /v1/crawl-runs via the generated CrawlRunsRequestBuilder.
539
+ # The endpoint is a tagged union: a start request requires +action: "start"+
540
+ # and a +seed_urls+ array (not a bare +url+).
541
+ #
542
+ # Pass +wait: true+ to block until the run reaches a terminal state
543
+ # (completed | failed | cancelled) or +timeout+ seconds elapse. This sends
544
+ # +wait_until: "completed"+ so the SERVER blocks — no client-side poll loop,
545
+ # no +sleep+. When the run is still running at +timeout+ the current
546
+ # (non-terminal) record is returned; call {#wait_for_completion} again to
547
+ # keep waiting.
548
+ #
549
+ # @param url [String] seed URL
550
+ # @param max_depth [Integer] maximum crawl depth (default 2)
551
+ # @param max_pages [Integer] maximum pages (default 100)
552
+ # @param wait [Boolean] block until the run is terminal (default false)
553
+ # @param timeout [Integer] total seconds to wait when +wait: true+ (default 300)
554
+ # @param raise_on_result_error [Boolean] raise {GhostCrawl::ScrapeError} on a
555
+ # target-side (HTTP-200) failure instead of returning the raw hash (default true).
556
+ # Ignored on the +wait: true+ path, which always returns the terminal run so
557
+ # the caller can inspect a +failed+ status.
558
+ # @return [Hash] crawl run record with +run_id+ and +status+ (results present
559
+ # when it completed while waiting)
560
+ def start(url:, max_depth: 2, max_pages: 100, wait: false, timeout: 300,
561
+ raise_on_result_error: true, **opts)
562
+ data = { "action" => "start", "seed_urls" => [url],
563
+ "max_depth" => max_depth, "max_pages" => max_pages }
564
+ .merge(opts.transform_keys(&:to_s))
565
+
566
+ unless wait
567
+ hash = ResponseHelper.to_hash(@v1.crawl_runs.post(AdditionalDataBody.new(data)))
568
+ return raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
569
+ end
570
+
571
+ # Start-and-wait: ask the server to block until terminal. Bound the first
572
+ # server block to a safe window, then long-poll the rest via GET so a low
573
+ # per-request read timeout can never abort the whole wait.
574
+ window = [timeout, WAIT_WINDOW_S].min
575
+ data["wait_until"] = "completed"
576
+ data["timeout_s"] = window
577
+ hash = ResponseHelper.to_hash(@v1.crawl_runs.post(AdditionalDataBody.new(data)))
578
+ return hash if terminal?(hash)
579
+
580
+ run_id = hash["run_id"]
581
+ remaining = timeout - window
582
+ return hash if run_id.nil? || run_id.to_s.empty? || remaining <= 0
583
+ wait_for_completion(run_id, timeout: remaining)
584
+ end
585
+
586
+ # Block until an existing crawl run reaches a terminal state, or +timeout+
587
+ # seconds elapse. Event-driven: each iteration issues a SERVER-blocking
588
+ # +GET /v1/crawl-runs/{run_id}?wait=true&timeout_s=N+ that returns the moment
589
+ # the run goes terminal (or after its own window). There is no client +sleep+
590
+ # — the wait cost lives on the server, and successive blocking windows are
591
+ # chained until the caller's +timeout+ deadline.
592
+ #
593
+ # On timeout the current non-terminal run is returned (never raises for a
594
+ # slow run); a terminal run is returned as soon as it is observed.
595
+ #
596
+ # @param run_id [String] the run to wait on
597
+ # @param timeout [Integer] total seconds to wait (default 300)
598
+ # @return [Hash] the run record (terminal when it finished in time)
599
+ def wait_for_completion(run_id, timeout: 300)
600
+ if run_id.nil? || run_id.to_s.empty?
601
+ raise ArgumentError, "run_id is required"
602
+ end
603
+
604
+ deadline = monotonic + timeout
605
+ loop do
606
+ remaining = deadline - monotonic
607
+ # Deadline reached — one final non-blocking read of the current state.
608
+ return get(run_id) if remaining <= 0
609
+
610
+ window = [remaining, WAIT_WINDOW_S].min
611
+ run = fetch_waiting(run_id, window)
612
+ return run if terminal?(run)
613
+ # The server just blocked for ~window seconds; loop straight into the
614
+ # next blocking window. No client-side delay.
615
+ end
616
+ end
617
+
618
+ # List crawl runs.
619
+ # Delegates to GET /v1/crawl-runs via the generated CrawlRunsRequestBuilder.
620
+ def list
621
+ ResponseHelper.to_hash(@v1.crawl_runs.get)
622
+ end
623
+
624
+ # Get a single crawl run by ID.
625
+ # Delegates to GET /v1/crawl-runs/{run_id} via the generated builder.
626
+ def get(run_id)
627
+ ResponseHelper.to_hash(@v1.crawl_runs.by_run_id(run_id).get)
628
+ end
629
+
630
+ # Cancel a running crawl run.
631
+ # Delegates to POST /v1/crawl-runs/{run_id}/cancel via the generated builder.
632
+ def cancel(run_id)
633
+ ResponseHelper.to_hash(@v1.crawl_runs.by_run_id(run_id).cancel.post)
634
+ end
635
+
636
+ private
637
+
638
+ # True when +run+ is a Hash carrying a terminal +status+.
639
+ def terminal?(run)
640
+ run.is_a?(Hash) && TERMINAL_STATUSES.include?(run["status"].to_s)
641
+ end
642
+
643
+ # Issues one SERVER-blocking GET on the run with +?wait=true&timeout_s=N+.
644
+ #
645
+ # The generated item builder's +to_get_request_information+ does not wire
646
+ # query parameters (its URL template has no query placeholder), so we take
647
+ # the request it builds, extend the template with +{?wait,timeout_s}+, and
648
+ # set the params before sending through the raw adapter — reusing the
649
+ # Binary (raw-JSON) response factory the generated builders already use.
650
+ def fetch_waiting(run_id, timeout_s)
651
+ request_info = @v1.crawl_runs.by_run_id(run_id).to_get_request_information(nil)
652
+ request_info.url_template = "{+baseurl}/v1/crawl-runs/{run_id}{?wait,timeout_s}"
653
+ request_info.query_parameters["wait"] = "true"
654
+ request_info.query_parameters["timeout_s"] = timeout_s.to_i.to_s
655
+ ResponseHelper.to_hash(@adapter.send_async(request_info, GhostCrawl::V1::Binary, {}))
656
+ end
657
+
658
+ # Monotonic clock (seconds) — immune to wall-clock adjustments during a wait.
659
+ def monotonic
660
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
661
+ end
662
+ end
663
+
664
+ # Manage browser sessions — /v1/sessions.
665
+ class SessionsClient
666
+ def initialize(v1)
667
+ @v1 = v1
668
+ end
669
+
670
+ # List all active sessions.
671
+ # Delegates to GET /v1/sessions via the generated SessionsRequestBuilder.
672
+ def list
673
+ ResponseHelper.to_hash(@v1.sessions.get)
674
+ end
675
+
676
+ # Create a new browser session.
677
+ # Delegates to POST /v1/sessions/create via the generated builder.
678
+ # @param profile_name [String] identity profile to use
679
+ def create(profile_name:, **opts)
680
+ data = { "profile" => profile_name }.merge(opts.transform_keys(&:to_s))
681
+ ResponseHelper.to_hash(@v1.sessions.create.post(AdditionalDataBody.new(data)))
682
+ end
683
+
684
+ # Extend a session's TTL.
685
+ # Delegates to POST /v1/sessions/{id}/extend via the generated builder.
686
+ def extend(session_id, duration_seconds: 300)
687
+ ResponseHelper.to_hash(@v1.sessions.by_profile_id(session_id).extend.post(
688
+ AdditionalDataBody.new({ "ttl_seconds" => duration_seconds })
689
+ ))
690
+ end
691
+
692
+ # Release a session back to the pool.
693
+ # Delegates to POST /v1/sessions/{id}/release via the generated builder.
694
+ def release(session_id)
695
+ ResponseHelper.to_hash(@v1.sessions.by_profile_id(session_id).release.post)
696
+ end
697
+ end
698
+
699
+ # Manage identity profiles — /v1/profiles.
700
+ class ProfilesClient
701
+ def initialize(v1, adapter = nil)
702
+ @v1 = v1
703
+ @adapter = adapter
704
+ end
705
+
706
+ # List all profiles.
707
+ # Delegates to GET /v1/profiles via the generated ProfilesRequestBuilder.
708
+ def list
709
+ ResponseHelper.to_hash(@v1.profiles.get)
710
+ end
711
+
712
+ # Get a profile by name.
713
+ # Delegates to GET /v1/profiles/{name} via the generated builder.
714
+ def get(name)
715
+ ResponseHelper.to_hash(@v1.profiles.by_name(name).get)
716
+ end
717
+
718
+ # Create a new profile.
719
+ # Delegates to POST /v1/profiles via the generated ProfilesRequestBuilder.
720
+ def create(name:, **config)
721
+ body = AdditionalDataBody.new({ "name" => name }.merge(config.transform_keys(&:to_s)))
722
+ ResponseHelper.to_hash(@v1.profiles.post(body))
723
+ end
724
+
725
+ # Update a profile.
726
+ # Delegates to PUT /v1/profiles/{name} via the generated builder.
727
+ def update(name, **config)
728
+ ResponseHelper.to_hash(@v1.profiles.by_name(name).put(AdditionalDataBody.new(config.transform_keys(&:to_s))))
729
+ end
730
+
731
+ # Delete a profile.
732
+ # Delegates to DELETE /v1/profiles/{name} via the generated builder.
733
+ # The endpoint answers 204 No Content; routing through {ResponseHelper.void_request!}
734
+ # avoids the Kiota JSON parser choking on the empty body.
735
+ def delete(name)
736
+ ResponseHelper.void_request!(@adapter, @v1.profiles.by_name(name).to_delete_request_information(nil))
737
+ end
738
+ end
739
+
740
+ # Manage webhooks — /v1/webhooks.
741
+ class WebhooksClient
742
+ def initialize(v1, adapter = nil)
743
+ @v1 = v1
744
+ @adapter = adapter
745
+ end
746
+
747
+ # List all webhooks.
748
+ # Delegates to GET /v1/webhooks via the generated WebhooksRequestBuilder.
749
+ def list
750
+ ResponseHelper.to_hash(@v1.webhooks.get)
751
+ end
752
+
753
+ # Get a webhook by ID.
754
+ # Delegates to GET /v1/webhooks/{id} via the generated builder.
755
+ def get(webhook_id)
756
+ ResponseHelper.to_hash(@v1.webhooks.by_webhook_id(webhook_id).get)
757
+ end
758
+
759
+ # Register a new webhook endpoint.
760
+ # Delegates to POST /v1/webhooks via the generated WebhooksRequestBuilder.
761
+ def create(url:, event_types: nil, events: nil, **opts)
762
+ data = { "url" => url }.merge(opts.transform_keys(&:to_s))
763
+ # API field is "event_types"; "events" kept as a back-compat alias.
764
+ et = event_types.nil? ? events : event_types
765
+ data["event_types"] = et unless et.nil?
766
+ ResponseHelper.to_hash(@v1.webhooks.post(AdditionalDataBody.new(data)))
767
+ end
768
+
769
+ # Delete a webhook.
770
+ # Delegates to DELETE /v1/webhooks/{id}. The generated builder passes a +nil+
771
+ # response factory (204 void), which the Kiota adapter rejects with a bare
772
+ # StandardError BEFORE sending the request — so we build the request info and
773
+ # run it through {ResponseHelper.void_request!} instead, which actually fires
774
+ # the DELETE and returns +{}+ on success.
775
+ def delete(webhook_id)
776
+ ResponseHelper.void_request!(@adapter, @v1.webhooks.by_webhook_id(webhook_id).to_delete_request_information(nil))
777
+ end
778
+
779
+ # Rotate the signing secret for a webhook.
780
+ # Delegates to POST /v1/webhooks/{id}/rotate-secret via the generated builder.
781
+ def rotate_secret(webhook_id)
782
+ ResponseHelper.to_hash(@v1.webhooks.by_webhook_id(webhook_id).rotate_secret.post)
783
+ end
784
+ end
785
+
786
+ # Manage schedules — /v1/schedules.
787
+ class SchedulesClient
788
+ def initialize(v1, adapter = nil)
789
+ @v1 = v1
790
+ @adapter = adapter
791
+ end
792
+
793
+ # List all schedules.
794
+ # Delegates to GET /v1/schedules via the generated SchedulesRequestBuilder.
795
+ def list
796
+ ResponseHelper.to_hash(@v1.schedules.get)
797
+ end
798
+
799
+ # Get a schedule by ID.
800
+ # Delegates to GET /v1/schedules/{id} via the generated builder.
801
+ def get(schedule_id)
802
+ ResponseHelper.to_hash(@v1.schedules.by_schedule_id(schedule_id).get)
803
+ end
804
+
805
+ # Create a new schedule.
806
+ # Delegates to POST /v1/schedules via the generated SchedulesRequestBuilder.
807
+ #
808
+ # The API's ScheduleCreateRequest requires +name+, +job_type+
809
+ # ("scrape" | "crawl" | "change_monitor"), +cron_expr+, and +job_params+
810
+ # (the full scrape/crawl request body). It rejects any other top-level field
811
+ # with a 422 — so a legacy +task+ key must be TRANSLATED, never forwarded.
812
+ #
813
+ # @param cron [String] cron expression (sent as +cron_expr+)
814
+ # @param name [String] schedule name (required by the API)
815
+ # @param job_type [String, nil] "scrape" | "crawl" | "change_monitor"
816
+ # @param job_params [Hash, nil] full job request body (e.g. { url: ... })
817
+ # @param task [Hash, nil] DEPRECATED legacy shape { "action" => ..., ...rest };
818
+ # when given (and job_type/job_params are absent) it is split into
819
+ # +job_type+ (from +task["action"]+) and +job_params+ (the remaining keys).
820
+ def create(cron:, name: nil, job_type: nil, job_params: nil, task: nil, **opts)
821
+ # Back-compat: derive job_type/job_params from a legacy `task` hash.
822
+ if task.is_a?(Hash)
823
+ t = task.transform_keys(&:to_s)
824
+ job_type ||= t["action"] || t["job_type"] || t["type"]
825
+ job_params ||= t.reject { |k, _| %w[action job_type type].include?(k) }
826
+ end
827
+
828
+ data = { "cron_expr" => cron }
829
+ data["name"] = name unless name.nil?
830
+ data["job_type"] = job_type unless job_type.nil?
831
+ data["job_params"] = job_params unless job_params.nil?
832
+ # opts may override/supply name/job_type/job_params/notify_webhook/monitor_mode.
833
+ # Never forward a raw `task` field — the API 422s on it.
834
+ data.merge!(opts.transform_keys(&:to_s).reject { |k, _| k == "task" })
835
+
836
+ ResponseHelper.to_hash(@v1.schedules.post(AdditionalDataBody.new(data)))
837
+ end
838
+
839
+ # Delete a schedule.
840
+ # Delegates to DELETE /v1/schedules/{id}. The generated builder returns a Fiber
841
+ # that must be resumed to actually send the request (the old +; {}+ discarded it,
842
+ # so the DELETE never fired). {ResponseHelper.void_request!} runs it and tolerates
843
+ # the empty 204 body.
844
+ def delete(schedule_id)
845
+ ResponseHelper.void_request!(@adapter, @v1.schedules.by_schedule_id(schedule_id).to_delete_request_information(nil))
846
+ end
847
+ end
848
+
849
+ # Manage datasets — /v1/datasets.
850
+ class DatasetsClient
851
+ def initialize(v1)
852
+ @v1 = v1
853
+ end
854
+
855
+ # List all datasets.
856
+ # Delegates to GET /v1/datasets via the generated DatasetsRequestBuilder.
857
+ def list
858
+ ResponseHelper.to_hash(@v1.datasets.get)
859
+ end
860
+
861
+ # Get a dataset by name.
862
+ # Delegates to GET /v1/datasets/{name} via the generated builder.
863
+ def get(name)
864
+ ResponseHelper.to_hash(@v1.datasets.by_name(name).get)
865
+ end
866
+
867
+ # Create a new dataset.
868
+ # Delegates to POST /v1/datasets via the generated DatasetsRequestBuilder.
869
+ def create(name:, **opts)
870
+ body = AdditionalDataBody.new({ "name" => name }.merge(opts.transform_keys(&:to_s)))
871
+ ResponseHelper.to_hash(@v1.datasets.post(body))
872
+ end
873
+
874
+ # Delete a dataset.
875
+ # Delegates to DELETE /v1/datasets/{name} via the generated builder.
876
+ def delete(name)
877
+ ResponseHelper.to_hash(@v1.datasets.by_name(name).delete)
878
+ end
879
+
880
+ # Get rows from a dataset.
881
+ # Delegates to GET /v1/datasets/{name}/rows via the generated builder.
882
+ def rows(name)
883
+ ResponseHelper.to_hash(@v1.datasets.by_name(name).rows.get)
884
+ end
885
+
886
+ # Append rows to a dataset.
887
+ # Delegates to POST /v1/datasets/{name}/rows/append via the generated builder.
888
+ def append(name, rows)
889
+ body = AdditionalDataBody.new({ "rows" => rows })
890
+ ResponseHelper.to_hash(@v1.datasets.by_name(name).rows.append.post(body))
891
+ end
892
+ end
893
+
894
+ # Manage session recordings — /v1/recordings.
895
+ class RecordingsClient
896
+ def initialize(v1, adapter = nil)
897
+ @v1 = v1
898
+ @adapter = adapter
899
+ end
900
+
901
+ # List all recordings.
902
+ # Delegates to GET /v1/recordings via the generated RecordingsRequestBuilder.
903
+ def list
904
+ ResponseHelper.to_hash(@v1.recordings.get)
905
+ end
906
+
907
+ # Get a recording by ID.
908
+ # Delegates to GET /v1/recordings/{id} via the generated builder.
909
+ # (The generated accessor is +by_recording_id+, single underscore.)
910
+ def get(recording_id)
911
+ ResponseHelper.to_hash(@v1.recordings.by_recording_id(recording_id).get)
912
+ end
913
+
914
+ # Delete a recording.
915
+ # Delegates to DELETE /v1/recordings/{id}. Same void-DELETE defect as webhooks
916
+ # (generated builder passes a +nil+ factory → adapter raises before sending), so
917
+ # we route through {ResponseHelper.void_request!}.
918
+ def delete(recording_id)
919
+ ResponseHelper.void_request!(@adapter, @v1.recordings.by_recording_id(recording_id).to_delete_request_information(nil))
920
+ end
921
+ end
922
+
923
+ # Key-value store — /v1/kv.
924
+ class KVClient
925
+ def initialize(v1)
926
+ @v1 = v1
927
+ end
928
+
929
+ # Get a value by key.
930
+ # Delegates to GET /v1/kv/{key} via the generated KvRequestBuilder.
931
+ def get(key)
932
+ ResponseHelper.to_hash(@v1.kv.by_key(key).get)
933
+ end
934
+
935
+ # Set a key-value pair.
936
+ # Delegates to PUT /v1/kv/{key} via the generated builder.
937
+ def set(key, value)
938
+ body = AdditionalDataBody.new({ "value" => value })
939
+ ResponseHelper.to_hash(@v1.kv.by_key(key).put(body))
940
+ end
941
+
942
+ # Delete a key.
943
+ # Delegates to DELETE /v1/kv/{key} via the generated builder.
944
+ def delete(key)
945
+ ResponseHelper.to_hash(@v1.kv.by_key(key).delete)
946
+ end
947
+ end
948
+
949
+ # ---------------------------------------------------------------------------
950
+ # Main facade — Client
951
+ # ---------------------------------------------------------------------------
952
+
953
+ # GhostCrawl idiomatic API client.
954
+ #
955
+ # Delegates all HTTP transport, URL routing, serialization, and auth to the
956
+ # Kiota-generated canonical core (_generated/). This facade is the shipped API.
957
+ #
958
+ # @example
959
+ # require "ghostcrawl"
960
+ # client = GhostCrawl::Client.new(token: "gck_live_YOUR_KEY")
961
+ # result = client.scrape(url: "https://example.com")
962
+ class Client
963
+ # Default read timeout (seconds). Browser-rendered scrapes/crawls are slow,
964
+ # and the underlying net/http default (60s) is too short for them.
965
+ DEFAULT_TIMEOUT = 300
966
+
967
+ # @param token [String, nil] API key. Falls back to +GHOSTCRAWL_API_KEY+ env var.
968
+ # @param base_url [String, nil] Override API base URL. Falls back to +GHOSTCRAWL_BASE_URL+ env var.
969
+ # @param timeout [Integer, nil] Per-request read timeout in seconds. Falls back
970
+ # to +GHOSTCRAWL_TIMEOUT+ env var, then {DEFAULT_TIMEOUT}.
971
+ def initialize(token: nil, base_url: nil, timeout: nil)
972
+ resolved_token = token || ENV.fetch("GHOSTCRAWL_API_KEY", nil)
973
+ if resolved_token.nil? || resolved_token.empty?
974
+ raise ArgumentError,
975
+ "token is required — pass token: or set GHOSTCRAWL_API_KEY. " \
976
+ "Get your key at https://ghostcrawl.io"
977
+ end
978
+
979
+ resolved_base = (base_url ||
980
+ ENV.fetch("GHOSTCRAWL_BASE_URL", nil) ||
981
+ DEFAULT_BASE_URL).gsub(%r{/+$}, "")
982
+
983
+ resolved_timeout = (timeout ||
984
+ ENV.fetch("GHOSTCRAWL_TIMEOUT", nil) ||
985
+ DEFAULT_TIMEOUT).to_i
986
+
987
+ # Build the Kiota core via BaseBearerTokenAuthenticationProvider + FaradayRequestAdapter.
988
+ # All HTTP, auth, serialization, and URL routing delegate to the generated core.
989
+ auth_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(
990
+ StaticTokenProvider.new(resolved_token)
991
+ )
992
+ adapter = MicrosoftKiotaFaraday::FaradayRequestAdapter.new(auth_provider)
993
+ adapter.set_base_url(resolved_base)
994
+
995
+ # The default Faraday connection sets no timeout, so it inherits net/http's
996
+ # 60s read timeout — too short for browser-rendered work. Raise it.
997
+ if adapter.client.respond_to?(:options) && resolved_timeout.positive?
998
+ adapter.client.options.timeout = resolved_timeout
999
+ adapter.client.options.open_timeout = 30
1000
+ end
1001
+
1002
+ @core = GhostCrawl::GhostCrawlClient.new(adapter)
1003
+ @v1 = @core.v1
1004
+ # Kept for the raw-adapter dispatch paths (void DELETEs, binary PDF/
1005
+ # screenshot, the long-poll crawl-run wait, and the ad-hoc JSON routes
1006
+ # the generated core has no builder for).
1007
+ @adapter = adapter
1008
+
1009
+ # CrawlRunsClient needs the raw adapter for the event-driven long-poll wait
1010
+ # (the generated item builder can't attach ?wait/&timeout_s query params).
1011
+ @crawl_runs = CrawlRunsClient.new(@v1, @adapter)
1012
+ @sessions = SessionsClient.new(@v1)
1013
+ # Sub-clients with void-DELETE endpoints (204 No Content) also need the raw
1014
+ # adapter so ResponseHelper.void_request! can work around the broken generated
1015
+ # delete builders. See ResponseHelper.void_request! for the two Kiota defects.
1016
+ @profiles = ProfilesClient.new(@v1, @adapter)
1017
+ @webhooks = WebhooksClient.new(@v1, @adapter)
1018
+ @schedules = SchedulesClient.new(@v1, @adapter)
1019
+ @datasets = DatasetsClient.new(@v1)
1020
+ @recordings = RecordingsClient.new(@v1, @adapter)
1021
+ @kv = KVClient.new(@v1)
1022
+ end
1023
+
1024
+ # @return [CrawlRunsClient]
1025
+ attr_reader :crawl_runs
1026
+ # @return [SessionsClient]
1027
+ attr_reader :sessions
1028
+ # @return [ProfilesClient]
1029
+ attr_reader :profiles
1030
+ # @return [WebhooksClient]
1031
+ attr_reader :webhooks
1032
+ # @return [SchedulesClient]
1033
+ attr_reader :schedules
1034
+ # @return [DatasetsClient]
1035
+ attr_reader :datasets
1036
+ # @return [RecordingsClient]
1037
+ attr_reader :recordings
1038
+ # @return [KVClient]
1039
+ attr_reader :kv
1040
+
1041
+ # ---------------------------------------------------------------------------
1042
+ # Top-level facade methods — delegate to generated builders
1043
+ # ---------------------------------------------------------------------------
1044
+
1045
+ # Scrape a single URL and return the rendered content.
1046
+ # Delegates to POST /v1/scrape via the generated ScrapeRequestBuilder.
1047
+ # @param url [String] target URL
1048
+ # @param format [String] output format: "markdown" (default), "html", "text"
1049
+ # @param engine [String] browser engine: "auto" (default), "chrome", "firefox", "webkit"
1050
+ # @param javascript [Boolean] enable JavaScript rendering (default true)
1051
+ # @param extract_schema [Hash, nil] JSON Schema for structured extraction
1052
+ # @param raise_on_result_error [Boolean] raise {GhostCrawl::ScrapeError} on a
1053
+ # target-side (HTTP-200) failure instead of returning the raw hash (default true)
1054
+ # @return [Hash] response with +content+, +markdown+, +status+, and other fields
1055
+ def scrape(url:, format: "markdown", engine: "auto", javascript: true, extract_schema: nil,
1056
+ raise_on_result_error: true, **opts)
1057
+ # Use AdditionalDataBody to send only the fields we specify — the generated
1058
+ # ScrapeRequest model would serialize typed defaults (nulls + empty enums) that
1059
+ # cause 422 validation errors on the server.
1060
+ data = { "url" => url, "format" => format, "engine" => engine,
1061
+ "javascript_enabled" => javascript }.merge(opts.transform_keys(&:to_s))
1062
+ data["extract_schema"] = extract_schema unless extract_schema.nil?
1063
+ hash = ResponseHelper.to_hash(@v1.scrape.post(AdditionalDataBody.new(data)))
1064
+ normalize_scrape_content(hash)
1065
+ raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
1066
+ end
1067
+
1068
+ # Search the web and return results.
1069
+ # Delegates to POST /v1/search via the generated SearchRequestBuilder.
1070
+ #
1071
+ # /v1/search requires your own search-backend API key (BYO; GhostCrawl
1072
+ # charges no markup). Pass it as +provider_key+ — it is sent as the
1073
+ # +X-Provider-Authorization: Bearer <provider_key>+ header the backend
1074
+ # requires. Without it the API replies 401 search_backend_key_missing.
1075
+ # @param query [String] search query
1076
+ # @param engine [String] search engine: "google" (default), "bing", "duckduckgo"
1077
+ # @param limit [Integer] maximum results (default 10)
1078
+ # @param provider_key [String, nil] BYO search-backend key (sent as X-Provider-Authorization)
1079
+ # @return [Hash] response with +results+ list
1080
+ def search(query:, engine: "google", limit: 10, provider_key: nil, **opts)
1081
+ data = { "query" => query, "engine" => engine,
1082
+ "limit" => limit }.merge(opts.transform_keys(&:to_s))
1083
+ config = nil
1084
+ unless provider_key.nil?
1085
+ config = MicrosoftKiotaAbstractions::RequestConfiguration.new
1086
+ headers = MicrosoftKiotaAbstractions::RequestHeaders.new
1087
+ headers.add("X-Provider-Authorization", "Bearer #{provider_key}")
1088
+ config.headers = headers
1089
+ end
1090
+ ResponseHelper.to_hash(@v1.search.post(AdditionalDataBody.new(data), config))
1091
+ end
1092
+
1093
+ # Extract structured data from a URL using a JSON Schema.
1094
+ # Delegates to POST /v1/extract via the generated ExtractRequestBuilder.
1095
+ # @param url [String] target URL
1096
+ # @param schema [Hash] JSON Schema describing the shape to extract
1097
+ # @param raise_on_result_error [Boolean] raise {GhostCrawl::ScrapeError} on a
1098
+ # target-side (HTTP-200) failure instead of returning the raw hash (default true)
1099
+ # @return [Hash] extracted data
1100
+ def extract(url:, schema:, raise_on_result_error: true, **opts)
1101
+ data = { "url" => url, "schema" => schema }.merge(opts.transform_keys(&:to_s))
1102
+ hash = ResponseHelper.to_hash(@v1.extract.post(AdditionalDataBody.new(data)))
1103
+ raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
1104
+ end
1105
+
1106
+ # Start a deep crawl from a seed URL.
1107
+ # Delegates to POST /v1/crawl/deep via the generated CrawlDeepRequestBuilder.
1108
+ #
1109
+ # Pass +wait: true+ to block until the run is terminal. The deep-crawl start
1110
+ # returns a +run_id+, then the wait is handled by the same event-driven
1111
+ # server-blocking long-poll as {CrawlRunsClient#wait_for_completion} — no
1112
+ # client-side +sleep+ loop.
1113
+ #
1114
+ # @param url [String] seed URL
1115
+ # @param max_depth [Integer] maximum crawl depth (default 2)
1116
+ # @param max_pages [Integer] maximum pages (default 100)
1117
+ # @param wait [Boolean] block until the run is terminal (default false)
1118
+ # @param timeout [Integer] total seconds to wait when +wait: true+ (default 300)
1119
+ # @param raise_on_result_error [Boolean] raise {GhostCrawl::ScrapeError} on a
1120
+ # target-side (HTTP-200) failure instead of returning the raw hash (default true).
1121
+ # Ignored on the +wait: true+ path, which returns the terminal run.
1122
+ # @return [Hash] crawl run record (terminal when +wait: true+ and it finished in time)
1123
+ def crawl(url:, max_depth: 2, max_pages: 100, wait: false, timeout: 300,
1124
+ raise_on_result_error: true, **opts)
1125
+ data = { "seed_urls" => [url], "max_depth" => max_depth,
1126
+ "max_urls" => max_pages }.merge(opts.transform_keys(&:to_s))
1127
+ hash = ResponseHelper.to_hash(@v1.crawl.deep.post(AdditionalDataBody.new(data)))
1128
+
1129
+ unless wait
1130
+ return raise_on_result_error ? ResponseHelper.raise_on_result_error!(hash) : hash
1131
+ end
1132
+
1133
+ run_id = hash["run_id"]
1134
+ return hash if run_id.nil? || run_id.to_s.empty?
1135
+ @crawl_runs.wait_for_completion(run_id, timeout: timeout)
1136
+ end
1137
+
1138
+ # Map all URLs reachable from a seed URL.
1139
+ # Delegates to POST /v1/map via the generated MapRequestBuilder.
1140
+ # @param url [String] seed URL
1141
+ # @return [Hash] response with +urls+ list
1142
+ def map(url:, **opts)
1143
+ data = { "url" => url }.merge(opts.transform_keys(&:to_s))
1144
+ ResponseHelper.to_hash(@v1.map.post(AdditionalDataBody.new(data)))
1145
+ end
1146
+
1147
+ # Render a URL to a PDF document and return the raw +application/pdf+ bytes.
1148
+ # Delegates to POST /v1/pdf.
1149
+ #
1150
+ # +/v1/pdf+ responds with binary, not a JSON envelope, so this returns the
1151
+ # bytes verbatim (an ASCII-8BIT String) — write them straight to a file:
1152
+ #
1153
+ # data = client.pdf(url: "https://example.com")
1154
+ # File.binwrite("page.pdf", data)
1155
+ #
1156
+ # PDF output is Chrome-only; a request that resolves to a Firefox or WebKit
1157
+ # identity is rejected with 400 +pdf_engine_unsupported+ (a {GhostCrawlError}).
1158
+ #
1159
+ # +/v1/pdf+ has no generated request builder, so this hand-builds a
1160
+ # {MicrosoftKiotaAbstractions::RequestInformation} and routes it through the
1161
+ # SAME adapter (base URL + bearer auth + transport) as the modeled calls.
1162
+ #
1163
+ # @param url [String] target URL to render
1164
+ # @param paper_format [String] page size: "a4" (default), "letter", "legal", "tabloid"
1165
+ # @param landscape [Boolean] render in landscape orientation (default false)
1166
+ # @param engine [String] browser engine (PDF is Chrome-only; default "auto")
1167
+ # @return [String] the raw PDF bytes (ASCII-8BIT)
1168
+ def pdf(url:, paper_format: "a4", landscape: false, engine: "auto", **opts)
1169
+ data = { "url" => url, "paper_format" => paper_format,
1170
+ "landscape" => landscape, "engine" => engine }.merge(opts.transform_keys(&:to_s))
1171
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1172
+ request_info.http_method = :POST
1173
+ request_info.url_template = "{+baseurl}/v1/pdf"
1174
+ request_info.headers.try_add("Accept", "application/pdf")
1175
+ request_info.set_stream_content(JSON.generate(data), "application/json")
1176
+ ResponseHelper.binary_request!(@adapter, request_info)
1177
+ end
1178
+
1179
+ # Capture a screenshot of a URL and return the raw +image/png+ bytes.
1180
+ # Delegates to POST /v1/screenshot.
1181
+ #
1182
+ # +/v1/screenshot+ responds with a binary image, not a JSON envelope, so this
1183
+ # returns the bytes verbatim (an ASCII-8BIT String) — write them straight to a
1184
+ # file:
1185
+ #
1186
+ # data = client.screenshot(url: "https://example.com")
1187
+ # File.binwrite("page.png", data)
1188
+ #
1189
+ # +/v1/screenshot+ has no generated request builder, so this hand-builds a
1190
+ # {MicrosoftKiotaAbstractions::RequestInformation} and routes it through the
1191
+ # SAME adapter (base URL + bearer auth + transport) as the modeled calls,
1192
+ # mirroring {#pdf} exactly (the raw-bytes dispatch idiom).
1193
+ #
1194
+ # @param url [String] target URL to capture
1195
+ # @param format [String] image format: "png" (default), "jpeg", "webp"
1196
+ # @param full_page [Boolean] capture the full scrollable page (default false)
1197
+ # @param screenshot_selector [String, nil] CSS selector to clip to (optional)
1198
+ # @param engine [String] browser engine (default "auto")
1199
+ # @return [String] the raw image bytes (ASCII-8BIT)
1200
+ def screenshot(url:, format: "png", full_page: false, screenshot_selector: nil,
1201
+ engine: "auto", **opts)
1202
+ data = { "url" => url, "format" => format, "full_page" => full_page,
1203
+ "engine" => engine }
1204
+ data["screenshot_selector"] = screenshot_selector unless screenshot_selector.nil?
1205
+ data.merge!(opts.transform_keys(&:to_s))
1206
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1207
+ request_info.http_method = :POST
1208
+ request_info.url_template = "{+baseurl}/v1/screenshot"
1209
+ request_info.headers.try_add("Accept", "image/png")
1210
+ request_info.set_stream_content(JSON.generate(data), "application/json")
1211
+ ResponseHelper.binary_request!(@adapter, request_info)
1212
+ end
1213
+
1214
+ # Render a URL and return the rendered-content JSON envelope as a Hash.
1215
+ # Delegates to POST /v1/content.
1216
+ #
1217
+ # +/v1/content+ responds with +application/json+ ({+content+, +url+, +status+,
1218
+ # +format+, +status_code+, +bytes+}); this returns the decoded Hash. It uses
1219
+ # the same hand-built {MicrosoftKiotaAbstractions::RequestInformation} +
1220
+ # shared adapter dispatch as {#pdf}, parsing the JSON body at the end.
1221
+ #
1222
+ # @param url [String] target URL to render
1223
+ # @param engine [String] browser engine (default "auto")
1224
+ # @return [Hash] the rendered-content envelope (top-level +content+ = HTML)
1225
+ def content(url:, engine: "auto", **opts)
1226
+ data = { "url" => url, "engine" => engine }.merge(opts.transform_keys(&:to_s))
1227
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1228
+ request_info.http_method = :POST
1229
+ request_info.url_template = "{+baseurl}/v1/content"
1230
+ request_info.headers.try_add("Accept", "application/json")
1231
+ request_info.set_stream_content(JSON.generate(data), "application/json")
1232
+ bytes = ResponseHelper.binary_request!(@adapter, request_info)
1233
+ parsed = JSON.parse(bytes)
1234
+ parsed.is_a?(Hash) ? parsed : { "content" => parsed }
1235
+ end
1236
+
1237
+ # Execute an agent task via POST /v1/agent.
1238
+ #
1239
+ # The agent capability is gated per account; when it is not enabled the API
1240
+ # replies +404 not_found+ — this returns that problem+json body as a Hash
1241
+ # (carrying +"detail"+) rather than raising, so callers can branch on
1242
+ # +result.key?("detail")+. The agent brings its own LLM (BYO) — this method
1243
+ # handles the client serialize→POST→parse plumbing against the real route.
1244
+ #
1245
+ # +/v1/agent+ has no generated request builder, so this hand-builds a
1246
+ # {MicrosoftKiotaAbstractions::RequestInformation} and routes it through the
1247
+ # SAME adapter (base URL + bearer auth + transport) as the modeled calls.
1248
+ #
1249
+ # @param url [String, nil] optional starting URL (folded into +task+ as +start_url+)
1250
+ # @param instruction [String, nil] natural-language task instruction
1251
+ # @param task [Hash, nil] explicit structured task object (overrides url/instruction)
1252
+ # @return [Hash] the agent result, or the capability-gated 404 problem+json body (has +"detail"+)
1253
+ def agent(url: nil, instruction: nil, task: nil, **opts)
1254
+ payload = task || { "instruction" => instruction, "start_url" => url }
1255
+ data = { "task" => payload }.merge(opts.transform_keys(&:to_s))
1256
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1257
+ request_info.http_method = :POST
1258
+ request_info.url_template = "{+baseurl}/v1/agent"
1259
+ request_info.headers.try_add("Accept", "application/json")
1260
+ request_info.set_stream_content(JSON.generate(data), "application/json")
1261
+ bytes = ResponseHelper.binary_request!(@adapter, request_info)
1262
+ parsed = JSON.parse(bytes)
1263
+ parsed.is_a?(Hash) ? parsed : { "detail" => parsed }
1264
+ rescue GhostCrawl::GhostCrawlError => e
1265
+ # Agent is account-gated: a 404 not_found is the documented "capability
1266
+ # disabled" answer, not a transport failure. Surface the problem+json body
1267
+ # as a Hash (carrying "detail") rather than raising. Any other status is a
1268
+ # real error and is re-raised.
1269
+ raise unless e.status_code == 404
1270
+ body = e.body.to_s
1271
+ brace = body.index("{")
1272
+ decoded =
1273
+ if brace
1274
+ begin
1275
+ JSON.parse(body[brace..])
1276
+ rescue StandardError
1277
+ nil
1278
+ end
1279
+ end
1280
+ decoded.is_a?(Hash) ? decoded : { "detail" => e.message, "code" => (e.code || "not_found") }
1281
+ end
1282
+
1283
+ # List the account's persisted session storage-states.
1284
+ # Delegates to GET /v1/storage-states.
1285
+ # @return [Hash] the storage-states envelope
1286
+ def storage_states
1287
+ json_request(:GET, "/v1/storage-states", nil)
1288
+ end
1289
+
1290
+ private
1291
+
1292
+ # Route an ad-hoc JSON request through the SAME adapter (base URL + bearer
1293
+ # auth + transport) as the modeled calls, for routes the generated core has
1294
+ # no builder for. Returns the decoded Hash. Mirrors {#content}'s dispatch.
1295
+ # @api private
1296
+ def json_request(method, path, body)
1297
+ request_info = MicrosoftKiotaAbstractions::RequestInformation.new
1298
+ request_info.http_method = method
1299
+ request_info.url_template = "{+baseurl}#{path}"
1300
+ request_info.headers.try_add("Accept", "application/json")
1301
+ if !body.nil? && method != :GET
1302
+ request_info.set_stream_content(JSON.generate(body), "application/json")
1303
+ end
1304
+ bytes = ResponseHelper.binary_request!(@adapter, request_info)
1305
+ parsed = (JSON.parse(bytes) if bytes && !bytes.empty?)
1306
+ parsed.is_a?(Hash) ? parsed : {}
1307
+ end
1308
+
1309
+ # Normalizes a +"content"+ key onto a decoded scrape response.
1310
+ #
1311
+ # The API has two scrape response shapes:
1312
+ # * the FLAT +format="markdown"+ shape, which carries the rendered page at
1313
+ # the top level under the format-specific key (+"markdown"+/+"html"+/
1314
+ # +"text"+) but omits +identity_id+; and
1315
+ # * the standard ENVELOPE ({ +status+, +results+, +routing_mode+,
1316
+ # +request_class+, +identity_id+ }), where the rendered page lives under
1317
+ # +results[0]+ and the top-level +identity_id+ is present.
1318
+ # The documented quickstart reads +result["content"]+, so this mirrors the
1319
+ # rendered page onto +"content"+ in place from WHICHEVER shape is present,
1320
+ # KEEPING the original keys intact (backward compatible).
1321
+ #
1322
+ # No-op unless +result+ is a Hash that does not already carry +"content"+.
1323
+ # The value chosen is: the field named by +result["format"]+ when that field
1324
+ # is a String, else the first String among +"markdown"+, +"html"+, +"text"+ —
1325
+ # looked up first at the top level (flat shape), then on +results[0]+
1326
+ # (envelope shape).
1327
+ #
1328
+ # @param result [Object] the decoded response (only mutated when a Hash)
1329
+ # @return [Object] the same +result+, unchanged reference
1330
+ # @api private
1331
+ def normalize_scrape_content(result)
1332
+ return result unless result.is_a?(Hash) && !result.key?("content")
1333
+
1334
+ fmt = result["format"]
1335
+ value = scrape_content_from(result, fmt)
1336
+
1337
+ # Envelope shape: the page is under results[0], not at the top level.
1338
+ unless value.is_a?(String)
1339
+ first = result["results"]
1340
+ first = first.first if first.is_a?(Array)
1341
+ value = scrape_content_from(first, fmt) if first.is_a?(Hash)
1342
+ end
1343
+
1344
+ result["content"] = value if value.is_a?(String)
1345
+ result
1346
+ end
1347
+
1348
+ # Picks the rendered-page String out of a scrape result Hash: the field named
1349
+ # by +fmt+ when present, else the first String among +"markdown"+, +"html"+,
1350
+ # +"text"+. Returns +nil+ when +src+ carries no such String.
1351
+ # @api private
1352
+ def scrape_content_from(src, fmt)
1353
+ return nil unless src.is_a?(Hash)
1354
+
1355
+ value = src[fmt] if fmt.is_a?(String)
1356
+ return value if value.is_a?(String)
1357
+
1358
+ %w[markdown html text].each do |key|
1359
+ candidate = src[key]
1360
+ return candidate if candidate.is_a?(String)
1361
+ end
1362
+ nil
1363
+ end
1364
+ end
1365
+ end