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.pyOfficernd 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_bookingsInitialise 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)
-
In the visible browser window, log in to Officernd as normal.
-
Ensure you are viewing the Bookings list.
-
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
hreftimestamps; 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.