webpipe-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +95 -0
- data/lib/webpipe/browser.rb +134 -0
- data/lib/webpipe/client.rb +94 -0
- data/lib/webpipe/errors.rb +37 -0
- data/lib/webpipe/http_client.rb +98 -0
- data/lib/webpipe/types.rb +77 -0
- data/lib/webpipe/version.rb +5 -0
- data/lib/webpipe.rb +15 -0
- metadata +48 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b49c39a2dc09f4401f0cee5341c3aff38edf3faea72ca270e0ad3568918931a5
|
|
4
|
+
data.tar.gz: 7faf803b7c1193549d4c611b5b0cf42b51f0a2511c377d3379737a99a1ca0c90
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dca50f00ecced076a680314d2131122a52e9dd2e5a19982b8549d6486107a74d40e2c08db1deeb42c1743978ce415e596209a912f1a251b7536fd4a90d97c0c9
|
|
7
|
+
data.tar.gz: de0802c5500aa2a080ed86a97f17471b8187bfa3d5b4f188d19d77ab0b0ae017c54d822cff3e1685ca4b4707ebc7504a543c6a2135f797a6c30aae1ac01c6a41
|
data/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# webpipe-sdk (Ruby)
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for [WebPipe.ai](https://webpipe.ai/) — turn any webpage into clean, structured data.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install webpipe-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Ruby ≥ 3.0. **Zero runtime dependencies** — stdlib `net/http` only.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "webpipe"
|
|
17
|
+
|
|
18
|
+
client = Webpipe::Client.new # reads WEBPIPE_API_KEY, or Webpipe::Client.new(api_key: "wc-...")
|
|
19
|
+
|
|
20
|
+
# Scrape a page
|
|
21
|
+
doc = client.scrape("https://example.com", formats: ["markdown", "metadata"])
|
|
22
|
+
puts doc.markdown
|
|
23
|
+
puts doc.metadata["title"]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Crawl a whole site (polls automatically)
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
job = client.crawl("https://docs.example.com", limit: 50,
|
|
30
|
+
scrape_options: { formats: ["markdown"] }, timeout: 600)
|
|
31
|
+
job.data.each do |page|
|
|
32
|
+
puts "#{page.metadata["sourceURL"]} #{page.metadata["title"]}"
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Need control? `job = client.start_crawl(url, limit: 50)` returns immediately; poll with `client.get_crawl_status(job.id)`.
|
|
37
|
+
|
|
38
|
+
### Browser session (JS-rendered / logged-in pages)
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
session = client.browser.create(url: "https://example.com/login")
|
|
42
|
+
begin
|
|
43
|
+
session.wait_until_running
|
|
44
|
+
session.fill("input[name=email]", "you@example.com")
|
|
45
|
+
session.fill("input[name=password]", "secret")
|
|
46
|
+
session.press("Enter", wait_after: 3000)
|
|
47
|
+
|
|
48
|
+
doc = session.navigate("https://example.com/dashboard", formats: ["markdown"])
|
|
49
|
+
puts doc.markdown
|
|
50
|
+
session.save_session # reuse login state next time via session_name
|
|
51
|
+
ensure
|
|
52
|
+
session.close
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Map (discover all URLs)
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
client.map("https://example.com", limit: 100, search: "blog").each do |link|
|
|
60
|
+
puts "#{link.url} #{link.title}"
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Errors
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
begin
|
|
68
|
+
client.scrape("https://example.com")
|
|
69
|
+
rescue Webpipe::AuthenticationError
|
|
70
|
+
# bad API key
|
|
71
|
+
rescue Webpipe::RateLimitError => e
|
|
72
|
+
# e.status_code == 429; e.message keeps the server's error text
|
|
73
|
+
rescue Webpipe::Error
|
|
74
|
+
# anything else
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`429` and `5xx` responses are retried automatically with exponential backoff (`max_retries: 2` by default).
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Param | Env var | Default |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `api_key` | `WEBPIPE_API_KEY` | — (required) |
|
|
85
|
+
| `base_url` | `WEBPIPE_API_URL` | `https://api.web2json.ai` |
|
|
86
|
+
| `timeout` | — | 60s |
|
|
87
|
+
| `max_retries` | — | 2 |
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
bundle install
|
|
93
|
+
ruby -Ilib -Itest test/client_test.rb
|
|
94
|
+
ruby -Ilib -Itest test/browser_test.rb
|
|
95
|
+
```
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webpipe
|
|
4
|
+
# Factory for browser sessions, exposed as +client.browser+.
|
|
5
|
+
# @api private (constructed via Client)
|
|
6
|
+
class BrowserResource
|
|
7
|
+
def initialize(http)
|
|
8
|
+
@http = http
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Create a session. It starts asynchronously — call +wait_until_running+
|
|
12
|
+
# before sending commands for a controlled experience.
|
|
13
|
+
def create(url: nil, cookie: nil, proxy: nil, session_name: nil)
|
|
14
|
+
body = drop_nil(url: url, cookie: cookie, proxy: proxy, session_name: session_name)
|
|
15
|
+
info = @http.post("/api/v1/browser", body)
|
|
16
|
+
BrowserSession.new(@http, id: info["id"], status: info["status"], url: info["url"],
|
|
17
|
+
session_name: info["session_name"], expires_at: info["expires_at"])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def drop_nil(hash) = hash.reject { |_, v| v.nil? }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Handle to a persistent headless Chromium session on the server.
|
|
26
|
+
#
|
|
27
|
+
# Server-side facts: sessions expire after 30 minutes idle; at most 2
|
|
28
|
+
# concurrent running sessions per user. Always +close+ when done.
|
|
29
|
+
class BrowserSession
|
|
30
|
+
attr_reader :id, :status, :url, :session_name, :expires_at
|
|
31
|
+
|
|
32
|
+
TERMINAL_STATUSES = %w[stopped error expired].freeze
|
|
33
|
+
|
|
34
|
+
def initialize(http, id:, status:, url: nil, session_name: nil, expires_at: nil)
|
|
35
|
+
@http = http
|
|
36
|
+
@id = id
|
|
37
|
+
@status = status
|
|
38
|
+
@url = url
|
|
39
|
+
@session_name = session_name
|
|
40
|
+
@expires_at = expires_at
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Fetch the latest session state from the server.
|
|
44
|
+
def refresh
|
|
45
|
+
info = @http.get(base)
|
|
46
|
+
@status = info["status"]
|
|
47
|
+
@url = info["url"]
|
|
48
|
+
@session_name = info["session_name"]
|
|
49
|
+
@expires_at = info["expires_at"]
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Block until the session is ready to accept commands.
|
|
54
|
+
def wait_until_running(timeout: 60, poll_interval: 1)
|
|
55
|
+
deadline = Time.now + timeout
|
|
56
|
+
loop do
|
|
57
|
+
refresh
|
|
58
|
+
return self if @status == "running"
|
|
59
|
+
raise Error, "Browser session #{@id} entered terminal status '#{@status}'" if TERMINAL_STATUSES.include?(@status)
|
|
60
|
+
raise PollTimeoutError, "Browser session #{@id} not running after #{timeout}s" if Time.now >= deadline
|
|
61
|
+
|
|
62
|
+
sleep poll_interval
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Stop the session and release server resources.
|
|
67
|
+
def close
|
|
68
|
+
@http.delete(base)
|
|
69
|
+
@status = "stopped"
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Navigate to a new URL (with anti-bot handling) and scrape it.
|
|
74
|
+
def navigate(url, formats: nil, scroll_to_bottom: nil, max_scrolls: nil, scroll_wait: nil, scroll_step: nil)
|
|
75
|
+
body = { url: url }.merge(scrape_body(formats:, scroll_to_bottom:, max_scrolls:, scroll_wait:, scroll_step:))
|
|
76
|
+
Document.from_hash(@http.post("#{base}/navigate", body)["data"])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Scrape the current page without navigating (faster than +navigate+).
|
|
80
|
+
def scrape(formats: nil, scroll_to_bottom: nil, max_scrolls: nil, scroll_wait: nil, scroll_step: nil)
|
|
81
|
+
body = scrape_body(formats:, scroll_to_bottom:, max_scrolls:, scroll_wait:, scroll_step:)
|
|
82
|
+
Document.from_hash(@http.post("#{base}/scrape", body)["data"])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Run a raw browser action. Returns the raw API payload Hash.
|
|
86
|
+
def action(type, **params)
|
|
87
|
+
@http.post("#{base}/action", { type: type }.merge(params))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def click(selector, wait_after: nil) = action("click", **drop_nil(selector:, wait_after:))
|
|
91
|
+
def fill(selector, value) = action("fill", selector:, value:)
|
|
92
|
+
def select(selector, value) = action("select", selector:, value:)
|
|
93
|
+
|
|
94
|
+
def press(key, selector: nil, wait_after: nil)
|
|
95
|
+
action("press", **drop_nil(key:, selector:, wait_after:))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def scroll(direction: "down", amount: nil) = action("scroll", **drop_nil(direction:, amount:))
|
|
99
|
+
def wait(ms = 1000) = action("wait", ms:)
|
|
100
|
+
def wait_for(selector, timeout: nil) = action("wait_for", **drop_nil(selector:, timeout:))
|
|
101
|
+
|
|
102
|
+
# Take a screenshot. The API returns a base64 PNG inside the payload.
|
|
103
|
+
def screenshot(full_page: false) = action("screenshot", full_page:)
|
|
104
|
+
|
|
105
|
+
# Evaluate a JavaScript expression in the page (value in payload's "result").
|
|
106
|
+
def evaluate(expression) = action("evaluate", expression:)
|
|
107
|
+
def hover(selector) = action("hover", selector:)
|
|
108
|
+
def check(selector) = action("check", selector:)
|
|
109
|
+
|
|
110
|
+
def get_cookies
|
|
111
|
+
data = @http.get("#{base}/cookies")
|
|
112
|
+
data["cookies"] || data["data"] || []
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def set_cookies(cookies) = @http.post("#{base}/cookies", { op: "set", cookies: cookies })
|
|
116
|
+
def clear_cookies = @http.post("#{base}/cookies", { op: "clear" })
|
|
117
|
+
|
|
118
|
+
# Persist cookies/storage under this session's +session_name+.
|
|
119
|
+
def save_session = @http.post("#{base}/save_session")
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def base = "/api/v1/browser/#{@id}"
|
|
124
|
+
|
|
125
|
+
def scrape_body(formats:, scroll_to_bottom:, max_scrolls:, scroll_wait:, scroll_step:)
|
|
126
|
+
drop_nil(
|
|
127
|
+
formats: formats, scroll_to_bottom: scroll_to_bottom,
|
|
128
|
+
max_scrolls: max_scrolls, scroll_wait: scroll_wait, scroll_step: scroll_step
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def drop_nil(hash) = hash.reject { |_, v| v.nil? }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webpipe
|
|
4
|
+
# The WebPipe API client.
|
|
5
|
+
#
|
|
6
|
+
# client = Webpipe::Client.new # reads WEBPIPE_API_KEY
|
|
7
|
+
# doc = client.scrape("https://example.com", formats: ["markdown"])
|
|
8
|
+
# puts doc.markdown
|
|
9
|
+
class Client
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.web2json.ai"
|
|
11
|
+
|
|
12
|
+
attr_reader :browser
|
|
13
|
+
|
|
14
|
+
def initialize(api_key: nil, base_url: nil, timeout: 60, max_retries: 2)
|
|
15
|
+
api_key ||= ENV["WEBPIPE_API_KEY"]
|
|
16
|
+
if api_key.nil? || api_key.empty?
|
|
17
|
+
raise ArgumentError,
|
|
18
|
+
"Missing API key. Pass api_key: or set the WEBPIPE_API_KEY environment " \
|
|
19
|
+
"variable. Create one at https://webpipe.ai/api-keys.html"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
base_url ||= ENV["WEBPIPE_API_URL"] || DEFAULT_BASE_URL
|
|
23
|
+
@http = HttpClient.new(
|
|
24
|
+
base_url: base_url.sub(%r{/+$}, ""),
|
|
25
|
+
api_key: api_key,
|
|
26
|
+
timeout: timeout,
|
|
27
|
+
max_retries: max_retries,
|
|
28
|
+
user_agent: "webpipe-sdk-ruby/#{Webpipe::VERSION}"
|
|
29
|
+
)
|
|
30
|
+
@browser = BrowserResource.new(@http)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Scrape a single page into markdown/html/links/metadata/json.
|
|
34
|
+
def scrape(url, formats: nil, json_schema: nil, use_proxy: nil, proxy: nil, cookie: nil)
|
|
35
|
+
body = drop_nil(url:, formats:, json_schema:, use_proxy:, proxy:, cookie:)
|
|
36
|
+
Document.from_hash(@http.post("/api/v1/scrape", body)["data"])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Discover URLs of a website (robots.txt → sitemap → in-page links).
|
|
40
|
+
def map(url, limit: nil, search: nil, sitemap: nil, same_domain: nil,
|
|
41
|
+
use_proxy: nil, proxy: nil, cookie: nil)
|
|
42
|
+
body = drop_nil(url:, limit:, search:, sitemap:, same_domain:, use_proxy:, proxy:, cookie:)
|
|
43
|
+
(@http.post("/api/v1/map", body)["links"] || []).map { |l| MapLink.from_hash(l) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Submit an async crawl job and return its handle immediately.
|
|
47
|
+
def start_crawl(url, limit: nil, max_discovery_depth: nil, include_paths: nil,
|
|
48
|
+
exclude_paths: nil, allow_subdomains: nil, allow_external_links: nil,
|
|
49
|
+
crawl_entire_domain: nil, ignore_query_params: nil, sitemap: nil,
|
|
50
|
+
delay: nil, max_concurrency: nil, scrape_options: nil,
|
|
51
|
+
use_proxy: nil, proxy: nil, cookie: nil)
|
|
52
|
+
body = drop_nil(
|
|
53
|
+
url:, limit:, max_discovery_depth:, include_paths:, exclude_paths:,
|
|
54
|
+
allow_subdomains:, allow_external_links:, crawl_entire_domain:,
|
|
55
|
+
ignore_query_params:, sitemap:, delay:, max_concurrency:,
|
|
56
|
+
scrape_options:, use_proxy:, proxy:, cookie:
|
|
57
|
+
)
|
|
58
|
+
res = @http.post("/api/v1/crawl", body)
|
|
59
|
+
CrawlJobStart.new(res.fetch("id"), res["url"])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Fetch the current status and partial results of a crawl job.
|
|
63
|
+
def get_crawl_status(job_id)
|
|
64
|
+
CrawlJob.from_hash(@http.get("/api/v1/crawl/#{job_id}"))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Submit a crawl job and poll until it completes.
|
|
68
|
+
#
|
|
69
|
+
# Yields the current CrawlJob after each poll when a block is given —
|
|
70
|
+
# the idiomatic Ruby way to observe a long crawl (progress bars, logging).
|
|
71
|
+
#
|
|
72
|
+
# Raises CrawlError if the job fails, PollTimeoutError if +timeout+ is
|
|
73
|
+
# exceeded. Other keywords are the same as +start_crawl+.
|
|
74
|
+
def crawl(url, poll_interval: 2, timeout: nil, **options)
|
|
75
|
+
job = start_crawl(url, **options)
|
|
76
|
+
deadline = timeout && Time.now + timeout
|
|
77
|
+
loop do
|
|
78
|
+
status = get_crawl_status(job.id)
|
|
79
|
+
return status if status.status == CrawlJob::STATUS_COMPLETED
|
|
80
|
+
raise CrawlError, "Crawl job #{job.id} failed: #{status.errors}" if status.status == CrawlJob::STATUS_FAILED
|
|
81
|
+
yield status if block_given?
|
|
82
|
+
if deadline && Time.now >= deadline
|
|
83
|
+
raise PollTimeoutError, "Crawl job #{job.id} did not complete within #{timeout}s"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
sleep poll_interval
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def drop_nil(hash) = hash.reject { |_, v| v.nil? }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webpipe
|
|
4
|
+
# Base class for all webpipe-sdk errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# An error response returned by the WebPipe API. Always carries the server's
|
|
8
|
+
# original error message and the HTTP status code.
|
|
9
|
+
class ApiError < Error
|
|
10
|
+
attr_reader :status_code, :body
|
|
11
|
+
|
|
12
|
+
def initialize(message, status_code:, body: nil)
|
|
13
|
+
super("[#{status_code}] #{message}")
|
|
14
|
+
@status_code = status_code
|
|
15
|
+
@body = body
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# 400 — invalid parameters (missing url, bad format, ...).
|
|
20
|
+
class BadRequestError < ApiError; end
|
|
21
|
+
# 401 — API key missing, invalid or deleted.
|
|
22
|
+
class AuthenticationError < ApiError; end
|
|
23
|
+
# 403 — accessing a resource owned by another user.
|
|
24
|
+
class ForbiddenError < ApiError; end
|
|
25
|
+
# 404 — job/session does not exist or has expired.
|
|
26
|
+
class NotFoundError < ApiError; end
|
|
27
|
+
# 429 — rate limit exceeded, or too many concurrent browser sessions.
|
|
28
|
+
class RateLimitError < ApiError; end
|
|
29
|
+
# 5xx — scrape timeout, target site unreachable, ...
|
|
30
|
+
class ServerError < ApiError; end
|
|
31
|
+
|
|
32
|
+
# A crawl job reached status "failed".
|
|
33
|
+
class CrawlError < Error; end
|
|
34
|
+
|
|
35
|
+
# Polling did not finish within the allotted timeout.
|
|
36
|
+
class PollTimeoutError < Error; end
|
|
37
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Webpipe
|
|
8
|
+
# Thin net/http wrapper: auth headers, retries with backoff, error mapping.
|
|
9
|
+
# @api private
|
|
10
|
+
class HttpClient
|
|
11
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
12
|
+
RETRYABLE_ERRORS = [
|
|
13
|
+
Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
STATUS_TO_ERROR = {
|
|
17
|
+
400 => BadRequestError,
|
|
18
|
+
401 => AuthenticationError,
|
|
19
|
+
403 => ForbiddenError,
|
|
20
|
+
404 => NotFoundError,
|
|
21
|
+
429 => RateLimitError
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(base_url:, api_key:, timeout:, max_retries:, user_agent:)
|
|
25
|
+
@base_uri = URI(base_url)
|
|
26
|
+
@api_key = api_key
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
@max_retries = max_retries
|
|
29
|
+
@user_agent = user_agent
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def get(path) = request("GET", path)
|
|
33
|
+
def post(path, body = nil) = request("POST", path, body)
|
|
34
|
+
def delete(path) = request("DELETE", path)
|
|
35
|
+
|
|
36
|
+
def request(method, path, body = nil)
|
|
37
|
+
attempt = 0
|
|
38
|
+
loop do
|
|
39
|
+
response = perform(method, path, body)
|
|
40
|
+
if RETRYABLE_STATUSES.include?(response.code.to_i) && attempt < @max_retries
|
|
41
|
+
attempt += 1
|
|
42
|
+
sleep backoff(response, attempt)
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
return handle(response)
|
|
46
|
+
rescue *RETRYABLE_ERRORS
|
|
47
|
+
attempt += 1
|
|
48
|
+
raise if attempt > @max_retries
|
|
49
|
+
|
|
50
|
+
sleep(0.5 * (2**attempt))
|
|
51
|
+
retry
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def perform(method, path, body)
|
|
58
|
+
http = Net::HTTP.new(@base_uri.host, @base_uri.port)
|
|
59
|
+
http.use_ssl = @base_uri.scheme == "https"
|
|
60
|
+
http.open_timeout = @timeout
|
|
61
|
+
http.read_timeout = @timeout
|
|
62
|
+
|
|
63
|
+
request_class = Net::HTTP.const_get(method.capitalize)
|
|
64
|
+
request = request_class.new(path)
|
|
65
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
66
|
+
request["Content-Type"] = "application/json"
|
|
67
|
+
request["User-Agent"] = @user_agent
|
|
68
|
+
request.body = JSON.generate(body) if body
|
|
69
|
+
|
|
70
|
+
http.request(request)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def handle(response)
|
|
74
|
+
status = response.code.to_i
|
|
75
|
+
payload = begin
|
|
76
|
+
body = response.body.to_s
|
|
77
|
+
body.empty? ? {} : JSON.parse(body)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
{}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
return payload if status < 400
|
|
83
|
+
|
|
84
|
+
message = payload.is_a?(Hash) && payload["error"] ? payload["error"] : response.message
|
|
85
|
+
error_class = status >= 500 ? ServerError : (STATUS_TO_ERROR[status] || ApiError)
|
|
86
|
+
raise error_class.new(message, status_code: status, body: payload)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def backoff(response, attempt)
|
|
90
|
+
retry_after = response["Retry-After"]
|
|
91
|
+
return Float(retry_after) if retry_after
|
|
92
|
+
|
|
93
|
+
0.5 * (2**attempt)
|
|
94
|
+
rescue ArgumentError, TypeError
|
|
95
|
+
0.5 * (2**attempt)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webpipe
|
|
4
|
+
# A link extracted from a page.
|
|
5
|
+
Link = Struct.new(:text, :href) do
|
|
6
|
+
def self.from_hash(hash)
|
|
7
|
+
new(hash["text"], hash["href"])
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Result of scraping a single page (scrape / browser / crawl data item).
|
|
12
|
+
# `metadata` is a plain Hash — the API adds extra keys (og:*, sourceURL, ...).
|
|
13
|
+
class Document
|
|
14
|
+
attr_reader :markdown, :html, :raw_html, :links, :metadata, :json
|
|
15
|
+
|
|
16
|
+
def initialize(markdown: nil, html: nil, raw_html: nil, links: nil, metadata: nil, json: nil)
|
|
17
|
+
@markdown = markdown
|
|
18
|
+
@html = html
|
|
19
|
+
@raw_html = raw_html
|
|
20
|
+
@links = links
|
|
21
|
+
@metadata = metadata
|
|
22
|
+
@json = json
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_hash(hash)
|
|
26
|
+
hash ||= {}
|
|
27
|
+
new(
|
|
28
|
+
markdown: hash["markdown"],
|
|
29
|
+
html: hash["html"],
|
|
30
|
+
raw_html: hash["rawHtml"],
|
|
31
|
+
links: hash["links"]&.map { |l| Link.from_hash(l) },
|
|
32
|
+
metadata: hash["metadata"],
|
|
33
|
+
json: hash["json"]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A URL discovered by the Map endpoint.
|
|
39
|
+
MapLink = Struct.new(:url, :title, :description) do
|
|
40
|
+
def self.from_hash(hash)
|
|
41
|
+
new(hash["url"], hash["title"], hash["description"])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Handle returned when a crawl job is submitted.
|
|
46
|
+
CrawlJobStart = Struct.new(:id, :url)
|
|
47
|
+
|
|
48
|
+
# Status and (partial or final) results of a crawl job.
|
|
49
|
+
# Results expire 24h after completion (see +expires_at+).
|
|
50
|
+
class CrawlJob
|
|
51
|
+
STATUS_SCRAPING = "scraping"
|
|
52
|
+
STATUS_COMPLETED = "completed"
|
|
53
|
+
STATUS_FAILED = "failed"
|
|
54
|
+
|
|
55
|
+
attr_reader :status, :total, :completed, :data, :errors, :expires_at
|
|
56
|
+
|
|
57
|
+
def initialize(status:, total: 0, completed: 0, data: [], errors: [], expires_at: nil)
|
|
58
|
+
@status = status
|
|
59
|
+
@total = total
|
|
60
|
+
@completed = completed
|
|
61
|
+
@data = data
|
|
62
|
+
@errors = errors
|
|
63
|
+
@expires_at = expires_at
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.from_hash(hash)
|
|
67
|
+
new(
|
|
68
|
+
status: hash["status"],
|
|
69
|
+
total: hash["total"] || 0,
|
|
70
|
+
completed: hash["completed"] || 0,
|
|
71
|
+
data: (hash["data"] || []).map { |d| Document.from_hash(d) },
|
|
72
|
+
errors: hash["errors"] || [],
|
|
73
|
+
expires_at: hash["expires_at"]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/webpipe.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# webpipe-sdk — the official Ruby SDK for WebPipe.ai.
|
|
4
|
+
#
|
|
5
|
+
# require "webpipe"
|
|
6
|
+
# client = Webpipe::Client.new(api_key: "wc-...")
|
|
7
|
+
# doc = client.scrape("https://example.com", formats: ["markdown"])
|
|
8
|
+
# puts doc.markdown
|
|
9
|
+
|
|
10
|
+
require_relative "webpipe/version"
|
|
11
|
+
require_relative "webpipe/errors"
|
|
12
|
+
require_relative "webpipe/types"
|
|
13
|
+
require_relative "webpipe/http_client"
|
|
14
|
+
require_relative "webpipe/browser"
|
|
15
|
+
require_relative "webpipe/client"
|
metadata
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: webpipe-sdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- WebPipe.ai
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Scrape, Map, Crawl and persistent Browser Sessions via the WebPipe.ai
|
|
13
|
+
API. Zero runtime dependencies (stdlib net/http).
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- README.md
|
|
19
|
+
- lib/webpipe.rb
|
|
20
|
+
- lib/webpipe/browser.rb
|
|
21
|
+
- lib/webpipe/client.rb
|
|
22
|
+
- lib/webpipe/errors.rb
|
|
23
|
+
- lib/webpipe/http_client.rb
|
|
24
|
+
- lib/webpipe/types.rb
|
|
25
|
+
- lib/webpipe/version.rb
|
|
26
|
+
homepage: https://webpipe.ai
|
|
27
|
+
licenses:
|
|
28
|
+
- MIT
|
|
29
|
+
metadata: {}
|
|
30
|
+
rdoc_options: []
|
|
31
|
+
require_paths:
|
|
32
|
+
- lib
|
|
33
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
34
|
+
requirements:
|
|
35
|
+
- - ">="
|
|
36
|
+
- !ruby/object:Gem::Version
|
|
37
|
+
version: '3.0'
|
|
38
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '0'
|
|
43
|
+
requirements: []
|
|
44
|
+
rubygems_version: 4.0.16
|
|
45
|
+
specification_version: 4
|
|
46
|
+
summary: Official Ruby SDK for WebPipe.ai — turn any webpage into clean, structured
|
|
47
|
+
data
|
|
48
|
+
test_files: []
|