How NearByMeNow decides "does this business belong on the <niche> page?" and "what is actually near this person right now?". Three bugs lived here at once, all invisible because each one only ever made results smaller, never wrong.
A business carries businesses.primary_niche (our single-value guess) and businesses.types (Google's array). Niche pages matched only the first. That is one guess per business against ~90 niches, so most businesses were unreachable from the page that describes them.
Measured 2026-08-23 on the live network:
| niche page | matched by primary_niche | also tagged by Google |
|---|---|---|
| restaurants | 82 | +361 |
| bars | 59 | +191 |
| cafes | 9 | +56 |
| hair salons | 14 | +19 |
Use nbmnNicheMatchClause($nicheField, $typesField, $niche, $params, $db), never nbmnNicheInClause directly, for anything a visitor browses. It returns (primary_niche IN (...) OR JSON_CONTAINS(types, ...) OR ...).
Two rules that keep it honest:
establishment and point_of_interest are excluded from the types side.Google stamps them on 3,393 of 3,635 businesses, so including them makes every niche universal.
primary_niche matches rank FIRST ($nichePrecision innbmnFetchListings). Google tags a bowling alley restaurant because it serves food; that is a fine page-3 result and a bad page-1 result.
MariaDB 10.6 - no JSON_OVERLAPS. Use OR'd JSON_CONTAINS(f, JSON_QUOTE(:x), '$').
hair-salons is not hair_salon. auto-repair is not car_repair. plumbers is not plumber. restaurants is not restaurant.
Stripping punctuation out of a URL slug does not produce a place type, and Google silently ignores a type it does not recognise - the search still returns 200 with generic nearby establishments, so the bug looks like "discovery is mediocre" rather than "discovery was never asked the question".
Always resolve through niche_mappings via nbmnNicheTypeValues($niche). When a niche maps to several types, prefer the one matching the slug's singular (hair-salons -> hair_salon, not the broader hair_care).
nbmnJunkTypes() keeps the untyped "who else is on this street" widget free of ATMs, bus stops and gas stations. It contains car_wash, dentist, doctor, pharmacy, hospital, bank, hotel, insurance_agency, real_estate_agency, storage, funeral_home.
It was applied to explicit searches too. A visitor on the car-washes page pressed "Show what's near me", got a correctly typed Google search for car washes, and had every result discarded for being a car wash. Car washes and dentists returned zero cards, every time, for as long as the feature existed.
In nbmnNearbyDiscover, the junk filter runs only when $type === ''. With an explicit type Google has already constrained the set; filtering again can only subtract the answer.
None of them threw. Each one returned a smaller, plausible result set, and a short list of local businesses looks exactly like a short list of local businesses. The symptom was recorded for months as "thin inventory" and "dead-end niches" - a data problem - when all three were query problems. When a surface looks under-stocked, check what the query is actually asking before concluding the shelf is empty.
Method: for every path a visitor can take to find a business, compute what the query SHOULD return and compare to what it DOES. Four more defects, same signature - all subtractive, none threw.
niche_mappings.google_type was never validatedIt is the question every discovery call asks Google. Some values are not Google types. Google answers 400 INVALID_ARGUMENT, callers turn that into [], and the niche reads "nothing near you" forever while billing for each attempt.
Rejections now land in places_bad_types; dz nichetypes reports them with the niches affected, and fix=1 live=1 applies renames. It applies ONLY renames Google itself rejected - inventing a plausible type would just create a new silent zero. roofer -> roofing_contractor took that niche from 0 results to 18.
An errored lookup and an empty neighbourhood both arrive as []. Both were cached. One bad request could pin a niche to "nothing here" for four months after the cause was fixed. prDiscoveryLastCallFailed() separates them; only a real ZERO_RESULTS is remembered. Never cache a failure as an answer.
The route forced sort=closest whenever an origin was known. Two labels, one order. Best match is now a genuine blend - quality decayed by distance, EXP(-d/8) - which is what a person means by "best" in a local directory. Related: the sort whitelist in the route silently dropped any value not in [closest, rating, reviews], so adding a sort to the UI alone does nothing.
include_in_sitemap gates BOTH the sitemap and the directory listing query."Do not submit this to Google" and "do not show this to a visitor browsing the directory" are different questions sharing one flag. 662 enriched par-create skeletons are invisible because of it.
association_or_organization - the lodges, which drive 68% of human traffic and have no directory category at all. Venue types are unmapped too.
Every defect here returned a smaller, plausible answer. A short list of local businesses looks exactly like a short list of local businesses, so none of them could be found by using the site - only by counting. Any query that can return fewer results than it should needs a way to say so out loud: record the rejection, flag the fallback, distinguish the error from the empty.