Shelfmarkthe book, then the aisle

Methodology

Every endpoint this site talks to, the exact request it sends, what comes back, and how the answer is read. Written down because a tool that tells you which shelf to walk to should be checkable, and because most of what is below was learned by probing rather than from documentation.

The shelf map

Surveyed by hand, one shelf face at a time: the first and last call number actually on the face, which side of the unit it is (L or R), which row, which level. Nothing derives it from the catalog, because the catalog does not know how the furniture is arranged. The survey ships inside the page, keyed by level, row index and side, so locating a call number needs no network request at all.

That has consequences worth stating. The map is accurate as of when a face was walked, and shelves get shifted. A face nobody has surveyed shows as a dash rather than as an empty shelf, because those are different claims. Where a range start equals its end, many volumes share one call number and every matching face is returned for you to check the spine.

Matching a call number to a shelf

A call number is parsed into parts and compared part by part, not as a string. Class letters first, then the class number as a number, then each Cutter as a decimal. String comparison is what produces the classic wrong answer, where AM477 sorts before AM4733 because 7 precedes 3 as a character. As decimals, .4733 is less than .477, which is how the shelf is actually ordered.

W1 numbers get their own handling: the space after W1 is part of the number, not decoration.


Endpoints

Seven services, five called straight from the browser and two through a Worker. Which is which is not architectural taste: a service gets a proxy only when it cannot be called from a page.

Alma SRU
Direct. Keyless, and sends CORS headers.
OpenURL resolver
Direct. Keyless, and sends CORS headers.
LibCal
Direct. Keyless, and sends CORS headers.
Open Library
Direct, and only when you ask for covers.
jsDelivr
Direct, and only when you use the photo reader.
Primo PNX
Proxied. Answers without a key but sends no Access-Control-Allow-Origin, so a page cannot read it.
LibGuides A to Z
Proxied. CORS-open, but 1.17 MB of escaped markup to extract about 60 KB of facts.

1. Alma SRU, the catalog

https://ucla.alma.exlibrisgroup.com/view/sru/01UCS_LAL

Every book search is this endpoint, called from your browser. The request is ordinary SRU.

version=1.2
Fixed.
operation=searchRetrieve
Fixed.
recordSchema=marcxml
Full MARC, because the shelf logic needs the call number subfields and the holdings, not a summary.
query=
CQL, built from what you typed and from the filters.
maximumRecords=
50 at most. Alma silently caps it there, so asking for more is a request that quietly returns fewer.
startRecord=
One-based paging.

The CQL is assembled from named indexes rather than thrown at a single catch-all: alma.all_for_ui for free text, then alma.title, alma.creator, alma.subjects, alma.series, alma.uniform_title, alma.notes, alma.publisher, alma.publisher_location, alma.isbn, alma.nlm_call_number, alma.main_pub_date, alma.type_of_record, alma.bib_level, alma.mms_resource_type and alma.material_type. Every token in the search syntax maps onto one of those.

Two facts about this tenant are in no documentation and were established by probing. A sortBy clause inside the CQL is honoured, while the equivalent request parameter is accepted and ignored, which is worse than being rejected: a search that looks sorted and is not gives you a confident wrong first result. And maximumRecords=0 returns a count alone, in well under a tenth of a second once the connection is warm, which is the only reason probing a dozen spelling candidates is affordable.

How its answers are handled

Ranking, because SRU does none

Records come back in no useful order. Up to 150 are pulled, over at most three pages, before anything is shown, and each is scored against what was typed: a title that is the query beats a title that merely contains it, and rare words count for more than common ones, so “Atlas shrugged” is not outranked by every anatomy atlas. Editions of one work are grouped, newest on top.

Scoping

The search runs at the library you say you are at, and widens to the rest of UCLA only if that library has nothing, or nothing that matches the title. When it widens it says so, and if the answer turns out to be at another library the “only my library” filter is suspended rather than left to hide it.

Spelling

SRU corrects nothing and suggests nothing: a misspelt word returns zero records and no explanation. Two passes follow, cheapest first.

2. Primo PNX, the article index, through /api/articles

Upstream: https://search.library.ucla.edu/primaws/rest/pub/pnxs

The only endpoint that searches the article index at all, and the only one that needs a server. The route accepts a fixed set of parameters and refuses anything else.

q
Required, 300 characters at most.
field
any, title, creator or sub, becoming q=<field>,contains,<query>.
type
articles, reviews, books, book_chapters, dissertations, conference_proceedings, newspaper_articles, or any.
from to
Four-digit years, 1500 to next year.
peer oa
yes to require peer review or open access.
lang
A three-letter MARC code from a fixed list.
jtitle
One journal, exactly as the index spells it.
sort
rank, newest, oldest, title, author.
beyond
yes to search past UCLA’s holdings.
limit offset
Up to 20 rows, 10 by default.

The parameters that identify the institution are hardcoded rather than accepted: vid=01UCS_LAL:UCLA, inst=01UCS_LAL, scope=MyInst_and_CI, tab=LibraryCatalog, lang=en. Filters become one qInclude string joined with |,|. Sort names are translated: newest is date_d, oldest is date_a.

An unknown value is a 400 with a message rather than a silent default. That matters more here than usual, because a filter Primo does not understand returns a full, plausible, unfiltered result set, so a typo would otherwise look like an answer.

What the index will and will not filter on

All probed against the live endpoint on 10 August 2026.

What the Worker sends and returns

It identifies itself, as Shelfmark/1.0 (UCLA library tool; +https://shelfmark.phineasfritsch.com), with an Accept and a Referer. An unidentified client is the one nobody can ask about. A 5xx gets one retry after 400 ms; a 4xx does not, because it will not fix itself. Two requests is the ceiling for one search.

The 17 KB internal record is reduced to the roughly 2 KB a row renders: title, authors, journal, volume, issue, pages, date, DOI, ISSN, type, source, an access verdict, an open access flag, and a permalink built from the record id, with the context set to PC for a central record and L for a local one. Three details worth naming:

Answers are cached at the Cloudflare edge for ten minutes, keyed on the upstream request, so two readers asking the same thing cost Primo one answer, and a cached reply says so. Only successes are cached: caching failures meant one transient 522 was stored for ten minutes and no retry could get past it.

3. /api/suggest, spelling

The same Primo host, asking for one row and throwing the row away. The only thing wanted is did_u_mean, which rides along on an ordinary search response.

It is a route of its own because the catalog side has no dictionary, and because of a trap: did_u_mean is present on a bare query and absent the moment any qInclude is applied. The article panel filters to articles by default, so the one search that needs a suggestion is the one that never carries it. An empty article result therefore asks a second time, unfiltered, purely to read the correction.

What comes back cannot be used as it stands. Primo’s corrector treats an acronym as a misspelling: parkisons tFUS comes back as parkinsons thus, which finds the wrong papers. So the correction is applied word by word, and any word carrying a capital letter after the first is left alone. That turns it into parkinsons tFUS, which is the search that was meant and finds 14 papers. If the suggestion differs from the original only in the acronym, nothing is offered. Cached for a day, since a spelling is a property of the dictionary rather than of the collection.

4. /api/databases, the A to Z list

Upstream: https://lgapi-us.libapps.com/widgets.php?site_id=705&widget_type=2&output_format=1

LibGuides has no JSON API without a key, but the public widget carries the whole list as escaped HTML inside a JavaScript file. It is CORS-open, so a browser could fetch it, except that it is 1.17 MB of markup to extract about 190 KB of facts. Parsing it in the Worker and caching for a day means the page downloads the answer instead of the haystack, and LibGuides sees one request a day rather than one per reader. The page fetches it the first time the panel is opened, not on load.

The parse unescapes the payload and then finds each record by its tracked anchor: LibGuides wraps every database link in an onclick that reports the click back to Springshare, and nothing else in the payload carries one. That is what separates a database from the little key icon beside it, which is also a link and which a looser pattern reads as a database called nothing, pointing at a UCLA help page. A record owns the markup between its own link and the next one.

Whether a database needs a login is not a field: it is an icon, an image whose alt text says it requires UCLA authentication, so that is what gets tested. Best bets are flagged the same way, by the badge class the library’s own page uses. Links that are not http or https are dropped rather than passed through to the page. An empty parse is reported as an error rather than as an empty list, because those are different facts. Verified against the live widget on 10 August 2026: 1,360 databases, 1,254 of them needing a login, 340 best bets, and 106 that open to anybody.

Two things the list does not carry. The first is descriptions. Every one of the 1,365 description elements in the payload is empty, and asking the widget for them changes nothing, so the panel shows names and links and does not pretend to more. The second is entitlement. UCLA tags one database as a trial and none as free, which makes this an inventory rather than an access map, so the free-versus-licensed question is answered per provider by the resolver instead.

5. The OpenURL resolver, who carries the full text

https://ucla.alma.exlibrisgroup.com/view/uresolver/01UCS_LAL/openurl

Called from the browser, once per pasted identifier and once per result row that has a DOI.

url_ver=Z39.88-2004
Fixed.
svc_dat=CTO
Ask for the service list rather than a redirect.
svc.fulltext=yes
Include full-text services.
rft_id=info:doi/…
For a DOI. A PubMed ID uses info:pmid/….
rft.issn=…
For an ISSN, which asks about the journal rather than the paper.
rft.genre=
article for an identifier naming one paper, journal for an ISSN.

The reply is XML in the resolver’s own namespace. Each context_service carries a service_type and a set of key elements read as name and value pairs, and only getFullTxt services are kept. The link is either the Authentication key or the resolution_url that sits alongside in document order. From each service: package_public_name or package_display_name for the package, interface_name for the platform, Is_free for whether it opens without UCLA, Availability for coverage years, and electronic_material_type.

Four things had to be handled.

Which of them the button uses

Platforms are tiered, and free breaks ties inside a tier: publisher platforms and the large hosts first, unrecognised platforms and general aggregators next, broad free shelves and news databases last. Sorting on free alone handed the primary link to the Biodiversity Heritage Library for 2024 gene-editing papers, which is a coverage claim about a journal run rather than about the article. Two cases keep the DOI instead: an open access paper, where a licensed route would swap a link that opens anywhere for one that needs a sign-in, and a broad shelf, for the same reason it is ranked last.

Access and route are kept separate. Primo says whether UCLA can deliver the paper, the resolver says whether a particular door opens for anybody. Green means the second thing only, and the button describes the link behind it rather than the paper in front of it.

6. LibCal, opening hours

https://api2.libcal.com/api_hours_grid.php?iid=3244&weeks=1&format=json

Keyless, CORS-open, carries no personal data, and one request returns all seven days for all 32 locations, so moving between days in a week is a re-render rather than a fetch. Any date can be asked for with date=YYYY-MM-DD, which returns the Sunday-anchored week containing it. Worth knowing that from= and start= are ignored: they fail silently by returning this week and looking like they worked. Weeks are kept once fetched, so walking a term back and forth costs one request per new week and none for a revisit.

Dates are handled two deliberately different ways. Today is computed from local time rather than from an ISO string, because an ISO string is UTC and puts Los Angeles on tomorrow’s row after 4pm. Arithmetic on dates is done in UTC, because these are calendar dates rather than moments, and adding a day in local time across a daylight saving boundary can give 23 or 25 hours and slip the answer by a day.

7. Open Library, cover images

https://covers.openlibrary.org/b/isbn/<isbn>-L.jpg?default=false

Only while the show covers box is ticked, and the box says what it does: it sends the ISBN to openlibrary.org. default=false makes a missing cover a 404 rather than a placeholder, so the image removes itself instead of leaving a grey box in the row.

Also, on demand only

jsDelivr
tesseract.js@5.1.1 and heic2any@0.0.4, fetched the first time you read call numbers off a photograph. Both are pinned to an exact version and checked against a hash of the reviewed bytes, so the CDN cannot hand this page different code than the code that was read. Recognition runs in your browser and the images are never uploaded.
doi.org
A link target, not a request this page makes.
Google Fonts
Two font files. The stylesheet is loaded without blocking the paint, and both families name a local fallback picked to break lines in the same places, so the page does not move when the fonts arrive.

The Worker

The whole server is one file with three routes, and it exists because Primo cannot be called from a page. Everything else is a static asset from the same deployment.

Privacy, in one paragraph

No accounts, no cookies, no analytics, and no third-party code on load. What you type goes to UCLA’s public endpoints, and to Primo by way of the Worker, in order to answer the search. The Worker sees the query because it has to forward it, and retains it only inside the cache key of a cached answer. Cover images are the one request that leaves the UCLA and Cloudflare pair, and they happen only if you ask for covers.

What this is not

It is not an official UCLA service, it has no access to your library account, and it cannot place holds or requests. It reads the same public endpoints anyone can read. When it is wrong about a shelf, the shelf is right.