├── .gitignore
├── README.md
├── create_win_executable.bat
├── images
├── Clip-Digital-Coupons.png
├── Fuel-Points-Balance.png
├── Home-Screen-Screenshot.png
├── Kroger-Survey.gif
└── Purchases-Summary.gif
├── kroger_cli
├── __init__.py
├── __main__.py
├── api.py
├── cli.py
├── helper.py
└── memoize.py
├── requirements.txt
└── setup.py
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | venv
3 | build
4 | dist
5 | .user-data
6 | *.pyc
7 | *.spec
8 | *.pkl
9 | config.ini
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Kroger CLI
2 | ==========
3 |
4 | 
5 |
6 | I got tired of manually applying the digital coupons while grocery shopping and decided to automate the process. While doing so I've added extra features, currently this python's cli can:
7 |
8 | * Display basic information from your account (name, address, rewards card number, etc)
9 | * [Complete the Kroger’s Survey on your behalf](images/Kroger-Survey.gif) (to earn 50 points and enter the sweepstake)
10 | * [Clip Digital Coupons](#clip-digital-coupons) (first 150 coupons only, sorted by relevance)
11 | * [Display Purchases Summary](#purchases-summary) (number of store visits and dollars spent)
12 | * [Retrieve Points Balance](#fuel-points-balance)
13 |
14 | The script works on kroger.com and other Kroger-owned grocery stores (Ralphs, Fry's, Fred Meyer, Dillons, Food 4 Less, [etc](https://en.wikipedia.org/wiki/Kroger#Chains)).
15 |
16 | Install/Download
17 | ----------------
18 |
19 | ### Windows
20 |
21 | You can download the latest version from the GitHub's [releases tab](https://github.com/Shmakov/kroger-cli/releases).
22 |
23 | ### Linux
24 |
25 | * Clone the repository: `git clone git@github.com:Shmakov/kroger-cli.git && cd ./kroger-cli`
26 | * Creating virtual environment: `python3.8 -m venv ./venv` (you might need to install `sudo apt-get install python3.8-venv`)
27 | * And activate it: `source venv/bin/activate`
28 | * Install the requirements: `pip install -r requirements.txt`
29 | * And you should be able to launch the project: `python -m kroger_cli`
30 |
31 | CLI Commands
32 | ------------
33 |
34 | The application support non-interactive mode by passing a name of a command. An example on how to complete Kroger's Survey: `kroger-cli survey`.
35 |
36 | Please use `kroger-cli --help` to see list of all available commands. Alternatively you can run the application without any command to launch the interactive mode (you can see the screenshot of it below).
37 |
38 | Screenshots
39 | -----------
40 |
41 | ### Main Interface
42 |
43 | 
44 |
45 | ### Clip Digital Coupons
46 |
47 | 
48 |
49 | ### Purchases Summary
50 |
51 | 
52 |
53 | ### Fuel Points Balance
54 |
55 | 
56 |
57 | ### Complete Kroger's Feedback Form
58 |
59 | [Watch](images/Kroger-Survey.gif)
60 |
61 | Side Notes
62 | ----------
63 |
64 | The initial plan was to use plain HTTP (and `requests` package), however I couldn't sign in to the Kroger's website using it. Possibly had to do with csrf token validation and/or JavaScript-based protection. Because of that I had to use `pyppeteer`, which is a python's port of `Puppeteer` (Headless Chrome).
65 |
66 | ### TODO
67 |
68 | * Purchased items (receipt data) Excel export, which could be useful for budgeting/categorization/filtering
69 | * Notification on when certain items go on sale
--------------------------------------------------------------------------------
/create_win_executable.bat:
--------------------------------------------------------------------------------
1 | REM Creates Windows executable using the pyinstaller
2 |
3 | call venv\scripts\activate
4 |
5 | pyinstaller -n kroger-cli ^
6 | --onefile ^
7 | --exclude-module tkinter ^
8 | --hidden-import=six ^
9 | --hidden-import=packaging ^
10 | --hidden-import=packaging.version ^
11 | --hidden-import=packaging.requirements ^
12 | --hidden-import=packaging.specifiers ^
13 | --hidden-import=pkg_resources ^
14 | --hidden-import=pkg_resources.py2_warn ^
15 | kroger_cli/__main__.py
--------------------------------------------------------------------------------
/images/Clip-Digital-Coupons.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Shmakov/kroger-cli/7b8f6532d9104f2b9407c00c8d5ce173758797a6/images/Clip-Digital-Coupons.png
--------------------------------------------------------------------------------
/images/Fuel-Points-Balance.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Shmakov/kroger-cli/7b8f6532d9104f2b9407c00c8d5ce173758797a6/images/Fuel-Points-Balance.png
--------------------------------------------------------------------------------
/images/Home-Screen-Screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Shmakov/kroger-cli/7b8f6532d9104f2b9407c00c8d5ce173758797a6/images/Home-Screen-Screenshot.png
--------------------------------------------------------------------------------
/images/Kroger-Survey.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Shmakov/kroger-cli/7b8f6532d9104f2b9407c00c8d5ce173758797a6/images/Kroger-Survey.gif
--------------------------------------------------------------------------------
/images/Purchases-Summary.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Shmakov/kroger-cli/7b8f6532d9104f2b9407c00c8d5ce173758797a6/images/Purchases-Summary.gif
--------------------------------------------------------------------------------
/kroger_cli/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Shmakov/kroger-cli/7b8f6532d9104f2b9407c00c8d5ce173758797a6/kroger_cli/__init__.py
--------------------------------------------------------------------------------
/kroger_cli/__main__.py:
--------------------------------------------------------------------------------
1 | import click
2 | from kroger_cli.cli import KrogerCLI
3 |
4 | kroger_cli = KrogerCLI()
5 |
6 |
7 | @click.group(invoke_without_command=True)
8 | @click.pass_context
9 | @click.option('--disable-headless', is_flag=True, help='Disable chromium\'s headless mode (useful for debug).')
10 | def cli(ctx, disable_headless):
11 | if disable_headless:
12 | kroger_cli.api.browser_options['headless'] = False
13 |
14 | # CLI call without a command
15 | if ctx.invoked_subcommand is None:
16 | kroger_cli.prompt_options()
17 |
18 |
19 | @click.command('account-info', help='Display account info.')
20 | def account_info():
21 | kroger_cli.option_account_info()
22 |
23 |
24 | @click.command('clip-coupons', help='Clip all digital coupons.')
25 | def clip_coupons():
26 | kroger_cli.option_clip_coupons()
27 |
28 |
29 | @click.command('purchases-summary', help='Purchases Summary.')
30 | def purchases_summary():
31 | kroger_cli.option_purchases_summary()
32 |
33 |
34 | @click.command('points-balance', help='Retrieve Points Balance.')
35 | def points_balance():
36 | kroger_cli.option_points_balance()
37 |
38 |
39 | @click.command('survey', help='Complete Kroger’s Survey (to earn 50 points).')
40 | def survey():
41 | kroger_cli.option_survey()
42 |
43 |
44 | if __name__ == '__main__':
45 | cli.add_command(account_info)
46 | cli.add_command(clip_coupons)
47 | cli.add_command(purchases_summary)
48 | cli.add_command(points_balance)
49 | cli.add_command(survey)
50 |
51 | cli()
52 |
--------------------------------------------------------------------------------
/kroger_cli/api.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 | import re
4 | import datetime
5 | import kroger_cli.cli
6 | from kroger_cli.memoize import memoized
7 | from kroger_cli import helper
8 | from pyppeteer import launch
9 |
10 |
11 | class KrogerAPI:
12 | browser_options = {
13 | 'headless': True,
14 | 'userDataDir': '.user-data',
15 | 'args': ['--blink-settings=imagesEnabled=false', # Disable images for hopefully faster load-time
16 | '--no-sandbox']
17 | }
18 | headers = {
19 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
20 | 'Chrome/81.0.4044.129 Safari/537.36',
21 | 'Accept-Language': 'en-US,en;q=0.9'
22 | }
23 |
24 | def __init__(self, cli):
25 | self.cli: kroger_cli.cli.KrogerCLI = cli
26 |
27 | def complete_survey(self):
28 | # Cannot use headless mode here for some reason (sign-in cookie doesn't stick)
29 | self.browser_options['headless'] = False
30 | res = asyncio.get_event_loop().run_until_complete(self._complete_survey())
31 | self.browser_options['headless'] = True
32 |
33 | return res
34 |
35 | @memoized
36 | def get_account_info(self):
37 | return asyncio.get_event_loop().run_until_complete(self._get_account_info())
38 |
39 | @memoized
40 | def get_points_balance(self):
41 | return asyncio.get_event_loop().run_until_complete(self._get_points_balance())
42 |
43 | def clip_coupons(self):
44 | return asyncio.get_event_loop().run_until_complete(self._clip_coupons())
45 |
46 | @memoized
47 | def get_purchases_summary(self):
48 | return asyncio.get_event_loop().run_until_complete(self._get_purchases_summary())
49 |
50 | async def _retrieve_feedback_url(self):
51 | self.cli.console.print('Loading `My Purchases` page (to retrieve the Feedback’s Entry ID)')
52 |
53 | # Model overlay pop up (might not exist)
54 | # Need to click on it, as it prevents me from clicking on `Order Details` link
55 | try:
56 | await self.page.waitForSelector('.ModalitySelectorDynamicTooltip--Overlay', {'timeout': 10000})
57 | await self.page.click('.ModalitySelectorDynamicTooltip--Overlay')
58 | except Exception:
59 | pass
60 |
61 | try:
62 | # `See Order Details` link
63 | await self.page.waitForSelector('.PurchaseCard-top-view-details-button', {'timeout': 10000})
64 | await self.page.click('.PurchaseCard-top-view-details-button a')
65 | # `View Receipt` link
66 | await self.page.waitForSelector('.PurchaseCard-top-view-details-button a', {'timeout': 10000})
67 | await self.page.click('.PurchaseCard-top-view-details-button a')
68 | content = await self.page.content()
69 | except Exception:
70 | link = 'https://www.' + self.cli.config['main']['domain'] + '/mypurchases'
71 | self.cli.console.print('[bold red]Couldn’t retrieve the latest purchase, please make sure it exists: '
72 | '[link=' + link + ']' + link + '[/link][/bold red]')
73 | raise Exception
74 |
75 | try:
76 | match = re.search('Entry ID: (.*?) ', content)
77 | entry_id = match[1]
78 | match = re.search('Date: (.*?) ', content)
79 | entry_date = match[1]
80 | match = re.search('Time: (.*?) ', content)
81 | entry_time = match[1]
82 | self.cli.console.print('Entry ID retrieved: ' + entry_id)
83 | except Exception:
84 | self.cli.console.print('[bold red]Couldn’t retrieve Entry ID from the receipt, please make sure it exists: '
85 | '[link=' + self.page.url + ']' + self.page.url + '[/link][/bold red]')
86 | raise Exception
87 |
88 | entry = entry_id.split('-')
89 | hour = entry_time[0:2]
90 | minute = entry_time[3:5]
91 | meridian = entry_time[5:7].upper()
92 | date = datetime.datetime.strptime(entry_date, '%m/%d/%y')
93 | full_date = date.strftime('%m/%d/%Y')
94 | month = date.strftime('%m')
95 | day = date.strftime('%d')
96 | year = date.strftime('%Y')
97 |
98 | url = f'https://www.krogerstoresfeedback.com/Index.aspx?' \
99 | f'CN1={entry[0]}&CN2={entry[1]}&CN3={entry[2]}&CN4={entry[3]}&CN5={entry[4]}&CN6={entry[5]}&' \
100 | f'Index_VisitDateDatePicker={month}%2f{day}%2f{year}&' \
101 | f'InputHour={hour}&InputMeridian={meridian}&InputMinute={minute}'
102 |
103 | return url, full_date
104 |
105 | async def _complete_survey(self):
106 | signed_in = await self.sign_in_routine(redirect_url='/mypurchases', contains=['My Purchases'])
107 | if not signed_in:
108 | await self.destroy()
109 | return None
110 |
111 | try:
112 | url, survey_date = await self._retrieve_feedback_url()
113 | except Exception:
114 | await self.destroy()
115 | return None
116 |
117 | await self.page.goto(url)
118 | await self.page.waitForSelector('#Index_VisitDateDatePicker', {'timeout': 10000})
119 | # We need to manually set the date, otherwise the validation fails
120 | js = "() => {$('#Index_VisitDateDatePicker').datepicker('setDate', '" + survey_date + "');}"
121 | await self.page.evaluate(js)
122 | await self.page.click('#NextButton')
123 |
124 | for i in range(35):
125 | current_url = self.page.url
126 | try:
127 | await self.page.waitForSelector('#NextButton', {'timeout': 5000})
128 | except Exception:
129 | if 'Finish' in current_url:
130 | await self.destroy()
131 | return True
132 | await self.page.evaluate(helper.get_survey_injection_js(self.cli.config))
133 | await self.page.click('#NextButton')
134 |
135 | await self.destroy()
136 | return False
137 |
138 | async def _get_account_info(self):
139 | signed_in = await self.sign_in_routine()
140 | if not signed_in:
141 | await self.destroy()
142 | return None
143 |
144 | self.cli.console.print('Loading profile info..')
145 | await self.page.goto('https://www.' + self.cli.config['main']['domain'] + '/accountmanagement/api/profile')
146 | try:
147 | content = await self.page.content()
148 | profile = self._get_json_from_page_content(content)
149 | user_id = profile['userId']
150 | except Exception:
151 | profile = None
152 | await self.destroy()
153 |
154 | return profile
155 |
156 | async def _get_points_balance(self):
157 | signed_in = await self.sign_in_routine()
158 | if not signed_in:
159 | await self.destroy()
160 | return None
161 |
162 | self.cli.console.print('Loading points balance..')
163 | await self.page.goto('https://www.' + self.cli.config['main']['domain'] + '/accountmanagement/api/points-summary')
164 | try:
165 | content = await self.page.content()
166 | balance = self._get_json_from_page_content(content)
167 | program_balance = balance[0]['programBalance']['balance']
168 | except Exception:
169 | balance = None
170 | await self.destroy()
171 |
172 | return balance
173 |
174 | async def _clip_coupons(self):
175 | signed_in = await self.sign_in_routine(redirect_url='/cl/coupons/', contains=['Coupons Clipped'])
176 | if not signed_in:
177 | await self.destroy()
178 | return None
179 |
180 | js = """
181 | window.scrollTo(0, document.body.scrollHeight);
182 | for (let i = 0; i < 150; i++) {
183 | let el = document.getElementsByClassName('kds-Button--favorable')[i];
184 | if (el !== undefined) {
185 | el.scrollIntoView();
186 | el.click();
187 | }
188 | }
189 | """
190 |
191 | self.cli.console.print('[italic]Applying the coupons, please wait..[/italic]')
192 | await self.page.keyboard.press('Escape')
193 | for i in range(6):
194 | await self.page.evaluate(js)
195 | await self.page.keyboard.press('End')
196 | await self.page.waitFor(1000)
197 | await self.page.waitFor(3000)
198 | await self.destroy()
199 | self.cli.console.print('[bold]Coupons successfully clipped to your account! :thumbs_up:[/bold]')
200 |
201 | async def _get_purchases_summary(self):
202 | signed_in = await self.sign_in_routine()
203 | if not signed_in:
204 | await self.destroy()
205 | return None
206 |
207 | self.cli.console.print('Loading your purchases..')
208 | await self.page.goto('https://www.' + self.cli.config['main']['domain'] + '/mypurchases/api/v1/receipt/summary/by-user-id')
209 | try:
210 | content = await self.page.content()
211 | data = self._get_json_from_page_content(content)
212 | except Exception:
213 | data = None
214 | await self.destroy()
215 |
216 | return data
217 |
218 | async def init(self):
219 | self.browser = await launch(self.browser_options)
220 | self.page = await self.browser.newPage()
221 | await self.page.setExtraHTTPHeaders(self.headers)
222 | await self.page.setViewport({'width': 700, 'height': 0})
223 |
224 | async def destroy(self):
225 | await self.page.close()
226 | await self.browser.close()
227 |
228 | async def sign_in_routine(self, redirect_url='/account/update', contains=None):
229 | if contains is None and redirect_url == '/account/update':
230 | contains = ['Profile Information']
231 |
232 | await self.init()
233 | self.cli.console.print('[italic]Signing in.. (please wait, it might take awhile)[/italic]')
234 | signed_in = await self.sign_in(redirect_url, contains)
235 |
236 | if not signed_in and self.browser_options['headless']:
237 | self.cli.console.print('[red]Sign in failed. Trying one more time..[/red]')
238 | self.browser_options['headless'] = False
239 | await self.destroy()
240 | await self.init()
241 | signed_in = await self.sign_in(redirect_url, contains)
242 |
243 | if not signed_in:
244 | self.cli.console.print('[bold red]Sign in failed. Please make sure the username/password is correct.'
245 | '[/bold red]')
246 |
247 | return signed_in
248 |
249 | async def sign_in(self, redirect_url, contains):
250 | timeout = 20000
251 | if not self.browser_options['headless']:
252 | timeout = 60000
253 | await self.page.goto('https://www.' + self.cli.config['main']['domain'] + '/signin?redirectUrl=' + redirect_url)
254 | await self.page.click('#SignIn-emailInput', {'clickCount': 3}) # Select all in the field
255 | await self.page.type('#SignIn-emailInput', self.cli.username)
256 | await self.page.click('#SignIn-passwordInput', {'clickCount': 3})
257 | await self.page.type('#SignIn-passwordInput', self.cli.password)
258 | await self.page.keyboard.press('Enter')
259 | try:
260 | await self.page.waitForNavigation(timeout=timeout)
261 | except Exception:
262 | return False
263 |
264 | if contains is not None:
265 | html = await self.page.content()
266 | for item in contains:
267 | if item not in html:
268 | return False
269 |
270 | return True
271 |
272 | def _get_json_from_page_content(self, content):
273 | match = re.search('
(.*?)', content)
274 | return json.loads(match[1])
275 |
--------------------------------------------------------------------------------
/kroger_cli/cli.py:
--------------------------------------------------------------------------------
1 | import configparser
2 | import os
3 | import click
4 | import time
5 | from rich.console import Console
6 | from rich.panel import Panel
7 | from rich.table import Table
8 | from rich import box
9 | from kroger_cli.api import KrogerAPI
10 | from kroger_cli import helper
11 |
12 |
13 | class KrogerCLI:
14 | def __init__(self, config_file='config.ini'):
15 | self.config_file = config_file
16 | self.config = configparser.ConfigParser()
17 | self.username = None
18 | self.password = None
19 | self.console = Console()
20 | self.api = KrogerAPI(self)
21 | if not os.path.exists(self.config_file):
22 | self._init_config_file()
23 | self.config.read(self.config_file)
24 | self.init()
25 |
26 | def init(self):
27 | self.prompt_store_selection()
28 |
29 | if self.username is None and self.config['main']['username'] != '':
30 | self.username = self.config['main']['username']
31 | self.password = self.config['main']['password']
32 | else:
33 | self.prompt_credentials()
34 |
35 | def prompt_store_selection(self):
36 | if self.config['main']['username'] != '':
37 | return
38 |
39 | for store_key in helper.stores:
40 | store = helper.stores[store_key]
41 | self.console.print('[bold]' + str(store_key) + '[/bold] - ' + store['label'] + ' (' + store['domain'] + ')')
42 |
43 | selected_store = click.prompt('Please select preferred store', type=int, default=1)
44 | if selected_store in helper.stores:
45 | self.config['main']['domain'] = helper.stores[selected_store]['domain']
46 | self._write_config_file()
47 | else:
48 | self.console.print('[bold red]Incorrect entry, please try again.[/bold red]')
49 | self.prompt_store_selection()
50 |
51 | self.console.rule()
52 |
53 | def prompt_credentials(self):
54 | self.console.print('In order to continue, please enter your username (email) and password for kroger.com '
55 | '(also works with Ralphs, Dillons, Smith’s and other Kroger’s Chains)')
56 | username = click.prompt('Username (email)')
57 | password = click.prompt('Password')
58 | self._set_credentials(username, password)
59 |
60 | def prompt_options(self):
61 | if self.config['profile']['first_name'] != '':
62 | self.console.print(Panel('[bold]Welcome Back, ' + self.config['profile']['first_name'] + '! :smiley:\n'
63 | '[dark_blue]Kroger[/dark_blue] CLI[/bold]', box=box.ASCII))
64 | else:
65 | self.console.print(Panel('[bold]Welcome to [dark_blue]Kroger[/dark_blue] CLI[/bold] (unofficial command '
66 | 'line interface)', box=box.ASCII))
67 |
68 | while True:
69 | self.console.print('[bold]1[/bold] - Display account info')
70 | self.console.print('[bold]2[/bold] - Clip all digital coupons')
71 | self.console.print('[bold]3[/bold] - Purchases Summary')
72 | self.console.print('[bold]4[/bold] - Points Balance')
73 | self.console.print('[bold]5[/bold] - Complete Kroger’s Survey (to earn 50 points)')
74 | self.console.print('[bold]8[/bold] - Re-Enter username/password')
75 | self.console.print('[bold]9[/bold] - Exit')
76 | option = click.prompt('Please select from one of the options', type=int)
77 | self.console.rule()
78 |
79 | if option == 1:
80 | self.option_account_info()
81 | elif option == 2:
82 | self.option_clip_coupons()
83 | elif option == 3:
84 | self.option_purchases_summary()
85 | elif option == 4:
86 | self.option_points_balance()
87 | elif option == 5:
88 | self.option_survey()
89 | elif option == 8:
90 | self.prompt_credentials()
91 | elif option == 9:
92 | return
93 |
94 | self.console.rule()
95 | time.sleep(2)
96 |
97 | def _write_config_file(self):
98 | with open(self.config_file, 'w') as f:
99 | self.config.write(f)
100 |
101 | def _init_config_file(self):
102 | self.config.add_section('main')
103 | self.config['main']['username'] = ''
104 | self.config['main']['password'] = ''
105 | self.config['main']['domain'] = 'kroger.com'
106 | self.config.add_section('profile')
107 | self.config['profile']['first_name'] = ''
108 | self._write_config_file()
109 |
110 | def _set_credentials(self, username, password):
111 | self.username = username
112 | self.password = password
113 | self.config['main']['username'] = self.username
114 | self.config['main']['password'] = self.password
115 | self._write_config_file()
116 |
117 | def _get_details_for_survey(self):
118 | if self.config['profile']['first_name'] == '':
119 | self.console.print('[bold]We need to retrieve the account info in order to fill out the survey form. '
120 | 'Please wait..[/bold]')
121 | self.option_account_info()
122 |
123 | greetings = False
124 | for field in helper.survey_mandatory_fields:
125 | if field not in self.config['profile'] or self.config['profile'][field] == '':
126 | if not greetings:
127 | self.console.print('[bold]We need some extra information in order to fill out the feedback form.[/bold]')
128 | greetings = True
129 | if field == 'age':
130 | inp = click.prompt(helper.survey_field_labels[field], type=int)
131 | else:
132 | inp = click.prompt(helper.survey_field_labels[field])
133 | self.config['profile'][field] = str(inp)
134 | self._write_config_file()
135 |
136 | def option_survey(self):
137 | self._get_details_for_survey()
138 |
139 | result = self.api.complete_survey()
140 | if result == True:
141 | self.console.print('[bold]The feedback form has been completed successfully![/bold]')
142 | else:
143 | self.console.print('[bold red]Couldn\'t complete the feedback form :([/bold red]')
144 |
145 | def option_account_info(self):
146 | info = self.api.get_account_info()
147 | if info is None:
148 | self.console.print('[bold red]Couldn\'t retrieve the account info.[/bold red]')
149 | else:
150 | self.config = helper.map_account_info(self.config, info)
151 | self._write_config_file()
152 | self.console.print(self.config.items(section='profile'))
153 |
154 | def option_points_balance(self):
155 | balance = self.api.get_points_balance()
156 | if balance is None:
157 | self.console.print('[bold red]Couldn\'t retrieve the points balance.[/bold red]')
158 | elif len(balance) == 1:
159 | link = 'https://www.' + self.config['main']['domain'] + '/account/dashboard'
160 | self.console.print('[bold red]Couldn\'t retrieve the points balance. Please visit for more info: '
161 | '[link=' + link + ']' + link + '[/link][/bold red]')
162 | else:
163 | for i in range(1, len(balance)):
164 | item = balance[i]
165 | self.console.print(item['programDisplayInfo']['loyaltyProgramName'] + ': '
166 | '[bold]' + item['programBalance']['balanceDescription'] + '[/bold]')
167 |
168 | def option_clip_coupons(self):
169 | self.api.clip_coupons()
170 |
171 | def option_purchases_summary(self):
172 | purchases = self.api.get_purchases_summary()
173 | if purchases is None:
174 | self.console.print('[bold red]Couldn\'t retrieve the purchases.[/bold red]')
175 | else:
176 | data = helper.process_purchases_summary(purchases)
177 | if data is not None:
178 | total = data['total']
179 | table = Table(title='Purchases Summary (' + data['first_purchase']['transactionTime'][:10] + ' to ' + data['last_purchase']['transactionTime'][:10] + ')')
180 | table.add_column('Year')
181 | table.add_column('Store Visits')
182 | table.add_column('Dollars Spent')
183 | table.add_column('Dollars Saved')
184 |
185 | for key, year in data['years'].items():
186 | table.add_row(str(key), str(year['store_visits']), str(f'${year["total"]:.2f}'), str(f'${year["total_savings"]:.2f}'))
187 | table.add_row('Total', str(total['store_visits']), str(f'${total["total"]:.2f}'), str(f'${total["total_savings"]:.2f}'))
188 |
189 | self.console.print(table)
190 |
--------------------------------------------------------------------------------
/kroger_cli/helper.py:
--------------------------------------------------------------------------------
1 | stores = {
2 | 1: {
3 | 'label': 'Kroger',
4 | 'domain': 'kroger.com'
5 | },
6 | 2: {
7 | 'label': 'Ralphs',
8 | 'domain': 'ralphs.com'
9 | },
10 | 3: {
11 | 'label': 'Baker’s',
12 | 'domain': 'bakersplus.com'
13 | },
14 | 4: {
15 | 'label': 'City Market',
16 | 'domain': 'citymarket.com'
17 | },
18 | 5: {
19 | 'label': 'Dillons',
20 | 'domain': 'dillons.com'
21 | },
22 | 6: {
23 | 'label': 'Food 4 Less',
24 | 'domain': 'food4less.com'
25 | },
26 | 7: {
27 | 'label': 'Fred Meyer',
28 | 'domain': 'fredmeyer.com'
29 | },
30 | 8: {
31 | 'label': 'Fry’s',
32 | 'domain': 'frysfood.com'
33 | },
34 | 9: {
35 | 'label': 'Smith’s Food and Drug',
36 | 'domain': 'smithsfoodanddrug.com'
37 | },
38 | 10: {
39 | 'label': 'King Soopers',
40 | 'domain': 'kingsoopers.com'
41 | },
42 | 11: {
43 | 'label': 'Mariano’s Fresh Market',
44 | 'domain': 'marianos.com'
45 | },
46 | 12: {
47 | 'label': 'QFC (Quality Food Centers)',
48 | 'domain': 'qfc.com'
49 | },
50 | 13: {
51 | 'label': 'Metro Market',
52 | 'domain': 'metromarket.net'
53 | },
54 | 14: {
55 | 'label': 'Pick ’n Save',
56 | 'domain': 'picknsave.com'
57 | }
58 | }
59 |
60 | survey_mandatory_fields = ['first_name', 'last_name', 'email_address', 'loyalty_card_number', 'mobile_phone',
61 | 'address_line1', 'city', 'state', 'zip', 'age']
62 | survey_field_labels = {'first_name': 'First Name', 'last_name': 'Last Name', 'email_address': 'Email Address',
63 | 'loyalty_card_number': 'Loyalty Card Number', 'mobile_phone': 'Mobile Phone',
64 | 'address_line1': 'Address Line 1', 'city': 'City', 'state': 'State (2 letters abbreviation)',
65 | 'zip': 'Zip Code', 'age': 'Age'}
66 |
67 | survey_states_mapping = {'AL': 1, 'AK': 2, 'AZ': 3, 'AR': 4, 'CA': 5, 'CO': 6, 'CT': 7, 'DE': 8, 'DC': 9, 'FL': 10,
68 | 'GA': 11, 'HI': 12, 'ID': 13, 'IL': 14, 'IN': 15, 'IA': 16, 'KS': 17, 'KY': 18, 'LA': 19,
69 | 'ME': 20, 'MD': 21, 'MA': 22, 'MI': 23, 'MN': 24, 'MS': 25, 'MO': 26, 'MT': 27, 'NE': 28,
70 | 'NV': 29, 'NH': 30, 'NJ': 31, 'NM': 32, 'NY': 33, 'NC': 34, 'ND': 35, 'OH': 36, 'OK': 37,
71 | 'OR': 38, 'PA': 39, 'PR': 40, 'RI': 41, 'SC': 42, 'SD': 43, 'TN': 44, 'TX': 45, 'UT': 46,
72 | 'VT': 47, 'VA': 48, 'WA': 49, 'WV': 50, 'WI': 51, 'WY': 52}
73 |
74 |
75 | def process_purchases_summary(purchases):
76 | default_dict = {
77 | 'total': 0.00,
78 | 'total_savings': 0.00,
79 | 'store_visits': 0,
80 | }
81 | years = {}
82 | total = dict(default_dict)
83 | first_purchase = None
84 | last_purchase = None
85 |
86 | for purchase in purchases:
87 | if first_purchase is None:
88 | first_purchase = purchase
89 |
90 | last_purchase = purchase
91 |
92 | year = int(purchase['transactionTime'][:4])
93 | if year not in years:
94 | years[year] = dict(default_dict)
95 |
96 | if 'total' in purchase:
97 | years[year]['total'] += purchase['total']
98 | years[year]['store_visits'] += 1
99 | total['total'] += purchase['total']
100 | total['store_visits'] += 1
101 |
102 | if 'totalSavings' in purchase:
103 | years[year]['total_savings'] += purchase['totalSavings']
104 | total['total_savings'] += purchase['totalSavings']
105 |
106 | if last_purchase is None:
107 | return None
108 |
109 | return {
110 | 'years': years,
111 | 'total': total,
112 | 'first_purchase': first_purchase,
113 | 'last_purchase': last_purchase,
114 | }
115 |
116 |
117 | def map_account_info(config, account_info):
118 | if account_info['firstName']:
119 | config['profile']['first_name'] = account_info['firstName']
120 | if account_info['lastName']:
121 | config['profile']['last_name'] = account_info['lastName']
122 | if account_info['emailAddress']:
123 | config['profile']['email_address'] = account_info['emailAddress']
124 | if account_info['loyaltyCardNumber']:
125 | config['profile']['loyalty_card_number'] = account_info['loyaltyCardNumber']
126 | if account_info['mobilePhoneNumber']:
127 | config['profile']['mobile_phone'] = account_info['mobilePhoneNumber']
128 |
129 | if account_info['address']['addressLine1']:
130 | config['profile']['address_line1'] = account_info['address']['addressLine1']
131 | if account_info['address']['addressLine2']:
132 | config['profile']['address_line2'] = account_info['address']['addressLine2']
133 | if account_info['address']['city']:
134 | config['profile']['city'] = account_info['address']['city']
135 | if account_info['address']['stateCode']:
136 | config['profile']['state'] = account_info['address']['stateCode']
137 | if account_info['address']['zip']:
138 | config['profile']['zip'] = account_info['address']['zip']
139 |
140 | return config
141 |
142 |
143 | def get_survey_injection_js(config):
144 | loyalty_card_number = config['profile']['loyalty_card_number']
145 | first_name = config['profile']['first_name']
146 | last_name = config['profile']['last_name']
147 | address_line1 = config['profile']['address_line1']
148 | if 'address_line2' in config['profile']:
149 | address_line2 = config['profile']['address_line2']
150 | else:
151 | address_line2 = ''
152 | city = config['profile']['city']
153 | zip = config['profile']['zip']
154 | mobile_phone = config['profile']['mobile_phone']
155 | email_address = config['profile']['email_address']
156 | state_value = survey_states_mapping[config['profile']['state']]
157 | age = config['profile']['age']
158 |
159 | js = f"""
160 | () => {{
161 | items = document.getElementsByClassName('simpleInput');
162 | if (items.length == 2) {{
163 | items[1].checked = true;
164 | }}
165 | if (items.length != 0) {{
166 | for (let i=0; i < items.length; i++) {{
167 | item = items[i]
168 | item.style.display = "";
169 | if (item.value == 4 || item.value == 9) {{
170 | item.checked = true;
171 | }}
172 | }}
173 | }}
174 | if (items.length == 11) {{
175 | items[2].checked = true;
176 | }}
177 | age = document.getElementById('R002004');
178 | if (age) {{
179 | age.value = {age};
180 | }}
181 | gender = document.getElementById('R002003');
182 | if (gender) {{
183 | gender.value = 9;
184 | }}
185 | adults1 = document.getElementById('R002017');
186 | if (adults1) {{
187 | adults1.value = 9;
188 | }}
189 | adults2 = document.getElementById('R002018');
190 | if (adults2) {{
191 | adults2.value = 9;
192 | }}
193 | education = document.getElementById('R002005');
194 | if (education) {{
195 | education.value = 99;
196 | }}
197 | income = document.getElementById('R002006');
198 | if (income) {{
199 | income.value = 99;
200 | }}
201 | employee = document.getElementById('R003002.2');
202 | if (employee) {{
203 | employee.checked = true;
204 | }}
205 | sweepstake = document.getElementById('R003003.1');
206 | if (sweepstake) {{
207 | sweepstake.checked = true;
208 | }}
209 | card = document.getElementById('R003005.1');
210 | if (card) {{
211 | card.checked = true;
212 | }}
213 | card_number = document.getElementById('R003006');
214 | if (card_number) {{
215 | card_number.value = '{loyalty_card_number}';
216 | }}
217 |
218 | first_name = document.getElementById('S003014');
219 | if (first_name) {{
220 | first_name.value = '{first_name}';
221 | }}
222 | last_name = document.getElementById('S003015');
223 | if (last_name) {{
224 | last_name.value = '{last_name}';
225 | }}
226 | address_line1 = document.getElementById('S003016');
227 | if (address_line1) {{
228 | address_line1.value = '{address_line1}';
229 | }}
230 | address_line2 = document.getElementById('S003017');
231 | if (address_line2) {{
232 | address_line2.value = '{address_line2}';
233 | }}
234 | city = document.getElementById('S003018');
235 | if (city) {{
236 | city.value = '{city}';
237 | }}
238 | zip = document.getElementById('S003020');
239 | if (zip) {{
240 | zip.value = '{zip}';
241 | }}
242 | mobile_phone = document.getElementById('S003021');
243 | if (mobile_phone) {{
244 | mobile_phone.value = '{mobile_phone}';
245 | }}
246 | email_address = document.getElementById('S003022');
247 | if (email_address) {{
248 | email_address.value = '{email_address}';
249 | }}
250 | email_address2 = document.getElementById('S003023');
251 | if (email_address2) {{
252 | email_address2.value = '{email_address}';
253 | }}
254 | state = document.getElementById('S003019');
255 | if (state) {{
256 | state.value = '{state_value}';
257 | }}
258 |
259 | return '';
260 | }}
261 | """
262 |
263 | return js
264 |
--------------------------------------------------------------------------------
/kroger_cli/memoize.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import pickle
3 | from datetime import datetime, timedelta
4 |
5 |
6 | class memoized(object):
7 | """Decorator. Caches a function's return value each time it is called.
8 | If called later with the same arguments, the cached value is returned, unless returned value is equal to None
9 | """
10 |
11 | cache_file = '.cache.pkl'
12 | cache_expiration_hours = 1
13 | default_cache = {
14 | 'expire': datetime.now() + timedelta(hours=cache_expiration_hours),
15 | 'data': {}
16 | }
17 |
18 | def __init__(self, func):
19 | self.func = func
20 | self.cache = self.default_cache
21 | self._init_file_cache()
22 |
23 | def __call__(self, *args):
24 | key = self.func.__name__
25 | if key in self.cache['data']:
26 | return self.cache['data'][key]
27 | else:
28 | value = self.func(*args)
29 | if value is not None:
30 | self.cache['data'][key] = value
31 | self._save_cache_file()
32 | return value
33 |
34 | def __get__(self, obj, objtype):
35 | """Support instance methods."""
36 | return functools.partial(self.__call__, obj)
37 |
38 | def _init_file_cache(self):
39 | try:
40 | with open(self.cache_file, 'rb') as f:
41 | cache = pickle.load(f)
42 | except Exception:
43 | cache = None
44 |
45 | if cache is not None:
46 | self.cache = cache
47 |
48 | if 'expire' in self.cache and datetime.now() > self.cache['expire']:
49 | self.cache = self.default_cache
50 |
51 | def _save_cache_file(self):
52 | with open(self.cache_file, 'wb') as f:
53 | pickle.dump(self.cache, f, pickle.HIGHEST_PROTOCOL)
54 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | altgraph==0.17
2 | appdirs==1.4.4
3 | click==7.1.2
4 | colorama==0.4.3
5 | commonmark==0.9.1
6 | future==0.18.2
7 | packaging==20.3
8 | pefile==2019.4.18
9 | pprintpp==0.4.0
10 | pyee==7.0.2
11 | Pygments==2.6.1
12 | git+git://github.com/pyinstaller/pyinstaller@develop#egg=pyinstaller
13 | pyparsing==2.4.7
14 | pyppeteer==0.2.2
15 | pywin32-ctypes==0.2.0
16 | rich==1.1.3
17 | six==1.14.0
18 | tqdm==4.46.0
19 | typing-extensions==3.7.4.2
20 | urllib3==1.25.9
21 | websockets==8.1
22 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | setup(
4 | name='kroger_cli',
5 | version='',
6 | packages=['kroger_cli'],
7 | url='https://github.com/Shmakov/kroger-cli',
8 | license='',
9 | author='Nikolay Shmakov',
10 | author_email='hello@shmakov.net',
11 | description='Clip coupons and earn points at Kroger-owned grocery stores'
12 | )
13 |
--------------------------------------------------------------------------------