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