Below are clean, concise instructions suitable for a .md file in your Obsidian vault. The style is neutral and procedural so it will integrate cleanly into your technical notes.

Feel free to tell me if you want YAML front-matter, tagging structure, or formatting changes.


# Quick Version
 
cd /Users/john/workspace/officernd_bookings
 
poetry run python scrape_officernd_bookings.py

Officernd Room Booking Scraper

Overview

This script uses Playwright to log into Officernd through a real Chromium browser and extract all room bookings from the bookings page:

https://hybrid.officernd.com/community/all-ears-centre/account/bookings

Booking start and end times are parsed directly from the booking link URLs (which include ISO timestamps). The script converts times to Europe/London, calculates duration in hours, and writes everything to a CSV file called:

officernd_bookings.csv

The CSV can be imported into Obsidian, used with DataviewJS, or processed in other tools.


Prerequisites

These instructions assume:

  • macOS

  • Python installed

  • Poetry installed

  • Obsidian available for storing notes and CSV files

Playwright will download its own Chromium runtime.


Initial Setup

Open a terminal and create a working directory:

mkdir officernd_bookings
cd officernd_bookings
# Now set up
cd /Users/john/workspace/officernd_bookings

Initialise a Poetry project:

poetry init -n
poetry add playwright
poetry run playwright install chromium

Create the file:

scrape_officernd_bookings.py

Paste the complete scraper script into it.

from playwright.sync_api import sync_playwright
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from datetime import datetime
from zoneinfo import ZoneInfo
import csv
 
BOOKINGS_URL = "https://hybrid.officernd.com/community/all-ears-centre/account/bookings"
 
# Persistent Playwright profile (kept next to the script)
CONTEXT_DIR = Path(".playwright_officernd_profile")
 
# Local timezone for conversion
LOCAL_TZ = ZoneInfo("Europe/London")
 
# OUTPUT DIRECTORY: Obsidian vault folder for room bookings CSVs
OUTPUT_DIR = Path("/Users/john/workspace/Obsidian/Psychotherapy Notes/100 CPD Record/Room Bookings")
 
 
def auto_scroll(page):
    """
    Scrolls to the bottom of the page to trigger lazy-loading of all bookings.
    Adjust timing if needed.
    """
    while True:
        previous_height = page.evaluate("document.body.scrollHeight")
        page.mouse.wheel(0, 20000)
        page.wait_for_timeout(1000)
        new_height = page.evaluate("document.body.scrollHeight")
        if new_height == previous_height:
            break
 
 
def fetch_booking_hrefs():
    """
    Opens Officernd in a persistent browser context.
 
    1. Opens the bookings URL.
    2. You log in manually if needed.
    3. Once you can see your bookings list, you press ENTER in the terminal.
    4. Then this function scrolls and collects all booking hrefs.
 
    Returns a list of href strings like:
    /community/all-ears-centre/account/bookings/<id>?start=...&end=...
    """
    CONTEXT_DIR.mkdir(exist_ok=True)
 
    with sync_playwright() as p:
        browser = p.chromium.launch_persistent_context(
            user_data_dir=str(CONTEXT_DIR),
            headless=False  # keep visible while setting up login
        )
 
        page = browser.new_page()
        page.goto(BOOKINGS_URL)
 
        print("\n---------------------------------------------------")
        print("  Log in to Officernd in the visible browser now.")
        print("  Make sure you are on the BOOKINGS LIST page.")
        print("  Then come back to this terminal window and")
        print("  press ENTER to start scraping.")
        print("---------------------------------------------------\n")
 
        input("Press ENTER to continue once the bookings page is fully visible... ")
 
        # Ensure all bookings are loaded
        auto_scroll(page)
 
        anchors = page.query_selector_all(
            'a[href^="/community/all-ears-centre/account/bookings/"]'
        )
 
        hrefs = []
        for a in anchors:
            href = a.get_attribute("href")
            if href:
                hrefs.append(href)
 
        browser.close()
 
    return hrefs
 
 
def parse_booking_href(href: str):
    """
    Parse a single booking href into structured data.
 
    Example href:
    /community/all-ears-centre/account/bookings/69308ce86a458b15faf83d45
      ?start=2025-12-10T14:00:00.000Z&end=2025-12-10T16:30:00.000Z
    """
    parsed = urlparse(href)
    path_parts = [p for p in parsed.path.split("/") if p]
    booking_id = path_parts[-1] if path_parts else ""
 
    qs = parse_qs(parsed.query)
    start_str = qs.get("start", [None])[0]
    end_str = qs.get("end", [None])[0]
 
    if not start_str or not end_str:
        return None
 
    # Convert 'Z' to '+00:00' for datetime.fromisoformat
    start_utc = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
    end_utc = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
 
    start_local = start_utc.astimezone(LOCAL_TZ)
    end_local = end_utc.astimezone(LOCAL_TZ)
 
    duration_hours = (end_utc - start_utc).total_seconds() / 3600.0
 
    return {
        "booking_id": booking_id,
        "start_utc": start_utc.isoformat(),
        "end_utc": end_utc.isoformat(),
        "start_local": start_local.isoformat(),
        "end_local": end_local.isoformat(),
        "date_local": start_local.date().isoformat(),
        "start_local_time": start_local.strftime("%H:%M"),
        "end_local_time": end_local.strftime("%H:%M"),
        "duration_hours": round(duration_hours, 2),
    }
 
 
def write_bookings_csv(bookings, path: Path):
    fieldnames = [
        "booking_id",
        "date_local",
        "start_local_time",
        "end_local_time",
        "duration_hours",
        "start_local",
        "end_local",
        "start_utc",
        "end_utc",
    ]
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for b in bookings:
            writer.writerow(b)
 
 
def main():
    hrefs = fetch_booking_hrefs()
 
    # Deduplicate by booking_id in case an href appears more than once
    bookings_by_id = {}
    for href in hrefs:
        rec = parse_booking_href(href)
        if rec is None:
            continue
        bookings_by_id[rec["booking_id"]] = rec
 
    bookings = list(bookings_by_id.values())
    bookings.sort(key=lambda b: (b["date_local"], b["start_local_time"]))
 
    # Timestamped output name, e.g. "officernd_bookings-20251203-193045.csv"
    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    output_csv = OUTPUT_DIR / f"officernd_bookings-{timestamp}.csv"
 
    write_bookings_csv(bookings, output_csv)
 
    # Quick monthly totals
    totals = {}
    for b in bookings:
        month_key = b["date_local"][:7]  # YYYY-MM
        totals[month_key] = totals.get(month_key, 0.0) + b["duration_hours"]
 
    print(f"Wrote {len(bookings)} bookings to {output_csv}")
    print("Monthly totals (hours):")
    for month, total in sorted(totals.items()):
        print(f"  {month}: {total:.2f} hours")
 
 
if __name__ == "__main__":
    main()
 
 

This will also create a persistent browser profile directory:

.playwright_officernd_profile/

Running the Scraper

From inside the project directory:

poetry run python scrape_officernd_bookings.py

A Chromium window will open automatically.

First run (manual login required)

  1. In the visible browser window, log in to Officernd as normal.

  2. Ensure you are viewing the Bookings list.

  3. Return to the terminal and press ENTER when prompted.

The script will:

  • scroll the page to load all bookings

  • extract booking links

  • parse start/end timestamps

  • compute durations

  • write officernd_bookings.csv

  • output monthly totals in the terminal

The CSV file is written to the script directory.

Subsequent runs

Your login session is stored in:

.playwright_officernd_profile/

If Officernd remembers your session, the script can run without login.

To automate:

  • Open the script

  • Change:

headless=False

to:

headless=True

The browser will then run invisibly.


CSV Output Format

Each row contains:

  • booking_id

  • date_local (YYYY-MM-DD)

  • start_local_time (24-hour)

  • end_local_time

  • duration_hours

  • start_local (full ISO)

  • end_local

  • start_utc

  • end_utc

These fields are safe to ingest into DataviewJS or any data-processing pipeline.


Example DataviewJS Query (optional)

Summarise total hours per month:

// Build path to CSV in the same folder as this note
const folder = dv.current().file.folder;  // e.g. "080 IT Stuff for psychotherapy"
const path = (folder ? folder + "/" : "") + "officernd_bookings.csv";
 
// Load raw CSV text
let text;
try {
    text = await dv.io.load(path);
} catch (e) {
    dv.paragraph("Error loading file at " + path + ": " + e);
    return;
}
 
if (!text) {
    dv.paragraph("No content loaded from " + path);
    return;
}
 
// Simple CSV parsing (no quotes/commas inside fields expected)
const lines = text.trim().split(/\r?\n/);
if (lines.length <= 1) {
    dv.paragraph("CSV at " + path + " has no data rows.");
    return;
}
 
// First line is the header
const headers = lines[0].split(",").map(h => h.trim());
 
// Convert each subsequent line into an object keyed by header
const rows = [];
for (let i = 1; i < lines.length; i++) {
    const line = lines[i].trim();
    if (!line) continue;  // skip empty lines
    const cols = line.split(",");  // safe for our simple CSV
    const obj = {};
    headers.forEach((h, idx) => {
        obj[h] = (cols[idx] ?? "").trim();
    });
    rows.push(obj);
}
 
if (rows.length === 0) {
    dv.paragraph("No data rows parsed from " + path);
    return;
}
 
// Aggregate hours by month (YYYY-MM from date_local)
const byMonth = {};
for (let row of rows) {
    const date = row.date_local;
    if (!date || date.length < 7) continue;
 
    const month = date.slice(0, 7);  // "YYYY-MM"
    const hours = Number(row.duration_hours) || 0;
    byMonth[month] = (byMonth[month] || 0) + hours;
}
 
// Build table rows sorted by month
const tableRows = Object.entries(byMonth)
    .sort(([m1], [m2]) => m1.localeCompare(m2))
    .map(([m, h]) => [m, h.toFixed(2)]);
 
if (tableRows.length === 0) {
    dv.paragraph("No monthly data to display.");
} else {
    dv.table(["Month", "Hours"], tableRows);
}
 

For full version, see All Ears Room Booking Report.md


Notes

  • If Officernd changes its URL structure, update the scraper accordingly.

  • The script relies only on the href timestamps; it does not depend on page layout or visible text.

  • The CSV file can be version-controlled, archived, or used for monthly reporting.

If the login session expires, simply run the script again with headless=False, log in manually, and continue.