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.
Access-Control-Allow-Origin, so a page cannot read it.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.2operation=searchRetrieverecordSchema=marcxmlquery=maximumRecords=startRecord=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
- One request in flight. Rate limits are undocumented, so requests go through a queue, never a burst.
- Three attempts, backing off half a second, then a second and a half. If the
failure might be the
sortByclause, the clause is dropped and the query retried once before any error reaches you. - Diagnostics are read as errors. SRU reports failure inside a 200, so the
parse looks for a
messageelement and treats its text as the error, and readsnumberOfRecordsfor the count. - MARCXML is parsed in the browser with the platform XML parser, namespace aware. A parser error is treated as an unreadable response rather than as no results.
- Twenty-five requests per question. Spelling recovery can be expensive, and when the allowance runs out the page says so instead of reporting no match.
- Repeats are free. Every response is kept for the session, keyed on page size, start record and query, so paging back and forth costs nothing.
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.
- Primo’s dictionary, one request, through the suggest route below.
- The catalog’s own vocabulary. The broken word is identified by asking
which single word of the query is in no UCLA record at all, then repaired in three rungs: likely
typos enumerated and tested, a truncated form searched so the real word can be read out of the
titles that come back, and finally every one-edit variant asked about in batches, since 26
variants as one
orquery cost one request rather than 26. Each candidate is scored against what you typed before it is accepted, because a candidate that matches the wrong books is worse than no candidate.
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.
qfieldany, title, creator or sub, becoming q=<field>,contains,<query>.typefrom topeer oayes to require peer review or open access.langjtitlesortbeyondyes to search past UCLA’s holdings.limit offsetThe 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.
facet_rtype,facet_tlevel,facet_langandfacet_jtitlenarrow both halves of the search, local holdings and the central index alike.- Dates work in exactly one syntax.
facet_searchcreationdate,include,[2020 TO 2026]filters both halves, and an open end takes*. The pipe-separated form Primo’s own facet links use drops the central index entirely, reportingtotalResultsPCas -1, which on an article search means dropping everything: zero results once the articles filter is also on. - Sorting by date needs a bound. Newest first with no upper bound returns a wall of records dated 2027 through 2029, so it is capped at the current year unless you name a later one. Oldest first starts at 1900 rather than at the year-zero records below it.
- Author cannot be a filter.
facet_creatoris local-only: an author with 1,162 central hits faceted down to 5 local books. So author is a query field instead, which is also why it cannot be combined with a topic. - Several plausible parameters do nothing.
mfacet,facet,dr_sanddr_eare accepted and ignored, as are the sort spellingsdate,scdate,dateOldandsortby=, each of which silently means rank. - No facet lists come back in a search response, so there is nothing to build a menu of journals or authors from. That is why the journal filter is only offered from a result row, where the exact string the index uses is already in hand. Typing it would mostly fail: “The Lancet” matches 26 records and “lancet” matches none.
- Holdings-only is the default. Without
pcAvailabilitythe central index answers with what UCLA can deliver and nothing else. For “crispr gene editing” that is 39,743 papers against 48,636 expanded, and the 8,893 difference is results a reader would click into a dead end. The wider search is one button away.
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:
- Title variants are collapsed. Primo joins them with a slash, and one JAMA reply arrived as the same sentence three times. Exact repeats are dropped, and so is any variant that is only the start of a longer one.
- Authors are split apart. Sometimes one name per entry, sometimes forty names in a single string joined with semicolons. A page that trusts the array length shows either three names or a five-line wall depending on the record, so the field is normalised into a real list.
- Duplicates are merged on DOI, or on title and year where there is no DOI. The request over-fetches by five rows, capped at 30, so dropping duplicates does not hand back a short page.
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-2004svc_dat=CTOsvc.fulltext=yesrft_id=info:doi/…info:pmid/….rft.issn=…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.
- Coverage text contains markup.
Availabilityarrives as “Available from 01/01/1990 volume: 322 issue: 1.<br>” in a field that is otherwise plain text, so tags are stripped rather than rendered. - One entry per platform, not per package. NEJM alone returns five services, two of them NEJM packages split at 1989. Five buttons for one publisher is the list being loud about licensing rather than about the paper.
- Some free services are not places to read. CLOCKSS and Portico are dark preservation archives, DOAJ and the ISSN Centre’s ROAD are registries. All four are marked free, so they sorted to the front and became the link the title pointed at. They are dropped, not demoted: a reader has no use for a button that opens a catalogue.
- An ISSN is not asked about per row. Given an ISSN the resolver answers about the journal, and the links land on a table of contents or a publisher’s front page. Verified: eLife’s ISSN returns a DOAJ table of contents, the eLife home page and a PMC journal page, none of which is the paper.
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
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.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.
GET /api/articles,GET /api/suggestandGET /api/databases, plusHEADand a CORS preflight on each. Any other method is a 405 naming what it does allow, and any other path falls through to the static assets. Those answer with a real 404 page rather than the tool wearing a 200.- Only the parameters documented above reach Primo, and the ones identifying the institution are fixed in the source. It is not an open proxy and cannot be pointed anywhere else. Every value is checked against a list of the ones that mean something, and a value that is not on it is refused rather than passed along: an unknown facet does not fail upstream, it comes back as a full, plausible, unfiltered result set wearing a filter’s label.
- Two characters carry grammar in Primo’s query language and are therefore not passed
through as text. A comma opens a new clause, so commas in a search phrase become spaces. That
changes no result:
smith, johnandsmith johnreturn the same 395,471 records. The|,|that separates facets means a journal-title filter containing a vertical bar is refused outright. - Replies carry
Access-Control-Allow-Origin: *, since they hold nothing but bibliographic facts, and aCache-Controlmatching the edge cache. Errors are sentno-store. Every reply is JSON, including the ones nobody planned for: an unexpected failure is caught and answered in the same shape, because an error page without that header reads to the browser as being offline rather than as a server saying no. - Both upstreams are given eight seconds. Neither promises to answer, and a request nobody is waiting on still occupies a slot.
- There is no database and no state between requests beyond the edge cache.
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.