├── .gitignore ├── .dockerignore ├── .env.sample ├── .editorconfig ├── src ├── get-available-appointments.js ├── send-telegram-notification.js ├── get-booking-page-html.js └── index.js ├── Dockerfile ├── .github └── workflows │ └── docker-build.yml ├── package.json ├── LICENSE ├── test ├── get-available-appointments.test.js └── fixtures │ ├── no-appointment-available.html │ ├── one-appointment-available.html │ └── multiple-appointments-available.html └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .github 4 | README.md 5 | LICENSE 6 | .env.sample 7 | .editorconfig 8 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | BOOKING_URL= 2 | USER_AGENT= 3 | CHECK_INTERVAL_MINUTES= 4 | HEALTHCHECKS_IO_TOKEN= 5 | TELEGRAM_BOT_TOKEN= 6 | TELEGRAM_CHAT_ID= 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/get-available-appointments.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio"; 2 | 3 | export function getAvailableAppointments(html) { 4 | const $ = cheerio.load(html); 5 | const appointmentLinks = $("td.buchbar a"); 6 | return appointmentLinks 7 | .map((_, appointmentLink) => { 8 | const appointmentUrl = appointmentLink.attribs["href"]; 9 | const appointmentDate = appointmentUrl.split("/")[4]; 10 | return new Date(appointmentDate * 1000); 11 | }) 12 | .toArray(); 13 | } 14 | -------------------------------------------------------------------------------- /src/send-telegram-notification.js: -------------------------------------------------------------------------------- 1 | import got from "got"; 2 | 3 | export async function sendTelegramNotification(message) { 4 | const token = process.env.TELEGRAM_BOT_TOKEN; 5 | const chatId = process.env.TELEGRAM_CHAT_ID; 6 | 7 | const requestBody = { 8 | chat_id: chatId, 9 | text: message, 10 | }; 11 | 12 | await got(`https://api.telegram.org/bot${token}/sendMessage`, { 13 | method: "POST", 14 | body: JSON.stringify(requestBody), 15 | headers: { 16 | "content-type": "application/json", 17 | }, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | LABEL maintainer="similicious" 3 | LABEL description="Berlin Buergeramt Bot" 4 | 5 | # Create app directory 6 | WORKDIR /usr/src/app 7 | 8 | COPY package*.json ./ 9 | RUN npm install --production 10 | 11 | COPY . . 12 | 13 | # Create a non-root user 14 | RUN addgroup -S botuser && adduser -S -G botuser botuser 15 | 16 | # Set proper ownership 17 | RUN chown -R botuser:botuser /usr/src/app 18 | 19 | # Use the non-root user 20 | USER botuser 21 | 22 | # Set NODE_ENV 23 | ENV NODE_ENV production 24 | 25 | # Start the app 26 | CMD [ "node", "src/index.js" ] 27 | -------------------------------------------------------------------------------- /src/get-booking-page-html.js: -------------------------------------------------------------------------------- 1 | import got from "got"; 2 | 3 | export async function getBookingPageHtml() { 4 | // Request booking url to receive the booking system cookie 5 | let res = await got(process.env.BOOKING_URL, { 6 | headers: { "user-agent": process.env.USER_AGENT }, 7 | // As got isn't following redirects properly, we have to make the second request explicitly 8 | followRedirect: false, 9 | }); 10 | 11 | // Get cookie 12 | const cookie = res.headers["set-cookie"][0]; 13 | 14 | // Request appointment page using the cookie 15 | res = await got("https://service.berlin.de/terminvereinbarung/termin/day/", { 16 | headers: { 17 | cookie: cookie, 18 | "user-agent": process.env.USER_AGENT, 19 | }, 20 | followRedirect: false, 21 | }); 22 | return res.body; 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Build docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3 18 | 19 | - name: Log in to GitHub Container Registry 20 | uses: docker/login-action@v3 21 | with: 22 | registry: ghcr.io 23 | username: ${{ github.actor }} 24 | password: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: Build and push Docker image 27 | uses: docker/build-push-action@v5 28 | with: 29 | context: . 30 | push: true 31 | tags: ghcr.io/${{ github.repository }}:latest 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "berlin-buergeramt-bot", 3 | "version": "1.0.0", 4 | "description": "A bot notify me when an buergeramt appointment is available.", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "dev": "nodemon ./src/index.js", 8 | "start": "node ./src/index.js", 9 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", 10 | "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch" 11 | }, 12 | "author": "similicious", 13 | "license": "MIT", 14 | "dependencies": { 15 | "cheerio": "^1.0.0", 16 | "dotenv": "^16.4.7", 17 | "got": "^14.4.6", 18 | "prettier": "3.5.1", 19 | "toad-scheduler": "^3.0.1" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^22.13.4", 23 | "jest": "^29.7.0", 24 | "nodemon": "^3.1.9" 25 | }, 26 | "type": "module", 27 | "engines": { 28 | "node": ">=20.0.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 similicious 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. -------------------------------------------------------------------------------- /test/get-available-appointments.test.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import { join, dirname } from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import { getAvailableAppointments } from "../src/get-available-appointments"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | test("returns an empty array, when no appointment is available", () => { 10 | const html = fs.readFileSync( 11 | join(__dirname, "fixtures", "no-appointment-available.html"), 12 | { encoding: "utf-8" }, 13 | ); 14 | 15 | const appointments = getAvailableAppointments(html); 16 | expect(appointments).toEqual([]); 17 | }); 18 | 19 | test("returns one appointment, when one is available", () => { 20 | const html = fs.readFileSync( 21 | join(__dirname, "fixtures", "one-appointment-available.html"), 22 | { encoding: "utf-8" }, 23 | ); 24 | 25 | const appointments = getAvailableAppointments(html); 26 | expect(appointments).toEqual([new Date("2021-10-25T22:00:00.000Z")]); 27 | }); 28 | 29 | test("returns multiple appointment, when multiple are available", () => { 30 | const html = fs.readFileSync( 31 | join(__dirname, "fixtures", "multiple-appointments-available.html"), 32 | { encoding: "utf-8" }, 33 | ); 34 | 35 | const appointments = getAvailableAppointments(html); 36 | expect(appointments).toEqual([ 37 | new Date("2021-09-07T22:00:00.000Z"), 38 | new Date("2021-10-25T22:00:00.000Z"), 39 | ]); 40 | }); 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Berlin Bürgeramt Appointment Bot 2 | 3 | A bot that sends a notification when a buergeramt appointment is available. 4 | It periodically checks the given buergeramt appointment page for new appointments. It then notifies the user using Telgram. 5 | As of now, this bot is meant to be self hosted - it can easily be run on a raspberry pi or a cheap vps. 6 | 7 | **Please use responsibly**: Set a USER_AGENT containing contact information in case something goes wrong. 8 | 9 | **USE AT OWN RISK** 10 | 11 | ## Limitations 12 | 13 | Currently this bot is only checking the current and the next month, as I was mainly going for appointments on short notice. 14 | 15 | ## Getting started 16 | 17 | - clone the repository 18 | 19 | ``` 20 | git clone https://github.com/similicious/berlin-buergeramt-bot 21 | ``` 22 | 23 | - cd into the cloned repository 24 | - install dependencies 25 | 26 | ``` 27 | npm install 28 | ``` 29 | 30 | - create .env by copying .env.sample 31 | 32 | ``` 33 | cp .env.sample .env 34 | ``` 35 | 36 | - edit .env to suit your needs 37 | 38 | ``` 39 | BOOKING_URL=The link to the appointments page. Use the link behind "Termin buchen" or "Termin berlinweit suchen" buttons. 40 | USER_AGENT=A string containing your email address 41 | CHECK_INTERVAL_MINUTES=The interval in minutes to check 42 | TELEGRAM_BOT_TOKEN= 43 | TELEGRAM_CHAT_ID= 44 | HEALTHCHECKS_IO_TOKEN=Optional healthchecks.io token to monitor the bot 45 | ``` 46 | 47 | - start src/index.js 48 | 49 | ``` 50 | node src/index.js 51 | ``` 52 | 53 | Optionally, you can use a node process manager like pm2 to monitor the app and automatically start the bot on boot. 54 | 55 | ### Docker 56 | 57 | The bot can also be run using Docker. The image is built and pushed automatically on each push to the `main` branch. To pull and run the image, execute: 58 | 59 | ``` 60 | docker pull ghcr.io/similicious/berlin-buergeramt-bot:latest 61 | ``` 62 | 63 | Next, create an .env as outlined above and run the container 64 | 65 | ``` 66 | docker run -d --env-file .env --name berlin-buergeramt-bot --restart unless-stopped ghcr.io/similicious/berlin-buergeramt-bot:latest 67 | ``` 68 | 69 | ### Obtaining a bot token and chat id 70 | 71 | It's easiest to use the web / desktop client of Telegram for initial setup. More info on bots [here](https://core.telegram.org/bots/features#creating-a-new-bot). 72 | 73 | - Register a new bot using the Telegram bot `BotFather`. 74 | - Send the bot a message 75 | - Open https://api.telegram.org/bot<>/getUpdates in a browser and extract the chatId from `result[0].message.chat.id`. You might need to refresh to see the message. 76 | 77 | ## Run tests 78 | 79 | I included some snapshots of appointment pages in various states. Execute test with 80 | 81 | ``` 82 | npm run test 83 | ``` 84 | 85 | ``` 86 | 87 | ``` 88 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Imports 2 | import "dotenv/config"; 3 | import { HTTPError } from "got"; 4 | import got from "got"; 5 | import { ToadScheduler, SimpleIntervalJob, AsyncTask } from "toad-scheduler"; 6 | 7 | import { getAvailableAppointments } from "./get-available-appointments.js"; 8 | import { getBookingPageHtml } from "./get-booking-page-html.js"; 9 | import { sendTelegramNotification } from "./send-telegram-notification.js"; 10 | 11 | // Validate configuration values in .env 12 | const telegramNotificationsEnabled = 13 | process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID; 14 | validateConfig(); 15 | 16 | (async () => { 17 | await sendTelegramNotification("Berlin Buergeramt Bot has started."); 18 | })(); 19 | 20 | // Setup simple scheduler 21 | const scheduler = new ToadScheduler(); 22 | const checkForAppointmentsTask = new AsyncTask( 23 | "checkForAppointments", 24 | checkForAppointments, 25 | handleErrors, 26 | ); 27 | const job = new SimpleIntervalJob( 28 | { minutes: process.env.CHECK_INTERVAL_MINUTES, runImmediately: true }, 29 | checkForAppointmentsTask, 30 | ); 31 | scheduler.addSimpleIntervalJob(job); 32 | 33 | async function checkForAppointments() { 34 | let bookingPageHtml; 35 | 36 | try { 37 | bookingPageHtml = await getBookingPageHtml(); 38 | } catch (err) { 39 | // For now we do not act on rate limiting. This code only exists to ensure, we're correctly detecting rate limiting. 40 | if (err instanceof HTTPError && err.request.statusCode === 429) { 41 | console.error("Bot is rate limited."); 42 | } else { 43 | throw err; 44 | } 45 | } 46 | 47 | if (!bookingPageHtml) { 48 | return; 49 | } 50 | 51 | const dates = getAvailableAppointments(bookingPageHtml); 52 | 53 | if (dates.length > 0 && telegramNotificationsEnabled) { 54 | const message = `Buergeramt appointments are available now! Check ${process.env.BOOKING_URL}`; 55 | await sendTelegramNotification(message); 56 | } 57 | 58 | const date = new Date().toISOString(); 59 | const message = `${dates.length} appointments found.`; 60 | console.log(`${date} ${message}`); 61 | // Ping healthchecks.io 62 | if (process.env.HEALTHCHECKS_IO_TOKEN) { 63 | await got(`https://hc-ping.com/${process.env.HEALTHCHECKS_IO_TOKEN}`, { 64 | method: "POST", 65 | body: message, 66 | }); 67 | } 68 | } 69 | 70 | async function handleErrors(err) { 71 | console.error(err); 72 | if (telegramNotificationsEnabled) { 73 | await sendTelegramNotification(JSON.stringify(err)); 74 | } 75 | } 76 | 77 | function validateConfig() { 78 | const { 79 | BOOKING_URL, 80 | USER_AGENT, 81 | CHECK_INTERVAL_MINUTES, 82 | HEALTHCHECKS_IO_TOKEN, 83 | TELEGRAM_BOT_TOKEN, 84 | TELEGRAM_CHAT_ID, 85 | } = process.env; 86 | if (!BOOKING_URL | !CHECK_INTERVAL_MINUTES) { 87 | console.error( 88 | "BOOKING_URL or CHECK_INTERVAL_MINUTES have not been set. Please set values in .env according to README.", 89 | ); 90 | process.exit(1); 91 | } 92 | 93 | if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) { 94 | console.warn( 95 | "TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID has not been set. You will receive no notifications for appointments.", 96 | ); 97 | } 98 | 99 | if (!USER_AGENT) { 100 | console.warn( 101 | "USER_AGENT has not been set. Please add contact information.", 102 | ); 103 | } 104 | 105 | if (!HEALTHCHECKS_IO_TOKEN) { 106 | console.info( 107 | "HEALTHCHECKS_IO_TOKEN has not been set. The script execution will not be monitored.", 108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /test/fixtures/no-appointment-available.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 |
September 2021 
MoDiMiDoFrSaSo
0102030405
060809101112
13141516171819
20212223242526
27282930 
74 |
75 |
76 | 77 | 78 | 79 | 80 | 81 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 |
 Oktober 2021 82 | » 87 |
MoDiMiDoFrSaSo
010203
04050607080910
11121314151617
18192021222324
25262728293031
145 |
146 |
147 | 148 | 149 | -------------------------------------------------------------------------------- /test/fixtures/one-appointment-available.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 |
September 2021 
MoDiMiDoFrSaSo
0102030405
060809101112
13141516171819
20212223242526
27282930 
74 |
75 |
76 | 77 | 78 | 79 | 80 | 81 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 |
 Oktober 2021 82 | » 87 |
MoDiMiDoFrSaSo
010203
04050607080910
11121314151617
18192021222324
25 136 | 26 141 | 2728293031
151 |
152 |
153 | 154 | 155 | -------------------------------------------------------------------------------- /test/fixtures/multiple-appointments-available.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
September 2021 
MoDiMiDoFrSaSo
0102030405
0607 43 | 08 48 | 09101112
13141516171819
20212223242526
27282930 
81 |
82 |
83 | 84 | 85 | 86 | 87 | 88 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 |
 Oktober 2021 89 | » 94 |
MoDiMiDoFrSaSo
010203
04050607080910
11121314151617
18192021222324
25 143 | 26 148 | 2728293031
158 |
159 |
160 | 161 | 162 | --------------------------------------------------------------------------------