Skip to content

Instantly share code, notes, and snippets.

@Rovack
Last active May 3, 2024 12:38
Show Gist options
  • Save Rovack/51e0fb558ee0fa4ce0e2cd5f0ab17cb1 to your computer and use it in GitHub Desktop.
Save Rovack/51e0fb558ee0fa4ce0e2cd5f0ab17cb1 to your computer and use it in GitHub Desktop.
A simple script that waits for tickets to become available for the Harry Potter Studio Tour in London, and grabs them
// Note that this has some limitations, such as looking specifically for adult tickets,
// looking for the given days only in the nearest month that has availability,
// and always choosing the earliest time if several are found within the desired dates.
function setAdultTickets(adultTicketsWanted) {
const adultTicketsCount = parseInt($('.quantity-control.row > input')[0].value, 10);
const ticketChangeIterations = Math.abs(adultTicketsWanted - adultTicketsCount);
const ticketChangeButton = $(`.quantity-control.row > button.typcn-${adultTicketsCount < adultTicketsWanted ? 'plus' : 'minus'}`)[0];
for (let i = 0; i < ticketChangeIterations; i++) {
ticketChangeButton.click();
}
}
function playSound(src) {
return new Promise((resolve) => {
const audio = new Audio(src);
audio.onended = resolve;
audio.play();
});
}
function repeatHeyListen() {
playSound('https://www.myinstants.com/media/sounds/hey_listen.mp3')
.then(repeatHeyListen);
}
function waitForAvailability() {
return new Promise((resolve) => {
setTimeout(() => {
const availableEls = $('.calendar>.row:not(.blankLoader) .calendar-body .day.available'), isLoading = $('.calendar-modal[data-component=eventTimeModal] .modal-content > .loading-mask.hide').length === 0;
if (isLoading) {
return waitForAvailability()
.then((res) => resolve(res));
}
resolve(availableEls);
}, 1000);
});
}
function playSounds() {
playSound('https://www.myinstants.com/media/sounds/mlg-airhorn.mp3')
.then(() => playSound('https://www.myinstants.com/media/sounds/sound-9______.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/ps_1.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/wrong-answer-sound-effect.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/lalalalala.swf.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/tuturu_1.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/hallelujahshort.swf.mp3'))
.then(repeatHeyListen);
}
function addTicketsToBasket(dayElement) {
dayElement.click();
setTimeout(() => waitForAvailability()
.then(() => {
$('.ui-control.button.select-time')[0].click();
setTimeout(() => {
$('.typcn.typcn-shopping-cart.ng-binding')[0].click();
}, 2000);
}), 2000);
}
function checkForTickets(datesWanted=[6, 7, 8], adultTicketsWanted=2, checkFrequency=15) {
setAdultTickets(adultTicketsWanted);
function check() {
$('.shared-calendar-button').click();
waitForAvailability()
.then(availableEls => {
console.log(new Date(), 'Availability loaded. Checking for relevant dates...');
for (let i = 0; i < availableEls.length; i++) {
const day = parseInt(availableEls[i].innerText, 10);
console.log('Day', day, 'is available...');
if (datesWanted.includes(day)) {
console.log('Found tickets!!!!!');
playSounds();
addTicketsToBasket(availableEls[i]);
return;
}
}
console.log(`Relevant dates not yet available. Will check again in ${checkFrequency} seconds.`);
$('#page > div:nth-child(11) > div.modal.info-modal.w-auto-c > div > div.close').click();
setTimeout(check, checkFrequency * 1000);
});
};
check();
}
@Rovack
Copy link
Author

Rovack commented Jan 25, 2020

Ah, of course! Excellent idea. :)
For future reference, in case anyone else encounters this issue, you should be able to see the date and time by typing the following in the Console:

$('[ng-if="packageItem.packageEventDate"] > .selected-date')[0].innerHTML

So glad to hear you got tickets @mihaela2020. Have a great time!

P.S. I've now also updated the Caveats in the top comment to reflect this problem and your solution, so others may benefit from your experience. :)

@Rovack
Copy link
Author

Rovack commented Feb 5, 2020

So happy to hear that, @Capybara218!
Thanks for letting me know, and I hope you and your SO have an amazing time there! :)

@Flame239
Copy link

Flame239 commented Feb 19, 2023

Nowadays after some time you get a notification about expiring session and have to click Extend session button.
Here is updated script - at the beginning of check() method we click extend session button if its available, like so:

if ($('.ui-control.button.extendSession').length != 0) {
	console.log('Extending session');
	$('.ui-control.button.extendSession').click();
}	

Full script:

function setAdultTickets(adultTicketsWanted) {
	const adultTicketsCount = parseInt($('.quantity-control.row > input')[0].value, 10);
	const ticketChangeIterations = Math.abs(adultTicketsWanted - adultTicketsCount);
	const ticketChangeButton = $(`.quantity-control.row > button.typcn-${adultTicketsCount < adultTicketsWanted ? 'plus' : 'minus'}`)[0];

	for (let i = 0; i < ticketChangeIterations; i++) {
		ticketChangeButton.click();
	}
}

function playSound(src) {
	return new Promise((resolve) => {
		const audio = new Audio(src);
		audio.onended = resolve;
		audio.play();
	});
}

function repeatHeyListen() {
	playSound('https://www.myinstants.com/media/sounds/hey_listen.mp3')
		.then(repeatHeyListen);
}

function waitForAvailability(monthWanted) {
	return new Promise((resolve) => {
		setTimeout(() => {
			const availableEls = $('.c-14-all.available'), isLoading = $('.calendar-modal[data-component=eventTimeModal] .modal-content > .loading-container.hide').length === 0;
			if (isLoading) {
				return waitForAvailability(monthWanted)
					.then((res) => resolve(res));
			}

			if (monthWanted == null) {
				resolve({ availableEls });
			}

			const monthValue = $('[name="ctl00$ContentPlaceHolder$SalesChannelDetailControl$EventsDateTimeSelectorModal$EventsDateTimeSelector$CalendarSelector$MonthDropDownList"]')[0].value;
			const month = parseInt(monthValue.replace(/^\D+/g, ''), 10);

			if (month < monthWanted) {
				console.log(`Month too early (${month}) - skipping to next month.`);
				$('[name="ctl00$ContentPlaceHolder$SalesChannelDetailControl$EventsDateTimeSelectorModal$EventsDateTimeSelector$CalendarSelector$NextMonthImageButton"]').click();
				return waitForAvailability(monthWanted).then(res => resolve(res));
			}

			resolve({ availableEls, month });
		}, 1000);
	});
}

function playSounds() {
	playSound('https://www.myinstants.com/media/sounds/mlg-airhorn.mp3')
		.then(() => playSound('https://www.myinstants.com/media/sounds/sound-9______.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/ps_1.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/wrong-answer-sound-effect.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/lalalalala.swf.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/tuturu_1.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/hallelujahshort.swf.mp3'))
		.then(repeatHeyListen);
}

function addTicketsToBasket(dayElement) {
	dayElement.click();

	setTimeout(() => waitForAvailability()
		.then(() => {
			$('.ui-control.button.select-time')[0].click();

			setTimeout(() => {
				$('.typcn.typcn-shopping-cart.ng-binding')[0].click();
			}, 2000);
		}), 2000);
}

function checkForTickets(datesWanted=[6, 7, 8], adultTicketsWanted=2, checkFrequency=15, monthWanted) {
	setAdultTickets(adultTicketsWanted);

	function check() {
		if ($('.ui-control.button.extendSession').length != 0) {
			console.log('Extending session');
			$('.ui-control.button.extendSession').click();
		}
		
		$('.shared-calendar-button').click();

		waitForAvailability(monthWanted)
			.then(({ availableEls, month }) => {
				console.log(new Date(), `Availability loaded${month != null ? ` for month ${month}` : ''}. Checking for relevant dates...`);

				if (monthWanted != null && month > monthWanted) {
					console.log(`Month is too late (${month}). Will check again in ${checkFrequency} seconds.`);
					setTimeout(check, checkFrequency * 1000);
					return;
				}

				for (let i = 0; i < availableEls.length; i++) {
					const day = parseInt(availableEls[i].innerText, 10);
					console.log('Day', day, 'is available...');
					if (datesWanted.includes(day)) {
						console.log('Found tickets!!!!!');
						playSounds();
						addTicketsToBasket(availableEls[i]);
						return;
					}
				}

				console.log(`Relevant dates not yet available. Will check again in ${checkFrequency} seconds.`);
				setTimeout(check, checkFrequency * 1000);
		});
	};
	check();
}

function checkForTicketsInMonth(datesWanted, monthWanted, adultTicketsWanted, checkFrequency) {
	return checkForTickets(datesWanted, adultTicketsWanted, checkFrequency, monthWanted);
}

@Rovack
Copy link
Author

Rovack commented Feb 20, 2023

Oh that's great to know, thanks for the fix @Flame239! Added it to the instructions.

@Silverleaf42
Copy link

Silverleaf42 commented Feb 21, 2023

The script is really cool. Nice work on it, @Rovack . I have been running it for a day now and this morning for the first time, it found a hit on one of my days, but with "Insufficient Quantities" of tickets available. When that happens there's no way to stop the mp3 from playing even though it can't add the available time to the basket. Since I'm looking for the "Family (2 adults + 2 children)" and 1 extra Child ticket, I commented out "setAdultTickets(adultTicketsWanted);" and selected my quantities manually from the site. Is there a way I could change the script to look specifically for what I need so it wouldn't celebrate unless all my needed tickets were found?
220341603-ae90d96c-1846-4f62-86a7-a4f387bfb9ff

@Rovack
Copy link
Author

Rovack commented Feb 25, 2023

Hey @Silverleaf42, really sorry for the late response. Have you gotten it to work yet?

If not (though I hope you have), I must admit it's my first time hearing about this "Insufficient Quantities" case. I'd have thought if you specify multiple types of tickets, the site would only show you times where they're all available at once. I guess for some reason, in certain cases they prefer to show dates/times that aren't really available.

To address this in the script would take a few changes, to both addTicketsToBasket and check. Here's a version that seems to work:

[...]

async function addTicketsToBasket(dayElement) {
	dayElement.click();

	await new Promise((resolve) => setTimeout(resolve, 2000));
	await waitForAvailability();

	const chooseTimeButton = $('.ui-control.button.select-time:not(.disabled)')[0];
	if (!chooseTimeButton) return false;

	console.log('Found tickets!!!!!');
	playSounds();

	chooseTimeButton.click();

	await new Promise((resolve) => setTimeout(resolve, 2000));

	$('.typcn.typcn-shopping-cart.ng-binding')[0].click();
	return true;
}

function checkForTickets(datesWanted=[6, 7, 8], adultTicketsWanted=2, checkFrequency=15, monthWanted) {
	setAdultTickets(adultTicketsWanted);

	async function check() {
		if ($('.ui-control.button.extendSession').length != 0) {
			console.log('Extending session');
			$('.ui-control.button.extendSession').click();
		}
		
		$('.shared-calendar-button').click();

		waitForAvailability(monthWanted)
			.then(async ({ availableEls, month }) => {
				console.log(new Date(), `Availability loaded${month != null ? ` for month ${month}` : ''}. Checking for relevant dates...`);

				if (monthWanted != null && month > monthWanted) {
					console.log(`Month is too late (${month}). Will check again in ${checkFrequency} seconds.`);
					setTimeout(check, checkFrequency * 1000);
					return;
				}

				for (let i = 0; i < availableEls.length; i++) {
					const day = parseInt($('.calendar>.row:not(.blankLoader) .calendar-body .day.available')[i].innerText, 10);
					console.log('Day', day, 'is available...');
					if (datesWanted.includes(day)) {
						const succeeded = await addTicketsToBasket($('.calendar>.row:not(.blankLoader) .calendar-body .day.available')[i]);
						if (succeeded) return;
					}
				}

				console.log(`Relevant dates not yet available. Will check again in ${checkFrequency} seconds.`);
				setTimeout(check, checkFrequency * 1000);
		});
	};
	check();
}

[...]

(There may be a very slight race condition here, where if there are multiple relevant dates but the first turn(s) out to have insufficient quantity, in theory availability could change before we attempt the next date and the script wouldn't necessarily handle that. But I think it's fairly unlikely, not too harmful if it does happen, and I can't think of a very easy solution since we have to re-fetch $('.calendar>.row:not(.blankLoader) .calendar-body .day.available') after each time a date is clicked, so hopefully it won't be a problem.)

On a separate note, if anyone in the future does also want the script to automatically choose the quantities with multiple types of tickets (though IMO just doing it manually is probably easiest), you could just make the following modifications:

Replace setAdultTickets with:

function setTickets(ticketsWanted, categoryLineNumber = 0) {
	const ticketsCount = parseInt($('.quantity-control.row > input')[categoryLineNumber].value, 10);
	const ticketChangeIterations = Math.abs(ticketsWanted - ticketsCount);
	const ticketChangeButton = $(`.quantity-control.row > button.typcn-${ticketsCount < ticketsWanted ? 'plus' : 'minus'}`)[categoryLineNumber];

	for (let i = 0; i < ticketChangeIterations; i++) {
		ticketChangeButton.click();
	}
}

And in the first line of checkForTickets, replace setAdultTickets(adultTicketsWanted); with e.g.:

	setTickets(1, 3); // 1 x "Family (2 Adults + 2 Children)" ticket
	setTickets(1, 1); // 1 x "Child" ticket

As you can probably tell, the 2nd parameter is the line number of the type of ticket you want, starting from 0 (so e.g. 0 for "Adult", 1 for "Child", 2 for "4 and Under", and so on), and the 1st parameter is how many of that type of ticket you want.

Hope this helps!

@Silverleaf42
Copy link

setTickets(1, 3); // 1 x "Family (2 Adults + 2 Children)" ticket
setTickets(1, 1); // 1 x "Child" ticket

Thanks @Rovack! I haven't gotten them yet, but I'm trying the changes you suggested now to see if it works and I'll let you know.

@Silverleaf42
Copy link

BTW, I ran the script all day yesterday and not a thing popped up. Ran it again early this morning (starting at 6:15am ET) and got our tickets within 15 minutes. Thanks again, @Rovack

@Rovack
Copy link
Author

Rovack commented Mar 8, 2023

Yay, that's great news @Silverleaf42!! Thanks for letting me know it worked.
Hope you have a great time there! 😄

@sjezewska
Copy link

Hello!

First of all, I want to show my gratitude and say thank you SO much for sharing this with everyone and helping people out even to this day :) :)

I managed to get the script running which is great, but I'd like to check a couple of things if you don't mind. Sometimes I see "Insufficient quantities" though as far as I can tell, I've only specified one type of ticket (family). I've attached the code I'm using. Am I maybe doing something wrong there?

Not a big deal, though after some time I see a timeout. Again, not sure if I've missed something or am doing something incorrectly.

Thank you in advance and I hope you're having a great day!

function setAdultTickets(adultTicketsWanted) { const adultTicketsCount = parseInt($('.quantity-control.row > input')[3].value, 10); const ticketChangeIterations = Math.abs(adultTicketsWanted - adultTicketsCount); const ticketChangeButton = $(.quantity-control.row > button.typcn-${adultTicketsCount < adultTicketsWanted ? 'plus' : 'minus'}`)[3];

for (let i = 0; i < ticketChangeIterations; i++) {
	ticketChangeButton.click();
}

}

function playSound(src) {
return new Promise((resolve) => {
const audio = new Audio(src);
audio.onended = resolve;
audio.play();
});
}

function repeatHeyListen() {
playSound('https://www.myinstants.com/media/sounds/hey_listen.mp3')
.then(repeatHeyListen);
}

function waitForAvailability(monthWanted) {
return new Promise((resolve) => {
setTimeout(() => {
const availableEls = $('.c-14-all.available'), isLoading = $('.calendar-modal[data-component=eventTimeModal] .modal-content > .loading-container.hide').length === 0;
if (isLoading) {
return waitForAvailability(monthWanted)
.then((res) => resolve(res));
}

		if (monthWanted == null) {
			resolve({ availableEls });
		}

		const monthValue = $('[name="ctl00$ContentPlaceHolder$SalesChannelDetailControl$EventsDateTimeSelectorModal$EventsDateTimeSelector$CalendarSelector$MonthDropDownList"]')[0].value;
		const month = parseInt(monthValue.replace(/^\D+/g, ''), 10);

		if (month < monthWanted) {
			console.log(`Month too early (${month}) - skipping to next month.`);
			$('[name="ctl00$ContentPlaceHolder$SalesChannelDetailControl$EventsDateTimeSelectorModal$EventsDateTimeSelector$CalendarSelector$NextMonthImageButton"]').click();
			return waitForAvailability(monthWanted).then(res => resolve(res));
		}

		resolve({ availableEls, month });
	}, 1000);
});

}

function playSounds() {
playSound('https://www.myinstants.com/media/sounds/mlg-airhorn.mp3')
.then(() => playSound('https://www.myinstants.com/media/sounds/sound-9______.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/ps_1.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/wrong-answer-sound-effect.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/lalalalala.swf.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/tuturu_1.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/hallelujahshort.swf.mp3'))
.then(repeatHeyListen);
}

function addTicketsToBasket(dayElement) {
dayElement.click();

setTimeout(() => waitForAvailability()
	.then(() => {
		$('.ui-control.button.select-time')[0].click();

		setTimeout(() => {
			$('.typcn.typcn-shopping-cart.ng-binding')[0].click();
		}, 2000);
	}), 2000);

}

function checkForTickets(datesWanted=[6, 7, 8], adultTicketsWanted=1, checkFrequency=10, monthWanted) {
setAdultTickets(adultTicketsWanted);

function check() {
if ($('.ui-control.button.extendSession').length != 0) {
console.log('Extending session');
$('.ui-control.button.extendSession').click();
}

	$('.shared-calendar-button').click();

	waitForAvailability(monthWanted)
		.then(({ availableEls, month }) => {
			console.log(new Date(), `Availability loaded${month != null ? ` for month ${month}` : ''}. Checking for relevant dates...`);

			if (monthWanted != null && month > monthWanted) {
				console.log(`Month is too late (${month}). Will check again in ${checkFrequency} seconds.`);
				setTimeout(check, checkFrequency * 1000);
				return;
			}

			for (let i = 0; i < availableEls.length; i++) {
				const day = parseInt(availableEls[i].innerText, 10);
				console.log('Day', day, 'is available...');
				if (datesWanted.includes(day)) {
					console.log('Found tickets!!!!!');
					playSounds();
					addTicketsToBasket(availableEls[i]);
					return;
				}
			}

			console.log(`Relevant dates not yet available. Will check again in ${checkFrequency} seconds.`);
			$('#page > div:nth-child(11) > div.modal.info-modal.w-auto-c > div > div.close').click();
			setTimeout(check, checkFrequency * 1000);
	});
};
check();

}

function checkForTicketsInMonth(datesWanted, monthWanted, adultTicketsWanted, checkFrequency) {
return checkForTickets(datesWanted, adultTicketsWanted, checkFrequency, monthWanted);
}`

Timeout 2023-06-08 at 5 09 32 PM

@Rovack
Copy link
Author

Rovack commented Jun 8, 2023

Hey @sjezewska. :) For the "Insufficient quantities" case, it's even weirder that it can happen with just 1 type of ticket, but I guess it's just a thing the system does now. Anyway, the changes specified here, to addTicketsToBasket and check, should address that.

As for the timeout, I never saw it myself, but could it be the same "Extend session" issue Flame239 mentioned here?
If so, you could try the fix they suggested.

Let me know if either of these problems persists even with these changes, and I can look more deeply into it.

@sjezewska
Copy link

@Rovack sorry for the late reply and thanks for getting back to me. I kept seeing "Insufficient quantities" from time to time and I think the timeout issue was because of my internet connection sigh. I ended up getting tickets though, so REALLY happy! Thank you again :)

@Rovack
Copy link
Author

Rovack commented Jun 16, 2023

Great to hear it, @sjezewska! Hope you have a wonderful time. :)

@amandaoliver15
Copy link

Hi Rovack! Thanks for the script, I'm looking for tickets this October and the script is working great. However since the 2024 tickets have been released now the script skips through until October '24, which of course it can find availability for and adds them to the basket, thus stopping the loop.

I'm sure there is a way to specify the year, so that the script will keep looking for tickets in October '23 - I'm just not savvy enough to know how to find the correct syntax for this. Can you please help to add this in?

@Rovack
Copy link
Author

Rovack commented Oct 14, 2023

Hi @amandaoliver15. This is actually a really interesting case - normally the version that picks a specific month shouldn't run into this problem, because it would just stop once it gets to a month that's later than the requested one. Except currently the closest availability is actually in January 2024, and since the script doesn't look at the year, it just figures January is earlier than October, and progresses through the months.

To fix this, you should be able to just add the following lines:

			const yearValue = $('[ng-model="viewModel.calendar.year"]')[0].value;
			const year = parseInt(yearValue.replace(/^\D+/g, ''), 10);
			const currentYear = new Date().getFullYear();
			if (year > currentYear) {
				console.log(`Year too late (${year}) - stopping.`);
				return resolve({ availableEls, month: month + 12 * (year - currentYear) });
			}

They go right before the code that checks the month, i.e. before:

if (month < monthWanted) {

but after:

const month = parseInt(monthValue.replace(/^\D+/g, ''), 10);

(There might be slightly cleaner/more general ways to handle year selection, but for the current situation at least this should work.)

Once you try it out, if it there are any problems or questions or anything else, just let me know. :)

@amandaoliver15
Copy link

amandaoliver15 commented Oct 15, 2023

Hi Rovack, script works perfectly, now it's a waiting game. Thanks again for the amazing work, and quick response 😊

Update: Managed to get some tickets after about an hour! Thanks again!

@gneotel
Copy link

gneotel commented Nov 6, 2023

Thank you so much to Rovack and everybody who commented!
I've managed to find the tickets I needed at the busiest time of the year.
It took me four days of continuous running...
Thanks again!!

@Rovack
Copy link
Author

Rovack commented Nov 7, 2023

So glad to hear it, @gneotel. 😁 Hope you have a great time!!

@spankowski
Copy link

spankowski commented Nov 12, 2023

Hi @Rovack, first big thanks for what you did here! the script is amazing. I hope I will manage to find a 2 free slot in December.
I do also have one question. Sometimes I am getting in console:
angular.js:16410 TypeError: Cannot read properties of undefined (reading 'value') at WBUK-core.min.js?v=wbuk_7.8.0.21.1-13-g1fac657e7b:6:15250 at Object.r [as forEach] (angular.js:487:20) at e.getTimes (WBUK-core.min.js?v=wbuk_7.8.0.21.1-13-g1fac657e7b:6:15125) at e.getTimes (WBUK-core.min.js?v=wbuk_7.8.0.21.1-13-g1fac657e7b:2:11130) at e.parse (WBUK-core.min.js?v=wbuk_7.8.0.21.1-13-g1fac657e7b:6:14771) at e.parse (WBUK-core.min.js?v=wbuk_7.8.0.21.1-13-g1fac657e7b:2:11130) at e.<anonymous> (WBUK-core.min.js?v=wbuk_7.8.0.21.1-13-g1fac657e7b:14:18472) at angular.js:1506:3 at angular.js:18242:20 at m.$digest (angular.js:19366:15) 'Possibly unhandled rejection: {}'
and on the screen I see calendar popup but without calendar inside. (it seems that this part was not loaded)
Did you experience something similar?
EDIT:
It's probably happen when there is no free slots in the entire month, like:
image

@Rovack
Copy link
Author

Rovack commented Nov 12, 2023

@spankowski I can't say I've ever encountered that error. Looking at it, it appears maybe something in the site's code (as that's what's throwing the error - not the script) isn't properly handling the case where a month has 0 available days, as you said, but only sometimes - I can't get the error to happen for me even when there's no availability.

Though, either way this isn't necessarily a problem; what does the script do in this case? If it just recognizes that there's no availability and continues refreshing and waiting for that to change, that should be fine.
If the error throws it off, though, and makes it stop searching pending human intervention, that's a bigger problem and we'll need to debug further.

@AntoineGpp
Copy link

Hello everyone,
As I'd like to book two tickets during May as I finally got to make a passport demand in France, I tried to use this script. It's the first time I'm using javascript, and I encountered issues: To test out the script, I tried grabbing some tickets between the 27th and the 29th of May.
image

But after writing "checkForTicketsInMonth([27, 28, 29, 30], 5)" and hitting enter, the only thing that happens is the calendar opening.

I'd really appreciate getting any help. Seeing how many family trips you guys saved already made my day though :)

Thanks !
Antoine

@Rovack
Copy link
Author

Rovack commented Mar 21, 2024

Hi @AntoineGpp, thanks for bringing this up! Apparently they've made some minor changes to the website code, which require tweaking the script. Namely, the line:

const availableEls = $('.c-14-all.available'), isLoading = $('.calendar-modal[data-component=eventTimeModal] .modal-content > .loading-container.hide').length === 0;

Needs to be changed to:

const availableEls = $('.calendar>.row:not(.blankLoader) .calendar-body .day.available'), isLoading = $('.calendar-modal[data-component=eventTimeModal] .modal-content > .loading-mask.hide').length === 0;

I haven't tested these changes very extensively, so there may be some edge cases I'm missing, but the simple flow at least appears to work.
If you do run into any additional issues, please let me know and I can see what else needs to be adjusted.

In the meanwhile, I'll correct all the existing code snippets to use this updated line. Thanks again, and hope this helps! :)

@AntoineGpp
Copy link

AntoineGpp commented Mar 21, 2024

Thank you for your fast reply !
It's almost working now, I can see the calendar reloading every fifteen seconds. However, my console doesn't show me any message after starting the script, else than "undefined".
Maybe there's something to activate in my browser ? I'm using Brave with shield off.

EDIT: I tried the script in Edge, and it seems to work now. I'll keep you updated !
Thanks a lot man :)

@Mirtel123
Copy link

Mirtel123 commented Mar 23, 2024

Hello everyone,
i just tested the script on a day that i know its already available. But the script tell me, the Relevant dates not yet available. Can anybody help me about this?
Thats my code:

function setAdultTickets(adultTicketsWanted) {
	const adultTicketsCount = parseInt($('.quantity-control.row > input')[0].value, 10);
	const ticketChangeIterations = Math.abs(adultTicketsWanted - adultTicketsCount);
	const ticketChangeButton = $(`.quantity-control.row > button.typcn-${adultTicketsCount < adultTicketsWanted ? 'plus' : 'minus'}`)[0];

	for (let i = 0; i < ticketChangeIterations; i++) {
		ticketChangeButton.click();
	}
}

function playSound(src) {
	return new Promise((resolve) => {
		const audio = new Audio(src);
		audio.onended = resolve;
		audio.play();
	});
}

function repeatHeyListen() {
	playSound('https://www.myinstants.com/media/sounds/hey_listen.mp3')
		.then(repeatHeyListen);
}

function waitForAvailability(monthWanted) {
	return new Promise((resolve) => {
		setTimeout(() => {
			const availableEls = $('.calendar>.row:not(.blankLoader) .calendar-body .day.available'), isLoading = $('.calendar-modal[data-component=eventTimeModal] .modal-content > .loading-mask.hide').length === 0;
			if (isLoading) {
				return waitForAvailability(monthWanted)
					.then((res) => resolve(res));
			}

			if (monthWanted == null) {
				resolve({ availableEls });
			}

			const monthValue = $('[name="ctl00$ContentPlaceHolder$SalesChannelDetailControl$EventsDateTimeSelectorModal$EventsDateTimeSelector$CalendarSelector$MonthDropDownList"]')[0].value;
			const month = parseInt(monthValue.replace(/^\D+/g, ''), 10);

			if (month < monthWanted) {
				console.log(`Month too early (${month}) - skipping to next month.`);
				$('[name="ctl00$ContentPlaceHolder$SalesChannelDetailControl$EventsDateTimeSelectorModal$EventsDateTimeSelector$CalendarSelector$NextMonthImageButton"]').click();
				return waitForAvailability(monthWanted).then(res => resolve(res));
			}

			resolve({ availableEls, month });
		}, 1000);
	});
}

function playSounds() {
	playSound('https://www.myinstants.com/media/sounds/mlg-airhorn.mp3')
		.then(() => playSound('https://www.myinstants.com/media/sounds/sound-9______.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/ps_1.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/wrong-answer-sound-effect.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/lalalalala.swf.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/tuturu_1.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/hallelujahshort.swf.mp3'))
		.then(repeatHeyListen);
}

async function addTicketsToBasket(dayElement) {
	dayElement.click();

	await new Promise((resolve) => setTimeout(resolve, 2000));
	await waitForAvailability();

	const chooseTimeButton = $('.ui-control.button.select-time:not(.disabled)')[0];
	if (!chooseTimeButton) return false;

	console.log('Found tickets!!!!!');
	playSounds();

	chooseTimeButton.click();

	await new Promise((resolve) => setTimeout(resolve, 2000));

	$('.typcn.typcn-shopping-cart.ng-binding')[0].click();
	return true;
}

function checkForTickets(datesWanted=[6, 7, 8], adultTicketsWanted=2, checkFrequency=15, monthWanted) {
	setAdultTickets(adultTicketsWanted);

	async function check() {
		if ($('.ui-control.button.extendSession').length != 0) {
			console.log('Extending session');
			$('.ui-control.button.extendSession').click();
		}
		
		$('.shared-calendar-button').click();

		waitForAvailability(monthWanted)
			.then(async ({ availableEls, month }) => {
				console.log(new Date(), `Availability loaded${month != null ? ` for month ${month}` : ''}. Checking for relevant dates...`);

				if (monthWanted != null && month > monthWanted) {
					console.log(`Month is too late (${month}). Will check again in ${checkFrequency} seconds.`);
					setTimeout(check, checkFrequency * 1000);
					return;
				}

				for (let i = 0; i < availableEls.length; i++) {
					const day = parseInt($('.c-14-all.available')[i].innerText, 10);
					console.log('Day', day, 'is available...');
					if (datesWanted.includes(day)) {
						const succeeded = await addTicketsToBasket($('.c-14-all.available')[i]);
						if (succeeded) return;
					}
				}

				console.log(`Relevant dates not yet available. Will check again in ${checkFrequency} seconds.`);
				setTimeout(check, checkFrequency * 1000);
		});
	};
	check();
}

function checkForTicketsInMonth(datesWanted, monthWanted, adultTicketsWanted, checkFrequency) {
	return checkForTickets(datesWanted, adultTicketsWanted, checkFrequency, monthWanted);
}

and thats what i´m looking for
checkForTicketsInMonth([3, 4], 5, 1, 3)

on the 3rd are available time slots

the Console say:

Day NaN is available...
Relevant dates not yet available. Will check again in 15 seconds.

thank you very much for your help!
best regards Martin

@Rovack
Copy link
Author

Rovack commented Mar 23, 2024

Hey @Mirtel123, looks like this is because when I was updating snippets throughout these comments (as per this), I missed one that was still using .c-14-all.available as the selector.

I've now updated that one too, replacing .c-14-all.available (in both lines in which it appears) with .calendar>.row:not(.blankLoader) .calendar-body .day.available, and it seems like that should work.
Please let me know if there are still any problems after this change. Thanks!

@Mirtel123
Copy link

Mirtel123 commented Mar 23, 2024

OMG it works great 👍 thank u very much! Do you think its possible to pretend the time for the tickets? I mean only tickets befor 12 o´clock or similar?

@AntoineGpp
Copy link

I finally got my tickets for the month of May ! It took me 2,5 days to get them 🤯
First experience with javascript, so interesting to see how it works!
I'm truly grateful @Rovack & everyone who took part in building this!

@Rovack
Copy link
Author

Rovack commented Mar 24, 2024

Amazing to hear @AntoineGpp! Hope you have an awesome time there.
And glad this proved a not-too-painful intro to JS. 😄

And @Mirtel123, sure! I think some changes to addTicketsToBasket should mostly do the trick - just replace it with this version:

async function addTicketsToBasket(dayElement, { minHour, maxHour } = {}) {
	dayElement.click();

	await new Promise((resolve) => setTimeout(resolve, 2000));
	await waitForAvailability();

	const timeRows = $('.time-selector .times .time.row');
	for (let i = 0; i < timeRows.length; i++) {
		const row = $(timeRows[i]);
		const timeString = row.find('.time')[0].innerText;
		const chooseTimeButton = row.find('.select-time:not(.disabled)')[0];

		if (!chooseTimeButton) continue;

		const hour = parseInt(timeString.split(':')[0], 10);
		if (minHour != null && hour < minHour) {
			console.log(`Tickets found at wanted date but time is too early (${timeString})`);
			continue;
		}
		if (maxHour != null && hour > maxHour) {
			console.log(`Tickets found at wanted date but time is too late (${timeString})`);
			continue;
		}

		console.log('Found tickets!!!!!');
		playSounds();
	
		chooseTimeButton.click();
	
		await new Promise((resolve) => setTimeout(resolve, 2000));
	
		$('.typcn.typcn-shopping-cart.ng-binding')[0].click();
		return true;	
	}

	return false;
}

Then we just need to pass the hour range around. To that end, the 1st line in checkForTickets becomes:

function checkForTickets(datesWanted=[6, 7, 8], adultTicketsWanted=2, checkFrequency=15, monthWanted, hourRange) {

And the line inside it that references addTicketsToBasket changes into:

const succeeded = await addTicketsToBasket($('.calendar>.row:not(.blankLoader) .calendar-body .day.available')[i], hourRange);

Finally checkForTicketsInMonth becomes:

function checkForTicketsInMonth(datesWanted, monthWanted, adultTicketsWanted, checkFrequency, hourRange) {
	return checkForTickets(datesWanted, adultTicketsWanted, checkFrequency, monthWanted, hourRange);
}

At that point, you should be able to run e.g. checkForTicketsInMonth([3, 4], 5, 1, 3, { minHour: 0, maxHour: 12 }), to only accept tickets midnight to 12 PM (inclusive; if you want entry at 11:30 at the latest, then just change 12 to 11).
If you'd rather be able to specify non-consecutive ranges, or if half hours are important as well, it should be easy enough to adjust this to take an array of specific acceptable times instead of just a min and max hour. Just let me know, and I can tweak it as needed.

Hope this works, and let me know if you run into any further issues!

@Mirtel123
Copy link

Hello @Rovack i just tried it and if its on a day with available timeslots it works very fine 👍 but with a error:
this is my code now and bellow you see the errors

function setAdultTickets(adultTicketsWanted) {
	const adultTicketsCount = parseInt($('.quantity-control.row > input')[0].value, 10);
	const ticketChangeIterations = Math.abs(adultTicketsWanted - adultTicketsCount);
	const ticketChangeButton = $(`.quantity-control.row > button.typcn-${adultTicketsCount < adultTicketsWanted ? 'plus' : 'minus'}`)[0];

	for (let i = 0; i < ticketChangeIterations; i++) {
		ticketChangeButton.click();
	}
}

function playSound(src) {
	return new Promise((resolve) => {
		const audio = new Audio(src);
		audio.onended = resolve;
		audio.play();
	});
}

function repeatHeyListen() {
	playSound('https://www.myinstants.com/media/sounds/hey_listen.mp3')
		.then(repeatHeyListen);
}

function waitForAvailability(monthWanted) {
	return new Promise((resolve) => {
		setTimeout(() => {
			const availableEls = $('.calendar>.row:not(.blankLoader) .calendar-body .day.available'), isLoading = $('.calendar-modal[data-component=eventTimeModal] .modal-content > .loading-mask.hide').length === 0;
			if (isLoading) {
				return waitForAvailability(monthWanted)
					.then((res) => resolve(res));
			}

			if (monthWanted == null) {
				resolve({ availableEls });
			}

			const monthValue = $('[name="ctl00$ContentPlaceHolder$SalesChannelDetailControl$EventsDateTimeSelectorModal$EventsDateTimeSelector$CalendarSelector$MonthDropDownList"]')[0].value;
			const month = parseInt(monthValue.replace(/^\D+/g, ''), 10);

			if (month < monthWanted) {
				console.log(`Month too early (${month}) - skipping to next month.`);
				$('[name="ctl00$ContentPlaceHolder$SalesChannelDetailControl$EventsDateTimeSelectorModal$EventsDateTimeSelector$CalendarSelector$NextMonthImageButton"]').click();
				return waitForAvailability(monthWanted).then(res => resolve(res));
			}

			resolve({ availableEls, month });
		}, 1000);
	});
}

function playSounds() {
	playSound('https://www.myinstants.com/media/sounds/mlg-airhorn.mp3')
		.then(() => playSound('https://www.myinstants.com/media/sounds/sound-9______.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/ps_1.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/wrong-answer-sound-effect.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/lalalalala.swf.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/tuturu_1.mp3'))
		.then(() => playSound('https://www.myinstants.com/media/sounds/hallelujahshort.swf.mp3'))
		.then(repeatHeyListen);
}

async function addTicketsToBasket(dayElement, { minHour, maxHour } = {}) {
	dayElement.click();

	await new Promise((resolve) => setTimeout(resolve, 2000));
	await waitForAvailability();

	const timeRows = $('.time-selector .times .time.row');
	for (let i = 0; i < timeRows.length; i++) {
		const row = $(timeRows[i]);
		const timeString = row.find('.time')[0].innerText;
		const chooseTimeButton = row.find('.select-time:not(.disabled)')[0];

		if (!chooseTimeButton) continue;

		const hour = parseInt(timeString.split(':')[0], 10);
		if (minHour != null && hour < minHour) {
			console.log(`Tickets found at wanted date but time is too early (${timeString})`);
			continue;
		}
		if (maxHour != null && hour > maxHour) {
			console.log(`Tickets found at wanted date but time is too late (${timeString})`);
			continue;
		}

		console.log('Found tickets!!!!!');
		playSounds();
	
		chooseTimeButton.click();
	
		await new Promise((resolve) => setTimeout(resolve, 2000));
	
		$('.typcn.typcn-shopping-cart.ng-binding')[0].click();
		return true;	
	}

	return false;
}
function checkForTickets(datesWanted=[6, 7, 8], adultTicketsWanted=2, checkFrequency=15, monthWanted, hourRange) {
	setAdultTickets(adultTicketsWanted);

	async function check() {
		if ($('.ui-control.button.extendSession').length != 0) {
			console.log('Extending session');
			$('.ui-control.button.extendSession').click();
		}
		
		$('.shared-calendar-button').click();

		waitForAvailability(monthWanted)
			.then(async ({ availableEls, month }) => {
				console.log(new Date(), `Availability loaded${month != null ? ` for month ${month}` : ''}. Checking for relevant dates...`);

				if (monthWanted != null && month > monthWanted) {
					console.log(`Month is too late (${month}). Will check again in ${checkFrequency} seconds.`);
					setTimeout(check, checkFrequency * 1000);
					return;
				}

				for (let i = 0; i < availableEls.length; i++) {
					const day = parseInt($('.calendar>.row:not(.blankLoader) .calendar-body .day.available')[i].innerText, 10);
					console.log('Day', day, 'is available...');
					if (datesWanted.includes(day)) {
						const succeeded = await addTicketsToBasket($('.calendar>.row:not(.blankLoader) .calendar-body .day.available')[i], hourRange);
						if (succeeded) return;
					}
				}

				console.log(`Relevant dates not yet available. Will check again in ${checkFrequency} seconds.`);
				setTimeout(check, checkFrequency * 1000);
		});
	};
	check();
}

function checkForTicketsInMonth(datesWanted, monthWanted, adultTicketsWanted, checkFrequency, hourRange) {
	return checkForTickets(datesWanted, adultTicketsWanted, checkFrequency, monthWanted, hourRange);
}

VM233:37 Uncaught TypeError: Cannot read properties of undefined (reading 'value')
at :37:176

  | (anonymous) | @ | VM233:37
  | setTimeout (async) |   |  
  | (anonymous) | @ | VM233:26
  | waitForAvailability | @ | VM233:25
  | addTicketsToBasket | @ | VM233:66
  | await in addTicketsToBasket (async) |   |  
  | (anonymous) | @ | VM233:124
  | Promise.then (async) |   |  
  | check | @ | VM233:111
  | checkForTickets | @ | VM233:133
  | checkForTicketsInMonth | @ | VM233:137
  | (anonymous) | @ | VM238:1

and

Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'innerText')
at :121:97
  | (anonymous) | @ | VM233:121
  | setTimeout (async) |   |  
  | (anonymous) | @ | VM233:26
  | waitForAvailability | @ | VM233:25
  | addTicketsToBasket | @ | VM233:66
  | await in addTicketsToBasket (async) |   |  
  | (anonymous) | @ | VM233:124
  | Promise.then (async) |   |  
  | check | @ | VM233:111
  | checkForTickets | @ | VM233:133
  | checkForTicketsInMonth | @ | VM233:137
  | (anonymous) | @ | VM238:1

it is impressive, how fast you can do this coding!
Thank you for your support and patience

@Rovack
Copy link
Author

Rovack commented Mar 24, 2024

Hmm, how interesting... I haven't been able to get it to happen for me, but sounds like it could be a result of the race condition mentioned here, which may become more likely once we add hour constraints.

If you could send the exact parameters you pass to checkForTicketsInMonth, maybe I'll be able to see how it happens. Or, if you could send the lines printed above the error, so I can see what was happening that led up to it, that could help as well.

Of course, if this happens randomly only every so often, and not in some deterministic fashion, one workaround would be to just automatically ignore the error and continue. I think you might be able to achieve this by turning checkForTickets into:

function checkForTickets(datesWanted=[6, 7, 8], adultTicketsWanted=2, checkFrequency=15, monthWanted, hourRange) {
	setAdultTickets(adultTicketsWanted);

	async function check() {
		try {
			if ($('.ui-control.button.extendSession').length != 0) {
				console.log('Extending session');
				$('.ui-control.button.extendSession').click();
			}
			
			$('.shared-calendar-button').click();

			await waitForAvailability(monthWanted)
				.then(async ({ availableEls, month }) => {
					console.log(new Date(), `Availability loaded${month != null ? ` for month ${month}` : ''}. Checking for relevant dates...`);

					if (monthWanted != null && month > monthWanted) {
						console.log(`Month is too late (${month}). Will check again in ${checkFrequency} seconds.`);
						setTimeout(check, checkFrequency * 1000);
						return;
					}

					for (let i = 0; i < availableEls.length; i++) {
						const day = parseInt($('.calendar>.row:not(.blankLoader) .calendar-body .day.available')[i].innerText, 10);
						console.log('Day', day, 'is available...');
						if (datesWanted.includes(day)) {
							const succeeded = await addTicketsToBasket($('.calendar>.row:not(.blankLoader) .calendar-body .day.available')[i], hourRange);
							if (succeeded) return;
						}
					}

					console.log(`Relevant dates not yet available. Will check again in ${checkFrequency} seconds.`);
					setTimeout(check, checkFrequency * 1000);
			});
		} catch (err) {
			console.error('Error checking. Just gonna keep trying.', err);
			setTimeout(check, checkFrequency * 1000);
		}
	};
	check();
}

@Mirtel123
Copy link

@Rovack today i tried it again and it works perfectly 👍 i dont now why it dosn‘t the last time. Thank u very much for your support

@Rovack
Copy link
Author

Rovack commented Mar 25, 2024

Great! Glad to hear it @Mirtel123.
Let me know how it turns out or if you run into any further issues. :)

@gokhanalpdogan
Copy link

gokhanalpdogan commented Apr 16, 2024

Hello there,

Sorry for this dumb questions but really i need to ask :/

i've modified the script in which days i want to buy. (14 - 15 may) here is the script code;


// Note that this has some limitations, such as looking specifically for adult tickets,
// looking for the given days only in the nearest month that has availability,
// and always choosing the earliest time if several are found within the desired dates.

function setAdultTickets(adultTicketsWanted) {
const adultTicketsCount = parseInt($('.quantity-control.row > input')[0].value, 10);
const ticketChangeIterations = Math.abs(adultTicketsWanted - adultTicketsCount);
const ticketChangeButton = $(.quantity-control.row > button.typcn-${adultTicketsCount < adultTicketsWanted ? 'plus' : 'minus'})[0];

for (let i = 0; i < ticketChangeIterations; i++) {
	ticketChangeButton.click();
}

}

function playSound(src) {
return new Promise((resolve) => {
const audio = new Audio(src);
audio.onended = resolve;
audio.play();
});
}

function repeatHeyListen() {
playSound('https://www.myinstants.com/media/sounds/hey_listen.mp3')
.then(repeatHeyListen);
}

function waitForAvailability() {
return new Promise((resolve) => {
setTimeout(() => {
const availableEls = $('.c-14-all.available')
if (availableEls.length === 0) {
return waitForAvailability()
.then((res) => resolve(res));
}

		resolve(availableEls);
	}, 1000);
});

}

function playSounds() {
playSound('https://www.myinstants.com/media/sounds/mlg-airhorn.mp3')
.then(() => playSound('https://www.myinstants.com/media/sounds/sound-9______.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/ps_1.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/wrong-answer-sound-effect.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/lalalalala.swf.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/tuturu_1.mp3'))
.then(() => playSound('https://www.myinstants.com/media/sounds/hallelujahshort.swf.mp3'))
.then(repeatHeyListen);
}

function addTicketsToBasket(dayElement) {
dayElement.click();

waitForAvailability()
	.then(() => {
		$('.ui-control.button.select-time')[0].click();

		setTimeout(() => {
			$('.typcn.typcn-shopping-cart.ng-binding')[0].click();
		}, 2000);
	});

}

function checkForTickets(datesWanted=[14, 15], adultTicketsWanted=2, checkFrequency=15) {
setAdultTickets(adultTicketsWanted);

function check() {
	$('.shared-calendar-button').click();

	waitForAvailability()
		.then(availableEls => {
			console.log(new Date(), 'Availability loaded. Checking for relevant dates...');
			for (let i = 0; i < availableEls.length; i++) {
				const day = parseInt(availableEls[i].innerText, 10);
				console.log('Day', day, 'is available...');
				if (datesWanted.includes(day)) {
					console.log('Found tickets!!!!!');
					playSounds();
					addTicketsToBasket(availableEls[i]);
					return;
				}
			}

			console.log(`Relevant dates not yet available. Will check again in ${checkFrequency} seconds.`);
			$('#page > div:nth-child(11) > div.modal.info-modal.w-auto-c > div > div.close').click();
			setTimeout(check, checkFrequency * 1000);
	});
};
check();

}

i just updated datesWanted.

here are the questions;

  1. when i run script, google chrome says ''undefined'' i think this is normal but i have to be sure :)
  2. after a while webpage popups an error ''SESSION WARNING
    Your session has expired.
    START OVER''

is it normal?

again sorry for disturb.
thanks for your help.

@Rovack
Copy link
Author

Rovack commented Apr 17, 2024

Hi @gokhanalpdogan! First of all, yes: getting "undefined" is normal. Just make sure after you run that, you also actually use checkForTickets, as described here, because until you do nothing is actually happening. That's also actually where you're supposed to specify the dates, although the way you did it should be fine too.

That same comment also mentions, near the bottom, how to deal with these expiring sessions, by linking here where there's a solution for that.

Hope this helps, but let me know if there are any further questions or complications.

@chfrodin
Copy link

chfrodin commented Apr 30, 2024

Hey @Rovack I just wanted to say a big thank you for this one. Got it up and running, took about five days straight use and I was able to get four tickets for next week.

@Rovack
Copy link
Author

Rovack commented May 3, 2024

@chfrodin That's awesome, makes me really happy to hear - thanks so much for letting me know! 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment