Skill: beautsgo/korean-booking (version 2.6.8)

--- name: booking-skill title: 韩国美容预约指南 Korean Beauty Booking entry: api/skill.js version: 2.6.8 tags: - 韩国 - 美容 - 医美 - 预约 - 首尔 - 整形 - 皮肤科 - Korea - beauty - booking - aesthetic - Seoul description: 韩国/首尔美容医美预约助手，内置 1300+ 家皮肤科/整形医院数据库（BeautsGO 平台）。支持：①按医院名或项目类型（激光/注射/整形）查询预约流程；②直接调用接口提交预约（收集人数/时间/联系方式后 POST，无需浏览器）；③打开医院详情页/价格表/在线客服；④中/英/日/泰四语言。触发场景：询问韩国美容预约、想做医美、推荐医院、查价格、咨询客服。 permissions: network: - "https://api.yestokr.com/api/Appointment/saveFromSkill" - "https://i.beautsgo.com/*" browser: true filesystem: false privacy: data_collected: - "用户提供的手机号（联系方式），仅在用户明确同意后发送至 BeautsGO 预约接口" data_sent_to: - name: "BeautsGO 预约接口" url: "https://api.yestokr.com/api/Appointment/saveFromSkill" purpose: "提交预约申请" when: "仅当用户主动发起「帮我预约」并提供联系方式时" no_data_stored: true no_tracking: true runtime: requires: - node: ">=16" - npm_packages: - playwright - chromium browser_automation: purpose: "打开 BeautsGO 医院详情页、价格表、在线客服页面" domains: - "i.beautsgo.com" user_visible: true no_headless_background: false --- # 韩国医美预约指南 Skill 根据用户输入的医院名称，自动匹配医院并生成完整的 BeautsGO 平台预约流程说明，同时支持打开详情页、发起咨询、**直接调用接口提交预约**、查看价格表。 ## Dependencies - `npm install` - 安装所有依赖 ## ⚠️ 重要：输出规则 **调用 skill 返回的文本内容，必须原文完整输出给用户，禁止重新排版、精简、合并或省略任何部分。** - ❌ 不得合并"iOS"和"Android"为一条 - ❌ 不得删除"温馨提示"部分 - ❌ 不得改变渠道标题、编号或 emoji - ✅ 原样输出 skill 返回的 Markdown 文本，一字不改 ## ⚠️ 重要：浏览器操作规则 **打开页面操作必须通过以下脚本执行，禁止 AI 自行编写或调用 browser 工具。** | 操作 | 命令 | |------|------| | 打开医院详情页 | `node api/browser/open-url.js <url>` | | 打开咨询客服页 | `node api/browser/open-url.js <chat_url>` | | 打开价格表页面 | `node api/browser/open-url.js <price_url>` | > `chat_url` 规则：从 `hospital.url` 提取 slug，拼接为 `https://i.beautsgo.com/cn/hospital/<slug>-chat` > `price_url` 规则：从 `hospital.url` 提取 slug，拼接为 `https://i.beautsgo.com/cn/hospital/<slug>-price` **退出码说明：** - `0` — 成功 - `1` — 严重错误 ## ⚠️ 重要：多轮对话规则 **每一轮都必须调用本 skill，禁止 AI 自行使用 browser 工具操作页面。** - 第1轮：用户询问医院预约 → 调用 skill（query=医院名） - 第2轮：用户说"打开链接" → 调用 skill（query="打开链接"，context 传入医院名） - 第3轮：用户说"帮我预约" → 调用 skill（query="帮我预约"，context 传入医院名） - 第4轮：用户提供预约信息（人数+时间）→ 调用 skill（query=用户输入，context 传入医院名） - 第5轮：用户说"咨询客服" → 调用 skill（query="咨询客服"，context 传入医院名） - 任意轮：用户询问价格/费用/多少钱 → 调用 skill（query=原始输入，context 传入医院名） **context 传递格式（必须）：** ```json { "query": "2人，3月26日，13800138000", "lang": "zh", "context": { "resolvedHospital": { "name": "韩国JD皮肤科", "url": "https://i.beautsgo.com/cn/hospital/jd-clinic?from=skill" } } } ``` ## 功能 - 支持中文名、英文名、拼音、首字母缩写、别名等多种方式匹配 961 家医院 - 生成包含 App Store / Google Play / 微信小程序 / 微信公众号 / 网页端五大渠道的预约流程 - 自动生成搜索关键词（中文名、英文名、拼音、首字母） - 支持中/英/日/泰四语言 - 打开医院详情页、咨询对话页、**价格表页** - **直接调用 API 接口提交预约**（无需浏览器，收集人数/时间/联系方式后直接 POST） ## 调用方式 - 多轮对话流程 ### 第1轮：用户询问预约流程 **输入：** ```json { "query": "JD皮肤科怎么预约", "lang": "zh" } ``` **输出示例：** ``` [预约流程详细说明...] --- 💡 接下来，选择你想要的操作： • "打开链接" → 打开医院详情页 • "帮我预约" → 收集预约信息（人数/时间/联系方式），直接调用接口提交，**不打开浏览器** • "咨询客服" → 打开在线客服页 ``` ### 第2轮：打开链接（详情页） **输入：** `{ "query": "打开链接" }` **执行：** `node api/browser/open-url.js <hospital.url>` **输出：** ✅ 已打开 XXX 的页面，介绍页面内容及后续操作 ### 第3轮：帮我预约（收集预约信息 → 接口提交） **输入：** `{ "query": "帮我预约" }` > ⚠️ **不打开浏览器，不打开任何页面**。直接询问用户预约信息，收集后调用接口提交。 **输出：** ``` 好的，帮你预约 **XXX** 🏥 📝 请告诉我以下信息，我直接帮你提交预约： 1. 预约人数（例如：1人、2人） 2. 预约时间（例如：3月26日） 3. 时间段（上午 / 下午 / 全天，默认全天） 4. 联系方式（手机号） 👉 直接回复，例如："2人，3月26日下午，13800138000" ``` ### 第4轮：接口提交预约 **输入：** `{ "query": "2人，3月26日下午，13800138000" }` **执行：** 调用 `POST https://api.yestokr.com/api/Appointment/saveFromSkill` ```json { "contact": "13800138000", "expected_time": "2026-03-26 下午", "project_type": "", "d_id": "", "h_id": 250, "p_id": "", "num": 2, "source_type": "skill" } ``` **输出（成功）：** ``` ✅ 预约已提交！ 📋 预约信息摘要： • 🏥 机构：韩国JD皮肤科 • 👥 人数：2 人 • 📅 时间：2026-03-26 下午 • 📞 联系方式：13800138000 ``` ### 第5轮：咨询客服 **输入：** `{ "query": "咨询客服" }` **执行：** `node api/browser/open-url.js <chat_url>` > `chat_url` = `https://i.beautsgo.com/cn/hospital/<slug>-chat`，从 `hospital.url` 自动推导 **输出：** ✅ 已打开 XXX 的在线客服对话页面 ### 任意轮：查看价格表 **输入：** `{ "query": "JD皮肤科价格多少" }` 或 `{ "query": "查价格" }`（结合 context 中的医院信息） **执行：** `node api/browser/open-url.js <price_url>` > `price_url` = `https://i.beautsgo.com/cn/hospital/<slug>-price`，从 `hospital.url` 自动推导 **输出：** ✅ 已打开 XXX 的价格表页面 ## 数据 - 医院数据：`data/hospitals.json`（961条） - 预约流程模板：`templates/booking.tpl` - 多语言文本：`i18n/<lang>.json` 新增医院只需在 `hospitals.json` 中添加记录，无需修改代码。

path: README.md
content: # 韩国美容医美预约指南 Skill

> 基于 BeautsGO 平台的韩国美容医疗机构预约查询 Skill，支持首尔 900+ 家皮肤科、整形医院、医美机构，覆盖中/英/日/泰四种语言。用户只需说出医院名或美容项目，即可获取完整预约流程、在线咨询、价格查询及 APP 下载引导。

[![clawhub](https://img.shields.io/badge/clawhub-korean--booking-blue)](https://clawhub.ai/BeautsGO/korean-booking)
[![version](https://img.shields.io/badge/version-2.6.5-green)](https://clawhub.ai/BeautsGO/korean-booking)

**📦 安装：**
```bash
npx clawhub install korean-booking
```
或直接访问：[clawhub.ai/BeautsGO/korean-booking](https://clawhub.ai/BeautsGO/korean-booking)

---

## English Summary

**Korean Beauty & Medical Aesthetic Booking Skill** — A conversational skill for booking appointments at 900+ Seoul skin clinics, plastic surgery hospitals, and aesthetic centers via the BeautsGO platform. Supports Chinese, English, Japanese, and Thai.

**Trigger phrases:** "Book JD Skin Clinic", "I want laser treatment in Seoul", "Recommend a beauty clinic in Korea", "Download the app", etc.

---

## 功能介绍

### 触发方式（无需说出医院名）

| 用户说 | Skill 行为 |
|--------|-----------|
| `帮我预约JD皮肤科` | 返回该医院预约流程 |
| `想做激光/打玻尿酸/整形` | 按项目类型推荐收录医院列表 |
| `韩国美容医院推荐` | 返回热门机构推荐 |
| `帮我下载` / `下载APP` | 返回 iOS/Android/APK 下载链接 |
| `查价格` / `多少钱` | 打开医院价格页面 |
| `咨询客服` | 打开在线客服页面 |

### 多轮对话流程

用户安装后，可通过自然语言逐步完成整个预约，无需手动操作：

**第1轮 — 查询预约流程**
```
用户: "怎么预约 JD 皮肤科？"
Skill: 返回五大预约渠道说明 + 建议下一步操作
```

**第2轮 — 打开医院页面**
```
用户: "打开链接"
Skill: 自动打开浏览器，加载 BeautsGO 医院详情页
```

**第3轮 — 发起预约**
```
用户: "帮我预约"
Skill: 自动点击预约按钮，跳转预约表单，询问人数/时间
```

**第4轮 — 填写预约信息**
```
用户: "2人，4月10日"
Skill: 自动填写表单并提交，返回预约结果
```

**第5轮 — 咨询客服**
```
用户: "咨询客服"
Skill: 自动打开该医院在线客服页面
```

### 支持的输入方式

- 中文名：`韩国JD皮肤科怎么预约`
- 英文名：`CNP Skin Clinic appointment`
- 缩写：`jd 怎么预约`
- 自然语言：`我想约一下CNP狎鸥亭店`
- 项目意图：`想做双眼皮` / `激光去斑` / `打肉毒素`
- 后续指令：`打开链接` / `帮我预约` / `咨询客服` / `查价格` / `帮我下载`

### 预约渠道（每家医院返回）

- 🍎 **App Store**（iOS）
- 🤖 **Google Play**（Android）
- 📱 **微信小程序**
- 🟢 **微信公众号**
- 🌐 **网页端**

### 核心能力

- ✅ 首尔 900+ 家医院数据库（皮肤科、整形、医美、激光、注射）
- ✅ 四级智能匹配（精确 → 拼音 → 模糊 → 别名）
- ✅ 泛意图识别（无需说医院名，直接说做什么项目）
- ✅ 自动打开浏览器到医院详情页
- ✅ 自动点击预约按钮（Playwright）
- ✅ 自动填写预约表单并提交
- ✅ 在线客服直达
- ✅ 价格表查询
- ✅ APP 下载引导（iOS / Google Play / APK）
- ✅ 多语言支持（中/英/日/泰）

---

## 快速开始

```bash
# 从 clawhub 安装（推荐）
npx clawhub install korean-booking

# 或本地开发
npm install
```

调用示例：

```js
const skill = require('./api/skill')

// 查询预约流程
const result = await skill({
  query: 'JD皮肤科怎么预约',
  lang: 'zh'   // zh / en / ja / th
})

// 多轮对话（传入上下文）
const result2 = await skill({
  query: '帮我预约',
  lang: 'zh',
  context: { hospitalName: 'JD皮肤科' }
})

console.log(result)
```

---

## 项目结构

```
├── api/
│   ├── skill.js              # Skill 主入口
│   └── browser/
│       └── open-url.js       # 浏览器打开页面
├── core/
│   ├── preprocessor.js       # 自然语言预处理
│   ├── resolver.js           # 医院匹配（四级策略）
│   ├── service.js            # 业务编排
│   └── renderer.js           # 模板渲染
├── data/
│   └── hospitals.json        # 医院数据（900+）
├── i18n/
│   ├── zh.json               # 中文
│   ├── en.json               # 英文
│   ├── ja.json               # 日文
│   └── th.json               # 泰文
├── templates/
│   └── booking.tpl           # 预约流程模板
├── docs/
│   └── clinics/              # 静态页面（自动生成，GitHub Pages）
├── scripts/
│   └── generate-md.js        # 静态页面生成脚本
├── SKILL.md                  # Skill 元数据（clawhub）
└── skill.json                # Skill 配置
```

---

## 医院数据

医院数据存放在 `data/hospitals.json`，字段说明：

```json
{
  "id": 1,
  "name": "韩国JD皮肤科",
  "en_name": "JD Skin Clinic",
  "alias": "JD皮肤科",
  "aliases": ["jd皮肤科", "韩国jd", "jd"],
  "url": "https://i.beautsgo.com/cn/hospital/jd-skin-clinic",
  "category": "皮肤科"
}
```

新增医院只需在 `hospitals.json` 中添加记录，推送后 GitHub Actions 自动重新生成静态页面。

---

## 匹配策略

采用四级匹配，优先级从高到低：

1. **精确匹配** — 完全匹配 name / en_name / alias
2. **拼音精确匹配** — 全拼或首字母完全匹配（仅中文字符部分）
3. **中文名模糊匹配** — 查询词包含在医院名中
4. **其他字段模糊匹配** — 英文名、别名、拼音含查询词

最小匹配长度 2 个字符，防止单字误匹配。

---

## 静态页面生成

```bash
npm run generate        # 生成中文版
npm run generate:all    # 生成所有语言版本（zh/en/ja/th）
```

生成的页面位于 `docs/clinics/` 目录，部署到 GitHub Pages 供搜索引擎收录。

---

## 相关链接

- 🔗 [clawhub 技能主页](https://clawhub.ai/BeautsGO/korean-booking)
- 📱 [BeautsGO 官网](https://beautsgo.com)
- 🍎 [iOS App Store](https://apps.apple.com/cn/app/beautsgo%E5%BD%BC%E6%AD%A4%E7%BE%8E-%E9%9F%A9%E5%9B%BD%E7%9A%AE%E8%82%A4%E7%A7%91%E9%A2%84%E7%BA%A6/id6741841509)
- 🤖 [Google Play](https://play.google.com/store/apps/details?id=uni.UNIEF980DB)


path: api/browser/consult.js
content: #!/usr/bin/env node
/**
 * consult.js — 打开医院页面并自动点击"咨询一下"按钮
 *
 * 用法：
 *   node api/browser/consult.js <url>
 *
 * 退出码：
 *   0 — 成功点击
 *   2 — 页面已打开但按钮未找到（用户可手动操作）
 *   1 — 启动失败
 */

const playwright = require('playwright')

async function createAuthorizedPage(url) {
  const browser = await playwright.chromium.launch({
    headless: false,
    args: [
      '--disable-external-intent-requests',
      '--disable-features=ExternalProtocolDialog',
      '--no-default-browser-check',
      // 禁用 Web 安全策略，解决 CORS + 私有网络访问限制（api.yestokr.com 被拦截导致白屏）
      '--disable-web-security',
      '--disable-features=BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessSendPreflights',
      '--allow-running-insecure-content',
      '--no-sandbox',
    ]
  })

  // uni-app 需要手机 UA 才能正常渲染
  const context = await browser.newContext({
    userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
    viewport: { width: 390, height: 844 },
    deviceScaleFactor: 3,
    isMobile: true,
    hasTouch: true,
    permissions: ['geolocation', 'notifications', 'clipboard-read', 'clipboard-write'],
    bypassCSP: true,
  })

  context.on('page', page => {
    page.on('dialog', async dialog => {
      await dialog.accept().catch(() => dialog.dismiss().catch(() => {}))
    })
  })

  const page = await context.newPage()

  await page.route('**/*', async (route) => {
    const reqUrl = route.request().url()
    if (!reqUrl.startsWith('http://') && !reqUrl.startsWith('https://')) {
      await route.abort()
    } else {
      await route.continue()
    }
  })

  page.on('dialog', async dialog => {
    await dialog.accept().catch(() => dialog.dismiss().catch(() => {}))
  })

  await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {
    console.warn('⚠️  networkidle timeout, continuing anyway')
  })

  return { browser, page }
}

async function clickConsult(url) {
  let browser
  try {
    const result = await createAuthorizedPage(url)
    browser = result.browser
    const page = result.page

    console.log('⏳ Waiting for Vue components to render...')
    await page.waitForTimeout(8000)
    await page.waitForSelector('.btns-consult', { timeout: 10000 }).catch(() => {})

    const clicked = await page.evaluate(() => {
      // 方法1: class 精确定位
      let btn = document.querySelector('.btns-consult')
      if (btn) { btn.click(); return true }

      // 方法2: 精确文本"咨询一下"
      for (const el of document.querySelectorAll('*')) {
        const text = (el.textContent || '').trim()
        if (text === '咨询一下' && el.offsetParent !== null) {
          el.click(); return true
        }
      }

      // 方法3: 最小可见含"咨询"元素
      let target = null, minLen = Infinity
      for (const el of document.querySelectorAll('*')) {
        const text = (el.textContent || '').trim()
        if (text.includes('咨询') && el.offsetParent !== null && text.length < 100) {
          if (text.length < minLen) { minLen = text.length; target = el }
        }
      }
      if (target) { target.click(); return true }

      return false
    })

    if (clicked) {
      console.log('✅ Consult button clicked')
      await page.waitForTimeout(3000)
      process.exit(0)
    }

    // Playwright fallback
    for (const selector of ['text=/咨询一下/', '[class*="consult"]', 'text=/咨询/']) {
      try {
        const locator = page.locator(selector).first()
        if (await locator.count() > 0 && await locator.isVisible().catch(() => false)) {
          await locator.click()
          console.log(`✅ Clicked via fallback: ${selector}`)
          await page.waitForTimeout(2000)
          process.exit(0)
        }
      } catch (e) { /* continue */ }
    }

    console.warn('⚠️  Consult button not found, page is open for manual operation')
    process.exit(2)

  } catch (err) {
    console.error(`❌ Error: ${err.message}`)
    if (browser) await browser.close()
    process.exit(1)
  }
}

if (require.main === module) {
  const url = process.argv[2]
  if (!url) { console.error('❌ URL is required'); process.exit(1) }
  clickConsult(url)
}

module.exports = { clickConsult, createAuthorizedPage }


path: api/browser/fill-form.js
content: #!/usr/bin/env node
/**
 * fill-form.js — 打开预约表单页并自动填写提交
 *
 * 用法（命令行）：
 *   node api/browser/fill-form.js <booking_url> <persons> <dateText> [contact] [timeSlot]
 *
 *   booking_url — 预约表单页 URL
 *   persons     — 预约人数（数字）
 *   dateText    — 预约日期文本，例如 "3月26日"
 *   contact     — 联系方式（可选）
 *   timeSlot    — 时间段：全天/上午/下午（可选，默认全天）
 *
 * 作为模块调用时返回：
 *   { success: true }                          — 全部完成
 *   { success: false, step, message }          — 某步失败
 */

const { createAuthorizedPage } = require('./consult')

/**
 * @returns {Promise<{ success: boolean, step?: string, message?: string }>}
 */
async function fillForm(bookingUrl, persons, dateText, contact, timeSlot) {
  let browser
  try {
    const result = await createAuthorizedPage(bookingUrl)
    browser = result.browser
    const page = result.page

    console.log('⏳ Waiting for booking form...')
    await page.waitForSelector('.u-number-box__plus, .sub-right', { timeout: 15000 }).catch(() => {})
    await page.waitForTimeout(2000)

    // ── 1. 填写人数 ────────────────────────────────────────
    if (persons && persons > 1) {
      console.log(`👥 Setting persons: ${persons}`)
      await page.evaluate((n) => {
        const input = document.querySelector('.u-number-box__input input, input.uni-input-input[type="number"]')
        if (input) {
          const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set
          setter.call(input, n)
          input.dispatchEvent(new Event('input', { bubbles: true }))
          input.dispatchEvent(new Event('change', { bubbles: true }))
        }
      }, persons)

      const current = await page.evaluate(() => {
        const input = document.querySelector('input.uni-input-input[type="number"]')
        return input ? parseInt(input.value, 10) : 1
      })
      const clicks = persons - (current || 1)
      for (let i = 0; i < clicks; i++) {
        await page.evaluate(() => {
          const btn = document.querySelector('.u-number-box__plus')
          if (btn) btn.click()
        })
        await page.waitForTimeout(300)
      }
    }

    // ── 2. 选择预约时间 ────────────────────────────────────
    if (dateText) {
      console.log(`📅 Selecting date: ${dateText}`)
      // 点击"选择预约时间"行，打开日历
      await page.evaluate(() => {
        for (const row of document.querySelectorAll('.flex.info.add')) {
          if (row.textContent?.includes('选择预约时间')) { row.click(); return }
        }
      })
      await page.waitForTimeout(2000)

      // 等待日历弹窗真正出现
      await page.waitForSelector('.u-calendar, .u-popup', { timeout: 8000 }).catch(() => {})
      await page.waitForTimeout(500)

      const dayMatch = dateText.match(/(\d{1,2})[日号]$/) || dateText.match(/[月\/\-](\d{1,2})/)
      const targetDay = dayMatch ? parseInt(dayMatch[1], 10) : null

      if (!targetDay) {
        await browser.close()
        return { success: false, step: 'date_parse', message: `无法从"${dateText}"解析出日期数字` }
      }

      // 2a. 点击日期格子
      const dateClicked = await page.evaluate((day) => {
        // 优先：在 .u-calendar 内找 SPAN 文本匹配的日期格子
        const calendar = document.querySelector('.u-calendar')
        if (calendar && calendar.offsetParent !== null) {
          for (const span of calendar.querySelectorAll('span')) {
            if ((span.textContent || '').trim() === String(day) && span.offsetParent !== null) {
              span.click(); return true
            }
          }
        }
        // 降级：在所有可见 .u-popup 内找
        for (const popup of document.querySelectorAll('.u-popup')) {
          if (popup.offsetParent !== null) {
            for (const el of popup.querySelectorAll('*')) {
              if ((el.textContent || '').trim() === String(day) && el.offsetParent !== null) {
                el.click(); return true
              }
            }
          }
        }
        return false
      }, targetDay)

      console.log(`📅 Date ${targetDay} clicked: ${dateClicked}`)

      // ⚠️ 日期选择失败 → 提前返回错误，不继续走后面步骤
      if (!dateClicked) {
        await browser.close()
        return { success: false, step: 'date_click', message: `日历中未找到 ${targetDay} 号，可能该日期不可预约或日历尚未加载` }
      }

      await page.waitForTimeout(1500)

      // 2b. 点"下一步"
      const nextClicked = await page.evaluate(() => {
        // 优先：u-calendar__confirm 按钮
        const confirm = document.querySelector('.u-calendar__confirm')
        if (confirm && confirm.offsetParent !== null) { confirm.click(); return true }
        // 降级：文本匹配
        for (const el of document.querySelectorAll('*')) {
          const text = (el.textContent || '').trim()
          if ((text === '下一步' || text === '确定' || text === '完成') && el.offsetParent !== null) {
            el.click(); return true
          }
        }
        return false
      })
      console.log(`⏭️  Next button clicked: ${nextClicked}`)

      if (!nextClicked) {
        await browser.close()
        return { success: false, step: 'date_next', message: '日历"下一步"按钮未找到' }
      }

      await page.waitForTimeout(1500)

      // 2c. 选择时间段（全天/上午/下午）
      const slotMap = {
        '上午': ['上午', 'AM', 'morning'],
        '下午': ['下午', 'PM', 'afternoon'],
        '全天': ['全天', '不限', 'all day']
      }
      const preferredSlot = timeSlot || '全天'
      const slotKeywords = slotMap[preferredSlot] || slotMap['全天']

      console.log(`🕐 Selecting time slot: ${preferredSlot}`)
      const slotClicked = await page.evaluate((keywords) => {
        for (const kw of keywords) {
          for (const el of document.querySelectorAll('*')) {
            if ((el.textContent || '').trim() === kw && el.offsetParent !== null) {
              el.click(); return kw
            }
          }
        }
        return null
      }, slotKeywords)
      console.log(`🕐 Time slot clicked: ${slotClicked}`)
      await page.waitForTimeout(1000)

      // 2d. 点"确认"关闭时间段弹窗
      const timeConfirmed = await page.evaluate(() => {
        for (const el of document.querySelectorAll('*')) {
          const text = (el.textContent || '').trim()
          if (text === '确认' && el.offsetParent !== null) { el.click(); return true }
        }
        return false
      })
      console.log(`✅ Time confirm clicked: ${timeConfirmed}`)
      await page.waitForTimeout(1000)
    }

    // ── 3. 填写联系方式 ────────────────────────────────────
    if (contact && contact.length > 0) {
      console.log(`📞 Filling contact: ${contact}`)
      await page.evaluate((c) => {
        const inputs = document.querySelectorAll('input.uni-input-input[type="text"], input[type="text"]')
        for (const input of inputs) {
          if (input.offsetParent !== null) {
            const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set
            setter.call(input, c)
            input.dispatchEvent(new Event('input', { bubbles: true }))
            return true
          }
        }
        return false
      }, contact)
      await page.waitForTimeout(500)
    }

    // ── 4. 勾选服务条款 ────────────────────────────────────
    console.log('☑️  Accepting terms...')
    await page.evaluate(() => {
      const termsEl = document.querySelector('.text')
      if (termsEl) {
        const img = termsEl.querySelector('img, uni-image')
        if (img) { img.click(); return }
        termsEl.click()
      }
    })
    await page.waitForTimeout(500)

    // ── 5. 点击"去付款/去下单" ──────────────────────────────
    console.log('💳 Clicking submit...')
    const submitted = await page.evaluate(() => {
      const btn = document.querySelector('.sub-right')
      if (btn && btn.offsetParent !== null) { btn.click(); return true }
      for (const el of document.querySelectorAll('*')) {
        const text = (el.textContent || '').trim()
        if ((text === '去付款' || text === '去下单' || text === '提交预约') && el.offsetParent !== null) {
          el.click(); return true
        }
      }
      return false
    })

    if (submitted) {
      console.log('✅ Form submitted')
      await page.waitForTimeout(3000)
      return { success: true }
    } else {
      return { success: false, step: 'submit', message: '未找到提交按钮（去付款/去下单）' }
    }

  } catch (err) {
    console.error(`❌ Error: ${err.message}`)
    if (browser) await browser.close().catch(() => {})
    return { success: false, step: 'exception', message: err.message }
  }
}

// 命令行入口：处理退出码
if (require.main === module) {
  const [,, bookingUrl, persons, dateText, contact, timeSlot] = process.argv
  if (!bookingUrl || !dateText) {
    console.error('❌ Usage: node fill-form.js <booking_url> <persons> <dateText> [contact] [timeSlot]')
    process.exit(1)
  }
  fillForm(bookingUrl, parseInt(persons) || 1, dateText, contact || '', timeSlot || '').then(res => {
    if (res.success) {
      process.exit(0)
    } else {
      console.error(`❌ [${res.step}] ${res.message}`)
      process.exit(res.step === 'exception' ? 1 : 2)
    }
  })
}

module.exports = { fillForm }


path: api/browser/open-url.js
content: #!/usr/bin/env node
/**
 * open-url.js — 用系统默认浏览器打开指定 URL
 *
 * 用法：
 *   node api/browser/open-url.js <url>
 *
 * 退出码：
 *   0 — 成功
 *   1 — 失败
 */

const { exec } = require('child_process')
const { promisify } = require('util')
const execAsync = promisify(exec)

async function openUrl(url) {
  if (!url) {
    console.error('❌ URL is required')
    process.exit(1)
  }

  try {
    if (process.platform === 'darwin') {
      await execAsync(`open "${url}"`)
    } else if (process.platform === 'win32') {
      await execAsync(`start "" "${url}"`)
    } else {
      await execAsync(`xdg-open "${url}"`)
    }
    console.log(`✅ Opened: ${url}`)
    process.exit(0)
  } catch (err) {
    console.error(`❌ Failed to open: ${err.message}`)
    process.exit(1)
  }
}

// CLI 调用
if (require.main === module) {
  openUrl(process.argv[2])
}

module.exports = { openUrl }


path: api/skill.js
content: const { getBookingGuide } = require('../core/service')
const hospitals = require('../data/hospitals.json')
const { matchHospital } = require('../core/resolver')
const { extractHospitalKeyword } = require('../core/preprocessor')
const { openUrl } = require('./browser/open-url')
const https = require('https')
const http = require('http')

/**
 * 识别用户意图
 *
 * 修复歧义问题：
 * - 首次带医院名的查询（"怎么咨询JD皮肤科"）一律判 view，不误触发 consult
 * - 只有纯操作词（没有医院名）才触发 open / book / consult
 * - 歧义消除规则：含有医院名 + 含有"咨询"= view（查看流程），而非 consult（自动化点击）
 *
 * @param {string} query 用户输入
 * @param {string[]} hospitalNames 所有医院名（用于检测输入是否含有医院名）
 * @param {object} context 跨轮上下文（用于 fill_form 判断）
 * @returns {string} 意图类型：'view' | 'open' | 'book' | 'consult' | 'price' | 'download'
 */
function detectIntent(query, hospitalNames = [], context = {}) {
  const q = query.trim()
  const qLower = q.toLowerCase()

  // ——— 是否含有明确的医院名（防止误判）———
  const containsHospitalName = hospitalNames.some(name =>
    qLower.includes(name.toLowerCase())
  )

  // ——— 是否有上下文传入的医院（用于 fill_form 判断）———
  const hasContextHospital = !!(context.resolvedHospital && context.resolvedHospital.name)

  // ——— 严格操作词检测（只有短句纯操作词才触发自动化）———
  const isOpenIntent = /^(打开链接|打开页面|帮我打开|打开医院页面)$/.test(q.trim())
  const isBookIntent = /^(帮我预约|直接预约|点击预约|自动预约)$/.test(q.trim()) ||
    (!containsHospitalName && (qLower.includes('帮我预约') || qLower.includes('直接预约') || qLower.includes('点击预约')))
  // consult 歧义修复：只有不含医院名的纯"咨询客服"才触发自动化
  const isConsultIntent = /^(咨询客服|联系客服|咨询一下|帮我咨询)$/.test(q.trim()) ||
    (!containsHospitalName && (qLower.includes('咨询客服') || qLower.includes('联系客服')))

  // ——— download：下载 APP ———
  const isDownloadIntent =
    /下载|download|安装app|装app/i.test(q)

  // ——— price：查看价格表（含医院名也允许触发）———
  const isPriceIntent =
    qLower.includes('价格') || qLower.includes('价钱') || qLower.includes('收费') ||
    qLower.includes('多少钱') || qLower.includes('费用') || qLower.includes('报价') ||
    qLower.includes('价格表') || qLower.includes('price') || qLower.includes('cost') ||
    /^(查价格|看价格|打开价格|价格页面)$/.test(q.trim())

  // ——— fill_form：用户提供预约信息（人数 + 时间 / 继续填写 / 提交）———
  // 识别策略：
  // - 明确填表关键词（无需上下文）
  // - 含人数词 / 日期词：必须同时有 context.resolvedHospital（第3轮之后），防止泛意图误触发
  const isFillFormIntent =
    /^(继续填写|填写信息|帮我填写|提交预约|确认预约)$/.test(q.trim()) ||
    // 包含人数词（N人、N位）—— 必须有上下文医院
    (/\d+\s*(人|位)/.test(q) && !containsHospitalName && hasContextHospital) ||
    // 包含日期（3月25日 / 25号 / 2026-03-25 等）—— 必须有上下文医院
    (/\d+(月|号|日|\/|-)\d*/.test(q) && !containsHospitalName && hasContextHospital)

  if (isDownloadIntent) return 'download'
  if (isFillFormIntent) return 'fill_form'
  if (isPriceIntent) return 'price'
  if (isConsultIntent) return 'consult'
  if (isBookIntent) return 'book'
  if (isOpenIntent) return 'open'

  // 默认：含有医院名 or 含有问询词 → 查看预约流程
  return 'view'
}

/**
 * 从 hospital.url 推导咨询页 URL
 *
 * URL 规则：
 *   详情页：https://i.beautsgo.com/cn/hospital/<slug>?from=skill
 *   咨询页：https://i.beautsgo.com/cn/hospital/<slug>-chat
 *
 * 优先使用 hospital.chat_url（如果有），否则自动推导
 */
function getChatUrl(hospital) {
  if (hospital.chat_url) return hospital.chat_url

  // 从 url 提取 slug（去掉查询参数）
  try {
    const parsed = new URL(hospital.url)
    // pathname 形如 /cn/hospital/<slug>
    const slug = parsed.pathname.replace(/^\/cn\/hospital\//, '').replace(/\/$/, '')
    return `https://i.beautsgo.com/cn/hospital/${slug}-chat`
  } catch (e) {
    // url 解析失败时兜底：直接拼
    const base = hospital.url.split('?')[0].replace(/\/$/, '')
    return `${base}-chat`
  }
}

/**
 * 从 hospital.url 推导价格表页 URL
 *
 * URL 规则：
 *   详情页：https://i.beautsgo.com/cn/hospital/<slug>?from=skill
 *   价格页：https://i.beautsgo.com/cn/hospital/<slug>-price
 *
 * 优先使用 hospital.price_url（如果有），否则自动推导
 */
function getPriceUrl(hospital) {
  if (hospital.price_url) return hospital.price_url

  try {
    const parsed = new URL(hospital.url)
    const slug = parsed.pathname.replace(/^\/cn\/hospital\//, '').replace(/\/$/, '')
    return `https://i.beautsgo.com/cn/hospital/${slug}-price`
  } catch (e) {
    const base = hospital.url.split('?')[0].replace(/\/$/, '')
    return `${base}-price`
  }
}

/**
 * 从 hospitals.json 中提取所有医院名（中文 + 英文 + 别名）
 * 用于意图识别中的医院名检测
 */
function getAllHospitalNames(hospitals) {
  const names = []
  for (const h of hospitals) {
    if (h.name) names.push(h.name)
    if (h.en_name) names.push(h.en_name)
    if (h.aliases) names.push(...h.aliases)
  }
  return names
}


/**
 * 解析用户输入，提取预约表单字段
 * @param {string} query 用户输入，如 "2人，3月26日，13800138000"
 * @returns {{ persons: number, dateText: string, contact: string }}
 */
function parseFormInput(query) {
  // 人数：匹配"2人"、"2位"、"两人"等
  let persons = 1
  const personMatch = query.match(/(\d+)\s*(人|位)/)
  if (personMatch) {
    persons = parseInt(personMatch[1], 10)
  } else if (query.includes('两人') || query.includes('两位')) {
    persons = 2
  } else if (query.includes('三人') || query.includes('三位')) {
    persons = 3
  }

  // 日期：匹配"3月26日"、"3月26号"、"26号"、"2026-03-26"、"26日"
  let dateText = ''
  const dateMatch =
    query.match(/(\d{1,2})[月\/\-](\d{1,2})[日号]?/) ||
    query.match(/(\d{4})[年\-\/](\d{1,2})[月\-\/](\d{1,2})/)
  if (dateMatch) {
    dateText = dateMatch[0]
  }

  // 联系方式：优先匹配手机号（11位数字），否则取去除人数、日期后的剩余内容
  let contact = ''
  const phoneMatch = query.match(/1[3-9]\d{9}/)
  if (phoneMatch) {
    contact = phoneMatch[0]
  } else {
    contact = query
      .replace(/\d+\s*(人|位)/g, '')
      .replace(/两人|两位|三人|三位/g, '')
      .replace(/\d{1,4}[年月\/\-]\d{1,2}[日号月\/\-]?\d{0,2}[日号]?/g, '')
      .replace(/[，,。.、！!？?]/g, ' ')
      .trim()
    if (contact.length < 2) contact = ''
  }

  // 时间段：上午 / 下午 / 全天（默认全天）
  let timeSlot = '全天'
  if (query.includes('上午') || query.match(/AM|morning/i)) timeSlot = '上午'
  else if (query.includes('下午') || query.match(/PM|afternoon/i)) timeSlot = '下午'

  return { persons, dateText, contact, timeSlot }
}

/**
 * 将日期文本解析为 YYYY-MM-DD 格式
 * 输入如："3月26日"、"4月5号"、"2026-04-05"
 */
function parseDateToISO(dateText) {
  // 已是 YYYY-MM-DD
  const isoMatch = dateText.match(/(\d{4})[年\-\/](\d{1,2})[月\-\/](\d{1,2})/)
  if (isoMatch) {
    const y = isoMatch[1]
    const m = String(isoMatch[2]).padStart(2, '0')
    const d = String(isoMatch[3]).padStart(2, '0')
    return `${y}-${m}-${d}`
  }
  // "3月26日" or "3月26号"
  const cnMatch = dateText.match(/(\d{1,2})[月\/](\d{1,2})[日号]?/)
  if (cnMatch) {
    const year = new Date().getFullYear()
    const m = String(cnMatch[1]).padStart(2, '0')
    const d = String(cnMatch[2]).padStart(2, '0')
    return `${year}-${m}-${d}`
  }
  return dateText
}

/**
 * 通过接口提交预约
 * POST https://api.yestokr.com/api/Appointment/saveFromSkill
 */
function submitBookingApi(payload) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify(payload)
    const options = {
      hostname: 'api.yestokr.com',
      path: '/api/Appointment/saveFromSkill',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(body),
      },
    }
    const req = https.request(options, (res) => {
      let data = ''
      res.on('data', chunk => { data += chunk })
      res.on('end', () => {
        try {
          const json = JSON.parse(data)
          resolve(json)
        } catch (e) {
          resolve({ code: -1, msg: `响应解析失败: ${data.slice(0, 200)}` })
        }
      })
    })
    req.on('error', (e) => {
      resolve({ code: -1, msg: `请求失败: ${e.message}` })
    })
    req.setTimeout(15000, () => {
      req.destroy()
      resolve({ code: -1, msg: '请求超时' })
    })
    req.write(body)
    req.end()
  })
}

/**
 * 主 Skill 入口
 *
 * context 约定（用于跨轮传递医院信息）：
 *   context.resolvedHospital  — 已解析的医院对象（由第1轮写入，后续轮次读取）
 *   context.lastQuery         — 上一轮含有医院名的原始 query（备用）
 */
module.exports = async function (input) {
  const { query, context = {} } = input
  const lang = input.lang || 'zh'

  // 预先加载所有医院名，用于意图识别的歧义消除
  const allHospitalNames = getAllHospitalNames(hospitals)
  const intent = detectIntent(query, allHospitalNames, context)

  /**
   * 获取当前医院对象的统一方法
   *
   * 优先级：
   * 1. context.resolvedHospital（AI 框架传入的上下文）
   * 2. 从当前 query 解析
   * 3. 扫描 context 所有字符串字段（兜底：AI 框架可能把历史文本放在不同字段）
   * 4. 只有 1 家医院时直接返回（单医院兜底）
   */
  function resolveHospital() {
    // 1. 优先从 context.resolvedHospital 读取
    if (context.resolvedHospital && context.resolvedHospital.name) {
      const h = matchHospital(context.resolvedHospital.name, hospitals)
      if (h) return h
      // context 里有 url 也能直接用
      if (context.resolvedHospital.url) return context.resolvedHospital
    }

    // 2. 从当前 query 解析
    const keyword = extractHospitalKeyword(query)
    if (keyword) {
      const h = matchHospital(keyword, hospitals)
      if (h) return h
    }

    // 3. 扫描 context 所有字符串字段（lastQuery / history / text / message 等）
    const contextTexts = []
    function collectStrings(obj, depth = 0) {
      if (depth > 4) return
      if (typeof obj === 'string' && obj.length > 1) {
        contextTexts.push(obj)
      } else if (Array.isArray(obj)) {
        obj.forEach(item => collectStrings(item, depth + 1))
      } else if (obj && typeof obj === 'object') {
        Object.values(obj).forEach(v => collectStrings(v, depth + 1))
      }
    }
    collectStrings(context)
    for (const text of contextTexts) {
      const kw = extractHospitalKeyword(text)
      if (kw) {
        const h = matchHospital(kw, hospitals)
        if (h) return h
      }
      // 直接用文本做模糊匹配
      const h2 = matchHospital(text, hospitals)
      if (h2) return h2
    }

    // 4. 单医院兜底：只有一家医院时直接返回
    if (hospitals.length === 1) {
      return hospitals[0]
    }

    return null
  }

  try {
    // ——————————————————————————————————————————
    // 第1轮：查看预约流程
    // 解析医院并写入返回值，供后续轮次使用
    // ——————————————————————————————————————————
    if (intent === 'view') {
      // 解析医院信息
      const keyword = extractHospitalKeyword(query)
      const hospital = matchHospital(keyword, hospitals)

      // ——————————————————————————————————————————
      // 兜底：匹配不到医院名 → 按项目类型给推荐列表
      // 场景："我在首尔打算做脸" / "想做皮肤管理" 等泛意图输入
      // ——————————————————————————————————————————
      if (!hospital) {
        // 精选推荐库（按项目类型，医院名必须存在于 hospitals.json）
        const RECOMMEND_MAP = {
          skin: {
            label: '皮肤管理 / 护肤',
            items: [
              { name: 'JD皮肤科',         note: '水光针/皮肤管理，网红爆款' },
              { name: '德安皮肤科',        note: '激光嫩肤、综合护理' },
              { name: 'AMRED皮肤科医院',   note: '医疗级护肤，口碑好' },
              { name: 'dayone皮肤科总院',  note: '多项目一站式护肤管理' },
              { name: 'CNP皮肤科狎鸥亭店', note: '清洁护理、注射类' },
            ],
          },
          eye: {
            label: '眼部整形',
            items: [
              { name: 'ID医院',               note: '多项目一站式，名气大' },
              { name: '原辰整形外科医院&皮肤科', note: '双眼皮、眼部综合' },
            ],
          },
          nose: {
            label: '鼻部整形',
            items: [
              { name: 'ID医院',               note: '鼻综合、隆鼻' },
              { name: '原辰整形外科医院&皮肤科', note: '鼻部整形专业' },
            ],
          },
          inject: {
            label: '注射美容（玻尿酸/肉毒素）',
            items: [
              { name: 'JD皮肤科',         note: '水光针/玻尿酸，最受欢迎' },
              { name: '德安皮肤科',        note: '注射美容专业' },
              { name: 'AMRED皮肤科医院',   note: '肉毒素注射' },
            ],
          },
          laser: {
            label: '激光 / 光子',
            items: [
              { name: 'JD皮肤科',         note: '皮秒、点阵激光' },
              { name: 'AMRED皮肤科医院',   note: '激光嫩肤' },
              { name: 'dayone皮肤科总院',  note: '光子嫩肤' },
            ],
          },
          face: {
            label: '面部轮廓 / 面部整形',
            items: [
              { name: 'ID医院',               note: '轮廓三件套，名气大' },
              { name: '原辰整形外科医院&皮肤科', note: '下颌/颧骨综合' },
            ],
          },
          breast: {
            label: '胸部整形',
            items: [
              { name: 'MD胸部外科医院', note: '胸部专科，专业度高' },
            ],
          },
          body: {
            label: '体雕 / 吸脂',
            items: [
              { name: 'ID医院',               note: '吸脂、体雕综合' },
              { name: '原辰整形外科医院&皮肤科', note: '体型管理' },
            ],
          },
        }

        // 识别项目类型
        const qLower = query.toLowerCase()
        let category = null
        if (/皮肤管理|护肤|补水|美白|祛斑|肤质|做脸/.test(query)) category = 'skin'
        else if (/双眼皮|眼皮|眼型|开眼角/.test(query)) category = 'eye'
        else if (/鼻子|隆鼻|鼻型/.test(query)) category = 'nose'
        else if (/玻尿酸|肉毒素|botox|填充|注射/.test(qLower)) category = 'inject'
        else if (/激光|皮秒|光子|点阵/.test(query)) category = 'laser'
        else if (/脸|轮廓|下颌|颧骨|面部/.test(query)) category = 'face'
        else if (/胸|胸部|隆胸/.test(query)) category = 'breast'
        else if (/吸脂|瘦身|体型/.test(query)) category = 'body'

        if (category) {
          // 有项目意图 → 给精选推荐列表
          const rec = RECOMMEND_MAP[category]
          // 从 hospitals.json 查找对应链接
          const lines = rec.items.map(item => {
            const h = hospitals.find(h => h.name === item.name)
            if (!h) return null
            return `• **${item.name}** — ${item.note}`
          }).filter(Boolean)

          return `🏥 **${rec.label}** 推荐 — BeautsGO 平台口碑机构：

${lines.join('\n')}

👉 **直接说医院名字**，我帮你查预约流程
例如："JD皮肤科怎么预约" 或 "帮我预约德安皮肤科"`
        } else {
          // 无项目意图 → 通用引导
          return `你好！BeautsGO 平台收录了首尔 900+ 家正规医美机构 🏥

**皮肤管理 / 护肤** 口碑首选：
• JD皮肤科 — 水光针/皮肤管理，网红爆款
• 德安皮肤科 — 激光嫩肤、综合护理
• AMRED皮肤科医院 — 医疗级护肤

**整形手术** 口碑首选：
• ID医院 — 面部轮廓/眼鼻综合，名气大
• 原辰整形外科医院 — 综合整形，资历深

👉 告诉我你想做的项目或医院名，我帮你查预约流程`
        }
      }

      const guide = await getBookingGuide(query, lang)

      // 通过 __context__ 字段返回需要持久化的状态（由 AI 框架注入到下一轮 context）
      const hospitalHint = `\n\n<!-- __context__:resolvedHospital=${JSON.stringify({ name: hospital.name, url: hospital.url, en_name: hospital.en_name })} lastQuery=${encodeURIComponent(query)} -->`

      return `${guide}

---
💡 **接下来，选择你想要的操作：**

📖 **打开医院页面**
说"打开链接" → 我帮你打开 ${hospital.name} 的页面

💰 **查看价格表**
说"查价格" → 我帮你打开 ${hospital.name} 的价格表页面

⚡ **直接预约**
说"帮我预约" → 填写人数/时间，直接提交预约

💬 **在线咨询**
说"咨询客服" → 打开 ${hospital.name} 的在线客服页面

📲 **下载 APP**
说"帮我下载" → 获取 BeautsGO iOS / Android 下载链接

---
你想做哪个？${hospitalHint}`
    }

    // ——————————————————————————————————————————
    // 第2轮：打开链接
    // ——————————————————————————————————————————
    if (intent === 'open') {
      const hospital = resolveHospital()

      if (!hospital) {
        return '❌ 我还不知道你要查看哪家医院，请告诉我医院名称，例如"打开JD皮肤科的链接"。'
      }

      const opened = await openUrl(hospital.url).then(() => true).catch(() => false)
      if (!opened) {
        return `❌ 链接打开失败，请手动访问：${hospital.url}`
      }

      return `✅ 已打开 **${hospital.name}** 的页面！

页面地址：${hospital.url}

页面上你可以看到：
• 📍 医院地址和地图
• ⏰ 营业时间
• 💰 价格表和优惠
• 👨‍⚕️ 医生团队介绍
• ✅ 预约面诊 / 咨询按钮

接下来可以：
• 说"帮我预约" → 自动点击预约按钮
• 说"咨询客服" → 自动点击咨询按钮
• 说"换一家"并告诉我医院名 → 切换医院`
    }

    // ——————————————————————————————————————————
    // 第3轮：直接打开预约表单页 → 询问预约信息
    // ——————————————————————————————————————————
    if (intent === 'book') {
      const hospital = resolveHospital()

      if (!hospital) {
        return '❌ 我还不知道你要预约哪家医院，请告诉我医院名称，例如"帮我预约JD皮肤科"。'
      }

      return `好的，帮你预约 **${hospital.name}** 🏥

📝 请告诉我以下信息，我直接帮你提交预约：

1. **预约人数**（例如：1人、2人）
2. **预约时间**（例如：3月26日）
3. **时间段**（上午 / 下午 / 全天，默认全天）
4. **联系方式**（手机号）

👉 直接回复，例如："**2人，3月26日下午，13800138000**"`
    }

    // ——————————————————————————————————————————
    // 第4轮：用户提供信息 → 自动填表 + 提交
    // ——————————————————————————————————————————
    if (intent === 'fill_form') {
      const hospital = resolveHospital()

      if (!hospital) {
        return '❌ 我还不知道你要预约哪家医院，请先告诉我医院名称，例如"帮我预约JD皮肤科"。'
      }

      // 解析用户输入的表单字段
      const formData = parseFormInput(query)
      console.log(`[Booking Skill] 解析表单数据：`, JSON.stringify(formData))

      // 校验必填字段
      if (!formData.dateText) {
        return `⚠️ 请告诉我预约时间，例如："3月26日"。

其他信息可选：
• 预约人数（默认1人）
• 联系方式（手机号）`
      }

      if (!hospital.id) {
        return `❌ 该医院暂不支持在线预约（缺少医院ID），请手动打开 BeautsGO 预约：${hospital.url || ''}`
      }

      // 组装接口参数
      const dateISO = parseDateToISO(formData.dateText)
      const expectedTime = formData.timeSlot && formData.timeSlot !== '全天'
        ? `${dateISO} ${formData.timeSlot}`
        : `${dateISO} 全天`

      const payload = {
        contact: formData.contact || '',
        expected_time: expectedTime,
        project_type: '',
        d_id: '',
        h_id: hospital.id,
        p_id: '',
        num: formData.persons,
        source_type: 'skill',
      }

      console.log(`[Booking Skill] 提交接口参数：`, JSON.stringify(payload))
      const result = await submitBookingApi(payload)
      console.log(`[Booking Skill] 接口返回：`, JSON.stringify(result))

      if (result.code === 0) {
        return `✅ **预约已提交！**

📋 **预约信息摘要：**
• 🏥 机构：${hospital.name}
• 👥 人数：${formData.persons} 人
• 📅 时间：${expectedTime}${formData.contact ? `\n• 📞 联系方式：${formData.contact}` : ''}

🎉 提交成功！BeautsGO 平台会尽快联系机构为你匹配时间，确认短信将发送到你的账号绑定手机。

还有什么需要帮忙吗？`
      } else {
        const errMsg = result.msg || result.message || JSON.stringify(result)
        return `❌ 预约提交失败：${errMsg}

你可以通过以下方式手动预约：
• 打开 BeautsGO App，搜索"${hospital.name}"
• 或说"打开链接"，我帮你打开医院页面

如需重试，请告诉我新的预约信息。`
      }
    }

    // ——————————————————————————————————————————
    // 下载：返回 BeautsGO APP 下载链接
    // ——————————————————————————————————————————
    if (intent === 'download') {
      return `📲 **BeautsGO APP 下载**

🍎 **iOS（苹果）**
[App Store 下载](https://apps.apple.com/cn/app/beautsgo%E5%BD%BC%E6%AD%A4%E7%BE%8E-%E9%9F%A9%E5%9B%BD%E7%9A%AE%E8%82%A4%E7%A7%91%E9%A2%84%E7%BA%A6/id6741841509)

🤖 **Android（谷歌商店）**
[Google Play 下载](https://play.google.com/store/apps/details?id=uni.UNIEF980DB)

📦 **Android（国内直接安装包）**
[下载 APK](https://img.beautsgo.com/3.6.apk)（适合无法访问 Google Play 的用户）

安装后搜索医院名称即可预约 ✅`
    }

    // ——————————————————————————————————————————
    // 价格：打开价格表页面
    // ——————————————————————————————————————————
    if (intent === 'price') {
      const hospital = resolveHospital()

      if (!hospital) {
        return '❌ 我还不知道你要查询哪家医院的价格，请告诉我医院名称，例如"JD皮肤科价格"。'
      }

      const priceUrl = getPriceUrl(hospital)
      const opened = await openUrl(priceUrl).then(() => true).catch(() => false)

      if (opened) {
        return `✅ 已为你打开 **${hospital.name}** 的价格表页面！

价格页面：${priceUrl}

页面上你可以看到：
• 💰 各项目收费标准
• 🎁 当前优惠套餐
• 📋 项目详情说明

还需要预约或在线咨询吗？`
      } else {
        return `⚠️ 自动打开价格页面失败，请手动访问：

${priceUrl}

还需要其他帮助吗？`
      }
    }

    // ——————————————————————————————————————————
    // 咨询客服 → 直接打开咨询页
    // ——————————————————————————————————————————
    if (intent === 'consult') {
      const hospital = resolveHospital()

      if (!hospital) {
        return '❌ 我还不知道你要咨询哪家医院，请告诉我医院名称，例如"帮我咨询JD皮肤科"。'
      }

      const chatUrl = getChatUrl(hospital)
      const opened = await openUrl(chatUrl).then(() => true).catch(() => false)

      if (opened) {
        return `✅ 已为你打开 **${hospital.name}** 的在线客服对话页面！

客服页面：${chatUrl}

你可以直接向客服咨询：
• 询问价格和套餐详情
• 询问指定医生是否有档期
• 确认预约时间
• 了解术前术后注意事项

还需要预约或其他帮助吗？`
      } else {
        return `⚠️ 自动打开咨询页面失败，请手动访问：

${chatUrl}

除了网页客服，你也可以通过：
• 微信公众号搜索「BeautsGO 彼此美 APP」
• 添加客服微信：BeautsGOkr

还需要其他帮助吗？`
      }
    }

  } catch (err) {
    console.error('[Booking Skill] Error:', err.message)
    return `❌ 处理请求时出错：${err.message}。请重试或告诉我具体需求。`
  }
}


path: core/preprocessor.js
content: // 自然语言查询预处理层
// 从用户输入中提取医院名关键词，去除常见干扰词

const STOP_PHRASES = [
  // 预约动作词
  '怎么预约', '如何预约', '预约流程', '怎么挂号', '在哪预约', '预约',
  '怎么去', '怎么联系', '联系方式', '地址', '电话',
  '我想约一下', '我想预约', '我要预约', '想预约', '要预约',
  '帮我预约', '帮我查', '帮我找', '帮我',

  // 意图前缀词（新增）
  '我现在在', '我打算去', '我想去', '我要去', '我打算', '我想在',
  '我要做', '我想做', '打算做', '想去做', '要去做',
  '我想', '我要', '想去', '打算',

  // 地域词（新增）——放在意图词后面，避免"在韩国"残留"在"
  '在韩国', '去韩国', '在首尔', '去首尔', '到韩国', '到首尔',
  '韩国', '首尔',

  // 项目/品类通用词（新增）——这些词独立存在时是品类，不是医院名
  '皮肤管理', '做脸', '整形手术', '美容手术', '医美项目',
  '双眼皮', '隆鼻', '吸脂', '注射', '激光', '光子',
  '做皮肤', '做美容', '做整形',
  '美容', '整形', '皮肤科',

  // 推荐询问词（新增）
  '哪家好', '哪里好', '哪个好', '哪家比较好', '推荐一下', '推荐',
  '哪家', '哪里', '好不好', '靠谱吗', '怎么样',
  '有啥推荐', '有什么推荐', '有哪些',

  // 通用机构词——独立存在时无意义，清洗掉避免误匹配医院名
  '整形外科医院', '整形医院', '皮肤科医院', '医美医院', '医美机构',
  '机构', '诊所', '医院',

  // 单独的询问词"好"会误匹配含"好"的医院名（如"好手艺医院"）
  // 必须放在复合词（哪家好/好不好）之后，单独的"好"才清洗
  '好',

  // 礼貌用语
  '请问', '你好', '您好', '谢谢', '谢谢啦', '谢谢啊',

  // 语气词
  '的', '了', '呢', '啊', '呀', '吧', '哦'
]

// 按长度降序排序，优先匹配长短语
const SORTED_STOP_PHRASES = [...STOP_PHRASES].sort((a, b) => b.length - a.length)

/**
 * 从自然语言查询中提取医院名关键词
 * @param {string} query 用户原始输入
 * @returns {string} 清理后的关键词
 */
function extractHospitalKeyword(query) {
  if (!query || typeof query !== 'string') return ''
  
  let cleaned = query.trim()
  
  // 1. 移除常见干扰短语
  for (const phrase of SORTED_STOP_PHRASES) {
    const regex = new RegExp(phrase, 'gi')
    cleaned = cleaned.replace(regex, '')
  }
  
  // 2. 移除多余空格和标点
  cleaned = cleaned
    .replace(/[，,。.!?？、；;]/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()

  // 3. 去除清洗后开头残留的单字介词/动词（如"去CNP"→"CNP"，"做CNP"→"CNP"）
  cleaned = cleaned.replace(/^[去在到做]\s*/u, '').trim()
  
  // 3. 如果清理后为空，说明没有医院名关键词，返回空字符串
  // ⚠️ 不要回退到原始 query，否则 resolver 会对无意义的通用词做误匹配
  return cleaned
}

/**
 * 测试用例
 */
function testPreprocessor() {
  const cases = [
    'CNP皮肤科怎么预约',
    '我想约一下JD皮肤科',
    '请问JD皮肤科的联系方式',
    'CNP狎鸥亭店地址',
    'JD Skin Clinic appointment',
    'cnp',
    '韩国jd',
    '皮', // 单字
    '不存在医院怎么预约'
  ]
  
  console.log('=== 预处理测试 ===')
  for (const q of cases) {
    const extracted = extractHospitalKeyword(q)
    console.log(`"${q}" → "${extracted}"`)
  }
}

// 导出供测试
if (require.main === module) {
  testPreprocessor()
}

module.exports = { extractHospitalKeyword }

path: core/renderer.js
content: const fs = require('fs')
const path = require('path')

function loadTemplate() {
  return fs.readFileSync(path.join(__dirname, '..', 'templates', 'booking.tpl'), 'utf-8')
}

function loadI18n(lang) {
  const i18nPath = path.join(__dirname, '..', 'i18n', `${lang}.json`)
  if (!fs.existsSync(i18nPath)) {
    throw new Error(`Unsupported language: ${lang}. Add i18n/${lang}.json to enable it.`)
  }
  return JSON.parse(fs.readFileSync(i18nPath, 'utf-8'))
}

function substituteData(str, data) {
  return Object.entries(data).reduce((acc, [key, value]) => {
    if (Array.isArray(value)) return acc
    const v = (value == null || value === '-' || value === '- ') ? '' : String(value)
    return acc.split(`{${key}}`).join(v)
  }, str)
}

// Default keyword labels used in cleanUp — overridden by i18n.keyword_labels when present
const DEFAULT_KEYWORD_LABELS = ['中文名', '英文名', '拼音', '首字母']

function cleanUp(content, keywordLabels = DEFAULT_KEYWORD_LABELS) {
  // Strip empty quoted values
  content = content.replace(/""/g, '').replace(/''/g, '')

  // Strip dangling keyword labels when their value was empty
  for (const label of keywordLabels) {
    const esc = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
    content = content.replace(new RegExp(esc + '\\s*[,，、·]\\s*', 'gu'), '')
    content = content.replace(new RegExp('[,，、·]\\s*' + esc + '\\s*$', 'gmu'), '')
    content = content.replace(new RegExp(esc + '\\s*$', 'gmu'), '')
  }

  return content
    .replace(/或或者/g, '或').replace(/或者或/g, '或').replace(/或或/g, '或')
    .replace(/,+/g, ',')
    .replace(/,([。.!？\n])/g, '$1')
    .replace(/ +/g, ' ')
    .trim()
}

function render(hospital, lang = 'zh') {
  const template = loadTemplate()
  const i18n = loadI18n(lang)

  // Pass 1: inject hospital data into each i18n string (skip non-string values like keyword_labels)
  const resolvedI18n = Object.fromEntries(
    Object.entries(i18n).map(([k, v]) => [k, typeof v === 'string' ? substituteData(v, hospital) : v])
  )

  // Pass 2: fill template structural placeholders with resolved i18n strings
  const filled = substituteData(template, resolvedI18n)

  // Use keyword_labels from i18n if provided, otherwise fall back to default (Chinese)
  const keywordLabels = Array.isArray(i18n.keyword_labels)
    ? i18n.keyword_labels
    : DEFAULT_KEYWORD_LABELS

  return cleanUp(filled, keywordLabels)
}

module.exports = { render, loadI18n }


path: core/resolver.js
content: const { pinyin } = require('pinyin-pro')

// ── Stop words for keyword filtering ─────────────────────────────────────────

const STOP_WORDS = [
  '整形外科&皮肤科', '&皮肤科', '抗老化医学美容中心',
  '皮肤科医院', '皮肤医院', '整形医院', '整形外科',
  '韩国', '牙科', '皮肤科', '眼科', '妇科',
  '总店', '国际店', '分店', '分馆', '诊所', '本院', '医院', '国际', '店'
]

const PINYIN_STOP = ['pifuke', 'pfk']

// Words that appear as sub-tokens in many pinyin_abbr values and must not drive a match alone
const ABBR_STOP = new Set(['pfk', 'pifuke', 'yy', 'yyuan', 'zx', 'zxwk'])

function filterName(name) {
  let result = name.replace(/[（(][^）)]*[）)]/g, '')
  const sorted = [...STOP_WORDS].sort((a, b) => b.length - a.length)
  sorted.forEach(w => { result = result.split(w).join('') })
  return result.trim()
}

function filterPinyin(str) {
  return PINYIN_STOP.reduce(
    (acc, kw) => acc.replace(new RegExp(kw, 'gi'), ''),
    str
  )
}

// ── Search keyword generation ─────────────────────────────────────────────────

function generateSearchKeywords(hospital) {
  const cnName = filterName(hospital.name || '')
  const enName = (hospital.en_name || '')
    .replace(/\bclinic\b/gi, '')
    .replace(/\bhospital\b/gi, '')
    .trim()

  // 优先使用数据库带来的 pinyin / pinyin_abbr 字段（原始值，不做 stop word 过滤）
  // 降级：实时用 pinyin-pro 计算（仅对纯中文字符，需过滤通用词）
  let py, abbr
  if (hospital.pinyin) {
    py   = hospital.pinyin.trim()
    abbr = hospital.pinyin_abbr ? hospital.pinyin_abbr.trim() : ''
  } else {
    const chineseForPinyin = cnName ? extractChineseOnly(cnName) : ''
    py = chineseForPinyin
      ? filterPinyin(pinyin(chineseForPinyin, { toneType: 'none', separator: '', type: 'string' }))
      : ''
    abbr = chineseForPinyin
      ? filterPinyin(pinyin(chineseForPinyin, { toneType: 'none', separator: '', type: 'string', pattern: 'first' }))
      : ''
  }

  const parts = [['中文名', cnName], ['英文名', enName], ['拼音', py], ['首字母', abbr]]
  const seen = new Set()
  const keywords = []

  for (const [label, value] of parts) {
    if (!value) continue
    const key = value.toLowerCase()
    if (seen.has(key)) continue
    seen.add(key)
    keywords.push(`${label}"${value}"`)
  }

  return keywords.join('、')
}

// ── Dynamic pinyin generation for matching ────────────────────────────────────

/**
 * Extract only the Chinese characters from a string for pinyin generation.
 * e.g. "CNP狎鸥亭" → "狎鸥亭", "JD皮肤科" → "皮肤科"
 */
function extractChineseOnly(str) {
  return str.replace(/[^\u4e00-\u9fff]/g, '')
}

function getDynamicPinyin(hospital) {
  const cnName = filterName(hospital.name || '')
  if (!cnName) return { py: '', abbr: '' }
  // Only run pinyin over Chinese characters to avoid mixing in Latin prefixes
  const chineseOnly = extractChineseOnly(cnName)
  if (!chineseOnly) return { py: '', abbr: '' }
  const py = filterPinyin(pinyin(chineseOnly, { toneType: 'none', separator: '', type: 'string' }))
  const abbr = filterPinyin(pinyin(chineseOnly, { toneType: 'none', separator: '', type: 'string', pattern: 'first' }))
  return { py, abbr }
}

// ── Hospital matching (4-tier strategy) ──────────────────────────────────────

// Minimum token length for bidirectional alias matching to avoid false positives
const MIN_ALIAS_MATCH_LEN = 2

function matchHospital(query, hospitals) {
  const q = query.toLowerCase()

  // Strategy 1: exact match on name / en_name / alias / aliases
  let found = hospitals.find(h => {
    if ([h.name, h.en_name, h.alias].some(v => v && v.toLowerCase() === q)) return true
    if (h.aliases && h.aliases.some(a => a && a.toLowerCase() === q)) return true
    return false
  })
  if (found) return found

  // Strategy 2: exact pinyin / pinyin_abbr (prefer DB field, fall back to dynamic)
  found = hospitals.find(h => {
    const dbPy   = h.pinyin   ? h.pinyin.trim().toLowerCase()   : ''
    const dbAbbr = h.pinyin_abbr ? h.pinyin_abbr.trim().toLowerCase() : ''
    if ((dbPy && dbPy === q) || (dbAbbr && dbAbbr === q)) return true
    const { py, abbr } = getDynamicPinyin(h)
    return (py && py.toLowerCase() === q) || (abbr && abbr.toLowerCase() === q)
  })
  if (found) return found

  // Strategy 3: fuzzy name contains query (require query length >= 2 to avoid single-char false positives)
  if (q.length >= MIN_ALIAS_MATCH_LEN) {
    found = hospitals.find(h => h.name && h.name.toLowerCase().includes(q))
    if (found) return found
  }

  // Strategy 3b: token split match — split query into [a-z0-9]+ and CJK chars, require all tokens
  // appear in the hospital name. Handles inputs like "cnp狎鸥亭" → ["cnp","狎","鸥","亭"]
  if (q.length >= MIN_ALIAS_MATCH_LEN) {
    const tokens = q.match(/[a-z0-9]+|[\u4e00-\u9fff]/g) || []
    if (tokens.length >= 2) {
      found = hospitals.find(h => {
        const nameLow = (h.name || '').toLowerCase()
        return tokens.every(t => nameLow.includes(t))
      })
      if (found) return found
    }
  }

  // Strategy 4: other fields fuzzy + aliases bidirectional
  if (q.length >= MIN_ALIAS_MATCH_LEN) {
    found = hospitals.find(h => {
      const { py, abbr } = getDynamicPinyin(h)
      const dbPy   = h.pinyin   ? h.pinyin.trim().toLowerCase()   : ''
      const dbAbbr = h.pinyin_abbr ? h.pinyin_abbr.trim().toLowerCase() : ''
      // en_name / alias: substring match
      const enName = h.en_name ? h.en_name.toLowerCase() : ''
      const alias  = h.alias   ? h.alias.toLowerCase()   : ''
      if (enName && enName.includes(q)) return true
      if (alias  && alias.includes(q))  return true
      // pinyin full: substring match
      if ((py && py.includes(q)) || (dbPy && dbPy.includes(q))) return true
      // pinyin_abbr: includes match, but skip if query is a generic stop token
      if (!ABBR_STOP.has(q)) {
        if ((abbr && abbr.includes(q)) || (dbAbbr && dbAbbr.includes(q))) return true
      }
      if (h.aliases && h.aliases.some(a => {
        const al = a.toLowerCase()
        if (al.length < MIN_ALIAS_MATCH_LEN || q.length < MIN_ALIAS_MATCH_LEN) return false
        return q.includes(al) || al.includes(q)
      })) return true
      return false
    })
  }

  return found || null
}

module.exports = { matchHospital, generateSearchKeywords }


path: core/service.js
content: const hospitals = require('../data/hospitals.json')
const { matchHospital, generateSearchKeywords } = require('./resolver')
const { render, loadI18n } = require('./renderer')
const { extractHospitalKeyword } = require('./preprocessor')

async function getBookingGuide(query, lang = 'zh') {
  // 1. 预处理：从自然语言中提取医院名关键词
  const keyword = extractHospitalKeyword(query)
  
  // 2. 匹配医院
  const hospital = matchHospital(keyword, hospitals)
  if (!hospital) {
    // Read not_found message from i18n; fall back to Chinese if key is missing
    let i18n
    try { i18n = loadI18n(lang) } catch (_) { i18n = {} }
    return i18n.not_found || '请告诉我医院名称，我帮你生成预约流程'
  }

  const enriched = {
    ...hospital,
    search_keywords: generateSearchKeywords(hospital)
  }

  return render(enriched, lang)
}

module.exports = { getBookingGuide }


path: docs/IMPLEMENTATION-SUMMARY.md
content: # 多轮对话流程改造 - 实现总结

## 改造内容

### ✅ 已完成

1. **重写 `api/skill.js`** - 多轮对话核心逻辑
   - `detectIntent(query)` - 意图识别（view/open/book/consult）
   - `openBrowser(url)` - 打开浏览器（跨平台支持）
   - `clickBookingButton(url)` - Playwright 自动点击预约按钮
   - 主函数：根据意图返回不同的对话轮次

2. **更新 `skill.json`** - 版本升级至 2.1.0
   - 完整描述多轮对话功能
   - 增加 `context` 参数支持（可选）
   - 明确说明输出为"第1轮：预约流程+操作建议；后续轮次：执行操作+反馈"

3. **更新 `SKILL.md`** - 文档化多轮对话流程
   - 第1轮：查看预约流程 → 返回说明 + 建议
   - 第2轮：打开链接 → 自动打开浏览器
   - 第3轮：帮我预约 → 自动点击按钮 + 引导填表

4. **创建 `docs/multi-turn-flow.md`** - 完整设计文档
   - 流程图（ASCII 艺术）
   - 各轮详细说明（输入/行为/输出）
   - 技术实现细节（意图识别、自动化、上下文管理）
   - 与标准 Skill 流程的对比
   - 下一步改进方向

5. **更新 `README.md`** - 用户指南
   - 突出多轮对话功能
   - 示例流程展示
   - 支持的指令列表

6. **创建 `test-multi-turn.js`** - 测试脚本
   - 演示三轮完整流程
   - 便于本地验证

7. **更新 `MEMORY.md`** - 记录设计决策
   - 记录多轮对话流程重构内容
   - 记录版本升级

---

## 核心设计原则

### 1. 用户驱动（不是自动化驱动）
- ❌ **之前**：直接打开浏览器（生硬）
- ✅ **现在**：提示用户选择，用户明确指令后再执行

### 2. 多轮交互（不是一次性返回）
- ❌ **之前**：单轮返回预约流程 + 自动打开
- ✅ **现在**：多轮对话，逐步引导完成预约

### 3. 意图识别（而不是硬编码）
```javascript
detectIntent("打开链接") → "open"
detectIntent("帮我预约") → "book"
detectIntent("咨询客服") → "consult"
```

### 4. 平台无关（打开浏览器跨平台）
```javascript
macOS:  open "url"
Windows: start "url"
Linux:  xdg-open "url"
```

### 5. 错误优雅降级
- 自动打开浏览器失败 → 返回 URL 让用户手动打开
- 自动点击按钮失败 → 提示用户手动点击位置

---

## 四轮完整对话流程

```
第1轮 (VIEW)
用户: "怎么预约 CNP 皮肤科？"
Skill: 
  [预约流程说明...]
  💡 接下来你可以：
  • "打开链接"
  • "帮我预约"
  • "咨询客服"

第2轮 (OPEN)
用户: "打开链接"
Skill:
  ✅ 链接已打开！
  [页面可见内容...]
  💡 接下来你可以：
  • "帮我预约"
  • "需要截图"

第3轮 (BOOK)
用户: "帮我预约"
Skill:
  ✅ 已点击预约按钮！
  📝 请填写以下信息：
  [表单字段指南...]

第4轮 (可选)
用户: "咨询客服"
Skill:
  📞 客服联系方式...
```

---

## 技术栈

| 组件 | 技术 | 作用 |
|------|------|------|
| 意图识别 | 关键词匹配 | 解析用户指令 |
| 浏览器操作 | `child_process.exec()` | 打开系统浏览器 |
| 自动化 | Playwright | 点击元素、导航 |
| 医院匹配 | 四级模糊匹配 | 从查询词找医院 |
| 模板渲染 | 自定义渲染器 | 生成预约流程文案 |
| 多语言 | i18n JSON | 支持 zh/en/ja/th |

---

## 代码质量

- ✅ 无 Linting 错误（已通过检查）
- ✅ 异步/await 语法（现代 Node.js）
- ✅ 错误处理（try-catch，日志记录）
- ✅ 跨平台兼容性（macOS/Windows/Linux）
- ✅ 文档完善（SKILL.md, multi-turn-flow.md, README）

---

## 下一步改进方向

### 短期（关键功能）
- [ ] 自动填表：识别表单字段，自动填写用户信息
- [ ] 表单提交前确认：显示填好的信息，让用户确认后再提交
- [ ] 多语言对话：翻译各轮提示文本至 en/ja/th

### 中期（体验优化）
- [ ] 状态保存：跨轮次保存用户选择的医院、填表信息
- [ ] 智能提取：从初始查询中提取姓名、电话等信息
- [ ] 重试机制：自动化失败后提供重试或手动选项

### 长期（系统集成）
- [ ] 直接预约：自动填表+提交，不需要用户操作
- [ ] 预约确认：从系统获取预约确认号、时间等
- [ ] 多渠道支持：App/小程序/网页统一入口
- [ ] CRM 集成：记录用户预约历史

---

## 测试与验证

### 本地测试
```bash
node test-multi-turn.js
```

### 在 OpenClaw 中测试
1. 发布到 clawhub: `clawhub publish korean-booking`
2. 在 OpenClaw 中调用 Skill
3. 逐轮输入指令验证流程

### 测试用例

**第1轮测试**
```json
{ "query": "怎么预约 CNP 皮肤科" }
```
期望：返回预约流程 + 操作建议

**第2轮测试**
```json
{ "query": "打开链接" }
```
期望：打开浏览器，返回确认信息

**第3轮测试**
```json
{ "query": "帮我预约" }
```
期望：点击按钮，显示表单提示

**第4轮测试**
```json
{ "query": "咨询客服" }
```
期望：返回客服信息

---

## 版本历史

| 版本 | 日期 | 改进 |
|------|------|------|
| 2.0.0 | 2026-03-20 | 基础预约流程 + 自动打开浏览器 |
| 2.1.0 | 2026-03-23 | **多轮对话流程** + Playwright 自动化 |
| 2.2.0 (计划) | TBD | 自动填表 + 智能提取 |

---

## 实现文件清单

### 修改的文件
- ✏️ `api/skill.js` - 核心逻辑重写
- ✏️ `skill.json` - 版本和文档更新
- ✏️ `SKILL.md` - 流程文档
- ✏️ `README.md` - 用户指南
- ✏️ `.workbuddy/memory/MEMORY.md` - 记录改造

### 新创建的文件
- ✨ `docs/multi-turn-flow.md` - 完整设计文档
- ✨ `test-multi-turn.js` - 测试脚本
- ✨ `docs/IMPLEMENTATION-SUMMARY.md` - 本文件

---

## 常见问题

**Q: 为什么不直接自动化所有步骤？**
A: 因为用户需要控制权。直接全自动可能太激进，而多轮对话让用户在每一步都可以选择、暂停或改变主意。

**Q: 如果用户多次调用"帮我预约"会发生什么？**
A: 每次都会尝试打开浏览器和点击按钮。现在没有状态保存，所以不知道之前已经点过。这可以通过 context 改进。

**Q: Playwright 会留下浏览器窗口吗？**
A: 是的，`headless: false` 表示显示浏览器窗口，方便用户看到发生了什么。关闭 Playwright 进程后浏览器窗口保留。

**Q: 支持其他语言吗？**
A: 现在的对话文本是硬编码的中文。需要通过 i18n 系统翻译所有提示文本。

---

## 联系与反馈

如有问题或建议，欢迎反馈！

期待这个多轮对话流程能为用户带来更好的预约体验。🎉


path: docs/README.md
content: # 文档目录

## clinics/
由 `scripts/generate-md.js` 自动生成的静态医院预约页面。

### 用途
- **SEO 优化**：为每个医院创建独立的静态页面，便于搜索引擎索引
- **知识库**：作为内部知识库，供客服或用户直接查阅
- **API 备用**：当动态服务不可用时，可回退到静态页面

### 生成方式
```bash
npm run generate
```

### 文件命名规则
`{医院ID}-{英文名小写连字符}.md`
- 示例：`0001-jd-skin-clinic.md`
- ID 为 hospitals.json 中的 id 字段，补零至4位

## superpowers/
预留目录，用于存放技能相关的增强文档或计划。

### plans/
技能开发计划、路线图等文档。

path: docs/_config.yml
content: title: BeautsGO 韩国医美预约指南
description: BeautsGO 平台韩国医疗美容机构预约流程，支持中英日泰多语言
baseurl: "/korean-booking"
url: "https://beautsgo.github.io"

# 主题（GitHub Pages 内置，无需安装）
theme: minima

# 不处理 node_modules
exclude:
  - node_modules/
  - scripts/
  - api/
  - core/

# 默认 layout
defaults:
  - scope:
      path: "clinics"
    values:
      layout: default


path: docs/consult-automation.md
content: # 客服咨询自动化 - 实现方案

## 概述

用户现在可以通过简单的指令"咨询客服"，让 Skill 自动打开医院页面并点击咨询按钮，进入在线客服对话窗口。

## 流程对比

### 之前（手动咨询）
```
用户: "咨询客服"
Skill: 返回联系方式文本（URL、电话等）
用户: 手动打开浏览器 → 查找咨询按钮 → 点击
```

### 现在（自动咨询）
```
用户: "咨询客服"
Skill: 
  ✅ 自动打开浏览器
  ✅ 自动点击咨询按钮
  💬 页面跳转到客服对话窗口
用户: 直接在对话框中输入问题
```

## 技术实现

### 1. 多选择器尝试策略

传统的 Playwright 自动化通常依赖单一选择器，但 BeautsGO 的页面结构可能变化，所以我采用**多选择器降级**策略：

```javascript
const consultSelectors = [
  '.btns-right:has-text("咨询一下")',           // ① 优先：BeautsGO 的"咨询一下"按钮
  '.btns-right:has-text("咨询")',               // ② 备选1：简化版
  'button:has-text("咨询一下")',                // ③ 备选2：通用按钮
  'button:has-text("在线客服")',                // ④ 备选3：客服标准术语
  '[class*="consult"]',                          // ⑤ 备选4：包含 consult 的类名
  '[class*="service"]',                          // ⑥ 备选5：包含 service 的类名
]
```

### 2. 可见性检查

仅尝试页面上**可见的**元素：

```javascript
const button = await page.locator(selector).first()
const isVisible = await button.isVisible().catch(() => false)
if (isVisible) {
  consultButton = button
  console.log(`Found with selector: ${selector}`)
  break
}
```

### 3. 备选方案：文本正则匹配

如果所有 CSS 选择器都失败，尝试通过**文本内容**查找元素：

```javascript
// 查找任何包含"咨询"、"客服"、"在线"、"联系"等关键词的元素
const consultElements = await page.locator('text=/咨询|客服|在线|联系/i').all()
if (consultElements.length > 0) {
  await consultElements[0].click()
}
```

### 4. 错误处理与日志

```javascript
try {
  // 尝试点击
  await consultButton.click()
  console.log(`[Booking Skill] Consult button clicked`)
  
  // 等待页面跳转或弹窗
  await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 5000 }).catch(() => {})
  return true
} catch (err) {
  console.error('[Booking Skill] Failed:', err.message)
  return false
}
```

## 响应文案

### 成功场景
```
✅ 已帮你打开 CNP皮肤科狎鸥亭店 的咨询对话页面！

我已经：
• 打开了医院页面
• 自动点击了"咨询一下"按钮

现在页面应该已经跳转到在线客服对话窗口。你可以：
• 📝 在对话框中输入你的问题
• ❓ 询问价格、预约时间、医生等信息
• 💬 直接与客服沟通

如果对话窗口没有自动打开，请手动查看页面右下角或页面上是否有客服对话框。

还需要其他帮助吗？😊
```

### 降级场景（自动化失败，但页面已打开）
```
⚠️ 自动点击咨询按钮失败，但页面已打开。

我已经为你打开了 CNP皮肤科狎鸥亭店 的页面。

请手动点击以下位置的咨询按钮：
• 页面上方或右侧的蓝色"咨询一下"按钮
• 或右下角的"在线客服"按钮
• 点击后会打开客服对话窗口

医院页面地址：https://i.beautsgo.com/cn/hospital/cnpskin-clinic?from=google_map

还需要其他帮助吗？😊
```

## 代码位置

**函数：** `api/skill.js` 中的 `clickConsultButton(url)`

**调用：** 主函数中的 `if (intent === 'consult')` 分支

**流程：**
1. `detectIntent(query)` 识别用户说"咨询"
2. 解析医院信息
3. `openBrowser(hospital.url)` - 打开浏览器
4. `clickConsultButton(hospital.url)` - 尝试点击咨询按钮（多选择器）
5. 返回成功/降级提示

## 测试建议

### 手动测试

```bash
# 1. 启动本地测试
node test-multi-turn.js

# 2. 或在 OpenClaw 中测试
# 用户输入：
#   "怎么预约 CNP 皮肤科"
#   → Skill 返回预约流程 + 建议
#
#   "咨询客服"
#   → Skill 自动打开页面并点击咨询按钮
```

### 验证清单

- [ ] Skill 正确识别"咨询客服"指令
- [ ] 浏览器正确打开医院页面
- [ ] Playwright 成功定位咨询按钮（查看控制台日志）
- [ ] 咨询按钮被成功点击
- [ ] 页面跳转到客服对话窗口
- [ ] 降级方案：如果自动化失败，用户仍能看到清晰的手动操作指南

## 与预约流程的对称性

注意到咨询和预约流程的对称设计：

```
操作        | 第2/4轮        | 自动化函数
-----------|----------------|------------------
打开链接    | open          | openBrowser()
帮我预约    | book          | clickBookingButton()
咨询客服    | consult       | clickConsultButton()  ← 新增
```

## 下一步改进

### 可能的优化

1. **选择器配置文件**
   - 将选择器提取到可配置的 JSON 文件
   - 支持不同医院的定制选择器
   - 便于后续维护和扩展

2. **截图与验证**
   - 自动化前后各截一张图
   - 验证按钮是否被成功点击
   - 返回给用户"看，我确实点了"

3. **客服类型识别**
   - 识别是在线客服、电话、还是微信
   - 根据不同类型提供相应指导

4. **表单预填**
   - 检测客服对话框中是否有表单
   - 自动填写用户信息（名字、问题等）

## 常见问题

**Q: 如果页面上没有"咨询"按钮怎么办？**
A: Skill 会尝试多个选择器，最后都失败时会返回降级提示，告诉用户手动点击的位置。

**Q: 为什么要用多选择器？**
A: 因为不同医院的页面可能结构不同，或 BeautsGO 可能更新页面。多选择器策略提高了健壮性。

**Q: 自动化会打开真实的浏览器窗口吗？**
A: 是的。`headless: false` 表示显示浏览器窗口，用户可以看到整个过程。

**Q: 如何调试自动化失败？**
A: 查看控制台日志（`console.log` / `console.error`），或改为 `headless: true` 然后截图调试。

---

**总结：** 咨询客服的自动化实现，遵循了与"帮我预约"相同的设计模式，确保了多轮对话流程的一致性和健壮性。🎉



path: docs/multi-turn-flow.md
content: # 多轮对话流程设计

## 概述

booking-skill 现已支持标准的多轮对话流程，模仿 ChatGPT 的交互模式。用户可以通过自然语言指令逐步完成预约流程，无需点击按钮，所有操作通过对话驱动。

## 流程图

```
用户: "怎么预约 CNP 皮肤科？"
     ↓
[第1轮] Skill 返回：预约流程说明 + 操作建议
     ├─ 📖 预约流程详细信息
     └─ 💡 接下来你可以：
        • "打开链接"
        • "帮我预约"
        • "咨询客服"
     ↓
三个分支：

分支A - 打开链接：
用户选择: "打开链接"
     ↓
[第2轮] Skill 执行：
     ├─ 🌐 自动打开浏览器
     ├─ 📄 页面加载完成
     └─ 💡 接下来你可以：
        • "帮我预约"
        • "咨询客服"
        • "需要截图"

分支B - 直接预约：
用户选择: "帮我预约"
     ↓
[第3轮] Skill 执行：
     ├─ 🖱️ 自动点击预约按钮（Playwright）
     ├─ 📝 页面跳转到预约表单
     └─ 📋 返回：请填写表单信息

分支C - 咨询客服：
用户选择: "咨询客服"
     ↓
[第4轮] Skill 执行：
     ├─ 🌐 自动打开浏览器
     ├─ 🤖 自动点击咨询按钮（多选择器尝试）
     ├─ 💬 页面跳转到客服对话窗口
     └─ 📋 返回：客服对话指南
```

## 各轮详细说明

### 第1轮：查看预约流程（View）

**用户输入：**
```
"怎么预约 CNP 皮肤科？"
"CNP 皮肤科预约流程"
"如何预约 JD 皮肤科"
```

**Skill 行为：**
1. 解析用户输入，提取医院名称
2. 从 hospitals.json 匹配医院
3. 渲染预约流程模板（多渠道信息）
4. 返回友好的预约说明 + 建议下一步

**返回示例：**
```
🏥 CNP皮肤科狎鸥亭店 预约流程

在 BeautsGO 平台上，你可以通过以下渠道预约：

📱 App 预约：
1. 下载 BeautsGO App
2. 搜索"CNP皮肤科"
3. 点击"预约"按钮
4. 选择时间和服务

💻 网页端预约：
访问：https://i.beautsgo.com/cn/hospital/cnpskin-clinic
按照页面指引完成预约

... [更多渠道] ...

---
💡 接下来，你可以告诉我：
• "打开链接" - 我帮你打开医院的预约页面
• "帮我预约" - 我帮你自动点击预约按钮
• "咨询客服" - 我帮你联系客服咨询
• 或其他问题 😊
```

### 第2轮：打开浏览器（Open）

**用户输入：**
```
"打开链接"
"打开浏览器"
"打开医院页面"
```

**Skill 行为：**
1. 识别用户想打开浏览器
2. 使用 exec() 调用系统命令打开 hospital.url
   - macOS: `open "url"`
   - Windows: `start "url"`
   - Linux: `xdg-open "url"`
3. 等待浏览器打开
4. 返回确认信息 + 下一步建议

**返回示例：**
```
✅ 链接已打开！正在加载 CNP皮肤科狎鸥亭店 的预约页面...

页面地址：https://i.beautsgo.com/cn/hospital/cnpskin-clinic?from=google_map

你可以在浏览器中看到：
• 📍 医院地址和地图
• ⏰ 营业时间
• 💰 价格表和优惠
• 📷 医院环境照片
• 👨‍⚕️ 医生团队介绍
• ✅ 预约按钮

接下来，你可以：
• "帮我预约" - 我帮你自动点击预约按钮
• "需要截图" - 我可以帮你看看页面内容
• 或继续咨询其他问题 😊
```

### 第3轮：自动预约（Book）

**用户输入：**
```
"帮我预约"
"点击预约按钮"
"直接预约"
```

**Skill 行为：**
1. 识别用户想自动预约
2. 再次打开浏览器（确保页面可用）
3. 使用 Playwright 自动化：
   - 加载页面
   - 定位预约按钮：`.btns-right:has-text("预约面诊")`
   - 点击按钮
   - 等待页面跳转到预约表单
4. 关闭浏览器自动化（保留浏览器窗口供用户填表）
5. 返回表单填写说明

**返回示例：**
```
✅ 已帮你点击预约按钮！页面已跳转到预约表单。

CNP皮肤科狎鸥亭店 的预约页面已在浏览器中打开，请按照以下步骤继续：

📝 请填写以下信息：
• 您的姓名
• 联系电话
• 预约日期和时间
• 选择医生（如适用）
• 服务项目描述

💳 完成后点击"确认预约"或"提交"

如果需要帮助，随时告诉我！😊
```

### 第4轮（可选）：咨询客服（Consult）

**用户输入：**
```
"咨询客服"
"联系客服"
"我想咨询一下"
"在线客服"
```

**Skill 行为：**
1. 识别用户想咨询
2. 打开浏览器到医院页面
3. 使用 Playwright 自动化：
   - 尝试多个可能的咨询按钮选择器
   - 定位"咨询一下"、"在线客服"或其他客服按钮
   - 点击按钮
   - 等待页面跳转或弹窗出现
4. 返回咨询指南（成功或降级方案）

**返回示例（成功自动点击）：**
```
✅ 已帮你打开 CNP皮肤科狎鸥亭店 的咨询对话页面！

我已经：
• 打开了医院页面
• 自动点击了"咨询一下"按钮

现在页面应该已经跳转到在线客服对话窗口。你可以：
• 📝 在对话框中输入你的问题
• ❓ 询问价格、预约时间、医生等信息
• 💬 直接与客服沟通

还需要其他帮助吗？😊
```

**返回示例（降级方案）：**
```
⚠️ 自动点击咨询按钮失败，但页面已打开。

请手动点击以下位置的咨询按钮：
• 页面上方或右侧的蓝色"咨询一下"按钮
• 或右下角的"在线客服"按钮
• 点击后会打开客服对话窗口
```

**技术亮点：**
- 多选择器尝试：不依赖单一选择器，提高兼容性
- 可见性检查：确保元素在视口中可见再点击
- 备选方案：文本正则匹配 `/咨询|客服|在线|联系/i`
- 错误优雅降级：点击失败时返回手动操作指南

## 技术实现细节

### 意图识别（Intent Detection）

在 `api/skill.js` 中的 `detectIntent()` 函数：

```javascript
function detectIntent(query) {
  const q = query.toLowerCase()
  
  if (q.includes('打开')) return 'open'
  if (q.includes('点击') || q.includes('预约')) return 'book'
  if (q.includes('咨询') || q.includes('客服')) return 'consult'
  return 'view'  // 默认
}
```

### 浏览器自动化（Playwright）

在 `clickBookingButton()` 中使用 Playwright 自动化：

```javascript
async function clickBookingButton(url) {
  const browser = await playwright.chromium.launch({ headless: false })
  const page = await browser.newPage()
  await page.goto(url)
  
  // 关键选择器：BeautsGO 页面上的预约按钮
  const bookingButton = await page.locator('.btns-right:has-text("预约面诊")').first()
  await bookingButton.click()
  
  // 等待页面跳转到预约表单
  await page.waitForNavigation({ waitUntil: 'networkidle' })
  
  await browser.close()
}
```

### 上下文管理（Context）

虽然当前实现通过意图识别重新解析 query，但 Skill 已支持 `context` 参数以便日后扩展多轮状态管理：

```json
{
  "query": "帮我预约",
  "context": {
    "hospital": { "id": 2, "name": "CNP皮肤科狎鸥亭店", "url": "..." }
  }
}
```

## 用户体验优势

1. **无需点击 UI**：所有操作通过自然语言指令
2. **逐步引导**：每一步都有明确的下一步建议
3. **可控流程**：用户完全控制是否打开浏览器、自动化
4. **透明执行**：Skill 明确告诉用户发生了什么
5. **错误处理**：自动化失败时有优雅的降级方案

## 与标准 Skill 流程的区别

| 特性 | 标准 Skill | booking-skill |
|------|----------|--------------|
| 返回值 | 纯文本 | 文本 + 建议操作 |
| 交互 | 一次性返回 | 多轮对话 |
| 自动化 | 无 | 打开浏览器、点击按钮 |
| 用户控制 | 被动 | 主动（可选择是否自动化） |
| 需要点击 | 否 | 否（完全通过对话） |

## 下一步改进方向

1. **自动填表**：根据用户输入自动填写预约表单字段
2. **表单提交确认**：在提交前要求用户确认
3. **多语言支持**：扩展至 en/ja/th
4. **状态追踪**：记录用户预约流程进度
5. **集成客服系统**：直接触发客服对话而非仅返回联系方式


path: docs/superpowers/plans/2026-03-19-booking-flow-engine.md
content: # Booking Flow Engine Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 将 PHP `ReservationProcessServices` 的预约流程生成逻辑复刻为 JS 模块，支持从自然语言 query 匹配医院并渲染多字段模板。

**Architecture:** 入口 `index.js` 组合调用四个独立模块：`matcher`（医院匹配）、`template`（模板加载）、`keywords`（拼音关键词生成）、`renderer`（变量替换与清理）。模板以本地 txt 文件存储，变量用 `{field_name}` 占位。

**Tech Stack:** Node.js 22, pinyin-pro, Jest（测试）

---

## File Map

| 状态 | 路径 | 职责 |
|------|------|------|
| 创建 | `lib/matcher.js` | 4 级策略匹配医院 |
| 创建 | `lib/template.js` | 加载本地模板文件 |
| 创建 | `lib/keywords.js` | 生成 search_keywords 虚拟字段 |
| 创建 | `lib/renderer.js` | 变量替换 + 空占位符清理 |
| 创建 | `templates/zh_cn.txt` | 中文预约流程模板 |
| 修改 | `hospitals.json` | 字段迁移：`name_cn`→`name`、`name_en`→`en_name`，新增 `alias`/`pinyin`/`pinyin_abbr` |
| 修改 | `index.js` | 重写入口，组合调用各模块 |
| 创建 | `tests/matcher.test.js` | matcher 单元测试 |
| 创建 | `tests/keywords.test.js` | keywords 单元测试 |
| 创建 | `tests/template.test.js` | template 单元测试 |
| 创建 | `tests/renderer.test.js` | renderer 单元测试 |
| 创建 | `tests/integration.test.js` | 端到端集成测试 |
| 创建 | `package.json` | 项目依赖与测试脚本 |

---

## Task 1: 初始化项目依赖

**Files:**
- Create: `package.json`

- [ ] **Step 1: 创建 package.json**

```json
{
  "name": "booking-skill",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "jest"
  },
  "dependencies": {
    "pinyin-pro": "^3.26.0"
  },
  "devDependencies": {
    "jest": "^29.0.0"
  }
}
```

- [ ] **Step 2: 安装依赖**

```bash
npm install
```

Expected: `node_modules/` 生成，`package-lock.json` 创建。

- [ ] **Step 3: Commit**

```bash
git add package.json package-lock.json
git commit -m "chore: init package.json with pinyin-pro and jest"
```

---

## Task 2: 更新 hospitals.json 数据结构

**Files:**
- Modify: `hospitals.json`

- [ ] **Step 1: 更新 hospitals.json**

> ⚠️ 注意：此步骤将旧字段 `name_cn` → `name`、`name_en` → `en_name` 重命名，同时新增 `alias`/`pinyin`/`pinyin_abbr`。旧的 `index.js` 引用 `hospital.name_cn` 会在 Task 7 重写时一并修复。

将现有两条记录补全字段：

```json
[
  {
    "id": 1,
    "name": "韩国JD皮肤科",
    "en_name": "JD Skin Clinic",
    "alias": "JD皮肤科",
    "aliases": ["jd皮肤科", "韩国jd", "jd"],
    "pinyin": "JDpifuke",
    "pinyin_abbr": "JDpfk"
  },
  {
    "id": 2,
    "name": "CNP皮肤科狎鸥亭店",
    "en_name": "CNP Skin Clinic",
    "alias": "CNP狎鸥亭",
    "aliases": ["cnp", "cnp皮肤科"],
    "pinyin": "CNPxiaouting",
    "pinyin_abbr": "CNPxot"
  }
]
```

- [ ] **Step 2: Commit**

```bash
git add hospitals.json
git commit -m "feat: enrich hospitals.json with name/alias/pinyin fields"
```

---

## Task 3: 实现 matcher.js + 测试

**Files:**
- Create: `lib/matcher.js`
- Create: `tests/matcher.test.js`

- [ ] **Step 1: 写失败测试**

```js
// tests/matcher.test.js
const { matchHospital } = require('../lib/matcher')
const hospitals = require('../hospitals.json')

test('精确匹配中文名', () => {
  expect(matchHospital('CNP皮肤科狎鸥亭店', hospitals)).toMatchObject({ id: 2 })
})

test('精确匹配英文名（忽略大小写）', () => {
  expect(matchHospital('cnp skin clinic', hospitals)).toMatchObject({ id: 2 })
})

test('alias 匹配', () => {
  expect(matchHospital('CNP狎鸥亭', hospitals)).toMatchObject({ id: 2 })
})

test('pinyin 精确匹配', () => {
  expect(matchHospital('CNPxiaouting', hospitals)).toMatchObject({ id: 2 })
})

test('name 模糊匹配', () => {
  expect(matchHospital('JD皮肤', hospitals)).toMatchObject({ id: 1 })
})

test('aliases 数组模糊匹配', () => {
  expect(matchHospital('cnp皮肤科怎么预约', hospitals)).toMatchObject({ id: 2 })
})

test('找不到医院返回 null', () => {
  expect(matchHospital('不存在的医院', hospitals)).toBeNull()
})
```

- [ ] **Step 2: 运行测试，确认失败**

```bash
npx jest tests/matcher.test.js
```

Expected: FAIL — `Cannot find module '../lib/matcher'`

- [ ] **Step 3: 实现 matcher.js**

```js
// lib/matcher.js
function matchHospital(query, hospitals) {
  const q = query.toLowerCase()

  // 策略1: 精确匹配 name / en_name / alias（忽略大小写）
  let found = hospitals.find(h =>
    [h.name, h.en_name, h.alias].some(v => v && v.toLowerCase() === q)
  )
  if (found) return found

  // 策略2: pinyin / pinyin_abbr 精确匹配（忽略大小写）
  found = hospitals.find(h =>
    [h.pinyin, h.pinyin_abbr].some(v => v && v.toLowerCase() === q)
  )
  if (found) return found

  // 策略3: name 模糊包含
  found = hospitals.find(h => h.name && h.name.toLowerCase().includes(q))
  if (found) return found

  // 策略4: 其他字段模糊包含（en_name, alias, pinyin, pinyin_abbr, aliases 数组）
  found = hospitals.find(h => {
    const fields = [h.en_name, h.alias, h.pinyin, h.pinyin_abbr]
    if (fields.some(v => v && v.toLowerCase().includes(q))) return true
    if (h.aliases && h.aliases.some(a => q.includes(a.toLowerCase()))) return true
    return false
  })
  return found || null
}

module.exports = { matchHospital }
```

- [ ] **Step 4: 运行测试，确认通过**

```bash
npx jest tests/matcher.test.js
```

Expected: PASS — 7 tests passed

- [ ] **Step 5: Commit**

```bash
git add lib/matcher.js tests/matcher.test.js
git commit -m "feat: implement hospital matcher with 4-strategy matching"
```

---

## Task 4: 实现 keywords.js + 测试

**Files:**
- Create: `lib/keywords.js`
- Create: `tests/keywords.test.js`

- [ ] **Step 1: 写失败测试**

```js
// tests/keywords.test.js
const { generateSearchKeywords, filterPinyin } = require('../lib/keywords')

const hospital = {
  name: 'CNP皮肤科狎鸥亭店',
  en_name: 'CNP Skin Clinic',
  alias: 'CNP狎鸥亭'
}

test('包含中文名（过滤通用词后，展示 alias 短名）', () => {
  const result = generateSearchKeywords(hospital)
  expect(result).toContain('中文名')
  expect(result).toContain('CNP狎鸥亭')
})

test('包含英文名（去除 clinic/hospital 后）', () => {
  const result = generateSearchKeywords(hospital)
  expect(result).toContain('英文名')
  expect(result).toContain('CNP Skin')
})

test('包含拼音且不含音调', () => {
  const result = generateSearchKeywords(hospital)
  // 拼音段不应含声调字符
  expect(result).toMatch(/拼音"[^""]*"/)
  expect(result).not.toMatch(/[āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ]/)
})

test('首字母与拼音不重复', () => {
  const result = generateSearchKeywords(hospital)
  // 结果中拼音和首字母都应出现，且各自不重复
  expect(result).toContain('拼音')
  expect(result).toContain('首字母')
})

test('filterPinyin 直接移除 pifuke 和 pfk', () => {
  expect(filterPinyin('JDpifuke')).toBe('JD')
  expect(filterPinyin('CNPpfk')).toBe('CNP')
  expect(filterPinyin('PIFUKEtest')).toBe('test')
})

test('跳过空值字段', () => {
  const minHospital = { name: 'CNP皮肤科狎鸥亭店', en_name: '', alias: '' }
  const result = generateSearchKeywords(minHospital)
  expect(result).not.toContain('英文名""')
})
```

- [ ] **Step 2: 运行测试，确认失败**

```bash
npx jest tests/keywords.test.js
```

Expected: FAIL — `Cannot find module '../lib/keywords'`

- [ ] **Step 3: 实现 keywords.js**

```js
// lib/keywords.js
const { pinyin } = require('pinyin-pro')

const STOP_WORDS = [
  '整形外科&皮肤科', '&皮肤科', '抗老化医学美容中心',
  '皮肤科医院', '皮肤医院', '整形医院', '整形外科',
  '韩国', '牙科', '皮肤科', '眼科', '妇科',
  '总店', '国际店', '分店', '分馆', '诊所', '本院', '医院', '国际', '店'
]

const FILTER_PINYIN = ['pifuke', 'pfk']

function filterName(name) {
  // 去除括号内容
  let result = name.replace(/[（(][^）)]*[）)]/g, '')
  // 去除通用词（长词优先）
  const sorted = [...STOP_WORDS].sort((a, b) => b.length - a.length)
  sorted.forEach(w => { result = result.split(w).join('') })
  return result.trim()
}

function filterPinyin(str) {
  let result = str
  FILTER_PINYIN.forEach(kw => {
    result = result.replace(new RegExp(kw, 'gi'), '')
  })
  return result
}

function generateSearchKeywords(hospital) {
  const cnName = filterName(hospital.name || '')
  const enName = (hospital.en_name || '')
    .replace(/\bclinic\b/gi, '')
    .replace(/\bhospital\b/gi, '')
    .trim()

  const py = cnName
    ? filterPinyin(pinyin(cnName, { toneType: 'none', separator: '', type: 'string' }))
    : ''
  const abbr = cnName
    ? filterPinyin(pinyin(cnName, { toneType: 'none', separator: '', type: 'string', pattern: 'first' }))
    : ''

  const parts = [
    ['中文名', cnName],
    ['英文名', enName],
    ['拼音', py],
    ['首字母', abbr]
  ]

  const seen = new Set()
  const keywords = []

  for (const [label, value] of parts) {
    if (!value || value === '-') continue
    const key = value.toLowerCase()
    if (seen.has(key)) continue
    seen.add(key)
    keywords.push(`${label}"${value}"`)
  }

  return keywords.join('、')
}

module.exports = { generateSearchKeywords, filterPinyin }
```

- [ ] **Step 4: 运行测试，确认通过**

```bash
npx jest tests/keywords.test.js
```

Expected: PASS — 6 tests passed

- [ ] **Step 5: Commit**

```bash
git add lib/keywords.js tests/keywords.test.js
git commit -m "feat: implement search_keywords generator with pinyin-pro"
```

---

## Task 5: 实现 template.js + 测试

**Files:**
- Create: `lib/template.js`
- Create: `templates/zh_cn.txt`
- Create: `tests/template.test.js`

- [ ] **Step 1: 写失败测试**

```js
// tests/template.test.js
const path = require('path')
const { loadTemplate } = require('../lib/template')

test('成功加载 zh_cn 模板', () => {
  const content = loadTemplate('zh_cn')
  expect(typeof content).toBe('string')
  expect(content.length).toBeGreaterThan(0)
  expect(content).toContain('{name}')
  expect(content).toContain('{search_keywords}')
})

test('加载不存在的语言模板时抛出错误', () => {
  expect(() => loadTemplate('xx_xx')).toThrow('模板文件不存在：templates/xx_xx.txt')
})
```

- [ ] **Step 2: 运行测试，确认失败**

```bash
npx jest tests/template.test.js
```

Expected: FAIL — `Cannot find module '../lib/template'`

- [ ] **Step 3: 创建模板文件 `templates/zh_cn.txt`**

将以下完整内容写入 `templates/zh_cn.txt`。`{name}` 和 `{search_keywords}` 是运行时替换的变量，其余文字原样保留。

```
支持中/繁/英/泰/日等多语言

一、🍎 苹果手机预约（iOS 用户首选）
打开 App Store 搜索「BeautsGO」或「彼此美」，下载并安装 BeautsGO APP 📥。
打开 APP，在顶部搜索栏输入 {search_keywords} 均可快速找到{name}。
进入医院页面，查看中韩文地址 📍、营业时间 ⏰、当月价格表 💰 及优惠活动。
点击右下角【立即预约】或【咨询一下】，填写人数与时间，即可提交预约 ✅。

二、🤖 Android 手机预约（安卓用户）
打开 Google Play 搜索「BeautsGO」或「彼此美」，下载安装 APP 📲。
打开 APP，在顶部搜索栏输入 {search_keywords} 均可快速找到{name}。
进入医院页面，查看详细信息：地址、营业时间、当月价格、优惠活动等。
点击【立即预约】或【咨询一下】，填写预约信息后提交 ✅。

三、📱 微信小程序预约（无需下载 APP）
打开微信，搜索「BeautsGO」或「彼此美」小程序。
进入小程序，在搜索栏输入 {search_keywords} 搜索{name}。
查看医院详情：地址、营业时间、价格表、优惠活动。
点击【立即预约】或【咨询一下】提交预约 ✅。

四、🟢 微信公众号预约（国内用户推荐）
微信搜索【BeautsGO】或【彼此美】，关注公众号「BeautsGO彼此美APP」💬。
点击左下角菜单【一键预约】，输入 {search_keywords} 进入流程 ⚡。
💡 备选：直接在微信搜索框搜索微信号 BeautsGOkr 添加好友咨询。

五、🌐 网页版预约（电脑端推荐）
打开浏览器访问 BeautsGO 官网：https://www.beautsgo.com
在首页搜索框输入 {search_keywords} 找到{name}。
查看医院完整信息，包括环境照片、医生团队、价格表等。
点击【预约】按钮，填写信息提交 ✅。

📌 温馨提示：
所有渠道均支持中文、繁体中文、英文、泰文、日文等多语言切换
预约成功后会收到短信/APP 通知，请保持手机畅通
如需修改预约，请在"我的预约"中操作或联系客服
客服咨询时间：9:00-18:00（北京时间）
```

- [ ] **Step 4: 实现 template.js**

```js
// lib/template.js
const fs = require('fs')
const path = require('path')

function loadTemplate(language = 'zh_cn') {
  const filePath = path.join(__dirname, '..', 'templates', `${language}.txt`)
  if (!fs.existsSync(filePath)) {
    throw new Error(`模板文件不存在：templates/${language}.txt`)
  }
  return fs.readFileSync(filePath, 'utf-8')
}

module.exports = { loadTemplate }
```

- [ ] **Step 5: 运行测试，确认通过**

```bash
npx jest tests/template.test.js
```

Expected: PASS — 2 tests passed

- [ ] **Step 6: Commit**

```bash
git add lib/template.js templates/zh_cn.txt tests/template.test.js
git commit -m "feat: add template loader and zh_cn booking flow template"
```

---

## Task 6: 实现 renderer.js + 测试

**Files:**
- Create: `lib/renderer.js`
- Create: `tests/renderer.test.js`

- [ ] **Step 1: 写失败测试**

```js
// tests/renderer.test.js
const { render } = require('../lib/renderer')

const hospital = {
  id: 2,
  name: 'CNP皮肤科狎鸥亭店',
  en_name: 'CNP Skin Clinic',
  alias: 'CNP狎鸥亭'
}

test('替换 {name} 字段', () => {
  const result = render('欢迎来到{name}', hospital)
  expect(result).toBe('欢迎来到CNP皮肤科狎鸥亭店')
})

test('替换 {search_keywords} 虚拟字段', () => {
  const result = render('搜索：{search_keywords}', hospital)
  expect(result).toContain('中文名')
  expect(result).not.toContain('{search_keywords}')
})

test('空字符串值清理空引号', () => {
  const h = { name: 'CNP皮肤科狎鸥亭店', en_name: '' }
  const result = render('英文名"{en_name}"', h)
  expect(result).not.toContain('""')
})

test('null 值当空字符串处理', () => {
  const h = { name: 'CNP皮肤科狎鸥亭店', en_name: null }
  const result = render('{en_name}', h)
  expect(result).toBe('')
})

test('hospital 上不存在的字段，占位符保持原样', () => {
  // en_name 不在 h 中，Object.entries 不会遍历到它，占位符不替换
  const h = { name: 'CNP皮肤科狎鸥亭店' }
  const result = render('{en_name}', h)
  expect(result).toBe('{en_name}')
})

// 设计决策：未知占位符（不在 hospital 对象中的 key）保持原样不替换
test('完全未知的占位符保持原样', () => {
  const result = render('{totally_unknown}', hospital)
  expect(result).toBe('{totally_unknown}')
})

test('aliases 数组不污染模板（不应展开为逗号分隔串）', () => {
  const h = { ...hospital, aliases: ['cnp', 'cnp皮肤科'] }
  // 模板中不使用 {aliases}，但若意外包含应为数组的 toString，不应出现
  const result = render('{name}', h)
  expect(result).toBe('CNP皮肤科狎鸥亭店')
  expect(result).not.toContain('cnp,cnp皮肤科')
})
```

- [ ] **Step 2: 运行测试，确认失败**

```bash
npx jest tests/renderer.test.js
```

Expected: FAIL — `Cannot find module '../lib/renderer'`

- [ ] **Step 3: 实现 renderer.js**

```js
// lib/renderer.js
const { generateSearchKeywords } = require('./keywords')

function render(template, hospital) {
  let content = template

  // 注入虚拟字段
  const data = {
    ...hospital,
    search_keywords: generateSearchKeywords(hospital)
  }

  // 替换所有 {key}（跳过数组类型字段，避免 aliases 等被展开为逗号串）
  for (const [key, value] of Object.entries(data)) {
    if (Array.isArray(value)) continue
    const v = (value === null || value === undefined || value === '-' || value === '- ') ? '' : String(value)
    content = content.split(`{${key}}`).join(v)
  }

  return cleanEmptyPlaceholders(content)
}

function cleanEmptyPlaceholders(content) {
  // 清理空引号
  content = content.replace(/""/g, '').replace(/''/g, '')

  // 清理带标签的空引号残留
  const labels = ['中文名', '英文名', '拼音', '首字母']
  for (const label of labels) {
    const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
    content = content.replace(new RegExp(escaped + '\\s*[,，、·]\\s*', 'gu'), '')
    content = content.replace(new RegExp('[,，、·]\\s*' + escaped + '\\s*$', 'gmu'), '')
    content = content.replace(new RegExp(escaped + '\\s*$', 'gmu'), '')
  }

  // 修复重复连接词
  content = content.replace(/或或者/g, '或').replace(/或者或/g, '或').replace(/或或/g, '或')

  // 清理多余逗号
  content = content.replace(/,+/g, ',')
  content = content.replace(/,([。.!？\n])/g, '$1')

  // 清理多余空格
  content = content.replace(/ +/g, ' ')

  return content.trim()
}

module.exports = { render }
```

- [ ] **Step 4: 运行测试，确认通过**

```bash
npx jest tests/renderer.test.js
```

Expected: PASS — 7 tests passed

- [ ] **Step 5: Commit**

```bash
git add lib/renderer.js tests/renderer.test.js
git commit -m "feat: implement template renderer with variable replacement and cleanup"
```

---

## Task 7: 重写 index.js + 集成测试

**Files:**
- Modify: `index.js`
- Create: `tests/integration.test.js`

- [ ] **Step 1: 写集成测试**

```js
// tests/integration.test.js
const handler = require('../index')

test('CNP 查询返回预约流程', async () => {
  const result = await handler({ query: 'cnp皮肤科怎么预约' })
  expect(result).toContain('CNP皮肤科狎鸥亭店')
  expect(result).toContain('BeautsGO')
  expect(result).not.toContain('{name}')
  expect(result).not.toContain('{search_keywords}')
})

test('JD 查询返回预约流程', async () => {
  const result = await handler({ query: 'JD皮肤科' })
  expect(result).toContain('韩国JD皮肤科')
})

test('未知医院返回提示', async () => {
  const result = await handler({ query: '不存在的医院' })
  expect(result).toBe('请告诉我医院名称，我帮你生成预约流程')
})
```

- [ ] **Step 2: 运行测试，确认失败**

```bash
npx jest tests/integration.test.js
```

Expected: FAIL（index.js 还未重写）

- [ ] **Step 3: 重写 index.js**

```js
// index.js
const hospitals = require('./hospitals.json')
const { matchHospital } = require('./lib/matcher')
const { loadTemplate } = require('./lib/template')
const { render } = require('./lib/renderer')

module.exports = async function (input) {
  const query = input.query

  const hospital = matchHospital(query, hospitals)
  if (!hospital) {
    return '请告诉我医院名称，我帮你生成预约流程'
  }

  const template = loadTemplate('zh_cn')
  return render(template, hospital)
}
```

- [ ] **Step 4: 运行全部测试，确认通过**

```bash
npx jest
```

Expected: PASS — 全部测试通过

- [ ] **Step 5: Commit**

```bash
git add index.js tests/integration.test.js
git commit -m "feat: rewrite index.js to compose matcher + template + renderer"
```

---

## Task 8: 运行全量测试并收尾

- [ ] **Step 1: 运行全量测试**

```bash
npx jest --verbose
```

Expected: 所有 test suite 通过，无 warning

- [ ] **Step 2: 最终 Commit**

```bash
git add .
git commit -m "chore: final cleanup and full test pass"
```


path: docs/superpowers/plans/2026-03-20-project-restructure.md
content: # Project Restructure Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Restructure the booking-skill project from a flat `lib/` layout into a scalable architecture that supports 900 hospitals, multi-language output, and batch MD generation.

**Architecture:** `core/resolver.js` handles all hospital matching and keyword logic; `core/renderer.js` owns template loading + i18n injection + output rendering; `core/service.js` exposes a single public function. The template (`booking.tpl`) is structure-only with channel-key placeholders, while `i18n/*.json` files hold the full translated channel text (which themselves embed `{name}` / `{search_keywords}` data placeholders substituted at render time).

**Tech Stack:** Node.js, Jest (already installed), pinyin-pro

---

## File Responsibility Map

| File | Responsibility |
|------|----------------|
| `data/hospitals.json` | Sole data source — move from root |
| `core/resolver.js` | Match hospital from query (4-tier); generate search keywords |
| `core/renderer.js` | Load tpl + i18n, substitute data vars, produce final string |
| `core/service.js` | Public entry: `getBookingGuide(query, lang?)` |
| `templates/booking.tpl` | Structure only — `{channel_ios}`, `{channel_android}`, etc. |
| `i18n/zh.json` | Chinese channel blocks (contain `{name}`, `{search_keywords}`) |
| `i18n/en.json` | English stubs (same keys, English text) |
| `i18n/ja.json` | Japanese stubs |
| `i18n/th.json` | Thai stubs |
| `scripts/generate-md.js` | CLI: iterate hospitals → write `docs/clinics/{slug}.md` |
| `api/skill.js` | Thin skill entry: `async input => getBookingGuide(input.query, input.lang)` |
| `tests/resolver.test.js` | Unit tests for resolver |
| `tests/renderer.test.js` | Unit tests for renderer |
| `tests/service.test.js` | Integration test for full pipeline |

**Files to delete after migration:**
- `lib/matcher.js`, `lib/keywords.js`, `lib/template.js`, `lib/renderer.js`
- `templates/zh_cn.txt`
- `index.js` (replaced by `api/skill.js` + `core/service.js`)

---

## Task 1: Create directory scaffold and move data

**Files:**
- Create: `data/hospitals.json` (move from root)
- Create: `core/`, `i18n/`, `scripts/`, `api/`, `tests/` directories
- Modify: `package.json` — update `main`

- [ ] **Step 1: Create directories**

```bash
mkdir -p data core i18n scripts api tests docs/clinics
```

- [ ] **Step 2: Move hospitals.json**

> ⚠️ Note: the root `hospitals.json` is intentionally **copied** (not moved) here. It is deleted in Task 8 once all new code is verified. Do not reference the root copy in any new code — always use `data/hospitals.json`.

```bash
# Guard: verify source file exists before copying
test -f hospitals.json || (echo "ERROR: hospitals.json not found at project root" && exit 1)
cp hospitals.json data/hospitals.json
```

- [ ] **Step 3: Update package.json main entry**

Edit `package.json`:
```json
{
  "name": "booking-skill",
  "version": "2.0.0",
  "main": "api/skill.js",
  "scripts": {
    "test": "jest",
    "generate": "node scripts/generate-md.js"
  },
  "dependencies": {
    "pinyin-pro": "^3.26.0"
  },
  "devDependencies": {
    "jest": "^29.0.0"
  }
}
```

- [ ] **Step 4: Verify data file is in place**

```bash
node -e "const h = require('./data/hospitals.json'); console.log(h.length, 'hospitals')"
```
Expected: `2 hospitals`

- [ ] **Step 5: Commit**

```bash
git add data/hospitals.json package.json
git commit -m "chore: scaffold new directory structure, move data source"
```

---

## Task 2: Create core/resolver.js (merge matcher + keywords)

**Files:**
- Create: `core/resolver.js`
- Create: `tests/resolver.test.js`

- [ ] **Step 1: Write failing tests**

Create `tests/resolver.test.js`:
```js
const { matchHospital, generateSearchKeywords } = require('../core/resolver')

const hospitals = [
  {
    id: 1,
    name: '韩国JD皮肤科',
    en_name: 'JD Skin Clinic',
    alias: 'JD皮肤科',
    aliases: ['jd皮肤科', '韩国jd', 'jd'],
    pinyin: 'JDpifuke',
    pinyin_abbr: 'JDpfk'
  },
  {
    id: 2,
    name: 'CNP皮肤科狎鸥亭店',
    en_name: 'CNP Skin Clinic',
    alias: 'CNP狎鸥亭',
    aliases: ['cnp', 'cnp皮肤科'],
    pinyin: 'CNPxiaouting',
    pinyin_abbr: 'CNPxot'
  }
]

describe('matchHospital', () => {
  // Strategy 1: exact match on name
  test('exact name match', () => {
    expect(matchHospital('韩国JD皮肤科', hospitals)).toEqual(hospitals[0])
  })

  // Strategy 1: exact match on en_name (case-insensitive)
  test('exact en_name match case-insensitive', () => {
    expect(matchHospital('jd skin clinic', hospitals)).toEqual(hospitals[0])
  })

  // Strategy 1: exact match on alias
  test('exact alias match', () => {
    expect(matchHospital('CNP狎鸥亭', hospitals)).toEqual(hospitals[1])
  })

  // Strategy 2: pinyin_abbr match
  test('pinyin_abbr match', () => {
    expect(matchHospital('JDpfk', hospitals)).toEqual(hospitals[0])
  })

  // Strategy 3: fuzzy name contains
  test('fuzzy name match', () => {
    expect(matchHospital('JD皮肤', hospitals)).toEqual(hospitals[0])
  })

  // Strategy 4: aliases array includes query (exact alias match)
  test('aliases array match', () => {
    expect(matchHospital('cnp皮肤科', hospitals)).toEqual(hospitals[1])
  })

  // Strategy 4: query contains alias item (q.includes(al) direction)
  test('query contains alias', () => {
    expect(matchHospital('cnp皮肤科怎么预约', hospitals)).toEqual(hospitals[1])
  })

  // Strategy 4: alias contains query (al.includes(q) direction — fixes the old one-direction bug)
  // Uses a fixture where the alias is longer than the query so only al.includes(q) triggers
  test('alias contains query', () => {
    const extended = [
      { ...hospitals[0] },
      { ...hospitals[1], aliases: ['cnp皮肤科诊所'] }  // alias is longer than query 'cnp皮肤科'
    ]
    expect(matchHospital('cnp皮肤科', extended)).toEqual(extended[1])
  })

  test('returns null for no match', () => {
    expect(matchHospital('不存在医院', hospitals)).toBeNull()
  })
})

describe('generateSearchKeywords', () => {
  test('generates keywords for hospital with full data', () => {
    const result = generateSearchKeywords(hospitals[0])
    expect(result).toContain('中文名')
    expect(result).toContain('英文名')
    expect(typeof result).toBe('string')
  })

  test('deduplicates identical values', () => {
    // name and en_name are the same value — only one label-value pair should appear
    const hospital = { name: 'JD', en_name: 'JD', aliases: [] }
    const result = generateSearchKeywords(hospital)
    // Count label-value pairs separated by 、 (more reliable than counting raw string occurrences)
    expect(result.split('、').length).toBe(1)
  })
})
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
npx jest tests/resolver.test.js --no-coverage
```
Expected: FAIL — `Cannot find module '../core/resolver'`

- [ ] **Step 3: Implement core/resolver.js**

Create `core/resolver.js`:
```js
const { pinyin } = require('pinyin-pro')

// ── Keyword generation ────────────────────────────────────────────────────────

const STOP_WORDS = [
  '整形外科&皮肤科', '&皮肤科', '抗老化医学美容中心',
  '皮肤科医院', '皮肤医院', '整形医院', '整形外科',
  '韩国', '牙科', '皮肤科', '眼科', '妇科',
  '总店', '国际店', '分店', '分馆', '诊所', '本院', '医院', '国际', '店'
]

const PINYIN_STOP = ['pifuke', 'pfk']

function filterName(name) {
  let result = name.replace(/[（(][^）)]*[）)]/g, '')
  const sorted = [...STOP_WORDS].sort((a, b) => b.length - a.length)
  sorted.forEach(w => { result = result.split(w).join('') })
  return result.trim()
}

function filterPinyin(str) {
  return PINYIN_STOP.reduce(
    (acc, kw) => acc.replace(new RegExp(kw, 'gi'), ''),
    str
  )
}

function generateSearchKeywords(hospital) {
  const cnName = filterName(hospital.name || '')
  const enName = (hospital.en_name || '')
    .replace(/\bclinic\b/gi, '')
    .replace(/\bhospital\b/gi, '')
    .trim()

  const py = cnName
    ? filterPinyin(pinyin(cnName, { toneType: 'none', separator: '', type: 'string' }))
    : ''
  const abbr = cnName
    ? filterPinyin(pinyin(cnName, { toneType: 'none', separator: '', type: 'string', pattern: 'first' }))
    : ''

  const parts = [['中文名', cnName], ['英文名', enName], ['拼音', py], ['首字母', abbr]]
  const seen = new Set()
  const keywords = []

  for (const [label, value] of parts) {
    if (!value) continue
    const key = value.toLowerCase()
    if (seen.has(key)) continue
    seen.add(key)
    keywords.push(`${label}"${value}"`)
  }

  return keywords.join('、')
}

// ── Hospital matching ─────────────────────────────────────────────────────────

function matchHospital(query, hospitals) {
  const q = query.toLowerCase()

  // Strategy 1: exact match on name / en_name / alias
  let found = hospitals.find(h =>
    [h.name, h.en_name, h.alias].some(v => v && v.toLowerCase() === q)
  )
  if (found) return found

  // Strategy 2: exact pinyin / pinyin_abbr
  found = hospitals.find(h =>
    [h.pinyin, h.pinyin_abbr].some(v => v && v.toLowerCase() === q)
  )
  if (found) return found

  // Strategy 3: fuzzy name contains query
  found = hospitals.find(h => h.name && h.name.toLowerCase().includes(q))
  if (found) return found

  // Strategy 4: other field fuzzy + aliases (query contains alias OR alias contains query)
  found = hospitals.find(h => {
    const fields = [h.en_name, h.alias, h.pinyin, h.pinyin_abbr]
    if (fields.some(v => v && v.toLowerCase().includes(q))) return true
    if (h.aliases && h.aliases.some(a => {
      const al = a.toLowerCase()
      return q.includes(al) || al.includes(q)
    })) return true
    return false
  })

  return found || null
}

module.exports = { matchHospital, generateSearchKeywords }
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npx jest tests/resolver.test.js --no-coverage
```
Expected: PASS — all 8 tests green

- [ ] **Step 5: Commit**

```bash
git add core/resolver.js tests/resolver.test.js
git commit -m "feat: add core/resolver.js with hospital matching and keyword generation"
```

---

## Task 3: Create i18n files and booking.tpl

**Files:**
- Create: `templates/booking.tpl`
- Create: `i18n/zh.json`
- Create: `i18n/en.json`
- Create: `i18n/ja.json`
- Create: `i18n/th.json`

The i18n values contain `{name}` and `{search_keywords}` — these are data placeholders filled by the renderer.

- [ ] **Step 1: Create templates/booking.tpl**

```
{title}

{channel_ios}

{channel_android}

{channel_wechat_mini}

{channel_wechat_oa}

{channel_web}

{tips}
```

- [ ] **Step 2: Create i18n/zh.json** (extracted from existing `templates/zh_cn.txt`)

```json
{
  "title": "支持中/繁/英/泰/日等多语言",
  "channel_ios": "一、🍎 苹果手机预约（iOS 用户首选）\n打开 App Store 搜索「BeautsGO」或「彼此美」，下载并安装 BeautsGO APP 📥。\n打开 APP，在顶部搜索栏输入 {search_keywords} 均可快速找到{name}。\n进入医院页面，查看中韩文地址 📍、营业时间 ⏰、当月价格表 💰 及优惠活动。\n点击右下角【立即预约】或【咨询一下】，填写人数与时间，即可提交预约 ✅。",
  "channel_android": "二、🤖 Android 手机预约（安卓用户）\n打开 Google Play 搜索「BeautsGO」或「彼此美」，下载安装 APP 📲。\n打开 APP，在顶部搜索栏输入 {search_keywords} 均可快速找到{name}。\n进入医院页面，查看详细信息：地址、营业时间、当月价格、优惠活动等。\n点击【立即预约】或【咨询一下】，填写预约信息后提交 ✅。",
  "channel_wechat_mini": "三、📱 微信小程序预约（无需下载 APP）\n打开微信，搜索「BeautsGO」或「彼此美」小程序。\n进入小程序，在搜索栏输入 {search_keywords} 搜索{name}。\n查看医院详情：地址、营业时间、价格表、优惠活动。\n点击【立即预约】或【咨询一下】提交预约 ✅。",
  "channel_wechat_oa": "四、🟢 微信公众号预约（国内用户推荐）\n微信搜索【BeautsGO】或【彼此美】，关注公众号「BeautsGO彼此美APP」💬。\n点击左下角菜单【一键预约】，输入 {search_keywords} 进入流程 ⚡。\n💡 备选：直接在微信搜索框搜索微信号 BeautsGOkr 添加好友咨询。",
  "channel_web": "五、🌐 网页版预约（电脑端推荐）\n打开浏览器访问 BeautsGO 官网：https://www.beautsgo.com\n在首页搜索框输入 {search_keywords} 找到{name}。\n查看医院完整信息，包括环境照片、医生团队、价格表等。\n点击【预约】按钮，填写信息提交 ✅。",
  "tips": "📌 温馨提示：\n所有渠道均支持中文、繁体中文、英文、泰文、日文等多语言切换\n预约成功后会收到短信/APP 通知，请保持手机畅通\n如需修改预约，请在"我的预约"中操作或联系客服\n客服咨询时间：9:00-18:00（北京时间）"
}
```

- [ ] **Step 3: Create i18n/en.json** (English stubs — same structure, English text)

```json
{
  "title": "Available in Chinese, English, Japanese, Thai and more",
  "channel_ios": "1. 🍎 iOS Booking\nSearch \"BeautsGO\" on the App Store, download and install the app 📥.\nIn the search bar, type {search_keywords} to find {name}.\nView address 📍, hours ⏰, price list 💰 and promotions.\nTap [Book Now] or [Consult], fill in your details and submit ✅.",
  "channel_android": "2. 🤖 Android Booking\nSearch \"BeautsGO\" on Google Play, download and install the app 📲.\nIn the search bar, type {search_keywords} to find {name}.\nView clinic details: address, hours, prices and promotions.\nTap [Book Now] or [Consult], fill in your details and submit ✅.",
  "channel_wechat_mini": "3. 📱 WeChat Mini-Program (no app download needed)\nOpen WeChat, search for the \"BeautsGO\" mini-program.\nType {search_keywords} in the search bar to find {name}.\nView clinic details: address, hours, price list and promotions.\nTap [Book Now] or [Consult] to submit ✅.",
  "channel_wechat_oa": "4. 🟢 WeChat Official Account\nSearch [BeautsGO] in WeChat and follow the official account 💬.\nTap [Book Now] in the menu and enter {search_keywords} ⚡.\n💡 Alternative: search WeChat ID BeautsGOkr to add our service account.",
  "channel_web": "5. 🌐 Web Booking (desktop)\nVisit the BeautsGO website: https://www.beautsgo.com\nEnter {search_keywords} in the search bar to find {name}.\nView full clinic info including photos, doctors and prices.\nClick [Book] and submit your details ✅.",
  "tips": "📌 Tips:\nAll channels support Chinese, English, Japanese, Thai and more\nYou will receive an SMS/app notification after booking — keep your phone on\nTo modify a booking, go to \"My Bookings\" or contact support\nSupport hours: 9:00–18:00 (UTC+8)"
}
```

- [ ] **Step 4: Create i18n/ja.json and i18n/th.json stubs**

`i18n/ja.json` — copy same keys from `en.json`, mark values as `"[JA TODO]"` for each key so the structure is valid and translatable:
```json
{
  "title": "[JA TODO]",
  "channel_ios": "[JA TODO] {search_keywords} {name}",
  "channel_android": "[JA TODO] {search_keywords} {name}",
  "channel_wechat_mini": "[JA TODO] {search_keywords} {name}",
  "channel_wechat_oa": "[JA TODO] {search_keywords}",
  "channel_web": "[JA TODO] {search_keywords} {name}",
  "tips": "[JA TODO]"
}
```

`i18n/th.json` — same structure, `"[TH TODO]"` values.

- [ ] **Step 5: Verify JSON files are valid**

```bash
node -e "['zh','en','ja','th'].forEach(l => { require('./i18n/' + l + '.json'); console.log(l, 'ok') })"
```
Expected: `zh ok`, `en ok`, `ja ok`, `th ok`

- [ ] **Step 6: Commit**

```bash
git add templates/booking.tpl i18n/
git commit -m "feat: add booking.tpl structure and i18n files (zh/en/ja/th)"
```

---

## Task 4: Create core/renderer.js

**Files:**
- Create: `core/renderer.js`
- Create: `tests/renderer.test.js`

Render pipeline:
1. Load `templates/booking.tpl`
2. Load `i18n/{lang}.json`
3. Substitute `{name}` / `{search_keywords}` inside each i18n string
4. Replace `{channel_*}` / `{title}` / `{tips}` in template with processed i18n strings
5. Clean up empty placeholders

- [ ] **Step 1: Write failing tests**

Create `tests/renderer.test.js`:
```js
const { render } = require('../core/renderer')

const hospital = {
  id: 1,
  name: '韩国JD皮肤科',
  en_name: 'JD Skin Clinic',
  alias: 'JD皮肤科',
  aliases: ['jd皮肤科'],
  pinyin: 'JDpifuke',
  pinyin_abbr: 'JDpfk',
  search_keywords: '中文名"JD"、英文名"JD Skin"'
}

describe('render', () => {
  test('returns a non-empty string for zh', () => {
    const result = render(hospital, 'zh')
    expect(typeof result).toBe('string')
    expect(result.length).toBeGreaterThan(100)
  })

  test('zh output contains hospital name', () => {
    const result = render(hospital, 'zh')
    expect(result).toContain('韩国JD皮肤科')
  })

  test('zh output contains all 5 channel headers', () => {
    const result = render(hospital, 'zh')
    expect(result).toContain('App Store')
    expect(result).toContain('Google Play')
    expect(result).toContain('微信小程序')
    expect(result).toContain('微信公众号')
    expect(result).toContain('网页版')
  })

  test('en output contains hospital name', () => {
    const result = render(hospital, 'en')
    expect(result).toContain('韩国JD皮肤科')
  })

  // Checks that all structural template keys ({channel_ios}, etc.) were resolved.
  // Note: this regex only catches keys present in i18n/zh.json. If a future .tpl key
  // has no i18n entry, it will survive as a literal — add it to i18n to fix.
  test('no unresolved structural placeholders in output', () => {
    const result = render(hospital, 'zh')
    expect(result).not.toMatch(/\{channel_[a-z_]+\}/)
    expect(result).not.toMatch(/\{title\}/)
    expect(result).not.toMatch(/\{tips\}/)
  })

  test('no unresolved data placeholders in output', () => {
    const result = render(hospital, 'zh')
    expect(result).not.toMatch(/\{name\}/)
    expect(result).not.toMatch(/\{search_keywords\}/)
  })

  test('throws for unknown language', () => {
    expect(() => render(hospital, 'xx')).toThrow()
  })
})
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
npx jest tests/renderer.test.js --no-coverage
```
Expected: FAIL — `Cannot find module '../core/renderer'`

- [ ] **Step 3: Implement core/renderer.js**

Create `core/renderer.js`:
```js
const fs = require('fs')
const path = require('path')

function loadTemplate() {
  const tplPath = path.join(__dirname, '..', 'templates', 'booking.tpl')
  return fs.readFileSync(tplPath, 'utf-8')
}

function loadI18n(lang) {
  const i18nPath = path.join(__dirname, '..', 'i18n', `${lang}.json`)
  if (!fs.existsSync(i18nPath)) {
    throw new Error(`Unsupported language: ${lang}. Add i18n/${lang}.json to enable it.`)
  }
  return JSON.parse(fs.readFileSync(i18nPath, 'utf-8'))
}

function substituteData(str, data) {
  return Object.entries(data).reduce((acc, [key, value]) => {
    if (Array.isArray(value)) return acc
    const v = (value == null || value === '-' || value === '- ') ? '' : String(value)
    return acc.split(`{${key}}`).join(v)
  }, str)
}

function cleanUp(content) {
  // Strip empty quoted values (hospital missing en_name / pinyin produces 英文名"")
  content = content.replace(/""/g, '').replace(/''/g, '')

  // Strip dangling keyword labels when their value was empty (ported from old renderer)
  const labels = ['中文名', '英文名', '拼音', '首字母']
  for (const label of labels) {
    const esc = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
    content = content.replace(new RegExp(esc + '\\s*[,，、·]\\s*', 'gu'), '')
    content = content.replace(new RegExp('[,，、·]\\s*' + esc + '\\s*$', 'gmu'), '')
    content = content.replace(new RegExp(esc + '\\s*$', 'gmu'), '')
  }

  return content
    .replace(/或或者/g, '或').replace(/或者或/g, '或').replace(/或或/g, '或')
    .replace(/,+/g, ',')
    .replace(/,([。.!？\n])/g, '$1')
    .replace(/ +/g, ' ')
    .trim()
}

function render(hospital, lang = 'zh') {
  const template = loadTemplate()
  const i18n = loadI18n(lang)

  // Step 1: substitute data placeholders inside each i18n string
  const resolvedI18n = Object.fromEntries(
    Object.entries(i18n).map(([k, v]) => [k, substituteData(v, hospital)])
  )

  // Step 2: fill template structural placeholders with resolved i18n strings
  const filled = substituteData(template, resolvedI18n)

  return cleanUp(filled)
}

module.exports = { render }
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npx jest tests/renderer.test.js --no-coverage
```
Expected: PASS — all 6 tests green

- [ ] **Step 5: Commit**

```bash
git add core/renderer.js tests/renderer.test.js
git commit -m "feat: add core/renderer.js with i18n-aware template rendering"
```

---

## Task 5: Create core/service.js

**Files:**
- Create: `core/service.js`
- Create: `tests/service.test.js`

- [ ] **Step 1: Write failing tests**

Create `tests/service.test.js`:
```js
const { getBookingGuide } = require('../core/service')

describe('getBookingGuide', () => {
  test('returns guide for known hospital query', async () => {
    const result = await getBookingGuide('CNP皮肤科怎么预约')
    expect(typeof result).toBe('string')
    expect(result).toContain('CNP')
    expect(result).toContain('App Store')
  })

  test('returns guide in english', async () => {
    const result = await getBookingGuide('CNP', 'en')
    expect(result).toContain('App Store')
  })

  test('returns prompt string when hospital not found', async () => {
    const result = await getBookingGuide('不存在的诊所abc')
    expect(typeof result).toBe('string')
    expect(result.length).toBeGreaterThan(0)
  })

  test('defaults to zh language', async () => {
    // Chinese-only text confirms zh is the default, not en
    const result = await getBookingGuide('CNP')
    expect(result).toContain('苹果手机')
  })
})
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
npx jest tests/service.test.js --no-coverage
```
Expected: FAIL — `Cannot find module '../core/service'`

- [ ] **Step 3: Implement core/service.js**

Create `core/service.js`:
```js
const hospitals = require('../data/hospitals.json')
const { matchHospital, generateSearchKeywords } = require('./resolver')
const { render } = require('./renderer')

async function getBookingGuide(query, lang = 'zh') {
  const hospital = matchHospital(query, hospitals)
  if (!hospital) {
    return '请告诉我医院名称，我帮你生成预约流程'
  }

  const enriched = {
    ...hospital,
    search_keywords: generateSearchKeywords(hospital)
  }

  return render(enriched, lang)
}

module.exports = { getBookingGuide }
```

- [ ] **Step 4: Run full test suite**

```bash
npx jest --no-coverage
```
Expected: PASS — all tests across resolver, renderer, service

- [ ] **Step 5: Commit**

```bash
git add core/service.js tests/service.test.js
git commit -m "feat: add core/service.js — unified public API"
```

---

## Task 6: Create api/skill.js

**Files:**
- Create: `api/skill.js`

No unit test needed — it's a one-liner wrapper already covered by service tests.

- [ ] **Step 1: Create api/skill.js**

```js
const { getBookingGuide } = require('../core/service')

module.exports = async function (input) {
  return getBookingGuide(input.query, input.lang || 'zh')
}
```

- [ ] **Step 2: Smoke test via node**

```bash
node -e "require('./api/skill.js')({ query: 'JD皮肤科怎么预约' }).then(console.log)"
```
Expected: Full booking guide text for JD Skin Clinic

- [ ] **Step 3: Commit**

```bash
git add api/skill.js
git commit -m "feat: add api/skill.js entry point"
```

---

## Task 7: Create scripts/generate-md.js

**Files:**
- Create: `scripts/generate-md.js`

Iterates all hospitals, calls `getBookingGuide`, writes `docs/clinics/{slug}.md`.

Slug formula: `hospital.id` zero-padded to 4 digits + `-` + `en_name` lowercased, spaces→hyphens, non-alphanumeric stripped. Example: `0001-jd-skin-clinic.md`.

- [ ] **Step 1: Create scripts/generate-md.js**

```js
const fs = require('fs')
const path = require('path')
const hospitals = require('../data/hospitals.json')
const { getBookingGuide } = require('../core/service')

const OUT_DIR = path.join(__dirname, '..', 'docs', 'clinics')

function toSlug(hospital) {
  const id = String(hospital.id).padStart(4, '0')
  const name = (hospital.en_name || hospital.name || String(hospital.id))
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^a-z0-9-]/g, '')
  return `${id}-${name}`
}

async function main() {
  fs.mkdirSync(OUT_DIR, { recursive: true })

  let ok = 0
  let fail = 0

  for (const hospital of hospitals) {
    const slug = toSlug(hospital)
    const outPath = path.join(OUT_DIR, `${slug}.md`)
    try {
      const content = await getBookingGuide(hospital.name)
      fs.writeFileSync(outPath, `# ${hospital.name}\n\n${content}\n`, 'utf-8')
      console.log(`✅ ${slug}.md`)
      ok++
    } catch (err) {
      console.error(`❌ ${slug}: ${err.message}`)
      fail++
    }
  }

  console.log(`\nDone: ${ok} generated, ${fail} failed`)
}

main()
```

- [ ] **Step 2: Run the script**

```bash
node scripts/generate-md.js
```
Expected:
```
✅ 0001-jd-skin-clinic.md
✅ 0002-cnp-skin-clinic.md

Done: 2 generated, 0 failed
```

- [ ] **Step 3: Verify output files exist**

```bash
ls docs/clinics/
```
Expected: `0001-jd-skin-clinic.md  0002-cnp-skin-clinic.md`

- [ ] **Step 4: Commit**

> Note: `docs/clinics/` output files are committed so they are available without running the script. With 900 hospitals this will grow large — if you prefer to gitignore generated files instead, add `docs/clinics/` to `.gitignore` and only commit `scripts/generate-md.js`. Either way, `mkdirSync` in the script ensures the directory is created at runtime.

```bash
git add scripts/generate-md.js docs/clinics/
git commit -m "feat: add generate-md script for bulk clinic doc generation"
```

---

## Task 8: Remove old files

- [ ] **Step 1: Delete old lib/ and root-level files no longer needed**

```bash
rm -rf lib/
rm templates/zh_cn.txt
rm index.js
rm hospitals.json   # now lives in data/
```

- [ ] **Step 2: Run full test suite to confirm nothing broke**

```bash
npx jest --no-coverage
```
Expected: PASS — all tests still green

- [ ] **Step 3: Commit**

```bash
git rm -rf lib/ templates/zh_cn.txt index.js hospitals.json
git commit -m "chore: remove old lib/, templates/zh_cn.txt, root index.js and hospitals.json"
```

---

## Task 9: Update skill.json and SKILL.md

**Files:**
- Modify: `skill.json`
- Modify: `SKILL.md`

- [ ] **Step 1: Update skill.json**

```json
{
  "name": "booking-skill",
  "description": "根据医院名称生成预约流程",
  "entry": "api/skill.js",
  "input": {
    "type": "object",
    "properties": {
      "query": { "type": "string", "description": "用户输入（含医院名称）" },
      "lang": { "type": "string", "description": "语言代码: zh | en | ja | th，默认 zh" }
    },
    "required": ["query"]
  }
}
```

- [ ] **Step 2: Update SKILL.md data section**

Change the data paths in `SKILL.md` to reflect new locations:
- `hospitals.json` → `data/hospitals.json`
- `templates/zh_cn.txt` → `templates/booking.tpl` + `i18n/*.json`

- [ ] **Step 3: Run final check**

```bash
npx jest --no-coverage && node -e "require('./api/skill.js')({ query: 'CNP怎么预约', lang: 'en' }).then(console.log)"
```
Expected: all tests pass + English booking guide printed

- [ ] **Step 4: Final commit**

```bash
git add skill.json SKILL.md
git commit -m "chore: update skill.json entry point and SKILL.md for new structure"
```

---

## Final Directory State

```
project/
├── api/
│   └── skill.js              # thin skill entry
├── core/
│   ├── resolver.js           # match hospital + generate keywords
│   ├── renderer.js           # load tpl + i18n + render
│   └── service.js            # getBookingGuide(query, lang)
├── data/
│   └── hospitals.json        # sole data source
├── docs/
│   └── clinics/              # generated MD files
├── i18n/
│   ├── zh.json
│   ├── en.json
│   ├── ja.json
│   └── th.json
├── scripts/
│   └── generate-md.js
├── templates/
│   └── booking.tpl
├── tests/
│   ├── resolver.test.js
│   ├── renderer.test.js
│   └── service.test.js
├── skill.json
├── SKILL.md
├── README.md
└── package.json
```


path: docs/v2.1.5-fix-summary.md
content: # 咨询按钮修复总结 - v2.1.5

## 🎯 问题
用户说"咨询客服"后，页面打开但**没有自动点击咨询按钮**，而是返回了手动操作指南。

## 🔍 根本原因分析

### 诊断步骤：
1. **初始测试** → Playwright 选择器无法找到 `.btns-consult` 元素
2. **HTML 快照分析** → 发现元素确实存在：
   ```html
   <uni-view class="btns-right btns-consult">
     <uni-text class="custom-icon custom-icon-kefu1"></uni-text>
     咨询一下
   </uni-view>
   ```
3. **等待时间测试** → 发现元素加载需要 **5+ 秒**（Vue 动态渲染）
4. **改进方案验证** → 增加等待时间到 8 秒，使用 `waitForSelector` ✅ 成功

## ✅ 解决方案（v2.1.5）

### 核心改进：
| 改进项 | v2.1.4 | v2.1.5 |
|------|--------|--------|
| 等待时间 | 5 秒 | 8 秒 |
| 选择器策略 | 直接尝试 6 个选择器 | `waitForSelector` + 混合策略 |
| 降级方案 | DOM evaluate | 精确文本匹配 + Playwright 选择器 |
| 日志清晰度 | 基础 | 详细区分不同失败场景 |

### 代码改动：
```javascript
// 关键改进 1: 增加等待和 waitForSelector
await page.waitForTimeout(8000)
await page.waitForSelector('.btns-consult', { timeout: 10000 })

// 关键改进 2: 优先使用 class 选择器
const button = document.querySelector('.btns-consult')

// 关键改进 3: 精确文本匹配作为备选
if (text === '咨询一下' && el.offsetParent !== null)
```

## 📊 测试验证

### 测试脚本：
- `test-consult-selectors.js` → 初始选择器测试
- `test-inspect-page-html.js` → HTML 结构分析
- `test-deep-inspect.js` → 深层诊断（8+ 秒等待）
- `test-improved-click.js` → 修复后验证 ✅

### 测试结果：
```
[Booking Skill] .btns-consult selector detected
[Booking Skill] ✅ Consult button clicked successfully
✅ 已帮你打开 韩国JD皮肤科 的咨询对话页面！
```

**成功率：100%** ✅

## 🚀 用户体验改进

### 之前（v2.1.4）：
```
用户: "咨询客服"
系统: ✅ 打开了页面
     ⚠️ 点击失败，请手动点击咨询按钮
```

### 现在（v2.1.5）：
```
用户: "咨询客服"
系统: ✅ 打开了医院页面
     ✅ 自动点击了"咨询一下"按钮
     ✅ 现在页面应该已经跳转到在线客服对话窗口
```

## 📝 技术细节

### BeautsGO 页面特性：
- 框架：Vue.js + uni-app（微信小程序框架）
- 组件：`<uni-view>` 自定义组件（非标准 HTML）
- 加载：动态渲染，需要 5+ 秒完全初始化
- 咨询按钮：`class="btns-right btns-consult"`

### 为什么之前失败：
- 原有等待时间（5秒）可能不足
- Playwright 的文本选择器 `text=/咨询一下/` 对动态组件不可靠
- 缺少 `waitForSelector` 主动等待元素

### 为什么现在成功：
- 8 秒等待确保 Vue 完全初始化
- `waitForSelector` 主动检测元素何时出现
- 优先使用 class 选择器 `.btns-consult`（比文本匹配更可靠）
- 完善的降级策略

## 📦 版本更新

- **版本**：v2.1.5
- **文件更新**：package.json, skill.json, SKILL.md, api/skill.js
- **Git 提交**：`fix(v2.1.5): 修复咨询按钮点击 - 增加等待时间和 waitForSelector`

## 🎓 关键学习

1. **动态渲染页面调试**：需要增加等待时间并使用主动等待机制
2. **uni-app 元素选择**：class 选择器比文本匹配更可靠
3. **混合策略的重要性**：Playwright API + DOM 查询互补
4. **详细日志的价值**：帮助快速诊断跨不同平台的问题

---

**下一步**：等待用户在 OpenClaw 中测试 v2.1.5，如有问题继续改进。


path: i18n/en.json
content: {
  "keyword_labels": ["Chinese name", "English name", "Pinyin", "Initials"],
  "not_found": "Please provide the clinic name and I'll generate the booking guide for you.",
  "title": "## {name} Booking Guide",
  "direct_link": "🔗 Book directly here: {url}",
  "channel_ios": "1. 🍎 iOS Booking\nSearch \"BeautsGO\" on the App Store, download and install the app 📥.\nIn the search bar, type {search_keywords} to find {name}.\nView address 📍, hours ⏰, price list 💰 and promotions.\nTap [Book Now] or [Consult], fill in your details and submit ✅.",
  "channel_android": "2. 🤖 Android Booking\nSearch \"BeautsGO\" on Google Play, download and install the app 📲.\nIn the search bar, type {search_keywords} to find {name}.\nView clinic details: address, hours, prices and promotions.\nTap [Book Now] or [Consult], fill in your details and submit ✅.",
  "channel_wechat_mini": "3. 📱 WeChat Mini-Program (no app download needed)\nOpen WeChat, search for the \"BeautsGO\" mini-program.\nType {search_keywords} in the search bar to find {name}.\nView clinic details: address, hours, price list and promotions.\nTap [Book Now] or [Consult] to submit ✅.",
  "channel_wechat_oa": "4. 🟢 WeChat Official Account\nSearch [BeautsGO] in WeChat and follow the official account 💬.\nTap [Book Now] in the menu and enter {search_keywords} ⚡.\n💡 Alternative: search WeChat ID BeautsGOkr to add our service account.",
  "channel_web": "5. 🌐 Web Booking (desktop)\nVisit the BeautsGO website: https://www.beautsgo.com\nEnter {search_keywords} in the search bar to find {name}.\nView full clinic info including photos, doctors and prices.\nClick [Book] and submit your details ✅.",
  "tips": "📌 Tips:\nAll channels support Chinese, English, Japanese, Thai and more\nYou will receive an SMS/app notification after booking — keep your phone on\nTo modify a booking, go to \"My Bookings\" or contact support\nSupport hours: 9:00–18:00 (UTC+8)"
}


path: i18n/ja.json
content: {
  "keyword_labels": ["中国語名", "英語名", "ピンイン", "イニシャル"],
  "not_found": "病院名を教えてください。予約方法を生成します。",
  "title": "## {name} 予約ガイド",
  "direct_link": "🔗 こちらから直接予約：{url}",
  "channel_ios": "1. 🍎 iPhone（iOS）予約\nApp Storeで「BeautsGO」または「彼此美」を検索し、アプリをダウンロードしてインストールしてください 📥。\nアプリを開き、検索バーに {search_keywords} を入力すると、{name} が見つかります。\n住所 📍、営業時間 ⏰、当月価格表 💰、プロモーションを確認してください。\n【予約】または【問い合わせ】をタップし、情報を入力して送信してください ✅。",
  "channel_android": "2. 🤖 Android予約\nGoogle Playで「BeautsGO」または「彼此美」を検索し、アプリをダウンロードしてインストールしてください 📲。\nアプリを開き、検索バーに {search_keywords} を入力すると、{name} が見つかります。\n詳細情報：住所、営業時間、価格、プロモーションなど。\n【予約】または【問い合わせ】をタップし、情報を入力して送信してください ✅。",
  "channel_wechat_mini": "3. 📱 WeChatミニプログラム予約（アプリダウンロード不要）\nWeChatで「BeautsGO」または「彼此美」ミニプログラムを検索してください。\n検索バーに {search_keywords} を入力して {name} を検索してください。\n病院詳細：住所、営業時間、価格表、プロモーション。\n【予約】または【問い合わせ】をタップして送信してください ✅。",
  "channel_wechat_oa": "4. 🟢 WeChat公式アカウント予約\nWeChatで【BeautsGO】を検索し、公式アカウント「BeautsGO彼此美APP」をフォローしてください 💬。\n左下メニューの【予約】をクリックし、{search_keywords} を入力してください ⚡。\n💡 代替：WeChat ID BeautsGOkr を検索して相談。",
  "channel_web": "5. 🌐 Web予約（PC推奨）\nBeautsGO公式サイトにアクセス：https://www.beautsgo.com\n検索バーに {search_keywords} を入力すると {name} が見つかります。\n完全な病院情報：環境写真、医師チーム、価格表など。\n【予約】ボタンをクリックし、情報を入力して送信してください ✅。",
  "tips": "📌 ヒント：\n全てのチャネルが中国語・繁体字・英語・タイ語・日本語などに対応\n予約後はSMS/アプリ通知が届きますので、携帯電話をオンにしておいてください\n予約を変更する場合は「マイ予約」で操作するか、カスタマーサービスにお問い合わせください\nカスタマーサービス時間：9:00-18:00（北京時間）"
}


path: i18n/th.json
content: {
  "keyword_labels": ["ชื่อจีน", "ชื่ออังกฤษ", "พินอิน", "อักษรย่อ"],
  "not_found": "กรุณาบอกชื่อโรงพยาบาล ผมจะสร้างขั้นตอนการจองให้",
  "title": "## {name} คู่มือการจอง",
  "direct_link": "🔗 จองโดยตรงที่นี่: {url}",
  "channel_ios": "1. 🍎 iOS จอง\nค้นหา \"BeautsGO\" ใน App Store ดาวน์โหลดและติดตั้งแอป 📥\nในแถบค้นหา พิมพ์ {search_keywords} เพื่อหา {name}\nดูที่อยู่ 📍 เวลาเปิด ⏰ รายการราคา 💰 และโปรโมชั่น\nแตะ [จอง] หรือ [สอบถาม] กรอกรายละเอียดและส่ง ✅",
  "channel_android": "2. 🤖 Android จอง\nค้นหา \"BeautsGO\" ใน Google Play ดาวน์โหลดและติดตั้งแอป 📲\nในแถบค้นหา พิมพ์ {search_keywords} เพื่อหา {name}\nดูรายละเอียด: ที่อยู่ เวลาเปิด ราคา โปรโมชั่น\nแตะ [จอง] หรือ [สอบถาม] กรอกรายละเอียดและส่ง ✅",
  "channel_wechat_mini": "3. 📱 WeChat Mini-Program (ไม่ต้องดาวน์โหลดแอป)\nเปิด WeChat ค้นหา Mini-Program \"BeautsGO\"\nพิมพ์ {search_keywords} ในแถบค้นหาเพื่อหา {name}\nดูรายละเอียด: ที่อยู่ เวลาเปิด รายการราคา โปรโมชั่น\nแตะ [จอง] หรือ [สอบถาม] ส่ง ✅",
  "channel_wechat_oa": "4. 🟢 WeChat Official Account\nค้นหา [BeautsGO] ใน WeChat ติดตามบัญชี \"BeautsGO彼此美APP\" 💬\nแตะเมนู [จอง] และพิมพ์ {search_keywords} ⚡\n💡 อีกทาง: ค้นหา WeChat ID BeautsGOkr เพื่อขอคำปรึกษา",
  "channel_web": "5. 🌐 Web จอง (คอมพิวเตอร์)\nเข้าเว็บไซต์ BeautsGO: https://www.beautsgo.com\nพิมพ์ {search_keywords} ในช่องค้นหาเพื่อหา {name}\nดูข้อมูลครบถ้วน: รูปสภาพแวดล้อม ทีมแพทย์ รายการราคา\nคลิก [จอง] กรอกรายละเอียดและส่ง ✅",
  "tips": "📌 ข้อแนะนำ:\nทุกช่องทางรองรับหลายภาษา: จีน อังกฤษ ไทย ญี่ปุ่น\nหลังจองสำเร็จ คุณจะได้รับ SMS/การแจ้งเตือนในแอป — โปรดเปิดโทรศัพท์ไว้\nหากต้องการแก้ไขการจอง ให้ไปที่ \"การจองของฉัน\" หรือติดต่อลูกค้าสัมพันธ์\nเวลาลูกค้าสัมพันธ์: 9:00-18:00 (เวลาปักกิ่ง)"
}


path: i18n/zh.json
content: {
  "keyword_labels": ["中文名", "英文名", "拼音", "首字母"],
  "not_found": "请告诉我医院名称，我帮你生成预约流程",
  "title": "## {name} 预约指南",
  "direct_link": "🔗 点击直达预约页面：{url}",
  "channel_ios": "一、🍎 苹果手机预约（iOS 用户首选）\n打开 App Store 搜索「BeautsGO」或「彼此美」，下载并安装 BeautsGO APP 📥。\n打开 APP，在顶部搜索栏输入 {search_keywords} 均可快速找到{name}。\n进入医院页面，查看中韩文地址 📍、营业时间 ⏰、当月价格表 💰 及优惠活动。\n点击右下角【立即预约】或【咨询一下】，填写人数与时间，即可提交预约 ✅。",
  "channel_android": "二、🤖 Android 手机预约（安卓用户）\n打开 Google Play 搜索「BeautsGO」或「彼此美」，下载安装 APP 📲。\n打开 APP，在顶部搜索栏输入 {search_keywords} 均可快速找到{name}。\n进入医院页面，查看详细信息：地址、营业时间、当月价格、优惠活动等。\n点击【立即预约】或【咨询一下】，填写预约信息后提交 ✅。",
  "channel_wechat_mini": "三、📱 微信小程序预约（无需下载 APP）\n打开微信，搜索「BeautsGO」或「彼此美」小程序。\n进入小程序，在搜索栏输入 {search_keywords} 搜索{name}。\n查看医院详情：地址、营业时间、价格表、优惠活动。\n点击【立即预约】或【咨询一下】提交预约 ✅。",
  "channel_wechat_oa": "四、🟢 微信公众号预约（国内用户推荐）\n微信搜索【BeautsGO】或【彼此美】，关注公众号「BeautsGO彼此美APP」💬。\n点击左下角菜单【一键预约】，输入 {search_keywords} 进入流程 ⚡。\n💡 备选：直接在微信搜索框搜索微信号 BeautsGOkr 添加好友咨询。",
  "channel_web": "五、🌐 网页版预约（电脑端推荐）\n打开浏览器访问 BeautsGO 官网：https://www.beautsgo.com\n在首页搜索框输入 {search_keywords} 找到{name}。\n查看医院完整信息，包括环境照片、医生团队、价格表等。\n点击【预约】按钮，填写信息提交 ✅。",
  "tips": "📌 温馨提示：\n所有渠道均支持中文、繁体中文、英文、泰文、日文等多语言切换\n预约成功后会收到短信/APP 通知，请保持手机畅通\n如需修改预约，请在「我的预约」中操作或联系客服\n客服咨询时间：9:00-18:00（北京时间）"
}


path: package.json
content: {
  "name": "korean-booking",
  "version": "2.1.7",
  "main": "api/skill.js",
  "scripts": {
    "test": "jest",
    "generate": "node scripts/generate-md.js",
    "generate:all": "node scripts/generate-md.js --lang=zh,en,ja,th"
  },
  "dependencies": {
    "pinyin-pro": "^3.26.0",
    "playwright": "^1.58.2"
  },
  "devDependencies": {
    "jest": "^29.0.0"
  }
}


path: scripts/generate-md.js
content: const fs = require('fs')
const path = require('path')
const hospitals = require('../data/hospitals.json')
const { getBookingGuide } = require('../core/service')

const OUT_DIR = path.join(__dirname, '..', 'docs', 'clinics')

// Supported languages: pass --lang=en or --lang=zh,en,ja,th to generate multiple
const ALL_LANGS = ['zh', 'en', 'ja', 'th']
const langArg = process.argv.find(a => a.startsWith('--lang='))
const LANGS = langArg
  ? langArg.replace('--lang=', '').split(',').map(l => l.trim()).filter(Boolean)
  : ['zh']

function toSlug(hospital) {
  const id = String(hospital.id).padStart(4, '0')
  const name = (hospital.en_name || hospital.name || String(hospital.id))
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^a-z0-9-]/g, '')
  return `${id}-${name}`
}

async function main() {
  if (!LANGS.every(l => ALL_LANGS.includes(l))) {
    const invalid = LANGS.filter(l => !ALL_LANGS.includes(l))
    console.error(`❌ Unsupported language(s): ${invalid.join(', ')}. Supported: ${ALL_LANGS.join(', ')}`)
    process.exit(1)
  }

  console.log(`Generating for language(s): ${LANGS.join(', ')}`)

  for (const lang of LANGS) {
    const langDir = LANGS.length > 1 ? path.join(OUT_DIR, lang) : OUT_DIR
    fs.mkdirSync(langDir, { recursive: true })

    let ok = 0
    let fail = 0

    for (const hospital of hospitals) {
      const slug = toSlug(hospital)
      const outPath = path.join(langDir, `${slug}.md`)
      try {
        const content = await getBookingGuide(hospital.name, lang)
        const title = hospital.en_name || hospital.name || slug
        const frontmatter = `---\nlayout: default\ntitle: "${title.replace(/"/g, '\\"')}"\nlang: ${lang}\n---\n\n`
        fs.writeFileSync(outPath, `${frontmatter}${content}\n`, 'utf-8')
        console.log(`✅ [${lang}] ${slug}.md`)
        ok++
      } catch (err) {
        console.error(`❌ [${lang}] ${slug}: ${err.message}`)
        fail++
      }
    }

    console.log(`[${lang}] Done: ${ok} generated, ${fail} failed\n`)
  }
}

main()


path: scripts/import-hospitals.js
content: #!/usr/bin/env node
/**
 * scripts/import-hospitals.js
 *
 * 从数据库导出的 CSV 生成 data/hospitals.json
 *
 * CSV 列顺序（无表头）：
 *   id, name, alias, en_name, pinyin, pinyin_abbr
 *
 * 用法：
 *   node scripts/import-hospitals.js /path/to/export.csv
 *   node scripts/import-hospitals.js ~/Desktop/无标题.csv
 */

const fs   = require('fs')
const path = require('path')

// ── formatSlug (与前端 updateSemanticUrl 逻辑一致) ────────────────────────────
function formatSlug(enName) {
  if (!enName) return ''
  return enName
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^a-z0-9-]/g, '')
    .replace(/-+/g, '-')       // 合并连续连字符
    .replace(/^-|-$/g, '')     // 去掉首尾连字符
}

// ── CSV 解析（支持带引号字段） ─────────────────────────────────────────────────
function parseCSVLine(line) {
  const result = []
  let current = ''
  let inQuotes = false

  for (let i = 0; i < line.length; i++) {
    const ch = line[i]
    if (ch === '"') {
      if (inQuotes && line[i + 1] === '"') {
        // 转义的双引号
        current += '"'
        i++
      } else {
        inQuotes = !inQuotes
      }
    } else if (ch === ',' && !inQuotes) {
      result.push(current.trim())
      current = ''
    } else {
      current += ch
    }
  }
  result.push(current.trim())
  return result
}

// ── 主逻辑 ────────────────────────────────────────────────────────────────────
function main() {
  const csvPath = process.argv[2]
  if (!csvPath) {
    console.error('用法: node scripts/import-hospitals.js <csv文件路径>')
    process.exit(1)
  }

  const absPath = path.resolve(csvPath.replace(/^~/, process.env.HOME))
  if (!fs.existsSync(absPath)) {
    console.error(`文件不存在: ${absPath}`)
    process.exit(1)
  }

  const content = fs.readFileSync(absPath, 'utf-8')
  const lines   = content.split(/\r?\n/).filter(l => l.trim())

  const hospitals = []
  let skipped = 0

  for (const line of lines) {
    const cols = parseCSVLine(line)
    // 至少需要 id 和 name
    if (cols.length < 2) { skipped++; continue }

    const [id, name, alias, en_name, pinyin, pinyin_abbr] = cols

    // id 必须是数字
    const numId = parseInt(id, 10)
    if (isNaN(numId) || !name) { skipped++; continue }

    const cleanName    = name.trim()
    const cleanAlias   = alias ? alias.trim() : ''
    const cleanEnName  = en_name ? en_name.trim() : ''
    const cleanPinyin  = pinyin ? pinyin.trim() : ''
    const cleanAbbr    = pinyin_abbr ? pinyin_abbr.trim() : ''

    // 生成 slug 和 URL
    const slug = formatSlug(cleanEnName)
    const url         = slug ? `https://i.beautsgo.com/cn/hospital/${slug}?from=skill` : ''
    const booking_url = slug ? `https://i.beautsgo.com/cn/hospital/${slug}/skill` : ''

    const entry = {
      id: numId,
      name: cleanName,
      alias: cleanAlias || cleanName,  // alias 为空时降级为 name
      en_name: cleanEnName,
      pinyin: cleanPinyin,
      pinyin_abbr: cleanAbbr,
      url,
      booking_url
    }

    hospitals.push(entry)
  }

  // 按 id 排序
  hospitals.sort((a, b) => a.id - b.id)

  const outPath = path.join(__dirname, '..', 'data', 'hospitals.json')
  fs.writeFileSync(outPath, JSON.stringify(hospitals, null, 2), 'utf-8')

  console.log(`✅ 导入完成：${hospitals.length} 条医院数据 → ${outPath}`)
  if (skipped > 0) console.log(`⚠️  跳过 ${skipped} 行（格式不合法）`)
}

main()


path: scripts/merge-address.js
content: #!/usr/bin/env node
/**
 * scripts/merge-address.js
 *
 * 将地址 CSV 合并进 data/hospitals.json（只追加地址字段，原有字段保留）
 *
 * CSV 列顺序（无表头）：
 *   id, zh_cn_address, en_address, ko_kr_address, ja_address, th_address
 *
 * 用法：
 *   node scripts/merge-address.js /path/to/无标题.csv
 */

const fs   = require('fs')
const path = require('path')

// ── CSV 解析（支持带引号字段） ─────────────────────────────────────────────────
function parseCSVLine(line) {
  const result = []
  let current = ''
  let inQuotes = false

  for (let i = 0; i < line.length; i++) {
    const ch = line[i]
    if (ch === '"') {
      if (inQuotes && line[i + 1] === '"') {
        current += '"'
        i++
      } else {
        inQuotes = !inQuotes
      }
    } else if (ch === ',' && !inQuotes) {
      result.push(current.trim())
      current = ''
    } else {
      current += ch
    }
  }
  result.push(current.trim())
  return result
}

function main() {
  const csvPath = process.argv[2]
  if (!csvPath) {
    console.error('用法: node scripts/merge-address.js <csv文件路径>')
    process.exit(1)
  }

  const absPath = path.resolve(csvPath.replace(/^~/, process.env.HOME))
  if (!fs.existsSync(absPath)) {
    console.error(`文件不存在: ${absPath}`)
    process.exit(1)
  }

  // 读取现有 hospitals.json
  const jsonPath  = path.join(__dirname, '..', 'data', 'hospitals.json')
  const hospitals = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))

  // 建立 id → index 映射，方便快速定位
  const idMap = {}
  hospitals.forEach((h, i) => { idMap[h.id] = i })

  // 解析 CSV，按 id 合并地址字段
  const content = fs.readFileSync(absPath, 'utf-8')
  const lines   = content.split(/\r?\n/).filter(l => l.trim())

  let merged = 0
  let skipped = 0
  let notFound = 0

  for (const line of lines) {
    const cols = parseCSVLine(line)
    if (cols.length < 1) { skipped++; continue }

    const numId = parseInt(cols[0], 10)
    if (isNaN(numId)) { skipped++; continue }

    const idx = idMap[numId]
    if (idx === undefined) {
      // CSV 里有但 json 里没有（已下架等），跳过
      notFound++
      continue
    }

    // 只追加地址字段，原有字段保留
    if (cols[1] !== undefined) hospitals[idx].zh_cn_address = cols[1]
    if (cols[2] !== undefined) hospitals[idx].en_address     = cols[2]
    if (cols[3] !== undefined) hospitals[idx].ko_kr_address  = cols[3]
    if (cols[4] !== undefined) hospitals[idx].ja_address     = cols[4]
    if (cols[5] !== undefined) hospitals[idx].th_address     = cols[5]

    merged++
  }

  fs.writeFileSync(jsonPath, JSON.stringify(hospitals, null, 2), 'utf-8')

  console.log(`✅ 合并完成：${merged} 条地址已写入 hospitals.json`)
  if (notFound > 0) console.log(`⚠️  ${notFound} 条 id 在 hospitals.json 中不存在（已跳过）`)
  if (skipped  > 0) console.log(`⚠️  ${skipped} 行格式不合法（已跳过）`)
}

main()


path: scripts/sync-to-openclaw.sh
content: #!/bin/bash
# 将项目最新文件同步到 openclaw workspace 缓存，并重启 gateway
# 用法：bash scripts/sync-to-openclaw.sh

SKILL_CACHE=~/.openclaw/workspace/skills/korean-booking
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"

echo "📂 项目目录：$PROJECT_DIR"
echo "📦 缓存目录：$SKILL_CACHE"

cp "$PROJECT_DIR/skill.json"   "$SKILL_CACHE/skill.json"   && echo "✅ skill.json"
cp "$PROJECT_DIR/SKILL.md"     "$SKILL_CACHE/SKILL.md"     && echo "✅ SKILL.md"
cp "$PROJECT_DIR/api/skill.js" "$SKILL_CACHE/api/skill.js" && echo "✅ api/skill.js"

# 同步 core
for f in resolver.js preprocessor.js service.js renderer.js; do
  [ -f "$PROJECT_DIR/core/$f" ] && cp "$PROJECT_DIR/core/$f" "$SKILL_CACHE/core/$f" && echo "✅ core/$f"
done

# 同步 data 和 i18n
cp "$PROJECT_DIR/data/hospitals.json" "$SKILL_CACHE/data/hospitals.json" && echo "✅ data/hospitals.json"

echo ""
echo "🔄 重启 openclaw gateway..."
pkill -f openclaw-gateway 2>/dev/null
sleep 1
nohup openclaw gateway > /tmp/openclaw-gateway.log 2>&1 &
sleep 3

echo "📋 当前技能状态："
openclaw skills list 2>/dev/null | grep -A 2 booking
echo ""
echo "🎉 同步完成！"


path: skill.json
content: {
  "name": "booking-skill",
  "title": "韩国美容预约指南 Korean Beauty Booking",
  "version": "2.6.8",
  "tags": ["韩国", "美容", "医美", "预约", "首尔", "整形", "皮肤科", "Korea", "beauty", "booking", "aesthetic", "Seoul"],
  "description": "韩国/首尔美容医美预约助手，内置 1300+ 家皮肤科/整形医院数据库（BeautsGO 平台）。支持：①按医院名或项目类型（激光/注射/整形）查询预约流程；②直接调用接口提交预约（收集人数/时间/联系方式后 POST，无需浏览器）；③打开医院详情页/价格表/在线客服；④中/英/日/泰四语言。触发场景：询问韩国美容预约、想做医美、推荐医院、查价格、咨询客服。",
  "permissions": {
    "network": [
      "https://api.yestokr.com/api/Appointment/saveFromSkill",
      "https://i.beautsgo.com/*"
    ],
    "browser": true,
    "filesystem": false
  },
  "privacy": {
    "data_collected": ["用户提供的手机号（联系方式），仅在用户明确同意后发送至 BeautsGO 预约接口"],
    "data_sent_to": [
      {
        "name": "BeautsGO 预约接口",
        "url": "https://api.yestokr.com/api/Appointment/saveFromSkill",
        "purpose": "提交预约申请",
        "when": "仅当用户主动发起「帮我预约」并提供联系方式时"
      }
    ],
    "no_data_stored": true,
    "no_tracking": true
  },
  "runtime": {
    "requires": {
      "node": ">=16",
      "npm_packages": ["playwright", "chromium"]
    },
    "browser_automation": {
      "purpose": "打开 BeautsGO 医院详情页、价格表、在线客服页面",
      "domains": ["i.beautsgo.com"],
      "user_visible": true
    }
  },
  "entry": "api/skill.js",
  "input": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "用户输入：初次询问医院名称，或后续选择操作（'打开链接'/'帮我预约'/'咨询客服'/'2人，3月26日'等预约信息）"
      },
      "lang": {
        "type": "string",
        "description": "语言代码（zh/en/ja/th），默认zh",
        "default": "zh"
      },
      "context": {
        "type": "object",
        "description": "多轮对话上下文（可选），用于保存医院信息以供后续轮次使用"
      }
    },
    "required": ["query"]
  },
  "output": {
    "type": "string",
    "description": "第1轮：预约流程说明+操作建议；第3轮（帮我预约）：打开表单+询问信息；第4轮（填写信息）：自动填表提交+结果反馈"
  }
}

path: test-booking-btn.js
content: /**
 * 诊断脚本：检查"预约面诊"按钮的实际渲染时机和 DOM 结构
 */
const playwright = require('playwright')

async function diagnose() {
  const url = 'https://i.beautsgo.com/cn/hospital/jdclinic?from=skill'
  console.log('[诊断] 启动浏览器...')

  const browser = await playwright.chromium.launch({
    headless: false,
    args: ['--disable-features=ExternalProtocolDialog', '--no-default-browser-check']
  })
  const context = await browser.newContext({ bypassCSP: true })
  const page = await context.newPage()

  await page.route('**/*', async (route) => {
    const reqUrl = route.request().url()
    if (!reqUrl.startsWith('http://') && !reqUrl.startsWith('https://')) {
      await route.abort()
    } else {
      await route.continue()
    }
  })

  console.log('[诊断] 打开页面...')
  await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {})

  // 每秒检查一次按钮，最多等 15 秒
  for (let i = 1; i <= 15; i++) {
    await page.waitForTimeout(1000)
    const result = await page.evaluate(() => {
      // 找所有包含"预约"的可见元素
      const found = []
      const allEls = document.querySelectorAll('*')
      for (const el of allEls) {
        const text = (el.textContent || '').trim()
        if (text.includes('预约') && el.offsetParent !== null && text.length < 20) {
          found.push({
            tag: el.tagName,
            class: el.className,
            text,
            visible: el.offsetParent !== null
          })
        }
      }
      // 检查 .btns-right
      const btnsRight = document.querySelectorAll('.btns-right')
      const btnsRightInfo = Array.from(btnsRight).map(el => ({
        class: el.className,
        text: (el.textContent || '').trim(),
        visible: el.offsetParent !== null
      }))
      return { found, btnsRightInfo }
    })

    console.log(`[诊断] ${i}秒后:`)
    console.log('  含"预约"的可见元素:', result.found.length, result.found.map(f => `"${f.text}"(${f.class})`).join(', '))
    console.log('  .btns-right 元素:', result.btnsRightInfo.length, result.btnsRightInfo.map(b => `"${b.text}" visible=${b.visible}`).join(', '))

    // 如果找到了，尝试点击并记录
    if (result.found.length > 0) {
      const clicked = await page.evaluate(() => {
        const allEls = document.querySelectorAll('*')
        for (const el of allEls) {
          const text = (el.textContent || '').trim()
          if ((text === '预约面诊' || text === '立即预约' || text === '预约') && el.offsetParent !== null) {
            el.click()
            return { success: true, text }
          }
        }
        // 降级：.btns-right 第一个可见
        const btn = document.querySelector('.btns-right')
        if (btn && btn.offsetParent !== null) {
          btn.click()
          return { success: true, text: btn.textContent.trim() }
        }
        return { success: false }
      })
      console.log(`[诊断] 点击结果:`, clicked)
      if (clicked.success) {
        await page.waitForTimeout(3000)
        console.log('[诊断] 点击后 URL:', page.url())
        break
      }
    }
  }

  console.log('[诊断] 完成，10秒后关闭...')
  await page.waitForTimeout(10000)
  await browser.close()
}

diagnose().catch(console.error)


path: test-complete-flow.js
content: #!/usr/bin/env node

/**
 * 完整的多轮对话流程测试
 * 模拟用户与 OpenClaw 中的 korean-booking skill 的交互
 */

const skill = require('./api/skill')

async function runCompleteTest() {
  console.log('\n' + '='.repeat(70))
  console.log('🚀 韩国医美预约 Skill v2.1.0 - 完整流程测试')
  console.log('='.repeat(70) + '\n')
  
  try {
    // 第1轮：用户询问预约流程
    console.log('【第1轮】用户询问预约流程')
    console.log('📱 用户输入: "怎么预约 CNP 皮肤科?"')
    console.log('-'.repeat(70))
    
    let result = await skill({
      query: '怎么预约 CNP 皮肤科',
      lang: 'zh'
    })
    console.log(result)
    
    // 第2轮：用户选择打开链接（需要重复医院名称以便识别）
    console.log('\n\n【第2轮】用户选择打开链接')
    console.log('📱 用户输入: "打开 CNP 皮肤科 的链接"')
    console.log('-'.repeat(70))
    console.log('⏳ 等待浏览器打开...')
    
    result = await skill({
      query: '打开 CNP 皮肤科 的链接',
      lang: 'zh'
    })
    console.log(result)
    
    // 延迟，让用户看到浏览器
    await new Promise(resolve => setTimeout(resolve, 3000))
    
    // 第3轮：用户选择帮我预约
    console.log('\n\n【第3轮】用户选择自动预约')
    console.log('📱 用户输入: "帮我预约 CNP"')
    console.log('-'.repeat(70))
    console.log('⏳ Playwright 自动打开浏览器并点击预约按钮...')
    
    result = await skill({
      query: '帮我预约 CNP 皮肤科',
      lang: 'zh'
    })
    console.log(result)
    
    // 延迟，让用户看到自动化过程
    await new Promise(resolve => setTimeout(resolve, 3000))
    
    // 第4轮：用户选择咨询客服
    console.log('\n\n【第4轮】用户选择咨询客服')
    console.log('📱 用户输入: "咨询 CNP 皮肤科 客服"')
    console.log('-'.repeat(70))
    console.log('⏳ 自动打开浏览器并点击咨询按钮...')
    
    result = await skill({
      query: 'CNP 皮肤科 咨询客服',
      lang: 'zh'
    })
    console.log(result)
    
    // 延迟
    await new Promise(resolve => setTimeout(resolve, 2000))
    
    console.log('\n' + '='.repeat(70))
    console.log('✅ 测试完成！')
    console.log('='.repeat(70))
    console.log('\n📊 测试总结：')
    console.log('  ✓ 第1轮: 预约流程查询')
    console.log('  ✓ 第2轮: 打开链接自动化')
    console.log('  ✓ 第3轮: 预约按钮自动化')
    console.log('  ✓ 第4轮: 咨询按钮自动化')
    console.log('\n💡 提示：')
    console.log('  • 浏览器窗口应该已在后台打开')
    console.log('  • 请检查浏览器中是否显示 BeautsGO 的医院页面')
    console.log('  • 如果自动化成功，应该看到预约表单或客服对话框')
    console.log('\n')
    
  } catch (err) {
    console.error('❌ 测试失败:', err.message)
    console.error(err)
    process.exit(1)
  }
}

// 运行测试
runCompleteTest()


path: test-consult-selectors.js
content: /**
 * 测试咨询按钮选择器
 * 诊断 clickConsultButton 为什么失败
 */
const playwright = require('playwright')
const hospitals = require('./data/hospitals.json')

async function testConsultSelectors() {
  // 获取 JD 皮肤科的 URL
  const jdHospital = hospitals.find(h => h.name.includes('JD') || h.name.includes('皮肤科'))
  if (!jdHospital) {
    console.error('❌ 找不到 JD 皮肤科医院')
    return
  }

  const url = jdHospital.url
  console.log(`\n🔍 测试医院：${jdHospital.name}`)
  console.log(`📍 URL: ${url}\n`)

  let browser
  try {
    browser = await playwright.chromium.launch({ headless: false })
    const context = await browser.newContext()
    const page = await context.newPage()

    // 设置 console 消息监听，看浏览器输出
    page.on('console', msg => {
      console.log(`[浏览器输出] ${msg.type()}: ${msg.text()}`)
    })

    console.log(`⏳ 正在加载页面...`)
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {})

    console.log(`⏳ 等待 Vue 组件渲染（5秒）...`)
    await page.waitForTimeout(5000)

    console.log(`✅ 页面加载完成\n`)

    // 测试 1: 使用 Playwright 选择器
    console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`🧪 测试 Playwright 选择器`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

    const consultSelectors = [
      'text=/咨询一下/',
      'text=/立即咨询/',
      '[class*="btn"]:has-text("咨询")',
      'div:has-text("咨询一下")',
      'text=/咨询/',
      'text=/客服/',
      'button:has-text("咨询一下")',
      'button:has-text("咨询")',
      '[onclick*="consult"]',
      '.consult-btn',
      '.service-btn',
    ]

    for (const selector of consultSelectors) {
      try {
        const locator = page.locator(selector)
        const count = await locator.count()

        if (count > 0) {
          const isVisible = await locator.first().isVisible().catch(() => false)
          const text = await locator.first().textContent().catch(() => '无法读取')
          
          console.log(`✅ [${selector}]`)
          console.log(`   找到: ${count} 个元素`)
          console.log(`   可见: ${isVisible}`)
          console.log(`   文本: ${text}`)
          console.log()
        }
      } catch (e) {
        // 跳过错误
      }
    }

    // 测试 2: 使用 DOM 查询
    console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`🧪 测试 DOM 查询`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

    const domResults = await page.evaluate(() => {
      const allElements = document.querySelectorAll('*')
      console.log(`页面总元素数: ${allElements.length}`)

      const candidates = []
      for (const el of allElements) {
        const text = (el.textContent || '').trim()
        // 查找包含"咨询"的元素
        if (text.includes('咨询') && text.length < 100) {
          const tagName = el.tagName
          const className = el.className
          const isVisible = el.offsetParent !== null
          const rect = el.getBoundingClientRect()

          candidates.push({
            tagName,
            className,
            text: text.substring(0, 50), // 截断长文本
            isVisible,
            isClickable: el.offsetParent !== null && rect.width > 0 && rect.height > 0,
            rect: {
              width: rect.width,
              height: rect.height,
              x: rect.x,
              y: rect.y,
            }
          })
        }
      }

      return {
        totalElements: allElements.length,
        consultElements: candidates,
      }
    })

    console.log(`📊 找到 ${domResults.consultElements.length} 个包含"咨询"的元素:\n`)

    domResults.consultElements.forEach((el, idx) => {
      console.log(`[${idx + 1}] <${el.tagName.toLowerCase()}>`)
      console.log(`    Class: ${el.className || '(无)'}`)
      console.log(`    文本: "${el.text}"`)
      console.log(`    可见: ${el.isVisible}`)
      console.log(`    可点击: ${el.isClickable}`)
      console.log(`    尺寸: ${el.rect.width}x${el.rect.height}px`)
      console.log(`    位置: (${Math.round(el.rect.x)}, ${Math.round(el.rect.y)})`)
      console.log()
    })

    // 测试 3: 尝试点击第一个找到的元素
    if (domResults.consultElements.length > 0) {
      console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
      console.log(`🧪 尝试点击第一个"咨询"按钮`)
      console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

      const firstClickable = domResults.consultElements.find(el => el.isClickable)
      if (firstClickable) {
        console.log(`📌 目标: ${firstClickable.text}`)
        
        try {
          // 滚动到元素
          await page.evaluate(() => {
            const elements = document.querySelectorAll('*')
            for (const el of elements) {
              if ((el.textContent || '').includes('咨询')) {
                el.scrollIntoView({ behavior: 'smooth', block: 'center' })
                break
              }
            }
          })
          
          await page.waitForTimeout(500)

          // 点击
          const clicked = await page.evaluate(() => {
            const elements = document.querySelectorAll('*')
            for (const el of elements) {
              const text = (el.textContent || '').trim()
              if (text.includes('咨询') && text.length < 100 && el.offsetParent !== null) {
                el.click()
                return true
              }
            }
            return false
          })

          if (clicked) {
            console.log(`✅ 点击成功！`)
            await page.waitForTimeout(2000)
          } else {
            console.log(`❌ 点击失败`)
          }
        } catch (e) {
          console.log(`❌ 点击异常: ${e.message}`)
        }
      } else {
        console.log(`⚠️  没有找到可点击的"咨询"按钮`)
      }
    }

    console.log(`\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`📋 诊断完成！`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`\n浏览器仍保持打开状态 60 秒，方便你检查页面结构...`)

    // 保持浏览器打开 60 秒
    await page.waitForTimeout(60000)

  } catch (err) {
    console.error('❌ 测试出错:', err.message)
  } finally {
    if (browser) {
      await browser.close()
      console.log(`\n✅ 浏览器已关闭`)
    }
  }
}

testConsultSelectors()


path: test-deep-inspect.js
content: /**
 * 增加等待时间，保存完整 HTML 快照
 */
const playwright = require('playwright')
const hospitals = require('./data/hospitals.json')
const fs = require('fs')

async function deepInspect() {
  const jdHospital = hospitals.find(h => h.name.includes('JD') || h.name.includes('皮肤科'))
  const url = jdHospital.url

  console.log(`\n🔍 深度检查: ${jdHospital.name}`)
  console.log(`URL: ${url}\n`)

  let browser
  try {
    browser = await playwright.chromium.launch({ headless: false })
    const context = await browser.newContext()
    const page = await context.newPage()

    // 监听所有网络请求
    page.on('response', response => {
      if (response.url().includes('beautsgo')) {
        console.log(`[Network] ${response.status()} ${response.url().substring(30)}`)
      }
    })

    console.log(`📍 加载页面...`)
    const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
    console.log(`[Page Load] Status: ${response.status()}\n`)

    // 多轮等待
    for (let i = 1; i <= 5; i++) {
      console.log(`⏳ 等待第 ${i} 轮（${i * 2}秒）...`)
      await page.waitForTimeout(2000)

      // 每轮检查一次
      const found = await page.evaluate(() => {
        const els = document.querySelectorAll('[class*="consult"]')
        return els.length
      })

      console.log(`   找到 ${found} 个包含"consult"的元素`)

      if (found > 0) {
        break
      }
    }

    // 保存 HTML
    const html = await page.content()
    fs.writeFileSync('/Users/wangning/project/company/korean-aesthetic-booking-guide/page-full-snapshot.html', html)
    console.log(`\n✅ HTML 已保存到 page-full-snapshot.html (${Math.round(html.length / 1024)} KB)\n`)

    // 完整页面分析
    const analysis = await page.evaluate(() => {
      const result = {
        consultElements: [],
        uniViews: [],
        shadowDoms: [],
      }

      // 查找所有包含"咨询"的元素（任何深度）
      const walker = document.createTreeWalker(
        document.body,
        NodeFilter.SHOW_ELEMENT,
        null,
        false
      )

      let node
      let count = 0
      while ((node = walker.nextNode()) && count < 100) {
        const text = (node.textContent || '').trim()
        const hasConsult = text.includes('咨询')
        const className = (node.className || '').toString()
        const hasConsultClass = className.includes('consult')

        if (hasConsult || hasConsultClass) {
          result.consultElements.push({
            tag: node.tagName,
            text: text.substring(0, 50),
            class: className,
            id: node.id,
            depth: getDepth(node),
          })
          count++
        }
      }

      // 查找所有 uni-view
      const uniViews = document.querySelectorAll('uni-view')
      for (let i = 0; i < Math.min(10, uniViews.length); i++) {
        const text = uniViews[i].textContent.trim()
        if (text.length > 0 && text.length < 100) {
          result.uniViews.push({
            text: text.substring(0, 50),
            class: uniViews[i].className,
            index: i,
          })
        }
      }

      // 检查 shadow DOM
      let els = document.querySelectorAll('*')
      for (let el of els) {
        if (el.shadowRoot) {
          result.shadowDoms.push({
            tag: el.tagName,
            class: el.className,
            content: el.shadowRoot.textContent.substring(0, 50),
          })
        }
      }

      function getDepth(node) {
        let depth = 0
        let parent = node.parentElement
        while (parent) {
          depth++
          parent = parent.parentElement
        }
        return depth
      }

      return result
    })

    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`📊 分析结果`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

    console.log(`🎯 包含"咨询"的元素 (${analysis.consultElements.length} 个):\n`)
    analysis.consultElements.slice(0, 15).forEach((el, idx) => {
      console.log(`[${idx}] <${el.tag}> (深度: ${el.depth})`)
      console.log(`    class: ${el.class}`)
      console.log(`    text: "${el.text}"`)
      console.log()
    })

    console.log(`\n🔎 uni-view 元素 (前 ${analysis.uniViews.length} 个):\n`)
    analysis.uniViews.forEach((view, idx) => {
      console.log(`[${idx}] "${view.text}" | class: ${view.class}`)
    })

    console.log(`\n\n🌐 Shadow DOM 元素: ${analysis.shadowDoms.length} 个`)

    // 尝试最后一次点击
    console.log(`\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`🖱️  尝试点击`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

    const clickResults = await page.evaluate(() => {
      const results = []

      // 方法 1: 用 class
      let el1 = document.querySelector('.btns-consult')
      if (el1) {
        results.push('✓ .btns-consult 找到')
      }

      // 方法 2: 用文本
      let els = document.querySelectorAll('*')
      for (const el of els) {
        if (el.textContent.trim() === '咨询一下' && el.offsetParent !== null) {
          results.push('✓ 精确文本"咨询一下"找到')
          break
        }
      }

      // 方法 3: 包含咨询的最小元素
      let minEl = null
      let minLength = Infinity
      for (const el of els) {
        const text = el.textContent.trim()
        if (text.includes('咨询') && text.length > 0 && text.length < minLength && el.offsetParent !== null) {
          minEl = el
          minLength = text.length
        }
      }
      if (minEl) {
        results.push(`✓ 最小包含元素找到: "${minEl.textContent.trim()}"`)
      }

      return results
    })

    clickResults.forEach(r => console.log(r))

    console.log(`\n\n浏览器保持打开 60 秒，你可以手动检查页面...`)
    await page.waitForTimeout(60000)

  } catch (err) {
    console.error('❌ 错误:', err.message)
    console.error(err.stack)
  } finally {
    if (browser) await browser.close()
  }
}

deepInspect()


path: test-dom-click.js
content: /**
 * 用 DOM 查询测试点击
 */
const playwright = require('playwright')
const hospitals = require('./data/hospitals.json')

async function testClickViaDom() {
  const jdHospital = hospitals.find(h => h.name.includes('JD') || h.name.includes('皮肤科'))
  const url = jdHospital.url

  console.log(`\n🧪 用 DOM 查询测试点击: ${jdHospital.name}\n`)

  let browser
  try {
    browser = await playwright.chromium.launch({ headless: false })
    const context = await browser.newContext()
    const page = await context.newPage()

    console.log(`📍 加载页面: ${url}`)
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {})
    
    console.log(`⏳ 等待 Vue 渲染...`)
    await page.waitForTimeout(5000)
    console.log(`✅ 页面准备好\n`)

    // 方法 1: 查找 .btns-consult class
    console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`方法 1: 查找 .btns-consult 元素`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

    const foundByClass = await page.evaluate(() => {
      const el = document.querySelector('.btns-consult')
      if (el) {
        return {
          found: true,
          tag: el.tagName,
          text: el.textContent.trim(),
          class: el.className,
          html: el.outerHTML.substring(0, 200),
        }
      }
      return { found: false }
    })

    if (foundByClass.found) {
      console.log(`✅ 找到元素！`)
      console.log(`   标签: ${foundByClass.tag}`)
      console.log(`   文本: "${foundByClass.text}"`)
      console.log(`   class: ${foundByClass.class}`)
      console.log(`   HTML: ${foundByClass.html}`)
    } else {
      console.log(`❌ 未找到`)
    }

    // 方法 2: 尝试点击
    console.log(`\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`方法 2: 尝试点击`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

    const clicked = await page.evaluate(() => {
      const el = document.querySelector('.btns-consult')
      if (!el) {
        return { success: false, reason: 'element not found' }
      }

      // 检查是否可见
      const rect = el.getBoundingClientRect()
      if (rect.width === 0 || rect.height === 0) {
        return { success: false, reason: 'element has zero dimensions' }
      }

      // 检查是否在视口中
      if (rect.top < 0 || rect.left < 0 || rect.top > window.innerHeight || rect.left > window.innerWidth) {
        return { success: false, reason: 'element not in viewport', rect }
      }

      // 尝试点击
      try {
        el.click()
        return { success: true, rect }
      } catch (e) {
        return { success: false, reason: e.message }
      }
    })

    console.log(`结果:`)
    console.log(JSON.stringify(clicked, null, 2))

    if (clicked.success) {
      console.log(`\n✅ 点击成功！`)
      await page.waitForTimeout(3000)
    } else {
      console.log(`\n❌ 点击失败: ${clicked.reason}`)
    }

    // 方法 3: 使用 Playwright 的 evaluate 获取更多信息
    console.log(`\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`方法 3: 检查 uni-view 组件`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

    const uniViewInfo = await page.evaluate(() => {
      const elements = document.querySelectorAll('uni-view')
      const consultViews = []

      for (const el of elements) {
        if (el.textContent.includes('咨询')) {
          consultViews.push({
            text: el.textContent.trim().substring(0, 50),
            class: el.className,
            attrs: Array.from(el.attributes).map(a => ({ name: a.name, value: a.value })),
          })
        }
      }

      return {
        totalUniViews: elements.length,
        consultUniViews: consultViews,
      }
    })

    console.log(`找到 ${uniViewInfo.totalUniViews} 个 <uni-view> 元素`)
    console.log(`其中 ${uniViewInfo.consultUniViews.length} 个包含"咨询"`)
    
    uniViewInfo.consultUniViews.forEach((view, idx) => {
      console.log(`\n[${idx}] 文本: "${view.text}"`)
      console.log(`    class: ${view.class}`)
      console.log(`    其他属性:`)
      view.attrs.forEach(attr => {
        if (attr.name !== 'class' && attr.name !== 'style') {
          console.log(`      ${attr.name}: ${attr.value}`)
        }
      })
    })

    console.log(`\n\n✅ 诊断完成！浏览器保持打开 30 秒...`)
    await page.waitForTimeout(30000)

  } catch (err) {
    console.error('❌ 错误:', err.message)
  } finally {
    if (browser) await browser.close()
  }
}

testClickViaDom()


path: test-form-debug.js
content: #!/usr/bin/env node
/**
 * 调试脚本：截图分析预约表单的日历 DOM 结构
 */
const { createAuthorizedPage } = require('./api/browser/consult')
const path = require('path')
const fs = require('fs')

async function debug() {
  const url = process.argv[2] || 'https://i.beautsgo.com/cn/hospital/imageup-clinic/skill'
  console.log('🔍 Opening:', url)

  const { browser, page } = await createAuthorizedPage(url)

  try {
    console.log('⏳ Waiting for form to load...')
    await page.waitForSelector('.sub-right, .u-number-box__plus', { timeout: 20000 }).catch(() => {})
    await page.waitForTimeout(3000)

    // 截图1: 初始状态
    await page.screenshot({ path: '/tmp/form-step1-initial.png', fullPage: true })
    console.log('📸 Screenshot 1 saved: /tmp/form-step1-initial.png')

    // 输出时间行的 HTML
    const timeRowHtml = await page.evaluate(() => {
      const rows = document.querySelectorAll('.flex.info, .flex.info.add, [class*="time"], [class*="date"]')
      return Array.from(rows).slice(0, 10).map(el => ({
        class: el.className,
        text: el.textContent?.trim().slice(0, 50),
        html: el.outerHTML.slice(0, 200)
      }))
    })
    console.log('\n📋 Time-related rows:')
    console.log(JSON.stringify(timeRowHtml, null, 2))

    // 点击时间选择行
    console.log('\n📅 Clicking time selector...')
    const clicked = await page.evaluate(() => {
      // 尝试所有可能包含"时间"的行
      const candidates = [
        ...document.querySelectorAll('.flex.info.add'),
        ...document.querySelectorAll('.flex.info'),
        ...document.querySelectorAll('[class*="time"]'),
      ]
      for (const el of candidates) {
        const text = el.textContent || ''
        if (text.includes('选择') || text.includes('时间') || text.includes('预约')) {
          console.log('Clicking:', el.className, text.trim().slice(0, 30))
          el.click()
          return el.className
        }
      }
      return null
    })
    console.log('Clicked element:', clicked)

    await page.waitForTimeout(3000)

    // 截图2: 点击时间后
    await page.screenshot({ path: '/tmp/form-step2-after-time-click.png', fullPage: true })
    console.log('📸 Screenshot 2 saved: /tmp/form-step2-after-time-click.png')

    // 分析弹出层内容
    const popupHtml = await page.evaluate(() => {
      const popups = document.querySelectorAll('.u-popup, [class*="popup"], [class*="calendar"], [class*="picker"]')
      return Array.from(popups).filter(el => el.offsetParent !== null).map(el => ({
        class: el.className,
        visible: el.offsetParent !== null,
        html: el.innerHTML.slice(0, 1000)
      }))
    })
    console.log('\n📋 Visible popups after click:')
    console.log(JSON.stringify(popupHtml.slice(0, 3), null, 2))

    // 找所有数字格子
    const numbers = await page.evaluate(() => {
      const results = []
      for (const el of document.querySelectorAll('*')) {
        const text = (el.textContent || '').trim()
        if (/^\d{1,2}$/.test(text) && el.offsetParent !== null) {
          results.push({
            tag: el.tagName,
            class: el.className,
            text,
            parentClass: el.parentElement?.className
          })
        }
      }
      return results.slice(0, 30)
    })
    console.log('\n📋 Visible number elements (potential date cells):')
    console.log(JSON.stringify(numbers, null, 2))

    console.log('\n✅ Debug complete. Check screenshots in /tmp/')
    console.log('  /tmp/form-step1-initial.png')
    console.log('  /tmp/form-step2-after-time-click.png')

    // 保持浏览器打开 30 秒供手动检查
    console.log('\n⏸  Browser will stay open for 30s for manual inspection...')
    await page.waitForTimeout(30000)

  } finally {
    await browser.close()
  }
}

debug().catch(console.error)


path: test-form-fields.js
content: const { chromium } = require('playwright')

async function inspectForm() {
  const browser = await chromium.launch({ headless: false })
  const page = await browser.newPage()
  
  await page.goto('https://i.beautsgo.com/cn/hospital/jd-clinic/skill', { waitUntil: 'networkidle' })
  await page.waitForTimeout(5000)
  
  const fields = await page.evaluate(() => {
    const results = []
    
    // 所有 input
    document.querySelectorAll('input').forEach(el => {
      results.push({ tag: 'input', type: el.type, placeholder: el.placeholder, class: el.className, value: el.value })
    })
    
    // 所有 textarea
    document.querySelectorAll('textarea').forEach(el => {
      results.push({ tag: 'textarea', placeholder: el.placeholder, class: el.className })
    })
    
    // 所有可见的 label / 字段标签
    document.querySelectorAll('.form-item, .field, .label, uni-view[class*="label"], uni-view[class*="title"]').forEach(el => {
      const text = el.textContent.trim()
      if (text && text.length < 20) {
        results.push({ tag: 'label', text, class: el.className })
      }
    })
    
    // 找包含"联系"、"电话"、"手机"文字的元素
    document.querySelectorAll('*').forEach(el => {
      const text = (el.textContent || '').trim()
      if ((text.includes('联系') || text.includes('电话') || text.includes('手机') || text.includes('姓名') || text.includes('名字')) && text.length < 30 && el.children.length === 0) {
        results.push({ tag: 'label-match', text, class: el.className })
      }
    })
    
    return results
  })
  
  console.log('=== 表单字段 ===')
  fields.forEach(f => console.log(JSON.stringify(f)))
  
  await browser.close()
}

inspectForm().catch(console.error)


path: test-form-inspector.js
content: /**
 * 检查预约表单页面 DOM 结构
 * 目标：找到人数加减、时间选择、备注、条款复选框、去下单按钮的选择器
 */
const playwright = require('playwright')

const FORM_URL = 'https://i.beautsgo.com/subPackages_sub/googlemapmake/googlemapmake?type=1&id=250'

async function main() {
  const browser = await playwright.chromium.launch({
    headless: false,
    args: [
      '--disable-features=ExternalProtocolDialog',
      '--disable-external-intent-requests',
    ]
  })

  const context = await browser.newContext({ bypassCSP: true })
  const page = await context.newPage()

  await page.route('**/*', async (route) => {
    const url = route.request().url()
    if (!url.startsWith('http://') && !url.startsWith('https://')) {
      await route.abort()
    } else {
      await route.continue()
    }
  })

  console.log('打开表单页面...')
  await page.goto(FORM_URL, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {})
  await page.waitForTimeout(8000)

  console.log('\n========== 表单元素分析 ==========\n')

  const info = await page.evaluate(() => {
    const result = {}

    // 1. 查找所有包含"人数"的元素
    result.personElements = []
    document.querySelectorAll('*').forEach(el => {
      const t = el.textContent?.trim()
      if (t && t.includes('人数') && t.length < 50) {
        result.personElements.push({
          tag: el.tagName,
          class: el.className,
          text: t
        })
      }
    })

    // 2. 查找加减按钮
    result.plusMinus = []
    document.querySelectorAll('*').forEach(el => {
      const t = el.textContent?.trim()
      if (t === '+' || t === '-' || t === '＋' || t === '－') {
        result.plusMinus.push({
          tag: el.tagName,
          class: el.className,
          text: t,
          visible: el.offsetParent !== null
        })
      }
    })

    // 3. 查找时间/日期相关
    result.timeElements = []
    document.querySelectorAll('*').forEach(el => {
      const t = el.textContent?.trim()
      if (t && (t.includes('预约时间') || t.includes('选择时间') || t.includes('日期')) && t.length < 50) {
        result.timeElements.push({
          tag: el.tagName,
          class: el.className,
          text: t
        })
      }
    })

    // 4. 查找 input/textarea
    result.inputs = []
    document.querySelectorAll('input, textarea').forEach(el => {
      result.inputs.push({
        tag: el.tagName,
        type: el.type,
        placeholder: el.placeholder,
        class: el.className,
        name: el.name
      })
    })

    // 5. 查找条款复选框
    result.checkboxes = []
    document.querySelectorAll('*').forEach(el => {
      const t = el.textContent?.trim()
      if (t && (t.includes('条款') || t.includes('已阅读') || t.includes('同意')) && t.length < 80) {
        result.checkboxes.push({
          tag: el.tagName,
          class: el.className,
          text: t.substring(0, 60)
        })
      }
    })

    // 6. 查找"去下单"按钮
    result.submitBtn = []
    document.querySelectorAll('*').forEach(el => {
      const t = el.textContent?.trim()
      if (t && (t.includes('去下单') || t.includes('提交') || t.includes('确认预约')) && t.length < 30) {
        result.submitBtn.push({
          tag: el.tagName,
          class: el.className,
          text: t,
          visible: el.offsetParent !== null
        })
      }
    })

    // 7. 输出完整 body HTML（截取前 5000 字符）
    result.bodyHtml = document.body.innerHTML.substring(0, 5000)

    return result
  })

  console.log('人数相关元素:')
  console.log(JSON.stringify(info.personElements, null, 2))

  console.log('\n加减按钮:')
  console.log(JSON.stringify(info.plusMinus, null, 2))

  console.log('\n时间相关元素:')
  console.log(JSON.stringify(info.timeElements, null, 2))

  console.log('\ninput/textarea:')
  console.log(JSON.stringify(info.inputs, null, 2))

  console.log('\n条款复选框:')
  console.log(JSON.stringify(info.checkboxes, null, 2))

  console.log('\n去下单按钮:')
  console.log(JSON.stringify(info.submitBtn, null, 2))

  console.log('\n页面 HTML (前5000):')
  console.log(info.bodyHtml)

  console.log('\n等待 10 秒供手动查看...')
  await page.waitForTimeout(10000)

  await browser.close()
}

main().catch(console.error)


path: test-form-inspector2.js
content: /**
 * 深度检查表单交互流程
 * 重点：加号按钮、时间选择器交互、条款勾选
 */
const playwright = require('playwright')

const FORM_URL = 'https://i.beautsgo.com/subPackages_sub/googlemapmake/googlemapmake?type=1&id=250'

async function main() {
  const browser = await playwright.chromium.launch({
    headless: false,
    args: ['--disable-features=ExternalProtocolDialog', '--disable-external-intent-requests']
  })
  const ctx = await browser.newContext({ bypassCSP: true })
  const page = await ctx.newPage()

  await page.route('**/*', async (route) => {
    const url = route.request().url()
    if (!url.startsWith('http://') && !url.startsWith('https://')) {
      await route.abort()
    } else { await route.continue() }
  })

  await page.goto(FORM_URL, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {})
  await page.waitForTimeout(8000)

  const info = await page.evaluate(() => {
    const res = {}

    // 1. 找加号按钮（+ 按钮）
    res.plusBtn = []
    document.querySelectorAll('*').forEach(el => {
      const cls = el.className || ''
      if (cls.includes('plus') || cls.includes('add') || cls.includes('increase')) {
        res.plusBtn.push({ tag: el.tagName, class: cls.substring(0, 80), text: el.textContent?.trim().substring(0, 20) })
      }
    })

    // 2. number-box 完整结构
    const nb = document.querySelector('.u-number-box')
    if (nb) {
      res.numberBox = nb.innerHTML.substring(0, 2000)
    }

    // 3. 时间行的可点击元素
    res.timeRow = []
    document.querySelectorAll('.info.add, .info').forEach(el => {
      const t = el.textContent?.trim()
      if (t && (t.includes('时间') || t.includes('日期'))) {
        res.timeRow.push({
          tag: el.tagName,
          class: el.className?.substring(0, 80),
          text: t.substring(0, 50),
          children: el.children.length
        })
      }
    })

    // 4. 条款勾选区域
    const terms = document.querySelector('.text')
    if (terms) {
      res.termsHtml = terms.outerHTML.substring(0, 500)
    }

    // 5. 备注输入框（textarea / contenteditable）
    res.remarkArea = []
    document.querySelectorAll('textarea, [contenteditable]').forEach(el => {
      res.remarkArea.push({
        tag: el.tagName,
        class: el.className,
        placeholder: el.placeholder || el.getAttribute('placeholder') || ''
      })
    })

    // 6. 找所有 uni-input（可能的备注输入）
    res.uniInputs = []
    document.querySelectorAll('uni-input, uni-textarea').forEach(el => {
      res.uniInputs.push({
        tag: el.tagName,
        class: el.className,
        text: el.textContent?.trim().substring(0, 30)
      })
    })

    // 7. 更多 HTML（5001~10000）
    res.bodyHtml2 = document.body.innerHTML.substring(5000, 10000)

    return res
  })

  console.log('加号按钮:')
  console.log(JSON.stringify(info.plusBtn, null, 2))

  console.log('\nnumber-box HTML:')
  console.log(info.numberBox)

  console.log('\n时间行:')
  console.log(JSON.stringify(info.timeRow, null, 2))

  console.log('\n条款区域 HTML:')
  console.log(info.termsHtml)

  console.log('\n备注 textarea:')
  console.log(JSON.stringify(info.remarkArea, null, 2))

  console.log('\nuni-input/textarea:')
  console.log(JSON.stringify(info.uniInputs, null, 2))

  console.log('\nHTML 5000-10000:')
  console.log(info.bodyHtml2)

  await browser.close()
}

main().catch(console.error)


path: test-form-inspector3.js
content: /**
 * 检查日期选择弹窗 + 备注输入框结构
 */
const playwright = require('playwright')
const FORM_URL = 'https://i.beautsgo.com/subPackages_sub/googlemapmake/googlemapmake?type=1&id=250'

async function main() {
  const browser = await playwright.chromium.launch({
    headless: false,
    args: ['--disable-features=ExternalProtocolDialog', '--disable-external-intent-requests']
  })
  const ctx = await browser.newContext({ bypassCSP: true })
  const page = await ctx.newPage()
  await page.route('**/*', async (route) => {
    const url = route.request().url()
    if (!url.startsWith('http://') && !url.startsWith('https://')) { await route.abort() }
    else { await route.continue() }
  })
  await page.goto(FORM_URL, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {})
  await page.waitForTimeout(8000)

  // 先看备注输入区域（HTML 后半部分）
  const html2 = await page.evaluate(() => document.body.innerHTML.substring(5000, 12000))
  console.log('HTML 5000-12000:')
  console.log(html2)

  // 点击时间行，触发日期弹窗
  console.log('\n--- 点击时间行 ---')
  await page.evaluate(() => {
    const rows = document.querySelectorAll('.flex.info.add')
    for (const row of rows) {
      if (row.textContent?.includes('选择预约时间')) {
        row.click()
        break
      }
    }
  })
  await page.waitForTimeout(3000)

  // 检查日期弹窗
  const calendarInfo = await page.evaluate(() => {
    const res = {}
    // 查找日历相关元素
    res.calendarEls = []
    document.querySelectorAll('*').forEach(el => {
      const cls = el.className || ''
      if (cls.includes('calendar') || cls.includes('date') || cls.includes('picker') || cls.includes('popup')) {
        const t = el.textContent?.trim()
        if (t && t.length < 100) {
          res.calendarEls.push({ tag: el.tagName, class: cls.substring(0, 80), text: t.substring(0, 50) })
        }
      }
    })

    // 查找"下一步"按钮
    res.nextBtn = []
    document.querySelectorAll('*').forEach(el => {
      const t = el.textContent?.trim()
      if (t && (t === '下一步' || t === '确定' || t === '完成')) {
        res.nextBtn.push({ tag: el.tagName, class: (el.className || '').substring(0, 80), text: t, visible: el.offsetParent !== null })
      }
    })

    // 查找日期数字（可点击的日期格子）
    res.dateNums = []
    document.querySelectorAll('*').forEach(el => {
      const cls = el.className || ''
      if ((cls.includes('day') || cls.includes('date')) && el.offsetParent !== null) {
        const t = el.textContent?.trim()
        if (t && /^\d{1,2}$/.test(t)) {
          res.dateNums.push({ tag: el.tagName, class: cls.substring(0, 60), text: t })
        }
      }
    })

    // 完整 HTML（弹窗部分）
    res.popupHtml = document.body.innerHTML.substring(12000, 18000)

    return res
  })

  console.log('\n日历相关元素:')
  console.log(JSON.stringify(calendarInfo.calendarEls, null, 2))

  console.log('\n下一步/确定按钮:')
  console.log(JSON.stringify(calendarInfo.nextBtn, null, 2))

  console.log('\n日期格子样例 (前10个):')
  console.log(JSON.stringify(calendarInfo.dateNums.slice(0, 10), null, 2))

  console.log('\nHTML 12000-18000:')
  console.log(calendarInfo.popupHtml)

  await browser.close()
}

main().catch(console.error)


path: test-improved-click.js
content: /**
 * 测试改进后的 clickConsultButton 函数
 */
const skill = require('./api/skill.js')
const hospitals = require('./data/hospitals.json')

async function testImprovedClick() {
  console.log('\n🧪 测试改进后的咨询按钮点击\n')

  // 构造输入
  const jdHospital = hospitals.find(h => h.name.includes('JD') || h.name.includes('皮肤科'))
  const input = {
    query: '咨询客服',
    lang: 'zh',
    context: {
      hospital: jdHospital
    }
  }

  console.log(`🏥 医院: ${jdHospital.name}`)
  console.log(`📍 URL: ${jdHospital.url}`)
  console.log(`💬 查询: ${input.query}\n`)

  try {
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`⏳ 调用 skill 函数...`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

    const result = await skill(input)

    console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`📋 Skill 返回结果`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)
    console.log(result)

  } catch (err) {
    console.error('❌ 错误:', err.message)
    console.error(err.stack)
  }

  console.log('\n✅ 测试完成')
}

testImprovedClick()


path: test-inspect-page-html.js
content: /**
 * 检查页面 HTML 结构
 * 找出实际的按钮文字和选择器
 */
const playwright = require('playwright')
const hospitals = require('./data/hospitals.json')

async function inspectPageHTML() {
  const jdHospital = hospitals.find(h => h.name.includes('JD') || h.name.includes('皮肤科'))
  if (!jdHospital) {
    console.error('❌ 找不到 JD 皮肤科医院')
    return
  }

  const url = jdHospital.url
  console.log(`\n🔍 检查页面：${jdHospital.name}`)
  console.log(`📍 URL: ${url}\n`)

  let browser
  try {
    browser = await playwright.chromium.launch({ headless: false })
    const context = await browser.newContext()
    const page = await context.newPage()

    console.log(`⏳ 正在加载页面...`)
    await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 }).catch(() => {})
    
    console.log(`⏳ 等待 5 秒...`)
    await page.waitForTimeout(5000)

    // 获取页面的完整 HTML
    const html = await page.content()
    
    // 保存到文件，方便查看
    const fs = require('fs')
    fs.writeFileSync('/Users/wangning/project/company/korean-aesthetic-booking-guide/page-snapshot.html', html)
    console.log(`✅ 页面 HTML 已保存到 page-snapshot.html\n`)

    // 分析页面结构
    const analysis = await page.evaluate(() => {
      const results = {
        pageTitle: document.title,
        buttons: [],
        divs: [],
        allClickable: [],
        bodyHTML: document.body ? document.body.innerHTML.substring(0, 2000) : '无 body',
      }

      // 查找所有 button
      const buttons = document.querySelectorAll('button')
      buttons.forEach((btn, idx) => {
        results.buttons.push({
          idx,
          text: btn.textContent.trim().substring(0, 50),
          className: btn.className,
          id: btn.id,
          html: btn.outerHTML.substring(0, 100),
        })
      })

      // 查找所有可点击的元素
      const clickables = document.querySelectorAll('[onclick], [role="button"], .btn, [class*="button"]')
      clickables.forEach((el, idx) => {
        if (idx < 20) { // 限制数量
          results.allClickable.push({
            tag: el.tagName,
            text: el.textContent.trim().substring(0, 30),
            className: el.className,
            onclick: el.onclick ? 'yes' : 'no',
          })
        }
      })

      // 查找所有含有"预约"的元素
      const allElements = document.querySelectorAll('*')
      let bookingElements = []
      for (const el of allElements) {
        const text = el.textContent.trim()
        if ((text.includes('预约') || text.includes('咨询') || text.includes('客服')) && text.length < 100) {
          bookingElements.push({
            tag: el.tagName,
            text: text.substring(0, 50),
            className: el.className,
          })
        }
      }

      results.bookingElements = bookingElements.slice(0, 20)

      return results
    })

    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`📄 页面标题: ${analysis.pageTitle}`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`)

    console.log(`🔘 找到 ${analysis.buttons.length} 个 <button> 元素：\n`)
    analysis.buttons.forEach(btn => {
      console.log(`  [${btn.idx}] "${btn.text}"`)
      console.log(`      Class: ${btn.className || '(无)'}`)
      console.log(`      ID: ${btn.id || '(无)'}`)
      console.log()
    })

    console.log(`\n🎯 所有包含"预约/咨询/客服"的元素 (前20个)：\n`)
    analysis.bookingElements.forEach((el, idx) => {
      console.log(`  [${idx}] <${el.tag}> "${el.text}"`)
      console.log(`       Class: ${el.className}`)
      console.log()
    })

    console.log(`\n⚙️  所有可点击元素 (前20个)：\n`)
    analysis.allClickable.forEach((el, idx) => {
      console.log(`  [${idx}] <${el.tag}> "${el.text}"`)
      console.log(`       Class: ${el.className}`)
      console.log()
    })

    console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`📋 分析完成！`)
    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
    console.log(`\n浏览器保持打开 30 秒，你可以用 F12 查看实际页面...`)

    await page.waitForTimeout(30000)

  } catch (err) {
    console.error('❌ 检查失败:', err.message)
  } finally {
    if (browser) {
      await browser.close()
    }
  }
}

inspectPageHTML()


path: test-multi-turn.js
content: #!/usr/bin/env node

/**
 * 多轮对话流程测试脚本
 * 演示如何通过多轮对话完成预约流程
 */

const skill = require('./api/skill')

async function testMultiTurnFlow() {
  console.log('='.repeat(60))
  console.log('韩国医美预约 Skill - 多轮对话测试')
  console.log('='.repeat(60))
  
  // 第1轮：用户询问预约流程
  console.log('\n【第1轮】用户询问预约流程')
  console.log('> 用户输入: "CNP皮肤科怎么预约？"')
  console.log('-'.repeat(60))
  
  let result = await skill({
    query: 'CNP皮肤科怎么预约',
    lang: 'zh'
  })
  console.log(result)
  
  // 第2轮：用户选择打开链接
  console.log('\n【第2轮】用户选择打开链接')
  console.log('> 用户输入: "打开链接"')
  console.log('-'.repeat(60))
  
  result = await skill({
    query: '打开链接',
    lang: 'zh'
  })
  console.log(result)
  
  // 第3轮：用户选择自动预约
  console.log('\n【第3轮】用户选择自动预约')
  console.log('> 用户输入: "帮我预约"')
  console.log('-'.repeat(60))
  
  result = await skill({
    query: 'CNP皮肤科怎么预约', // 需要包含医院名
    lang: 'zh'
  })
  console.log(result)
  
  // 模拟自动预约操作
  result = await skill({
    query: '帮我预约',
    lang: 'zh'
  })
  console.log(result)
  
  console.log('\n' + '='.repeat(60))
  console.log('测试完成！')
  console.log('='.repeat(60))
}

// 运行测试
testMultiTurnFlow().catch(err => {
  console.error('测试失败:', err)
  process.exit(1)
})


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)