├── .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 | ![Display Purchases Summary](images/Purchases-Summary.gif) 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 | ![Kroger CLI Screenshot](images/Home-Screen-Screenshot.png) 44 | 45 | ### Clip Digital Coupons 46 | 47 | ![Clip all Kroger's Digital Coupons](images/Clip-Digital-Coupons.png) 48 | 49 | ### Purchases Summary 50 | 51 | ![Display Purchases Summary](images/Purchases-Summary.gif) 52 | 53 | ### Fuel Points Balance 54 | 55 | ![Fuel Points Balance](images/Fuel-Points-Balance.png) 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 | --------------------------------------------------------------------------------