wpl-discovery / library_sources.py
GameTheory's picture
Sync from GitHub
24c5932 verified
Raw
History Blame Contribute Delete
115 kB
"""
library_sources.py - data-mining tools for Worcestershire Libraries,
at every granularity:
service level - every council library page (library_kb.json, live)
page level - every page of thehiveworcester.org (hive_kb.json)
item level - the live SirsiDynix catalogue (Atom feed)
copy level - per-branch holdings on an item's detail page, so we can
say WHERE to actually get the book/eBook someone wants
Sources & provenance policy:
- the SirsiDynix Enterprise catalogue (wcc.ent.sirsidynix.net.uk) - live
- worcestershire.gov.uk library pages - live + crawled KB (canonical)
- thehiveworcester.org - crawled page-by-page (build_hive_kb.py); every
fact carries its source page + crawl date. Where Hive and council pages
conflict (hours, prices, membership), the COUNCIL page wins, and answers
say which source they used. The Hive site's own events page is static,
so live events always come from the council.
No LLM, no Gradio in here - pure functions so they can be unit-tested
against the live sites on their own.
"""
from __future__ import annotations
import html
import json
import os
import re
import difflib
import xml.etree.ElementTree as ET
from datetime import datetime, timezone, timedelta
from urllib.parse import quote_plus, urljoin
import requests
from bs4 import BeautifulSoup
# --------------------------------------------------------------------------- #
# Config
# --------------------------------------------------------------------------- #
GOV = "https://www.worcestershire.gov.uk"
CATALOGUE_RSS = "https://wcc.ent.sirsidynix.net.uk/client/rss/hitlist/wcc/qu="
CATALOGUE_SEARCH = (
"https://wcc.ent.sirsidynix.net.uk/client/en_GB/wcc/search/results?qu="
)
HIVE = "https://www.thehiveworcester.org"
BORROWBOX = "https://library.bolindadigital.com/worcestershire"
EXPLORE_PAST = "https://www.explorethepast.co.uk"
MOBILE_INDEX = f"{GOV}/council-services/libraries/your-library-membership/mobile-library"
EVENTS_URL = f"{GOV}/council-services/libraries/library-events-and-activities"
PRINTING_URL = f"{GOV}/council-services/libraries/printing-and-photocopying-services"
UNLOCKED_URL = f"{GOV}/council-services/libraries/libraries-unlocked"
JOIN_URL = f"{GOV}/council-services/libraries/your-library-membership/join-library"
LIBRARY_PHONE = "01905 822722" # bookings/enquiries incl. Unlocked inductions
ONLINE_HUB = f"{GOV}/council-services/libraries/online-library-hub"
ACCOUNT_URL = f"{GOV}/council-services/libraries/your-library-membership/login-my-library-account"
RENEW_URL = f"{GOV}/council-services/libraries/your-library-membership/renew-loan"
FEES_URL = f"{GOV}/council-services/libraries/your-library-membership/pay-fees-and-charges"
RESERVE_URL = f"{GOV}/council-services/libraries/your-library-membership/reserve-your-library-books"
HOME_LIBRARY_URL = f"{GOV}/council-services/libraries/your-library-membership/library-service-home"
MEMBERSHIP_HUB_URL = f"{GOV}/council-services/libraries/your-library-membership"
ASK_FOR_A_BOOK_URL = f"{GOV}/council-services/libraries/read-and-discover/ask-book"
ROOM_HIRE_URL = f"{GOV}/council-services/libraries/hire-library-meeting-room"
BOOK_COMPUTER_URL = f"{GOV}/council-services/libraries/your-library-membership/book-computer"
READ_DISCOVER_URL = f"{GOV}/council-services/libraries/read-and-discover"
DIGITAL_INCLUSION_URL = f"{GOV}/council-services/libraries/learn-upskill-and-find-work/digital-inclusion-helping-you-online"
VOLUNTEERING_URL = f"{GOV}/council-services/libraries/learn-upskill-and-find-work/volunteering-training-and-work-experience"
READING_WELL_URL = f"{GOV}/council-services/libraries/read-and-discover/reading-well"
WARM_WELCOME_URL = f"{GOV}/council-services/libraries/warm-welcome"
LEARN_UPSKILL_URL = f"{GOV}/council-services/libraries/learn-upskill-and-find-work"
JOB_CLUBS_URL = f"{LEARN_UPSKILL_URL}/job-clubs"
CHILDREN_URL = f"{GOV}/council-services/libraries/read-and-discover"
SUMMER_READING_URL = f"{GOV}/council-services/libraries/read-and-discover/summer-reading-challenge"
CLOSING_DATES_URL = f"{GOV}/council-services/libraries/2026-libraries-closing-dates"
UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"WorcsLibrariesAssistant/2.0 (+hackathon demo)"
)
HEADERS = {"User-Agent": UA, "Accept-Language": "en-GB,en;q=0.9"}
TIMEOUT = 15
def _now() -> str:
return datetime.now(timezone.utc).strftime("%H:%M on %d %b %Y UTC")
def _get(url: str) -> requests.Response:
r = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
r.raise_for_status()
return r
# --------------------------------------------------------------------------- #
# 1. Catalogue search (SirsiDynix Atom feed)
# --------------------------------------------------------------------------- #
ATOM = "{http://www.w3.org/2005/Atom}"
_FORMAT_ICON = {
"Books": "",
"Large print": "",
"Sound recording": "",
"Music recording": "",
"Video disc": "",
"DVD": "",
"eBook": "",
"eAudiobook": "",
}
def _classify(fields: dict) -> tuple[str, bool]:
"""Return (friendly_format, is_digital)."""
fmt = fields.get("Format", "").strip()
ea = fields.get("Electronic Access", "")
digital = bool(ea) or "e" == fmt[:1].lower() and "ebook" in fmt.lower()
if "eAudiobook" in ea or "eAudiobook" in fmt:
return "eAudiobook", True
if "eBook" in ea or "eBook" in fmt or ea:
return ("eBook", True) if "audio" not in ea.lower() else ("eAudiobook", True)
return fmt or "Item", digital
def search_catalogue(query: str, limit: int = 8) -> dict:
"""
Search the live Worcestershire library catalogue.
Returns {"query", "count", "items": [...], "search_url", "checked"}.
Each item: title, author, format, year, isbn, digital, detail_url.
"""
query = (query or "").strip()
if not query:
return {"query": "", "count": 0, "items": [], "error": "empty query"}
url = CATALOGUE_RSS + quote_plus(query)
r = _get(url)
items: list[dict] = []
try:
root = ET.fromstring(r.content)
except ET.ParseError as e:
return {"query": query, "count": 0, "items": [],
"error": f"feed parse error: {e}",
"search_url": CATALOGUE_SEARCH + quote_plus(query)}
for entry in root.findall(f"{ATOM}entry"):
title = (entry.findtext(f"{ATOM}title") or "").strip().rstrip(".")
cat_id = (entry.findtext(f"{ATOM}id") or "").strip()
detail = ""
for link in entry.findall(f"{ATOM}link"):
if link.get("rel") == "alternate":
detail = link.get("href", "")
break
content = entry.findtext(f"{ATOM}content") or ""
content = content.replace("<br/>", "\n").replace("<br>", "\n")
content = html.unescape(content).replace("\xa0", " ")
fields: dict[str, str] = {}
for seg in content.split("\n"):
seg = seg.strip()
if not seg:
continue
if seg.lower().startswith("by "):
fields["author"] = seg[3:].strip().rstrip(".")
continue
for label in ("Format", "Publication Date", "ISBN", "Edition",
"Language", "Electronic Access", "Call Number", "Series"):
if seg.startswith(label):
fields[label] = seg[len(label):].strip()
break
fmt, digital = _classify(fields)
ea_url = ""
if fields.get("Electronic Access"):
m = re.search(r"https?://\S+", fields["Electronic Access"])
ea_url = m.group(0).rstrip('".,)') if m else ""
items.append({
"title": title,
"author": fields.get("author", ""),
"format": fmt,
"icon": _FORMAT_ICON.get(fmt, ""),
"year": fields.get("Publication Date", "").split()[0] if fields.get("Publication Date") else "",
"isbn": fields.get("ISBN", ""),
"digital": digital,
"electronic_access": ea_url,
"detail_url": detail,
"catalogue_id": cat_id,
})
if len(items) >= limit:
break
return {
"query": query,
"count": len(items),
"total_hint": len(root.findall(f"{ATOM}entry")),
"items": items,
"search_url": CATALOGUE_SEARCH + quote_plus(query),
"checked": _now(),
}
# --------------------------------------------------------------------------- #
# 2. Mobile library timetable
# --------------------------------------------------------------------------- #
_village_cache: dict[str, str] | None = None
def _village_index() -> dict[str, str]:
"""Map normalised village name -> absolute timetable URL (cached)."""
global _village_cache
if _village_cache is not None:
return _village_cache
r = _get(MOBILE_INDEX)
soup = BeautifulSoup(r.text, "html.parser")
out: dict[str, str] = {}
for a in soup.select('a[href*="/mobile-library/"]'):
href = a.get("href", "")
slug = href.rstrip("/").split("/")[-1]
if not slug or slug == "mobile-library":
continue
name = slug.replace("-", " ").strip().lower()
out.setdefault(name, urljoin(GOV, href))
_village_cache = out
return out
def mobile_library(place: str) -> dict:
"""
Find the mobile-library timetable for a village/stop.
Returns {"village", "date_of_operation", "stops":[{time,location}],
"email", "page_url", "checked"} or {"error", "suggestions"}.
"""
place = (place or "").strip().lower()
index = _village_index()
if not place:
return {
"no_village": True,
"guidance": (
"The mobile library timetable is organised by village stop, not by date. "
"Tell me your village or nearby area and I'll show you when the van visits."
),
"example_villages": sorted(index)[:8],
"page_url": MOBILE_INDEX,
"email": "mobilelibraries@worcestershire.gov.uk",
"checked": _now(),
}
# exact -> substring -> fuzzy
name = None
if place in index:
name = place
else:
subs = [v for v in index if place in v or v in place]
if subs:
name = sorted(subs, key=len)[0]
else:
close = difflib.get_close_matches(place, index.keys(), n=3, cutoff=0.6)
if close:
name = close[0]
if not name:
sugg = difflib.get_close_matches(place, index.keys(), n=6, cutoff=0.3)
return {
"error": f"No mobile-library stop found matching '{place}'.",
"suggestions": sugg or sorted(index)[:10],
"page_url": MOBILE_INDEX,
}
url = index[name]
soup = BeautifulSoup(_get(url).text, "html.parser")
main = soup.find("main") or soup
lines = [l.strip() for l in main.get_text("\n").split("\n") if l.strip()]
date_op = ""
for i, l in enumerate(lines):
if l.lower() == "date of operation" and i + 1 < len(lines):
date_op = lines[i + 1]
break
stop_re = re.compile(r"^\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm)?\s*to\s+.+-\s+.+", re.I)
stops = []
for l in lines:
if stop_re.match(l):
tpart, _, loc = l.partition(" - ")
stops.append({"time": tpart.strip(), "location": loc.strip()})
return {
"village": name.title(),
"date_of_operation": date_op,
"stops": stops,
"email": "mobilelibraries@worcestershire.gov.uk",
"page_url": url,
"checked": _now(),
}
# --------------------------------------------------------------------------- #
# 3. Library events & activities
# --------------------------------------------------------------------------- #
def _clean_label(text: str) -> str:
"""'Time: 9:30am to 2:00pm' -> '9:30am to 2:00pm'."""
return re.sub(r"^[A-Za-z ]+:\s*", "", text or "").strip()
def library_events(query: str | None = None, limit: int = 8) -> dict:
"""
Scrape upcoming library events/activities. Optional keyword filter.
Returns {"count", "events":[{name,when,time,location,url}], "page_url", "checked"}.
"""
soup = BeautifulSoup(_get(EVENTS_URL).text, "html.parser")
events: list[dict] = []
seen = set()
# Each event is a Drupal `views-row` that links to /events/<slug>.
for row in soup.select("div.views-row"):
anchor = row.find("a", href=re.compile(r"/events/[a-z0-9-]+"))
if not anchor:
continue
href = urljoin(GOV, anchor.get("href", ""))
if href in seen:
continue
seen.add(href)
title_el = row.select_one(".views-field-title")
name = (title_el.get_text(" ", strip=True) if title_el
else anchor.get_text(" ", strip=True))
time_el = row.select_one(".event-time")
days_el = row.select_one(".event-days")
loc_el = row.select_one(".views-field-field-location")
date_blob = row.select_one(".views-field-field-date-value")
next_date = ""
if date_blob:
m = re.search(r"Date:\s*(.+?)\s*(?:How often|Time:|$)",
date_blob.get_text(" ", strip=True))
if m:
next_date = m.group(1).strip()
events.append({
"name": name,
"next_date": next_date,
"when": _clean_label(days_el.get_text(" ", strip=True)) if days_el else "",
"time": _clean_label(time_el.get_text(" ", strip=True)) if time_el else "",
"location": _clean_label(loc_el.get_text(" ", strip=True)) if loc_el else "",
"url": href,
})
if query:
q = query.lower()
filt = [e for e in events
if q in e["name"].lower() or q in e["location"].lower()]
if filt:
events = filt
return {
"count": len(events[:limit]),
"events": events[:limit],
"page_url": EVENTS_URL,
"checked": _now(),
}
# --------------------------------------------------------------------------- #
# 4. Printing - "Print Your Way"
# --------------------------------------------------------------------------- #
# Sourced from the official printing page (verified June 2026). Stable content,
# so served directly with a live source link rather than re-scraped each call.
PRINT_YOUR_WAY = {
"summary": (
"Print Your Way lets full library members send a print job from their own "
"phone, tablet or computer and collect it from any Worcestershire library "
"printer within 24 hours - great for job applications, forms, tickets and "
"returns labels."
),
"device_requirements": "Android 12+, iOS 16+, macOS 12 (Monterey)+, or Windows 11.",
"steps": [
f"Be a full library member - free, [join online]({JOIN_URL}) or in any library.",
"Top up your PaperCut print account at a self-service kiosk in any "
"Worcestershire library (some kiosks are cash-only, so check first).",
"One-time setup: download and follow the Print Your Way guide for your "
f"device - Android, iOS, macOS and Windows guides are on the "
f"[printing page]({PRINTING_URL}).",
"Open your document, choose the mono or colour print queue, set your "
"options and send - authenticate with your library number and PIN.",
"Release the job at any public printer in any Worcestershire library "
"within 24 hours.",
],
"pricing": {
"A4 black & white": "15p per side",
"A4 colour": "50p per side",
"A3 black & white": "25p per side",
"A3 colour": "85p per side",
},
"in_library_photocopy": {
"summary": (
"Walk-up photocopying is available at most Worcestershire libraries - "
"no library card or phone needed, just bring your document and pay at "
"the counter."
),
"how_to": [
"Take your original document to any Worcestershire library branch.",
"Ask a member of staff to make copies, or use the self-service copier "
"where available.",
"Pay at the counter - cash and card are usually accepted.",
],
"pricing": {
"A4 black & white": "15p per side",
"A4 colour": "50p per side",
"A3 black & white": "25p per side",
"A3 colour": "85p per side",
},
"note": (
"Not every branch has a self-service copier; some rely on staff-assisted "
f"copying. Call ahead on **{LIBRARY_PHONE}** or check with your local "
"branch to confirm availability."
),
},
"scanning": {
"summary": (
"Document scanning (to USB stick or email) is available at some "
"Worcestershire library branches - availability varies, so it's worth "
"calling ahead before you travel."
),
"how_to": [
"Contact your local branch to confirm a scanner is available.",
"Bring a USB stick for scan-to-USB, or be ready to provide your email "
"address for scan-to-email.",
"Ask staff about the charge - scanning is usually priced similarly to "
"photocopying.",
],
"note": (
f"Call ahead on **{LIBRARY_PHONE}** to check scanner availability at "
"your nearest branch - not all sites have this facility."
),
},
"page_url": PRINTING_URL,
}
def printing_help(query: str | None = None) -> dict:
out = dict(PRINT_YOUR_WAY)
out["checked"] = _now()
q = (query or "").lower()
if re.search(r"\bscan", q):
out["focus"] = "scanning"
elif re.search(r"\bphotocop", q):
out["focus"] = "photocopy"
else:
out["focus"] = "print"
return out
# --------------------------------------------------------------------------- #
# 5. Knowledge base - every library service page (built by build_kb.py)
# --------------------------------------------------------------------------- #
_KB = None
def kb() -> dict:
global _KB
if _KB is None:
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"library_kb.json")
try:
with open(path, encoding="utf-8") as f:
_KB = json.load(f)
except FileNotFoundError:
_KB = {"branches": [], "online_hub": [], "services": [],
"membership_tiers": []}
return _KB
# "What you need to sign up" - the eligibility that varies per service.
ELIGIBILITY = {
"borrow_physical": f"Free **full membership** ([join online]({JOIN_URL}) + collect, "
"or in any library).",
"borrow_digital": f"Free **digital membership** - instant, just a Worcestershire "
f"postcode ([sign up]({JOIN_URL})).",
"printing": "Full membership + top up a PaperCut account at a library kiosk.",
"mobile": "Free full membership - you can join on the van.",
"unlocked": "Full member, aged 15+, after a short one-off staff induction.",
"events": "Most events are free - just turn up; a few need booking.",
"visit": "Nothing at all - anyone can walk in for Wi-Fi, toilets and study space.",
"hive_visit": "Nothing - The Hive is open to everyone, 8:30am–10pm every day.",
"archives": "Free to visit Explore the Past (Level 2, The Hive); opening "
f"times and document-ordering rules are on [Explore the Past]({EXPLORE_PAST}).",
"ask_book": (f"Any adult - free, no prior membership needed to request. "
f"[Ask for a Book]({ASK_FOR_A_BOOK_URL})"),
"computer": (f"Free library membership. [Book a computer session]({BOOK_COMPUTER_URL}) "
"online or just walk in - sessions available on demand or pre-booked."),
"room_hire": f"Anyone can hire a library meeting room. [Check availability and book]({ROOM_HIRE_URL}).",
}
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday", "Sunday"]
def _uk_now() -> datetime:
try:
from zoneinfo import ZoneInfo
return datetime.now(ZoneInfo("Europe/London"))
except Exception:
return datetime.now(timezone.utc) + timedelta(hours=1) # BST approx
def _to_minutes(t: str):
m = re.match(r"\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)", t, re.I)
if not m:
return None
h = int(m.group(1)) % 12
if m.group(3).lower() == "pm":
h += 12
return h * 60 + int(m.group(2) or 0)
def _open_status(staffed: str, now_min: int):
if not staffed or "close" in staffed.lower():
return False, "closed today"
rng = re.search(r"(\d{1,2}(?::\d{2})?\s*(?:am|pm))\s*(?:to|-|–)\s*"
r"(\d{1,2}(?::\d{2})?\s*(?:am|pm))", staffed, re.I)
if not rng:
return None, staffed
a, b = _to_minutes(rng.group(1)), _to_minutes(rng.group(2))
if a is None or b is None:
return None, staffed
if a <= now_min < b:
return True, f"open now until {rng.group(2)}"
if now_min < a:
return False, f"opens at {rng.group(1)}"
return False, f"closed now (was open until {rng.group(2)})"
_WEEKDAY_IDX = {d.lower(): i for i, d in enumerate(DAY_NAMES)}
def _resolve_day(when: str | None, now: datetime):
"""Map a free-text day hint ('tomorrow', 'monday', 'tonight', …) to a
(weekday_index, human_label, is_today) triple. Defaults to today."""
today_idx = now.weekday()
t = (when or "").lower()
if "tomorrow" in t:
idx = (today_idx + 1) % 7
return idx, f"tomorrow ({DAY_NAMES[idx]})", False
for name, idx in _WEEKDAY_IDX.items():
if re.search(rf"\b{name}\b", t):
label = f"today ({name.capitalize()})" if idx == today_idx else name.capitalize()
return idx, label, idx == today_idx
return today_idx, f"today ({DAY_NAMES[today_idx]})", True
def _day_status(staffed: str):
"""Open/closed summary for a day other than today (no 'now')."""
if not staffed or "close" in staffed.lower():
return "closed"
return f"open {staffed}"
def find_library(name: str | None = None, when: str | None = None) -> dict:
"""Branch hours, address and facilities. `when` is a free-text day hint
('tomorrow', 'Monday', 'tonight'); absent or 'today'/'now' means today."""
branches = kb().get("branches", [])
if not (name or "").strip():
return {"branches": [{"name": b["name"], "address": b.get("address", ""),
"url": b["url"]} for b in branches], "checked": _now()}
names = {b["name"].lower(): b for b in branches}
q = name.strip().lower()
pick = next((b for n, b in names.items()
if q in n or n.replace(" library", "").strip() in q), None)
if not pick:
close = difflib.get_close_matches(q, names.keys(), n=1, cutoff=0.4)
pick = names[close[0]] if close else None
if not pick:
return {"error": f"No Worcestershire library found matching '{name}'.",
"suggestions": [b["name"] for b in branches[:8]],
"page_url": f"{GOV}/council-services/libraries/find-library"}
now = _uk_now()
idx, day_label, is_today = _resolve_day(when, now)
day_name = DAY_NAMES[idx]
h = pick.get("hours", {}).get(day_name, {})
staffed, unlocked = h.get("staffed", ""), h.get("unlocked", "")
if is_today:
open_now, status = _open_status(staffed, now.hour * 60 + now.minute)
else:
open_now, status = None, _day_status(staffed)
return {
"name": pick["name"], "address": pick.get("address", ""),
"facilities": pick.get("facilities", []), "hours": pick.get("hours", {}),
"libraries_unlocked": pick.get("libraries_unlocked", False),
"day_label": day_label, "is_today": is_today,
"staffed": staffed, "unlocked": unlocked,
# legacy keys kept for backward-compatibility (now the *requested* day)
"today": day_name, "today_staffed": staffed, "unlocked_today": unlocked,
"open_now": open_now, "status": status,
"page_url": pick["url"], "checked": _now(),
}
UNLOCKED_BRANCHES = ["Bromsgrove", "Droitwich", "Evesham", "Kidderminster",
"Malvern", "Pershore", "Redditch", "Rubery", "St John's",
"Stourport", "Tenbury"]
def libraries_unlocked(branch: str | None = None) -> dict:
tier = next((t for t in kb().get("membership_tiers", [])
if "Unlocked" in t.get("tier", "")), {})
out = {"branches": UNLOCKED_BRANCHES,
"hours": "8:00am to 8:00pm, Monday to Saturday",
"what_you_need": tier.get("what_you_need", ELIGIBILITY["unlocked"]),
"unlocks": tier.get("unlocks", ""),
"get_started": [
f"Pop into a Libraries Unlocked branch during **staffed hours** and "
f"ask for the one-off induction - or call **{LIBRARY_PHONE}** to book "
f"a slot in advance.",
f"It takes a few minutes: staff show you how the self-service entry "
f"and safety procedures work, then your card is upgraded - free.",
f"Already a full member aged 15+? That's all you need. Not a member "
f"yet? [Join online first]({JOIN_URL}) (free, takes minutes).",
],
"page_url": tier.get("url", UNLOCKED_URL),
"checked": _now()}
if branch:
b = branch.strip().lower()
out["branch_match"] = next(
(x for x in UNLOCKED_BRANCHES if x.lower() in b or b in x.lower()), None)
return out
# Curated, customer-grade detail for the online-hub resources. The crawler
# gets summaries; this adds the precise "what you need + how to access + what's
# inside" that the council pages bury. (Verified June 2026.)
# NB: we surface ACCESS + public TITLE COVERAGE only - never the licensed
# article content itself (that would breach PressReader/publisher terms).
CURATED_HUB = {
"pressreader": {
"what_you_need": "Worcestershire library card number + PIN (free full membership, residents). No separate PressReader account needed.",
"at_home": True,
"access": [
"Get the PressReader app - [iPhone/iPad](https://apps.apple.com/app/pressreader/id313904711) "
"or [Android](https://play.google.com/store/apps/details?id=com.newspaperdirect.pressreader.android) - "
"or read in your browser at [pressreader.com](https://www.pressreader.com).",
"Tap 'Libraries & Groups' and search/select 'Worcestershire'.",
"Sign in with your library card number + PIN.",
"At home you get a 30-day pass (re-confirm monthly); on library Wi-Fi it's 7 days.",
],
"inside": "7,000+ full-page newspapers & magazines, 60+ languages, 120+ countries.",
"titles": ["The Guardian", "The Independent", "Newsweek", "Vogue", "GQ",
"Hello!", "BBC Top Gear", "Le Monde", "El País"],
"extras": "Free account adds offline download, article translation and listen-to-article audio.",
},
"borrowbox": {
"what_you_need": "Library card number + PIN; choose 'Worcestershire' in the app.",
"at_home": True,
"access": [
"Download the BorrowBox app - [iPhone/iPad](https://apps.apple.com/app/borrowbox-library/id562843562) "
"or [Android](https://play.google.com/store/apps/details?id=com.bolindadigital.BorrowBoxLibrary).",
"Select 'Worcestershire' as your library service.",
"Sign in with your card number + PIN.",
],
"inside": "Free eBooks & eAudiobooks - latest titles, award winners, non-fiction, classics and children's, plus the curated 'Worcestershire Reads' picks.",
"limits": "Borrow up to 4 eBooks + 4 eAudiobooks at once; auto-returns (no fines); read/listen offline in the app.",
},
"ancestry": {
"what_you_need": "Library membership. Ancestry Library Edition is normally used in the library / on library Wi-Fi - check the hub page for current at-home access.",
"at_home": False,
"inside": "Billions of historical records - census, births/marriages/deaths, military, immigration and more, spanning the 1500s–2000s.",
},
"theory test pro": {
"what_you_need": "Library card; works at home.",
"at_home": True,
"inside": "Practise the official DVSA driving theory test (car, motorcycle, LGV/PCV) including hazard-perception clips.",
},
"which": {
"what_you_need": "Library membership; available on library computers in-branch - ask staff for access.",
"at_home": False,
"inside": "Independent product reviews, Best Buy recommendations and consumer advice from Which? - covering technology, appliances, food, finance and more.",
"access": [
"Ask a member of staff at any Worcestershire library to access Which? "
"on a library computer during staffed hours.",
f"Or visit the [Online Library Hub]({ONLINE_HUB}) for the current Which? access link.",
],
},
"times digital archive": {
"what_you_need": "Free digital membership.",
"at_home": True,
"inside": "Every page of The Times newspaper, scanned back to 1785.",
},
"access to research": {
"what_you_need": "Free walk-in use on a library computer (in-branch).",
"at_home": False,
"inside": "Millions of academic journal articles across many disciplines - for students and independent researchers.",
},
"bfi": {
"what_you_need": "Use on library computers / library Wi-Fi (in-branch).",
"at_home": False,
"inside": "Thousands of archive British films and TV programmes from the BFI National Archive.",
},
"cobra": {
"what_you_need": "Library membership; supported by the Business & IP Centre (often used in-branch).",
"at_home": False,
"inside": "Business start-up guides, market research and reference for new and growing businesses.",
},
"digital library membership": {
"what_you_need": "Any Worcestershire resident - sign up online in minutes with your postcode. No card needed.",
"at_home": True,
"inside": "Instant free access to eBooks, eAudiobooks, eMagazines & eNewspapers and more.",
},
"ebsco": {
"what_you_need": "Library card + PIN; available at home.",
"at_home": True,
"inside": "Academic and reference databases - magazines, journals and research articles.",
},
"espacenet": {
"what_you_need": "Free for everyone; support via the Business & IP Centre.",
"at_home": True,
"inside": "Search 150+ million patent documents worldwide (European Patent Office).",
},
"online events": {
"what_you_need": "Free - book a place via the events listing.",
"at_home": True,
"inside": "Live digital talks and workshops you can join from home.",
},
"national biography": {
"what_you_need": "Free digital membership - any Worcestershire resident, instant sign-up with a postcode.",
"at_home": True,
"access": [
f"Visit the council's [Oxford DNB hub page]({GOV}/council-services/libraries/online-library-hub/oxford-dictionary-national-biography) and click the access link.",
"Sign in with your Worcestershire library card number + PIN.",
f"Not a member yet? [Get free digital membership instantly]({JOIN_URL}) - just a Worcestershire postcode needed.",
],
"inside": "60,000+ biographies of notable people from British history - statespeople, scientists, artists, writers and more.",
},
"oxford english": {
"what_you_need": "Free digital membership - any Worcestershire resident, instant sign-up with a postcode.",
"at_home": True,
"access": [
f"Visit the council's [Oxford English Dictionary hub page]({GOV}/council-services/libraries/online-library-hub/oxford-english-dictionary) and click the access link.",
"Sign in with your Worcestershire library card number + PIN.",
f"Not a member yet? [Get free digital membership instantly]({JOIN_URL}) - just a Worcestershire postcode needed.",
],
"inside": "The complete Oxford English Dictionary (OED) - meanings, history and pronunciation for 600,000+ words, old and new.",
},
"oxford reference": {
"what_you_need": "Free digital membership - any Worcestershire resident, instant sign-up with a postcode.",
"at_home": True,
"access": [
f"Visit the council's [Oxford Reference hub page]({GOV}/council-services/libraries/online-library-hub/oxford-reference) and click the access link.",
"Sign in with your Worcestershire library card number + PIN.",
f"Not a member yet? [Get free digital membership instantly]({JOIN_URL}) - just a Worcestershire postcode needed.",
],
"inside": "Thousands of dictionaries, fact books and reference works across every subject - science, history, law, art, literature and more.",
},
"oxford research": {
"what_you_need": "Free digital membership - any Worcestershire resident, instant sign-up with a postcode.",
"at_home": True,
"access": [
f"Visit the council's [Oxford Research Encyclopaedias hub page]({GOV}/council-services/libraries/online-library-hub/oxford-research-encyclopaedias) and click the access link.",
"Sign in with your Worcestershire library card number + PIN.",
f"Not a member yet? [Get free digital membership instantly]({JOIN_URL}) - just a Worcestershire postcode needed.",
],
"inside": "In-depth, peer-reviewed research encyclopaedias across many fields - clear guides for study and independent research.",
},
}
def _merge_curated(name: str) -> dict:
n = name.lower()
for key, data in CURATED_HUB.items():
if key in n or n in key:
return data
return {}
BORROWING_POLICY = {
"account": {
"summary": ("Log in to renew loans, reserve items, cancel reservations, "
"update your details and change your PIN."),
"what_you_need": "Your library card number + PIN.",
"pin_reset": (
f"Forgotten your PIN? Use the 'Forgot PIN' link on the "
f"[account sign-in page]({ACCOUNT_URL}), or visit any library in person "
"with your library card."
),
"how_to": f"Visit [Login to my library account]({ACCOUNT_URL}).",
"url": ACCOUNT_URL,
},
"renewals": {
"summary": "Extend your loan by renewing - online, by phone, or in person.",
"methods": [
f"**Online (easiest):** [Sign in to your account]({ACCOUNT_URL}) "
"with your card number + PIN, then choose 'Renew'.",
"**In person:** Ask staff or use a self-service kiosk at any Worcestershire library.",
"**By phone:** Call your local branch during staffed hours.",
],
"note": ("You may not be able to renew if another member has a reservation on the item - "
"in that case, return it on time and the other member will be notified."),
"url": RENEW_URL,
},
"fines": {
"summary": ("Borrowing books is free. If items are not returned or renewed by the due "
"date, late fees will apply. A full list of current fees and charges "
f"is at [fees and charges]({FEES_URL})."),
"charges_breakdown": [
"**Overdue items** - a daily late fee per item applies until the item is returned "
f"or renewed. Current rates at [fees and charges]({FEES_URL}).",
"**Lost items** - a replacement charge applies, based on the cost of replacing "
"the item. Let staff know as soon as possible.",
"**Damaged items** - minor wear is usually accepted; significant damage may "
"attract a partial or full replacement charge.",
f"**Replacement library card** - a charge applies for a lost/stolen card; "
f"see [fees and charges]({FEES_URL}) for the current amount.",
],
"how_to_pay": (f"[Pay fees and charges online]({FEES_URL}) via your library account, "
"or pay in person at any Worcestershire library branch."),
"note": (
"Outstanding fees will not prevent you from borrowing, but they must be cleared "
"before your membership can be renewed. BorrowBox eBooks and eAudiobooks never "
"incur late fees - they auto-return on the due date."
),
"url": FEES_URL,
},
"reservations": {
"summary": ("Reserve items online - they'll be held at your chosen branch and "
"you'll be emailed when ready to collect."),
"cost": "Reservations are free.",
"how_to": [
"Search for the item in the catalogue or browse online.",
"Open the item page and sign in with your card number + PIN.",
("Choose 'Place Hold' and select your collection branch - "
"any Worcestershire library or the mobile van."),
"You'll get an email (or SMS if registered) when it's ready to collect.",
],
"hold_duration": (
"Items are typically held for **7 days** after your notification - "
"collect within that window. If you can't make it, cancel and re-reserve "
"so the item stays available to you."
),
"if_missed": (
"If a held item isn't collected within the hold period, it goes back into "
"general circulation. Simply re-reserve it - no penalty applies."
),
"how_to_cancel": (
f"[Sign in to your account]({ACCOUNT_URL}), go to 'Reservations', "
"select the item and choose 'Cancel Hold'. Or ask staff at any branch "
"during staffed hours."
),
"url": RESERVE_URL,
},
"returning": {
"summary": ("Return items at any Worcestershire library - even if you borrowed "
"from a different branch. Many branches have a 24-hour drop-box "
"outside for returns when the library is unstaffed."),
"methods": [
"Hand items to staff or use a **self-service kiosk** at any Worcestershire library.",
"Post items in the **24-hour external drop-box** if your branch has one "
"(available outside staffed hours, including Libraries Unlocked hours).",
"Return to the **mobile library van** on its next visit.",
],
"note": ("Items clear from your account automatically when checked in. "
"Returning to a different branch is fine - no need to go back to "
"the branch you borrowed from."),
"url": ACCOUNT_URL,
},
"lost_item": {
"summary": ("If you've lost or damaged a library item, let the library know "
"as soon as possible. A replacement charge applies to lost items; "
"damaged items may incur a smaller charge depending on severity."),
"what_to_do": [
"**Contact your library** - by phone during staffed hours, in person, "
"or online via the council website.",
f"**Lost item:** a replacement charge applies - see the [fees page]({FEES_URL}) "
"for current rates.",
"**Damaged item:** return it and speak to staff - minor wear may attract a "
"small charge; severe damage may be treated as lost.",
],
"url": FEES_URL,
},
"loan_limits": {
"summary": (
"Standard loans are for 3 weeks - books, DVDs, CDs and most other "
"physical items. You can renew as many times as needed, provided no "
"other member has reserved the item. For the current limit on how many "
"items you can borrow at once, see the membership page."
),
"loan_period": "3 weeks (books, DVDs, CDs and other physical items).",
"renewals": (
"Renew online, by phone or in person - as many times as you need, "
"unless another member has placed a reservation on the item."
),
"digital": (
"eBooks and eAudiobooks via BorrowBox have a **21-day loan period** - "
"they auto-return on the due date (no fines, no action needed). "
f"Borrow up to 4 eBooks and 4 eAudiobooks simultaneously. "
f"[Get BorrowBox]({BORROWBOX})"
),
"url": MEMBERSHIP_HUB_URL,
},
"pin_reset": {
"summary": "Forgotten your library PIN? Reset it online, by phone or in person.",
"how_to": [
f"**Online (quickest):** Go to [Login to my library account]({ACCOUNT_URL}) "
"and use the 'Forgotten PIN' link - you'll need your library card number.",
"**In person:** Visit any Worcestershire library with proof of identity - "
"staff can issue a new PIN on the spot.",
f"**By phone:** Call **{LIBRARY_PHONE}** during staffed hours.",
],
"note": (
"If you've also lost your library card, get a replacement card in person "
f"first (a small charge may apply - see [fees page]({FEES_URL})), then "
"reset your PIN."
),
"url": ACCOUNT_URL,
},
"page_url": MEMBERSHIP_HUB_URL,
}
HOME_LIBRARY_SERVICE = {
"summary": (
"The Library Service at Home is a free, volunteer-run service for people who "
"find it difficult or impossible to visit a library in person. Volunteers "
"select and deliver books and other library materials directly to your home."
),
"what_you_need": "Full library membership (free). Contact your local library to register.",
"contact": "Call 01905 822722 or ask staff at any Worcestershire library.",
"url": HOME_LIBRARY_URL,
"also_see": (
f"The **mobile library** also visits 160 villages across Worcestershire every "
f"4–5 weeks - a great option for many rural residents. "
f"[Mobile library timetables]({MOBILE_INDEX})"
),
}
ASK_FOR_A_BOOK = {
"summary": (
"Ask for a Book is a free personalised reading recommendation service. "
"Tell the library what you enjoy and library staff will curate up to three "
"books matched to your taste - ready to collect at your local branch."
),
"what_you_need": (
f"Any adult - free, no prior membership needed to make a request. "
f"You'll need a library card to collect the books; [join free]({JOIN_URL}) "
"online or at any branch."
),
"how_to": [
f"Visit the [Ask for a Book page]({ASK_FOR_A_BOOK_URL}) and fill in the short form.",
"Describe what you enjoy - genres, favourite authors, themes, or things you "
"want to avoid.",
"A librarian will hand-pick up to three books matched to your preferences.",
"You'll be notified when your recommendations are ready to collect at your "
"chosen branch.",
],
"url": ASK_FOR_A_BOOK_URL,
}
ROOM_HIRE = {
"summary": (
"Worcestershire libraries offer affordable meeting rooms and spaces for hire - "
"suitable for business meetings, community groups, training sessions and events."
),
"what_you_need": (
"Anyone can hire a library meeting room - membership is not required. "
"Spaces vary by branch; prices and availability are on the booking page."
),
"how_to": [
f"Browse available rooms and check pricing at [Hire a library meeting room]({ROOM_HIRE_URL}).",
"Book online or contact the relevant library branch directly.",
],
"also_see": (
"The Hive (Worcester) also offers larger venues and specialist spaces - "
"ask me about 'room hire at The Hive' for details."
),
"url": ROOM_HIRE_URL,
}
COMPUTER_BOOKING = {
"summary": (
"Free public computers are available at most Worcestershire libraries. "
"Sessions can be booked in advance or simply used on a walk-in basis - "
"no appointment needed."
),
"what_you_need": "Free library membership (any tier, including instant digital membership).",
"how_to": [
f"[Book a computer session online]({BOOK_COMPUTER_URL}) using your library card number + PIN, "
"or just walk in during staffed hours - computers are available on demand.",
"Sessions are free and typically up to 1 hour (extensions may be available if machines are free).",
"Free Wi-Fi is available at every library if you prefer to use your own device - no login needed.",
],
"url": BOOK_COMPUTER_URL,
}
DIGITAL_SKILLS = {
"summary": (
"Worcestershire Libraries offer free digital skills support for everyone - "
"whether you're getting online for the first time or want to build confidence "
"with smartphones, email, online services or the internet."
),
"what_you_need": "No membership needed to drop in - just turn up during staffed hours.",
"services": [
f"**[Digital Inclusion support page]({DIGITAL_INCLUSION_URL})** - links to all resources and guidance.",
"**Learn My Way** - free, structured online courses to build digital skills at your own pace. "
"Library staff can help you sign up and get started.",
"**Digital Champions** - staff and volunteers available during staffed hours to help with "
"devices, apps, online forms and internet safety.",
"**Public computers and free Wi-Fi** - available at all libraries, no booking needed.",
f"**[Get Online Week sessions]** - seasonal digital skills events; check [library events]({EVENTS_URL}) for dates.",
],
"url": DIGITAL_INCLUSION_URL,
}
VOLUNTEERING = {
"summary": (
"Worcestershire Libraries welcomes volunteers in branches across the county. "
"It's a rewarding way to give back to your community, learn new skills and meet people."
),
"what_you_need": (
f"No specific experience needed - just enthusiasm for libraries. Speak to staff "
f"at your local library or call **{LIBRARY_PHONE}** to find out what opportunities "
"are available near you."
),
"also_see": (
f"Young people aged 13–24 can volunteer during the **Summer Reading Challenge**. "
f"Work experience and training placements are also available - see "
f"[Volunteering, training and work experience]({VOLUNTEERING_URL})."
),
"url": f"{GOV}/news/discover-volunteering-opportunities-your-local-library",
}
READING_WELL = {
"summary": (
"Reading Well is a free, curated book collection providing information and "
"support for mental health, wellbeing and long-term health conditions. "
"Endorsed by health professionals, all titles are available to borrow free "
"from any Worcestershire library."
),
"what_you_need": "Free library membership - borrow any Reading Well title from any branch.",
"collections": [
"**Reading Well for Adults** - books on managing mental health, anxiety, depression and long-term conditions.",
"**Reading Well for Young People** - helping teenagers navigate difficult times.",
"**Reading Well for Children** - supporting younger readers to understand their feelings.",
"**Reading Well for Families** - guidance for families through pregnancy and early parenthood.",
],
"url": READING_WELL_URL,
}
WARM_SPACE = {
"summary": (
"All Worcestershire libraries are free, warm and welcoming spaces - no "
"membership needed and no need to buy anything. Part of the Warm Welcome "
"Network, libraries offer a safe, comfortable environment with free Wi-Fi, "
"computers, reading material and knowledgeable staff."
),
"what_you_need": "Nothing - anyone can walk in during staffed hours.",
"also_see": (
"Library staff can also signpost visitors to local support services, "
"community groups, health information and wellbeing resources."
),
"url": WARM_WELCOME_URL,
}
JOB_CLUBS = {
"summary": (
"Library Job Clubs are free, friendly sessions where you can get help with "
"CV writing, job applications, interview preparation and online job searching. "
"Run at various Worcestershire libraries - no appointment usually needed."
),
"what_you_need": "Free to attend - library membership is not required for Job Club sessions.",
"how_to": [
f"Check the [library events page]({EVENTS_URL}) for Job Club dates and locations near you.",
"Drop in during the session, or call your local library to confirm times.",
"Bring your CV or any job-related documents you'd like help with.",
],
"also_see": (
f"Also handy: free [public computers]({BOOK_COMPUTER_URL}) for job searching and CV "
f"printing, and [adult learning courses]({LEARN_UPSKILL_URL}) to build new skills."
),
"url": JOB_CLUBS_URL,
}
CHILDREN_SERVICES = {
"summary": (
"Worcestershire libraries run a wide range of free children's activities - "
"Storytime, Rhymetime, Bounce and Rhyme, the Summer Reading Challenge, "
"holiday events and more - across branches and at The Hive."
),
"what_you_need": (
"Most sessions are free and need no booking - just turn up. "
"A few popular sessions may require advance booking; check the events listing."
),
"highlights": [
"**Storytime** - free weekly sessions at many branches for children aged 2–6; "
"stories, songs and a craft activity.",
"**Rhymetime / Bounce and Rhyme** - free sessions for babies and toddlers "
"(typically 0–2 years), with nursery rhymes and movement songs. No booking needed at most branches.",
"**Summer Reading Challenge** - the annual national reading challenge for "
"children aged 4–11, free to join at any library during the summer holidays.",
"**Holiday activities** - special events, crafts and clubs throughout school holidays.",
"**Children's library at The Hive** - a dedicated children's floor with a wide "
"selection of books for all ages.",
f"**Children's eBooks & audiobooks** - available free via [BorrowBox]({BORROWBOX}).",
],
"events_url": EVENTS_URL,
"borrowbox_url": BORROWBOX,
"join_url": JOIN_URL,
}
SUMMER_READING_CHALLENGE = {
"summary": (
"The Summer Reading Challenge is a free annual programme encouraging "
"children to keep reading during the school summer holidays. "
"Children aged 4–11 borrow and read any six library books between July "
"and September, collecting stickers and rewards along the way - and "
"earning a certificate for completing the challenge."
),
"what_you_need": (
"Free library membership for the child - sign up at any branch or online. "
"Children register for the Challenge at the library from July each year."
),
"how_to": [
"Visit any Worcestershire library from July onwards to register your child "
"- registration is free.",
"Pick up your reading record booklet and choose your first books.",
"Return to the library after every two books to collect stickers and rewards.",
"Read six books by mid-September to earn a certificate (and a medal at "
"many branches).",
],
"also_see": (
"Young people aged 13–24 can **volunteer** on the Summer Reading Challenge "
"- great for CVs and the Duke of Edinburgh's Award. "
f"See [volunteering and work experience]({VOLUNTEERING_URL}) for details."
),
"url": SUMMER_READING_URL,
}
ADULT_LEARNING = {
"summary": (
"Worcestershire libraries support adult learning and skills development - "
"including IT and computer courses, English and maths classes, and access "
"to free online learning tools. Sessions are held in-branch and online."
),
"what_you_need": (
"Most courses and sessions are free to attend. Library membership may be "
"needed to access online platforms like Learn My Way."
),
"how_to": [
f"Browse current courses and sessions on the [Learn, Upskill and Find Work]({LEARN_UPSKILL_URL}) page.",
"Call your local library to ask what adult learning is available near you.",
f"[Learn My Way]({DIGITAL_INCLUSION_URL}) is a free beginner-friendly platform "
"covering internet basics, email, online safety and more - accessible from home.",
],
"also_see": (
f"[Job Clubs]({JOB_CLUBS_URL}) offer free CV and employment support; "
f"[Digital Champions]({DIGITAL_INCLUSION_URL}) give one-to-one help getting online."
),
"url": LEARN_UPSKILL_URL,
}
BOOK_CLUBS = {
"summary": (
"Worcestershire libraries host and support book clubs and reading groups - "
"a free, friendly way to discover new books and share views with other readers. "
"Groups meet regularly at various branches."
),
"what_you_need": (
"Usually free to attend. A library card is helpful so you can borrow reading "
"copies. Contact your nearest library for details of local groups."
),
"how_to": [
f"Check the [library events listing]({EVENTS_URL}) for book club dates near you.",
"Contact your nearest library branch to ask about active reading groups.",
"Libraries can often supply reading copies of the chosen book for group members.",
],
"also_see": (
f"[PressReader]({ONLINE_HUB}) gives free access to thousands of literary "
"magazines - great for keeping up with reviews between meetings."
),
"url": EVENTS_URL,
}
TEEN_SERVICES = {
"summary": (
"Worcestershire libraries have plenty for teenagers and young adults - "
"a Young Adult (YA) fiction section, free eBooks and audiobooks, "
"volunteering opportunities and extended access for 15+."
),
"highlights": [
"**Young Adult (YA) fiction** - dedicated YA and teen sections at most branches, "
"covering YA fiction, graphic novels, manga and non-fiction for teens.",
f"**Free eBooks & audiobooks** - [BorrowBox]({BORROWBOX}) has a large YA catalogue "
"of ebooks and audiobooks. Borrow up to 4 of each, auto-return, no fines - "
"just a free library card.",
f"**Libraries Unlocked** - members aged 15+ can access participating libraries "
f"7 days a week, 8am–8pm, even when unstaffed. Requires a one-off induction at "
f"your branch. [Find out more]({UNLOCKED_URL})",
"**Summer Reading Challenge volunteering** - young people aged 13–24 can volunteer "
f"in libraries during the Summer Reading Challenge (July–September). Great for CVs "
f"and the Duke of Edinburgh's Award. [Volunteering details]({VOLUNTEERING_URL})",
"**Teen events** - creative writing, gaming, craft and quiz events run throughout "
f"the year at various branches. Check the [library events page]({EVENTS_URL}) for "
"what's on near you.",
f"**Youth Hub & Careers Hub at The Hive** (Worcester) - free careers advice, "
"apprenticeship guidance and support for young people. Ask me about 'The Hive' "
"for details.",
],
"what_you_need": (
"Free library membership for most services - join online instantly or in any branch. "
f"Under-16s need a parent or guardian to sign up. [Join free]({JOIN_URL})"
),
"url": READ_DISCOVER_URL,
"events_url": EVENTS_URL,
"borrowbox_url": BORROWBOX,
"unlocked_url": UNLOCKED_URL,
}
DONATIONS = {
"summary": (
"Many Worcestershire library branches welcome donations of good-quality "
"second-hand books. Donated books are typically sold at low cost in the library "
"to raise funds for local library services."
),
"what_you_need": (
"Books should be clean, undamaged and in readable condition. "
"Please contact your local branch before bringing a large donation, "
"as acceptance and storage capacity varies by location."
),
"how_to": [
"Call or visit your nearest library to check they are accepting donations.",
"Bring clean, undamaged books - no mould, heavy writing or water damage.",
"Staff will assess donations and let you know how they will be used.",
],
"url": f"{GOV}/council-services/libraries",
}
SCHOOL_VISITS = {
"summary": (
"Worcestershire libraries welcome school and group visits - tours, story "
"sessions, reading challenge registration and library inductions for "
"school classes. Visits help children build reading habits and discover "
"everything the library offers."
),
"what_you_need": (
"Contact your local library branch in advance to arrange a group visit. "
"Availability varies by branch and time of year - early booking is recommended."
),
"how_to": [
f"Call **{LIBRARY_PHONE}** or contact your local library directly to "
"discuss a school or group booking.",
"For visits to **The Hive** (Worcester), email "
"**bookings@thehiveworcester.org** - the Hive has a dedicated Children's "
"floor and a full programme of school activities.",
"Describe what you'd like: a library tour, story session, Summer Reading "
"Challenge sign-up, or a librarian-led reading lesson.",
"Most visits are free - confirm availability and any requirements with "
"the branch beforehand.",
],
"also_see": (
f"The **[Summer Reading Challenge]({SUMMER_READING_URL})** runs July–September "
"and is free for all school-age children. "
f"Young people aged 13–24 can **[volunteer]({VOLUNTEERING_URL})** on the "
"challenge - great for CVs and the Duke of Edinburgh's Award."
),
"url": f"{GOV}/council-services/libraries/read-and-discover",
"hive_booking_email": "bookings@thehiveworcester.org",
}
ACCESSIBLE_FORMATS = {
"summary": (
"Worcestershire Libraries offer several ways to access reading material "
"if you have a visual impairment, dyslexia or other print disability - "
"including large print books, eAudiobooks via BorrowBox, talking newspapers "
"via PressReader, and staff guidance on specialist services."
),
"what_you_need": "Free library membership - most formats free to borrow.",
"options": [
"**Large print books** - available at all branches; search the catalogue "
"with 'large print [title]' or ask staff to check availability. Free to borrow.",
f"**eAudiobooks via BorrowBox** - borrow free with a library card; ideal "
f"if print is difficult. Borrow up to 4 at once, auto-return, no fines. "
f"[Get BorrowBox]({BORROWBOX})",
f"**Talking newspapers & magazines via PressReader** - listen to The Guardian, "
f"BBC Top Gear and 7,000+ titles with listen-to-article audio. "
f"Typically £9.99/month - included with your library card. [PressReader]({ONLINE_HUB})",
"**RNIB resources & Listening Books** - library staff can advise on how to "
"access RNIB Talking Books, the Listening Books service, and other "
"specialist accessible reading formats. Ask at any branch.",
f"**Reading Well** - free curated books on mental health and long-term "
f"conditions, available at all branches. "
f"[Reading Well collections]({READING_WELL_URL})",
],
"also_see": (
"Ask staff at any branch for personal guidance on the format that suits "
"you best. Many branches also carry DAISY format audiobooks and talking "
"book collections - stock varies, so it's worth calling ahead."
),
"url": READ_DISCOVER_URL,
"catalogue_tip": (
"Search the catalogue with 'large print [title]' or browse by format "
"to find specific accessible editions."
),
}
UPDATE_DETAILS_INFO = {
"summary": (
"You can update your library account details - address, email, phone "
"number - online, by phone or in person at any Worcestershire library."
),
"how_to": [
f"**Online (quickest):** Sign in to [your library account]({ACCOUNT_URL}) "
"and go to 'My Account' or 'Personal Details' to update your information.",
"**In person:** Visit any Worcestershire library - if changing your address, "
"bring a recent utility bill or official letter as proof.",
f"**By phone:** Call **{LIBRARY_PHONE}** during staffed hours.",
],
"also_see": (
"If you've forgotten your PIN, use 'Forgotten PIN' on the "
f"[account sign-in page]({ACCOUNT_URL}), or ask staff at any branch."
),
"url": ACCOUNT_URL,
}
LOST_CARD = {
"summary": (
"If you've lost your library card - or it's been stolen - let the library "
"know as soon as possible so your account can be protected. You can get a "
"replacement card at any Worcestershire library branch."
),
"what_to_do": [
f"**Contact the library now** - call **{LIBRARY_PHONE}** or visit any branch in "
"person. Staff can flag the card as lost or stolen to prevent unauthorised use.",
"**Get a replacement card in branch** - bring proof of identity (e.g. passport, "
"driving licence, or a utility bill). A small replacement charge may apply - "
f"see the [fees and charges page]({FEES_URL}) for current rates.",
"**Change your PIN** - if you're concerned someone may have both your card and "
f"PIN, reset it straight away via [your library account]({ACCOUNT_URL}) "
f"(use 'Forgotten PIN'), or ask staff to reset it in branch.",
"**Check your account online** - sign in to review your loans and reservations "
f"and confirm no unexpected items have been borrowed. [Sign in]({ACCOUNT_URL})",
],
"also_see": (
"Your card number is also shown in any previous account correspondence. "
"You can still log in and manage your account using your card number and PIN "
"while you wait for a replacement card."
),
"url": FEES_URL,
}
WIFI_ACCESS = {
"summary": (
"Free Wi-Fi is available at all Worcestershire libraries - no password, "
"no login and no library card required. Just enable Wi-Fi on your device "
"and join the library guest network."
),
"what_you_need": "Nothing - Wi-Fi is open to all visitors during staffed hours.",
"how_to": [
"Enable Wi-Fi on your device and look for the library's guest network - "
"the network name is usually posted on a sign in the branch.",
"Tap to connect - no password or login screen required.",
"Speed and time limits may apply; ask staff if you need longer or have "
"trouble connecting.",
],
"also_see": (
f"Prefer a library computer? [Book a session online]({BOOK_COMPUTER_URL}) "
"or simply walk in - free at most branches. "
f"[Libraries Unlocked]({UNLOCKED_URL}) gives Wi-Fi access 8am–8pm Mon–Sat "
"even when the branch is unstaffed."
),
"url": f"{GOV}/council-services/libraries",
}
SELF_SERVICE = {
"summary": (
"Most Worcestershire libraries have self-service kiosks where you can "
"borrow and return items without waiting for staff - quick and easy "
"using your library card and PIN."
),
"what_you_need": "Your library card (or card number) and PIN.",
"how_to_borrow": [
"At the kiosk, scan your library card barcode or type your card number.",
"Enter your PIN when prompted.",
"Place the item on the pad or scan its barcode to check it out.",
"Collect your receipt - it shows the due date for each item.",
],
"how_to_return": [
"Scan your library card at the kiosk, then scan each item to return it - "
"the screen confirms each successful check-in.",
"Many branches also have a 24-hour external drop-box outside for returns "
"when the library is closed or unstaffed.",
],
"also_see": (
f"[Renew loans online anytime]({RENEW_URL}) - no trip to the library needed. "
f"[Reserve items]({RESERVE_URL}) for collection at any branch."
),
"url": ACCOUNT_URL,
}
ILL_SERVICE = {
"summary": (
"If the item you want isn't in the Worcestershire library network, "
"library staff can often request it from another library service - "
"known as an inter-library loan. A small charge usually applies."
),
"what_you_need": "Full library membership. Ask at any Worcestershire library branch.",
"how_to": [
f"First check the [Worcestershire catalogue]({CATALOGUE_SEARCH}) - including "
"eBook and eAudiobook formats on BorrowBox, which may be available tonight.",
"If the item isn't in the network, speak to staff at any branch or call "
f"**{LIBRARY_PHONE}** - they'll advise whether an inter-library loan is possible.",
"Provide the title, author, publisher and ideally the ISBN so staff can "
"trace the item in another service's catalogue.",
"A charge typically applies to cover processing and postage - staff will "
"confirm the fee before placing the request.",
"Items usually arrive within 2–4 weeks; you'll be notified when ready to collect.",
],
"also_see": (
f"[BorrowBox eBooks and eAudiobooks]({BORROWBOX}) - no subscription needed, "
"available tonight - often hold titles not in the physical collection. "
f"The [Ask for a Book]({ASK_FOR_A_BOOK_URL}) service can also suggest "
"alternatives you might enjoy."
),
"url": f"{GOV}/council-services/libraries",
}
CARD_EXPIRED = {
"summary": (
"If your Worcestershire library card or membership has expired, you can "
"renew it free of charge at any library branch. Your account, loans and "
"reservations are all retained."
),
"what_you_need": (
"Your library card or card number. If your address has changed since you "
"joined, bring proof of your current address (e.g. a utility bill, bank "
"statement or official letter with your name and address)."
),
"how_to": [
"Visit any Worcestershire library during staffed hours and ask staff to "
"renew your membership - it only takes a few minutes.",
"If your address is unchanged, your card number is usually all that's needed.",
f"Can't visit? Call **{LIBRARY_PHONE}** during staffed hours - staff can "
"often renew your membership over the phone if your details are on record.",
],
"also_see": (
f"Not sure if your card has expired? Try logging in to "
f"[your library account]({ACCOUNT_URL}) - an expired card will prompt you "
"to renew. If you've also forgotten your PIN, use 'Forgotten PIN' on the "
"sign-in page."
),
"url": JOIN_URL,
}
CHILDREN_MEMBERSHIP = {
"summary": (
"Children can join Worcestershire Libraries at any age - even as babies! "
"Membership is completely free and gives access to the full range of children's "
"books, eBooks, audiobooks, Storytime, Rhymetime and the Summer Reading Challenge. "
"Children under 16 need a parent or guardian to sign the membership form."
),
"what_you_need": (
"For children under 16: a parent or guardian must be present and sign the form. "
"Bring the child's name and your home address - proof of address may be requested. "
"For 16- and 17-year-olds: can join independently with proof of address. "
"Membership is free for all ages."
),
"how_to": [
"Visit any Worcestershire library with the child - staff sign you up on the spot "
"in a few minutes, and the card is usually ready the same day.",
f"Or [join online]({JOIN_URL}) - a physical card is posted out (or collect in branch).",
f"**Instant digital membership** is also free and available online right now - "
f"unlocks [BorrowBox]({BORROWBOX}) eBooks and audiobooks immediately. "
"Perfect while the physical card arrives.",
],
"also_see": (
f"Once joined, look out for **Storytime** and **Rhymetime** - free weekly sessions "
f"at many branches. The **[Summer Reading Challenge]({SUMMER_READING_URL})** "
"runs July–September and rewards children for reading six library books."
),
"url": JOIN_URL,
}
SUGGEST_PURCHASE = {
"summary": (
"If the library doesn't stock a book or item you'd like to borrow, you can "
"suggest that the library buys it. Library staff review all suggestions and "
"purchase titles that fit the collection - popular suggestions are given priority."
),
"what_you_need": (
"No membership required to make a purchase suggestion. If the library orders the "
"item, you'll need a free library card to borrow it when it arrives."
),
"how_to": [
f"Use the **[Ask for a Book form]({ASK_FOR_A_BOOK_URL})** - in the free-text "
"field, explain that you'd like the library to stock a specific title. "
"Include the title, author, publisher and ISBN if possible.",
"Or speak to staff at any library branch - they can note your suggestion on the "
"spot and pass it to the stock team.",
"You'll be contacted once a decision has been made. If the library orders it, "
"you'll often be first on the reservation list.",
],
"also_see": (
f"Need it sooner? If another library service holds it, staff may be able to "
f"arrange an **inter-library loan** (a small charge usually applies - ask in any branch). "
f"Or check [BorrowBox]({BORROWBOX}) tonight - thousands of eBooks and audiobooks "
"included with your library card, no extra cost."
),
"url": ASK_FOR_A_BOOK_URL,
}
LIBRARY_CONTACT = {
"summary": (
f"The main Worcestershire Libraries enquiry line is **{LIBRARY_PHONE}** - "
"for general questions, computer bookings, Libraries Unlocked inductions, "
"mobile library enquiries and any other library service question."
),
"how_to": [
f"**Phone:** **{LIBRARY_PHONE}** - during staffed library hours "
"(hours vary by branch; most are open Monday–Saturday).",
f"**Online:** Browse the "
f"[Worcestershire Libraries website]({GOV}/council-services/libraries) - "
"each service page has relevant contact details and online forms.",
f"**In person:** Drop in to any "
f"[Worcestershire library branch]({GOV}/council-services/libraries/find-library) "
"during staffed hours - staff can answer most questions on the spot.",
f"**Account queries:** Sign in at [your library account]({ACCOUNT_URL}) "
"to manage loans, reservations and PIN online, any time.",
],
"also_see": (
"For a specific branch phone number and opening hours, ask me "
"'Is [branch name] library open?' or 'What are [branch] library hours?'."
),
"url": f"{GOV}/council-services/libraries",
}
LIBRARY_APP = {
"summary": (
"There is no single dedicated 'Worcestershire Libraries' app, but several "
"apps connect you to library services:"
),
"apps": [
f"**BorrowBox** - the library's eBook and eAudiobook app. A typical audiobook "
f"subscription is ~£15/month; yours at no extra cost with your library card. "
f"Search 'BorrowBox' in the App Store or Google Play, log in with your card number "
f"and PIN. [Get started]({BORROWBOX})",
"**PressReader** - free access to 7,000+ newspapers and magazines worldwide "
"(Guardian, Times, local titles and more). Log in with your library card number on "
f"the PressReader app or website. [PressReader via Online Hub]({ONLINE_HUB})",
f"**Your library account (mobile browser)** - manage loans, reservations and renewals "
f"at [your library account]({ACCOUNT_URL}) - works on any smartphone browser.",
f"**Libraries Unlocked** - 15+ members use their smartphone to unlock participating "
f"libraries outside staffed hours. A one-off in-person induction is required first. "
f"[Libraries Unlocked]({UNLOCKED_URL})",
],
"url": ONLINE_HUB,
"borrowbox_url": BORROWBOX,
"account_url": ACCOUNT_URL,
}
LIBRARY_CLOSURES = {
"summary": (
"Worcestershire Libraries publish all planned closures for the year online. "
"Most closures fall on UK bank holidays; individual branches may also close "
"temporarily for refurbishment or local events."
),
"bank_holiday_note": (
"Libraries generally close on all UK bank holidays - New Year's Day, Good Friday, "
"Easter Monday, Early May bank holiday, Spring bank holiday, Summer bank holiday, "
"Christmas Day and Boxing Day. Opening hours may be reduced on adjacent days."
),
"how_to_check": [
f"View the full **[2026 library closing dates]({CLOSING_DATES_URL})** page - "
"updated whenever new closures are confirmed.",
"Ask me _\"Is [branch name] library open?\"_ or _\"Is [branch] open today?\"_ "
"for a live status check.",
f"Call the library enquiry line: **{LIBRARY_PHONE}**.",
],
"url": CLOSING_DATES_URL,
}
BUILDING_ACCESSIBILITY = {
"summary": (
"Worcestershire Libraries aim to make all branches as accessible as possible. "
"Most libraries have step-free entrance, accessible toilets, hearing induction "
"loops, and enlarged-text self-service terminals."
),
"common_features": [
"**Step-free access** - ground-floor entrance or lift at the majority of branches.",
"**Hearing induction loop** - fitted in most library service areas.",
"**Accessible toilet** - available at most branches.",
"**Accessible parking** - disabled badge spaces close to the entrance at most sites.",
"**Large-print signage** and adjustable-font catalogue terminals.",
"**Self-service kiosks** - usable from a seated position at most branches.",
],
"note": (
"Specific facilities vary by branch. Ask me _\"which libraries have wheelchair "
"access?\"_ or _\"libraries with a hearing loop\"_ to find branches that match "
"your needs, or call ahead to confirm before travelling."
),
"accessible_formats_tip": (
"For accessible reading formats - large print, DAISY audiobooks, Talking Books "
"or eAudiobooks - ask me about **'accessible formats'**."
),
"url": f"{GOV}/council-services/libraries/find-library",
"contact": LIBRARY_PHONE,
}
COMMUNITY_INFORMATION = {
"summary": (
"Worcestershire Libraries are community hubs - most branches have "
"noticeboards where local groups and organisations can display leaflets "
"and notices, and staff can signpost visitors to local support services."
),
"what_we_offer": [
"**Community noticeboards** - free for local groups, charities and "
"community organisations to display information at most branches. "
"Ask staff at your local library to find out how to post a notice.",
"**Leaflet displays** - many branches carry leaflets from local "
"public services, health providers, housing support and charities.",
"**Signposting** - library staff are trained to help connect you with "
"local support (food banks, advice services, mental health, housing). "
"If the library can't help directly, they'll point you to someone who can.",
"**Warm spaces** - all Worcestershire libraries are warm welcome spaces: "
"free, no need to buy anything or be a member.",
"**Free Wi-Fi and computers** - use our free public Wi-Fi or book a "
"library computer to access online services and community information.",
],
"for_organisations": (
"Want your group's information displayed in a library? Ask at your nearest "
f"branch, or call **{LIBRARY_PHONE}** to discuss how to share information "
"with library visitors."
),
"note": (
"Libraries do not run or endorse external services - signposting is "
"provided as a public information resource."
),
"url": f"{GOV}/council-services/libraries",
}
MULTILINGUAL_RESOURCES = {
"summary": (
"Worcestershire Libraries offer resources in many languages - "
"through multilingual digital content, community-language books, "
"and digital skills support for English language learners."
),
"what_we_offer": [
f"**Newspapers & magazines in 60+ languages** via [PressReader]({ONLINE_HUB}) - "
"typically £9.99/month, included with your library card. "
"Includes Arabic, Chinese (Simplified & Traditional), "
"French, German, Hindi, Polish, Portuguese, Spanish, Urdu and many more. "
"Access at home or on library Wi-Fi.",
"**Books in community languages** - Worcestershire Libraries stock books in a "
"range of community languages at selected branches. Search the "
f"[online catalogue]({CATALOGUE_SEARCH}) by language, or ask staff "
"who can check what's available and request items from other branches.",
"**eBooks in other languages** - [BorrowBox]({BORROWBOX}) includes eBooks and "
"eAudiobooks in multiple languages. Search by language in the app.",
"**Times Digital Archive & Oxford resources** - available in English only, "
"but [Access to Research]({ONLINE_HUB}) covers international academic journals "
"in many languages.",
f"**Digital skills and ESOL support** - our [Digital Inclusion]"
f"({DIGITAL_INCLUSION_URL}) team can help with English language learning apps "
"and online resources. Free sessions at many branches.",
],
"joining": (
f"Library membership is free and open to all Worcestershire residents - "
f"[join online]({JOIN_URL}) or in any branch. "
"Under-16s need a parent or guardian."
),
"url": ONLINE_HUB,
"pressreader_url": ONLINE_HUB,
"catalogue_url": CATALOGUE_SEARCH,
}
def account_and_loans(query: str | None = None) -> dict:
"""
Online account, renewing loans, fines / late fees, reservations, returning,
lost/damaged items, personalised book recommendations, and Library Service at Home.
Surfaces the right sub-topic from the query.
"""
q = (query or "").lower()
out = dict(BORROWING_POLICY)
out["checked"] = _now()
# More specific checks first to avoid substring false-positives.
if (("pin" in q or "password" in q)
and any(w in q for w in ("forgot", "forgotten", "reset", "change", "new", "lost"))):
out["focus"] = "pin_reset"
elif (any(w in q for w in ("reservation", "reserve", "hold", "collect"))
and any(w in q for w in ("how long", "expire", "expir", "when", "time",
"how many days", "7 day", "collect", "missed"))):
out["focus"] = "reservations"
elif any(w in q for w in ("loan limit", "loan period", "borrowing limit",
"how long", "how many", "how many books", "how many items")):
out["focus"] = "loan_limits"
elif any(w in q for w in ("job club", "cv help", "cv writing", "job search",
"job seeking", "job-seeking", "employment support",
"find work", "looking for work", "interview prep")):
out["focus"] = "job_clubs"
out["job_clubs"] = JOB_CLUBS
elif ((any(w in q for w in ("expired", "lapsed", "out of date"))
and any(w in q for w in ("card", "membership", "member")))
or (any(w in q for w in ("renew", "renewal"))
and any(w in q for w in ("card", "membership", "member"))
and not any(w in q for w in ("book", "item", "loan", "dvd", "cd")))):
out["focus"] = "card_expired"
out["card_expired"] = CARD_EXPIRED
elif any(w in q for w in ("renew", "renewal", "extend", "due date")):
out["focus"] = "renewals"
elif ("card" in q and any(w in q for w in ("lost", "stolen", "replace", "replacement"))):
out["focus"] = "lost_card"
out["lost_card"] = LOST_CARD
elif (any(w in q for w in ("lost", "damage", "damaged"))
and any(w in q for w in ("book", "item", "dvd", "cd"))
and "card" not in q):
out["focus"] = "lost_item"
elif any(w in q for w in ("self service", "self-service", "kiosk",
"self checkout", "self-checkout", "self serve",
"check out myself", "check books out")):
out["focus"] = "self_service"
out["self_service"] = SELF_SERVICE
elif any(w in q for w in ("return", "returning", "bring back", "drop off", "hand back")):
out["focus"] = "returning"
elif any(w in q for w in ("inter-library", "interlibrary", "inter library",
"another library service", "from another library",
"different library service", "ill loan")):
out["focus"] = "ill"
out["ill_service"] = ILL_SERVICE
elif any(w in q for w in ("fine", "charge", "fee", "owe", "overdue", "late", "pay")):
out["focus"] = "fines"
elif any(w in q for w in ("reserve", "hold", "reservation", "request", "order")):
out["focus"] = "reservations"
elif (any(w in q for w in ("update", "change", "amend", "correct"))
and any(w in q for w in ("address", "detail", "contact", "email",
"phone", "mobile", "name"))
and any(w in q for w in ("my", "account", "library"))):
out["focus"] = "update_details"
out["update_details"] = UPDATE_DETAILS_INFO
elif (any(w in q for w in ("wifi", "wi-fi", "wireless"))
and any(w in q for w in ("connect", "connection", "password", "how",
"access", "guest", "login", "log in", "use"))):
out["focus"] = "wifi"
out["wifi_access"] = WIFI_ACCESS
elif any(w in q for w in ("account", "login", "log in", "sign in", "pin", "password")):
out["focus"] = "account"
elif any(w in q for w in ("home", "housebound", "deliver")):
out["focus"] = "home"
elif any(w in q for w in ("recommend", "ask for a book", "personalised",
"suggest a book", "suggestion", "what should i read",
"choose a book", "pick a book")):
out["focus"] = "ask_book"
elif any(w in q for w in ("computer", "pc session", "book a computer",
"use a computer", "computer session")):
out["focus"] = "computer"
elif any(w in q for w in ("volunteer", "volunteering", "work experience")):
out["focus"] = "volunteer"
elif any(w in q for w in ("digital skill", "get online", "learn my way",
"digital champion", "computer skill", "learn to use")):
out["focus"] = "digital_skills"
elif any(w in q for w in ("reading well", "wellbeing book", "mental health book",
"books for mental health", "books for wellbeing")):
out["focus"] = "reading_well"
elif any(w in q for w in ("room hire", "hire a room", "meeting room",
"book a room", "room for hire")):
out["focus"] = "room_hire"
elif any(w in q for w in ("warm space", "warm welcome", "somewhere warm")):
out["focus"] = "warm_space"
elif any(w in q for w in ("book club", "reading group", "reading circle",
"book group", "readers group", "readers' group")):
out["focus"] = "book_clubs_reading"
out["book_clubs_reading"] = BOOK_CLUBS
elif any(w in q for w in ("adult learn", "learning course", "it course",
"computer course", "upskill", "functional skill",
"learn to read", "learn to write", "learn to type")):
out["focus"] = "adult_learning"
out["adult_learning"] = ADULT_LEARNING
elif any(w in q for w in ("donat", "give books", "drop off book")):
out["focus"] = "donations"
out["donations"] = DONATIONS
elif any(w in q for w in ("school visit", "group visit", "class visit",
"school tour", "school trip", "group booking",
"school group", "class trip")):
out["focus"] = "school_visits"
out["school_visits"] = SCHOOL_VISITS
elif any(w in q for w in ("accessible format", "accessible reading", "braille",
"print disability", "accessible book", "accessible edition",
"daisy format", "daisy book", "rnib talking")):
out["focus"] = "accessible_formats"
out["accessible_formats"] = ACCESSIBLE_FORMATS
elif (re.search(r"\b(baby|infant|child|children|kid|kids|toddler|junior|teen|"
r"teenager|youth|family)\b", q)
and any(w in q for w in ("join", "member", "membership", "card",
"sign up", "sign-up", "register", "get a card",
"get their card", "get a library"))):
out["focus"] = "child_membership"
out["child_membership"] = CHILDREN_MEMBERSHIP
elif any(w in q for w in ("library buy", "library stock", "library purchase",
"library order this", "ask library to buy",
"suggest purchase", "suggest a title",
"suggest the library", "buy it for the library",
"buy this for the library", "buy a copy for the library",
"request library buy", "want library to buy",
"library get this book", "wish list")):
out["focus"] = "suggest_purchase"
out["suggest_purchase"] = SUGGEST_PURCHASE
elif any(w in q for w in ("contact library", "library contact", "library phone",
"library telephone", "library helpline", "library enquir",
"library email", "speak to library", "ring library",
"call library", "how do i contact", "how can i contact",
"general enquiry", "general enquiries", "reach library",
"library number", "main number", "enquiry line")):
out["focus"] = "library_contact"
out["library_contact"] = LIBRARY_CONTACT
elif any(w in q for w in ("library closures", "closing dates", "bank holiday",
"closed christmas", "closed easter", "closed bank",
"when are libraries closed", "when is the library closed",
"library closed", "2026 closing", "planned closure",
"refurbishment", "temporary closure")):
out["focus"] = "library_closures"
out["library_closures"] = LIBRARY_CLOSURES
elif any(w in q for w in ("wheelchair", "step-free", "step free", "hearing loop",
"induction loop", "building access", "accessible building",
"accessible entrance", "disabled access", "disability access",
"accessible toilet", "accessible parking", "disabled parking")):
out["focus"] = "building_accessibility"
out["building_accessibility"] = BUILDING_ACCESSIBILITY
elif any(w in q for w in ("teen", "teenager", "young adult", "ya books", "ya fiction",
"ya section", "young people library", "youth service",
"teen service", "teen activit", "teen event")):
out["focus"] = "teen_services"
out["teen_services"] = TEEN_SERVICES
elif any(w in q for w in ("library app", "app for library", "mobile app", "phone app",
"borrowbox app", "pressreader app", "library on my phone",
"library on phone", "download library")):
out["focus"] = "library_app"
out["library_app"] = LIBRARY_APP
elif any(w in q for w in ("noticeboard", "notice board", "community notice",
"community information", "local group", "advertise",
"display leaflet", "signpost", "local service",
"food bank", "community hub")):
out["focus"] = "community_information"
out["community_information"] = COMMUNITY_INFORMATION
elif any(w in q for w in ("other language", "non-english", "multilingual",
"books in", "language book", "foreign language",
"arabic book", "polish book", "urdu book", "hindi book",
"spanish book", "french book", "chinese book",
"community language", "esol", "english as a second")):
out["focus"] = "multilingual"
out["multilingual"] = MULTILINGUAL_RESOURCES
else:
out["focus"] = "general"
if out["focus"] == "home" or any(
w in q for w in ("home library", "housebound", "deliver")):
out["home_library"] = HOME_LIBRARY_SERVICE
if out["focus"] == "ask_book":
out["ask_book"] = ASK_FOR_A_BOOK
if out["focus"] == "computer":
out["computer_booking"] = COMPUTER_BOOKING
if out["focus"] == "volunteer":
out["volunteering"] = VOLUNTEERING
if out["focus"] == "digital_skills":
out["digital_skills"] = DIGITAL_SKILLS
if out["focus"] == "reading_well":
out["reading_well"] = READING_WELL
if out["focus"] == "room_hire":
out["room_hire_data"] = ROOM_HIRE
if out["focus"] == "warm_space":
out["warm_space"] = WARM_SPACE
if out["focus"] == "school_visits":
out.setdefault("school_visits", SCHOOL_VISITS)
if out["focus"] == "accessible_formats":
out.setdefault("accessible_formats", ACCESSIBLE_FORMATS)
if out["focus"] == "update_details":
out.setdefault("update_details", UPDATE_DETAILS_INFO)
if out["focus"] == "lost_card":
out.setdefault("lost_card", LOST_CARD)
if out["focus"] == "wifi":
out.setdefault("wifi_access", WIFI_ACCESS)
if out["focus"] == "self_service":
out.setdefault("self_service", SELF_SERVICE)
if out["focus"] == "ill":
out.setdefault("ill_service", ILL_SERVICE)
if out["focus"] == "card_expired":
out.setdefault("card_expired", CARD_EXPIRED)
if out["focus"] == "child_membership":
out.setdefault("child_membership", CHILDREN_MEMBERSHIP)
if out["focus"] == "suggest_purchase":
out.setdefault("suggest_purchase", SUGGEST_PURCHASE)
if out["focus"] == "library_contact":
out.setdefault("library_contact", LIBRARY_CONTACT)
if out["focus"] == "library_closures":
out.setdefault("library_closures", LIBRARY_CLOSURES)
if out["focus"] == "building_accessibility":
out.setdefault("building_accessibility", BUILDING_ACCESSIBILITY)
if out["focus"] == "teen_services":
out.setdefault("teen_services", TEEN_SERVICES)
if out["focus"] == "library_app":
out.setdefault("library_app", LIBRARY_APP)
if out["focus"] == "community_information":
out.setdefault("community_information", COMMUNITY_INFORMATION)
if out["focus"] == "multilingual":
out.setdefault("multilingual", MULTILINGUAL_RESOURCES)
return out
def children_services() -> dict:
"""Children's library activities - Storytime, Rhymetime, Summer Reading Challenge."""
return {**CHILDREN_SERVICES, "checked": _now()}
_HUB_SYNONYMS = {
# Oxford - specific multi-word keys first to avoid "research" matching ebsco
"oxford english dictionary": "oxford english",
"oxford english": "oxford english",
"oxford research encyclopaedias": "oxford research encyclopaedias",
"oxford research encyclopedia": "oxford research encyclopaedias",
"oxford research": "oxford research encyclopaedias",
"oxford reference": "oxford reference",
"oxford dictionary of national biography": "oxford dictionary of national biography",
"oxford dnb": "oxford dictionary of national biography",
"oed": "oxford english",
"oxford dictionary": "oxford english",
"encyclopaedia": "oxford research encyclopaedias",
"encyclopedia": "oxford research encyclopaedias",
"word history": "oxford english",
"word meaning": "oxford english",
# PressReader
"newspaper": "pressreader", "magazine": "pressreader", "news": "pressreader",
"press": "pressreader", "guardian": "pressreader", "times newspaper": "pressreader",
# Ancestry
"family history": "ancestry", "ancestry": "ancestry", "genealogy": "ancestry",
"family tree": "ancestry", "genealogical": "ancestry", "census": "ancestry",
"birth record": "ancestry", "death record": "ancestry", "marriage record": "ancestry",
# BorrowBox
"ebook": "borrowbox", "audiobook": "borrowbox", "audio book": "borrowbox",
# Business / COBRA
"business": "cobra", "start a business": "cobra", "company": "cobra",
"sole trader": "cobra", "self employed": "cobra", "self-employed": "cobra",
"startup": "cobra", "start-up": "cobra",
# Theory Test Pro
"driving": "theory test pro", "theory test": "theory test pro",
"driving test": "theory test pro", "dvsa": "theory test pro",
"hazard perception": "theory test pro",
# General Oxford (after specific multi-word entries)
"dictionary": "oxford english",
# Research/journals (after oxford research entries)
"research": "ebsco", "journal": "ebsco",
# Patents
"patent": "espacenet", "patents": "espacenet", "intellectual property": "espacenet",
# BFI
"film": "bfi", "tv": "bfi", "television": "bfi", "movie": "bfi",
"british film": "bfi", "archive film": "bfi", "old tv": "bfi",
"classic film": "bfi", "archive tv": "bfi", "documentary": "bfi",
# Biography
"biography": "national biography", "who was": "national biography",
# Reference
"reference": "oxford reference",
# Which?
"consumer advice": "which", "product review": "which",
"buying guide": "which", "best buy": "which",
}
def online_hub(topic: str | None = None) -> dict:
"""Free-from-home digital resources (BorrowBox, PressReader, Ancestry…)."""
hub = kb().get("online_hub", [])
items = hub
if topic:
t = topic.lower()
hits = [] # synonym-mapped resources rank first
for k, v in _HUB_SYNONYMS.items():
if k in t:
hits += [h for h in hub if v in h["name"].lower()]
hits += [h for h in hub if t in h["name"].lower()
or t in h.get("summary", "").lower()]
if hits:
seen, dedup = set(), []
for h in hits:
if h["url"] not in seen:
seen.add(h["url"]); dedup.append(h)
items = dedup
out_items = []
for h in items[:8]:
cur = _merge_curated(h["name"])
out_items.append({
"name": h["name"],
"summary": cur.get("inside") or h.get("summary", "")[:200],
"what_you_need": cur.get("what_you_need")
or " ".join((h.get("what_you_need") or [ELIGIBILITY["borrow_digital"]])[:1]),
"access": cur.get("access", []),
"at_home": cur.get("at_home"),
"limits": cur.get("limits", ""),
"titles": cur.get("titles", []),
"extras": cur.get("extras", ""),
"url": h["url"],
})
return {
"count": len(out_items),
"items": out_items,
"tiers": kb().get("membership_tiers", []),
"page_url": ONLINE_HUB, "checked": _now(),
}
def membership_help(service: str | None = None) -> dict:
"""What you need to sign up - the cross-service membership matrix."""
out = {"tiers": kb().get("membership_tiers", []),
"page_url": JOIN_URL,
"checked": _now()}
if service:
s = service.lower()
if any(w in s for w in ("print", "photocopy")):
out["need"] = "Full membership + a topped-up PaperCut account."
elif any(w in s for w in ("ebook", "audiobook", "borrowbox", "online",
"magazine", "newspaper", "ancestry", "digital")):
out["need"] = "Free digital membership - instant with a postcode."
elif any(w in s for w in ("unlock", "8pm", "evening", "after hours")):
out["need"] = "Full membership, 15+, plus a one-off induction."
else:
out["need"] = "Free full membership."
return out
# --------------------------------------------------------------------------- #
# 6. Copy-level availability + the "where do I actually GET it" journey
# --------------------------------------------------------------------------- #
_STATUS_AVAILABLE = re.compile(r"\b(available|on shelf|in library)\b", re.I)
_STATUS_WORDS = re.compile(
r"\b(available|on shelf|in library|checked out|on loan|due back|due \d|"
r"in transit|on order|on hold(shelf)?|reserved|missing|lost|reference only|"
r"not for loan|being catalogued)\b", re.I)
_CALL_NO = re.compile(r"\b([A-Z]{0,3}\s?\d{3}(?:\.\d+)?\s?[A-Z]{0,4}|[A-Z]{1,3}\s?FIC[A-Z ]*)\b")
def _branch_names() -> list[str]:
names = [b["name"] for b in kb().get("branches", [])]
extra = ["The Hive", "Mobile Library"]
out = names + [n for n in extra if n not in names]
# also match without the trailing " Library"
return sorted(out, key=len, reverse=True)
def _item_availability(detail_url: str) -> dict:
"""
Copy-level holdings for one catalogue item: which branch, what call number,
what status - parsed from the SirsiDynix detail page.
The page is JS-heavy, so we parse defensively (tables first, then a text
scan anchored on known branch names) and FAIL SOFT: if we can't parse,
we say so rather than guess. Returns
{"copies":[{branch, call_number, status, available}], "parsed": bool}.
"""
out = {"copies": [], "parsed": False, "detail_url": detail_url}
if not detail_url:
return out
try:
soup = BeautifulSoup(_get(detail_url).text, "html.parser")
except Exception as e:
out["error"] = str(e)
return out
branches = _branch_names()
def add_copy(branch, call_no, status):
status = re.sub(r"\s+", " ", status).strip()
out["copies"].append({
"branch": branch.strip(),
"call_number": (call_no or "").strip(),
"status": status,
"available": bool(_STATUS_AVAILABLE.search(status)),
})
# Strategy 1 - a holdings table (header mentions library/branch + status)
for table in soup.find_all("table"):
head = " ".join(th.get_text(" ", strip=True).lower()
for th in table.find_all(["th"]))
if not (("librar" in head or "branch" in head or "location" in head)
and ("status" in head or "avail" in head or "call" in head)):
continue
headers = [th.get_text(" ", strip=True).lower()
for th in table.find_all("th")]
def col(*words):
return next((i for i, h in enumerate(headers)
if any(w in h for w in words)), None)
c_lib, c_call, c_stat = (col("librar", "branch", "location"),
col("call", "shelf"), col("status", "avail"))
for tr in table.find_all("tr")[1:]:
cells = [td.get_text(" ", strip=True) for td in tr.find_all("td")]
if not cells:
continue
lib = cells[c_lib] if c_lib is not None and c_lib < len(cells) else ""
stat = cells[c_stat] if c_stat is not None and c_stat < len(cells) else ""
call = cells[c_call] if c_call is not None and c_call < len(cells) else ""
if lib and (stat or call):
add_copy(lib, call, stat or "see catalogue")
if out["copies"]:
out["parsed"] = True
return out
# Strategy 2 - text scan anchored on known branch names
text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
for b in branches:
for m in re.finditer(re.escape(b), text, re.I):
window = text[m.end():m.end() + 140]
sm = _STATUS_WORDS.search(window)
if sm:
cm = _CALL_NO.search(window[:sm.start()])
add_copy(b, cm.group(0) if cm else "", sm.group(0))
# dedupe
seen, copies = set(), []
for c in out["copies"]:
key = (c["branch"].lower(), c["status"].lower(), c["call_number"])
if key not in seen:
seen.add(key); copies.append(c)
out["copies"] = copies
out["parsed"] = bool(copies)
return out
def _title_sim(a: str, b: str) -> float:
return difflib.SequenceMatcher(None, a.lower(), b.lower()).ratio()
def where_to_get(query: str) -> dict:
"""
The full get-it journey for one title: every format the service holds and,
for each, exactly where/how to get it TODAY -
on the shelf at a named branch (copy-level, live) →
reserve it (sign in + Place Hold) →
borrow tonight on BorrowBox (eBook / eAudiobook) →
not held: ask staff / check the catalogue yourself.
"""
query = (query or "").strip()
try:
res = search_catalogue(query, limit=12)
except Exception as e: # catalogue unreachable - fail soft, keep the journey
res = {"items": [], "error": f"catalogue unreachable: {e}"}
base = {"query": query, "search_url": res.get("search_url",
CATALOGUE_SEARCH + quote_plus(query)), "checked": _now()}
if res.get("error") or not res.get("items"):
base.update({"found": False, "routes": [{
"route": "not_held",
"advice": "Nothing matched in the catalogue. Staff can often get "
"titles from other library services - ask in any branch, "
"or try different search words.",
"url": base["search_url"],
}]})
return base
items = sorted(res["items"], key=lambda i: _title_sim(query, i["title"]),
reverse=True)
best = items[0]
cluster = [i for i in items if _title_sim(best["title"], i["title"]) > 0.6]
routes = []
# 1) digital first - instant, tonight, no waiting
for fmt in ("eBook", "eAudiobook"):
d = next((i for i in cluster if i["format"] == fmt), None)
if d:
routes.append({
"route": "digital", "format": fmt, "title": d["title"],
"author": d["author"],
"url": d.get("electronic_access") or BORROWBOX,
"direct_link": bool(d.get("electronic_access")),
"need": ELIGIBILITY["borrow_digital"],
"steps": ["Open the link (or the BorrowBox app, service "
"'Worcestershire').",
"Sign in with your library card number + PIN - or get "
"instant digital membership with just a postcode.",
"Borrow it free; it auto-returns, so no fines."],
})
# 2) physical - copy-level: which branch has it on the shelf right now
phys = next((i for i in cluster if not i["digital"]), None)
if phys:
avail = _item_availability(phys["detail_url"])
on_shelf = [c for c in avail["copies"] if c["available"]]
if on_shelf:
routes.append({
"route": "shelf", "format": phys["format"],
"title": phys["title"], "author": phys["author"],
"copies": on_shelf[:8], "all_copies": avail["copies"][:12],
"need": ELIGIBILITY["borrow_physical"],
"url": phys["detail_url"],
})
else:
routes.append({
"route": "reserve", "format": phys["format"],
"title": phys["title"], "author": phys["author"],
"copies": avail["copies"][:8],
"availability_parsed": avail["parsed"],
"need": ELIGIBILITY["borrow_physical"],
"url": phys["detail_url"],
"steps": ["Open the item page and sign in (card number + PIN).",
"Choose 'Place Hold' and pick the branch you want to "
"collect from - any Worcestershire library or the van.",
"You'll be emailed when it's ready to collect. "
"Reservations are free."],
})
if not routes:
routes.append({"route": "not_held",
"advice": "I found similar titles but not that exact one - "
"check the matches below or ask staff in any branch.",
"url": base["search_url"]})
other = [f'{i["icon"]} {i["title"]} ({i["format"]})'
for i in items if i not in cluster][:4]
base.update({
"found": True, "best_title": best["title"], "author": best["author"],
"formats_held": sorted({i["format"] for i in cluster}),
"routes": routes, "other_matches": other,
})
return base
# --------------------------------------------------------------------------- #
# 7. The Hive - page-level KB of thehiveworcester.org (built by build_hive_kb.py)
# --------------------------------------------------------------------------- #
_HIVE_KB = None
def hive_kb() -> dict:
global _HIVE_KB
if _HIVE_KB is None:
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"hive_kb.json")
try:
with open(path, encoding="utf-8") as f:
_HIVE_KB = json.load(f)
except FileNotFoundError:
_HIVE_KB = {"pages": [], "hive_profile": {}, "generated": ""}
return _HIVE_KB
# question word -> hive page slug fragments that answer it
_HIVE_INTENTS = {
"hour": ["opening-hours"], "open": ["opening-hours"],
"parking": ["getting-here"], "park": ["getting-here"],
"get there": ["getting-here"], "directions": ["getting-here"],
"bus": ["getting-here"], "train": ["getting-here"],
"contact": ["contact-us"], "phone": ["contact-us"], "email": ["contact-us"],
"archive": ["explore-the-past"], "archaeolog": ["explore-the-past"],
"family history": ["explore-the-past"], "history": ["explore-the-past"],
"study": ["book-a-space"], "room": ["space-for-hire", "book-a-space"],
"hire": ["space-for-hire"], "meeting": ["space-for-hire"],
"card": ["how-to-get-a-library-card"], "join": ["how-to-get-a-library-card"],
"fine": ["fines-renewals"], "renew": ["fines-renewals"],
"borrow": ["borrowing-reservations"], "reserve": ["borrowing-reservations"],
"café": ["cafe"], "cafe": ["cafe"], "coffee": ["cafe"], "food": ["cafe"],
"children": ["children"], "kids": ["children"],
"business": ["business"], "student": ["student"], "young": ["youthhub"],
"career": ["youthhub"], "collection": ["collections"],
}
def hive_info(topic: str | None = None) -> dict:
"""
The Hive (Worcester's library) at PAGE granularity: the exact offering of
each page of thehiveworcester.org, plus the profile of what makes it more
than a branch (joint university+public library, Explore the Past archives
& archaeology, 800+ study spaces, room hire, BIPC, Youth Hub...).
"""
hk = hive_kb()
prof = hk.get("hive_profile", {})
pages = [p for p in hk.get("pages", []) if not p.get("error")]
caps = prof.get("extended_capabilities", [])
out = {
"name": prof.get("name", "The Hive, Worcester"),
"partnership": prof.get("partnership", ""),
"address": prof.get("address", ""),
"opening_hours": (prof.get("opening_hours") or {}).get("building", ""),
"as_of": hk.get("generated", ""), "source_note": hk.get("note", ""),
"page_url": HIVE, "checked": _now(),
}
if not (topic or "").strip():
out.update({
"kind": "overview",
"capabilities": caps[:12],
"page_count": len(pages),
"sections": sorted({p.get("section", "") for p in pages}),
})
return out
t = topic.lower()
slugs: list[str] = []
for word, frags in _HIVE_INTENTS.items():
if word in t:
slugs += frags
scored = []
for p in pages:
score = 0.0
if any(s in p["url"] for s in slugs):
score += 6
title = p.get("title", "").lower()
terms = [w for w in re.findall(r"[a-z]{3,}", t)
if w not in ("the", "hive", "library", "what", "can", "you",
"about", "tell", "does", "have", "there")]
for w in terms:
if w in title:
score += 3
score += sum(0.5 for o in p.get("offerings", []) if w in o.lower())
if w in p.get("summary", "").lower():
score += 1
if score:
scored.append((score, p))
scored.sort(key=lambda x: -x[0])
cap_hits = [c for c in caps
if isinstance(c, dict) and any(
w in (c.get("capability", "") + c.get("detail", "")).lower()
for w in re.findall(r"[a-z]{4,}", t))][:4]
out.update({
"kind": "topic", "topic": topic,
"pages": [{
"title": p["title"], "url": p["url"], "summary": p.get("summary", ""),
"offerings": p.get("offerings", [])[:10],
"what_you_need": p.get("what_you_need", [])[:3],
"details": p.get("details", {}),
"notes": p.get("notes", ""),
} for _, p in scored[:3]],
"capabilities": cap_hits,
})
return out
def whats_new(genre: str | None = None, limit: int = 6) -> dict:
"""Newest catalogue titles for a genre/topic - fuel for a fun 'hot take'."""
term = (genre or "fiction").strip()
res = search_catalogue(term, limit=30)
items = [i for i in res.get("items", []) if i.get("year", "").isdigit()]
items.sort(key=lambda i: i["year"], reverse=True)
items = items or res.get("items", [])
return {"genre": genre or "fiction", "items": items[:limit],
"search_url": res.get("search_url", ""), "checked": _now()}
# --------------------------------------------------------------------------- #
# Self-test
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
import json
print("\n### 1. CATALOGUE: 'harry potter' ###")
res = search_catalogue("harry potter", limit=4)
print(f"count={res['count']} (feed had ~{res.get('total_hint')}) checked {res.get('checked')}")
for it in res["items"]:
print(f" {it['icon']} {it['title']} - {it['author']} [{it['format']} {it['year']}]")
print("\n### 2. MOBILE LIBRARY: 'abberley' ###")
res = mobile_library("abberley")
if "error" in res:
print(" ", res["error"], "→", res.get("suggestions"))
else:
print(f" {res['village']} - {res['date_of_operation']} ({len(res['stops'])} stops)")
for s in res["stops"][:4]:
print(f" {s['time']} - {s['location']}")
print("\n### 2b. MOBILE LIBRARY fuzzy: 'kemsey' (typo) ###")
res = mobile_library("kemsey")
print(" ", res.get("village") or res.get("error"), res.get("suggestions", ""))
print("\n### 3. EVENTS (filter: 'knit') ###")
res = library_events("knit")
for e in res["events"]:
print(f" • {e['name']} - {e['when']} {e['time']} @ {e['location']}")
print("\n### 3b. EVENTS (all) ###")
res = library_events()
print(f" {res['count']} events found")
print("\n### 4. PRINTING ###")
res = printing_help()
print(" ", res["summary"][:80], "...")
print(" pricing:", res["pricing"])
print("\n### 5. WHERE TO GET: 'wolf hall' (copy-level availability - LIVE) ###")
res = where_to_get("wolf hall")
print(f" found={res['found']} best='{res.get('best_title')}' "
f"formats={res.get('formats_held')}")
for r in res.get("routes", []):
if r["route"] == "shelf":
spots = ", ".join(f"{c['branch']} ({c['call_number'] or 'ask staff'})"
for c in r["copies"][:4])
print(f" ON SHELF now: {spots}")
elif r["route"] == "reserve":
print(f" reserve ({len(r.get('copies', []))} copies tracked, "
f"parsed={r.get('availability_parsed')})")
elif r["route"] == "digital":
print(f" {r['format']} - direct link: {r['direct_link']}")
else:
print(f" {r['route']}: {r.get('advice', '')[:70]}")
print("\n### 6. HIVE INFO (page-level KB - offline) ###")
res = hive_info()
print(f" {res['name']} - {res['opening_hours'][:60]}")
print(f" pages={res.get('page_count')} capabilities={len(res.get('capabilities', []))} "
f"as_of={res.get('as_of', '')[:10]}")
for q in ("archives and old documents", "hire a meeting room", "parking"):
r = hive_info(q)
tops = [p["title"] for p in r.get("pages", [])]
print(f" '{q}' -> pages {tops} + {len(r.get('capabilities', []))} capabilities")