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 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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'crawler_use_case'
@@ -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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'json_rpc_channel_client'
@@ -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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ensure_driver_runtime_use_case'
4
+ require_relative 'runtime_manifest_contract'
@@ -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: []