autobaan/src/runner/baanreserveren/service.ts

276 lines
7.9 KiB
TypeScript
Raw Normal View History

import { Inject, Injectable } from '@nestjs/common'
import { Dayjs } from 'dayjs'
import { ElementHandle, Page } from 'puppeteer'
import { EmptyPage } from '../pages/empty'
import dayjs from '../../common/dayjs'
import { Reservation } from '../../reservations/entity'
2023-06-28 09:28:24 +02:00
import { LoggerService } from 'src/logger/service'
const baanReserverenRoot = 'https://squashcity.baanreserveren.nl'
export enum BaanReserverenUrls {
2023-06-27 16:06:19 +02:00
Reservations = '/reservations',
Logout = '/auth/logout',
}
enum SessionAction {
2023-06-27 16:06:19 +02:00
NoAction,
Logout,
Login,
}
interface BaanReserverenSession {
2023-06-27 16:06:19 +02:00
username: string
startedAt: Dayjs
}
@Injectable()
export class BaanReserverenService {
2023-06-27 16:06:19 +02:00
private session: BaanReserverenSession | null = null
constructor(
2023-06-28 09:28:24 +02:00
@Inject(LoggerService)
private readonly loggerService: LoggerService,
2023-06-27 16:06:19 +02:00
@Inject(EmptyPage)
private readonly page: Page,
) {}
private checkSession(username: string) {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Checking session', {
username,
session: this.session,
})
2023-06-27 16:06:19 +02:00
if (this.page.url().endsWith(BaanReserverenUrls.Reservations)) {
return this.session?.username !== username
? SessionAction.Logout
: SessionAction.NoAction
}
return SessionAction.Login
}
private startSession(username: string) {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Starting session', { username })
2023-06-27 16:06:19 +02:00
if (this.session && this.session.username !== username) {
throw new Error('Session already started')
}
if (this.session?.username === username) {
return
}
this.session = {
username,
startedAt: dayjs(),
}
}
private endSession() {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Ending session', { session: this.session })
2023-06-27 16:06:19 +02:00
this.session = null
}
private async login(username: string, password: string) {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Logging in', { username })
2023-06-27 16:06:19 +02:00
await this.page
.waitForSelector('input[name=username]')
.then((i) => i?.type(username))
.catch((e: Error) => {
throw new RunnerLoginUsernameInputError(e)
2023-06-27 16:06:19 +02:00
})
await this.page
.$('input[name=password]')
.then((i) => i?.type(password))
.catch((e: Error) => {
throw new RunnerLoginPasswordInputError(e)
2023-06-27 16:06:19 +02:00
})
await this.page
.$('button')
.then((b) => b?.click())
.catch((e: Error) => {
throw new RunnerLoginSubmitError(e)
2023-06-27 16:06:19 +02:00
})
this.startSession(username)
}
private async logout() {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Logging out')
2023-06-27 16:06:19 +02:00
await this.page.goto(`${baanReserverenRoot}${BaanReserverenUrls.Logout}`)
this.endSession()
}
private async init(reservation: Reservation) {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Initializing', { reservation })
2023-06-27 16:06:19 +02:00
await this.page.goto(baanReserverenRoot)
2023-06-28 09:28:24 +02:00
const action = this.checkSession(reservation.username)
2023-06-27 16:06:19 +02:00
switch (action) {
case SessionAction.Logout:
await this.logout()
await this.login(reservation.username, reservation.password)
break
case SessionAction.Login:
await this.login(reservation.username, reservation.password)
break
case SessionAction.NoAction:
default:
break
}
}
private getLastVisibleDay(): Dayjs {
const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0)
let daysToAdd = 0
switch (lastDayOfMonth.day()) {
case 0:
daysToAdd = 0
break
default:
daysToAdd = 7 - lastDayOfMonth.day()
break
}
return lastDayOfMonth.add(daysToAdd, 'day')
}
private async navigateToDay(date: Dayjs): Promise<void> {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Navigating to day', { date })
2023-06-27 16:06:19 +02:00
if (this.getLastVisibleDay().isBefore(date)) {
await this.page
?.waitForSelector('td.month.next')
.then((d) => d?.click())
.catch((e: Error) => {
this.loggerService.error('Failed to switch months', { error: e })
2023-06-27 16:06:19 +02:00
throw new RunnerNavigationMonthError(e)
})
}
await this.page
?.waitForSelector(
`td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
'date',
)}`,
)
.then((d) => d?.click())
.catch((e: Error) => {
this.loggerService.error('Failed to select day', { error: e })
2023-06-27 16:06:19 +02:00
throw new RunnerNavigationDayError(e)
})
await this.page
?.waitForSelector(
`td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
'date',
)}.selected`,
)
.catch((e: Error) => {
this.loggerService.error('Failed to wait for selected day', {
error: e,
})
2023-06-27 16:06:19 +02:00
throw new RunnerNavigationSelectionError(e)
})
}
private async selectAvailableTime(reservation: Reservation): Promise<void> {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Selecting available time', { reservation })
2023-06-27 16:06:19 +02:00
let freeCourt: ElementHandle | null | undefined
let i = 0
const possibleDates = reservation.createPossibleDates()
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Possible dates', { possibleDates })
2023-06-27 16:06:19 +02:00
while (i < possibleDates.length && !freeCourt) {
const possibleDate = possibleDates[i]
const timeString = possibleDate.format('HH:mm')
const selector =
`tr[data-time='${timeString}']` + `> td.free[rowspan='3'][type='free']`
freeCourt = await this.page?.$(selector)
i++
}
if (!freeCourt) {
throw new NoCourtAvailableError()
}
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Free court found', { freeCourt })
2023-06-27 16:06:19 +02:00
await freeCourt.click().catch((e: Error) => {
throw new RunnerCourtSelectionError(e)
})
}
private async selectOpponent(id: string, name: string): Promise<void> {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Selecting opponent', { id, name })
2023-06-27 16:06:19 +02:00
const player2Search = await this.page
?.waitForSelector('tr.res-make-player-2 > td > input')
.catch((e: Error) => {
throw new RunnerOpponentSearchError(e)
})
await player2Search?.type(name).catch((e: Error) => {
throw new RunnerOpponentSearchInputError(e)
})
await this.page?.waitForNetworkIdle().catch((e: Error) => {
throw new RunnerOpponentSearchNetworkError(e)
})
await this.page
?.$('select.br-user-select[name="players[2]"]')
.then((d) => d?.select(id))
.catch((e: Error) => {
throw new RunnerOpponentSearchSelectionError(e)
})
}
private async confirmReservation(): Promise<void> {
2023-06-28 09:28:24 +02:00
this.loggerService.debug('Confirming reservation')
2023-06-27 16:06:19 +02:00
await this.page
?.$('input#__make_submit')
.then((b) => b?.click())
.catch((e: Error) => {
throw new RunnerReservationConfirmButtonError(e)
})
await this.page
?.waitForSelector('input#__make_submit2')
.then((b) => b?.click())
.catch((e: Error) => {
throw new RunnerReservationConfirmSubmitError(e)
})
}
public async performReservation(reservation: Reservation) {
await this.init(reservation)
await this.navigateToDay(reservation.dateRangeStart)
await this.selectAvailableTime(reservation)
await this.selectOpponent(reservation.opponentId, reservation.opponentName)
2023-06-28 09:28:24 +02:00
await this.confirmReservation()
2023-06-27 16:06:19 +02:00
}
}
export class RunnerError extends Error {
2023-06-27 16:06:19 +02:00
constructor(error: Error) {
super(error.message)
this.stack = error.stack
}
}
export class PuppeteerError extends RunnerError {}
export class PuppeteerBrowserLaunchError extends PuppeteerError {}
export class PuppeteerNewPageError extends PuppeteerError {}
export class RunnerNewSessionError extends RunnerError {}
export class RunnerLogoutError extends RunnerError {}
export class RunnerLoginNavigationError extends RunnerError {}
export class RunnerLoginUsernameInputError extends RunnerError {}
export class RunnerLoginPasswordInputError extends RunnerError {}
export class RunnerLoginSubmitError extends RunnerError {}
export class RunnerNavigationMonthError extends RunnerError {}
export class RunnerNavigationDayError extends RunnerError {}
export class RunnerNavigationSelectionError extends RunnerError {}
export class RunnerCourtSelectionError extends RunnerError {}
export class NoCourtAvailableError extends Error {}
export class RunnerOpponentSearchError extends RunnerError {}
export class RunnerOpponentSearchInputError extends RunnerError {}
export class RunnerOpponentSearchNetworkError extends RunnerError {}
export class RunnerOpponentSearchSelectionError extends RunnerError {}
export class RunnerReservationConfirmButtonError extends RunnerError {}
export class RunnerReservationConfirmSubmitError extends RunnerError {}