Создание приложения, чтобы получить некоторые труднодоступные билеты

Я считаю, что программирование - самое удобное хобби. Вы постоянно изучаете что-то новое, структурируете свой разум, чтобы сосредоточить и упорядочить свой код, и, самое главное, вы можете развить навыки, которыми пока что обладают немногие.

Этот проект пришел мне в голову, когда я пытался получить билеты для предварительного просмотра (Avant-Premières на французском языке) на веб-сайте Opéra de Paris. Каждый раз все ~ 1800 билетов продавались в течение 1-2 минут. Я подумал про себя, если бы только удача могла заставить меня забронировать лучшие места, я бы лучше поработал над ботом и заслужил это!

Во время кодирования я поискал в Интернете и нашел не простой и понятный учебник, в котором рассказывается, как создать такой сценарий. Я надеюсь, что это поможет людям избежать раздражения из-за плохо спроектированных систем бронирования и, возможно, также поможет разработчикам найти способы уменьшить нежелательное поведение на их веб-сайтах (например, внедрение ограничений скорости вызовов API).

Что вам понадобится

Шаг 1: Разведка

Прежде чем рассматривать какое-либо кодирование, вам необходимо понять, как работает ваш целевой веб-сайт. В Opéra de Paris, как и на большинстве сайтов бронирования, вам понадобятся две вещи:

  • Способ входа в систему (доступ к форме бронирования)
  • Соответствующие запросы API (для более быстрого доступа к ссылке бронирования)

Вход в систему

Вы можете выбрать подход, ориентированный на веб-сайт, и напрямую найти нужный API-запрос, используемый для входа в систему. Вместо этого я выбрал более общий и простой:

  1. Найти страницу входа
  2. Найти поля ввода
  3. Найдите определенные теги, показывающие успешный вход

1. Найдите страницу входа

Это просто. Вот тот, который я нашел для Парижской оперы: https://www.operadeparis.fr/login

2. Найдите поля для ввода логина.

Кукольнику они нужны, чтобы выбрать, ввести и щелкнуть в нужных полях. Вот почему вам нужно предоставить правильные теги для вашего скрипта. Для этого щелкните правой кнопкой мыши в поле имени пользователя и выберите Inspect element. Инспектор покажет тег поля ввода. Щелкните правой кнопкой мыши тег в инспекторе, наведите указатель мыши на Copy и выберите Copy selector.

После этого вставьте его в свой config.js и повторите процесс для ввода пароля и кнопки отправить.

3) Найдите тег, связанный с успехом

Войдите и скопируйте селектор, который появляется только при подключении. В большинстве случаев это будет тег имени пользователя, приветственное сообщение или кнопка выхода.
Вы знаете, что такое упражнение: щелкните правой кнопкой мыши, скопируйте, скопируйте селектор и вставьте config.js, , который теперь должен выглядеть примерно так:

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'
}

Примечание. В зависимости от целевого веб-сайта вам может потребоваться несколько селекторов. Например, у Opéra de Paris есть отдельные версии для настольных компьютеров и мобильных устройств, то есть два разных селектора успеха.

Перехват вызовов API

Проблема: operadeparis.fr работает очень медленно за пять минут до поступления билетов, что делает ссылку недоступной в ключевой момент.
Решение: вызовите API, поэтому нам нужно только дождаться ответа JSON. ожидания загрузки всех остальных файлов (CSS, JS и т. д.).

На этом шаге также требуется использовать инспектор, за исключением того, что на этот раз мы выберем вкладку с надписью Сеть вместо Элементы (выбранной по умолчанию).

В нашем случае найти подходящие запросы API было довольно просто. При загрузке страницы производительности вы сначала увидите много запросов. Отфильтруйте их, выбрав XHR. Вы найдете запрос с пометкой performance. Многообещающе, правда? Выберите его, чтобы открыть долгожданный запрос:
https://www.operadeparis.fr/saison-19-20/ballet/hiroshi-sugimoto-william-forsythe/performances

Теперь мы знаем, что для получения информации о любой игре нам нужно всего лишь добавить /performances в конец «общедоступной» страницы.

Где моя ссылка на бронирование?

Чтобы его найти, просто найдите выступление, у которого есть хотя бы одна опубликованная ссылка для бронирования.
Вот пример того, что вы получите в 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"
    }
  ]
}

Вот пример того, где вы можете найти ссылку для бронирования:
Object.items[1].content.bloc.buttons[1].url

Мы хотим получить билеты на мероприятия Avant-Premières. Поскольку это всегда будет первая игра, мы знаем, что целевая ссылка для бронирования всегда будет указана по адресу: Object.items[0].content.bloc.buttons[1].url

Когда вы разрабатываете бот для бронирования, важно думать наперед и обрабатывать изменения API. Вы должны быть готовы противостоять любым изменениям в открытии и предотвратить сбой вашего скрипта. Поэтому целесообразно выполнить некоторую проверку:

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

Шаг 2: планирование

Прежде чем перейти к кодированию, мы должны получить общее представление о том, как будет работать наш бот.

Стартер

  • Получить название производительности: чтобы сделать правильный запрос API
  • Получите учетные данные пользователей: чтобы иметь возможность забронировать билет

Основное блюдо

  • Войти: это необходимо сделать в первую очередь для беспрепятственного процесса бронирования.
  • Сделайте запрос API
  • Обновить: пока не будет опубликована ссылка на бронирование.

Десерт

  • Перенаправить на страницу бронирования
  • Уведомить пользователя: в Парижской опере после того, как вы впервые нажмете - или имитирует щелчок - по ссылке, вам нужно будет ввести капчу, чтобы ввести очередь ожидания. С уведомлением пользователь не пропустит и не забудет его ввести.

Шаг 3. Кодирование

Это моя любимая часть.

Стартер

Начнем с анализа всех исполнений Avant-Première с веб-сайта:

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()
      })
    })
}

В этом файле мы выполняем два основных шага:

  1. Анализируйте и получайте все требуемые характеристики с веб-сайта (мы не хотим, чтобы они не принадлежали к типу Avant-Première).
  2. Шаг выбор, на котором пользователи могут выбрать, какой из них они хотят получить.

Поскольку API не может возвращать все исполнения сразу, я решил по отдельности вызывать выступления из Оперы Гарнье и Оперы Бастилии.

После завершения синтаксического анализа у нас остается словарь в формате:
{ performanceName: performanceApiLink, ... }. Когда пользователи выбирают производительность, мы сразу узнаем, какой вызов API сделать.

Теперь для входа мы можем использовать env переменных. Нам понадобятся две функции:

  1. Спросите, хочет ли пользователь использовать сохраненные учетные данные (в случае, если он экспортировал свои учетные данные раньше)
  2. Спросите учетные данные (если он решит ввести их вручную)
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()
      })
    })
}

Еще одна вещь - нам нужно написать нашу функцию входа в систему:

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')
  }
}

Отлично, у нас получилось! У нас есть все основные функции, необходимые для запуска нашей программы. Хотите увидеть, как все это складывается? Пойдем.

Основное блюдо

Помните наши первые функции? parseEvents.js и getInputs.js?
Давайте используем их в начале нашего файла 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)
}

Вы должны постоянно обнаруживать потенциальные ошибки на каждом этапе, чтобы знать, что исправить в случае сбоя.

Это было просто. Теперь давайте посмотрим, как работает основная логика скрипта:

// 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)
  })
}

Сначала мы создаем экземпляр браузера. Мы установим для него значение false, чтобы увидеть, как это работает вживую. DefaultViewport имеет значение null, поэтому новые страницы не ограничиваются определенной шириной и высотой.

В этой части мы также:
1. Выполним вход в систему
2. Сделаем вызов API (в соответствии с выбором пользователя)
3. Получим данные из ответа JSON.
4. Обновляйте, пока целевое событие не перестанет быть помечено как «доступно».

Вы также можете добавить индикатор частоты обновления для отслеживания активности вашего скрипта.

Десерт

Конец сценария легче, чем остальная часть. Вам нужно только перенаправить пользователя на найденную ссылку и убедиться, что он ее видит, что бы он ни делал в это время.

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

Сначала я использовал пакет NodeJS под названием play-sound для воспроизведения звука при обнаружении ссылки. Как только звук воспроизводится, он отображает ссылку в пользовательском терминале, и пользователь перенаправляется на целевую страницу. bringToFront() наконец фокусируется на соответствующей странице браузера.

Вот и все! Вы только что создали работающего бота для бронирования. Как видите, это не требует особых навыков программирования. Хотя эта программа работает для определенного веб-сайта, имейте в виду, что она может использовать другой подход, чтобы сделать то же самое для другой системы бронирования.

Обратите внимание, что эта программа никоим образом не предназначена причинить вред operadeparis.fr. Он недостаточно мощный, чтобы вызвать отказ в обслуживании. Он также не предназначен для использования всеми, кто пытается получить билет.

Не будь глупцом, используй его с умом.

Вот ссылка на основное репо: