tesco 0.4.1 → 0.4.2
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +64 -66
- data/Rakefile +19 -19
- data/VERSION +1 -1
- data/lib/tesco.rb +568 -566
- data/tesco.gemspec +39 -0
- metadata +23 -44
- data/readme.md +0 -67
data/README.md
CHANGED
@@ -1,67 +1,65 @@
|
|
1
|
-
Tesco Grocery API
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
#
|
22
|
-
|
23
|
-
#
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
b
|
34
|
-
b
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
#
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
b2
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
#
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
t.the_new_api_call(:searchtext => "A string!", :becool => true)
|
66
|
-
|
1
|
+
# Tesco Grocery API
|
2
|
+
A seriously simple library for accessing the Tesco Grocery API.
|
3
|
+
|
4
|
+
## Preparations
|
5
|
+
The first step here is to register (https://secure.techfortesco.com/tescoapiweb/) at the Tech for Tesco so you can use their Grocery API.
|
6
|
+
Some of the calls (like working with baskets) will require non-anonymous login, so you may also want a Tesco account. They don't use any Oauth type stuff, you'll just need a username and password.
|
7
|
+
|
8
|
+
## Examples
|
9
|
+
|
10
|
+
Everyone loves examples. You don't want to read through all the documentation to get started!
|
11
|
+
|
12
|
+
require 'tesco'
|
13
|
+
|
14
|
+
t = Tesco::Groceries.new('dev_key','api_key')
|
15
|
+
|
16
|
+
# Returns a Products object (see below, it's basically a read-only array)
|
17
|
+
s = t.search("Chocolates")
|
18
|
+
|
19
|
+
# Check out the Product class for more info about these badboys.
|
20
|
+
p milky = s[0]
|
21
|
+
# => Tesco Milk Chocolate Big Buttons 170G
|
22
|
+
|
23
|
+
# Now you'll need to log in:
|
24
|
+
t.login('joebloggs@tesco.com','supersecret!')
|
25
|
+
|
26
|
+
# This will return *the* instance (ie. calling it twice will give you the same object)
|
27
|
+
# of the basket for t's currently logged in user.
|
28
|
+
b = t.basket
|
29
|
+
|
30
|
+
# You can arrange products in the basket like so:
|
31
|
+
b < milky # Push into the basket (or increment quantity)
|
32
|
+
b > milky # Completely remove from the basket
|
33
|
+
# b[milky] is now a 'BasketItem' Object, which you can use to alter amounts and the shopper note.
|
34
|
+
b[milky].quantity = 5 # Set a specific quantity
|
35
|
+
b[milky].note = "Please say \"I'm the Milky Bar Kid!\" as you pick it up. Please!"
|
36
|
+
|
37
|
+
# Potentially counter-intuitive, request
|
38
|
+
|
39
|
+
# There can only ever be one basket instance per logged in user:
|
40
|
+
b.object_id == t.basket.object_id # => true
|
41
|
+
|
42
|
+
# But logging in as a new user won't bugger things up:
|
43
|
+
t.login('fredsmith@tesco.com','supersecreter!')
|
44
|
+
b2 = t.basket
|
45
|
+
b.customer_id # => 1234
|
46
|
+
b2.customer_id # => 5678
|
47
|
+
|
48
|
+
# And it'll make sure you're not going to bugger things up:
|
49
|
+
b > milky
|
50
|
+
# NotAuthenticatedError, Please reauthenticate as this basket's owner before attempting to modify it
|
51
|
+
|
52
|
+
|
53
|
+
### The Products Class
|
54
|
+
As with most APIs that cover loads of information, the data is usually paginated. In order to make life simple I've designed this library so you don't have to worry about that at all. Paginated responses are
|
55
|
+
|
56
|
+
### Keeping up with Tesco API development
|
57
|
+
The default endpoint for the service is currently the beta 1 (http://www.techfortesco.com/groceryapi_b1/RESTService.aspx). You can alter this after instantiating a Tesco object with:
|
58
|
+
|
59
|
+
t.endpoint = "http://another.tesco.api/testing"
|
60
|
+
|
61
|
+
I'll endeavour to keep this library as up to date as I can, but in the event that there's a method you want to use that I haven't created you can either hack at the code by forking the repo on Github (http://github.com/jphastings/TescoGroceries) or you can just do this:
|
62
|
+
|
63
|
+
t.the_new_api_call(:searchtext => "A string!", :becool => true)
|
64
|
+
|
67
65
|
This will send the command to the Tesco API as 'THENEWAPICALL' with the parameters in tow. You'll get a Hash back of the parsed JSON that comes direct from the API, so no fancy objects/DSL to work with, but the `api_request` method (which does all the hard work) *will* raise a subclass of TescoApiError if your request was dodgey, as per usual.
|
data/Rakefile
CHANGED
@@ -1,20 +1,20 @@
|
|
1
|
-
require 'rubygems'
|
2
|
-
require 'rake'
|
3
|
-
|
4
|
-
begin
|
5
|
-
require 'jeweler'
|
6
|
-
Jeweler::Tasks.new do |gem|
|
7
|
-
gem.name = "tesco"
|
8
|
-
gem.summary = %Q{An extremely straightforward library for the Tesco Grocery API}
|
9
|
-
gem.description = %Q{Search the Tesco Groceries API, through a very object oriented library}
|
10
|
-
gem.email = "jphastings@gmail.com"
|
11
|
-
gem.homepage = "http://github.com/jphastings/TescoGroceries"
|
12
|
-
gem.authors = ["JP Hastings-Spital"]
|
13
|
-
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
14
|
-
end
|
15
|
-
Jeweler::GemcutterTasks.new
|
16
|
-
rescue LoadError
|
17
|
-
puts "Jeweler (or a dependency) not available. Install it with:
|
18
|
-
end
|
19
|
-
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "tesco"
|
8
|
+
gem.summary = %Q{An extremely straightforward library for the Tesco Grocery API}
|
9
|
+
gem.description = %Q{Search the Tesco Groceries API, through a very object oriented library}
|
10
|
+
gem.email = "jphastings@gmail.com"
|
11
|
+
gem.homepage = "http://github.com/jphastings/TescoGroceries"
|
12
|
+
gem.authors = ["JP Hastings-Spital"]
|
13
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
14
|
+
end
|
15
|
+
Jeweler::GemcutterTasks.new
|
16
|
+
rescue LoadError
|
17
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
18
|
+
end
|
19
|
+
|
20
20
|
task :default => :build
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.4.
|
1
|
+
0.4.2
|
data/lib/tesco.rb
CHANGED
@@ -1,567 +1,569 @@
|
|
1
|
-
#
|
2
|
-
|
3
|
-
#
|
4
|
-
#
|
5
|
-
#
|
6
|
-
#
|
7
|
-
# *
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
require '
|
12
|
-
require '
|
13
|
-
require '
|
14
|
-
require '
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
#
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
@
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
#
|
94
|
-
#
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
#
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
#
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
@
|
121
|
-
@
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
#
|
135
|
-
#
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
:
|
143
|
-
:
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
#
|
167
|
-
#
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
#
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
attr_reader :
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
#
|
183
|
-
#
|
184
|
-
#
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
new_product.
|
201
|
-
new_product.instance_variable_set(:@
|
202
|
-
new_product.
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
@
|
227
|
-
@
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
@
|
233
|
-
@
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
#
|
245
|
-
#
|
246
|
-
#
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
@@basket[api.customer_id].
|
255
|
-
@@basket[api.customer_id].
|
256
|
-
@@basket[api.customer_id]
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
#
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
@
|
274
|
-
@
|
275
|
-
@
|
276
|
-
|
277
|
-
res['
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
self[product]
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
#
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
#
|
338
|
-
basket_item.
|
339
|
-
|
340
|
-
basket_item.instance_variable_set(:@
|
341
|
-
basket_item.instance_variable_set(:@
|
342
|
-
|
343
|
-
basket_item
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
#
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
@quantity
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
@
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
@
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
@
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
#
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
@
|
459
|
-
|
460
|
-
@
|
461
|
-
@
|
462
|
-
@
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
#
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
if
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
#
|
488
|
-
#
|
489
|
-
#
|
490
|
-
#
|
491
|
-
#
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
pages
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
previous
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
@
|
541
|
-
@
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
566
|
-
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
# == Tesco API
|
4
|
+
# See the readme for examples and such.
|
5
|
+
#
|
6
|
+
# = TODO
|
7
|
+
# * Evaluate usefulness of the Department/Aisle/Shelf class division currently used
|
8
|
+
# * Substitution with the basket?
|
9
|
+
# * Add some useful barcode features, maybe allow lookup of products by barcode?
|
10
|
+
|
11
|
+
require 'net/http'
|
12
|
+
require 'uri'
|
13
|
+
require 'json'
|
14
|
+
require 'time'
|
15
|
+
require 'digest/md5'
|
16
|
+
require 'delegate'
|
17
|
+
|
18
|
+
module Tesco
|
19
|
+
|
20
|
+
# Unobtrusive modifications to the Class class.
|
21
|
+
class Class
|
22
|
+
# Pass a block to attr_reader and the block will be evaluated in the context of the class instance before
|
23
|
+
# the instance variable is returned.
|
24
|
+
def attr_reader(*params,&block)
|
25
|
+
if block_given?
|
26
|
+
params.each do |sym|
|
27
|
+
# Create the reader method
|
28
|
+
define_method(sym) do
|
29
|
+
# Force the block to execute before we…
|
30
|
+
self.instance_eval(&block)
|
31
|
+
# … return the instance variable
|
32
|
+
self.instance_variable_get("@#{sym}")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
else # Keep the original function of attr_reader
|
36
|
+
params.each do |sym|
|
37
|
+
attr sym
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# You'll need an API and Developer key from https://secure.techfortesco.com/tescoapiweb/
|
44
|
+
class Groceries
|
45
|
+
attr_accessor :endpoint
|
46
|
+
attr_reader(:customer_name,:customer_forename, :customer_id, :branch_number) { raise NotAuthenticatedError if @anonymous_mode }
|
47
|
+
|
48
|
+
# Instantiates a tesco object with your developer and application keys
|
49
|
+
def initialize(developer_key,application_key)
|
50
|
+
@endpoint = URI.parse('http://www.techfortesco.com/groceryapi_b1/RESTService.aspx')
|
51
|
+
@developer_key = developer_key
|
52
|
+
@application_key = application_key
|
53
|
+
end
|
54
|
+
|
55
|
+
# Sets the api endpoint as a URI object
|
56
|
+
def endpoint=(uri)
|
57
|
+
@endpoint = URI.parse(uri)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Search Tesco's grocery product listing. Returns a list of Products in a special object that acts like a read-only array.
|
61
|
+
def search(q)
|
62
|
+
Products.new(self,api_request(:productsearch,:searchtext => q))
|
63
|
+
end
|
64
|
+
|
65
|
+
# List all products currently on offer
|
66
|
+
def on_offer
|
67
|
+
Products.new(self,api_request(:listproductoffers))
|
68
|
+
end
|
69
|
+
|
70
|
+
# List all favourite grocery items (requires a non-anonymous login)
|
71
|
+
def favourites
|
72
|
+
raise NotAuthenticatedError if @anonymous_mode
|
73
|
+
Products.new(self,api_request(:listfavourites))
|
74
|
+
end
|
75
|
+
|
76
|
+
def departments
|
77
|
+
@@shelves = []
|
78
|
+
api_request(:listproductcategories)['Departments'].collect {|dept|
|
79
|
+
dept['Aisles'].each do |aisle|
|
80
|
+
aisle['Shelves'].each do |shelf|
|
81
|
+
@@shelves.push(Shelf.new(self,shelf))
|
82
|
+
end
|
83
|
+
end
|
84
|
+
Department.new(self,dept)
|
85
|
+
}
|
86
|
+
end
|
87
|
+
|
88
|
+
# Returns a Basket instance, Tesco API keeps track of the items in your basket in between sessions (TODO: i think!)
|
89
|
+
def basket
|
90
|
+
Basket.new(self)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Lists all the products in the given category, as determined from the shelf id. You're probably better off using #departments, then
|
94
|
+
# #Department#aisles, then #Aisle#shelves then Shelf#products which is an alias for this method.
|
95
|
+
#
|
96
|
+
# ie. tesco.departments[0].aisles[0].shelves[0].products
|
97
|
+
def products_by_category(shelf_id)
|
98
|
+
raise ArgumentError, "#{shelf_id} is not a valid Shelf ID" if not shelf_id.to_i > 0
|
99
|
+
Products.new(self,api_request(:listproductsbycategory,:category => shelf_id))
|
100
|
+
end
|
101
|
+
|
102
|
+
# A convenience method, this will search all the shelves by name and return an array of Shelf objects that match.
|
103
|
+
#
|
104
|
+
# You'll probably want to send a regexp with the case insensitive tag: /kitchen/i
|
105
|
+
def search_shelves(q)
|
106
|
+
raise ArgumentError, "The argument needs to be a Regular Expression." if not q.is_a? Regexp
|
107
|
+
departments if not @@shelves.is_a? Array
|
108
|
+
@@shelves.select {|shelf| shelf.name =~ q }
|
109
|
+
end
|
110
|
+
|
111
|
+
# Authenticates as the given user or as anonymous with the default parameters. Anonymous login will occur automatically
|
112
|
+
# upon any request if a login hasn't already occured
|
113
|
+
def login(email = '',password = '')
|
114
|
+
res = api_request(:login,:email => email, :password => password)
|
115
|
+
@anonymous_mode = (res['ChosenDeliverySlotInfo'] == "Not applicable with anonymous login")
|
116
|
+
# TODO:InAmendOrderMode
|
117
|
+
|
118
|
+
# The Tesco API returns "Mrs. Test-Farquharson-Symthe" for CustomerName in anonymous mode, for now I'll not include this in the Ruby library
|
119
|
+
if !@anonymous_mode # Not for anonymous mode
|
120
|
+
@customer_forename = res['CustomerForename']
|
121
|
+
@customer_name = res['CustomerName']
|
122
|
+
@customer_id = res['CustomerId']
|
123
|
+
@branch_number = res['BranchNumber']
|
124
|
+
end
|
125
|
+
@session_key = res['SessionKey']
|
126
|
+
return true
|
127
|
+
end
|
128
|
+
|
129
|
+
# Are we in anonymous mode?
|
130
|
+
def anonymous?
|
131
|
+
!@anonymous_mode
|
132
|
+
end
|
133
|
+
|
134
|
+
# Send a command to the Tesco API directly using the keys set up already. It will return a parsed version
|
135
|
+
# of the direct output from the RESTful service. Status codes other than 0 will still raise errors as usual.
|
136
|
+
#
|
137
|
+
# Useful if you want a little more control over the the results, shouldn't be necessary.
|
138
|
+
def api_request(command,params = {})
|
139
|
+
login if @session_key.nil? and command != :login # Do an anonymous login if we're not authenticated
|
140
|
+
params.merge!({:sessionkey => @session_key}) if not @session_key.nil?
|
141
|
+
params = {
|
142
|
+
:command => command,
|
143
|
+
:applicationkey => @application_key,
|
144
|
+
:developerkey => @developer_key,
|
145
|
+
:page => 1 # Will be overwritten by a page in params
|
146
|
+
}.merge(params)
|
147
|
+
|
148
|
+
json = Net::HTTP.get(@endpoint.host,@endpoint.path+"?"+params.collect { |k,v| "#{k}=#{URI::escape(v.to_s)}" }.join('&'))
|
149
|
+
|
150
|
+
res = JSON::load(json)
|
151
|
+
res[:requestparameters] = params
|
152
|
+
|
153
|
+
case res['StatusCode']
|
154
|
+
when 0
|
155
|
+
# Everything went well
|
156
|
+
return res
|
157
|
+
when 200
|
158
|
+
raise NotAuthenticatedError
|
159
|
+
# TODO: Other status codes
|
160
|
+
else
|
161
|
+
p res
|
162
|
+
raise TescoApiError, "Unknown status code! Something went wrong - sorry"
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# If there are any other (ie. new) Tesco API calls this will make them available directly:
|
167
|
+
#
|
168
|
+
# An api command 'SEARCHELECTRONICS' (if one existed) would be available as
|
169
|
+
# #search_electronics(:searchtext => 'computer',:parameter1 => 'an option')
|
170
|
+
def method_missing(method,params = {})
|
171
|
+
api_request(method.to_s.gsub("_",""),params)
|
172
|
+
end
|
173
|
+
|
174
|
+
# Represents an individual grocery item, #healthier_alternative, #cheaper_alternative and #base_product are
|
175
|
+
# populated as detailess Products. Requesting any information from these will retrieve full information from
|
176
|
+
# the API.
|
177
|
+
class Product
|
178
|
+
attr_reader(:image_url, :name, :max_quantity, :offer) { details if @name.nil? }
|
179
|
+
attr_reader :healthier_alternative, :cheaper_alternative, :base_product
|
180
|
+
attr_reader :product_id
|
181
|
+
|
182
|
+
# Don't use this yourself!
|
183
|
+
#
|
184
|
+
# The unusual initialization here is so that there is only ever one instance of each product.
|
185
|
+
# This means that using #Products as keys in a hash will always work, and (as they're identical)
|
186
|
+
# it'll also save memory.
|
187
|
+
def self.new(api,product_id,more = nil) # :nodoc:
|
188
|
+
raise ArgumentError, "Not a product id" if not product_id =~ /^\d+$/
|
189
|
+
@@instances ||= {}
|
190
|
+
|
191
|
+
# Make sure we only ever have on instance of each product
|
192
|
+
# If we have an instance then we should just return that
|
193
|
+
if @@instances[product_id]
|
194
|
+
# If we've been passed more then set it as it'll be more up-to-date
|
195
|
+
@@instances[product_id].instance_variable_set(:@more,more) if !more.nil?
|
196
|
+
return @@instances[product_id]
|
197
|
+
end
|
198
|
+
|
199
|
+
# We don't have an instance of this product yet, go ahead and make one
|
200
|
+
new_product = self.allocate
|
201
|
+
new_product.instance_variable_set(:@product_id,product_id)
|
202
|
+
new_product.instance_variable_set(:@api,api)
|
203
|
+
new_product.instance_variable_set(:@more,more)
|
204
|
+
new_product.details if !more.nil?
|
205
|
+
@@instances[product_id] = new_product
|
206
|
+
end
|
207
|
+
|
208
|
+
def inspect
|
209
|
+
name
|
210
|
+
end
|
211
|
+
|
212
|
+
# Will refresh the details of the product.
|
213
|
+
def details
|
214
|
+
# If we had some free 'more' data from the instanciation we should use it!
|
215
|
+
if @more
|
216
|
+
more = @more
|
217
|
+
remove_instance_variable(:@more)
|
218
|
+
end
|
219
|
+
|
220
|
+
# If we have no data then we should get it from the ProductId
|
221
|
+
if more.nil?
|
222
|
+
# TODO: check to see if there are more than one
|
223
|
+
more = @api.api_request('productsearch',:searchtext => @product_id)['Products'][0]
|
224
|
+
end
|
225
|
+
|
226
|
+
@healthier_alternative = Product.new(@api,more['HealthierAlthernativeProductId']) rescue nil
|
227
|
+
@cheaper_alternative = Product.new(@api,more['CheaperAlthernativeProductId']) rescue nil
|
228
|
+
@base_product = Product.new(@api,more['BaseProductId']) rescue nil
|
229
|
+
@image_url = more['ImagePath']
|
230
|
+
# ProducyType?
|
231
|
+
# OfferValidity?
|
232
|
+
@name = more['Name']
|
233
|
+
@max_quantity = more['MaximumPurchaseQuantity']
|
234
|
+
@barcode = Barcode.new(more['EANBarcode'])
|
235
|
+
@offer = Offer.new(more['OfferLabelImagePath'],more['OfferPromotion'],more['OfferValidity']) rescue nil
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
# Represents a shopping basket.
|
240
|
+
class Basket < Hash
|
241
|
+
attr_reader :basket_id, :quantity, :customer_id
|
242
|
+
attr_reader(:price, :multi_buy_savings, :clubcard_points) { sync } # These are calculated on the server, so we need to sync before returning them
|
243
|
+
|
244
|
+
# You can initialize your own basket with Basket.new(tesco_api_instance), but I'd recommend using
|
245
|
+
# #Tesco#basket.
|
246
|
+
#
|
247
|
+
# Because this object will sync with the Tesco server there can only ever be one instance. It will
|
248
|
+
# keep track of different users' baskets. (Wipe this memory with #Basket#flush)
|
249
|
+
def self.new(api)
|
250
|
+
raise ArgumentError, "The argument needs to be a Tesco instance" if not api.is_a? Tesco::Groceries
|
251
|
+
begin
|
252
|
+
return @@basket[api.customer_id]
|
253
|
+
rescue
|
254
|
+
(@@basket ||= {})[api.customer_id] = self.allocate
|
255
|
+
@@basket[api.customer_id].instance_variable_set(:@api,api)
|
256
|
+
@@basket[api.customer_id].instance_variable_set(:@customer_id,api.customer_id)
|
257
|
+
@@basket[api.customer_id].sync
|
258
|
+
@@basket[api.customer_id]
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
# This class keeps track of all the baskets for each user that's been logged in, to save on API calls.
|
263
|
+
# If you need to remove this information from memory this method will destroy the class variable that holds
|
264
|
+
# it, without affecting anything on the Tesco servers.
|
265
|
+
def self.flush
|
266
|
+
@@basket.clean
|
267
|
+
end
|
268
|
+
|
269
|
+
# Makes sure this object reflects the basket on Tesco online.
|
270
|
+
def sync
|
271
|
+
authtest
|
272
|
+
res = @api.api_request(:listbasket)
|
273
|
+
@basket_id = res['BasketId'].to_i
|
274
|
+
@price = res['BasketGuidePrice'].to_f
|
275
|
+
@multi_buy_savings = res['BasketGuideMultiBuySavings'].to_f
|
276
|
+
@clubcard_points = res['BasketTotalClubcardPoints'].to_i
|
277
|
+
@quantity = res['BasketQuantity'] # TODO: Is this just length?
|
278
|
+
|
279
|
+
res['BasketLines'].each do |more|
|
280
|
+
self[Product.new(@api,more['ProductId'],more)] = BasketItem.new(self,more)
|
281
|
+
end
|
282
|
+
|
283
|
+
return true
|
284
|
+
end
|
285
|
+
|
286
|
+
# Change the note for the shopper on a product in your basket
|
287
|
+
def note(product,note) # TODO: should work with multiple products
|
288
|
+
authtest
|
289
|
+
raise IndexError, "That item is not in the basket" if not self[product]
|
290
|
+
@api.api_request(:changebasket,:productid => product.product_id,:changequantity => 0,:noteforshopper => note)
|
291
|
+
self[product].instance_variable_set(:@note,note)
|
292
|
+
end
|
293
|
+
|
294
|
+
# Adds the given product(s) to the basket. It increments that item's quantity if it's already present in the basket. Leave a note for the shopper against these items with note.
|
295
|
+
def <(products)
|
296
|
+
authtest
|
297
|
+
[products].flatten.each do |product|
|
298
|
+
raise ArgumentError, "That is not a Tesco Product object" if !product.is_a?(Tesco::Groceries::Product)
|
299
|
+
(self[product] ||= BasketItem.new(self,{'ProductId' => product.product_id})).add(1)
|
300
|
+
end
|
301
|
+
end
|
302
|
+
alias_method :add, :<
|
303
|
+
|
304
|
+
# Removes the given product(s) completely from the basket.
|
305
|
+
def >(products)
|
306
|
+
authtest
|
307
|
+
[products].flatten.each do |product|
|
308
|
+
raise ArgumentError, "That is not a Tesco Product object" if !product.is_a?(Tesco::Groceries::Product)
|
309
|
+
delete(product).remove # Removes the product from the basket, then deletes it from the API
|
310
|
+
end
|
311
|
+
end
|
312
|
+
alias_method :remove, :>
|
313
|
+
|
314
|
+
# Empties the basket completely — this may take a while for large baskets
|
315
|
+
def clean
|
316
|
+
authtest
|
317
|
+
self.each_pair do |product,basket_item|
|
318
|
+
delete(product).remove # Removes the product from the basket, then deletes it from the API
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
# tests to make sure you are authenticated as this basket's owner
|
323
|
+
def authtest
|
324
|
+
raise NotAuthenticatedError, "Please reauthenticate as this basket's owner before attempting to modify it" if @customer_id != @api.customer_id
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
# Assists in the modification of basketed products
|
329
|
+
#
|
330
|
+
# TODO: Correct basket auth?
|
331
|
+
class BasketItem < DelegateClass(Product)
|
332
|
+
attr_accessor :note, :quantity, :error_message, :promo_message
|
333
|
+
# I wouldn't mess around with this from your code, its essentially internal
|
334
|
+
def self.new(basket,more) # :nodoc:
|
335
|
+
@basket = basket
|
336
|
+
|
337
|
+
# With a little hackiness because Product initializes with self.new, not initialize
|
338
|
+
basket_item = super(Product.new(basket.instance_variable_get(:@api),more['ProductId'],more))
|
339
|
+
# Set it's instance variables
|
340
|
+
basket_item.instance_variable_set(:@quantity,(more['BasketLineQuantity'].to_i rescue 0))
|
341
|
+
basket_item.instance_variable_set(:@error_message,(more['BasketLineErrorMessage'] rescue "")) # TODO: Parse this
|
342
|
+
basket_item.instance_variable_set(:@promo_message,(more['BasketLinePromoMessage'] rescue ""))
|
343
|
+
basket_item.instance_variable_set(:@note,(more['NoteForPersonalShopper'] rescue ""))
|
344
|
+
|
345
|
+
basket_item
|
346
|
+
end
|
347
|
+
|
348
|
+
# Update the server if the NoteForShopper is changed
|
349
|
+
|
350
|
+
# TODO: set shopper note
|
351
|
+
def note=(note)
|
352
|
+
@basket.authtest
|
353
|
+
end
|
354
|
+
|
355
|
+
# Add a certain number of items to the basket
|
356
|
+
def add(val = 1)
|
357
|
+
@basket.authtest
|
358
|
+
return remove if (@quantity + val) <= 0
|
359
|
+
__getobj__.instance_variable_get(:@api).api_request(:changebasket,:productid => self.product_id,:changequantity => val,:noteforshopper => @note)
|
360
|
+
@quantity
|
361
|
+
end
|
362
|
+
|
363
|
+
# Remove a certain number of items from the basket
|
364
|
+
def drop(val = 1)
|
365
|
+
@basket.authtest
|
366
|
+
add(val * -1)
|
367
|
+
end
|
368
|
+
|
369
|
+
# Alter the quantity to a specific amount
|
370
|
+
def quantity=(amount)
|
371
|
+
@basket.authtest
|
372
|
+
return @quantity if @quantity == amount # No need to do anything if they're the same
|
373
|
+
raise ArgumentError, "amount must be >= 0 and <= #{self.max_quantity}" if (not amount.is_a? Integer) or amount < 0 or amount > self.max_quantity
|
374
|
+
__getobj__.instance_variable_get(:@api).api_request(:changebasket,:productid => self.product_id,:changequantity => amount - @quantity,:noteforshopper => @note)
|
375
|
+
@quantity -= amount
|
376
|
+
end
|
377
|
+
|
378
|
+
# Remove this item from the basket completely
|
379
|
+
def remove
|
380
|
+
@basket.authtest
|
381
|
+
@basket.remove(__getobj__)
|
382
|
+
end
|
383
|
+
|
384
|
+
def inspect
|
385
|
+
@basket.authtest
|
386
|
+
"#{@quantity} item"<<((@quantity == 1) ? "" : "s")
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
class Department
|
391
|
+
attr_reader :id, :name
|
392
|
+
# No point in creating these by hand
|
393
|
+
def initialize(api,details) # :nodoc:
|
394
|
+
@id = details['Id']
|
395
|
+
@name = details['Name']
|
396
|
+
@aisles = details['Aisles'].collect { |aisle|
|
397
|
+
Aisle.new(api,aisle)
|
398
|
+
}
|
399
|
+
end
|
400
|
+
|
401
|
+
# Lists all aisles in this department. Each item is an Aisle object
|
402
|
+
def aisles
|
403
|
+
@aisles
|
404
|
+
end
|
405
|
+
|
406
|
+
def inspect
|
407
|
+
"#{@name} Department"
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
class Aisle
|
412
|
+
attr_reader :aisle_id, :name
|
413
|
+
# No point in creating these by hand.
|
414
|
+
def initialize(api,details) # :nodoc:
|
415
|
+
@id = details['Id']
|
416
|
+
@name = details['Name']
|
417
|
+
@shelves = details['Shelves'].collect { |shelf|
|
418
|
+
Shelf.new(api,shelf)
|
419
|
+
}
|
420
|
+
end
|
421
|
+
|
422
|
+
# Lists all shelves in this aisle. Each item is a Shelf object
|
423
|
+
def shelves
|
424
|
+
@shelves
|
425
|
+
end
|
426
|
+
|
427
|
+
def inspect
|
428
|
+
"#{@name} Aisle"
|
429
|
+
end
|
430
|
+
end
|
431
|
+
|
432
|
+
class Shelf
|
433
|
+
attr_reader :department,:aisle, :aisle_id, :name
|
434
|
+
# No point in creating these by hand.
|
435
|
+
def initialize(api,details) # :nodoc:
|
436
|
+
@api = api
|
437
|
+
@id = details['Id']
|
438
|
+
@name = details['Name']
|
439
|
+
end
|
440
|
+
|
441
|
+
def products
|
442
|
+
@api.products_by_category(@id)
|
443
|
+
end
|
444
|
+
|
445
|
+
def inspect
|
446
|
+
"#{@name} Shelf"
|
447
|
+
end
|
448
|
+
end
|
449
|
+
|
450
|
+
# A special class that takes care of product pagination automatically. It'll work like a read-only array for the most part
|
451
|
+
# but you can request a specific page with #page and requesting a specific item with #[] will request the required page automatically
|
452
|
+
# if it hasn't already been retrieved and stored within the instance's cache.
|
453
|
+
class Paginated
|
454
|
+
attr_reader :length, :pages
|
455
|
+
# Don't use this yourself!
|
456
|
+
def initialize(api,res) # :nodoc:
|
457
|
+
@cached_pages = {}
|
458
|
+
@api = api
|
459
|
+
# Do the page we've been given (usually the first)
|
460
|
+
@cached_pages[res['PageNumber'] || 1] = parse_items(res)
|
461
|
+
@pages = res['TotalPageCount'] || 1
|
462
|
+
@perpage = res['PageProductCount']
|
463
|
+
@length = res['TotalProductCount'] || @perpage
|
464
|
+
@params = res[:requestparameters]
|
465
|
+
end
|
466
|
+
|
467
|
+
# Will return the item at the requested index, even if that page hasn't yet been retreived
|
468
|
+
def [](n)
|
469
|
+
raise TypeError, "That isn't a valid array reference" if not (n.is_a? Integer and n >= 0)
|
470
|
+
raise PaginationError, "That index exceeds the number of items" if n >= @length
|
471
|
+
page_num = (n / @perpage).floor + 1
|
472
|
+
page(page_num)[n - page_num * @perpage]
|
473
|
+
end
|
474
|
+
|
475
|
+
# Will return all the items on the requested page (indeces will be relative to the page).
|
476
|
+
# Specifying page = 0 or page = :all will give an array of all items, retrieving all details first
|
477
|
+
# This could take a very, vey long time!
|
478
|
+
def page(page)
|
479
|
+
page = 0 if page == :all
|
480
|
+
raise PaginationError, "That isn't a valid page reference" if not (page.is_a? Integer and page >= 0 and page <= @pages)
|
481
|
+
if !@cached_pages.keys.include?(page)
|
482
|
+
@cached_pages[res['PageNumber'] || 1] = parse_items(@api.api_request(nil,@params.merge({:page => page})))
|
483
|
+
end
|
484
|
+
@cached_pages[page]
|
485
|
+
end
|
486
|
+
|
487
|
+
# Akin to Array#each, except you must specify which page, or range of pages, of products you wish to iterate over.
|
488
|
+
#
|
489
|
+
# Specifying page = 0 or page = :all will iterate over every item on every page
|
490
|
+
#
|
491
|
+
# The items on each page will be passed to your block as they're retrieved, so you'll get spurts of output.
|
492
|
+
#
|
493
|
+
# NB. This method won't check your enumberable for each item being a valid page until it's processed all prior pages.
|
494
|
+
def each(pages = :all)
|
495
|
+
pages = (1..@pages) if (pages == :all or pages == 0)
|
496
|
+
pages = [pages] if not pages.is_a? Enumerable
|
497
|
+
pages.each do |page|
|
498
|
+
raise PaginationError, "#{page.inspect} isn't a valid page reference" if not (page.is_a? Integer and page >= 0 and page <= @pages)
|
499
|
+
page(page).each do |item|
|
500
|
+
yield item
|
501
|
+
end
|
502
|
+
end
|
503
|
+
end
|
504
|
+
|
505
|
+
def inspect
|
506
|
+
output = ""
|
507
|
+
previous = 0
|
508
|
+
@cached_pages.each_pair do |page,content|
|
509
|
+
output << ", … #{(page - previous) * @perpage} more …" if (previous + 1) != page
|
510
|
+
output << ", " << content.inspect[1..-2]
|
511
|
+
previous = page
|
512
|
+
end
|
513
|
+
output << ", … #{@length - previous * @perpage} more" if previous != @pages
|
514
|
+
"[#{output[2..-1]}]"
|
515
|
+
end
|
516
|
+
|
517
|
+
private
|
518
|
+
# There's no parsing for the default pagination class, make a subclass and write your own parse method
|
519
|
+
# Take a look at #Products if you want to see how it's done.
|
520
|
+
def parse_items(res); res; end
|
521
|
+
end
|
522
|
+
|
523
|
+
# Deals with the specifics of paginating products.
|
524
|
+
class Products < Paginated
|
525
|
+
private
|
526
|
+
def parse_items(res)
|
527
|
+
res['Products'].collect{|json|
|
528
|
+
Product.new(@api,json['ProductId'],json)
|
529
|
+
}
|
530
|
+
end
|
531
|
+
end
|
532
|
+
|
533
|
+
class Offer
|
534
|
+
attr_reader :image_url, :description, :validity
|
535
|
+
attr_reader :valid_from, :valid_until
|
536
|
+
|
537
|
+
# No point in making these by hand!
|
538
|
+
def initialize(image_url,descr,validity) # :nodoc:
|
539
|
+
raise ArgumentError, "Not a valid offer" if descr.nil? or descr.empty?
|
540
|
+
@description = descr
|
541
|
+
@image_url = image_url
|
542
|
+
@validity = validity
|
543
|
+
@valid_from, @valid_until = Time.utc($3,$2,$1), Time.utc($6,$5,$4) if validity =~ /^valid from (\d{1,2})\/(\d{1,2})\/(\d{4}) until (\d{1,2})\/(\d{1,2})\/(\d{4})$/
|
544
|
+
end
|
545
|
+
end
|
546
|
+
|
547
|
+
private
|
548
|
+
class PaginationError < IndexError; end
|
549
|
+
class TescoApiError < RuntimeError
|
550
|
+
def to_s; "An unspecified error has occured on the server side."; end
|
551
|
+
end
|
552
|
+
class NoSessionKeyError < TescoApiError
|
553
|
+
def to_s; "The session key has been declined, try logging in again."; end
|
554
|
+
end
|
555
|
+
class NotAuthenticatedError < TescoApiError
|
556
|
+
def to_s; "You must be an authenticated non-anonymous user."; end
|
557
|
+
end
|
558
|
+
end
|
559
|
+
|
560
|
+
class Barcode
|
561
|
+
def initialize(code)
|
562
|
+
@barcode = code.to_s
|
563
|
+
end
|
564
|
+
|
565
|
+
def to_s
|
566
|
+
@barcode
|
567
|
+
end
|
568
|
+
end
|
567
569
|
end
|