Initial commit with first working version

This commit is contained in:
Collin Duncan 2021-11-15 11:28:39 +01:00
commit 905cd4e194
No known key found for this signature in database
11 changed files with 5295 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# environment
.env*
# node / javascript
node_modules/
dist/
# terraform
.terraform/
deploy/

1
README.md Normal file
View file

@ -0,0 +1 @@
# autobaan

3
babel.config.json Normal file
View file

@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-typescript"]
}

4909
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "autobaan",
"version": "1.0.0",
"description": "",
"main": "index.js",
"engines": {
"node": "14.x"
},
"scripts": {
"build": "tsc",
"zip": "mkdir deploy && zip deploy/reservation-lambda.zip -r dist"
},
"author": "",
"license": "ISC",
"dependencies": {
"puppeteer": "^11.0.0"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/preset-env": "^7.16.0",
"@babel/preset-typescript": "^7.16.0",
"@rollup/plugin-babel": "^5.3.0",
"@types/aws-lambda": "^8.10.85",
"@types/puppeteer": "^5.4.4",
"typescript": "^4.4.4"
}
}

18
src/handler.ts Normal file
View file

@ -0,0 +1,18 @@
import { SQSEvent, SQSHandler } from "aws-lambda"
import { validateRequestEvent } from "./request"
import { Reservation } from './reservation'
import { Runner } from './runner'
export const run: SQSHandler = async (event: SQSEvent): Promise<void> => {
const { request, error } = validateRequestEvent(event)
if (error || !request) {
throw new Error(error?.message)
}
const { username, password, dateTimes, opponent } = request
const reservations = dateTimes.map((dt) => new Reservation(dt, opponent))
const runner = new Runner(username, password, reservations)
await runner.run({ headless: false })
}

49
src/local.ts Normal file
View file

@ -0,0 +1,49 @@
import { IncomingRequest, validateRequestEvent } from "./request"
import { Reservation } from "./reservation"
import { Runner } from "./runner"
const run = async (request: IncomingRequest) => {
const { username, password, dateTimes, opponent } = request
const reservations = dateTimes.map((dt) => new Reservation(dt, opponent))
const runner = new Runner(username, password, reservations)
await runner.run({ headless: false })
}
// get supplied args
const args = process.argv.filter((_, index) => index >= 2)
if (args.length !== 7) {
console.error('Usage: ./local <year> <month> <day> <startTime> <endTime> <opponentName> <opponentId>')
process.exit(1)
}
const [year, month, day, startTime, endTime, opponentName, opponentId] = args
const [startHour, startMinute] = startTime.split(':').map((t) => Number.parseInt(t))
const [endHour, endMinute] = endTime.split(':').map((t) => Number.parseInt(t))
run({
username: process.env.USERNAME ?? '',
password: process.env.PASSWORD ?? '',
dateTimes: [{
year: Number.parseInt(year),
month: Number.parseInt(month),
day: Number.parseInt(day),
timeRange: {
start: {
hour: startHour,
minute: startMinute,
},
end: {
hour: endHour,
minute: endMinute,
},
},
}],
opponent: {
id: opponentId,
name: opponentName,
},
})
.then(() => console.log('Success'))
.catch((e) => console.error(e))

120
src/request.ts Normal file
View file

@ -0,0 +1,120 @@
import {
APIGatewayProxyEventV2, SQSEvent,
} from "aws-lambda";
import {
DateTime, Opponent
} from "./reservation";
export interface IncomingRequest {
username: string
password: string
dateTimes: DateTime[]
opponent: Opponent
}
export interface ValidationError {
message: string
code: number
}
export const validateRequestEvent = (event: SQSEvent): { request?: IncomingRequest, error ?: ValidationError } => {
try {
const request = validateRequestBody(event.Records[0].body)
validateRequestDateTimes(request.dateTimes)
validateRequestOpponent(request.opponent)
return { request }
} catch (err: any) {
return { error: { message: 'Invalid request', code: err.code ?? 0 } }
}
}
const validateRequestBody = (body?: string): IncomingRequest => {
if (body === undefined) {
throw {
message: 'Invalid request',
code: 1
}
}
let jsonBody: IncomingRequest
try {
jsonBody = JSON.parse(body)
} catch (err) {
throw {
message: 'Invalid request',
code: 2,
}
}
const {
username,
password,
dateTimes,
} = jsonBody
if (!username || username.length < 1 || !password || password.length < 1 || !dateTimes) {
throw {
message: 'Invalid request',
code: 3,
}
}
return jsonBody
}
const validateRequestDateTimes = (dateTimes: DateTime[]): void => {
const now = new Date()
for (let i = 0; i < dateTimes.length; i++) {
const dateTime = dateTimes[i]
const {
year,
month,
day,
timeRange
} = dateTime
const {
start,
end
} = timeRange
if (
typeof year !== 'number' ||
typeof month !== 'number' ||
typeof day !== 'number' ||
typeof start.hour !== 'number' || typeof start.minute !== 'number' ||
typeof end.hour !== 'number' || typeof end.minute !== 'number'
) {
throw {
message: 'Invalid request',
code: 4,
}
}
const date = new Date()
date.setFullYear(year, month - 1, day)
date.setHours(start.hour, start.minute)
if (now.getTime() >= date.getTime()) {
throw {
message: 'Invalid request',
code: 5,
}
}
}
}
const validateRequestOpponent = (opponent?: Opponent): void => {
if (!opponent) return
const { id, name } = opponent
if (
typeof id !== 'string' ||
typeof name !== 'string' ||
id.length < 1 ||
name.length < 1
) {
throw {
message: 'Invalid request',
code: 6,
}
}
}

53
src/reservation.ts Normal file
View file

@ -0,0 +1,53 @@
export interface Time {
hour: number
minute: number
}
export interface Opponent {
id: string
name: string
}
export interface DateTime {
year: number
month: number
day: number
timeRange: {
start: Time
end: Time
}
}
export const timeToString = ({ hour, minute }: Time): string => `${`${hour}`.padStart(2, '0')}:${`${minute}`.padStart(2, '0')}`
export class Reservation {
public readonly dateTime: DateTime
public readonly opponent: Opponent
public readonly possibleTimes: Time[]
public booked: boolean = false
constructor (dateTime: DateTime, opponent: Opponent) {
this.dateTime = dateTime
this.opponent = opponent
this.possibleTimes = this.createPossibleTimes()
}
private createPossibleTimes() {
const possibleTimes: Time[] = []
const { start, end } = this.dateTime.timeRange
let { hour, minute } = start
let { hour: endHour, minute: endMinute } = end
while (hour <= endHour && minute <= endMinute) {
possibleTimes.push({ hour, minute })
minute = (minute + 15) % 60
if (minute === 0) {
hour++
}
}
return possibleTimes
}
}

86
src/runner.ts Normal file
View file

@ -0,0 +1,86 @@
import puppeteer, { Browser, BrowserConnectOptions, BrowserLaunchArgumentOptions, ElementHandle, LaunchOptions, Page } from "puppeteer"
import { DateTime, Opponent, Reservation, timeToString } from "./reservation";
export class Runner {
private readonly username: string
private readonly password: string
private readonly reservations: Reservation[]
private browser: Browser | undefined
private page: Page | undefined
public constructor(username: string, password: string, reservations: Reservation[], options?: LaunchOptions) {
this.username = username
this.password = password
this.reservations = reservations
}
public async run(options?: LaunchOptions & BrowserLaunchArgumentOptions & BrowserConnectOptions): Promise<Reservation[]> {
this.browser = await puppeteer.launch(options)
this.page = await this.browser.newPage()
await this.login()
return await this.makeReservations()
}
private async login() {
await this.page!.goto('https://squashcity.baanreserveren.nl/')
await this.page!.waitForSelector('input[name=username]').then(i => i!.type(this.username))
await this.page!.$('input[name=password]').then(i => i!.type(this.password))
await this.page!.$('button').then((b) => b!.click())
}
private async makeReservations(): Promise<Reservation[]> {
for (let i = 0; i < this.reservations.length; i++) {
await this.makeReservation(this.reservations[i])
}
return this.reservations
}
private async makeReservation(reservation: Reservation): Promise<void> {
try {
await this.navigateToDay(reservation.dateTime)
await this.selectAvailableTime(reservation)
await this.selectOpponent(reservation.opponent)
await this.confirmReservation()
reservation.booked = true
} catch (err) {
console.error(err)
}
}
private async navigateToDay(dt: DateTime): Promise<void> {
await this.page!.waitForSelector(`td#cal_${dt.year}_${dt.month}_${dt.day}`).then((d) => d!.click())
await this.page!.waitForSelector(`td#cal_${dt.year}_${dt.month}_${dt.day}.selected`)
}
private async selectAvailableTime(res: Reservation): Promise<void> {
let freeCourt: ElementHandle | null = null
let i = 0
while (i < res.possibleTimes.length && freeCourt !== null) {
const possibleTime = res.possibleTimes[i]
const timeString = timeToString(possibleTime)
const selector = `tr[data-time='${timeString}']` +
`> td.free[rowspan='3'][type='free']`
freeCourt = await this.page!.$(selector)
}
if (freeCourt === null) {
throw new Error("No free court available")
}
await freeCourt.click()
}
private async selectOpponent(opponent: Opponent): Promise<void> {
const player2Search = await this.page!.waitForSelector('tr.res-make-player-2 > td > input')
await player2Search!.type(opponent.name)
await this.page!.waitForNetworkIdle()
await this.page!.$('select.br-user-select[name="players[2]"]').then(d => d!.select(opponent.id))
}
private async confirmReservation(): Promise<void> {
await this.page!.$('input#__make_submit').then(b => b!.click())
await this.page!.waitForSelector("input#__make_submit2").then(b => b!.click())
}
}

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"lib": ["es2021"],
"module": "commonjs",
"target": "es2021",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist/"
},
"include": [
"src/**/*.ts"
]
}