opencraw 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 +38 -0
- data/lib/opencraw/crawl_session/crawl_session.rb +3 -0
- data/lib/opencraw/crawl_session/crawler_use_case.rb +226 -0
- data/lib/opencraw/driver_channel/driver_channel.rb +3 -0
- data/lib/opencraw/driver_channel/json_rpc_channel_client.rb +104 -0
- data/lib/opencraw/driver_runtime/driver_runtime.rb +4 -0
- data/lib/opencraw/driver_runtime/ensure_driver_runtime_use_case.rb +124 -0
- data/lib/opencraw/driver_runtime/runtime_manifest_contract.rb +41 -0
- data/lib/opencraw.rb +12 -0
- metadata +49 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7e0794fc1cda09d04c2effc3b94f4ca71b5fb782e337550ae186f42761b84f74
|
|
4
|
+
data.tar.gz: 25588199f1e6d378952986bc973945dca5da39861d4c97aff2821b10ef90e1ad
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ae93dc575f790bf105aa5c1c9492a125be4d74db9badd2371658ce5b328d185fbd08902ca5cc6ecbabe9af1c65fb7e73a9f63c4e00231d9b40d58b2ccfb1aed2
|
|
7
|
+
data.tar.gz: 3b2a0ef4e130d0fcb02e6c41cfb152ba181a31c0038a01b18ab91ebf8acecd501e0cb3c28882c7fbb61b09764654c1bebfb5070d2c60ef273431cb52880680a2
|
data/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# opencraw (Ruby)
|
|
2
|
+
|
|
3
|
+
Crawl with [OpenCraw](https://github.com/Open-Craw/OpenCraw) from Ruby, with no Node to install. `OpenCraw::Crawler`
|
|
4
|
+
downloads the driver's runtime for this platform once (a Node binary and the engine in one archive, checked against
|
|
5
|
+
its SHA-256 and cached in `~/.cache/opencraw/driver/`), starts it, and drives it over its pipes. Standard library
|
|
6
|
+
only; Ruby 3.2 or later.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
gem install opencraw
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require 'opencraw'
|
|
14
|
+
|
|
15
|
+
OpenCraw::Crawler.open(version: '0.7.1') do |crawler|
|
|
16
|
+
crawler.hook('tidy') { |value, _args, _request| value.to_s.strip.downcase }
|
|
17
|
+
|
|
18
|
+
run = crawler.run(paths: ['recipes/'])
|
|
19
|
+
run.records.each { |record| puts record } # as they are emitted
|
|
20
|
+
puts run.report['records']
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **`run`** takes the driver's run options (`out:`, `only:`, `dryRun:`, `parallel:`, `access:`, ...); `records` and
|
|
25
|
+
`events` are enumerators that yield as it crawls, `report` waits for the end, `cancel` stops it.
|
|
26
|
+
- **`work(next_item, ...)`** is worker mode: `next_item.call` returns each window's item (`{ id:, vars: }`), or `nil`
|
|
27
|
+
to end.
|
|
28
|
+
- **`hook`, `captcha_solver`, `access_plugin`** register blocks by name; raising fails that one call
|
|
29
|
+
(`OpenCraw::CalloutError` for a plain message), never the run.
|
|
30
|
+
- **`validate`, `probe`, `diff`, `export`** answer as the driver does.
|
|
31
|
+
- `OPENCRAW_DRIVER=<folder>` uses an unpacked runtime; `OPENCRAW_DRIVER_OFFLINE=1` (or `offline: true`) never
|
|
32
|
+
downloads; `command:` starts any other driver.
|
|
33
|
+
|
|
34
|
+
## Layout and development
|
|
35
|
+
|
|
36
|
+
One slice per outcome under `lib/opencraw/`: `driver_runtime` (the runtime's download and cache), `driver_channel`
|
|
37
|
+
(JSON-RPC over the driver's pipes) and `crawl_session` (`Crawler` and `Run`). `smith run lint typecheck test -p
|
|
38
|
+
opencraw-ruby`; the specs start the repository's built driver and replay its conformance transcripts.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../driver_channel/driver_channel'
|
|
4
|
+
require_relative '../driver_runtime/driver_runtime'
|
|
5
|
+
|
|
6
|
+
module OpenCraw
|
|
7
|
+
# OpenCraw's engine from Ruby: one driver process, runs and worker pools, and the blocks it calls back.
|
|
8
|
+
module CrawlSession
|
|
9
|
+
# The driver protocol this client speaks.
|
|
10
|
+
PROTOCOL_VERSION = '1.2.0'
|
|
11
|
+
|
|
12
|
+
# Raised by a hook, captcha solver or access plugin to fail that one call with this message, and nothing else.
|
|
13
|
+
class CalloutError < StandardError; end
|
|
14
|
+
|
|
15
|
+
# A run, or a worker pool, the driver is crawling.
|
|
16
|
+
class Run
|
|
17
|
+
attr_reader :id
|
|
18
|
+
|
|
19
|
+
def initialize(id, &cancel)
|
|
20
|
+
@id = id
|
|
21
|
+
@cancel = cancel
|
|
22
|
+
@lock = Mutex.new
|
|
23
|
+
@changed = ConditionVariable.new
|
|
24
|
+
@records = []
|
|
25
|
+
@events = []
|
|
26
|
+
@ended = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Each record's data, as it is emitted, from the start; the enumerator ends with the run.
|
|
30
|
+
def records = follow(@records)
|
|
31
|
+
|
|
32
|
+
# Every crawl event, as core reports it, from the start; the enumerator ends with the run.
|
|
33
|
+
def events = follow(@events)
|
|
34
|
+
|
|
35
|
+
# The run's report, once it has finished; raises when the run could not run.
|
|
36
|
+
def report
|
|
37
|
+
@lock.synchronize do
|
|
38
|
+
@changed.wait(@lock) until @ended
|
|
39
|
+
@report or raise(@error || "#{@id} ended without a report")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Stops the run; it still ends, and report still answers.
|
|
44
|
+
def cancel = @cancel.call
|
|
45
|
+
|
|
46
|
+
# @api private
|
|
47
|
+
def add(kind, item)
|
|
48
|
+
@lock.synchronize do
|
|
49
|
+
(kind == :record ? @records : @events) << item
|
|
50
|
+
@changed.broadcast
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @api private
|
|
55
|
+
def finish(params)
|
|
56
|
+
@lock.synchronize do
|
|
57
|
+
@report = params['report']
|
|
58
|
+
@error = params['error']
|
|
59
|
+
@ended = true
|
|
60
|
+
@changed.broadcast
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def follow(items)
|
|
67
|
+
Enumerator.new do |yielder|
|
|
68
|
+
seen = 0
|
|
69
|
+
loop do
|
|
70
|
+
fresh, done = @lock.synchronize do
|
|
71
|
+
@changed.wait(@lock) while seen == items.length && !@ended
|
|
72
|
+
[items[seen..], @ended]
|
|
73
|
+
end
|
|
74
|
+
fresh.each { |item| yielder << item }
|
|
75
|
+
seen += fresh.length
|
|
76
|
+
break if done && seen == items.length
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Starts the driver once and drives it: validate, run, work, probe, diff and export. Hooks, captcha solvers
|
|
83
|
+
# and access plugins are blocks registered by name before runs start; what a block returns is its answer,
|
|
84
|
+
# and raising fails that one call, never the run.
|
|
85
|
+
class Crawler
|
|
86
|
+
attr_reader :info
|
|
87
|
+
|
|
88
|
+
# The driver is command when given; else OPENCRAW_DRIVER or the downloaded runtime of version (no Node needed).
|
|
89
|
+
def self.open(**)
|
|
90
|
+
crawler = new(**)
|
|
91
|
+
return crawler unless block_given?
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
yield crawler
|
|
95
|
+
ensure
|
|
96
|
+
crawler.close
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def initialize(version: nil, command: nil, cache_dir: nil, offline: nil, env: nil)
|
|
101
|
+
@callouts = { 'hook' => {}, 'captcha' => {}, 'access' => {} }
|
|
102
|
+
@runs = {}
|
|
103
|
+
@next_items = {}
|
|
104
|
+
@lock = Mutex.new
|
|
105
|
+
@routed = ConditionVariable.new
|
|
106
|
+
command ||= DriverRuntime.ensure_driver_runtime(version.to_s, cache_dir: cache_dir, offline: offline).command
|
|
107
|
+
@channel = DriverChannel::JsonRpcChannel.new(command, env: env) { |method, params| handle(method, params) }
|
|
108
|
+
@info = @channel.request('initialize',
|
|
109
|
+
{ clientName: 'opencraw-ruby', clientVersion: '0.0.1',
|
|
110
|
+
protocolVersion: PROTOCOL_VERSION })
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Serves the hook name: block(input, args, request).
|
|
114
|
+
def hook(name, &block) = register('hook', name, block)
|
|
115
|
+
|
|
116
|
+
# Serves a captcha solver: block(challenge, request) answering { 'token' => ... } or { 'text' => ... }.
|
|
117
|
+
def captcha_solver(name, &block) = register('captcha', name, block)
|
|
118
|
+
|
|
119
|
+
# Serves an access plugin: block(input, request), a lease (phase "lease") or a release.
|
|
120
|
+
def access_plugin(name, &block) = register('access', name, block)
|
|
121
|
+
|
|
122
|
+
# Checks recipes without running them: the inputs, the hooks they call, every problem with its path.
|
|
123
|
+
def validate(paths: nil, recipes: nil) = @channel.request('validate', source(paths, recipes))
|
|
124
|
+
|
|
125
|
+
# Starts a run; options are the driver's run options (out, only, dryRun, parallel, access, ...).
|
|
126
|
+
def run(paths: nil, recipes: nil, **options) = start('run', source(paths, recipes).merge(options))
|
|
127
|
+
|
|
128
|
+
# Starts a worker pool: next_item gives each window its item ({ 'id', 'vars' }), nil ends the work.
|
|
129
|
+
def work(next_item, paths: nil, recipes: nil, **options)
|
|
130
|
+
run = start('work', source(paths, recipes).merge(options))
|
|
131
|
+
@lock.synchronize do
|
|
132
|
+
@next_items[run.id] = next_item
|
|
133
|
+
@routed.broadcast
|
|
134
|
+
end
|
|
135
|
+
run
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Fetches a page (or renders it, browser: true) and says where its data lives.
|
|
139
|
+
def probe(url, **)
|
|
140
|
+
@channel.request('probe', { url: url, **, accessPlugins: @callouts['access'].keys })
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Compares two runs' JSON Lines files by key.
|
|
144
|
+
def diff(previous, current, **)
|
|
145
|
+
@channel.request('diff', { previous: previous, current: current, ** })
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Writes a run's JSON Lines file in another format, with an output recipe's columns.
|
|
149
|
+
def export(records, output, out) = @channel.request('export', { records: records, output: output, out: out })
|
|
150
|
+
|
|
151
|
+
# Asks the driver to shut down and waits for it to exit.
|
|
152
|
+
def close
|
|
153
|
+
begin
|
|
154
|
+
@channel.request('shutdown')
|
|
155
|
+
rescue DriverChannel::DriverError
|
|
156
|
+
nil # Already gone: nothing to stop.
|
|
157
|
+
end
|
|
158
|
+
@channel.close
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def register(kind, name, block)
|
|
164
|
+
@lock.synchronize { @callouts[kind][name.to_s] = block }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def source(paths, recipes)
|
|
168
|
+
raise ArgumentError, 'give exactly one of paths or recipes' if paths.nil? == recipes.nil?
|
|
169
|
+
|
|
170
|
+
paths ? { paths: paths.map(&:to_s) } : { recipes: recipes }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def start(method, params)
|
|
174
|
+
served = @lock.synchronize do
|
|
175
|
+
{ hooks: @callouts['hook'].keys, captchaSolvers: @callouts['captcha'].keys,
|
|
176
|
+
accessPlugins: @callouts['access'].keys }
|
|
177
|
+
end
|
|
178
|
+
id = @channel.request(method, params.merge(served))['runId']
|
|
179
|
+
run = Run.new(id) { @channel.request('cancel', { runId: id })['cancelled'] }
|
|
180
|
+
@lock.synchronize do
|
|
181
|
+
@runs[id] = run
|
|
182
|
+
@routed.broadcast
|
|
183
|
+
end
|
|
184
|
+
run
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A run's first messages can arrive with its answer, before start has stored it.
|
|
188
|
+
def wait_for(&lookup)
|
|
189
|
+
@lock.synchronize do
|
|
190
|
+
@routed.wait(@lock) until (found = lookup.call)
|
|
191
|
+
found
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle(method, params)
|
|
196
|
+
return answer_callout(params['request']) if method == 'callout'
|
|
197
|
+
|
|
198
|
+
id = params['runId']
|
|
199
|
+
run = wait_for { @runs[id] }
|
|
200
|
+
case method
|
|
201
|
+
when 'work.next' then { item: wait_for { @next_items[id] }.call }
|
|
202
|
+
when 'event' then run.add(:event, params['event'])
|
|
203
|
+
when 'record' then run.add(:record, params['data'])
|
|
204
|
+
when 'finished'
|
|
205
|
+
@lock.synchronize { @runs.delete(id) }
|
|
206
|
+
run.finish(params)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def answer_callout(call)
|
|
211
|
+
block = @lock.synchronize { @callouts.dig(call['kind'], call['name']) }
|
|
212
|
+
return { status: 'error', error: "this client serves no #{call['kind']} #{call['name'].inspect}" } unless block
|
|
213
|
+
|
|
214
|
+
output = if call['kind'] == 'hook'
|
|
215
|
+
block.call(call['input'], call['args'] || {},
|
|
216
|
+
call)
|
|
217
|
+
else
|
|
218
|
+
block.call(call['input'], call)
|
|
219
|
+
end
|
|
220
|
+
{ status: 'ok', output: output }
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
{ status: 'error', error: e.is_a?(CalloutError) ? e.message : "#{e.class}: #{e.message}" }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module OpenCraw
|
|
7
|
+
# The driver's protocol: JSON-RPC 2.0, one message per line, over the driver process's pipes.
|
|
8
|
+
module DriverChannel
|
|
9
|
+
# The driver answered a request with an error: JSON-RPC's code and message.
|
|
10
|
+
class DriverError < StandardError
|
|
11
|
+
attr_reader :code
|
|
12
|
+
|
|
13
|
+
def initialize(code, message)
|
|
14
|
+
super(message)
|
|
15
|
+
@code = code
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A started driver and the protocol over its stdin and stdout. A reader thread settles answers and hands
|
|
20
|
+
# the driver's notifications to the handler in order; each request from the driver (a callout, work.next)
|
|
21
|
+
# runs on its own thread, so a slow hook never holds the channel. Writes are one line each, never interleaved.
|
|
22
|
+
class JsonRpcChannel
|
|
23
|
+
def initialize(command, env: nil, &handler)
|
|
24
|
+
args = env ? [env, *command, { unsetenv_others: true }] : command
|
|
25
|
+
@stdin, @stdout, @stderr, @process = Open3.popen3(*args)
|
|
26
|
+
@handler = handler
|
|
27
|
+
@pending = {}
|
|
28
|
+
@next_id = 1
|
|
29
|
+
@lock = Mutex.new
|
|
30
|
+
@write = Mutex.new
|
|
31
|
+
@closed = false
|
|
32
|
+
Thread.new { @stderr.each_line { nil } }
|
|
33
|
+
@reader = Thread.new { read }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sends a request and waits for its result; raises DriverError with the driver's error.
|
|
37
|
+
def request(method, params = nil)
|
|
38
|
+
reply = Queue.new
|
|
39
|
+
id = @lock.synchronize do
|
|
40
|
+
raise DriverError.new(-32_000, 'the driver has exited') if @closed
|
|
41
|
+
|
|
42
|
+
@pending[@next_id] = reply
|
|
43
|
+
(@next_id += 1) - 1
|
|
44
|
+
end
|
|
45
|
+
write(jsonrpc: '2.0', id: id, method: method, params: params)
|
|
46
|
+
answer = reply.pop
|
|
47
|
+
raise answer if answer.is_a?(DriverError)
|
|
48
|
+
|
|
49
|
+
answer
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Closes the driver's stdin and waits for it to exit.
|
|
53
|
+
def close
|
|
54
|
+
@stdin.close unless @stdin.closed?
|
|
55
|
+
@reader.join
|
|
56
|
+
@process.value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def write(message)
|
|
62
|
+
@write.synchronize do
|
|
63
|
+
@stdin.puts(JSON.generate(message))
|
|
64
|
+
@stdin.flush
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def answer(message)
|
|
69
|
+
result = @handler.call(message['method'], message['params'] || {})
|
|
70
|
+
write(jsonrpc: '2.0', id: message['id'], result: result)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
write(jsonrpc: '2.0', id: message['id'], error: { code: -32_000, message: e.message })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def read
|
|
76
|
+
@stdout.each_line do |line|
|
|
77
|
+
next if line.strip.empty?
|
|
78
|
+
|
|
79
|
+
message = JSON.parse(line)
|
|
80
|
+
if message['method']
|
|
81
|
+
if message.key?('id')
|
|
82
|
+
Thread.new { answer(message) }
|
|
83
|
+
else
|
|
84
|
+
# In order: a run's last events land before its finished.
|
|
85
|
+
@handler.call(message['method'], message['params'] || {})
|
|
86
|
+
end
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
reply = @lock.synchronize { @pending.delete(message['id']) }
|
|
90
|
+
next unless reply
|
|
91
|
+
|
|
92
|
+
error = message['error']
|
|
93
|
+
reply << (error ? DriverError.new(error['code'], error['message']) : message['result'])
|
|
94
|
+
end
|
|
95
|
+
ensure
|
|
96
|
+
@lock.synchronize do
|
|
97
|
+
@closed = true
|
|
98
|
+
@pending.each_value { |reply| reply << DriverError.new(-32_000, 'the driver has exited') }
|
|
99
|
+
@pending.clear
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require 'rbconfig'
|
|
8
|
+
require 'rubygems/package'
|
|
9
|
+
require 'securerandom'
|
|
10
|
+
require 'uri'
|
|
11
|
+
require 'zlib'
|
|
12
|
+
require_relative 'runtime_manifest_contract'
|
|
13
|
+
|
|
14
|
+
module OpenCraw
|
|
15
|
+
# The driver's runtime: one archive per platform, downloaded once, so a Ruby program needs no Node.
|
|
16
|
+
module DriverRuntime
|
|
17
|
+
# Where the driver's releases are: <base>/driver@<version>/driver-runtime.json and the archives beside it.
|
|
18
|
+
RELEASES_URL = 'https://github.com/Open-Craw/OpenCraw/releases/download'
|
|
19
|
+
|
|
20
|
+
# A runtime ready to start: its folder, and the command, absolute.
|
|
21
|
+
Runtime = Struct.new(:folder, :command, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
# This machine as the runtime names platforms: Node's process.platform-process.arch.
|
|
24
|
+
def self.current_platform
|
|
25
|
+
host = RbConfig::CONFIG['host_os']
|
|
26
|
+
system = if host.match?(/mswin|mingw|cygwin/) then 'win32'
|
|
27
|
+
elsif host.include?('darwin') then 'darwin'
|
|
28
|
+
else 'linux'
|
|
29
|
+
end
|
|
30
|
+
arch = RbConfig::CONFIG['host_cpu'].match?(/arm64|aarch64/) ? 'arm64' : 'x64'
|
|
31
|
+
"#{system}-#{arch}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The driver's runtime for this platform, downloaded once into a per-user cache. OPENCRAW_DRIVER names an
|
|
35
|
+
# unpacked runtime to use as it is; a cached one is used as it is. Otherwise, unless offline, the release's
|
|
36
|
+
# driver-runtime.json names this platform's archive: it is downloaded, refused when its SHA-256 differs,
|
|
37
|
+
# unpacked into a temporary folder (refusing links and entries outside it) and moved into the cache in one
|
|
38
|
+
# step, so a half-written runtime is never used.
|
|
39
|
+
def self.ensure_driver_runtime(version, cache_dir: nil, offline: nil, platform: nil, base_url: RELEASES_URL,
|
|
40
|
+
env: ENV, fetch: nil)
|
|
41
|
+
named = env['OPENCRAW_DRIVER'].to_s
|
|
42
|
+
return runtime_in(File.expand_path(named)) unless named.empty?
|
|
43
|
+
|
|
44
|
+
where = platform || current_platform
|
|
45
|
+
cache = cache_dir || File.join(Dir.home, '.cache', 'opencraw', 'driver')
|
|
46
|
+
folder = File.join(cache, version, where)
|
|
47
|
+
return runtime_in(folder) if File.exist?(File.join(folder, 'runtime.json'))
|
|
48
|
+
|
|
49
|
+
offline = env['OPENCRAW_DRIVER_OFFLINE'] == '1' if offline.nil?
|
|
50
|
+
if offline
|
|
51
|
+
raise "driver #{version} for #{where} is not in #{cache}, and downloading is off (OPENCRAW_DRIVER_OFFLINE)"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
get = fetch || method(:download)
|
|
55
|
+
release = "#{base_url}/#{URI.encode_www_form_component("driver@#{version}")}"
|
|
56
|
+
manifest = parse_manifest(JSON.parse(get.call("#{release}/driver-runtime.json")))
|
|
57
|
+
archive = manifest.archives[where]
|
|
58
|
+
raise "driver #{version} has no runtime for #{where}: it has #{manifest.archives.keys.join(', ')}" unless archive
|
|
59
|
+
|
|
60
|
+
bytes = get.call("#{release}/#{archive.file}")
|
|
61
|
+
sha256 = Digest::SHA256.hexdigest(bytes)
|
|
62
|
+
if sha256 != archive.sha256
|
|
63
|
+
raise "#{archive.file} does not match driver-runtime.json " \
|
|
64
|
+
"(sha256 #{sha256}, expected #{archive.sha256}): refusing it"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
FileUtils.mkdir_p(File.join(cache, version))
|
|
68
|
+
unpacking = File.join(cache, version, ".#{where}-#{SecureRandom.hex(4)}")
|
|
69
|
+
begin
|
|
70
|
+
untar(bytes, unpacking)
|
|
71
|
+
File.rename(unpacking, folder)
|
|
72
|
+
rescue StandardError
|
|
73
|
+
FileUtils.rm_rf(unpacking)
|
|
74
|
+
# Another process may have put the same runtime there first: that one is as good.
|
|
75
|
+
return runtime_in(folder) if File.exist?(File.join(folder, 'runtime.json'))
|
|
76
|
+
|
|
77
|
+
raise
|
|
78
|
+
end
|
|
79
|
+
runtime_in(folder)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.download(url, limit = 5)
|
|
83
|
+
response = Net::HTTP.get_response(URI(url))
|
|
84
|
+
return download(response['location'], limit - 1) if response.is_a?(Net::HTTPRedirection) && limit.positive?
|
|
85
|
+
raise "GET #{url}: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
86
|
+
|
|
87
|
+
response.body.b
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Unpacks a .tar.gz into folder, refusing any link and any entry that would land outside it.
|
|
91
|
+
def self.untar(bytes, folder)
|
|
92
|
+
FileUtils.mkdir_p(folder)
|
|
93
|
+
root = "#{File.expand_path(folder)}/"
|
|
94
|
+
Zlib::GzipReader.wrap(StringIO.new(bytes)) do |zipped|
|
|
95
|
+
Gem::Package::TarReader.new(zipped) do |entries|
|
|
96
|
+
entries.each do |entry|
|
|
97
|
+
target = File.expand_path(entry.full_name, folder)
|
|
98
|
+
unless "#{target}/".start_with?(root)
|
|
99
|
+
raise "the archive's #{entry.full_name.inspect} is outside the runtime's folder: refusing it"
|
|
100
|
+
end
|
|
101
|
+
if entry.symlink? || entry.header.typeflag == '1'
|
|
102
|
+
raise "the archive's #{entry.full_name.inspect} is a link: refusing it"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if entry.directory?
|
|
106
|
+
FileUtils.mkdir_p(target)
|
|
107
|
+
elsif entry.file?
|
|
108
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
109
|
+
File.binwrite(target, entry.read.to_s)
|
|
110
|
+
File.chmod((entry.header.mode & 0o777) | 0o600, target)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.runtime_in(folder)
|
|
118
|
+
described = JSON.parse(File.read(File.join(folder, 'runtime.json')))
|
|
119
|
+
Runtime.new(folder: folder, command: described.fetch('command').map { |part| File.join(folder, part) })
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private_class_method :download, :untar, :runtime_in
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenCraw
|
|
4
|
+
# The driver's runtime: its release manifest's shape.
|
|
5
|
+
module DriverRuntime
|
|
6
|
+
# The platforms the runtime is built for.
|
|
7
|
+
PLATFORMS = %w[win32-x64 darwin-arm64 darwin-x64 linux-x64 linux-arm64].freeze
|
|
8
|
+
|
|
9
|
+
# One platform's archive: its file name beside the manifest, its SHA-256 and its size.
|
|
10
|
+
RuntimeArchive = Struct.new(:file, :sha256, :byte_size, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# driver-runtime.json, the driver's schemas/driver-runtime.schema.json: every platform's archive of one release.
|
|
13
|
+
RuntimeManifest = Struct.new(:driver_version, :protocol_version, :node, :archives, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# The manifest, checked the way its schema checks it.
|
|
16
|
+
def self.parse_manifest(value)
|
|
17
|
+
raise ArgumentError, 'driver-runtime.json is not an object' unless value.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
%w[driverVersion protocolVersion node].each do |field|
|
|
20
|
+
raise ArgumentError, "driver-runtime.json: #{field} is required" unless value[field].is_a?(String)
|
|
21
|
+
end
|
|
22
|
+
raise ArgumentError, 'driver-runtime.json: archives is required' unless value['archives'].is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
archives = value['archives'].to_h do |platform, archive|
|
|
25
|
+
raise ArgumentError, "driver-runtime.json: #{platform} is not a platform" unless PLATFORMS.include?(platform)
|
|
26
|
+
|
|
27
|
+
valid = archive.is_a?(Hash) && archive['file'].to_s.match?(/\Aopencraw-driver-[\w.-]+\.tar\.gz\z/) &&
|
|
28
|
+
archive['sha256'].to_s.match?(/\A[0-9a-f]{64}\z/)
|
|
29
|
+
unless valid
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"driver-runtime.json: the archive for #{platform} has no valid file and sha256"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
archived = RuntimeArchive.new(file: archive['file'], sha256: archive['sha256'], byte_size: archive['size'].to_i)
|
|
35
|
+
[platform, archived]
|
|
36
|
+
end
|
|
37
|
+
RuntimeManifest.new(driver_version: value['driverVersion'], protocol_version: value['protocolVersion'],
|
|
38
|
+
node: value['node'], archives: archives)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/opencraw.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'opencraw/crawl_session/crawl_session'
|
|
4
|
+
|
|
5
|
+
# Crawl with OpenCraw from Ruby, with no Node to install: OpenCraw::Crawler downloads the driver's runtime for
|
|
6
|
+
# this platform once, starts it, and drives it over its pipes. Its code lives in the slices under opencraw/.
|
|
7
|
+
module OpenCraw
|
|
8
|
+
Crawler = CrawlSession::Crawler
|
|
9
|
+
Run = CrawlSession::Run
|
|
10
|
+
CalloutError = CrawlSession::CalloutError
|
|
11
|
+
DriverError = DriverChannel::DriverError
|
|
12
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: opencraw
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- opencraw
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
executables: []
|
|
13
|
+
extensions: []
|
|
14
|
+
extra_rdoc_files: []
|
|
15
|
+
files:
|
|
16
|
+
- README.md
|
|
17
|
+
- lib/opencraw.rb
|
|
18
|
+
- lib/opencraw/crawl_session/crawl_session.rb
|
|
19
|
+
- lib/opencraw/crawl_session/crawler_use_case.rb
|
|
20
|
+
- lib/opencraw/driver_channel/driver_channel.rb
|
|
21
|
+
- lib/opencraw/driver_channel/json_rpc_channel_client.rb
|
|
22
|
+
- lib/opencraw/driver_runtime/driver_runtime.rb
|
|
23
|
+
- lib/opencraw/driver_runtime/ensure_driver_runtime_use_case.rb
|
|
24
|
+
- lib/opencraw/driver_runtime/runtime_manifest_contract.rb
|
|
25
|
+
homepage: https://github.com/Open-Craw/OpenCraw
|
|
26
|
+
licenses:
|
|
27
|
+
- MIT
|
|
28
|
+
metadata:
|
|
29
|
+
rubygems_mfa_required: 'true'
|
|
30
|
+
smith_kind: package
|
|
31
|
+
rdoc_options: []
|
|
32
|
+
require_paths:
|
|
33
|
+
- lib
|
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.2'
|
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0'
|
|
44
|
+
requirements: []
|
|
45
|
+
rubygems_version: 3.6.9
|
|
46
|
+
specification_version: 4
|
|
47
|
+
summary: 'Crawl with OpenCraw from Ruby, with no Node to install: the engine through
|
|
48
|
+
its driver.'
|
|
49
|
+
test_files: []
|