├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── TODO.md ├── deploy.sh ├── images ├── configure-otp.png ├── configure-username.png ├── metabase-1.png ├── metabase-2.png ├── overview.png └── swiggy-analytics-fetch.gif ├── requirements.txt ├── setup.cfg ├── setup.py └── swiggy_analytics ├── __init__.py ├── cli.py ├── constants.py ├── db.py ├── exceptions.py ├── helper.py ├── queries.py ├── swiggy_analytics.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | testenv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ 117 | 118 | 119 | .vscode 120 | *.db 121 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Karan Sharma 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. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.in 2 | include *.md 3 | include *.rst 4 | include *.txt 5 | include LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # swiggy-analytics 2 | 3 | [![version](https://img.shields.io/pypi/v/swiggy-analytics.svg)](https://pypi.org/project/swiggy-analytics/) 4 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/mr-karan/fate/master/LICENSE) 5 | 6 | ![Overview image](images/overview.png) 7 | 8 | `swiggy-analytics` is CLI for fetching past orders from Swiggy and storing them in a local `SQLLite` DB. It also displays basic stats on your order history in the terminal, such as your most frequently order dish, your monthly expenditure, weekday wise distribution of orders and much more. For more advanced users, who wish to perform their own analysis, can do so by using the `sqlite3` db file which is generated. 9 | 10 | _NOTE_: **Only** supported by Python3. Seriously, if you're still using Python2, _please_ [STOP](https://pythonclock.org/). I've tested it on Python3.5+ on OSX and Ubuntu 18.04 Vagrant machine. 11 | 12 | ## Important Disclaimer 13 | 14 | This tool needs to login to your Swiggy account and fetch the order history. The account username lives on your local file system. If you don't feel comfortable entering these details, you can audit the code yourself to be sure that this information is not used in any evil way. There's an OTP which is sent to your registered mobile number, which is required everytime you fetch orders. Cheers! 15 | 16 | ## Getting Started 17 | 18 | ### Installation 19 | 20 | `pip install swiggy-analytics` 21 | 22 | ### Setting up account 23 | 24 | If you run `swiggy-analytics` for the first time, it will prompt you for your registered mobile number. An OTP will be sent to your phone and after the session is authenticated, a file `.swiggy-analytics-config.ini` is created in your home directory. The file contains your username to your Swiggy account, so you don't have to re-enter this detail everytime you run the tool. 25 | 26 | ![](images/configure-otp.png) 27 | 28 | You can also regenrate your config file by explictly passing the flag with the command executable, such as: `swiggy-analytics --configure` 29 | 30 | **NOTE**: Username is your phone number which you have registered with your Swiggy account. 31 | 32 | ### Quickstart 33 | 34 | ```sh 35 | swiggy-analytics --save 36 | ``` 37 | 38 | ![fetch](images/swiggy-analytics-fetch.gif) 39 | 40 | - If you want to persist the orders data to a local database, you should use the `--save` flag. This option would generate a `swiggy.db` file in your current working directory. 41 | - In case you just want to run it once and see basic stats, you can skip 42 | this flag and the tool will hold the data in an in memory DB which would last only till the program is running. 43 | 44 | ## Advanced 45 | 46 | You can write your own SQL queries by importing `swiggy.db` in your DB tool. I personally used Metabase and setup a dashboard with interesting charts presenting patterns on my ordering behaviour. 47 | 48 | ![metabase-1](images/metabase-1.png) 49 | ![metabase-2](images/metabase-2.png) 50 | 51 | ## Credits 52 | 53 | - [@knadh](https://github.com/knadh) for the [`print_bars`](https://github.com/knadh/git-bars/blob/master/gitbars/gitbars.py#L24) function which is used to draw simple bar graph on the terminal. 54 | - [Python Prompt Toolkit](https://python-prompt-toolkit.readthedocs.io/en/master/) for helping me flesh out a good looking CLI so easily! <3 this library. 55 | 56 | ## Contribution 57 | 58 | PRs on Feature Requests, Bug fixes are welcome. Feel free to open an issue and have a discussion first. 59 | 60 | ## License 61 | 62 | [MIT](license) 63 | 64 | ### Disclaimer 65 | 66 | This tool is only intended for personal use and is a simple demonstration. It is in open domain and I am not responsible if you use it and violate any TnC. Or as they say, 67 | it's **for science**. 68 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | [x] fetch orders and return data 2 | 3 | [x] save to sqllite db 4 | 5 | [x] progress bar 6 | 7 | [x] cli configure 8 | 9 | [x] cli fluff 10 | 11 | [x] cleanup code with class instances, better variable names and comments 12 | 13 | [x] add stats 14 | 15 | [x] pass config flag 16 | 17 | [x] retry mechanism for login 18 | 19 | [x] handle exceptions in db 20 | 21 | [x] graphs in term? 22 | 23 | [x] sql as external file 24 | 25 | [x] add readme 26 | 27 | [x] add screenies, asciiterm 28 | 29 | [ ] write blogpost 30 | 31 | [x] publish on pypi 32 | 33 | [x] test on ubuntu vagrant 34 | 35 | ### Next Version 36 | 37 | [ ] email monthly reports 38 | [ ] keep offset id in a text file, so that if there is a partial fetch, the complete fetch continues only from the fetch - \*Will do in next release 39 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rm -rf dist/ 3 | python setup.py sdist 4 | twine upload dist/* -------------------------------------------------------------------------------- /images/configure-otp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr-karan/swiggy-analytics/df6d6151a11eed2501937d473de14987db2ce372/images/configure-otp.png -------------------------------------------------------------------------------- /images/configure-username.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr-karan/swiggy-analytics/df6d6151a11eed2501937d473de14987db2ce372/images/configure-username.png -------------------------------------------------------------------------------- /images/metabase-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr-karan/swiggy-analytics/df6d6151a11eed2501937d473de14987db2ce372/images/metabase-1.png -------------------------------------------------------------------------------- /images/metabase-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr-karan/swiggy-analytics/df6d6151a11eed2501937d473de14987db2ce372/images/metabase-2.png -------------------------------------------------------------------------------- /images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr-karan/swiggy-analytics/df6d6151a11eed2501937d473de14987db2ce372/images/overview.png -------------------------------------------------------------------------------- /images/swiggy-analytics-fetch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr-karan/swiggy-analytics/df6d6151a11eed2501937d473de14987db2ce372/images/swiggy-analytics-fetch.gif -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ansible-base==2.10.8 2 | apache-libcloud==3.2.0 3 | argcomplete==1.8.1 4 | Babel==2.8.0 5 | bcc==0.18.0 6 | blinker==1.4 7 | Brlapi==0.8.3 8 | certifi==2020.6.20 9 | chardet==3.0.4 10 | charset-normalizer==3.3.2 11 | chrome-gnome-shell==0.0.0 12 | click==8.0.3 13 | colorama==0.4.4 14 | command-not-found==0.3 15 | cryptography==3.4.8 16 | cupshelpers==1.0 17 | dbus-python==1.2.18 18 | defer==1.0.6 19 | distlib==0.3.4 20 | distro==1.7.0 21 | dnspython==2.1.0 22 | docutils==0.20.1 23 | filelock==3.6.0 24 | hidpidaemon==18.4.6 25 | httplib2==0.20.2 26 | idna==2.8 27 | importlib-metadata==4.6.4 28 | jeepney==0.7.1 29 | Jinja2==3.0.3 30 | jmespath==0.10.0 31 | kernelstub==3.1.4 32 | keyring==23.5.0 33 | language-selector==0.1 34 | launchpadlib==1.10.16 35 | lazr.restfulclient==0.14.4 36 | lazr.uri==1.0.6 37 | lockfile==0.12.2 38 | louis==3.20.0 39 | macaroonbakery==1.3.1 40 | markdown-it-py==3.0.0 41 | MarkupSafe==2.0.1 42 | mdurl==0.1.2 43 | more-itertools==8.10.0 44 | netaddr==0.8.0 45 | netifaces==0.11.0 46 | nh3==0.2.15 47 | ntlm-auth==1.4.0 48 | oauthlib==3.2.0 49 | packaging==21.3 50 | pkginfo==1.9.6 51 | platformdirs==2.5.1 52 | pop-transition==1.1.2 53 | prompt-toolkit==3.0.43 54 | protobuf==3.12.4 55 | pycairo==1.20.1 56 | pycryptodomex==3.11.0 57 | pycups==2.0.1 58 | pydbus==0.6.0 59 | Pygments==2.17.2 60 | PyGObject==3.42.1 61 | PyJWT==2.3.0 62 | pykerberos==1.1.14 63 | pymacaroons==0.13.0 64 | PyNaCl==1.5.0 65 | pyparsing==2.4.7 66 | pyRFC3339==1.1 67 | python-apt==2.4.0+ubuntu2 68 | python-debian==0.1.43+ubuntu1.1 69 | python-gnupg==0.4.8 70 | python-xlib==0.29 71 | pytz==2022.1 72 | pywinrm==0.3.0 73 | pyxdg==0.27 74 | PyYAML==5.4.1 75 | readme-renderer==42.0 76 | repolib==2.2.1 77 | repoman==1.4.0 78 | requests==2.31.0 79 | requests-kerberos==0.12.0 80 | requests-ntlm==1.1.0 81 | requests-toolbelt==0.9.1 82 | rfc3986==2.0.0 83 | rich==13.7.0 84 | SecretStorage==3.3.1 85 | selinux==3.3 86 | sessioninstaller==0.0.0 87 | simplejson==3.17.6 88 | six==1.16.0 89 | systemd-python==234 90 | ubuntu-advantage-tools==8001 91 | ubuntu-drivers-common==0.0.0 92 | ufw==0.36.1 93 | urllib3==2.1.0 94 | virtualenv==20.13.0+ds 95 | wadllib==1.3.6 96 | wcwidth==0.2.12 97 | xdg==5 98 | xkit==0.0.0 99 | xmltodict==0.12.0 100 | zipp==1.0.0 101 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from os import path 4 | 5 | from setuptools import find_packages, setup 6 | 7 | 8 | # read the contents of your README file 9 | def read_readme(): 10 | this_directory = path.abspath(path.dirname(__file__)) 11 | with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: 12 | return f.read() 13 | 14 | 15 | def requirements(): 16 | with open('requirements.txt') as f: 17 | return f.read().splitlines() 18 | 19 | 20 | setup( 21 | name="swiggy_analytics", 22 | version="1.7.0", 23 | description="A CLI for fetching past orders from Swiggy and performing basic stats in the terminal", 24 | long_description=read_readme(), 25 | # long_description_content_type='text/markdown', 26 | author="Karan Sharma", 27 | author_email="karansharma1295@gmail.com", 28 | url="https://github.com/mr-karan/swiggy-analytics", 29 | install_requires=requirements(), 30 | include_package_data=True, 31 | packages=find_packages(), 32 | download_url="https://github.com/mr-karan/swiggy-analytics", 33 | license="MIT License", 34 | entry_points={ 35 | 'console_scripts': [ 36 | 'swiggy-analytics = swiggy_analytics.swiggy_analytics:main', 37 | ], 38 | }, 39 | classifiers=[ 40 | "Topic :: Utilities", 41 | "Intended Audience :: End Users/Desktop", 42 | "Programming Language :: Python", 43 | "Natural Language :: English", 44 | "License :: OSI Approved :: MIT License", 45 | 'Topic :: Software Development :: Libraries', 46 | 'Topic :: Software Development :: Libraries :: Python Modules', 47 | 'Operating System :: OS Independent', 48 | 'Programming Language :: Python :: 3 :: Only', 49 | ] 50 | ) 51 | -------------------------------------------------------------------------------- /swiggy_analytics/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr-karan/swiggy-analytics/df6d6151a11eed2501937d473de14987db2ce372/swiggy_analytics/__init__.py -------------------------------------------------------------------------------- /swiggy_analytics/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from prompt_toolkit import prompt 4 | from prompt_toolkit.completion import WordCompleter 5 | from prompt_toolkit.shortcuts import button_dialog, input_dialog 6 | from prompt_toolkit.validation import ValidationError, Validator 7 | 8 | from swiggy_analytics.constants import NO_ANSWER_CHOICES, YES_ANSWER_CHOICES 9 | from swiggy_analytics.exceptions import SwiggyCliQuitError 10 | 11 | 12 | class YesNoValidator(Validator): 13 | 14 | def validate(self, document): 15 | """ 16 | Display error message if text not valid. 17 | 18 | return: 19 | A status bar with validation error message 20 | """ 21 | text = document.text.lower() 22 | if text not in YES_ANSWER_CHOICES+NO_ANSWER_CHOICES: 23 | raise ValidationError(message='Enter "yes" if you want to continue ' 24 | '"no" if you want to refetch the orders.') 25 | 26 | 27 | def quit_prompt(): 28 | """ 29 | Generic function to display a confirmation screen shown if 30 | user decides to cancel any operation. 31 | """ 32 | result = button_dialog( 33 | title='Are you sure you want to exit?', 34 | text='Do you want to confirm?', 35 | buttons=[ 36 | ('Yes', True), 37 | ('No', False), 38 | ], 39 | ) 40 | return result 41 | 42 | 43 | def get_input_value(title, text): 44 | """ 45 | Handle input by user 46 | """ 47 | value = input_dialog(title, text) 48 | if not value: 49 | # if user decides to cancel 50 | if not quit_prompt(): 51 | # show the input dialogue again if user doesn't want to quit 52 | return input_dialog(title, text) 53 | # in case user wants to cancel 54 | raise SwiggyCliQuitError("No input provided by user") 55 | # raw value entered by user 56 | return value 57 | 58 | 59 | # SOURCE: Modified version of https://github.com/knadh/git-bars/blob/master/gitbars/gitbars.py#L24 60 | def print_bars(items=None, block=u"\u2580", width=50): 61 | """ 62 | Print unicode bar representations of dates and scores. 63 | """ 64 | for i in range(len(items)): 65 | num = str(items[i]["count"]) 66 | 67 | sys.stdout.write(" ") 68 | sys.stdout.write(num) 69 | sys.stdout.write((5 - len(num)) * " ") 70 | 71 | # Colour the weekend bars. 72 | # if items[i]["weekend"]: 73 | # sys.stdout.write("\033[93m") 74 | 75 | sys.stdout.write(block * int(items[i]["score"] * width)) 76 | 77 | sys.stdout.write("\033[93m") 78 | sys.stdout.write(" {} ".format(items[i]["name"])) 79 | sys.stdout.write("\x1b[0m") 80 | 81 | if items[i].get("extra"): 82 | sys.stdout.write("\033[91m") 83 | sys.stdout.write((items[i].get("extra"))) 84 | sys.stdout.write("\x1b[0m") 85 | 86 | sys.stdout.write("\n") 87 | 88 | 89 | def user_continue(): 90 | """ 91 | Validates the user's answer as one of Yes/No 92 | """ 93 | html_completer = WordCompleter(YES_ANSWER_CHOICES+NO_ANSWER_CHOICES) 94 | answer = prompt('You already have a swiggy.db file, do you want to use the same? (Yes/no) ', completer=html_completer, 95 | validator=YesNoValidator(), default="yes") 96 | 97 | if answer.lower() in YES_ANSWER_CHOICES: 98 | return True 99 | return False 100 | -------------------------------------------------------------------------------- /swiggy_analytics/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from prompt_toolkit.shortcuts.progress_bar import formatters 5 | from prompt_toolkit.styles import Style 6 | 7 | SWIGGY_URL = 'https://www.swiggy.com' 8 | SWIGGY_LOGIN_URL = SWIGGY_URL + '/dapi/auth/signin-with-check' 9 | SWIGGY_ORDER_URL = SWIGGY_URL + '/dapi/order/all' 10 | SWIGGY_SEND_OTP_URL = SWIGGY_URL + '/dapi/auth/sms-otp' 11 | SWIGGY_VERIFY_OTP_URL = SWIGGY_URL + '/dapi/auth/otp-verify' 12 | SWIGGY_API_CALL_INTERVAL = 1.5 # interval between API calls. (in seconds) 13 | 14 | CONFIG_FILEPATH = os.path.join( 15 | str(Path.home()), '.swiggy-analytics-config.ini') 16 | DB_FILEPATH = os.path.join(str(os.getcwd()), 'swiggy.db') 17 | 18 | PROGRESS_BAR_STYLE = Style.from_dict({ 19 | 'label': 'bg:#FFA500 #000000', 20 | 'percentage': 'bg:#FFA500 #000000', 21 | 'current': '#448844', 22 | 'bar': '', 23 | }) 24 | 25 | PROGRESS_BAR_FORMATTER = [ 26 | formatters.Label(), 27 | formatters.Text(': [', style='class:percentage'), 28 | formatters.Percentage(), 29 | formatters.Text(']', style='class:percentage'), 30 | formatters.Text(' '), 31 | formatters.Bar(sym_a='#', sym_b='#', sym_c='.'), 32 | formatters.Text(' '), 33 | ] 34 | 35 | YES_ANSWER_CHOICES = ['y', 'yes', 'yeah', 'yup'] 36 | NO_ANSWER_CHOICES = ['n', 'no', 'nope'] 37 | -------------------------------------------------------------------------------- /swiggy_analytics/db.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | from swiggy_analytics.constants import DB_FILEPATH 4 | from swiggy_analytics.exceptions import SwiggyDBError 5 | from swiggy_analytics.queries import (create_items_table_query, 6 | create_orders_table_query, 7 | insert_items_query, insert_orders_query) 8 | 9 | 10 | class SwiggyDB(object): 11 | def init_db(self, persist=False): 12 | try: 13 | if persist: 14 | print("Connecting to {}".format(DB_FILEPATH)) 15 | self.conn = sqlite3.connect(DB_FILEPATH) 16 | else: 17 | print("Connecting to in memory db") 18 | self.conn = sqlite3.connect(':memory:') 19 | except Exception: 20 | raise SwiggyCliDBException("Unable to connect to DB") 21 | 22 | def create_db(self): 23 | cur = self.conn.cursor() 24 | cur.execute(create_orders_table_query) 25 | cur.execute(create_items_table_query) 26 | self.conn.commit() 27 | 28 | def insert_orders_details(self, orders): 29 | cur = self.conn.cursor() 30 | # CAVEAT: Since this is a batch insert, even if one of the order already exists, the whole 31 | # transaction will fail and result in not adding the other unique orders 32 | # This is by design. Will think of a better way to handle later, it is not a huge issue now. 33 | try: 34 | cur.executemany(insert_orders_query, orders) 35 | self.conn.commit() 36 | except sqlite3.Error as e: 37 | if "UNIQUE" in "{}".format(e): 38 | pass 39 | else: 40 | raise SwiggyDBError( 41 | "Error while inserting orders: {}".format(e)) 42 | except Exception as e: 43 | raise SwiggyDBError("Error while executing query: {}".format(e)) 44 | 45 | def insert_order_items(self, items): 46 | cur = self.conn.cursor() 47 | try: 48 | cur.executemany(insert_items_query, items) 49 | self.conn.commit() 50 | except sqlite3.Error as e: 51 | if "UNIQUE" in "{}".format(e): 52 | pass 53 | else: 54 | raise SwiggyDBError("Error while inserting items {}".format(e)) 55 | except Exception as e: 56 | raise SwiggyDBError("Error while executing query: {}".format(e)) 57 | 58 | def fetch_result(self, query): 59 | cur = self.conn.cursor() 60 | try: 61 | cur.execute(query) 62 | except sqlite3.Error as e: 63 | raise SwiggyDBError("Error while fetching items: {}".format(e)) 64 | except Exception as e: 65 | raise SwiggyDBError("Error while executing query: {}".format(e)) 66 | return cur.fetchall() 67 | -------------------------------------------------------------------------------- /swiggy_analytics/exceptions.py: -------------------------------------------------------------------------------- 1 | class SwiggyCliQuitError(Exception): 2 | pass 3 | 4 | 5 | class SwiggyCliAuthError(Exception): 6 | pass 7 | 8 | 9 | class SwiggyCliConfigError(Exception): 10 | pass 11 | 12 | 13 | class SwiggyAPIError(Exception): 14 | pass 15 | 16 | 17 | class SwiggyDBError(Exception): 18 | pass 19 | -------------------------------------------------------------------------------- /swiggy_analytics/helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | from collections import namedtuple 5 | from math import ceil 6 | 7 | import requests 8 | from prompt_toolkit import print_formatted_text 9 | from prompt_toolkit.formatted_text import HTML 10 | from prompt_toolkit.shortcuts import ProgressBar 11 | 12 | from swiggy_analytics.cli import get_input_value, print_bars, quit_prompt 13 | from swiggy_analytics.constants import (PROGRESS_BAR_FORMATTER, 14 | PROGRESS_BAR_STYLE, 15 | SWIGGY_API_CALL_INTERVAL, 16 | SWIGGY_LOGIN_URL, SWIGGY_ORDER_URL, 17 | SWIGGY_SEND_OTP_URL, SWIGGY_URL, 18 | SWIGGY_VERIFY_OTP_URL) 19 | from swiggy_analytics.db import SwiggyDB 20 | from swiggy_analytics.exceptions import (SwiggyAPIError, SwiggyCliAuthError, 21 | SwiggyCliConfigError, 22 | SwiggyCliQuitError, SwiggyDBError) 23 | from swiggy_analytics.queries import (get_items_name_count_query, 24 | get_monthly_spend_count, 25 | get_order_count_day_of_week, 26 | get_top_20_restaurants_query, 27 | get_total_amount_query, 28 | get_total_orders_query) 29 | from swiggy_analytics.utils import (format_amount, get_config, get_month, 30 | get_scores, get_weekday_name, save_config) 31 | 32 | session = requests.Session() 33 | session.headers = { 34 | "user-agent": ( 35 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)" 36 | " Chrome/115.0.0.0 Safari/537.36" 37 | ) 38 | } 39 | 40 | def fetch_orders_info(orders): 41 | """ 42 | Parses the list of orders and extracts only the fields 43 | required for analytics. Prepares the result as 44 | input for the db 45 | """ 46 | order_details = [] 47 | order_items = [] 48 | OrderDetails = namedtuple( 49 | 'OrderDetails', ['order_id', 'order_total', 'restaurant_name', 'order_time', 'rain_mode', 'on_time']) 50 | OrderItems = namedtuple( 51 | 'OrderItems', ['order_id', 'name', 'is_veg']) 52 | 53 | # filter orders which are delivered 54 | delivered_orders = list(filter(lambda i: i.get( 55 | 'order_status', '') == 'Delivered', orders)) 56 | for order in delivered_orders: 57 | order_id = order.get('order_id') 58 | order_total = order.get('order_total') 59 | restaurant_name = order.get('restaurant_name') 60 | order_time = order.get('order_time') 61 | rain_mode = order.get('rain_mode', False) 62 | on_time = order.get('on_time', True) 63 | 64 | order_details.append(OrderDetails(order_id=order_id, 65 | order_total=order_total, 66 | restaurant_name=restaurant_name, 67 | order_time=order_time, 68 | rain_mode=rain_mode, 69 | on_time=on_time)) 70 | if order.get('order_items'): 71 | for item in order.get('order_items'): 72 | is_veg = item.get('is_veg') 73 | name = item.get('name') 74 | order_items.append(OrderItems(order_id=order_id, 75 | name=name, 76 | is_veg=is_veg 77 | )) 78 | 79 | return {'order_details': order_details, 'order_items': order_items} 80 | 81 | 82 | def fetch_orders(offset_id): 83 | """ 84 | Fetches a set of orders (limited to 10) using the history API 85 | """ 86 | try: 87 | response = session.get( 88 | SWIGGY_ORDER_URL + '?order_id=' + str(offset_id)) 89 | except requests.exceptions.ConnectionError: 90 | fetch_orders(offset_id) 91 | except Exception as e: 92 | raise SwiggyAPIError("Error while fetching orders: {}".format(e)) 93 | return response.json().get('data').get('orders', []) 94 | 95 | 96 | def initial_setup_prompt(): 97 | """ 98 | Prompt shown for the first time setup 99 | or when configure flag is passed. 100 | Fetch the keys from user and store in config file 101 | """ 102 | try: 103 | swiggy_username = get_input_value(title='First time setup', 104 | text='Please enter your mobile number registered with Swiggy') 105 | except SwiggyCliQuitError: 106 | sys.exit("Bye") 107 | 108 | save_config(username=swiggy_username) 109 | return None 110 | 111 | 112 | def perform_login(): 113 | """ 114 | Attemps to make a GET request to Swiggy and on success, 115 | uses the CSRF token to make a POST request to login and 116 | maintain the cookies in the same session object, which is used 117 | for further calls. 118 | """ 119 | establish_connection = session.get(SWIGGY_URL) 120 | # This is the most ugliest parsing I have ever written. Don't @ me 121 | csrf_token = establish_connection.text.split("csrfToken")[1].split("=")[ 122 | 1].split(";")[0][2:-1] 123 | # Trying to act smart eh, swiggy? ;) 124 | sw_cookie = establish_connection.cookies.get_dict().get('__SW') 125 | if not csrf_token or not sw_cookie: 126 | raise SwiggyCliAuthError("Unable to establish connection with the website. Login failed") 127 | # fetch username from config 128 | try: 129 | username = get_config() 130 | except SwiggyCliConfigError as e: 131 | raise e 132 | # send OTP request 133 | 134 | otp_response = session.post(SWIGGY_SEND_OTP_URL, headers={'content-type': 'application/json', 135 | 'Cookie':'__SW={}'.format(sw_cookie), 136 | }, 137 | json={"mobile": username, '_csrf': csrf_token}) 138 | # Swiggy APIs send 200 for error responses, so cannot do a status check. 139 | if otp_response== "Invalid Request": 140 | raise SwiggyCliAuthError( 141 | "Error from Swiggy API while sending OTP") 142 | # Get the new csrf token 143 | re_establish_connection = session.get(SWIGGY_URL) 144 | # This is the most ugliest parsing I have ever written. Don't @ me 145 | csrf_token = re_establish_connection.text.split("csrfToken")[1].split("=")[ 146 | 1].split(";")[0][2:-1] 147 | # prompt for OTP 148 | otp_input = get_input_value(title='Verify OTP', 149 | text='Please enter the OTP sent to your registered mobile number {}'.format(username)) 150 | 151 | otp_verify_response = session.post(SWIGGY_VERIFY_OTP_URL, headers={'content-type': 'application/json'}, 152 | json={"otp": otp_input, '_csrf': csrf_token}) 153 | 154 | if otp_verify_response.text == "Invalid Request": 155 | perform_login() 156 | 157 | if otp_verify_response.status_code != 200: 158 | raise SwiggyCliAuthError( 159 | "Login response non success {}".format(otp_verify_response.status_code)) 160 | 161 | 162 | def insert_orders_data(db, orders): 163 | # extract only the fields required for analytics 164 | try: 165 | orders_info = fetch_orders_info(orders) 166 | except SwiggyAPIError as e: 167 | raise SwiggyAPIError(e) 168 | 169 | # store the order data in db 170 | try: 171 | db.insert_orders_details(orders_info.get('order_details')) 172 | except SwiggyDBError as e: 173 | print(e) 174 | try: 175 | db.insert_order_items(orders_info.get('order_items')) 176 | except SwiggyDBError as e: 177 | print(e) 178 | 179 | 180 | def fetch_and_store_orders(db): 181 | """ 182 | Fetches all the historical orders for the user and saves them in db 183 | """ 184 | response = session.get(SWIGGY_ORDER_URL) 185 | if not response.json().get('data', None): 186 | raise SwiggyAPIError("Unable to fetch orders") 187 | 188 | # get the last order_id to use as offset param for next order fetch call 189 | orders = response.json().get('data').get('orders', None) 190 | # check if user has zero orders 191 | if isinstance(orders, list) and len(orders)==0: 192 | sys.exit("You have not placed any order, no data to fetch :)") 193 | if not orders: 194 | raise SwiggyAPIError("Unable to fetch orders") 195 | 196 | # extract order meta data and insert in db 197 | insert_orders_data(db, orders) 198 | 199 | offset_id = orders[-1]['order_id'] 200 | count = response.json().get('data')['total_orders'] 201 | pages = ceil(count/10) 202 | label = "Fetching {} orders".format(count) 203 | 204 | # Updates the progress bar on every orders fetch call (i.e. after 10 unique orders) 205 | with ProgressBar(style=PROGRESS_BAR_STYLE, formatters=PROGRESS_BAR_FORMATTER) as pb: 206 | for i in pb(range(pages), label=label): 207 | try: 208 | orders = fetch_orders(offset_id) 209 | except SwiggyAPIError as e: 210 | raise SwiggyAPIError(e) 211 | if len(orders) == 0: 212 | break 213 | 214 | # extract order meta data and insert in db 215 | insert_orders_data(db, orders) 216 | # Responsible Scraping. Code word for "dont wanna overload their servers :P" :) 217 | 218 | time.sleep(SWIGGY_API_CALL_INTERVAL) 219 | # SAD PANDA FACE BEGIN 220 | # The way it works is that, the first API call returns a paginated set of 10 orders and to fetch the next result, you need 221 | # to send the last order_id from this result set as an offset parameter. Because the way this offset/cursor 222 | # is designed it makes it impossible to use any kind of async/await magic. 223 | # SAD PANDA FACE OVER 224 | offset_id = orders[-1]['order_id'] 225 | 226 | 227 | def display_stats(db): 228 | """ 229 | Queries the DB to get basic stats and display 230 | using `print_bars` 231 | """ 232 | print_formatted_text( 233 | HTML("Some basic stats based on your order history:")) 234 | # orders summary sec 235 | try: 236 | orders_count = db.fetch_result(query=get_total_orders_query)[0][0] 237 | except SwiggyDBError as e: 238 | raise("Error while fetching total orders count {}".format(e)) 239 | print_formatted_text(HTML( 240 | '\nYour total delivered orders are: {}\n'.format(orders_count))) 241 | 242 | try: 243 | total_amount = db.fetch_result(query=get_total_amount_query)[0][0] 244 | except SwiggyDBError as e: 245 | raise("Error while fetching total amount {}".format(e)) 246 | print_formatted_text(HTML( 247 | 'You have spent a total sum of {}'.format(format_amount(total_amount)))) 248 | 249 | # spend pattern 250 | print_formatted_text( 251 | HTML('\nYour spend and orders count distributed monthly:')) 252 | try: 253 | items_count_bar_graph = db.fetch_result( 254 | query=get_monthly_spend_count) 255 | except SwiggyDBError as e: 256 | raise("Error while fetching items v/s count {}".format(e)) 257 | print_bars(get_scores([{"name": get_month(i[0]), "count":i[2], "extra":format_amount(i[1])} 258 | for i in items_count_bar_graph])) 259 | 260 | # topK weekdays 261 | print_formatted_text(HTML( 262 | '\nWeekday wise distribution of your orders:')) 263 | try: 264 | weekday_count_bar_graph = db.fetch_result( 265 | query=get_order_count_day_of_week) 266 | except SwiggyDBError as e: 267 | raise("Error while fetching total orders v/s weekday data {}".format(e)) 268 | 269 | print_bars(get_scores([{"name": get_weekday_name(i[0]), "count":i[1]} 270 | for i in weekday_count_bar_graph])) 271 | 272 | # topK restaraunts 273 | print_formatted_text( 274 | HTML('\nTop 10 restaraunts from where you have ordered:')) 275 | try: 276 | items_count_bar_graph = db.fetch_result( 277 | query=get_top_20_restaurants_query) 278 | except SwiggyDBError as e: 279 | raise("Error while fetching items v/s count {}".format(e)) 280 | print_bars(get_scores([{"name": i[0], "count":i[1]} 281 | for i in items_count_bar_graph])) 282 | # topK items 283 | print_formatted_text( 284 | HTML('\nTop 10 items that you have ordered:')) 285 | try: 286 | items_count_bar_graph = db.fetch_result( 287 | query=get_items_name_count_query) 288 | except SwiggyDBError as e: 289 | raise("Error while fetching items v/s count {}".format(e)) 290 | print_bars(get_scores([{"name": i[0], "count":i[1]} 291 | for i in items_count_bar_graph])) 292 | -------------------------------------------------------------------------------- /swiggy_analytics/queries.py: -------------------------------------------------------------------------------- 1 | # create table queries 2 | create_orders_table_query = """ 3 | CREATE TABLE IF NOT EXISTS orders(id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | order_id INT UNIQUE, order_total FLOAT, restaurant_name VARCHAR, 5 | order_time DATETIME, rain_mode BOOLEAN, on_time BOOLEAN) 6 | """ 7 | 8 | create_items_table_query = """ 9 | CREATE TABLE IF NOT EXISTS items(id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INT, 10 | name VARCHAR, is_veg BOOLEAN, 11 | FOREIGN KEY(order_id) REFERENCES orders(order_id)) 12 | """ 13 | 14 | # insert records queries 15 | insert_orders_query = """ 16 | INSERT INTO orders(order_id, order_total, restaurant_name, order_time, rain_mode, on_time) VALUES (?,?,?,?,?,?) 17 | """ 18 | insert_items_query = """ 19 | INSERT INTO items(order_id, name, is_veg) VALUES (?,?,?) 20 | """ 21 | 22 | # analytics queries 23 | get_total_orders_query = """ 24 | SELECT count(order_id) from orders 25 | """ 26 | get_total_amount_query = """ 27 | SELECT sum(order_total) from orders 28 | """ 29 | get_items_name_count_query = """ 30 | SELECT name, count(id) as ctr from items group by name order by ctr desc limit 10 31 | """ 32 | get_top_20_restaurants_query = """ 33 | SELECT "restaurant_name" AS "restaurant_name", count(*) AS "count" 34 | FROM "orders" 35 | GROUP BY "restaurant_name" 36 | ORDER BY "count" DESC LIMIT 10 37 | """ 38 | get_order_count_day_of_week = """ 39 | SELECT CAST((strftime('%w', "order_time")) AS integer) AS "order_time", count(*) AS "count" 40 | FROM "orders" 41 | GROUP BY CAST((strftime('%w', "order_time")) AS integer) 42 | ORDER BY CAST((strftime('%w', "order_time")) AS integer) ASC 43 | """ 44 | get_monthly_spend_count = """ 45 | SELECT date("order_time", 'start of month') AS "order_time", sum("order_total") AS "sum", count(*) AS "count" 46 | FROM "orders" 47 | GROUP BY date("order_time", 'start of month') 48 | ORDER BY date("order_time", 'start of month') ASC 49 | """ 50 | -------------------------------------------------------------------------------- /swiggy_analytics/swiggy_analytics.py: -------------------------------------------------------------------------------- 1 | 2 | #!/usr/bin/python 3 | 4 | """ 5 | swiggy-cli-expense is a utility to fetch your orders 6 | history from Swiggy and provide basic stats on them. 7 | 8 | Author: Karan Sharma, https://mrkaran.dev 9 | 10 | Licensed under the MIT License. 11 | """ 12 | 13 | import argparse 14 | import os 15 | import sys 16 | 17 | from swiggy_analytics.cli import user_continue 18 | from swiggy_analytics.constants import DB_FILEPATH 19 | from swiggy_analytics.db import SwiggyDB 20 | from swiggy_analytics.exceptions import (SwiggyAPIError, SwiggyCliAuthError, 21 | SwiggyCliConfigError) 22 | from swiggy_analytics.helper import (display_stats, fetch_and_store_orders, 23 | initial_setup_prompt, perform_login) 24 | from swiggy_analytics.utils import config_file_present 25 | 26 | 27 | def main(): 28 | parser = argparse.ArgumentParser( 29 | description="Fetch your past swiggy orders " + 30 | "using the command line", 31 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 32 | parser.add_argument('--configure', action='store_true', 33 | help="Configure swiggy-analytics CLI options. On running this command, " + 34 | "you will be prompted for configuration values such as your Swiggy Username " + 35 | "and an OTP sent to your registered mobile number. " + 36 | "(the default location is ~/.aws/config). The AWS CLI will create/overwrite " + 37 | "a configuration file for you, at ~/.swiggy-exepnse-config. \n" + 38 | 39 | "Note: If you are not comfortable sharing your username with the utility, " + 40 | "you can audit the code yourself. This file will only live in your filesystem and " + 41 | "accessible by your username only. " 42 | ) 43 | parser.add_argument('--save', action='store_true', 44 | help="Use this flag if you want to store your orders " + 45 | "in a sqllite db file. After the program has completed the " + 46 | "fetching orders, `swiggy.db` file wile be created in you current working directory. ") 47 | 48 | args = parser.parse_args() 49 | 50 | print('''Welcome to swiggy-analytics. 51 | 52 | This command line tool will help you fetch your order history from https://swiggy.com. 53 | You can choose to persist the detailed order information in a SQLite database or 54 | perform lightweight stats operations using in-memory calculations. 55 | ''') 56 | if not config_file_present() or args.configure: 57 | initial_setup_prompt() 58 | 59 | if os.path.exists(DB_FILEPATH) and user_continue(): 60 | db = SwiggyDB() 61 | # connect to the existing db 62 | db.init_db(persist=True) 63 | # show basic stats by fetching the results from already existing db 64 | display_stats(db) 65 | return None 66 | 67 | db = SwiggyDB() 68 | db.init_db(persist=args.save) 69 | db.create_db() 70 | 71 | try: 72 | perform_login() 73 | except SwiggyCliConfigError: 74 | sys.exit( 75 | "Error reading config file. Please generate a config file using --configure flag.") 76 | except SwiggyCliAuthError: 77 | sys.exit("Login to swiggy failed.") 78 | 79 | try: 80 | orders = fetch_and_store_orders(db) 81 | except SwiggyAPIError: 82 | sys.exit( 83 | "Error fetching orders from Swiggy. " + 84 | "Please check your credentials. " + 85 | "You can use swiggy-analytics --configure to regenerate") 86 | 87 | display_stats(db) 88 | 89 | return None 90 | 91 | 92 | if __name__ == "__main__": 93 | sys.exit(main()) 94 | -------------------------------------------------------------------------------- /swiggy_analytics/utils.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import configparser 3 | import os 4 | from datetime import datetime 5 | 6 | from swiggy_analytics.constants import CONFIG_FILEPATH 7 | 8 | 9 | 10 | def save_config(username): 11 | """ 12 | Saves a config file to the user's specified location. 13 | """ 14 | Config = configparser.RawConfigParser() 15 | with open(CONFIG_FILEPATH, 'w') as config_file: 16 | # add the settings to the structure of the file, and lets write it out... 17 | Config.add_section('Auth') 18 | Config.set('Auth', 'Username', username) 19 | Config.write(config_file) 20 | 21 | 22 | def get_config(): 23 | """ 24 | Returns the user's credentials from the config if it exists. 25 | """ 26 | if not config_file_present(): 27 | raise SwiggyCliConfigError("No config file present") 28 | 29 | Config = configparser.RawConfigParser() 30 | Config.read(CONFIG_FILEPATH) 31 | return Config.get('Auth', 'Username') 32 | 33 | 34 | def config_file_present(): 35 | """ 36 | Checks whether the config file exists or not. 37 | """ 38 | return os.path.exists(os.path.join(CONFIG_FILEPATH)) 39 | 40 | 41 | def normalize(x, xmin, xmax): 42 | """Normalize a number to a 0-1 range given a min and max of its set.""" 43 | return float(x - xmin) / float(xmax - xmin) 44 | 45 | 46 | def get_scores(items): 47 | """Compute normalized scores (0-1) for order count.""" 48 | vals = [i["count"] for i in items] 49 | vals.append(0) 50 | 51 | xmin = min(vals) 52 | xmax = max(vals) 53 | 54 | # Normalize. 55 | for i in items: 56 | i["score"] = normalize(i["count"], xmin, xmax) 57 | 58 | return items 59 | 60 | 61 | def get_weekday_name(int_day): 62 | """ 63 | Returns name of the day based on it's int representation (eg Sunday=0, Monday=1). 64 | """ 65 | return calendar.day_name[int_day-1] 66 | 67 | 68 | def get_month(date): 69 | """ 70 | Converts string representation of date in form of 2018-11-10 as Nov-18 71 | """ 72 | return datetime.strptime(date, '%Y-%m-%d').strftime('%b-%y') 73 | 74 | 75 | def format_amount(data): 76 | """ 77 | Prefixes the amount with the rupee symbol. 78 | """ 79 | return "₹"+str(data) 80 | --------------------------------------------------------------------------------