Mountain Project Route Search
Purpose
Search mountainproject.com — the public crowd-sourced US/worldwide rock-climbing route database — for routes matching a structured filter set OR a free-form text query, and return per-route structured data: id, name, URL, grade (in YDS / V / WI / AI / M / Aid), route type(s), pitches, length, star rating, vote count, area-breadcrumb path, lat/lng, and first ascent. Read-only — never logs in, never edits or rates routes.
When to Use
- "Find sport routes 5.10a–5.11d in Boulder Canyon with ≥ 2.8 stars and ≥ 100 votes."
- "What are the highest-rated V4–V6 boulder problems in Bishop?"
- "List all multi-pitch trad in Red Rocks above 5.10."
- "Resolve the area ID for 'Indian Creek' and dump the top 200 cracks."
- Bulk catalog extraction for ML/training, climbing-trip planning, or guide-app ingestion. Anywhere you'd hand-scrape MP's route-finder.
Workflow
Mountain Project's /route-finder is a plain HTTP GET form (no CSRF on read, no auth required for cookieless fetches via residential proxy) and ships a sister /route-finder-export endpoint that returns the same query result as a CSV. Lead with the CSV; only fall back to HTML scraping when you need fields the CSV doesn't include (vote_count, first_ascent) or when results exceed the CSV's 1000-row cap. The official /data/get-routes JSON API exists but is apiKey-gated and returns 403 {"success":0,"message":"Invalid Key"} cookieless — don't try it.
1. Resolve the area ID (if filtering by location)
Mountain Project's route-finder filters by integer area ID, not by free-text. Resolve a name like "Boulder Canyon" via the public autocomplete:
GET https://www.mountainproject.com/ajax/public/search/suggestions?q=<URL-encoded name>
Response:
{
"totalResults": 10000,
"html": "<div class=\"suggestion-results\">
<div class=\"section\">Areas</div>
<a class=\"suggestion\" href=\"https://www.mountainproject.com/area/105744222/boulder-canyon?...\">
Boulder Canyon <div class=\"right-text\">Colorado</div>
</a>
<a class=\"suggestion\" href=\"https://www.mountainproject.com/area/118874741/boulder-canyon?...\">
Boulder Canyon <div class=\"right-text\">Utah</div>
</a>
..."
}
- The JSON
htmlfield is an HTML string. Parse each<a class="suggestion" href="...">— the path/area/<ID>/<slug>gives you the area ID. - The endpoint disambiguates by state/country in
<div class="right-text">. Pick the match whose state matches the caller's intent. Boulder Canyon alone resolves to 3 distinct areas (Colorado=105744222, Utah=118874741, Arizona=106389009) — always disambiguate. - Sections returned: Areas, Routes, Photos, More (sites/users). For area resolution take the first
.suggestion(not.suggestion.route) in the Areas section. - For free-text route search (caller passed a route name like "The Bastille Crack"), take entries marked
<a class="suggestion route">— those include the grade inline in<div class="route-difficulty">5.7</div>. The href path is/route/<id>/<slug>.
To search globally (any area), pass selectedIds=0 and skip this step entirely.
2. Build the route-finder URL
/route-finder accepts the entire filter set as GET query-string params. The URL is fully bookmark-stable — Mountain Project just URL-decodes and applies; you can hand-construct it.
GET https://www.mountainproject.com/route-finder
?selectedIds=<areaId> # 0 = global
&type=rock # rock | boulder | aid | ice | mixed
&diffMinrock=<minRank> # see internal-rank tables below
&diffMaxrock=<maxRank>
&is_trad_climb=1 # rock sub-filter — 1 includes, 0 excludes
&is_sport_climb=1 # OR-combined across the three sub-flags
&is_top_rope=0
&stars=<minStars> # 0 | 1.8 | 2.3 | 2.8 | 3.3 | 3.8
&pitches=<n> # 0=any, 1=exactly 1, 2..5=at least N, 6=6+
&sort1=<sort> # area | rating | popularity desc | title
&sort2=<sort> # tiebreaker, same options
&page=<n> # 1, 2, 3, ... (50 rows/page — NOT tunable)
Internal grade-rank tables (from option-value harvest of the route-finder form; verified 2026-05-18):
| Grade system | type= | diffMin* name | Sample values (text → rank) |
|---|---|---|---|
| YDS rock | rock | diffMinrock / diffMaxrock | 5.7→1800, 5.9→2300, 5.10a→2600, 5.10b→2700, 5.10c→3100, 5.10d→3300, 5.11a→4600, 5.11d→5300, 5.12a→6600, 5.13a→8600, 5.14a→10500, 5.15d→12400. Min and Max selects share the text labels but use slightly different internal ranks for Max (e.g. 5.10a max = 2800, 5.11d max = 5500) — the form ships both lists; cache them or extract once from /route-finder HTML. |
| Bouldering | boulder | diffMinboulder / diffMaxboulder | V0→20000, V4→20350, V10→20950, V17→21650 (min); add 50 for max (V0 max=20050, V17 max=21700) |
| Water ice | ice | diffMinice / diffMaxice | WI1→30000, WI4→32500, AI1→38000, AI6→38500 |
| Mixed | mixed | diffMinmixed / diffMaxmixed | M1→50000, M5→53500, M16→64900 |
| Aid | aid | diffMinaid / diffMaxaid | A0/C0→70000 (min), A5/C5→74500 (min); max range adds 10/750 (A5/C5 max=75260) |
These ranks are not contiguous within a grade letter — 5.10c=3100, 5.10d=3300 but 5.11a=4600 — Mountain Project pads the integer space so future intermediate grades can slot in. Always harvest the full mapping once from the form rather than guessing; see the canonical extract in this skill's screenshots #1.
The form only exposes 5 top-level type= values. "Trad", "Sport", and "Top Rope" are sub-filters of rock expressed via is_trad_climb / is_sport_climb / is_top_rope. Alpine, snow, and chossaneering are NOT filterable — they're free-form tags on individual routes but the route-finder has no checkbox for them.
3. Fetch results — CSV path (primary)
For any query that returns ≤ 1000 routes, the CSV export is the fastest and most structured path:
GET https://www.mountainproject.com/route-finder-export?<same query string as /route-finder>
Returns text/csv with columns:
Route, Location, URL, "Avg Stars", "Your Stars", "Route Type", Rating, Pitches, Length, "Area Latitude", "Area Longitude"
Example row:
The Bastille Crack,"Bastille > Eldorado Canyon SP > Boulder > Colorado",https://www.mountainproject.com/route/105748490/the-bastille-crack,3.5,-1,Trad,5.7,5,350,39.93083,-105.28315
- Parse
idfrom theURLcolumn:/route/(\d+)/. - Parse
area_pathby splittingLocationon>(note the spaces) and reversing — MP serializes most-specific-first; the canonical "USA → State → ... → Crag" order is the reverse of the CSV value. Some routes (international, or routes pinned at the top level) omit "USA" entirely; do not insert "USA" unless the last token is a US state name. Lengthis in feet (numeric, blank for unknown).Pitchesis integer (blank for unknown).Avg Starsis the precise float (0.0–4.0).Your Starsis-1cookieless (it's the requesting user's vote — useless without auth).Route Typecan be a comma-separated multi-discipline string ("Trad, Sport","Sport, TR"). Split on,then trim.Ratingis the grade text (5.11d,V4,WI4). Infergrade_systemfrom the leading characters (5.→ YDS,V→ V-scale,WI/AI→ ice,M\d→ mixed,A\d/C\d→ aid).- The CSV is hard-capped at 1000 rows regardless of total matches. There is no
page=paging on the CSV —&page=2returns the same first 1000 rows. For results 1001+ you must fall through to step 4.
4. Fetch results — HTML path (for vote_count, first_ascent, or > 1000 results)
GET https://www.mountainproject.com/route-finder?<query>&page=<N>
Returns the full route-finder HTML page. 50 rows per page, hard-coded. There is no total-count number rendered — the only signal of total size is the last-page link in the pagination block:
<a href="https://www.mountainproject.com/route-finder?...&page=7">
<img src="/img/arrows/last.svg" alt="Last">
</a>
Parse page=N from the <img alt="Last"> anchor to compute total_pages. Multiply by 50 to estimate total_count (final page may be partial — fetch it to get the exact tail count). Pages beyond the last-page link return 200 OK with zero route-rows (silent — always check rows.length > 0 before claiming the page exists).
Each row is <tr class="route-row">. Extract per row:
<tr class="route-row">
<td>
<a href="https://www.mountainproject.com/route/105753505/animal-magnetism" class="text-black route-row">
<div class="float-xs-right text-xs-right">
<span class='rateYDS'>5.11c</span>
<span class='rateFrench'>7a</span> <span class='rateEwbanks'>24</span>
<span class='rateUIAA'>VIII</span> <span class='rateZA'>25</span> <span class='rateBritish'>E5 6a</span>
<div>
<span class='scoreStars'>
<img src='/img/stars/starBlue.svg'> ... <!-- integer-rounded stars -->
</span>
<span class="text-muted small"> 467</span> <!-- vote_count -->
</div>
</div>
<div class="text-truncate"><strong>Animal Magnetism</strong></div>
<div class="small text-warm">Sport</div> <!-- route types + " N pitches" if multi -->
</a>
<div class="small text-warm">
<a href=".../area/105744222/boulder-canyon">Boulder Canyon</a> > ... >
<a href=".../area/.../high-energy-area">High Energy Area</a>
</div>
</td>
</tr>
id—/route/(\d+)/name—<strong>([^<]+)</strong>(HTML-entity-decode)grade— text in<span class='rateYDS'>(orrateV,rateWI, etc. depending on type). The same row carries alternate-system equivalents (rateFrench,rateEwbanks,rateUIAA,rateZA,rateBritish) — capture them if the caller wants cross-system grades.star_rating_integer— count ofstarBlue.svgimages. Add 0.5 perstarHalfBlue.svg. Note this is rounded (Avg Stars=3.9renders as 4 full blue stars, no half) — for precise float values, prefer the CSV or the route-detail JSON-LD (step 5).vote_count—<span class="text-muted small"> (\d+)</span>after the stars block. This field is NOT in the CSV.type— text in the inner<div class="small text-warm">directly inside the anchor (e.g.Sport,Trad, TR,Trad, Sport 2 pitches).pitches— the trailingN pitchestext in the same div, when present. Single-pitch routes omit it.area_path— the outer<div class="small text-warm">after the</a>, which lists the breadcrumb as a sequence of<a href=".../area/...">anchors separated by>. Note: this breadcrumb is rendered most-general-first (opposite of the CSV's Location column).
5. Enrich a single route — /route/<id>/<slug> (for first_ascent, precise rating, exact lat/lng)
The route detail page is the canonical source for first_ascent (not in CSV, not in row), precise star_rating float, exact GPS, and any other detail field. Server-rendered HTML — works cookieless.
Two extraction surfaces:
(a) Inline JSON-LD (most reliable for rating/lat/lng):
<script type="application/ld+json">
{
"@type": "LocalBusiness",
"name": "Animal Magnetism",
"description": "5.11c Sport",
"geo": { "@type": "GeoCoordinates", "latitude": "39.9984566", "longitude": "-105.41396584" },
"aggregateRating": { "@type": "AggregateRating", "ratingValue": "3.8", "reviewCount": "467" }
}
</script>
(b) description-details table for the typed fields:
Type: Trad, 350 ft (106 m), 5 pitches | Fixed Hardware (2)
GPS: 39.93083, -105.28315
FA: US Army climbers, 1954. FFA: Stan Shepard, Allen Bergen, 1957
- Parse
Type:as a comma-separated list. The first 1–N elements (until the first match of^\d+\s*ft) are route types; the remainder is<length> ft (<m> m), <N> pitchesfor multi-pitch, or<length> ft (<m> m)only for single-pitch. Trailing pipe-separated tags (Fixed Hardware (2)) are gear notes — capture as a separategear_notesfield if the caller wants them. FA:(first ascent) is free-form text — preserve as-is. May includeFFA:(first free ascent) for routes initially aided. Routes without recorded FA simply omit the row.- The breadcrumb "You are here" path at the top of the page is the most authoritative
area_path— use it to override step 3/4's parsed path when discrepancies arise.
6. Pagination, deduping, output shape
- The CSV path always returns up to 1000 rows in one request. For any query where the last-page link in the HTML route-finder shows
page=NwithN > 20(i.e. > 1000 results), the CSV will be truncated to popularity-sorted top 1000. Document the truncation in the response:"truncated": true, "max_csv_rows": 1000. - For results > 1000, fall through to the HTML path and iterate
&page=1through&page=N(50 per page). Total wall cost: ~ceil(N/50)HTTP round-trips at ~1 s each through the residential proxy. - Routes can appear in multiple type-filters (a Trad+Sport route is matched by either
is_trad_climb=1oris_sport_climb=1); dedupe byidif you union filters client-side.
Site-Specific Gotchas
/data/get-routesand/data/get-routes-for-lat-lonare auth-gated. Cookieless calls return403 {"success":0,"message":"Invalid Key"}even with a residential-proxy session. The apiKey is only obtainable from a logged-in user profile under/data/. Don't try to use the JSON data API in a stateless agent — verified 2026-05-18 with both?routeIds=…and?key=test. The CSV export gives ≥ 90% of the same fields (no first_ascent, no vote_count, but everything else) and is unauthenticated.- Grade range filtering is per-system, not cross-system. Setting
diffMinrock=2600&diffMaxrock=5500(YDS 5.10a–5.11d) only filters whentype=rock. ThediffMin*/diffMax*selects are mutually exclusive in the UI — only the one matchingtype=is honored; others are ignored. If a caller asks for "5.10 trad OR V4 boulder," issue two separate route-finder queries and merge. type=only has 5 values —rock,boulder,aid,ice,mixed. The task brief's "trad / sport / top rope / alpine / snow / chossaneering" are NOT first-class types. Trad/sport/TR are rock sub-filters (is_trad_climb/is_sport_climb/is_top_rope); alpine/snow/chossaneering are uncategorized free-form tags on individual routes — to find them, scan theRoute Typecolumn post-fetch and substring-match onAid/Alpine/Snow/Ice.- The rock sub-filter checkboxes (
is_*) are OR-combined, not AND-combined. A route taggedTrad, TRwill appear whenis_trad_climb=1even withis_sport_climb=0,is_top_rope=0, AND whenis_top_rope=1withis_trad_climb=0. Verified 2026-05-18 with a Boulder Canyon query: "Dementia" (Trad, TR) showed up under both filters. The route-finder shows a route if ANY of its disciplines is checked. stars=is bucketed, not continuous. Only these 6 buckets are accepted:0 | 1.8 | 2.3 | 2.8 | 3.3 | 3.8(corresponding to "1+", "1.5+", "2+", "2.5+", "3+ of 4 stars"). You can't passstars=2.5— the form will silently coerce. If the caller wants a finer threshold (e.g. ≥ 3.4 stars), setstars=2.8and filter client-side using the preciseAvg Starsfrom the CSV.- No
min_votesfilter exists in the form. Filtering by vote count is purely client-side — gate on thevote_countyou parsed from each row (HTML path only — CSV doesn't include vote_count). - No "hardest first" / "easiest first" sort.
sort1=only takesarea | rating | popularity desc | title.ratingis ascending (easiest first). Appendingdesc(sort1=rating desc) is silently ignored — the result set falls through to popularity order. For "hardest first," fetch withsort1=ratingand reverse client-side. viewAll=1is a documented form param but returns500 Internal Server Errorcookieless (verified 2026-05-18 via residential-proxy fetch). It works in a real logged-in browser. Don't pass it from cookieless fetches; paginate with&page=Ninstead.- Per-page is hard-coded to 50. Verified 2026-05-18 —
pp,perPage,routesPerPage,count,limitare all silently ignored. - Pages past the last-page link return
200 OK+ empty result list. Mountain Project does not emit a 404 or error for over-paged URLs — always checkrows.length > 0before claiming the page exists. - There is no total-count number rendered anywhere on the result page. The only signal of total result count is the
page=Nvalue on the last-page anchor in the pagination block. Computetotal_count ≈ N × 50 + (rows on page N). - Star count in HTML rows is integer-rounded. A route with
Avg Stars=3.9shows 4 blue stars in the row. Always cross-check with the CSV'sAvg Starscolumn (precise float) or the JSON-LDaggregateRating.ratingValueon the detail page when precision matters. area_pathdirection is opposite between CSV and HTML. CSVLocationcolumn lists most-specific-first (Bastille > Eldorado Canyon SP > Boulder > Colorado); the HTML breadcrumb on both the result row and the detail page lists most-general-first (Colorado > Boulder > Eldorado Canyon SP > Bastille). Reverse the CSV value if you want canonical "USA → state → ... → crag" order.- The autocomplete endpoint accepts
q=only. Verified 2026-05-18:term=,search=,query=,s=all return{"categories":[],"results":[]}. The internal autocomplete returns a JSON wrapper{ "totalResults": <N>, "html": "..." }with HTML embedded — parse thehtmlfield, don't expect a clean structured list. - Suggestions response truncates to ~10 ranked entries across all sections combined (Areas + Routes + Photos + More). For a high-cardinality query like "Boulder" you'll get the top 7 Areas, 2 Routes, 1 Photo; the
totalResultsfield reports the underlying total (in the 10000s) but you cannot paginate the suggestions endpoint. For deeper search use/route-finderwith grade/type filters or visit/search?q=…directly (note:/searchis JS-rendered — its inlinedata-propsJSON exposes the configured search facets but the actual result list is loaded by JS and not visible to cookieless fetches). mountainproject.com/route-finder/<id>is NOT bookmark-stable in the way the prompt hypothesized. Mountain Project's URLs are/route-finder?<query>not/route-finder/<id>. The full filter set IS encoded in the URL (every param shown above), so any submitted route-finder URL is a complete shareable bookmark — but there's no compact numeric "filter set ID."- MP is moderate-anti-bot. A residential proxy (
browse cloud fetch --proxies) is the difference between consistent200 OKand intermittent 403s/Akamai-style blocks. Stealth (--verified) is recommended; cookieless requests through a clean residential IP have been working consistently across both iters. Keep aggregate request rate ≤ 1 / second to stay under whatever throttle MP runs. - Browser screenshots could not be captured in this sandbox. The host environment refused DNS for
connect.*.browserbase.com, blocking WebSocket-driven page rendering. All evidence in this skill comes frombrowse cloud fetch(HTTPS toapi.browserbase.com, which IS reachable) — page-driving viabrowse open/browse screenshotis not available. The included PNG screenshots are schematic illustrations of fetched HTML/JSON responses, not photographic captures; they identify selectors and param names accurately. Future runs in a sandbox with full Browserbase WS connectivity should re-capture real screenshots.
Expected Output
Top-level envelope (always returned, even on zero matches):
{
"success": true,
"total_count": 312,
"total_count_method": "last_page_link",
"page": 1,
"per_page": 50,
"applied_filters": {
"area": "Boulder Canyon",
"area_id": 105744222,
"route_type": ["sport"],
"grade_min": "5.10a",
"grade_max": "5.11d",
"grade_system": "YDS",
"min_star_rating": 2.8,
"min_votes": 20,
"pitches": "any",
"sort": "popularity",
"_raw_query": "selectedIds=105744222&type=rock&diffMinrock=2600&diffMaxrock=5500&is_trad_climb=0&is_sport_climb=1&is_top_rope=0&stars=2.8&pitches=0&sort1=popularity+desc&sort2=rating"
},
"truncated": false,
"routes": [ /* see per-route shape */ ],
"error_reasoning": null
}
Per-route shape (one entry per result):
{
"id": "105753505",
"name": "Animal Magnetism",
"url": "https://www.mountainproject.com/route/105753505/animal-magnetism",
"grade": "5.11c",
"grade_system": "YDS",
"grade_alts": { "French": "7a+", "Ewbanks": "25", "UIAA": "VIII", "ZA": "26", "British": "E5 6a" },
"type": ["Sport"],
"pitches": 1,
"length_ft": 110,
"star_rating": 3.8,
"star_rating_method": "csv_avg_stars | jsonld_aggregateRating | html_row_rounded",
"vote_count": 467,
"area_path": ["Colorado", "Boulder", "Boulder Canyon", "Cob Rock Area", "High Energy Area"],
"lat": 39.9984566,
"lng": -105.41396584,
"first_ascent": null,
"gear_notes": [],
"source": "csv"
}
Distinct outcome shapes:
// (a) Normal success
{ "success": true, "total_count": 312, "routes": [...], "truncated": false }
// (b) Truncated success (> 1000 matches; CSV cap hit and caller didn't paginate the HTML)
{ "success": true, "total_count": 4720, "routes": [/* 1000 */], "truncated": true, "max_csv_rows": 1000 }
// (c) Zero matches (impossible filter combo)
{ "success": true, "total_count": 0, "routes": [], "applied_filters": {...} }
// (d) Ambiguous area resolution (caller passed a name with multiple ID matches and didn't disambiguate)
{ "success": false, "reason": "ambiguous_area", "matches": [
{ "area_id": 105744222, "name": "Boulder Canyon", "state": "Colorado" },
{ "area_id": 118874741, "name": "Boulder Canyon", "state": "Utah" },
{ "area_id": 106389009, "name": "Boulder Canyon", "state": "Arizona" }
] }
// (e) Area not found
{ "success": false, "reason": "area_not_found", "query": "Some Made Up Crag" }
// (f) Filter requested an unsupported route type (alpine, snow, chossaneering, or trad+boulder combo)
{ "success": false, "reason": "unsupported_filter",
"detail": "Route type 'alpine' is not a first-class route-finder type. Mountain Project only filters by rock|boulder|aid|ice|mixed; alpine routes are free-form tagged on individual routes and require a post-fetch substring match." }
// (g) Anti-bot wall (rare with residential proxy)
{ "success": false, "reason": "blocked", "status_code": 403, "detail": "..." }