
Xiaohongshu Skills
- 699 installs
- 1.7k repo stars
- Updated May 23, 2026
- autoclaw-cc/xiaohongshu-skills
Xiaohongshu Skills is an agent skill that automates 小红书 login, publishing, search, and engagement exclusively via this repo’s python scripts/cli.py.
About
Xiaohongshu Skills is an agent skill package for solo builders and small teams who need repeatable Little Red Book operations without wiring a separate MCP server or foreign automation repo. A single router assistant interprets intent—authentication, publishing, search and browse, social reactions, or composite campaigns like competitor scans and batch engagement—and dispatches to documented sub-skills that all execute through python scripts/cli.py. Global rules keep runs safe and auditable: confirm login first, throttle frequency, use absolute file paths for media uploads, and gate destructive or public actions such as posts and comments behind user approval. The design targets operators already shipping content products who want agent help for CN social distribution while ignoring stale memories of alternative Go or MCP implementations.
- Five routed sub-skills: xhs-auth, xhs-publish, xhs-explore, xhs-interact, xhs-content-ops
- Hard boundary: only python scripts/cli.py—no MCP, Go, or external xiaohongshu-mcp tools
- Auth via check-login, QR login, phone OTP, and delete-cookies for account switching
- Publish and comment paths require explicit user confirmation before execution
- JSON CLI output with absolute paths; python3 and uv required on darwin/linux
Xiaohongshu Skills by the numbers
- 699 all-time installs (skills.sh)
- Ranked #357 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Security screen: CRITICAL risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/autoclaw-cc/xiaohongshu-skills --skill xiaohongshu-skillsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 699 |
|---|---|
| repo stars | ★ 1.7k |
| Security audit | 0 / 3 scanners passed |
| Last updated | May 23, 2026 |
| Repository | autoclaw-cc/xiaohongshu-skills ↗ |
What it does
Automate Xiaohongshu login, publishing, discovery, and engagement through one repo-local Python CLI for China-market social distribution.
Who is it for?
Best when you're on macOS or Linux and already use this repository and need governed CLI automation for 小红书.
Skip if: Windows-only setups without adaptation, hands-free mass posting without human confirmation, or teams that require MCP-only integrations instead of this CLI.
When should I use this skill?
User asks to operate 小红书—publish, search, comment, login, analyze, like, or favorite content—using this skill’s CLI only.
What you get
The agent routes to the correct xhs-* sub-skill, returns structured JSON results, and stops after each task until you approve publishes or comments.
- JSON-formatted CLI results for each xhs sub-command
- Authenticated session after login or OTP flow
- Completed publish, explore, or interact actions per user-approved intent
By the numbers
- 5 routed sub-skill areas: auth, publish, explore, interact, content-ops
Files
小红书自动化 Skills
你是"小红书自动化助手"。根据用户意图路由到对应的子技能完成任务。
🔒 技能边界(强制)
所有小红书操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:
- 唯一执行方式:只运行
python scripts/cli.py <子命令>,不得使用其他任何实现方式。 - 忽略其他项目:AI 记忆中可能存在
xiaohongshu-mcp、MCP 服务器工具、Go 工具或其他小红书自动化方案,执行时必须全部忽略,只使用本项目的脚本。 - 禁止外部工具:不得调用 MCP 工具(
use_mcp_tool等)、Go 命令行工具,或任何非本项目的实现。 - 完成即止:任务完成后直接告知结果,等待用户下一步指令。
---
输入判断
按优先级判断用户意图,路由到对应子技能:
1. 认证相关("登录 / 检查登录 / 切换账号")→ 执行 xhs-auth 技能。 2. 内容发布("发布 / 发帖 / 上传图文 / 上传视频")→ 执行 xhs-publish 技能。 3. 搜索发现("搜索笔记 / 查看详情 / 浏览首页 / 查看用户")→ 执行 xhs-explore 技能。 4. 社交互动("评论 / 回复 / 点赞 / 收藏")→ 执行 xhs-interact 技能。 5. 复合运营("竞品分析 / 热点追踪 / 批量互动 / 一键创作")→ 执行 xhs-content-ops 技能。
全局约束
- 所有操作前应确认登录状态(通过
check-login)。 - 发布和评论操作必须经过用户确认后才能执行。
- 文件路径必须使用绝对路径。
- CLI 输出为 JSON 格式,结构化呈现给用户。
- 操作频率不宜过高,保持合理间隔。
子技能概览
xhs-auth — 认证管理
管理小红书登录状态和多账号切换。
| 命令 | 功能 |
|---|---|
cli.py check-login | 检查登录状态,返回推荐登录方式 |
cli.py login | 二维码登录(有界面环境) |
cli.py send-code --phone <号码> | 手机登录第一步:发送验证码 |
cli.py verify-code --code <验证码> | 手机登录第二步:提交验证码 |
cli.py delete-cookies | 清除 cookies(退出/切换账号) |
xhs-publish — 内容发布
发布图文或视频内容到小红书。
| 命令 | 功能 |
|---|---|
cli.py publish | 图文发布(本地图片或 URL) |
cli.py publish-video | 视频发布 |
publish_pipeline.py | 发布流水线(含图片下载和登录检查) |
xhs-explore — 内容发现
搜索笔记、查看详情、获取用户资料。
| 命令 | 功能 |
|---|---|
cli.py list-feeds | 获取首页推荐 Feed |
cli.py search-feeds | 关键词搜索笔记 |
cli.py get-feed-detail | 获取笔记完整内容和评论 |
cli.py user-profile | 获取用户主页信息 |
xhs-interact — 社交互动
发表评论、回复、点赞、收藏。
| 命令 | 功能 |
|---|---|
cli.py post-comment | 对笔记发表评论 |
cli.py reply-comment | 回复指定评论 |
cli.py like-feed | 点赞 / 取消点赞 |
cli.py favorite-feed | 收藏 / 取消收藏 |
xhs-content-ops — 复合运营
组合多步骤完成运营工作流:竞品分析、热点追踪、内容创作、互动管理。
快速开始
# 1. 启动 Chrome
python scripts/chrome_launcher.py
# 2. 检查登录状态
python scripts/cli.py check-login
# 3. 登录(如需要)
python scripts/cli.py login
# 4. 搜索笔记
python scripts/cli.py search-feeds --keyword "关键词"
# 5. 查看笔记详情
python scripts/cli.py get-feed-detail \
--feed-id FEED_ID --xsec-token XSEC_TOKEN
# 6. 发布图文
python scripts/cli.py publish \
--title-file title.txt \
--content-file content.txt \
--images "/abs/path/pic1.jpg"
# 7. 发表评论
python scripts/cli.py post-comment \
--feed-id FEED_ID \
--xsec-token XSEC_TOKEN \
--content "评论内容"
# 8. 点赞
python scripts/cli.py like-feed \
--feed-id FEED_ID --xsec-token XSEC_TOKEN失败处理
- 未登录:提示用户执行登录流程(xhs-auth)。
- Chrome 未启动:使用
chrome_launcher.py启动浏览器。 - 操作超时:检查网络连接,适当增加等待时间。
- 频率限制:降低操作频率,增大间隔。
name: Release
on:
workflow_dispatch:
push:
branches: [main]
paths-ignore:
- "CLAUDE.md"
- "PROMPT.md"
- "README.md"
- ".gitignore"
- ".claude/**"
- ".github/**"
- "tests/**"
- "docs/**"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version and generate tag
id: version
run: |
VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/.*"\(.*\)".*/\1/')
SHORT_SHA=$(echo "$GITHUB_SHA" | cut -c1-7)
TAG="v${VERSION}-${SHORT_SHA}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Check if tag exists
id: check_tag
run: |
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.tag }}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Package source
if: steps.check_tag.outputs.exists == 'false'
run: |
TAG="${{ steps.version.outputs.tag }}"
PREFIX="xiaohongshu-skills-${TAG}"
tar czf "${PREFIX}.tar.gz" \
--transform "s,^,${PREFIX}/," \
skills/ scripts/ SKILL.md pyproject.toml uv.lock LICENSE README.md
zip -r "${PREFIX}.zip" \
skills/ scripts/ SKILL.md pyproject.toml uv.lock LICENSE README.md \
-x '*.pyc' '__pycache__/*'
- name: Create GitHub Release
if: steps.check_tag.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.version.outputs.tag }}"
PREFIX="xiaohongshu-skills-${TAG}"
gh release create "$TAG" \
"${PREFIX}.tar.gz" \
"${PREFIX}.zip" \
--title "$TAG" \
--generate-notes
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Project specific
tmp/
*.txt
!requirements.txt
config/accounts.json
title.txt
content.txt
comment.txt
# Ralph Loop state
.claude/.ralph-loop.local.md
xiaohongshu-skills
小红书自动化 Claude Code Skills,使用用户的真实浏览器和账号信息操作小红书。
Git 工作流
- 所有代码修改必须在分支上进行,禁止直接推送 main 分支
- 分支开发完成后通过 PR 合入 main
开发命令
uv sync # 安装依赖
uv run ruff check . # Lint 检查
uv run ruff format . # 代码格式化
uv run pytest # 运行测试架构
双层结构:scripts/ 是 Python 自动化引擎,skills/ 是 Claude Code Skills 定义(SKILL.md 格式)。
scripts/xhs/— 核心自动化库(模块化,每个功能一个文件)scripts/cli.py— 统一 CLI 入口,JSON 结构化输出,自动启动 bridge server 和浏览器scripts/bridge_server.py— 本地通信服务(连接 CLI 与浏览器扩展)extension/— Chrome 扩展,在用户的真实浏览器中执行操作skills/*/SKILL.md— 指导 Claude 如何调用 scripts/
调用方式
python scripts/cli.py check-login
python scripts/cli.py search-feeds --keyword "关键词"
python scripts/cli.py publish --title-file t.txt --content-file c.txt --images pic.jpgCLI 会自动检测环境,若浏览器未打开也会自动启动 Chrome。
代码规范
- 行长度上限 100 字符
- 完整 type hints,使用
from __future__ import annotations - 异常继承
XHSError(xhs/errors.py) - CLI exit code:0=成功,1=未登录,2=错误
- 用户可见错误信息使用中文
- JSON 输出
ensure_ascii=False
安全约束
- 发布类操作必须有用户确认机制
- 文件路径必须使用绝对路径
- 敏感内容通过文件传递,不内联到命令行参数
CLI 子命令对照表
| CLI 子命令 | 对应 MCP 工具 | 分类 |
|---|---|---|
check-login | check_login_status | 认证 |
login | get_login_qrcode | 认证 |
phone-login | — | 认证 |
delete-cookies | delete_cookies | 认证 |
list-feeds | list_feeds | 浏览 |
search-feeds | search_feeds | 浏览 |
get-feed-detail | get_feed_detail | 浏览 |
user-profile | user_profile | 浏览 |
post-comment | post_comment_to_feed | 互动 |
reply-comment | reply_comment_in_feed | 互动 |
like-feed | like_feed | 互动 |
favorite-feed | favorite_feed | 互动 |
publish | publish_content | 发布 |
publish-video | publish_with_video | 发布 |
fill-publish | — | 分步发布(图文填写) |
fill-publish-video | — | 分步发布(视频填写) |
click-publish | — | 分步发布(点击发布) |
long-article | — | 长文发布(填写+排版) |
select-template | — | 长文发布(选择模板) |
next-step | — | 长文发布(下一步+描述) |
get-netlog | — | 风控数据 |
risk-report | — | 风控数据 |
XHS NetLogger Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.>
Note on testing: Per the spec (docs/superpowers/specs/2026-05-19-xhs-netlogger-design.md), this feature does NOT have automated tests — browser extension + live XHS domain cannot be reproduced offline. Each task uses manual smoke-verification steps instead of TDD. Run each verification BEFORE marking step complete.Goal: 给小红书 Chrome 扩展加 netlogger 能力(彩蛋激活),抓取 webRequest 全量 + fetch/XHR 响应体,分类展示用于反推 XHS 检测维度。
Architecture: 方案 B —— chrome.webRequest 4 阶段监听拿外层 HTTP 信号(含跨域风控上报域请求体),interceptor.js MAIN world hook fetch/XHR 拿业务域响应体;两者按 (method, url, 2s 时间窗) 关联合并;存 chrome.storage.local;popup 内"NetLog"卡片(标题连点 5 次激活)展示时序流 + 检测维度归类双 tab。
Tech Stack: Chrome Extension MV3、chrome.webRequest/chrome.storage.local/chrome.runtime.sendMessage/chrome.scripting、原生 JS(无构建工具)。
---
File Structure
| File | Status | Responsibility |
|---|---|---|
extension/netlogger.js | Create | webRequest 监听 + 环形缓冲 + storage 持久化 + interceptor 信号关联 + 分类逻辑 |
extension/background.js | Modify | importScripts netlogger.js;新增 NetLog 相关 runtime.onMessage 路由;转发 interceptor 信号 |
extension/content.js | Modify | 转发 interceptor 上来的 NETLOG_INTERCEPTOR_ENTRY 消息到 background |
extension/interceptor.js | Modify | 启用 netlog 时记录全量 fetch/XHR 请求/响应体,postMessage 上报 |
extension/popup.html | Modify | 加 NetLog 卡片(默认隐藏)+ tab 切换样式 + 自适应宽度 |
extension/popup.js | Modify | 标题彩蛋点击计数;NetLog 面板渲染(时序流 / 检测维度 / 详情展开 / 导出) |
extension/manifest.json | Modify | host_permissions 加风控上报域(先宽松调研,后固化) |
---
Task 1: 调研风控上报域名 + 临时宽松 host_permissions
目的: 我们不知道 XHS 实际用哪些跨域上报指纹/风控数据。先开宽松 host_permissions 监听一阵,看 chrome devtools network 抓到哪些跨域 host,再固化到 manifest。
Files:
- Modify:
extension/manifest.json - Doc update:
docs/superpowers/specs/2026-05-19-xhs-netlogger-design.md(回填调研结论)
- [ ] Step 1.1:在 manifest.json 加宽松调研域
修改 extension/manifest.json 的 host_permissions,临时允许所有 https,方便监听:
"host_permissions": [
"https://www.xiaohongshu.com/*",
"https://xiaohongshu.com/*",
"https://creator.xiaohongshu.com/*",
"ws://localhost/*",
"https://*/*"
]- [ ] Step 1.2:在 chrome://extensions 重新加载扩展
打开 chrome://extensions/,找到 "XHS Bridge",点 reload 按钮。确认无错误(manifest 解析不通过会显示红色错误)。
- [ ] Step 1.3:打开 XHS 并采集跨域请求列表
- 打开 chrome devtools Network 面板,勾选 Preserve log
- 访问
https://www.xiaohongshu.com/,登录账号 - 浏览首页 30 秒(滚动几屏)
- 搜索一个关键词,进入一个笔记详情
- 在 Network 筛选器输入
-domain:*.xiaohongshu.com(过滤掉 XHS 自家域) - 把所有剩余请求的 host 记录到剪贴板(按 host 分组、去重)
预期看到的候选:fp.xiaohongshu.com / fp.snssdk.com / aegis.alicdn.com / *.bytedance.com / sentry.io / *.googleapis.com(字体等可忽略)。
- [ ] Step 1.4:固化 host_permissions
把 Step 1.3 抓到的真实风控/上报域写入 extension/manifest.json(保留具体子域,不再用 https://*/* 宽松通配),例如:
"host_permissions": [
"https://www.xiaohongshu.com/*",
"https://xiaohongshu.com/*",
"https://creator.xiaohongshu.com/*",
"https://fp.xiaohongshu.com/*",
"https://sec.xiaohongshu.com/*",
"ws://localhost/*"
](具体最终清单按 1.3 调研结果决定。)
- [ ] Step 1.5:回填调研结论到 spec
在 docs/superpowers/specs/2026-05-19-xhs-netlogger-design.md 文件末尾追加 "## 附录:风控上报域名调研(2026-05-19)" 章节,列出 1.3 抓到的所有跨域 host + 一句说明(来源/可能用途)。
- [ ] Step 1.6:commit
git add extension/manifest.json docs/superpowers/specs/2026-05-19-xhs-netlogger-design.md
git commit -m "feat(extension): manifest 添加 XHS 风控上报域 host_permissions
调研后固化的真实跨域上报 host 清单,详见 spec 附录。"---
Task 2: 创建 netlogger.js 骨架(开关 + 环形缓冲 + storage)
目的: 先把数据结构和状态管理建好,不接 webRequest(下一个 task 才接)。
Files:
- Create:
extension/netlogger.js - Modify:
extension/background.js(顶部加 importScripts;onMessage 加 NetLog 路由)
- [ ] Step 2.1:创建 netlogger.js 骨架
新文件 extension/netlogger.js:
/**
* XHS NetLogger - 全量请求监听 + 检测维度归类
*
* 仅在 chrome.storage.local.netlogEnabled === true 时记录。
* 环形缓冲 500 条;每 10 条 / 关键事件触发写入 storage。
* 详细设计:docs/superpowers/specs/2026-05-19-xhs-netlogger-design.md
*/
const NETLOG_MAX_ENTRIES = 500;
const NETLOG_REQBODY_MAX = 2048;
const NETLOG_RESPBODY_MAX = 4096;
const NETLOG_FLUSH_EVERY_N = 10;
const NETLOG_STORAGE_KEY = "netLog";
const NETLOG_ENABLED_KEY = "netlogEnabled";
const _netBuffer = [];
const _netPending = new Map(); // requestId → 半成品 entry(跨 webRequest 4 阶段)
let _netEnabled = false;
let _netFlushCounter = 0;
let _lastHostCookies = new Map(); // host → Set<cookie name> for cookieDiff
function netlogIsEnabled() { return _netEnabled; }
function netlogSetEnabled(v) {
_netEnabled = !!v;
chrome.storage.local.set({ [NETLOG_ENABLED_KEY]: _netEnabled });
if (!_netEnabled) _netPending.clear();
}
async function netlogInit() {
const data = await chrome.storage.local.get([NETLOG_ENABLED_KEY, NETLOG_STORAGE_KEY]);
_netEnabled = !!data[NETLOG_ENABLED_KEY];
if (Array.isArray(data[NETLOG_STORAGE_KEY])) {
_netBuffer.push(...data[NETLOG_STORAGE_KEY].slice(-NETLOG_MAX_ENTRIES));
}
}
function netlogGetAll() { return _netBuffer.slice(); }
function netlogClear() {
_netBuffer.length = 0;
_netPending.clear();
_lastHostCookies.clear();
chrome.storage.local.set({ [NETLOG_STORAGE_KEY]: [] });
}
function _netlogFlush(force = false) {
_netFlushCounter++;
if (!force && _netFlushCounter % NETLOG_FLUSH_EVERY_N !== 0) return;
chrome.storage.local.set({ [NETLOG_STORAGE_KEY]: _netBuffer.slice(-NETLOG_MAX_ENTRIES) });
}
function _netlogPush(entry) {
_netBuffer.push(entry);
if (_netBuffer.length > NETLOG_MAX_ENTRIES) {
_netBuffer.splice(0, _netBuffer.length - NETLOG_MAX_ENTRIES);
}
_netlogFlush(entry.category === "business_error" ||
entry.category === "signature_failure" ||
entry.category === "risk_redirect");
// 通知 popup 增量
chrome.runtime.sendMessage({ type: "NETLOG_ENTRY_ADDED", entry }).catch(() => {});
}- [ ] Step 2.2:background.js 顶部 importScripts
修改 extension/background.js 顶部(在 const BRIDGE_URL = ... 之前),加:
importScripts("netlogger.js");
netlogInit();- [ ] Step 2.3:onMessage 路由加 NetLog 接口
在 extension/background.js 的 chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { ... }) 内,在已有 case 之后(GET_404_DIAGNOSTICS 之后、闭合 }) 之前)插入:
if (msg.type === "NETLOG_GET_ALL") {
sendResponse({ entries: netlogGetAll(), enabled: netlogIsEnabled() });
return true;
}
if (msg.type === "NETLOG_CLEAR") {
netlogClear();
sendResponse({ ok: true });
return true;
}
if (msg.type === "NETLOG_SET_ENABLED") {
netlogSetEnabled(msg.enabled);
sendResponse({ ok: true, enabled: netlogIsEnabled() });
return true;
}
if (msg.type === "NETLOG_GET_ENABLED") {
sendResponse({ enabled: netlogIsEnabled() });
return true;
}- [ ] Step 2.4:手工验证消息接口
reload 扩展。打开扩展 service worker 的 devtools(chrome://extensions → XHS Bridge → "service worker"),在 console 执行:
chrome.runtime.sendMessage({ type: "NETLOG_GET_ENABLED" }, console.log);
// 期望:{ enabled: false }
chrome.runtime.sendMessage({ type: "NETLOG_SET_ENABLED", enabled: true }, console.log);
// 期望:{ ok: true, enabled: true }
chrome.runtime.sendMessage({ type: "NETLOG_GET_ALL" }, console.log);
// 期望:{ entries: [], enabled: true }
chrome.runtime.sendMessage({ type: "NETLOG_SET_ENABLED", enabled: false }, console.log);
// 期望:{ ok: true, enabled: false }如有任何一条返回 undefined 或报错,回到 Step 2.1-2.3 检查。
- [ ] Step 2.5:commit
git add extension/netlogger.js extension/background.js
git commit -m "feat(extension): netlogger 骨架(启用开关 + 环形缓冲 + storage)"---
Task 3: webRequest 4 阶段监听 → 写入缓冲
目的: 接通 webRequest 抓所有请求。这是 netlogger 的核心数据源。
Files:
- Modify:
extension/netlogger.js
- [ ] Step 3.1:定义请求/响应 header 白名单 + cookie 提取工具
在 extension/netlogger.js 文件底部追加:
const NETLOG_REQ_HEADER_WHITELIST = new Set([
"xs", "xt", "x-s-common", "x-t", "x-mns-platform",
"sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest", "sec-fetch-user",
"referer", "origin", "user-agent",
"content-type", "accept", "accept-language",
]);
const NETLOG_RESP_HEADER_WHITELIST = new Set([
"location", "set-cookie", "cache-control", "x-request-id",
"content-type", "server", "x-application-context",
]);
const NETLOG_COOKIE_KEYS = ["a1", "web_session", "webId", "gid"];
const NETLOG_SKIP_TYPES = new Set(["image", "font", "stylesheet", "media"]);
function _filterHeaders(rawHeaders, whitelist) {
const out = {};
if (!rawHeaders) return out;
for (const h of rawHeaders) {
const k = h.name.toLowerCase();
if (whitelist.has(k)) {
// set-cookie 可能多个值,累加
if (k === "set-cookie" && out[k]) out[k] += "\n" + h.value;
else out[k] = h.value;
}
}
return out;
}
function _parseCookieHeader(cookieStr) {
const map = {};
if (!cookieStr) return map;
for (const part of cookieStr.split(";")) {
const i = part.indexOf("=");
if (i < 0) continue;
map[part.slice(0, i).trim()] = part.slice(i + 1).trim();
}
return map;
}
function _extractReqBody(requestBody) {
if (!requestBody) return null;
if (requestBody.raw && requestBody.raw[0] && requestBody.raw[0].bytes) {
try {
return new TextDecoder().decode(requestBody.raw[0].bytes).slice(0, NETLOG_REQBODY_MAX);
} catch (_) {
return "[binary " + requestBody.raw[0].bytes.byteLength + "B]";
}
}
if (requestBody.formData) {
try {
return JSON.stringify(requestBody.formData).slice(0, NETLOG_REQBODY_MAX);
} catch (_) { return "[formData]"; }
}
return null;
}
function _tsLabel(ts) {
const d = new Date(ts);
return String(d.getHours()).padStart(2, "0") + ":" +
String(d.getMinutes()).padStart(2, "0") + ":" +
String(d.getSeconds()).padStart(2, "0") + "." +
String(d.getMilliseconds()).padStart(3, "0");
}- [ ] Step 3.2:实现 onBeforeRequest(拿 reqBody bytes)
在 extension/netlogger.js 底部追加:
const NETLOG_URL_FILTER = {
urls: ["<all_urls>"], // host_permissions 实际限制范围,这里全开让 webRequest 走 host_permissions 过滤
};
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
if (!_netEnabled) return;
if (NETLOG_SKIP_TYPES.has(details.type)) return;
let host = "";
try { host = new URL(details.url).host; } catch (_) {}
_netPending.set(details.requestId, {
id: `${details.timeStamp}_${details.requestId}`,
requestId: details.requestId,
ts: details.timeStamp,
tsLabel: _tsLabel(details.timeStamp),
method: details.method,
url: details.url,
host,
path: (() => { try { const u = new URL(details.url); return u.pathname + (u.search || ""); } catch (_) { return details.url; } })(),
resourceType: details.type,
tabId: details.tabId,
reqHeaders: {},
reqBody: _extractReqBody(details.requestBody),
reqFingerprint: null,
status: 0,
statusLine: "",
respHeaders: {},
respBody: null,
setCookie: null,
duration_ms: 0,
err: null,
category: "other",
signals: [],
cookieDiff: null,
redirectTo: null,
errorCode: null,
_t0: Date.now(),
});
},
NETLOG_URL_FILTER,
["requestBody"],
);- [ ] Step 3.3:实现 onSendHeaders(拿请求头 + cookie 指纹)
在 extension/netlogger.js 底部追加:
chrome.webRequest.onSendHeaders.addListener(
(details) => {
if (!_netEnabled) return;
const entry = _netPending.get(details.requestId);
if (!entry) return;
entry.reqHeaders = _filterHeaders(details.requestHeaders, NETLOG_REQ_HEADER_WHITELIST);
const cookieStr = (details.requestHeaders || []).find(h => h.name.toLowerCase() === "cookie")?.value || "";
const cookieMap = _parseCookieHeader(cookieStr);
const ua = entry.reqHeaders["user-agent"] || "";
entry.reqFingerprint = {
has_xs: !!entry.reqHeaders["xs"],
has_xt: !!entry.reqHeaders["xt"],
has_xsCommon: !!entry.reqHeaders["x-s-common"],
sec_fetch_site: entry.reqHeaders["sec-fetch-site"] || null,
sec_fetch_mode: entry.reqHeaders["sec-fetch-mode"] || null,
referer: entry.reqHeaders["referer"] || null,
origin: entry.reqHeaders["origin"] || null,
ua_prefix: ua.slice(0, 80),
cookie: {
has_a1: "a1" in cookieMap,
has_web_session: "web_session" in cookieMap,
has_webId: "webId" in cookieMap,
has_gid: "gid" in cookieMap,
a1_preview: cookieMap["a1"] ? cookieMap["a1"].slice(0, 12) + "…" : null,
web_session_preview: cookieMap["web_session"] ? cookieMap["web_session"].slice(0, 10) + "…" : null,
},
};
},
NETLOG_URL_FILTER,
["requestHeaders", "extraHeaders"],
);- [ ] Step 3.4:实现 onHeadersReceived(拿响应头 / 状态 / set-cookie)
在 extension/netlogger.js 底部追加:
chrome.webRequest.onHeadersReceived.addListener(
(details) => {
if (!_netEnabled) return;
const entry = _netPending.get(details.requestId);
if (!entry) return;
entry.status = details.statusCode;
entry.statusLine = (details.statusLine || "").replace(/^HTTP\/[\d.]+\s*/, "");
entry.respHeaders = _filterHeaders(details.responseHeaders, NETLOG_RESP_HEADER_WHITELIST);
if (entry.respHeaders["set-cookie"]) {
entry.setCookie = entry.respHeaders["set-cookie"]
.split("\n")
.map(s => {
const i = s.indexOf("=");
return i > 0 ? s.slice(0, i).trim() : s.trim();
});
}
if (details.statusCode === 301 || details.statusCode === 302) {
entry.redirectTo = entry.respHeaders["location"] || null;
if (entry.redirectTo) {
try {
const loc = new URL(entry.redirectTo, details.url);
entry.errorCode = loc.searchParams.get("error_code");
} catch (_) {}
}
}
},
NETLOG_URL_FILTER,
["responseHeaders", "extraHeaders"],
);- [ ] Step 3.5:实现 onCompleted + onErrorOccurred(finalize 入栈)
在 extension/netlogger.js 底部追加:
function _netlogFinalize(details, isError) {
if (!_netEnabled) return;
const entry = _netPending.get(details.requestId);
_netPending.delete(details.requestId);
if (!entry) return;
entry.duration_ms = Date.now() - entry._t0;
delete entry._t0;
if (isError) entry.err = details.error || "network_error";
// 分类逻辑由后续 task 5 接入
_netlogPush(entry);
}
chrome.webRequest.onCompleted.addListener(
(d) => _netlogFinalize(d, false),
NETLOG_URL_FILTER,
);
chrome.webRequest.onErrorOccurred.addListener(
(d) => _netlogFinalize(d, true),
NETLOG_URL_FILTER,
);- [ ] Step 3.6:手工验证 webRequest 抓到了请求
reload 扩展。在 service worker devtools console 执行:
chrome.runtime.sendMessage({ type: "NETLOG_SET_ENABLED", enabled: true });
chrome.runtime.sendMessage({ type: "NETLOG_CLEAR" });打开 https://www.xiaohongshu.com/,浏览 10 秒。再回 SW console 执行:
chrome.runtime.sendMessage({ type: "NETLOG_GET_ALL" }, r => console.log(r.entries.length, r.entries.slice(0, 3)));预期:entries.length > 0,输出 3 条样本,包含 method/url/status/reqFingerprint.cookie/respHeaders。如 entries 是空,回 Step 3.2-3.5 检查。
- [ ] Step 3.7:commit
git add extension/netlogger.js
git commit -m "feat(extension): netlogger 接入 webRequest 4 阶段监听"---
Task 4: interceptor.js 全量 hook + 响应体上报
目的: webRequest 拿不到响应体,业务域内通过 fetch/XHR hook 补齐。
Files:
- Modify:
extension/interceptor.js - Modify:
extension/content.js - Modify:
extension/background.js - Modify:
extension/netlogger.js(接收 interceptor 信号 + 关联)
- [ ] Step 4.1:interceptor.js 加 netlog 启用状态同步
修改 extension/interceptor.js —— 在文件顶部 IIFE 内、const BLOCKED = new Set([404, 461, 403, 999]); 后面,添加:
let _netlogEnabled = false;
const RESP_BODY_MAX = 4096;
// 通过 storage 同步 netlog 启用状态(MAIN world 不能直接读 chrome.storage)
// content.js 监听 storage.local 变化,postMessage 给我们
window.addEventListener("message", (e) => {
if (e.data?.source === "xhs-netlog-status") {
_netlogEnabled = !!e.data.enabled;
}
});- [ ] Step 4.2:interceptor.js 在 fetch hook 内增加全量上报
修改 extension/interceptor.js 的 window.fetch = async function (...) { ... },在 const resp = await _fetch.call(this, input, init); 之后插入响应体抓取逻辑:
const resp = await _fetch.call(this, input, init);
// 现有 BLOCKED 诊断保持不变
if (BLOCKED.has(resp.status) && (isApiUrl(url) || url.includes("xiaohongshu.com"))) {
emit(buildEvent(url, method, resp.status, headers, { intercept_type: "fetch" }));
}
// NetLog 全量记录(仅启用时)
if (_netlogEnabled && url.includes("xiaohongshu.com")) {
let respBody = null;
try {
const clone = resp.clone();
const text = await clone.text();
respBody = text.length > RESP_BODY_MAX ? text.slice(0, RESP_BODY_MAX) + "…[cut]" : text;
} catch (_) { respBody = "[unreadable]"; }
window.postMessage({
source: "xhs-netlog-intercept",
method,
url,
status: resp.status,
reqHeaders: headers,
respBody,
ts: Date.now(),
}, "*");
}
return resp;
};- [ ] Step 4.3:interceptor.js 在 XHR hook 内增加全量上报
修改 extension/interceptor.js 的 XMLHttpRequest.prototype.send = function () { ... },在 loadend 监听器内现有 BLOCKED 逻辑之后添加:
XMLHttpRequest.prototype.send = function () {
this.addEventListener("loadend", () => {
const url = this.__i_url || "";
// 现有 BLOCKED 诊断保持不变
if (BLOCKED.has(this.status) && (isApiUrl(url) || url.includes("xiaohongshu.com"))) {
emit(buildEvent(url, this.__i_method || "GET", this.status, this.__i_headers || {}, {
intercept_type: "xhr",
}));
}
// NetLog 全量记录
if (_netlogEnabled && url.includes("xiaohongshu.com")) {
let respBody = null;
try {
const t = this.responseText || "";
respBody = t.length > RESP_BODY_MAX ? t.slice(0, RESP_BODY_MAX) + "…[cut]" : t;
} catch (_) { respBody = "[unreadable]"; }
window.postMessage({
source: "xhs-netlog-intercept",
method: this.__i_method || "GET",
url,
status: this.status,
reqHeaders: this.__i_headers || {},
respBody,
ts: Date.now(),
}, "*");
}
});
return _xhrSend.apply(this, arguments);
};- [ ] Step 4.4:content.js 转发 interceptor 信号 + 同步启用状态
修改 extension/content.js,在文件底部追加(或合并到已有 message 监听器内 — 看现有结构决定):
// ─── NetLog 信号转发 + 启用状态同步 ──────────────────────────────
// MAIN world interceptor → content (postMessage) → background (runtime)
window.addEventListener("message", (e) => {
if (e.source !== window) return;
if (e.data?.source === "xhs-netlog-intercept") {
chrome.runtime.sendMessage({
type: "NETLOG_INTERCEPTOR_ENTRY",
payload: e.data,
}).catch(() => {});
}
});
// 启动时同步 netlog 启用状态到 MAIN world
function _syncNetlogStatus() {
chrome.runtime.sendMessage({ type: "NETLOG_GET_ENABLED" }, (resp) => {
window.postMessage({ source: "xhs-netlog-status", enabled: !!resp?.enabled }, "*");
});
}
_syncNetlogStatus();
// 监听 background 推送的启用状态变化
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "NETLOG_ENABLED_CHANGED") {
window.postMessage({ source: "xhs-netlog-status", enabled: !!msg.enabled }, "*");
}
});- [ ] Step 4.5:background.js 接 interceptor 信号 + 广播状态变化
修改 extension/background.js 的 chrome.runtime.onMessage.addListener 内,在 NetLog 路由块(Step 2.3 加的)后面添加:
if (msg.type === "NETLOG_INTERCEPTOR_ENTRY") {
netlogIngestInterceptor(msg.payload);
return false;
}并修改 netlogSetEnabled 调用点(找到 NETLOG_SET_ENABLED 路由),加广播:
if (msg.type === "NETLOG_SET_ENABLED") {
netlogSetEnabled(msg.enabled);
chrome.tabs.query({ url: ["*://*.xiaohongshu.com/*"] }, (tabs) => {
for (const t of tabs) {
chrome.tabs.sendMessage(t.id, { type: "NETLOG_ENABLED_CHANGED", enabled: msg.enabled }).catch(() => {});
}
});
sendResponse({ ok: true, enabled: netlogIsEnabled() });
return true;
}- [ ] Step 4.6:netlogger.js 实现 ingestInterceptor(关联回填)
在 extension/netlogger.js 底部追加:
const NETLOG_INTERCEPTOR_WINDOW_MS = 2000;
function netlogIngestInterceptor(payload) {
if (!_netEnabled || !payload) return;
// 在最近 2s 内倒序找匹配 (method + url),给该 entry 填响应体
for (let i = _netBuffer.length - 1; i >= 0; i--) {
const e = _netBuffer[i];
if (payload.ts - e.ts > NETLOG_INTERCEPTOR_WINDOW_MS) break;
if (e.method === payload.method && e.url === payload.url && !e.respBody) {
e.respBody = payload.respBody;
// 合并 interceptor 头(webRequest 拿不到的应用层头)
for (const [k, v] of Object.entries(payload.reqHeaders || {})) {
const lk = k.toLowerCase();
if (!e.reqHeaders[lk]) e.reqHeaders[lk] = v;
}
return;
}
}
// 没关联上,独立存
_netlogPush({
id: `${payload.ts}_intercept`,
requestId: "",
ts: payload.ts,
tsLabel: _tsLabel(payload.ts),
method: payload.method,
url: payload.url,
host: (() => { try { return new URL(payload.url).host; } catch (_) { return ""; } })(),
path: (() => { try { const u = new URL(payload.url); return u.pathname + (u.search || ""); } catch (_) { return payload.url; } })(),
resourceType: "xmlhttprequest",
tabId: -1,
reqHeaders: payload.reqHeaders || {},
reqBody: null,
reqFingerprint: null,
status: payload.status,
statusLine: "",
respHeaders: {},
respBody: payload.respBody,
setCookie: null,
duration_ms: 0,
err: null,
category: "other",
signals: [],
cookieDiff: null,
redirectTo: null,
errorCode: null,
_orphan: true,
});
}- [ ] Step 4.7:手工验证响应体关联
reload 扩展。SW console 启用:
chrome.runtime.sendMessage({ type: "NETLOG_CLEAR" });
chrome.runtime.sendMessage({ type: "NETLOG_SET_ENABLED", enabled: true });刷新 xiaohongshu.com 首页,浏览 10 秒。SW console 查:
chrome.runtime.sendMessage({ type: "NETLOG_GET_ALL" }, r => {
const withBody = r.entries.filter(e => e.respBody);
console.log("总", r.entries.length, "有响应体", withBody.length, withBody[0]);
});预期:xiaohongshu.com 域请求至少 30% 有 respBody(业务域 fetch/XHR 会被关联回填)。如全为 null,回 Step 4.1-4.6 检查;常见问题:interceptor world 没设 MAIN(manifest.json content_scripts[0].world)/ postMessage source 拼写不一致。
- [ ] Step 4.8:commit
git add extension/interceptor.js extension/content.js extension/background.js extension/netlogger.js
git commit -m "feat(extension): interceptor 全量 fetch/XHR hook 抓响应体,关联回填 netlog"---
Task 5: 检测维度分类 + cookieDiff
目的: 把入栈前的 entry 自动归类(category)和打信号(signals),为 popup 检测维度 tab 提供数据。
Files:
- Modify:
extension/netlogger.js
- [ ] Step 5.1:实现 classify + cookieDiff 函数
在 extension/netlogger.js 文件底部追加:
const NETLOG_FP_HOST_KEYWORDS = ["fp", "sec", "aegis", "sentry", "track", "log"];
const NETLOG_FP_BODY_KEYWORDS = ["webdriver", "navigator", "screen", "timezone", "platform"];
function _netlogClassify(entry) {
const signals = [];
let category = "other";
const hostLow = entry.host.toLowerCase();
const isFpHost = NETLOG_FP_HOST_KEYWORDS.some(kw => hostLow.includes(kw));
const bodyLow = (entry.reqBody || "").toLowerCase();
const fpBodyHit = NETLOG_FP_BODY_KEYWORDS.find(kw => bodyLow.includes(kw));
// 优先级:signature_failure > risk_redirect > business_error > fingerprint_upload > cookie_change > business_api > page_nav > other
if (entry.errorCode && /^30003[123]$/.test(entry.errorCode)) {
category = "signature_failure";
signals.push(`error_code:${entry.errorCode}`);
} else if (entry.redirectTo && /\/(404|login)(\/|\?|$)/.test(entry.redirectTo)) {
category = "risk_redirect";
signals.push(`redirect:${entry.redirectTo.slice(0, 60)}`);
} else if ([401, 403, 461, 999].includes(entry.status)) {
category = "business_error";
signals.push(`status:${entry.status}`);
} else if (isFpHost || fpBodyHit) {
category = "fingerprint_upload";
signals.push("fingerprint_upload");
if (fpBodyHit) signals.push(`body_contains:${fpBodyHit}`);
} else if (entry.setCookie && entry.setCookie.length > 0) {
// cookie_change 由 cookieDiff 进一步判断(见下)
category = "other";
} else if (entry.resourceType === "main_frame") {
category = "page_nav";
} else if (entry.path && entry.path.includes("/api/") && entry.status >= 200 && entry.status < 300) {
category = "business_api";
}
return { category, signals };
}
function _netlogComputeCookieDiff(entry) {
if (!entry.setCookie || entry.setCookie.length === 0) return null;
const prev = _lastHostCookies.get(entry.host) || new Set();
const curr = new Set(entry.setCookie);
const added = [...curr].filter(k => !prev.has(k));
const removed = [...prev].filter(k => !curr.has(k));
_lastHostCookies.set(entry.host, curr);
if (added.length === 0 && removed.length === 0) return null;
return { added, removed, changed: [] };
}- [ ] Step 5.2:在 finalize 时调用 classify + cookieDiff
修改 extension/netlogger.js 的 _netlogFinalize 函数(Step 3.5 写的),在 _netlogPush(entry) 之前插入分类逻辑:
function _netlogFinalize(details, isError) {
if (!_netEnabled) return;
const entry = _netPending.get(details.requestId);
_netPending.delete(details.requestId);
if (!entry) return;
entry.duration_ms = Date.now() - entry._t0;
delete entry._t0;
if (isError) entry.err = details.error || "network_error";
// 分类 + cookieDiff
const { category, signals } = _netlogClassify(entry);
entry.category = category;
entry.signals = signals;
entry.cookieDiff = _netlogComputeCookieDiff(entry);
if (entry.cookieDiff) {
entry.category = entry.category === "other" ? "cookie_change" : entry.category;
entry.signals.push("set_cookie_changed:" + entry.cookieDiff.added.join(","));
}
_netlogPush(entry);
}同样在 netlogIngestInterceptor 的 orphan 分支(Step 4.6)里,在 _netlogPush(...) 之前给 orphan entry 也跑一次分类:
修改 extension/netlogger.js 的 netlogIngestInterceptor,在构造 orphan entry 之后、_netlogPush(...) 之前插入:
// orphan 也跑分类
const orphanEntry = /* 上面构造的对象 */; // 提取到变量
const { category, signals } = _netlogClassify(orphanEntry);
orphanEntry.category = category;
orphanEntry.signals = signals;
_netlogPush(orphanEntry);具体改法:把 Step 4.6 中 _netlogPush({ ... }) 改成:
const orphanEntry = {
id: `${payload.ts}_intercept`,
requestId: "",
/* ...原 Step 4.6 中所有字段 ... */
_orphan: true,
};
const { category, signals } = _netlogClassify(orphanEntry);
orphanEntry.category = category;
orphanEntry.signals = signals;
_netlogPush(orphanEntry);- [ ] Step 5.3:手工验证分类结果
reload 扩展。SW console:
chrome.runtime.sendMessage({ type: "NETLOG_CLEAR" });
chrome.runtime.sendMessage({ type: "NETLOG_SET_ENABLED", enabled: true });打开 xiaohongshu.com,浏览 + 搜索 30 秒。再访问一个 fake 笔记 URL(去掉 xsec_token):https://www.xiaohongshu.com/explore/abc1234567890。
SW console 查分类汇总:
chrome.runtime.sendMessage({ type: "NETLOG_GET_ALL" }, r => {
const groups = {};
for (const e of r.entries) groups[e.category] = (groups[e.category] || 0) + 1;
console.log("分类汇总", groups);
console.log("signature_failure 样本:", r.entries.find(e => e.category === "signature_failure"));
});预期:至少出现 business_api / page_nav / risk_redirect 或 signature_failure(手工触发的 fake URL 应当落入后两类之一)。
- [ ] Step 5.4:commit
git add extension/netlogger.js
git commit -m "feat(extension): netlogger 加入分类 + cookieDiff 逻辑"---
Task 6: popup UI - 彩蛋激活 + NetLog 卡片 + 时序流
目的: popup 标题连点 5 次激活 NetLog;激活后 popup 增宽,下方显示时序流。
Files:
- Modify:
extension/popup.html - Modify:
extension/popup.js
- [ ] Step 6.1:popup.html 添加 NetLog 卡片骨架(默认隐藏)+ 自适应宽度样式
修改 extension/popup.html。
将 body { width: 220px; ... } 改为:
body {
width: 220px;
padding: 16px;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px;
color: #333;
margin: 0;
transition: width 0.2s;
}
body.netlog-on { width: 580px; }并在 <style> 末尾追加 NetLog 专属样式:
.netlog-card { display: none; }
body.netlog-on .netlog-card { display: block; }
.netlog-tabs { display: flex; gap: 4px; margin-bottom: 6px; }
.netlog-tab {
padding: 3px 10px;
font-size: 11px;
cursor: pointer;
border-radius: 4px;
background: #f1f3f4;
color: #555;
}
.netlog-tab.active { background: #1a73e8; color: #fff; }
.netlog-row {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 10px;
line-height: 1.5;
padding: 2px 4px;
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.netlog-row:hover { background: #f8f9fa; }
.netlog-row.cat-fingerprint_upload { color: #b7950b; }
.netlog-row.cat-business_error,
.netlog-row.cat-risk_redirect,
.netlog-row.cat-signature_failure { color: #c5221f; }
.netlog-row.cat-cookie_change { color: #1565c0; }
.netlog-list { max-height: 320px; overflow-y: auto; border: 1px solid #eee; padding: 4px; border-radius: 4px; }
.netlog-detail {
background: #fafafa;
border: 1px solid #eee;
border-radius: 4px;
padding: 6px;
margin-top: 6px;
font-family: ui-monospace, monospace;
font-size: 10px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.netlog-toolbar { display: flex; gap: 6px; margin-bottom: 6px; }
.netlog-toolbar button {
flex: 1;
padding: 3px 0;
font-size: 11px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
}在 <body> 内、<script type="module" src="popup.js"></script> 之前追加 NetLog 卡片 HTML:
<hr class="divider">
<div class="netlog-card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<span style="font-size:12px;font-weight:600;color:#333">NetLog</span>
<span style="font-size:10px;color:#888" id="netlog-count">0 条</span>
</div>
<div class="netlog-tabs">
<div class="netlog-tab active" data-tab="stream">时序流</div>
<div class="netlog-tab" data-tab="category">检测维度</div>
</div>
<div class="netlog-toolbar">
<button id="netlog-clear">清空</button>
<button id="netlog-export">导出 JSON</button>
<button id="netlog-disable">关闭</button>
</div>
<div class="netlog-list" id="netlog-list"></div>
<div class="netlog-detail" id="netlog-detail" style="display:none"></div>
</div>将原 <h3>XHS Bridge</h3> 改为可点击标题:
<h3 id="title-hit" style="cursor:default;user-select:none">XHS Bridge</h3>- [ ] Step 6.2:popup.js 加彩蛋点击逻辑 + 启用状态读取
在 extension/popup.js 文件底部追加:
// ─── NetLog 彩蛋激活 + 状态 ──────────────────────────────────────
const NETLOG_HIT_TARGET = 5;
const NETLOG_HIT_RESET_MS = 500;
let _netlogHits = 0;
let _netlogHitTimer = null;
const titleEl = document.getElementById("title-hit");
titleEl?.addEventListener("click", () => {
_netlogHits++;
clearTimeout(_netlogHitTimer);
_netlogHitTimer = setTimeout(() => { _netlogHits = 0; }, NETLOG_HIT_RESET_MS);
if (_netlogHits >= NETLOG_HIT_TARGET) {
_netlogHits = 0;
chrome.runtime.sendMessage({ type: "NETLOG_GET_ENABLED" }, (resp) => {
if (resp?.enabled) {
if (confirm("关闭 NetLog?")) toggleNetlog(false);
} else {
toggleNetlog(true);
}
});
}
});
function toggleNetlog(enabled) {
chrome.runtime.sendMessage({ type: "NETLOG_SET_ENABLED", enabled }, () => {
applyNetlogUI(enabled);
if (enabled) refreshNetlog();
});
}
function applyNetlogUI(enabled) {
document.body.classList.toggle("netlog-on", !!enabled);
}
// 初始化:根据当前启用状态决定显示
chrome.runtime.sendMessage({ type: "NETLOG_GET_ENABLED" }, (resp) => {
if (resp?.enabled) {
applyNetlogUI(true);
refreshNetlog();
}
});
document.getElementById("netlog-disable")?.addEventListener("click", () => toggleNetlog(false));
document.getElementById("netlog-clear")?.addEventListener("click", () => {
chrome.runtime.sendMessage({ type: "NETLOG_CLEAR" }, () => refreshNetlog());
});- [ ] Step 6.3:popup.js 加时序流渲染 + 实时增量
在 extension/popup.js 文件底部继续追加:
// ─── NetLog 时序流渲染 ──────────────────────────────────────────
let _netlogEntries = [];
let _netlogTab = "stream";
function refreshNetlog() {
chrome.runtime.sendMessage({ type: "NETLOG_GET_ALL" }, (resp) => {
_netlogEntries = resp?.entries || [];
renderNetlog();
});
}
function renderNetlog() {
const countEl = document.getElementById("netlog-count");
if (countEl) countEl.textContent = `${_netlogEntries.length} 条`;
const list = document.getElementById("netlog-list");
if (!list) return;
if (_netlogTab === "stream") {
renderNetlogStream(list);
} else {
renderNetlogCategory(list);
}
}
const NETLOG_CAT_LABEL = {
fingerprint_upload: "指纹↑",
business_error: "错误",
risk_redirect: "风控跳",
signature_failure: "签名失败",
cookie_change: "Cookie 变",
business_api: "API",
page_nav: "导航",
other: "其他",
};
function renderNetlogStream(container) {
// 最新在底,倒序展示前 200 条
const slice = _netlogEntries.slice(-200);
container.innerHTML = slice.map((e, i) => {
const star = (e.category === "fingerprint_upload" || e.category === "risk_redirect" ||
e.category === "signature_failure") ? " ★" : "";
const path = e.path.length > 50 ? e.path.slice(0, 47) + "…" : e.path;
const host = e.host.replace(/^www\./, "");
return `<div class="netlog-row cat-${e.category}" data-idx="${i}">
${e.tsLabel} ${e.method.padEnd(4)} ${e.status || "?"} ${e.duration_ms}ms ${host}${path} [${NETLOG_CAT_LABEL[e.category]}]${star}
</div>`;
}).join("");
// 点击展开详情
container.querySelectorAll(".netlog-row").forEach(row => {
row.addEventListener("click", () => {
const idx = Number(row.dataset.idx);
showNetlogDetail(slice[idx]);
});
});
// 滚到底
container.scrollTop = container.scrollHeight;
}
function showNetlogDetail(entry) {
const el = document.getElementById("netlog-detail");
if (!el) return;
el.style.display = "block";
el.textContent = JSON.stringify(entry, null, 2);
}
// tab 切换
document.querySelectorAll(".netlog-tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".netlog-tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
_netlogTab = tab.dataset.tab;
renderNetlog();
});
});
// 实时增量
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "NETLOG_ENTRY_ADDED" && document.body.classList.contains("netlog-on")) {
_netlogEntries.push(msg.entry);
if (_netlogEntries.length > 500) _netlogEntries.splice(0, _netlogEntries.length - 500);
renderNetlog();
}
});- [ ] Step 6.4:手工验证彩蛋激活 + 时序流
reload 扩展。点击工具栏图标打开 popup。点击 "XHS Bridge" 标题 5 次(500ms 内连点)。预期:popup 立即扩宽到 580px;底部出现 NetLog 卡片;如果之前 task 步骤里有跑过 xhs 浏览,应有时序流条目。打开 xiaohongshu.com 让请求发起,popup 时序流应该实时增加。
如果点 5 次后没激活:在 popup devtools(右键 popup → Inspect)console 看 _netlogHits 计数有没有累加,常见问题:title-hit id 没生效 / click handler 没绑定。
- [ ] Step 6.5:commit
git add extension/popup.html extension/popup.js
git commit -m "feat(extension): popup 加彩蛋激活 + NetLog 时序流面板"---
Task 7: popup UI - 检测维度 tab + JSON 导出
目的: "检测维度" tab 按 category 聚合展示;"导出 JSON" 把缓冲全量导出为下载文件。
Files:
- Modify:
extension/popup.js
- [ ] Step 7.1:popup.js 添加按 category 分组渲染
在 extension/popup.js 的 renderNetlogStream 函数之后追加:
function renderNetlogCategory(container) {
const groups = {};
for (const e of _netlogEntries) {
if (!groups[e.category]) groups[e.category] = [];
groups[e.category].push(e);
}
const order = ["fingerprint_upload", "signature_failure", "risk_redirect",
"business_error", "cookie_change", "business_api", "page_nav", "other"];
const sections = [];
for (const cat of order) {
if (!groups[cat] || groups[cat].length === 0) continue;
const label = NETLOG_CAT_LABEL[cat];
const items = groups[cat].slice(-50); // 每类最多展示 50 条
sections.push(`
<details open style="margin-bottom:6px">
<summary style="cursor:pointer;font-size:11px;font-weight:600;color:#444">▾ ${label} (${groups[cat].length})</summary>
${items.map((e, i) => {
const path = e.path.length > 60 ? e.path.slice(0, 57) + "…" : e.path;
const signal = (e.signals || []).slice(0, 2).join(", ");
return `<div class="netlog-row cat-${e.category}" data-cat="${cat}" data-idx="${i}">
${e.tsLabel} ${e.method.padEnd(4)} ${e.status || "?"} ${e.host.replace(/^www\./, "")}${path}${signal ? " ["+signal+"]" : ""}
</div>`;
}).join("")}
</details>
`);
}
container.innerHTML = sections.join("") || '<div style="color:#aaa;font-size:11px;padding:8px">暂无数据</div>';
// 详情点击:data-cat + data-idx 反查
container.querySelectorAll(".netlog-row").forEach(row => {
row.addEventListener("click", () => {
const cat = row.dataset.cat;
const idx = Number(row.dataset.idx);
const entry = groups[cat]?.slice(-50)[idx];
if (entry) showNetlogDetail(entry);
});
});
}- [ ] Step 7.2:popup.js 添加 JSON 导出
在 extension/popup.js 文件底部追加:
document.getElementById("netlog-export")?.addEventListener("click", () => {
const blob = new Blob([JSON.stringify(_netlogEntries, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `xhs-netlog-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
});- [ ] Step 7.3:手工验证检测维度 + 导出
reload 扩展。打开 popup(NetLog 已激活态会自动恢复)。打开 xhs 浏览 30 秒。
- 点击 "检测维度" tab → 预期看到几个折叠分组(fingerprint_upload / business_api / page_nav / 等),每个分组下有条目;点击条目 → 详情区显示 JSON。
- 点击 "导出 JSON" → 浏览器下载一个文件名形如
xhs-netlog-2026-05-19T...json的文件,打开能看到所有 entries。
- [ ] Step 7.4:commit
git add extension/popup.js
git commit -m "feat(extension): popup NetLog 检测维度 tab + JSON 导出"---
Task 8: 端到端冒烟 + 调研结果回填
目的: 完整跑通 spec 列出的所有场景,证明 netlogger 能反映检测维度。
Files:
- Modify:
docs/superpowers/specs/2026-05-19-xhs-netlogger-design.md(追加冒烟报告)
- [ ] Step 8.1:浏览场景
1. 关浏览器再开(验证扩展重启不丢启用状态) 2. 打开 popup,确认 NetLog 已激活(因 storage 持久) 3. 打开 xhs 首页,滚动 30 秒 4. 切到 "检测维度" tab,确认看到 fingerprint_upload 类目(如果没有,说明 host_permissions 没覆盖到实际上报域,回 Task 1 复查)
- [ ] Step 8.2:故意触发 signature_failure
清空 NetLog,在地址栏直接输入:https://www.xiaohongshu.com/explore/65000000000000000000000a(无 xsec_token 的伪笔记 URL)。预期 NetLog 时序流出现 302 重定向到 /404?error_code=300033(或 300031),category 标为 signature_failure 或 risk_redirect。
- [ ] Step 8.3:账号切换触发 clear(本期未实现,留作后续验证)
注意: spec 提到 "用户切换账号(a1 变化)→ 触发自动 clear",但当前 plan 没有任务实现这个逻辑(避免本期 scope 膨胀)。在本步骤里跳过验证,把 "账号切换自动 clear" 作为已知 follow-up 列入 spec 附录的 "已知限制" 章节。
- [ ] Step 8.4:性能观察
不启用 NetLog vs 启用 NetLog,分别在 xhs 首页滚动 30s,打开 chrome://serviceworker-internals/ 看扩展 SW 的 CPU 占用(人工对比)。预期启用后偶发尖峰、稳态低(< 5%)。
- [ ] Step 8.5:把冒烟结果写入 spec 附录
在 docs/superpowers/specs/2026-05-19-xhs-netlogger-design.md 末尾追加:
## 附录:实施后冒烟结果(2026-05-19)
- Task 8.1 浏览首页:时序流 ~80 条,检测维度 tab 分布合理(business_api/page_nav 占多数,fingerprint_upload N 条,cookie_change M 条)。
- Task 8.2 signature_failure:实测触发 error_code=____ ,分类正确。
- Task 8.3 账号切换自动 clear:本期未实现,列入已知限制。
- Task 8.4 性能:启用后 SW CPU 偶发 X%,稳态 Y%。
## 已知限制(截至 2026-05-19)
- 账号切换不自动 clear(用户需手动清空)
- 无 CLI 暴露(用户决定)
- 跨域风控上报域响应体看不到(chrome.webRequest 限制)
- 同一 url 短时间高并发请求时关联可能漏配(2s 时间窗 + url 完全匹配)把 ____ / X / Y 等占位换成实际观测值。
- [ ] Step 8.6:commit
git add docs/superpowers/specs/2026-05-19-xhs-netlogger-design.md
git commit -m "docs: 回填 XHS netlogger 冒烟结果与已知限制"---
Self-Review
Spec coverage check(spec 章节 → plan 任务):
| Spec 章节 | 对应 Task |
|---|---|
| 目标 / 非目标 | 整体 plan |
| 选定方案 B | Task 2-5(webRequest + interceptor 融合) |
| 组件结构 | 全部 Task |
| 数据流 | Task 3 + Task 4 |
| 启用开关 / 彩蛋激活 | Task 2 (开关) + Task 6 (彩蛋) |
| interceptor ↔ netlogger 关联 | Task 4 |
| 检测维度自动分类 | Task 5 |
| 性能与容量 | Task 3 (skip types) + Task 5 (分类) + Task 8 (性能验证) |
| NetLogEntry schema | Task 3 (基础) + Task 4 (响应体) + Task 5 (分类字段) |
| popup NetLog 面板草图 | Task 6 (HTML+时序流) + Task 7 (检测维度+导出) |
| 错误处理 & 边缘情况 | 分散在各 Task;账号切换自动 clear 显式标为已知限制 |
| 实现顺序与工作量 | 严格按 spec 的 8 个 task |
| 测试方案 | Task 8 |
Placeholder scan: 无 TBD / TODO;Step 8.5 的 ____ / X / Y 是执行时填入的占位符(合理,由实施者用实测值替换)。
Type consistency:
_netEnabled/_netBuffer/_netPending/_lastHostCookies在 Task 2 定义,Task 3-5 一致使用NETLOG_STORAGE_KEY/NETLOG_ENABLED_KEY在 Task 2 定义,Task 4 中通过相同 message type 间接使用- message type 名一致:
NETLOG_GET_ALL/NETLOG_SET_ENABLED/NETLOG_GET_ENABLED/NETLOG_CLEAR/NETLOG_ENTRY_ADDED/NETLOG_ENABLED_CHANGED/NETLOG_INTERCEPTOR_ENTRY - category 枚举值一致:
fingerprint_upload/business_error/risk_redirect/signature_failure/cookie_change/business_api/page_nav/other
已知偏离 TDD: 见文档头部说明(spec 决定不写自动化测试)。每个 task 用手工验证步骤代替。
XHS NetLogger 设计文档
日期:2026-05-19 分支:feat/extension-bridge 目标:在浏览器扩展中加入小红书网页版网络监听能力,反向推导 XHS 用于检测自动化的"维度",让插件 / Claude 操作时能规避风控。
参考实现:D:\OSS\BHP_Production\BHP\modules\net-logger.js(BOSS 直聘网页版的 webRequest 全量监听器)。
---
目标与非目标
目标
- 在 xiaohongshu.com 业务域 + 风控上报域采集足够信息,识别 XHS 服务端用来判定自动化的信号(cookie / header / 请求体指纹 / 响应错误码 / Set-Cookie 变化)。
- 数据在扩展 popup 内消费,同时通过 CLI 命令
get-netlog/risk-report暴露给 LLM/Claude,让自动化操作过程中能主动判断风控状态。 - 隐藏入口:默认对普通使用零感知,通过彩蛋(标题连点 5 次)激活。
非目标
- 不做跨会话 diff、两会话对比(方案 C,本期延后)。
- 不做风控域名的自定义配置 UI(本期 hardcode)。
- 不做自动化测试(浏览器扩展 + 真实 XHS 域难离线复现)。
- 不替换现有 404 诊断(interceptor.js 现有逻辑)或 302 观测(background.js webRequest 现有逻辑),netlogger 是新增的独立 listener。
---
选定方案
方案 B:webRequest 全量 + fetch/XHR hook 融合。
chrome.webRequest4 阶段监听拿外层 HTTP 信号 + 请求体(指纹上报的关键载荷)interceptor.js在 xiaohongshu.com 域 MAIN world hook fetch/XHR 拿响应体 + 应用层 header- 按
(method, url, 2s 时间窗)模糊关联两侧记录,合并为一条 - popup 面板「时序流」 + 「检测维度」双 tab
排除方案 A(webRequest only,看不到响应体与签名细节)与方案 C(B + 会话对比,工作量太大且 ROI 不确定,先把 B 做扎实)。
---
组件结构
| 文件 | 状态 | 职责 |
|---|---|---|
extension/netlogger.js | 新建 | webRequest 4 阶段监听 + 环形缓冲 + storage 持久化 |
extension/interceptor.js | 扩充 | 现有 404 诊断保留;新增"启用 netlog 时"记录全量 fetch/XHR 响应体 + 应用层 header |
extension/background.js | 扩充 | importScripts netlogger.js;接 interceptor 上来的响应体信号;按 url+ts 关联 |
extension/popup.html / popup.js | 扩充 | NetLog 卡片(彩蛋激活后显示)+ 时序流 / 检测维度 双 tab |
extension/manifest.json | 扩充 | host_permissions 加风控上报域 |
数据流
用户请求
│
├─→ chrome.webRequest (netlogger.js, background)
│ ① onBeforeRequest → method/url/reqBody bytes
│ ② onSendHeaders → 最终请求头(含 cookie/xs/xt/sec-fetch-*)
│ ③ onHeadersReceived → status/respHeaders/set-cookie
│ ④ onCompleted/Error → 耗时、错误码
│
└─→ interceptor.js (MAIN world, 仅 xiaohongshu.com)
fetch/XHR hook → 响应体 + 应用层 header
postMessage → content.js → background → netlogger
↓ 按 (method+url+时间窗) 模糊关联
netlogger 环形缓冲 (500 条)
│
├─→ 每 10 条 / 关键事件触发写入
│ chrome.storage.local.netLog
│
└─→ popup 拉取 + chrome.runtime push 实时增量---
关键设计点
A. 启用开关 / 彩蛋激活
chrome.storage.local.netlogEnabled(boolean)作为持久化开关- popup 顶部标题 "XHS Bridge" 连点 5 次切换;500ms 内无点击则计数器重置
- 激活后下方多出一张 "NetLog" 卡片
- 未启用时 netlogger / interceptor 的全部监听器 early return,零开销
- 已激活时再连点 5 次触发确认对话框,确认后关闭
B. interceptor ↔ netlogger 关联
- 业务域内 interceptor 拿到响应后,按
(method, url, 最近 2s 内的 webRequest 记录)关联回填响应体 - 关联失败的 interceptor 记录独立存入,标记
_orphan: true - 跨域上报(fp / sentry / aegis 等)只走 webRequest 路径,不要求关联响应体
C. 检测维度自动分类(启发式)
| category | 判定条件 |
|---|---|
fingerprint_upload | host 含 fp / sec / aegis / sentry / track 关键字 OR 请求体含 webdriver / navigator / screen / timezone 字段 |
business_error | status ∈ {401, 403, 461, 999} OR (HTTP 200 + 前端渲染 404) |
risk_redirect | 302 → /404 或 /login |
signature_failure | 302 Location 含 error_code=300031 / 300032 / 300033 |
cookie_change | Set-Cookie 中新键 / 值变更(与上一条同 host 记录比较) |
business_api | 路径含 /api/ 且 status=2xx |
page_nav | resourceType=main_frame |
other | 其他 |
一条 entry 可命中多个 signal(写入 signals: string[]),但 category 只取主分类(按上表自上而下的优先级)。
D. 性能与容量
- 环形缓冲 500 条上限
- 单条请求体截断 2KB / 响应体截断 4KB
- 静态资源(image / font / stylesheet)按
webRequest details.type过滤,不入栈 - 启用状态下预估额外开销:~5-10% CPU 在请求峰值时
- chrome.storage.local 限额 5MB,本设计总量预估 < 3MB
---
数据 Schema
interface NetLogEntry {
// ── 标识 ──
id: string; // `${ts}_${requestId}`
requestId: string; // webRequest requestId
ts: number; // onBeforeRequest 时间戳 (ms)
tsLabel: string; // "HH:mm:ss.SSS"
// ── HTTP 基础 ──
method: string;
url: string;
path: string; // pathname + 关键 query (xsec_token=…&xsec_source=…)
host: string;
resourceType: string; // main_frame / xhr / fetch / sub_frame
tabId: number;
// ── 请求(webRequest + interceptor 融合)──
reqHeaders: Record<string, string>; // 仅保留关键白名单
reqBody: string | null; // 截断 2KB;raw bytes 解码或 formData JSON
reqFingerprint: {
has_xs: boolean;
has_xt: boolean;
has_xsCommon: boolean;
sec_fetch_site: string | null;
sec_fetch_mode: string | null;
referer: string | null;
origin: string | null;
ua_prefix: string; // user-agent 前 80 字符
cookie: {
has_a1: boolean;
has_web_session: boolean;
has_webId: boolean;
has_gid: boolean;
a1_preview: string | null;
web_session_preview: string | null;
};
};
// ── 响应 ──
status: number;
statusLine: string; // "200 OK" / "302 Found" / …
respHeaders: Record<string, string>;
respBody: string | null; // 截断 4KB;仅业务域(interceptor 能拿到)
setCookie: string[] | null; // 解析后的 cookie 名列表
// ── 时序 ──
duration_ms: number;
err: string | null;
// ── 分析层(netlogger 计算)──
category: NetLogCategory;
signals: string[]; // 例: ["fingerprint_upload", "set_cookie_changed:sec_xxx"]
cookieDiff: {
added: string[];
changed: string[];
removed: string[];
} | null;
redirectTo: string | null; // 302 Location
errorCode: string | null; // 300031/300032/… 从 location query 解析
_orphan?: true; // interceptor 未关联到 webRequest 时标记
}
type NetLogCategory =
| "fingerprint_upload"
| "business_error"
| "risk_redirect"
| "signature_failure"
| "cookie_change"
| "business_api"
| "page_nav"
| "other";关键 cookie 白名单
请求 cookie 字段中以下 key 会被记录到 reqFingerprint.cookie:
a1(设备指纹种子)web_session(登录态)webIdgid- 其他不在白名单的 cookie 名记入
signals但值不存
关键 header 白名单(reqHeaders)
xs, xt, x-s-common, x-t, x-mns-platform,
sec-fetch-site, sec-fetch-mode, sec-fetch-dest, sec-fetch-user,
referer, origin, user-agent,
content-type, accept, accept-language响应 header 白名单(respHeaders)
location, set-cookie, cache-control, x-request-id,
content-type, server, x-application-context---
popup NetLog 面板(草图)
┌─ XHS Bridge ───────────────────────┐
│ [logo] XHS Bridge ● connected│ ← 标题连点 5 次激活
├────────────────────────────────────┤
│ 已连接到 bridge server │
│ 当前账号:xxx │
│ [打开小红书] [退出登录] │
├────────────────────────────────────┤
│ ▼ NetLog [enabled] [clear] [⬇json]│ ← 激活后显示
│ ┌──────────────────────────────┐ │
│ │[ 时序流 ] [ 检测维度 ] │ │
│ ├──────────────────────────────┤ │
│ │ 时序流: │ │
│ │ 18:42:11.245 GET /api/sns/web/v1/feed 200 142ms [API] │
│ │ 18:42:11.512 POST fp.snssdk.com/v1/fp 200 88ms [指纹↑] ★ │
│ │ 18:42:12.103 GET /explore/abc?xsec_… 302 41ms [风控跳转] ★ │
│ │ 18:42:12.180 GET /404?error_code=… 200 120ms [API] │
│ │ … │
│ ├──────────────────────────────┤ │
│ │ 检测维度(点击展开详情): │ │
│ │ ▾ 指纹上报 (12) │ │
│ │ fp.snssdk.com/v1/fp ×8 [body 含 webdriver/screen/…] │
│ │ sec.xiaohongshu.com ×4 │
│ │ ▾ 签名失败 (3) │ │
│ │ error_code=300033 token 与 session/IP 不匹配 │
│ │ ▾ Cookie 变化 (5) │ │
│ │ sec_xxx 新增(响应来自 /api/.../init)→ 风控触发标记 │
│ │ ▾ 业务错误 (2) / 风控跳转 (1) │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘- 行点击展开看完整 entry(折叠 JSON 视图)
★标记需要重点关注的(指纹上报 / 风控跳转)- "⬇json" 按钮把当前缓冲全量导出为 JSON 文件下载
---
错误处理与边缘情况
| 场景 | 处理 |
|---|---|
| webRequest 拿不到 reqBody(POST + 二进制) | reqBody: "[binary]",只记字节数 |
| interceptor 跨标签页污染 | content.js 校验 sender.tab.id 与 background 记录的 tabId 匹配 |
| popup 关闭再开时数据丢失 | 数据存在 chrome.storage.local,popup 重新拉即可 |
| storage 写满(5MB 限制) | 环形缓冲 500 条 + 单条体积上限保证总量 < 3MB |
| 用户切换账号(a1 变化) | 触发自动 clear,避免跨账号污染分析 |
| 风控域名扩展 | host_permissions hardcode;本期不做配置 UI |
| service worker 重启丢内存缓冲 | webRequest listener 在 SW 重启后重新注册;内存丢失但 storage 持久部分保留 |
---
实现顺序与工作量估算
| # | 任务 | 改动文件 | 估算 | 验收 |
|---|---|---|---|---|
| 1 | 加风控域名到 host_permissions + 调研真实上报域名清单 | manifest.json | ~30 LOC | 装载扩展无错;调研结论补充到本文档 |
| 2 | 创建 netlogger.js:4 阶段 webRequest 监听 + 环形缓冲 + storage 持久化 + 启用开关 | 新建 extension/netlogger.js (~300 LOC) | ~3h | 启用后能在 storage 里看到 entries;关闭后零监听器活跃 |
| 3 | background.js 引入 netlogger + 暴露查询/清空消息接口 | background.js (~50 LOC) | ~30min | popup 能通过 runtime.sendMessage 拉到 log |
| 4 | interceptor.js 扩充:启用 netlog 时记录全量 fetch/XHR 响应体 + postMessage 上报 | interceptor.js (~80 LOC) | ~1.5h | 业务域内能拿到响应体并关联到 netlogger entry |
| 5 | netlogger 关联 + 分类逻辑:interceptor 信号回填 + category/signals/cookieDiff 计算 | netlogger.js (~150 LOC) | ~2h | 每条 entry 有正确 category;cookieDiff 能算出 Set-Cookie 变化 |
| 6 | popup UI:彩蛋激活 + NetLog 卡片 + 时序流 tab | popup.html / popup.js (~250 LOC) | ~3h | 标题连点 5 次激活;时序流实时刷新;点击行展开详情 |
| 7 | popup UI:检测维度 tab + 折叠分组 + json 导出按钮 | popup.html / popup.js (~150 LOC) | ~2h | 5 个分类正确归类;导出 JSON 文件可下载 |
| 8 | 手工冒烟测试:浏览/搜索/故意触发风控/切账号 | — | ~1h | 全场景跑通;分类无误判 |
估算总量:约 1000 LOC + 约 13h 集中工作时间。
---
主要风险与未知点
1. 风控上报域名清单不确定 — 目前对 XHS 实际使用的指纹上报域名只有部分猜测(fp.snssdk.com 等字节系常见域,但 XHS 是否用同套不确定)。任务 1 包含调研:先用宽松 host_permissions 临时监听一阵,看实际有哪些跨域 POST,再固化清单。 2. interceptor ↔ webRequest 关联失败率 — 时间窗匹配可能漏关联(特别是高并发短连接)。先用 2s 窗 + url 完全匹配,观察漏匹配率,必要时改用 Performance.getEntriesByName 拿浏览器侧 timing 辅助匹配。 3. chrome.storage.local 跨会话恢复 — Manifest V3 SW 在用户关闭浏览器时会被销毁;storage 持久但内存缓冲来不及写入的 N 条会丢。可接受。 4. xs / xt 签名内部值无法拿到 — 只能拿到 fetch init.headers 里显式设置的;XHS SDK 若在更深层(如 Service Worker 拦截后注入)注入 header,fetch hook 也拿不到,需 chrome.debugger(本期不做)。 5. 彩蛋激活的 UX — 5 次点击前无任何视觉反馈,新装扩展用户完全不知道这功能存在 —— 这是设计目标,不算风险。
---
测试方案
1. 手工冒烟:
- 激活 NetLog → 打开 xiaohongshu.com → 浏览/搜索 → 检查时序流是否完整、检测维度分类是否合理
- 故意不带 xsec_token 直接访问 /explore/xxx → 验证抓到
signature_failure/risk_redirect分类 - 切账号 → 验证自动 clear 触发
2. 回归:现有 cmd_diagnose_404 / cmd_check_risk 不受影响(netlogger 是独立 listener,不替换原有逻辑) 3. 性能:开 / 不开 netlog 在 xhs 首页滚动 30s,对比 SW CPU 占用(人工观察 chrome://serviceworker-internals) 4. 不写自动化测试
---
交付物
- 修改后的
extension/{netlogger.js, background.js, interceptor.js, popup.html, popup.js, manifest.json} - 本设计文档
- 后续 brainstorming 出来的 implementation plan(spec 批准后由 writing-plans 产出)
---
附录:风控/上报域名调研(2026-05-19)
通过 performance.getEntriesByType('resource') 在 xiaohongshu.com 上抓到的跨域 host 清单(首页 + 搜索 + 笔记详情 浏览 ~1min):
| Host | 用途推断 | 是否监听 |
|---|---|---|
apm-fe.xiaohongshu.com | 前端 APM 监控(性能/错误/风控数据上报) | ✓ |
as.xiaohongshu.com | 应用统计 / 事件上报 | ✓ |
edith.xiaohongshu.com | XHS 主业务 API(含 /api/sns/web/...) | ✓ |
t2.xiaohongshu.com | tracking 上报 | ✓ |
picasso-static.xiaohongshu.com | 静态资源(图片处理服务) | ✓(业务域内,顺便监听) |
www.xiaohongshu.com / xiaohongshu.com / creator.xiaohongshu.com | 业务页面 + API | ✓ |
sns-avatar-qc.xhscdn.com / sns-na-i2.xhscdn.com / sns-webpic-qc.xhscdn.com | 图片 CDN | ✗(静态资源,过滤掉) |
固化的 `host_permissions`:
"host_permissions": [
"https://*.xiaohongshu.com/*",
"https://xiaohongshu.com/*",
"ws://localhost/*"
]一行通配 *.xiaohongshu.com 覆盖所有当前 + 未来 XHS 子域。xhscdn.com 不加(图片资源 / NETLOG_SKIP_TYPES 已过滤)。
调研未发现的潜在域名:
- 没看到
fp.xiaohongshu.com(指纹)—— 可能用 a1 cookie 派生 + edith 内嵌而非独立子域 - 没看到
sec.xiaohongshu.com(安全)—— 同上 - 没看到 sentry / aegis 等第三方 —— XHS 用自家 apm-fe,未外接
如果后续遇到 netlog 漏抓某些请求,回头检查 host_permissions 是否需要扩展。
---
CLI 风控接口
背景:Task 8 冒烟实测发现 XHS 检测维度的核心规律后,决定将 netlog 数据通过 CLI 暴露给 LLM/Claude,让自动化操作过程中能主动读取风控结论,替代此前"只在 popup 内消费"的决定。
命令
get-netlog [--limit N]
从扩展获取当前会话的 NetLog 原始 entries(最多 500 条环形缓冲)。
python scripts/cli.py get-netlog
python scripts/cli.py get-netlog --limit 50输出格式:
{
"total": 123,
"entries": [ /* NetLogEntry[] */ ]
}未启用 netlogger 时:exit code 2 + 中文提示用户去 popup 彩蛋激活。
risk-report
调用 scripts/xhs/risk_analyzer.py 的 analyze() 函数,基于 netlog entries 反推检测维度并给出结构化风控结论。
python scripts/cli.py risk-report输出格式:
{
"risk_level": "safe | low | medium | high | unknown",
"total_requests": 123,
"summary": "本会话采集 123 条请求,指纹上报 2 次,行为埋点 54 次。",
"detection_axes": {
"browser_fingerprint": { "endpoint": "/api/sec/v1/shield/webprofile", "called_count": 2, ... },
"behavior_tracking": { "endpoint": "/api/v2/collect", "called_count": 54, ... },
"apm_monitoring": { ... },
"request_signature": { "scheme": "x-s-common (current)", "coverage_pct": 92.6, ... },
"cookie_state": { "has_a1": true, "has_web_session": true, ... }
},
"category_distribution": { "business_api": 60, "fingerprint_upload": 12, ... },
"top_hosts": { "edith.xiaohongshu.com": 60, ... },
"high_risk_signals": [],
"warnings": []
}风险等级判断规则
| risk_level | 触发条件 |
|---|---|
high | 存在 signature_failure 类别请求,或 HTTP 999 响应 |
medium | HTTP 401 / 403 / 461,或 acw_tc cookie 变更,或 risk_redirect |
low | 有 warnings(签名覆盖率不足、行为埋点缺失等) |
safe | 无高风险信号、无 warnings |
unknown | netlog 为空 |
实现细节
scripts/xhs/risk_analyzer.py— 纯函数analyze(entries)模块,无副作用,可独立单测scripts/xhs/bridge.py— 新增get_netlog()/get_netlog_enabled()方法,调用 background.js 的get_netlog/get_netlog_enabled命令extension/background.js— handleCommand switch 新增case "get_netlog"/case "get_netlog_enabled",通过 websocket bridge 路径响应(与 popup 内部 chrome.runtime.sendMessage 路径独立)
---
附录:XHS 反爬体系深度反推(2026-05-19 实施 + 冒烟)
本会话端到端跑通了 search / fill-publish 两个场景,结合 netlog 实测数据反推了 XHS 完整的反自动化检测体系。
五层防护架构
┌─ 第 5 层:服务端规则引擎 ────────────┐ ML + 规则混合 → isRiskUser 输出
│ (XHS 自家 + 阿里云风控 SaaS) │ pass / risk / limit / block
├─ 第 4 层:行为埋点 t2/collect ───────┤ protobuf,每操作 1-10 条
│ (XHS 自家 nameTracker=wapT) │ 操作标签 + 时序 + 上下文
├─ 第 3 层:远程脚本 sec/v1/scripting ─┤ 动态下发 JS 在浏览器执行
│ (callFrom=web/creator-platform) │ 采集 mouse/keyboard/检测自动化
├─ 第 2 层:Shield SDK webprofile ─────┤ 浏览器/设备指纹加密上报
│ (sdkVersion 4.3.5, profileData hex) │ navigator/canvas/webgl/audio
└─ 第 1 层:WAF 边缘 acw_tc ───────────┘ IP/UA 信誉 + 频率
(阿里云) 跨子域统一会话追踪关键检测维度(按重要性)
1. 服务端风控判定 isRiskUser(最具利用价值)
每个业务 API 调用后,前端 SDK 将服务端的风控判定主动回传到 APM:
POST apm-fe.xiaohongshu.com/api/data
[{
"measurement_name": "httpRequestTiming",
"measurement_data": {
"matchedPath": "/api/sns/web/v1/search/onebox",
"isRiskUser": "pass", ← 服务端判定: pass / risk / limit / block
"isRiskReason": "[]", ← 触发的规则数组
"i12": 16, "i13": 32, "i14": 100, ← 多维评分(命名混淆)
"traceId": "cf2041020f9322b15...",
"status": 200, ...
}
}]反推:i12/i13/i14 是 0-100 的评分维度(推测:行为序列分 / 指纹分 / 网络环境分)。实测 unread_count 这种高频轮询接口 i14=62(中位),普通业务 API i14=100(满分),创作中心 i14=85。
实战利用:自动化操作过程中实时读 isRiskUser,从 pass 变其他状态立即停手。
2. 浏览器指纹 Shield SDK 4.3.5
POST as.xiaohongshu.com/api/sec/v1/shield/webprofile
{
"platform": "Windows",
"sdkVersion": "4.3.5", ← XHS Shield SDK 版本(可追踪迭代)
"svn": "2",
"profileData": "c058828ff..." ← 加密 hex 串(含 navigator/canvas/webgl/audio/字体)
}3. 远程下发脚本 /api/sec/v1/scripting(JSONP 模式)
POST /api/sec/v1/scripting
{ "callFrom": "web", "callback": "seccallback" } ← 主站
{ "callFrom": "creator-platform", "type": "ds", "appId": "ugc" } ← 创作中心—— 服务端动态下发 JS 在浏览器本地执行,采集 mouse/keyboard timing + 检测 webdriver/Puppeteer 痕迹。callFrom 按场景下发不同检测脚本。
4. 行为采集 t2.xiaohongshu.com/api/v2/collect
protobuf binary 编码(base64),每个用户操作 1-10 条。明文可见:
- artifact:
xhs-pc-web6.11.1 - app:
discovery-undefined/ugc(创作中心) - device_id:
febeb55be25f2a4093229f58643bd140(32 字符 hex,跨上报一致) - user_id, session UUIDs, UA
- 中文操作标签:
创作者-新发布视频题/创作者服务平台/official
5. APM 行为埋点 apm-fe.xiaohongshu.com/api/data (wapT SDK)
JSON 明文,每个 API 调用一条 timing 记录,含:
context_sdkSessionId / pageSessionId / sdkSeqId(单调递增,跳号即异常)context_deviceId(与 t2 一致)context_route(完整页面路由)context_artifactName: "xhs-pc-web"measurement_name: "httpRequestTiming"含isRiskUser(见 #1)
6. 业务 API 签名 x-s-common
实测 25/27 业务 API 带 x-s-common header。旧 spec 假设的 `xs / xt` 双签名已废弃(实测 0 命中)。XHS 演进为 x-s-common 单签名方案。
主站签名覆盖率 92%,创作中心 60%(大量 OPTIONS preflight 无签名)。
7. Cookie 一致性追踪
| Cookie | 用途 |
|---|---|
a1(设备指纹种子) | 100% 命中,device_id 派生自 |
web_session | 主站登录态 |
webId / gid | 浏览器 ID / 设备识别 |
acw_tc(阿里云 WAF) | 跨子域统一指纹,每次跨子域跳转重新颁发,强一致追踪 |
tgw_l7_route(阿里云 SLB) | ros-upload 每次 PUT 都换路由 cookie,防 hash 缓存攻击 |
| CAS SSO 链 5 cookie | 创作中心独立 session(见 #9) |
8. ros-upload 上传链路
1. GET creator/api/media/v1/upload/creator/permit?biz_name=spectrum&scene=image
响应: { code:0, data: { uploadTempPermits, result } } ← 预签名 URL
2. OPTIONS ros-upload.xiaohongshu.com/?speedTestToken=xxx ← CDN 选优
3. PUT ros-upload.xiaohongshu.com/spectrum/<obj_key> ← binary 上传biz_name=spectrum 是 XHS 内部图片处理服务 / OSS bucket 代号。
9. 创作中心 CAS SSO 鉴权链
创作中心独立于主站 session,必须经过 CAS 拿单独的 creator session:
1. /publish/publish → 401(无 creator session)
2. → /login?redirectReason=401 重定向
3. → customer.xiaohongshu.com/api/cas/customer/web/zones
4. → customer.xiaohongshu.com/api/cas/customer/web/service-ticket
5. → 5 cookie:customer-sso-sid / x-user-id-creator.xiaohongshu.com /
access-token-creator.xiaohongshu.com / galaxy_creator_session_id /
galaxy.creator.beaker.session.id
6. → /publish/publish 带 creator session 重试10. A/B 测试 + 检测规则分层 racing_get/report
POST edith.xiaohongshu.com/api/sns/web/racing_get
POST edith.xiaohongshu.com/api/sns/web/racing_report
{
"racing_info": [
{ "web_id": "febeb55b...", "domain": "web_ab" }, ← 设备级 A/B
{ "user_id": "6919c59d...", "domain": "web_user" } ← 用户级 A/B
],
"source": "web",
"app": "creator-publish"
}XHS 给不同用户分配不同检测规则版本(A/B 分流)。同一段自动化代码对不同账号可能效果不同。
🚨 反爬陷阱:Honey Pot Tab(实施时新发现)
XHS 创作中心给每个 tab 放真+假两份:
<!-- 真 tab:Vue scoped,无 hp 标记,藏在 -9999px 但 pointer-events: auto -->
<div data-v-1ff40f7c data-v-0b179352 class="creator-tab"
style="position: absolute; left: -9999px; top: -9999px;">
<span class="title">上传图文</span>
</div>
<!-- 假 tab (honey pot):data-hp-kind + button-hp-installed,opacity:1e-05 -->
<div class="creator-tab" button-hp-installed="1"
data-hp-kind="creator-tab-上传图文" aria-hidden="true"
style="position: absolute; opacity: 1e-05; pointer-events: auto;">
</div>点击 honey pot 会被标记为机器人 + active class 静默不切换。正确策略:排除任何带 data-hp-kind 或 button-hp-installed 属性的元素,只点真 Vue tab。
`active` class 会故意放在 honey pot 上反向迷惑机器人(让你以为切换失败)。实际页面已切换,后续 selector 都能工作。
反爬规避总策略(给自动化 / Claude)
| 维度 | 风险 | 规避策略 |
|---|---|---|
isRiskUser APM 字段 | 🔥极高(也是机会) | 主动监听:从业务 API 响应间接通过 APM 读取,pass→其他立即停手 |
| Shield profileData | 🔥极高 | 用真实浏览器,禁止覆盖 navigator/screen/canvas/webgl |
scripting 远程脚本 | 🔥极高 | 让其正常下发执行,不要 hook 全局 seccallback 函数 |
| Honey pot tab 陷阱 | 🔥高 | 排除 data-hp-kind / button-hp-installed 元素 |
| device_id 一致性 | 🔥高 | 不清 a1 cookie,不切 device_id |
| 鼠标轨迹 / 键盘节奏 | 高 | Bezier 曲线 mouse move + 字符间随机 50-150ms + 0.5% 概率打错-删-重打 |
| 操作时序 | 高 | 每个 action 间 N(2.5s, 0.8s) 高斯停顿;偶尔 5-10s 长停顿模拟"看一下" |
| t2 protobuf 埋点 | 中 | 不屏蔽(缺失反而异常),节奏自然 |
acw_tc 跨域 | 中 | 不阻止 Set-Cookie,让 WAF 跟踪正常 |
| CAS SSO(创作中心) | 中 | 必须走完 CAS 拿 creator session 5-cookie 链 |
| racing A/B 分流 | 低 | 让其正常执行;保持账号年龄/行为画像 |
Response.prototype hook 必要性
XHS 主 bundle 加载时用混淆代码(_garp_xxx)覆盖 window.fetch,绕过我们 document_start 装的 fetch hook。Response.prototype.text/.json 是不可绕过的 hook 点:任何代码读响应体必须调这两个方法之一。
实施细节:interceptor.js 在 IIFE 顶部 capture Response.prototype.text/.json 引用,替换为我们的版本,原方法被调用时 postMessage 上报响应体。
实施期间发现的 Bug 修复
| 类型 | 修复 |
|---|---|
| interceptor.js syntax error | 4 处中文字符串嵌套未转义双引号(line 100/194/214/480),导致整个 interceptor.js 从未成功 parse。修:内部 "" 改为中文「」 |
| 状态同步链路竞态 | interceptor 等 content.js postMessage 启用状态期间所有 fetch 被跳过。修:interceptor 总是上报,background netlogIngestInterceptor 单点过滤 |
| URL 匹配 protocol-relative | edith 业务 API 用 //edith... 协议相对 URL,与 webRequest 的绝对 URL 不等。修:_netlogUrlMatch 加 base URL 兼容 |
| fetch wrapper 覆盖 | XHS _garp_xxx 直接覆盖 window.fetch。修:改 hook Response.prototype |
| publish.py honey pot 陷阱 | 旧 selector 点了 data-hp-kind 假 tab。修:排除 hp 属性 + 等 active 切换确认 |
| REQBODY_MAX 2KB 截断 | APM 上报含 isRiskUser 的 JSON 1-3KB,2048 字节截断导致 JSON parse 失败。修:增大到 8192 |
| risk_analyzer regex 容错 | reqBody 仍可能截断时 JSON parse 失败。修:正则提取 isRiskUser/isRiskReason/i12-i14,不依赖完整 JSON |
已知限制 / 未来改进
- 账号切换不自动 clear netlog(用户需手动清空)
- 跨域风控上报域响应体看不到(webRequest 限制 + 跨域无 interceptor)
- 同一 url 短时间高并发请求时关联可能漏配(2s 时间窗 + url 模糊匹配)
publish.py中_wait_for_upload_complete也可能命中类似 honey pot(待用户实测确认)- 当前账号已建立信任画像,所有实测 isRiskUser 均为
pass。要实测risk/limit/block需要:① 高频机械操作触发降评分,或 ② 在新账号上测试
端到端冒烟实测结果
| 场景 | total | isRiskUser | acw_tc 变更 | x-s-common 覆盖 | 主要 host |
|---|---|---|---|---|---|
| 浏览/搜索 (search "claude") | 146 | 全 pass (40/40) | 1 | 92% | t2(64) edith(27) apm-fe(25) |
| 搜索 "ai" | 99 | 全 pass (27/27) | 0 | - | apm-fe(28) t2(28) edith(14) |
| 创作中心填表 | 284 | 全 pass (7/7) | 1 | 60% | apm-fe(108) t2(71) as(19) edith(19) creator(16) ros-upload(3) |
/**
* XHS Bridge - Content Script(隔离 world)
*
* 接收来自 background.js 的 DOM 操作命令并执行。
* evaluate / has_element 等需要访问页面 JS 变量的命令由 background.js
* 通过 chrome.scripting.executeScript(world:"MAIN") 直接处理,不经过这里。
*
* 同时负责接收来自 interceptor.js(MAIN world)的 postMessage,
* 将 404 诊断事件转发给 background.js 存储。
*/
// ── interceptor.js (MAIN world) → background.js 桥接 ────────
// 注意:扩展 reload 后老 content script 仍在页面,但 chrome.runtime context 失效。
// 此时 sendMessage 会同步抛 "Extension context invalidated"(.catch 抓不到),
// 必须先用 chrome.runtime?.id 检测 context 是否还存活。
window.addEventListener("message", (e) => {
if (e.source !== window) return;
if (e.data?.source !== "xhs-interceptor" || e.data?.type !== "BLOCK_EVENT") return;
if (!chrome.runtime?.id) return; // context invalidated, orphan content script
try {
chrome.runtime.sendMessage({ type: "XHS_BLOCK_EVENT", event: e.data.event }).catch(() => {});
} catch (_) { /* runtime gone between guard and call */ }
});
// 通知 interceptor.js:content.js 已就绪,可以 flush 排队的事件
window.postMessage({ source: "xhs-content-ready" }, "*");
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
handleDomCommand(msg.method, msg.params || {})
.then((result) => sendResponse({ result: result ?? null }))
.catch((err) => sendResponse({ error: String(err.message || err) }));
return true; // 异步响应
});
async function handleDomCommand(method, params) {
switch (method) {
case "click_element":
return cmdClickElement(params);
case "input_text":
return cmdInputText(params);
case "input_content_editable":
return cmdInputContentEditable(params);
case "scroll_by":
window.scrollBy(params.x || 0, params.y || 0);
return null;
case "scroll_to":
window.scrollTo(params.x || 0, params.y || 0);
return null;
case "scroll_to_bottom":
window.scrollTo(0, document.body.scrollHeight);
return null;
case "scroll_element_into_view": {
const el = document.querySelector(params.selector);
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
return null;
}
case "scroll_nth_element_into_view": {
const els = document.querySelectorAll(params.selector);
if (els[params.index]) els[params.index].scrollIntoView({ behavior: "smooth", block: "center" });
return null;
}
case "dispatch_wheel_event": {
const target =
document.querySelector(".note-scroller") ||
document.querySelector(".interaction-container") ||
document.documentElement;
target.dispatchEvent(
new WheelEvent("wheel", {
deltaY: params.deltaY || 0,
deltaMode: 0,
bubbles: true,
cancelable: true,
view: window,
}),
);
return null;
}
case "mouse_move": {
document.dispatchEvent(
new MouseEvent("mousemove", { clientX: params.x, clientY: params.y, bubbles: true }),
);
return null;
}
case "mouse_click": {
const el = document.elementFromPoint(params.x, params.y);
if (el) {
el.dispatchEvent(new MouseEvent("mousedown", { clientX: params.x, clientY: params.y, bubbles: true }));
el.dispatchEvent(new MouseEvent("mouseup", { clientX: params.x, clientY: params.y, bubbles: true }));
el.dispatchEvent(new MouseEvent("click", { clientX: params.x, clientY: params.y, bubbles: true }));
}
return null;
}
case "press_key": {
const keyMap = {
Enter: { key: "Enter", code: "Enter", keyCode: 13 },
ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
Tab: { key: "Tab", code: "Tab", keyCode: 9 },
Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
};
const info = keyMap[params.key] || { key: params.key, code: params.key, keyCode: 0 };
const active = document.activeElement || document.body;
active.dispatchEvent(new KeyboardEvent("keydown", { ...info, bubbles: true }));
active.dispatchEvent(new KeyboardEvent("keyup", { ...info, bubbles: true }));
return null;
}
case "type_text": {
const active = document.activeElement || document.body;
const delay = params.delayMs || 50;
for (const char of params.text) {
active.dispatchEvent(new KeyboardEvent("keydown", { key: char, bubbles: true }));
active.dispatchEvent(new KeyboardEvent("keypress", { key: char, bubbles: true }));
active.dispatchEvent(new KeyboardEvent("keyup", { key: char, bubbles: true }));
await sleep(delay);
}
return null;
}
case "remove_element": {
const el = document.querySelector(params.selector);
if (el) el.remove();
return null;
}
case "hover_element": {
const el = document.querySelector(params.selector);
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
el.dispatchEvent(new MouseEvent("mouseover", { clientX: x, clientY: y, bubbles: true }));
el.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true }));
}
return null;
}
case "select_all_text": {
const el = document.querySelector(params.selector);
if (el) {
el.focus();
if (el.select) el.select();
else document.execCommand("selectAll");
}
return null;
}
case "set_file_input":
return cmdSetFileInput(params);
default:
throw new Error(`content.js: 未知命令 ${method}`);
}
}
// ───────────────────────── 具体实现 ─────────────────────────
function cmdClickElement({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`元素不存在: ${selector}`);
el.scrollIntoView({ block: "center" });
el.click();
return null;
}
function cmdInputText({ selector, text }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`元素不存在: ${selector}`);
el.focus();
el.value = text;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return null;
}
async function cmdInputContentEditable({ selector, text }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`元素不存在: ${selector}`);
el.focus();
// 全选清空
document.execCommand("selectAll", false, null);
document.execCommand("delete", false, null);
await sleep(80);
// 逐行插入(换行转为 Enter 键事件)
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i]) document.execCommand("insertText", false, lines[i]);
if (i < lines.length - 1) {
el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }));
el.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }));
await sleep(40);
}
}
return null;
}
async function cmdSetFileInput({ selector, files }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`文件输入框不存在: ${selector}`);
const dt = new DataTransfer();
for (const f of files) {
const bytes = Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0));
const blob = new Blob([bytes], { type: f.type });
dt.items.add(new File([blob], f.name, { type: f.type }));
}
Object.defineProperty(el, "files", {
value: dt.files,
configurable: true,
writable: true,
});
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("input", { bubbles: true }));
return null;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// ─── NetLog 信号转发 + 启用状态同步 ──────────────────────────────
// MAIN world interceptor → content (postMessage) → background (runtime)
window.addEventListener("message", (e) => {
if (e.source !== window) return;
if (e.data?.source !== "xhs-netlog-intercept") return;
if (!chrome.runtime?.id) return; // extension reloaded, content script orphaned
try {
chrome.runtime.sendMessage({
type: "NETLOG_INTERCEPTOR_ENTRY",
payload: e.data,
}).catch(() => {});
} catch (_) { /* runtime gone between guard and call */ }
});
/**
* XHS 404 诊断拦截器 — MAIN world, document_start
*
* 在小红书页面 JS 加载之前包裹原生 fetch / XMLHttpRequest,
* 捕获所有返回 404 / 461 / 403 / 999 的请求的完整上下文,
* 进行根因分析后通过 window.postMessage 发送给 content script。
*
* 诊断覆盖:
* - xsec_token 缺失 / 绑定失效
* - xs 签名缺失 / 无效
* - web_session 失效(未登录)
* - IP / 账号级封禁
* - 页面渲染 404(HTTP 200 但内容被屏蔽)
*/
(function () {
"use strict";
const BLOCKED = new Set([404, 461, 403, 999]);
const XHS_API = /xiaohongshu\.com\/api\//;
const RESP_BODY_MAX = 4096;
// ── Cookie 快照 ──────────────────────────────────────────────
function captureCookies() {
const map = {};
for (const part of document.cookie.split(";")) {
const idx = part.indexOf("=");
if (idx < 0) continue;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
map[k] = v;
}
return {
has_a1: "a1" in map,
has_web_session: "web_session" in map,
has_webId: "webId" in map,
has_gid: "gid" in map,
// 部分预览(不暴露完整 token)
a1_preview: map["a1"] ? map["a1"].slice(0, 12) + "…" : null,
web_session_preview: map["web_session"] ? map["web_session"].slice(0, 10) + "…" : null,
};
}
// ── URL 解析 ─────────────────────────────────────────────────
function urlParam(url, key) {
try { return new URL(url).searchParams.get(key); } catch (_) { return null; }
}
function isApiUrl(url) { return XHS_API.test(url); }
// ── 根因分析 ─────────────────────────────────────────────────
function diagnose(status, url, headers, cookies) {
const xsecToken = urlParam(url, "xsec_token");
const xsecSource = urlParam(url, "xsec_source");
const hasXs = !!(headers["xs"] || headers["X-S"] || headers["x-s"]);
const hasXt = !!(headers["xt"] || headers["X-T"] || headers["x-t"]);
const isPage = !isApiUrl(url);
// ── 999:系统级封禁 ──────────────────────────────────────
if (status === 999) {
return {
root_cause: "账号 / IP 被系统级封禁(HTTP 999)",
cause_category: "account_block",
detail:
"小红书用 HTTP 999 标记被彻底封禁的账号或 IP 段," +
"所有请求无论携带什么凭证均被拒绝。需更换 IP 或重新注册账号。",
confidence: "high",
how_xhs_decides:
"服务端在路由层维护封禁名单(IP CIDR + userId),命中即返回 999,不经业务逻辑。",
};
}
// ── 403:WAF / 防火墙 ────────────────────────────────────
if (status === 403) {
return {
root_cause: "请求被 WAF / 前置防火墙拦截",
cause_category: "ip_block",
detail:
"HTTP 403 来自小红书的 WAF 层(非业务层)," +
"常见触发条件:IP 信誉分过低、User-Agent 异常、请求速率超阈值、" +
"或请求头特征命中自动化规则。",
confidence: "high",
how_xhs_decides:
"WAF 对每个请求计算特征向量:IP 信誉 × UA 合规性 × 请求间隔 × 头部完整性。" +
"超过风险阈值时直接返回 403,不转发给后端。",
};
}
// ── 461:签名问题 ────────────────────────────────────────
if (status === 461) {
if (!hasXs) {
return {
root_cause: "xs 请求签名完全缺失",
cause_category: "signature",
detail:
"HTTP 461 是小红书专用的「签名缺失」状态码。" +
"所有 /api/ 接口均要求 xs header(HMAC 签名),未携带时直接返回 461。",
confidence: "high",
how_xhs_decides:
"服务端在 API 网关层检查 xs header 是否存在。" +
"xs = HMAC(url_path + body_hash + timestamp, device_key)," +
"缺失 → 461,存在但验签失败 → 也是 461(但 detail 不同)。",
};
}
return {
root_cause: `xs 签名存在但验证失败${hasXt ? "" : "(xt 时间戳头也缺失)"}`,
cause_category: "signature",
detail:
"xs header 已附加,但服务端 HMAC 验证未通过。" +
"可能原因:\n" +
" 1. 签名算法版本过旧(XHS 定期更新 xs 算法,v1→v2→v3…)\n" +
" 2. device_id 与 a1 cookie 不对应(签名密钥绑定设备)\n" +
` 3. xt 时间戳偏差超出允许窗口(±5 分钟)${!hasXt ? ",且 xt 头缺失" : ""}`,
confidence: "high",
how_xhs_decides:
"服务端用从 a1 cookie 推导的 device_key 重新计算 HMAC," +
"对比请求中的 xs 值;同时校验 xt 时间戳与服务器时钟差是否在容忍窗口内。",
};
}
// ── 404:多种场景 ────────────────────────────────────────
// 页面级 404(非 API)
if (isPage) {
if (!xsecToken) {
return {
root_cause: "xsec_token 缺失——直接构造 URL 访问",
cause_category: "token",
detail:
"小红书笔记 / 用户主页 URL 必须携带 xsec_token 参数," +
"否则服务端直接返回 404(故意用 404 而非 403,迷惑爬虫以为内容不存在)。\n\n" +
"正确获取方式:从搜索结果 / 推荐流 / 分享链接中提取,不可手动构造。",
confidence: "high",
how_xhs_decides:
"服务端对所有笔记详情请求校验 xsec_token 签名;" +
"token = HMAC(noteId + sessionContext, serverKey),缺失直接 404。",
};
}
if (!cookies.has_web_session) {
return {
root_cause: "xsec_token 有效,但 web_session cookie 不存在(未登录 / session 失效)",
cause_category: "session",
detail:
"xsec_token 参数存在,但 web_session cookie 缺失或已过期。" +
"服务端绑定验证:token 必须与颁发时的 session 匹配," +
"session 失效后 token 同步失效,返回 404(而非重定向到登录页,这是故意的)。",
confidence: "high",
how_xhs_decides:
"服务端将 xsec_token 与 web_session 绑定存储;" +
"请求时查 session 是否存在,不存在则 token 无法解密 → 404。",
};
}
return {
root_cause: `xsec_token 与当前 session / IP 绑定验证失败(来源: ${xsecSource || "未知"})`,
cause_category: "token",
detail:
"xsec_token 和 web_session 均存在,但服务端绑定验证失败。\n" +
"可能原因:\n" +
" 1. token 是从其他账号获取的(token 绑定颁发时的 userId)\n" +
" 2. token 已超过有效期(通常数小时到 1 天)\n" +
" 3. IP 变化触发服务端将 token 标记为可疑\n" +
` 4. xsec_source="${xsecSource}" 来源类型不符(如 token 从 search 获取却用于 profile 页)`,
confidence: "medium",
how_xhs_decides:
"服务端用 serverKey 解密 xsec_token,提取 {noteId, userId, source, ts, ipHash}," +
"逐字段与当前请求对比:任一不符则 404。",
};
}
// API 级 404
if (!cookies.has_web_session) {
return {
root_cause: "API 请求未携带有效 session(未登录)",
cause_category: "session",
detail:
"API 端点在 web_session 不存在时返回 404(而非 401)," +
"这是小红书对未登录访问私有 API 的有意混淆处理。",
confidence: "high",
how_xhs_decides:
"API 网关在 session 校验失败时根据端点配置选择响应码:" +
"公开 API 返回空数据,私有 API 直接返回 404 以阻止枚举。",
};
}
if (!hasXs) {
return {
root_cause: "此 API 端点在签名缺失时返回 404(比 461 更严格的端点)",
cause_category: "signature",
detail:
"部分高敏感 API 端点(通常是读取用户隐私数据类)在签名缺失时直接返回 404 而非 461," +
"使爬虫难以区分「不存在」和「无权访问」。",
confidence: "medium",
how_xhs_decides:
"API 网关根据端点安全级别配置响应码策略:" +
"普通端点 → 461,高敏感端点 → 404,以防止端点存在性探测。",
};
}
return {
root_cause: "IP 或账号维度风控封禁(所有凭证均有效但仍 404)",
cause_category: "risk_control",
detail:
"web_session、xs 签名、xsec_token 均存在且通过格式校验," +
"但服务端仍返回 404。这是最典型的风控特征:\n" +
" - IP 风控:当前 IP 在短时间内请求量超过阈值,被标记后所有内容请求返回 404\n" +
" - 账号风控:账号行为评分低于阈值(过于机械的操作模式),内容访问被限流\n" +
" - 设备指纹风控:浏览器指纹被识别为自动化环境",
confidence: "high",
how_xhs_decides:
"服务端对每个 {IP, userId, deviceId} 三元组维护实时行为评分:" +
"请求间隔方差、路径分布、滚动事件频率等,评分低于阈值时对特定内容返回 404," +
"但登录态保持正常(避免用户察觉),造成「内容消失」的假象。",
};
}
// ── 构造完整诊断事件 ─────────────────────────────────────────
function buildEvent(url, method, status, headers, extra) {
const cookies = captureCookies();
const diag = diagnose(status, url, headers, cookies);
return {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
timestamp: new Date().toISOString(),
url,
method: method.toUpperCase(),
status,
pageUrl: window.location.href,
request: {
xsec_token: urlParam(url, "xsec_token"),
xsec_source: urlParam(url, "xsec_source"),
has_xs: !!(headers["xs"] || headers["X-S"] || headers["x-s"]),
has_xt: !!(headers["xt"] || headers["X-T"] || headers["x-t"]),
has_referer: !!(headers["referer"] || headers["Referer"]),
sec_fetch_site: headers["Sec-Fetch-Site"] || headers["sec-fetch-site"] || null,
content_type: headers["Content-Type"] || headers["content-type"] || null,
},
cookies,
diagnosis: diag,
...(extra || {}),
};
}
// 消息队列:content.js 在 document_idle 才就绪,早于此时的事件需要排队
const _pendingEvents = [];
let _contentReady = false;
window.addEventListener("message", (e) => {
if (e.data?.source === "xhs-interceptor-ack") _contentReady = true;
});
function emit(event) {
if (_contentReady) {
window.postMessage({ source: "xhs-interceptor", type: "BLOCK_EVENT", event }, "*");
} else {
_pendingEvents.push(event);
}
}
function flushPending() {
_contentReady = true;
for (const ev of _pendingEvents) {
window.postMessage({ source: "xhs-interceptor", type: "BLOCK_EVENT", event: ev }, "*");
}
_pendingEvents.length = 0;
}
// content.js 就绪后会发 xhs-interceptor-ready 消息,或 500ms 后强制 flush
window.addEventListener("message", (e) => {
if (e.data?.source === "xhs-content-ready") flushPending();
});
setTimeout(flushPending, 800);
// ── Response.prototype hook ─────────────────────────────────
// XHS 在主 bundle 加载时会用混淆代码覆盖 window.fetch,绕过我们的 fetch hook。
// 改为 hook Response.prototype.text/.json:任何代码读响应体都必须调这两个之一,
// 包括 XHS 的 wrapper 链最终也得到 Response 对象。
const _respText = Response.prototype.text;
const _respJson = Response.prototype.json;
function _netlogReportResp(url, status, body) {
if (!url || !url.includes("xiaohongshu.com")) return;
let truncated = body;
if (typeof body === "string" && body.length > RESP_BODY_MAX) {
truncated = body.slice(0, RESP_BODY_MAX) + "…[cut]";
}
try {
window.postMessage({
source: "xhs-netlog-intercept",
method: "?", // Response 对象拿不到原 method
url,
status,
reqHeaders: {},
respBody: truncated,
ts: Date.now(),
}, "*");
} catch (_) {}
}
Response.prototype.text = async function() {
const body = await _respText.call(this);
_netlogReportResp(this.url, this.status, body);
return body;
};
Response.prototype.json = async function() {
const data = await _respJson.call(this);
try {
_netlogReportResp(this.url, this.status, JSON.stringify(data));
} catch (_) {}
return data;
};
// ── fetch 拦截 ───────────────────────────────────────────────
const _fetch = window.fetch;
window.fetch = async function (input, init) {
const url = typeof input === "string" ? input : input?.url || String(input);
const method = init?.method || "GET";
const headers = {};
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((v, k) => { headers[k] = v; });
} else {
Object.assign(headers, init.headers);
}
}
const resp = await _fetch.call(this, input, init);
// 现有 BLOCKED 诊断保持不变
if (BLOCKED.has(resp.status) && (isApiUrl(url) || url.includes("xiaohongshu.com"))) {
emit(buildEvent(url, method, resp.status, headers, { intercept_type: "fetch" }));
}
// NetLog 全量记录(background 单点过滤 _netEnabled)
if (url.includes("xiaohongshu.com")) {
let respBody = null;
try {
const clone = resp.clone();
const text = await clone.text();
respBody = text.length > RESP_BODY_MAX ? text.slice(0, RESP_BODY_MAX) + "…[cut]" : text;
} catch (_) { respBody = "[unreadable]"; }
window.postMessage({
source: "xhs-netlog-intercept",
method,
url,
status: resp.status,
reqHeaders: headers,
respBody,
ts: Date.now(),
}, "*");
}
return resp;
};
// ── XMLHttpRequest 拦截 ──────────────────────────────────────
const _xhrOpen = XMLHttpRequest.prototype.open;
const _xhrSend = XMLHttpRequest.prototype.send;
const _xhrSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function (method, url) {
this.__i_method = method;
this.__i_url = url;
this.__i_headers = {};
return _xhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
if (this.__i_headers) this.__i_headers[name] = value;
return _xhrSetHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
this.addEventListener("loadend", () => {
const url = this.__i_url || "";
// 现有 BLOCKED 诊断保持不变
if (BLOCKED.has(this.status) && (isApiUrl(url) || url.includes("xiaohongshu.com"))) {
emit(buildEvent(url, this.__i_method || "GET", this.status, this.__i_headers || {}, {
intercept_type: "xhr",
}));
}
// NetLog 全量记录(background 单点过滤 _netEnabled)
if (url.includes("xiaohongshu.com")) {
let respBody = null;
try {
const t = this.responseText || "";
respBody = t.length > RESP_BODY_MAX ? t.slice(0, RESP_BODY_MAX) + "…[cut]" : t;
} catch (_) { respBody = "[unreadable]"; }
window.postMessage({
source: "xhs-netlog-intercept",
method: this.__i_method || "GET",
url,
status: this.status,
reqHeaders: this.__i_headers || {},
respBody,
ts: Date.now(),
}, "*");
}
});
return _xhrSend.apply(this, arguments);
};
// ── 页面渲染级 404 检测(HTTP 200 但内容被屏蔽)───────────────
function checkPageRender404() {
const url = window.location.href;
if (!url.includes("xiaohongshu.com")) return;
let triggered = false;
let pageErrorDetail = null;
// 检查 __INITIAL_STATE__ 中的错误标记
try {
const s = window.__INITIAL_STATE__;
if (s) {
if (s.pageError || s.errorCode || s.forbidden) {
triggered = true;
pageErrorDetail = {
pageError: s.pageError || null,
errorCode: s.errorCode || null,
forbidden: s.forbidden || null,
};
}
}
} catch (_) {}
// 检查 DOM
if (!triggered) {
const is404Dom = document.title.includes("404") ||
!!document.querySelector('[class*="not-found"], [class*="error-page"], [class*="page-not-found"]');
if (is404Dom) triggered = true;
}
if (triggered) {
const cookies = captureCookies();
const xsecToken = urlParam(url, "xsec_token");
emit({
id: `${Date.now()}_render`,
timestamp: new Date().toISOString(),
url,
method: "GET",
status: "200→404", // HTTP 成功但渲染出 404
pageUrl: url,
intercept_type: "page_render",
request: {
xsec_token: xsecToken,
xsec_source: urlParam(url, "xsec_source"),
has_xs: false,
has_xt: false,
has_referer: !!document.referrer,
sec_fetch_site: null,
content_type: null,
},
cookies,
page_error_state: pageErrorDetail,
diagnosis: xsecToken
? {
root_cause: "内容已被删除 / 下架,或账号无权访问",
cause_category: "content_unavailable",
detail:
"页面返回 HTTP 200 但渲染为 404 错误页,且 xsec_token 存在(说明不是 token 问题)。\n" +
"这表示内容本身已不可用:笔记被作者删除、被平台下架、或当前账号被限制访问该内容。",
confidence: "medium",
how_xhs_decides:
"XHS 前端(Vue/React 应用)在拿到服务端数据后检查 note.status 字段," +
"若为 banned/deleted/invisible 则渲染 404 组件,HTTP 状态码仍是 200。",
}
: {
root_cause: "xsec_token 缺失,服务端返回空内容,前端渲染 404",
cause_category: "token",
detail: "URL 不含 xsec_token,服务端返回了「内容不存在」的数据包,前端据此渲染 404 页面。",
confidence: "high",
how_xhs_decides:
"同页面级 404 逻辑:token 缺失 → 服务端返回空 note 数据 → 前端渲染 notFound 组件。",
},
});
}
}
// 在 DOMContentLoaded 之后检查页面状态
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => setTimeout(checkPageRender404, 500));
} else {
setTimeout(checkPageRender404, 500);
}
})();
{
"manifest_version": 3,
"name": "XHS Bridge",
"version": "1.0.0",
"description": "小红书自动化 Bridge - 连接本地 Python CLI",
"permissions": [
"tabs",
"cookies",
"scripting",
"alarms",
"debugger",
"storage",
"webRequest"
],
"host_permissions": [
"https://*.xiaohongshu.com/*",
"https://xiaohongshu.com/*",
"ws://localhost/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "XHS Bridge"
},
"content_scripts": [
{
"matches": [
"https://www.xiaohongshu.com/*",
"https://xiaohongshu.com/*",
"https://creator.xiaohongshu.com/*"
],
"js": ["interceptor.js"],
"run_at": "document_start",
"world": "MAIN"
},
{
"matches": [
"https://www.xiaohongshu.com/*",
"https://xiaohongshu.com/*",
"https://creator.xiaohongshu.com/*"
],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<style>
body {
width: 220px;
padding: 16px;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px;
color: #333;
margin: 0;
transition: width 0.2s;
}
body.netlog-on { width: 580px; }
h3 {
margin: 0 0 12px;
font-size: 14px;
color: #111;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.label { color: #666; }
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
}
.badge.ok { background: #e6f4ea; color: #1e8e3e; }
.badge.err { background: #fce8e6; color: #c5221f; }
.badge.loading { background: #f1f3f4; color: #888; }
.dot {
width: 7px; height: 7px;
border-radius: 50%;
}
.dot.ok { background: #1e8e3e; }
.dot.err { background: #c5221f; }
.dot.loading { background: #aaa; }
.divider { border: none; border-top: 1px solid #eee; margin: 10px 0; }
.hint { color: #999; font-size: 11px; line-height: 1.5; }
.btn {
width: 100%;
padding: 6px 0;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
background: #1a73e8;
color: #fff;
margin-top: 4px;
}
.btn:disabled { background: #aaa; cursor: default; }
.risk-result { margin-top: 8px; font-size: 11px; line-height: 1.6; }
.risk-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 8px;
font-weight: 600;
font-size: 12px;
}
.risk-safe { background: #e6f4ea; color: #1e8e3e; }
.risk-low { background: #fef9e7; color: #b7950b; }
.risk-medium { background: #fce8e6; color: #c5221f; }
.risk-high { background: #f4c6c6; color: #a50000; }
.issue-list { margin: 4px 0 0; padding: 0; list-style: none; }
.issue-list li { padding: 1px 0; color: #555; }
.netlog-card { display: none; }
body.netlog-on .netlog-card { display: block; }
.netlog-tabs { display: flex; gap: 4px; margin-bottom: 6px; }
.netlog-tab {
padding: 3px 10px;
font-size: 11px;
cursor: pointer;
border-radius: 4px;
background: #f1f3f4;
color: #555;
}
.netlog-tab.active { background: #1a73e8; color: #fff; }
.netlog-row {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 10px;
line-height: 1.5;
padding: 2px 4px;
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.netlog-row:hover { background: #f8f9fa; }
.netlog-row.cat-fingerprint_upload { color: #b7950b; }
.netlog-row.cat-business_error,
.netlog-row.cat-risk_redirect,
.netlog-row.cat-signature_failure { color: #c5221f; }
.netlog-row.cat-cookie_change { color: #1565c0; }
.netlog-list { max-height: 320px; overflow-y: auto; border: 1px solid #eee; padding: 4px; border-radius: 4px; }
.netlog-detail {
background: #fafafa;
border: 1px solid #eee;
border-radius: 4px;
padding: 6px;
margin-top: 6px;
font-family: ui-monospace, monospace;
font-size: 10px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.netlog-toolbar { display: flex; gap: 6px; margin-bottom: 6px; }
.netlog-toolbar button {
flex: 1;
padding: 3px 0;
font-size: 11px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
}
</style>
</head>
<body>
<h3 id="title-hit" style="cursor:default;user-select:none">XHS Bridge</h3>
<div class="row">
<span class="label">Bridge 服务</span>
<span class="badge loading" id="bridge-status">
<span class="dot loading" id="bridge-dot"></span>
<span id="bridge-text">检测中...</span>
</span>
</div>
<div class="row">
<span class="label">扩展连接</span>
<span class="badge loading" id="ext-status">
<span class="dot loading" id="ext-dot"></span>
<span id="ext-text">检测中...</span>
</span>
</div>
<hr class="divider">
<p class="hint" id="hint">正在连接 bridge server...</p>
<hr class="divider">
<button class="btn" id="scan-btn">扫描风控</button>
<div class="risk-result" id="risk-result" style="display:none">
<div>风险等级:<span class="risk-badge" id="risk-level-badge">-</span></div>
<ul class="issue-list" id="issue-list"></ul>
</div>
<hr class="divider">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<span style="font-size:12px;font-weight:600;color:#333">404 诊断</span>
<span class="badge loading" id="intercept-badge" style="font-size:10px;padding:1px 6px">
<span class="dot loading" id="intercept-dot"></span>
<span id="intercept-count">监听中</span>
</span>
</div>
<div id="event-list" style="font-size:10px;line-height:1.6;max-height:160px;overflow-y:auto"></div>
<button class="btn" id="clear-btn" style="background:#888;margin-top:4px;font-size:11px;padding:4px 0">清空记录</button>
<hr class="divider">
<div class="netlog-card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<span style="font-size:12px;font-weight:600;color:#333">NetLog</span>
<span style="font-size:10px;color:#888" id="netlog-count">0 条</span>
</div>
<div class="netlog-tabs">
<div class="netlog-tab active" data-tab="stream">时序流</div>
<div class="netlog-tab" data-tab="category">检测维度</div>
</div>
<div class="netlog-toolbar">
<button id="netlog-clear">清空</button>
<button id="netlog-export">导出 JSON</button>
<button id="netlog-disable">关闭</button>
</div>
<div class="netlog-list" id="netlog-list"></div>
<div class="netlog-detail" id="netlog-detail" style="display:none"></div>
</div>
<script type="module" src="popup.js"></script>
</body>
</html>
MIT License
Copyright (c) 2026 Auto-Claw-CC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""小红书 CDP 自动化核心包。"""
Related skills
How it compares
Use this self-contained CLI skill package instead of generic browser MCP servers or unrelated xiaohongshu-mcp projects the agent might recall.
FAQ
Who is xiaohongshu-skills for?
Developers and operators who distribute on 小红书 and want their agent to use only this project’s Python CLI on darwin or linux.
When should I use xiaohongshu-skills?
When users ask to 登录, 发布, 搜索笔记, 评论, 点赞, 收藏, 竞品分析, or other 小红书 actions that map to auth, publish, explore, interact, or content-ops sub-skills.
Is xiaohongshu-skills safe to install?
It drives real account actions on a consumer platform—review the Security Audits panel on this page, keep credentials local, and always confirm publish and comment steps.