├── .gitattributes ├── LICENSE ├── README.md ├── VFSBot.py ├── captcha.png ├── config.ini ├── record.txt ├── requirements.txt └── utils.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hnavidan 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 | 2 | # VFS Appointment Bot 3 | This python script automatically checks the available VFS appointments and notifies the earliest opening via Telegram. 4 | 5 | ## Dependencies 6 | Check 'requirements.txt' for Python packages. 7 | The captcha recognition is done using Tesseract OCR, which can be installed from [here](https://github.com/tesseract-ocr/tesseract). 8 | 9 | - Selenium 10 | - Undetected-chromedriver 11 | - OpenCV 12 | - PyTesseract 13 | - Python-telegram-bot 14 | 15 | ## How to use 16 | 1. Clone the repo. 17 | 2. Install the dependencies. 18 | 3. Download the latest Chromedriver from [here](https://chromedriver.chromium.org/). 19 | 4. Move the chromedriver.exe file into the repo directory. 20 | 5. Create a Telegram bot using [BotFather](https://t.me/BotFather) and save the auth token. 21 | 6. Make a Telegram channel to notify the appointment updates. 22 | 7. Use [this bot](https://t.me/username_to_id_bot) to find the channel id and your own account id. 23 | 8. Update the config.ini file with your VFS URL, account info, telegram token, and ids. 24 | 9. Run the script! 25 | 26 | ## Description 27 | This script was initially made for the Belgium visa center. However, it can also be used for other centers around the world. You might have to change the XPATH (available through inspect element) addresses in the check_appointment() function to your desired values. 28 | 29 | ### Captcha 30 | So far, I've used OpenCV along with Tesseract to recognize the captcha. Its accuracy is very low, as sometimes it may take 10-15 tries to enter the correct captcha. However, this will not cause any problems as the whole process is automated, and the script will keep trying until it successfully logs in. 31 | 32 | ### Telegram 33 | The created bot should have two default commands: 34 | 1. /start: Starts the bot. 35 | 2. /quit: Stops the bot. (It can be started again using /start as long as the Python script is running.) 36 | 37 | Next, add the created bot in the channel you want to post updates to and make sure it has admin priviliges. In order to prevent repitition of messages, the script will keep a record of updates in the record.txt file. Furthermore, by specifying your account id as admin_id in the config.ini, you can prevent others from using the bot, which might cause unexpected behaivor. If you want multiple accounts to access the bot, you can enter multiple ids in the config file separated by space. 38 | 39 | ## TODO: 40 | 1. Check multiple countries at the same time. 41 | -------------------------------------------------------------------------------- /VFSBot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import undetected_chromedriver as uc 3 | from utils import * 4 | from selenium.webdriver.support.ui import Select 5 | from selenium.webdriver.common.by import By 6 | from selenium.webdriver.support.ui import WebDriverWait 7 | from selenium.webdriver.support import expected_conditions as EC 8 | from telegram import Update 9 | from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler 10 | from configparser import ConfigParser 11 | 12 | class VFSBot: 13 | def __init__(self): 14 | self.config = ConfigParser() 15 | self.config.read('config.ini') 16 | 17 | self.url = self.config.get('VFS', 'url') 18 | self.email_str = self.config.get('VFS', 'email') 19 | self.pwd_str = self.config.get('VFS', 'password') 20 | self.interval = self.config.getint('VFS', 'interval') 21 | self.channel_id = self.config.get('TELEGRAM', 'channel_id') 22 | token = self.config.get('TELEGRAM', 'auth_token') 23 | admin_ids = list(map(int, self.config.get('TELEGRAM', 'admin_ids').split(" "))) 24 | self.started = False 25 | self.admin_handler = AdminHandler(admin_ids) 26 | 27 | self.app = ApplicationBuilder().token(token).build() 28 | 29 | self.app.add_handler(MessageHandler( 30 | self.admin_handler.filter_admin(), 31 | self.admin_handler.unauthorized_access)) 32 | 33 | self.app.add_handler(CommandHandler("start", self.start)) 34 | self.app.add_handler(CommandHandler("help", self.help)) 35 | self.app.add_handler(CommandHandler("quit", self.quit)) 36 | self.app.add_handler(CommandHandler("setting", self.setting)) 37 | 38 | 39 | self.app.run_polling() 40 | 41 | async def login(self, update: Update, context: CallbackContext): 42 | self.browser.get((self.url)) 43 | 44 | # await asyncio.sleep(500) # For debugging purposes 45 | if "You are now in line." in self.browser.page_source: 46 | update.message.reply_text("You are now in queue.") 47 | 48 | WebDriverWait(self.browser, 600).until(EC.presence_of_element_located((By.NAME, 'EmailId'))) 49 | await asyncio.sleep(1) 50 | 51 | self.browser.find_element(by=By.NAME, value='EmailId').send_keys(self.email_str) 52 | self.browser.find_element(by=By.NAME, value='Password').send_keys(self.pwd_str) 53 | 54 | 55 | #update.message.reply_text("Sending Captcha...") 56 | 57 | captcha_img = self.browser.find_element(by=By.ID, value='CaptchaImage') 58 | 59 | self.captcha_filename = 'captcha.png' 60 | with open(self.captcha_filename, 'wb') as file: 61 | file.write(captcha_img.screenshot_as_png) 62 | 63 | captcha = break_captcha() 64 | 65 | self.browser.find_element(by=By.NAME, value='CaptchaInputText').send_keys(captcha) 66 | await asyncio.sleep(1) 67 | self.browser.find_element(by=By.ID, value='btnSubmit').click() 68 | 69 | if "Reschedule Appointment" in self.browser.page_source: 70 | update.message.reply_text("Successfully logged in!") 71 | while True: 72 | try: 73 | await self.check_appointment(update, context) 74 | except WebError: 75 | update.message.reply_text("An WebError has occured.\nTrying again.") 76 | raise WebError 77 | except Offline: 78 | update.message.reply_text("Downloaded offline version. \nTrying again.") 79 | continue 80 | except Exception as e: 81 | update.message.reply_text("An error has occured: " + e + "\nTrying again.") 82 | raise WebError 83 | await asyncio.sleep(self.interval) 84 | elif "Your account has been locked, please login after 2 minutes." in self.browser.page_source: 85 | update.message.reply_text("Account locked.\nPlease wait 2 minutes.") 86 | await asyncio.sleep(120) 87 | return 88 | elif "The verification words are incorrect." in self.browser.page_source: 89 | #update.message.reply_text("Incorrect captcha. \nTrying again.") 90 | return 91 | elif "You are being rate limited" in self.browser.page_source: 92 | update.message.reply_text("Rate Limited. \nPlease wait 5 minutes.") 93 | await asyncio.sleep(300) 94 | return 95 | else: 96 | update.message.reply_text("An unknown error has occured. \nTrying again.") 97 | #self.browser.find_element(by=By.XPATH, value='//*[@id="logoutForm"]/a').click() 98 | raise WebError 99 | 100 | 101 | async def login_helper(self, update, context): 102 | self.browser = uc.Chrome(options=self.options) 103 | 104 | while True and self.started: 105 | try: 106 | await self.login(update, context) 107 | except Exception as e: 108 | print(e) 109 | continue 110 | 111 | async def help(self, update: Update, context: CallbackContext): 112 | await update.message.reply_text("This is a VFS appointment bot!\nPress /start to begin.") 113 | 114 | async def start(self, update: Update, context: CallbackContext): 115 | self.options = uc.ChromeOptions() 116 | self.options.add_argument('--disable-gpu') 117 | #Uncomment the following line to run headless 118 | #self.options.add_argument('--headless=new') 119 | 120 | if hasattr(self, 'thr') and self.thr is not None: 121 | await update.message.reply_text("Bot is already running.") 122 | return 123 | 124 | self.started = True 125 | self.thr = asyncio.create_task(self.login_helper(update, context)) 126 | await update.message.reply_text("Bot started successfully.") 127 | 128 | 129 | async def quit(self, update: Update, context: CallbackContext): 130 | if not self.started: 131 | await update.message.reply_text("Cannot quit. Bot is not running.") 132 | return 133 | 134 | try: 135 | self.browser.quit() 136 | self.thr = None 137 | self.started = False 138 | await update.message.reply_text("Quit successfully.") 139 | except: 140 | await update.message.reply_text("Quit unsuccessful.") 141 | pass 142 | 143 | async def setting(self, update: Update, context: CallbackContext): 144 | if not context.args or len(context.args) < 3: 145 | await update.message.reply_text("Usage: /setting
\nExample: /setting VFS url https://example.com") 146 | return 147 | 148 | section, key, value = context.args[0], context.args[1], ' '.join(context.args[2:]) 149 | 150 | if not self.config.has_section(section): 151 | await update.message.reply_text(f"Section '{section}' does not exist in the config file.") 152 | return 153 | 154 | if not self.config.has_option(section, key): 155 | await update.message.reply_text(f"Key '{key}' does not exist in section '{section}'.") 156 | return 157 | 158 | # Prevent changing the auth token 159 | if section == 'TELEGRAM' and key == 'auth_token': 160 | await update.message.reply_text("Cannot change the auth token.") 161 | return 162 | 163 | self.config.set(section, key, value) 164 | with open('config.ini', 'w') as configfile: 165 | self.config.write(configfile) 166 | 167 | if section == 'VFS': 168 | if key == 'url': 169 | self.url = value 170 | elif key == 'email': 171 | self.email_str = value 172 | elif key == 'password': 173 | self.pwd_str = value 174 | elif section == 'DEFAULT' and key == 'interval': 175 | self.interval = int(value) 176 | elif section == 'TELEGRAM' and key == 'channel_id': 177 | self.channel_id = value 178 | 179 | await update.message.reply_text(f"Configuration updated: [{section}] {key} = {value}") 180 | 181 | def check_errors(self): 182 | if "Server Error in '/Global-Appointment' Application." in self.browser.page_source: 183 | return True 184 | elif "Cloudflare" in self.browser.page_source: 185 | return True 186 | elif "Sorry, looks like you were going too fast." in self.browser.page_source: 187 | return True 188 | elif "Session expired." in self.browser.page_source: 189 | return True 190 | elif "Sorry, looks like you were going too fast." in self.browser.page_source: 191 | return True 192 | elif "Sorry, Something has gone" in self.browser.page_source: 193 | return True 194 | 195 | def check_offline(self): 196 | if "offline" in self.browser.page_source: 197 | return True 198 | 199 | async def check_appointment(self, update, context): 200 | await asyncio.sleep(5) 201 | 202 | self.browser.find_element(by=By.XPATH, 203 | value='//*[@id="Accordion1"]/div/div[2]/div/ul/li[1]/a').click() 204 | if self.check_errors(): 205 | raise WebError 206 | if self.check_offline(): 207 | raise Offline 208 | 209 | WebDriverWait(self.browser, 100).until(EC.presence_of_element_located(( 210 | By.XPATH, '//*[@id="LocationId"]'))) 211 | 212 | self.browser.find_element(by=By.XPATH, value='//*[@id="LocationId"]').click() 213 | if self.check_errors(): 214 | raise WebError 215 | await asyncio.sleep(3) 216 | 217 | 218 | self.browser.find_element(by=By.XPATH, value='//*[@id="LocationId"]/option[2]').click() 219 | if self.check_errors(): 220 | raise WebError 221 | 222 | await asyncio.sleep(3) 223 | 224 | 225 | if "There are no open seats available for selected center - Belgium Long Term Visa Application Center-Tehran" in self.browser.page_source: 226 | #update.message.reply_text("There are no appointments available.") 227 | records = open("record.txt", "r+") 228 | last_date = records.readlines()[-1] 229 | 230 | if last_date != '0': 231 | await context.bot.send_message(chat_id=self.channel_id, 232 | text="There are no appointments available right now.") 233 | records.write('\n' + '0') 234 | records.close 235 | else: 236 | select = Select(self.browser.find_element(by=By.XPATH, value='//*[@id="VisaCategoryId"]')) 237 | select.select_by_value('1314') 238 | 239 | WebDriverWait(self.browser, 100).until(EC.presence_of_element_located(( 240 | By.XPATH, '//*[@id="dvEarliestDateLnk"]'))) 241 | 242 | await asyncio.sleep(2) 243 | new_date = self.browser.find_element(by=By.XPATH, 244 | value='//*[@id="lblDate"]').get_attribute('innerHTML') 245 | 246 | records = open("record.txt", "r+") 247 | last_date = records.readlines()[-1] 248 | 249 | if new_date != last_date and len(new_date) > 0: 250 | await context.bot.send_message(chat_id=self.channel_id, 251 | text=f"Appointment available on {new_date}.") 252 | records.write('\n' + new_date) 253 | records.close() 254 | #Uncomment if you want the bot to notify everytime it checks appointments. 255 | #update.message.reply_text("Checked!", disable_notification=True) 256 | return True 257 | 258 | if __name__ == '__main__': 259 | VFSbot = VFSBot() -------------------------------------------------------------------------------- /captcha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hnavidan/VFSBot/55fdb85d692e6f4ed4da60b97009679b3941f1b5/captcha.png -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [VFS] 2 | url = https://online.vfsglobal.com/Global-Appointment/Account/RegisteredLogin?q=shSA0YnE4pLF9Xzwon/x/DkngnsuyV06lOAvm8bxUnGJJuvKsIkLE4ykj+VjhbSUR8Hopef3ZGxHhSzt9qb9qQ== 3 | email = 4 | password = 5 | interval = 600 6 | 7 | [TELEGRAM] 8 | auth_token = 9 | channel_id = 10 | admin_ids = -------------------------------------------------------------------------------- /record.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | selenium==4.9.0 2 | opencv-python==4.7.0.72 3 | pytesseract==0.3.10 4 | python-telegram-bot==21.7 5 | undetected-chromedriver==3.4.6 6 | numpy==1.24.0 7 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import re 3 | import pytesseract 4 | import telegram 5 | import numpy as np 6 | from telegram import Update 7 | from telegram.ext import filters, CallbackContext 8 | 9 | pytesseract.pytesseract.tesseract_cmd = 'C:/Program Files/Tesseract-OCR/tesseract.exe' 10 | 11 | class WebError(Exception): 12 | pass 13 | 14 | class Offline(Exception): 15 | pass 16 | 17 | class AdminHandler: 18 | def __init__(self, admin_ids): 19 | self.admin_ids = admin_ids 20 | 21 | async def unauthorized_access(self, update: Update, context: CallbackContext): 22 | await update.message.reply_text('Unauthorized access!') 23 | 24 | def filter_admin(self): 25 | return ~filters.User(user_id=self.admin_ids) 26 | 27 | def break_captcha(): 28 | image = cv2.imread("captcha.png") 29 | image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) 30 | image = cv2.copyMakeBorder(image, 5, 5, 5, 5, cv2.BORDER_CONSTANT, value=[250]) 31 | image = cv2.filter2D(image, -1, np.ones((4, 4), np.float32) / 16) 32 | 33 | se = cv2.getStructuringElement(cv2.MORPH_RECT, (8,8)) 34 | bg = cv2.morphologyEx(image, cv2.MORPH_DILATE, se) 35 | image = cv2.divide(image, bg, scale=255) 36 | image = cv2.filter2D(image, -1, np.ones((3, 4), np.float32) / 12) 37 | image = cv2.threshold(image, 0, 255, cv2.THRESH_OTSU)[1] 38 | 39 | image = cv2.copyMakeBorder(image, 5, 5, 5, 5, cv2.BORDER_CONSTANT, value=[250]) 40 | 41 | captcha = pytesseract.image_to_string(image, config='--psm 13 -c tessedit_char_whitelist=ABCDEFGHIJKLMNPQRSTUVWYZ') 42 | denoised_captcha = re.sub('[\W_]+', '', captcha).strip() 43 | 44 | return denoised_captcha 45 | --------------------------------------------------------------------------------