Créer un Bot de Réservation d'Opéra en Node.js
Je pense que la programmation est le hobby le plus pratique qu'on puisse avoir. On apprend constamment de nouvelles choses, on structure son esprit pour se concentrer et organiser son code et surtout : on développe des compétences que peu de gens possèdent — pour l'instant.
Ce projet m'est venu à l'esprit alors que j'essayais d'obtenir des billets d'avant-première sur le site de l'Opéra de Paris. À chaque fois, tous les ~1800 billets se vendaient en 1 à 2 minutes. Je me suis dit, si seule la chance peut m'aider à réserver les meilleures places, autant travailler sur un bot et le mériter !
En codant, j'ai cherché sur internet et je n'ai trouvé aucun tutoriel simple ni direct expliquant comment faire ce genre de script. J'espère que celui-ci aidera les gens à éviter les désagréments des systèmes de réservation mal conçus et peut-être aussi aider les développeurs à trouver des moyens de réduire les comportements indésirables sur leur site web (par exemple, implémenter des limitations de taux d'appels API).
Ce dont vous aurez besoin
- Éditeur de texte (ex. Visual Studio Code, Atom…)
- Node.js (comment l'installer)
- Puppeteer (comment l'obtenir)
- L'inspecteur de votre navigateur
Étape 1 : Reconnaissance
Avant d'envisager tout codage, vous devez comprendre comment fonctionne le site web ciblé. Sur l'Opéra de Paris, comme la plupart des sites de réservation, vous aurez besoin de deux choses :
- Un moyen de se connecter (pour accéder au formulaire de réservation)
- Les requêtes API pertinentes (pour accéder plus rapidement au lien de réservation)
Se connecter
Vous pourriez opter pour une approche "ciblée sur le site web" et trouver directement la bonne requête API utilisée pour se connecter. Au lieu de cela, j'ai choisi la méthode plus générale et facile :
- Trouver la page de connexion
- Trouver les champs de saisie
- Trouver les tag(s) spécifique(s) révélant une connexion réussie
1. Trouver la page de connexion
Celle-ci est facile. Voici celle que j'ai trouvée pour l'Opéra de Paris : https://www.operadeparis.fr/login
2. Trouver les champs de saisie de connexion
Puppeteer en a besoin pour sélectionner, taper et cliquer sur les bons champs. C'est pourquoi vous devez fournir les bons tags à votre script. Pour ce faire, clic droit sur le champ nom d'utilisateur et sélectionnez Inspecter l'élément. L'inspecteur révélera le tag du champ de saisie. Clic droit sur le tag dans l'inspecteur, survolez Copier puis sélectionnez Copier le sélecteur.
Une fois fait, collez-le dans votre config.js et répétez le processus pour le champ mot de passe et le bouton valider.
3. Trouver le tag spécifique au succès
Connectez-vous et copiez le sélecteur qui apparaît uniquement quand vous êtes connecté. La plupart du temps, ce sera un tag nom d'utilisateur, message de bienvenue ou bouton de déconnexion.
Vous connaissez la procédure : clic droit, copier, copier le sélecteur et coller dans config.js, qui devrait maintenant ressembler à quelque chose comme ça :
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 : vous pourriez avoir besoin de trouver plusieurs sélecteurs, selon le site web que vous ciblez. Par exemple, l'Opéra de Paris a une version desktop et mobile distincte, donc deux sélecteurs de succès distincts.
Intercepter les appels API
Problème : operadeparis.fr est extrêmement lent cinq minutes avant que les billets ne soient mis en vente, rendant le lien à peine accessible au moment clé. Solution : faire des appels API, donc nous n'avons qu'à attendre une réponse JSON au lieu d'attendre que tous les autres fichiers se chargent (CSS, JS, etc...).
Cette étape nécessite aussi que vous utilisiez votre inspecteur, sauf que cette fois nous sélectionnerons l'onglet intitulé Network au lieu d'Elements (sélectionné par défaut).
Dans notre cas, trouver les bonnes requêtes API était assez simple. Lors du chargement de la page de spectacle, vous verrez d'abord beaucoup de requêtes. Filtrez-les en sélectionnant XHR. Vous trouverez une requête intitulée performance. Prometteur, non ? Sélectionnez-la pour découvrir la requête tant attendue :
https://www.operadeparis.fr/saison-19-20/ballet/hiroshi-sugimoto-william-forsythe/performances
Nous savons maintenant que pour obtenir des infos sur n'importe quel spectacle, nous n'avons qu'à ajouter /performances à la fin de la page "publique".
Où est mon lien de réservation ?
Pour le trouver, trouvez simplement un spectacle avec au moins un lien de réservation publié. Voici un échantillon de ce que vous obtenez sur l'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"
}
\]
}
Voici un exemple où vous pouvez trouver un lien de réservation :
Object.items[1].content.bloc.buttons[1].url
Nous voulons obtenir des billets pour les événements Avant-Premières. Comme ce sera toujours le premier spectacle de tous, nous savons que le lien de réservation ciblé sera toujours listé à : Object.items[0].content.bloc.buttons[1].url
Quand vous développez un bot de réservation, il est essentiel de penser à l'avenir et de gérer les changements d'API. Vous devriez être prêt à confronter tout changement lors des ouvertures et empêcher votre script de planter. Par conséquent, il est sage de faire quelques vérifications :
if (Object.items[0].content.bloc.buttons[1])
// cool, ce lien existe
else if (Object.items[0].content.bloc.buttons[0])
// un bouton a été désactivé
else
console.log(`le lien final n'a pas pu être trouvé dans la réponse API`) // gérer un changement inattendu
Étape 2 : Planification
Avant de se lancer dans la partie codage, nous devrions avoir une idée générale de comment notre bot va fonctionner.
Entrée
- Obtenir le nom du spectacle : pour faire la bonne requête API
- Obtenir les identifiants de l'utilisateur : pour pouvoir réserver un billet
Plat principal
- Se connecter : ceci devrait être fait en premier pour un processus de réservation fluide
- Faire la requête API
- Actualiser : jusqu'à ce que le lien de réservation soit publié
Dessert
- Rediriger vers la page de réservation
- Notifier l'utilisateur : sur l'Opéra de Paris, une fois que vous cliquez — ou émulez un clic — sur le lien pour la première fois, vous devrez entrer un captcha pour entrer dans la file d'attente. Avec une notification, l'utilisateur ne manquera pas ni n'oubliera de l'entrer.
Étape 3 : Codage
C'est ma partie préférée.
Entrée
Commençons par analyser toutes les performances Avant-Première du site web :
const config = require('../config.js')
const getJSON = require('get-json')
const striptags = require('striptags')
const inquirer = require('inquirer')
// Obtenir le nom du spectacle + lien API correspondant
async function parseOnePerformance(elem) {
response = await getJSON(`${elem.link}/performances`, (error, response) => {
if (error) {
throw new Error(`Erreur lors de l'analyse de ${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('​', '') // Nettoyer le nom du spectacle
return [title, elem.link + '/performances']
}
return null
}
// Obtenir les spectacles d'une seule salle
async function getPerformancesFromVenue(venueRequestPage) {
response = await getJSON(venueRequestPage, async function (error, response) {
if (error) {
throw new Error(`Erreur lors de l'analyse de ${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
}
// Obtenir les spectacles pour les deux salles (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)
}
// Afficher les options à l'utilisateur
module.exports.getLink = async function (performances) {
return new Promise((resolve) => {
inquirer
.prompt([
{
type: 'list',
name: 'performance',
message: 'Quel spectacle voulez-vous ?',
choices: Object.keys(performances),
},
])
.then((answers) => {
process.env.OPERA_PERF_LINK = performances[answers.performance]
resolve()
})
})
}
Dans ce fichier, nous procédons en deux étapes principales :
- Analyser et obtenir tous les spectacles voulus du site web (nous ne voulons pas ceux qui n'appartiennent pas au type Avant-Première).
- L'étape de sélection, où les utilisateurs choisissent celui qu'ils veulent obtenir.
Parce que l'API ne retournerait pas tous les spectacles en une fois, j'ai choisi d'appeler séparément ceux de l'Opéra Garnier et de l'Opéra Bastille.
Une fois l'analyse terminée, nous nous retrouvons avec un dictionnaire formaté comme :
{ nomSpectacle: lienApiSpectacle, ... }. Quand les utilisateurs sélectionnent un spectacle, nous saurons tout de suite quel appel API faire.
Maintenant pour la connexion, nous pouvons utiliser des variables env. Nous aurons besoin de deux fonctions :
- Demander si l'utilisateur veut utiliser les identifiants sauvegardés (au cas où il aurait exporté ses identifiants avant)
- Demander les identifiants (s'il décide de les saisir manuellement)
const inquirer = require('inquirer')
// Demander si l'utilisateur veut utiliser les identifiants sauvegardés
module.exports.keepCredentials = async function () {
return new Promise((resolve) => {
inquirer
.prompt([
{
type: 'confirm',
name: 'credentials',
message: 'Voulez-vous utiliser vos identifiants sauvegardés ?',
default: [true],
},
])
.then((answers) => {
resolve(answers.credentials)
})
})
}
// Demander les identifiants de l'utilisateur et les stocker comme variables env
module.exports.getCredentials = async function () {
return new Promise((resolve) => {
inquirer
.prompt([
{
type: 'input',
name: 'username',
message: 'Nom d\'utilisateur ?',
},
{
type: 'password',
name: 'password',
message: 'Mot de passe ?',
},
])
.then((answers) => {
process.env.OPERA_USERNAME = answers.username
process.env.OPERA_PASSWORD = answers.password
resolve()
})
})
}
Une chose de plus — nous devons écrire notre fonction de connexion :
const config = require('../config.js')
module.exports.login = async function (page) {
// Saisir les identifiants
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)
// Vérifier si la connexion a réussi
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('Échec de la connexion, réessayez')
}
}
Parfait, nous l'avons fait ! Nous avons toutes les fonctions principales dont nous avons besoin pour démarrer notre programme. Vous voulez voir comment tout s'additionne ? Allons-y.
Plat principal
Vous vous souvenez de nos premières fonctions ? parseEvents.js et getInputs.js ?
Utilisons-les au début de notre fichier getMeATciket.js :
// Obtenir les liens avant-première
try {
console.log('Récupération des avant-premières de la saison 19-20...')
performances = await events.getPerformances()
await events.getLink(performances)
} catch (error) {
console.log(error)
console.log('Erreur lors de l\'analyse des pages de spectacle')
process.exit(1)
}
// Obtenir les identifiants
try {
if (!process.env.OPERA_USERNAME || !process.env.OPERA_PASSWORD) {
console.log(
`Sauvegardez vos identifiants avec "export OPERA_USERNAME=votreNomUtilisateur && export OPERA_PASSWORD=votreMotDePasse"`
)
await inputs.getCredentials()
} else {
response = await inputs.keepCredentials()
if (response == false) {
await inputs.getCredentials()
}
}
} catch (error) {
console.log('Erreur lors de la récupération de vos identifiants')
process.exit(1)
}
Vous devez continuer à capturer les erreurs potentielles à chaque étape pour savoir quoi corriger en cas de plantage.
C'était facile. Maintenant voyons comment fonctionne la logique principale du script :
// Lancer puppeteer
let browser
try {
browser = await puppeteer.launch({
headless: false,
defaultViewport: null,
})
} catch (error) {
console.log('Erreur lors de l\'obtention du lien de spectacle')
process.exit(1)
}
const page = await browser.newPage()
await page.setDefaultTimeout(0) // Désactiver le timeout -> quand l'API ralentit, le script ne plantera pas
// Se connecter au compte utilisateur
try {
await login.login(page)
} catch (error) {
console.log('Échec de la connexion (timeout ou mauvais identifiants)')
process.exit(1)
}
await page.goto(process.env.OPERA_PERF_LINK, { waitUntil: 'load' })
await page.content()
body = await page.evaluate(() => {
// Obtenir le contenu de la page
return JSON.parse(document.querySelector('body').innerText) // Récupérer la réponse API
})
// Répéter jusqu'à ce que la réservation soit disponible
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)
})
}
Nous créons d'abord une instance de navigateur. Nous la définirons à false pour voir comment ça fonctionne en direct. DefaultViewport est défini à null pour que les nouvelles pages ne soient pas contraintes à une certaine largeur et hauteur.
Dans cette partie, nous aussi :
- Nous connectons
- Faisons l'appel API (correspondant au choix de l'utilisateur)
- Récupérons les données de la réponse JSON
- Actualisons jusqu'à ce que l'événement ciblé ne soit pas labellisé "available"
Vous pourriez aussi ajouter un indicateur de taux d'actualisation pour surveiller l'activité de votre script.
Dessert
La fin du script est plus facile que le reste. Vous n'avez qu'à rediriger l'utilisateur vers le lien trouvé et vous assurer qu'il le voit, peu importe ce qu'il fait à ce moment-là.
await utils.beep()
console.log(body.items[0].content.block.buttons[1].url)
console.log('Trouvé ! Je vous y emmène...')
await page.bringToFront()
await page.goto(body.items[0].content.block.buttons[1].url, {
waitUntil: 'networkidle0',
})
return 0
J'ai d'abord utilisé un package NodeJS appelé play-sound pour jouer un son quand un lien était trouvé. Une fois le son joué, il affiche un lien dans le terminal de l'utilisateur et l'utilisateur est redirigé vers la page cible. bringToFront() se concentre finalement sur la page de navigateur correspondante.
C'est tout ! Vous venez de concevoir un bot de réservation fonctionnel. Comme vous pouvez le voir, cela ne nécessite pas beaucoup de compétences en programmation. Bien que ce programme fonctionne pour un site web spécifique, soyez averti qu'il pourrait falloir une approche différente pour faire la même chose pour un autre système de réservation.
Veuillez noter que ce programme n'a pas l'intention de causer du mal à operadeparis.fr de quelque manière que ce soit. Il n'est pas assez puissant pour causer un déni de service. Il n'est pas non plus destiné à être utilisé par tous ceux qui essaient d'obtenir un billet.
Ne soyez pas un idiot, utilisez-le sagement.
Voici un lien vers le repo principal : cute_little_bot