520 lines
20 KiB
JavaScript
520 lines
20 KiB
JavaScript
// Copyright (c) 2024, snegov
|
||
// All rights reserved.
|
||
|
||
const pathnameRegex = /^\/\w{2}-\w{2}\/n?iv/;
|
||
const MAX_SIGNIN_ATTEMPTS = 1;
|
||
const PAGE_WAIT_TIME = 3792;
|
||
const MINUTE = 60;
|
||
const SOFT_BAN_TIMEOUT = 27;
|
||
const NOTIF_CHANNEL = "snegov_test"
|
||
|
||
let config = {
|
||
activate: null,
|
||
username: null,
|
||
password: null,
|
||
frequency: null,
|
||
apptId: null,
|
||
currentAppt: {
|
||
consulate: null,
|
||
date: null,
|
||
},
|
||
signinAttempts: null,
|
||
consulates: null,
|
||
deltaAppt: null,
|
||
deltaNow: null,
|
||
};
|
||
let isRunning = false;
|
||
let msg = "";
|
||
|
||
async function delay(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function sendNotification(message, channel = NOTIF_CHANNEL) {
|
||
await fetch('https://ntfy.sh/' + channel, {
|
||
method: 'POST',
|
||
body: message,
|
||
})
|
||
.then(() => console.log('Notification sent'))
|
||
.catch(e => console.error(e));
|
||
}
|
||
|
||
function getRandomInt(max) {
|
||
return Math.floor(Math.random() * Math.floor(max));
|
||
}
|
||
|
||
function getFutureDate(minutes, maxRandomSeconds = 0) {
|
||
// return date some amount of minutes in future plus random amount of seconds
|
||
let futureDate = new Date();
|
||
futureDate.setMinutes(futureDate.getMinutes() + minutes);
|
||
futureDate.setSeconds(futureDate.getSeconds() + getRandomInt(maxRandomSeconds));
|
||
return futureDate.toISOString();
|
||
}
|
||
|
||
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 goToSignInPage() {
|
||
document.querySelector(".homeSelectionsContainer a[href*='/sign_in']").click();
|
||
await delay(PAGE_WAIT_TIME);
|
||
return isSignInPage();
|
||
}
|
||
|
||
async function enterCredentials() {
|
||
document.getElementById("user_email").value = config.username;
|
||
document.getElementById("user_password").value = config.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),
|
||
"isSelected": option.selected,
|
||
"bestDate": null,
|
||
"currentDate": null,
|
||
"nextCheckAt": getFutureDate(0, config.frequency * MINUTE),
|
||
"autobook": false,
|
||
};
|
||
}
|
||
return consulatesDict;
|
||
}
|
||
|
||
async function getAvailableDates(consulateId) {
|
||
let uri = window.location.pathname + `/days/${consulateId}.json?appointments[expedite]=false`
|
||
let dates = fetch(uri, { headers: { "x-requested-with": "XMLHttpRequest" }})
|
||
.then(d => d.json())
|
||
.then(data => {
|
||
let dateList = data.map(item => item.date);
|
||
dateList.sort();
|
||
return dateList;
|
||
})
|
||
.catch(e => null);
|
||
// TODO catch for unauthorized
|
||
return dates;
|
||
}
|
||
|
||
async function filterDates(dates, currentAppt, deltaFromAppt, deltaFromNow) {
|
||
let maxDate = new Date(currentAppt);
|
||
// let maxDate = new Date("2029-09-09");
|
||
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 = await fetch(uri, { headers: { "x-requested-with": "XMLHttpRequest" } })
|
||
.then(d => d.json())
|
||
.then(data => data.available_times)
|
||
.catch(e => null);
|
||
// TODO catch for unauthorized
|
||
return times;
|
||
}
|
||
|
||
async function runner() {
|
||
if (isRunning) {
|
||
return;
|
||
}
|
||
isRunning = true;
|
||
// console.log('runner start');
|
||
|
||
let prev_config = Object.assign({}, config);
|
||
|
||
let result = await new Promise(resolve => chrome.storage.local.get(null, resolve));
|
||
config.activate = result['__activate'] || false;
|
||
config.username = result['__username'] || "";
|
||
config.password = result['__password'] || "";
|
||
config.frequency = parseInt(result['__frequency'] || 1);
|
||
config.signinAttempts = result['__signinAttempts'] || 0;
|
||
config.apptId = result['__apptId'] || null;
|
||
config.currentAppt = result['__currentAppt'] || { consulate: null, date: null };
|
||
config.consulates = result['__consulates'] || null;
|
||
config.deltaAppt = result['__deltaAppt'] || 1;
|
||
config.deltaNow = result['__deltaNow'] || 1;
|
||
|
||
if (prev_config.activate === null) {
|
||
console.log('Reading config: ' + JSON.stringify(config));
|
||
isRunning = false;
|
||
return;
|
||
}
|
||
|
||
for (let key in config) {
|
||
if (config.hasOwnProperty(key)
|
||
&& !_.isEqual(config[key], prev_config[key])) {
|
||
console.log(`Config change: ${key}, ${JSON.stringify(prev_config[key])} => ${JSON.stringify(config[key])}`);
|
||
|
||
// reduce wait times for consulates if frequency is increased
|
||
if (key === 'frequency') {
|
||
let wasChanged = false;
|
||
for (let consulate in config.consulates) {
|
||
let newNextCheckAt = getFutureDate(config.frequency, 10);
|
||
if (config.consulates[consulate].nextCheckAt > newNextCheckAt) {
|
||
config.consulates[consulate].nextCheckAt = newNextCheckAt;
|
||
wasChanged = true;
|
||
console.log(`Reducing wait time for ${consulate} from ${config.consulates[consulate].nextCheckAt} to ${newNextCheckAt}`);
|
||
}
|
||
}
|
||
if (wasChanged) {
|
||
await chrome.storage.local.set({ "__consulates": config.consulates });
|
||
}
|
||
}
|
||
|
||
if (key === 'activate') {
|
||
if (config[key]) {
|
||
console.log('Activating extension');
|
||
} else {
|
||
console.log('Deactivating extension');
|
||
await chrome.storage.local.set({ "__status": "inactive" });
|
||
}
|
||
}
|
||
|
||
// clear signin attempts when credentials are changed
|
||
if (key === 'username' && config[key]
|
||
|| key === 'password' && config[key]) {
|
||
config.signinAttempts = 0;
|
||
await chrome.storage.local.set({ "__signinAttempts": config.signinAttempts });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!config.activate) {
|
||
isRunning = false;
|
||
return;
|
||
}
|
||
|
||
if (config.username === "" || config.password === "") {
|
||
console.log('Username or password is empty');
|
||
await chrome.storage.local.set({ "__status": "missing credentials" });
|
||
isRunning = false;
|
||
return;
|
||
}
|
||
|
||
if (config.frequency <= 0) {
|
||
console.log('Frequency is 0 or negative');
|
||
await chrome.storage.local.set({ "__status": "invalid frequency" });
|
||
isRunning = false;
|
||
return;
|
||
}
|
||
|
||
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({ "__status": msg });
|
||
isRunning = false;
|
||
return;
|
||
};
|
||
|
||
}
|
||
|
||
else if (isSignInPage()) {
|
||
// Prevent brute forcing
|
||
if (config.signinAttempts >= MAX_SIGNIN_ATTEMPTS) {
|
||
await chrome.storage.local.set({ "__status": "too many sign in attempts" });
|
||
isRunning = false;
|
||
return;
|
||
}
|
||
|
||
// Sign in
|
||
msg = 'Signing in attempt: ' + config.signinAttempts;
|
||
console.log(msg)
|
||
await chrome.storage.local.set({ "__status": msg });
|
||
let signedIn = await enterCredentials();
|
||
config.signinAttempts += 1;
|
||
await chrome.storage.local.set({ "__signinAttempts": config.signinAttempts });
|
||
if (!signedIn) {
|
||
msg = 'Failed to sign in';
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": msg });
|
||
isRunning = false;
|
||
return;
|
||
};
|
||
|
||
}
|
||
|
||
else if (isDashboardPage()) {
|
||
// reset signin attempts when successfully logged in
|
||
config.signinAttempts = 0;
|
||
await chrome.storage.local.set({ "__status": "fetching appointment info" });
|
||
await chrome.storage.local.set({ "__signinAttempts": config.signinAttempts });
|
||
|
||
// get appointmentId
|
||
if (!config.apptId) {
|
||
config.apptId = await getAppointmentId();
|
||
if (config.apptId) {
|
||
console.log(`Appointment ID: ${config.apptId}`);
|
||
await chrome.storage.local.set({ "__apptId": config.apptId });
|
||
} else {
|
||
msg = 'No appointments found';
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": msg });
|
||
isRunning = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// get current appointment date
|
||
let apptInfoCard = document.querySelector("p.consular-appt [href*='" + config.apptId + "']").parentNode.parentNode.parentNode
|
||
if (!apptInfoCard.querySelector("h4").innerText.match(/Attend Appointment/)) {
|
||
msg = 'Appointment not available';
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": 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
|
||
&& (apptDate != config.currentAppt.date
|
||
|| apptConsulate != config.currentAppt.consulate)) {
|
||
console.log(`New appointment date: ${apptDate} at ${apptConsulate},
|
||
old: ${config.currentAppt.consulate} at ${config.currentAppt.date}`);
|
||
config.currentAppt.date = apptDate;
|
||
config.currentAppt.consulate = apptConsulate;
|
||
await chrome.storage.local.set({ "__currentAppt": config.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 (!config.currentAppt.date) {
|
||
console.log('No appointment date is set, going back to dashboard');
|
||
window.location = window.location.pathname.replace(/schedule.*/g, "/account");
|
||
await delay(PAGE_WAIT_TIME);
|
||
isRunning = false;
|
||
return;
|
||
}
|
||
|
||
// await chrome.storage.local.set({ "__status": "fetching consulates" });
|
||
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;
|
||
}
|
||
|
||
if (!document.getElementById("consulate_date_time")) {
|
||
msg = 'No available appointments';
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": msg });
|
||
isRunning = false;
|
||
return;
|
||
};
|
||
|
||
if (!config.consulates) {
|
||
config.consulates = await getConsulates();
|
||
await chrome.storage.local.set({ "__consulates": config.consulates });
|
||
}
|
||
if (!config.consulates) {
|
||
console.log('No consulates found');
|
||
await chrome.storage.local.set({ "__status": "no consulates found" });
|
||
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(config.consulates).filter(c => config.consulates[c].isSelected);
|
||
if (selectedConsulates.length == 0) {
|
||
msg = 'No selected consulates found';
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": msg });
|
||
isRunning = false;
|
||
return;
|
||
}
|
||
|
||
for (let consulate of selectedConsulates) {
|
||
// skip if not time to check
|
||
if (config.consulates[consulate].nextCheckAt > new Date().toISOString()) {
|
||
continue;
|
||
}
|
||
|
||
console.log('Checking dates for ' + consulate);
|
||
let availDates = await getAvailableDates(config.consulates[consulate].id);
|
||
config.consulates[consulate].nextCheckAt = getFutureDate(config.frequency, 10);
|
||
|
||
if (!availDates) {
|
||
msg = `Failed to fetch available dates in ${consulate}`;
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": msg });
|
||
continue;
|
||
}
|
||
|
||
// if empty list, either we're banned or non operational hours or dead consulate
|
||
// wait for some time before checking again
|
||
if (availDates.length == 0) {
|
||
msg = `No available dates in ${consulate}, probably banned`;
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": msg });
|
||
config.consulates[consulate].nextCheckAt = getFutureDate(SOFT_BAN_TIMEOUT, 10);
|
||
continue;
|
||
}
|
||
|
||
console.log(`Available dates for ${consulate}: ${availDates}`);
|
||
config.consulates[consulate].currentDate = availDates[0];
|
||
if (!config.consulates[consulate].bestDate
|
||
|| availDates[0] < config.consulates[consulate].bestDate) {
|
||
console.log(`New record for ${consulate}: ${availDates[0]}`);
|
||
config.consulates[consulate].bestDate = availDates[0];
|
||
}
|
||
|
||
// filter dates with our requests
|
||
let filteredDates = await filterDates(availDates, config.currentAppt.date, config.deltaAppt, config.deltaNow);
|
||
if (!filteredDates.length) {
|
||
msg = `No better dates in ${consulate}, currently available ${availDates[0]}`;
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": msg});
|
||
continue;
|
||
}
|
||
|
||
console.log(`Dates worth rescheduling in ${consulate}: ${filteredDates}`);
|
||
let chosenDate = filteredDates[0];
|
||
await chrome.storage.local.set({ "__status": `Found in ${consulate} 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 = "<option></option>"
|
||
|
||
let availTimes = await getAvailableTimes(config.consulates[consulate].id, chosenDate);
|
||
if (!availTimes) {
|
||
msg = `Failed to fetch available timeslots in ${consulate} at ${chosenDate}`;
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": msg});
|
||
continue;
|
||
}
|
||
if (availTimes.length == 0) {
|
||
msg = `No timeslots in ${consulate} at ${chosenDate}`;
|
||
console.log(msg);
|
||
await chrome.storage.local.set({ "__status": msg});
|
||
continue;
|
||
}
|
||
console.log(`Available timeslots in ${consulate} at ${chosenDate}: ${availTimes}`);
|
||
let chosenTime = availTimes[0];
|
||
|
||
// fill timeslot in reschedule form
|
||
await delay(PAGE_WAIT_TIME);
|
||
document.getElementById("appointments_consulate_appointment_time").innerHTML = `<option value='${chosenTime}'>${chosenTime}</option>`;
|
||
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 ${consulate} at ${chosenDate} ${chosenTime}`;
|
||
console.log(msg);
|
||
await sendNotification(msg);
|
||
|
||
if (config.consulates[consulate].autobook) {
|
||
await delay(PAGE_WAIT_TIME);
|
||
console.log('Auto booking');
|
||
document.querySelector(".reveal-overlay:last-child [data-reveal] .button.alert").click();
|
||
}
|
||
} // end consulates loop
|
||
|
||
for (let consulate in config.consulates) {
|
||
if (!prev_config?.consulates?.[consulate]) continue;
|
||
if (config.consulates[consulate].nextCheckAt != prev_config.consulates[consulate].nextCheckAt) {
|
||
console.log(`Next check for ${consulate} at ${config.consulates[consulate].nextCheckAt}`);
|
||
}
|
||
}
|
||
await chrome.storage.local.set({ "__consulates": config.consulates });
|
||
isRunning = false;
|
||
return;
|
||
}
|
||
|
||
else if (isConfirmationPage) {
|
||
// go back to schedule after successful reschedule
|
||
await delay(PAGE_WAIT_TIME);
|
||
config.currentAppt = { consulate: null, date: null};
|
||
await chrome.storage.local.set({"__currentAppt": config.currentAppt});
|
||
console.log('Rescheduled successfully');
|
||
|
||
// switch off autobook for all consulates after successful reschedule
|
||
for (let consulate in config.consulates) {
|
||
config.consulates[consulate].autobook = false;
|
||
}
|
||
|
||
window.location = window.location.pathname.replace(/schedule.*/g, "");
|
||
await delay(PAGE_WAIT_TIME);
|
||
}
|
||
|
||
// console.log('runner done');
|
||
isRunning = false;
|
||
}
|
||
|
||
setInterval(runner, 1000);
|