원클릭으로
headless-scraping
PocketMind 本地无头浏览器爬取架构,适用于小红书/知乎抓取、后台爬虫服务、MethodChannel、Cookie 管理与 MetadataManager 协作场景。
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
PocketMind 本地无头浏览器爬取架构,适用于小红书/知乎抓取、后台爬虫服务、MethodChannel、Cookie 管理与 MetadataManager 协作场景。
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
Use when implementing or debugging PocketMind mobile chat flows, including note-scoped sessions, global multi-session switching, sync gating, stream send behavior, and dialog interaction regressions.
PocketMind 后端 notes->resource_records->context_catalog 一致性改造专项 Skill。当用户讨论 resource_records 真相层、context_catalog 索引层、Outbox/Projector、检索 fallback、SessionCommit 长事务、transcript 重复同步或相关回归测试时必须触发。
Use when you need to understand or modify PocketMind note save to resource indexing flow, including outbox, MQ hint/DLQ compensation, projector consumption, and consistency boundaries.
PocketMind 移动端笔记同步架构专项 Skill。当涉及以下问题时必须触发:预览字段(previewTitle等)被同步覆盖、多端一致性与离线冲突问题、同步链路改造(Pull/Push)、UI层违规调用底层Provider、抓取/轮询等写入未进入同步队列、或维护同步守卫测试。
设计、重构和实现 PocketMind 项目中的整体上下文架构(Context Architecture),用于统一规划和落地 resources、user memories、agent memories、tenant skills、session、retrieval、ingestion、storage 与现有 Note/Chat/Asset 的边界。当用户要求为 PocketMind 新增长期记忆、重构 AI 上下文体系、借鉴 OpenViking 的上下文类型/层级/URI/存储/检索/会话思想,或需要分阶段实施 Context Service 时使用。
Cache data in a Flutter app
SOC 직업 분류 기준
| name | headless-scraping |
| description | PocketMind 本地无头浏览器爬取架构,适用于小红书/知乎抓取、后台爬虫服务、MethodChannel、Cookie 管理与 MetadataManager 协作场景。 |
| metadata | {"version":"2.1.1","updated":"2026-02-14","tags":["scraper","爬虫","xhs","小红书","zhihu","知乎","抓取","Cookie","WebView","stealth","前台服务"]} |
⚠️ 调度链路已重构(2026-05)。本文档下面"架构概览 / 数据流 / 队列 / 重试"等 章节描述的是旧实现(SharedPreferences
needCallBackUrl队列 + retry count map), 仅作为历史参考。新的调度模型请直接看docs/architecture/mobile/resource-fetch-pipeline.md(Note.resourceStatus三态 +ScrapeAttempt作业表 + lease 自愈 + Phase 1/2/3 流水线)。本文档仍然适用的部分:平台爬虫实现(XhsScraper / ZhihuScraper / BilibiliScraper)、 Cookie 管理、stealth.js 注入、MethodChannel 协议、Android 前台服务这些底层细节。 它们没被这次重构动到。
PocketMind 使用 HeadlessInAppWebView 实现无头浏览器爬取,用于从小红书等需要登录的平台提取内容。
┌──────────────────────────────────────────────────────────────────────────┐
│ URL 生产入口(双来源) │
│ │
│ A. 其他 App 分享 URL │
│ ShareActivity -> main_share.dart -> enqueuePendingUrlIfAbsent() │
│ │
│ B. 主 App 启动/回前台 │
│ main.dart(init/resume) -> NoteService.processPendingUrls() │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 统一调度入口(唯一) │
│ │
│ NoteService.processPendingUrls() │
│ ├─ 读取 needCallBackUrl │
│ ├─ evaluateRetryEligibility()(队列存在 + 最大重试次数) │
│ ├─ 达上限:markUrlsAsFailedAndStopRetry() + notifyRetryExhausted │
│ └─ 可重试:注册 Workmanager 任务 scrapeAndSave │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 后台执行与结果处理 │
│ │
│ callbackDispatcher -> scrapeAndSave() │
│ ├─ MetadataManager 多策略抓取(平台爬虫/后端/API/本地) │
│ ├─ 成功 URL 清理 pending + 清理重试计数 │
│ ├─ 失败 URL 增加重试计数,达到 3 次转终态 FAILED │
│ └─ NotificationService 发送成功/失败/三次失败通知 │
└──────────────────────────────────────────────────────────────────────────┘
NoteService.processPendingUrls()needCallBackUrl 前先去重,防止重复分享造成并发抓取FAILED,并发送“已尝试3次抓取均失败”通知修改以下任一文件时,必须同步更新此 SKILL.md:
| 文件 | 职责 |
|---|---|
mobile/lib/service/note_service.dart | 核心调度:pending 队列管理、重试资格判定、重试计数、终态失败落库 |
mobile/lib/service/call_back_dispatcher.dart | 后台执行入口:Workmanager 任务分发、抓取结果处理、重试策略落地 |
mobile/lib/service/notification_service.dart | 通知交互层:重试/忽略按钮回调、三次失败通知 |
mobile/lib/service/scraper/platform_scraper_service.dart | 爬虫服务:Cookie 管理、图片下载、批量爬取 |
mobile/lib/service/scraper/xhs_scraper.dart | 小红书爬虫:HeadlessInAppWebView + stealth.js |
mobile/lib/service/scraper/zhihu_scraper.dart | 知乎爬虫:支持回答/文章/视频,从 js-initialData 提取 |
mobile/lib/service/scraper/platform_scraper_interface.dart | 接口定义:IPlatformScraper + ScrapedMetadata + CookieExpiredException |
mobile/lib/service/scraper/stealth_js_loader.dart | stealth.js 单例加载器 |
mobile/lib/service/scraper/scraper_task.dart | 任务模型:@JsonSerializable(TaskStatus 枚举 + 重试机制) |
mobile/lib/service/scraper/scraper.dart | 模块统一导出文件 |
mobile/lib/util/platform_detector.dart | 平台检测 + 爬虫工厂:URL → PlatformType + getScraper() |
mobile/lib/main_share.dart | 分享入口:写入 pending 队列并调用 processPendingUrls() |
mobile/lib/main.dart | 主应用入口:启动与 resumed 时统一调用 processPendingUrls() |
mobile/lib/service/metadata_manager.dart | 元数据管理:多策略获取(爬虫 → 后端API → LinkPreview → 本地解析) |
| 文件 | 职责 |
|---|---|
mobile/android/.../service/ScraperForegroundService.kt | 前台服务:通知栏进度、保活进程、静态方法(start/stop/updateProgress) |
mobile/android/.../MainActivity.kt | 主应用 MethodChannel 注册 |
mobile/android/.../ShareActivity.kt | 分享入口:FlutterEngine 缓存 + MethodChannel 注册 + 剪贴板读取 |
重要:MainActivity 和 ShareActivity 都需要注册 scraper Channel,因为它们运行在不同的 FlutterEngine 实例中。
Channel: com.doublez.pocketmind/scraper
// 启动前台服务
"startForegroundService" { taskCount: Int }
// 停止前台服务
"stopForegroundService" {}
// 更新通知进度
"updateProgress" { currentUrl: String, pendingCount: Int }
// 触发队列处理
"processQueue" {}
// 取消任务
"cancelTask" { noteId: Int }
// 查询状态
"getQueueStatus" → { queueLength, currentUrl, isProcessing }
/// 平台类型枚举
enum PlatformType {
/// 小红书
xhs('小红书', 'xhs'),
/// 知乎
zhihu('知乎', 'zhihu'),
/// 通用平台(使用默认策略)
generic('通用', 'generic');
final String displayName;
final String identifier;
const PlatformType(this.displayName, this.identifier);
}
支持的知乎 URL 格式:
https://www.zhihu.com/question/123456/answer/789012https://zhuanlan.zhihu.com/p/123456789https://www.zhihu.com/zvideo/123456789MetadataManager 策略优先级: 0. 平台专用爬虫(小红书、知乎等需要登录的平台)
// main_share.dart - 分享时仅负责入队(幂等)
await noteService.enqueuePendingUrlIfAbsent(url);
// 分享完成后与主应用回前台,都走统一入口
await noteService.processPendingUrls(userQuestion: userQuestion);
// note_service.dart - 统一调度
Future<void> processPendingUrls({String? userQuestion}) async {
final urls = loadPendingUrls();
final eligibility = await evaluateRetryEligibility(urls);
if (eligibility.reachedMaxRetryUrls.isNotEmpty) {
await markUrlsAsFailedAndStopRetry(eligibility.reachedMaxRetryUrls);
enqueueTask('notifyRetryExhausted', eligibility.reachedMaxRetryUrls);
}
if (eligibility.retryableUrls.isNotEmpty) {
enqueueTask('scrapeAndSave', eligibility.retryableUrls, uq: userQuestion);
}
}
// call_back_dispatcher.dart - 后台执行
// scrapeAndSave() 负责调用 MetadataManager、落库、更新重试计数;
// 失败达到 3 次后调用 notifyRetryExhausted() 给用户发终态通知。
// PlatformDetector.detectPlatform()
static PlatformType detectPlatform(String url) {
if (url.isEmpty) return PlatformType.generic;
// 小红书检测(含短链)
if (_xhsPattern.hasMatch(url)) { // (xhslink|xiaohongshu)\.com
return PlatformType.xhs;
}
// 知乎检测
if (_zhihuPattern.hasMatch(url)) { // (zhihu|zhuanlan\.zhihu)\.com
return PlatformType.zhihu;
}
return PlatformType.generic;
}
// PlatformDetector.getScraper() - 工厂方法
static IPlatformScraper? getScraper(String url) {
final platform = detectPlatform(url);
switch (platform) {
case PlatformType.xhs:
return XhsScraper();
case PlatformType.zhihu:
return ZhihuScraper();
case PlatformType.generic:
return null; // 通用平台走 MetadataManager
}
}
// zhihu_scraper.dart - 从 js-initialData 提取
// 知乎页面包含 <script id="js-initialData"> 标签,内含 JSON 数据
Future<ScrapedMetadata?> _extractFromInitialData(controller, contentType) async {
// 1. 获取 js-initialData 脚本内容
var scriptEl = document.getElementById('js-initialData');
var data = JSON.parse(scriptEl.textContent);
// 2. 根据内容类型提取
var entities = data.initialState.entities;
if (contentType == 'answer') {
// 从 entities.answers 提取
var answer = entities.answers[Object.keys(entities.answers)[0]];
return { title: answer.question.title, content: answer.content, ... };
} else if (contentType == 'article') {
// 从 entities.articles 提取
var article = entities.articles[Object.keys(entities.articles)[0]];
return { title: article.title, content: article.content, ... };
} else if (contentType == 'zvideo') {
// 从 entities.zvideos 提取
var video = entities.zvideos[Object.keys(entities.zvideos)[0]];
return { title: video.title, desc: video.description, ... };
}
}
// 知乎必需 Cookie: d_c0, z_c0
// xhs_scraper.dart - 在 WebView 创建时注入 stealth.js
onWebViewCreated: (ctrl) {
controller = ctrl;
// 注入 stealth.js(在 document 开始时)
if (stealthJs.isNotEmpty) {
ctrl.addUserScript(
userScript: UserScript(
source: stealthJs,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
);
}
},
stealth.js 作用:
navigator.webdriver 检测assets/js/stealth.min.js// IPlatformScraper 接口定义
abstract class IPlatformScraper {
Future<ScrapedMetadata?> scrape(String url, Map<String, String> cookieDict);
List<String> getRequiredCookieNames();
bool validateCookies(Map<String, String> cookieDict);
}
// XhsScraper 实现
@override
List<String> getRequiredCookieNames() => ['a1', 'webId'];
// ZhihuScraper 实现
@override
List<String> getRequiredCookieNames() => ['d_c0', 'z_c0'];
@override
bool validateCookies(Map<String, String> cookieDict) {
final required = getRequiredCookieNames();
for (var name in required) {
if (!cookieDict.containsKey(name) || cookieDict[name]!.isEmpty) {
return false;
}
}
return true;
}
// CookieExpiredException - 当 Cookie 失效时抛出
class CookieExpiredException implements Exception {
final String message;
final String? platform;
CookieExpiredException(this.message, {this.platform});
}
// SharedPreferences JSON 存储
static const String _queueKey = 'scraper_task_queue';
// 任务模型(@JsonSerializable)
@JsonSerializable()
class ScraperTask {
final int noteId;
final String url;
final String platform;
TaskStatus status;
final DateTime createdAt;
DateTime? startedAt;
DateTime? completedAt;
String? errorMessage;
int retryCount;
DateTime? nextRetryAt;
// 是否可以立即执行
bool get canExecuteNow {
if (status != TaskStatus.pending) return false;
if (nextRetryAt == null) return true;
return DateTime.now().isAfter(nextRetryAt!);
}
// 是否可以重试(最多 3 次)
bool get canRetry => retryCount < 3;
}
// 任务状态枚举
enum TaskStatus { pending, running, completed, failed, cancelled }
// DateTime 序列化(ISO8601 字符串格式)
DateTime _dateTimeFromJson(String json) => DateTime.parse(json);
String _dateTimeToJson(DateTime dateTime) => dateTime.toIso8601String();
// ScraperForegroundService.kt - 静态方法调用
companion object {
const val METHOD_CHANNEL = "com.doublez.pocketmind/scraper"
// 启动前台服务
fun start(context: Context, taskCount: Int = 0)
// 停止前台服务
fun stop(context: Context)
// 更新任务进度
fun updateProgress(context: Context, currentUrl: String, pendingCount: Int)
}
// Android 8.0+ 必须使用前台服务
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
taskCount = intent.getIntExtra(EXTRA_TASK_COUNT, 0)
startForeground(NOTIFICATION_ID, createNotification())
}
ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
ACTION_UPDATE_PROGRESS -> {
// 更新通知并检查是否完成
if (pendingCount <= 0 && taskCount > 0) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
return START_NOT_STICKY
}
1. ShareActivity.kt 接收分享 Intent(或剪贴板触发)
2. MethodChannel('com.doublez.pocketmind/share') -> Flutter main_share.dart
3. main_share._handleMethodCall('showShare'):
- noteService.addNote() 先落库
- noteService.enqueuePendingUrlIfAbsent(url) 写入 needCallBackUrl(幂等)
4. 用户关闭分享页 -> main_share._dismissUI():
- 调用 noteService.processPendingUrls(userQuestion?)(统一入口)
5. NoteService.processPendingUrls():
- evaluateRetryEligibility() 做重试资格筛选
- 达上限 URL:markUrlsAsFailedAndStopRetry() + notifyRetryExhausted
- 可重试 URL:注册 Workmanager 任务 scrapeAndSave
6. callbackDispatcher.scrapeAndSave():
- MetadataManager 多策略抓取(平台爬虫/后端/API/本地)
- 成功 URL 清理 pending + 清理重试计数
- 失败 URL 累计重试次数,达到 3 次则终态 FAILED
7. NotificationService 展示结果通知(成功/失败/三次失败)
小红书分享的短链接 (xhslink.com/xxx) 无法直接提取笔记 ID:
_extractNoteId() 返回 nullIPlatformScraper 接口(包括 scrape(), getRequiredCookieNames(), validateCookies())PlatformType 枚举添加新平台(包含 displayName 和 identifier)PlatformDetector 添加 URL 匹配正则和 getScraper() 分支scraper.dart 添加导出CookieExpiredExceptionCookieManagerService.markAsExpired() 标记过期markFailed(canRetry: false) 不允许自动重试a1, webIdd_c0, z_c0UserScriptInjectionTime.AT_DOCUMENT_START).xiaohongshu.com,知乎:.zhihu.com)MainActivity 和 ShareActivity 运行在不同 FlutterEnginecom.doublez.pocketmind/scraper ChannelFOREGROUND_SERVICE 权限无法从 URL 提取笔记 ID(可能是短链接) 是 Debug 级别share_url_retry_count_map)resourceStatus=FAILED,并发送终态失败通知needCallBackUrl 移除,同时清理对应重试计数needCallBackUrlprocessPendingUrls() 自动续跑