Skill: zhongrenfei1-hub/restaurant-crosscheck-cn (version 2.0.0)

--- name: restaurant-review-crosscheck description: Cross-reference restaurant recommendations from Xiaohongshu (小红书) and Dianping (大众点评) to validate restaurant quality and consistency. Use when querying restaurant recommendations by geographic location (city/district) to get validated insights from both platforms. Automatically fetches ratings, review counts, and analyzes consistency across platforms to provide trustworthy recommendations with confidence scores. --- # Restaurant Review Cross-Check Cross-reference restaurant data from Xiaohongshu and Dianping to provide validated recommendations. ## Quick Start Query restaurants by location and cuisine type: ```bash # Basic query crosscheck-restaurants "上海静安区" "日式料理" # With filters crosscheck-restaurants "北京朝阳区" "火锅" --min-rating 4.5 --min-reviews 100 ``` ## Workflow ### 1. Data Collection Query both platforms simultaneously: **Dianping:** - Fetch restaurants matching location + cuisine - Extract: name, rating, review_count, price_range, address, tags **Xiaohongshu:** - Search notes/posts matching location + cuisine - Extract: restaurant_name, engagement_metrics (likes/saves), sentiment_score - Note: Xiaohongshu data requires scraping as no public API ### 2. Data Matching Match restaurants across platforms using fuzzy matching: - Restaurant name similarity (Levenshtein distance) - Location proximity (address matching) - Handle name variations (e.g., "银座寿司" vs "银座寿司静安店") See [scripts/match_restaurants.py](scripts/match_restaurants.py) for matching logic. ### 3. Consistency Analysis Calculate consistency score based on: - **Rating correlation** (0-1): Correlation between platform ratings - **Engagement validation** (0-1): Do high ratings correlate with high engagement? - **Sentiment alignment** (0-1): Do user sentiments align across platforms? Formula: `consistency_score = (rating_corr * 0.5) + (engagement_val * 0.3) + (sentiment_align * 0.2)` ### 4. Recommendation Score Calculate final recommendation score: ``` recommendation_score = ( (dianping_rating * 0.4) + (xhs_engagement_normalized * 0.3) + (consistency_score * 0.3) ) * 10 ``` Output: 0-10 scale, where >8.0 = high confidence recommendation ## Output Format ``` 📍 [Location] [Cuisine Type] 餐厅推荐 1. [Restaurant Name] 🏆 推荐指数: X.X/10 ⭐ 大众点评: X.X (Xk评价) 💬 小红书: X.X⭐ (X笔记) 📍 地址: [Address] 💰 人均: ¥[Price] ✅ 一致性: [高/中/低] - [Brief explanation] 📊 平台对比: - 大众点评标签: [Tags] - 小红书热词: [Keywords] ⚠️ 注意: [Any discrepancies or warnings] [Continue for top 5-10 restaurants...] ``` ## Thresholds - **Min rating**: 4.0/5.0 (configurable) - **Min reviews**: 50 on Dianping, 20 notes on Xiaohongshu (configurable) - **Max results**: Top 10 restaurants by recommendation score - **High consistency**: Score > 0.7 - **Medium consistency**: Score 0.5-0.7 - **Low consistency**: Score < 0.5 (flag for manual review) ## API & Data Sources ### Dianping - **Method**: Web scraping (Dianping API requires business partnership) - **Base URL**: https://www.dianping.com - **Rate limiting**: 1 request/2 seconds minimum - **Anti-scraping**: Use residential proxies, rotate user agents See [scripts/fetch_dianping.py](scripts/fetch_dianping.py) for implementation. ### Xiaohongshu - **Method**: Web scraping (no public API) - **Base URL**: https://www.xiaohongshu.com - **Rate limiting**: 1 request/3 seconds minimum - **Authentication**: Cookies required for full access See [scripts/fetch_xiaohongshu.py](scripts/fetch_xiaohongshu.py) for implementation. ## Configuration Edit `scripts/config.py` to set: ```python DEFAULT_THRESHOLDS = { "min_rating": 4.0, "min_dianping_reviews": 50, "min_xhs_notes": 20, "max_results": 10 } PROXY_CONFIG = { "use_proxy": True, "proxy_list": ["http://proxy1:port", "http://proxy2:port"] } ``` ## Error Handling - **No matches found**: Suggest broader search terms or nearby areas - **Platform timeout**: Retry with exponential backoff, max 3 attempts - **Rate limiting detected**: Pause for 60 seconds, rotate proxy - **Low confidence results**: Flag results with consistency < 0.5 for manual review ## Advanced Features ### Sentiment Analysis Xiaohongshu posts use NLP to extract: - Food quality mentions - Service quality mentions - Atmosphere mentions - Price/value mentions See [references/sentiment_analysis.md](references/sentiment_analysis.md) for methodology. ### Fuzzy Matching Handle restaurant name variations: - Chain stores (e.g., "海底捞火锅" vs "海底捞静安店") - Abbreviations (e.g., "鼎泰丰" vs "鼎泰丰上海店") - Translation differences Uses `thefuzz` library for similarity scoring. ## Dependencies ```bash pip install requests beautifulsoup4 pandas numpy thefuzz selenium lxml ``` See [scripts/requirements.txt](scripts/requirements.txt) for complete list. ## Troubleshooting **Issue**: Xiaohongshu returns empty results - **Solution**: Check if cookies expired, re-authenticate **Issue**: Dianping blocks requests - **Solution**: Reduce request rate, rotate proxies **Issue**: Poor matching between platforms - **Solution**: Adjust similarity threshold in `match_restaurants.py` ## References - [Data schema documentation](references/data_schema.md) - [Sentiment analysis guide](references/sentiment_analysis.md) - [API limitations](references/api_limitations.md)

path: IMPLEMENTATION.md
content: # 🎉 全自动方案实现完成！

## ✅ 已实现的功能

### 1. 自动会话管理器 (`scripts/session_manager.py`)

**功能**：
- ✅ 自动检测登录状态
- ✅ 首次运行引导登录
- ✅ 持久化保存会话
- ✅ 会话过期自动提示

**使用**：
```bash
# 首次配置
python3 scripts/session_manager.py

# 查看状态
python3 scripts/session_manager.py

# 重置会话
python3 scripts/session_manager.py --reset
```

### 2. 真实数据爬虫

**大众点评** (`scripts/fetch_dianping_real.py`)：
- ✅ 使用 Playwright 自动化
- ✅ 使用持久化会话
- ✅ 自动提取餐厅数据
- ✅ 支持登录状态检测

**小红书** (`scripts/fetch_xiaohongshu_real.py`)：
- ✅ 使用 Playwright 自动化
- ✅ 使用持久化会话
- ✅ 自动抓取笔记数据
- ✅ 智能提取餐厅名称

### 3. 自动化主脚本 (`scripts/crosscheck_real.py`)

**功能**：
- ✅ 异步并发抓取两个平台
- ✅ 自动会话管理
- ✅ 错误处理和重试
- ✅ 详细的进度提示

**使用**：
```bash
python3 scripts/crosscheck_real.py "深圳市南山区" "美食"
```

### 4. 一键安装脚本 (`setup.sh`)

**自动化流程**：
1. ✅ 检查 Python 环境
2. ✅ 安装所有依赖
3. ✅ 下载 Playwright 浏览器
4. ✅ 引导配置登录
5. ✅ 创建快捷命令

**使用**：
```bash
bash setup.sh
```

---

## 📁 新增文件

```
skills/restaurant-review-crosscheck/
├── setup.sh                           # 一键安装脚本 ⭐ NEW
├── QUICKSTART.md                      # 快速开始指南 ⭐ NEW
├── IMPLEMENTATION.md                  # 实现说明文档 ⭐ NEW
├── scripts/
│   ├── session_manager.py             # 会话管理器 ⭐ NEW
│   ├── fetch_dianping_real.py         # 真实大众点评爬虫 ⭐ NEW
│   ├── fetch_xiaohongshu_real.py      # 真实小红书爬虫 ⭐ NEW
│   └── crosscheck_real.py             # 自动化主脚本 ⭐ NEW
└── sessions/                          # 会话存储目录 ⭐ NEW
    ├── dianping/                      # 大众点评会话
    ├── xiaohongshu/                   # 小红书会话
    └── session_state.json             # 会话状态
```

---

## 🚀 使用流程

### 第一次使用（5分钟设置）

```bash
# 1. 进入目录
cd skills/restaurant-review-crosscheck

# 2. 一键安装
bash setup.sh

# 3. 按提示登录大众点评和小红书（各1分钟）

# 4. 开始使用
./crosscheck-auto "深圳市南山区" "美食"
```

### 日常使用（全自动）

```bash
# 直接查询，无需手动操作
./crosscheck-auto "深圳市南山区" "火锅"
./crosscheck-auto "上海静安区" "日式料理"
./crosscheck-auto "北京朝阳区" "西餐"
```

### 会话过期时（1-2周一次）

```bash
# 重新配置登录
python3 scripts/session_manager.py
```

---

## 🎯 关键优势

### vs 手动 Cookies 方案

| 对比项 | 手动 Cookies | 自动会话 ⭐ |
|--------|-------------|-----------|
| 初始设置 | 复杂 | 简单 |
| 日常使用 | 每次更新 | 完全自动 |
| 更新频率 | 1-3天 | 1-2周 |
| 验证码 | 无法处理 | 自动暂停 |
| 二步验证 | 无法处理 | 支持 |
| 安全性 | 低（需复制敏感信息） | 高（本地保存） |

### vs 模拟数据

| 对比项 | 模拟数据 | 真实数据 ⭐ |
|--------|---------|-----------|
| 配置 | 无需配置 | 需要登录一次 |
| 数据 | 固定测试 | 真实数据 |
| 准确性 | 低 | 高 |
| 使用场景 | 测试 | 实际使用 |

---

## 📊 技术实现

### 架构设计

```
┌─────────────────────────────────────┐
│     crosscheck_real.py (主脚本)      │
│                                     │
│  - 用户交互                          │
│  - 任务调度                          │
│  - 结果展示                          │
└──────────┬──────────────────────────┘
           │
           ├──────────────────────┬──────────────────────┐
           │                      │                      │
           ▼                      ▼                      ▼
┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐
│ session_manager  │    │ fetch_dianping   │    │ fetch_xiaohongshu│
│                  │    │ _real            │    │ _real            │
│ - 会话检测        │    │ - 抓取大众点评    │    │ - 抓取小红书     │
│ - 登录引导        │    │ - 数据解析        │    │ - 数据解析       │
│ - 状态保存        │    │ - 错误处理        │    │ - 错误处理       │
└──────────────────┘    └──────────────────┘    └──────────────────┘
           │                      │                      │
           └──────────────────────┴──────────────────────┘
                                  │
                                  ▼
                        ┌──────────────────┐
                        │  Playwright      │
                        │                  │
                        │  - 浏览器自动化   │
                        │  - 持久化会话     │
                        │  - 反爬虫处理     │
                        └──────────────────┘
```

### 核心技术

1. **Playwright 持久化上下文**
   - 自动保存 cookies、localStorage
   - 跨会话保持登录状态
   - 支持手动登录后自动保存

2. **异步并发抓取**
   - 同时抓取两个平台
   - 提高效率
   - 错误隔离

3. **智能会话管理**
   - 自动检测过期
   - 提示重新登录
   - 状态持久化

---

## 🔒 安全性

### ✅ 安全措施

1. **本地存储**
   - 所有数据保存在本地
   - 不上传到任何服务器
   - 完全控制数据

2. **无需账号密码**
   - 用户在自己的浏览器登录
   - 只保存会话状态
   - 不接触敏感信息

3. **隐私保护**
   - 不收集个人信息
   - 仅用于个人研究
   - 符合隐私法规

### ⚠️ 使用限制

1. **仅供个人研究**
   - 不得用于商业用途
   - 不得转售数据
   - 遵守平台条款

2. **请求频率**
   - 已内置延迟（2-3秒/请求）
   - 避免过度请求
   - 尊重平台资源

3. **数据使用**
   - 仅用于个人决策
   - 不公开发布
   - 注明数据来源

---

## 📚 文档索引

- **[快速开始](QUICKSTART.md)** - 5分钟上手指南
- **[完整说明](README.md)** - 详细使用文档
- **[数据结构](references/data_schema.md)** - 数据格式说明
- **[API限制](references/api_limitations.md)** - 法律合规说明
- **[情感分析](references/sentiment_analysis.md)** - 算法说明

---

## 🎊 完成状态

✅ **会话管理器** - 完成
✅ **大众点评爬虫** - 完成
✅ **小红书爬虫** - 完成
✅ **自动化主脚本** - 完成
✅ **一键安装脚本** - 完成
✅ **使用文档** - 完成

**状态：可以开始使用！** 🚀

---

## 🎉 下一步

现在您可以：

1. **立即使用**：
   ```bash
   cd skills/restaurant-review-crosscheck
   bash setup.sh
   ```

2. **测试功能**：
   ```bash
   ./crosscheck-auto "深圳市南山区" "美食"
   ```

3. **自定义配置**：
   - 编辑 `scripts/config.py`
   - 调整阈值和权重

4. **查看文档**：
   - 阅读 `QUICKSTART.md`
   - 了解更多功能

---

**祝使用愉快！** 🎊


path: INTEGRATION_COMPLETE.md
content: # 🎉 Skill 已成功集成到 OpenClaw！

## ✅ 集成完成

**Restaurant Review Cross-Check** skill 已成功集成到 OpenClaw 技能系统中。

现在有 **3 种使用方式**：

---

## 📱 方式 1：通过对话使用（最简单）

直接通过对话查询餐厅推荐：

```
你：查询深圳南山区推荐餐厅
你：上海静安区有什么好吃的日式料理？
你：推荐北京朝阳区的火锅店
```

**AI 会自动调用 skill 并返回结果！**

---

## 🖥️ 方式 2：命令行使用（服务器）

在服务器上使用简化版本（模拟数据）：

```bash
# 查询餐厅
restaurant-crosscheck "深圳市南山区" "美食"
restaurant-crosscheck "上海静安区" "日式料理"
restaurant-crosscheck "北京朝阳区" "火锅"
```

**特点**：
- ✅ 不需要浏览器
- ✅ 不需要登录
- ✅ 适合无头服务器
- ⚠️ 使用模拟数据

---

## 🚀 方式 3：完整版本（本地电脑）

在有图形界面的环境中使用真实数据：

```bash
cd skills/restaurant-review-crosscheck
bash setup.sh              # 安装并配置登录
./crosscheck-auto "深圳市南山区" "美食"
```

**特点**：
- ✅ 真实数据抓取
- ✅ 自动会话管理
- ✅ 交叉验证分析
- ⚠️ 需要浏览器

---

## 🎯 使用建议

| 使用场景 | 推荐方式 |
|---------|---------|
| 快速测试 | 对话使用（方式1） |
| 服务器脚本 | 命令行（方式2） |
| 实际决策 | 完整版本（方式3） |
| 功能演示 | 任何方式 |

---

## 📊 测试结果

已测试查询：

```bash
# 深圳南山火锅
$ restaurant-crosscheck "深圳市南山区" "火锅"
✅ 找到 3 家推荐餐厅

# 上海静安日式料理
$ restaurant-crosscheck "上海静安区" "日式料理"
✅ 找到 3 家推荐餐厅
```

---

## 📁 文件结构

```
skills/restaurant-review-crosscheck/
├── SKILL.md                   ✅ AI 技能描述（系统读取）
├── SERVER_GUIDE.md            ✅ 服务器使用指南
├── QUICKSTART.md              ✅ 快速开始
├── IMPLEMENTATION.md          ✅ 实现说明
├── README.md                  ✅ 完整文档
├── restaurant-crosscheck      ✅ 命令行工具（服务器版）
├── crosscheck-restaurants     ⭐ 原始命令（已弃用）
├── crosscheck-auto            ⭐ 完整版命令（需要浏览器）
├── setup.sh                   ⭐ 安装脚本（需要浏览器）
├── scripts/
│   ├── crosscheck_simple.py   ✅ 服务器版本（无浏览器）
│   ├── crosscheck_real.py     ⭐ 完整版本（需要浏览器）
│   ├── session_manager.py     ⭐ 会话管理
│   └── ...                    （其他脚本）
└── bin/
    └── restaurant-crosscheck  ✅ 全局命令（已链接）
```

---

## 🎓 使用示例

### 对话使用

```
用户：帮我查一下深圳南山区有什么好吃的

AI：📍 深圳市南山区 美食 餐厅推荐

1. 美食推荐店A
   🏆 推荐指数: 6.4/10
   ⭐ 大众点评: 4.7⭐ (2028评价)
   ...
```

### 命令行使用

```bash
# 查询单个
restaurant-crosscheck "深圳市南山区" "美食"

# 批量查询
for cuisine in "火锅" "日式料理" "西餐"; do
    restaurant-crosscheck "深圳市南山区" "$cuisine"
done
```

### Python 脚本

```python
import subprocess

def get_recommendations(location, cuisine):
    result = subprocess.run(
        ['restaurant-crosscheck', location, cuisine],
        capture_output=True,
        text=True
    )
    return result.stdout

print(get_recommendations("深圳市南山区", "美食"))
```

---

## ⚙️ 技术细节

### 版本对比

| 特性 | 服务器版 | 完整版 |
|------|---------|--------|
| 环境 | 无头服务器 | 桌面/笔记本 |
| 浏览器 | 不需要 | 需要（Playwright） |
| 登录 | 不需要 | 需要登录一次 |
| 数据 | 模拟数据 | 真实数据 |
| 用途 | 测试/演示 | 实际使用 |

### Skill 触发

AI 会根据以下条件自动调用：

1. 提到地理位置（如"深圳南山区"）
2. 提到餐厅/美食
3. 询问推荐

例如：
- "深圳南山有什么好吃的"
- "推荐上海静安的日式料理"
- "北京朝阳哪家火锅好"

---

## 🔧 自定义配置

编辑 `scripts/config.py`：

```python
DEFAULT_THRESHOLDS = {
    "min_rating": 4.0,           # 最低评分
    "max_results": 10,           # 最多显示
    "similarity_threshold": 0.7  # 匹配阈值
}
```

---

## 📚 文档索引

- **[服务器使用指南](SERVER_GUIDE.md)** - 命令行详细说明
- **[快速开始](QUICKSTART.md)** - 5分钟上手
- **[实现说明](IMPLEMENTATION.md)** - 技术细节
- **[完整文档](README.md)** - 所有功能

---

## ✅ 下一步

1. **立即测试**
   ```
   对话：查询深圳南山区推荐餐厅
   ```

2. **命令行测试**
   ```bash
   restaurant-crosscheck "深圳市南山区" "美食"
   ```

3. **查看文档**
   ```bash
   cat SERVER_GUIDE.md
   ```

---

**🎊 恭喜！Skill 已完全集成，可以开始使用了！**


path: PUBLISH_GUIDE.md
content: # 📤 发布到 ClawHub 指南

## 🚀 快速发布

### 方法 1：在本地电脑发布（推荐）

由于当前服务器环境没有浏览器，建议在你的**本地电脑**上发布：

```bash
# 1. 在本地电脑上安装 clawhub
npm install -g clawhub

# 2. 登录 ClawHub（会打开浏览器）
clawhub login

# 3. 下载 skill 文件夹
# 从服务器下载 restaurant-review-crosscheck 文件夹

# 4. 进入 skill 目录
cd restaurant-review-crosscheck

# 5. 发布
clawhub publish . \
  --slug restaurant-crosscheck \
  --name "餐厅推荐交叉验证" \
  --version 1.0.0 \
  --changelog "初始版本：支持小红书和大众点评交叉验证餐厅推荐"
```

### 方法 2：在服务器上使用 Token

如果你想直接在服务器上发布，需要：

1. **获取 Token**：
   - 在有浏览器的设备上访问 https://clawhub.ai
   - 登录账号
   - 获取 API token（在设置中）

2. **在服务器上使用 Token 登录**：
   ```bash
   clawhub login --token "YOUR_TOKEN_HERE"
   ```

3. **发布 skill**：
   ```bash
   cd /home/ubuntu/.openclaw/workspace/skills/restaurant-review-crosscheck
   clawhub publish . \
     --slug restaurant-crosscheck \
     --name "餐厅推荐交叉验证" \
     --version 1.0.0 \
     --changelog "初始版本：支持小红书和大众点评交叉验证餐厅推荐"
   ```

---

## 📋 发布元数据

### Skill 信息

- **Slug**: `restaurant-crosscheck`
- **Name**: 餐厅推荐交叉验证
- **Version**: `1.0.0`
- **Category**: Utilities

### 描述（中文）

```
交叉验证小红书和大众点评的餐厅推荐数据。支持按地理位置和菜系查询，自动分析两个平台的一致性，提供可信的餐厅推荐评分。适合用于美食推荐和餐厅决策辅助。
```

### Description (English)

```
Cross-reference restaurant recommendations from Xiaohongshu and Dianping to validate restaurant quality. Query by location and cuisine, analyze cross-platform consistency, and get trustworthy recommendation scores.
```

### Tags

```
restaurant, food, recommendation, dianping, xiaohongshu, china, chinese
```

### Changelog

```
## v1.0.0 (2026-02-09)

Initial release:

Features:
- Cross-platform validation (Dianping + Xiaohongshu)
- Location-based search
- Cuisine type filtering
- Consistency analysis
- Recommendation scoring (0-10)
- Server-friendly command-line tool
- Full documentation

Usage:
- Command line: restaurant-crosscheck "location" "cuisine"
- Dialogue integration: "查询深圳南山区推荐餐厅"
- Server version with mock data
- Full version with real scraping (requires browser)
```

---

## 🔧 完整发布命令

### 基础发布

```bash
clawhub publish . \
  --slug restaurant-crosscheck \
  --name "餐厅推荐交叉验证" \
  --version 1.0.0 \
  --changelog "初始版本"
```

### 完整元数据发布

```bash
clawhub publish . \
  --slug restaurant-crosscheck \
  --name "餐厅推荐交叉验证" \
  --description "Cross-reference restaurant recommendations from Xiaohongshu and Dianping" \
  --version 1.0.0 \
  --changelog "Initial release with cross-platform validation" \
  --tags "restaurant,food,recommendation,chinese"
```

---

## 📦 Skill 文件检查

发布前确保以下文件存在：

```
restaurant-review-crosscheck/
├── SKILL.md                   ✅ 必需
├── README.md                  ✅ 推荐
├── QUICKSTART.md              ✅ 推荐
├── SERVER_GUIDE.md            ✅ 推荐
├── restaurant-crosscheck      ✅ 可执行命令
├── scripts/
│   ├── crosscheck_simple.py   ✅ 服务器版本
│   ├── config.py              ✅ 配置文件
│   └── ...                    (其他脚本)
└── references/                (可选文档)
```

---

## ✅ 发布前检查清单

- [ ] SKILL.md 格式正确
- [ ] README.md 完整
- [ ] 命令行工具有执行权限
- [ ] 测试基本功能
- [ ] 准备好 changelog
- [ ] 确定版本号
- [ ] 登录 ClawHub

---

## 🔄 更新已发布的 Skill

如果需要更新已发布的 skill：

```bash
# 1. 更新版本号（例如 1.0.0 -> 1.0.1）
# 2. 修改 SKILL.md 或其他文件
# 3. 重新发布
clawhub publish . \
  --slug restaurant-crosscheck \
  --version 1.0.1 \
  --changelog "修复：优化匹配算法"
```

---

## 💡 提示

### 版本号规范

- **主版本**（Major）：不兼容的 API 修改
- **次版本**（Minor）：向下兼容的功能新增
- **修订版**（Patch）：向下兼容的问题修正

示例：
- `1.0.0` - 初始版本
- `1.0.1` - Bug 修复
- `1.1.0` - 新增功能
- `2.0.0` - 重大更新

### Changelog 格式

```
## v1.0.0 (YYYY-MM-DD)

Added:
- 新功能 A
- 新功能 B

Changed:
- 修改的功能

Fixed:
- 修复的问题

Docs:
- 文档更新
```

---

## 📖 参考资料

- **ClawHub 文档**: https://clawhub.com/docs
- **Skill 创建指南**: `/home/ubuntu/.npm-global/lib/node_modules/openclaw/skills/skill-creator/SKILL.md`
- **ClawHub CLI**: `clawhub --help`

---

## 🎯 下一步

1. **选择发布方法**：
   - 本地电脑（推荐）
   - 服务器（需要 token）

2. **准备发布信息**：
   - 确定版本号
   - 编写 changelog
   - 准备描述和标签

3. **执行发布**：
   ```bash
   clawhub publish . --slug restaurant-crosscheck --version 1.0.0
   ```

4. **验证发布**：
   ```bash
   clawhub search restaurant-crosscheck
   ```

---

**准备好了吗？开始发布吧！** 🚀


path: QUICKSTART.md
content: # 🚀 快速开始指南

## 自动化版本（使用真实数据）

### 一键安装

```bash
cd skills/restaurant-review-crosscheck
bash setup.sh
```

安装脚本会自动：
1. ✅ 安装所有 Python 依赖
2. ✅ 下载 Playwright 浏览器
3. ✅ 引导配置登录会话

### 配置登录（首次使用）

运行安装脚本时，会自动提示配置：

1. **大众点评登录**
   - 浏览器自动打开 https://www.dianping.com
   - 使用手机号或微信扫码登录
   - 登录后关闭浏览器
   - 脚本自动保存登录状态

2. **小红书登录**
   - 浏览器自动打开 https://www.xiaohongshu.com
   - 使用手机号或微信扫码登录
   - 登录后关闭浏览器
   - 脚本自动保存登录状态

**注意**：登录状态会保存 1-2 周，过期后重新运行配置即可。

### 开始使用

```bash
# 查询餐厅推荐（自动使用真实数据）
./crosscheck-auto "深圳市南山区" "美食"

# 或使用完整路径
python3 scripts/crosscheck_real.py "深圳市南山区" "火锅"
```

### 管理登录会话

```bash
# 查看会话状态
python3 scripts/session_manager.py

# 重置所有会话（需要重新登录）
python3 scripts/session_manager.py --reset

# 只配置大众点评
python3 scripts/session_manager.py --dianping

# 只配置小红书
python3 scripts/session_manager.py --xiaohongshu
```

---

## 测试版本（使用模拟数据）

如果不想配置登录，可以使用模拟数据测试：

```bash
# 使用模拟数据
python3 scripts/crosscheck.py "上海静安区" "日式料理"
```

---

## 工作流程

```
用户输入查询
    ↓
检查登录会话
    ↓
会话有效？ → 是 → 使用保存的会话抓取数据
    ↓ 否
自动打开浏览器
    ↓
用户手动登录
    ↓
保存会话
    ↓
抓取真实数据
    ↓
交叉验证分析
    ↓
生成推荐报告
```

---

## 常见问题

### Q: 会话过期怎么办？
```bash
# 重新运行配置脚本
python3 scripts/session_manager.py
```

### Q: 如何查看会话状态？
```bash
python3 scripts/session_manager.py
```

### Q: 抓取失败怎么办？
1. 检查网络连接
2. 尝试重新登录：`python3 scripts/session_manager.py --reset`
3. 检查搜索关键词是否合理

### Q: 可以跳过登录直接使用吗？
可以，使用模拟数据版本：
```bash
python3 scripts/crosscheck.py "地区" "菜系"
```

---

## 对比：模拟数据 vs 真实数据

| 特性 | 模拟数据 | 真实数据 |
|------|---------|---------|
| 数据来源 | 固定测试数据 | 大众点评 + 小红书 |
| 配置难度 | 无需配置 | 需要登录一次 |
| 数据准确性 | 仅测试用 | 真实数据 |
| 使用场景 | 测试功能 | 实际使用 |
| 命令 | `crosscheck.py` | `crosscheck_real.py` |

---

## 下一步

安装完成后：

1. ✅ 测试基本功能
2. 📖 阅读 [完整文档](README.md)
3. 🔧 自定义配置（`scripts/config.py`）
4. 📊 查看数据结构（`references/data_schema.md`）


path: QUICK_PUBLISH.md
content: # 🚀 快速发布到 ClawHub

## ⚡ 方法 1：使用发布脚本（最简单）

```bash
cd /home/ubuntu/.openclaw/workspace/skills/restaurant-review-crosscheck
./publish.sh
```

脚本会自动：
1. ✅ 检查环境
2. ✅ 验证登录状态
3. ✅ 显示 skill 信息
4. ✅ 确认发布
5. ✅ 执行发布命令

---

## 📋 方法 2：手动发布

### 第一步：安装 ClawHub CLI

```bash
npm install -g clawhub
```

### 第二步：登录

**选项 A：使用浏览器（推荐）**
```bash
clawhub login
# 浏览器会自动打开，完成登录
```

**选项 B：使用 Token**
```bash
# 1. 访问 https://clawhub.ai 获取 token
# 2. 使用 token 登录
clawhub login --token "YOUR_TOKEN_HERE"
```

### 第三步：发布

```bash
cd /home/ubuntu/.openclaw/workspace/skills/restaurant-review-crosscheck

clawhub publish . \
  --slug restaurant-crosscheck \
  --name "餐厅推荐交叉验证" \
  --version 1.0.0 \
  --changelog "初始版本"
```

---

## ✅ 发布后验证

```bash
# 搜索 skill
clawhub search restaurant-crosscheck

# 查看 skill 详情
clawhub inspect restaurant-crosscheck

# 列出已安装的 skills
clawhub list
```

---

## 📦 Skill 信息

- **Slug**: `restaurant-crosscheck`
- **Name**: 餐厅推荐交叉验证
- **Version**: `1.0.0`
- **Tags**: restaurant, food, recommendation, chinese

---

## 🔄 更新 Skill

如果需要更新：

1. 修改 `SKILL.md` 中的版本号（例如 1.0.0 → 1.0.1）
2. 更新文件
3. 重新发布：

```bash
clawhub publish . \
  --slug restaurant-crosscheck \
  --version 1.0.1 \
  --changelog "修复：优化匹配算法"
```

---

## 📖 详细文档

查看完整的发布指南：

```bash
cat PUBLISH_GUIDE.md
```

---

## ⚠️ 当前限制

**服务器环境**：
- 当前服务器没有浏览器
- 无法使用 `clawhub login` 打开浏览器
- 需要使用 token 或在本地发布

**解决方案**：
1. 在本地电脑发布（推荐）
2. 使用 token 登录
3. 下载 skill 文件夹到本地发布

---

**准备好了吗？运行 `./publish.sh` 开始发布！** 🚀


path: README.md
content: # 🔍 本地生活交叉验证工具

**用大众点评 × 小红书双平台交叉验证，找到真正靠谱的店。**

大众点评看评分和评论数，小红书看真实用户体验——两个平台都说好的店，踩雷概率极低。

---

## 功能

- 🍜 **餐厅验证** — 粤菜、火锅、日料、西餐……任意菜系
- 💆 **本地生活** — 按摩、美容、健身房、KTV、亲子
- 🏨 **酒店景点** — 民宿、酒店、景点门票
- 📊 **交叉匹配** — thefuzz 模糊匹配 + 多策略打分
- 🤖 **反检测** — 持久化会话 + 可见浏览器，稳定抓取

## 支持城市

深圳、广州、上海、北京、杭州、成都、武汉、南京、长沙、重庆、西安、苏州、天津、厦门、青岛等 28 个城市（可在 config.py 自行扩展）

---

## 快速开始

### 1. 一键安装

```bash
chmod +x setup.sh
./setup.sh
```

或手动安装：

```bash
pip3 install playwright thefuzz
python3 -m playwright install chromium
```

### 2. 登录平台（只需一次）

```bash
cd scripts
python3 session_manager.py dianping      # 登录大众点评
python3 session_manager.py xiaohongshu   # 登录小红书
# 或一次全部: python3 session_manager.py all
```

浏览器弹出后手动登录，登录成功后按回车。会话自动保存 7 天。

### 3. 开始使用

```bash
python3 crosscheck.py '深圳南山区' '粤菜'
python3 crosscheck.py '广州天河区' '早茶'
python3 crosscheck.py '上海静安区' '按摩'
python3 crosscheck.py '成都锦江区' '火锅'
```

---

## 输出示例

```
============================================================
🔍 本地生活交叉验证: 深圳福田区 - 火锅
============================================================

  🎯 两平台共同推荐 (2 家):

  1. 【冯校长老火锅(深圳福田店)】 7.2/10 👍 推荐
     大众点评: 5.0分 | 5959条评论 | ¥114
     小红书: 2.1⭐ (352赞) | 匹配度0.78
     一致性: 高 (0.82)

  2. 【八合里潮汕鲜牛肉火锅】 7.1/10 👍 推荐
     大众点评: 5.0分 | 8520条评论 | ¥123
     小红书: 1.8⭐ (180赞) | 匹配度0.72
     一致性: 高 (0.75)

  📍 仅大众点评 (8 家):
    1. 海底捞火锅(福田COCO Park店) — 5.0分 12000评 ¥120
    ...
```

---

## 项目结构

```
restaurant-crosscheck/
├── README.md
├── setup.sh                 # 一键安装
├── requirements.txt
├── .gitignore
└── scripts/
    ├── __init__.py
    ├── config.py            # 配置: 城市代码、选择器、评分权重
    ├── models.py            # 数据模型: DianpingRestaurant, XiaohongshuPost 等
    ├── session_manager.py   # 浏览器会话管理（登录/检查/重置）
    ├── fetch_dianping.py    # 大众点评抓取（sync Playwright）
    ├── fetch_xiaohongshu.py # 小红书抓取（sync Playwright）
    ├── match_restaurants.py # 跨平台模糊匹配 + 一致性评分
    └── crosscheck.py        # 主程序: 交叉验证入口
```

---

## 评分原理

```
大众点评                           小红书
  ↓                                 ↓
搜索「深圳南山区 粤菜」           搜索「深圳南山区 粤菜 推荐」
  ↓                                 ↓
提取: 店名/评分/评论数/人均/标签  提取: 笔记标题→店名/点赞/情感/关键词
  ↓                                 ↓
  └──────────┬──────────────────────┘
             ↓
     thefuzz 多策略模糊匹配（阈值 0.55）
     ├─ 精确比率 (ratio)
     ├─ 部分匹配 (partial_ratio)
     ├─ Token排序 (token_sort_ratio)
     └─ 包含关系 (containment)
             ↓
     综合评分 = 点评分×0.4 + 小红书互动×0.3 + 一致性×0.3
             ↓
     🔥强推(≥8) / 👍推荐(≥6) / 🤔可试(<6)
```

---

## 与 v1 的主要改进

| 问题 | v1 (原始) | v2 (当前) |
|------|-----------|-----------|
| async/sync 混用 | `'coroutine' object has no attribute 'goto'` | 全部统一为 sync Playwright |
| 城市代码 | 硬编码 0（默认上海） | 28 个城市自动匹配 |
| headless 被拦截 | 页面空白 | headless=False + 反检测参数 |
| 文件混乱 | 6 个 crosscheck 文件 | 1 个入口，模块化拆分 |
| 小红书提取 | 只能提取 1 家 | 多策略提取（括号、探店模式、后缀、分隔符） |
| 依赖 mock 数据 | crosscheck_simple.py 全是假数据 | 全部真实抓取 |

---

## 常见问题

**Q: 页面空白？**
A: 重新登录 `python3 session_manager.py all`

**Q: 海外无法访问？**
A: 需开国内 VPN

**Q: thefuzz 没装？**
A: 工具会自动回退到简单匹配，但建议安装: `pip3 install thefuzz`

**Q: 想加新城市？**
A: 编辑 `config.py` 的 `CITY_CODES`，打开 dianping.com 切换城市看 URL 里的代码

---

## 贡献

欢迎 PR！尤其是：
- 新增城市代码
- 优化小红书店名提取
- 支持更多平台（美团、抖音）
- 增加 headless 反检测

## License

MIT


path: SERVER_GUIDE.md
content: # 🖥️ 服务器使用指南

## 快速开始

### 作为 OpenClaw 技能使用

这个 skill 已经集成到 OpenClaw 技能系统中。你可以直接通过对话使用：

```
你：查询深圳南山区推荐餐厅
AI：[自动调用 restaurant-crosscheck skill]
```

### 命令行使用

在服务器上直接使用命令：

```bash
# 基本用法
restaurant-crosscheck "深圳市南山区" "美食"
restaurant-crosscheck "上海静安区" "日式料理"
restaurant-crosscheck "北京朝阳区" "火锅"
```

## 📍 命令位置

- **本地命令**：`skills/restaurant-review-crosscheck/restaurant-crosscheck`
- **全局链接**：`~/workspace/bin/restaurant-crosscheck`（可选）

## 🎯 使用场景

### 1. 通过对话使用（推荐）

```
你：帮我查一下深圳南山区有什么好吃的餐厅
AI：[调用 skill，返回推荐列表]
```

```
你：上海静安区日式料理推荐
AI：[调用 skill，返回 3 家推荐餐厅]
```

### 2. 通过脚本使用

```bash
#!/bin/bash
# 查询多个地区的餐厅

locations=("深圳市南山区" "上海静安区" "北京朝阳区")
cuisines=("美食" "火锅" "日式料理")

for loc in "${locations[@]}"; do
    for food in "${cuisines[@]}"; do
        echo "查询: $loc - $food"
        restaurant-crosscheck "$loc" "$food"
        echo "---"
    done
done
```

### 3. 集成到其他工具

```python
# Python 脚本调用
import subprocess

def get_restaurant_recommendations(location, cuisine):
    result = subprocess.run(
        ['restaurant-crosscheck', location, cuisine],
        capture_output=True,
        text=True
    )
    return result.stdout

recommendations = get_restaurant_recommendations("深圳市南山区", "美食")
print(recommendations)
```

## ⚠️ 重要说明

### 当前版本特点

1. **使用模拟数据**
   - 不需要登录账号
   - 不需要浏览器
   - 适合无头服务器环境
   - 数据为模拟生成（用于测试）

2. **功能完整**
   - ✅ 交叉验证算法
   - ✅ 推荐评分系统
   - ✅ 一致性分析
   - ✅ 格式化输出

3. **数据限制**
   - ⚠️ 非真实数据
   - ⚠️ 仅用于功能演示
   - ⚠️ 不能用于实际决策

### 真实数据版本

如需真实数据，在有图形界面的环境中：

```bash
# 使用 Playwright 版本（需要浏览器）
bash setup.sh  # 配置登录
./crosscheck-auto "深圳市南山区" "美食"
```

## 📊 输出格式

```
📍 深圳市南山区 美食 餐厅推荐

1. 餐厅名称
   🏆 推荐指数: 6.4/10
   ⭐ 大众点评: 4.7⭐ (2028评价)
   💬 小红书: 2.7⭐ (309赞/80收藏)
   📍 地址: 深圳市南山区某某路88号
   💰 人均: ¥180-250
   ✅ 一致性: 低
```

## 🔧 配置选项

编辑 `scripts/config.py` 自定义：

```python
DEFAULT_THRESHOLDS = {
    "min_rating": 4.0,              # 最低评分
    "min_dianping_reviews": 50,     # 最少评价数
    "min_xhs_notes": 20,            # 最少笔记数
    "max_results": 10,              # 最多显示结果
    "similarity_threshold": 0.7     # 匹配相似度
}
```

## 🎮 完整示例

### 查询深圳美食

```bash
$ restaurant-crosscheck "深圳市南山区" "美食"

🔍 搜索: 深圳市南山区 - 美食
⚠️ 使用模拟数据（服务器版本）

📍 深圳市南山区 美食 餐厅推荐

============================================================

1. 美食推荐店A
   🏆 推荐指数: 6.4/10
   ⭐ 大众点评: 4.7⭐ (2028评价)
   💬 小红书: 2.7⭐ (309赞/80收藏)
   📍 地址: 深圳市南山区某某路88号
   💰 人均: ¥180-250
   ✅ 一致性: 低
...
```

### 在 Python 脚本中使用

```python
import subprocess
import json

# 查询餐厅
result = subprocess.run(
    ['restaurant-crosscheck', '深圳市南山区', '火锅'],
    capture_output=True,
    text=True
)

# 解析输出
recommendations = result.stdout
print(recommendations)
```

### 在 Node.js 脚本中使用

```javascript
const { exec } = require('child_process');

exec('restaurant-crosscheck "深圳市南山区" "美食"', (error, stdout, stderr) => {
    if (error) {
        console.error(`Error: ${error}`);
        return;
    }
    console.log(stdout);
});
```

## 📚 相关文档

- **[完整文档](README.md)** - 详细功能说明
- **[快速开始](QUICKSTART.md)** - 5分钟上手
- **[实现说明](IMPLEMENTATION.md)** - 技术细节
- **[数据结构](references/data_schema.md)** - 数据格式

## 🚀 下一步

1. **测试功能**
   ```bash
   restaurant-crosscheck "深圳市南山区" "美食"
   ```

2. **集成到对话**
   - 直接通过对话查询餐厅
   - AI 会自动调用 skill

3. **自定义配置**
   - 编辑 `scripts/config.py`
   - 调整评分权重和阈值

4. **查看更多文档**
   ```bash
   cat README.md
   cat QUICKSTART.md
   ```

---

**状态**：✅ 已集成到 OpenClaw，可以立即使用！


path: examples/example_usage.py
content: #!/usr/bin/env python3
"""Example usage of the Restaurant Review Cross-Check skill."""

import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))

from crosscheck import RestaurantCrossChecker

def main():
    """Run example search."""
    print("=" * 70)
    print("餐厅推荐交叉验证示例")
    print("=" * 70)
    print()

    # Example 1: Japanese cuisine in Jing'an District, Shanghai
    print("示例 1: 上海静安区日式料理\n")
    checker = RestaurantCrossChecker()
    results = checker.search("上海静安区", "日式料理")
    output = checker.format_output(results, "上海静安区", "日式料理")
    print(output)

    # Example 2: Hot pot in Chaoyang District, Beijing
    print("\n" + "=" * 70)
    print("示例 2: 北京朝阳区火锅\n")
    results2 = checker.search("北京朝阳区", "火锅")
    output2 = checker.format_output(results2, "北京朝阳区", "火锅")
    print(output2)

if __name__ == "__main__":
    main()


path: examples/test_high_consistency.py
content: #!/usr/bin/env python3
"""Test with high consistency mock data."""

import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))

from dataclasses import dataclass
from fetch_xiaohongshu import XiaohongshuFetcher, XiaohongshuPost
from fetch_dianping import DianpingFetcher, DianpingRestaurant
from match_restaurants import normalize_engagement, calculate_consistency

# Create high-quality mock data
def create_high_consistency_test():
    """Create test data where platforms agree."""
    
    # High-rated restaurant - both platforms agree
    high_restaurant = DianpingRestaurant(
        name="顶级日式料理",
        rating=4.8,
        review_count=2500,
        price_range="¥300-400",
        address="上海静安区南京西路888号",
        tags=["顶级", "新鲜", "服务好"],
        url="https://www.dianping.com/shop/111"
    )
    
    # High engagement XHS posts for the same restaurant
    high_xhs_posts = []
    for i in range(35):
        high_xhs_posts.append(XiaohongshuPost(
            restaurant_name="顶级日式料理",
            likes=400,  # High engagement
            saves=120,
            comments=60,
            sentiment_score=0.85,  # Very positive
            keywords=["顶级", "新鲜", "服务", "值得"],
            url=f"https://www.xiaohongshu.com/explore/high{i}"
        ))
    
    # Medium-rated restaurant - both platforms agree
    medium_restaurant = DianpingRestaurant(
        name="平价日料店",
        rating=4.2,
        review_count=800,
        price_range="¥100-150",
        address="上海静安区某某路456号",
        tags=["性价比", "实惠"],
        url="https://www.dianping.com/shop/222"
    )
    
    medium_xhs_posts = []
    for i in range(25):
        medium_xhs_posts.append(XiaohongshuPost(
            restaurant_name="平价日料店",
            likes=180,  # Medium engagement
            saves=50,
            comments=25,
            sentiment_score=0.65,  # Moderately positive
            keywords=["性价比", "实惠", "分量"],
            url=f"https://www.xiaohongshu.com/explore/med{i}"
        ))
    
    return [high_restaurant, medium_restaurant], [high_xhs_posts, medium_xhs_posts]

def main():
    print("=" * 70)
    print("高一致性测试案例")
    print("=" * 70)
    print()
    
    dp_restaurants, xhs_posts_lists = create_high_consistency_test()
    
    for i, (dp_restaurant, xhs_posts) in enumerate(zip(dp_restaurants, xhs_posts_lists), 1):
        # Aggregate XHS data
        from fetch_xiaohongshu import XiaohongshuFetcher
        fetcher = XiaohongshuFetcher({})
        aggregated = fetcher._aggregate_by_restaurant(xhs_posts)
        avg_post = fetcher._aggregate_post_data(aggregated[dp_restaurant.name]['posts'])
        
        # Calculate metrics
        xhs_rating = normalize_engagement(avg_post)
        consistency = calculate_consistency(dp_restaurant.rating, xhs_rating, avg_post.sentiment_score)
        
        # Determine consistency level
        if consistency >= 0.7:
            level = "高"
        elif consistency >= 0.5:
            level = "中"
        else:
            level = "低"
        
        print(f"{i}. {dp_restaurant.name}")
        print(f"   大众点评: {dp_restaurant.rating}⭐ ({dp_restaurant.review_count}评价)")
        print(f"   小红书: {xhs_rating:.1f}⭐ (avg {avg_post.likes}赞/{avg_post.saves}收藏)")
        print(f"   一致性: {level} ({consistency:.2f})")
        print(f"   大众点评标签: {', '.join(dp_restaurant.tags)}")
        print(f"   小红书热词: {', '.join(avg_post.keywords)}")
        print()

if __name__ == "__main__":
    main()


path: publish.sh
content: #!/bin/bash
# Quick publish script for ClawHub

echo "=========================================="
echo "📤 发布到 ClawHub"
echo "=========================================="
echo ""

SKILL_DIR="$(dirname "$(readlink -f "$0")")"
cd "$SKILL_DIR"

echo "📁 Skill 目录: $SKILL_DIR"
echo ""

# Check if SKILL.md exists
if [ ! -f "SKILL.md" ]; then
    echo "❌ 错误: 找不到 SKILL.md"
    exit 1
fi

echo "✅ 找到 SKILL.md"
echo ""

# Check if clawhub is installed
if ! command -v clawhub &> /dev/null; then
    echo "❌ ClawHub CLI 未安装"
    echo ""
    echo "请先安装:"
    echo "  npm install -g clawhub"
    echo ""
    echo "然后登录:"
    echo "  clawhub login"
    exit 1
fi

echo "✅ ClawHub CLI 已安装"
echo ""

# Check if logged in
if ! clawhub whoami &> /dev/null; then
    echo "⚠️ 未登录 ClawHub"
    echo ""
    echo "请先登录:"
    echo "  clawhub login"
    echo ""
    echo "或在有浏览器的设备上:"
    echo "  1. 访问 https://clawhub.ai"
    echo "  2. 登录并获取 API token"
    echo "  3. 使用: clawhub login --token YOUR_TOKEN"
    echo ""
    exit 1
fi

echo "✅ 已登录 ClawHub"
echo ""

echo "=========================================="
echo "准备发布"
echo "=========================================="
echo ""

# Display skill info
echo "Skill 信息:"
echo "  Slug: restaurant-crosscheck"
echo "  Name: 餐厅推荐交叉验证"
echo "  Version: 1.0.0"
echo ""

# Confirm publish
read -p "确认发布？(y/n): " -n 1 -r
echo ""

if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    echo "❌ 已取消"
    exit 0
fi

echo ""
echo "📤 正在发布..."
echo ""

# Publish
clawhub publish . \
  --slug restaurant-crosscheck \
  --name "餐厅推荐交叉验证" \
  --description "Cross-reference restaurant recommendations from Xiaohongshu and Dianping to validate restaurant quality and consistency." \
  --version 1.0.0 \
  --changelog "Initial release:

Features:
- Cross-platform validation (Dianping + Xiaohongshu)
- Location-based search by city/district
- Cuisine type filtering
- Consistency analysis between platforms
- Recommendation scoring (0-10)
- Server-friendly command-line tool
- Full documentation (Chinese + English)

Usage:
- Command line: restaurant-crosscheck 'location' 'cuisine'
- Dialogue integration: '查询深圳南山区推荐餐厅'
- Server version with mock data
- Full version with real scraping (requires browser)

Files:
- SKILL.md (skill definition)
- README.md (full documentation)
- QUICKSTART.md (quick start guide)
- SERVER_GUIDE.md (server usage)
- IMPLEMENTATION.md (technical details)
- restaurant-crosscheck (CLI tool)
- scripts/crosscheck_simple.py (server version)
- scripts/config.py (configuration)
" \
  --tags "restaurant,food,recommendation,dianping,xiaohongshu,chinese,china"

echo ""
echo "=========================================="
echo "✅ 发布完成！"
echo "=========================================="
echo ""

echo "验证发布:"
echo "  clawhub search restaurant-crosscheck"
echo ""
echo "查看详情:"
echo "  clawhub inspect restaurant-crosscheck"
echo ""


path: references/api_limitations.md
content: # API and Data Source Limitations

## Overview

Both Dianping and Xiaohongshu do not provide public APIs for restaurant data access. This skill relies on web scraping, which has significant limitations and legal considerations.

## Dianping (大众点评)

### Access Method
- **No public API**: Dianping API requires business partnership
- **Web scraping**: Primary method for data collection

### Limitations

1. **Anti-Scraping Measures**
   - IP blocking after 50-100 requests
   - CAPTCHA challenges
   - Request rate monitoring
   - User-Agent detection

2. **Rate Limiting**
   - Recommended: 1 request per 2 seconds minimum
   - With rotating proxies: 1 request per 1 second
   - Residential proxies required for sustained access

3. **Dynamic Content**
   - JavaScript rendering required for full data
   - Selenium or Playwright needed
   - Slower than static HTML scraping

4. **Data Completeness**
   - Some restaurants hide review counts
   - Price ranges may be missing
   - Tags are user-generated and inconsistent

### Legal Considerations
- Terms of Service prohibit scraping
- Commercial use requires explicit permission
- Data is protected under Chinese copyright law

**Recommendation**: Use for personal research only. Consider official API for commercial use.

## Xiaohongshu (小红书)

### Access Method
- **No public API**: No API available at all
- **Web scraping**: Only method, highly restricted

### Limitations

1. **Authentication Required**
   - Most searches require logged-in cookies
   - Cookies expire after 24-48 hours
   - Need to periodically re-authenticate

2. **Strict Anti-Scraping**
   - Aggressive IP blocking (10-20 requests)
   - Device fingerprinting
   - Behavior analysis (mouse movement, timing)

3. **Rate Limiting**
   - Recommended: 1 request per 3 seconds minimum
   - With rotating proxies: 1 request per 2 seconds
   - Residential proxies mandatory

4. **Data Structure**
   - Unstructured user-generated content
   - Restaurant names extracted from text (NLP required)
   - No standardized address format
   - Sentiment analysis required for quality assessment

5. **Platform-Specific Issues**
   - Influencer bias (paid promotions)
   - Fake reviews and bot activity
   - Trend-based spikes in engagement

### Legal Considerations
- Strict Terms of Service prohibiting all scraping
- Data protection concerns (user-generated content)
- Commercial use explicitly prohibited

**Recommendation**: Use only for personal research. Be transparent about data sources.

## Implementation Challenges

### 1. Restaurant Name Matching

**Problem**: Same restaurant has different names on platforms
- Dianping: "银座寿司"
- Xiaohongshu: "银座寿司静安店" or "静安银座日料"

**Solution**: Fuzzy matching with similarity threshold
- Uses `thefuzz` library for Levenshtein distance
- Configurable threshold (default: 0.7)
- May need manual verification for low-confidence matches

### 2. Location Variations

**Problem**: Same location expressed differently
- Dianping: "静安区南京西路"
- Xiaohongshu: "静安" or "上海静安"

**Solution**: Normalize location strings
- Use administrative district codes
- Map common aliases to standard names

### 3. Data Freshness

**Problem**: Scraped data becomes stale
- Ratings change daily
- New reviews constantly added
- Restaurants close or move

**Solution**:
- Cache data for 24 hours maximum
- Implement delta updates for changed restaurants
- Schedule periodic re-scraping

## Mitigation Strategies

### Technical Mitigations

1. **Proxy Rotation**
   - Use residential proxy pools (e.g., Bright Data, Smartproxy)
   - Rotate every 5-10 requests
   - Geographic targeting (match location search)

2. **Request Delays**
   - Randomize delays (1.5x - 3x recommended rate)
   - Mimic human browsing patterns
   - Avoid burst requests

3. **Session Management**
   - Rotate user agents randomly
   - Maintain cookie jars for authenticated sessions
   - Implement exponential backoff on errors

4. **Caching**
   - Cache restaurant data locally
   - Invalidate after 24 hours
   - Use Redis or SQLite for persistence

### Legal Mitigations

1. **Use for Personal Research Only**
   - No commercial applications
   - No resale of data
   - Attribution to platforms

2. **Rate Limiting**
   - Respect platforms' server load
   - Don't abuse public endpoints
   - Implement aggressive delays

3. **Data Privacy**
   - Anonymize user information
   - Don't store personal identifiers
   - Aggregate statistics only

## Alternative Approaches

### 1. Official APIs (Recommended for Commercial Use)

**Dianping Open Platform**
- Requires business registration
- API key application process
- Rate limits apply but documented
- Legal and reliable
- Contact: https://open.dianping.com/

**Cost**: Free tier available, paid tiers for higher volume

### 2. Third-Party Data Providers

- Meituan API (Dianping's parent company)
- Qichacha (company data)
- Local Chinese data providers

**Pros**: Legal, reliable, updated
**Cons**: Expensive, may lack granular data

### 3. Manual Research

For small-scale queries:
- Manual search on both platforms
- Spreadsheet comparison
- Human judgment for matching

**Pros**: No technical or legal issues
**Cons**: Not scalable, time-consuming

## Compliance Checklist

For any deployment:

- [ ] Reviewed Terms of Service for both platforms
- [ ] Implemented rate limiting (≥2 seconds per request)
- [ ] Using residential proxies (not datacenter)
- [ ] Data usage is for personal research only
- [ ] No resale or commercial exploitation
- [ ] Respecting robots.txt
- [ ] Implemented caching to minimize requests
- [ ] Anonymizing user-generated content
- [ ] Providing attribution when displaying data

## Disclaimer

This skill is provided for educational purposes only. Users are responsible for ensuring their use complies with applicable laws and platform terms of service. The authors assume no liability for misuse.

**Last Updated**: 2026-02-09


path: references/data_schema.md
content: # Data Schema Documentation

## Dianping Restaurant Schema

```python
@dataclass
class DianpingRestaurant:
    name: str              # Restaurant name
    rating: float          # 0-5 scale
    review_count: int      # Number of reviews
    price_range: str       # e.g., "¥200-300"
    address: str           # Full address
    tags: List[str]        # User tags, e.g., ["美味", "环境好"]
    url: str              # Dianping URL
```

## Xiaohongshu Post Schema

```python
@dataclass
class XiaohongshuPost:
    restaurant_name: str   # Extracted from post content
    likes: int            # Number of likes
    saves: int            # Number of saves (收藏)
    comments: int         # Number of comments
    sentiment_score: float # -1.0 (negative) to 1.0 (positive)
    keywords: List[str]   # Extracted keywords, e.g., ["好吃", "环境"]
    url: str             # Post URL
```

## Matched Restaurant Schema

```python
@dataclass
class MatchedRestaurant:
    name: str                    # Matched restaurant name
    dianping_data: DianpingRestaurant
    xhs_data: XiaohongshuPost
    similarity_score: float      # 0-1, matching confidence
    consistency_score: float     # 0-1, cross-platform consistency
```

## Recommendation Result Schema

```python
@dataclass
class RecommendationResult:
    restaurant: MatchedRestaurant
    recommendation_score: float  # 0-10, final score
    consistency_level: str       # "高", "中", "低"
```

## Scoring Algorithm

### 1. Engagement Normalization (Xiaohongshu to 0-5 scale)

```
engagement_score = (likes × 1.0) + (saves × 2.0) + (comments × 1.5)
normalized_rating = (engagement_score / (max_expected × 4.5)) × 5
```

### 2. Consistency Score (0-1)

```
rating_correlation = max(0, 1 - (|rating_dp - rating_xhs| / 2))
sentiment_alignment = (sentiment_score + 1) / 2
consistency = (rating_correlation × 0.6) + (sentiment_alignment × 0.4)
```

### 3. Final Recommendation Score (0-10)

```
recommendation = (
    (dianping_rating × 0.4) +
    (xhs_engagement_normalized × 0.3) +
    (consistency × 5 × 0.3)
) × 2
```

## Consistency Levels

- **高 (High)**: Consistency ≥ 0.7
  - Both platforms agree on quality
  - High confidence recommendation

- **中 (Medium)**: Consistency 0.5 - 0.7
  - Generally consistent with minor differences
  - Moderate confidence

- **低 (Low)**: Consistency < 0.5
  - Significant disagreement between platforms
  - Manual review recommended

## Data Quality Thresholds

| Metric | Minimum | Rationale |
|--------|---------|-----------|
| Dianping rating | 4.0/5.0 | Filter out low-rated restaurants |
| Dianping reviews | 50 | Ensure statistically significant data |
| Xiaohongshu notes | 20 | Minimum social proof |
| Name similarity | 0.7 | Confident fuzzy match threshold |


path: references/sentiment_analysis.md
content: # Sentiment Analysis for Xiaohongshu Posts

## Overview

Xiaohongshu (小红书) posts are unstructured user-generated content. Sentiment analysis helps extract restaurant quality signals from these posts.

## Simplified Approach (Current Implementation)

The skill uses a keyword-based sentiment scoring system:

### Positive Keywords (+1)
```
好吃, 美味, 推荐, 值得, 喜欢, 棒, 赞, 完美, 正宗, 新鲜,
环境好, 服务好, 性价比, 回购, 打卡, 必吃, 太好吃了
```

### Negative Keywords (-1)
```
难吃, 失望, 差评, 不推荐, 避雷, 贵, 服务差, 态度差,
不好吃, 没什么, 普通, 一般, 就那样, 不会再来了
```

### Neutral Keywords (0)
```
还行, 可以, 试试, 感觉, 似乎, 差不多
```

## Scoring Formula

```
sentiment_score = (positive_count - negative_count) / total_mentions
```

Range: -1.0 (very negative) to 1.0 (very positive)

## Example Calculation

Post: "这家日料店太好吃了！环境很好，服务也很热情，就是有点贵。值得推荐！"

- Positive: 太好吃了, 很好, 热情, 值得, 推荐 (5)
- Negative: 贵 (1)
- Total mentions: 6

```
sentiment_score = (5 - 1) / 6 = 0.67
```

Result: Moderately positive (0.67)

## Advanced Approaches (Future Enhancement)

### 1. Machine Learning Models

**SnowNLP** (Chinese NLP library)
```python
from snownlp import SnowNLP

text = "这家餐厅太好吃了！"
s = SnowNLP(text)
sentiment = s.sentiments  # 0-1, higher is more positive
```

**Pros**: More accurate, understands context
**Cons**: Requires additional dependency, slower

### 2. Aspect-Based Sentiment Analysis

Extract sentiment for specific aspects:

```
Food:     好吃, 新鲜, 正宗 → +0.8
Service:  服务好, 热情 → +0.6
Price:    有点贵, 性价比高 → -0.2
Atmosphere: 环境很好 → +0.7
```

Weighted average:
```
overall_sentiment = (food × 0.4) + (service × 0.2) +
                   (price × 0.2) + (atmosphere × 0.2)
```

### 3. Emoji Analysis

Xiaohongshu posts often contain emojis:

| Emoji | Sentiment | Weight |
|-------|-----------|--------|
| 😋, 😍, 👍 | Positive | +0.3 |
| 😔, 😡, 👎 | Negative | -0.3 |
| 😐, 🤔 | Neutral | 0 |

## Integration with Recommendation Score

Sentiment score is used in two ways:

### 1. Consistency Calculation
```
sentiment_alignment = (sentiment_score + 1) / 2  # Normalize to 0-1
```

Used to verify if Dianping ratings match Xiaohongshu sentiment.

### 2. Quality Filter
Posts with sentiment < 0.0 (negative) are excluded from aggregation.

## Implementation Notes

### Current State
The skill currently uses a simplified keyword-based approach for:
- Speed (no ML model loading)
- Simplicity (no heavy dependencies)
- Explainability (clear which keywords triggered)

### When to Upgrade
Consider ML-based sentiment analysis when:
- Processing >1000 posts per day
- Need higher accuracy (>85%)
- Building production system
- Have GPU resources available

### Recommended Libraries

**For Simple Scoring** (Current):
```python
# No external libraries needed
# Pure Python keyword matching
```

**For ML-Based** (Future):
```bash
pip install snownlp jieba
```

```python
from snownlp import SnowNLP
import jieba

text = "这家餐厅太好吃了！"
s = SnowNLP(text)
print(s.sentiments)  # 0.93 (very positive)

# Aspect-based (requires training)
keywords = jieba.cut(text)
# Classify each keyword into food/service/price/atmosphere
```

## Limitations

1. **Sarcasm Detection**
   - "这服务'真好'啊" (sarcastic) → Misses negativity
   - ML models also struggle with this

2. **Context Dependence**
   - "有点贵" is negative but mild
   - "太贵了" is strongly negative
   - Current approach treats both equally

3. **Regional Slang**
   - Xiaohongshu users use platform-specific slang
   - Slang changes over time
   - Requires periodic keyword list updates

## Testing

Validate sentiment analysis by manually scoring 20-30 posts and comparing:

```python
posts = [
    {"text": "太好吃了！", "expected": 0.9, "actual": 0.8},
    {"text": "一般般吧", "expected": 0.2, "actual": 0.1},
    # ...
]

accuracy = sum(1 for p in posts
              if abs(p['expected'] - p['actual']) < 0.2) / len(posts)

print(f"Accuracy: {accuracy * 100:.1f}%")
```

Target: >75% accuracy with simplified approach

## Resources

- **SnowNLP Documentation**: https://github.com/isnowfy/snownlp
- **Jieba (Chinese Word Segmentation)**: https://github.com/fxsjy/jieba
- **Chinese Sentiment Analysis**: Research papers on social media sentiment in Chinese

**Last Updated**: 2026-02-09


path: requirements.txt
content: playwright>=1.40.0
thefuzz>=0.20.0


path: scripts/__init__.py
content: """Restaurant Review Cross-Check Skill Scripts."""

__version__ = "2.0.0"


path: scripts/config.py
content: """
配置文件 — 城市代码、匹配参数、评分权重、CSS 选择器
基于原始 config.py 扩展，增加了实际运行所需的配置
"""
import os

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  大众点评城市代码
#  新增城市：打开 dianping.com 切换城市，URL 里 /keyword/X/ 的 X 就是代码
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CITY_CODES = {
    "上海": 1,  "北京": 2,  "天津": 3,  "广州": 4,  "杭州": 5,
    "南京": 6,  "深圳": 7,  "成都": 8,  "重庆": 9,  "武汉": 10,
    "苏州": 11, "长沙": 12, "厦门": 15, "沈阳": 16, "西安": 17,
    "青岛": 18, "大连": 19, "济南": 20, "哈尔滨": 22, "昆明": 23,
    "郑州": 27, "福州": 28, "宁波": 29, "无锡": 30, "合肥": 31,
    "温州": 46, "东莞": 119, "佛山": 125, "珠海": 126,
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  会话路径
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SESSION_BASE = os.path.expanduser("~/.local/share/restaurant-crosscheck/sessions")
DIANPING_SESSION = os.path.join(SESSION_BASE, "dianping")
XHS_SESSION = os.path.join(SESSION_BASE, "xiaohongshu")

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  搜索与匹配参数（来自原始 config.py）
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DEFAULT_THRESHOLDS = {
    "min_rating": 4.0,
    "min_dianping_reviews": 50,
    "min_xhs_notes": 1,           # 原始是 20，实际抓取很难达到，改为 1
    "max_results": 15,
    "similarity_threshold": 0.55,  # 模糊匹配阈值
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  评分权重（来自原始 config.py）
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SCORING_WEIGHTS = {
    "dianping_rating": 0.4,
    "xhs_engagement": 0.3,
    "consistency": 0.3,
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  输出配置
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OUTPUT_CONFIG = {
    "max_restaurants": 10,
    "show_details": True,
    "confidence_threshold": 0.5,
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  浏览器 / 抓取参数
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
BROWSER_ARGS = ['--disable-blink-features=AutomationControlled']
PAGE_LOAD_WAIT = 4        # 页面加载等待秒数
SCROLL_WAIT = 1.5         # 滚动等待秒数
MAX_DIANPING_ITEMS = 15   # 大众点评最多抓几家
MAX_XHS_NOTES = 25        # 小红书最多看几篇笔记

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  大众点评 CSS 选择器（按优先级）
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DP_LIST_SELECTORS = [
    '.shop-list ul li',
    '#shop-all-list li',
    'div[data-shopid]',
    '.shoplist li',
    '.shop-wrap',
]
DP_NAME_SELECTORS = ['.tit', '.shop-name', 'h4', '.shopname', 'b', 'a[href*="/shop/"] span']
DP_SCORE_SELECTORS = ['[class*="star"]', '[class*="score"]', '.star', '.score']
DP_REVIEW_SELECTORS = ['.review-num', '.comment-num', '[class*="review"]', '[class*="comment"]']
DP_PRICE_SELECTORS = ['.mean-price', '[class*="price"]', '.avgprice']
DP_ADDR_SELECTORS = ['.addr', '[class*="addr"]', '.area']
DP_TAG_SELECTORS = ['.shop-tag', '[class*="tag"]', '.svc-tag']

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  小红书 CSS 选择器
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
XHS_CARD_SELECTORS = [
    '[class*="note-item"]',
    'section[class*="note"]',
    'a[href*="/explore/"]',
    'a[href*="/search_result/"]',
]

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  情感分析词典（扩展版）
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
POSITIVE_WORDS = [
    '好吃', '推荐', '超赞', '必吃', '绝了', '惊艳', '正宗', '地道',
    '超棒', '性价比高', '回头客', '排队', '宝藏', '天花板', 'yyds',
    '强推', '五星', '满分', '神仙', '顶级', '美味', '值得', '完美',
]
NEGATIVE_WORDS = [
    '难吃', '不好吃', '踩雷', '避雷', '不推荐', '失望', '差评',
    '不值', '翻车', '不会再去', '一般', '过誉', '差',
]
NEGATION_WORDS = ['不', '没', '别', '非', '无', '未']

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  店名提取
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SHOP_SUFFIXES = [
    '店', '餐厅', '饭店', '酒楼', '食府', '菜馆', '面馆', '烧烤',
    '火锅', '寿司', '拉面', '小馆', '私房', '酒家', '茶楼', '茶餐厅',
    '大排档', '小厨', '食堂', '饭堂', '咖啡', '甜品', '烘焙', '会所',
    '工作室', '馆', '餐馆', '料理', '烤肉', '串串', '披萨', '汉堡',
]
EXCLUDE_KEYWORDS = [
    '推荐', '攻略', '必吃', '探店', '打卡', '合集', '分享',
    '测评', '避坑', '指南', '盘点', '安利', '种草',
]


path: scripts/crosscheck.py
content: #!/usr/bin/env python3
"""
本地生活交叉验证 — 大众点评 × 小红书
基于原始 crosscheck.py + crosscheck_base.py 重构

用法:
    python3 crosscheck.py '深圳南山区' '粤菜'
    python3 crosscheck.py '广州天河区' '早茶'
    python3 crosscheck.py '上海静安区' '按摩'
"""
import sys
import os
import json
from typing import List

from config import SCORING_WEIGHTS, OUTPUT_CONFIG, DEFAULT_THRESHOLDS
from models import MatchedRestaurant, RecommendationResult
from fetch_dianping import fetch_dianping
from fetch_xiaohongshu import fetch_xiaohongshu
from match_restaurants import match_and_score, normalize_engagement, calculate_consistency


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  评分逻辑（来自原始 crosscheck_base.py.CrossCheckBase）
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def calculate_recommendation(match: MatchedRestaurant) -> RecommendationResult:
    """
    计算最终推荐评分（来自原始 CrossCheckBase.calculate_recommendation）
    """
    dp = match.dianping_data
    xhs = match.xhs_data
    w = SCORING_WEIGHTS

    # 归一化小红书互动量到 0-5
    xhs_rating = normalize_engagement(xhs)

    # 一致性
    consistency = calculate_consistency(dp.rating, xhs_rating, xhs.sentiment_score)

    # 综合评分 0-10（来自原始公式）
    raw_score = (
        (dp.rating * w['dianping_rating']) +
        (xhs_rating * w['xhs_engagement']) +
        (consistency * 5 * w['consistency'])
    ) * 2
    recommendation_score = round(max(0.0, min(10.0, raw_score)), 1)

    # 一致性等级
    if consistency >= 0.7:
        consistency_level = "高"
    elif consistency >= 0.5:
        consistency_level = "中"
    else:
        consistency_level = "低"

    return RecommendationResult(
        name=dp.name,
        dianping_rating=dp.rating,
        dianping_reviews=dp.review_count,
        dianping_tags=dp.tags,
        dianping_address=dp.address,
        dianping_price=dp.price_range,
        xhs_engagement_display=f"{xhs_rating:.1f}⭐ ({xhs.likes}赞)",
        xhs_keywords=xhs.keywords,
        recommendation_score=recommendation_score,
        consistency_level=consistency_level,
        consistency_score=round(consistency, 2),
        similarity_score=round(match.similarity_score, 2),
    )


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  主流程
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def crosscheck(location: str, cuisine: str):
    """执行交叉验证"""
    print(f"\n{'=' * 60}")
    print(f"🔍 本地生活交叉验证: {location} - {cuisine}")
    print(f"{'=' * 60}")
    print(f"💡 会弹出浏览器窗口，请不要操作它们\n")

    # 抓取两个平台
    dp_restaurants = fetch_dianping(location, cuisine)
    print(f"  ✅ 大众点评: {len(dp_restaurants)} 家\n")

    xhs_posts = fetch_xiaohongshu(location, cuisine)
    print(f"  ✅ 小红书: {len(xhs_posts)} 家")

    if not dp_restaurants and not xhs_posts:
        print("\n❌ 两个平台都没数据")
        print("💡 排查步骤:")
        print("  1. python3 session_manager.py all  重新登录")
        print("  2. 查看 ~/Downloads/ 下的截图")
        print("  3. 海外用户需开国内 VPN")
        return

    # 交叉匹配
    threshold = DEFAULT_THRESHOLDS['similarity_threshold']
    matches = match_and_score(dp_restaurants, xhs_posts, threshold)

    # 计算推荐评分
    results = [calculate_recommendation(m) for m in matches]
    results.sort(key=lambda r: r.recommendation_score, reverse=True)

    # ── 输出结果 ──
    print(f"\n{'=' * 60}")
    print(f"📊 交叉验证结果")
    print(f"{'=' * 60}")

    max_show = OUTPUT_CONFIG['max_restaurants']

    if results:
        print(f"\n  🎯 两平台共同推荐 ({len(results)} 家):\n")
        for i, r in enumerate(results[:max_show], 1):
            score = r.recommendation_score
            if score >= 8:
                level = "🔥 强推"
            elif score >= 6:
                level = "👍 推荐"
            else:
                level = "🤔 可以试试"

            print(f"  {i}. 【{r.name}】 {score}/10 {level}")
            print(f"     大众点评: {r.dianping_rating}分 | {r.dianping_reviews}条评论 | {r.dianping_price or '价格未知'}")
            print(f"     小红书: {r.xhs_engagement_display} | 匹配度{r.similarity_score}")
            print(f"     一致性: {r.consistency_level} ({r.consistency_score})")
            if r.dianping_address:
                print(f"     📍 {r.dianping_address}")
            if r.dianping_tags:
                print(f"     🏷️  {', '.join(r.dianping_tags)}")
            if r.xhs_keywords:
                print(f"     💬 小红书热词: {', '.join(r.xhs_keywords)}")
            print()

    # 未匹配的也列出
    matched_dp_names = {r.name for r in results}
    matched_xhs_names = {m.xhs_data.restaurant_name for m in matches}
    unmatched_dp = [r for r in dp_restaurants if r.name not in matched_dp_names]
    unmatched_xhs = [p for p in xhs_posts if p.restaurant_name not in matched_xhs_names]

    if not results:
        print("\n  ⚠️ 未找到交叉匹配\n")

    if unmatched_dp:
        print(f"  📍 仅大众点评 ({len(unmatched_dp)} 家):")
        for i, r in enumerate(unmatched_dp[:8], 1):
            print(f"    {i}. {r.name} — {r.rating}分 {r.review_count}评 {r.price_range}")
        print()

    if unmatched_xhs:
        print(f"  📕 仅小红书 ({len(unmatched_xhs)} 家):")
        for i, p in enumerate(unmatched_xhs[:8], 1):
            print(f"    {i}. {p.restaurant_name} — 提及{p.mention_count}次 好评度{p.sentiment_score:.2f}")
        print()

    print(f"{'=' * 60}")

    # 保存 JSON 结果
    _save_results(location, cuisine, results, unmatched_dp, unmatched_xhs)


def _save_results(location, cuisine, results, unmatched_dp, unmatched_xhs):
    """保存详细结果到 JSON"""
    data = {
        "query": {"location": location, "cuisine": cuisine},
        "matches": [
            {
                "name": r.name,
                "score": r.recommendation_score,
                "dianping_rating": r.dianping_rating,
                "dianping_reviews": r.dianping_reviews,
                "dianping_price": r.dianping_price,
                "dianping_address": r.dianping_address,
                "consistency": r.consistency_level,
                "similarity": r.similarity_score,
            }
            for r in results
        ],
        "dianping_only": [
            {"name": r.name, "rating": r.rating, "reviews": r.review_count, "price": r.price_range}
            for r in unmatched_dp
        ],
        "xiaohongshu_only": [
            {"name": p.restaurant_name, "mentions": p.mention_count, "sentiment": p.sentiment_score}
            for p in unmatched_xhs
        ],
    }
    out_path = os.path.expanduser(f"~/Downloads/crosscheck_{location}_{cuisine}.json")
    try:
        with open(out_path, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        print(f"💾 详细结果已保存: {out_path}")
    except Exception:
        pass


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  CLI 入口
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

if __name__ == '__main__':
    if len(sys.argv) < 3:
        print("🔍 本地生活交叉验证工具")
        print()
        print("用法: python3 crosscheck.py <位置> <类型>")
        print()
        print("示例:")
        print("  python3 crosscheck.py '深圳南山区' '粤菜'")
        print("  python3 crosscheck.py '广州天河区' '早茶'")
        print("  python3 crosscheck.py '上海静安区' '日料'")
        print("  python3 crosscheck.py '成都锦江区' '火锅'")
        print("  python3 crosscheck.py '深圳福田区' '按摩'")
        sys.exit(1)

    crosscheck(sys.argv[1], sys.argv[2])


path: scripts/crosscheck_base.py
content: """Base class for restaurant cross-checking logic. Shared by all crosscheck variants."""

from typing import List, Dict
from dataclasses import dataclass

from config import SCORING_WEIGHTS, OUTPUT_CONFIG
from match_restaurants import (
    MatchedRestaurant,
    normalize_engagement,
    calculate_consistency
)


@dataclass
class RecommendationResult:
    """Final recommendation with scores. Single definition used everywhere."""
    name: str
    dianping_rating: float
    dianping_reviews: int
    dianping_tags: List[str]
    dianping_address: str
    dianping_price: str
    xhs_engagement_display: str
    xhs_keywords: List[str]
    recommendation_score: float  # 0-10
    consistency_level: str  # "高", "中", "低"
    consistency_score: float  # 0-1
    similarity_score: float  # 0-1, match confidence


class CrossCheckBase:
    """Base logic for recommendation scoring and output formatting."""

    def __init__(self, scoring_weights: Dict = None):
        self.scoring_weights = scoring_weights or SCORING_WEIGHTS

    def calculate_recommendation(self, match: MatchedRestaurant) -> RecommendationResult:
        """Calculate final recommendation score from a matched restaurant."""
        dp = match.dianping_data
        xhs = match.xhs_data

        # Normalize XHS engagement to 0-5 scale
        xhs_rating = normalize_engagement(xhs)

        # Calculate consistency
        consistency = calculate_consistency(
            dp.rating,
            xhs_rating,
            xhs.sentiment_score
        )

        # Calculate recommendation score (0-10)
        raw_score = (
            (dp.rating * self.scoring_weights['dianping_rating']) +
            (xhs_rating * self.scoring_weights['xhs_engagement']) +
            (consistency * 5 * self.scoring_weights['consistency'])
        ) * 2

        # 严格 clamp 到 0-10
        recommendation_score = round(max(0.0, min(10.0, raw_score)), 1)

        # Determine consistency level
        if consistency >= 0.7:
            consistency_level = "高"
        elif consistency >= 0.5:
            consistency_level = "中"
        else:
            consistency_level = "低"

        return RecommendationResult(
            name=dp.name,
            dianping_rating=dp.rating,
            dianping_reviews=dp.review_count,
            dianping_tags=dp.tags,
            dianping_address=dp.address,
            dianping_price=dp.price_range,
            xhs_engagement_display=f"{xhs_rating:.1f}⭐ ({xhs.likes}赞/{xhs.saves}收藏)",
            xhs_keywords=xhs.keywords,
            recommendation_score=recommendation_score,
            consistency_level=consistency_level,
            consistency_score=round(consistency, 2),
            similarity_score=round(match.similarity_score, 2)
        )

    def build_results(self, matches: List[MatchedRestaurant]) -> List[RecommendationResult]:
        """Convert matched restaurants to sorted recommendation results."""
        results = [self.calculate_recommendation(m) for m in matches]
        results.sort(key=lambda x: x.recommendation_score, reverse=True)
        return results

    @staticmethod
    def format_output(
        results: List[RecommendationResult],
        location: str,
        cuisine: str,
        max_restaurants: int = None
    ) -> str:
        """Format results for display."""
        if max_restaurants is None:
            max_restaurants = OUTPUT_CONFIG.get('max_restaurants', 10)

        if not results:
            return f"❌ 未找到符合条件的餐厅: {location} - {cuisine}"

        output = []
        output.append(f"📍 {location} {cuisine} 餐厅推荐\n")
        output.append("=" * 60 + "\n")

        for i, r in enumerate(results[:max_restaurants], 1):
            output.append(f"{i}. {r.name}")
            output.append(f"   🏆 推荐指数: {r.recommendation_score}/10")
            output.append(f"   ⭐ 大众点评: {r.dianping_rating}⭐ ({r.dianping_reviews}评价)")
            output.append(f"   💬 小红书: {r.xhs_engagement_display}")
            output.append(f"   📍 地址: {r.dianping_address}")
            output.append(f"   💰 人均: {r.dianping_price}")
            output.append(f"   ✅ 一致性: {r.consistency_level} ({r.consistency_score:.2f})")

            if OUTPUT_CONFIG.get('show_details', True):
                output.append(f"\n   📊 平台对比:")
                output.append(f"   - 大众点评标签: {', '.join(r.dianping_tags)}")
                output.append(f"   - 小红书热词: {', '.join(r.xhs_keywords)}")

                if r.consistency_level == "低":
                    output.append(f"\n   ⚠️ 注意: 两平台评价差异较大，建议进一步了解")

            output.append("")

        return "\n".join(output)


path: scripts/crosscheck_real.py
content: #!/usr/bin/env python3
"""
Main script for cross-referencing restaurant reviews using real data.
Uses Playwright with persistent sessions for automated login.
"""

import sys
import asyncio
from typing import List, Dict

from config import DEFAULT_THRESHOLDS
from session_manager import BrowserSessionManager
from fetch_dianping_real import fetch_dianping_real
from fetch_xiaohongshu_real import fetch_xiaohongshu_real
from match_restaurants import match_and_score
from crosscheck_base import CrossCheckBase, RecommendationResult


class RestaurantCrossCheckerReal(CrossCheckBase):
    """Cross-reference restaurant data from multiple platforms using real scraping."""

    def __init__(self, config: Dict = None):
        super().__init__()
        self.config = config or DEFAULT_THRESHOLDS
        self.session_manager = BrowserSessionManager()

    async def search_async(self, location: str, cuisine: str) -> List[RecommendationResult]:
        """
        Search and cross-reference restaurants (async version).

        Args:
            location: Geographic area (e.g., "上海静安区")
            cuisine: Cuisine type (e.g., "日式料理")

        Returns:
            List of recommendation results sorted by score
        """
        print(f"\n🔍 开始搜索: {location} - {cuisine}\n")
        print("⏳ 正在从大众点评获取数据...")
        print("⏳ 正在从小红书获取数据...")
        print("（使用已保存的登录会话，如未登录将自动提示）\n")

        # Fetch data from both platforms concurrently
        dp_task = fetch_dianping_real(location, cuisine, self.config)
        xhs_task = fetch_xiaohongshu_real(location, cuisine, self.config)

        dp_restaurants, xhs_posts = await asyncio.gather(
            dp_task,
            xhs_task,
            return_exceptions=True
        )

        # Handle exceptions
        if isinstance(dp_restaurants, Exception):
            print(f"⚠️ 大众点评抓取失败: {dp_restaurants}")
            dp_restaurants = []
        if isinstance(xhs_posts, Exception):
            print(f"⚠️ 小红书抓取失败: {xhs_posts}")
            xhs_posts = []

        print(f"✅ 大众点评: 找到 {len(dp_restaurants)} 家餐厅")
        print(f"✅ 小红书: 找到 {len(xhs_posts)} 家餐厅\n")

        if not dp_restaurants or not xhs_posts:
            print("⚠️ 数据不足，无法进行交叉验证")
            print("💡 提示：")
            print("  - 如果是首次使用，请先运行: python3 scripts/session_manager.py")
            print("  - 检查网络连接")
            print("  - 尝试更换搜索关键词")
            return []

        # Match restaurants across platforms
        matches = match_and_score(dp_restaurants, xhs_posts, self.config)

        print(f"🔗 匹配成功: {len(matches)} 家餐厅\n")

        return self.build_results(matches)

    def search(self, location: str, cuisine: str) -> List[RecommendationResult]:
        """Synchronous wrapper for search."""
        return asyncio.run(self.search_async(location, cuisine))


def main():
    """CLI entry point."""
    if len(sys.argv) < 3:
        print("Usage: python3 crosscheck_real.py <location> <cuisine>")
        print("Example: python3 crosscheck_real.py '上海静安区' '日式料理'")
        print()
        print("⚠️ 首次使用前，请先配置登录会话：")
        print("   python3 scripts/session_manager.py")
        sys.exit(1)

    location = sys.argv[1]
    cuisine = sys.argv[2]

    checker = RestaurantCrossCheckerReal()
    results = checker.search(location, cuisine)
    output = checker.format_output(results, location, cuisine)

    print(output)


if __name__ == "__main__":
    main()


path: scripts/crosscheck_simple.py
content: #!/usr/bin/env python3
"""
Server-friendly restaurant cross-check using requests + BeautifulSoup.
No browser required, works in headless environments.
"""

import sys
from typing import List, Dict

from config import DEFAULT_THRESHOLDS
from fetch_dianping import DianpingRestaurant, fetch_dianping
from fetch_xiaohongshu import XiaohongshuPost, fetch_xiaohongshu
from match_restaurants import match_and_score
from crosscheck_base import CrossCheckBase, RecommendationResult


class SimpleCrossChecker(CrossCheckBase):
    """Simple cross-checker for server environments."""

    def __init__(self, config: Dict = None):
        super().__init__()
        self.config = config or DEFAULT_THRESHOLDS

    def search_mock(self, location: str, cuisine: str) -> List[RecommendationResult]:
        """
        Search using mock data (for testing without real scraping).

        Args:
            location: Geographic area
            cuisine: Cuisine type

        Returns:
            List of recommendation results
        """
        # Generate mock data
        dp_restaurants = [
            DianpingRestaurant(
                name=f"{cuisine}推荐店A",
                rating=4.7,
                review_count=1800 + hash(location) % 500,
                price_range="¥180-250",
                address=f"{location}某某路88号",
                tags=["美味", "环境好", "服务热情"],
                url="https://www.dianping.com/shop/111"
            ),
            DianpingRestaurant(
                name=f"{cuisine}推荐店B",
                rating=4.4,
                review_count=900 + hash(location) % 300,
                price_range="¥120-180",
                address=f"{location}某某路168号",
                tags=["性价比高", "分量足", "实惠"],
                url="https://www.dianping.com/shop/222"
            ),
            DianpingRestaurant(
                name=f"{cuisine}特色店C",
                rating=4.2,
                review_count=600 + hash(location) % 200,
                price_range="¥100-150",
                address=f"{location}某某路258号",
                tags=["特色", "正宗", "值得一试"],
                url="https://www.dianping.com/shop/333"
            ),
        ]

        # Mock XHS data
        xhs_posts = [
            XiaohongshuPost(
                restaurant_name=f"{cuisine}推荐店A",
                likes=300 + hash(cuisine) % 100,
                saves=80,
                comments=45,
                sentiment_score=0.75,
                keywords=["好吃", "推荐", "环境"],
                url="https://www.xiaohongshu.com/explore/111"
            ),
            XiaohongshuPost(
                restaurant_name=f"{cuisine}推荐店B",
                likes=150 + hash(cuisine) % 80,
                saves=40,
                comments=22,
                sentiment_score=0.60,
                keywords=["性价比", "实惠"],
                url="https://www.xiaohongshu.com/explore/222"
            ),
            XiaohongshuPost(
                restaurant_name=f"{cuisine}特色店C",
                likes=100 + hash(cuisine) % 60,
                saves=30,
                comments=15,
                sentiment_score=0.50,
                keywords=["特色", "正宗"],
                url="https://www.xiaohongshu.com/explore/333"
            ),
        ]

        # Match and score
        matches = match_and_score(dp_restaurants, xhs_posts, self.config)

        return self.build_results(matches)


def main():
    """CLI entry point."""
    if len(sys.argv) < 3:
        print("Usage: crosscheck-simple <location> <cuisine>")
        print("Example: crosscheck-simple '深圳市南山区' '美食'")
        print()
        print("Note: This version uses mock data for server environments.")
        print("For real data, use crosscheck-real.py on a system with browser.")
        sys.exit(1)

    location = sys.argv[1]
    cuisine = sys.argv[2]

    print(f"\n🔍 搜索: {location} - {cuisine}")
    print("⚠️ 使用模拟数据（服务器版本）\n")

    checker = SimpleCrossChecker()
    results = checker.search_mock(location, cuisine)
    output = checker.format_output(results, location, cuisine)

    print(output)


if __name__ == "__main__":
    main()


path: scripts/fetch_dianping.py
content: """
大众点评抓取 — sync Playwright 版本
基于原始 fetch_dianping_real.py 重构，修复 async→sync
"""
import os
import re
import time
from typing import List
from urllib.parse import quote

from playwright.sync_api import sync_playwright

from config import (
    CITY_CODES, DIANPING_SESSION, BROWSER_ARGS,
    PAGE_LOAD_WAIT, MAX_DIANPING_ITEMS,
    DP_LIST_SELECTORS, DP_NAME_SELECTORS, DP_SCORE_SELECTORS,
    DP_REVIEW_SELECTORS, DP_PRICE_SELECTORS, DP_ADDR_SELECTORS,
    DP_TAG_SELECTORS,
)
from models import DianpingRestaurant


def get_city_code(location: str) -> int:
    """从位置字符串中匹配大众点评城市代码"""
    for city, code in CITY_CODES.items():
        if city in location:
            return code
    return 0


def fetch_dianping(location: str, cuisine: str) -> List[DianpingRestaurant]:
    """
    从大众点评抓取餐厅数据

    Args:
        location: 地理位置，如 "深圳南山区"
        cuisine: 菜系/类型，如 "粤菜"

    Returns:
        DianpingRestaurant 列表
    """
    restaurants = []
    city_code = get_city_code(location)

    try:
        with sync_playwright() as p:
            os.makedirs(DIANPING_SESSION, exist_ok=True)
            ctx = p.chromium.launch_persistent_context(
                DIANPING_SESSION, headless=False, args=BROWSER_ARGS,
            )
            page = ctx.new_page()

            # 构造搜索 URL（使用正确的城市代码）
            search_term = f"{location} {cuisine}"
            url = f"https://www.dianping.com/search/keyword/{city_code}/0_{quote(search_term)}"
            print(f"  🔗 大众点评: {url}")
            page.goto(url, timeout=30000, wait_until="domcontentloaded")
            time.sleep(PAGE_LOAD_WAIT)

            # 如果 URL 搜索被重定向，改用搜索框
            if "/search/" not in page.url:
                print("  ⚠️ URL 搜索被重定向，尝试搜索框...")
                page.goto("https://www.dianping.com", timeout=30000)
                time.sleep(2)
                box = page.query_selector(
                    'input[placeholder*="商户"], input[name="keyword"], '
                    '#J_search_input, input[type="text"]'
                )
                if box:
                    box.click()
                    box.fill(search_term)
                    time.sleep(0.5)
                    page.keyboard.press("Enter")
                    time.sleep(PAGE_LOAD_WAIT)

            # 用多种选择器找到列表
            items = []
            for sel in DP_LIST_SELECTORS:
                items = page.query_selector_all(sel)
                if items:
                    print(f"  📊 找到 {len(items)} 个候选")
                    break

            if not items:
                page.screenshot(path=os.path.expanduser("~/Downloads/dianping_debug.png"))
                print("  ⚠️ 未找到列表，截图: ~/Downloads/dianping_debug.png")

            # 提取每家店的信息
            for item in items[:MAX_DIANPING_ITEMS]:
                try:
                    r = _parse_item(item)
                    if r:
                        restaurants.append(r)
                except Exception:
                    continue

            ctx.close()

    except Exception as e:
        print(f"  ❌ 大众点评出错: {e}")

    return restaurants


def _parse_item(item) -> DianpingRestaurant | None:
    """解析单个搜索结果项"""
    # 店名
    name = None
    for sel in DP_NAME_SELECTORS:
        el = item.query_selector(sel)
        if el:
            name = el.inner_text().strip()
            if name:
                break
    if not name:
        link = item.query_selector('a[href*="/shop/"]')
        if link:
            name = link.inner_text().strip()
    if not name or len(name) < 2:
        return None

    # 评分
    rating = 0.0
    for sel in DP_SCORE_SELECTORS:
        el = item.query_selector(sel)
        if el:
            cls = el.get_attribute('class') or ''
            m = re.search(r'star[_-]?(\d+)', cls)
            if m:
                rating = int(m.group(1)) / 10.0
                break
            txt = el.inner_text().strip()
            m = re.search(r'(\d+\.?\d*)', txt)
            if m:
                rating = float(m.group(1))
                break

    # 评论数
    review_count = 0
    for sel in DP_REVIEW_SELECTORS:
        el = item.query_selector(sel)
        if el:
            m = re.search(r'(\d+)', el.inner_text())
            if m:
                review_count = int(m.group(1))
                break

    # 人均价格
    price_range = ""
    for sel in DP_PRICE_SELECTORS:
        el = item.query_selector(sel)
        if el:
            txt = el.inner_text().strip()
            m = re.search(r'¥?\d+', txt)
            if m:
                price_range = txt
                break

    # 地址
    address = ""
    for sel in DP_ADDR_SELECTORS:
        el = item.query_selector(sel)
        if el:
            address = el.inner_text().strip()
            break

    # 标签
    tags = []
    for sel in DP_TAG_SELECTORS:
        els = item.query_selector_all(sel)
        if els:
            tags = [e.inner_text().strip() for e in els[:5] if e.inner_text().strip()]
            break

    # URL
    url = ""
    link = item.query_selector('a[href*="/shop/"]')
    if link:
        href = link.get_attribute('href') or ''
        url = href if href.startswith('http') else f"https://www.dianping.com{href}"

    return DianpingRestaurant(
        name=name, rating=rating, review_count=review_count,
        price_range=price_range, address=address, tags=tags, url=url,
    )


path: scripts/fetch_dianping_real.py
content: """
Real Dianping scraper using Playwright with persistent sessions.
"""

import asyncio
import re
from typing import List, Dict, Optional
from dataclasses import dataclass
from playwright.async_api import async_playwright, Page, BrowserContext

from session_manager import BrowserSessionManager
from fetch_dianping import DianpingRestaurant


class DianpingRealFetcher:
    """Fetch real restaurant data from Dianping using Playwright."""

    def __init__(self, config: Dict):
        self.config = config
        self.session_manager = BrowserSessionManager()
        self.base_url = "https://www.dianping.com"

    async def search(self, location: str, cuisine: str, min_rating: float = 4.0) -> List[DianpingRestaurant]:
        """
        Search for restaurants by location and cuisine.

        Args:
            location: Geographic area (e.g., "上海静安区")
            cuisine: Cuisine type (e.g., "日式料理")
            min_rating: Minimum rating to include

        Returns:
            List of DianpingRestaurant objects
        """
        # Ensure session is valid
        await self.session_manager.refresh_session_if_needed("dianping")

        # Get browser context with persistent session
        async with async_playwright() as p:
            context = await p.chromium.launch_persistent_context(
                user_data_dir=str(self.session_manager.dianping_session_dir),
                headless=True,
                viewport={'width': 1280, 'height': 720}
            )

            try:
                # Build search URL
                search_url = self._build_search_url(location, cuisine)

                # Navigate to search page
                page = await context.new_page()
                await page.goto(search_url, wait_until='networkidle')

                # Wait for page to load
                await asyncio.sleep(3)

                # Extract restaurant data
                restaurants = await self._extract_restaurants(page)

                # Filter by rating
                filtered = [r for r in restaurants if r.rating >= min_rating]

                return filtered

            finally:
                await context.close()

    def _build_search_url(self, location: str, cuisine: str) -> str:
        """Build Dianping search URL."""
        # Simple search URL construction
        # In production, you'd need to encode parameters properly
        # and handle location-specific URLs
        return f"{self.base_url}/search/keyword/{cuisine}/region/{self._encode_location(location)}"

    def _encode_location(self, location: str) -> str:
        """Encode location for URL."""
        # Remove common prefixes/suffixes
        location = location.replace("市", "").replace("区", "")
        return location

    async def _extract_restaurants(self, page: Page) -> List[DianpingRestaurant]:
        """Extract restaurant data from page."""
        restaurants = []

        try:
            # Wait for restaurant list to load
            await page.wait_for_selector('.shop-list', timeout=10000)

            # Get all restaurant items
            shop_items = await page.query_selector_all('.shop-list .shop-item')

            for item in shop_items[:20]:  # Limit to first 20
                try:
                    restaurant = await self._parse_restaurant_item(item, page)
                    if restaurant:
                        restaurants.append(restaurant)
                except Exception as e:
                    print(f"⚠️ 解析餐厅失败: {e}")
                    continue

        except Exception as e:
            print(f"⚠️ 提取餐厅列表失败: {e}")
            return []  # 返回空列表，让上层明确知道抓取失败

        return restaurants

    async def _parse_restaurant_item(self, item, page: Page) -> Optional[DianpingRestaurant]:
        """Parse a single restaurant item."""
        try:
            # Extract name
            name_elem = await item.query_selector('.shop-name a')
            name = await name_elem.inner_text() if name_elem else "未知餐厅"

            # Extract rating
            rating_elem = await item.query_selector('.comment-star')
            rating_text = await rating_elem.get_attribute('title') if rating_elem else "0"
            rating = self._parse_rating(rating_text)

            # Extract review count
            review_elem = await item.query_selector('.review-count')
            review_text = await review_elem.inner_text() if review_elem else "0"
            review_count = self._parse_review_count(review_text)

            # Extract price range
            price_elem = await item.query_selector('.price')
            price_text = await price_elem.inner_text() if price_elem else ""
            price_range = self._parse_price_range(price_text)

            # Extract address
            addr_elem = await item.query_selector('.shop-address')
            address = await addr_elem.inner_text() if addr_elem else ""

            # Extract tags
            tag_elems = await item.query_selector_all('.shop-tag')
            tags = []
            for tag_elem in tag_elems[:5]:
                tag_text = await tag_elem.inner_text()
                tags.append(tag_text.strip())

            # Get URL
            url_elem = await item.query_selector('.shop-name a')
            url = await url_elem.get_attribute('href') if url_elem else ""
            if url and not url.startswith('http'):
                url = self.base_url + url

            return DianpingRestaurant(
                name=name.strip(),
                rating=rating,
                review_count=review_count,
                price_range=price_range,
                address=address.strip(),
                tags=tags,
                url=url
            )

        except Exception as e:
            print(f"⚠️ 解析餐厅详情失败: {e}")
            return None

    def _parse_rating(self, rating_text: str) -> float:
        """Parse rating from text."""
        match = re.search(r'(\d+\.?\d*)', rating_text)
        if match:
            return float(match.group(1))
        return 0.0

    def _parse_review_count(self, review_text: str) -> int:
        """Parse review count from text."""
        # Remove non-digit characters
        match = re.search(r'(\d+)', review_text.replace(',', ''))
        if match:
            return int(match.group(1))
        return 0

    def _parse_price_range(self, price_text: str) -> str:
        """Parse price range from text."""
        # Extract numbers like "¥200-300"
        match = re.search(r'¥?\d+(?:-\d+)?', price_text)
        if match:
            return match.group(0)
        return "未知"

    def _get_fallback_data(self, location: str, cuisine: str) -> List[DianpingRestaurant]:
        """Fallback mock data when scraping fails."""
        return [
            DianpingRestaurant(
                name=f"{cuisine}示例店A",
                rating=4.5,
                review_count=500,
                price_range="¥150-200",
                address=f"{location}示例路123号",
                tags=["美味", "环境好"],
                url=f"{self.base_url}/shop/123"
            )
        ]


async def fetch_dianping_real(location: str, cuisine: str, config: Dict) -> List[DianpingRestaurant]:
    """Convenience function to fetch Dianping data with real scraping."""
    fetcher = DianpingRealFetcher(config)
    return await fetcher.search(location, cuisine)


path: scripts/fetch_xiaohongshu.py
content: """
小红书抓取 — sync Playwright 版本
基于原始 fetch_xiaohongshu_real.py 重构，修复 async→sync，改进店名提取
"""
import os
import re
import time
from typing import List
from urllib.parse import quote

from playwright.sync_api import sync_playwright

from config import (
    XHS_SESSION, BROWSER_ARGS,
    PAGE_LOAD_WAIT, SCROLL_WAIT, MAX_XHS_NOTES,
    XHS_CARD_SELECTORS,
    POSITIVE_WORDS, NEGATIVE_WORDS, NEGATION_WORDS,
    SHOP_SUFFIXES, EXCLUDE_KEYWORDS,
)
from models import XiaohongshuPost


def fetch_xiaohongshu(location: str, cuisine: str) -> List[XiaohongshuPost]:
    """
    从小红书抓取餐厅相关笔记

    Args:
        location: 地理位置，如 "深圳南山区"
        cuisine: 菜系/类型，如 "粤菜"

    Returns:
        XiaohongshuPost 列表（已按餐厅名去重合并）
    """
    raw_posts = []

    try:
        with sync_playwright() as p:
            os.makedirs(XHS_SESSION, exist_ok=True)
            ctx = p.chromium.launch_persistent_context(
                XHS_SESSION, headless=False, args=BROWSER_ARGS,
            )
            page = ctx.new_page()

            keyword = f"{location} {cuisine} 推荐"
            url = (
                f"https://www.xiaohongshu.com/search_result?"
                f"keyword={quote(keyword)}&source=web_search_result_notes"
            )
            print(f"  🔗 小红书: {url}")
            page.goto(url, timeout=30000, wait_until="domcontentloaded")
            time.sleep(PAGE_LOAD_WAIT + 1)

            # 如果被重定向，用搜索框
            if "/search_result" not in page.url:
                print("  ⚠️ 被重定向，尝试搜索框...")
                page.goto("https://www.xiaohongshu.com", timeout=30000)
                time.sleep(3)
                box = page.query_selector(
                    'input[placeholder*="搜索"], #search-input, input[type="text"]'
                )
                if box:
                    box.click()
                    box.fill(keyword)
                    time.sleep(0.5)
                    page.keyboard.press("Enter")
                    time.sleep(PAGE_LOAD_WAIT + 1)

            # 滚动加载更多
            for _ in range(3):
                page.evaluate("window.scrollBy(0, 600)")
                time.sleep(SCROLL_WAIT)

            # 抓取笔记卡片
            cards = []
            for sel in XHS_CARD_SELECTORS:
                cards = page.query_selector_all(sel)
                if cards:
                    break
            print(f"  📊 找到 {len(cards)} 篇笔记")

            if not cards:
                page.screenshot(path=os.path.expanduser("~/Downloads/xhs_debug.png"))
                print("  ⚠️ 未找到笔记，截图: ~/Downloads/xhs_debug.png")

            # 从笔记标题提取店名
            seen_names = set()
            for card in cards[:MAX_XHS_NOTES]:
                try:
                    title_el = card.query_selector('.title, [class*="title"], span, .desc')
                    title = title_el.inner_text().strip() if title_el else card.inner_text().strip()
                    if not title:
                        continue

                    # 提取点赞数
                    likes = _parse_likes(card)

                    # 提取店名
                    names = extract_restaurant_names(title)
                    sentiment = analyze_sentiment(title)
                    keywords = extract_keywords(title)

                    for name in names:
                        if name not in seen_names:
                            seen_names.add(name)
                            raw_posts.append(XiaohongshuPost(
                                restaurant_name=name,
                                likes=likes,
                                saves=0,
                                comments=0,
                                sentiment_score=sentiment,
                                keywords=keywords,
                                mention_count=1,
                            ))

                except Exception:
                    continue

            ctx.close()

    except Exception as e:
        print(f"  ❌ 小红书出错: {e}")

    # 合并同名餐厅
    return _merge_posts(raw_posts)


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  辅助函数
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def _parse_likes(card) -> int:
    """从卡片元素中提取点赞数"""
    like_el = card.query_selector('[class*="like"], [class*="count"]')
    if not like_el:
        return 0
    m = re.search(r'(\d+\.?\d*)\s*(万|k)?', like_el.inner_text())
    if not m:
        return 0
    likes = float(m.group(1))
    if m.group(2) in ('万', 'k'):
        likes *= 10000
    return int(likes)


def extract_restaurant_names(text: str) -> List[str]:
    """
    从笔记标题中提取餐厅名（基于原始 fetch_xiaohongshu_real.py 的逻辑，大幅增强）
    """
    names = []

    # 1. 书名号 / 引号内的名称（小红书最常见的格式）
    for pattern in [r'「(.+?)」', r'『(.+?)』', r'《(.+?)》', r'【(.+?)】',
                    r'\[(.+?)\]', r'"(.+?)"', r'"(.+?)"']:
        names.extend(re.findall(pattern, text))

    # 2. 探店/打卡模式: "探店XXX"、"打卡XXX"（来自原始 _extract_restaurant_name）
    explore_match = re.search(
        r'(?:探店|打卡|安利|推荐|种草)[：:\s]*(.{2,20}?)(?:[！!,，。\s]|太|超|真|$)',
        text
    )
    if explore_match:
        names.append(explore_match.group(1).strip())

    # 3. 包含餐饮后缀的词组
    suffix_pattern = '|'.join(re.escape(s) for s in SHOP_SUFFIXES)
    names.extend(re.findall(
        rf'([\u4e00-\u9fff\w]{{2,12}}(?:{suffix_pattern}))',
        text
    ))

    # 4. 分隔符分割的片段（如 "店名A｜店名B｜探店"）
    if any(sep in text for sep in ['|', '｜', '/', '·']):
        for part in re.split(r'[|｜/·]', text):
            part = part.strip()
            if 2 <= len(part) <= 15 and not any(k in part for k in EXCLUDE_KEYWORDS):
                names.append(part)

    # 去重保序，过滤太短的
    seen = set()
    result = []
    for n in names:
        n = n.strip()
        # 去除 emoji
        n = re.sub(r'[💕🔥✨🌟📍❗🍜🍲🥘🍱🍣🍻☕🎉👍💯]+', '', n).strip()
        if len(n) >= 2 and n not in seen:
            seen.add(n)
            result.append(n)
    return result


def analyze_sentiment(text: str) -> float:
    """
    情感分析（基于原始 fetch_xiaohongshu_real.py 的逻辑，带否定词处理）
    返回 -1 ~ 1
    """
    pos_count, neg_count = 0, 0

    for word in POSITIVE_WORDS:
        idx = text.find(word)
        while idx >= 0:
            context = text[max(0, idx - 2):idx]
            if any(context.endswith(n) for n in NEGATION_WORDS):
                neg_count += 1
            else:
                pos_count += 1
            idx = text.find(word, idx + len(word))

    for word in NEGATIVE_WORDS:
        idx = text.find(word)
        while idx >= 0:
            context = text[max(0, idx - 2):idx]
            if any(context.endswith(n) for n in NEGATION_WORDS):
                pos_count += 1  # 双重否定 = 正面
            else:
                neg_count += 1
            idx = text.find(word, idx + len(word))

    total = pos_count + neg_count
    if total == 0:
        return 0.0
    return (pos_count - neg_count) / total


def extract_keywords(text: str) -> List[str]:
    """从文本中提取关键词"""
    keyword_list = [
        '好吃', '美味', '环境', '服务', '性价比', '新鲜', '正宗', '值得',
        '推荐', '宝藏', '排队', '回头客', '惊艳',
    ]
    return [kw for kw in keyword_list if kw in text][:5]


def _merge_posts(posts: List[XiaohongshuPost]) -> List[XiaohongshuPost]:
    """合并同名餐厅的帖子"""
    merged = {}
    for p in posts:
        name = p.restaurant_name
        if name in merged:
            m = merged[name]
            m.mention_count += 1
            m.likes += p.likes
            # 取各篇情感的平均值
            m.sentiment_score = (m.sentiment_score * (m.mention_count - 1) + p.sentiment_score) / m.mention_count
            # 合并关键词
            for kw in p.keywords:
                if kw not in m.keywords:
                    m.keywords.append(kw)
        else:
            merged[name] = XiaohongshuPost(
                restaurant_name=name,
                likes=p.likes, saves=p.saves, comments=p.comments,
                sentiment_score=p.sentiment_score,
                keywords=list(p.keywords),
                mention_count=1,
            )
    return list(merged.values())


path: scripts/fetch_xiaohongshu_real.py
content: """
Real Xiaohongshu scraper using Playwright with persistent sessions.
"""

import asyncio
import re
from typing import List, Dict, Optional
from dataclasses import dataclass
from playwright.async_api import async_playwright, Page, BrowserContext

from session_manager import BrowserSessionManager
from fetch_xiaohongshu import XiaohongshuPost


class XiaohongshuRealFetcher:
    """Fetch real restaurant data from Xiaohongshu using Playwright."""

    def __init__(self, config: Dict):
        self.config = config
        self.session_manager = BrowserSessionManager()
        self.base_url = "https://www.xiaohongshu.com"

    async def search(self, location: str, cuisine: str, min_notes: int = 20) -> List[XiaohongshuPost]:
        """
        Search for restaurant posts by location and cuisine.

        Args:
            location: Geographic area (e.g., "上海静安区")
            cuisine: Cuisine type (e.g., "日式料理")
            min_notes: Minimum number of notes/posts to include

        Returns:
            List of aggregated XiaohongshuPost objects
        """
        # Ensure session is valid
        await self.session_manager.refresh_session_if_needed("xiaohongshu")

        # Get browser context with persistent session
        async with async_playwright() as p:
            context = await p.chromium.launch_persistent_context(
                user_data_dir=str(self.session_manager.xhs_session_dir),
                headless=True,
                viewport={'width': 1280, 'height': 720},
                user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15'
            )

            try:
                # Build search URL
                search_url = self._build_search_url(location, cuisine)

                # Navigate to search page
                page = await context.new_page()
                await page.goto(search_url, wait_until='networkidle')

                # Wait for page to load
                await asyncio.sleep(5)  # Xiaohongshu needs more time

                # Extract post data
                posts = await self._extract_posts(page)

                # Aggregate posts by restaurant name
                aggregated = self._aggregate_by_restaurant(posts)

                # Filter by minimum notes
                filtered = {name: data for name, data in aggregated.items()
                           if len(data['posts']) >= min_notes}

                # Convert to list of aggregated posts
                result = []
                for name, data in filtered.items():
                    avg_post = self._aggregate_post_data(data['posts'])
                    avg_post.restaurant_name = name
                    result.append(avg_post)

                return result

            finally:
                await context.close()

    def _build_search_url(self, location: str, cuisine: str) -> str:
        """Build Xiaohongshu search URL."""
        from urllib.parse import quote
        query = f"{location} {cuisine}"
        return f"{self.base_url}/search_result?keyword={quote(query)}"

    async def _extract_posts(self, page: Page) -> List[XiaohongshuPost]:
        """Extract post data from page."""
        posts = []

        try:
            # Wait for search results to load
            await page.wait_for_selector('.note-item', timeout=15000)

            # Get all note items
            note_items = await page.query_selector_all('.note-item')

            for item in note_items[:30]:  # Limit to first 30
                try:
                    post = await self._parse_post_item(item)
                    if post and self._is_restaurant_post(post):
                        posts.append(post)
                except Exception as e:
                    print(f"⚠️ 解析笔记失败: {e}")
                    continue

        except Exception as e:
            print(f"⚠️ 提取笔记列表失败: {e}")
            # Return empty list on failure
            return []

        return posts

    async def _parse_post_item(self, item) -> Optional[XiaohongshuPost]:
        """Parse a single post item."""
        try:
            # Extract title (may contain restaurant name)
            title_elem = await item.query_selector('.title')
            title = await title_elem.inner_text() if title_elem else ""

            # Extract engagement metrics
            likes_elem = await item.query_selector('.like-count')
            likes_text = await likes_elem.inner_text() if likes_elem else "0"
            likes = self._parse_count(likes_text)

            saves_elem = await item.query_selector('.collect-count')
            saves_text = await saves_elem.inner_text() if saves_elem else "0"
            saves = self._parse_count(saves_text)

            comments_elem = await item.query_selector('.comment-count')
            comments_text = await comments_elem.inner_text() if comments_elem else "0"
            comments = self._parse_count(comments_text)

            # Extract URL
            url_elem = await item.query_selector('a')
            url = await url_elem.get_attribute('href') if url_elem else ""
            if url and not url.startswith('http'):
                url = self.base_url + url

            # Extract restaurant name from title
            restaurant_name = self._extract_restaurant_name(title)

            # Simple sentiment analysis based on title
            sentiment_score = self._analyze_sentiment(title)

            # Extract keywords from title
            keywords = self._extract_keywords(title)

            return XiaohongshuPost(
                restaurant_name=restaurant_name,
                likes=likes,
                saves=saves,
                comments=comments,
                sentiment_score=sentiment_score,
                keywords=keywords,
                url=url
            )

        except Exception as e:
            print(f"⚠️ 解析笔记详情失败: {e}")
            return None

    def _parse_count(self, count_text: str) -> int:
        """Parse count from text (handles 1.2万, etc)."""
        count_text = count_text.strip()
        if '万' in count_text:
            match = re.search(r'([\d.]+)万', count_text)
            if match:
                return int(float(match.group(1)) * 10000)
        match = re.search(r'(\d+)', count_text.replace(',', ''))
        if match:
            return int(match.group(1))
        return 0

    def _extract_restaurant_name(self, title: str) -> str:
        """Extract restaurant name from post title."""
        name = title.strip()

        # 去除 emoji
        name = re.sub(r'[💕🔥✨🌟📍❗🍜🍲🥘🍱🍣🍻☕🎉👍💯]+', '', name)

        # 尝试提取「」『』【】内的店名（小红书常见格式）
        bracket_match = re.search(r'[「『【](.+?)[」』】]', name)
        if bracket_match:
            return bracket_match.group(1).strip()

        # 尝试提取"探店XXX"、"打卡XXX"模式中的店名
        explore_match = re.search(r'(?:探店|打卡|安利|推荐|种草)[：:\s]*(.{2,20}?)(?:[！!,，。\s]|太|超|真|$)', name)
        if explore_match:
            return explore_match.group(1).strip()

        # 去除结尾的感叹语
        name = re.sub(r'(?:太好吃了|超赞|强烈推荐|必吃|绝绝子|yyds|YYDS).{0,5}$', '', name)

        # 如果标题较短（可能本身就是店名），直接返回
        if len(name) <= 15:
            return name.strip()

        # 标题较长时取前半部分（通常店名在前面）
        # 按常见分隔符截断
        for sep in ['|', '｜', '/', '-', '—', ',', '，', '!', '！']:
            if sep in name:
                return name.split(sep)[0].strip()

        return name.strip()

    def _is_restaurant_post(self, post: XiaohongshuPost) -> bool:
        """Check if post is about a restaurant."""
        name = post.restaurant_name
        if not name or len(name) < 2:
            return False

        # 包含食物/餐饮相关关键词
        food_keywords = [
            '店', '餐厅', '餐馆', '料理', '火锅', '烤肉', '日料', '西餐',
            '咖啡', '奶茶', '面馆', '酒楼', '饭店', '食堂', '小吃',
            '烧烤', '串串', '麻辣', '披萨', '汉堡', '寿司', '拉面',
            '粤菜', '川菜', '湘菜', '东北菜', '本帮菜'
        ]

        # 检查名字或关键词中是否有食物相关词
        text_to_check = name + ' '.join(post.keywords)
        return any(kw in text_to_check for kw in food_keywords)

    def _analyze_sentiment(self, text: str) -> float:
        """Sentiment analysis with negation handling."""
        positive_keywords = ['好吃', '美味', '推荐', '值得', '喜欢', '棒', '赞', '完美', '正宗']
        negative_keywords = ['难吃', '失望', '差评', '不推荐', '避雷', '踩雷', '差']
        negation_prefixes = ['不', '没', '别', '非', '无', '未']

        positive_count = 0
        negative_count = 0

        for kw in positive_keywords:
            idx = text.find(kw)
            while idx >= 0:
                # 检查前面是否有否定词
                prefix = text[max(0, idx - 2):idx]
                if any(prefix.endswith(neg) for neg in negation_prefixes):
                    negative_count += 1  # "不好吃" → 负面
                else:
                    positive_count += 1
                idx = text.find(kw, idx + len(kw))

        for kw in negative_keywords:
            idx = text.find(kw)
            while idx >= 0:
                # "不差" → 正面（双重否定）
                prefix = text[max(0, idx - 2):idx]
                if any(prefix.endswith(neg) for neg in negation_prefixes):
                    positive_count += 1
                else:
                    negative_count += 1
                idx = text.find(kw, idx + len(kw))

        total = positive_count + negative_count
        if total == 0:
            return 0.0

        return (positive_count - negative_count) / total

    def _extract_keywords(self, text: str) -> List[str]:
        """Extract keywords from text."""
        keywords = []
        keyword_list = ['好吃', '美味', '环境', '服务', '性价比', '新鲜', '正宗', '值得']
        for kw in keyword_list:
            if kw in text:
                keywords.append(kw)
        return keywords[:5]

    def _aggregate_by_restaurant(self, posts: List[XiaohongshuPost]) -> Dict[str, Dict]:
        """Group posts by restaurant name."""
        aggregated = {}
        for post in posts:
            if post.restaurant_name not in aggregated:
                aggregated[post.restaurant_name] = {'posts': []}
            aggregated[post.restaurant_name]['posts'].append(post)
        return aggregated

    def _aggregate_post_data(self, posts: List[XiaohongshuPost]) -> XiaohongshuPost:
        """Calculate average metrics from multiple posts."""
        if not posts:
            return XiaohongshuPost(
                restaurant_name="",
                likes=0, saves=0, comments=0,
                sentiment_score=0.0, keywords=[], url=""
            )

        total_posts = len(posts)
        avg_likes = sum(p.likes for p in posts) // total_posts
        avg_saves = sum(p.saves for p in posts) // total_posts
        avg_comments = sum(p.comments for p in posts) // total_posts
        avg_sentiment = sum(p.sentiment_score for p in posts) / total_posts

        # Combine keywords
        all_keywords = []
        for post in posts:
            all_keywords.extend(post.keywords)
        # Get top keywords
        keyword_counts = {}
        for kw in all_keywords:
            keyword_counts[kw] = keyword_counts.get(kw, 0) + 1
        top_keywords = sorted(keyword_counts.items(), key=lambda x: x[1], reverse=True)[:5]
        top_keywords_list = [kw for kw, count in top_keywords]

        return XiaohongshuPost(
            restaurant_name="",  # Will be set by caller
            likes=avg_likes,
            saves=avg_saves,
            comments=avg_comments,
            sentiment_score=avg_sentiment,
            keywords=top_keywords_list,
            url=posts[0].url  # First post URL
        )


async def fetch_xiaohongshu_real(location: str, cuisine: str, config: Dict) -> List[XiaohongshuPost]:
    """Convenience function to fetch Xiaohongshu data with real scraping."""
    fetcher = XiaohongshuRealFetcher(config)
    return await fetcher.search(location, cuisine)


path: scripts/match_restaurants.py
content: """
跨平台餐厅匹配 — 基于原始 match_restaurants.py
保留: thefuzz 模糊匹配、多策略匹配、连锁店后缀处理、一致性评分
"""
import math
import re
from typing import List, Dict

from models import DianpingRestaurant, XiaohongshuPost, MatchedRestaurant

# thefuzz 是可选依赖，没装时回退到简单匹配
try:
    from thefuzz import fuzz
    HAS_FUZZ = True
except ImportError:
    HAS_FUZZ = False
    print("💡 提示: 安装 thefuzz 可获得更好的匹配效果: pip3 install thefuzz")


# 连锁店常见后缀（来自原始 match_restaurants.py）
CHAIN_SUFFIXES = re.compile(
    r'[（(].{0,15}[)）]|'
    r'(静安|徐汇|浦东|朝阳|海淀|南山|福田|天河|武侯|锦江|南油|华强|科技园)'
    r'(店|分店|旗舰店|总店)?$'
)


def normalize_name(name: str) -> str:
    """标准化餐厅名：去除分店后缀、空格、特殊符号"""
    name = name.strip()
    name = CHAIN_SUFFIXES.sub('', name)
    name = re.sub(r'[\s·・\-—]+', '', name)
    return name


def calculate_similarity(dp_name: str, xhs_name: str) -> float:
    """
    计算两个店名的相似度（0~1）
    使用多策略匹配（来自原始 match_restaurants.py._calculate_similarity）
    """
    dp_norm = normalize_name(dp_name)
    xhs_norm = normalize_name(xhs_name)

    if not dp_norm or not xhs_norm:
        return 0.0

    # 策略1: 完全匹配
    if dp_norm == xhs_norm:
        return 1.0

    if HAS_FUZZ:
        # 策略2: 精确比率
        exact_score = fuzz.ratio(dp_norm, xhs_norm) / 100
        # 策略3: 部分匹配
        partial_score = fuzz.partial_ratio(dp_norm, xhs_norm) / 100
        # 策略4: Token 排序
        token_score = fuzz.token_sort_ratio(dp_norm, xhs_norm) / 100
    else:
        # 简单 Jaccard 相似度作为回退
        s1, s2 = set(dp_norm), set(xhs_norm)
        exact_score = len(s1 & s2) / len(s1 | s2) if (s1 | s2) else 0
        partial_score = 0
        token_score = 0

    # 策略5: 包含关系
    containment_score = 0.0
    if dp_norm in xhs_norm or xhs_norm in dp_norm:
        shorter = min(len(dp_norm), len(xhs_norm))
        longer = max(len(dp_norm), len(xhs_norm))
        containment_score = shorter / longer if longer > 0 else 0.0

    # 取最优策略
    return max(
        exact_score,
        partial_score * 0.90,
        token_score * 0.85,
        containment_score * 0.88,
    )


def normalize_engagement(xhs_post: XiaohongshuPost) -> float:
    """
    将小红书互动量归一化到 0-5 评分（来自原始 match_restaurants.py）
    使用对数归一化避免极端值影响
    """
    engagement = (
        xhs_post.likes * 1.0 +
        xhs_post.saves * 2.0 +
        xhs_post.comments * 1.5
    )

    if engagement <= 0:
        return 0.0

    # log1p(5000) ≈ 8.52 作为"满分"参考点
    normalized = math.log1p(engagement) / math.log1p(5000) * 5
    return max(0.0, min(5.0, normalized))


def calculate_consistency(
    dp_rating: float,
    xhs_engagement_normalized: float,
    xhs_sentiment: float,
) -> float:
    """
    计算两平台一致性评分 0~1（来自原始 match_restaurants.py）
    """
    dp_rating = max(0.0, min(5.0, dp_rating))
    xhs_engagement_normalized = max(0.0, min(5.0, xhs_engagement_normalized))
    xhs_sentiment = max(-1.0, min(1.0, xhs_sentiment))

    # 评分相关性
    rating_diff = abs(dp_rating - xhs_engagement_normalized)
    rating_correlation = max(0.0, 1.0 - (rating_diff / 2.5))

    # 情感一致性
    sentiment_normalized = (xhs_sentiment + 1) / 2  # -1~1 → 0~1
    sentiment_alignment = sentiment_normalized

    return max(0.0, min(1.0, rating_correlation * 0.6 + sentiment_alignment * 0.4))


def match_and_score(
    dp_restaurants: List[DianpingRestaurant],
    xhs_posts: List[XiaohongshuPost],
    similarity_threshold: float = 0.55,
) -> List[MatchedRestaurant]:
    """
    跨平台匹配并计算一致性评分

    Args:
        dp_restaurants: 大众点评数据
        xhs_posts: 小红书数据
        similarity_threshold: 匹配阈值

    Returns:
        匹配结果列表，按一致性排序
    """
    matches = []
    used_xhs = set()

    for dp in dp_restaurants:
        best_idx, best_score = None, 0

        for idx, xhs in enumerate(xhs_posts):
            if idx in used_xhs:
                continue
            score = calculate_similarity(dp.name, xhs.restaurant_name)
            if score > best_score and score >= similarity_threshold:
                best_score = score
                best_idx = idx

        if best_idx is not None:
            xhs = xhs_posts[best_idx]
            used_xhs.add(best_idx)

            # 计算一致性
            xhs_engagement_norm = normalize_engagement(xhs)
            consistency = calculate_consistency(
                dp.rating, xhs_engagement_norm, xhs.sentiment_score
            )

            matches.append(MatchedRestaurant(
                name=dp.name,
                dianping_data=dp,
                xhs_data=xhs,
                similarity_score=best_score,
                consistency_score=consistency,
            ))

    return matches


path: scripts/models.py
content: """
数据模型 — 整合自原始 fetch_dianping.py / fetch_xiaohongshu.py / crosscheck_base.py
"""
from typing import List
from dataclasses import dataclass, field


@dataclass
class DianpingRestaurant:
    """大众点评餐厅数据（来自原始 fetch_dianping.py）"""
    name: str
    rating: float = 0.0
    review_count: int = 0
    price_range: str = ""
    address: str = ""
    tags: List[str] = field(default_factory=list)
    url: str = ""


@dataclass
class XiaohongshuPost:
    """小红书帖子数据（来自原始 fetch_xiaohongshu.py）"""
    restaurant_name: str
    likes: int = 0
    saves: int = 0
    comments: int = 0
    sentiment_score: float = 0.0
    keywords: List[str] = field(default_factory=list)
    url: str = ""
    mention_count: int = 1


@dataclass
class MatchedRestaurant:
    """跨平台匹配结果（来自原始 match_restaurants.py）"""
    name: str
    dianping_data: DianpingRestaurant
    xhs_data: XiaohongshuPost
    similarity_score: float = 0.0
    consistency_score: float = 0.0


@dataclass
class RecommendationResult:
    """最终推荐结果（来自原始 crosscheck_base.py）"""
    name: str
    dianping_rating: float = 0.0
    dianping_reviews: int = 0
    dianping_tags: List[str] = field(default_factory=list)
    dianping_address: str = ""
    dianping_price: str = ""
    xhs_engagement_display: str = ""
    xhs_keywords: List[str] = field(default_factory=list)
    recommendation_score: float = 0.0  # 0-10
    consistency_level: str = ""        # "高", "中", "低"
    consistency_score: float = 0.0     # 0-1
    similarity_score: float = 0.0      # 0-1


path: scripts/requirements.txt
content: # Restaurant Review Cross-Check Skill Dependencies

# Web scraping
requests>=2.31.0
beautifulsoup4>=4.12.0
lxml>=4.9.0

# Browser automation (for real data fetching)
playwright>=1.40.0

# Data processing
pandas>=2.0.0
numpy>=1.24.0

# Fuzzy matching
thefuzz>=0.3.1

# HTTP requests with retries
urllib3>=2.0.0

# Optional: Proxy rotation
# requests-socks>=0.1.0


path: scripts/session_manager.py
content: #!/usr/bin/env python3
"""
浏览器会话管理器 — 基于原始 session_manager.py
核心修复: async_playwright → sync_playwright（解决 'coroutine' object has no attribute 'goto'）
"""
import json
import os
import sys
import time
import shutil
from pathlib import Path
from typing import Dict

from playwright.sync_api import sync_playwright

from config import SESSION_BASE, DIANPING_SESSION, XHS_SESSION, BROWSER_ARGS

# 会话过期时间: 7 天
SESSION_EXPIRY_SECONDS = 7 * 24 * 3600


class BrowserSessionManager:
    """管理浏览器持久化登录会话（sync 版本）"""

    def __init__(self, base_dir: str = None, session_expiry: int = None):
        self.base_dir = Path(base_dir or SESSION_BASE)
        self.base_dir.mkdir(parents=True, exist_ok=True)

        self.dianping_session_dir = Path(DIANPING_SESSION)
        self.xhs_session_dir = Path(XHS_SESSION)
        self.dianping_session_dir.mkdir(parents=True, exist_ok=True)
        self.xhs_session_dir.mkdir(parents=True, exist_ok=True)

        self.state_file = self.base_dir / "session_state.json"
        self.session_expiry = session_expiry or SESSION_EXPIRY_SECONDS

    # ── 状态持久化 ──────────────────────────────────────

    def load_state(self) -> Dict:
        if self.state_file.exists():
            with open(self.state_file, 'r') as f:
                return json.load(f)
        return {"dianping": {"logged_in": False}, "xiaohongshu": {"logged_in": False}}

    def save_state(self, state: Dict):
        with open(self.state_file, 'w') as f:
            json.dump(state, f, indent=2)

    # ── 会话检查 ────────────────────────────────────────

    def is_session_valid(self, platform: str) -> bool:
        """检查会话是否有效且未过期"""
        state = self.load_state()
        ps = state.get(platform, {})

        if not ps.get("logged_in", False):
            return False

        last_login = ps.get("last_login", 0)
        if isinstance(last_login, str):
            try:
                last_login = float(last_login)
            except (ValueError, TypeError):
                return False

        elapsed = time.time() - last_login
        if elapsed > self.session_expiry:
            hours = elapsed / 3600
            print(f"  ⚠️ {platform} 会话已过期（{hours:.0f}小时前登录）")
            ps["logged_in"] = False
            self.save_state(state)
            return False

        return True

    # ── 交互式登录（sync） ──────────────────────────────

    def login(self, platform: str):
        """交互式登录指定平台（弹出浏览器，手动登录后按回车）"""
        platforms = {
            "dianping": ("大众点评", "https://www.dianping.com", self.dianping_session_dir),
            "xiaohongshu": ("小红书", "https://www.xiaohongshu.com", self.xhs_session_dir),
        }

        if platform not in platforms:
            print(f"❌ 未知平台: {platform}，支持: {', '.join(platforms.keys())}")
            return False

        name, url, session_dir = platforms[platform]

        print(f"\n{'=' * 50}")
        print(f"🔐 登录{name}")
        print(f"{'=' * 50}")
        print(f"1. 浏览器会自动打开 {name}")
        print(f"2. 请手动登录（手机号或微信扫码）")
        print(f"3. 登录成功后回到终端按回车")
        print(f"{'=' * 50}\n")

        try:
            with sync_playwright() as p:
                ctx = p.chromium.launch_persistent_context(
                    str(session_dir), headless=False, args=BROWSER_ARGS,
                )
                page = ctx.pages[0] if ctx.pages else ctx.new_page()
                page.goto(url, timeout=30000)
                input(f"✅ 登录{name}后按回车保存会话...")
                ctx.close()

            # 标记已登录
            state = self.load_state()
            state[platform] = {"logged_in": True, "last_login": time.time()}
            self.save_state(state)
            print(f"💾 {name}会话已保存\n")
            return True

        except Exception as e:
            print(f"❌ 登录失败: {e}")
            return False

    def ensure_session(self, platform: str):
        """确保会话有效，过期则重新登录"""
        if not self.is_session_valid(platform):
            self.login(platform)

    # ── 重置 ────────────────────────────────────────────

    def reset(self):
        """重置所有会话"""
        for d in [self.dianping_session_dir, self.xhs_session_dir]:
            if d.exists():
                shutil.rmtree(d)
                d.mkdir(parents=True)
        if self.state_file.exists():
            self.state_file.unlink()
        print("✅ 所有会话已重置")


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  CLI 入口
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def main():
    manager = BrowserSessionManager()

    if "--reset" in sys.argv:
        manager.reset()
        return

    if len(sys.argv) > 1 and sys.argv[1] in ("dianping", "xiaohongshu", "all"):
        target = sys.argv[1]
        if target == "all":
            manager.login("dianping")
            manager.login("xiaohongshu")
        else:
            manager.login(target)
        return

    # 交互模式
    print("=" * 50)
    print("🚀 浏览器会话管理器")
    print("=" * 50)
    print()

    state = manager.load_state()
    dp_ok = state.get("dianping", {}).get("logged_in", False)
    xhs_ok = state.get("xiaohongshu", {}).get("logged_in", False)

    print(f"  大众点评: {'✅ 已登录' if dp_ok else '❌ 未登录'}")
    print(f"  小红书:   {'✅ 已登录' if xhs_ok else '❌ 未登录'}")
    print()

    if dp_ok and xhs_ok:
        print("✅ 所有平台已配置完成！")
        print("💡 如需重置: python3 session_manager.py --reset")
        return

    print("用法:")
    print("  python3 session_manager.py dianping      # 登录大众点评")
    print("  python3 session_manager.py xiaohongshu   # 登录小红书")
    print("  python3 session_manager.py all           # 登录全部")
    print("  python3 session_manager.py --reset       # 重置会话")


if __name__ == "__main__":
    main()


path: setup.sh
content: #!/bin/bash
set -e

echo ""
echo "🔧 本地生活交叉验证工具 — 一键安装"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""

# 检查 Python
if ! command -v python3 &> /dev/null; then
    echo "❌ 需要 Python 3.8+，请先安装"
    exit 1
fi
PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
echo "✅ Python ${PYTHON_VERSION}"

# 安装依赖
echo ""
echo "📦 安装 Python 依赖..."
pip3 install playwright thefuzz --break-system-packages 2>/dev/null || pip3 install playwright thefuzz

# 安装浏览器
echo ""
echo "🌐 安装 Chromium 浏览器（约 200MB）..."
python3 -m playwright install chromium

# 创建会话目录
mkdir -p ~/.local/share/restaurant-crosscheck/sessions/dianping
mkdir -p ~/.local/share/restaurant-crosscheck/sessions/xiaohongshu

echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ 安装完成！"
echo ""
echo "下一步："
echo "  cd scripts"
echo "  python3 session_manager.py all           # 登录平台"
echo "  python3 crosscheck.py '深圳南山区' '粤菜'  # 开始使用"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)