You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

477 lines
14 KiB

package ktmtrainbot
import (
"context"
"errors"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/user"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/devices"
"github.com/go-rod/rod/lib/launcher"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const IMAGE_DIR = "/tmp/screenshots"
const TIMEOUT_MINUTE = 60
func (env *Env) BackgroundJobRunner() {
log.Println("Initialising background job...")
initialiseRodBrowser()
log.Println("Browser initialised...")
// Initialise silent logger
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
logger.Config{
SlowThreshold: time.Second, // Slow SQL threshold
LogLevel: logger.Silent, // Log level
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
Colorful: true, // Disable color
},
)
tx := env.DB.Session(&gorm.Session{Logger: newLogger})
for {
var jobToDo Booking
err := tx.Model(&jobToDo).
Where("status = ?", "pending").
Preload("User").
First(&jobToDo).Error
// if no jobs pending found
if err != nil {
time.Sleep(time.Second)
continue
} else { // if there's job to do
// Create next run where it's not the past (either from old NextRun or now())
timeNow := time.Now()
if timeNow.Hour() == 0 && timeNow.Minute() == 10 {
err := env.DB.Where(&user.Profile{UserID: jobToDo.UserID}).First(&jobToDo.User.Profile).Error
if err != nil {
log.Println(err)
}
log.Printf("Start doing job: %v", jobToDo.ID)
username := jobToDo.User.Profile.KtmTrainUsername
password := jobToDo.User.Profile.KtmTrainPassword
creditCardType := jobToDo.User.Profile.KtmTrainCreditCardType
creditCard := jobToDo.User.Profile.KtmTrainCreditCard
creditCardCVV := jobToDo.User.Profile.KtmTrainCreditCardCVV
creditCardExpiry := jobToDo.User.Profile.KtmTrainCreditCardExpiry
func() {
defer func() {
if r := recover(); r != nil {
log.Println("Recovering from job panic...")
jobToDo.Status = "pending"
env.DB.Save(&jobToDo)
}
}()
success := env.startBooking(&jobToDo, username, password, creditCardType, creditCard, creditCardCVV, creditCardExpiry)
if success {
fmt.Println("Successfully made a booking.")
jobToDo.Status = "success"
env.DB.Save(jobToDo)
} else {
jobToDo.Status = "pending"
env.DB.Save(&jobToDo)
fmt.Println("Failed to make a booking.")
}
}()
jobToDo.Status = "running"
env.DB.Save(&jobToDo)
log.Printf("Job Started: %v", jobToDo.ID)
}
}
}
}
func initialiseRodBrowser() {
u := launcher.New().
Set("headless").
MustLaunch()
defaultDevice := devices.LaptopWithMDPIScreen
browser := rod.New().ControlURL(u).MustConnect().DefaultDevice(defaultDevice)
page := browser.MustPage("https://www.google.com").MustWindowFullscreen()
page.MustWaitLoad()
browser.MustClose()
// Initialise screenshot directory
if _, err := os.Stat(IMAGE_DIR); errors.Is(err, os.ErrNotExist) {
err := os.Mkdir(IMAGE_DIR, os.ModePerm)
if err != nil {
log.Println(err)
}
}
}
func (env *Env) startBooking(job *Booking, username string, password string, creditCardType string, creditCard string, creditCardCVV string, creditCardExpiry string) bool {
timerCtx, cancelTimer := context.WithTimeout(context.Background(), TIMEOUT_MINUTE*time.Minute)
defer cancelTimer()
headless := os.Getenv("HEADLESS")
var u string
if strings.ToUpper(headless) == "FALSE" {
u = launcher.New().
Set("headless").
Delete("--headless").
MustLaunch()
} else {
u = launcher.New().
Set("headless").
MustLaunch()
}
defaultDevice := devices.LaptopWithMDPIScreen
// defaultDevice.Screen.Vertical.Height = defaultDevice.Screen.Horizontal.Height
// defaultDevice.Screen.Vertical.Width = defaultDevice.Screen.Horizontal.Width
defaultDevice.UserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36"
browser := rod.New().ControlURL(u).MustConnect().DefaultDevice(defaultDevice)
// Defer closing browser
defer browser.MustClose()
postLoginPage := ktmTrainLogin(browser, username, password)
nowTimeStr := time.Now().Format("2006-01-02-15_04_05")
postLoginPage.MustWaitLoad().MustScreenshot(fmt.Sprintf("%s/%s-01-login.png", IMAGE_DIR, nowTimeStr))
// Exits if context cancelled
select {
case <-timerCtx.Done():
browser.MustClose()
return false
default:
}
var page *rod.Page
getBookingSlotCtx, cancelGetBookingSlot := context.WithTimeout(context.Background(), TIMEOUT_MINUTE*time.Minute)
defer cancelGetBookingSlot()
pageChan := make(chan *rod.Page)
onwardDate := job.TravelDate.Format("2 Jan 2006")
timeCode := job.TimeCode
name := job.Name
gender := job.Gender
passport := job.Passport
passportExpiry := job.PassportExpiry.Format("2 Jan 2006")
contact := job.Contact
threadCount := 10
for i := 0; i < threadCount; i++ {
time.Sleep(time.Millisecond * 100)
go func() {
currPage := getBookingSlots(browser, onwardDate)
log.Println("Booking page loaded.")
currPage = selectBookingSlot(getBookingSlotCtx, currPage, timeCode)
log.Println("Booking slot selected.")
select {
case <-getBookingSlotCtx.Done():
return
default:
log.Println("First page loaded.")
}
// Cancelling context
cancelGetBookingSlot()
pageChan <- currPage
}()
}
page = <-pageChan
page.MustActivate()
// Exits if context cancelled
select {
case <-timerCtx.Done():
browser.MustClose()
return false
default:
}
page = fillPassengerDetails(page, name, gender, passport, passportExpiry, contact)
log.Println("Passenger details filled.")
// Exits if context cancelled
select {
case <-timerCtx.Done():
browser.MustClose()
return false
default:
}
page = choosePayment(page)
log.Println("Payment method chosen.")
// Wait 5 seconds for payment gateway to load
time.Sleep(time.Second * 5)
for _, currPage := range browser.MustPages() {
currPage.MustWaitLoad()
var currTitle string
err := rod.Try(func() {
currTitle = currPage.Timeout(100 * time.Millisecond).MustElement("title").MustText()
})
if err != nil {
currTitle = ""
}
if strings.Contains(currTitle, "Payment Acceptance") {
page = currPage
}
}
// Exits if context cancelled
select {
case <-timerCtx.Done():
browser.MustClose()
return false
default:
}
expiryMonth := strings.Split(creditCardExpiry, "/")[0]
expiryYear := strings.Split(creditCardExpiry, "/")[1]
page = makePayment(page, creditCardType, creditCard, expiryMonth, expiryYear, creditCardCVV)
log.Println("Payment made.")
// // Start debug screenshots
// debugScreenshotCtx, cancelDebugScreenshot := context.WithCancel(context.Background())
// go takeDebugScreenshots(debugScreenshotCtx, courtPage)
// // Defer done with debug screenshot
// defer cancelDebugScreenshot()
time.Sleep(600 * time.Second)
_ = page
browser.MustClose()
return true
}
func ktmTrainLogin(browser *rod.Browser, username string, password string) *rod.Page {
page := browser.MustPage("https://online.ktmb.com.my/Account/Login")
page.MustElement("#Email").MustInput(username)
page.MustElement("#Password").MustInput(password)
page.MustElement("#LoginButton").MustClick()
return page
}
func getBookingSlots(browser *rod.Browser, onwardDate string) *rod.Page {
page := browser.MustPage("https://shuttleonline.ktmb.com.my/Home/Shuttle")
page.MustWaitLoad()
// Dismiss system maintenance warning
bodyText := page.MustElement("body").MustText()
containsCheckStr := "System maintenance scheduled at 23:00 to 00:15"
if strings.Contains(bodyText, containsCheckStr) {
page.MustEval(`() => document.querySelector("#validationSummaryModal > div > div > div.modal-body > div > div.text-center > button").click()`)
}
passengerCount := 1
passengerCountStr := strconv.Itoa(passengerCount)
requestVerificationToken := page.MustElement("#theForm > input[name=__RequestVerificationToken]").MustAttribute("value")
// Get JB Sentral Station Info
jBSentralData := page.MustElement("#FromStationData").MustAttribute("value")
jBSentralID := page.MustElement("#FromStationId").MustAttribute("value")
// Get Woodlands Station Info
woodlandsData := page.MustElement("#ToStationData").MustAttribute("value")
woodlandsID := page.MustElement("#ToStationId").MustAttribute("value")
sensitiveCustomForm(page, *woodlandsData, *jBSentralData, *woodlandsID, *jBSentralID, onwardDate, passengerCountStr, *requestVerificationToken)
page.MustWaitLoad()
return page
}
func selectBookingSlot(ctx context.Context, page *rod.Page, timeCode string) *rod.Page {
time.Sleep(1 * time.Second)
// Initial closing of maintenance modal
bodyText := page.MustElement("body").MustText()
if strings.Contains(bodyText, "System maintenance scheduled at 23:00 to 00:15 (UTC+8)") {
closeModalButton := page.MustElement("#popupModalCloseButton")
closeModalButton.Eval(`this.click()`)
time.Sleep(1000 * time.Millisecond)
}
// Start probing
completed := false
for !completed {
page.MustWaitLoad()
departTripsTable := page.MustElement(".depart-trips")
departRows := departTripsTable.MustElements("tr")
var rowElement *rod.Element
for _, row := range departRows {
timeCodeElement := row.MustAttribute("data-hourminute")
if *timeCodeElement == timeCode {
rowElement = row
}
}
// Checks for context before clicking
select {
case <-ctx.Done():
return page
default:
}
selectButtonElement := rowElement.MustElement("a")
selectButtonElement.Eval(`this.click()`)
time.Sleep(1000 * time.Millisecond)
page.MustWaitLoad()
// Check before exiting
bodyText := page.MustElement("body").MustText()
if strings.Contains(bodyText, "System maintenance scheduled at 23:00 to 00:15 (UTC+8).") {
completed = false
closeModalButton := page.MustElement("#popupModalCloseButton")
closeModalButton.Eval(`this.click()`)
time.Sleep(1000 * time.Millisecond)
} else {
log.Println("Completed probing")
completed = true
}
}
// Checks for context before clicking
select {
case <-ctx.Done():
return page
default:
}
proceedButton := page.MustElement(".proceed-btn")
proceedButton.Eval(`this.click()`)
return page
}
func fillPassengerDetails(page *rod.Page, name string, gender string, passport string, passportExpiry string, contact string) *rod.Page {
ticketType := "DEWASA/ADULT"
nameElement := page.MustElement(".FullName")
nameElement.MustInput(name)
passportElement := page.MustElement(".PassportNo")
passportElement.MustInput(passport)
passportExpiryElement := page.MustElement("#Passengers_0__PassportExpiryDate")
passportExpiryElement.MustInput(passportExpiry)
contactElement := page.MustElement(".ContactNo")
contactElement.MustInput(contact)
if gender == "M" {
maleElement := page.MustElement("#Passengers_0__GenderMale")
maleElement.Eval(`this.click()`)
} else {
femaleElement := page.MustElement("#Passengers_0__GenderFemale")
femaleElement.Eval(`this.click()`)
}
ticketTypeElement := page.MustElement("#Passengers_0__TicketTypeId")
ticketTypeElement.MustSelect(ticketType)
paymentButton := page.MustElement("#btnConfirmPayment")
paymentButton.Eval(`this.click()`)
page.MustWaitLoad()
return page
}
func choosePayment(page *rod.Page) *rod.Page {
creditCardButton := page.MustElement(".btn-public-bank")
creditCardButton.Eval(`this.click()`)
page.MustWaitLoad()
paymentGatewayButton := page.MustElement("#PaymentGateway")
paymentGatewayButton.Eval(`this.click()`)
page.MustWaitLoad()
return page
}
func makePayment(page *rod.Page, cardType string, creditCard string, expiryMonth string, expiryYear string, creditCardCVV string) *rod.Page {
if cardType == "Visa" {
visaRadio := page.MustElement("#card_type_001")
visaRadio.Eval(`this.click()`)
} else if cardType == "Mastercard" {
masterRadio := page.MustElement("#card_type_002")
masterRadio.Eval(`this.click()`)
}
creditCardElement := page.MustElement("#card_number")
creditCardElement.MustInput(creditCard)
expiryMonthElement := page.MustElement("#card_expiry_month")
expiryMonthElement.MustSelect(expiryMonth)
expiryYearElement := page.MustElement("#card_expiry_year")
expiryYearElement.MustSelect(expiryYear)
creditCardCVVElement := page.MustElement("#card_cvn")
creditCardCVVElement.MustInput(creditCardCVV)
log.Println("Before payment")
time.Sleep(10 * time.Millisecond)
payButton := page.MustElement(".pay_button")
payButton.Eval(`this.click()`)
return page
}
func sensitiveCustomForm(
page *rod.Page,
fromStationData string,
toStationData string,
fromStationID string,
toStationID string,
onwardDate string,
passengerCount string,
csrf string,
) *rod.Page {
defer func() {
if r := recover(); r != nil {
log.Println("Recovered in sensitiveCustomForm", r)
}
}()
formHTML := fmt.Sprintf(`
<form action="https://shuttleonline.ktmb.com.my/ShuttleTrip" method="POST">
<input type="hidden" name="FromStationData" value="%s" />
<input type="hidden" name="ToStationData" value="%s" />
<input type="hidden" name="FromStationId" value="%s" />
<input type="hidden" name="ToStationId" value="%s" />
<input type="hidden" name="OnwardDate" value="%s" />
<input type="hidden" name="ReturnDate" value="" />
<input type="hidden" name="PassengerCount" value="%s" />
<input type="hidden" name="__RequestVerificationToken" value="%s" />
<input type="submit" id="presshere" value="Submit request" />
</form>
UniqueStringHere
`, fromStationData, toStationData, fromStationID, toStationID, onwardDate, passengerCount, csrf)
page.MustElement("body").MustEval(fmt.Sprintf("() => this.innerHTML = `%s`", formHTML))
// page.MustElement("#presshere").MustClick()
page.MustEval(`() => document.querySelector("#presshere").click()`)
return page
}