instacart_api 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +26 -0
- data/.github/workflows/release.yml +36 -0
- data/.gitignore +19 -0
- data/.rspec +3 -0
- data/.rubocop.yml +47 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +17 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +171 -0
- data/LICENSE +21 -0
- data/README.md +99 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/rspec +29 -0
- data/bin/setup +8 -0
- data/capture/README.md +34 -0
- data/capture/capture.js +61 -0
- data/capture/extract_hashes.rb +60 -0
- data/example_usage.rb +35 -0
- data/instacart_api.gemspec +43 -0
- data/lib/instacart_api/client.rb +102 -0
- data/lib/instacart_api/errors.rb +16 -0
- data/lib/instacart_api/graphql_client.rb +95 -0
- data/lib/instacart_api/models/cart.rb +33 -0
- data/lib/instacart_api/models/item.rb +43 -0
- data/lib/instacart_api/models/search_result.rb +16 -0
- data/lib/instacart_api/persisted_queries.rb +35 -0
- data/lib/instacart_api/version.rb +5 -0
- data/lib/instacart_api.rb +18 -0
- metadata +172 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 709de7ef54e38bf9ddd109b29ec88de2edf21878bf99ca0d12cf93b0b9ff5718
|
|
4
|
+
data.tar.gz: c19bcb606a6108a23db2ff2c7e405feb77290a93749cb4bfcbba98cbf6eac755
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 80973fb6564acf12e660fabbd4905bdee1c0124e2c9205f5b48f805a59878e54681e7679c3cb89e0027b43b3a92849636bbd62d13dabade717655e6f8c1eaf90
|
|
7
|
+
data.tar.gz: 9fe73567686b8bf022f2215382b27d626ffb805ce3a922a0733b0b4aaed0e484234b5fc5aabe8ede9034985992bb26951ac78c133bd2b31c08904a6eef93a288
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [ main, master ]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
rspec:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout code
|
|
14
|
+
uses: actions/checkout@v5
|
|
15
|
+
|
|
16
|
+
- name: Set up Ruby
|
|
17
|
+
uses: ruby/setup-ruby@v1
|
|
18
|
+
with:
|
|
19
|
+
ruby-version: .ruby-version
|
|
20
|
+
bundler-cache: true
|
|
21
|
+
|
|
22
|
+
- name: Run RSpec
|
|
23
|
+
run: bundle exec rspec
|
|
24
|
+
|
|
25
|
+
- name: Run RuboCop
|
|
26
|
+
run: bundle exec rubocop
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishes the gem to RubyGems whenever a version tag (e.g. v0.2.0) is pushed.
|
|
4
|
+
# Uses RubyGems trusted publishing (OIDC) — no API key secret required. See
|
|
5
|
+
# https://guides.rubygems.org/trusted-publishing/ and configure this repo +
|
|
6
|
+
# workflow as a trusted publisher for the gem on rubygems.org first.
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags:
|
|
11
|
+
- "v*"
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
release:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
environment: release
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
contents: write # create the GitHub release
|
|
20
|
+
id-token: write # mint the OIDC token for trusted publishing
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- name: Checkout code
|
|
24
|
+
uses: actions/checkout@v5
|
|
25
|
+
|
|
26
|
+
- name: Set up Ruby
|
|
27
|
+
uses: ruby/setup-ruby@v1
|
|
28
|
+
with:
|
|
29
|
+
ruby-version: .ruby-version
|
|
30
|
+
bundler-cache: true
|
|
31
|
+
|
|
32
|
+
- name: Verify tests pass before releasing
|
|
33
|
+
run: bundle exec rspec
|
|
34
|
+
|
|
35
|
+
- name: Publish to RubyGems
|
|
36
|
+
uses: rubygems/release-gem@v1
|
data/.gitignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/.bundle/
|
|
2
|
+
/.yardoc
|
|
3
|
+
/_yardoc/
|
|
4
|
+
/coverage/
|
|
5
|
+
/doc/
|
|
6
|
+
/pkg/
|
|
7
|
+
/spec/reports/
|
|
8
|
+
/tmp/
|
|
9
|
+
|
|
10
|
+
# rspec failure tracking
|
|
11
|
+
.rspec_status
|
|
12
|
+
|
|
13
|
+
# built gems
|
|
14
|
+
/*.gem
|
|
15
|
+
|
|
16
|
+
# capture tooling (node deps + recorded traffic, which contains session data)
|
|
17
|
+
/capture/node_modules/
|
|
18
|
+
/capture/calls.jsonl
|
|
19
|
+
/capture/package*.json
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.1
|
|
3
|
+
NewCops: disable
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
Exclude:
|
|
6
|
+
- "bin/**/*"
|
|
7
|
+
- "vendor/**/*"
|
|
8
|
+
|
|
9
|
+
Layout/LineLength:
|
|
10
|
+
Max: 80
|
|
11
|
+
|
|
12
|
+
Metrics/BlockLength:
|
|
13
|
+
Exclude:
|
|
14
|
+
- "spec/**/*"
|
|
15
|
+
- "*.gemspec"
|
|
16
|
+
|
|
17
|
+
# GraphQL operations carry verbose variable hashes; allow roomier methods.
|
|
18
|
+
Metrics/MethodLength:
|
|
19
|
+
Max: 25
|
|
20
|
+
|
|
21
|
+
Metrics/AbcSize:
|
|
22
|
+
Max: 20
|
|
23
|
+
|
|
24
|
+
Lint/AmbiguousBlockAssociation:
|
|
25
|
+
Exclude:
|
|
26
|
+
- "spec/**/*"
|
|
27
|
+
|
|
28
|
+
Style/Documentation:
|
|
29
|
+
Enabled: false
|
|
30
|
+
|
|
31
|
+
Layout/DotPosition:
|
|
32
|
+
EnforcedStyle: trailing
|
|
33
|
+
Enabled: true
|
|
34
|
+
|
|
35
|
+
Style/StringLiterals:
|
|
36
|
+
EnforcedStyle: double_quotes
|
|
37
|
+
ConsistentQuotesInMultiline: true
|
|
38
|
+
Enabled: true
|
|
39
|
+
|
|
40
|
+
Style/LambdaCall:
|
|
41
|
+
Enabled: false
|
|
42
|
+
|
|
43
|
+
Style/SymbolArray:
|
|
44
|
+
EnforcedStyle: brackets
|
|
45
|
+
|
|
46
|
+
Style/WordArray:
|
|
47
|
+
EnforcedStyle: percent
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
Complete rewrite for Instacart's modern web GraphQL API.
|
|
6
|
+
|
|
7
|
+
- **Auth:** session-cookie based (`session_cookie:`) instead of scripted
|
|
8
|
+
email/password login, which is now bot-protected.
|
|
9
|
+
- **Transport:** `GraphqlClient` speaks Instacart's Apollo persisted-query
|
|
10
|
+
protocol; hashes live in `PersistedQueries` and are refreshable via the
|
|
11
|
+
`capture/` tooling.
|
|
12
|
+
- **Client operations:** `#search`, `#items`, `#add_item_to_cart`,
|
|
13
|
+
`#add_items_to_cart`, `#active_carts`, returning `Item`, `SearchResult`, and
|
|
14
|
+
`Cart` models.
|
|
15
|
+
- Removed the legacy `#login`, `#available_stores`, and v3 container endpoints.
|
|
16
|
+
- Modernized tooling: Ruby 4.0, GitHub Actions CI, RuboCop, 100% test coverage,
|
|
17
|
+
and a RubyGems trusted-publishing release workflow.
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
instacart_api (0.2.0)
|
|
5
|
+
|
|
6
|
+
GEM
|
|
7
|
+
remote: https://rubygems.org/
|
|
8
|
+
specs:
|
|
9
|
+
addressable (2.9.0)
|
|
10
|
+
public_suffix (>= 2.0.2, < 8.0)
|
|
11
|
+
ast (2.4.3)
|
|
12
|
+
bigdecimal (4.1.2)
|
|
13
|
+
coderay (1.1.3)
|
|
14
|
+
crack (1.0.1)
|
|
15
|
+
bigdecimal
|
|
16
|
+
rexml
|
|
17
|
+
diff-lcs (1.6.2)
|
|
18
|
+
docile (1.4.1)
|
|
19
|
+
erb (6.0.4)
|
|
20
|
+
hashdiff (1.2.1)
|
|
21
|
+
io-console (0.8.2)
|
|
22
|
+
irb (1.18.0)
|
|
23
|
+
pp (>= 0.6.0)
|
|
24
|
+
prism (>= 1.3.0)
|
|
25
|
+
rdoc (>= 4.0.0)
|
|
26
|
+
reline (>= 0.4.2)
|
|
27
|
+
json (2.21.1)
|
|
28
|
+
language_server-protocol (3.17.0.6)
|
|
29
|
+
lint_roller (1.1.0)
|
|
30
|
+
logger (1.7.0)
|
|
31
|
+
method_source (1.1.0)
|
|
32
|
+
parallel (2.1.0)
|
|
33
|
+
parser (3.3.11.1)
|
|
34
|
+
ast (~> 2.4.1)
|
|
35
|
+
racc
|
|
36
|
+
pp (0.6.4)
|
|
37
|
+
prettyprint
|
|
38
|
+
prettyprint (0.2.0)
|
|
39
|
+
prism (1.9.0)
|
|
40
|
+
pry (0.16.0)
|
|
41
|
+
coderay (~> 1.1)
|
|
42
|
+
method_source (~> 1.0)
|
|
43
|
+
reline (>= 0.6.0)
|
|
44
|
+
public_suffix (7.0.5)
|
|
45
|
+
racc (1.8.1)
|
|
46
|
+
rainbow (3.1.1)
|
|
47
|
+
rake (13.4.2)
|
|
48
|
+
rbs (4.0.3)
|
|
49
|
+
logger
|
|
50
|
+
prism (>= 1.6.0)
|
|
51
|
+
tsort
|
|
52
|
+
rdoc (8.0.0)
|
|
53
|
+
erb
|
|
54
|
+
prism (>= 1.6.0)
|
|
55
|
+
rbs (>= 4.0.0)
|
|
56
|
+
tsort
|
|
57
|
+
regexp_parser (2.12.0)
|
|
58
|
+
reline (0.6.3)
|
|
59
|
+
io-console (~> 0.5)
|
|
60
|
+
rexml (3.4.4)
|
|
61
|
+
rspec (3.13.2)
|
|
62
|
+
rspec-core (~> 3.13.0)
|
|
63
|
+
rspec-expectations (~> 3.13.0)
|
|
64
|
+
rspec-mocks (~> 3.13.0)
|
|
65
|
+
rspec-core (3.13.6)
|
|
66
|
+
rspec-support (~> 3.13.0)
|
|
67
|
+
rspec-expectations (3.13.5)
|
|
68
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
69
|
+
rspec-support (~> 3.13.0)
|
|
70
|
+
rspec-mocks (3.13.8)
|
|
71
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
72
|
+
rspec-support (~> 3.13.0)
|
|
73
|
+
rspec-support (3.13.7)
|
|
74
|
+
rubocop (1.88.2)
|
|
75
|
+
json (~> 2.3)
|
|
76
|
+
language_server-protocol (~> 3.17.0.2)
|
|
77
|
+
lint_roller (~> 1.1.0)
|
|
78
|
+
parallel (>= 1.10)
|
|
79
|
+
parser (>= 3.3.0.2)
|
|
80
|
+
rainbow (>= 2.2.2, < 4.0)
|
|
81
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
82
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
83
|
+
ruby-progressbar (~> 1.7)
|
|
84
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
|
85
|
+
rubocop-ast (1.50.0)
|
|
86
|
+
parser (>= 3.3.7.2)
|
|
87
|
+
prism (~> 1.7)
|
|
88
|
+
ruby-progressbar (1.13.0)
|
|
89
|
+
simplecov (0.22.0)
|
|
90
|
+
docile (~> 1.1)
|
|
91
|
+
simplecov-html (~> 0.11)
|
|
92
|
+
simplecov_json_formatter (~> 0.1)
|
|
93
|
+
simplecov-html (0.13.2)
|
|
94
|
+
simplecov_json_formatter (0.1.4)
|
|
95
|
+
tsort (0.2.0)
|
|
96
|
+
unicode-display_width (3.2.0)
|
|
97
|
+
unicode-emoji (~> 4.1)
|
|
98
|
+
unicode-emoji (4.2.0)
|
|
99
|
+
webmock (3.26.2)
|
|
100
|
+
addressable (>= 2.8.0)
|
|
101
|
+
crack (>= 0.3.2)
|
|
102
|
+
hashdiff (>= 0.4.0, < 2.0.0)
|
|
103
|
+
|
|
104
|
+
PLATFORMS
|
|
105
|
+
arm64-darwin-25
|
|
106
|
+
ruby
|
|
107
|
+
|
|
108
|
+
DEPENDENCIES
|
|
109
|
+
instacart_api!
|
|
110
|
+
irb
|
|
111
|
+
pry (~> 0.14)
|
|
112
|
+
rake (~> 13.0)
|
|
113
|
+
rspec (~> 3.13)
|
|
114
|
+
rubocop (~> 1.60)
|
|
115
|
+
simplecov (~> 0.22)
|
|
116
|
+
webmock (~> 3.20)
|
|
117
|
+
|
|
118
|
+
CHECKSUMS
|
|
119
|
+
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
|
120
|
+
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
121
|
+
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
|
|
122
|
+
coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
|
|
123
|
+
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
|
|
124
|
+
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
|
|
125
|
+
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
|
|
126
|
+
erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9
|
|
127
|
+
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
|
|
128
|
+
instacart_api (0.2.0)
|
|
129
|
+
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
|
130
|
+
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
|
131
|
+
json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583
|
|
132
|
+
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
|
|
133
|
+
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
134
|
+
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
135
|
+
method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
|
|
136
|
+
parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
|
|
137
|
+
parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
|
|
138
|
+
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
|
|
139
|
+
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
|
|
140
|
+
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
141
|
+
pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
|
|
142
|
+
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
|
143
|
+
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
|
144
|
+
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
|
|
145
|
+
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
|
146
|
+
rbs (4.0.3) sha256=5a7bf70e2628549d9a1f44eae447b2cfe55968a9c60cfff52693a4bdcc020e14
|
|
147
|
+
rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
|
|
148
|
+
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
|
|
149
|
+
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
|
|
150
|
+
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
|
151
|
+
rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
|
|
152
|
+
rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
|
|
153
|
+
rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
|
|
154
|
+
rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
|
|
155
|
+
rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
|
|
156
|
+
rubocop (1.88.2) sha256=8def251c90cd955feb4daa3edc0ab56893250c4ce90ef81e6c80c03f9a939bbf
|
|
157
|
+
rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db
|
|
158
|
+
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
|
159
|
+
simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
|
|
160
|
+
simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
|
|
161
|
+
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
|
|
162
|
+
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
|
163
|
+
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
|
164
|
+
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
|
165
|
+
webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
|
|
166
|
+
|
|
167
|
+
RUBY VERSION
|
|
168
|
+
ruby 4.0.5
|
|
169
|
+
|
|
170
|
+
BUNDLED WITH
|
|
171
|
+
4.0.10
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 James Klein
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Instacart Api
|
|
2
|
+
|
|
3
|
+
[](https://github.com/kleinjm/instacart_api/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Ruby wrapper around Instacart's (undocumented) web GraphQL API: search for
|
|
6
|
+
products across the stores serving an address, look up item details, and add
|
|
7
|
+
items to the logged-in user's cart.
|
|
8
|
+
|
|
9
|
+
> ⚠️ This talks to Instacart's private web API, not an official/supported
|
|
10
|
+
> product. Endpoints and persisted-query hashes change without notice, and
|
|
11
|
+
> automated access may be against Instacart's Terms of Service. Use it for
|
|
12
|
+
> personal automation at your own risk.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "instacart_api"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Authentication
|
|
21
|
+
|
|
22
|
+
Instacart's login is bot-protected and can't be scripted, so the gem
|
|
23
|
+
authenticates with the **session cookie** from a real browser login rather than
|
|
24
|
+
an email/password. After logging in at instacart.com, copy the `Cookie` header
|
|
25
|
+
(at minimum the `__Host-instacart_sid` cookie and its companions) from your
|
|
26
|
+
browser devtools.
|
|
27
|
+
|
|
28
|
+
You also need your delivery `postal_code` and `zone_id` (both visible in the
|
|
29
|
+
GraphQL request variables on instacart.com), which scope search and pricing to
|
|
30
|
+
the stores serving that location.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
require "instacart_api"
|
|
36
|
+
|
|
37
|
+
client = InstacartApi::Client.new(
|
|
38
|
+
session_cookie: ENV.fetch("INSTACART_COOKIE"),
|
|
39
|
+
postal_code: "97202",
|
|
40
|
+
zone_id: "103"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Search across the stores serving your location.
|
|
44
|
+
results = client.search(term: "bananas", shop_ids: %w[1292 580])
|
|
45
|
+
group = results.first
|
|
46
|
+
|
|
47
|
+
# Fetch full details (name, price, availability) for the matched item ids.
|
|
48
|
+
items = client.items(ids: group.item_ids, shop_id: group.shop_id)
|
|
49
|
+
cheapest = items.select(&:available?).min # Item is Comparable by price
|
|
50
|
+
|
|
51
|
+
# Add it to the cart (quantity: 0 removes it). Returns the updated cart.
|
|
52
|
+
cart = client.add_item_to_cart(item_id: cheapest.id, quantity: 1)
|
|
53
|
+
cart.item_count # => 1
|
|
54
|
+
|
|
55
|
+
# Or add several at once, and inspect the active carts.
|
|
56
|
+
client.add_items_to_cart(items: [
|
|
57
|
+
{ item_id: cheapest.id, quantity: 2 }
|
|
58
|
+
])
|
|
59
|
+
client.active_carts
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
See [example_usage.rb](example_usage.rb) for a runnable version.
|
|
63
|
+
|
|
64
|
+
## Keeping it working
|
|
65
|
+
|
|
66
|
+
The gem pins the persisted-query hashes Instacart's web client uses. When
|
|
67
|
+
Instacart ships a new frontend build these rotate and calls start raising
|
|
68
|
+
`GraphqlError`/`UnknownOperationError`. Recapture them with the tool in
|
|
69
|
+
[`capture/`](capture/README.md) and update
|
|
70
|
+
`lib/instacart_api/persisted_queries.rb`.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
bundle install
|
|
76
|
+
bundle exec rspec # test suite (100% line coverage enforced)
|
|
77
|
+
bundle exec rubocop # lint
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Releasing
|
|
81
|
+
|
|
82
|
+
Releases publish to RubyGems via GitHub Actions using
|
|
83
|
+
[trusted publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC — no
|
|
84
|
+
API key stored). One-time setup: on rubygems.org, add this repository and the
|
|
85
|
+
`Release` workflow as a trusted publisher for the gem.
|
|
86
|
+
|
|
87
|
+
Then bump `InstacartApi::VERSION`, commit, and push a matching tag:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
git tag v0.2.0
|
|
91
|
+
git push origin v0.2.0
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The `Release` workflow runs the tests and publishes the gem.
|
|
95
|
+
|
|
96
|
+
## Contributing
|
|
97
|
+
|
|
98
|
+
Bug reports and pull requests are welcome at
|
|
99
|
+
https://github.com/kleinjm/instacart_api.
|
data/Rakefile
ADDED
data/bin/console
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "instacart_api"
|
|
6
|
+
|
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
9
|
+
|
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
11
|
+
# require "pry"
|
|
12
|
+
# Pry.start
|
|
13
|
+
|
|
14
|
+
require "irb"
|
|
15
|
+
IRB.start(__FILE__)
|
data/bin/rspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
#
|
|
5
|
+
# This file was generated by Bundler.
|
|
6
|
+
#
|
|
7
|
+
# The application 'rspec' is installed as part of a gem, and
|
|
8
|
+
# this file is here to facilitate running it.
|
|
9
|
+
#
|
|
10
|
+
|
|
11
|
+
require "pathname"
|
|
12
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
|
13
|
+
Pathname.new(__FILE__).realpath)
|
|
14
|
+
|
|
15
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
|
16
|
+
|
|
17
|
+
if File.file?(bundle_binstub)
|
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
|
19
|
+
load(bundle_binstub)
|
|
20
|
+
else
|
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require "rubygems"
|
|
27
|
+
require "bundler/setup"
|
|
28
|
+
|
|
29
|
+
load Gem.bin_path("rspec-core", "rspec")
|
data/bin/setup
ADDED
data/capture/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Refreshing persisted-query hashes
|
|
2
|
+
|
|
3
|
+
Instacart's web client uses Apollo **persisted queries**: it sends an operation
|
|
4
|
+
name plus a SHA-256 hash instead of the full GraphQL document, and the server
|
|
5
|
+
only honors hashes it already knows. Those hashes change every time Instacart
|
|
6
|
+
ships a new frontend build, so `lib/instacart_api/persisted_queries.rb` goes
|
|
7
|
+
stale periodically — you'll see `GraphqlError`/`UnknownOperationError` when it
|
|
8
|
+
does.
|
|
9
|
+
|
|
10
|
+
This directory holds a tool to recapture them from a real browser session.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
cd capture
|
|
16
|
+
npm install playwright
|
|
17
|
+
npx playwright install chromium
|
|
18
|
+
|
|
19
|
+
node capture.js # opens a browser — log in, then search + add an item
|
|
20
|
+
ruby extract_hashes.rb
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`capture.js` records every `/graphql` call to `calls.jsonl` while you drive the
|
|
24
|
+
shopping flow. `extract_hashes.rb` reads that file and prints an updated
|
|
25
|
+
`HASHES = { ... }` block. Paste it into
|
|
26
|
+
`lib/instacart_api/persisted_queries.rb`, run the specs, and commit.
|
|
27
|
+
|
|
28
|
+
## Notes
|
|
29
|
+
|
|
30
|
+
- `calls.jsonl` contains data from your authenticated session and is gitignored
|
|
31
|
+
— don't commit it.
|
|
32
|
+
- If `extract_hashes.rb` warns that an operation is missing, you didn't exercise
|
|
33
|
+
that part of the flow (e.g. you never added an item, so
|
|
34
|
+
`UpdateCartItemsMutation` wasn't fired). Re-run and complete every step.
|
data/capture/capture.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Capture Instacart's current persisted-query hashes.
|
|
2
|
+
//
|
|
3
|
+
// Instacart's web client sends Apollo persisted queries (an operation name +
|
|
4
|
+
// a SHA-256 hash). Those hashes rotate whenever Instacart ships a new frontend
|
|
5
|
+
// build, so `lib/instacart_api/persisted_queries.rb` needs periodic refreshing.
|
|
6
|
+
//
|
|
7
|
+
// This opens a real browser, waits for you to log in and drive the shopping
|
|
8
|
+
// flow (search, add an item to the cart), and records every /graphql call to
|
|
9
|
+
// calls.jsonl. Then run `ruby extract_hashes.rb` to turn that into the hash
|
|
10
|
+
// map you paste into persisted_queries.rb.
|
|
11
|
+
//
|
|
12
|
+
// npm install playwright && npx playwright install chromium
|
|
13
|
+
// node capture.js
|
|
14
|
+
// ruby extract_hashes.rb
|
|
15
|
+
const { chromium } = require("playwright");
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
|
|
19
|
+
const OUT = path.join(__dirname, "calls.jsonl");
|
|
20
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
|
|
22
|
+
(async () => {
|
|
23
|
+
const browser = await chromium.launch({ headless: false });
|
|
24
|
+
const context = await browser.newContext();
|
|
25
|
+
const page = await context.newPage();
|
|
26
|
+
|
|
27
|
+
const out = fs.createWriteStream(OUT, { flags: "w" });
|
|
28
|
+
page.on("request", (req) => {
|
|
29
|
+
if (!/instacart\.com\/graphql/.test(req.url())) return;
|
|
30
|
+
out.write(JSON.stringify({
|
|
31
|
+
method: req.method(), url: req.url(), post: req.postData() || null,
|
|
32
|
+
}) + "\n");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await page.goto("https://www.instacart.com/login", {
|
|
36
|
+
waitUntil: "domcontentloaded",
|
|
37
|
+
});
|
|
38
|
+
console.log(
|
|
39
|
+
"\nLog in, then: pick a store, SEARCH for an item, and ADD one to your cart."
|
|
40
|
+
);
|
|
41
|
+
console.log("Capturing /graphql calls... this window closes automatically.\n");
|
|
42
|
+
|
|
43
|
+
// Capture for a fixed window; exit ~15s after a cart mutation is seen.
|
|
44
|
+
let sawMutation = false;
|
|
45
|
+
page.on("request", (req) => {
|
|
46
|
+
if (/UpdateCartItemsMutation|updateCartItems/.test(req.postData() || "")) {
|
|
47
|
+
sawMutation = true;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
let mutAt = null;
|
|
51
|
+
for (let i = 0; i < 200; i++) {
|
|
52
|
+
await sleep(3000);
|
|
53
|
+
if (sawMutation && mutAt === null) mutAt = i;
|
|
54
|
+
if (mutAt !== null && i - mutAt >= 5) break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
out.end();
|
|
58
|
+
console.log(`Wrote ${OUT}. Now run: ruby ${__dirname}/extract_hashes.rb`);
|
|
59
|
+
await browser.close();
|
|
60
|
+
process.exit(0);
|
|
61
|
+
})();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Reads calls.jsonl (produced by capture.js) and prints the persisted-query
|
|
4
|
+
# hashes for the operations the gem uses, formatted to paste straight into
|
|
5
|
+
# lib/instacart_api/persisted_queries.rb.
|
|
6
|
+
#
|
|
7
|
+
# ruby extract_hashes.rb
|
|
8
|
+
|
|
9
|
+
require "json"
|
|
10
|
+
require "uri"
|
|
11
|
+
|
|
12
|
+
OPERATIONS = %w[
|
|
13
|
+
SearchCrossRetailerGroupResults
|
|
14
|
+
Items
|
|
15
|
+
UpdateCartItemsMutation
|
|
16
|
+
PersonalActiveCarts
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
calls_path = File.join(__dir__, "calls.jsonl")
|
|
20
|
+
unless File.exist?(calls_path)
|
|
21
|
+
abort "Missing #{calls_path} — run capture.js first."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
hashes = {}
|
|
25
|
+
|
|
26
|
+
File.foreach(calls_path) do |line|
|
|
27
|
+
line = line.strip
|
|
28
|
+
next if line.empty?
|
|
29
|
+
|
|
30
|
+
call = JSON.parse(line)
|
|
31
|
+
operation = nil
|
|
32
|
+
extensions = nil
|
|
33
|
+
|
|
34
|
+
if call["post"]
|
|
35
|
+
begin
|
|
36
|
+
body = JSON.parse(call["post"])
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
operation = body["operationName"]
|
|
41
|
+
extensions = body["extensions"]
|
|
42
|
+
else
|
|
43
|
+
query = URI.decode_www_form(URI(call["url"]).query || "").to_h
|
|
44
|
+
operation = query["operationName"]
|
|
45
|
+
raw = query["extensions"]
|
|
46
|
+
extensions = JSON.parse(raw) if raw
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
next unless OPERATIONS.include?(operation)
|
|
50
|
+
|
|
51
|
+
sha = extensions&.dig("persistedQuery", "sha256Hash")
|
|
52
|
+
hashes[operation] = sha if sha
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
missing = OPERATIONS - hashes.keys
|
|
56
|
+
warn "WARNING: no hash captured for #{missing.join(', ')}" unless missing.empty?
|
|
57
|
+
|
|
58
|
+
puts " HASHES = {"
|
|
59
|
+
puts hashes.map { |op, sha| %( "#{op}" =>\n "#{sha}") }.join(",\n")
|
|
60
|
+
puts " }.freeze"
|
data/example_usage.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Run this file with `ruby -I lib example_usage.rb`.
|
|
4
|
+
#
|
|
5
|
+
# Instacart's login is bot-protected and can't be scripted, so authentication
|
|
6
|
+
# is the session cookie from a real browser login. Grab it from your browser's
|
|
7
|
+
# devtools (the `__Host-instacart_sid` cookie and its companions) or with the
|
|
8
|
+
# capture script in `capture/`, and export it along with your delivery zone:
|
|
9
|
+
#
|
|
10
|
+
# export INSTACART_COOKIE="__Host-instacart_sid=...; ..."
|
|
11
|
+
# export INSTACART_POSTAL_CODE="97202"
|
|
12
|
+
# export INSTACART_ZONE_ID="103"
|
|
13
|
+
require "instacart_api"
|
|
14
|
+
|
|
15
|
+
client = InstacartApi::Client.new(
|
|
16
|
+
session_cookie: ENV.fetch("INSTACART_COOKIE"),
|
|
17
|
+
postal_code: ENV.fetch("INSTACART_POSTAL_CODE"),
|
|
18
|
+
zone_id: ENV.fetch("INSTACART_ZONE_ID")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Search across the stores serving your location. `shop_ids` are the shops to
|
|
22
|
+
# search; you can get them from your store context (the search response also
|
|
23
|
+
# echoes the retailers that matched).
|
|
24
|
+
results = client.search(term: "bananas", shop_ids: %w[1292 580])
|
|
25
|
+
group = results.first
|
|
26
|
+
puts "#{group.item_ids.size} matches at shop #{group.shop_id}"
|
|
27
|
+
|
|
28
|
+
# Look up full details (name, price, availability) for those item ids.
|
|
29
|
+
items = client.items(ids: group.item_ids, shop_id: group.shop_id)
|
|
30
|
+
cheapest = items.select(&:available?).min # Item is Comparable by price
|
|
31
|
+
puts "Cheapest: #{cheapest.name} — $#{cheapest.price}"
|
|
32
|
+
|
|
33
|
+
# Add it to your cart (quantity: 0 would remove it). Returns the updated cart.
|
|
34
|
+
cart = client.add_item_to_cart(item_id: cheapest.id, quantity: 1)
|
|
35
|
+
puts "Cart #{cart.id} now has #{cart.item_count} item(s)"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
+
require "instacart_api/version"
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = "instacart_api"
|
|
9
|
+
spec.version = InstacartApi::VERSION
|
|
10
|
+
spec.authors = ["James Klein"]
|
|
11
|
+
spec.email = ["kleinjm007@gmail.com"]
|
|
12
|
+
|
|
13
|
+
spec.summary = "A Ruby wrapper for Instacart's API"
|
|
14
|
+
spec.description = "A Ruby wrapper for Instacart's API"
|
|
15
|
+
spec.homepage = "https://github.com/kleinjm/instacart_api"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
|
|
18
|
+
spec.required_ruby_version = ">= 3.1"
|
|
19
|
+
|
|
20
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
21
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["source_code_uri"] = "https://github.com/kleinjm/instacart_api"
|
|
23
|
+
spec.metadata["changelog_uri"] = "https://github.com/kleinjm/instacart_api/blob/master/CHANGELOG.md"
|
|
24
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
25
|
+
|
|
26
|
+
# Files shipped in the gem: everything tracked by git except the test tree.
|
|
27
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
28
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
29
|
+
f.match(%r{^(test|spec|features)/})
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
spec.bindir = "exe"
|
|
33
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
34
|
+
spec.require_paths = ["lib"]
|
|
35
|
+
|
|
36
|
+
spec.add_development_dependency "irb"
|
|
37
|
+
spec.add_development_dependency "pry", "~> 0.14"
|
|
38
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
39
|
+
spec.add_development_dependency "rspec", "~> 3.13"
|
|
40
|
+
spec.add_development_dependency "rubocop", "~> 1.60"
|
|
41
|
+
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
42
|
+
spec.add_development_dependency "webmock", "~> 3.20"
|
|
43
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InstacartApi
|
|
4
|
+
# High-level Instacart client: search for products, look up item details, and
|
|
5
|
+
# add items to the logged-in user's cart.
|
|
6
|
+
#
|
|
7
|
+
# client = InstacartApi::Client.new(
|
|
8
|
+
# session_cookie: ENV.fetch("INSTACART_COOKIE"),
|
|
9
|
+
# postal_code: "97202",
|
|
10
|
+
# zone_id: "103"
|
|
11
|
+
# )
|
|
12
|
+
#
|
|
13
|
+
# `postal_code` and `zone_id` come from the account's delivery address; they
|
|
14
|
+
# scope search and pricing to the stores that serve that location.
|
|
15
|
+
class Client
|
|
16
|
+
def initialize(session_cookie:, postal_code:, zone_id:, shop_id: "0")
|
|
17
|
+
@postal_code = postal_code
|
|
18
|
+
@zone_id = zone_id
|
|
19
|
+
@shop_id = shop_id
|
|
20
|
+
@graphql = GraphqlClient.new(session_cookie: session_cookie)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Search across the retailers serving this location. `shop_ids` is the list
|
|
24
|
+
# of shop ids to search (from the account's store context). Returns one
|
|
25
|
+
# SearchResult per retailer that had matches.
|
|
26
|
+
def search(term:, shop_ids:, first: 7)
|
|
27
|
+
data = graphql.query(
|
|
28
|
+
"SearchCrossRetailerGroupResults",
|
|
29
|
+
searchSource: "cross_retailer_search",
|
|
30
|
+
query: term,
|
|
31
|
+
shopIds: shop_ids,
|
|
32
|
+
first: first,
|
|
33
|
+
shopId: shop_id,
|
|
34
|
+
zoneId: zone_id,
|
|
35
|
+
postalCode: postal_code,
|
|
36
|
+
disableAutocorrect: false,
|
|
37
|
+
includeDebugInfo: false,
|
|
38
|
+
overrideFeatureStates: [],
|
|
39
|
+
autosuggestImpressionId: nil,
|
|
40
|
+
pageViewId: nil
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
data.fetch("searchCrossRetailerGroupResults").
|
|
44
|
+
fetch("results").
|
|
45
|
+
map { |result| SearchResult.new(result) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Full item details for a set of item ids at one shop. Returns [Item].
|
|
49
|
+
def items(ids:, shop_id:)
|
|
50
|
+
data = graphql.query(
|
|
51
|
+
"Items",
|
|
52
|
+
ids: ids,
|
|
53
|
+
shopId: shop_id,
|
|
54
|
+
zoneId: zone_id,
|
|
55
|
+
postalCode: postal_code
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
data.fetch("items").map { |item| Item.new(item) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Add (or update) a single item in the active cart. A `quantity` of 0
|
|
62
|
+
# removes it. Returns the updated Cart.
|
|
63
|
+
def add_item_to_cart(item_id:, quantity: 1, quantity_type: "each")
|
|
64
|
+
add_items_to_cart(
|
|
65
|
+
items: [
|
|
66
|
+
{ item_id: item_id, quantity: quantity, quantity_type: quantity_type }
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Add (or update) several items in one mutation. Each entry is a hash with
|
|
72
|
+
# :item_id, :quantity, and optional :quantity_type. Returns the updated
|
|
73
|
+
# Cart.
|
|
74
|
+
def add_items_to_cart(items:)
|
|
75
|
+
updates = items.map do |item|
|
|
76
|
+
{
|
|
77
|
+
itemId: item.fetch(:item_id),
|
|
78
|
+
quantity: item.fetch(:quantity),
|
|
79
|
+
quantityType: item.fetch(:quantity_type, "each"),
|
|
80
|
+
trackingParams: { trackingProperties: {} }
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
data = graphql.mutate(
|
|
85
|
+
"UpdateCartItemsMutation", cartItemUpdates: updates
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
Cart.new(data.fetch("updateCartItems").fetch("cart"))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The logged-in user's active carts. Returns [Cart].
|
|
92
|
+
def active_carts
|
|
93
|
+
data = graphql.query("PersonalActiveCarts", {})
|
|
94
|
+
|
|
95
|
+
data.fetch("userCarts").fetch("carts").map { |cart| Cart.new(cart) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
attr_reader :graphql, :postal_code, :zone_id, :shop_id
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InstacartApi
|
|
4
|
+
# Base class for every error the gem raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# A non-200 HTTP response from Instacart.
|
|
8
|
+
class ResponseError < Error; end
|
|
9
|
+
|
|
10
|
+
# A 200 response whose GraphQL body carried an `errors` array.
|
|
11
|
+
class GraphqlError < Error; end
|
|
12
|
+
|
|
13
|
+
# A request for an operation we have no persisted-query hash for.
|
|
14
|
+
# Usually means the captured hashes are stale — re-run the capture script.
|
|
15
|
+
class UnknownOperationError < Error; end
|
|
16
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InstacartApi
|
|
4
|
+
# Thin transport over Instacart's GraphQL endpoint.
|
|
5
|
+
#
|
|
6
|
+
# Authentication is the logged-in session cookie (the
|
|
7
|
+
# `__Host-instacart_sid` cookie, plus its companions) captured from a real
|
|
8
|
+
# browser login — Instacart's login flow is bot-protected and can't be
|
|
9
|
+
# scripted, so the caller supplies the cookie string. Every request also
|
|
10
|
+
# needs the `X-Client-Identifier: web` header, without which the
|
|
11
|
+
# persisted-query resolver rejects the variables.
|
|
12
|
+
class GraphqlClient
|
|
13
|
+
ENDPOINT = URI("https://www.instacart.com/graphql")
|
|
14
|
+
|
|
15
|
+
# A recent desktop Chrome UA; Instacart serves the web GraphQL API to it.
|
|
16
|
+
DEFAULT_USER_AGENT =
|
|
17
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " \
|
|
18
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) " \
|
|
19
|
+
"Chrome/126.0.0.0 Safari/537.36"
|
|
20
|
+
|
|
21
|
+
def initialize(session_cookie:, user_agent: DEFAULT_USER_AGENT)
|
|
22
|
+
@session_cookie = session_cookie
|
|
23
|
+
@user_agent = user_agent
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Run a persisted query (HTTP GET). Returns the `data` hash.
|
|
27
|
+
def query(operation, variables)
|
|
28
|
+
uri = ENDPOINT.dup
|
|
29
|
+
uri.query = URI.encode_www_form(
|
|
30
|
+
operationName: operation,
|
|
31
|
+
variables: JSON.generate(variables),
|
|
32
|
+
extensions: JSON.generate(extensions(operation))
|
|
33
|
+
)
|
|
34
|
+
perform(Net::HTTP::Get.new(uri))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Run a persisted mutation (HTTP POST). Returns the `data` hash.
|
|
38
|
+
def mutate(operation, variables)
|
|
39
|
+
request = Net::HTTP::Post.new(ENDPOINT)
|
|
40
|
+
request.body = JSON.generate(
|
|
41
|
+
operationName: operation,
|
|
42
|
+
variables: variables,
|
|
43
|
+
extensions: extensions(operation)
|
|
44
|
+
)
|
|
45
|
+
perform(request)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :session_cookie, :user_agent
|
|
51
|
+
|
|
52
|
+
def extensions(operation)
|
|
53
|
+
{
|
|
54
|
+
persistedQuery: {
|
|
55
|
+
version: 1,
|
|
56
|
+
sha256Hash: PersistedQueries.hash_for(operation)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def perform(request)
|
|
62
|
+
configure(request)
|
|
63
|
+
|
|
64
|
+
response = Net::HTTP.start(
|
|
65
|
+
ENDPOINT.hostname, ENDPOINT.port, use_ssl: true
|
|
66
|
+
) { |http| http.request(request) }
|
|
67
|
+
|
|
68
|
+
unless response.code == "200"
|
|
69
|
+
raise ResponseError, "Instacart responded with HTTP #{response.code}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
parse(response.body)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def configure(request)
|
|
76
|
+
request["Accept"] = "application/json"
|
|
77
|
+
request["Content-Type"] = "application/json"
|
|
78
|
+
request["X-Client-Identifier"] = "web"
|
|
79
|
+
request["X-Requested-With"] = "XMLHttpRequest"
|
|
80
|
+
request["User-Agent"] = user_agent
|
|
81
|
+
request["Cookie"] = session_cookie
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse(body)
|
|
85
|
+
json = JSON.parse(body)
|
|
86
|
+
|
|
87
|
+
if (errors = json["errors"])
|
|
88
|
+
messages = errors.map { |error| error["message"] }.join("; ")
|
|
89
|
+
raise GraphqlError, messages
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
json.fetch("data")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InstacartApi
|
|
4
|
+
# A shopping cart, as returned by the cart mutation/queries. Instacart keeps a
|
|
5
|
+
# separate cart per retailer; `item_count` is the total line quantity.
|
|
6
|
+
class Cart
|
|
7
|
+
# A single line in the cart.
|
|
8
|
+
class LineItem
|
|
9
|
+
attr_reader :id, :item_id, :product_id, :quantity, :quantity_type
|
|
10
|
+
|
|
11
|
+
def initialize(data)
|
|
12
|
+
@id = data["id"]
|
|
13
|
+
basket_product = data["basketProduct"] || {}
|
|
14
|
+
@item_id = basket_product["id"]
|
|
15
|
+
@product_id = basket_product["productId"]
|
|
16
|
+
@quantity = data["quantity"]
|
|
17
|
+
@quantity_type = data["quantityType"]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :id, :cart_type, :retailer_id, :item_count, :items
|
|
22
|
+
|
|
23
|
+
def initialize(data)
|
|
24
|
+
@id = data["id"]
|
|
25
|
+
@cart_type = data["cartType"]
|
|
26
|
+
@retailer_id = data["retailerId"]
|
|
27
|
+
@item_count = data["itemCount"]
|
|
28
|
+
|
|
29
|
+
line_items = data.dig("cartItemCollection", "cartItems") || []
|
|
30
|
+
@items = line_items.map { |line_item| LineItem.new(line_item) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InstacartApi
|
|
4
|
+
# A single product returned by the `Items` query.
|
|
5
|
+
#
|
|
6
|
+
# Instacart item ids look like `items_<retailerLocationId>-<productId>`
|
|
7
|
+
# (e.g. `items_3113-22373938`); that full id is what the cart mutation
|
|
8
|
+
# expects.
|
|
9
|
+
class Item
|
|
10
|
+
include Comparable
|
|
11
|
+
|
|
12
|
+
attr_reader :id, :name, :size, :product_id, :brand_name,
|
|
13
|
+
:price, :full_price, :quantity_type, :stock_level
|
|
14
|
+
|
|
15
|
+
def initialize(data)
|
|
16
|
+
@id = data["id"]
|
|
17
|
+
@name = data["name"]
|
|
18
|
+
@size = data["size"]
|
|
19
|
+
@product_id = data["productId"]
|
|
20
|
+
@brand_name = data["brandName"]
|
|
21
|
+
|
|
22
|
+
pricing = data.dig("price", "viewSection") || {}
|
|
23
|
+
@price = pricing["priceValueString"]&.to_f
|
|
24
|
+
@full_price = pricing["fullPriceString"]
|
|
25
|
+
|
|
26
|
+
availability = data["availability"] || {}
|
|
27
|
+
@available = availability["available"]
|
|
28
|
+
@stock_level = availability["stockLevel"]
|
|
29
|
+
|
|
30
|
+
@quantity_type =
|
|
31
|
+
(data["quantityAttributes"] || {})["quantityType"] || "each"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def available?
|
|
35
|
+
@available == true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Compare by price so a list of items can be sorted cheapest-first.
|
|
39
|
+
def <=>(other)
|
|
40
|
+
price <=> other.price
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InstacartApi
|
|
4
|
+
# One retailer's slice of a cross-retailer search: which store it is and the
|
|
5
|
+
# ids of the items that matched there. Fetch full item details by passing
|
|
6
|
+
# `item_ids` (with `shop_id`) to `Client#items`.
|
|
7
|
+
class SearchResult
|
|
8
|
+
attr_reader :retailer_id, :shop_id, :item_ids
|
|
9
|
+
|
|
10
|
+
def initialize(data)
|
|
11
|
+
@retailer_id = data["retailerId"]
|
|
12
|
+
@shop_id = data["shopId"]
|
|
13
|
+
@item_ids = data["itemIds"] || []
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InstacartApi
|
|
4
|
+
# Instacart's web client sends Apollo *persisted queries*: instead of the
|
|
5
|
+
# full GraphQL document, it sends an operation name plus a SHA-256 hash the
|
|
6
|
+
# server already knows. The server rejects unknown hashes
|
|
7
|
+
# ("PersistedQueryNotSupported"), so we must send the exact hashes the live
|
|
8
|
+
# client uses.
|
|
9
|
+
#
|
|
10
|
+
# These hashes are tied to a frontend build and rotate when Instacart
|
|
11
|
+
# deploys. When calls start failing with UnknownOperationError or
|
|
12
|
+
# GraphqlError, refresh them by re-running the capture script (see
|
|
13
|
+
# capture/README.md) and pasting the new values here.
|
|
14
|
+
module PersistedQueries
|
|
15
|
+
HASHES = {
|
|
16
|
+
"SearchCrossRetailerGroupResults" =>
|
|
17
|
+
"0ef32d339ed761d8609b91d8232a26b7b6b05206baf32694c6fa47f7f8e73a33",
|
|
18
|
+
"Items" =>
|
|
19
|
+
"9ad66078d7fa81276b6bd4eb6a6f6fcdd1f4022ff0c3f5b4663c62877f06692a",
|
|
20
|
+
"UpdateCartItemsMutation" =>
|
|
21
|
+
"ba4bf465d294d1d528d82a4ac48ac13980d528149874c0e52082dc1d833bdb09",
|
|
22
|
+
"PersonalActiveCarts" =>
|
|
23
|
+
"eac9d17bd45b099fbbdabca2e111acaf2a4fa486f2ce5bc4e8acbab2f31fd8c0"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# The persisted-query hash for an operation, or raise if we don't have one.
|
|
27
|
+
def self.hash_for(operation)
|
|
28
|
+
HASHES.fetch(operation) do
|
|
29
|
+
raise UnknownOperationError,
|
|
30
|
+
"No persisted-query hash for #{operation.inspect}. " \
|
|
31
|
+
"The captured hashes may be stale — re-run the capture script."
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require "instacart_api/version"
|
|
8
|
+
require "instacart_api/errors"
|
|
9
|
+
require "instacart_api/persisted_queries"
|
|
10
|
+
require "instacart_api/graphql_client"
|
|
11
|
+
require "instacart_api/models/item"
|
|
12
|
+
require "instacart_api/models/search_result"
|
|
13
|
+
require "instacart_api/models/cart"
|
|
14
|
+
require "instacart_api/client"
|
|
15
|
+
|
|
16
|
+
# Ruby wrapper around Instacart's (undocumented) web GraphQL API.
|
|
17
|
+
module InstacartApi
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: instacart_api
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- James Klein
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: irb
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: pry
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.14'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.14'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rspec
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.13'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.13'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.60'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.60'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: simplecov
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.22'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.22'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: webmock
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.20'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '3.20'
|
|
110
|
+
description: A Ruby wrapper for Instacart's API
|
|
111
|
+
email:
|
|
112
|
+
- kleinjm007@gmail.com
|
|
113
|
+
executables: []
|
|
114
|
+
extensions: []
|
|
115
|
+
extra_rdoc_files: []
|
|
116
|
+
files:
|
|
117
|
+
- ".github/workflows/ci.yml"
|
|
118
|
+
- ".github/workflows/release.yml"
|
|
119
|
+
- ".gitignore"
|
|
120
|
+
- ".rspec"
|
|
121
|
+
- ".rubocop.yml"
|
|
122
|
+
- ".ruby-version"
|
|
123
|
+
- CHANGELOG.md
|
|
124
|
+
- Gemfile
|
|
125
|
+
- Gemfile.lock
|
|
126
|
+
- LICENSE
|
|
127
|
+
- README.md
|
|
128
|
+
- Rakefile
|
|
129
|
+
- bin/console
|
|
130
|
+
- bin/rspec
|
|
131
|
+
- bin/setup
|
|
132
|
+
- capture/README.md
|
|
133
|
+
- capture/capture.js
|
|
134
|
+
- capture/extract_hashes.rb
|
|
135
|
+
- example_usage.rb
|
|
136
|
+
- instacart_api.gemspec
|
|
137
|
+
- lib/instacart_api.rb
|
|
138
|
+
- lib/instacart_api/client.rb
|
|
139
|
+
- lib/instacart_api/errors.rb
|
|
140
|
+
- lib/instacart_api/graphql_client.rb
|
|
141
|
+
- lib/instacart_api/models/cart.rb
|
|
142
|
+
- lib/instacart_api/models/item.rb
|
|
143
|
+
- lib/instacart_api/models/search_result.rb
|
|
144
|
+
- lib/instacart_api/persisted_queries.rb
|
|
145
|
+
- lib/instacart_api/version.rb
|
|
146
|
+
homepage: https://github.com/kleinjm/instacart_api
|
|
147
|
+
licenses:
|
|
148
|
+
- MIT
|
|
149
|
+
metadata:
|
|
150
|
+
allowed_push_host: https://rubygems.org
|
|
151
|
+
homepage_uri: https://github.com/kleinjm/instacart_api
|
|
152
|
+
source_code_uri: https://github.com/kleinjm/instacart_api
|
|
153
|
+
changelog_uri: https://github.com/kleinjm/instacart_api/blob/master/CHANGELOG.md
|
|
154
|
+
rubygems_mfa_required: 'true'
|
|
155
|
+
rdoc_options: []
|
|
156
|
+
require_paths:
|
|
157
|
+
- lib
|
|
158
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
159
|
+
requirements:
|
|
160
|
+
- - ">="
|
|
161
|
+
- !ruby/object:Gem::Version
|
|
162
|
+
version: '3.1'
|
|
163
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
164
|
+
requirements:
|
|
165
|
+
- - ">="
|
|
166
|
+
- !ruby/object:Gem::Version
|
|
167
|
+
version: '0'
|
|
168
|
+
requirements: []
|
|
169
|
+
rubygems_version: 4.0.10
|
|
170
|
+
specification_version: 4
|
|
171
|
+
summary: A Ruby wrapper for Instacart's API
|
|
172
|
+
test_files: []
|