Given a Cruise Critic ship page (URL, or cruise line + ship name resolved via search), extract ship metadata plus a filtered slice of member reviews as structured JSON, leading with a Browserbase stealth session and using the per-review Next.js data JSON endpoint as a per-review enrichment shortcut.
Automate repetitive workflows and reduce manual effort
Example: Generate reports, summarize documents, draft communications
Save 3-5 hours per week on routine tasks
Learn new skills, understand complex topics, get expert guidance
Example: Explain concepts, provide examples, suggest learning resources
Accelerate learning and skill development by 2x
Enhance output quality through reviews, suggestions, and refinements
Example: Review drafts, suggest improvements, catch errors
Improve work quality by 30-40% with less effort
Time estimate: 15-45 minutes depending on use case complexity
Prerequisites
Submit your Claude Code skill and start earning
browse install cruisecritic.com/extract-reviews-8r7ocjCompatible with
Supported agents
| name | extract-reviews |
| title | Cruise Critic Extract Reviews |
| description | >- Given a Cruise Critic ship page (URL, or cruise line + ship name resolved via search), extract ship metadata plus a filtered slice of member reviews as structured JSON, leading with a Browserbase stealth session and using the per-review Next.js data JSON endpoint as a per-review enrichment shortcut. |
| website | cruisecritic.com |
| category | travel |
| tags | - cruises - reviews - travel - datadome - stealth - next-js |
| source | 'browserbase: agent-runtime 2026-05-18' |
| updated | '2026-05-18' |
| recommended_method | hybrid |
| alternative_methods | - method: browser rationale: >- Browserbase stealth + residential-proxy session is the only reliable way to load the >1 MB SSR list page and to apply rating / traveler-type / cabin / sailed-within / sort filters (all React UI state — no URL form exists). - method: api rationale: >- Per-review `_next/data/{buildId}/cruise/{line}/{ship}/reviews/{review-id}.json` returns the full review payload as ~150 kB JSON, well under the Browserbase Fetch 1 MB cap. Used in hybrid mode for per-review enrichment after review IDs are scraped from the list page. The equivalent list-page `_next/data` endpoint is DataDome-blocked (403). - method: url-param rationale: >- `/cruise/{line}/{ship}/reviews/destination/{slug}` is the only URL-form filter that works — all other querystring/path filter variants are silently ignored or return 404. |
| verified | true |
| proxies | true |
Given a Cruise Critic ship page (a https://www.cruisecritic.com/cruise/{cruise-line-slug}/{ship-slug}/reviews URL, or a cruise line + ship name pair resolved via Browserbase Search), extract ship-level metadata (name, line, year built, passenger capacity, crew, overall rating, total review count, per-category averages) plus a filtered slice of member reviews each with {review_id, reviewer_username, reviewer_traveler_type, sailed_date, cruise_length_nights, destination, cabin_type_booked, overall_rating, sub_ratings: {...}, title, body_text, helpful_vote_count, review_url} and any cruise-line response. Read-only — never click Write a Review, Sign In, helpful-vote, or report-review controls.
Cruise Critic is a Next.js (Apollo Client) SSR site protected by DataDome (X-Datadome: protected on every response). The reviews-list page is server-rendered with the full visible review payload baked into the HTML (typically 800 kB – >1 MB), so the public browse cloud fetch path can return 502 The response body exceeded the maximum allowed size of 1 MB for the list, and DataDome will start serving captcha-challenge HTML after roughly 5–8 unauthenticated browse cloud fetch calls from the same source IP. Lead with a Browserbase session with stealth and residential proxies enabled. Two undocumented shortcuts that hold up under stealth dramatically cut cost — both are documented below as optimizations.
_next/data enrichmentStart a stealthed session.
sid=$(browse cloud sessions create --keep-alive --proxies --verified | jq -r .id)
export BROWSE_SESSION="$sid"
--verified (advanced stealth) plus --proxies (residential) is mandatory. A bare session is served a DataDome interstitial on the first navigation to /cruise/.../reviews.
Resolve the ship URL if the caller gave you a line + ship name instead of a URL:
# Cheap, no session needed
browse cloud search "site:cruisecritic.com $LINE $SHIP reviews" \
| jq -r '.results[] | select(.url | test("/cruise/[^/]+/[^/]+/reviews$")) | .url' \
| head -1
The canonical reviews URL is always https://www.cruisecritic.com/cruise/{cruise-line-slug}/{ship-slug}/reviews. Both slugs are kebab-case (royal-caribbean, symphony-of-the-seas, norwegian-cruise-line, viking-jupiter, …).
Optionally narrow by destination via URL path. Only destination is a URL-form filter; all other filters are React UI state.
/cruise/{line}/{ship}/reviews/destination/{destination-slug}
Verified working destination slugs: usa, caribbean, eastern-caribbean, western-caribbean, southern-caribbean, bahamas, mediterranean, europe, alaska, asia. The site canonicalises lower-case kebab; unknown slugs 404.
Open the list page with the session and capture both the rendered HTML and the Next.js data.
browse open "https://www.cruisecritic.com/cruise/$LINE_SLUG/$SHIP_SLUG/reviews${DEST_PATH:+/destination/$DEST_PATH}" \
--remote --session "$sid" --wait load --timeout 60000
browse wait timeout 2500 --remote --session "$sid" # Apollo hydration settles
browse get html body --remote --session "$sid" > /tmp/list.html
Extract the __NEXT_DATA__ blob — it carries everything you need without any further DOM scraping:
node -e '
const html = require("fs").readFileSync("/tmp/list.html", "utf8");
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
const j = JSON.parse(m[1]);
require("fs").writeFileSync("/tmp/list-next.json", JSON.stringify(j, null, 2));
'
j.props.pageProps.apolloState is the Apollo cache. Key entries on a list page:
Ships:{shipId} — ship core (name, seoName, slug, professionalOverallRating, totalShoreExcursions, cruiseLine.slug, reviewStatus).ShipAttributes:{attrId} — { passengerCapacity, totalCrew, maidenDate } (year built). Linked from Ships:{id}.attributes.__ref.ROOT_QUERY.searchReviewsWithFilters({"filters":{"isPhotoJournal":false,"shipId":[{id}]},"limit":N}) → { totalResults, stats: { averageMemberRating } }.Reviews:{reviewId} for every visible review in the current filter/sort/page bucket. Each Reviews:{id} carries { id, cruisedOn, hasChildren, withDisabled, numberOfCruisesTakenGroupId, cabinCategory, user.__ref, entries: [ReviewEntries refs] }.ReviewEntries:{entryId} — { reviewCategory, rating } for one sub-category.SsoUser:{userKey} — { username } for the reviewer.Pull review IDs with one expression:
node -e '
const j = require("/tmp/list-next.json");
const a = j.props.pageProps.apolloState;
console.log(JSON.stringify(Object.keys(a)
.filter(k => k.startsWith("Reviews:"))
.map(k => a[k].id)));
'
Apply rating / traveler-type / cabin / sailed-within / sort / language filters in-browser.
These are not URL filters. They render as a row of pills + dropdowns above the listing. The shape is stable: each filter is a button with aria-haspopup="listbox" and an accessible name like "Rating: Any", "Traveler Type: Any", "Cabin Type: Any", "Sailed Within: Any", "Sort By: Most Helpful". Pattern for each filter:
browse snapshot --remote --session "$sid"
# In the snapshot, find the button by its accessible name then the corresponding listbox option.
browse click "@<button-ref>" --remote --session "$sid"
browse wait timeout 500 --remote --session "$sid"
browse click "@<option-ref>" --remote --session "$sid"
browse wait timeout 1500 --remote --session "$sid" # Apollo refetch
After each filter change, the URL stays the same but __NEXT_DATA__ is regenerated on the next page load — to refresh it from React state, re-snapshot the body HTML or use browse eval against window.__APOLLO_STATE__ (Apollo writes the latest cache there if exposed; otherwise re-read the page via browse get html body). The simpler, cheaper alternative for non-destination filters is to fetch all visible reviews first and filter client-side from cruisedOn / entries / numberOfCruisesTakenGroupId — the data is denser than the UI exposes (e.g., hasChildren/withDisabled flags let you reconstruct the Family / Disabled traveler-type filter without a click).
Paginate. The site paginates ~10 reviews per page via an infinite-scroll / "Load more" pattern. Trigger more reviews to render:
# repeat until totalResults reached or you have enough
browse press End --remote --session "$sid"
browse wait timeout 1500 --remote --session "$sid"
# OR click the explicit "Load more reviews" button if present in the snapshot
Each load merges new Reviews:{id} entries into the Apollo cache. Re-extract __NEXT_DATA__ or re-snapshot to capture the growing set.
Enrich each visible review with its full body. The list-page payload contains review sub-rating entries but does not include the review body text or title — that lives only on the per-review page. Two paths, in cost order:
(Cheap, preferred) _next/data JSON endpoint — fetches the per-review SSR props as ~150 kB JSON, well under the fetch 1 MB cap, and DataDome currently allows it through Browserbase Fetch when the request rides residential proxies. The build ID is on every page (grep -oE '"buildId":"[^"]+"' /tmp/list.html | head -1). Example:
GET https://www.cruisecritic.com/_next/data/{buildId}/cruise/{line}/{ship}/reviews/{reviewId}.json
?cruise-line-slug={line}&cruise-ship-slug={ship}&review-id={reviewId}
Response shape (verified): pageProps.review with { id, title, shipReview (the body text, ~9 kB typical), cruisedOn, overallRating, helpfulVotes, cabinCategory, destination: {id, slug, seoName}, user: {username}, hasChildren, withDisabled, numberOfCruisesTakenGroupId, entries: [{reviewCategory, rating}], comments (cruise-line response, when present), images, nextReview: {id}, previousReview: {id} }. Pace requests to ≤ 1 req/sec with brief jitter; DataDome served captcha HTML after roughly 5 back-to-back unauthenticated bursts in testing.
(Fallback) Same browse open ... /reviews/{id} --remote --session "$sid" flow inside the active session. ~5–8× more wall time per review than the _next/data path but immune to per-IP fetch throttling because the session traffic shares stealth + residential proxy state.
(Walk-the-chain optimisation) When the caller doesn't need filters, you can skip the list-page entirely. The _next/data JSON for any review contains nextReview.id and previousReview.id for adjacent reviews in the site's default ordering — walk the chain in either direction until enough reviews are collected. This eliminates the >1 MB list-page load and the entire pagination loop. The chain order is approximately reverse-chronological but is not strictly sorted; verify by cruisedOn if you need date ordering.
Map sub-category labels. entries[].reviewCategory uses internal camelCase keys. Translate to the user-facing labels in your output:
reviewCategory (API) | UI label |
|---|---|
cabin | Cabins |
dining | Dining |
entertainment | Entertainment |
publicRooms | Public Rooms |
fitnessAndRecreation | Fitness & Recreation |
family | Family |
shoreExcursion | Shore Excursion |
embarkation | Embarkation |
service | Service |
valueForMoney | Value for Money |
Not every reviewer scores every category; absent entries are simply omitted from entries[]. Treat missing categories as null, not 0.
Derive reviewer_traveler_type. The site shows it as a badge but the data is split across three fields on Reviews:{id}:
hasChildren: true → FamilywithDisabled: true → DisabledapolloState on the list page — it is reliably present in the per-review _next/data JSON under a sibling field. If you need the full string for every review, source it from the per-review fetch in step 7.Build review URLs: https://www.cruisecritic.com/cruise/{cruise-line-slug}/{ship-slug}/reviews/{review-id}.
Release the session.
browse cloud sessions update "$sid" --status REQUEST_RELEASE
_next/data)If DataDome starts blocking _next/data JSON (it can happen on a hot residential exit IP), do all enrichment through browse open ... --remote --session "$sid" page loads inside the same stealthed session. Extract the review body from the rendered DOM via the JSON-LD <script type="application/ld+json"> block — the Product schema's review[0] contains name, datePublished, reviewBody (truncated to ~200 chars), author.name, reviewRating.ratingValue — and supplement the truncated body with browse get text body filtered to the main <article> selector.
X-Datadome: protected appears on all responses; X-Datadome-Isbot: false on the first few from a fresh proxy IP, then captcha HTML (<html lang="en"><head><title>cruisecritic.com</title>…geo.captcha-delivery.com…) once the IP gets flagged. browse cloud sessions create --proxies --verified is mandatory. Avoid sustained fan-out via browse cloud fetch — keep per-review enrichment inside the active stealth session, or pace _next/data fetches to ≤ 1 req/sec with jitter.browse cloud fetch returns 502 The response body exceeded the maximum allowed size of 1 MB for /cruise/.../reviews and any /cruise/.../reviews/destination/{slug} page (verified on usa, caribbean, mediterranean, alaska, bahamas, europe, asia — all > 1 MB). Always use a browser session for the list. The per-review _next/data JSON is ~150 kB and well under the cap._next/data JSON list path is DataDome-blocked. GET /_next/data/{buildId}/cruise/{line}/{ship}/reviews/destination/{slug}.json returns HTTP 403 with the DataDome challenge cookie, whereas the per-review variant GET /_next/data/{buildId}/cruise/{line}/{ship}/reviews/{review-id}.json?...&review-id={id} returns 200 on the same session and IP. Don't chase the list-page JSON endpoint — it's not a viable shortcut./graphql route is not exposed in any JS bundle reachable via browse cloud fetch (chunk inventory inspected: framework, main, webpack, per-page chunks — no graphql URL strings). The _app.js bundle exceeds 1 MB and cannot be inspected from this path. Don't waste iterations hunting a direct GraphQL POST endpoint — the _next/data JSON return is functionally equivalent and authenticates the same way the page does (no API key, just DataDome cookie).destination is a URL-form filter. Verified 404 for /reviews/rating/{N}, /reviews/traveler-type/{slug}, /reviews/cabin-type/{slug}, /reviews/sailed-within/{window}, /reviews/sort/{key}, /reviews/language/{lang}, and /reviews/page/{N}. Querystring forms (?rating=5, ?page=2, ?sortBy=mostRecent, ?travelerType=family) are silently ignored and return the unfiltered listing. All non-destination filters require clicking the React UI inside a session.?page=N and /page/{N} both fall through to the unfiltered first page. Trigger additional reviews by pressing End or clicking the explicit "Load more reviews" control in the snapshot. ~10 reviews load per increment.entries[].reviewCategory uses valueForMoney, publicRooms, fitnessAndRecreation, shoreExcursion — translate to the user-facing labels in your output mapping. Not every reviewer scores every category; missing → null, not 0.destination on a review is the itinerary destination, not the home port. It is an object { id, slug, seoName } (e.g., {slug:"eastern-caribbean", seoName:"the Eastern Caribbean"}). The departure port lives separately on DeparturePorts:{id} under Ships:{id}.departurePorts({"countryId":1}).cabinCategory is frequently null. Many reviews don't pin a cabin type; if the caller wants a Cabin filter, fall back to ReviewCabinPivots:{id} references on the review (when present, they carry {cabinType, deck, room} granularity).numberOfCruisesTakenGroupId is a bucketed-experience integer (1 = first cruise, larger = more experienced). Site renders this as a badge ("First time cruiser", "Experienced", etc.) but the mapping table is internal — emit the integer and let the consumer interpret, or hardcode the observed mapping (1=first, 2=novice, 3=intermediate, 4+=experienced) with a _unverified flag.nextReview / previousReview chains are not strictly chronological. They walk the site's default ordering, which is similar-but-not-identical to "Most Helpful". For strict date-window filtering, paginate via the list page and sort by cruisedOn client-side rather than walking the chain past your date boundary.review.comments. When the cruise line responded to a review, pageProps.review.comments is a non-null object { comment, user: {userName, title} }. Empty otherwise. Worth including in Expected Output because some downstream use-cases ask for it explicitly.shipReview is HTML-escaped plain text with \r\n newlines. Decode ", &, etc., before emitting. There is no rich-text markup.dpl_EoXom4Tk8881A4KbrwTMYtbTjHKs / build-TfctsWXpff2fKS were live during this skill's authoring. Always extract the current buildId from the page HTML before constructing _next/data URLs — a stale build ID 404s.robots.txt (User-agent: GPTBot|ClaudeBot|Google-Extended|Cohere-ai|CCBot|...), but Disallow rules under User-agent: * cover /search, /feeds, /member-center, /storyblok/, etc. — not /cruise/.../reviews. The review pages themselves are publicly indexable; the AI-crawler block is a policy signal rather than a per-route enforcement and DataDome operates regardless of user agent. Set a realistic browser UA on your session (Browserbase stealth does this by default).Reviews:{id} (the review_id in URLs) and ReviewEntries:{id} (per-subcategory rating rows). Don't conflate them. The review URL is built with the Reviews: id; the ReviewEntries: ids never appear in a URL.mra legacy path ({port}-{line}-{ship}-{destination}-cruises_dp{N}-cl{N}-sh{N}-de{N}/mra) 308-redirects to /cruise/{line}/{ship}/reviews/destination/{slug}. Don't try to use it directly — follow the redirect and treat the new-shape URL as canonical.Write a Review, Sign In, Helpful / vote controls, or Report Review. The first two start auth flows; the latter two mutate state and are disallowed by the task contract.Two shapes — success with payload, and error with reason. The skill emits success even when the filter window returns zero reviews (the empty array carries the same provenance + ship metadata as a populated one).
{
"success": true,
"ship": {
"ship_id": 984,
"name": "Symphony of the Seas",
"cruise_line": "Royal Caribbean International",
"cruise_line_slug": "royal-caribbean",
"ship_slug": "symphony-of-the-seas",
"year_built": "2018",
"year_refurbished": null,
"gross_tonnage": null,
"passenger_capacity": 5518,
"total_crew": 2200,
"length_meters": null,
"decks": null,
"professional_overall_rating": 4.50,
"member_overall_rating": 3.78,
"total_member_reviews": 463,
"rating_breakdown": {
"Cabins": null,
"Dining": null,
"Entertainment": null,
"Public Rooms": null,
"Fitness & Recreation": null,
"Family": null,
"Shore Excursion": null,
"Embarkation": null,
"Service": null,
"Value for Money": null
},
"url": "https://www.cruisecritic.com/cruise/royal-caribbean/symphony-of-the-seas/reviews"
},
"filters_applied": {
"min_rating": null,
"traveler_type": null,
"sailed_within": null,
"sailed_date_range": null,
"destination": "eastern-caribbean",
"cabin_type": null,
"sort": "Most Helpful",
"language": "en"
},
"total_results_matching_filters": 187,
"reviews_returned": 2,
"reviews": [
{
"review_id": 727851,
"review_url": "https://www.cruisecritic.com/cruise/royal-caribbean/symphony-of-the-seas/reviews/727851",
"reviewer_username": "steveknj",
"reviewer_traveler_type": "Couple",
"reviewer_experience_bucket": 3,
"sailed_date": "2025-04-30",
"cruise_length_nights": null,
"destination": {
"slug": "eastern-caribbean",
"label": "the Eastern Caribbean"
},
"cabin_type_booked": null,
"overall_rating": 5,
"sub_ratings": {
"Cabins": 4,
"Dining": 5,
"Entertainment": 4,
"Public Rooms": 5,
"Family": 5,
"Embarkation": 5,
"Service": 5,
"Value for Money": 5
},
"title": "Symphony of the Seas - 4/30/2025",
"body_text": "I wanted to preface this to say that this is NOT an extensive review… (full ~9000-character body)",
"pros": null,
"cons": null,
"tip_for_future_cruisers": null,
"helpful_vote_count": 2,
"images": [],
"cruise_line_response": null
},
{
"review_id": 738724,
"review_url": "https://www.cruisecritic.com/cruise/royal-caribbean/symphony-of-the-seas/reviews/738724",
"reviewer_username": "anonymous",
"reviewer_traveler_type": "Couple",
"reviewer_experience_bucket": 2,
"sailed_date": "2026-03-01",
"cruise_length_nights": null,
"destination": {
"slug": "eastern-caribbean",
"label": "the Eastern Caribbean"
},
"cabin_type_booked": null,
"overall_rating": 1,
"sub_ratings": {
"Cabins": 1,
"Dining": 1,
"Embarkation": 4,
"Entertainment": 3,
"Fitness & Recreation": 2,
"Public Rooms": 2,
"Service": 4,
"Value for Money": 1
},
"title": "Symphony is overcrowded, and kids gone wild",
"body_text": "…(full body)",
"pros": null,
"cons": null,
"tip_for_future_cruisers": null,
"helpful_vote_count": 0,
"images": [],
"cruise_line_response": {
"by": "Royal Caribbean Guest Services",
"comment": "We're sorry to hear about your experience…"
}
}
],
"evidence": {
"list_url_loaded": "https://www.cruisecritic.com/cruise/royal-caribbean/symphony-of-the-seas/reviews/destination/eastern-caribbean",
"build_id": "build-TfctsWXpff2fKS",
"session_id": "<browserbase-session-id>",
"fetched_at": "2026-05-18T18:35:00Z"
}
}
Error shapes:
// Ship not foun
AI-first code editor with Composer
Before installing skills in Cursor, ensure your development environment meets these requirements:
node --versionextract-reviewsExecute the skills CLI command in your project's root directory to begin installation:
browse install cruisecritic.com/extract-reviews-8r7ocjFetches extract-reviews from cruisecritic.com/extract-reviews-8r7ocj and configures it for Cursor.
The CLI shows a list of agents. Use arrow keys and space to select Cursor:
Confirm successful installation by checking the skill directory location:
.cursor/skills/extract-reviews
Restart Cursor to activate extract-reviews. Access via /extract-reviewsin your agent's command palette.
Learn more:View source on GitHubSkills CLI docsAbout CursorWhat are agent skills?
Steps
Common Pitfalls
✓ Do
✗ Don't
💡 Pro Tips
✓ Use when
Use when skill capabilities match your task, clear ROI on time saved, and you can validate outputs. Best for repetitive tasks, learning, and quality improvement.
✗ Avoid when
Avoid when task requires deep expertise you can't validate, involves sensitive decisions, or when learning process is more valuable than speed of completion.
search-cheapest-flight
skyscanner.net/search-cheapest-flight-v8nvut · travel · tag: travel
search-flights
southwest.com/search-flights-jl329m · travel · tag: travel
search-flights
google.com/search-flights-ts4g1f · travel · tag: travel
search-tickets
americanairlines.com/search-tickets-yj09ak · travel · tag: travel
browse-campsites
freecampsites.net/browse-campsites-qyj28m · travel · tag: travel
search-flights
united.com/search-flights-xs5157 · travel · tag: travel
Keeps context tight: extract-reviews is the kind of skill you can hand to a new teammate without a long onboarding doc.
extract-reviews has been reliable in day-to-day use. Documentation quality is above average for community skills.
extract-reviews is among the better-maintained entries we tried; worth keeping pinned for repeat workflows.
extract-reviews has been reliable in day-to-day use. Documentation quality is above average for community skills.
Keeps context tight: extract-reviews is the kind of skill you can hand to a new teammate without a long onboarding doc.
We added extract-reviews from the explainx registry; install was straightforward and the SKILL.md answered most questions upfront.
Solid pick for teams standardizing on skills: extract-reviews is focused, and the summary matches what you get after install.
extract-reviews has been reliable in day-to-day use. Documentation quality is above average for community skills.
Solid pick for teams standardizing on skills: extract-reviews is focused, and the summary matches what you get after install.
extract-reviews fits our agent workflows well — practical, well scoped, and easy to wire into existing repos.
showing 1-10 of 33