Booking.com does have an API that sets rates and availability — the Rates & Availability API, part of its Connectivity APIs — but a single property cannot get at it. Booking's own connectivity portal answers the question flatly: it does not accept direct connections from individual properties, and the route in is a channel manager. The same portal currently says it is pausing integrations with new connectivity providers until further notice. So for one small property the real options are: pay for a channel manager subscription, click the extranet by hand, or drive the extranet with a browser. I did the third.
The first version of this post, in August 2025, opened by saying the extranet has no public API for rate management. That was wrong and it is worth correcting properly rather than quietly. The API exists, it does exactly this job, and the reason I am not using it is commercial and procedural, not technical.
The repository has also moved on: it is now a TypeScript rewrite — version 2.0.0, Playwright and zod, domain logic under test — and nearly every item in the original "what I'd change" has since been built. I have kept the original reasoning where it survived and marked where it didn't, because the interesting part of this project was never the clicking.
Should a script generate your 2FA codes?#
The extranet requires 2FA. The first version handled it two ways: block on stdin and let a human read the code off their phone, or, if you had saved the base32 secret at enrolment, generate it with pyotp.
if self.totp_secret:
code = pyotp.TOTP(self.totp_secret).now()
else:
code = input('Enter the 2FA code from Pulse: ')Manual was the default, deliberately. A TOTP secret in a .env file next to the password collapses two factors into one: anyone who reads that file has both the thing you know and the thing you have, which is precisely what the second factor exists to prevent. That argument is still correct, and pyotp.TOTP(secret).now() is still the whole of the API in pyotp 2.10.0 — this is not hard code to write, it is hard code to justify.
The rewrite answers the question by refusing it. There is no login automation at all now. You log in yourself, once, in a real browser, and the tool saves only the resulting session state.
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(LOGIN_URL);
// ... wait for a keypress while a human logs in ...
await context.storageState({ path: ".auth/storage-state.json" });Every later command opens a context with storageState pointing at that file, so plan, push and a resumed run all inherit one session. This is the standard answer to "how do I not re-authenticate every run": it captures cookies, local storage and IndexedDB, which for most portals is the whole session.
Two things fall out of that which I did not anticipate. The login page is the most defended and most frequently redesigned surface on any extranet — device checks, changed prompts, the occasional CAPTCHA — so automating it means writing the most fragile code in the project and then repairing it forever. Handing that one step to a human deletes the category. And the repository now contains no credential handling at all, which is why it is comfortable to publish: no BOOKING_PASSWORD, no TOTP secret, and so no credential bug to have.
The cost is that sessions expire. The tool says so in words rather than timing out on a selector:
The calendar never showed any rooms. The saved session has most likely
expired — run `npm run login` again.Is running headless worth it when you cannot see the failure?#
The original argument was that headless is a lie you tell once. The first version ran headless, it worked until it didn't, and the failure was a bare timeout waiting for a selector with no way to know what the page looked like at that moment. Running headed converts every failure from a stack trace into something you watched happen.
That reasoning was right about the problem and wrong about the fix, and Playwright's trace viewer shows why. A trace records a DOM snapshot before, during and after every action, a screenshot filmstrip, every network request with headers and bodies, console output, and a scrubbable timeline with the source line for each step:
context.tracing.start(screenshots=True, snapshots=True, sources=True)
# ... drive the extranet ...
context.tracing.stop(path="trace.zip")Then playwright show-trace trace.zip, or drag the file onto trace.playwright.dev. The Node API is the same shape. Playwright 1.59 added live=True, which writes an unarchived trace you can watch update in real time, and tracing.group() for labelling stretches of a long run — both useful when the run is a hundred sequential bulk edits rather than a twelve-step test. Current stable is 1.62; the Python bindings are 1.62.0 and need Python 3.10 or newer.
So "I cannot see the failure" is no longer a reason to run headed. That leaves the reason I would actually defend now, and it is a different one: this tool writes prices to a live listing, and I want to watch it do that. Headed-by-default is about supervision, not debugging. In the rewrite --headless is an opt-in flag rather than a default I argued myself out of.
The honest gap is that the tool still does not record traces. That is the first thing I would add, and it is roughly four lines.
Why every run has to be resumable#
This is still the part I would defend hardest, and the rewrite sharpened it rather than changing it. Every write is appended to output/journal-<timestamp>.csv as it happens, not at the end of the run.
function keyOf(entry: Omit<JournalEntry, "appliedAt">): string {
return [entry.roomId, entry.kind, entry.from, entry.to, entry.value].join("|");
}The identity of a write is the room, the kind (availability, rate, breakfast-inclusive rate), the date range and the value — deliberately not the timestamp. --resume output/journal-1234567890.csv loads a previous journal, and any write whose key is already recorded is skipped.
Browser automation against a live site fails constantly for reasons that have nothing to do with your code: the session expires, the network blips, a modal appears that was not there yesterday. If a failure at range 100 of 128 means redoing all 128, you stop trusting the tool. If it means rerunning and skipping to 100, you keep using it. Buffering the log until the run finishes is the trap — the run that crashes is exactly the run whose record you need.
The other half of making failure cheap is making the run short. Rates and availability are collapsed into date ranges before anything touches the browser:
export function groupConsecutiveByValue(
entries: Array<{ date: string; value: number }>
): DateRange[]A season at one price is one range and one save. That is the difference between a few dozen writes for a year of pricing and several thousand, and it is why the resume file is small enough to read.
Parse the CSV properly, at the boundary#
The tool still reads a hand-maintained CSV — the stays you have sold through your own website or over the phone, subtracted from what the OTA is allowed to sell. Hand-maintained means hostile, and the parser tracks quote state across the whole document rather than splitting on newlines first, so a newline inside a quoted field stays data instead of becoming a record separator. Splitting on \n and then on , is the bug everyone writes once.
The general shape of this is the same one I argued for in reading matrices from files in Python: when the input is something a person typed, the parsing and validation is most of the program, and it belongs at the boundary where you can still say something useful about what was wrong. A malformed row should fail loudly with its own row number, not surface three layers down as a NaN in a price.
The same instinct runs through the config loader: a rate-plan label matching no rule aborts the run rather than being guessed at, because guessing "room only" on a breakfast plan gives breakfast away on every booking from then on. The rule that keeps this honest is that src/domain/ imports nothing with I/O — it is arithmetic over prices, seasons and dates, and it is the part that is actually tested. I have argued for that kind of structural line before in what a Go scaffold should look like; it is worth defending even in a small tool.
Name every selector, then verify them#
The original closing complaint was that the selectors are the weak point: tied to the extranet's current DOM, so a redesign breaks the bot silently. That is now fixed in the dullest possible way. Every selector lives in src/extranet/selectors.ts, and npm run verify-selectors opens the calendar and reports which ones still match.
The other half is preferring locators that describe intent over locators that describe markup. getByRole("button", { name: "Save changes" }) — get_by_role in Python — matches on the accessible name, which survives a class rename and a DOM reshuffle in a way that .bui-button--primary does not. Same for get_by_label on form fields. Where the extranet gives a data-test-id I use it; where it gives only a styling class I would rather match the text.
One failure no locator strategy fixes: the price fields are React-controlled, and fill() sets the DOM value and fires one synthetic event React ignores. The field looks correct on screen, React's internal state stays empty, Save never enables, and the run dies on an opaque timeout. Real keystrokes update it properly, so prices are typed character by character at 60 ms, then read back before saving.
await input.pressSequentially(price, { delay: 60 });
const readBack = await input.inputValue();
if (readBack !== price) {
throw new Error(
`Price field ${selector} reads back "${readBack}" after typing "${price}" — refusing to save`
);
}The read-back is not paranoia. This writes a price to a live listing, and failing loudly beats publishing whatever the field happened to contain. It is the same lesson as audio state in React is not React state, from the other side of the glass: when a component's real state lives somewhere other than the DOM, writing to the DOM tells you nothing.
Is automating a site you do not own actually allowed?#
This is the thing the original post left out entirely, and it deserves a straight answer rather than a disclaimer block. This tool operates on my own property account, doing exactly what I would do by hand in the same browser, with the same session, at a fraction of the pace. It logs in as me because I logged in as me. It does not spoof a user agent, patch navigator.webdriver, rotate proxies or solve CAPTCHAs — Playwright announces itself as automation and I have not taken that out, because anything on that list exists to defeat a control the platform put there deliberately, which is a different activity from managing your own listing.
None of which settles it for you. Your agreement with the platform governs what you may automate against your own account and it is the authority, not my README — read it. Rate limiting is the same: the tool pauses 400–1000 ms between saves, retries a failed range once and then fails loudly rather than looping, and runs one browser and one tab sequentially, but volume and rhythm matter more than any of that. A few hundred writes once a month in the daytime looks like a property manager; the same tool on an hourly cron looks like a scraper no matter how convincingly it types. If you get challenged or throttled, do less rather than finding a way around it.
What I'd change#
Record traces. It is four lines, it costs almost nothing, and it would make the one failure mode I still cannot reason about — a save that never enables for no visible reason — into something I can scrub through afterwards.
The pricing model has a hole I have not closed. undercutsDirect() in src/domain/pricing.ts will tell you whether an extra rate plan — a weekly rate, a last-minute deal — drops below your direct price once the discount stack applies, but nothing calls it automatically, because only the operator knows which plans exist and which were meant to be cheaper. An unwired check is worse than no check; I would rather it ran over every plan the panel offers and made you dismiss the warnings.
And the single retry is a guess. One retry clears the transient case where the panel leaves Save disabled on the first action after a save, which is genuinely most of them, but I have no data on the distribution because I have never logged the failures properly. Also a trace-viewer job.
Common questions#
Does Booking.com have a public API for setting rates?#
It has a Rates & Availability API under its Connectivity APIs, and it does cover inventory, pricing and restrictions. It is not open to individual properties: Booking's connectivity portal says it does not accept direct connections from single properties and points you at a channel manager, and as of September 2026 it is pausing integrations with new connectivity providers until further notice. Pricing types also require certification before use.
Can Playwright get past two-factor authentication?#
Not without defeating the point of it. You can generate TOTP codes with pyotp if you saved the enrolment secret, but storing that secret alongside the password means one file compromise gives up both factors. The better pattern is to log in by hand once and reuse the session with storage_state, which is what this tool does — no password, no secret and no login form to keep working.
How do I debug a Playwright failure that only happens headless?#
Record a trace: context.tracing.start(screenshots=True, snapshots=True, sources=True) before the run, context.tracing.stop(path="trace.zip") after, then playwright show-trace trace.zip. You get DOM snapshots around every action, a screenshot filmstrip, network and console output, and the source line for each step. Since 1.59 you can also pass live=True and watch the trace fill in as the run goes.
How do you make a long browser automation run resumable?#
Append a record of each write to a file the moment it lands, keyed by something stable — here it is room, kind, date range and value, with the timestamp excluded from the key. On the next run, load that file and skip anything already recorded. The rule that matters is that the log is flushed per write, not per run, because the run you need the log for is the one that crashed.
Will this break when Booking.com redesigns the extranet?#
Yes, and that is designed for rather than denied. Every selector lives in one file, npm run verify-selectors tells you which entries have gone stale, and prefer get_by_role and get_by_label over CSS classes so a restyle does not count as a redesign. A breakage should be a five-minute edit to one file, not an investigation.