Creating an Opera Booking Bot in Node.js

Picture of the inside of Garnier Opera in Paris

I think programming is the most convenient hobby one can have. You’re constantly learning new things, structuring your mind to focus and organize your code and most of all: you get to develop skills few people have — yet.

This project popped into my mind as I was trying to get preview tickets (Avant-Premières in French) on the website Opéra de Paris. Each time, all ~1800 tickets would sell in a matter of 1 to 2 minutes. I thought to myself, if only luck could get me to book the best seats, I’d rather work on a bot and deserve it!

While coding, I looked around the internet and found no simple nor straightforward tutorial teaching how to make that kind of script. I hope this one will help people avoid the annoyance of badly designed booking systems and perhaps also help developers figure out ways to reduce unwanted behaviors on their website (e.g. implementing API call rate-limits).


What You’ll Need


Step 1: Recon

Before considering any coding, you need to understand how your targeted website works. On Opéra de Paris, like most booking websites, you’ll need two things:

  • A way to log in (to access the booking form)
  • Relevant API requests (to access booking link faster)

Logging in

You could go for a “website targeted” approach and directly find the right API request used to log in. Instead, I chose the more general and easy one:

  1. Find login page
  2. Find input fields
  3. Find specific tag(s) revealing successful login

1. Find login page

That one is easy. Here’s the one I found for Opéra de Paris: https://www.operadeparis.fr/login

2. Find login input fields

Puppeteer needs them to select, type and click on the right fields. This is why you need to provide the right tags to your script. To do so, right-click on the username field and select Inspect element. The inspector will reveal the input field’s tag. Right-click on the tag in the inspector, hover over Copy then select Copy selector.

Once done, paste it in your config.js and repeat the process for the password input and submit button.

3. Find success-specific tag

Login and copy the selector that appears only when connected. Most of the time, this will be a username tag, welcome message or logout button. You know the drill: right-click, copy, copy selector and paste in config.js, which should now look something like this:

module.exports = {
  LOGIN\_PAGE: 'https://www.operadeparis.fr/login',
  USERNAME\_SELECTOR: 'body > div.content-wrapper > div > div > div > div.LoginBox\_\_form-container > form > input:nth-child(7)',
  PASSWORD\_SELECTOR: 'body > div.content-wrapper > div > div > div > div.LoginBox\_\_form-container > form > input:nth-child(8)',
  BUTTON\_SELECTOR: 'body > div.content-wrapper > div > div > div > div.LoginBox\_\_form-container > form > input.LoginBox\_\_form-submit.Button.Button--black.Button--block',
  SUCCESS\_SELECTOR\_1: 'body > div.content-wrapper > div > section.personalarea\_\_block--head.backToTop-visibilityTrigger',
  SUCCESS\_SELECTOR\_2: 'body > header > div > div > div.Header\_\_actions > div.Header\_\_account-only-mobile > div > a > span'
}

Note: you might need to find multiple selectors, depending on the website you’re targeting. For instance, Opéra de Paris has a distinct desktop and mobile version, thus two distinct success selectors.


Intercept API Calls

Problem: operadeparis.fr is extremely slow from five minutes before tickets go on sale, making the link barely accessible at the key moment. Solution: make API calls, so we only need to wait for a JSON response instead of waiting for all other files like to load (CSS, JS, etc…).

This step also requires you to use your inspector, except this time we’ll select the tab labeled Network instead of Elements (selected by default).

In our case, finding the right API requests was quite simple. When loading the performance page you’ll first see a lot of requests. Filter them out by selecting XHR. You’ll find a request labeled performance. Promising, right? Select it to discover the long-awaited request: https://www.operadeparis.fr/saison-19-20/ballet/hiroshi-sugimoto-william-forsythe/performances

We now know that to get info on any play, we only need to append /performances to the end of the “public” page.

Where’s my reservation link?

To find it, simply find a performance with at least one booking link published. Here’s a sample of what you get on Opéra de Paris.

{
  "items": \[
    {
      "dailyId": 1,
      "perfId": 2914,
      "content": {
        "performance": {
          "day": "jeu.",
          "dayNumber": "26",
          "month": "sept.",
          "time": "19:30",
          "type": "",
          "pictos": \[\],
          "mentions": \[
            "Avant-première jeunes (-28 ans)"
          \]
        },
        "prices": "Voir les tarifs",
        "expand": {
          "is\_gala": false,
          "is\_full": false,
          "categories": \[
            {
              "color": "FF9090",
              "code": "CatU",
              "price": "10 €",
              "availability": false
            }
          \]
        },
        "open\_to\_sale\_at": "En vente le 12 sept. à 12h00"
      },
      "template": "opening\_notice"
    },
    {
      "dailyId": 2,
      "perfId": 2661,
      "content": {
        "performance": {
          "day": "ven.",
          "dayNumber": "27",
          "month": "sept.",
          "time": "19:30",
          "type": "",
          "pictos": \[\],
          "mentions": \[
            "Première"
          \]
        },
        "prices": "de 135 à 210 €",
        "expand": {
          "is\_gala": false,
          "is\_full": false,
          "categories": \[
            {
              "color": "BCAA61",
              "code": "Optima",
              "price": "210 €",
              "availability": true
            },
            {
              "color": "E46567",
              "code": "Cat1",
              "price": "190 €",
              "availability": true
            }
          \]
        },
        "block": {
          "buttons": \[
            {
              "type": "CTASubscribe",
              "url": "https://www.operadeparis.fr/billetterie/487-les-indes-galantes/abonnements",
              "text": "S’abonner",
              "is\_bottom": false
            },
            {
              "type": "CTABook",
              "url": "https://billetterie.operadeparis.fr/api/1/redirect/product/performance?id=561186010&lang=fr",
              "text": "Réserver"
            }
          \]
        }
      },
      "template": "available"
    }
  \]
}

Here’s an example of where you can find a booking link: Object.items[1].content.bloc.buttons[1].url

We want to get tickets for Avant-Premières events. As this will always be the first play of all, we know the targeted booking link will always be listed at: Object.items[0].content.bloc.buttons[1].url

When you’re developing a booking bot, it’s essential to think ahead and handle API changes. You should be prepared to confront any change at openings and prevent your script from crashing. Therefore it’s wise to perform some verification:

if (Object.items[0].content.bloc.buttons[1])
    // cool, this link exists
else if (Object.items[0].content.bloc.buttons[0])
    // one button has be disabled
else
    console.log(`final link couldn't be found in API response`) // handle unexpected change

Step 2: Planning

Before getting into the coding part, we should get an overall idea of how our bot will work.

Starter

  • Get performance name: to make the right API request
  • Get users credentials: to be able to book a ticket

Main course

  • Log in: this should be performed first for a seamless booking process
  • Make the API request
  • Refresh: until booking link is published

Dessert

  • Redirect to the booking page
  • Notify user: on Opéra de Paris, once you click — or emulate a click — on the link for the first time, you’ll have to enter a captcha to enter the waiting line. With a notification, the user won’t miss nor forget to enter it.

Step 3: Coding

This is my favorite part.

Starter

Let’s start by parsing all Avant-Première performances from the website:

const config = require('../config.js')
const getJSON = require('get-json')
const striptags = require('striptags')
const inquirer = require('inquirer')

// Get performance name + corresponding API link
async function parseOnePerformance(elem) {
  response = await getJSON(`${elem.link}/performances`, (error, response) => {
    if (error) {
      throw new Error(`Error while parsing ${elem.link}/performances`)
    }
    return response
  })
  if (
    response &&
    response.items[0] &&
    response.items[0].content.performance.mentions[0] ==
      'Avant-première jeunes (-28 ans)'
  ) {
    let title = striptags(elem.title).replace('​', '') // Clean performance name
    return [title, elem.link + '/performances']
  }
  return null
}

// Get performances from single venue
async function getPerformancesFromVenue(venueRequestPage) {
  response = await getJSON(venueRequestPage, async function (error, response) {
    if (error) {
      throw new Error(`Error while parsing ${venueRequestPage}`)
    }
    return response
  })
  let events = {}
  for (let elem of response.datas) {
    result = await parseOnePerformance(elem)
    if (result && result[0] && result[1]) {
      events[result[0]] = result[1]
    }
  }
  return events
}

// Get performances for both venues (Opéra Garnier + Opéra Bastille)
module.exports.getPerformances = async function () {
  let events1 = await getPerformancesFromVenue(config.PERF_LIST_PAGE_GARNIER)
  let events2 = await getPerformancesFromVenue(config.PERF_LIST_PAGE_BASTILLE)
  return Object.assign(events1, events2)
}

// Display options to user
module.exports.getLink = async function (performances) {
  return new Promise((resolve) => {
    inquirer
      .prompt([
        {
          type: 'list',
          name: 'performance',
          message: 'Which performance do you want ?',
          choices: Object.keys(performances),
        },
      ])
      .then((answers) => {
        process.env.OPERA_PERF_LINK = performances[answers.performance]
        resolve()
      })
  })
}

In this file we proceed with two main steps:

  1. Parse and get all wanted performances from the website (we don’t want those not belonging to the Avant-Première type).
  2. The selection step, where users get to choose which one they want to get.

Because the API would not return all performances at once, I chose to separately call the ones from Opéra Garnier and Opéra Bastille.

Once parsing is done, we’re left with a dictionary formatted as: { performanceName: performanceApiLink, ... }. When users select a performance, we will know right away which API call to make.

Now for the login we can use env variables. We’ll need two functions:

  1. Ask if the user wants to use saved credentials (in case he exported his credentials before)
  2. Ask for credentials (if he decides to input them manually)
const inquirer = require('inquirer')

// Ask if user wants to use saved credentials
module.exports.keepCredentials = async function () {
  return new Promise((resolve) => {
    inquirer
      .prompt([
        {
          type: 'confirm',
          name: 'credentials',
          message: 'Do you want to use your saved credentials ?',
          default: [true],
        },
      ])
      .then((answers) => {
        resolve(answers.credentials)
      })
  })
}

// Ask for users credentials and store them as env variables
module.exports.getCredentials = async function () {
  return new Promise((resolve) => {
    inquirer
      .prompt([
        {
          type: 'input',
          name: 'username',
          message: 'Username ?',
        },
        {
          type: 'password',
          name: 'password',
          message: 'Password ?',
        },
      ])
      .then((answers) => {
        process.env.OPERA_USERNAME = answers.username
        process.env.OPERA_PASSWORD = answers.password
        resolve()
      })
  })
}

One more thing — we need to write our login function:

const config = require('../config.js')

module.exports.login = async function (page) {
  // Input credentials
  await page.goto(config.LOGIN_PAGE)
  await page.click(config.USERNAME_SELECTOR, { clickCount: 3 })
  await page.keyboard.type(process.env.OPERA_USERNAME)
  await page.click(config.PASSWORD_SELECTOR, { clickCount: 3 })
  await page.keyboard.type(process.env.OPERA_PASSWORD)
  await page.click(config.BUTTON_SELECTOR)

  // Check if login was successful
  try {
    const response = await page.waitForNavigation({ waituntil: 'loaded' })
    await response.request().redirectChain()
    await page.waitForSelector(
      `${config.SUCCESS_SELECTOR_1}, ${config.SUCCESS_SELECTOR_2}`
    )
    return
  } catch (error) {
    throw new Error('Failed to log in, try again')
  }
}

Great, we did it! We have all the main functions we need to start our program. Want to see how it all adds up? Let’s go.

Main course

Remember our first functions? parseEvents.js and getInputs.js? Let’s use them at the beginning of our file getMeATciket.js:

// Get avant-premiere links
try {
  console.log('Getting avant-premières of 19-20 season...')
  performances = await events.getPerformances()
  await events.getLink(performances)
} catch (error) {
  console.log(error)
  console.log('Error while parsing performance pages')
  process.exit(1)
}

// Get credentials
try {
  if (!process.env.OPERA_USERNAME || !process.env.OPERA_PASSWORD) {
    console.log(
      `Save your credentials with "export OPERA_USERNAME=yourUsername && export OPERA_PASSWORD=yourPassword"`
    )
    await inputs.getCredentials()
  } else {
    response = await inputs.keepCredentials()
    if (response == false) {
      await inputs.getCredentials()
    }
  }
} catch (error) {
  console.log('Error while getting your credentials')
  process.exit(1)
}

You must keep catching potential errors every step of the way so you know what to fix in case of a crash.

That was easy. Now let’s see how the core logic of the script works:

// Launch puppeteer
let browser
try {
  browser = await puppeteer.launch({
    headless: false,
    defaultViewport: null,
  })
} catch (error) {
  console.log('Error while getting performance link')
  process.exit(1)
}

const page = await browser.newPage()
await page.setDefaultTimeout(0) // Unset timeout -> when API slows down, script won't crash

// Login to users account
try {
  await login.login(page)
} catch (error) {
  console.log('Login failed (timeout or wrong credentials)')
  process.exit(1)
}
await page.goto(process.env.OPERA_PERF_LINK, { waitUntil: 'load' })

await page.content()
body = await page.evaluate(() => {
  // Getting page content
  return JSON.parse(document.querySelector('body').innerText) // Retrieve API response
})

// Repeat until booking available
while (body.items[0].template !== 'available') {
  await page.goto(process.env.OPERA_PERF_LINK, { waitUntil: 'load' })
  body = await page.evaluate(() => {
    return JSON.parse(document.querySelector('body').innerText)
  })
}

We first create a browser instance. We’ll set it to false to see how it works live. DefaultViewport is set to null so new pages aren’t constrained to a certain width and height.

In this part, we also:

  1. Log in
  2. Make the API call (corresponding to the users choice)
  3. Retrieve data from JSON response
  4. Refresh until the event targeted is not labeled “available

You could also add a refresh rate indicator to monitor the activity of your script.

Dessert

The end of the script is easier than the rest of it. You only need to redirect the user to the link found and make sure he sees it, whatever he’s doing at that time.

await utils.beep()
console.log(body.items[0].content.block.buttons[1].url)
console.log('Found it ! Getting you there...')

await page.bringToFront()

await page.goto(body.items[0].content.block.buttons[1].url, {
  waitUntil: 'networkidle0',
})
return 0

I first used a NodeJS package called play-sound to play a sound when a link was found. Once the sound is played, it displays a link in the users’ terminal and the user is redirected to the target page.bringToFront() finally focuses on the corresponding browser page.

That’s it! You just designed a working booking bot. As you can see, it doesn’t require much in the way of coding skills. Although this program works for a specific website, be advised that it might take a different approach to do the same thing for another reservation system.

Please note that this program does not intend to cause any harm to operadeparis.fr in any way. It’s not powerful enough to cause any denial of service. Neither is it meant to be used by everyone trying to get a ticket.

Don’t be a douche, use it wisely.

Here’s a link to the main repo: cute_little_bot