mountainproject.com

search-routes

Installation

Adds this website's skill for your agents

browse skills add mountainproject.com/search-routes-romkbp
Summary

Search mountainproject.com for climbing routes with the full route-finder filter surface (area, grade range across YDS/V/WI/AI/M/Aid, route type, pitches, stars, sort, pagination), returning structured per-route data including id, grade, type, pitches, length, star rating, vote count, area breadcrumb, lat/lng, and first ascent.

FIG. 01
FIG. 02
FIG. 03
FIG. 04
FIG. 05
FIG. 06
SKILL.md
360 lines

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 html field 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 systemtype=diffMin* nameSample values (text → rank)
YDS rockrockdiffMinrock / diffMaxrock5.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.
BoulderingboulderdiffMinboulder / diffMaxboulderV0→20000, V4→20350, V10→20950, V17→21650 (min); add 50 for max (V0 max=20050, V17 max=21700)
Water iceicediffMinice / diffMaxiceWI1→30000, WI4→32500, AI1→38000, AI6→38500
MixedmixeddiffMinmixed / diffMaxmixedM1→50000, M5→53500, M16→64900
AidaiddiffMinaid / diffMaxaidA0/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 id from the URL column: /route/(\d+)/.
  • Parse area_path by splitting Location on > (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.
  • Length is in feet (numeric, blank for unknown). Pitches is integer (blank for unknown).
  • Avg Stars is the precise float (0.0–4.0). Your Stars is -1 cookieless (it's the requesting user's vote — useless without auth).
  • Route Type can be a comma-separated multi-discipline string ("Trad, Sport", "Sport, TR"). Split on , then trim.
  • Rating is the grade text (5.11d, V4, WI4). Infer grade_system from 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=2 returns 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">&nbsp;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> &gt; ... &gt;
      <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'> (or rateV, 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 of starBlue.svg images. Add 0.5 per starHalfBlue.svg. Note this is rounded (Avg Stars=3.9 renders 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">&nbsp;(\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 trailing N pitches text 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 &gt;. 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> pitches for multi-pitch, or <length> ft (<m> m) only for single-pitch. Trailing pipe-separated tags (Fixed Hardware (2)) are gear notes — capture as a separate gear_notes field if the caller wants them.
  • FA: (first ascent) is free-form text — preserve as-is. May include FFA: (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=N with N > 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=1 through &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=1 or is_sport_climb=1); dedupe by id if you union filters client-side.

Site-Specific Gotchas

  • /data/get-routes and /data/get-routes-for-lat-lon are auth-gated. Cookieless calls return 403 {"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 when type=rock. The diffMin* / diffMax* selects are mutually exclusive in the UI — only the one matching type= 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 valuesrock, 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 the Route Type column post-fetch and substring-match on Aid/Alpine/Snow/Ice.
  • The rock sub-filter checkboxes (is_*) are OR-combined, not AND-combined. A route tagged Trad, TR will appear when is_trad_climb=1 even with is_sport_climb=0,is_top_rope=0, AND when is_top_rope=1 with is_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 pass stars=2.5 — the form will silently coerce. If the caller wants a finer threshold (e.g. ≥ 3.4 stars), set stars=2.8 and filter client-side using the precise Avg Stars from the CSV.
  • No min_votes filter exists in the form. Filtering by vote count is purely client-side — gate on the vote_count you parsed from each row (HTML path only — CSV doesn't include vote_count).
  • No "hardest first" / "easiest first" sort. sort1= only takes area | rating | popularity desc | title. rating is ascending (easiest first). Appending desc (sort1=rating desc) is silently ignored — the result set falls through to popularity order. For "hardest first," fetch with sort1=rating and reverse client-side.
  • viewAll=1 is a documented form param but returns 500 Internal Server Error cookieless (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=N instead.
  • Per-page is hard-coded to 50. Verified 2026-05-18 — pp, perPage, routesPerPage, count, limit are 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 check rows.length > 0 before 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=N value on the last-page anchor in the pagination block. Compute total_count ≈ N × 50 + (rows on page N).
  • Star count in HTML rows is integer-rounded. A route with Avg Stars=3.9 shows 4 blue stars in the row. Always cross-check with the CSV's Avg Stars column (precise float) or the JSON-LD aggregateRating.ratingValue on the detail page when precision matters.
  • area_path direction is opposite between CSV and HTML. CSV Location column 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 the html field, 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 totalResults field reports the underlying total (in the 10000s) but you cannot paginate the suggestions endpoint. For deeper search use /route-finder with grade/type filters or visit /search?q=… directly (note: /search is JS-rendered — its inline data-props JSON 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 consistent 200 OK and 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 from browse cloud fetch (HTTPS to api.browserbase.com, which IS reachable) — page-driving via browse open/browse screenshot is 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": "..." }
Mountain Project Route Search · browse.sh