harveyz-skill 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/package.json +2 -1
- package/skills/research/clip-url/SKILL.md +34 -10
- package/skills/research/clip-url/scripts/__pycache__/article_meta.cpython-314.pyc +0 -0
- package/skills/research/clip-url/scripts/__pycache__/browser_fetch_mcp_locate.cpython-314.pyc +0 -0
- package/skills/research/clip-url/scripts/__pycache__/dedup_check.cpython-314.pyc +0 -0
- package/skills/research/clip-url/scripts/__pycache__/mcp_debug_client.cpython-314.pyc +0 -0
- package/skills/research/clip-url/scripts/__pycache__/mcp_fetch_client.cpython-314.pyc +0 -0
- package/skills/research/clip-url/scripts/__pycache__/vault_config.cpython-314.pyc +0 -0
- package/skills/research/clip-url/scripts/__pycache__/write_meta_and_separate.cpython-314.pyc +0 -0
- package/skills/research/clip-url/scripts/browser_fetch_mcp_locate.py +48 -0
- package/skills/research/clip-url/scripts/chrome_profile_config.py +44 -5
- package/skills/research/clip-url/scripts/detect_xcom_chrome_profile.py +3 -4
- package/skills/research/clip-url/scripts/mcp_debug_client.py +3 -4
- package/skills/research/clip-url/scripts/mcp_fetch_client.py +2 -3
- package/skills/research/clip-url/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/__pycache__/test_article_meta.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/__pycache__/test_browser_fetch_mcp_locate.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/__pycache__/test_chrome_profile_config.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/__pycache__/test_dedup_check.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/__pycache__/test_detect_xcom_chrome_profile.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/__pycache__/test_mcp_debug_client.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/__pycache__/test_mcp_fetch_client.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/__pycache__/test_vault_config.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/__pycache__/test_write_meta_and_separate.cpython-314-pytest-9.1.1.pyc +0 -0
- package/skills/research/clip-url/tests/test_browser_fetch_mcp_locate.py +83 -0
- package/skills/research/clip-url/tests/test_chrome_profile_config.py +28 -0
- package/skills-index.json +9 -3
- package/tools/browser-fetch-mcp/browser-fetch-mcp.sh +36 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/__init__.py +1 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/__pycache__/config.cpython-314.pyc +0 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/__pycache__/cookies.cpython-314.pyc +0 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/__pycache__/extractors.cpython-314.pyc +0 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/__pycache__/images.cpython-314.pyc +0 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/__pycache__/markdown.cpython-314.pyc +0 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/__pycache__/profiles.cpython-314.pyc +0 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/__pycache__/server.cpython-314.pyc +0 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/config.py +25 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/cookies.py +35 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/extractors.py +659 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/images.py +74 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/markdown.py +96 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/profiles.py +97 -0
- package/tools/browser-fetch-mcp/browser_fetch_mcp/server.py +439 -0
- package/tools/browser-fetch-mcp/pyproject.toml +26 -0
- package/tools/browser-fetch-mcp/tests/__pycache__/test_config.cpython-314-pytest-9.1.1.pyc +0 -0
- package/tools/browser-fetch-mcp/tests/__pycache__/test_cookies.cpython-314-pytest-9.1.1.pyc +0 -0
- package/tools/browser-fetch-mcp/tests/__pycache__/test_evaluate_js.cpython-314-pytest-9.1.1.pyc +0 -0
- package/tools/browser-fetch-mcp/tests/__pycache__/test_extractors.cpython-314-pytest-9.1.1.pyc +0 -0
- package/tools/browser-fetch-mcp/tests/__pycache__/test_fetch_article.cpython-314-pytest-9.1.1.pyc +0 -0
- package/tools/browser-fetch-mcp/tests/__pycache__/test_images.cpython-314-pytest-9.1.1.pyc +0 -0
- package/tools/browser-fetch-mcp/tests/__pycache__/test_markdown.cpython-314-pytest-9.1.1.pyc +0 -0
- package/tools/browser-fetch-mcp/tests/__pycache__/test_profiles.cpython-314-pytest-9.1.1.pyc +0 -0
- package/tools/browser-fetch-mcp/tests/__pycache__/test_server.cpython-314-pytest-9.1.1.pyc +0 -0
- package/tools/browser-fetch-mcp/tests/test_config.py +25 -0
- package/tools/browser-fetch-mcp/tests/test_cookies.py +35 -0
- package/tools/browser-fetch-mcp/tests/test_evaluate_js.py +63 -0
- package/tools/browser-fetch-mcp/tests/test_extractors.py +73 -0
- package/tools/browser-fetch-mcp/tests/test_fetch_article.py +387 -0
- package/tools/browser-fetch-mcp/tests/test_images.py +67 -0
- package/tools/browser-fetch-mcp/tests/test_markdown.py +130 -0
- package/tools/browser-fetch-mcp/tests/test_profiles.py +71 -0
- package/tools/browser-fetch-mcp/tests/test_server.py +126 -0
- package/tools/browser-fetch-mcp/tool.json +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.27.0] - 2026-08-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `clip-url`:新增 browser-fetch-mcp 前置可用性检测,抓取前先确认 MCP 可用,避免执行到中途才发现环境问题
|
|
14
|
+
- `clip-url`:正文开头新增流程概览(路线图),列出各步骤编号,减少多步骤执行时的偏差
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- `clip-url`:优化 description 触发文案,定位为 extract-url 的通用替代品参与触发竞争,覆盖更多保存/归档/翻译存档场景
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `clip-url`:chrome_profile 询问从「可能每次任务都问」改为「只在首次使用本 skill 时问一次」,无论用户当时是否设置了值,都会记住已经问过
|
|
21
|
+
|
|
22
|
+
## [0.26.1] - 2026-08-13
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- `clip-url`:MCP 抓取脚本此前写死仓库内相对路径来定位 `browser-fetch-mcp`,在任何非源码 checkout 的已安装环境下都会 `FileNotFoundError`;改为 dev-mode(仓库内)优先、已安装模式(`~/.local/bin/browser-fetch-mcp`)兜底的探测逻辑,并把 `browser-fetch-mcp` 注册进 `skills-index.json` 的 `tools[]`(新增 `research-tools` bundle),使其首次可通过 `hskill install --tool browser-fetch-mcp` 安装
|
|
26
|
+
|
|
10
27
|
## [0.26.0] - 2026-08-12
|
|
11
28
|
|
|
12
29
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harveyz-skill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Skill manager for Claude Code, Cursor, Codex, OpenClaw, Hermes, OpenCode, and Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"skills/coding/handoff/",
|
|
58
58
|
"tools/hub/",
|
|
59
59
|
"tools/sync-agent/",
|
|
60
|
+
"tools/browser-fetch-mcp/",
|
|
60
61
|
"hooks/check-similar-branch/"
|
|
61
62
|
],
|
|
62
63
|
"engines": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clip-url
|
|
3
|
-
version: "0.7.
|
|
4
|
-
description: "
|
|
3
|
+
version: "0.7.5"
|
|
4
|
+
description: "Use this the instant a URL is shared with any intent to save, archive, clip, or translate-and-keep it in Obsidian — a bare link with no comment, \"save this\", \"archive this\", \"clip this\", \"add to obsidian\", \"存一下\", \"存到 vault/obsidian\", \"留个档\", \"翻译一下存\", \"帮我存/抓一下这个链接\", or a request to fetch a page via browser-fetch-mcp. Covers arXiv papers, WeChat/公众号 posts, X/Twitter threads, Hacker News links, blog posts, news articles, and general webpages — including sites needing special handling (login walls, images, JS-rendered content). Do not use for translate-or-summarize-only requests with no save intent, in-page actions like clicking buttons or filling forms, retagging or fixing metadata on an article already saved, links shared purely for reaction or jokes, or topic searches with no specific URL given."
|
|
5
5
|
user_invocable: true
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -9,6 +9,8 @@ user_invocable: true
|
|
|
9
9
|
|
|
10
10
|
这是 [browser-fetch-mcp](../../../tools/browser-fetch-mcp/) 的验证性消费者,跟 extract-url 的 Subagent 1/2 结构对齐,做"抓取(MCP,经 fetch_article 做站点感知抽取)→ 打标 + 翻译 → 存文件"两阶段流程。URL 去重和固定标签词表与 extract-url 共用同一份 `~/.hskill/url-extract/config.json`(`VAULT_PATH`)和 `fixed_tags.txt`,两边抓过的文章互相认得出"已抓取"。仍不是 extract-url 的完全等价替代(例如没有 `validate_article.py` 那样的 frontmatter 自动修复),只用于验证 MCP 抓取链路能否支撑一个完整的两阶段 skill 流程并逐步对齐生产行为。抓取产出的原文文件名与 extract-url 一致,按标题命名(`Origin/<标题>.md`,Translation 沿用同一文件名),两者共存于同一个 `<hash8>/` 目录下,去重判定只看 `meta.json` 的 `source_url`,不受文件名影响。
|
|
11
11
|
|
|
12
|
+
**依赖**:本 skill 的 MCP 抓取脚本(`scripts/mcp_fetch_client.py` 等)依赖 `browser-fetch-mcp`。在本仓库 checkout 内直接运行时会自动找到 `tools/browser-fetch-mcp/browser-fetch-mcp.sh`;若这个 skill 是通过 `hskill install` 安装到别处运行的(`~/.claude/skills/`、`~/.pi/agent/skills/` 等),需要额外单独运行 `hskill install --tool browser-fetch-mcp` 装好 launcher(落到 `~/.local/bin/browser-fetch-mcp`)。步骤 1.5 会在流程一开始就检测这个依赖是否满足。
|
|
13
|
+
|
|
12
14
|
## 路径变量
|
|
13
15
|
|
|
14
16
|
```
|
|
@@ -17,6 +19,17 @@ SkillDir: skills/research/clip-url
|
|
|
17
19
|
|
|
18
20
|
## 执行流程
|
|
19
21
|
|
|
22
|
+
流程概览(各步骤的判断条件和细节以下方对应小节为准,这里只做路线图):
|
|
23
|
+
|
|
24
|
+
1. 净化 URL
|
|
25
|
+
2. 确认默认 chrome_profile(只在第一次使用本 skill 时问一次)
|
|
26
|
+
2.5. 确认共享配置存在(VAULT_PATH / 固定词表)
|
|
27
|
+
3. 派发 Subagent 1:MCP 抓取
|
|
28
|
+
4. 判断抓取结果,决定是否需要自优化
|
|
29
|
+
4.5. 派发 Subagent 3:自优化(仅在步骤 4 判定需要时)
|
|
30
|
+
5. 派发 Subagent 2:打标 + 翻译
|
|
31
|
+
6. 向用户输出结果卡片
|
|
32
|
+
|
|
20
33
|
### 步骤 1:净化 URL
|
|
21
34
|
|
|
22
35
|
```python
|
|
@@ -24,16 +37,26 @@ import re
|
|
|
24
37
|
url_safe = re.sub(r'[\x00-\x1f\x7f]', '', url).strip()[:2048]
|
|
25
38
|
```
|
|
26
39
|
|
|
27
|
-
### 步骤
|
|
40
|
+
### 步骤 1.5:确认 browser-fetch-mcp 可用
|
|
41
|
+
|
|
42
|
+
运行 `python3 SkillDir/scripts/browser_fetch_mcp_locate.py`。
|
|
43
|
+
|
|
44
|
+
- 若输出 `FOUND: <path>`:继续步骤 2。
|
|
45
|
+
- 若输出 `NOT_FOUND: <error>`(exit code 1):向用户报告"browser-fetch-mcp 未安装或未找到:{error}。若在本仓库 checkout 内运行,请确认 `tools/browser-fetch-mcp/browser-fetch-mcp.sh` 存在;若是通过 `hskill install` 安装的 skill 副本,请先运行 `hskill install --tool browser-fetch-mcp`",流程终止,不再执行后续步骤。
|
|
46
|
+
|
|
47
|
+
### 步骤 2:确认默认 chrome_profile(只在第一次使用本 skill 时问一次,之后不再询问)
|
|
28
48
|
|
|
29
49
|
运行 `python3 SkillDir/scripts/chrome_profile_config.py get`。
|
|
30
50
|
|
|
31
|
-
- 若输出 `CONFIGURED: <path>`:已经配置过默认 profile,跳过下面的检测和提问,直接进入步骤
|
|
32
|
-
- 若输出 `NOT_CONFIGURED
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
- 若输出 `CONFIGURED: <path>`:已经配置过默认 profile,跳过下面的检测和提问,直接进入步骤 2.5。
|
|
52
|
+
- 若输出 `NOT_CONFIGURED`:运行 `python3 SkillDir/scripts/chrome_profile_config.py prompted`。
|
|
53
|
+
- 若输出 `YES`(之前已经问过一次,不管当时用户是设置了值还是选择不设置):跳过检测和提问,直接进入步骤 2.5。
|
|
54
|
+
- 若输出 `NO`(第一次遇到这个状态,不论当前 URL 是什么网站都会命中):
|
|
55
|
+
1. 运行 `python3 SkillDir/scripts/detect_xcom_chrome_profile.py`,把完整输出(对比表 + `RECOMMENDED_PROFILE:` 那行)原样展示给用户。
|
|
56
|
+
2. 向用户提问:把推荐的 profile 设为以后的默认值?或输入一个替代路径?也可以选择不设置。
|
|
57
|
+
3. 不论用户如何回答,运行 `python3 SkillDir/scripts/chrome_profile_config.py mark-prompted`,记录"已经问过一次"——此后所有网站的抓取都不会再触发这个设置流程(包括用户当时选择不设置的情况)。
|
|
58
|
+
4. 若用户提供了 profile 路径(推荐的或自己输入的):运行 `python3 SkillDir/scripts/chrome_profile_config.py set <path>` 持久化。
|
|
59
|
+
5. 若用户选择不设置:不持久化 profile 值,本次继续(x.com 的 URL 会在 Subagent 1 里因为 `fetch_article` 报错而失败——x.com 没有匿名抓取选项;非 x.com 的 URL 正常匿名抓取,不受影响)。用户之后可随时手动运行 `chrome_profile_config.py set <path>` 补配置。
|
|
37
60
|
|
|
38
61
|
**不允许**:跳过展示直接把探测到的 profile 设为默认值——必须等用户明确回答,且只有用户确认后才能调用 `chrome_profile_config.py set`。
|
|
39
62
|
|
|
@@ -132,6 +155,7 @@ result = subprocess.run(
|
|
|
132
155
|
| `references/subagent1-fetch-prompt.md` | Subagent 1(MCP 抓取)派发 prompt 模板,含去重检查 |
|
|
133
156
|
| `references/subagent2-tag-translate-prompt.md` | Subagent 2(两阶段打标 + 翻译)派发 prompt 模板 |
|
|
134
157
|
| `references/subagent-self-optimize-prompt.md` | Subagent 3(自优化,抓取失败/过薄时触发)派发 prompt 模板 |
|
|
158
|
+
| `scripts/browser_fetch_mcp_locate.py` | 步骤 1.5 前置检测:定位 browser-fetch-mcp launcher(dev-mode 优先,已安装模式兜底),也被下面几个 MCP client 脚本共用 |
|
|
135
159
|
| `scripts/vault_config.py` | 读共享 `VAULT_PATH`(`~/.hskill/url-extract/config.json`),计算文章路径 |
|
|
136
160
|
| `scripts/dedup_check.py` | URL 去重检查(读 `<hash8>/meta.json`) |
|
|
137
161
|
| `scripts/article_meta.py` | 去重索引写入 + 固定词表兜底移位(纯函数库) |
|
|
@@ -139,4 +163,4 @@ result = subprocess.run(
|
|
|
139
163
|
| `scripts/mcp_fetch_client.py` | 核心脚本:真实 MCP client,调用 browser-fetch-mcp 的 `fetch_article`,`fetch_and_report` 额外返回诊断字段 |
|
|
140
164
|
| `scripts/mcp_debug_client.py` | 自优化 subagent 用的调试客户端,包装 browser-fetch-mcp 的 `fetch_page`/`evaluate_js` |
|
|
141
165
|
| `scripts/detect_xcom_chrome_profile.py` | 通过 browser-fetch-mcp 的 `list_chrome_profiles` MCP 工具检测哪些 Chrome profile 登录了 x.com,仅供用户确认用,不自动使用检测结果 |
|
|
142
|
-
| `scripts/chrome_profile_config.py` | 读写 browser-fetch-mcp 持久化的默认 chrome_profile(`get`/`set` 子命令) |
|
|
166
|
+
| `scripts/chrome_profile_config.py` | 读写 browser-fetch-mcp 持久化的默认 chrome_profile(`get`/`set` 子命令),以及本地记录"是否已问过一次"的标记(`prompted`/`mark-prompted` 子命令) |
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/skills/research/clip-url/scripts/__pycache__/write_meta_and_separate.cpython-314.pyc
CHANGED
|
Binary file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Locates the browser-fetch-mcp launcher for clip-url's MCP client scripts.
|
|
2
|
+
|
|
3
|
+
Two supported layouts:
|
|
4
|
+
- Dev mode: this skill runs from inside a harveyz-skill git checkout, where
|
|
5
|
+
tools/browser-fetch-mcp/browser-fetch-mcp.sh sits four directories above
|
|
6
|
+
scripts/ (skills/research/clip-url/scripts -> repo root).
|
|
7
|
+
- Installed mode: this skill was installed via `hskill install` (to
|
|
8
|
+
~/.claude/skills, ~/.pi/agent/skills, etc.), and browser-fetch-mcp was
|
|
9
|
+
separately installed as a tool — its launcher lands at
|
|
10
|
+
~/.local/bin/browser-fetch-mcp (see tools/browser-fetch-mcp/tool.json).
|
|
11
|
+
"""
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_browser_fetch_mcp() -> str:
|
|
18
|
+
dev_path = Path(__file__).resolve().parents[4] / "tools" / "browser-fetch-mcp" / "browser-fetch-mcp.sh"
|
|
19
|
+
if dev_path.exists():
|
|
20
|
+
return str(dev_path)
|
|
21
|
+
|
|
22
|
+
on_path = shutil.which("browser-fetch-mcp")
|
|
23
|
+
if on_path:
|
|
24
|
+
return on_path
|
|
25
|
+
|
|
26
|
+
installed_path = Path.home() / ".local" / "bin" / "browser-fetch-mcp"
|
|
27
|
+
if installed_path.exists():
|
|
28
|
+
return str(installed_path)
|
|
29
|
+
|
|
30
|
+
raise FileNotFoundError(
|
|
31
|
+
"browser-fetch-mcp launcher not found. Run clip-url from a harveyz-skill "
|
|
32
|
+
"git checkout, or run `hskill install` and select the browser-fetch-mcp tool."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
"""CLI preflight check for SKILL.md: prints FOUND/NOT_FOUND instead of
|
|
38
|
+
letting the FileNotFoundError surface as a raw traceback from whichever
|
|
39
|
+
client script happens to import this module first."""
|
|
40
|
+
try:
|
|
41
|
+
print(f"FOUND: {find_browser_fetch_mcp()}")
|
|
42
|
+
except FileNotFoundError as e:
|
|
43
|
+
print(f"NOT_FOUND: {e}")
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main()
|
|
@@ -4,13 +4,23 @@ default Chrome profile (get_default_chrome_profile / set_default_chrome_profile
|
|
|
4
4
|
tools). Written from scratch, same stdio_client pattern as mcp_fetch_client.py
|
|
5
5
|
and detect_xcom_chrome_profile.py.
|
|
6
6
|
|
|
7
|
+
Also tracks, purely locally (no MCP call involved), whether clip-url has
|
|
8
|
+
already asked the user about chrome_profile once — so SKILL.md's step 2
|
|
9
|
+
only prompts on the very first run, regardless of whether the user set a
|
|
10
|
+
profile or declined, instead of re-prompting on every run until a profile
|
|
11
|
+
happens to get set.
|
|
12
|
+
|
|
7
13
|
Usage:
|
|
8
14
|
python3 chrome_profile_config.py get
|
|
9
15
|
python3 chrome_profile_config.py set <profile_path>
|
|
16
|
+
python3 chrome_profile_config.py prompted
|
|
17
|
+
python3 chrome_profile_config.py mark-prompted
|
|
10
18
|
|
|
11
19
|
get prints "CONFIGURED: <path>" or "NOT_CONFIGURED".
|
|
12
20
|
set prints "OK" on success; on failure, prints the error to stderr and
|
|
13
21
|
exits 1 (e.g. profile_path doesn't exist or isn't a directory).
|
|
22
|
+
prompted prints "YES" or "NO".
|
|
23
|
+
mark-prompted records that the user has been asked and prints "OK".
|
|
14
24
|
"""
|
|
15
25
|
import asyncio
|
|
16
26
|
import json
|
|
@@ -21,9 +31,25 @@ from pathlib import Path
|
|
|
21
31
|
from mcp import ClientSession, StdioServerParameters
|
|
22
32
|
from mcp.client.stdio import stdio_client
|
|
23
33
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
)
|
|
34
|
+
from browser_fetch_mcp_locate import find_browser_fetch_mcp
|
|
35
|
+
|
|
36
|
+
BROWSER_FETCH_MCP_SH = find_browser_fetch_mcp()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _prompted_marker_path() -> Path:
|
|
40
|
+
env_dir = os.environ.get("HSKILL_CLIP_URL_DATA_DIR")
|
|
41
|
+
base = Path(env_dir) if env_dir else Path.home() / ".hskill" / "clip-url"
|
|
42
|
+
return base / "chrome_profile_prompted"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_prompted() -> bool:
|
|
46
|
+
return _prompted_marker_path().exists()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def mark_prompted() -> None:
|
|
50
|
+
marker = _prompted_marker_path()
|
|
51
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
marker.write_text("", encoding="utf-8")
|
|
27
53
|
|
|
28
54
|
|
|
29
55
|
async def _get() -> str:
|
|
@@ -57,14 +83,27 @@ async def _set(profile_path: str) -> str:
|
|
|
57
83
|
|
|
58
84
|
|
|
59
85
|
def main():
|
|
60
|
-
|
|
61
|
-
|
|
86
|
+
valid = ("get", "set", "prompted", "mark-prompted")
|
|
87
|
+
if len(sys.argv) < 2 or sys.argv[1] not in valid:
|
|
88
|
+
print(
|
|
89
|
+
"Usage: chrome_profile_config.py get | set <profile_path> | prompted | mark-prompted",
|
|
90
|
+
file=sys.stderr,
|
|
91
|
+
)
|
|
62
92
|
sys.exit(1)
|
|
63
93
|
|
|
64
94
|
if sys.argv[1] == "get":
|
|
65
95
|
print(asyncio.run(_get()))
|
|
66
96
|
return
|
|
67
97
|
|
|
98
|
+
if sys.argv[1] == "prompted":
|
|
99
|
+
print("YES" if get_prompted() else "NO")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if sys.argv[1] == "mark-prompted":
|
|
103
|
+
mark_prompted()
|
|
104
|
+
print("OK")
|
|
105
|
+
return
|
|
106
|
+
|
|
68
107
|
if len(sys.argv) < 3:
|
|
69
108
|
print("Usage: chrome_profile_config.py set <profile_path>", file=sys.stderr)
|
|
70
109
|
sys.exit(1)
|
|
@@ -23,14 +23,13 @@ isError/structuredContent), same as mcp_fetch_client.py.
|
|
|
23
23
|
import asyncio
|
|
24
24
|
import json
|
|
25
25
|
import os
|
|
26
|
-
from pathlib import Path
|
|
27
26
|
|
|
28
27
|
from mcp import ClientSession, StdioServerParameters
|
|
29
28
|
from mcp.client.stdio import stdio_client
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
30
|
+
from browser_fetch_mcp_locate import find_browser_fetch_mcp
|
|
31
|
+
|
|
32
|
+
BROWSER_FETCH_MCP_SH = find_browser_fetch_mcp()
|
|
34
33
|
|
|
35
34
|
HOST_KEYS = [".x.com", ".twitter.com"]
|
|
36
35
|
COOKIE_NAMES = ["auth_token", "ct0", "twid"]
|
|
@@ -10,15 +10,14 @@ mcp 1.28.1 camelCase note as mcp_fetch_client.py).
|
|
|
10
10
|
"""
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
-
from pathlib import Path
|
|
14
13
|
from typing import Optional
|
|
15
14
|
|
|
16
15
|
from mcp import ClientSession, StdioServerParameters
|
|
17
16
|
from mcp.client.stdio import stdio_client
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
)
|
|
18
|
+
from browser_fetch_mcp_locate import find_browser_fetch_mcp
|
|
19
|
+
|
|
20
|
+
BROWSER_FETCH_MCP_SH = find_browser_fetch_mcp()
|
|
22
21
|
|
|
23
22
|
|
|
24
23
|
async def _call_tool(tool_name: str, tool_args: dict) -> dict:
|
|
@@ -33,10 +33,9 @@ from mcp import ClientSession, StdioServerParameters
|
|
|
33
33
|
from mcp.client.stdio import stdio_client
|
|
34
34
|
|
|
35
35
|
import vault_config
|
|
36
|
+
from browser_fetch_mcp_locate import find_browser_fetch_mcp
|
|
36
37
|
|
|
37
|
-
BROWSER_FETCH_MCP_SH = (
|
|
38
|
-
Path(__file__).resolve().parents[4] / "tools" / "browser-fetch-mcp" / "browser-fetch-mcp.sh"
|
|
39
|
-
)
|
|
38
|
+
BROWSER_FETCH_MCP_SH = find_browser_fetch_mcp()
|
|
40
39
|
|
|
41
40
|
|
|
42
41
|
async def fetch_and_report(url: str, chrome_profile: Optional[str] = None) -> dict:
|
|
Binary file
|
package/skills/research/clip-url/tests/__pycache__/test_article_meta.cpython-314-pytest-9.1.1.pyc
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/skills/research/clip-url/tests/__pycache__/test_dedup_check.cpython-314-pytest-9.1.1.pyc
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/skills/research/clip-url/tests/__pycache__/test_vault_config.cpython-314-pytest-9.1.1.pyc
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Unit tests for browser_fetch_mcp_locate.py's dev-mode / installed-mode
|
|
2
|
+
fallback — never touches a real browser-fetch-mcp installation."""
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
|
9
|
+
|
|
10
|
+
import browser_fetch_mcp_locate # noqa: E402
|
|
11
|
+
from browser_fetch_mcp_locate import find_browser_fetch_mcp, main # noqa: E402
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_prefers_dev_mode_path_when_it_exists(monkeypatch, tmp_path):
|
|
15
|
+
dev_sh = tmp_path / "tools" / "browser-fetch-mcp" / "browser-fetch-mcp.sh"
|
|
16
|
+
dev_sh.parent.mkdir(parents=True)
|
|
17
|
+
dev_sh.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
|
18
|
+
|
|
19
|
+
fake_module_path = tmp_path / "skills" / "research" / "clip-url" / "scripts" / "browser_fetch_mcp_locate.py"
|
|
20
|
+
monkeypatch.setattr(browser_fetch_mcp_locate, "__file__", str(fake_module_path))
|
|
21
|
+
|
|
22
|
+
assert find_browser_fetch_mcp() == str(dev_sh)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_falls_back_to_path_lookup_when_dev_path_missing(monkeypatch, tmp_path):
|
|
26
|
+
fake_module_path = tmp_path / "skills" / "research" / "clip-url" / "scripts" / "browser_fetch_mcp_locate.py"
|
|
27
|
+
monkeypatch.setattr(browser_fetch_mcp_locate, "__file__", str(fake_module_path))
|
|
28
|
+
monkeypatch.setattr(browser_fetch_mcp_locate.shutil, "which", lambda name: "/usr/local/bin/browser-fetch-mcp")
|
|
29
|
+
|
|
30
|
+
assert find_browser_fetch_mcp() == "/usr/local/bin/browser-fetch-mcp"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_falls_back_to_local_bin_when_not_on_path(monkeypatch, tmp_path):
|
|
34
|
+
fake_module_path = tmp_path / "skills" / "research" / "clip-url" / "scripts" / "browser_fetch_mcp_locate.py"
|
|
35
|
+
monkeypatch.setattr(browser_fetch_mcp_locate, "__file__", str(fake_module_path))
|
|
36
|
+
monkeypatch.setattr(browser_fetch_mcp_locate.shutil, "which", lambda name: None)
|
|
37
|
+
|
|
38
|
+
fake_home = tmp_path / "home"
|
|
39
|
+
installed = fake_home / ".local" / "bin" / "browser-fetch-mcp"
|
|
40
|
+
installed.parent.mkdir(parents=True)
|
|
41
|
+
installed.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
|
42
|
+
monkeypatch.setattr(browser_fetch_mcp_locate.Path, "home", classmethod(lambda cls: fake_home))
|
|
43
|
+
|
|
44
|
+
assert find_browser_fetch_mcp() == str(installed)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_raises_actionable_error_when_nothing_found(monkeypatch, tmp_path):
|
|
48
|
+
fake_module_path = tmp_path / "skills" / "research" / "clip-url" / "scripts" / "browser_fetch_mcp_locate.py"
|
|
49
|
+
monkeypatch.setattr(browser_fetch_mcp_locate, "__file__", str(fake_module_path))
|
|
50
|
+
monkeypatch.setattr(browser_fetch_mcp_locate.shutil, "which", lambda name: None)
|
|
51
|
+
monkeypatch.setattr(browser_fetch_mcp_locate.Path, "home", classmethod(lambda cls: tmp_path / "empty-home"))
|
|
52
|
+
|
|
53
|
+
with pytest.raises(FileNotFoundError, match="hskill install"):
|
|
54
|
+
find_browser_fetch_mcp()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_main_prints_found_and_exits_zero(monkeypatch, tmp_path, capsys):
|
|
58
|
+
dev_sh = tmp_path / "tools" / "browser-fetch-mcp" / "browser-fetch-mcp.sh"
|
|
59
|
+
dev_sh.parent.mkdir(parents=True)
|
|
60
|
+
dev_sh.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
fake_module_path = tmp_path / "skills" / "research" / "clip-url" / "scripts" / "browser_fetch_mcp_locate.py"
|
|
63
|
+
monkeypatch.setattr(browser_fetch_mcp_locate, "__file__", str(fake_module_path))
|
|
64
|
+
|
|
65
|
+
main()
|
|
66
|
+
|
|
67
|
+
captured = capsys.readouterr()
|
|
68
|
+
assert captured.out.strip() == f"FOUND: {dev_sh}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_main_prints_not_found_and_exits_one(monkeypatch, tmp_path, capsys):
|
|
72
|
+
fake_module_path = tmp_path / "skills" / "research" / "clip-url" / "scripts" / "browser_fetch_mcp_locate.py"
|
|
73
|
+
monkeypatch.setattr(browser_fetch_mcp_locate, "__file__", str(fake_module_path))
|
|
74
|
+
monkeypatch.setattr(browser_fetch_mcp_locate.shutil, "which", lambda name: None)
|
|
75
|
+
monkeypatch.setattr(browser_fetch_mcp_locate.Path, "home", classmethod(lambda cls: tmp_path / "empty-home"))
|
|
76
|
+
|
|
77
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
78
|
+
main()
|
|
79
|
+
|
|
80
|
+
assert exc_info.value.code == 1
|
|
81
|
+
captured = capsys.readouterr()
|
|
82
|
+
assert captured.out.startswith("NOT_FOUND: ")
|
|
83
|
+
assert "hskill install" in captured.out
|
|
@@ -44,3 +44,31 @@ def test_set_rejects_nonexistent_path(tmp_path):
|
|
|
44
44
|
result = _run(["set", str(tmp_path / "DoesNotExist")], data_dir)
|
|
45
45
|
assert result.returncode == 1
|
|
46
46
|
assert result.stderr.strip() != ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_prompted(args: list[str], data_dir: Path, clip_url_dir: Path) -> subprocess.CompletedProcess:
|
|
50
|
+
return subprocess.run(
|
|
51
|
+
[sys.executable, str(SCRIPT), *args],
|
|
52
|
+
env={
|
|
53
|
+
**os.environ,
|
|
54
|
+
"BROWSER_FETCH_MCP_DATA_DIR": str(data_dir),
|
|
55
|
+
"HSKILL_CLIP_URL_DATA_DIR": str(clip_url_dir),
|
|
56
|
+
},
|
|
57
|
+
capture_output=True, text=True, timeout=30,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_prompted_reports_no_initially(tmp_path):
|
|
62
|
+
result = _run_prompted(["prompted"], tmp_path / "data", tmp_path / "clip-url")
|
|
63
|
+
assert result.returncode == 0, result.stderr
|
|
64
|
+
assert result.stdout.strip() == "NO"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_mark_prompted_then_prompted_reports_yes(tmp_path):
|
|
68
|
+
clip_url_dir = tmp_path / "clip-url"
|
|
69
|
+
mark_result = _run_prompted(["mark-prompted"], tmp_path / "data", clip_url_dir)
|
|
70
|
+
assert mark_result.returncode == 0, mark_result.stderr
|
|
71
|
+
assert mark_result.stdout.strip() == "OK"
|
|
72
|
+
|
|
73
|
+
prompted_result = _run_prompted(["prompted"], tmp_path / "data", clip_url_dir)
|
|
74
|
+
assert prompted_result.stdout.strip() == "YES"
|
package/skills-index.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"toolBundleMeta": {
|
|
3
|
-
"tools": "开发者 OS 工具(hub — 项目管理 + git 状态 + 任务跟踪;sync — Syncthing 守护进程管理)"
|
|
3
|
+
"tools": "开发者 OS 工具(hub — 项目管理 + git 状态 + 任务跟踪;sync — Syncthing 守护进程管理)",
|
|
4
|
+
"research-tools": "研究抓取后端(browser-fetch-mcp — clip-url/extract-url 共用的认证浏览器抓取 MCP server)"
|
|
4
5
|
},
|
|
5
6
|
"tools": [
|
|
6
7
|
{
|
|
@@ -14,6 +15,11 @@
|
|
|
14
15
|
"path": "tools/sync-agent",
|
|
15
16
|
"bundle": "tools",
|
|
16
17
|
"installScope": "global"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "browser-fetch-mcp",
|
|
21
|
+
"path": "tools/browser-fetch-mcp",
|
|
22
|
+
"bundle": "research-tools"
|
|
17
23
|
}
|
|
18
24
|
],
|
|
19
25
|
"bundleMeta": {
|
|
@@ -93,8 +99,8 @@
|
|
|
93
99
|
"path": "research/clip-url",
|
|
94
100
|
"bundle": "research",
|
|
95
101
|
"installScope": "project",
|
|
96
|
-
"contentHash": "
|
|
97
|
-
"contentVersion": "0.7.
|
|
102
|
+
"contentHash": "46e5edd59daaa755",
|
|
103
|
+
"contentVersion": "0.7.5"
|
|
98
104
|
},
|
|
99
105
|
{
|
|
100
106
|
"path": "creative/capture-todo",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
|
|
6
|
+
# Auto-detect dev mode: script is running from the source tree
|
|
7
|
+
if [ -d "${SCRIPT_DIR}/browser_fetch_mcp" ] && [ -f "${SCRIPT_DIR}/pyproject.toml" ]; then
|
|
8
|
+
DEV_VENV="${SCRIPT_DIR}/.venv"
|
|
9
|
+
if [ ! -x "${DEV_VENV}/bin/browser-fetch-mcp" ]; then
|
|
10
|
+
python3 -m venv "${DEV_VENV}"
|
|
11
|
+
"${DEV_VENV}/bin/pip" install -q -e "${SCRIPT_DIR}"
|
|
12
|
+
"${DEV_VENV}/bin/python3" -m playwright install chromium
|
|
13
|
+
fi
|
|
14
|
+
exec "${DEV_VENV}/bin/browser-fetch-mcp" "$@"
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
VENV_DIR="${HOME}/.hskill/tools/browser-fetch-mcp/venv"
|
|
18
|
+
INSTALL_DIR="${HOME}/.hskill/tools/browser-fetch-mcp"
|
|
19
|
+
HASH_FILE="${VENV_DIR}/.installed_hash"
|
|
20
|
+
|
|
21
|
+
_hash_source() {
|
|
22
|
+
find "${INSTALL_DIR}" -type f \( -name "*.py" -o -name "*.toml" -o -name "*.json" \) \
|
|
23
|
+
! -path "*/__pycache__/*" ! -path "*/venv/*" \
|
|
24
|
+
| sort | xargs sha256sum 2>/dev/null | sha256sum | awk '{print $1}'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
CURRENT_HASH=$(_hash_source)
|
|
28
|
+
|
|
29
|
+
if [ ! -x "${VENV_DIR}/bin/browser-fetch-mcp" ] || [ "$(cat "${HASH_FILE}" 2>/dev/null)" != "${CURRENT_HASH}" ]; then
|
|
30
|
+
python3 -m venv "${VENV_DIR}"
|
|
31
|
+
"${VENV_DIR}/bin/pip" install -q --upgrade "${INSTALL_DIR}"
|
|
32
|
+
"${VENV_DIR}/bin/python3" -m playwright install chromium
|
|
33
|
+
echo "${CURRENT_HASH}" > "${HASH_FILE}"
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
exec "${VENV_DIR}/bin/browser-fetch-mcp" "$@"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Persisted default Chrome profile — a single JSON file in the server's
|
|
2
|
+
data dir, {"default_chrome_profile": "<path>"}. Read/write here is pure
|
|
3
|
+
I/O; the caller (server.py) owns resolving `data_dir` (BROWSER_FETCH_MCP_DATA_DIR
|
|
4
|
+
override for tests) and validating the path before calling set().
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _config_file(data_dir: Path) -> Path:
|
|
12
|
+
return data_dir / "config.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_default_chrome_profile(data_dir: Path) -> Optional[str]:
|
|
16
|
+
path = _config_file(data_dir)
|
|
17
|
+
if not path.exists():
|
|
18
|
+
return None
|
|
19
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
20
|
+
return data.get("default_chrome_profile")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_default_chrome_profile(data_dir: Path, profile_path: str) -> None:
|
|
24
|
+
path = _config_file(data_dir)
|
|
25
|
+
path.write_text(json.dumps({"default_chrome_profile": profile_path}), encoding="utf-8")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Chrome profile cookie extraction.
|
|
2
|
+
|
|
3
|
+
Mirrors docs/explanation/chrome-profile-cookie-injection.md's pattern:
|
|
4
|
+
copy the Cookies DB to a temp file first (Chrome holds an exclusive lock
|
|
5
|
+
on the original while running), then let pycookiecheat decrypt it. This
|
|
6
|
+
module only extracts plaintext cookies — injecting them into a browser
|
|
7
|
+
context happens in server.py, which owns the browser lifecycle.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pycookiecheat
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_cookies(url: str, chrome_profile: str) -> dict[str, str]:
|
|
18
|
+
"""Return {cookie_name: plaintext_value} for `url`'s domain from the
|
|
19
|
+
given Chrome profile.
|
|
20
|
+
|
|
21
|
+
Returns {} if the profile has no Cookies file, or if pycookiecheat
|
|
22
|
+
finds no matching cookies — this is a normal "not logged in" result,
|
|
23
|
+
not an error condition, so it never raises for that case.
|
|
24
|
+
"""
|
|
25
|
+
cookies_src = Path(chrome_profile) / "Cookies"
|
|
26
|
+
if not cookies_src.exists():
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
fd, tmp_path = tempfile.mkstemp(suffix=".db")
|
|
30
|
+
os.close(fd)
|
|
31
|
+
try:
|
|
32
|
+
shutil.copy2(cookies_src, tmp_path)
|
|
33
|
+
return pycookiecheat.chrome_cookies(url, cookie_file=tmp_path) or {}
|
|
34
|
+
finally:
|
|
35
|
+
os.unlink(tmp_path)
|