
Trip Map Builder
- 206 installs
- 175 repo stars
- Updated July 8, 2026
- hiyeshu/trip-map-builder
Plan a trip, research restaurants via Dianping and Xiaohongshu, then generate an interactive mobile-first Leaflet map page with a timeline and optional Vercel deploy.
About
Runs a Plan-Research-Build pipeline that turns constraints and wishlists into an area-based itinerary, enriches dining with Dianping/Xiaohongshu signals, and generates a Leaflet map page. A user runs it to create a deployable interactive trip map with navigation and reservation links.
- Single-file Leaflet template with location types, payment chips, and Xiaohongshu links
- Optional swap to alternative brand design systems from awesome-design-md
Trip Map Builder by the numbers
- 206 all-time installs (skills.sh)
- +19 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,042 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hiyeshu/trip-map-builder --skill trip-map-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 206 |
|---|---|
| repo stars | ★ 175 |
| Last updated | July 8, 2026 |
| Repository | hiyeshu/trip-map-builder ↗ |
What it does
Plan a trip, research restaurants via Dianping and Xiaohongshu, then generate an interactive mobile-first Leaflet map page with a timeline and optional Vercel deploy.
Files
Trip Map Builder
Three-phase pipeline: Plan → Research → Build.
The output is a reference itinerary, not a script the traveler must obey. During the trip, weather, current location, fatigue, and hunger can override the original plan.
Shared memory
Before planning or building, read ~/.trip-map-builder/MEMORY.md if it exists. Use it only for durable traveler context:
- pace preference
- food and drink preferences
- budget habits
- payment and navigation preferences
- previously generated trip outputs
- recurring constraints and unresolved follow-ups
If the file does not exist, continue normally. Do not block on memory setup.
Do not store raw screenshots, passport data, booking codes, full chat logs, or other sensitive/private material.
After each completed trip plan, research pass, or map build, update ~/.trip-map-builder/MEMORY.md with only durable facts:
# Trip Map Builder Memory
## Traveler Defaults
- Departure city:
- Pace:
- Food preferences:
- Budget habits:
- Payment preference:
- Navigation preference:
- Language preference:
## Past Trips
| Trip | Dates | Destination | Output | Notes |
|------|-------|-------------|--------|-------|
## Reusable Preferences
-
## Open Threads
-Phase 1: Plan the itinerary
Read references/trip-planning.md for the full methodology.
Core sequence:
1. Extract hard constraints — dates, flight times, terminals, hotel location 2. Group user's wishlist — city-easy / needs-reservation / far-suburbs / pass-through 3. Cut high-risk items first — too far, holiday-crowded, weather-dependent. Say what was cut and why. 4. Arrange by area — one main area per day, first day light, last day close to airport 5. Fill in meals — daily-area candidates first, 大众点评 + 小红书 signals second, fame last. 6. Add tickets & transport — only critical ones (museum tickets, airport transfer) 7. Write reference doc — conclusion first, then daily plan, weather-sensitive spots, meal areas, and what was cut
Key principles:
- Not everything the user listed fits. Delete for them.
- One area per day. One reservation-required spot per day max.
- Itineraries should be smooth, not packed.
- The plan gives coordinates for later adjustment; it does not pretend reality will follow the timeline.
- All user-facing questions follow the 4-beat format: Re-ground → Simplify → Recommend → Options. See
references/trip-planning.md§ 用户交互 for examples and anti-patterns.
Phase 2: Research via 大众点评 + 小红书
Read references/dianping-research.md for the 大众点评 OpenCLI workflow. Read references/xhs-research.md for the 小红书 OpenCLI + CDP workflow.
For restaurants, use 大众点评 as the main Chinese dining signal for taste, queue risk, value, and obvious traps. Use 小红书 to supplement atmosphere, recent experience, photo-worthiness, and soft warnings. Do not bend a whole day around a famous restaurant unless it is already on the route.
小红书 core sequence:
1. Launch Chrome with --remote-debugging-port=9223 2. Connect via OpenCLI's CDPBridge 3. Navigate to xiaohongshu.com/search_result?keyword=<encoded> (never simulate input box) 4. Intercept POST /api/sns/web/v1/search/notes response 5. Pick top 2-3 notes by relevance, open detail pages 6. Extract via DOM: #detail-title, #detail-desc, .author-container .username 7. Compress to one decision-useful sentence per store, write back to local .md
Filtering rules:
- Keep: specific store name, address, dish, personal experience, repeated keywords
- Drop: generic area roundups, reposts, pure emotion, "氛围很好" x3
- Output: store name + one representative link + 2-3 sentence verdict
Phase 3: Build the map page
1. Copy assets/template.html → index.html 2. Fill HOTEL object and DAYS array with structured data from Phase 1+2 3. Each location needs: name, lat/lng, type, time, desc; optional: budget, detail, pay, xhs, reserve, gmap 4. Fill overviewContent() with trip summary, payment warnings 5. Apply design system — default template uses Apple style, but can switch to any style from awesome-design-md
Location types: food | spot | drink | hotel | transport
Payment chip values: 1 = confirmed yes (green), 0.5 = maybe (orange), omit = not shown
Design system (optional)
Default template uses Apple design system (SF Pro, light theme, frosted glass).
To use a different style, grab a DESIGN.md from awesome-design-md:
# Browse available design systems
# Apple, Vercel, Linear, Stripe, Notion, Airbnb, Nike, Spotify, etc.
curl -O https://raw.githubusercontent.com/VoltAgent/awesome-design-md/main/design-md/<brand>/DESIGN.mdThen adjust template.html's :root CSS variables (colors, fonts, spacing, border-radius) to match the chosen DESIGN.md tokens.
Deploy (optional)
git init && git add . && git commit -m "trip map"
gh repo create REPO --public --source=. --push
# Import from vercel.com/new — auto-deploys on pushDependencies
| Tool | Purpose | Install |
|---|---|---|
| OpenCLI | 大众点评 adapter + 小红书调研 | npm install -g @jackwener/opencli |
| Chrome/Chromium | 浏览器 + 远程调试 | 已有 |
| Leaflet.js | 地图渲染(CDN 引入,无需安装) | template.html 内置 |
| gh CLI | GitHub 仓库创建(可选) | brew install gh |
Resources
references/trip-planning.md— itinerary planning methodology, input/output templates, selection principles, common pitfallsreferences/dianping-research.md— 大众点评 OpenCLI search/shop workflow, dining decision signals, writeback formatreferences/xhs-research.md— OpenCLI installation, Chrome CDP setup, 小红书 search workflow, API details, filtering criteriaassets/template.html— single-file HTML map template (Leaflet + Apple design system)- awesome-design-md — 60+ brand design systems (Apple, Vercel, Stripe, Linear, etc.) for alternative styling
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title><!-- REPLACE: Trip Title --></title>
<meta name="description" content="<!-- REPLACE: Trip Description -->">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
/* ═══════════════════════════════════════════
Design System — edit :root to change theme
═══════════════════════════════════════════ */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f7;
--bg-tertiary: #fbfbfd;
--bg-grouped: #f2f2f7;
--bg-elevated: #ffffff;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--text-tertiary: #aeaeb2;
--accent: #0071e3;
--accent-hover: #0077ed;
--tint-red: #ff3b30;
--tint-orange: #ff9500;
--tint-green: #34c759;
--tint-blue: #007aff;
--tint-purple: #af52de;
--tint-pink: #ff2d55;
--separator: rgba(60, 60, 67, .12);
--shadow-sm: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--shadow-md: 0 4px 14px rgba(0,0,0,.08);
--shadow-lg: 0 8px 30px rgba(0,0,0,.12);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
"Helvetica Neue", "Arial", sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.47059;
letter-spacing: -0.022em;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Map ── */
#map { width: 100%; height: 40vh; min-height: 280px; position: sticky; top: 0; z-index: 100; }
.locate-btn {
width: 36px; height: 36px; border-radius: 8px;
background: rgba(255,255,255,.92); backdrop-filter: blur(8px);
border: none; box-shadow: 0 1px 4px rgba(0,0,0,.18);
display: flex; align-items: center; justify-content: center;
font-size: 18px; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.locate-btn.active { color: #007aff; }
@keyframes loc-pulse {
0% { transform: scale(1); opacity: .55; }
100% { transform: scale(2.4); opacity: 0; }
}
.loc-pulse {
width: 14px; height: 14px; border-radius: 50%;
background: rgba(0,122,255,.25);
animation: loc-pulse 1.6s ease-out infinite;
}
/* ── Tabs ── */
.tabs {
display: flex; gap: 6px; padding: 12px 16px; overflow-x: auto; -webkit-overflow-scrolling: touch;
background: rgba(255,255,255,.85); -webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px); position: sticky; top: 40vh; z-index: 99;
border-bottom: .5px solid var(--separator); scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
flex-shrink: 0; padding: 7px 16px; border-radius: 980px; border: none;
background: var(--bg-grouped); color: var(--text-secondary);
font-size: 14px; font-weight: 500; cursor: pointer; transition: all .25s ease;
white-space: nowrap; letter-spacing: -0.01em;
}
.tab:active { transform: scale(.96); }
.tab.active { background: var(--accent); color: #fff; }
/* ── Main content ── */
.content { max-width: 680px; margin: 0 auto; padding: 16px 16px calc(24px + var(--safe-bottom)); }
/* ── Overview ── */
.hero { text-align: center; padding: 28px 20px 20px; }
.hero h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.025em; line-height: 1.15; }
.hero .subtitle { font-size: 17px; color: var(--text-secondary); margin-top: 6px; font-weight: 400; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 20px; }
.info-card { background: var(--bg-elevated); border-radius: var(--radius-lg); padding: 16px; box-shadow: var(--shadow-sm); }
.info-card .label { font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: .02em; margin-bottom: 6px; }
.info-card .value { font-size: 15px; font-weight: 600; line-height: 1.4; }
/* ── Section headers ── */
.section-head { font-size: 13px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: .03em; padding: 20px 0 8px; }
/* ── Day header ── */
.day-head { padding: 20px 0 12px; }
.day-head h2 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.day-head p { font-size: 15px; color: var(--text-secondary); margin-top: 2px; }
/* ── Route bar ── */
.route-bar { background: var(--bg-elevated); border-radius: var(--radius-md); padding: 12px 14px; margin-bottom: 14px; box-shadow: var(--shadow-sm); font-size: 13px; color: var(--text-secondary); line-height: 1.7; white-space: pre-line; }
.route-bar strong { color: var(--text-primary); }
/* ── Timeline ── */
.timeline { position: relative; padding-left: 24px; }
.timeline::before { content: ''; position: absolute; left: 7px; top: 20px; bottom: 20px; width: 1.5px; background: var(--separator); }
.tl-item { position: relative; margin-bottom: 10px; }
.tl-dot { position: absolute; left: -20px; top: 18px; width: 10px; height: 10px; border-radius: 50%; z-index: 1; box-shadow: 0 0 0 3px var(--bg-secondary); }
.tl-dot.food { background: var(--tint-red); }
.tl-dot.spot { background: var(--tint-blue); }
.tl-dot.hotel { background: var(--tint-orange); }
.tl-dot.drink { background: var(--tint-purple); }
.tl-dot.transport { background: var(--text-tertiary); }
/* ── Card ── */
.card { background: var(--bg-elevated); border-radius: var(--radius-md); padding: 14px 16px; box-shadow: var(--shadow-sm); transition: box-shadow .25s ease; cursor: pointer; }
.card:active { box-shadow: var(--shadow-md); }
.card .time { font-size: 12px; font-weight: 600; color: var(--text-tertiary); letter-spacing: .01em; }
.card .name { font-size: 17px; font-weight: 600; margin: 2px 0 4px; letter-spacing: -0.01em; display: flex; align-items: center; gap: 6px; }
.card .desc { font-size: 14px; color: var(--text-secondary); line-height: 1.45; }
/* Badge */
.badge { display: inline-flex; align-items: center; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 4px; }
.badge.food { background: rgba(255,59,48,.1); color: var(--tint-red); }
.badge.spot { background: rgba(0,122,255,.1); color: var(--tint-blue); }
.badge.drink { background: rgba(175,82,222,.1); color: var(--tint-purple); }
/* Budget */
.budget { display: inline-block; margin-top: 6px; padding: 3px 10px; background: var(--bg-grouped); border-radius: var(--radius-sm); font-size: 12px; color: var(--text-secondary); font-weight: 500; }
/* Payment */
.pays { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.pay { font-size: 11px; padding: 2px 7px; border-radius: 4px; font-weight: 600; }
.pay.ok { background: rgba(52,199,89,.1); color: var(--tint-green); }
.pay.no { background: rgba(255,59,48,.08); color: var(--tint-red); }
.pay.idk { background: rgba(255,149,0,.1); color: var(--tint-orange); }
/* Links row */
.links { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 8px 14px; border-radius: var(--radius-sm);
font-size: 13px; font-weight: 600; border: none; cursor: pointer;
text-decoration: none !important; transition: filter .2s;
}
.btn:active { filter: brightness(.9); }
.btn.nav { background: var(--accent); color: #fff; }
.btn.xhs { background: #ff2442; color: #fff; }
.btn.dp { background: #ffa700; color: #fff; }
.btn.rsv { background: var(--tint-green); color: #fff; }
/* Action Sheet */
.as-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,.35); backdrop-filter: blur(2px);
display: flex; align-items: flex-end; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .25s;
}
.as-overlay.show { opacity: 1; pointer-events: auto; }
.as-sheet {
width: 100%; max-width: 420px;
padding: 0 8px 34px;
transform: translateY(100%); transition: transform .3s cubic-bezier(.22,1,.36,1);
}
.as-overlay.show .as-sheet { transform: translateY(0); }
.as-group {
background: rgba(255,255,255,.88); backdrop-filter: blur(20px);
border-radius: 14px; overflow: hidden; margin-bottom: 8px;
}
.as-group .as-item {
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 16px; font-size: 18px; color: #007aff; font-weight: 500;
border: none; background: none; width: 100%; cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.as-group .as-item + .as-item { border-top: .5px solid rgba(0,0,0,.12); }
.as-group .as-item:active { background: rgba(0,0,0,.06); }
.as-cancel { font-weight: 600; }
/* Details — default open */
.details {
margin-top: 10px; padding-top: 10px;
border-top: .5px solid var(--separator);
font-size: 13px; color: var(--text-secondary); line-height: 1.6;
}
.details.closed { display: none; }
/* ── Overview day list ── */
.day-list .card { margin-bottom: 8px; }
.day-list .card .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
/* ── Payment summary ── */
.pay-summary { background: var(--bg-elevated); border-radius: var(--radius-md); padding: 14px 16px; box-shadow: var(--shadow-sm); font-size: 13px; line-height: 1.7; margin-bottom: 10px; }
.pay-summary h4 { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
.pay-summary .warn { color: var(--tint-red); }
.pay-summary .ok-text { color: var(--tint-green); }
/* ── Legend ── */
.legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 8px 0; font-size: 12px; color: var(--text-secondary); }
.legend-item { display: flex; align-items: center; gap: 5px; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
/* ── Leaflet overrides ── */
.leaflet-popup-content-wrapper { border-radius: var(--radius-md) !important; box-shadow: var(--shadow-lg) !important; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif !important; }
.leaflet-popup-content { font-size: 14px !important; line-height: 1.5 !important; margin: 12px 14px !important; }
.leaflet-popup-content a { color: var(--accent) !important; }
.leaflet-popup-tip { background: #fff !important; }
.leaflet-control-zoom a { width: 34px !important; height: 34px !important; line-height: 34px !important; font-size: 16px !important; border-radius: 10px !important; background: rgba(255,255,255,.85) !important; -webkit-backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important; border: .5px solid var(--separator) !important; color: var(--text-primary) !important; }
.leaflet-control-zoom { border-radius: 12px !important; overflow: hidden; box-shadow: var(--shadow-md) !important; border: none !important; }
.leaflet-control-attribution { font-size: 9px !important; opacity: .5; }
</style>
</head>
<body>
<div id="map"></div>
<div class="tabs" id="tabs"></div>
<div class="content" id="content"></div>
<script>
// ╔══════════════════════════════════════════════════════════════╗
// ║ REPLACE THIS SECTION — Hotel and Days data ║
// ╚══════════════════════════════════════════════════════════════╝
const HOTEL = { name: 'Hotel Name', lat: 0, lng: 0 };
const DAYS = [
{ id: 0, label: '总览', color: '#0071e3', locations: [] },
// { id: 1, label: 'D1 Label', date: '日期', title: '标题', color: '#ff9500', locations: [...] }
//
// Each location:
// {
// name: 'Name', lat: 35.68, lng: 139.77,
// type: 'food', // food | spot | drink | hotel | transport
// time: '18:30', // or '—' for alternatives
// desc: 'Description',
// budget: '¥3,000', // optional
// detail: 'Details', // optional, \n for newlines
// pay: { cash: 1, card: 1, suica: 0.5 }, // optional, 1=yes 0.5=maybe
// xhs: 'https://...', // optional 小红书 link
// xhsKeyword: '搜索词', // optional, overrides name for XHS search
// dianping: 'https://...', // optional 大众点评 link (presence enables button)
// dianpingKeyword: '搜索词', // optional, overrides name for 大众点评 search
// reserve: 'https://...', // optional reservation link
// gmap: 'Google+Maps+Search+Query'
// }
];
// ╔══════════════════════════════════════════════════════════════╗
// ║ REPLACE THIS SECTION — Overview tab content ║
// ╚══════════════════════════════════════════════════════════════╝
function overviewContent() {
return `
<div class="hero"><h1><!-- REPLACE: Title --></h1><p class="subtitle"><!-- REPLACE: Subtitle --></p></div>
<div class="info-grid">
<div class="info-card"><div class="label">酒店</div><div class="value"><!-- REPLACE --></div></div>
<div class="info-card"><div class="label">航班</div><div class="value"><!-- REPLACE --></div></div>
<div class="info-card"><div class="label">交通卡</div><div class="value"><!-- REPLACE --></div></div>
<div class="info-card"><div class="label">预估费用</div><div class="value"><!-- REPLACE --></div></div>
</div>
<div class="section-head">行程概览</div>
<div class="day-list">${DAYS.slice(1).map(d=>\`<div class="card" onclick="go(\${d.id})"><div class="name"><span class="dot" style="background:\${d.color}"></span>\${d.label} · \${d.title}</div><div class="desc">\${d.date}</div></div>\`).join('')}</div>
<div class="section-head">支付提醒</div>
<!-- REPLACE: Add pay-summary cards as needed -->
<div class="section-head">图例</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:var(--tint-red)"></div>餐厅</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--tint-blue)"></div>景点</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--tint-purple)"></div>酒吧</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--tint-orange)"></div>酒店</div>
</div>`;
}
// ╔══════════════════════════════════════════════════════════════╗
// ║ ENGINE — Do not modify below unless customizing behavior ║
// ╚══════════════════════════════════════════════════════════════╝
const map = L.map('map', { center: [HOTEL.lat || 35.69, HOTEL.lng || 139.74], zoom: 12, zoomControl: false, attributionControl: true });
L.control.zoom({ position: 'topright' }).addTo(map);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(map);
/* ── Geolocation ── */
const LocateControl = L.Control.extend({
options: { position: 'bottomright' },
onAdd() {
const btn = L.DomUtil.create('button', 'locate-btn');
btn.innerHTML = '📍';
btn.title = '我的位置';
L.DomEvent.disableClickPropagation(btn);
btn.addEventListener('click', () => {
btn.classList.add('active');
map.locate({ setView: true, maxZoom: 15 });
});
this._btn = btn;
return btn;
}
});
new LocateControl().addTo(map);
let locMarker, locCircle;
map.on('locationfound', e => {
const r = e.accuracy / 2;
if (locMarker) { locMarker.setLatLng(e.latlng); locCircle.setLatLng(e.latlng).setRadius(r); }
else {
locMarker = L.marker(e.latlng, {
icon: L.divIcon({ className:'', html:'<div style="width:14px;height:14px;border-radius:50%;background:#007aff;border:2.5px solid #fff;box-shadow:0 0 6px rgba(0,122,255,.5)"></div><div class="loc-pulse" style="position:absolute;top:0;left:0"></div>', iconSize:[14,14], iconAnchor:[7,7] }),
zIndexOffset: 2000
}).addTo(map);
locCircle = L.circle(e.latlng, { radius: r, color: '#007aff', fillColor: '#007aff', fillOpacity: .08, weight: 1 }).addTo(map);
}
});
map.on('locationerror', () => {});
function icon(color, emoji) {
return L.divIcon({ className: '', html: `<div style="width:28px;height:28px;border-radius:50%;background:${color};border:2.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:13px;box-shadow:0 2px 8px rgba(0,0,0,.18)">${emoji||''}</div>`, iconSize: [28,28], iconAnchor: [14,14], popupAnchor: [0,-16] });
}
if (HOTEL.lat) L.marker([HOTEL.lat, HOTEL.lng], { icon: icon('#ff9500','🏨'), zIndexOffset: 1000 }).addTo(map).bindPopup(`<b>${HOTEL.name}</b>`);
let markers = [], polylines = [];
const emojis = { food:'🍽️', spot:'📍', hotel:'🏨', transport:'🚃', drink:'🍺' };
function clearMap() { markers.forEach(m=>map.removeLayer(m)); polylines.forEach(p=>map.removeLayer(p)); markers=[]; polylines=[]; }
function showDay(idx) {
clearMap();
if (idx === 0) {
const pts = [];
DAYS.forEach(d => d.locations.forEach(l => { if(l.lat) pts.push([l.lat,l.lng]); }));
if(pts.length) map.fitBounds(pts, {padding:[30,30]});
DAYS.slice(1).forEach(d => {
d.locations.filter(l=>l.time!=='—').forEach(l => {
const m = L.circleMarker([l.lat,l.lng], { radius:4, fillColor:d.color, color:'#fff', weight:1.5, fillOpacity:.9 }).addTo(map).bindPopup(`<b>${l.name}</b><br><span style="color:#86868b">${d.label}</span>`);
markers.push(m);
});
});
return;
}
const day = DAYS.find(d=>d.id===idx);
if(!day) return;
const coords = [];
day.locations.forEach(l => {
if(!l.lat) return;
const m = L.marker([l.lat,l.lng], { icon: icon(day.color, emojis[l.type]||'📌') }).addTo(map);
const gmapQ = l.gmap||encodeURIComponent(l.name+' Tokyo');
m.bindPopup(`<b>${l.name}</b>${l.time!=='—'?'<br><span style="color:#86868b">'+l.time+'</span>':''}<br>${l.desc}${l.budget?'<br><span style="color:#ff9500">'+l.budget+'</span>':''}<br><a href="#" onclick="event.preventDefault();openAS('${l.name.replace(/'/g,"\\'")}',${l.lat},${l.lng},'${gmapQ}')">📍 导航</a>${l.xhs?' · <a href="'+l.xhs+'" target="_blank">📕 小红书</a>':''}`);
markers.push(m);
if(l.time!=='—') coords.push([l.lat,l.lng]);
});
if(coords.length>1) { const pl = L.polyline(coords, { color:day.color, weight:2.5, opacity:.5, dashArray:'6,5' }).addTo(map); polylines.push(pl); }
if(coords.length) map.fitBounds(coords, { padding:[40,40], maxZoom:14 });
}
// ══════════ RENDER ══════════
function payChips(pay) {
if(!pay) return '';
let h = '<div class="pays">';
const m = { cash:'💴', card:'💳', suica:'Suica', paypay:'PayPay', alipay:'支付宝', wechat:'微信' };
for(const [k,label] of Object.entries(m)) {
if(pay[k]!==undefined) { const c = pay[k]===1?'ok':pay[k]===0.5?'idk':'no'; h += `<span class="pay ${c}">${label}</span>`; }
}
return h+'</div>';
}
// ── 运行环境检测:在第三方 App 的 WebView 里,自定义 scheme 基本会被拦截 ──
const IN_APP_WEBVIEW = (() => {
const ua = (navigator.userAgent || '').toLowerCase();
return /micromessenger|xhs|xhsdiscover|discover\/|tiktok|aweme|weibo|qq\/|qqbrowser|alipayclient|dingtalk|lark|feishu|ucbrowser|baiduboxapp|toutiao|newsarticle/.test(ua);
})();
function xhsKeyword(loc) {
if(loc.xhsKeyword) return loc.xhsKeyword.trim();
return (loc.name||'').replace(/⭐.*$/g,'').replace(/(.*?)/g,'').replace(/\(.*?\)/g,'').replace(/\s+/g,' ').trim();
}
function xhsAppUrl(loc) { return `xhsdiscover://search/result?keyword=${encodeURIComponent(xhsKeyword(loc))}&target_search=notes&source=deeplink`; }
function xhsWebUrl(loc) { return `https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(xhsKeyword(loc))}&source=web_explore_feed`; }
function dpKeyword(loc) {
if(loc.dianpingKeyword) return loc.dianpingKeyword.trim();
return (loc.name||'').replace(/⭐.*$/g,'').replace(/(.*?)/g,'').replace(/\(.*?\)/g,'').replace(/\s+/g,' ').trim();
}
function dpAppUrl(loc) { return `dianping://searchshoplist?keyword=${encodeURIComponent(dpKeyword(loc))}`; }
function dpWebUrl(loc) { return `https://m.dianping.com/searchshop?keyword=${encodeURIComponent(dpKeyword(loc))}`; }
window._extLocs = {};
DAYS.forEach(d => d.locations.forEach(l => { window._extLocs[`${d.id}::${l.name}`] = l; }));
window._xhsLocs = window._extLocs;
function openExt(key, kind) {
const loc = window._extLocs[key]; if(!loc) return false;
if (IN_APP_WEBVIEW) {
window.location.href = kind==='dp' ? dpWebUrl(loc) : xhsWebUrl(loc);
} else {
window.location.href = kind==='dp' ? dpAppUrl(loc) : xhsAppUrl(loc);
}
return false;
}
function openXhs(key) { return openExt(key, 'xhs'); }
function openDp(key) { return openExt(key, 'dp'); }
function locCard(loc, dayIdx) {
const dot = loc.type==='food'?'food':loc.type==='spot'?'spot':loc.type==='drink'?'drink':loc.type==='hotel'?'hotel':'transport';
const bdg = loc.type==='food'?'<span class="badge food">餐</span>':loc.type==='spot'?'<span class="badge spot">景</span>':loc.type==='drink'?'<span class="badge drink">饮</span>':'';
const uid = `d${dayIdx}-${loc.name.replace(/\W/g,'')}`;
const locKey = `${dayIdx}::${loc.name}`;
const gmapQ = loc.gmap||encodeURIComponent(loc.name+' Tokyo');
let lnk = `<button class="btn nav" onclick="openAS('${loc.name.replace(/'/g,"\\'")}',${loc.lat},${loc.lng},'${gmapQ}')">📍 导航</button>`;
if(loc.xhs||loc.xhsKeyword) {
const xhsHref = IN_APP_WEBVIEW ? xhsWebUrl(loc) : xhsAppUrl(loc);
const xhsLabel = IN_APP_WEBVIEW ? '📕 小红书' : '📕 小红书 App';
lnk += `<a href="${xhsHref}" onclick="return openXhs('${locKey.replace(/'/g,"\\'")}')" class="btn xhs">${xhsLabel}</a>`;
}
const dpEnabled = loc.dianping !== false &&
(loc.dianping || loc.dianpingKeyword || loc.type==='food' || loc.type==='drink');
if(dpEnabled) {
const dpHref = IN_APP_WEBVIEW ? dpWebUrl(loc) : dpAppUrl(loc);
const dpLabel = IN_APP_WEBVIEW ? '🍜 大众点评' : '🍜 大众点评 App';
lnk += `<a href="${dpHref}" onclick="return openDp('${locKey.replace(/'/g,"\\'")}')" class="btn dp">${dpLabel}</a>`;
}
if(loc.reserve) lnk += `<a href="${loc.reserve}" target="_blank" class="btn rsv">📅 预约</a>`;
return `<div class="tl-item"><div class="tl-dot ${dot}"></div><div class="card" onclick="if(!event.target.closest('.btn,.links'))toggle('${uid}')"><div class="time">${loc.time!=='—'?loc.time:'备选'}</div><div class="name">${bdg} ${loc.name}</div><div class="desc">${loc.desc}</div>${loc.budget?'<div class="budget">'+loc.budget+'</div>':''}${payChips(loc.pay)}<div class="links">${lnk}</div>${loc.detail?'<div class="details" id="'+uid+'">'+loc.detail.replace(/\n/g,'<br>')+'</div>':''}</div></div>`;
}
function renderDay(idx) {
const d = DAYS.find(x=>x.id===idx); if(!d) return '';
const main = d.locations.filter(l=>l.time!=='—');
const alt = d.locations.filter(l=>l.time==='—');
let h = `<div class="day-head"><h2>${d.title}</h2><p>${d.date}</p></div>`;
if(main.length) { h += '<div class="section-head">行程</div><div class="timeline">'; main.forEach(l => h += locCard(l, idx)); h += '</div>'; }
if(alt.length) { h += '<div class="section-head">备选</div><div class="timeline">'; alt.forEach(l => h += locCard(l, idx)); h += '</div>'; }
return h;
}
// ══════════ NAV ══════════
let cur = 0;
const tabsEl = document.getElementById('tabs');
const contentEl = document.getElementById('content');
DAYS.forEach(d => {
const btn = document.createElement('button');
btn.className = 'tab' + (d.id===0?' active':'');
btn.dataset.day = d.id;
btn.textContent = d.label;
tabsEl.appendChild(btn);
});
function go(idx) {
cur = idx;
tabsEl.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', +t.dataset.day===idx));
contentEl.innerHTML = idx===0 ? overviewContent() : renderDay(idx);
showDay(idx);
contentEl.scrollTo({top:0});
const tab = tabsEl.querySelector(`[data-day="${idx}"]`);
if(tab) tab.scrollIntoView({behavior:'smooth', inline:'center', block:'nearest'});
}
function toggle(id) { const el=document.getElementById(id); if(el) el.classList.toggle('closed'); }
tabsEl.addEventListener('click', e => { const t=e.target.closest('.tab'); if(t) go(+t.dataset.day); });
go(0);
/* ── Action Sheet for Navigation ── */
const asOverlay = document.createElement('div');
asOverlay.className = 'as-overlay';
asOverlay.innerHTML = `<div class="as-sheet">
<div class="as-group" id="as-opts"></div>
<div class="as-group"><button class="as-item as-cancel" onclick="closeAS()">取消</button></div>
</div>`;
document.body.appendChild(asOverlay);
asOverlay.addEventListener('click', e => { if (e.target === asOverlay) closeAS(); });
const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
function amapAppUrl(name, lat, lng) {
const scheme = IS_IOS ? 'iosamap' : 'androidamap';
return `${scheme}://viewMap?sourceApplication=trip&poiname=${encodeURIComponent(name)}&lat=${lat}&lon=${lng}&dev=0`;
}
function amapWebUrl(name, lat, lng) {
return `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(name)}&src=trip&coordinate=wgs84&callnative=1`;
}
function openAmap(name, lat, lng) {
if (IN_APP_WEBVIEW) { window.open(amapWebUrl(name, lat, lng)); return; }
window.location.href = amapAppUrl(name, lat, lng);
}
function openAS(name, lat, lng, gmap) {
const q = gmap || encodeURIComponent(name + ' Tokyo');
const encName = encodeURIComponent(name);
const safeName = name.replace(/'/g,"\\'");
// WebView 内 maps:// 通常被拦截,改用 https Apple Maps URL —— iOS 系统会自动拉起 Apple Maps
const appleUrl = IN_APP_WEBVIEW
? `https://maps.apple.com/?q=${lat},${lng}&ll=${lat},${lng}&name=${encName}`
: `maps://maps.apple.com/?q=${lat},${lng}&name=${encName}`;
const googleUrl = `https://www.google.com/maps/search/?api=1&query=${q}`;
document.getElementById('as-opts').innerHTML =
`<button class="as-item" onclick="window.open('${appleUrl}');closeAS()">🍎 Apple Maps</button>` +
`<button class="as-item" onclick="window.open('${googleUrl}');closeAS()">🗺️ Google Maps</button>` +
`<button class="as-item" onclick="openAmap('${safeName}',${lat},${lng});closeAS()">🧭 高德地图 App</button>`;
asOverlay.classList.add('show');
}
function closeAS() { asOverlay.classList.remove('show'); }
</script>
</body>
</html>
trip-map-builder - 旅行地图技能包
Markdown + HTML + OpenCLI references
<directory> assets/ - 单文件地图模板 (1文件: template.html) references/ - 调研与规划方法论 (3文件: trip-planning.md, dianping-research.md, xhs-research.md) </directory>
<config> README.md - 对外说明技能定位、安装、流程和目录结构 SKILL.md - Agent 技能入口,定义触发条件、共享记忆和三阶段流程 </config>
法则: 行程是参考坐标,不是执行脚本。记忆只存下次仍有用的偏好。餐厅先看当天区域,再看大众点评和小红书。
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
Trip Map Builder
从零散信息到可部署的交互式行程地图页面。
三阶段流水线:规划行程 → 大众点评/小红书调研 → 生成地图页面。
定位:生成出发前的参考坐标,不是假装旅行会逐小时照做的执行脚本。
共享记忆
技能会优先读取 ~/.trip-map-builder/MEMORY.md,复用长期有效的旅行偏好:节奏、餐饮、预算、支付、导航方式、历史行程输出和未解决事项。它不保存原始截图、证件、订单号或完整聊天记录。
Demo
[tokyo-trip-pi.vercel.app](https://tokyo-trip-pi.vercel.app) — 东京 4 泊 5 日行程地图
这个技能做什么
给 AI agent 一套完整的旅行规划工作流:
1. 用户丢过来机票截图、酒店截图、想去的地方 2. Agent 提取硬约束(日期、航班、酒店位置),按区域分组,主动删掉塞不下的点,并标记天气敏感点 3. 餐厅按当天区域给候选,用大众点评 + 小红书判断口味、排队、踩雷、氛围和近期体验 4. 生成单文件 HTML 页面:Leaflet 地图 + 时间轴卡片 + Google Maps 导航 + 小红书链接 + 支付方式标签 5. 更新共享记忆,只保存下次旅行仍然有用的偏好和输出索引 6. 推到 GitHub,Vercel 自动部署,手机打开直接用
安装
一行命令安装(skills.sh 生态):
npx skills add hiyeshu/trip-map-builder或者手动 clone 到 skills 目录:
# Cursor
git clone https://github.com/hiyeshu/trip-map-builder.git ~/.cursor/skills/trip-map-builder
# Claude Code
git clone https://github.com/hiyeshu/trip-map-builder.git ~/.claude/skills/trip-map-builder触发词
说这些话会激活技能:
- "做个行程" / "行程规划" / "行程地图"
- "plan my trip" / "trip map" / "build itinerary"
- "帮我查一下小红书上这家店怎么样"
工作流
Phase 1:规划行程
从用户给的碎片信息里抽出硬约束,按区域分组,主动删高风险点。
核心原则:
- 一天一个主区域
- 不是越满越好,是越顺越好
- 行程是参考坐标,真实执行可以被天气、当前位置、体力和饥饿程度覆盖
- 餐厅是当天区域内的顺路候选,不为名店反向扭曲路线
- 替用户删东西,说清楚删了什么、为什么删
详见 `references/trip-planning.md`
Phase 2:大众点评 + 小红书调研
餐厅优先看大众点评和小红书:大众点评判断口味、排队、踩雷、值不值得;小红书补氛围、近期体验、拍照和软性提醒。
小红书可用 OpenCLI 的 CDPBridge 连接 Chrome,直接访问搜索结果页路由,拦截 search/notes API,提取前排笔记。
关键经验:不要模拟输入框,直接进 search_result?keyword= 路由更稳定。
两段式流程:先粗筛搜索结果(10-20 条),再精读最相关的 2-3 条详情页。
详见 `references/dianping-research.md` 和 `references/xhs-research.md`
Phase 3:生成地图页面
基于 `assets/template.html` 模板填入数据,生成单文件 HTML:
- Leaflet.js 交互地图(无需 API key)
- 按天切换的时间轴卡片
- 每个地点:
- 📍 导航:Action Sheet 三选——Apple Maps / Google Maps / 🧭 高德地图 App(直接 scheme 唤起,不打开网页)
- 📕 小红书 App:UA 检测,正常浏览器走
xhsdiscover://scheme,微信/抖音等 WebView 内自动降级到 m 站 - 🍜 大众点评 App:
food/drink类型自动启用,可用dianping: false关闭,或dianpingKeyword自定义搜索词 - 📅 预约按钮(可选)
- 支付方式标签(信用卡 / 支付宝 / 交通卡 / 现金)
- 默认 Apple 设计系统,可通过 awesome-design-md 切换风格
依赖
| 工具 | 用途 | 安装 |
|---|---|---|
| OpenCLI | 大众点评 adapter + 小红书调研 | npm install -g @jackwener/opencli |
| Chrome / Chromium | 浏览器 + 远程调试 | — |
| Leaflet.js | 地图渲染 | CDN 引入,无需安装 |
| gh CLI | GitHub 仓库创建(可选) | brew install gh |
目录结构
trip-map-builder/
├── CLAUDE.md # 项目地图,记录目录职责
├── SKILL.md # 技能入口,三阶段流程
├── README.md # 本文件
├── assets/
│ └── template.html # 可复用 HTML 地图模板
└── references/
├── CLAUDE.md # references 局部地图
├── trip-planning.md # 行程规划方法论
├── dianping-research.md # 大众点评调研 + OpenCLI adapter
└── xhs-research.md # 小红书调研 + OpenCLI 安装许可
MIT
references/
L2 | 父级: ../CLAUDE.md
成员清单 trip-planning.md: 行程规划方法论,定义参考行程、天气敏感点、区域候选餐厅和四拍交互。 dianping-research.md: 大众点评调研流程,定义 OpenCLI dianping search/shop 命令、餐厅判断标准和写回格式。 xhs-research.md: 小红书调研流程,定义 OpenCLI/CDP 路由搜索、笔记筛选、详情提取和压缩写回。
依赖关系 SKILL.md 读取本目录方法论,README.md 对外链接本目录参考文档,assets/template.html 消费调研后的结构化地点数据。
[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
大众点评调研工作流 (OpenCLI)
前提
大众点评用于餐厅硬信号:口味、排队、踩雷、价格、区域和是否值得。小红书只补氛围、近期体验、拍照和软性提醒。
OpenCLI 已提供大众点评 browser adapter,目标站点是 www.dianping.com。
环境要求
- Chrome 已登录
dianping.com - 已安装 OpenCLI Browser Bridge 扩展
- 优先使用 PC 站;移动站对非移动 UA 限制较多
搜索餐厅
围绕当天主区域搜索,不搜泛词。
opencli dianping search "银座 午餐" --city 东京 --limit 5 -f json
opencli dianping search "有乐町 晚餐" --city 东京 --limit 5 -f json
opencli dianping search "新宿 居酒屋" --city 东京 --limit 5 -f json命令格式:
opencli dianping search "<keyword>" --city <name-or-id> --limit <n> -f json--city 可用中文、拼音或大众点评 cityId;省略时使用当前 cookie 里的城市。
查看店铺详情
搜索结果里的 shop_id 可以继续查详情。
opencli dianping shop <shop_id> -f json
opencli dianping detail <shop_id> -f json也可以传完整店铺 URL:
opencli dianping shop "https://www.dianping.com/shop/<shop_id>"判断标准
优先看:
rating:基础稳定性reviews:评价量,太少说明信号弱price:是否符合预算cuisine:是否适合当前这顿饭district:是否落在当天区域- 评价关键词:排队、踩雷、服务、游客店、性价比、是否值得专门去
不要为了高分店扭曲路线。餐厅默认是当天区域里的补给点,只有预约餐、强目的餐、用户明确指定的店,才允许成为路线锚点。
写回格式
每顿饭只保留 2-3 个候选。
午餐区域:银座 / 有乐町
主推:店名 A
- 大众点评:评分稳定,评价量够,适合午餐,不需要专门绕路
- 小红书:近期反馈氛围好,拍照友好
备选:店名 B
- 大众点评:离地铁近,排队风险低
- 小红书:更像工作日简餐常见坑
- 只按评分选店,不看它是否在当天区域
- 为了一家店反向规划半天路线
- 把小红书种草当成餐厅硬口碑
- 忽略排队、预约和营业时间
- 搜索词太泛,得到一堆游客店
官方参考
- https://github.com/jackwener/OpenCLI/blob/main/docs/adapters/browser/dianping.md
行程规划方法论
用户输入
最少要有
- 出发和回程日期、到达/离开时间
- 机场和航站楼
- 酒店名和地址
- 想去的点
- 不想去的类型
最好再给
- 人数、年龄段、有无长辈小孩
- 吃饭预算、能否排队、能走多少路
- 喜好:二次元/咖啡/酒吧/甜点/夜景/温泉/拍照/购物
- 行李多不多、交通偏好、愿不愿意早起
输入模板
时间:
到达:
回程:
酒店:
酒店地址:
人数:
年龄段:
一定要去:
可以去:
不要去:
喜欢:
不喜欢:
餐饮预算:
能否排队:
能走多少:
补充:判断顺序
1. 天数够不够 2. 酒店在哪 3. 用户讨厌什么 4. 哪些点看天气 5. 哪些点需要预约 6. 哪些点会在节假日爆炸 7. 最后才补餐厅和咖啡
选点原则
- 一天一个主区域
- 一天只放一个重预约点
- 第一天下轻手
- 最后一天不跑远
- 长距离点只放在非节假日
- 户外点标记天气敏感,优先配附近室内备选
- 用户明确讨厌的类型直接避开
选餐原则
- 先保当天区域顺路
- 再给 2-3 个餐厅候选
- 大众点评看口味、排队、踩雷、值不值得
- 小红书看氛围、近期体验、拍照、软性提醒
- 最后才考虑名气
执行步骤
1. 抽硬约束
到达时间、回程时间、航站楼 → 决定:
- 第一天不能跑远
- 最后一天不能排太满
- 每天按区域走
2. 用户清单分组
- 城内轻松组
- 预约组
- 远郊组
- 可路过组
3. 删高风险点
不是用户写了就全塞。主动删:
- 天数不够的远郊
- 节假日核心段会爆的热门远郊
- 看天气的景点(放不进短行程时删掉,放得进时给室内备选)
明说删了什么,为什么删。
4. 按区域重组
一天一个主区域 → 地铁换乘少、吃饭好插、人累得慢、临时改也容易。
5. 补吃饭
按当天主区域给餐厅候选,而不是为了名店反向扭曲路线。
每顿饭写清:
- 吃饭区域
- 主推候选
- 近距离备选
- 大众点评判断
- 小红书补充判断
餐厅是旅途里的补给点,不是默认锚点。只有预约餐、强目的餐、用户明确指定的店,才允许反过来影响路线。
6. 补票务和交通
只查关键的:官方票务、营业时间、休馆日、机场到酒店、酒店到机场。
7. 写参考文档
先说结论 → 每天主区域 → 天气敏感点 → 每顿饭的区域候选 → 砍掉什么 → 为什么砍 → 提前订什么 → 最后一天怎么去机场。
输出模板
先说结论
每天怎么走
- Day 1
- Day 2
- Day 3
每天吃什么
- 午餐区域 / 主推候选 / 近距离备选
- 晚餐区域 / 主推候选 / 近距离备选
天气敏感点
提前订什么
删掉什么
为什么这样排信息源优先级
1. 官方景点官网、票务页、机场页、交通页 2. 餐厅:大众点评 + 小红书 3. 社媒种草、游记 — 只补感觉,不能当营业时间
常见坑
- 一股脑把用户清单全塞进去
- 把出发前计划当成旅途中必须执行的脚本
- 不看节假日
- 不看天气敏感点
- 不看机场航站楼
- 不看最后一天返程时间
- 不看酒店位置
- 用社媒内容当营业时间
- 为了名店把整天卡死
- 给出"什么都想要"的行程
核心原则
不端水。要替用户删东西。行程不是越满越好,是越顺越好。
行程是出发前的参考坐标,不是旅途中必须执行的脚本。真实执行时,天气、当前位置、体力、饥饿程度都可以覆盖原计划。计划的价值是给 agent 和用户一个清楚的基准:今天在哪个区域,哪些点是锚点,哪些点可放弃,吃饭在哪里找。
用户交互:四拍格式
所有需要用户决策的提问,统一遵循四拍节奏:Re-ground → Simplify → Recommend → Options。缺一不可,顺序不可换。
四拍是什么
| 拍 | 做什么 | 为什么 |
|---|---|---|
| Re-ground | 告诉用户"你在哪" | 用户可能 20 分钟没看屏幕了,打开窗口第一眼必须是定位 |
| Simplify | 用大白话说清在问什么 | 不用术语,不用内部阶段名 |
| Recommend | 给一个明确推荐 + 理由 | 降低决策负担,没异议直接点 |
| Options | 2–4 个可点击选项 | label 自解释,遮住 description 也能选 |
行程规划中的典型场景
场景一:信息不足,需要收集
用户只丢了一句"帮我做个东京行程":
Re-ground: 行程规划, Step 1: 基本信息
Simplify: 先搞清楚几件事,才能排行程。
Recommend: 建议先告诉我日期和酒店,其他我可以帮你选。
Q1 出行信息:
A) 我发截图 — 机票/酒店截图丢过来,我自己提取
B) 我打字说 — 我告诉你日期、酒店、想去的地方
Q2 人数和节奏:
A) 两人,能走 — 标准节奏
B) 带老人/小孩 — 放慢节奏
C) 一个人 — 自由安排场景二:删点确认
用户清单塞不下,需要砍:
Re-ground: 行程规划, 路线取舍
Simplify: 4 天放不下 12 个点,需要砍掉几个。
Recommend: 建议砍远郊(箱根/富士山/伊豆),因为黄金周远郊交通挤、
天数短来回浪费半天,留给下次专门去。
Options:
A) 按你说的砍远郊(推荐)
B) 我一定要去箱根,砍别的
C) 让我看看完整清单再决定场景三:餐厅风格
Re-ground: 行程规划, Day 1 晚餐
Simplify: 第一晚刚到东京,选什么风格的饭。
Recommend: 建议选落地型(KABEAT),离酒店近、出餐稳、不用排队,
刚下飞机别折腾。
Options:
A) 落地型 — KABEAT + 喝一杯(推荐)
B) 打卡型 — 小春轩老洋食 + 柳屋鲷鱼烧
C) 我有别的想法场景四:迭代确认
行程排完,让用户过目:
Re-ground: 行程规划, 方案确认
Simplify: 上面是完整 4 天行程,看看方向对不对。
Options:
A) 没问题,生成地图页面
B) 我要调几个地方
C) 大方向不对,重新来关键原则
- Smart skip — 用户指令已包含的信息,跳过对应问题。用户说了"两个人、不要浅草寺、喜欢吃",就别再问这三个。
- 问题合并 — 没有依赖关系的问题,合并到一次交互。能一次解决的不拆成三次。
- 不问就默认 — 非必要的探索性问题,用户没提就替他决定。只问 agent 自己无法决定的事。
- 严格区分 will 和 is — 还没执行的用"计划"/"接下来会",已完成的用"已经"。说"已经生成"但实际没有,信任直接裂。
反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 没有 Re-ground 直接问 | 用户不知道自己在哪 | 先说"行程规划, Day 1 晚餐"再问 |
| 列选项没推荐 | 选择瘫痪 | 推荐一个,说明为什么 |
| 要求用户打字回答 | 摩擦大 | 提供 A/B/C 选项 |
| 用户已经说清楚了还问 | 浪费时间 | Smart skip |
| 把未执行的说成已完成 | 摧毁信任 | 严格区分 will/is |
小红书调研工作流 (OpenCLI + Chrome CDP)
前提
agent-reach 的小红书 MCP 通道不稳定。稳定方案是 OpenCLI + Chrome CDP。
OpenCLI 安装
GitHub: https://github.com/jackwener/OpenCLI
npm install -g @jackwener/opencli安装后确认 PATH 能找到它。如果全局 npm 装在 ~/.npm-global/bin,需要在 ~/.zshenv 里加:
export PATH="$HOME/.npm-global/bin:$PATH"验证:
opencli --version # 应返回 1.7.0+
opencli doctor # 检查 daemon、extension、Chrome 连通性Browser Bridge 扩展
OpenCLI 需要一个 Chrome 扩展来桥接浏览器:
1. 从 GitHub Releases 下载 opencli-extension.zip 2. 解压到 ~/.opencli/extensions/opencli-extension 3. Chrome 打开 chrome://extensions → 开启「开发者模式」→ 「加载已解压的扩展程序」→ 选上面的目录
OpenCLI 内置小红书命令(备选方案)
OpenCLI 自带 xiaohongshu 适配器,支持 search、note、feed 等命令:
opencli xiaohongshu search '玉ひで 东京' --limit 10 -f json但这条路依赖 Browser Bridge 扩展 + daemon 全部在线,实测不一定稳定。 如果不稳定,走下面的 CDP 直连方案更可靠。
环境路径参考
opencli可执行文件:~/.npm-global/bin/opencliopencli安装目录:~/.npm-global/lib/node_modules/@jackwener/opencli- CDP 实现:
~/.npm-global/lib/node_modules/@jackwener/opencli/dist/src/browser/cdp.js - Browser Bridge 扩展:
~/.opencli/extensions/opencli-extension
最小复现流程
Step 1:启动可调试 Chrome
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' \
--user-data-dir=/tmp/opencli-chrome-cdp \
--profile-directory=Default \
--remote-debugging-port=9223 \
'https://www.xiaohongshu.com/explore'用单独的 user-data-dir,开 9223 调试口,先打开小红书。
Step 2:CDPBridge 连接
import { CDPBridge } from '~/.npm-global/lib/node_modules/@jackwener/opencli/dist/src/browser/cdp.js';
const bridge = new CDPBridge();
const page = await bridge.connect({
cdpEndpoint: 'http://127.0.0.1:9223',
timeout: 10
});Step 3:搜索 — 直接进路由
关键:不要模拟输入框。 小红书前端有双 input、透明 input、联想层、风控逻辑,模拟输入会假成功。
直接导航到搜索结果页:
await bridge.send('Page.navigate', {
url: 'https://www.xiaohongshu.com/search_result?keyword=' + encodeURIComponent(query)
});Step 4:拦截搜索 API
监听网络请求,抓:
POST https://edith.xiaohongshu.com/api/sns/web/v1/search/notes请求体示例:
{
"keyword": "人形町 玉秀",
"page": 1,
"page_size": 20,
"sort": "general",
"note_type": 0
}返回:笔记 id、xsec_token、标题、作者、点赞、收藏、评论。
第一轮筛选不需要开详情页。搜索前排结果就够判断信号强弱。
Step 5:详情页提取
搜索结果拿到 id + xsec_token,拼详情页 URL:
https://www.xiaohongshu.com/explore/<id>?xsec_token=<token>&xsec_source=DOM 提取:
document.querySelector('#detail-title')?.innerText // 标题
document.querySelector('#detail-desc')?.innerText // 正文
document.querySelector('.author-container .username')?.innerText // 作者找不到时退回 document.body.innerText.slice(0, 2000)。
Step 6:两段式流程
1. 搜索结果页抓前 10-20 条 2. 只开最相关的 2-3 条详情页
好处:快、不容易被风控、先判断信号强弱、写进 .md 更干净。
筛选标准
保留(真店信号)
- 店名明确、地址明确、菜品明确
- 有自己体验
- 高频词在多条笔记里重复出现
不保留
- 泛东京合集里顺手带一句
- 标题写酒店/散步,正文才顺手提店
- 明显搬运
能帮决策的信息
优先保留:
- 要不要排队
- 是主餐还是收尾
- 更适合白天还是晚上
- 更像打卡还是更像稳饭
- 容不容易踩空
不优先:纯情绪表达、漂亮但没用的形容、重复三遍的"氛围很好"
写回格式
写回时只留一层结论,不搬笔记原文:
- 店名
- 一条代表笔记链接
- 两三句压缩判断
搜索建议接口
GET https://edith.xiaohongshu.com/api/sns/web/v1/search/recommend?keyword=...能返回联想词。做行程研究价值不大,优先级低。
常见坑
1. agent-reach ≠ 小红书可用 — 先跑 agent-reach doctor,小红书 MCP 没配就别浪费时间 2. 输入框不好惹 — 搜索结果页路由是更稳入口 3. fetch 直接调接口可能被拦 — 返回 code:300011 要求切换账号。最稳还是走真实页面 + CDP 抓响应 4. 搜索结果混地区内容 — 不是噪音,能看出店在片区里的角色,但不能直接当单店口碑
技能拆分建议
SKILL.md:触发条件 + 总流程references/xhs-research.md:本文件references/query-patterns.md(未来可加):搜索词模板references/writeback-format.md(未来可加):压缩模板scripts/(未来可加):Chrome 启动、搜索抓取、批量详情提取