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