# 小红书爬取流程模拟分析

> 基于 Browser Skill 实际模拟测试，2026-01-31

## 一、关键发现与问题

### 1. 点击目标错误 ⚠️ 重大问题

**现象**：代码点击 `section.note-item` 或 `elem.click()` 无法打开弹窗

**正确做法**：必须点击 `a.cover` 链接才能打开弹窗

```javascript
// 错误 ❌
const note = document.querySelectorAll('section.note-item')[0];
note.click();  // 无效果！

// 正确 ✓
const cover = note.querySelector('a.cover');
cover.click();  // 成功打开弹窗
```

**原因分析**：
- `section.note-item` 是容器，没有点击事件
- `a.cover` 是封面图片的链接，带有正确的href和事件处理

### 2. 链接类型区别

笔记卡片内有多个链接，必须选择正确的：

| 链接类型 | 选择器 | href格式 | 点击效果 |
|---------|--------|----------|----------|
| `a.cover` | `section.note-item a.cover` | `/search_result/xxx?xsec_token=...` | ✅ 打开弹窗 |
| 其他`a` | `section.note-item a` | `/explore/xxx` | ❌ 页面跳转到404 |

### 3. 弹窗URL变化

- 点击前：`https://www.xiaohongshu.com/search_result?keyword=xxx`
- 弹窗打开后：`https://www.xiaohongshu.com/explore/笔记ID?xsec_token=...`
- 关闭弹窗后：恢复到 `search_result`

### 4. 关闭弹窗方式

**测试结果**：

| 方式 | 可靠性 | 说明 |
|------|--------|------|
| `history.back()` | ✅ 最可靠 | URL恢复到search_result，笔记列表保持 |
| `Escape键` | ⚠️ 不稳定 | 有时不起作用 |
| 点击关闭按钮 | ⚠️ 不稳定 | 按钮位置可能变化 |

### 5. 视频笔记与图文笔记区别

| 类型 | 标题选择器 | 内容位置 |
|------|-----------|----------|
| 图文笔记 | `.note-content .title` | `.note-text` |
| 视频笔记 | **无标题** | `.note-text` 直接存放内容 |

---

## 二、完整爬取流程

### 步骤1：导航到搜索页
```
URL: https://www.xiaohongshu.com/search_result?keyword=鞋子
等待: 2-3秒加载
```

### 步骤2：获取笔记列表
```javascript
const notes = document.querySelectorAll('section.note-item');
// 返回 14 个笔记
```

### 步骤3：获取位置并排序（瀑布流处理）
```javascript
// 三列布局：L=286(左), L=548(中), L=809(右)
// 按列分组，每列按top排序，然后交织合并
// 排序结果：[0, 1, 2, 4, 3, 5, 6, 7, 8, ...]
```

### 步骤4：点击笔记（必须用a.cover）
```javascript
const note = notes[idx];
const cover = note.querySelector('a.cover');
cover.click();
// 等待 0.5-1秒
```

### 步骤5：检查弹窗是否打开
```javascript
// URL变成 /explore/xxx 表示弹窗打开
const isPopup = window.location.href.includes('/explore/');
```

### 步骤6：提取数据
```javascript
// 标题（图文笔记）
const title = document.querySelector('.note-content .title')?.innerText;

// 内容（通用）
const content = document.querySelector('.note-text')?.innerText;

// 作者
const author = document.querySelector('.author-wrapper .name')?.innerText;

// 发布时间
const date = document.querySelector('.date')?.innerText;

// 互动数据
const likes = document.querySelector('.like-wrapper .count')?.innerText;
const collects = document.querySelector('.collect-wrapper .count')?.innerText;
const comments = document.querySelector('.chat-wrapper .count')?.innerText;
```

### 步骤7：关闭弹窗（用history.back）
```javascript
history.back();
// 等待 0.3-0.5秒
// 验证URL恢复到search_result
```

### 步骤8：验证返回成功
```javascript
const isBack = window.location.href.includes('search_result');
const noteCount = document.querySelectorAll('section.note-item').length;
// noteCount应该保持不变
```

### 步骤9：继续下一个笔记
重复步骤4-8

---

## 三、代码修复建议

### 修复1：点击目标改为a.cover
```python
# 之前
elem = elements[idx]
elem.click()

# 修复后
elem = elements[idx]
cover = elem.ele('css:a.cover', timeout=0.5)
if cover:
    cover.click()
else:
    elem.click()  # 降级方案
```

### 修复2：关闭弹窗改用history.back
```python
# 之前
page.actions.key_down('Escape').key_up('Escape')

# 修复后
page.run_js("history.back()")
time.sleep(0.3)
```

### 修复3：标题提取兼容视频笔记
```python
# 之前只查找.title
title = page.ele('css:.note-content .title', timeout=0.2)

# 修复后：如果没有标题，用内容第一行作为标题
title_el = page.ele('css:.note-content .title', timeout=0.2)
if title_el and title_el.text:
    title = title_el.text
else:
    # 视频笔记：用内容第一行
    content_el = page.ele('css:.note-text', timeout=0.2)
    if content_el and content_el.text:
        title = content_el.text.split('\n')[0][:50]
```

---

## 四、验证数据

### 测试笔记1（图文）
- 卡片位置：DOM索引0，左列第一个
- 卡片标题：简单日常的小鞋子
- 弹窗URL：`/explore/69311320000000001e029e37`
- 提取标题：简单日常的小鞋子 ✓
- 提取作者：吸溜溜溜吧唧 ✓
- 互动数据：👍1426 ⭐387 💬9 ✓

### 测试笔记2（视频）
- 卡片位置：DOM索引1，中列第一个
- 卡片标题：无
- 弹窗URL：`/explore/69251ec9000000001b0333aa`
- 提取标题：无（需用内容替代）
- 提取内容：这双鞋子真的经验到我了#袜子控... ✓
- 互动数据：👍288 ⭐90 💬1 ✓

---

## 五、数据提取优化（2026-02-02更新）

### 关键修复：使用URL中的noteId

小红书是SPA应用，`window.__INITIAL_STATE__` 会缓存多个笔记数据。直接使用 `currentNoteId` 可能获取到旧笔记的数据。

**正确做法**：从URL提取当前笔记ID

```javascript
// 从URL获取当前笔记ID（最可靠）
const urlMatch = window.location.href.match(/explore\/([a-zA-Z0-9]+)/);
const noteId = urlMatch ? urlMatch[1] : state.note.currentNoteId;

// 使用noteId获取对应数据
const noteData = state.note.noteDetailMap[noteId];
```

### 数据提取统一使用弹窗内选择器

```javascript
// 限定在弹窗容器内搜索
const modal = document.querySelector('.note-detail-mask, [class*="noteContainer"]');

// 标题
modal.querySelector('.title')

// 作者
modal.querySelector('.username')

// 互动数据
modal.querySelector('.buttons.engage-bar-style .like-wrapper .count')
modal.querySelector('.collect-wrapper .count')
modal.querySelector('.chat-wrapper .count')
```

### 等待页面更新

```python
# 点击笔记后，等待URL更新
for _ in range(10):
    current_url = page.url
    if note_id in current_url:
        break
    time.sleep(0.3)

# 等待图片轮播加载
for _ in range(5):
    if page.ele('css:.swiper-slide img', timeout=0.2):
        break
    time.sleep(0.3)
```

---

## 六、图片处理优化（2026-02-02更新）

### Live图过滤

Live图（动态图片）只保留一张静态版本：

```python
def _filter_live_images(image_urls):
    # 提取URL基础部分（去掉参数和live标记）
    base = re.sub(r'/live/', '/', url)
    base = re.sub(r'_live\d*', '', base)
    
    # 按基础URL分组，每组只保留一张
    # 优先级：不含live > .jpg/.png > 其他
```

### 表情包过滤

```python
def _is_emoji_image(url):
    # 关键词检测：emoji, sticker, emote
    # 尺寸检测：/w/120, imageView2/2/w/200
```

### 评论图片单独存储

```
note_folder/
├── img_1.jpg          # 笔记图片
├── video.mp4          # 视频
└── comments/          # 评论图片文件夹
    └── comment_img_1.jpg
```

---

## 七、更新日志

| 日期 | 版本 | 变更 |
|------|------|------|
| 2026-01-31 | v1.0 | 初始模拟测试，发现点击目标和关闭方式问题 |
| 2026-02-02 | v2.0 | 修复数据缓存问题，使用URL中的noteId |
| 2026-02-02 | v2.1 | 添加Live图过滤、评论图片单独存储 |
| 2026-02-02 | v2.2 | 优化GUI（斑马纹、排序、筛选、统计卡片） |
