├── config.sample.py ├── LICENSE ├── README.md └── toogoodtogo.py /config.sample.py: -------------------------------------------------------------------------------- 1 | # 2 | # watcher user configuration 3 | # 4 | config = { 5 | # your too good to go credentials 6 | 'email': 'too-good-to-go-email', 7 | 'password': 'too-good-to-go-password', 8 | 9 | # your location preference (for distance) 10 | 'latitude': 50.632905, 11 | 'longitude': 5.568583, 12 | 13 | # default waiting time 14 | 'normal-wait-from': 20, # min 20 seconds 15 | 'normal-wait-to': 50, # max 50 seconds 16 | 17 | # speed-up time range 18 | 'speedup-time-from': 1900, # 19;30 19 | 'speedup-time-to': 2030, # 20:30 20 | 'speedup-wait-from': 10, # min 10 seconds 21 | 'speedup-wait-to': 20, # max 20 seconds 22 | 23 | # pause the script during the night 24 | # pause at 21h for 9h long (21h00 > 6h00) 25 | 'pause-from': 2100, # 21:00 26 | 'pause-for': 9 * 60, # 9h (in minutes) 27 | 28 | # telegram bot 29 | 'telegram-token': '', 30 | 'telegram-chat-id': '', 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Maxime Daniel 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TooGoodToGo Watcher 2 | 3 | Python watcher for TooGoodToGo. 4 | 5 | # Features 6 | 7 | - Few dependencies (`requests`, `python-telegram-bot`) 8 | - Fetching updates with random time update 9 | - Night mode which prevent fetching during the night 10 | - Speedup mode which fetch more quickly for specific time range 11 | - Send notifications using Telegram Bot 12 | - Console listing 13 | 14 | # Setup 15 | 16 | ## Configure your account 17 | 18 | Create a new account dedicated, login with this account on your phone. Setup your favorites merchant on the app. 19 | 20 | ## Configure the watcher 21 | 22 | Copy the `config.sample.py` to `config.py` and update: 23 | - Set your credentials 24 | - Set coordinates for distance computing 25 | - Normal wait time are random range for default fetch 26 | - Speedup time and wait defines time range and wait range for speedup period 27 | - Pause fields set night mode which disable fetch during night 28 | - Telegram token and chatid for notification 29 | 30 | # Telegram Bot 31 | 32 | Setup your Telegram Bot by talking to `@BotFather`, then set your token on the config. 33 | Create a group and join it, joins your bot on this group. Then vists `https://api.telegram.org/bot/getUpdates` to get your `chat_id`. 34 | 35 | # Source 36 | Based on [marklagendijk/node-toogoodtogo-watcher](https://github.com/marklagendijk/node-toogoodtogo-watcher) version 37 | but written in python with few depencencies and extra features. 38 | -------------------------------------------------------------------------------- /toogoodtogo.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import requests 5 | import smtplib 6 | import random 7 | import time 8 | import datetime 9 | import telegram 10 | import base64 11 | from config import config 12 | 13 | class TooGoodToGo: 14 | def __init__(self): 15 | self.home = os.path.expanduser("~") 16 | self.cfgfile = "%s/.config/tgtgw/config.json" % self.home 17 | 18 | # default values 19 | self.config = { 20 | 'email': config['email'], 21 | 'password': config['password'], 22 | 'accesstoken': None, 23 | 'refreshtoken': None, 24 | 'userid': "", 25 | } 26 | 27 | self.availables = {} 28 | self.baseurl = 'https://apptoogoodtogo.com' 29 | self.session = requests.session() 30 | 31 | self.colors = { 32 | 'red': "\033[31;1m", 33 | 'green': "\033[32;1m", 34 | 'nc': "\033[0m", 35 | } 36 | 37 | self.bot = telegram.Bot(token=config['telegram-token']) 38 | 39 | # load configuration if exists 40 | self.load() 41 | 42 | # load configuration 43 | def load(self): 44 | if not os.path.exists(self.cfgfile): 45 | return False 46 | 47 | print("[+] loading configuration: %s" % self.cfgfile) 48 | with open(self.cfgfile, "r") as f: 49 | data = f.read() 50 | 51 | self.config = json.loads(data) 52 | 53 | print("[+] access token: %s" % self.config['accesstoken']) 54 | print("[+] refresh token: %s" % self.config['refreshtoken']) 55 | print("[+] user id: %s" % self.config['userid']) 56 | 57 | # save configuration 58 | def save(self): 59 | basepath = os.path.dirname(self.cfgfile) 60 | print("[+] configuration directory: %s" % basepath) 61 | 62 | if not os.path.exists(basepath): 63 | os.makedirs(basepath) 64 | 65 | with open(self.cfgfile, "w") as f: 66 | print("[+] writing configuration: %s" % self.cfgfile) 67 | f.write(json.dumps(self.config)) 68 | 69 | def isauthorized(self, payload): 70 | if not payload.get("error"): 71 | return True 72 | 73 | if payload['error'] == 'Unauthorized': 74 | print("[-] request: unauthorized request") 75 | return False 76 | 77 | return None 78 | 79 | def url(self, endpoint): 80 | return "%s%s" % (self.baseurl, endpoint) 81 | 82 | def post(self, endpoint, json): 83 | headers = { 84 | 'User-Agent': 'TooGoodToGo/20.1.1 (732) (iPhone/iPhone SE (GSM); iOS 13.3.1; Scale/2.00)', 85 | 'Accept': "application/json", 86 | 'Accept-Language': "en-US" 87 | } 88 | 89 | if self.config['accesstoken']: 90 | headers['Authorization'] = "Bearer %s" % self.config['accesstoken'] 91 | 92 | return self.session.post(self.url(endpoint), headers=headers, json=json) 93 | 94 | def login(self): 95 | login = { 96 | 'device_type': "UNKNOWN", 97 | 'email': self.config['email'], 98 | 'password': self.config['password'] 99 | } 100 | 101 | # disable access token to request a new one 102 | self.config['accesstoken'] = None 103 | 104 | print("[+] authentication: login using <%s> email" % login['email']) 105 | 106 | r = self.post("/api/auth/v1/loginByEmail", login) 107 | data = r.json() 108 | 109 | if self.isauthorized(data) == False: 110 | print("[-] authentication: login failed, unauthorized") 111 | self.rawnotifier("Could not authenticate watcher, stopping.") 112 | sys.exit(1) 113 | 114 | self.config['accesstoken'] = data['access_token'] 115 | self.config['refreshtoken'] = data['refresh_token'] 116 | self.config['userid'] = data['startup_data']['user']['user_id'] 117 | 118 | return True 119 | 120 | def refresh(self): 121 | data = {'refresh_token': self.config['refreshtoken']} 122 | ref = self.post('/api/auth/v1/token/refresh', data) 123 | 124 | payload = ref.json() 125 | if self.isauthorized(payload) == False: 126 | print("[-] authentication: refresh failed, re-loggin") 127 | return self.login() 128 | 129 | self.config['accesstoken'] = payload['access_token'] 130 | 131 | print("[+] new token: %s" % self.config['accesstoken']) 132 | 133 | return True 134 | 135 | def favorite(self): 136 | data = { 137 | 'favorites_only': True, 138 | 'origin': { 139 | 'latitude': config['latitude'], 140 | 'longitude': config['longitude'] 141 | }, 142 | 'radius': 200, 143 | 'user_id': self.config['userid'], 144 | 'page': 1, 145 | 'page_size': 20 146 | } 147 | 148 | while True: 149 | try: 150 | r = self.post("/api/item/v5/", data) 151 | if r.status_code >= 500: 152 | continue 153 | 154 | if r.status_code == 200: 155 | return r.json() 156 | 157 | except Exception as e: 158 | print(e) 159 | 160 | time.sleep(1) 161 | 162 | 163 | def datetimeparse(self, datestr): 164 | fmt = "%Y-%m-%dT%H:%M:%SZ" 165 | value = datetime.datetime.strptime(datestr, fmt) 166 | return value.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None) 167 | 168 | def issameday(self, d1, d2): 169 | return (d1.day == d2.day and d1.month == d2.month and d1.year == d2.year) 170 | 171 | def pickupdate(self, item): 172 | now = datetime.datetime.now() 173 | pfrom = self.datetimeparse(item['pickup_interval']['start']) 174 | pto = self.datetimeparse(item['pickup_interval']['end']) 175 | 176 | prange = "%02d:%02d - %02d:%02d" % (pfrom.hour, pfrom.minute, pto.hour, pto.minute) 177 | 178 | if self.issameday(pfrom, now): 179 | return "Today, %s" % prange 180 | 181 | return "%d/%d, %s" % (pfrom.day, pfrom.month, prange) 182 | 183 | 184 | def available(self, items): 185 | for item in items['items']: 186 | name = item['display_name'] 187 | price = item['item']['price']['minor_units'] / 100 188 | value = item['item']['value']['minor_units'] / 100 189 | color = "green" if item['items_available'] > 0 else "red" 190 | kname = "%s-%.2d" % (name, price) 191 | 192 | print("[+] merchant: %s%s%s" % (self.colors[color], name, self.colors['nc'])) 193 | 194 | if item['items_available'] == 0: 195 | if self.availables.get(kname): 196 | del self.availables[kname] 197 | 198 | continue 199 | 200 | print("[+] distance: %.2f km" % item['distance']) 201 | print("[+] available: %d" % item['items_available']) 202 | print("[+] price: %.2f € [%.2f €]" % (price, value)) 203 | print("[+] address: %s" % item['pickup_location']['address']['address_line']) 204 | print("[+] pickup: %s" % self.pickupdate(item)) 205 | 206 | if not self.availables.get(kname): 207 | print("[+]") 208 | print("[+] == NEW ITEMS AVAILABLE ==") 209 | self.notifier(item) 210 | self.availables[kname] = True 211 | 212 | 213 | print("[+]") 214 | 215 | # 216 | # STAGING BASKET / CHECKOUT 217 | # 218 | def basket(self, itemid): 219 | payload = { 220 | "supported_payment_providers": [ 221 | { 222 | "payment_provider": { 223 | "provider_id": "VOUCHER", 224 | "provider_version": 1 225 | }, 226 | "payment_types": [ 227 | "VOUCHER" 228 | ] 229 | }, 230 | { 231 | "payment_provider": { 232 | "provider_id": "ADYEN", 233 | "provider_version": 1 234 | }, 235 | "payment_types": [ 236 | "CREDITCARD", 237 | "PAYPAL", 238 | "IDEAL", 239 | "SOFORT", 240 | "VIPPS", 241 | "BCMCMOBILE", 242 | "DOTPAY", 243 | "APPLEPAY" 244 | ] 245 | }, 246 | { 247 | "payment_provider": { 248 | "provider_id": "PAYPAL", 249 | "provider_version": 1 250 | }, 251 | "payment_types": [ 252 | "PAYPAL" 253 | ] 254 | } 255 | ], 256 | "user_id": self.config['userid'] 257 | } 258 | 259 | r = self.post("/api/item/v4/%s/basket" % itemid, payload) 260 | data = r.json() 261 | 262 | if data['create_basket_state'] == 'SUCCESS': 263 | basketid = data['basket_id'] 264 | print("[+] basket created: %s" % basketid) 265 | 266 | self.checkout(basketid) 267 | 268 | pass 269 | 270 | def checkout(self, basketid): 271 | now = datetime.datetime.now().replace(microsecond=0).isoformat() + "Z" 272 | 273 | paymentsdk = { 274 | "locale": "en_BE", 275 | "deviceIdentifier": "", 276 | "platform": "ios", 277 | "osVersion": "13.3.1", 278 | "integration": "quick", 279 | "sdkVersion": "2.8.5", 280 | "deviceFingerprintVersion": "1.0", 281 | "generationTime": now, 282 | "deviceModel": "iPhone8,4" 283 | } 284 | 285 | sdkkey = json.dumps(paymentsdk) 286 | 287 | payload = { 288 | "items_count": 1, 289 | "payment_provider": { 290 | "provider_id": "ADYEN", 291 | "provider_version": 1 292 | }, 293 | "payment_sdk_key": base64.b64encode(sdkkey.encode('utf-8')), 294 | "payment_types": [ 295 | "CREDITCARD", 296 | "APPLEPAY", 297 | "BCMCMOBILE", 298 | "PAYPAL" 299 | ], 300 | "return_url": "toogoodtogoapp://" 301 | } 302 | 303 | print(payload) 304 | 305 | r = self.post("/api/basket/v2/%s/checkout" % basketid, payload) 306 | data = r.json() 307 | 308 | print(data) 309 | 310 | if data['result'] == 'CONTINUE_PAYMENT': 311 | print("OK OK") 312 | 313 | pass 314 | 315 | def debug(self): 316 | self.basket("43351i2634099") 317 | print("debug") 318 | 319 | # 320 | # 321 | # 322 | 323 | def rawnotifier(self, message): 324 | fmt = telegram.ParseMode.MARKDOWN 325 | self.bot.send_message(chat_id=config['telegram-chat-id'], text=message, parse_mode=fmt) 326 | 327 | def notifier(self, item): 328 | name = item['display_name'] 329 | items = item['items_available'] 330 | price = item['item']['price']['minor_units'] / 100 331 | pickup = self.pickupdate(item) 332 | 333 | fmt = telegram.ParseMode.MARKDOWN 334 | message = "*%s*\n*Available*: %d\n*Price*: %.2f €\n*Pickup*: %s" % (name, items, price, pickup) 335 | 336 | self.bot.send_message(chat_id=config['telegram-chat-id'], text=message, parse_mode=fmt) 337 | 338 | def daytime(self): 339 | now = datetime.datetime.now() 340 | nowint = (now.hour * 100) + now.minute 341 | return nowint 342 | 343 | def watch(self): 344 | if self.config['accesstoken'] is None: 345 | self.login() 346 | self.save() 347 | 348 | while True: 349 | fav = self.favorite() 350 | if self.isauthorized(fav) == False: 351 | print("[-] favorites: unauthorized request, refreshing token") 352 | self.refresh() 353 | continue 354 | 355 | self.available(fav) 356 | 357 | # 358 | # night pause 359 | # 360 | now = self.daytime() 361 | 362 | if now >= config['night-pause-from'] or now <= config['night-pause-to']: 363 | print("[+] night mode enabled, fetching disabled") 364 | 365 | while now >= config['night-pause-from'] or now <= config['night-pause-to']: 366 | now = self.daytime() 367 | time.sleep(60) 368 | 369 | print("[+] starting new day") 370 | 371 | # 372 | # speedup or normal waiting time 373 | # 374 | waitfrom = config['normal-wait-from'] 375 | waitto = config['normal-wait-to'] 376 | 377 | if now >= config['speedup-time-from'] and now <= config['speedup-time-to']: 378 | print("[+] speedup time range enabled") 379 | waitfrom = config['speedup-wait-from'] 380 | waitto = config['speedup-wait-to'] 381 | 382 | # 383 | # next iteration 384 | # 385 | wait = random.randrange(waitfrom, waitto) 386 | print("[+] waiting %d seconds" % wait) 387 | time.sleep(wait) 388 | 389 | self.save() 390 | 391 | if __name__ == '__main__': 392 | tgtg = TooGoodToGo() 393 | # tgtg.debug() 394 | tgtg.watch() 395 | --------------------------------------------------------------------------------