Free Campsites — Browse Campsites by Location
Purpose
Return a list of free (and paid / permit-required) campsites near a given location on freecampsites.net — for each result: name, latitude/longitude, distance from search center, fee tier, average star rating + vote count, short excerpt, canonical detail-page URL, and amenity/activity bitmask. Read-only; never edits site data, never posts reviews. The site is a WordPress + Leaflet map app with an undocumented JSON API (androidApp.php) that the web UI calls — this skill goes straight to the API.
When to Use
- Building a trip planner that needs free/dispersed camping options near waypoints.
- Bulk extraction of campsite metadata for a region (geo-bounded "all sites near X").
- Looking up campsite ratings/excerpts before driving to a known location.
- Anywhere you'd otherwise scrape the Leaflet map UI — the JSON API is ~100× cheaper and returns identical data plus structured lat/lon, ratings, fee tier, and activity bitmasks.
Workflow
The freecampsites.net web UI (the search box + map + side-panel listing) is a thin client over an undocumented androidApp.php JSON endpoint. There is no auth, no API key, no anti-bot wall — but residential proxies are mandatory when calling the endpoint from a Browserbase data-center IP (data-center IPs return HTTP 500). The endpoint geocodes internally but its name-resolver is flaky for "City, ST" (state-abbreviation) forms — geocode first with suggest.php, then search by lat,lon. Lead with the two-call API path; the browser flow is a backup only when proxies are exhausted or the proxy IP gets rate-limited (see the intermittent-empty gotcha below).
Recommended path — two API calls
-
Geocode the user's location via
suggest.php. Browserbase Fetch with--proxiesworks from a non-browser context; alternatively fire an XHR from inside abrowse open https://freecampsites.net/session (no proxy needed when the call is browser-originated).GET https://freecampsites.net/wp-content/themes/freecampsites/suggest.php ?q={url-encoded user query} &limit=5Returns a GeoJSON
FeatureCollection:{ "type": "FeatureCollection", "features": [ { "geometry": {"coordinates": ["-111.7609900", "34.8697400"], "type": "Point"}, "id": "geo5313667", "properties": { "name": "Sedona", "state": "Arizona", "stateAbbr": "AZ", "country": "United States", "population": 10388, "access_counter": 12425 } } ] }Coordinates are
[lon, lat](GeoJSON convention) — the standard GeoJSON order is longitude first, then latitude. Don't swap them. Pickfeatures[0]as the canonical hit. Iffeaturesis empty or the response body is empty, the location is unresolvable — surface the user-facing query unchanged and returnsuccess: false. -
Search for campsites at that lat/lon via
androidApp.php. Use--proxies(mandatory from data-center IPs) and write to file with--output— the in-bandcontentfield returned by Browserbase Fetch is unreliable for this response (often truncates to the leading whitespace preamble; the file output is the source of truth).GET https://freecampsites.net/wp-content/themes/freecampsites/androidApp.php ?location={url-encoded "lat,lon"} &coordinates={url-encoded "lat,lon"} &advancedSearch=%7B%7D &max_sites=40Both
locationandcoordinatesare required — they accept either a place name (e.g."Sedona, Arizona") or a lat/lon string (e.g."34.8697,-111.7610"). Lat/lon is the reliable form because the server-side name-resolver fails silently for ambiguous 2-part"City, ST"queries (see gotcha below).advancedSearch={}is the empty-filter sentinel.max_sites=40doubles the result count from the default 20 (the JS sets it whenever any toggle is active; the server caps higher values at 40 —max_sites=100still returns 40). Server response (after trimming the ~23-byte WordPress whitespace preamble):{ "latitude": "34.8648", "longitude": "-111.81", "queryspeed": "0.36ms", "type": "campsite", "default_icon": "...star.png", "search_icon": "...star.png", "resultList": [ /* up to max_sites entries */ ], "metadata": {"created":"...","reviewed":"...","modified":"..."}, "city": "Sedona", "region": "Arizona" } -
Decode each
resultList[i]into the output shape:id— int. Canonical detail-page URL:https://freecampsites.net/#!{id}&query=sitedetails(the slug-styleurlfield works too but is SEO-flavored and may 301).name,latitude,longitude,distance(miles from search center, integer),city,county,region,country,excerpt.ratings_average(float 0–5),ratings_count(int — votes),ratings_value(sum of votes, internal).type_specific.fee— one of"Free","Pay","Pass/Permit Required"(matched bytype_specific.iconcolor: green = Free, blue = Pass/Permit, red = Pay).type_specific.activities— bitmask integer, not a list. Known bits:1=OHV, 2=Biking, 4=?, 8=?, 16=Hiking, 32=Horse Trails, 64=?, 128=?, 256=?, 512=?, 1024=Wildlife Viewing. Higher bits exist; decode by OR-checking against the constants you've identified, or fall back to thetable_rowHTML which has<img class="act-amen-icon act-{N}" alt="{Activity Name}">for each bit set.type_specific.amenities— bitmask integer (often empty string""when none). Same approach: decode viatable_row'sclass="act-amen-icon"images withact-prefix.- Skip the bulky
ratingandtable_rowHTML strings unless you specifically need rendered markup — the structured fields above contain everything.
-
(Optional) Fetch full detail for one campsite by id:
GET https://freecampsites.net/wp-content/themes/freecampsites/androidApp.php ?siteid={id}Returns ~400KB JSON with
images[],reviews[](undercomments),address,elevation,phone,email,open_dates,notes,author, etc. Same trim-the-preamble + same--proxiesrequirement. -
Pagination / wider radius: there is no offset parameter —
offset=20is silently ignored. The only knob ismax_sites=40(default 20). To cover a wider geographic area than the implicit ~25-mile radius, geocode multiple anchor points and union the result sets, de-duplicating byid.
Browser fallback
When --proxies is exhausted, the proxy IP is rate-limited (returns the 21-byte whitespace-only preamble repeatedly — see gotcha), or you need a query whose server-side geocoding succeeds in-browser but fails via the API directly:
browse cloud sessions create --keep-alive(no--proxies, no--verified— site has no anti-bot wall and HTTP/2 errors out when proxies are on —ERR_HTTP2_PROTOCOL_ERRORon every navigation).browse open https://freecampsites.net/ --remote --session "$sid".browse snapshotand locate the search ref via accessibility tree:searchbox: Enter a Location(placeholder is the canonical anchor — ref index varies per snapshot; do not hardcode).browse click {ref}thenbrowse fill {ref} "Sedona, Arizona"— wait 2s for the Photon-backed autocomplete dropdown to render.browse click {autocomplete-suggestion-ref}— first item in the dropdown is a listitem containing a<strong>with the place name. The click navigates the URL hash to#!Sedona,+Arizona,+United+Statesand triggers the sameandroidApp.phpXHR shown above.- After ~3–5s, read results from the DOM:
Or, simpler: fire the same XHR yourself fromArray.from(document.querySelectorAll('a[href*=sitedetails]')).map(a => ({ url: a.href, // "https://freecampsites.net/#!{id}&query=sitedetails" id: a.href.match(/#!(\d+)/)[1], // The parent .table_row carries onmouseenter="mouseInRow({lat}, {lon}, ...)" }))browse eval:
The browser-originated XHR is the most reliable transport — same-origin, no proxy needed, no rate-limit symptoms seen during testing.new Promise(r => { const x = new XMLHttpRequest(); x.open('GET', '/wp-content/themes/freecampsites/androidApp.php?location=Sedona%2C+Arizona&coordinates=Sedona%2C+Arizona&advancedSearch=%7B%7D&max_sites=40'); x.onload = () => r(JSON.parse(x.responseText.trim())); x.send(); });responseTextalways carries the ~23-byte WordPress preamble before the JSON;trim()(orresponseText.replace(/^\s+/, '')) beforeJSON.parse.
Site-Specific Gotchas
--proxiesis mandatory from data-center IPs:browse cloud fetchwithout--proxiesreturns500 Internal Server Errorfor bothandroidApp.phpandsuggest.php(the site blocks well-known data-center ranges at the nginx layer). Inside abrowse opensession this isn't an issue because the request originates from a browser pod with a different IP class.browse cloud fetchcontent-field is unreliable; use--output: when fetchingandroidApp.php, the inlinecontentreturned by the Browserbase Fetch API often shows just"\n\n\n\n…"(the WordPress preamble) even though the full body did arrive. Always pass--output <file>and read from disk; thesizeBytesfield in the fetch response is honest.- HTTP/2 protocol error with
--proxies --verified: opening pages on freecampsites.net inside a session created with both--proxiesand--verifiedproducesERR_HTTP2_PROTOCOL_ERRORon every navigation. The site has no detectable anti-bot wall — drop--verified(and--proxiesfor the session, since the browser-originated XHRs work over the pod IP). Reserve--proxiesfor the directbrowse cloud fetchpath only. - State abbreviations break server-side geocoding silently: passing
location=Bend, Oregonorlocation=Yellowstone, WYreturnsHTTP 200with an empty body (just the WordPress whitespace preamble — 21 bytes total). The same queries with the 3-part form"Bend, Oregon, United States"or the bare 1-part form"Bend"succeed. Don't trust the"City, ST"shape — always geocode viasuggest.phpfirst and feed the result back aslocation=<lat>,<lon>(which is unambiguous). suggest.phpis itself imperfect for state-abbreviation queries:q=Yellowstone, WYresolves top-hit to "Yellowstone County, Montana" (because "WY" is parsed as a separate token rather than a state-disambiguator).q=Yellowstone(bare) correctly resolves to "Yellowstone National Park, WY". When the user-supplied query contains, {2-letter abbr}, consider stripping the abbreviation or trying both forms and picking the higher-access_counterresult.- Empty response ≠ no campsites — it means geocoding failed: a 21-byte response body (
/^\n{21}$/) is the silent-fail signature for "location not resolvable". The endpoint never returns an emptyresultListfor a successfully-geocoded location — it always returns the 20 nearest campsites regardless of distance (Antarctica → Argentina/Chile results; Pacific Ocean → Baja California results). If you need true "no campsites within N miles", filter the result list bydistanceon the client side after the call. advancedSearchJSON keys are activity/amenity bitmasks, not arbitrary filters:{"fee":"Free"}and{"maxDistance":100}are silently dropped and producecount=0(the empty-pair filter{}returns the normal 20). The recognized keys areactivities,amenities,featureTypes,fee(as a bitmask), etc., and the values are integer bitmasks built up by OR'ing the UI's toggle constants — the client builds them viaadvancedSearch[t]=advancedSearch[t]^awhereais a power of two. The full bitmask vocabulary isn't documented; client-side filtering on theresultListis more reliable than trying to push filters server-side.max_sitescaps at 40:max_sites=100still returns 40. The default (nomax_sitesparam) returns 20. There is no offset / next-page parameter —offset=20is silently ignored. To cover wider regions, query multiple anchor points and de-dupe byid.- Activity / amenity icons are bitmask-coded:
act-1=OHV, act-2=Biking, act-16=Hiking, act-32=Horse Trails, act-1024=Wildlife Viewing(verified). The integer intype_specific.activitiesis the OR of all bits —"1075"=1+2+16+32+1024= OHV + Biking + Hiking + Horse Trails + Wildlife Viewing. Higher bits exist; back-derive from thetable_rowHTML's<img class="act-amen-icon act-{N}" alt="{Name}">images, which carry both the bit value and a human-readable label. - Icon color encodes fee tier: the
type_specific.iconURL ends infc_icon-tent-green-24x24.png(Free),-blue-24x24.png(Pass/Permit Required), or-red-24x24.png(Pay). Thetype_specific.feestring field is the canonical source — three observed values:"Free","Pay","Pass/Permit Required". There is notype_specific.fee === "Unknown"— every campsite is classified. - Distances are integer miles from the geocoded search center, not from the user's intended location. If the user said "Yellowstone, WY" but
suggest.phpresolved to "Yellowstone County, MT", everydistanceis measured from the Montana centroid. Always echo back the resolvedcity/regionfrom theandroidApp.phpresponse so the caller can see what was actually searched. siteiddetail responses are ~400KB each and include base64-style image arrays, every review comment, and notes. Don't pre-fetch details for all 40 result rows by default — fetch on demand. Theimages[][0][0]field stores image URLs as\\'<url>\\'(escaped-and-quoted strings, not bare URLs); strip the leading\'and trailing\'before use.- WordPress preamble on every response: every
androidApp.phpandsuggest.phpresponse is prefixed with ~21–23 bytes of literal\ncharacters (the WordPress theme's pre-output). Always.trim()(or.replace(/^\s+/, '')) beforeJSON.parse. The 21-byte all-newlines body is the silent-fail signature. - Intermittent empty responses from
browse cloud fetch --proxies: the same query that worked moments earlier may return 21 bytes for several consecutive attempts before returning data again. Pattern observed: cold proxy IPs sometimes get the empty response on first hit, warm IPs cache and return. Retry up to 3 times with 0.5s spacing; if still empty after that, fall back to the in-browser XHR path (abrowse opensession firing the same XHR viaevalis the most reliable transport observed). - No
--verifiedstealth needed: site has no Cloudflare / Akamai / captcha / login wall. A barebrowse cloud sessions create --keep-aliveis sufficient for the browser fallback.
Expected Output
{
"success": true,
"query": "Sedona, Arizona",
"resolved": {
"name": "Sedona",
"state": "Arizona",
"country": "United States",
"latitude": 34.86974,
"longitude": -111.76099
},
"search_center": {"latitude": 34.8648, "longitude": -111.81},
"result_count": 40,
"results": [
{
"id": 4001,
"name": "FR525 & FR525C - West of Sedona",
"detail_url": "https://freecampsites.net/#!4001&query=sitedetails",
"canonical_url": "https://freecampsites.net/fr525-fr525c-west-of-sedona/",
"latitude": 34.833159,
"longitude": -111.908447,
"distance_miles": 6,
"city": "Sedona",
"county": "Yavapai County",
"region": "Arizona",
"country": "United States",
"fee": "Free",
"rating_average": 3.18,
"rating_count": 104,
"activities_bitmask": 1075,
"activities": ["OHV", "Biking", "Hiking", "Horse Trails", "Wildlife Viewing"],
"amenities_bitmask": 0,
"amenities": [],
"excerpt": "Travel 3 miles west of Lower Red Rock Loop Road on Hwy. 89A to Forest Road 525. The first 5 miles of FR 525 and all of FR 525C are …",
"icon_url": "https://freecampsites.net/wp-content/themes/freecampsites/images/map-icons/fc_icon-tent-green-24x24.png"
}
]
}
Geocoding-failure shape
{
"success": false,
"query": "zzzzzzzzzzzz",
"error_reasoning": "Location could not be geocoded — suggest.php returned an empty FeatureCollection. Try a more specific query (full city + state + country, or lat,lon directly).",
"resolved": null,
"results": []
}
Geocoder-misresolution warning shape
The endpoint always returns 20+ campsites for any successfully geocoded point, even if that point is mid-ocean or in Antarctica. When the resolved region doesn't match the user's intent (e.g. user asked for "Yellowstone, WY" and resolved.state came back as "Montana"), surface a warning:
{
"success": true,
"query": "Yellowstone, WY",
"resolved": {
"name": "Yellowstone County",
"state": "Montana",
"country": "United States",
"latitude": 45.93725,
"longitude": -108.27435
},
"warning": "Geocoder resolved 'Yellowstone, WY' to Yellowstone County, MT — likely not the intended location. Re-query with 'Yellowstone National Park' or coordinates (44.4280, -110.5885).",
"search_center": {"latitude": 45.93725, "longitude": -108.27435},
"result_count": 40,
"results": [/* ... 40 entries centered on Billings, MT */]
}
Detail-page output (single campsite via ?siteid=)
{
"id": 4001,
"name": "FR525 & FR525C - West of Sedona",
"type": "campsite",
"create_date": "2011-05-01T17:21:57+00:00",
"update_date": "2026-04-27T02:29:48+00:00",
"latitude": 34.833159,
"longitude": -111.908447,
"elevation": "...",
"address": "...",
"city": "Sedona",
"county": "Yavapai County",
"region": "Arizona",
"country": "United States",
"open_dates": "Year Round",
"fee": "Free",
"phone": "...",
"email": "...",
"rating_average": 3.18,
"rating_count": 104,
"excerpt": "...",
"notes": "<HTML body of the full notes/description>",
"images": [/* array of image URL + thumbnail tuples */],
"comments": [/* array of user reviews */],
"author": "...",
"author_url": "..."
}