freecampsites.net

browse-campsites

Installation

Adds this website's skill for your agents

 

Summary

Geocode a user's location query and return up to 40 nearby campsites from freecampsites.net (name, lat/lon, distance, fee tier, ratings, excerpt, detail-page URL, activity/amenity bitmasks) via the undocumented androidApp.php JSON endpoint, with suggest.php geocoding and an in-browser XHR fallback.

FIG. 01
FIG. 02
FIG. 03
SKILL.md
286 lines

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

  1. Geocode the user's location via suggest.php. Browserbase Fetch with --proxies works from a non-browser context; alternatively fire an XHR from inside a browse 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=5
    

    Returns 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. Pick features[0] as the canonical hit. If features is empty or the response body is empty, the location is unresolvable — surface the user-facing query unchanged and return success: false.

  2. 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-band content field 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=40
    

    Both location and coordinates are 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=40 doubles 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=100 still 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"
    }
    
  3. Decode each resultList[i] into the output shape:

    • id — int. Canonical detail-page URL: https://freecampsites.net/#!{id}&query=sitedetails (the slug-style url field 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 by type_specific.icon color: green = Free, blue = Pass/Permit, red = Pay).
    • type_specific.activitiesbitmask 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 the table_row HTML 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 via table_row's class="act-amen-icon" images with act- prefix.
    • Skip the bulky rating and table_row HTML strings unless you specifically need rendered markup — the structured fields above contain everything.
  4. (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[] (under comments), address, elevation, phone, email, open_dates, notes, author, etc. Same trim-the-preamble + same --proxies requirement.

  5. Pagination / wider radius: there is no offset parameter — offset=20 is silently ignored. The only knob is max_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 by id.

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:

  1. browse cloud sessions create --keep-alive (no --proxies, no --verified — site has no anti-bot wall and HTTP/2 errors out when proxies are onERR_HTTP2_PROTOCOL_ERROR on every navigation).
  2. browse open https://freecampsites.net/ --remote --session "$sid".
  3. browse snapshot and locate the search ref via accessibility tree: searchbox: Enter a Location (placeholder is the canonical anchor — ref index varies per snapshot; do not hardcode).
  4. browse click {ref} then browse fill {ref} "Sedona, Arizona" — wait 2s for the Photon-backed autocomplete dropdown to render.
  5. 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+States and triggers the same androidApp.php XHR shown above.
  6. After ~3–5s, read results from the DOM:
    Array.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}, ...)"
    }))
    
    Or, simpler: fire the same XHR yourself from browse eval:
    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();
    });
    
    The browser-originated XHR is the most reliable transport — same-origin, no proxy needed, no rate-limit symptoms seen during testing. responseText always carries the ~23-byte WordPress preamble before the JSON; trim() (or responseText.replace(/^\s+/, '')) before JSON.parse.

Site-Specific Gotchas

  • --proxies is mandatory from data-center IPs: browse cloud fetch without --proxies returns 500 Internal Server Error for both androidApp.php and suggest.php (the site blocks well-known data-center ranges at the nginx layer). Inside a browse open session this isn't an issue because the request originates from a browser pod with a different IP class.
  • browse cloud fetch content-field is unreliable; use --output: when fetching androidApp.php, the inline content returned 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; the sizeBytes field in the fetch response is honest.
  • HTTP/2 protocol error with --proxies --verified: opening pages on freecampsites.net inside a session created with both --proxies and --verified produces ERR_HTTP2_PROTOCOL_ERROR on every navigation. The site has no detectable anti-bot wall — drop --verified (and --proxies for the session, since the browser-originated XHRs work over the pod IP). Reserve --proxies for the direct browse cloud fetch path only.
  • State abbreviations break server-side geocoding silently: passing location=Bend, Oregon or location=Yellowstone, WY returns HTTP 200 with 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 via suggest.php first and feed the result back as location=<lat>,<lon> (which is unambiguous).
  • suggest.php is itself imperfect for state-abbreviation queries: q=Yellowstone, WY resolves 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_counter result.
  • 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 empty resultList for 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 by distance on the client side after the call.
  • advancedSearch JSON keys are activity/amenity bitmasks, not arbitrary filters: {"fee":"Free"} and {"maxDistance":100} are silently dropped and produce count=0 (the empty-pair filter {} returns the normal 20). The recognized keys are activities, 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 via advancedSearch[t]=advancedSearch[t]^a where a is a power of two. The full bitmask vocabulary isn't documented; client-side filtering on the resultList is more reliable than trying to push filters server-side.
  • max_sites caps at 40: max_sites=100 still returns 40. The default (no max_sites param) returns 20. There is no offset / next-page parameter — offset=20 is silently ignored. To cover wider regions, query multiple anchor points and de-dupe by id.
  • 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 in type_specific.activities is the OR of all bits — "1075" = 1+2+16+32+1024 = OHV + Biking + Hiking + Horse Trails + Wildlife Viewing. Higher bits exist; back-derive from the table_row HTML'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.icon URL ends in fc_icon-tent-green-24x24.png (Free), -blue-24x24.png (Pass/Permit Required), or -red-24x24.png (Pay). The type_specific.fee string field is the canonical source — three observed values: "Free", "Pay", "Pass/Permit Required". There is no type_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.php resolved to "Yellowstone County, MT", every distance is measured from the Montana centroid. Always echo back the resolved city/region from the androidApp.php response so the caller can see what was actually searched.
  • siteid detail 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. The images[][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.php and suggest.php response is prefixed with ~21–23 bytes of literal \n characters (the WordPress theme's pre-output). Always .trim() (or .replace(/^\s+/, '')) before JSON.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 (a browse open session firing the same XHR via eval is the most reliable transport observed).
  • No --verified stealth needed: site has no Cloudflare / Akamai / captcha / login wall. A bare browse cloud sessions create --keep-alive is 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": "..."
}