Initial commit with first working version
This commit is contained in:
commit
905cd4e194
11 changed files with 5295 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# environment
|
||||
.env*
|
||||
|
||||
# node / javascript
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# terraform
|
||||
.terraform/
|
||||
deploy/
|
||||
1
README.md
Normal file
1
README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# autobaan
|
||||
3
babel.config.json
Normal file
3
babel.config.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"presets": ["@babel/preset-typescript"]
|
||||
}
|
||||
4909
package-lock.json
generated
Normal file
4909
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal 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
18
src/handler.ts
Normal 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
49
src/local.ts
Normal 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
120
src/request.ts
Normal 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
53
src/reservation.ts
Normal 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
86
src/runner.ts
Normal 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
19
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue