crabfarm 0.0.9 → 0.0.10

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: bb3be13182df3bfb8b36dbf9561680f76b0a8c4a
4
- data.tar.gz: f2dfc98fd27fc4523ea715ec8dd811a98977237d
3
+ metadata.gz: 1031e279aeab473f8e46469b3f91383a4dffbd70
4
+ data.tar.gz: f366d36831117570d65b999de947bb5d6af0d0d1
5
5
  SHA512:
6
- metadata.gz: db1a6932c2ff5645f3dfe8c77c3e092a39fd9c43ad52293098180cff4f5732cc1b7cd71eb5460556fcb56de456d11592a2ba04d5681bad4e198552fab12aa71d
7
- data.tar.gz: 81b972f5cec55ba61d90ec943944ad17524b20fc199dbab37fad746a26de62631f18680efcc6f67cc12c03f1b383ca9ce800aa91f8d44df300e4579a57e1bdb3
6
+ metadata.gz: a5e46aeefc0f4fe6c96ad2f5d02ee312c826a5a724e20b9797818ddb4c876245b6e58331a85feaafc48ab7b7f97f38c3dd1fd497705c72020559bf84ee8c22e4
7
+ data.tar.gz: df9dd5a3874ef38e462d1adfcf8c4ae92bf4b8f7ed0cfda6456d7e3ed6a1ceb2b791e1dd6f89988bec33bdf8d3fa9ef7db86c38515c43ae1de47aa314876063e
data/bin/crabtrap ADDED
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env node
2
+
3
+ var net = require('net'),
4
+ http = require('http'),
5
+ https = require('https'),
6
+ url = require('url'),
7
+ fs = require('fs'),
8
+ zlib = require('zlib');
9
+
10
+ // Globals
11
+
12
+ var HTTPS_OPTIONS = {
13
+ key: '-----BEGIN RSA PRIVATE KEY-----\nMIIBOQIBAAJBAK/L/lXb/kxUzve1olo71s6mQLvuQCm3z2wqClq71NLerFnaXpN+\nFrNPy7+R3gZ1hdWXqbN5NqpWDMM9fcbd7p0CAwEAAQJAUDImN3Lhgl7Z/+TLSJCt\nwJ3VQCZC/QUOSdCv4o53Wy5aL/n8ootYFC3eoFC2Nal5bnH6onP9YR+X9l3HKLaT\n3QIhANXwb5SvJ+Kewa8F5wNHo9LFjSbL7WSSb1MyvYnOeFlPAiEA0lvaLz6UXRDL\n6T6Z1fkF0exmQqVimeL5qjY5o9Gk5lMCH1A52Z3oEQzqe7cmf3q7YrOnYUcrMdqF\nDzojzO/gfUECIQCe9fImiW+r9CljFH9Dhm6zd6S+8CNWjoKD8X4VITMvKQIgb3sg\nq9gPVzXn/+f8Qcc2KILSh3ffkIpA8yJK9omUIxI=\n-----END RSA PRIVATE KEY-----\n',
14
+ cert: '-----BEGIN CERTIFICATE-----\nMIIBmDCCAUICCQDGtiGKgI9AXjANBgkqhkiG9w0BAQUFADBTMQswCQYDVQQGEwJD\nTDELMAkGA1UECBMCUk0xETAPBgNVBAcTCFNhbnRpYWdvMREwDwYDVQQKEwhQbGF0\nYW51czERMA8GA1UEAxMIQ3JhYnRyYXAwHhcNMTUwMTE1MjAxNzMzWhcNNDIwNjAx\nMjAxNzMzWjBTMQswCQYDVQQGEwJDTDELMAkGA1UECBMCUk0xETAPBgNVBAcTCFNh\nbnRpYWdvMREwDwYDVQQKEwhQbGF0YW51czERMA8GA1UEAxMIQ3JhYnRyYXAwXDAN\nBgkqhkiG9w0BAQEFAANLADBIAkEAr8v+Vdv+TFTO97WiWjvWzqZAu+5AKbfPbCoK\nWrvU0t6sWdpek34Ws0/Lv5HeBnWF1Zeps3k2qlYMwz19xt3unQIDAQABMA0GCSqG\nSIb3DQEBBQUAA0EAmecqIZqQ8OXSIj0V2VKaIXwz8RBnhLzU7BJwcsWJE/Bex7zB\nWP+vLv9ML5ZRLCsXjL5IOav8qAX/NZXjoN3e3Q==\n-----END CERTIFICATE-----\n'
15
+ };
16
+
17
+ var LOG = {
18
+ DEBUG: 0,
19
+ INFO: 1,
20
+ WARN: 2,
21
+ ERROR: 3
22
+ };
23
+
24
+ var STACK = [],
25
+ MODE = false,
26
+ SOURCE = null,
27
+ PORT = 4000,
28
+ LOG_LEVEL = LOG.WARN;
29
+
30
+ (function() {
31
+ if(process.argv.length < 2) throw 'Must provide a proxy mode';
32
+ MODE = process.argv[2];
33
+ var i = 3;
34
+
35
+ if(MODE != 'pass') {
36
+ if(process.argv.length < 3) throw 'Must provide a bucket path';
37
+ SOURCE = process.argv[3];
38
+ i = 4;
39
+ }
40
+
41
+ for(; i < process.argv.length; i++) {
42
+ var parts = process.argv[i].split('=');
43
+ switch(parts[0]) {
44
+ case '--port': PORT = parseInt(parts[1], 10); break;
45
+ case '--quiet': PORT = parseInt(parts[1], 10); break;
46
+ default: throw 'Invalid option ' + parts[0];
47
+ }
48
+ }
49
+ })();
50
+
51
+ // Utility methods
52
+
53
+ function log(_level, _message) {
54
+ if(_level == LOG.DEBUG) _message = '\t' + _message;
55
+ if(_level >= LOG_LEVEL) console.log(_message);
56
+ }
57
+
58
+ function forOwn(_obj, _cb) {
59
+ for(var key in _obj) {
60
+ if(_obj.hasOwnProperty(key)) {
61
+ _cb(key, _obj[key]);
62
+ }
63
+ }
64
+ }
65
+
66
+ function keysToLowerCase(_obj) {
67
+ var result = {};
68
+ forOwn(_obj, function(k,v) { result[k.toLowerCase()] = v; });
69
+ return result;
70
+ }
71
+
72
+ function pickRandomPort() {
73
+ return 0; // This could fail on Linux...
74
+ }
75
+
76
+ function matchRequestToResource(_req, _resource) {
77
+ return _resource.method.toLowerCase() == _req.method.toLowerCase() && _resource.url == _req.url;
78
+ }
79
+
80
+ function matchRequestToResourceWOQuery(_req, _resource) {
81
+ if(_resource.method.toLowerCase() == _req.method.toLowerCase()) return false;
82
+
83
+ var reqUrl = url.parse(_req.url, true),
84
+ resUrl = url.parse(_resource.url, true);
85
+
86
+ return reqUrl.hostname == resUrl.hostname && reqUrl.pathname == resUrl.pathname;
87
+ }
88
+
89
+ function findAndMoveLast(_req, _array, _matches) {
90
+ for(var i = 0, l = _array.length; i < l; i++) {
91
+ if(_matches(_req, _array[i])) {
92
+ var resource = _array.splice(i, 1)[0];
93
+ _array.push(resource);
94
+ return resource;
95
+ }
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ function loadStackFrom(_path, _then) {
102
+ var data = fs.readFileSync(_path);
103
+ zlib.gunzip(data, function(err, buffer) {
104
+ if (!err) STACK = JSON.parse(buffer.toString());
105
+ _then();
106
+ });
107
+ }
108
+
109
+ function saveStackTo(_path, _then) {
110
+ var data = JSON.stringify(STACK);
111
+ zlib.gzip(data, function(err, buffer) {
112
+ if (!err) fs.writeFileSync(_path, buffer);
113
+ _then();
114
+ });
115
+ }
116
+
117
+ function resolveAndServeResource(_req, _resp) {
118
+ var resource = findInStack(_req);
119
+ if(resource) {
120
+ log(LOG.INFO, "Serving: " + resource.method + ' ' + resource.url);
121
+ log(LOG.DEBUG, "HTTP " + resource.statusCode);
122
+ log(LOG.DEBUG, JSON.stringify(resource.headers));
123
+
124
+ serveResource(resource, _resp);
125
+ } else {
126
+ log(LOG.WARN, 'Not found: ' + _req.url);
127
+ _resp.statusCode = 404;
128
+ _resp.end();
129
+ }
130
+ }
131
+
132
+ function serveLastResource(_resp) {
133
+ serveResource(STACK[STACK.length-1], _resp);
134
+ }
135
+
136
+ function serveResource(_resource, _resp) {
137
+ _resp.statusCode = _resource.statusCode;
138
+
139
+ forOwn(_resource.headers, function(k, v) { _resp.setHeader(k, v); });
140
+
141
+ if(_resource.content) {
142
+ var buf = new Buffer(_resource.content, _resource.encoding);
143
+ _resp.end(buf);
144
+ } else {
145
+ _resp.end();
146
+ }
147
+ }
148
+
149
+ function findAndMoveLast(_req, _matches) {
150
+ for(var i = 0, l = STACK.length; i < l; i++) {
151
+ if(_matches(_req, STACK[i])) {
152
+ var resource = STACK.splice(i, 1)[0];
153
+ STACK.push(resource);
154
+ return resource;
155
+ }
156
+ }
157
+
158
+ return null;
159
+ }
160
+
161
+ function findInStack(_req, _partial) {
162
+ return findAndMoveLast(_req, matchRequestToResource) ||
163
+ findAndMoveLast(_req, matchRequestToResourceWOQuery);
164
+ }
165
+
166
+ function cacheResponse(_req, _resp, _cb) {
167
+
168
+ log(LOG.INFO, "Caching Response");
169
+ log(LOG.DEBUG, "HTTP " + _resp.statusCode);
170
+ log(LOG.DEBUG, JSON.stringify(keysToLowerCase(_resp.headers)));
171
+
172
+ var encoding = null,
173
+ // TODO: consider storing port and protocoll in the resource.
174
+ resource = {
175
+ url: _req.url,
176
+ statusCode: _resp.statusCode,
177
+ method: _req.method,
178
+ // inHeaders: req.headers, // store request headers to aid in recognition?
179
+ headers: keysToLowerCase(_resp.headers),
180
+ content: '',
181
+ encoding: 'base64'
182
+ },
183
+ contentEncoding = resource.headers['content-encoding'],
184
+ contentType = resource.headers['content-type'],
185
+ outStream = _resp;
186
+
187
+ // add decompression if supported encoding:
188
+ if(contentEncoding == 'gzip') {
189
+ outStream = _resp.pipe(zlib.createGunzip());
190
+ delete resource.headers['content-encoding'];
191
+ contentEncoding = null;
192
+ } else if(contentEncoding == 'deflate') {
193
+ outStream = _resp.pipe(zlib.createInflate());
194
+ delete resource.headers['content-encoding'];
195
+ contentEncoding = null;
196
+ }
197
+
198
+ // use utf8 encoding for uncompresed text:
199
+ if(!contentEncoding && contentType) {
200
+ contentType = contentType.match(/([^\/]+)\/([^\s]+)(?:\s+(.+))?/i);
201
+ if(contentType && (contentType[1] == 'text' || contentType[1] == 'application')) {
202
+ resource.encoding = 'utf-8';
203
+ }
204
+ }
205
+
206
+ // remove unwanted headers:
207
+ delete resource.headers['content-length'];
208
+
209
+ // start receiving data:
210
+ if(resource.encoding) outStream.setEncoding(resource.encoding);
211
+ outStream.on('data', function(_chunk) {
212
+ resource.content += _chunk;
213
+ });
214
+
215
+ // when all data is received, store resource (dont know how this will handle more than one request)
216
+ outStream.on('end', function() {
217
+ STACK.push(resource);
218
+ _cb();
219
+ });
220
+ }
221
+
222
+ function prepareForwardRequest(_req) {
223
+ var urlObj = url.parse(_req.url);
224
+
225
+ var options = {
226
+ method: _req.method,
227
+ host: urlObj.host,
228
+ path: urlObj.path,
229
+ rejectUnauthorized: false,
230
+ headers: keysToLowerCase(_req.headers)
231
+ };
232
+
233
+ // Rewrite headers
234
+ options.headers['accept-encoding'] = 'gzip,deflate';
235
+ return options;
236
+ }
237
+
238
+ function passRequest(_req, _resp) {
239
+ log(LOG.INFO, 'Passing through ' + _req.method + ' request for ' + _req.url);
240
+
241
+ var urlObj = url.parse(_req.url);
242
+ var forward = (urlObj.protocol == 'https:' ? https : http).request({
243
+ method: _req.method,
244
+ host: urlObj.host,
245
+ path: urlObj.path,
246
+ headers: _req.headers
247
+ }, function(_fw_resp) {
248
+ // pipe response back untouched
249
+ _resp.writeHead(_fw_resp.statusCode, _fw_resp.headers);
250
+ _fw_resp.pipe(_resp);
251
+ });
252
+
253
+ _req.pipe(forward);
254
+ }
255
+
256
+ function captureRequest(_req, _resp, _useSSL) {
257
+ log(LOG.INFO, 'Forwarding ' + _req.method + ' request for ' + _req.url);
258
+
259
+ var urlObj = url.parse(_req.url);
260
+ var options = {
261
+ method: _req.method,
262
+ host: urlObj.host,
263
+ path: urlObj.path,
264
+ rejectUnauthorized: false,
265
+ headers: keysToLowerCase(_req.headers)
266
+ };
267
+
268
+ // Rewrite headers
269
+ options.headers['accept-encoding'] = 'gzip,deflate';
270
+ log(LOG.DEBUG, JSON.stringify(options));
271
+
272
+ var forward = (urlObj.protocol == 'https:' ? https : http).request(options, function(_fw_resp) {
273
+ cacheResponse(_req, _fw_resp, function() {
274
+ serveLastResource(_resp);
275
+ });
276
+ });
277
+
278
+ _req.pipe(forward); // forward request data
279
+ }
280
+
281
+ function replayRequest(_req, _resp) {
282
+ log(LOG.INFO, 'Resolving ' + _req.method + ' request for ' + _req.url);
283
+ resolveAndServeResource(_req, _resp);
284
+ }
285
+
286
+ function selectProxy() {
287
+ switch(MODE) {
288
+ case 'pass': return passRequest;
289
+ case 'capture': return captureRequest;
290
+ case 'replay': return replayRequest;
291
+ default: throw 'Invalid proxy mode';
292
+ }
293
+ }
294
+
295
+ var PROXY_FUN = selectProxy(),
296
+ SERVER = http.createServer(PROXY_FUN);
297
+
298
+ // Special handler for HTTPS request, creates a dedicated HTTPS proxy per connection,
299
+ // that way the CONNECT tunnel can be intercepted, requires support for self signed
300
+ // certificates in the client.
301
+ SERVER.on('connect', function (_req, _sock, _head) {
302
+
303
+ var urlObj = url.parse('http://' + _req.url);
304
+ log(LOG.INFO, 'New HTTPS request: starting https intercept on ' + urlObj.hostname);
305
+
306
+ var httpsServ = https.createServer(HTTPS_OPTIONS, function(_req, _resp) {
307
+ _req.url = 'https://' + urlObj.hostname + _req.url;
308
+ PROXY_FUN(_req, _resp);
309
+ });
310
+
311
+ httpsServ.listen(pickRandomPort());
312
+
313
+ var tunnelSock = net.connect(httpsServ.address().port, function() {
314
+ _sock.write('HTTP/1.1 200 Connection Established\r\n' +
315
+ 'Proxy-agent: Node-Proxy\r\n' +
316
+ '\r\n');
317
+ tunnelSock.write(_head);
318
+ tunnelSock.pipe(_sock);
319
+ _sock.pipe(tunnelSock);
320
+ });
321
+
322
+ _sock.on('close', function() {
323
+ httpsServ.close();
324
+ });
325
+ });
326
+
327
+ console.log("Starting crabtrap! mode: " + MODE);
328
+
329
+ if(MODE == 'replay') {
330
+ loadStackFrom(SOURCE, SERVER.listen.bind(SERVER, PORT));
331
+ } else {
332
+ SERVER.listen(PORT);
333
+ }
334
+
335
+ var EXITING = false;
336
+ process.on('SIGINT', function() {
337
+ if(EXITING) return;
338
+ EXITING = true;
339
+
340
+ console.log("Shutting down crabtrap!");
341
+ SERVER.close();
342
+ if(MODE == 'capture') {
343
+ saveStackTo(SOURCE, process.exit.bind(process));
344
+ } else {
345
+ process.exit();
346
+ }
347
+ });
data/lib/crabfarm/cli.rb CHANGED
@@ -10,14 +10,28 @@ module Crabfarm
10
10
  desc "Starts the crawler in console mode"
11
11
  command [:console, :c] do |c|
12
12
 
13
+ c.desc "Capture to crabtrap file"
14
+ c.flag :capture
15
+
16
+ c.desc "Replay from crabtrap file"
17
+ c.flag :replay
18
+
13
19
  Support::GLI.generate_options c
14
20
 
15
21
  c.action do |global_options,options,args|
16
22
  next puts "This command can only be run inside a crabfarm application" unless defined? CF_PATH
17
23
 
18
- require "crabfarm/modes/console"
19
24
  Crabfarm.config.set Support::GLI.parse_options options
20
- Crabfarm::Modes::Console.start
25
+
26
+ next puts "Cannot use --replay with --capture" if options[:capture] and options[:replay]
27
+
28
+ require 'crabfarm/crabtrap_context'
29
+ context = Crabfarm::CrabtrapContext.new
30
+ context.capture options[:capture] if options[:capture]
31
+ context.replay options[:replay] if options[:replay]
32
+
33
+ require "crabfarm/modes/console"
34
+ Crabfarm::Modes::Console.start context
21
35
  end
22
36
  end
23
37
 
@@ -84,9 +98,29 @@ module Crabfarm
84
98
  end
85
99
  end
86
100
 
101
+ desc "Perform an HTTP recording for use in tests"
102
+ command [:record, :r] do |c|
103
+ c.action do |global_options, options, args|
104
+ next puts "This command can only be run inside a crabfarm application" unless defined? CF_PATH
105
+
106
+ require "crabfarm/modes/recorder"
107
+ Crabfarm::Modes::Recorder.start args[0]
108
+ end
109
+ end
110
+
111
+ desc "Publish the crawler to a crabfarm cloud"
87
112
  command :publish do |c|
113
+ c.desc "Just list the files that are beign packaged"
114
+ c.switch :dry, :default_value => false
115
+
116
+ c.desc "Don't check for pending changes"
117
+ c.switch :unsafe, :default_value => false
118
+
88
119
  c.action do |global_options,options,args|
120
+ next puts "This command can only be run inside a crabfarm application" unless defined? CF_PATH
89
121
 
122
+ require "crabfarm/modes/publisher"
123
+ Crabfarm::Modes::Publisher.publish CF_PATH, options
90
124
  end
91
125
  end
92
126
 
@@ -9,6 +9,7 @@ module Crabfarm
9
9
  [:output_builder, :string, 'Default json output builder used by states'],
10
10
  [:driver_factory, :mixed, 'Driver factory, disabled if phantom_mode is used'],
11
11
  [:log_path, :string, 'Path where logs should be stored'],
12
+ [:proxy, :string, 'If given, a proxy is used to connect to the internet if driver supports it'],
12
13
 
13
14
  # Default driver configuration parameters
14
15
  [:driver, ['chrome', 'firefox', 'phantomjs', 'remote'], 'Webdriver to be user, common options: chrome, firefox, phantomjs, remote.'],
@@ -21,10 +22,14 @@ module Crabfarm
21
22
 
22
23
  # Phantom launcher configuration
23
24
  [:phantom_load_images, :boolean, 'Phantomjs image loading, only for phantomjs driver.'],
24
- [:phantom_proxy, :string, 'Phantonjs proxy address, only for phantomjs driver.'],
25
25
  [:phantom_ssl, ['sslv3', 'sslv2', 'tlsv1', 'any'], 'Phantomjs ssl mode: sslv3, sslv2, tlsv1 or any, only for phantomjs driver.'],
26
26
  [:phantom_bin_path, :string, 'Phantomjs binary path, only for phantomjs driver.'],
27
- [:phantom_lock_file, :string, 'Phantomjs lock file path, only for phantomjs driver.']
27
+ [:phantom_lock_file, :string, 'Phantomjs lock file path, only for phantomjs driver.'],
28
+
29
+ # Crabtrap launcher configuration
30
+ [:crabtrap_bin, :string, 'Crabtrap binary path.'],
31
+ [:crabtrap_port, :integer, 'Crabtrap port, defaults to 4000.'],
32
+ [:crabtrap_mode, ['capture', 'replay'], 'Crabtrap operation mode.']
28
33
  ]
29
34
  .map { |o| Option.new *o }
30
35
 
@@ -48,6 +53,7 @@ module Crabfarm
48
53
  output_builder: :hash,
49
54
  driver_factory: nil,
50
55
  log_path: 'logs',
56
+ proxy: nil,
51
57
 
52
58
  driver: 'phantomjs',
53
59
  driver_capabilities: Selenium::WebDriver::Remote::Capabilities.firefox,
@@ -58,10 +64,12 @@ module Crabfarm
58
64
  driver_window_height: 800,
59
65
 
60
66
  phantom_load_images: false,
61
- phantom_proxy: nil,
62
67
  phantom_ssl: 'any',
63
68
  phantom_bin_path: 'phantomjs',
64
- phantom_lock_file: nil
69
+ phantom_lock_file: nil,
70
+
71
+ crabtrap_bin: 'crabtrap',
72
+ crabtrap_port: 4000
65
73
  }
66
74
  end
67
75
 
@@ -79,6 +87,7 @@ module Crabfarm
79
87
  def driver_config
80
88
  {
81
89
  name: driver,
90
+ proxy: proxy,
82
91
  capabilities: driver_capabilities,
83
92
  remote_host: driver_remote_host,
84
93
  remote_timeout: driver_remote_timeout,
@@ -94,7 +103,7 @@ module Crabfarm
94
103
  def phantom_config
95
104
  {
96
105
  load_images: phantom_load_images,
97
- proxy: phantom_proxy,
106
+ proxy: proxy,
98
107
  ssl: phantom_ssl,
99
108
  bin_path: phantom_bin_path,
100
109
  lock_file: phantom_lock_file,
@@ -102,9 +111,17 @@ module Crabfarm
102
111
  }
103
112
  end
104
113
 
105
- # Add enviroment support (like a Gemfile)
106
- # group :test { set_driver :phantom }
107
- # set_driver :phantom, group: :test
114
+ def crabtrap_config
115
+ {
116
+ bin_path: crabtrap_bin,
117
+ port: crabtrap_port,
118
+ proxy: proxy
119
+ }
120
+ end
121
+
122
+ # Add enviroment support (like a Gemfile)
123
+ # group :test { set_driver :phantom }
124
+ # set_driver :phantom, group: :test
108
125
 
109
126
  end
110
127
 
@@ -7,23 +7,67 @@ module Crabfarm
7
7
  def_delegators :@pool, :driver
8
8
 
9
9
  def initialize
10
- @pool = DriverBucketPool.new
11
10
  @store = StateStore.new
11
+ @loaded = false
12
+ end
13
+
14
+ def load
15
+ unless @loaded
16
+ init_phantom_if_required
17
+ @pool = DriverBucketPool.new build_driver_factory
18
+ @loaded = true
19
+ end
12
20
  end
13
21
 
14
22
  def run_state(_name, _params={})
23
+ load
15
24
  state = LoaderService.load_state(_name).new @pool, @store, _params
16
25
  state.crawl
17
26
  state
18
27
  end
19
28
 
20
29
  def reset
30
+ load
21
31
  @store.reset
22
32
  @pool.reset
23
33
  end
24
34
 
25
35
  def release
26
- @pool.release
36
+ if @loaded
37
+ @pool.release
38
+ @phantom.stop unless @phantom.nil?
39
+ @loaded = false
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def init_phantom_if_required
46
+ if config.phantom_mode_enabled?
47
+ @phantom = PhantomRunner.new phantom_config
48
+ @phantom.start
49
+ end
50
+ end
51
+
52
+ def build_driver_factory
53
+ if @phantom
54
+ PhantomDriverFactory.new @phantom, driver_config
55
+ else
56
+ return config.driver_factory if config.driver_factory
57
+ DefaultDriverFactory.new driver_config
58
+ end
59
+ end
60
+
61
+ def config
62
+ Crabfarm.config
63
+ end
64
+
65
+ def driver_config
66
+ config.driver_config
67
+ end
68
+
69
+ def phantom_config
70
+ config.phantom_config
27
71
  end
28
72
 
29
73
  end
@@ -0,0 +1,54 @@
1
+ require 'active_support'
2
+ require 'crabfarm/crabtrap_runner'
3
+
4
+ module Crabfarm
5
+ class CrabtrapContext < Context
6
+
7
+ def load
8
+ pass_through if @runner.nil?
9
+ super
10
+ end
11
+
12
+ def pass_through
13
+ restart_with_options(mode: :pass) if @runner.nil? or @runner.mode != :pass
14
+ end
15
+
16
+ def capture(_path)
17
+ restart_with_options(mode: :capture, bucket_path: _path)
18
+ end
19
+
20
+ def replay(_path)
21
+ restart_with_options(mode: :replay, bucket_path: _path)
22
+ end
23
+
24
+ def release
25
+ super
26
+ stop_daemon
27
+ end
28
+
29
+ private
30
+
31
+ def restart_with_options(_options)
32
+ stop_daemon
33
+ @runner = CrabtrapRunner.new Crabfarm.config.crabtrap_config.merge(_options)
34
+ @runner.start
35
+ end
36
+
37
+ def stop_daemon
38
+ @runner.stop unless @runner.nil?
39
+ end
40
+
41
+ def driver_config
42
+ super.merge(proxy: proxy_address)
43
+ end
44
+
45
+ def phantom_config
46
+ super.merge(proxy: proxy_address)
47
+ end
48
+
49
+ def proxy_address
50
+ "127.0.0.1:#{@runner.port}"
51
+ end
52
+
53
+ end
54
+ end
@@ -0,0 +1,54 @@
1
+ require 'net/http'
2
+
3
+ module Crabfarm
4
+ class CrabtrapRunner
5
+
6
+ def initialize(_config={})
7
+ @config = _config;
8
+ @pid = nil
9
+ end
10
+
11
+ def port
12
+ @config[:port] # TODO: maybe select port dynamically...
13
+ end
14
+
15
+ def mode
16
+ @config.fetch(:mode, :pass).to_sym
17
+ end
18
+
19
+ def start
20
+ @pid = Process.spawn({}, crabtrap_cmd)
21
+ wait_for_server
22
+ end
23
+
24
+ def stop
25
+ unless @pid.nil?
26
+ Process.kill("INT", @pid)
27
+ Process.wait @pid
28
+ @pid = nil
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def crabtrap_cmd
35
+ cmd = [@config[:bin_path]]
36
+ cmd << mode.to_s
37
+ cmd << @config[:bucket_path] if mode != :pass
38
+ cmd << "--port=#{port}"
39
+ cmd.join(' ')
40
+ end
41
+
42
+ def wait_for_server
43
+ loop do
44
+ begin
45
+ # TODO: improve waiting, making this kind of request could change crabtrap's stack
46
+ Net::HTTP.get_response(URI.parse("http://127.0.0.1:#{port}/status"))
47
+ break
48
+ rescue
49
+ end
50
+ end
51
+ end
52
+
53
+ end
54
+ end
@@ -7,33 +7,79 @@ module Crabfarm
7
7
 
8
8
  def build_driver(_session_id)
9
9
 
10
- driver_name = @config[:name]
11
- raise ConfigurationError.new 'must provide a webdriver type' if driver_name.nil?
10
+ raise ConfigurationError.new 'must provide a webdriver type' unless config_present? :name
11
+ driver_name = @config[:name].to_sym
12
12
 
13
- case driver_name
13
+ driver = case driver_name
14
14
  when :noop
15
15
  require "crabfarm/mocks/noop_driver"
16
16
  driver = Crabfarm::Mocks::NoopDriver.new # TODO: improve dummy driver...
17
17
  when :remote
18
- # setup a custom client to use longer timeouts
19
- client = Selenium::WebDriver::Remote::Http::Default.new
20
- client.timeout = @config[:remote_timeout]
18
+ load_remote_driver
19
+ when :firefox
20
+ load_firefox_driver
21
+ when :chrome
22
+ load_chrome_driver
23
+ else
24
+ load_other_driver driver_name
25
+ end
21
26
 
22
- driver = Selenium::WebDriver.for :remote, {
23
- :url => @config[:remote_host],
24
- :http_client => client,
25
- :desired_capabilities => @config[:capabilities]
26
- }
27
+ # apply browser configuration to new driver
28
+ driver.manage.window.resize_to(@config[:window_width], @config[:window_height]) rescue nil
27
29
 
28
- driver.send(:bridge).setWindowSize(@config[:window_width], @config[:window_height])
29
- else
30
- driver = Selenium::WebDriver.for driver_name.to_sym
30
+ return driver
31
+ end
32
+
33
+ def load_remote_driver
34
+ client = Selenium::WebDriver::Remote::Http::Default.new
35
+ client.timeout = @config[:remote_timeout]
31
36
 
32
- # apply browser configuration to new driver
33
- driver.manage.window.resize_to(@config[:window_width], @config[:window_height]) rescue nil
37
+ if config_present? :proxy
38
+ client.proxy = Selenium::WebDriver::Proxy.new({
39
+ :http => @config[:proxy],
40
+ :ssl => @config[:proxy]
41
+ })
34
42
  end
35
43
 
36
- return driver
44
+ Selenium::WebDriver.for(:remote, {
45
+ :url => @config[:remote_host],
46
+ :http_client => client,
47
+ :desired_capabilities => @config[:capabilities]
48
+ })
49
+ end
50
+
51
+ def load_firefox_driver
52
+ profile = Selenium::WebDriver::Firefox::Profile.new
53
+
54
+ if config_present? :proxy
55
+ profile.proxy = Selenium::WebDriver::Proxy.new({
56
+ :http => @config[:proxy],
57
+ :ssl => @config[:proxy]
58
+ })
59
+ end
60
+
61
+ Selenium::WebDriver.for :firefox, :profile => profile
62
+ end
63
+
64
+ def load_chrome_driver
65
+ switches = []
66
+
67
+ if config_present? :proxy
68
+ switches << "--proxy-server=#{@config[:proxy]}"
69
+ switches << "--ignore-certificate-errors"
70
+ end
71
+
72
+ Selenium::WebDriver.for :chrome, :switches => switches
73
+ end
74
+
75
+ def load_other_driver(_name)
76
+ raise ConfigurationError.new 'default driver does not support proxy' if config_present? :proxy
77
+
78
+ Selenium::WebDriver.for _name.to_sym
79
+ end
80
+
81
+ def config_present?(_key)
82
+ not (@config[_key].nil? or @config[_key].empty?)
37
83
  end
38
84
 
39
85
  end
@@ -1,17 +1,15 @@
1
1
  module Crabfarm
2
2
  class DriverBucketPool
3
3
 
4
- def initialize
4
+ def initialize(_factory=nil)
5
+ @factory = _factory || DefaultDriverFactory.new(Crabfarm.config.driver_config)
5
6
  @buckets = Hash.new
6
- @phantom = nil
7
-
8
- init_phantom_if_required
9
7
  end
10
8
 
11
9
  def driver(_session_id=nil)
12
10
  _session_id ||= :default_driver
13
11
  bucket = @buckets[_session_id.to_sym]
14
- bucket = @buckets[_session_id.to_sym] = DriverBucket.new(_session_id, build_driver_factory) if bucket.nil?
12
+ bucket = @buckets[_session_id.to_sym] = DriverBucket.new(_session_id, @factory) if bucket.nil?
15
13
  bucket
16
14
  end
17
15
 
@@ -22,29 +20,6 @@ module Crabfarm
22
20
 
23
21
  def release
24
22
  reset
25
- @phantom.stop unless @phantom.nil?
26
- end
27
-
28
- private
29
-
30
- def init_phantom_if_required
31
- if config.phantom_mode_enabled?
32
- @phantom = PhantomRunner.new config.phantom_config
33
- @phantom.start
34
- end
35
- end
36
-
37
- def build_driver_factory
38
- if config.phantom_mode_enabled?
39
- PhantomDriverFactory.new @phantom, config.driver_config
40
- else
41
- return config.driver_factory if config.driver_factory
42
- DefaultDriverFactory.new config.driver_config
43
- end
44
- end
45
-
46
- def config
47
- Crabfarm.config
48
23
  end
49
24
 
50
25
  end
@@ -10,20 +10,19 @@ module Crabfarm
10
10
 
11
11
  class ConsoleDsl
12
12
 
13
- attr_reader :context
14
-
15
- def initialize
16
- reload!
13
+ def initialize(_context)
14
+ @context = _context
17
15
  end
18
16
 
19
17
  def reload!
20
- unless @context.nil?
21
- puts "Reloading crawler source".color(:green)
22
- @context.release
23
- ActiveSupport::Dependencies.clear
24
- end
18
+ puts "Reloading crawler source".color(:green)
19
+ ActiveSupport::Dependencies.clear
20
+ @context.reset
21
+ end
25
22
 
26
- @context = Crabfarm::Context.new
23
+ def reset
24
+ puts "Resetting crawling context".color(:green)
25
+ @context.reset
27
26
  end
28
27
 
29
28
  def transition(_name=nil, _params={})
@@ -53,17 +52,12 @@ module Crabfarm
53
52
  puts "Ejem..."
54
53
  end
55
54
 
56
- def reset
57
- puts "Resetting crawling context".color(:green)
58
- @context.reset
59
- end
60
-
61
55
  alias :t :transition
62
56
  alias :r :reset
63
57
  end
64
58
 
65
- def self.start
66
- dsl = ConsoleDsl.new
59
+ def self.start(_context)
60
+ dsl = ConsoleDsl.new _context
67
61
 
68
62
  loop do
69
63
  begin
@@ -78,7 +72,7 @@ module Crabfarm
78
72
  end
79
73
 
80
74
  puts "Releasing crawling context".color(:green)
81
- dsl.context.release
75
+ _context.release
82
76
  end
83
77
 
84
78
  end
@@ -20,6 +20,7 @@ module Crabfarm
20
20
  path(_name, 'Gemfile').render('Gemfile', binding)
21
21
  path(_name, 'Crabfile').render('Crabfile', binding)
22
22
  path(_name, '.rspec').render('dot_rspec', binding)
23
+ path(_name, '.crabfarm').render('dot_crabfarm', binding)
23
24
  path(_name, 'boot.rb').render('boot.rb', binding)
24
25
  path(_name, 'bin', 'crabfarm').render('crabfarm_bin', binding, 0755)
25
26
  path(_name, 'app', 'parsers', '.gitkeep').render('dot_gitkeep')
@@ -27,6 +28,8 @@ module Crabfarm
27
28
  path(_name, 'app', 'helpers', '.gitkeep').render('dot_gitkeep')
28
29
  path(_name, 'spec', 'spec_helper.rb').render('spec_helper.rb', binding)
29
30
  path(_name, 'spec', 'snapshots', '.gitkeep').render('dot_gitkeep')
31
+ path(_name, 'spec', 'mementos', '.gitkeep').render('dot_gitkeep')
32
+ path(_name, 'spec', 'integration', '.gitkeep').render('dot_gitkeep')
30
33
  end
31
34
  end
32
35
 
@@ -0,0 +1,189 @@
1
+ require 'yaml'
2
+ require 'git'
3
+ require 'zlib'
4
+ require 'rubygems/package'
5
+ require 'net/http/post/multipart'
6
+ require 'rainbow'
7
+ require 'rainbow/ext/string'
8
+ require 'digest/sha1'
9
+
10
+ module Crabfarm
11
+ module Modes
12
+ module Publisher
13
+ extend self
14
+
15
+ DEFAULT_HOST = 'http://www.crabfarm.io'
16
+
17
+ def publish(_path, _options={})
18
+
19
+ @crawler_path = _path
20
+ @options = _options
21
+
22
+ load_config
23
+ return unless dry_run or authenticated?
24
+ detect_git_repo
25
+
26
+ if inside_git_repo?
27
+ if not unsafe and is_tree_dirty?
28
+ puts "Aborting: Your working copy has uncommited changes! Use the --unsafe option to force.".color(:red)
29
+ return
30
+ end
31
+ load_files_from_git
32
+ else
33
+ load_files_from_fs
34
+ end
35
+
36
+ build_package
37
+ compress_package
38
+ generate_signature
39
+
40
+ send_package unless dry_run
41
+ end
42
+
43
+ private
44
+
45
+ def dry_run
46
+ @options.fetch(:dry, false)
47
+ end
48
+
49
+ def unsafe
50
+ @options.fetch(:unsafe, false)
51
+ end
52
+
53
+ def config_path
54
+ File.join(@crawler_path, '.crabfarm')
55
+ end
56
+
57
+ def home_config_path
58
+ File.join(Dir.home, '.crabfarm')
59
+ end
60
+
61
+ def load_config
62
+ config = YAML.load_file config_path
63
+
64
+ if File.exists? home_config_path
65
+ home_config = YAML.load_file home_config_path
66
+ config = home_config.merge config
67
+ end
68
+
69
+ @token = config['token']
70
+ @name = config['name']
71
+ @host = config['host'] || DEFAULT_HOST
72
+ @include = config['files']
73
+ end
74
+
75
+ def authenticated?
76
+ # TODO: if no token, ask for credentials and fetch token
77
+ if @token.nil? or @token.empty?
78
+ puts "No crabfarm API token has been provided".color(:red)
79
+ return false
80
+ end
81
+
82
+ true
83
+ end
84
+
85
+ def is_tree_dirty?
86
+ @git.chdir do
87
+ status = @git.status
88
+ (status.changed.count + status.added.count + status.deleted.count + status.untracked.count) > 0
89
+ end
90
+ end
91
+
92
+ def detect_git_repo
93
+ git_path = @crawler_path
94
+
95
+ path_to_git = []
96
+ while git_path != '/'
97
+ if File.exists? File.join(git_path, '.git')
98
+ @git = Git.open git_path
99
+ @rel_path = if path_to_git.count > 0 then File.join(*path_to_git.reverse!) else nil end
100
+ return
101
+ else
102
+ path_to_git << File.basename(git_path)
103
+ git_path = File.expand_path('..', git_path)
104
+ end
105
+ end
106
+
107
+ @git = nil
108
+ end
109
+
110
+ def inside_git_repo?
111
+ not @git.nil?
112
+ end
113
+
114
+ def load_files_from_git
115
+ @git.chdir do
116
+ @ref = @git.log.first.sha
117
+ puts "Packaging files from current HEAD (#{@ref}):".color(:green)
118
+ entries = @git.gtree(@ref).full_tree.map(&:split)
119
+ entries = entries.select { |e| e[1] == 'blob' }
120
+
121
+ @file_list = []
122
+ entries.each do |entry|
123
+ path = unless @rel_path.nil?
124
+ next unless entry[3].starts_with? @rel_path
125
+ entry[3][@rel_path.length+1..-1]
126
+ else entry[3] end
127
+
128
+ if @include.any? { |p| File.fnmatch? p, path }
129
+ @file_list << [path, entry[0].to_i(8), @git.show(@ref, entry[3])]
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ def load_files_from_fs
136
+ puts "Packaging files (no version control)".color(:green)
137
+ @file_list = Dir[*@include].map do |path|
138
+ full_path = File.join(@crawler_path, path)
139
+ [path, File.stat(full_path).mode, File.read(full_path)]
140
+ end
141
+ @ref = "filesystem"
142
+ end
143
+
144
+ def build_package
145
+ @package = StringIO.new("")
146
+ Gem::Package::TarWriter.new(@package) do |tar|
147
+ @file_list.each do |f|
148
+ puts "+ #{f[0]} - #{f[1]}"
149
+ path, mode, contents = f
150
+ tar.add_file(path, mode) { |tf| tf.write contents }
151
+ end
152
+ end
153
+
154
+ @package.rewind
155
+ end
156
+
157
+ def compress_package
158
+ @cpackage = StringIO.new("")
159
+ writer = Zlib::GzipWriter.new(@cpackage)
160
+ writer.write @package.string
161
+ writer.close
162
+ end
163
+
164
+ def generate_signature
165
+ @signature = Digest::SHA1.hexdigest @package.string
166
+ puts "Package SHA1: #{@signature}"
167
+ end
168
+
169
+ def send_package
170
+ url = URI.join(@host, 'api/crawlers/', @name)
171
+
172
+ req = Net::HTTP::Put::Multipart.new(url.path, {
173
+ "repo" => UploadIO.new(StringIO.new(@cpackage.string), "application/x-gzip", "tree.tar.gz"),
174
+ "sha" => @signature,
175
+ "ref" => @ref
176
+ }, {
177
+ 'X-Api-Token' => @token
178
+ })
179
+
180
+ res = Net::HTTP.start(url.host, url.port) do |http|
181
+ http.request(req)
182
+ end
183
+
184
+ puts res.body
185
+ end
186
+
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,42 @@
1
+ require 'rainbow'
2
+ require 'rainbow/ext/string'
3
+ require 'crabfarm/crabtrap_runner'
4
+
5
+ module Crabfarm
6
+ module Modes
7
+ class Recorder
8
+
9
+ def self.start(_target)
10
+ return puts "Must provide a recording name" unless _target.is_a? String
11
+
12
+ crabtrap_config = Crabfarm.config.crabtrap_config
13
+ crabtrap_config[:mode] = :capture
14
+ crabtrap_config[:bucket_path] = File.join(CF_PATH, 'spec/mementos', _target + '.json.gz')
15
+
16
+ crabtrap = CrabtrapRunner.new crabtrap_config
17
+ crabtrap.start
18
+
19
+ driver_config = Crabfarm.config.driver_config
20
+ driver_config[:name] = :firefox
21
+ driver_config[:proxy] = "127.0.0.1:#{crabtrap.port}"
22
+
23
+ driver = DefaultDriverFactory.new(driver_config).build_driver nil
24
+
25
+ begin
26
+ puts "Press Ctrl-C to stop capturing."
27
+ loop do
28
+ driver.current_url
29
+ sleep 1.0
30
+ end
31
+ rescue Selenium::WebDriver::Error::WebDriverError, SystemExit, Interrupt
32
+ # noop
33
+ end
34
+
35
+ puts "Releasing crawling context".color(:green)
36
+ driver.quit rescue nil
37
+ crabtrap.stop
38
+ end
39
+
40
+ end
41
+ end
42
+ end
@@ -1,4 +1,7 @@
1
- CF_TEST_CONTEXT = Crabfarm::Context::new
1
+ require 'crabfarm/crabtrap_context'
2
+
3
+ CF_TEST_CONTEXT = Crabfarm::CrabtrapContext::new
4
+ CF_TEST_CONTEXT.load
2
5
  CF_TEST_BUCKET = CF_TEST_CONTEXT.driver
3
6
 
4
7
  module Crabfarm
@@ -15,6 +18,28 @@ module Crabfarm
15
18
  CF_TEST_BUCKET.parse(described_class, _options)
16
19
  end
17
20
 
21
+ def crawl(_state=nil, _params={})
22
+ if _state.is_a? Hash
23
+ _params = _state
24
+ _state = nil
25
+ end
26
+
27
+ if _state.nil?
28
+ return nil unless described_class < BaseState # TODO: maybe raise an error here.
29
+ @state = @last_state = CF_TEST_CONTEXT.run_state(described_class, _params)
30
+ else
31
+ @last_state = CF_TEST_CONTEXT.run_state(_state, _params)
32
+ end
33
+ end
34
+
35
+ def state
36
+ @state ||= crawl
37
+ end
38
+
39
+ def last_state
40
+ @last_state
41
+ end
42
+
18
43
  def parser
19
44
  @parser
20
45
  end
@@ -26,9 +51,16 @@ RSpec.configure do |config|
26
51
  config.include Crabfarm::RSpec
27
52
 
28
53
  config.before(:example) do |example|
54
+
29
55
  if example.metadata[:parsing]
30
56
  @parser = parse example.metadata[:parsing], example.metadata[:using] || {}
31
57
  end
58
+
59
+ if example.metadata[:crawling]
60
+ CF_TEST_CONTEXT.replay File.join(CF_PATH, 'spec/mementos', example.metadata[:crawling] + '.json.gz')
61
+ else
62
+ CF_TEST_CONTEXT.pass_through
63
+ end
32
64
  end
33
65
 
34
66
  config.after(:suite) do
@@ -0,0 +1,9 @@
1
+ host: 'http://www.crabfarm.io'
2
+ name: '<%= name %>'
3
+ files:
4
+ - Crabfile
5
+ - Gemfile
6
+ - Gemfile.lock
7
+ - boot.rb
8
+ - app/**/*.*
9
+ - bin/**/*.*
@@ -1,2 +1 @@
1
- Gemfile.lock
2
1
  logs/*.*
@@ -1,3 +1,3 @@
1
1
  module Crabfarm
2
- VERSION = "0.0.9"
2
+ VERSION = "0.0.10"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crabfarm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.9
4
+ version: 0.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ignacio Baixas
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-01-12 00:00:00.000000000 Z
11
+ date: 2015-02-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jbuilder
@@ -142,6 +142,34 @@ dependencies:
142
142
  - - ~>
143
143
  - !ruby/object:Gem::Version
144
144
  version: 2.10.2
145
+ - !ruby/object:Gem::Dependency
146
+ name: git
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - '>='
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ type: :runtime
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - '>='
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ - !ruby/object:Gem::Dependency
160
+ name: multipart-post
161
+ requirement: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - '>='
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ type: :runtime
167
+ prerelease: false
168
+ version_requirements: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - '>='
171
+ - !ruby/object:Gem::Version
172
+ version: '0'
145
173
  - !ruby/object:Gem::Dependency
146
174
  name: bundler
147
175
  requirement: !ruby/object:Gem::Requirement
@@ -287,6 +315,7 @@ email:
287
315
  - ignacio@platan.us
288
316
  executables:
289
317
  - crabfarm
318
+ - crabtrap
290
319
  extensions: []
291
320
  extra_rdoc_files: []
292
321
  files:
@@ -301,6 +330,8 @@ files:
301
330
  - lib/crabfarm/cli.rb
302
331
  - lib/crabfarm/configuration.rb
303
332
  - lib/crabfarm/context.rb
333
+ - lib/crabfarm/crabtrap_context.rb
334
+ - lib/crabfarm/crabtrap_runner.rb
304
335
  - lib/crabfarm/default_driver_factory.rb
305
336
  - lib/crabfarm/driver_bucket.rb
306
337
  - lib/crabfarm/driver_bucket_pool.rb
@@ -314,6 +345,8 @@ files:
314
345
  - lib/crabfarm/mocks/noop_driver.rb
315
346
  - lib/crabfarm/modes/console.rb
316
347
  - lib/crabfarm/modes/generator.rb
348
+ - lib/crabfarm/modes/publisher.rb
349
+ - lib/crabfarm/modes/recorder.rb
317
350
  - lib/crabfarm/modes/server.rb
318
351
  - lib/crabfarm/phantom_driver_factory.rb
319
352
  - lib/crabfarm/phantom_runner.rb
@@ -325,6 +358,7 @@ files:
325
358
  - lib/crabfarm/templates/boot.rb.erb
326
359
  - lib/crabfarm/templates/crabfarm_bin.erb
327
360
  - lib/crabfarm/templates/Crabfile.erb
361
+ - lib/crabfarm/templates/dot_crabfarm.erb
328
362
  - lib/crabfarm/templates/dot_gitignore.erb
329
363
  - lib/crabfarm/templates/dot_gitkeep.erb
330
364
  - lib/crabfarm/templates/dot_rspec.erb
@@ -337,6 +371,7 @@ files:
337
371
  - lib/crabfarm/version.rb
338
372
  - lib/crabfarm.rb
339
373
  - bin/crabfarm
374
+ - bin/crabtrap
340
375
  homepage: https://github.com/platanus/crabfarm-gem
341
376
  licenses:
342
377
  - MIT