// Copyright (c) 2024, snegov // All rights reserved. const pathnameRegex = /^\/\w{2}-\w{2}\/n?iv/; const MAX_SIGNIN_ATTEMPTS = 3; const PAGE_WAIT_TIME = 3792; const MINUTE = 60; const RANDOM_JITTER = 0.1; const SOFT_BAN_TIMEOUT = 27; const NOTIF_CHANNEL = "snegov_test" let cfg = { activate: undefined, username: undefined, password: undefined, frequency: undefined, consulates: undefined, deltaAppt: undefined, deltaNow: undefined, }; let ctx = { apptId: undefined, currentAppt: { consulate: undefined, date: undefined, }, signinAttempts: undefined, consulates: undefined, statusMsg: undefined, } let isRunning = false; let msg = ""; let isFoundAppointment = false; async function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function sendNotification(message, channel = NOTIF_CHANNEL) { const msg = "[US visa bot] " + message await fetch('https://ntfy.sh/' + channel, { method: 'POST', body: msg, }) .then(() => console.log('Notification sent: ' + msg)) .catch(e => console.error(e)); } function getRandomInt(max) { return Math.floor(Math.random() * Math.floor(max)); } function getJitter(frequency) { return frequency * MINUTE * RANDOM_JITTER; } function getFutureDate(minutes, maxRandomSeconds = 0) { // return date some amount of minutes in future plus random amount of seconds let futureDate = new Date(); futureDate.setSeconds(futureDate.getSeconds() + minutes * MINUTE + getRandomInt(maxRandomSeconds)); return futureDate.toISOString(); } function hiddenPassword(cfg) { return { ...cfg, password: cfg.password.replace(/./g, "*"), }; } function diffObjects(obj1, obj2) { let diff = {}; for (let key in obj1) { if (typeof obj1[key] === 'object' && obj1[key] !== null && obj2[key]) { let nestedDiff = diffObjects(obj1[key], obj2[key]); if (Object.keys(nestedDiff).length > 0) { diff[key] = nestedDiff; } } else if (obj2[key] && obj1[key] !== obj2[key]) { diff[key] = [obj1[key], obj2[key]]; } } for (let key in obj2) { if (obj1[key] === undefined && obj2[key] !== undefined) { diff[key] = [undefined, obj2[key]]; } } return diff; } function hasAllKeys(obj, keys) { return keys.every(k => obj.hasOwnProperty(k)); } function ensureRequiredConsulateProperties(consCtx, consCfg, frequency = 1) { let reqCfgKeys = ["isSelected", "autoBook"]; let reqStateKeys = ["id", "bestDate", "currentDate", "nextCheckAt"]; for (let c in consCtx) { if (!hasAllKeys(consCtx[c], reqStateKeys)) { consCtx[c] = { "id": consCtx[c]?.id || null, "bestDate": consCtx[c]?.bestDate || null, "currentDate": consCtx[c]?.currentDate || null, "nextCheckAt": consCtx[c]?.nextCheckAt || getFutureDate(0, frequency * MINUTE), }; } if (!consCfg[c] || !hasAllKeys(consCfg[c], reqCfgKeys)) { consCfg[c] = { "isSelected": consCfg[c]?.isSelected || false, "autoBook": consCfg[c]?.autoBook || false, }; } } return [consCtx, consCfg]; } function getPathnameParts(pathname) { let pathParts = pathname.split('/'); let locale = pathParts[1] || 'en-us'; let visaType = pathParts[2] || 'niv'; return [ locale, visaType ]; } function isSignInPage() { return Boolean(window.location.pathname.match(/^\/\w{2}-\w{2}\/n?iv\/users\/sign_in/)); } function isLoggedOutPage() { return Boolean(window.location.pathname.match(/^\/\w{2}-\w{2}\/n?iv$/)); } function isDashboardPage() { return Boolean(window.location.pathname.match(/^\/\w{2}-\w{2}\/n?iv\/groups\/\d+/)); } function isAppointmentPage() { return Boolean(window.location.pathname.match(/^\/\w{2}-\w{2}\/n?iv\/schedule\/\d+\/appointment$/)); } function isConfirmationPage() { return Boolean(window.location.pathname.match(/^\/\w{2}-\w{2}\/n?iv\/schedule\/\d+\/appointment\/instructions$/)); } function isNotEnglishPage() { return Boolean(isSignInPage() || isLoggedOutPage() || isDashboardPage() || isAppointmentPage() || isConfirmationPage() ) && !window.location.pathname.match(/^\/en-/); } async function switchToEnglishPage() { window.location.href(window.location.pathname.replace(/^\/\w{2}-{2}/, '/en-us')); await delay(PAGE_WAIT_TIME); // Should be on English page } async function logOut() { let [locale, visaType] = getPathnameParts(window.location.pathname); let signOutPath = `/${locale}/${visaType}/users/sign_out`; window.location.href = window.location.origin + signOutPath; await delay(PAGE_WAIT_TIME); return isLoggedOutPage(); } async function goToSignInPage() { let [locale, visaType] = getPathnameParts(window.location.pathname); let signInPath = `/${locale}/${visaType}/users/sign_in`; window.location.href = window.location.origin + signInPath; await delay(PAGE_WAIT_TIME); return isSignInPage(); } async function goToDashboardPage() { let [locale, visaType] = getPathnameParts(window.location.pathname); let dashboardPath = `/${locale}/${visaType}/account`; window.location.href = window.location.origin + dashboardPath; await delay(PAGE_WAIT_TIME); return isDashboardPage(); } async function enterCredentials(username, password) { // close warning modal let modalElement = Array.from(document.querySelectorAll('div')) .find(d => d.textContent.includes('You need to sign in or sign up before continuing.')); if (modalElement && window.getComputedStyle(modalElement).display !== 'none') { let buttons = Array.from(modalElement.querySelectorAll('button')); let okButton = buttons.find(b => b.textContent === 'OK'); if (okButton) { okButton.click(); await delay(PAGE_WAIT_TIME); } } document.getElementById("user_email").value = username; document.getElementById("user_password").value = password; let policyConfirmed = document.querySelector('[for="policy_confirmed"]'); if (!policyConfirmed.getElementsByTagName('input')[0].checked) { policyConfirmed.click(); } document.querySelector("#sign_in_form input[type=submit]").click(); await delay(PAGE_WAIT_TIME); return isDashboardPage(); } async function getAppointmentId() { let appointments = document.querySelectorAll("p.consular-appt [href]") if (appointments.length > 1) { console.log("Multiple appointments found, taking the first one"); } let apptId = appointments[0].href.replace(/\D/g, ""); return apptId; } async function getConsulates() { let consulatesSelect = document.querySelector("#appointments_consulate_appointment_facility_id") let consulatesDict = {}; for (let option of consulatesSelect.options) { if (!option.value) continue; // skip empty option consulatesDict[option.text] = { "id": parseInt(option.value), }; } return consulatesDict; } async function getAvailableDates(consulateId) { const datesUri = window.location.pathname + `/days/${consulateId}.json?appointments[expedite]=false` try { const response = await fetch(datesUri, { headers: { "x-requested-with": "XMLHttpRequest", "accept": "application/json, text/javascript, */*; q=0.01", "cache-control": "no-cache", }}); if (!response.ok) { if (response.status === 401) { await sendNotification('Logged out due to 401 error'); await logOut(); } const body = await response.text(); throw new Error(`HTTP error ${response.status}: ${body}`); } const data = await response.json(); const dateList = data.map(item => item.date); dateList.sort(); return dateList; } catch (e) { console.error('Error:', e); return null; } } async function filterDates(dates, currentAppt, deltaFromAppt, deltaFromNow) { let maxDate = new Date(currentAppt); maxDate.setDate(maxDate.getDate() - deltaFromAppt); let minDate = new Date(); minDate.setDate(minDate.getDate() + deltaFromNow); let availableDates = dates.filter(d => new Date(d) >= minDate && new Date(d) < maxDate); return availableDates; } async function getAvailableTimes(consulateId, date) { let uri = window.location.pathname + `/times/${consulateId}.json?date=${date}&appointments[expedite]=false` let times = fetch(uri, { headers: { "x-requested-with": "XMLHttpRequest" } }) .then(d => d.json()) .then(data => data.available_times) .catch(e => null); return times; } async function runner() { if (isRunning) { return; } isRunning = true; let prev_cfg = Object.assign({}, cfg); let prev_ctx = Object.assign({}, ctx); let result = await new Promise(resolve => chrome.storage.local.get(null, resolve)); cfg.activate = result['cfg_activate'] || false; cfg.username = result['cfg_username'] || ""; cfg.password = result['cfg_password'] || ""; cfg.frequency = parseFloat(result['cfg_frequency'] || 1); cfg.consulates = result['cfg_consulates'] || {}; cfg.deltaAppt = result['cfg_deltaAppt'] || 1; cfg.deltaNow = result['cfg_deltaNow'] || 1; ctx.apptId = result['ctx_apptId'] || null; ctx.signinAttempts = result['ctx_signinAttempts'] || 0; ctx.currentAppt = result['ctx_currentAppt'] || { consulate: null, date: null }; ctx.consulates = result['ctx_consulates'] || {}; ctx.statusMsg = result['ctx_statusMsg'] || ""; [ctx.consulates, cfg.consulates] = ensureRequiredConsulateProperties( ctx.consulates, cfg.consulates, cfg.frequency ); await chrome.storage.local.set({ "cfg_consulates": cfg.consulates }); await chrome.storage.local.set({ "ctx_consulates": ctx.consulates }); if (prev_cfg.activate === undefined) { console.log('Reading config: ' + JSON.stringify(hiddenPassword(cfg))); isRunning = false; return; } for (let key in cfg) { if (cfg.hasOwnProperty(key) && !_.isEqual(cfg[key], prev_cfg[key])) { msg = `Config change: ${key}` if (key === 'password') { msg += ', ******** => ********'; } else if (key === 'consulates') { msg += `, ${JSON.stringify(diffObjects(cfg[key], prev_cfg[key]))}`; } else { msg += `, ${prev_cfg[key]} => ${cfg[key]}`; } console.log(msg); // reduce wait times for consulates if frequency is increased if (key === 'frequency') { let wasChanged = false; for (let c in ctx.consulates) { let newNextCheckAt = getFutureDate(cfg.frequency, getJitter(cfg.frequency)); if (ctx.consulates[c].nextCheckAt > newNextCheckAt) { console.log(`Reducing wait time for ${c} from ${ctx.consulates[c].nextCheckAt} to ${newNextCheckAt}`); ctx.consulates[c].nextCheckAt = newNextCheckAt; wasChanged = true; } } // TODO maybe causes additional requests if (wasChanged) { await chrome.storage.local.set({ "ctx_consulates": ctx.consulates }); } } if (key === 'activate') { if (cfg[key]) { console.log('Activating extension'); } else { console.log('Deactivating extension'); await chrome.storage.local.set({ "ctx_statusMsg": "inactive" }); } } // clear signin attempts when credentials are changed if (key === 'username' && cfg[key] || key === 'password' && cfg[key]) { ctx.signinAttempts = 0; await chrome.storage.local.set({ "ctx_signinAttempts": ctx.signinAttempts }); } } } if (!cfg.activate) { isRunning = false; return; } if (cfg.username === "" || cfg.password === "") { console.log('Username or password is empty'); await chrome.storage.local.set({ "ctx_statusMsg": "missing credentials" }); isRunning = false; return; } if (cfg.frequency <= 0) { console.log('Frequency is 0 or negative'); await chrome.storage.local.set({ "ctx_statusMsg": "invalid frequency" }); isRunning = false; return; } if (isFoundAppointment) { // don't do anything if appointment is found and manual booking is required if (isAppointmentPage()) { isRunning = false; return; // we're not on an appointment page, so seems like we're done, and we can check dates again } else { isFoundAppointment = false; } } if (isNotEnglishPage()) { await switchToEnglishPage(); } if (isLoggedOutPage()) { if (!await goToSignInPage()) { msg = 'Failed to go to sign in page'; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); isRunning = false; return; }; } else if (isSignInPage()) { // Prevent brute forcing if (ctx.signinAttempts >= MAX_SIGNIN_ATTEMPTS) { if (prev_ctx.signinAttempts < MAX_SIGNIN_ATTEMPTS) { msg = 'Too many sign in attempts'; console.log(msg); await sendNotification(msg); } await chrome.storage.local.set({ "ctx_statusMsg": "too many sign in attempts" }); isRunning = false; return; } // Sign in msg = 'Signing in attempt: ' + ctx.signinAttempts; console.log(msg) await chrome.storage.local.set({ "ctx_statusMsg": msg }); let signedIn = await enterCredentials(cfg.username, cfg.password); ctx.signinAttempts += 1; await chrome.storage.local.set({ "ctx_signinAttempts": ctx.signinAttempts }); if (!signedIn) { msg = 'Failed to sign in'; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); isRunning = false; return; }; } else if (isDashboardPage()) { // reset signin attempts when successfully logged in ctx.signinAttempts = 0; await chrome.storage.local.set({ "ctx_statusMsg": "fetching appointment info" }); await chrome.storage.local.set({ "ctx_signinAttempts": ctx.signinAttempts }); // get appointmentId if (!ctx.apptId) { ctx.apptId = await getAppointmentId(); if (ctx.apptId) { console.log(`Appointment ID: ${ctx.apptId}`); await chrome.storage.local.set({ "ctx_apptId": ctx.apptId }); } else { msg = 'No appointments found'; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); isRunning = false; return; } } // get current appointment date let apptInfoCard = document.querySelector("p.consular-appt [href*='" + ctx.apptId + "']").parentNode.parentNode.parentNode if (!apptInfoCard.querySelector("h4").innerText.match(/Attend Appointment/)) { msg = 'Appointment not available'; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); isRunning = false; return; } let apptInfo = apptInfoCard.querySelector("p.consular-appt").innerText; let apptConsulate = (apptInfo.match(/at (\w+)/) || [])[1]; let apptDate = new Date(apptInfo.match(/\d{1,2} \w+, \d{4}/)[0]); apptDate = apptDate.toISOString().slice(0, 10); if (!apptDate || !apptConsulate) { msg = 'Failed to fetch appointment date or consulate'; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); isRunning = false; return; } if (apptDate != ctx.currentAppt.date || apptConsulate != ctx.currentAppt.consulate) { console.log(`New appointment date: ${apptDate} at ${apptConsulate}, old: ${ctx.currentAppt.consulate} at ${ctx.currentAppt.date}`); ctx.currentAppt = { consulate: apptConsulate, date: apptDate }; await chrome.storage.local.set({ "ctx_currentAppt": ctx.currentAppt }); } // go to appointment page let apptLink = apptInfoCard.querySelector("p.consular-appt [href]") .getAttribute("href").replace("/addresses/consulate", "/appointment"); window.location.href = apptLink; await delay(PAGE_WAIT_TIME); } else if (isAppointmentPage()) { // if no apptDate, fetch it from dashboard page if (!ctx.currentAppt.date) { console.log('No appointment date is set, going back to dashboard'); await goToDashboardPage(); isRunning = false; return; } let applicantForm = document.querySelector('form[action*="' + window.location.pathname + '"]'); if (applicantForm && applicantForm.method.toLowerCase() == "get") { applicantForm.submit(); await delay(PAGE_WAIT_TIME); isRunning = false; return; } // TODO maybe it's a rare case if (!document.getElementById("consulate_date_time")) { msg = 'No available appointments'; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); isRunning = false; return; }; if (!ctx.consulates || Object.keys(ctx.consulates).length == 0) { await chrome.storage.local.set({ "ctx_statusMsg": "Fetching consulates" }); ctx.consulates = await getConsulates(); if (!ctx.consulates || Object.keys(ctx.consulates).length == 0) { msg = "No consulates found"; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); sendNotification(msg); } [ctx.consulates, cfg.consulates] = ensureRequiredConsulateProperties( ctx.consulates, cfg.consulates, cfg.frequency ); // if no consulates are selected, select one which we have appointment with let selectedConsulates = Object.keys(ctx.consulates).filter(c => cfg.consulates[c].isSelected); if (selectedConsulates.length == 0) { for (let c in cfg.consulates) { if (c === ctx.currentAppt.consulate) { cfg.consulates[c].isSelected = true; } } } await chrome.storage.local.set({ "cfg_consulates": cfg.consulates }); await chrome.storage.local.set({ "ctx_consulates": ctx.consulates }); isRunning = false; return; } // TODO choose ASC facility // document.querySelector("#appointments_asc_appointment_facility_id [selected]").innerText // for each selected consulate check available dates let selectedConsulates = Object.keys(ctx.consulates).filter(c => cfg.consulates[c].isSelected); if (selectedConsulates.length == 0) { msg = 'No selected consulates found'; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); isRunning = false; return; } let processedConsulates = 0; for (let c of selectedConsulates) { // only one consulate per run if (processedConsulates > 0) { break; } // skip if not time to check if (ctx.consulates[c].nextCheckAt > new Date().toISOString()) { continue; } processedConsulates += 1; msg = `Checking dates for ${c}`; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); let availDates = await getAvailableDates(ctx.consulates[c].id); ctx.consulates[c].nextCheckAt = getFutureDate(cfg.frequency, getJitter(cfg.frequency)); if (!availDates) { msg = `Failed to fetch available dates in ${c}`; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); continue; } // if empty list, either we're banned or non operational hours or dead consulate // wait for some time before checking again let now = new Date(); let currentHourUTC = now.getUTCHours(); let currentMinuteUTC = now.getUTCMinutes(); if (availDates.length == 0) { msg = `No available dates in ${c}, probably banned`; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg }); // Only set SOFT_BAN_TIMEOUT if it's not the first 5 minutes of 23pm UTC if (!(currentHourUTC === 23 && currentMinuteUTC < 5)) { ctx.consulates[c].nextCheckAt = getFutureDate(SOFT_BAN_TIMEOUT, getJitter(cfg.frequency)); } ctx.consulates[c].currentDate = null; continue; } msg = `Available dates for ${c}: ${availDates.slice(0, 5)}`; if (availDates.length > 5) { msg += ` and ${availDates.length - 10} more`; } console.log(msg); ctx.consulates[c].currentDate = availDates[0]; if (!ctx.consulates[c].bestDate || availDates[0] < ctx.consulates[c].bestDate) { console.log(`New record for ${c}: ${availDates[0]}`); ctx.consulates[c].bestDate = availDates[0]; } // filter dates with our requests let filteredDates = await filterDates(availDates, ctx.currentAppt.date, cfg.deltaAppt, cfg.deltaNow); if (!filteredDates.length) { console.log(`No better dates in ${c}, currently available ${availDates[0]}`); await chrome.storage.local.set({ "ctx_statusMsg": `no better dates in ${c}`}); continue; } console.log(`Dates worth rescheduling in ${c}: ${filteredDates}`); let chosenDate = filteredDates[0]; await chrome.storage.local.set({ "ctx_statusMsg": `Found in ${c} better date ${chosenDate}`}); // fill date in reschedule form await delay(PAGE_WAIT_TIME); document.getElementById("appointments_consulate_appointment_date").value = chosenDate; document.getElementById("appointments_consulate_appointment_time").innerHTML = "" let availTimes = await getAvailableTimes(ctx.consulates[c].id, chosenDate); if (!availTimes) { msg = `Failed to fetch available timeslots in ${c} at ${chosenDate}`; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg}); continue; } if (availTimes.length == 0) { msg = `No timeslots in ${c} at ${chosenDate}`; console.log(msg); await chrome.storage.local.set({ "ctx_statusMsg": msg}); continue; } console.log(`Available timeslots in ${c} at ${chosenDate}: ${availTimes}`); let chosenTime = availTimes[0]; // fill timeslot in reschedule form await delay(PAGE_WAIT_TIME); document.getElementById("appointments_consulate_appointment_time").innerHTML = ``; document.getElementById("appointments_consulate_appointment_time").value = chosenTime; // TODO process ASC facilities here await delay(PAGE_WAIT_TIME); document.getElementById("appointments_submit").removeAttribute("disabled"); document.getElementById("appointments_submit").click(); msg = `Found better appointment in ${c} at ${chosenDate} ${chosenTime}`; console.log(msg); await sendNotification(msg); if (!cfg.consulates[c].autoBook) { isFoundAppointment = true; } else { await delay(PAGE_WAIT_TIME); msg = `Auto booking in ${c} at ${chosenDate} ${chosenTime}`; console.log(msg); await sendNotification(msg); document.querySelector(".reveal-overlay:last-child [data-reveal] .button.alert").click(); } } // end consulates loop for (let c in ctx.consulates) { if (!prev_ctx?.consulates?.[c]) continue; if (ctx.consulates[c].nextCheckAt != prev_ctx.consulates[c].nextCheckAt) { console.log(`Next check for ${c} at ${ctx.consulates[c].nextCheckAt}`); } } await chrome.storage.local.set({ "cfg_consulates": cfg.consulates }); await chrome.storage.local.set({ "ctx_consulates": ctx.consulates }); isRunning = false; return; } else if (isConfirmationPage) { // go back to dashboard after successful reschedule await delay(PAGE_WAIT_TIME); ctx.currentAppt = { consulate: null, date: null}; await chrome.storage.local.set({"ctx_currentAppt": ctx.currentAppt}); console.log('Rescheduled successfully'); // switch off autoBook for all consulates after successful reschedule for (let c in cfg.consulates) { cfg.consulates[c].autoBook = false; } await goToDashboardPage(); } // console.log('runner done'); isRunning = false; } setInterval(runner, 500);