├── .dockerignore ├── .github └── FUNDING.yml ├── .gitignore ├── Dockerfile ├── .eslintrc.js ├── src ├── logger.js └── test.js ├── tests ├── stealth.test.js └── appointment.test.js ├── package.json ├── playwright.config.js ├── LICENSE └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ["stableway"] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | playwright-report/ 3 | test-results/ 4 | *.min.js 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/playwright:v1.40.1-jammy 2 | 3 | WORKDIR /home/pwuser/ 4 | 5 | COPY package.json . 6 | 7 | RUN npm i 8 | 9 | COPY . . 10 | 11 | ENV NODE_ENV=production 12 | 13 | ENTRYPOINT [ "npm" ] 14 | 15 | CMD [ "start" ] 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "prettier" 10 | ], 11 | "parserOptions": { 12 | "ecmaVersion": 12 13 | }, 14 | "rules": { 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require("winston"); 2 | 3 | module.exports = winston.createLogger({ 4 | format: winston.format.combine( 5 | winston.format.colorize(), 6 | winston.format.timestamp({ 7 | format: "YYYY-MM-DD HH:mm:ss", 8 | }), 9 | winston.format.printf( 10 | (info) => 11 | `${info.timestamp} ${info.level}: ${info.message}` + 12 | (info.splat !== undefined ? `${info.splat}` : " ") 13 | ) 14 | ), 15 | transports: [new winston.transports.Console()], 16 | level: process.env.LOGLEVEL || "info", 17 | }); 18 | -------------------------------------------------------------------------------- /tests/stealth.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("@playwright/test"); 2 | const test = require("../src/test")({}); 3 | 4 | test.describe("stealth", () => { 5 | test.describe("SannySoft", () => { 6 | test.beforeEach(async ({ page }) => { 7 | await page.goto("https://bot.sannysoft.com"); 8 | }); 9 | test("has 20 passed tests", async ({ page }) => { 10 | const passed = page 11 | .locator("table > tr > td.passed") 12 | .filter({ hasText: "ok" }); 13 | await expect(passed).toHaveCount(20); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/test.js: -------------------------------------------------------------------------------- 1 | const { test } = require("@playwright/test"); 2 | 3 | module.exports = (defaultParams) => 4 | test.extend({ 5 | context: async ({ context }, use) => { 6 | await context.addInitScript({ path: "stealth.min.js" }); 7 | await use(context); 8 | }, 9 | page: async ({ page }, use) => { 10 | await page.addInitScript({ path: "stealth.min.js" }); 11 | await use(page); 12 | }, 13 | params: [ 14 | // eslint-disable-next-line no-empty-pattern 15 | async ({}, use) => { 16 | const params = { ...defaultParams }; 17 | for (let key in params) { 18 | if (process.env[key]) { 19 | params[key] = process.env[key]; 20 | } 21 | } 22 | await use(params); 23 | }, 24 | { option: true }, 25 | ], 26 | }); 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anmeldung-berlin", 3 | "version": "0.0.1", 4 | "description": "This app will find and book any [service.berlin.de](https://service.berlin.de) appointment that can be booked online.", 5 | "scripts": { 6 | "start": "npx playwright test --project=appointment", 7 | "debug": "LOGLEVEL=debug npx playwright test --headed --trace=on --project=appointment" 8 | }, 9 | "author": "Daniel Cortez Stevenson", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@playwright/test": "^1.40.1", 13 | "bluebird": "^3.7.2", 14 | "mailslurp-client": "^15.17.4", 15 | "winston": "^3.8.2" 16 | }, 17 | "devDependencies": { 18 | "eslint": "^7.32.0", 19 | "eslint-config-prettier": "^8.3.0", 20 | "prettier": "2.8.7" 21 | }, 22 | "engine": { 23 | "node": ">=17.0.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig, devices } = require("@playwright/test"); 2 | 3 | module.exports = defineConfig({ 4 | name: "anmeldung-berlin", 5 | testDir: "./tests/", 6 | timeout: 0, 7 | reporter: [["html", { open: "never" }]], 8 | use: { 9 | ...devices["Desktop Chrome"], 10 | channel: "chrome", 11 | ignoreHTTPSErrors: true, 12 | bypassCSP: true, 13 | launchOptions: { 14 | args: ["--no-sandbox", "--disable-setuid-sandbox"], 15 | }, 16 | proxy: process.env.PROXY_URL 17 | ? { server: process.env.PROXY_URL } 18 | : undefined, 19 | actionTimeout: 10 * 1000, 20 | navigationTimeout: 60 * 1000, 21 | }, 22 | projects: [ 23 | { 24 | name: "appointment", 25 | testMatch: /appointment\.test\./, 26 | }, 27 | { 28 | name: "stealth", 29 | testMatch: /stealth\.test\./, 30 | }, 31 | ], 32 | }); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel Cortez Stevenson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # anmeldung-berlin 2 | 3 | This app will find and book any [service.berlin.de](https://service.berlin.de) appointment that can be booked online. 4 | 5 | ## Quickstart 6 | 7 | ### 1. Get a MailSlurp API Key 8 | 9 | Get a [MailSlurp API key here](https://app.mailslurp.com/sign-up/). 10 | 11 | ### 2a. Run with Docker (recommended) 12 | 13 | Build & run Docker container. 14 | 15 | ```bash 16 | # Update stealth evasions 17 | npx extract-stealth-evasions 18 | # Build 19 | docker build -t anmeldung-berlin . 20 | # Book an "Anmeldung einer Wohnung" appointment 21 | docker run \ 22 | -v $(pwd)/playwright-report:/home/pwuser/playwright-report \ 23 | -v $(pwd)/test-results:/home/pwuser/test-results \ 24 | -e MAILSLURP_API_KEY=*your-api-key* \ 25 | -e FORM_NAME=*your-name* \ 26 | -e FORM_PHONE=*your-phone-number* \ 27 | anmeldung-berlin 28 | # Book an "Blaue Karte EU auf einen neuen Pass übertragen" appointment on/after 01 Feb 2024 & before/on 28 Feb 2024 at any time. 29 | docker run \ 30 | -v $(pwd)/playwright-report:/home/pwuser/playwright-report \ 31 | -v $(pwd)/test-results:/home/pwuser/test-results \ 32 | -e MAILSLURP_API_KEY=*your-api-key* \ 33 | -e FORM_NAME=*your-name* \ 34 | -e FORM_PHONE=*your-phone-number* \ 35 | -e APPOINTMENT_SERVICE="Blaue Karte EU auf einen neuen Pass übertragen" \ 36 | -e APPOINTMENT_EARLIEST_DATE="2024-02-01 GMT" \ 37 | -e APPOINTMENT_LATEST_DATE="2024-02-28 GMT" \ 38 | anmeldung-berlin 39 | ``` 40 | 41 | ### 2b. Run Locally on Mac OS 42 | 43 | Run the program from the command line. 44 | 45 | ```bash 46 | # Update stealth evasions 47 | npx extract-stealth-evasions 48 | # Install dependencies 49 | npm i 50 | # Install Chrome browser 51 | npx playwright install chrome 52 | # Book an "Anmeldung einer Wohnung" appointment 53 | MAILSLURP_API_KEY=*your-api-key* FORM_NAME=*your-name* FORM_PHONE=*your-phone-number* \ 54 | npm start 55 | # Book an "Abmeldung einer Wohnung" appointment starting on/after 10:00 AM and before/at 1:00 PM on any date. 56 | MAILSLURP_API_KEY=*your-api-key* FORM_NAME=*your-name* FORM_PHONE=*your-phone-number* \ 57 | APPOINTMENT_SERVICE="Abmeldung einer Wohnung" \ 58 | APPOINTMENT_EARLIEST_TIME="10:00 GMT" \ 59 | APPOINTMENT_LATEST_TIME="13:00 GMT" \ 60 | npm run debug 61 | ``` 62 | 63 | ## Deployment 64 | 65 | Set [playwright.config.js](/playwright.config.js) `retries` to a high number, if you want to run the app locally until a successful booking is made. You may very well be blocked for exceeding a rate limit. In this case, try setting `PROXY_URL` to a back-connect proxy URL. 66 | 67 | ## Parameters 68 | 69 | The app is parameterized via environment variables at runtime, which have default values (sometimes `null`) defined in the [Playwright test](./tests/appointment.test.js) 70 | 71 | For [making an appointment](/tests/appointment.test.js), the parameters are: 72 | 73 | Environment Variable | Parameter Default | Description 74 | ---------|----------|--------- 75 | `MAILSLURP_API_KEY` | `null` | API key for MailSlurp service. [Required] 76 | `MAILSLURP_INBOX_ID` | `null` | Inbox ID for MailSlurp service. Use to avoid creating many MailSlurp inboxes. 77 | `FORM_NAME` | `null` | Your name. [Required] 78 | `FORM_PHONE` | `null` | Your phone number. [Required] 79 | `FORM_NOTE` | `null` | Your note for the Amt on your booking. 80 | `FORM_TAKE_SURVEY` | `"false"` | If you want to take the Amt's survey. 81 | `APPOINTMENT_SERVICE` | `"Anmeldung einer Wohnung"` | Name of the appointment type. 82 | `APPOINTMENT_LOCATIONS` | `null` | Comma separated location names for appointment. 83 | `APPOINTMENT_EARLIEST_DATE` | `"1970-01-01 GMT"` | Earliest date for appointment. 84 | `APPOINTMENT_LATEST_DATE` | `"2069-12-31 GMT"` | Latest date for appointment. 85 | `APPOINTMENT_EARLIEST_TIME` | `"00:00 GMT"` | Earliest time for appointment. 86 | `APPOINTMENT_LATEST_TIME` | `"23:59 GMT"` | Latest time for appointment. 87 | 88 | ## Environment Variables 89 | 90 | Variable | Default | Description 91 | ---------|----------|--------- 92 | `LOGLEVEL` | "info" | Set to "debug" to get stdout. 93 | `CONCURRENCY` | "16" | Max number of concurrent Pages. 94 | `PROXY_URL` | `undefined` | Hide your IP with a back-connect proxy. 95 | 96 | ## Debugging 97 | 98 | ```bash 99 | MAILSLURP_API_KEY=*your-api-key* FORM_NAME=*your-name* FORM_PHONE=*your-phone-number* \ 100 | npm run debug 101 | ``` 102 | 103 | ## Output 104 | 105 | [playwright-report](./playwright-report) will contain one or two .html files that are the body of the emails received during the booking process. There will also be an .ics file to add to your calendar. Check your MailSlurp email inbox for the appointment confirmations. 106 | 107 | ```bash 108 | npx playwright show-report 109 | ``` 110 | 111 | ## Known Issues 112 | 113 | - We can be blocked by a Captcha in some cases. 114 | 115 | ## Contributing 116 | 117 | If you're planning to contribute to the project, install dev dependencies and use `eslint` and `prettier` for linting and formatting, respectively. 118 | 119 | ```bash 120 | npm i --include=dev 121 | npx eslint --fix tests/ src/ playwright.config.js 122 | npx prettier -w tests/ src/ playwright.config.js 123 | ``` 124 | -------------------------------------------------------------------------------- /tests/appointment.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("@playwright/test"); 2 | const MailSlurp = require("mailslurp-client").default; 3 | const Promise = require("bluebird"); 4 | const logger = require("../src/logger"); 5 | const test = require("../src/test.js")({ 6 | MAILSLURP_API_KEY: null, 7 | MAILSLURP_INBOX_ID: null, 8 | FORM_NAME: null, 9 | FORM_PHONE: null, 10 | FORM_NOTE: null, 11 | FORM_TAKE_SURVEY: "false", 12 | APPOINTMENT_SERVICE: "Anmeldung einer Wohnung", 13 | APPOINTMENT_LOCATIONS: null, 14 | APPOINTMENT_EARLIEST_DATE: "1970-01-01 GMT", 15 | APPOINTMENT_LATEST_DATE: "2069-12-31 GMT", 16 | APPOINTMENT_EARLIEST_TIME: "00:00 GMT", 17 | APPOINTMENT_LATEST_TIME: "23:59 GMT", 18 | }); 19 | 20 | test("appointment", async ({ context, params }, testInfo) => { 21 | logger.debug(JSON.stringify(params, null, 2)); 22 | const serviceURL = await getServiceURL(await context.newPage(), { 23 | serviceName: params.APPOINTMENT_SERVICE, 24 | }); 25 | const dateURLs = await getDateURLs(await context.newPage(), serviceURL, { 26 | locations: params.APPOINTMENT_LOCATIONS, 27 | earliestDate: params.APPOINTMENT_EARLIEST_DATE, 28 | latestDate: params.APPOINTMENT_LATEST_DATE, 29 | }); 30 | expect(dateURLs.length, "No available appointment dates").toBeGreaterThan(0); 31 | 32 | const appointmentURLs = await getAppointmentURLs(context, dateURLs, { 33 | earliestTime: params.APPOINTMENT_EARLIEST_TIME, 34 | latestTime: params.APPOINTMENT_LATEST_TIME, 35 | }); 36 | expect( 37 | appointmentURLs.length, 38 | "No available appointments on any appointment date" 39 | ).toBeGreaterThan(0); 40 | 41 | for (const appointmentURL of appointmentURLs) { 42 | try { 43 | await bookAppointment( 44 | context, 45 | appointmentURL, 46 | { 47 | mailSlurpAPIKey: params.MAILSLURP_API_KEY, 48 | mailSlurpInboxId: params.MAILSLURP_INBOX_ID, 49 | formName: params.FORM_NAME, 50 | formTakeSurvey: params.FORM_TAKE_SURVEY, 51 | formNote: params.FORM_NOTE, 52 | formPhone: params.FORM_PHONE, 53 | }, 54 | testInfo 55 | ); 56 | return; 57 | } catch (e) { 58 | logger.error( 59 | `Booking appointment failed at ${appointmentURL}: ${e.message}` 60 | ); 61 | } 62 | } 63 | throw new Error("Booking failed for all appointments."); 64 | }); 65 | 66 | function timestamp() { 67 | return new Date(Date.now()).toUTCString(); 68 | } 69 | 70 | async function getServiceURL(page, { serviceName }) { 71 | return await test 72 | .step("get service url", async () => { 73 | page.on("load", async () => { 74 | await Promise.all([checkRateLimitExceeded(page), checkCaptcha(page)]); 75 | }); 76 | const servicesURL = "https://service.berlin.de/dienstleistungen/"; 77 | await page.goto(servicesURL, { waitUntil: "domcontentloaded" }); 78 | const serviceLinkLocator = page.getByRole("link", { 79 | name: serviceName, 80 | exact: true, 81 | }); 82 | await expect( 83 | serviceLinkLocator, 84 | `Service ${serviceName} not found at ${servicesURL}` 85 | ).toBeVisible(); 86 | const serviceUrl = await serviceLinkLocator.getAttribute("href"); 87 | return serviceUrl; 88 | }) 89 | .finally(async () => { 90 | await page.close(); 91 | }); 92 | } 93 | 94 | async function getDateURLs( 95 | page, 96 | serviceURL, 97 | { locations, earliestDate, latestDate } 98 | ) { 99 | return await test 100 | .step("get date urls", async () => { 101 | page.on("load", async () => { 102 | await Promise.all([checkRateLimitExceeded(page), checkCaptcha(page)]); 103 | }); 104 | await page.goto(serviceURL, { waitUntil: "domcontentloaded" }); 105 | await selectLocations(page, { 106 | locations: locations ? locations.split(",") : [], 107 | }); 108 | await Promise.all([ 109 | page.waitForNavigation(), 110 | page 111 | .getByRole("button", { 112 | name: "An diesem Standort einen Termin buchen", 113 | }) 114 | .click(), 115 | ]); 116 | await expect( 117 | page, 118 | "No appointments available for the selected locations" 119 | ).not.toHaveURL( 120 | /service\.berlin\.de\/terminvereinbarung\/termin\/taken/, 121 | { 122 | timeout: 1, 123 | } 124 | ); 125 | await expect( 126 | page.getByRole("heading", { name: "Wartung" }), 127 | "Website is down for maintenance" 128 | ).not.toBeVisible({ timeout: 1 }); 129 | await expect( 130 | page.getByRole("heading", { 131 | name: "Die Terminvereinbarung ist zur Zeit nicht", 132 | }), 133 | "Appointment booking not possible at this time" 134 | ).not.toBeVisible({ timeout: 1 }); 135 | 136 | logger.debug(`Calendar url: ${page.url()}`); 137 | const dateURLsPage1 = await scrapeDateURLs(page); 138 | // TODO: use page.getByRole for pagination button 139 | let dateURLsPage2 = []; 140 | const nextButtonLocator = page.locator("th.next"); 141 | if (await nextButtonLocator.isVisible()) { 142 | await Promise.all([ 143 | page.waitForNavigation(), 144 | page.locator("th.next").click(), 145 | ]); 146 | dateURLsPage2 = await scrapeDateURLs(page); 147 | } 148 | const dateURLs = [...new Set(dateURLsPage1.concat(dateURLsPage2))]; 149 | logger.debug(`Found ${dateURLs.length} appointment dates.`); 150 | const filteredDateURLs = filterURLsBetweenDates(dateURLs, { 151 | earliestDate, 152 | latestDate, 153 | }); 154 | logger.debug( 155 | `Found ${filteredDateURLs.length} appointment dates within the configured date range.` 156 | ); 157 | return filteredDateURLs; 158 | }) 159 | .finally(async () => { 160 | await page.close(); 161 | }); 162 | } 163 | 164 | async function selectLocations(page, { locations }) { 165 | await test.step("select locations", async () => { 166 | // All locations are selected if locations is empty. 167 | if (locations.length === 0) { 168 | // Actually click the first checkbox for submit actionability, then check all checkboxes in parallel for speed. 169 | const checkboxLocator = page.getByRole("checkbox"); 170 | await checkboxLocator.first().check(); 171 | await checkboxLocator.evaluateAll((els) => 172 | els.map((el) => (el.checked = true)) 173 | ); 174 | } else { 175 | // TODO: First location in params.APPOINTMENT_LOCATIONS must always exist or this will fail. 176 | await page 177 | .getByRole("checkbox", { name: locations[0], exact: true }) 178 | .check(); 179 | await Promise.map(locations, async (location) => 180 | page 181 | .getByRole("checkbox", { name: location, exact: true }) 182 | .evaluate((el) => (el.checked = true)) 183 | .catch((e) => 184 | logger.warn( 185 | `Failed to select location ${location} - Continuing without selecting it: ${e.message}` 186 | ) 187 | ) 188 | ); 189 | } 190 | }); 191 | } 192 | 193 | async function getAppointmentURLs( 194 | context, 195 | dateURLs, 196 | { earliestTime, latestTime } 197 | ) { 198 | return await test.step("get appointment urls", async () => { 199 | return await [].concat.apply( 200 | [], 201 | await Promise.map( 202 | dateURLs, 203 | async (url) => 204 | getAppointmentURLsForDateURL(await context.newPage(), url, { 205 | earliestTime, 206 | latestTime, 207 | }).catch((e) => { 208 | // If one URL fails, just return [] and go ahead with the URLs you could find. 209 | logger.error(`Get appointments failed for ${url} - ${e.message}`); 210 | return []; 211 | }), 212 | { concurrency: parseInt(process.env.CONCURRENCY || "16") } 213 | ) 214 | ); 215 | }); 216 | } 217 | 218 | async function getAppointmentURLsForDateURL( 219 | page, 220 | url, 221 | { earliestTime, latestTime } 222 | ) { 223 | return await test 224 | .step(`get appointment urls for ${url}`, async () => { 225 | page.on("load", async () => { 226 | await Promise.all([checkRateLimitExceeded(page), checkCaptcha(page)]); 227 | }); 228 | logger.debug(`Getting appointments for ${url}`); 229 | await page.goto(url, { waitUntil: "domcontentloaded" }); 230 | const urls = await scrapeAppointmentURLs(page); 231 | logger.debug(`Found ${urls.length} appointments for ${url}`); 232 | const filteredURLs = filterURLsBetweenTimes(urls, { 233 | earliestTime, 234 | latestTime, 235 | }); 236 | logger.debug( 237 | `Found ${filteredURLs.length} appointments within the configured time range for ${url}` 238 | ); 239 | return filteredURLs; 240 | }) 241 | .finally(async () => { 242 | await page.close(); 243 | }); 244 | } 245 | 246 | async function bookAppointment( 247 | context, 248 | url, 249 | { 250 | mailSlurpAPIKey, 251 | mailSlurpInboxId, 252 | formName, 253 | formTakeSurvey, 254 | formNote, 255 | formPhone, 256 | }, 257 | testInfo 258 | ) { 259 | await test.step(`book appointment at ${url}`, async () => { 260 | logger.debug(`Retrieving booking page for ${url}`); 261 | const page = await context.newPage(); 262 | page.on("load", async () => { 263 | await Promise.all([checkRateLimitExceeded(page), checkCaptcha(page)]); 264 | }); 265 | await page.goto(url, { waitUntil: "domcontentloaded" }); 266 | // This block is executed if the first appointment link failed and more than 1 appointment link was found. 267 | const startNewReservation = page.getByRole("link", { 268 | name: "Reservierung aufheben und neue Terminsuche starten", 269 | }); 270 | if (await startNewReservation.isVisible()) { 271 | logger.debug("Starting a new reservation process."); 272 | await Promise.all([ 273 | page.waitForNavigation(), 274 | startNewReservation.click(), 275 | ]); 276 | await page.goto(url); 277 | } 278 | await expect( 279 | page.getByRole("heading", { name: "Bitte entschuldigen Sie den Fehler" }), 280 | "Appointment already taken" 281 | ).not.toBeVisible({ timeout: 1 }); 282 | 283 | await expect( 284 | page.getByRole("heading", { name: "Terminvereinbarung" }), 285 | "Booking page not reached" 286 | ).toBeVisible(); 287 | 288 | // Set up MailSlurp inbox 289 | const mailslurp = new MailSlurp({ apiKey: mailSlurpAPIKey }); 290 | let inboxId = mailSlurpInboxId; 291 | if (!inboxId) { 292 | ({ id: inboxId } = await mailslurp.createInbox()); 293 | logger.debug(`Created MailSlurp inbox with id: ${inboxId}`); 294 | } 295 | const inbox = await mailslurp.getInbox(inboxId); 296 | 297 | logger.debug(`Filling booking form for ${url}`); 298 | await Promise.all([ 299 | page 300 | .locator("input#familyName") 301 | .evaluate((el, name) => (el.value = name), formName), 302 | page 303 | .locator("input#email") 304 | .evaluate((el, email) => (el.value = email.trim()), inbox.emailAddress), 305 | page 306 | .locator("input#emailequality") 307 | .evaluate((el, email) => (el.value = email.trim()), inbox.emailAddress), 308 | page 309 | .locator('select[name="surveyAccepted"]') 310 | .selectOption(formTakeSurvey === "true" ? "1" : "0"), 311 | page.locator("input#agbgelesen").check(), 312 | ]); 313 | 314 | // The note feature is not available for every location. 315 | if (formNote) { 316 | logger.debug( 317 | "Writing the configured note if the feature is available ..." 318 | ); 319 | const noteInput = page.locator("textarea[name=amendment]"); 320 | if (await noteInput.isVisible()) { 321 | await noteInput 322 | .evaluate((el, note) => (el.value = note), formNote) 323 | .catch((e) => 324 | logger.warn( 325 | `Write note failed. Continuing with booking with no note - ${e.message}` 326 | ) 327 | ); 328 | } 329 | } 330 | 331 | // Telephone entry is not available for every location. 332 | if (formPhone) { 333 | logger.debug( 334 | "Writing the configured phone number if the feature is available ..." 335 | ); 336 | const phoneInput = page.locator("input#telephone"); 337 | if (await phoneInput.isVisible()) { 338 | await phoneInput 339 | .evaluate((el, phone) => (el.value = phone), formPhone) 340 | .catch((e) => { 341 | logger.warn( 342 | `Failed to write phone number. Continuing with booking without providing a contact number - ${e.message}` 343 | ); 344 | }); 345 | } 346 | } 347 | 348 | logger.debug( 349 | "Reading all emails from MailSlurp inbox so we can wait for new unread emails ..." 350 | ); 351 | await mailslurp.getEmails(inboxId, { unreadOnly: true }); 352 | 353 | // Submit the appointment booking form. 354 | logger.debug("Submitting appointment booking form ..."); 355 | await expect(async () => { 356 | await Promise.all([ 357 | page.waitForNavigation(), 358 | page.locator("button#register_submit.btn").click(), 359 | ]); 360 | await expect( 361 | page.getByRole("heading", { 362 | name: /Terminbuchung - Email bestätigen|Terminbestätigung/, 363 | }) 364 | ).toBeVisible(); 365 | }, "Form submission failed").toPass(); 366 | 367 | // Wait for first email (confirmation or verification code) to arrive. 368 | logger.debug( 369 | `Waiting for first email (verification or confirmation) to arrive at inbox with id ${inboxId} ...` 370 | ); 371 | // TODO: "fetch failed" error message sporadically occurs here. 372 | const firstEmail = await mailslurp.waitForLatestEmail( 373 | inboxId, 374 | 300_000, 375 | true 376 | ); 377 | 378 | let verificationCode, secondEmail; 379 | if (/verifizieren/.exec(firstEmail.subject)) { 380 | logger.debug("Email verification code requested"); 381 | verificationCode = /

([0-9a-zA-Z]{6})<\/h2>/.exec(firstEmail.body)[1]; 382 | logger.debug(`Verification code: ${verificationCode}`); 383 | 384 | const verificationCodeInput = page.locator("input#verificationcode"); 385 | await expect(verificationCodeInput).toBeVisible(); 386 | 387 | // Fill & submit the verification code. 388 | await verificationCodeInput.fill(verificationCode); 389 | logger.debug("Submitting verification code ..."); 390 | await expect(async () => { 391 | await Promise.all([ 392 | page.waitForNavigation(), 393 | page.getByRole("button", { name: "Termin verifizieren" }).click(), 394 | ]); 395 | await expect( 396 | page.getByRole("heading", { name: "Terminbestätigung" }) 397 | ).toBeVisible(); 398 | }, "Verification code submission failed").toPass(); 399 | 400 | logger.debug("Waiting for second email (confirmation) to arrive ..."); 401 | // TODO: "fetch failed" error message sporadically occurs here. 402 | secondEmail = await mailslurp.waitForLatestEmail(inboxId, 300_000, true); 403 | } else { 404 | logger.debug("No email verification code requested"); 405 | await expect( 406 | page.getByRole("heading", { name: "Terminbestätigung" }), 407 | "Confirmation page not reached" 408 | ).toBeVisible(); 409 | } 410 | 411 | async function saveCalendarAttachment(email, suffix) { 412 | expect(email.attachments.length).toEqual(1); 413 | const attachmentDto = 414 | await mailslurp.emailController.downloadAttachmentBase64({ 415 | attachmentId: email.attachments[0], 416 | emailId: email.id, 417 | }); 418 | expect(attachmentDto.base64FileContents).toBeTruthy(); 419 | const fileContent = new Buffer( 420 | attachmentDto.base64FileContents, 421 | "base64" 422 | ).toString(); 423 | await testInfo.attach(`appointment-${suffix}.ics`, { body: fileContent }); 424 | } 425 | 426 | async function saveConfirmationPage(suffix) { 427 | const screenshot = await page.screenshot({ fullPage: true }); 428 | return testInfo.attach(`web-confirmation-${suffix}`, { 429 | body: screenshot, 430 | contentType: "image/png", 431 | }); 432 | } 433 | 434 | const savedAt = timestamp(); 435 | logger.debug(`Saving booking files for booking at ${savedAt} ...`); 436 | if (secondEmail) { 437 | await Promise.allSettled([ 438 | saveCalendarAttachment(secondEmail, savedAt), 439 | saveConfirmationPage(savedAt), 440 | testInfo.attach(`email-verification-${savedAt}`, { 441 | body: firstEmail.body, 442 | contentType: "text/html", 443 | }), 444 | testInfo.attach(`email-confirmation-${savedAt}`, { 445 | body: secondEmail.body, 446 | contentType: "text/html", 447 | }), 448 | ]); 449 | } else if (firstEmail) { 450 | await Promise.allSettled([ 451 | saveCalendarAttachment(firstEmail, savedAt), 452 | saveConfirmationPage(savedAt), 453 | testInfo.attach(`email-confirmation-${savedAt}`, { 454 | body: firstEmail.body, 455 | contentType: "text/html", 456 | }), 457 | ]); 458 | } 459 | await page.close(); 460 | }); 461 | } 462 | 463 | function scrapeDateURLs(page) { 464 | return page 465 | .locator("td.buchbar > a") 466 | .evaluateAll((els) => els.map((el) => el.href).filter((href) => !!href)); 467 | } 468 | 469 | function filterURLsBetweenDates(urls, { earliestDate, latestDate }) { 470 | return urls.filter((url) => { 471 | const linkDate = new Date(parseInt(url.match(/\d+/)[0]) * 1000); 472 | return ( 473 | new Date(earliestDate) <= linkDate && linkDate <= new Date(latestDate) 474 | ); 475 | }); 476 | } 477 | 478 | async function scrapeAppointmentURLs(page) { 479 | const timetable = page.locator(".timetable"); 480 | await expect(timetable, "No timetable found").toBeVisible(); 481 | return timetable 482 | .locator("td.frei > a") 483 | .evaluateAll((els) => els.map((el) => el.href).filter((href) => !!href)); 484 | } 485 | 486 | function filterURLsBetweenTimes(urls, { earliestTime, latestTime }) { 487 | return urls.filter((url) => { 488 | const linkDate = new Date(parseInt(url.match(/\d+/)[0]) * 1000); 489 | const linkTime = `${linkDate.getHours()}:${linkDate.getMinutes()} GMT`; 490 | return ( 491 | new Date("1970 " + earliestTime) <= new Date("1970 " + linkTime) && 492 | new Date("1970 " + linkTime) <= new Date("1970 " + latestTime) 493 | ); 494 | }); 495 | } 496 | 497 | function checkRateLimitExceeded(page) { 498 | return expect( 499 | page.getByRole("heading", { name: "Zu viele Zugriffe" }), 500 | "Rate limit exceeded" 501 | ).not.toBeVisible({ timeout: 1 }); 502 | } 503 | 504 | function checkCaptcha(page) { 505 | return expect( 506 | page.getByRole("heading", { name: "Bitte verifizieren sie sich" }), 507 | "Blocked by captcha" 508 | ).not.toBeVisible({ timeout: 1 }); 509 | } 510 | --------------------------------------------------------------------------------