Help us improve
Share bugs, ideas, or general feedback.
From trip-planner
Build travel itineraries from Aviasales and Ostrovok — extract flight/hotel data and compile a self-contained HTML with XLSX/PDF export and optional Vercel deploy. Triggers on avs.io / aviasales.ru / corp.ostrovok.ru links (even pasted without explanation), on Russian travel terms (отпуск, поездка, перелёт, отель, бронирование, маршрут), and on planning a trip with dates/destinations. Also remembers previously planned trips across sessions in ~/.trip-planner/ (recall past trips first, record each when done), so it triggers on 'какие поездки я уже планировал', 'покажи прошлые маршруты', 'what trips have I planned'.
npx claudepluginhub kyzdes/claude-skills --plugin trip-plannerHow this skill is triggered — by the user, by Claude, or both
Slash command
/trip-planner:trip-plannerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Build travel itineraries from Aviasales and Ostrovok. The job: take raw input (URLs, free-text, or screenshots), open the sites in a real browser, pull structured data, sanity-check logistics, and produce one self-contained HTML file the user can share, print, or deploy.
Guides Payload CMS config (payload.config.ts), collections, fields, hooks, access control, APIs. Debugs validation errors, security, relationships, queries, transactions, hook behavior.
Implements vector databases with Pinecone, Weaviate, Qdrant, Milvus, pgvector for semantic search, RAG, recommendations, and similarity systems. Optimizes embeddings, indexing, and hybrid search.
Share bugs, ideas, or general feedback.
Build travel itineraries from Aviasales and Ostrovok. The job: take raw input (URLs, free-text, or screenshots), open the sites in a real browser, pull structured data, sanity-check logistics, and produce one self-contained HTML file the user can share, print, or deploy.
avs.io/*, aviasales.ru/*, or corp.ostrovok.ru/* links (even without text)отпуск, поездка, перелёт, отель, бронирование, маршрут, обнови таблицу, добавь рейс, проверь ценыRecall → Load past-trip memory (run before Step 0; see "Recall" below)
Step 0 → Browser readiness check (do NOT skip)
Step 1 → Parse user input (URLs, text, screenshots)
Step 2 → Extract flight data (Aviasales, two-phase)
Step 3 → Extract hotel data (Ostrovok, browser-only)
Step 4 → Add transfers (see references/transfers.md)
Step 5 → Run logistics checks
Step 6 → Generate self-contained HTML (use assets/template.html)
Step 6.5→ Optional: Python export (XLSX + PDF) via scripts/export_trip.py
Step 7 → Present results to user
Step 8 → Optional: deploy to Vercel
Step 9 → Record the trip to memory (scripts/trip_registry.py)
Use mcp__claude-in-chrome__browser_batch whenever the next 2+ steps are independent navigates/JS-calls. Batched round-trips are ~3–5× faster than sequential.
This skill has a persistent memory of every trip it has planned. Before the browser check — recall has no browser dependency, and it answers pure history questions on its own — read the trip registry:
# honours $TRIP_PLANNER_HOME; defaults to ~/.trip-planner
cat "${TRIP_PLANNER_HOME:-$HOME/.trip-planner}/trips.json" 2>/dev/null || echo "no trips yet"
What to do with it:
html_path, re-run the relevant steps) instead of starting from scratch.status: planned (default), booked (committed — be careful suggesting changes), or archived (past/closed — de-emphasise; don't surface unless asked). Set it with trip_registry.py status --id <id> --set booked|archived. Archived trips sort last in the registry.If the file doesn't exist, there's no history yet — proceed silently to Step 0. The store is shared across all agents and sessions and persists on disk (see Memory store below).
Once you've recalled past trips (above) and the task needs scraping, this is the first browser action — call mcp__claude-in-chrome__tabs_context_mcp with createIfEmpty: true before any navigate/extract. This serves three purposes:
tabId for the rest of the session. Reuse it; never invent IDs.Why this matters: the MCP can disconnect silently during long sessions, and every downstream tool call will fail until reconnected. Catching it at Step 0 saves 20 retries.
Scan the user's message for:
avs.io/*) — 301-redirect to aviasales.ru/search/*. The redirect URL itself is signal: expected_price, expected_price_currency, route code in path (e.g., MOW2706IST2), airline code in t= param (first 2 chars: SU=Aeroflot, TK=Turkish, WY=Oman Air).corp.ostrovok.ru/hotel/*) — dates and guest count are in URL params.If the user describes a route in free text ("ищи Москва → Стамбул 27 июня на двоих"), build the search URL yourself:
https://www.aviasales.ru/search/{ORIGIN}{DDMM}{DESTINATION}{N_PAX}
ORIGIN/DESTINATION — IATA city or airport code (MOW, LED, IST, ZNZ, JRO)DDMM — departure date, day-month with leading zeros (2706 for 27 June)N_PAX — adult passenger count (2)Examples: MOW2706IST2, IST2906NAV2, MOW0309ZNZ2, DLM0607MOW2.
For round-trips Aviasales also accepts {ORIGIN}{DDMM}{DESTINATION}{DDMM_BACK}{N_PAX}, but for multi-city use separate one-way searches — it's simpler and the prices line up better.
If the user names a city without a URL, navigate to:
https://ostrovok.ru/hotel/{country-slug}/{city-slug}/?dates=DD.MM.YYYY-DD.MM.YYYY&guests=N
Examples: /hotel/turkey/istanbul/, /hotel/tanzania/arusha/, /hotel/uae/dubai/. From the result list, extract candidates with a[href*="/hotel/{country-slug}/"] and pick by rating + reviews + price (see Hotel search extractor below).
Island/multi-town destinations — Ostrovok uses the main town's slug, not the island name. If a tourist label (santorini, mykonos, bali) returns a 404, try the main town slug instead: Santorini → thira, Mykonos → mykonos_town, Bali → denpasar or ubud. The page title will confirm you landed on the right city ("Отели в Фире…" for Thira).
Aviasales is an SPA. WebFetch only returns shell HTML. Two phases:
WebFetch on the avs.io link returns a 301 with the full aviasales.ru URL. Parse expected_price and route info from URL params — this gives you a price hint before opening a browser. Compare it later with the real ticket price; if they diverge by >15%, mention it to the user.
Decode the t= param for segment times + layovers (no browser). The redirect URL's t= string encodes every segment ([airline][dep unix][arr unix][flight][orig][dest]). Run the bundled decoder to get departure/arrival times and connection durations for multi-leg flights without opening the page:
python3 "${CLAUDE_SKILL_DIR:-skills/trip-planner}/scripts/parse_tstring.py" "<aviasales URL or t-string>"
It returns segments (UTC times, duration) and layovers (is_layover = gap < 24h at a shared airport — distinguishes a real connection from a round-trip's two directions). Use it to fill layover durations in the table without a browser round-trip.
mcp__claude-in-chrome__navigate (batch with the next JS-extract via browser_batch if you can).setTimeout Promise — Aviasales hydrates results progressively.If the user asks for a specific airline ("только Oman Air", "Turkish Airlines"), don't trust the "Оптимальный" label. Verify by reading airline logo alt text:
const airlines = [...document.querySelectorAll('img[alt]')]
.map(i => i.alt).filter(a => a && a.length < 30 && /^[A-ZА-Я]/.test(a));
[...new Set(airlines)];
Two-letter airline codes (WY = Oman Air, SU = Aeroflot, TK = Turkish, EK = Emirates, EY = Etihad, QR = Qatar) appear in the t= URL param and img.avs.io/pics/al_square/{CODE}@... image sources.
Ostrovok is also an SPA. WebFetch returns only config JS — never use it. Browser only.
document.title !== 'Загрузка отеля...' — the page loads in two phases (shell first, then room data). 5–7 seconds is the safe wait. Scraping earlier gives zero rooms.Insert transfer rows between airports and hotels. Reference times live in references/transfers.md — read only the region(s) you need. The table covers Turkey, Tanzania, Greece, Egypt, UAE.
If the trip is in a country not covered, estimate from public sources (Google Maps, hotel website) and explicitly tell the user you used a rough estimate.
Flag these in the output:
23 kg × 1 on a 2-passenger booking = one bag for two people. Always flag.Save to ~/Desktop/trip_[destination]_[dates].html. The file must be self-contained — inline CSS, no external stylesheets, two export buttons (XLSX, PDF), data inline.
Reference template: assets/template.html in this skill. Copy its structure verbatim, then fill in trip-specific data. Don't redesign the CSS from scratch each time.
trip-data JSON blockThe template is data-driven. Edit only the <script id="trip-data" type="application/json"> block — the route table, the summary card, the notes list, and both export buttons (XLSX, PDF) are all rendered from it by the page's JS. Do not hand-write <tr> rows or duplicate values into the XLSX/PDF functions; that's the old 4-section duplication that caused drift (KI-11). Change a date once, in the JSON, and every view updates.
JSON shape (see the template for the full example):
meta — title, h1, destination, subtitle, updated, xlsxFile, pdfTitle, pdfH1, pdfSubtitle, pdfNotes[].rows[] — one object per itinerary line: type (flight/hotel/transfer), date, dateNote, title, sub, time, timeNote, details, detailsNote, rating ({ta, taReviews, taReviewsNum, ostrovok, taUrl} or omit), price, priceNum, links[] ({label, url}), x{} (XLSX-only columns: date, route, duration, operator, klass, meal, cancel, baggage), and PDF helpers day (header on the first row of a day), pdfTitle, pdfTime, pdfDetails.summary[] — {value, label} cards (add rub as a number to make a card convertible by the currency toggle). totals — {flights, hotels, total} numbers (XLSX subtotal rows). Keep priceNums consistent with totals (they should sum up).notes[] — HTML strings.Optional compare / currency fields (all off by default — omit them and the output is unchanged):
rows[].alternatives[] — {operator, time, price, note} other flight options for that leg; rendered as muted sub-lines under the chosen flight (KYZ-211). Use when presenting 2-3 options for the user to pick (D-08).variants[] — {label, total, nights, note} whole-route options; rendered as stacked summary cards ("Вариант А / Б") (KYZ-212).meta.fx — {EUR: <eur_per_rub>, USD: …} with live rates fetched at generation time; adds a currency toggle that recomputes prices from each priceNum / summary rub (KYZ-213). Omit if you don't have current rates.Because the table is rendered client-side, the trip data also lives in this JSON for tooling: trip_registry.py and export_trip.py read the trip-data block directly (with a fallback to scraping older, pre-refactor outputs).
For each date, compute the weekday from the date itself instead of hand-mapping:
// In the HTML <script>:
new Date('2026-06-27').toLocaleDateString('ru-RU', {weekday: 'long'})
// → "суббота"
Or, when generating the HTML from Python, use datetime.strptime(d, '%d.%m.%Y').strftime('%A') with locale set to ru_RU.UTF-8.
#0071e3) = flights, green (#34c759) = hotels, orange (#ff9500) = transfers, grey (#b0b0b0) = TBD/placeholder rows.#, Type, Date (+ weekday), Description, Time/Check-in-out, Details, Rating (TripAdvisor + Ostrovok for hotels), Price (2 чел.), Links.Total, flights subtotal, hotels subtotal, trip duration, night count.
Logistics warnings, price disclaimers, baggage notes, cancellation deadlines (earliest first).
Use SheetJS (https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js). All data in flat columns with separate columns for TripAdvisor rating, review count, Ostrovok rating, booking URL, TripAdvisor URL. Important: the XLSX button only works if the user opens the HTML in a real browser and clicks it. For end-to-end automation use Step 6.5.
Opens a new window with mobile-optimized card layout (max-width 420px), grouped by day, no prices (sharing-friendly), auto-triggers window.print(). Use break-inside: avoid for cards. Same caveat: requires user click.
If the user wants the XLSX and PDF as files on disk without clicking buttons, run scripts/export_trip.py:
python3 "${CLAUDE_SKILL_DIR:-skills/trip-planner}/scripts/export_trip.py" \
--html ~/Desktop/trip_destination_dates.html \
--out-dir ~/Desktop/
This produces a real .xlsx (via openpyxl, no SheetJS) and a real .pdf (via chrome --headless --print-to-pdf) from the same source HTML. See the script's --help for options.
Why bother: the HTML buttons require user-action and don't work end-to-end in scripted flows. The Python path is the default for "I want everything ready to share."
Tell the user:
expected_price)When you've offered multiple comparable options (e.g. two flights with different price/time/airline trade-offs), present them and ask the user to pick, don't auto-choose. Choosing silently has burned past sessions.
If the user asks to share a public link ("задеплой", "выложи", "deploy this"):
mkdir -p /tmp/trip-deploy
cp ~/Desktop/trip_*.html /tmp/trip-deploy/index.html
cd /tmp/trip-deploy
npx -y vercel@latest deploy --prod --yes
Vercel returns both a project alias (e.g. https://<project>.vercel.app) and a deployment URL. Use the project alias — it's stable and shorter. Verify it returns 200 before reporting success.
If the user is not logged in, vercel whoami will tell you. They'll need to run vercel login themselves (interactive).
Once the HTML exists (Step 6) and you've presented it (Step 7), write the trip to the persistent registry so future agents recall it. Pass --html to auto-fill what can be parsed (destination, route, dates, total, flight/hotel counts) and add explicit flags for the rest:
python3 "${CLAUDE_SKILL_DIR:-skills/trip-planner}/scripts/trip_registry.py" record \
--html ~/Desktop/trip_turkey_2026-06.html \
--destination "Турция" --dates "20–29 июня 2026" \
--start 2026-06-20 --end 2026-06-29 \
--route "MOW → IST → NAV → DLM → VKO" --pax 2 \
--total "≈316 047 ₽" \
--notes "1 место багажа на двоих на DLM→VKO; Cave Suites Adult Only +12"
Rules:
--id. Omit --id and it's derived from destination + start month (турция-2026-06). Re-running with the same id updates the entry — it never duplicates and created_at is preserved.record with the same id plus --deploy-url https://<project>.vercel.app to attach the public link.--html.Path note:
${CLAUDE_SKILL_DIR}is the absolute path to this skill's directory, set by Claude Code regardless of the current working directory — it's the portable way to call bundled scripts (works the same whether the skill is installed as a plugin or run from the repo). The:-skills/trip-plannerfallback covers running from the repo root in development. Use this same form forexport_trip.py(Step 6.5). Do not use${CLAUDE_PLUGIN_ROOT}here — that points at the plugin root, not the skill subdirectory.
Where the recalled/recorded data lives:
~/.trip-planner/ by default; override with $TRIP_PLANNER_HOME.trips.json (canonical, machine-managed) and trips.md (human-readable mirror, auto-generated — never hand-edit it).claude plugin update replaces its files), so anything stored inside the skill folder would be wiped. The registry survives updates and is shared by every agent and session.scripts/trip_registry.py writes these files (atomic writes, consistent schema). To read, just cat the JSON; to change anything, use the script.id, destination, dates, start, end, origin, route, pax, nights, flights, hotels, total, currency, status (planned/booked/archived), html_path, deploy_url, notes, data (cached structured trip — see below), created_at, updated_at.--html) also caches that trip's trip-data block under data. To change a date or swap a hotel later, you don't need the browser: either edit the trip-data block in the existing HTML (all views re-render from it), or regenerate the file from memory with render:python3 "${CLAUDE_SKILL_DIR:-skills/trip-planner}/scripts/trip_registry.py" render --id turkey-2026-06 --out ~/Desktop/trip_turkey_2026-06.html
Other commands: list (human table or --json), get --id <id>, remove --id <id>, render --id <id> [--out <path>], status --id <id> --set planned|booked|archived.
Tested JavaScript snippets for mcp__claude-in-chrome__javascript_tool. They work on the live DOM of Aviasales and Ostrovok and are resilient to hashed CSS class names because they match on text content.
Important: never include location.href, document.cookie, or any session-id-bearing string in the JSON you return — the MCP blocks output containing those and you get [BLOCKED: Cookie/query string data] instead of your data.
new Promise(resolve => setTimeout(() => {
const results = [];
document.querySelectorAll('*').forEach(el => {
const text = el.textContent.trim();
if ((text.includes('в пути') || text.includes('в полёте')) && text.includes('₽') && text.length < 600 && text.length > 50) {
results.push(text.substring(0, 500));
}
});
const baggage = [];
document.querySelectorAll('*').forEach(el => {
const text = el.textContent.trim();
if ((text.includes('багаж') || text.includes('кладь') || text.includes('Добавить багаж')) && text.length < 200) {
baggage.push(text);
}
});
resolve(JSON.stringify({
title: document.title,
flights: [...new Set(results)].slice(0, 6),
baggage: [...new Set(baggage)].slice(0, 5)
}, null, 2));
}, 4000))
const airlines = [...new Set(
[...document.querySelectorAll('img[alt]')]
.map(i => i.alt)
.filter(a => a && a.length < 30 && a.length > 1)
)];
JSON.stringify(airlines, null, 2);
const results = [];
document.querySelectorAll('*').forEach(el => {
const text = el.textContent.trim();
if (text.includes('Добавить багаж') && text.includes('₽') && text.length < 100) results.push(text);
if (text.includes('Выбрать багаж') && text.length < 100) results.push(text);
if (text === 'Без багажа' || (text.includes('багаж') && text.includes('кг') && text.length < 50)) results.push(text);
});
JSON.stringify([...new Set(results)], null, 2);
new Promise(resolve => setTimeout(() => {
const rooms = [];
document.querySelectorAll('*').forEach(el => {
const text = el.textContent.trim();
if (text.includes('₽') && text.length > 30 && text.length < 700) {
if (text.match(/\d[\s ]?\d{3,}\s*₽/) &&
(text.includes('Double') || text.includes('Single') || text.includes('Standard') ||
text.includes('Twin') || text.includes('номер') || text.includes('Queen'))) {
rooms.push(text.substring(0, 500));
}
}
});
resolve(JSON.stringify({
title: document.title,
rooms: [...new Set(rooms)].slice(0, 12)
}, null, 2));
}, 6000))
const seen = new Set();
const results = [];
document.querySelectorAll('a[href*="/hotel/"]').forEach(a => {
const slug = a.getAttribute('href').split('?')[0];
if (seen.has(slug)) return;
seen.add(slug);
const card = a.closest('[class*="Card"], [class*="card"], li, article, div[class*="result"]');
if (card) {
const text = card.textContent.trim();
if (text.length > 100 && text.length < 600) {
results.push({ slug, summary: text.substring(0, 400) });
}
}
});
JSON.stringify(results.slice(0, 12), null, 2);
const tripadvisor = [];
document.querySelectorAll('a[href*="tripadvisor"]').forEach(a => {
const img = a.querySelector('img[alt]');
tripadvisor.push({
href: a.href.split('?')[0],
rating: img ? img.alt : null
});
});
const ratings = [];
document.querySelectorAll('*').forEach(el => {
if (el.textContent.trim().match(/^\d[.,]\d$/) && el.textContent.trim().length <= 3) {
const parent = el.parentElement;
if (parent && parent.textContent.includes('отзыв')) {
ratings.push({ rating: el.textContent.trim(), context: parent.textContent.trim().substring(0, 100) });
}
}
});
JSON.stringify({ tripadvisor: tripadvisor.slice(0, 3), ratings: ratings.slice(0, 3) }, null, 2);
find tool)When JS is overkill, use mcp__claude-in-chrome__find:
"цена стоимость ₽ рублей номер""TripAdvisor tripadvisor rating отзыв""багаж добавить багаж стоимость""Oman Air Turkish Airlines Aeroflot"Non-obvious behaviors discovered through real usage. Read this list before scraping.
avs.io link doesn't preserve a specific ticket — it opens a search with the price as a hint. Real available flights and prices may differ from when the link was created. Always re-check on the live page. (KI-03)document.title !== 'Загрузка отеля...' (≈5 sec) — scraping the shell gives zero rooms. (KI-04)23 kg × 1 on a 2-pax booking = one bag for two people. Always flag.JSON.stringify includes location.href, document.cookie, or any session ID, the MCP returns [BLOCKED: Cookie/query string data] instead of your data. Strip those before serialising.→, ₽, → in <script> blocks confuse the Edit tool's matching. For HTML/JS surgery use a Python script with raw strings, or rewrite the file with the Write tool. (KI-07 / D-07)chrome --headless --print-to-pdf is the reliable PDF path. The window.print() button in the HTML works for humans but doesn't work end-to-end via the MCP (file:// URLs get mangled). Use scripts/export_trip.py. (KI-08)browser_batch is significantly faster for predictable sequences (navigate → wait → JS-extract → screenshot). Batch them whenever the next 2+ steps are independent. The MCP nudges you toward batches; listen.~/.trip-planner/, outside the plugin dir. It survives claude plugin update and is shared across agents/sessions. Never store it inside the skill folder, and never hand-edit trips.md (it's regenerated from trips.json on every write). Read it at Recall; write it at Step 9.If a comparable trade-off appears (two flights, two hotels, two routings), present both options to the user with the trade-off named ("Аэрофлот 41к / прямой / днём vs S7 47к / прямой / вечером — какой берём?") instead of silently picking. Past sessions burned trust by auto-choosing in ambiguous cases.
If a JS extractor returns nothing or partial data, fall back to a screenshot — the user can read the popup directly, and you can describe what you see. The screenshot fallback is also useful when the DOM structure changes between releases.