بنقرة واحدة
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() 自动续跑