Distribution repo skeleton
One `npx skills add ssh://gitea/agent-skills/skills` now offers all five skills in a grouped multi-select list. Groups come from .claude-plugin/marketplace.json: writing-clarity, windows-tools, image-to-data. skills/ holds real copies rather than submodules or pointers. The installer clones without --recurse-submodules (git.ts:241), so a submodule directory would arrive empty, and it skips remote sources declared in a marketplace manifest (plugin-manifest.ts:89), so only in-repo paths are resolvable. Grouping applies only to skills listed explicitly under a plugin's skills array with a ./-prefixed path (plugin-manifest.ts:147-151); anything not listed falls into "Other". think-twice and teach-me share a group because teach-me's SKILL.md points at double-check.md, self-contain.md and style.md inside the think-twice directory. Installing teach-me alone leaves those pointers unresolved. Syncing from the five upstream repos is manual for now.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "agent-skills",
|
||||
"owner": {
|
||||
"name": "im0o"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "writing-clarity",
|
||||
"description": "Rebuild an output so it stands alone and every claim is verified, and teach a subject layer by layer until the reader says they understand.",
|
||||
"skills": [
|
||||
"./skills/think-twice",
|
||||
"./skills/teach-me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "windows-tools",
|
||||
"description": "Read-only Windows diagnostics: what is filling a drive, and where a file lives.",
|
||||
"skills": [
|
||||
"./skills/disk-master",
|
||||
"./skills/everything-file-search"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "image-to-data",
|
||||
"description": "Turn a picture of a table into structured data.",
|
||||
"skills": [
|
||||
"./skills/baimiao-table-ocr"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Git Bash 下 `> nul` 会创建这个真实文件
|
||||
nul
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,45 @@
|
||||
# agent-skills
|
||||
|
||||
一次装齐 im0o 的 agent skill。
|
||||
|
||||
```bash
|
||||
npx skills add ssh://gitea/agent-skills/skills
|
||||
```
|
||||
|
||||
会出现一个多选列表,按用途分成三组,勾选需要的即可。仓库是私有的,`skills add`
|
||||
用系统 git 克隆,所以只要你的 SSH key 能访问就能装,不需要额外配置凭据。
|
||||
|
||||
## 目录里有什么
|
||||
|
||||
| 组 | skill | 做什么 | 平台 |
|
||||
|---|---|---|---|
|
||||
| Writing Clarity | `think-twice` | 重写一段输出,让它脱离上下文也能读懂,并且每条声明都经过验证 | 任意 |
|
||||
| Writing Clarity | `teach-me` | 逐层讲一个主题,每轮一层,由用户决定什么时候进下一层 | 任意 |
|
||||
| Windows Tools | `disk-master` | 只读扫描磁盘占用,解释盘为什么满了,生成可交互 HTML 报告 | Windows |
|
||||
| Windows Tools | `everything-file-search` | 借 Everything 的 IPC 按文件名和路径极快定位文件 | Windows,需要 Everything 在跑 |
|
||||
| Image To Data | `baimiao-table-ocr` | 表格截图转 CSV / JSON / Markdown / XLSX | 任意,需要网络 |
|
||||
|
||||
`think-twice` 和 `teach-me` 放同一组不是为了好看。`teach-me` 的 SKILL.md 直接引用
|
||||
`think-twice` 目录下的 `double-check.md`、`self-contain.md`、`style.md`,只装
|
||||
`teach-me` 会让这三个指针掉空。它每个指针都带了内联兜底说明,但拿到的是简写版。
|
||||
|
||||
`baimiao-table-ocr` 会把图片上传到第三方服务。隐私敏感的图不要用它。
|
||||
|
||||
## 这个仓和源码仓的关系
|
||||
|
||||
`skills/` 下的五个目录是真副本,不是子模块也不是软链接。原因在
|
||||
`vercel-labs/skills` 的两处代码:`git.ts:241` 的克隆没有 `--recurse-submodules`,
|
||||
子模块目录克隆出来是空的;`plugin-manifest.ts:89` 会跳过 marketplace.json 里的
|
||||
远端来源,只认仓内的本地路径。
|
||||
|
||||
各自的源码仓在同一个 org 下:
|
||||
|
||||
| skill | 源码仓 | 仓内路径 |
|
||||
|---|---|---|
|
||||
| `think-twice` | `agent-skills/think-twice` | 仓根 |
|
||||
| `teach-me` | `agent-skills/teach-me` | 仓根 |
|
||||
| `disk-master` | `agent-skills/disk-master-skill` | `skill/` |
|
||||
| `everything-file-search` | `agent-skills/everything-skill` | `everything-file-search/` |
|
||||
| `baimiao-table-ocr` | `agent-skills/img2excel-skill` | `skills/baimiao-table-ocr/` |
|
||||
|
||||
改动在源码仓做,这个仓只承载分发副本。同步目前是手工的,自动化还没做。
|
||||
@@ -0,0 +1,178 @@
|
||||
# baimiao_table_ocr.py CLI Usage
|
||||
|
||||
`baimiao_table_ocr.py` is a Python CLI for converting table images into structured table data through Baimiao's anonymous image-to-excel web API. It uploads the image to Baimiao's OSS flow, downloads the generated XLSX, and renders it as ASCII, Markdown, CSV, JSON, or XLSX.
|
||||
|
||||
## Location
|
||||
|
||||
```text
|
||||
scripts\baimiao_table_ocr.py
|
||||
```
|
||||
|
||||
Run by absolute path when the skill directory is not the current directory:
|
||||
|
||||
```powershell
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png"
|
||||
```
|
||||
|
||||
## Runtime
|
||||
|
||||
Preferred runtime:
|
||||
|
||||
```powershell
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png"
|
||||
```
|
||||
|
||||
The script includes PEP 723 dependency metadata for `requests` and `openpyxl`, so `uv run` can prepare dependencies automatically.
|
||||
|
||||
Fallback runtime:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r "<skill-dir>\scripts\requirements.txt"
|
||||
python "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png"
|
||||
```
|
||||
|
||||
## Synopsis
|
||||
|
||||
```powershell
|
||||
baimiao_table_ocr.py --image <source> [options]
|
||||
```
|
||||
|
||||
`<source>` can be a file path, `-` for stdin, or a base64/data URI image string.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
| --- | --- |
|
||||
| `-i, --image <source>` | Required. Image source: file path, `-` for stdin, or base64/data URI string. |
|
||||
| `-f, --format <type>` | Output format: `ascii`, `csv`, `json`, `xlsx`, or `md` (default: `ascii`). |
|
||||
| `-o, --out <path>` | Output file path. Required for `xlsx`; optional for text formats. |
|
||||
| `--sheet <name>` | Select a specific worksheet from a multi-sheet OCR result. |
|
||||
| `--uuid <uuid>` | Baimiao client UUID. Takes precedence over `BAIMIAO_UUID`. |
|
||||
| `--timeout <seconds>` | OCR polling timeout in seconds (default: `120`). |
|
||||
| `--proxy <url>` | Proxy URL, for example `http://127.0.0.1:8080` or `socks5://127.0.0.1:7890`. |
|
||||
| `--no-cache` | Skip local XLSX cache and force a new OCR request. |
|
||||
| `-q, --quiet` | Suppress progress logs on stderr. |
|
||||
| `-h, --help` | Show help. |
|
||||
|
||||
## Examples
|
||||
|
||||
```powershell
|
||||
# Default ASCII table to stdout
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png"
|
||||
|
||||
# Markdown table to stdout
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" -f md
|
||||
|
||||
# CSV to file
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" -f csv -o "D:\path\table.csv"
|
||||
|
||||
# JSON to stdout for agent parsing
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" -f json
|
||||
|
||||
# Save generated XLSX
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" -f xlsx -o "D:\path\result.xlsx"
|
||||
|
||||
# Select one sheet
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" -f json --sheet Table2
|
||||
|
||||
# Reuse a known UUID
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" --uuid "<UUID>"
|
||||
|
||||
# Prefer HTTP proxy examples
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" --proxy http://127.0.0.1:8080
|
||||
|
||||
# SOCKS5 proxy requires PySocks
|
||||
uv run --with PySocks "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" --proxy socks5://127.0.0.1:7890
|
||||
```
|
||||
|
||||
## Stdin Input
|
||||
|
||||
Use `--image -` when the image bytes come from stdin:
|
||||
|
||||
```powershell
|
||||
Get-Content -LiteralPath "D:\path\table.png" -AsByteStream | uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image - -f json
|
||||
```
|
||||
|
||||
## Base64 Input
|
||||
|
||||
The CLI accepts raw base64 image data or a data URI:
|
||||
|
||||
```powershell
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "data:image/png;base64,iVBORw0KGgo..." -f md
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
### ASCII
|
||||
|
||||
Default output. Width calculation is CJK-aware, so Chinese table cells align better in terminals.
|
||||
|
||||
### Markdown
|
||||
|
||||
Use `-f md`. Multi-sheet results are separated with `### SheetName` headings.
|
||||
|
||||
### CSV
|
||||
|
||||
Use `-f csv`. Multi-sheet results are separated with comment-like section headers such as `# Table1`.
|
||||
|
||||
### JSON
|
||||
|
||||
Use `-f json`. The output is a JSON object where each key is a worksheet name and each value is a two-dimensional array of strings:
|
||||
|
||||
```json
|
||||
{
|
||||
"Table1": [
|
||||
["Name", "Amount"],
|
||||
["A", "100"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### XLSX
|
||||
|
||||
Use `-f xlsx -o result.xlsx`. The downloaded OCR result file is copied to the requested output path.
|
||||
|
||||
## Cache
|
||||
|
||||
The CLI stores downloaded OCR XLSX files under:
|
||||
|
||||
```text
|
||||
scripts\.baimiao_cache\<image_md5>.xlsx
|
||||
```
|
||||
|
||||
Cache hits skip the network OCR flow and only re-render the requested output format. Use `--no-cache` to force recognition again.
|
||||
|
||||
On every run, the CLI automatically removes cached `.xlsx` files older than 7 days based on file modified time.
|
||||
|
||||
## UUID Behavior
|
||||
|
||||
UUID selection order:
|
||||
|
||||
1. `--uuid <uuid>`.
|
||||
2. `BAIMIAO_UUID` environment variable.
|
||||
3. Newly generated UUID.
|
||||
|
||||
The CLI does not write persistent auth files. Tokens are fetched per run through anonymous login.
|
||||
|
||||
Reusing a UUID avoids repeatedly creating anonymous accounts, but each UUID may still have OCR quota limits. When the server rejects new anonymous UUID creation with `code: 4`, reuse a previous UUID or switch to a different network/proxy exit.
|
||||
|
||||
## Error Messages
|
||||
|
||||
The CLI prints short one-line errors to stderr and exits non-zero. Common messages:
|
||||
|
||||
| Message | Meaning |
|
||||
| --- | --- |
|
||||
| `代理连接失败` | Proxy address, port, or protocol is wrong/unreachable. |
|
||||
| `SSL 握手超时` | Proxy can connect but cannot complete TLS to the target. |
|
||||
| `连接超时` | Target server is unreachable from the current network. |
|
||||
| `网络超时` | Request took too long; retry or use another proxy. |
|
||||
| `网络不通` | General connection failure. |
|
||||
| `UUID 配额已用尽` | Current UUID cannot request more OCR tasks. |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+.
|
||||
- `requests>=2.25`.
|
||||
- `openpyxl>=3.0`.
|
||||
- Optional for SOCKS5 proxy: `PySocks`.
|
||||
- Network access to Baimiao and Aliyun OSS endpoints.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
name: baimiao-table-ocr
|
||||
description: >
|
||||
将表格截图、扫描图或图片中的表格转换为结构化数据。用于用户提供图片并希望提取表格、转 CSV/JSON/Markdown/XLSX、或让 agent 读取图片里的行列内容时。通过 bundled Python CLI 调用白描匿名 Web API,支持文件路径、stdin、base64/data URI 输入,默认输出终端友好的 ASCII 表格。需要网络;不适合隐私敏感或不能上传到第三方服务的图片。
|
||||
---
|
||||
|
||||
# 白描表格 OCR
|
||||
|
||||
本 skill 用于把图片中的表格识别成结构化数据。它通过 bundled CLI 调用白描 Web 的匿名 image-to-excel API,上传图片并下载 OCR 后的 `.xlsx`,再按需要渲染为 ASCII、Markdown、CSV、JSON 或保留 XLSX。
|
||||
|
||||
CLI 的完整参数说明在 [CLI_USAGE.md](CLI_USAGE.md)。逆向 API 细节在 [references/api_reference.md](references/api_reference.md)。
|
||||
|
||||
它不负责:
|
||||
- 本地离线 OCR。
|
||||
- 保证敏感图片不离开本机。
|
||||
- 识别非表格型自然文本。
|
||||
- 修复 OCR 模型本身的识别错误。
|
||||
|
||||
## 何时优先使用
|
||||
|
||||
以下情况优先选择本 skill:
|
||||
|
||||
- 用户提供的是表格截图、账单表、价格表、清单、报表、课程表等图片。
|
||||
- 用户要求“图片转 Excel”“提取图片里的表格”“OCR 表格”“转 CSV/JSON/Markdown”。
|
||||
- agent 需要把图片里的表格内容转成可复制、可分析的结构化文本。
|
||||
- 用户希望保留原始 Excel 文件,用 `-f xlsx -o <path>` 输出。
|
||||
|
||||
## 何时不使用
|
||||
|
||||
以下情况不要使用本 skill:
|
||||
|
||||
- 图片内容隐私敏感,不能上传到第三方服务。
|
||||
- 用户只需要普通 OCR 文本,而不是表格结构。
|
||||
- 用户已经有可读的 XLSX/CSV/HTML 表格文件,直接读取原文件更可靠。
|
||||
- 当前网络不可用,或 Baimiao 匿名接口被限流且没有可用 UUID/代理。
|
||||
|
||||
## 默认调用策略
|
||||
|
||||
agent 使用本 skill 时,默认按以下策略构造命令:
|
||||
|
||||
1. 优先调用 skill 内 bundled CLI,不要假设命令已在 `PATH`:
|
||||
```powershell
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "<image>" [options]
|
||||
```
|
||||
|
||||
2. 如果没有 `uv`,回退系统 Python。回退前确保安装依赖:
|
||||
```powershell
|
||||
python -m pip install -r "<skill-dir>\scripts\requirements.txt"
|
||||
python "<skill-dir>\scripts\baimiao_table_ocr.py" --image "<image>" [options]
|
||||
```
|
||||
|
||||
3. 默认使用 `-f ascii`,因为它适合终端直接阅读。
|
||||
|
||||
4. 如果结果要被后续程序处理,优先用 `-f json` 或 `-f csv`。
|
||||
|
||||
5. 如果结果要贴进 Markdown 文档,使用 `-f md`。
|
||||
|
||||
6. 如果用户要求 Excel 文件,使用 `-f xlsx -o <output.xlsx>`;`xlsx` 格式必须指定 `--out`。
|
||||
|
||||
7. 如果用户已经提供 UUID,或上下文中有之前可用的 UUID,带 `--uuid <uuid>`;否则让 CLI 自动生成 UUID。
|
||||
|
||||
8. 如果用户请求使用代理,先给 HTTP 代理示例;只有明确需要 SOCKS5 时再使用 `socks5://`,并提示需要 `PySocks`。
|
||||
|
||||
9. 默认允许本地缓存。除非用户明确要求重新 OCR 或怀疑缓存过期,才使用 `--no-cache`。
|
||||
|
||||
## 推荐命令模板
|
||||
|
||||
```powershell
|
||||
# 默认:终端 ASCII 表格
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png"
|
||||
|
||||
# Markdown 表格,适合直接回复或写入文档
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" -f md
|
||||
|
||||
# JSON,适合 agent 后续解析
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" -f json
|
||||
|
||||
# 保存 XLSX
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" -f xlsx -o "D:\path\result.xlsx"
|
||||
|
||||
# 多 sheet 时只输出一个 sheet
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" -f json --sheet Table2
|
||||
|
||||
# 使用已有 UUID,避免反复创建匿名账号触发限流
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" --uuid "<UUID>"
|
||||
|
||||
# HTTP 代理优先
|
||||
uv run "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" --proxy http://127.0.0.1:8080
|
||||
|
||||
# SOCKS5 代理,需要额外安装 PySocks
|
||||
uv run --with PySocks "<skill-dir>\scripts\baimiao_table_ocr.py" --image "D:\path\table.png" --proxy socks5://127.0.0.1:7890
|
||||
```
|
||||
|
||||
## 输入说明
|
||||
|
||||
`--image` 支持三类输入:
|
||||
|
||||
- 文件路径,例如 `D:\path\table.png`。
|
||||
- stdin,用 `--image -`,图片二进制从标准输入传入。
|
||||
- base64 或 data URI,例如 `data:image/png;base64,...`。
|
||||
|
||||
## 输出说明
|
||||
|
||||
默认输出 ASCII 表格到 stdout。多 sheet 结果会按 sheet 分段输出。
|
||||
|
||||
可选格式:
|
||||
|
||||
- `ascii`:终端友好,默认格式。
|
||||
- `md`:Markdown 表格。
|
||||
- `csv`:CSV 文本;多 sheet 时用 `# SheetName` 分段。
|
||||
- `json`:对象形式,键为 sheet 名,值为二维数组。
|
||||
- `xlsx`:保存原始 OCR 结果文件,必须指定 `--out`。
|
||||
|
||||
## UUID 与限流
|
||||
|
||||
白描匿名流程使用客户端 UUID 登录获取 guest token。CLI 不持久化 token 或 UUID,只按以下顺序取 UUID:
|
||||
|
||||
1. `--uuid <uuid>`。
|
||||
2. 环境变量 `BAIMIAO_UUID`。
|
||||
3. 自动生成新 UUID。
|
||||
|
||||
同一个 UUID 可以反复登录获得新的 guest token,但 OCR 免费额度可能耗尽。创建新 UUID 可能被服务端按 IP 限流,表现为登录返回 `code: 4`。如果出现限流,优先复用已有 UUID;如果 UUID 额度耗尽,则需要更换 UUID 或切换可用出口 IP。
|
||||
|
||||
## 缓存策略
|
||||
|
||||
CLI 会按图片 MD5 缓存 OCR 下载得到的 `.xlsx`,缓存目录位于脚本目录下的 `.baimiao_cache`。每次运行会自动清理修改时间超过 7 天的 `.xlsx` 缓存文件。缓存命中时会跳过网络 OCR,只重新渲染请求的输出格式。
|
||||
|
||||
使用 `--no-cache` 可强制重新识别。
|
||||
|
||||
## 排查与回退
|
||||
|
||||
常见错误处理策略:
|
||||
|
||||
- `代理连接失败`:检查 `--proxy` 地址和端口。
|
||||
- `SSL 握手超时`:代理可能无法访问目标服务器,换 HTTP 代理或出口。
|
||||
- `连接超时` / `网络不通`:检查网络或代理。
|
||||
- `UUID 配额已用尽`:更换 UUID,或等待额度恢复。
|
||||
- `anonymous login failed: rate limited`:当前出口 IP 创建匿名账号过多,复用旧 UUID 或切换出口 IP。
|
||||
|
||||
如果用户不允许上传图片到第三方服务,停止使用本 skill,并说明需要本地 OCR 方案。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Python 3.9+。
|
||||
- 推荐安装 `uv`,脚本包含 PEP 723 内联依赖元数据,可用 `uv run` 自动准备 `requests` 和 `openpyxl`。
|
||||
- 网络能访问 `https://web.baimiaoapp.com` 和 `https://baimiaopdf.oss-cn-hangzhou.aliyuncs.com`。
|
||||
- SOCKS5 代理需要额外安装 `PySocks`,例如 `uv run --with PySocks ...` 或 `python -m pip install PySocks`。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,274 @@
|
||||
# Baimiao Table OCR API Reference
|
||||
|
||||
This reference summarizes the anonymous web API flow used by `scripts/baimiao_table_ocr.py`. It is based on reverse engineering of `https://web.baimiaoapp.com/image-to-excel`.
|
||||
|
||||
## Overview
|
||||
|
||||
```text
|
||||
client UUID
|
||||
-> POST /api/user/login/anonymous
|
||||
-> POST /api/perm/single
|
||||
-> GET /api/oss/sign
|
||||
-> POST https://baimiaopdf.oss-cn-hangzhou.aliyuncs.com/
|
||||
-> POST /api/ocr/table/parser
|
||||
-> GET result XLSX URL
|
||||
```
|
||||
|
||||
Base URL:
|
||||
|
||||
```text
|
||||
https://web.baimiaoapp.com
|
||||
```
|
||||
|
||||
Referer used by the CLI:
|
||||
|
||||
```text
|
||||
https://web.baimiaoapp.com/image-to-excel
|
||||
```
|
||||
|
||||
## Authentication Headers
|
||||
|
||||
Most API calls use:
|
||||
|
||||
| Header | Meaning |
|
||||
| --- | --- |
|
||||
| `X-AUTH-UUID` | Client-side UUID v4. |
|
||||
| `X-AUTH-TOKEN` | Guest token returned by anonymous login. |
|
||||
| `Content-Type` | `application/json;charset=UTF-8` for JSON POST requests. |
|
||||
| `Referer` | `https://web.baimiaoapp.com/image-to-excel`. |
|
||||
|
||||
The CLI does not persist tokens. It logs in once per run unless a cached XLSX result can satisfy the request.
|
||||
|
||||
## Step 1: Anonymous Login
|
||||
|
||||
```http
|
||||
POST /api/user/login/anonymous
|
||||
```
|
||||
|
||||
Headers:
|
||||
|
||||
```text
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
X-AUTH-UUID: <uuid>
|
||||
Referer: https://web.baimiaoapp.com/image-to-excel
|
||||
```
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
Typical success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": {
|
||||
"token": "guest-token",
|
||||
"user": null
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
Important observed responses:
|
||||
|
||||
| Response | Meaning |
|
||||
| --- | --- |
|
||||
| `code: 1` | Login succeeded. |
|
||||
| `code: 4` | New anonymous account creation is rate-limited for the current exit IP. |
|
||||
|
||||
## Step 2: OCR Permission Token
|
||||
|
||||
```http
|
||||
POST /api/perm/single
|
||||
```
|
||||
|
||||
Headers include `X-AUTH-UUID` and `X-AUTH-TOKEN`.
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "single",
|
||||
"version": "v2"
|
||||
}
|
||||
```
|
||||
|
||||
Typical success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": {
|
||||
"token": "task-token",
|
||||
"engine": "plus",
|
||||
"meta": {}
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
Observed quota/exhaustion response:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {},
|
||||
"msg": "error"
|
||||
}
|
||||
```
|
||||
|
||||
The CLI treats this as UUID/OCR quota exhaustion and raises a user-facing UUID quota error.
|
||||
|
||||
## Step 3: OSS Upload Signature
|
||||
|
||||
```http
|
||||
GET /api/oss/sign?mime_type=<mime>&t=<timestamp_ms>&r=<random_6>
|
||||
```
|
||||
|
||||
Supported image MIME types observed or inferred from the web client:
|
||||
|
||||
| MIME | Extension |
|
||||
| --- | --- |
|
||||
| `image/png` | `.png` |
|
||||
| `image/jpeg` | `.jpg`, `.jpeg` |
|
||||
| `image/gif` | `.gif` |
|
||||
| `image/bmp` | `.bmp` |
|
||||
| `image/webp` | `.webp` |
|
||||
|
||||
Typical success response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": {
|
||||
"result": {
|
||||
"policy": "base64-policy",
|
||||
"x_oss_signature_version": "OSS4-HMAC-SHA256",
|
||||
"x_oss_credential": "STS.../aliyun_v4_request",
|
||||
"x_oss_date": "20260514T180430Z",
|
||||
"signature": "hex-signature",
|
||||
"security_token": "security-token",
|
||||
"host": "https://baimiaopdf.oss-cn-hangzhou.aliyuncs.com",
|
||||
"file_key": "path/to/file.png",
|
||||
"content_types": ["image/png"]
|
||||
}
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Upload Image To OSS
|
||||
|
||||
Upload endpoint is the `host` returned by Step 3, with a trailing `/`.
|
||||
|
||||
```http
|
||||
POST https://baimiaopdf.oss-cn-hangzhou.aliyuncs.com/
|
||||
```
|
||||
|
||||
Multipart form fields:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `success_action_status` | `200` |
|
||||
| `policy` | `result.policy` |
|
||||
| `x-oss-signature` | `result.signature` |
|
||||
| `x-oss-signature-version` | `result.x_oss_signature_version` |
|
||||
| `x-oss-credential` | `result.x_oss_credential` |
|
||||
| `x-oss-date` | `result.x_oss_date` |
|
||||
| `key` | `result.file_key` |
|
||||
| `x-oss-security-token` | `result.security_token` |
|
||||
| `file` | Image bytes with the detected MIME type. |
|
||||
|
||||
Success is HTTP `200`. The CLI also checks for an OSS `<Error>` body.
|
||||
|
||||
The image MD5 is computed locally and used in the parser request.
|
||||
|
||||
## Step 5: Submit Table Parser Request
|
||||
|
||||
```http
|
||||
POST /api/ocr/table/parser
|
||||
```
|
||||
|
||||
Headers include `X-AUTH-UUID` and `X-AUTH-TOKEN`.
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "task-token-from-perm-single",
|
||||
"hash": "image-md5-hex",
|
||||
"fileKey": "oss-file-key"
|
||||
}
|
||||
```
|
||||
|
||||
The CLI polls this endpoint until a result URL appears or `--timeout` is exceeded.
|
||||
|
||||
Observed success response shapes:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": {
|
||||
"resultUrl": "https://.../result.xlsx"
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
Observed production response may nest the URL under `ydResp`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": {
|
||||
"ydResp": {
|
||||
"result": {
|
||||
"file_download_url": "https://.../result.xlsx",
|
||||
"file_preview_url": "https://.../preview.xlsx"
|
||||
}
|
||||
}
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
The CLI checks result URLs in this order:
|
||||
|
||||
1. `data.resultUrl`.
|
||||
2. `data.ydResp.result.file_download_url`.
|
||||
3. `data.ydResp.result.file_preview_url`.
|
||||
|
||||
If the parser temporarily returns an upload propagation error, the CLI waits and retries until timeout.
|
||||
|
||||
## Step 6: Download XLSX Result
|
||||
|
||||
The final result URL is fetched directly with `GET` and saved as XLSX bytes. Text outputs are generated locally by parsing the XLSX with `openpyxl`.
|
||||
|
||||
## Rate Limits And Quotas
|
||||
|
||||
Observed behavior:
|
||||
|
||||
- Reusing an existing UUID can still log in and obtain a new guest token.
|
||||
- `POST /api/perm/single` may return `code: 0, msg: "error"` when the UUID has no available OCR quota.
|
||||
- Creating a fresh anonymous UUID may return `code: 4` when the current IP is rate-limited.
|
||||
- Spoofing `X-Forwarded-For` did not bypass `code: 4` in testing.
|
||||
|
||||
Agent guidance:
|
||||
|
||||
- Prefer `--uuid` or `BAIMIAO_UUID` when available.
|
||||
- Use HTTP proxy first when changing network exit is required.
|
||||
- Use SOCKS5 only when PySocks is installed.
|
||||
- Do not create or rely on persistent auth/token files.
|
||||
|
||||
## Local Cache
|
||||
|
||||
The CLI caches the downloaded XLSX by image MD5:
|
||||
|
||||
```text
|
||||
scripts/.baimiao_cache/<image_md5>.xlsx
|
||||
```
|
||||
|
||||
This saves OCR quota and allows re-rendering to another format without another API call. Cached `.xlsx` files older than 7 days are removed automatically on each CLI run. Use `--no-cache` to bypass cache lookup.
|
||||
@@ -0,0 +1,591 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "requests>=2.25",
|
||||
# "openpyxl>=3.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
白描图片转表格 CLI — 非登录态完整调用链
|
||||
|
||||
用法:
|
||||
uv run baimiao_table_ocr.py --image table.png # 默认 ASCII 表格输出
|
||||
uv run baimiao_table_ocr.py --image table.png -f csv # CSV 输出
|
||||
uv run baimiao_table_ocr.py --image table.png -f xlsx -o out.xlsx # 保存 xlsx
|
||||
uv run baimiao_table_ocr.py --image table.png --proxy http://127.0.0.1:8080 # 通过代理
|
||||
|
||||
UUID 管理:
|
||||
优先使用 --uuid 参数,其次 $BAIMIAO_UUID 环境变量。
|
||||
未提供时自动生成新 UUID 并提示设置环境变量以跳过限流。
|
||||
同一个 UUID 可以反复登录拿新 token,不会触发 rate limiting。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import csv as _csv
|
||||
import hashlib
|
||||
import io
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unicodedata
|
||||
import uuid as _uuid
|
||||
|
||||
import requests
|
||||
|
||||
REQ_TIMEOUT = (15, 30) # (connect_timeout, read_timeout)
|
||||
BASE = "https://web.baimiaoapp.com"
|
||||
REFERER = "https://web.baimiaoapp.com/image-to-excel"
|
||||
DEFAULT_TIMEOUT = 120
|
||||
POLL_INTERVAL = 3
|
||||
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".baimiao_cache")
|
||||
CACHE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
SESSION = requests.Session()
|
||||
# SESSION.trust_env = False # uncomment to bypass system proxy
|
||||
|
||||
|
||||
def _file_md5(file_path):
|
||||
with open(file_path, "rb") as f:
|
||||
return hashlib.md5(f.read()).hexdigest()
|
||||
|
||||
|
||||
def _cache_path(md5_hash):
|
||||
return os.path.join(CACHE_DIR, f"{md5_hash}.xlsx")
|
||||
|
||||
|
||||
def _cache_get(image_path):
|
||||
md5 = _file_md5(image_path)
|
||||
path = _cache_path(md5)
|
||||
return (path, md5) if os.path.isfile(path) else (None, md5)
|
||||
|
||||
|
||||
def _cache_put(md5_hash, src_path):
|
||||
import shutil
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
shutil.copy2(src_path, _cache_path(md5_hash))
|
||||
|
||||
|
||||
def _cache_cleanup(max_age_seconds=CACHE_MAX_AGE_SECONDS):
|
||||
if not os.path.isdir(CACHE_DIR):
|
||||
return 0
|
||||
cutoff = time.time() - max_age_seconds
|
||||
removed = 0
|
||||
for name in os.listdir(CACHE_DIR):
|
||||
if not name.lower().endswith(".xlsx"):
|
||||
continue
|
||||
path = os.path.join(CACHE_DIR, name)
|
||||
try:
|
||||
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
|
||||
os.unlink(path)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def auth_headers(uuid_, token):
|
||||
return {
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"X-AUTH-UUID": uuid_,
|
||||
"X-AUTH-TOKEN": token,
|
||||
"Referer": REFERER,
|
||||
}
|
||||
|
||||
|
||||
# ─── UUID resolution ──────────────────────────────────────────────
|
||||
|
||||
def resolve_uuid(cli_uuid=None):
|
||||
"""返回 (uuid, is_new)"""
|
||||
if cli_uuid:
|
||||
return cli_uuid, False
|
||||
env_uuid = os.environ.get("BAIMIAO_UUID")
|
||||
if env_uuid:
|
||||
return env_uuid, False
|
||||
new_uuid = str(_uuid.uuid4())
|
||||
return new_uuid, True
|
||||
|
||||
|
||||
# ─── API steps ────────────────────────────────────────────────────
|
||||
|
||||
def step_anonymous_login(uuid_):
|
||||
for attempt in range(3):
|
||||
r = SESSION.post(
|
||||
f"{BASE}/api/user/login/anonymous",
|
||||
json={},
|
||||
headers={
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"X-AUTH-UUID": uuid_,
|
||||
"Referer": REFERER,
|
||||
},
|
||||
timeout=REQ_TIMEOUT,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("code") == 1:
|
||||
return data["data"]["token"]
|
||||
if data.get("code") == 4:
|
||||
wait = 2 * (attempt + 1)
|
||||
print(f" ⚠ rate limited, retry in {wait}s (attempt {attempt+1}/3)", file=sys.stderr)
|
||||
for _ in range(wait):
|
||||
time.sleep(1)
|
||||
continue
|
||||
raise RuntimeError(f"anonymous login failed: {data}")
|
||||
raise RuntimeError("anonymous login failed: rate limited after 3 attempts")
|
||||
|
||||
|
||||
def step_perm(uuid_, token):
|
||||
r = SESSION.post(
|
||||
f"{BASE}/api/perm/single",
|
||||
json={"mode": "single", "version": "v2"},
|
||||
headers=auth_headers(uuid_, token),
|
||||
timeout=REQ_TIMEOUT,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("code") != 1:
|
||||
if data.get("code") == 0 and data.get("msg") == "error":
|
||||
raise TokenExhaustedError(f"token exhausted or expired: {data}")
|
||||
raise RuntimeError(f"perm failed: {data}")
|
||||
return data["data"]["token"]
|
||||
|
||||
|
||||
def step_oss_sign(uuid_, token, mime_type):
|
||||
r = SESSION.get(
|
||||
f"{BASE}/api/oss/sign",
|
||||
params={
|
||||
"mime_type": mime_type,
|
||||
"t": str(int(time.time() * 1000)),
|
||||
"r": str(int.from_bytes(os.urandom(4), "big") % 1000000),
|
||||
},
|
||||
headers=auth_headers(uuid_, token),
|
||||
timeout=REQ_TIMEOUT,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("code") != 1:
|
||||
if data.get("code") == 0 and data.get("msg") == "error":
|
||||
raise TokenExhaustedError(f"token exhausted: {data}")
|
||||
raise RuntimeError(f"oss sign failed: {data}")
|
||||
return data["data"]["result"]
|
||||
|
||||
|
||||
def step_upload(sign, file_path, mime_type):
|
||||
host = sign["host"].rstrip("/")
|
||||
key = sign["file_key"]
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
md5 = hashlib.md5(file_bytes).hexdigest()
|
||||
|
||||
ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif",
|
||||
"image/bmp": ".bmp", "image/webp": ".webp"}.get(mime_type, ".bin")
|
||||
|
||||
upload_fields = [
|
||||
("success_action_status", (None, "200")),
|
||||
("policy", (None, sign["policy"])),
|
||||
("x-oss-signature", (None, sign["signature"])),
|
||||
("x-oss-signature-version", (None, sign["x_oss_signature_version"])),
|
||||
("x-oss-credential", (None, sign["x_oss_credential"])),
|
||||
("x-oss-date", (None, sign["x_oss_date"])),
|
||||
("key", (None, key)),
|
||||
("x-oss-security-token", (None, sign["security_token"])),
|
||||
("file", (f"file{ext}", file_bytes, mime_type)),
|
||||
]
|
||||
|
||||
r = SESSION.post(f"{host}/", files=upload_fields, timeout=(30, 120))
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"OSS upload failed HTTP {r.status_code}: {r.text[:500]}")
|
||||
if r.text.strip() and "<Error>" in r.text:
|
||||
raise RuntimeError(f"OSS upload error: {r.text[:500]}")
|
||||
return key, md5
|
||||
|
||||
|
||||
def step_parser(uuid_, token, task_token, file_hash, file_key, timeout=DEFAULT_TIMEOUT):
|
||||
body = {"token": task_token, "hash": file_hash, "fileKey": file_key}
|
||||
headers = auth_headers(uuid_, token)
|
||||
|
||||
deadline = time.time() + timeout
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
r = SESSION.post(
|
||||
f"{BASE}/api/ocr/table/parser",
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=REQ_TIMEOUT,
|
||||
)
|
||||
data = r.json()
|
||||
code = data.get("code")
|
||||
msg = data.get("msg", "")
|
||||
|
||||
if code == 1:
|
||||
resp_data = data.get("data", {})
|
||||
url = resp_data.get("resultUrl")
|
||||
if not url:
|
||||
yd = resp_data.get("ydResp", {})
|
||||
url = yd.get("result", {}).get("file_download_url")
|
||||
if not url:
|
||||
url = yd.get("result", {}).get("file_preview_url")
|
||||
if url:
|
||||
return url
|
||||
task_id = resp_data.get("taskId")
|
||||
if attempt % 5 == 1 and task_id:
|
||||
print(f" 识别中... (taskId={task_id})", file=sys.stderr)
|
||||
elif code == 0 and "上传出错" in msg:
|
||||
if attempt <= 2:
|
||||
print(" OSS propagation delay, retrying...", file=sys.stderr)
|
||||
else:
|
||||
raise RuntimeError(f"parser failed (attempt #{attempt}): {data}")
|
||||
|
||||
if time.time() > deadline:
|
||||
raise TimeoutError(f"recognition timed out after {timeout}s (last response: {data})")
|
||||
# interruptible sleep (responds to Ctrl+C)
|
||||
for _ in range(POLL_INTERVAL):
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def step_download(url, out_path):
|
||||
r = SESSION.get(url, timeout=(30, 300))
|
||||
r.raise_for_status()
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(r.content)
|
||||
|
||||
|
||||
# ─── output formatters ────────────────────────────────────────────
|
||||
|
||||
def parse_xlsx(file_path, sheet=None):
|
||||
"""返回 {sheet_name: list[list[str]]} 或指定 sheet 的 list[list[str]]"""
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(file_path, read_only=True, data_only=True)
|
||||
if sheet:
|
||||
ws = wb[sheet]
|
||||
rows = [[str(c.value).strip() if c.value is not None else "" for c in row] for row in ws.iter_rows()]
|
||||
wb.close()
|
||||
return rows
|
||||
result = {}
|
||||
for name in wb.sheetnames:
|
||||
ws = wb[name]
|
||||
result[name] = [[str(c.value).strip() if c.value is not None else "" for c in row] for row in ws.iter_rows()]
|
||||
wb.close()
|
||||
return result
|
||||
|
||||
|
||||
def _display_width(s):
|
||||
"""字符串终端显示宽度 (CJK 占 2, ASCII 占 1)"""
|
||||
w = 0
|
||||
for ch in s:
|
||||
ea = unicodedata.east_asian_width(ch)
|
||||
w += 2 if ea in ("F", "W") else 1
|
||||
return w
|
||||
|
||||
|
||||
def _pad_cell(s, target_width):
|
||||
"""将字符串填充到目标显示宽度"""
|
||||
current = _display_width(s)
|
||||
return s + " " * (target_width - current)
|
||||
|
||||
|
||||
def render_ascii(rows):
|
||||
if not rows:
|
||||
return "(empty)"
|
||||
|
||||
col_widths = [0] * max(len(r) for r in rows)
|
||||
for r in rows:
|
||||
for i, cell in enumerate(r):
|
||||
col_widths[i] = max(col_widths[i], _display_width(cell))
|
||||
|
||||
def _sep(left, mid, right, fill):
|
||||
parts = [fill * (w + 2) for w in col_widths]
|
||||
return left + mid.join(parts) + right + "\n"
|
||||
|
||||
top = _sep("┌", "┬", "┐", "─")
|
||||
bottom = _sep("└", "┴", "┘", "─")
|
||||
|
||||
def _row(cells):
|
||||
parts = [f" {_pad_cell(c, col_widths[i])} " for i, c in enumerate(cells)]
|
||||
return "│" + "│".join(parts) + "│\n"
|
||||
|
||||
out = top
|
||||
if rows:
|
||||
for r in rows:
|
||||
out += _row(r)
|
||||
out += bottom
|
||||
return out
|
||||
|
||||
|
||||
def render_csv(rows):
|
||||
buf = io.StringIO()
|
||||
w = _csv.writer(buf, lineterminator="\n")
|
||||
for r in rows:
|
||||
w.writerow(r)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def render_markdown(rows):
|
||||
if not rows:
|
||||
return ""
|
||||
lines = []
|
||||
lines.append("| " + " | ".join(rows[0]) + " |")
|
||||
lines.append("| " + " | ".join("---" for _ in rows[0]) + " |")
|
||||
for r in rows[1:]:
|
||||
padded = r + [""] * (len(rows[0]) - len(r))
|
||||
lines.append("| " + " | ".join(padded[:len(rows[0])]) + " |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def detect_mime(file_path):
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
mime_map = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".bmp": "image/bmp", ".webp": "image/webp",
|
||||
}
|
||||
mime = mime_map.get(ext)
|
||||
if mime:
|
||||
return mime
|
||||
mime, _ = mimetypes.guess_type(file_path)
|
||||
if mime and mime.startswith("image/"):
|
||||
return mime
|
||||
return "image/png"
|
||||
|
||||
|
||||
def resolve_image(image_arg):
|
||||
"""
|
||||
返回 (file_path, is_temp) 。支持:
|
||||
- 本地文件路径: table.png
|
||||
- stdin: -
|
||||
- base64 字符串: data:image/png;base64,xxxx 或纯 base64
|
||||
"""
|
||||
if not image_arg:
|
||||
raise ValueError("--image 不能为空")
|
||||
|
||||
# stdin
|
||||
if image_arg == "-":
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||
tmp.write(sys.stdin.buffer.read())
|
||||
tmp.close()
|
||||
return tmp.name, True
|
||||
|
||||
# base64
|
||||
if image_arg.startswith("data:") or _looks_like_base64(image_arg):
|
||||
b64 = image_arg
|
||||
if b64.startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1] if "," in b64 else b64.split(";base64,", 1)[-1]
|
||||
data = base64.b64decode(b64)
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
return tmp.name, True
|
||||
|
||||
# local file
|
||||
return image_arg, False
|
||||
|
||||
|
||||
def _looks_like_base64(s):
|
||||
return len(s) > 100 and not any(c in s for c in ("/", "\\", " ")) and not os.path.exists(s)
|
||||
|
||||
|
||||
# ─── custom exception ─────────────────────────────────────────────
|
||||
|
||||
class TokenExhaustedError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# ─── main ─────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="白描表格 OCR — 图片转表格 (非登录态)")
|
||||
parser.add_argument("--image", "-i", required=True, help="图片来源: 文件路径 / - (stdin) / base64 或 data URI")
|
||||
parser.add_argument("--out", "-o", default=None, help="输出文件路径 (xlsx 模式必填, 其他格式可选)")
|
||||
parser.add_argument("--format", "-f", choices=["ascii", "csv", "xlsx", "json", "md"], default="ascii",
|
||||
help="输出格式: ascii(默认) / csv / xlsx / json / md")
|
||||
parser.add_argument("--sheet", default=None, help="指定工作表名称 (多 sheet 时可选)")
|
||||
parser.add_argument("--uuid", default=None, help="白描客户端 UUID (优先于 $BAIMIAO_UUID)")
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="识别超时秒数")
|
||||
parser.add_argument("--proxy", default=None, help="代理地址 (如 http://127.0.0.1:8080 或 socks5://127.0.0.1:7890)")
|
||||
parser.add_argument("--no-cache", action="store_true", help="跳过缓存,强制重新 OCR")
|
||||
parser.add_argument("--quiet", "-q", action="store_true", help="抑制进度输出到 stderr")
|
||||
args = parser.parse_args()
|
||||
|
||||
log = (lambda *a, **kw: None) if args.quiet else (lambda *a, **kw: print(*a, file=sys.stderr, **kw))
|
||||
|
||||
removed_cache_files = _cache_cleanup()
|
||||
if removed_cache_files:
|
||||
log(f"[cache] 已清理 {removed_cache_files} 个 7 天前的缓存文件")
|
||||
|
||||
if args.proxy:
|
||||
SESSION.proxies = {"http": args.proxy, "https": args.proxy}
|
||||
SESSION.trust_env = False
|
||||
log(f"[proxy] 使用代理: {args.proxy}")
|
||||
|
||||
if not os.path.isfile(args.image) and args.image != "-" and not _looks_like_base64(args.image):
|
||||
print(f"❌ 图片不存在: {args.image}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.format == "xlsx" and not args.out:
|
||||
print("❌ xlsx 格式必须指定 --out", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
image_path, image_is_temp = resolve_image(args.image)
|
||||
|
||||
uuid_, is_new = resolve_uuid(args.uuid)
|
||||
log(f"[0/5] UUID: {uuid_}")
|
||||
if is_new and not args.quiet:
|
||||
print(f" 💡 首次使用,建议设置环境变量避免限流:", file=sys.stderr)
|
||||
print(f' export BAIMIAO_UUID="{uuid_}"', file=sys.stderr)
|
||||
print(f" 或每次调用时传 --uuid {uuid_}", file=sys.stderr)
|
||||
|
||||
# ── cache check ──
|
||||
is_cached = False
|
||||
cached_path, image_md5 = _cache_get(image_path)
|
||||
if cached_path and not args.no_cache:
|
||||
log(f"[cache] 命中缓存 (md5={image_md5[:12]}...),跳过 OCR")
|
||||
tmp_path = cached_path
|
||||
is_cached = True
|
||||
else:
|
||||
log("[1/5] 匿名登录获取 guest token ...")
|
||||
token = step_anonymous_login(uuid_)
|
||||
|
||||
mime = detect_mime(image_path)
|
||||
|
||||
# pipeline with auto-relogin on token exhaustion
|
||||
for retry in range(3):
|
||||
try:
|
||||
log(f"[2/5] 获取 OSS 直传签名 & 上传图片 (mime={mime}) ...")
|
||||
sign = step_oss_sign(uuid_, token, mime)
|
||||
file_key, file_hash = step_upload(sign, image_path, mime)
|
||||
|
||||
log("[3/5] 获取 OCR 任务凭证 ...")
|
||||
task_token = step_perm(uuid_, token)
|
||||
|
||||
log("[4/5] 提交表格识别 (轮询等待) ...")
|
||||
result_url = step_parser(uuid_, token, task_token, file_hash, file_key, args.timeout)
|
||||
|
||||
log("[5/5] 下载 & 解析结果 ...")
|
||||
break
|
||||
except TokenExhaustedError:
|
||||
if retry < 2:
|
||||
uuid_ = str(_uuid.uuid4())
|
||||
log(f" UUID 配额已用尽 → 新 UUID: {uuid_}")
|
||||
if not args.quiet:
|
||||
print(f" 💡 请更新环境变量:", file=sys.stderr)
|
||||
print(f' export BAIMIAO_UUID="{uuid_}"', file=sys.stderr)
|
||||
token = step_anonymous_login(uuid_)
|
||||
continue
|
||||
raise
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False)
|
||||
tmp.close()
|
||||
try:
|
||||
step_download(result_url, tmp.name)
|
||||
_cache_put(image_md5, tmp.name)
|
||||
tmp_path = tmp.name
|
||||
except Exception:
|
||||
os.unlink(tmp.name)
|
||||
raise
|
||||
|
||||
# ── render ──
|
||||
try:
|
||||
if args.sheet:
|
||||
rows = parse_xlsx(tmp_path, sheet=args.sheet)
|
||||
sheets_data = {args.sheet: rows}
|
||||
else:
|
||||
result = parse_xlsx(tmp_path)
|
||||
if isinstance(result, dict):
|
||||
sheets_data = result
|
||||
else:
|
||||
sheets_data = {"Sheet1": result}
|
||||
|
||||
if args.format == "ascii":
|
||||
out_parts = []
|
||||
for sname, srows in sheets_data.items():
|
||||
if len(sheets_data) > 1:
|
||||
out_parts.append(f"\n── {sname} ──\n")
|
||||
out_parts.append(render_ascii(srows))
|
||||
out = "".join(out_parts)
|
||||
if args.out:
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
f.write(out)
|
||||
else:
|
||||
sys.stdout.write(out)
|
||||
|
||||
elif args.format == "md":
|
||||
out_parts = []
|
||||
for sname, srows in sheets_data.items():
|
||||
if len(sheets_data) > 1:
|
||||
out_parts.append(f"### {sname}\n\n")
|
||||
out_parts.append(render_markdown(srows))
|
||||
out_parts.append("\n")
|
||||
out = "".join(out_parts)
|
||||
if args.out:
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
f.write(out)
|
||||
else:
|
||||
sys.stdout.write(out)
|
||||
|
||||
elif args.format == "csv":
|
||||
out_parts = []
|
||||
for sname, srows in sheets_data.items():
|
||||
if len(sheets_data) > 1:
|
||||
out_parts.append(f"# {sname}\n")
|
||||
out_parts.append(render_csv(srows))
|
||||
out_parts.append("\n")
|
||||
out = "".join(out_parts)
|
||||
if args.out:
|
||||
with open(args.out, "w", encoding="utf-8", newline="") as f:
|
||||
f.write(out)
|
||||
else:
|
||||
sys.stdout.write(out)
|
||||
|
||||
elif args.format == "json":
|
||||
import json
|
||||
out = json.dumps(sheets_data, ensure_ascii=False, indent=2)
|
||||
if args.out:
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
f.write(out)
|
||||
else:
|
||||
sys.stdout.write(out)
|
||||
|
||||
elif args.format == "xlsx":
|
||||
import shutil
|
||||
if args.out:
|
||||
shutil.copy2(tmp_path, args.out)
|
||||
finally:
|
||||
if args.format != "xlsx" and not is_cached:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
log("✅ 完成")
|
||||
|
||||
if image_is_temp:
|
||||
try:
|
||||
os.unlink(image_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except requests.exceptions.ProxyError:
|
||||
print("❌ 代理连接失败 — 请检查 --proxy 地址是否正确", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except requests.exceptions.SSLError:
|
||||
print("❌ SSL 握手超时 — 代理可能无法连接目标服务器", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except requests.exceptions.ConnectTimeout:
|
||||
print("❌ 连接超时 — 目标服务器不可达", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except requests.exceptions.ReadTimeout:
|
||||
print("❌ 网络超时 — 请检查网络连接或换一个 --proxy", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ 网络不通 — 请检查网络或代理是否可用", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except TokenExhaustedError:
|
||||
print("❌ UUID 配额已用尽且无法自动刷新 — 请更换 UUID", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("", file=sys.stderr)
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"❌ {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,2 @@
|
||||
requests>=2.25
|
||||
openpyxl>=3.0
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: disk-master
|
||||
description: Diagnose Windows disk usage with disk-master.exe, explain what is filling the drive, and generate an interactive SpaceSniffer-style HTML report.
|
||||
---
|
||||
|
||||
# Disk Master
|
||||
|
||||
Use this skill when the user asks why a Windows drive is full, wants to find large files or folders, wants to inspect C: drive software/AppData usage, or wants an interactive disk report.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. If the user does not specify a drive, first inspect available local drives, then ask which drive to analyze. Also ask whether they approve a UAC prompt for faster NTFS/MFT scanning. Explain that the scan is local and read-only.
|
||||
2. Run `disk-master.exe scan --drive C: --scan quick --out result.json` for a fast estimate, or `disk-master.exe scan --drive C: --scan full --out result.json` when the user asks for a complete scan.
|
||||
3. If the user explicitly agrees to a UAC prompt for privileged NTFS/MFT scanning, add `--elevate --mode mft` and keep `--out <path>`.
|
||||
4. After scanning, use the CLI status output to report the JSON path and a short human-readable summary. Do not run an extra file-existence check unless the command fails or the path is missing.
|
||||
5. Read `result.json` and explain the disk state: largest directories, largest files, file types, user folders, AppData hotspots, installed apps/games, and advice items.
|
||||
6. Generate an interactive static HTML report from `template.html` by replacing `%%DATA%%` with the JSON text.
|
||||
7. Present the summary first, then link the JSON path and HTML report path. Make clear that all suggestions are review-only and no cleanup was performed.
|
||||
|
||||
## Clarifying Questions
|
||||
|
||||
When the request is ambiguous, ask concise interactive questions before scanning:
|
||||
|
||||
- Drive: choose from detected local drives, for example `C:` or `D:`.
|
||||
- Scan depth: quick estimate or full scan.
|
||||
- UAC/MFT: whether to allow an administrator prompt for the faster NTFS engine.
|
||||
- Output: whether to generate only JSON or also an HTML report.
|
||||
|
||||
Recommended wording: "我可以全程本地只读扫描。允许 UAC 的话,NTFS 盘会优先用更快的 MFT 引擎;不允许也可以用普通模式,只是更慢。你想分析哪个分区?"
|
||||
|
||||
## SOP
|
||||
|
||||
1. Stop and ask before scanning if any required detail is not confirmed. Use the available interactive question tool when possible instead of guessing.
|
||||
2. Confirm the target drive. If the user only says "scan my disk" or similar, list local fixed drives first, then ask which one to analyze.
|
||||
3. Confirm whether to use fast index mode. Explain it as: "快速索引需要管理员权限,会优先使用 NTFS/MFT 引擎,速度更快;不授权也可以用普通遍历模式,只是更慢。"
|
||||
4. Confirm scan scope when unclear: quick scan for a fast estimate, full scan for the final report.
|
||||
5. Confirm output expectation: JSON only, natural-language analysis, HTML report, or all of them. Default to JSON + HTML report when the user asks for a report.
|
||||
6. If fast index mode is enabled, tell the user before running the command: "接下来会出现一个 Windows UAC/授权弹窗,请选择允许;扫描只读、仅在本机运行。"
|
||||
7. Run the scan with an explicit output path. Prefer timestamped names such as `result-<timestamp>.json` and `report-<timestamp>.html` to avoid overwriting prior reports.
|
||||
8. Expect the report JSON to be large. Be prepared to read it in chunks or use targeted extraction instead of assuming one read will return the whole file.
|
||||
9. Generate the HTML report by injecting the JSON into `template.html`. If the environment can display or open the HTML directly for the user, do so; otherwise provide the absolute report path.
|
||||
10. Present results in this order: short human summary, JSON path, HTML path, top findings, safety note. Never imply that cleanup was performed.
|
||||
|
||||
Minimum confirmations before execution:
|
||||
|
||||
- Target drive, for example `C:` or `D:`.
|
||||
- Fast index / administrator permission: yes or no.
|
||||
- Scan scope: quick or full.
|
||||
- Output format: JSON, HTML, or both.
|
||||
|
||||
## Report Sections
|
||||
|
||||
- Summary cards: used/free space, largest directory, number of high-priority suggestions.
|
||||
- SpaceSniffer section: interactive treemap with drill-down, breadcrumbs, search, and detail panel.
|
||||
- Diagnostic details: large files, user paths, AppData, C: drive apps/games, file extensions.
|
||||
- Advice tutorials: each recommendation expands into step-by-step safe handling guidance.
|
||||
- Scan metadata: mode, permissions, MFT state, errors, and raw JSON summary.
|
||||
|
||||
## Safety
|
||||
|
||||
The tool is read-only for user data. Do not delete files, uninstall applications, clean the registry, or modify user data as part of this skill.
|
||||
|
||||
Never present a recommendation as an automatic cleanup action. Use language like "建议检查", "可以考虑", and "请先确认" for uncertain items.
|
||||
Binary file not shown.
@@ -0,0 +1,595 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Disk Master Report</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--dm-blue: #3266ad;
|
||||
--dm-green: #3B6D11;
|
||||
--dm-amber: #BA7517;
|
||||
--dm-red: #A32D2D;
|
||||
--dm-gray: #73726c;
|
||||
--dm-bg: var(--color-background-primary, #ffffff);
|
||||
--dm-bg-2: var(--color-background-secondary, #f7f7f5);
|
||||
--dm-text: var(--color-text-primary, #22221f);
|
||||
--dm-text-2: var(--color-text-secondary, #5f5e58);
|
||||
--dm-text-3: var(--color-text-tertiary, #888780);
|
||||
--dm-border: var(--color-border-tertiary, rgba(120, 119, 111, .28));
|
||||
--dm-radius: var(--border-radius-md, 10px);
|
||||
--dm-radius-lg: var(--border-radius-lg, 14px);
|
||||
}
|
||||
body { font-family: var(--font-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); color: var(--dm-text); background: transparent; }
|
||||
.report { max-width: 1180px; margin: 0 auto; padding: 1.5rem 0 2rem; }
|
||||
.report-header { padding: 1.5rem 0 1.25rem; border-bottom: .5px solid var(--dm-border); margin-bottom: 1.25rem; }
|
||||
.header-meta { font-size: 11px; letter-spacing: .08em; color: var(--dm-text-3); text-transform: uppercase; margin-bottom: .5rem; }
|
||||
.header-title { font-size: 24px; line-height: 1.2; font-weight: 500; color: var(--dm-text); margin-bottom: .3rem; }
|
||||
.header-sub { font-size: 14px; color: var(--dm-text-2); }
|
||||
.badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 1rem; }
|
||||
.badge { font-size: 11px; padding: 3px 10px; border-radius: var(--dm-radius); border: .5px solid var(--dm-border); color: var(--dm-text-2); background: transparent; }
|
||||
.badge.blue { background: #E6F1FB; color: #185FA5; border-color: #B5D4F4; }
|
||||
.badge.green { background: #EAF3DE; color: #3B6D11; border-color: #C0DD97; }
|
||||
.badge.amber { background: #FAEEDA; color: #854F0B; border-color: #E1B76A; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 1.25rem; }
|
||||
.metric { background: var(--dm-bg-2); border-radius: var(--dm-radius); padding: 1rem; min-width: 0; }
|
||||
.metric-label { font-size: 12px; color: var(--dm-text-2); margin-bottom: 6px; }
|
||||
.metric-value { font-size: 22px; font-weight: 500; color: var(--dm-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.metric-note { font-size: 12px; color: var(--dm-text-3); margin-top: 4px; }
|
||||
.highlights { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1.25rem; }
|
||||
.highlight { border-left: 3px solid; background: var(--dm-bg-2); border-radius: 0 var(--dm-radius) var(--dm-radius) 0; padding: .75rem 1rem; }
|
||||
.hl-label { font-size: 11px; color: var(--dm-text-3); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.hl-value { font-size: 15px; font-weight: 500; color: var(--dm-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hl-note { font-size: 12px; color: var(--dm-text-2); margin-top: 2px; }
|
||||
.panel { display: block; margin-top: 1.5rem; }
|
||||
.info-card { background: var(--dm-bg-2); border: .5px solid var(--dm-border); border-radius: var(--dm-radius-lg); padding: 1rem 1.15rem; margin-bottom: 1.25rem; }
|
||||
.info-title { font-size: 14px; font-weight: 500; color: var(--dm-text); margin-bottom: .4rem; }
|
||||
.info-text { font-size: 13px; line-height: 1.65; color: var(--dm-text-2); }
|
||||
.evaluation { display: grid; grid-template-columns: minmax(0, .75fr) minmax(0, 1.25fr); gap: 1rem; align-items: stretch; margin-bottom: 1.25rem; }
|
||||
.score-card { border-left: 4px solid var(--dm-blue); }
|
||||
.score-value { font-size: 34px; line-height: 1; font-weight: 500; color: var(--dm-text); margin: .5rem 0 .35rem; }
|
||||
.score-label { font-size: 13px; color: var(--dm-text-2); }
|
||||
.section-title { font-size: 13px; font-weight: 500; color: var(--dm-text-2); letter-spacing: .06em; text-transform: uppercase; padding-bottom: .5rem; border-bottom: .5px solid var(--dm-border); margin-bottom: 1rem; }
|
||||
.card { background: var(--dm-bg); border: .5px solid var(--dm-border); border-radius: var(--dm-radius-lg); padding: 1rem 1.15rem; }
|
||||
.two-col { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr); gap: 1rem; }
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: .75rem; align-items: center; }
|
||||
.input, .select { border: .5px solid var(--dm-border); border-radius: var(--dm-radius); background: var(--dm-bg); color: var(--dm-text); padding: .45rem .6rem; font: inherit; font-size: 12px; min-height: 32px; }
|
||||
.input { flex: 1; min-width: 180px; }
|
||||
.button { border: .5px solid var(--dm-border); border-radius: var(--dm-radius); background: var(--dm-bg-2); color: var(--dm-text-2); padding: .45rem .65rem; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.button:hover { color: var(--dm-text); border-color: var(--dm-blue); }
|
||||
#treemap { width: 100%; height: 520px; border-radius: var(--dm-radius); overflow: hidden; background: var(--dm-bg-2); position: relative; }
|
||||
.crumbs { display: flex; flex-wrap: wrap; gap: 4px; margin: .75rem 0; font-size: 12px; color: var(--dm-text-2); }
|
||||
.crumb { cursor: pointer; padding: 2px 6px; border-radius: 6px; background: var(--dm-bg-2); }
|
||||
.node { position: absolute; border: 1px solid rgba(255,255,255,.75); overflow: hidden; cursor: pointer; border-radius: 4px; padding: 7px; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.55); z-index: 1; transition: outline-color .15s ease, opacity .15s ease; }
|
||||
.node::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.34), rgba(0,0,0,.05) 46%, rgba(0,0,0,.18)); pointer-events: none; }
|
||||
.node:hover { outline: 2px solid rgba(50,102,173,.8); z-index: 3; }
|
||||
.node.pruned { box-shadow: inset 0 0 0 2px rgba(255,255,255,.28); background-image: repeating-linear-gradient(135deg, rgba(255,255,255,.16) 0 8px, transparent 8px 16px); }
|
||||
.node.search-hit { outline: 3px solid #F2C14E; z-index: 4; }
|
||||
.node-name, .node-size, .node-hint { position: relative; z-index: 1; }
|
||||
.node-name { font-size: 12px; line-height: 1.22; font-weight: 650; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; color: #fff; }
|
||||
.node-size { font-size: 11px; line-height: 1.25; opacity: .96; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: rgba(255,255,255,.94); }
|
||||
.node-hint { position: absolute; left: 7px; right: 7px; bottom: 6px; font-size: 10px; line-height: 1.2; color: rgba(255,255,255,.86); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.detail-list { display: grid; gap: 8px; }
|
||||
.row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: .45rem 0; border-bottom: .5px solid var(--dm-border); }
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.row-main { min-width: 0; }
|
||||
.row-title { font-size: 13px; color: var(--dm-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row-sub { font-size: 12px; color: var(--dm-text-3); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-wrap { height: 6px; background: var(--dm-bg-2); border-radius: 999px; overflow: hidden; margin-top: 6px; }
|
||||
.bar { height: 100%; border-radius: 999px; background: var(--dm-blue); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th { text-align: left; color: var(--dm-text-3); font-weight: 500; border-bottom: .5px solid var(--dm-border); padding: .5rem .35rem; }
|
||||
td { border-bottom: .5px solid var(--dm-border); padding: .5rem .35rem; color: var(--dm-text-2); vertical-align: top; }
|
||||
td strong { color: var(--dm-text); font-weight: 500; }
|
||||
.path { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; word-break: break-all; }
|
||||
.advice { border: .5px solid var(--dm-border); border-radius: var(--dm-radius-lg); margin-bottom: .75rem; overflow: hidden; }
|
||||
.advice-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: .85rem 1rem; cursor: pointer; background: var(--dm-bg); }
|
||||
.priority { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: var(--dm-bg-2); color: var(--dm-text-2); }
|
||||
.priority.p1 { background: #FAE0DC; color: var(--dm-red); }
|
||||
.priority.p2 { background: #FAEEDA; color: var(--dm-amber); }
|
||||
.advice-body { display: none; padding: 0 1rem 1rem 1rem; color: var(--dm-text-2); font-size: 13px; }
|
||||
.advice.open .advice-body { display: block; }
|
||||
.steps { margin-top: .75rem; display: grid; gap: .5rem; }
|
||||
.step { background: var(--dm-bg-2); border-radius: var(--dm-radius); padding: .65rem .75rem; }
|
||||
.step strong { color: var(--dm-text); font-weight: 500; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; background: var(--dm-bg-2); border-radius: var(--dm-radius); padding: 1rem; font-size: 11px; color: var(--dm-text-2); max-height: 520px; overflow: auto; }
|
||||
.footer { padding-top: 1.25rem; border-top: .5px solid var(--dm-border); display: flex; justify-content: space-between; gap: 1rem; color: var(--dm-text-3); font-size: 11px; margin-top: 1.5rem; }
|
||||
.loading { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(255,255,255,.72); backdrop-filter: blur(8px); z-index: 9999; color: var(--dm-text-2); }
|
||||
.loading.hidden { display: none; }
|
||||
.spinner { width: 28px; height: 28px; border-radius: 999px; border: 2px solid rgba(50,102,173,.18); border-top-color: var(--dm-blue); animation: spin .8s linear infinite; margin: 0 auto .75rem; }
|
||||
.loading-text { font-size: 13px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 820px) {
|
||||
.metrics, .highlights, .grid-2, .grid-3, .two-col, .evaluation { grid-template-columns: 1fr; }
|
||||
#treemap { height: 420px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loading" id="loading"><div><div class="spinner"></div><div class="loading-text">正在生成交互式磁盘报告...</div></div></div>
|
||||
<main class="report">
|
||||
<section class="report-header">
|
||||
<div class="header-meta">Disk Master · Windows Disk Diagnostic</div>
|
||||
<h1 class="header-title" id="report-title">磁盘空间诊断报告</h1>
|
||||
<div class="header-sub" id="report-sub">只读扫描 · 用于空间占用地图和系统盘诊断</div>
|
||||
<div class="badges" id="badges"></div>
|
||||
</section>
|
||||
|
||||
<section class="metrics" id="metrics"></section>
|
||||
<section class="highlights" id="highlights"></section>
|
||||
<section class="evaluation" id="disk-evaluation"></section>
|
||||
<section class="info-card" id="skill-about"></section>
|
||||
|
||||
<section class="panel active" id="tab-overview">
|
||||
<div class="grid-2">
|
||||
<div class="card"><div class="section-title">Top 目录</div><div id="overview-dirs" class="detail-list"></div></div>
|
||||
<div class="card"><div class="section-title">文件类型分布</div><div id="overview-ext" class="detail-list"></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="tab-sniffer">
|
||||
<div class="two-col">
|
||||
<div class="card">
|
||||
<div class="section-title">空间占用地图</div>
|
||||
<div class="toolbar">
|
||||
<input class="input" id="tree-search" placeholder="在当前层级搜索并高亮">
|
||||
<select class="select" id="detail-level"><option value="350">简略视图</option><option value="800" selected>标准视图</option><option value="1600">更多细节</option></select>
|
||||
<button class="button" id="tree-reset">回到根目录</button>
|
||||
</div>
|
||||
<div class="row-sub" style="margin-bottom:.65rem">为保证每个可见块可读,过小或低占比子项会聚合为“其他子项”;真实大小仍保留在详情和统计中。</div>
|
||||
<div class="crumbs" id="crumbs"></div>
|
||||
<div id="treemap" role="img" aria-label="磁盘空间 Treemap"></div>
|
||||
</div>
|
||||
<aside class="card">
|
||||
<div class="section-title">当前选中</div>
|
||||
<div id="node-detail" class="detail-list"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="tab-details">
|
||||
<div class="grid-2">
|
||||
<div class="card"><div class="section-title">大文件</div><div id="large-files"></div></div>
|
||||
<div class="card"><div class="section-title">用户目录</div><div id="user-paths"></div></div>
|
||||
<div class="card"><div class="section-title">AppData 热点</div><div id="appdata"></div></div>
|
||||
<div class="card"><div class="section-title">C 盘应用/游戏</div><div id="apps"></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="tab-advice">
|
||||
<div id="advice-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="tab-raw">
|
||||
<div class="grid-2">
|
||||
<div class="card"><div class="section-title">扫描状态</div><div id="scan-meta" class="detail-list"></div></div>
|
||||
<div class="card"><div class="section-title">原始 JSON 摘要</div><pre id="raw-json"></pre></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="footer"><span>数据来源:disk-master.exe · 只读扫描</span><span>报告不会删除、移动或卸载任何内容</span></div>
|
||||
</main>
|
||||
|
||||
<script>const DATA = %%DATA%%;</script>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<script>
|
||||
const fmtBytes = (bytes) => {
|
||||
const n = Number(bytes || 0);
|
||||
if (n >= 1024 ** 4) return (n / 1024 ** 4).toFixed(2) + ' TB';
|
||||
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
|
||||
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
|
||||
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return n + ' B';
|
||||
};
|
||||
const pct = (part, total) => total > 0 ? Math.round((part / total) * 1000) / 10 : 0;
|
||||
const shortPath = (path) => String(path || '').split(/[\\/]/).filter(Boolean).pop() || path || '-';
|
||||
const colorFor = (node) => {
|
||||
const p = String(node.data.path || '').toLowerCase();
|
||||
if (p.includes('temp') || p.includes('cache') || p.includes('log')) return '#BA7517';
|
||||
if (p.includes('download') || p.includes('steam') || p.includes('game')) return '#3B6D11';
|
||||
if (p.includes('windows') || p.includes('program files')) return '#73726c';
|
||||
return '#3266ad';
|
||||
};
|
||||
const topItems = (items, n = 8) => [...(items || [])].sort((a,b) => (b.sizeBytes || 0) - (a.sizeBytes || 0)).slice(0, n);
|
||||
function cloneNode(node) {
|
||||
return JSON.parse(JSON.stringify(node || {path: DATA.drive || 'root', sizeBytes: 0, children: []}));
|
||||
}
|
||||
|
||||
function normalizeDiskPath(path) {
|
||||
return String(path || '').replace(/\//g, '\\').replace(/\\+$/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function displayPathJoin(parent, name) {
|
||||
const clean = String(parent || '').replace(/\\+$/g, '');
|
||||
return clean + '\\' + name;
|
||||
}
|
||||
|
||||
function splitDiskPath(path) {
|
||||
const text = String(path || '').replace(/\//g, '\\').replace(/\\+$/g, '');
|
||||
const driveMatch = text.match(/^[a-zA-Z]:/);
|
||||
const drive = driveMatch ? driveMatch[0] + '\\' : '';
|
||||
const rest = drive ? text.slice(3) : text;
|
||||
return { drive, parts: rest.split('\\').filter(Boolean) };
|
||||
}
|
||||
|
||||
function findChildByPath(node, path) {
|
||||
const key = normalizeDiskPath(path);
|
||||
return (node.children || []).find(c => normalizeDiskPath(c.path) === key);
|
||||
}
|
||||
|
||||
function ensureSyntheticPath(root, targetPath, sizeBytes = 0) {
|
||||
if (!root || !targetPath) return null;
|
||||
const rootPath = String(root.path || DATA.drive || '').replace(/\\+$/g, '');
|
||||
const target = String(targetPath).replace(/\//g, '\\').replace(/\\+$/g, '');
|
||||
if (!normalizeDiskPath(target).startsWith(normalizeDiskPath(rootPath))) return null;
|
||||
const relative = target.slice(rootPath.length).replace(/^\\+/, '');
|
||||
const parts = relative.split('\\').filter(Boolean);
|
||||
let current = root;
|
||||
let currentPath = rootPath;
|
||||
for (const part of parts) {
|
||||
current.children = current.children || [];
|
||||
currentPath = displayPathJoin(currentPath, part);
|
||||
let child = findChildByPath(current, currentPath);
|
||||
if (!child) {
|
||||
child = { path: currentPath, sizeBytes: 0, depth: (current.depth || 0) + 1, children: [], synthetic: true };
|
||||
current.children.push(child);
|
||||
}
|
||||
if (sizeBytes && child.sizeBytes < sizeBytes) child.sizeBytes = sizeBytes;
|
||||
current = child;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function enrichFocusTree(root) {
|
||||
if (!root) return root;
|
||||
const focusPaths = [];
|
||||
(DATA.userPaths || []).forEach(p => focusPaths.push({ path: p.path, sizeBytes: p.sizeBytes || 0 }));
|
||||
(DATA.appData || []).forEach(a => focusPaths.push({ path: a.path, sizeBytes: a.sizeBytes || 0 }));
|
||||
focusPaths.forEach(item => ensureSyntheticPath(root, item.path, item.sizeBytes));
|
||||
normalizeSyntheticSizes(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function normalizeSyntheticSizes(node) {
|
||||
const childSum = (node.children || []).reduce((sum, child) => sum + normalizeSyntheticSizes(child), 0);
|
||||
if (childSum > 0 && (!node.sizeBytes || node.synthetic || node.sizeBytes < childSum)) node.sizeBytes = childSum;
|
||||
return node.sizeBytes || 0;
|
||||
}
|
||||
|
||||
const RAW_ROOT = enrichFocusTree(cloneNode(DATA.dirTree && DATA.dirTree[0]));
|
||||
const PATH_INDEX = new Map();
|
||||
const ALLOW_HINTS = ['users', 'appdata', 'local', 'roaming', 'downloads', 'desktop', 'documents', 'pictures', 'videos', 'program files', 'programdata', 'steam', 'docker', 'virtualbox', 'vmware', 'temp'];
|
||||
const DENSE_HINTS = ['node_modules', '.git', 'winsxs', 'installer', 'package cache', 'cache', 'caches', 'overlay2', '.pnpm-store', '.npm', '.yarn'];
|
||||
const DETAIL_PRESETS = {
|
||||
350: { limit: 45, minRatio: 0.010, maxShare: 0.72 },
|
||||
800: { limit: 80, minRatio: 0.005, maxShare: 0.66 },
|
||||
1600: { limit: 140, minRatio: 0.0025, maxShare: 0.60 }
|
||||
};
|
||||
const MIN_TILE_WIDTH = 112;
|
||||
const MIN_TILE_HEIGHT = 64;
|
||||
const MIN_TILE_AREA = MIN_TILE_WIDTH * MIN_TILE_HEIGHT;
|
||||
const VIRTUAL_INDEX = new Map();
|
||||
|
||||
function indexTree(node) {
|
||||
if (!node || !node.path) return;
|
||||
PATH_INDEX.set(String(node.path).toLowerCase(), node);
|
||||
(node.children || []).forEach(indexTree);
|
||||
}
|
||||
indexTree(RAW_ROOT);
|
||||
|
||||
function hasHint(path, hints) {
|
||||
const text = String(path || '').toLowerCase();
|
||||
return hints.some(h => text.includes(h));
|
||||
}
|
||||
|
||||
function visualLimit(node, budget) {
|
||||
const children = node.children || [];
|
||||
const parentSize = Math.max(1, node.sizeBytes || 1);
|
||||
const ordered = topItems(children, Math.min(children.length, 8));
|
||||
const top5 = ordered.slice(0, 5).reduce((s, c) => s + (c.sizeBytes || 0), 0);
|
||||
const top5Ratio = pct(top5, parentSize);
|
||||
const fragmented = children.length > 180 && top5Ratio < 45;
|
||||
let limit = (DETAIL_PRESETS[budget] || DETAIL_PRESETS[800]).limit;
|
||||
if (hasHint(node.path, ALLOW_HINTS)) limit += 40;
|
||||
if (hasHint(node.path, DENSE_HINTS)) limit -= 25;
|
||||
if (fragmented) limit -= 35;
|
||||
return Math.max(12, Math.min(limit, 180));
|
||||
}
|
||||
|
||||
function visibleTileCapacity(width, height) {
|
||||
return Math.max(4, Math.floor((width * height) / (MIN_TILE_AREA * 1.35)));
|
||||
}
|
||||
|
||||
function makeTreemapView(rootNode, budget, width, height) {
|
||||
VIRTUAL_INDEX.clear();
|
||||
if (rootNode && rootNode.pruned) VIRTUAL_INDEX.set(String(rootNode.path || '').toLowerCase(), rootNode);
|
||||
const children = topItems(rootNode.children || [], Number.MAX_SAFE_INTEGER);
|
||||
const preset = DETAIL_PRESETS[budget] || DETAIL_PRESETS[800];
|
||||
const parentSize = Math.max(1, rootNode.sizeBytes || 1);
|
||||
const capacity = visibleTileCapacity(width, height);
|
||||
const limit = Math.min(visualLimit(rootNode, budget), capacity);
|
||||
const visibleMap = new Map();
|
||||
children.slice(0, limit).forEach(c => visibleMap.set(c.path, c));
|
||||
// Allow-list paths are not hidden by the first visual cut; they stay visible
|
||||
// for one more click level and their own children are judged again there.
|
||||
children.filter(c => hasHint(c.path, ALLOW_HINTS)).forEach(c => visibleMap.set(c.path, c));
|
||||
let visible = topItems([...visibleMap.values()], Math.max(limit, visibleMap.size));
|
||||
if (visible.length > limit) {
|
||||
const allowed = visible.filter(c => hasHint(c.path, ALLOW_HINTS));
|
||||
const normal = visible.filter(c => !hasHint(c.path, ALLOW_HINTS));
|
||||
visible = [...topItems(allowed, Math.min(allowed.length, Math.ceil(limit * 0.4))), ...topItems(normal, Math.max(0, limit - Math.min(allowed.length, Math.ceil(limit * 0.4))))];
|
||||
}
|
||||
const visiblePaths = new Set(visible.map(c => c.path));
|
||||
let hidden = children.filter(c => !visiblePaths.has(c.path));
|
||||
const areaRatioFloor = MIN_TILE_AREA / Math.max(1, width * height);
|
||||
const minDisplaySize = parentSize * Math.max(preset.minRatio, areaRatioFloor * 1.2);
|
||||
const maxDisplaySize = parentSize * preset.maxShare;
|
||||
const smallVisible = visible.filter(c => (c.sizeBytes || 0) < minDisplaySize && !hasHint(c.path, ALLOW_HINTS));
|
||||
if (smallVisible.length > 0) {
|
||||
const smallPaths = new Set(smallVisible.map(c => c.path));
|
||||
hidden = hidden.concat(smallVisible);
|
||||
visible = visible.filter(c => !smallPaths.has(c.path));
|
||||
}
|
||||
const mapped = visible.map(c => {
|
||||
const displaySizeBytes = Math.max(Math.min(c.sizeBytes || 0, maxDisplaySize), Math.min(minDisplaySize, c.sizeBytes || 0));
|
||||
return { path: c.path, sizeBytes: c.sizeBytes, displaySizeBytes, childCount: (c.children || []).length, hasChildren: (c.children || []).length > 0, isDenseHint: hasHint(c.path, DENSE_HINTS), isAllowHint: hasHint(c.path, ALLOW_HINTS) };
|
||||
});
|
||||
if (hidden.length > 0) {
|
||||
const hiddenBytes = hidden.reduce((s, c) => s + (c.sizeBytes || 0), 0);
|
||||
if (hiddenBytes <= 0) return { path: rootNode.path, sizeBytes: rootNode.sizeBytes, displaySizeBytes: rootNode.sizeBytes, children: mapped };
|
||||
const virtualPath = rootNode.path + ' :: 其他子项';
|
||||
const virtualNode = {
|
||||
path: virtualPath,
|
||||
sizeBytes: hiddenBytes,
|
||||
childCount: hidden.length,
|
||||
pruned: true,
|
||||
hasChildren: hidden.length > 0,
|
||||
pruneReason: '子项数量较多,已聚合低占比项目',
|
||||
children: hidden
|
||||
};
|
||||
VIRTUAL_INDEX.set(virtualPath.toLowerCase(), virtualNode);
|
||||
mapped.push({
|
||||
path: virtualPath,
|
||||
sizeBytes: hiddenBytes,
|
||||
displaySizeBytes: Math.max(Math.min(hiddenBytes, maxDisplaySize), minDisplaySize),
|
||||
childCount: hidden.length,
|
||||
pruned: true,
|
||||
hasChildren: hidden.length > 0,
|
||||
pruneReason: '子项数量较多,已聚合低占比项目'
|
||||
});
|
||||
}
|
||||
return { path: rootNode.path, sizeBytes: rootNode.sizeBytes, displaySizeBytes: rootNode.sizeBytes, children: mapped };
|
||||
}
|
||||
|
||||
function rawOrVirtual(path) {
|
||||
const key = String(path || '').toLowerCase();
|
||||
return VIRTUAL_INDEX.get(key) || PATH_INDEX.get(key);
|
||||
}
|
||||
|
||||
function layoutValue(d) {
|
||||
return d.children ? 0 : (d.displaySizeBytes || d.sizeBytes || 0);
|
||||
}
|
||||
|
||||
function findPathToNode(root, targetPath, acc = []) {
|
||||
if (!root) return null;
|
||||
const next = [...acc, root];
|
||||
if (root.path === targetPath) return next;
|
||||
for (const child of root.children || []) {
|
||||
const found = findPathToNode(child, targetPath, next);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function row(title, sub, value, ratio, color = '#3266ad') {
|
||||
return `<div class="row"><div class="row-main"><div class="row-title" title="${title}">${title}</div><div class="row-sub" title="${sub || ''}">${sub || ''}</div>${ratio != null ? `<div class="bar-wrap"><div class="bar" style="width:${Math.max(1, Math.min(100, ratio))}%; background:${color}"></div></div>` : ''}</div><strong>${value || ''}</strong></div>`;
|
||||
}
|
||||
function renderHeader() {
|
||||
document.getElementById('report-title').textContent = `${DATA.drive || '磁盘'} 空间诊断报告`;
|
||||
document.getElementById('report-sub').textContent = `${DATA.scanTime || ''} · ${DATA.scanMode || 'unknown'} · ${DATA.scanScope || 'unknown'}`;
|
||||
const badges = [
|
||||
`<span class="badge blue">${DATA.drive || '-'}</span>`,
|
||||
`<span class="badge ${DATA.isAdmin ? 'green' : 'amber'}">${DATA.isAdmin ? '管理员' : '普通权限'}</span>`,
|
||||
`<span class="badge ${DATA.scanMode === 'mft' ? 'green' : 'blue'}">${DATA.scanMode || '-'}</span>`,
|
||||
`<span class="badge">只读扫描</span>`
|
||||
];
|
||||
document.getElementById('badges').innerHTML = badges.join('');
|
||||
}
|
||||
function renderMetrics() {
|
||||
const disk = DATA.disk || {};
|
||||
const largestDir = topItems(DATA.topDirs, 1)[0];
|
||||
const highAdvice = (DATA.advice || []).filter(a => a.priority === 1).length;
|
||||
const cards = [
|
||||
['已用空间', fmtBytes(disk.usedBytes), `${pct(disk.usedBytes, disk.totalBytes)}% used`],
|
||||
['剩余空间', fmtBytes(disk.freeBytes), `${Number(disk.freePercent || 0).toFixed(1)}% free`],
|
||||
['最大目录', largestDir ? shortPath(largestDir.path) : '-', largestDir ? fmtBytes(largestDir.sizeBytes) : '-'],
|
||||
['建议数量', `${(DATA.advice || []).length} 条`, `${highAdvice} 条高优先级`]
|
||||
];
|
||||
document.getElementById('metrics').innerHTML = cards.map(c => `<div class="metric"><div class="metric-label">${c[0]}</div><div class="metric-value">${c[1]}</div><div class="metric-note">${c[2]}</div></div>`).join('');
|
||||
}
|
||||
function renderHighlights() {
|
||||
const dirs = topItems(DATA.topDirs, 1)[0];
|
||||
const movable = (DATA.advice || []).find(a => a.type === 'Movable');
|
||||
const clean = (DATA.advice || []).find(a => a.type === 'Cleanable');
|
||||
const cards = [
|
||||
['最大占用来源', dirs ? shortPath(dirs.path) : '-', dirs ? fmtBytes(dirs.sizeBytes) : '暂无数据', '#3266ad'],
|
||||
['可移动候选', movable ? movable.target : '暂无高置信候选', movable ? fmtBytes(movable.sizeMB * 1024 * 1024) : '保持观察', '#3B6D11'],
|
||||
['可清理/需检查', clean ? clean.target : '暂无明显项', clean ? fmtBytes(clean.sizeMB * 1024 * 1024) : '建议复扫确认', '#BA7517']
|
||||
];
|
||||
document.getElementById('highlights').innerHTML = cards.map(c => `<div class="highlight" style="border-color:${c[3]}"><div class="hl-label">${c[0]}</div><div class="hl-value" title="${c[1]}">${c[1]}</div><div class="hl-note">${c[2]}</div></div>`).join('');
|
||||
}
|
||||
|
||||
function evaluateDiskUsage() {
|
||||
const disk = DATA.disk || {};
|
||||
const isSystemDrive = String(DATA.drive || '').toLowerCase().startsWith('c:');
|
||||
const advice = DATA.advice || [];
|
||||
const high = advice.filter(a => a.priority === 1).length;
|
||||
const medium = advice.filter(a => a.priority === 2).length;
|
||||
const optimizableMb = advice.filter(a => ['Movable', 'Cleanable', 'Redirect'].includes(a.type)).reduce((s, a) => s + Number(a.sizeMB || 0), 0);
|
||||
const usedMb = Number(disk.usedBytes || 0) / 1024 / 1024;
|
||||
const optimizableRatio = usedMb > 0 ? optimizableMb / usedMb : 0;
|
||||
let score = 100;
|
||||
if ((disk.freePercent || 0) < 15) score -= 30;
|
||||
else if ((disk.freePercent || 0) < 25) score -= 15;
|
||||
score -= Math.min(30, high * 10 + medium * 4);
|
||||
score -= Math.min(20, optimizableRatio * 100);
|
||||
if (isSystemDrive) {
|
||||
const redirected = (DATA.userPaths || []).filter(p => ['Downloads','Desktop','Documents'].includes(p.name) && !p.isOnCDrive).length;
|
||||
score += redirected * 3;
|
||||
}
|
||||
score = Math.max(0, Math.min(100, Math.round(score)));
|
||||
let label = '使用结构健康';
|
||||
let color = '#3B6D11';
|
||||
let note = isSystemDrive ? '系统盘可优化项较少,用户数据和缓存压力可控。' : '数据盘没有明显系统盘风险,重点关注大文件和长期未用内容即可。';
|
||||
if (score < 55) { label = '需要优先优化'; color = '#A32D2D'; note = isSystemDrive ? '系统盘存在较多可移动或可清理项,建议先处理高优先级建议。' : '该盘存在明显大文件或可归档内容,建议检查 Top 目录和大文件。'; }
|
||||
else if (score < 75) { label = '有优化空间'; color = '#BA7517'; note = isSystemDrive ? '系统盘仍有可迁移或可清理项,建议逐项确认。' : '数据盘整体可用,但有部分目录值得整理或归档。'; }
|
||||
return {score, label, color, note, isSystemDrive, optimizableMb, high};
|
||||
}
|
||||
|
||||
function renderEvaluation() {
|
||||
const ev = evaluateDiskUsage();
|
||||
document.getElementById('disk-evaluation').innerHTML = `
|
||||
<div class="card score-card" style="border-left-color:${ev.color}">
|
||||
<div class="section-title">磁盘使用合理性</div>
|
||||
<div class="score-value">${ev.score}</div>
|
||||
<div class="score-label">${ev.label}</div>
|
||||
<div class="row-sub" style="margin-top:.5rem">${ev.note}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section-title">判断依据</div>
|
||||
${row('磁盘类型', '', ev.isSystemDrive ? '系统盘' : '数据盘', null)}
|
||||
${row('可优化项规模', 'Movable / Cleanable / Redirect 建议合计', fmtBytes(ev.optimizableMb * 1024 * 1024), null, '#BA7517')}
|
||||
${row('高优先级建议', '越少说明当前使用越合理', `${ev.high} 条`, null, ev.high ? '#A32D2D' : '#3B6D11')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSkillAbout() {
|
||||
document.getElementById('skill-about').innerHTML = `<div class="info-title">这个报告在做什么</div><div class="info-text">Disk Master 是一个只读磁盘诊断 Skill:CLI 负责快速采集磁盘、应用、AppData、用户目录和建议数据;报告负责把这些数据组织成空间地图、详细诊断和安全教程。它不会删除、移动、卸载或修改你的文件,所有“可清理/可移动”内容都需要你确认后手动处理。</div>`;
|
||||
}
|
||||
function renderOverview() {
|
||||
const total = (DATA.dirTree && DATA.dirTree[0] && DATA.dirTree[0].sizeBytes) || (DATA.disk || {}).usedBytes || 1;
|
||||
document.getElementById('overview-dirs').innerHTML = topItems(DATA.topDirs, 10).map(d => row(shortPath(d.path), d.path, fmtBytes(d.sizeBytes), pct(d.sizeBytes, total))).join('') || '暂无目录数据';
|
||||
const ext = Object.entries(DATA.extStats || {}).map(([name, v]) => ({name, ...v})).sort((a,b) => b.totalBytes - a.totalBytes).slice(0, 10);
|
||||
document.getElementById('overview-ext').innerHTML = ext.map(e => row(e.name, `${e.count} 个文件`, fmtBytes(e.totalBytes), pct(e.totalBytes, total), '#73726c')).join('') || '暂无扩展名数据';
|
||||
}
|
||||
let currentRoot;
|
||||
function renderTreemap(rootNode) {
|
||||
showLoading('正在绘制空间占用地图...');
|
||||
currentRoot = rootNode;
|
||||
const container = document.getElementById('treemap');
|
||||
container.innerHTML = '';
|
||||
const w = container.clientWidth, h = container.clientHeight;
|
||||
const viewRoot = makeTreemapView(rootNode, Number(document.getElementById('detail-level').value), w, h);
|
||||
const root = d3.hierarchy(viewRoot).sum(layoutValue).sort((a,b)=>b.value-a.value);
|
||||
d3.treemap().tile(d3.treemapSquarify.ratio(1.25)).size([w,h]).paddingInner(3).paddingOuter(2).round(true)(root);
|
||||
const nodes = (root.children || []).filter(d => d.x1 > d.x0 && d.y1 > d.y0);
|
||||
for (const d of nodes) {
|
||||
const left = Math.max(0, Math.min(w, d.x0));
|
||||
const tileTop = Math.max(0, Math.min(h, d.y0));
|
||||
const right = Math.max(left, Math.min(w, d.x1));
|
||||
const bottom = Math.max(tileTop, Math.min(h, d.y1));
|
||||
const tileWidth = Math.max(0, right - left);
|
||||
const tileHeight = Math.max(0, bottom - tileTop);
|
||||
if (tileWidth < 4 || tileHeight < 4) continue;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'node' + (d.data.pruned || d.data.isDenseHint ? ' pruned' : '');
|
||||
el.dataset.path = d.data.path || '';
|
||||
el.dataset.name = shortPath(d.data.path || '');
|
||||
el.style.left = left + 'px'; el.style.top = tileTop + 'px';
|
||||
el.style.width = tileWidth + 'px'; el.style.height = tileHeight + 'px';
|
||||
el.style.background = colorFor(d);
|
||||
el.title = `${d.data.path}\n实际大小:${fmtBytes(d.data.sizeBytes)}${d.data.displaySizeBytes !== d.data.sizeBytes ? '\n显示面积已做可读性调整' : ''}${d.data.pruned ? '\n' + d.data.pruneReason + '\n点击展开这些折叠子项' : ''}`;
|
||||
if (tileWidth >= 72 && tileHeight >= 38) {
|
||||
const hint = d.data.pruned && tileHeight >= 86 ? '<div class="node-hint">点击展开折叠项</div>' : '';
|
||||
el.innerHTML = `<div class="node-name">${shortPath(d.data.path)}</div><div class="node-size">${fmtBytes(d.data.sizeBytes)}${d.data.childCount ? ` · ${d.data.childCount}项` : ''}</div>${hint}`;
|
||||
}
|
||||
el.onclick = () => {
|
||||
const raw = rawOrVirtual(d.data.path);
|
||||
if (raw && (raw.children || []).length) renderTreemap(raw); else renderNodeDetail(d.data);
|
||||
};
|
||||
container.appendChild(el);
|
||||
}
|
||||
renderCrumbs(rootNode);
|
||||
renderNodeDetail(rootNode);
|
||||
requestAnimationFrame(() => hideLoading());
|
||||
}
|
||||
function renderCrumbs(node) {
|
||||
let path = findPathToNode(RAW_ROOT, node.path);
|
||||
if (!path && node.pruned) {
|
||||
const parentPath = String(node.path || '').split(' :: ')[0];
|
||||
path = findPathToNode(RAW_ROOT, parentPath);
|
||||
if (path) path = [...path, node];
|
||||
}
|
||||
path = path || [RAW_ROOT].filter(Boolean);
|
||||
document.getElementById('crumbs').innerHTML = path.map((item, index) => `<span class="crumb" data-path="${item.path}">${index === 0 ? (DATA.drive || 'root') : shortPath(item.path)}</span>`).join('<span>›</span>');
|
||||
document.querySelectorAll('#crumbs .crumb').forEach(el => el.onclick = () => {
|
||||
const raw = rawOrVirtual(el.dataset.path);
|
||||
if (raw) renderTreemap(raw);
|
||||
});
|
||||
}
|
||||
function renderNodeDetail(node) {
|
||||
const children = topItems(node.children || [], 12);
|
||||
const foldInfo = node.pruned ? row('已折叠为虚拟目录', `${node.childCount || 0} 个子项,点击 Treemap 中该块可单独展开查看`, '', null, '#BA7517') : '';
|
||||
document.getElementById('node-detail').innerHTML = row(shortPath(node.path), node.path, fmtBytes(node.sizeBytes), null) + foldInfo + children.map(c => row(shortPath(c.path), c.path, fmtBytes(c.sizeBytes), pct(c.sizeBytes, node.sizeBytes || 1))).join('');
|
||||
}
|
||||
function renderDetails() {
|
||||
document.getElementById('large-files').innerHTML = `<table><thead><tr><th>文件</th><th>大小</th><th>类型</th></tr></thead><tbody>${topItems(DATA.topFiles, 20).map(f => `<tr><td><strong>${shortPath(f.path)}</strong><div class="path">${f.path}</div></td><td>${fmtBytes(f.sizeBytes)}</td><td>${f.ext || '-'}</td></tr>`).join('')}</tbody></table>`;
|
||||
document.getElementById('user-paths').innerHTML = (DATA.userPaths || []).map(p => row(p.name, p.path, fmtBytes(p.sizeBytes), null, p.isOnCDrive ? '#BA7517' : '#3B6D11')).join('') || '暂无用户目录数据';
|
||||
document.getElementById('appdata').innerHTML = topItems(DATA.appData, 20).map(a => row(`${a.area} · ${a.name}`, a.path, fmtBytes(a.sizeBytes), null, '#BA7517')).join('') || '暂无 AppData 数据';
|
||||
const apps = (DATA.apps || []).filter(a => a.isOnCDrive).slice(0, 30);
|
||||
document.getElementById('apps').innerHTML = apps.map(a => row(a.name, `${a.source || '-'} · ${a.installLocation || ''}`, a.sizeMB ? `${a.sizeMB.toFixed(1)} MB` : '-', null, a.isGame ? '#3B6D11' : '#3266ad')).join('') || '暂无 C 盘应用数据';
|
||||
}
|
||||
function stepsFor(advice) {
|
||||
if (advice.type === 'Cleanable') return ['确认路径和来源,避免删除业务数据。', '优先使用 Windows 存储感知、磁盘清理或应用内置清理功能。', '不要手动清空不认识的系统目录。', '清理后重新运行 Disk Master 对比空间变化。'];
|
||||
if (advice.type === 'Movable') return ['确认目标内容不是系统组件。', '在应用设置中查找库目录、缓存目录或数据目录迁移选项。', '迁移到非系统盘后启动应用验证。', '确认无误后再清理旧位置。'];
|
||||
if (advice.type === 'Redirect') return ['打开对应用户文件夹属性。', '进入“位置”选项卡。', '选择非系统盘目录并应用。', '让 Windows 自动移动现有内容。'];
|
||||
return ['打开路径检查内容来源。', '确认是否仍在使用。', '必要时归档到非系统盘。', '不确定时不要删除。'];
|
||||
}
|
||||
function renderAdvice() {
|
||||
const items = DATA.advice || [];
|
||||
document.getElementById('advice-list').innerHTML = items.map((a, i) => `<div class="advice"><div class="advice-head"><span class="priority p${a.priority}">P${a.priority}</span><div><div class="row-title">${a.type} · ${a.target}</div><div class="row-sub">${a.reason}</div></div><strong>${fmtBytes((a.sizeMB || 0) * 1024 * 1024)}</strong></div><div class="advice-body"><div class="path">${a.target}</div><div class="steps">${stepsFor(a).map((s, idx) => `<div class="step"><strong>${idx + 1}. </strong>${s}</div>`).join('')}</div></div></div>`).join('') || '<div class="card">暂无建议。当前扫描未发现明显可移动或可清理候选。</div>';
|
||||
document.querySelectorAll('.advice-head').forEach(h => h.onclick = () => h.parentElement.classList.toggle('open'));
|
||||
}
|
||||
function renderRaw() {
|
||||
const meta = [
|
||||
['扫描模式', DATA.scanMode], ['扫描范围', DATA.scanScope], ['完整扫描', DATA.isComplete ? '是' : '否'], ['管理员', DATA.isAdmin ? '是' : '否'], ['MFT 可用', DATA.mft?.available ? '是' : '否'], ['错误', (DATA.errors || []).join('; ') || '无']
|
||||
];
|
||||
document.getElementById('scan-meta').innerHTML = meta.map(m => row(m[0], '', m[1], null)).join('');
|
||||
document.getElementById('raw-json').textContent = JSON.stringify({scanTime: DATA.scanTime, scanMode: DATA.scanMode, disk: DATA.disk, mft: DATA.mft, errors: DATA.errors}, null, 2);
|
||||
}
|
||||
function bind() {
|
||||
document.getElementById('tree-reset').onclick = () => renderTreemap(RAW_ROOT);
|
||||
document.getElementById('detail-level').onchange = () => renderTreemap(currentRoot || RAW_ROOT);
|
||||
document.getElementById('tree-search').oninput = (e) => {
|
||||
const q = e.target.value.trim().toLowerCase();
|
||||
const nodes = [...document.querySelectorAll('#treemap .node')];
|
||||
nodes.forEach(n => n.classList.remove('search-hit'));
|
||||
if (!q) return;
|
||||
const hits = nodes.filter(n => (n.dataset.path || '').toLowerCase().includes(q) || (n.dataset.name || '').toLowerCase().includes(q));
|
||||
hits.forEach(n => n.classList.add('search-hit'));
|
||||
if (hits[0]) {
|
||||
const raw = rawOrVirtual(hits[0].dataset.path);
|
||||
renderNodeDetail(raw || {path: hits[0].dataset.path, sizeBytes: 0, children: []});
|
||||
}
|
||||
};
|
||||
}
|
||||
function showLoading(text) {
|
||||
const el = document.getElementById('loading');
|
||||
if (!el) return;
|
||||
const label = el.querySelector('.loading-text');
|
||||
if (label && text) label.textContent = text;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
function hideLoading() {
|
||||
const el = document.getElementById('loading');
|
||||
if (el) el.classList.add('hidden');
|
||||
}
|
||||
showLoading('正在生成交互式磁盘报告...');
|
||||
renderHeader(); renderMetrics(); renderHighlights(); renderEvaluation(); renderSkillAbout(); renderOverview(); renderDetails(); renderAdvice(); renderRaw(); bind();
|
||||
if (RAW_ROOT) renderTreemap(RAW_ROOT); else hideLoading();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,153 @@
|
||||
# everything-agent-find CLI Usage
|
||||
|
||||
`everything-agent-find.exe` is a Windows-only JSON CLI for fast file discovery through the Everything SDK IPC interface. It supports Everything 1.4 classic IPC and Everything 1.5 SDK3 named-pipe IPC, including the default `1.5a` alpha instance. It requires a running Everything desktop process.
|
||||
|
||||
## Location
|
||||
|
||||
```text
|
||||
bin\everything-agent-find.exe
|
||||
```
|
||||
|
||||
If the executable is not on `PATH`, run it by absolute path:
|
||||
|
||||
```powershell
|
||||
& "<skill-dir>\bin\everything-agent-find.exe" --check
|
||||
```
|
||||
|
||||
## Synopsis
|
||||
|
||||
```powershell
|
||||
everything-agent-find <query> [options]
|
||||
```
|
||||
|
||||
The query uses Everything search syntax unless `--regex` is set.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
| --- | --- |
|
||||
| `--max <n>` | Maximum returned results, `1..500` (default: `50`) |
|
||||
| `--files` | Return files only |
|
||||
| `--folders` | Return folders only |
|
||||
| `--regex` | Treat the entire query as an Everything SDK regex |
|
||||
| `--sort <type>` | `name`, `path`, `size`, or `modified` (default: `name`) |
|
||||
| `--desc` | Sort descending |
|
||||
| `--workspace <dir>` | Only return results under this directory |
|
||||
| `--relative` | Include `relativePath` for results inside `--workspace` |
|
||||
| `--exclude <text>` | Exclude paths containing this text, case-insensitive; repeatable |
|
||||
| `--include-hidden` | Include hidden and system paths |
|
||||
| `--debug-query` | Include `query` and `effectiveQuery` in the output |
|
||||
| `--limit-scan <n>` | Maximum raw Everything results to scan, `1..1000000` (default: `20000`) |
|
||||
| `--instance <name>` | Force an Everything 1.5 SDK3 instance name, for example `1.5a`; default is auto-detect |
|
||||
| `--check` | Test Everything IPC connectivity and exit |
|
||||
| `--version` | Print human-readable CLI and SDK version information and exit |
|
||||
| `--dll <path>` | Override the path to `Everything64.dll` |
|
||||
| `--dll3 <path>` | Override the path to `Everything3_x64.dll` |
|
||||
| `--json` | Accepted as a no-op; JSON is always used |
|
||||
| `--help` | Show usage help |
|
||||
|
||||
`--files` and `--folders` are mutually exclusive.
|
||||
|
||||
## Examples
|
||||
|
||||
```powershell
|
||||
# Check IPC availability
|
||||
everything-agent-find --check
|
||||
|
||||
# Print version information
|
||||
everything-agent-find --version
|
||||
|
||||
# Find TypeScript files inside a workspace
|
||||
everything-agent-find "*.ts" --workspace D:\project --files --relative --max 30
|
||||
|
||||
# Exclude common noisy directories
|
||||
everything-agent-find "*.tsx" --workspace D:\project --files --relative --exclude node_modules --exclude .git --exclude dist --max 30
|
||||
|
||||
# Find folders named tests
|
||||
everything-agent-find "tests" --workspace D:\project --folders --relative --max 20
|
||||
|
||||
# Sort by recently modified files
|
||||
everything-agent-find "ext:md dm:thisweek" --workspace D:\project --files --sort modified --desc --relative
|
||||
|
||||
# Regex mode
|
||||
everything-agent-find "^test_.*\.ts$" --regex --workspace D:\project --files --relative --max 20
|
||||
|
||||
# Debug the final query sent to Everything
|
||||
everything-agent-find "*.rs" --workspace D:\repo --files --debug-query
|
||||
```
|
||||
|
||||
## Workspace Behavior
|
||||
|
||||
For non-regex searches, `--workspace` is pushed into the Everything query as a path constraint and is also verified locally against returned absolute paths.
|
||||
|
||||
For `--regex`, the CLI preserves the regex exactly and filters paged results locally. This avoids changing regex semantics while still preventing out-of-workspace paths from being returned.
|
||||
|
||||
## Output
|
||||
|
||||
Successful searches print JSON to stdout:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"path": "D:\\project\\src\\main.ts",
|
||||
"type": "file",
|
||||
"size": 1234,
|
||||
"modified": 1715702400000,
|
||||
"relativePath": "src\\main.ts"
|
||||
}
|
||||
],
|
||||
"ok": true,
|
||||
"backend": "sdk3",
|
||||
"instance": "1.5a",
|
||||
"count": 1,
|
||||
"scanned": 12,
|
||||
"truncated": false,
|
||||
"elapsedMs": 12
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `ok` | Whether the command succeeded |
|
||||
| `backend` | `sdk3` for Everything 1.5 named-pipe IPC, or `sdk2` for Everything 1.4 classic IPC |
|
||||
| `instance` | SDK3 instance name, usually `1.5a`; omitted for SDK2 |
|
||||
| `results` | Result objects |
|
||||
| `path` | Full absolute path |
|
||||
| `relativePath` | Path relative to `--workspace`; only emitted with `--relative` |
|
||||
| `type` | `file`, `folder`, or `volume` |
|
||||
| `size` | File size in bytes, or `-1` if unavailable |
|
||||
| `modified` | Unix epoch milliseconds for the modified timestamp |
|
||||
| `count` | Number of returned results |
|
||||
| `scanned` | Raw Everything results inspected before filters |
|
||||
| `truncated` | `true` when a successful search stopped at `--max` or `--limit-scan` |
|
||||
| `elapsedMs` | CLI runtime in milliseconds |
|
||||
| `query` | Original query, only with `--debug-query` |
|
||||
| `effectiveQuery` | Query sent to Everything, only with `--debug-query` |
|
||||
|
||||
Errors are JSON too:
|
||||
|
||||
```json
|
||||
{"ok":false,"error":"ipc_unavailable","message":"Everything IPC not available. Is Everything running with the taskbar icon?","elapsedMs":3}
|
||||
```
|
||||
|
||||
## Notes For Agents
|
||||
|
||||
Use `--workspace` whenever the user request is scoped to a project. Use `--relative` for workspace searches so summaries can refer to concise paths. Keep `--max` small, usually `20..50`. If `truncated` is `true`, ask for a narrower query or raise `--limit-scan` only when necessary.
|
||||
|
||||
Use `--check` to verify Everything IPC availability. The CLI tries SDK3 first with the main unnamed instance and then `1.5a`; if SDK3 is unavailable it falls back to SDK2 classic IPC. `--version` prints human-readable terminal output and only verifies that the CLI can load the SDK DLLs and report versions; it is not a connectivity check and is not JSON.
|
||||
|
||||
Use `--instance <name>` only when you need to force a specific Everything 1.5 instance. Without it, auto-detection handles the common Everything 1.4 and Everything 1.5a cases.
|
||||
|
||||
Use `--exclude` for noisy path fragments such as `node_modules`, `.git`, `dist`, `build`, or `.venv`. The exclusion is a case-insensitive substring match against the returned absolute path, not an Everything glob.
|
||||
|
||||
Hidden and system paths are excluded by default. Add `--include-hidden` when searching for dotfiles, system-managed files, or hidden project metadata.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows.
|
||||
- A running Everything desktop process with IPC available.
|
||||
- For Everything 1.5/1.5a: `Everything3_x64.dll` beside the executable, or `EVERYTHING3_SDK_DLL` pointing to it.
|
||||
- For Everything 1.4 classic IPC fallback: `Everything64.dll` beside the executable, or `EVERYTHING_SDK_DLL` pointing to it.
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: everything-file-search
|
||||
description: >
|
||||
Windows 上基于 Everything 的超快速文件发现工具。用于按文件名、扩展名或路径模式定位文件和目录,适合磁盘范围大、项目很大或搜索范围不明确的场景;支持 Everything 1.4 classic IPC 和 1.5/1.5a SDK3 named-pipe IPC。只负责文件发现,不负责全文搜索或代码语义搜索。
|
||||
---
|
||||
|
||||
# Everything 文件搜索
|
||||
|
||||
本 skill 用于 Windows 上的快速文件发现。它通过 bundled CLI 调用 [Everything](https://www.voidtools.com/) 的官方 SDK IPC,按文件名、路径和 Everything 查询语法定位候选文件或目录。CLI 支持 Everything 1.5/1.5a 的 SDK3 named-pipe IPC,并保留 Everything 1.4 classic IPC fallback。
|
||||
|
||||
它不负责:
|
||||
- 全文搜索
|
||||
- 代码语义搜索
|
||||
- 替代 `Grep` / `Read` 的内容验证工作
|
||||
|
||||
如果用户要“查内容”或“找某段代码在哪”,且搜索范围很大或候选路径未知,可先用本 skill 缩小候选路径,再用 `Grep` 或 `Read` 验证。若范围很小或目标是明确的函数、类、代码片段,优先直接用 `Grep`。
|
||||
|
||||
CLI 的完整参数说明在 [CLI_USAGE.md](CLI_USAGE.md)。
|
||||
|
||||
## 何时优先使用
|
||||
|
||||
以下情况优先选择本 skill,而不是直接用 `Glob` 或 `rg --files`:
|
||||
|
||||
- 用户请求本质是“找文件”“定位路径”“where is”“locate”“search for files”。
|
||||
- 项目或磁盘范围很大,遍历成本高。
|
||||
- 用户不确定文件在哪个目录,搜索范围可能跨盘或跨多个项目。
|
||||
- 需要先产出候选路径,再交给 `Read`、`Grep` 或其他工具继续处理。
|
||||
|
||||
## 何时不使用
|
||||
|
||||
以下情况不要优先使用本 skill:
|
||||
|
||||
- 用户要搜索文件内容、函数名、类名或代码语义。
|
||||
- 用户只在很小的目录树内找文件,且当前仓库的 `Glob` 已经足够快。
|
||||
- 用户明确要求全文搜索、正则搜索代码体、或搜索函数实现位置。
|
||||
|
||||
## 默认调用策略
|
||||
|
||||
agent 使用本 skill 时,默认按以下策略构造命令:
|
||||
|
||||
1. 优先调用 skill 内 bundled CLI,而不是假设命令已在 `PATH`:
|
||||
```powershell
|
||||
& "<skill-dir>\bin\everything-agent-find.exe" <query> [options]
|
||||
```
|
||||
|
||||
2. 如果不确定 Everything 是否可用,先运行:
|
||||
```powershell
|
||||
& "<skill-dir>\bin\everything-agent-find.exe" --check
|
||||
```
|
||||
|
||||
CLI 会自动先尝试 SDK3 main instance 和 `1.5a` instance,再回退 SDK2 classic IPC。只有需要强制指定 Everything 1.5 instance 时才使用 `--instance <name>`。
|
||||
|
||||
3. 只要用户请求有明确项目或目录范围,就带 `--workspace`。
|
||||
|
||||
4. 默认带 `--relative`,让输出结果在 workspace 内时同时给出 `relativePath`,更适合后续总结和展示。
|
||||
|
||||
5. 默认使用 `--files` 或 `--folders`;除非用户明确要两类结果,才同时省略。
|
||||
|
||||
6. 保持 `--max` 较小,通常 `20` 到 `50`。
|
||||
|
||||
7. 对常见噪声目录,默认建议使用 `--exclude`,例如:
|
||||
- `node_modules`
|
||||
- `.git`
|
||||
- `dist`
|
||||
- `build`
|
||||
- `.venv`
|
||||
|
||||
8. 如果成功输出中出现 `truncated: true`,优先建议用户缩小查询范围;只在确实需要时再调大 `--limit-scan`。当 `ok: false` 时,先处理错误,不要根据 `truncated` 做范围判断。
|
||||
|
||||
## 推荐命令模板
|
||||
|
||||
```powershell
|
||||
# 首选:workspace + files + relative
|
||||
& "<skill-dir>\bin\everything-agent-find.exe" "*.ts" --workspace D:\project --files --relative --max 30
|
||||
|
||||
# 进一步排除噪声
|
||||
& "<skill-dir>\bin\everything-agent-find.exe" "*.tsx" --workspace D:\project --files --relative --exclude node_modules --exclude .git --exclude dist --max 30
|
||||
|
||||
# 查找目录
|
||||
& "<skill-dir>\bin\everything-agent-find.exe" "tests" --workspace D:\project --folders --relative --max 20
|
||||
|
||||
# 最近修改文件
|
||||
& "<skill-dir>\bin\everything-agent-find.exe" "ext:md dm:thisweek" --workspace D:\project --files --sort modified --desc --relative
|
||||
|
||||
# regex 查询
|
||||
& "<skill-dir>\bin\everything-agent-find.exe" "^test_.*\.ts$" --regex --workspace D:\project --files --relative --max 20
|
||||
```
|
||||
|
||||
## 输出说明
|
||||
|
||||
CLI 始终向 stdout 输出 JSON。成功时主要字段包括:
|
||||
|
||||
- `ok`
|
||||
- `results`
|
||||
- `count`
|
||||
- `scanned`
|
||||
- `truncated`
|
||||
- `elapsedMs`
|
||||
|
||||
每条 result 通常包含:
|
||||
|
||||
- `path`:绝对路径
|
||||
- `relativePath`:仅在 `--relative` 且结果位于 `--workspace` 内时出现
|
||||
- `type`
|
||||
- `size`
|
||||
- `modified`
|
||||
|
||||
典型错误输出:
|
||||
|
||||
```json
|
||||
{"ok":false,"error":"ipc_unavailable","message":"Everything IPC not available. Is Everything running with the taskbar icon?","elapsedMs":3}
|
||||
```
|
||||
|
||||
## 排查与回退
|
||||
|
||||
当返回 `ok: false` 或 `error: "ipc_unavailable"` 时,agent 应:
|
||||
|
||||
1. 告诉用户安装 Everything,或打开 Everything 桌面应用。
|
||||
2. 说明需要运行的是带任务栏图标的用户进程,而不只是 Windows service。
|
||||
3. 退回 `Glob`、`rg --files` 或 `Get-ChildItem -Recurse`。
|
||||
4. 如果问题出现在查询构造上,可使用 `--debug-query` 检查实际发送给 Everything 的查询。
|
||||
5. `--version` 只验证 CLI 和 DLL 可加载;判断 Everything IPC 是否可用必须使用 `--check`。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Windows
|
||||
- 正在运行的 Everything 桌面进程
|
||||
- Everything 1.5/1.5a:`Everything3_x64.dll` 与 exe 在同一目录,或设置 `EVERYTHING3_SDK_DLL`
|
||||
- Everything 1.4 classic fallback:`Everything64.dll` 与 exe 在同一目录,或设置 `EVERYTHING_SDK_DLL`
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,138 @@
|
||||
# Everything Query Syntax Reference
|
||||
|
||||
Full Everything search syntax for constructing agent queries.
|
||||
|
||||
## Basic operators
|
||||
|
||||
| Syntax | Meaning | Example |
|
||||
|------------------|-------------------------------------------|----------------------|
|
||||
| `word1 word2` | AND (space-separated words must all match)| `readme .md` |
|
||||
| `word1|word2` | OR | `*.ts|*.tsx` |
|
||||
| `!word` | NOT | `!node_modules *.js` |
|
||||
| `<word1 word2>` | Grouping | `<*.c|*.h> main` |
|
||||
| `"exact phrase"` | Literal match with spaces | `"my file.txt"` |
|
||||
|
||||
## Wildcards
|
||||
|
||||
| Syntax | Meaning | Example |
|
||||
|-----------|----------------------|------------------------|
|
||||
| `*` | Zero or more chars | `*.png` |
|
||||
| `?` | Exactly one char | `file?.txt` |
|
||||
| `#` | Exactly one digit | `img####.jpg` |
|
||||
| `[abc]` | Character class | `file[0-9].txt` |
|
||||
|
||||
## Search functions (prefix with function name and colon)
|
||||
|
||||
| Function | Description | Example |
|
||||
|-----------------|--------------------------------|--------------------------------|
|
||||
| `ext:` | File extension | `ext:rs` |
|
||||
| `path:` | Path contains | `path:src\components` |
|
||||
| `folder:` | Folder name | `folder:node_modules` |
|
||||
| `parent:` | Parent folder name | `parent:src *.ts` |
|
||||
| `root:` | Root / drive | `root:C:` |
|
||||
| `size:` | File size (bytes / KB / MB) | `size:>1mb` |
|
||||
| `dm:` | Date modified | `dm:today`, `dm:lastweek` |
|
||||
| `dc:` | Date created | `dc:2024-01-01..2024-12-31` |
|
||||
| `da:` | Date accessed | `da:thisweek` |
|
||||
| `attrib:` | File attributes | `attrib:H` (hidden) |
|
||||
| `dupe:` | Find duplicates | `dupe:size` |
|
||||
| `regex:` | Regex (Everything flavor) | `regex:^test_.*\.spec\.ts$` |
|
||||
| `case:` | Case-sensitive search | `case:ReadMe.md` |
|
||||
| `wholeword:` | Match whole word | `wholeword:main` |
|
||||
| `child:` | Files inside folder | `child:src *.ts` |
|
||||
| `type:` | File type | `type:video` |
|
||||
| `infolder:` | Exact folder path | `infolder:D:\project\src` |
|
||||
| `depth:` | Folder depth from root | `depth:<=3 *.ts` |
|
||||
| `count:` | File count in folder | `count:>100` |
|
||||
| `empty:` | Empty folders | `empty:` |
|
||||
|
||||
## Date macros
|
||||
|
||||
| Macro | Meaning |
|
||||
|---------------|--------------------------------------|
|
||||
| `today` | Today |
|
||||
| `yesterday` | Yesterday |
|
||||
| `lastweek` | Previous 7 days |
|
||||
| `thisweek` | Current week |
|
||||
| `lastmonth` | Previous month |
|
||||
| `thismonth` | Current month |
|
||||
| `lastyear` | Previous year |
|
||||
| `thisyear` | Current year |
|
||||
|
||||
Date ranges: `dm:2025-01-01..2025-06-30` or `dm:>2025-01-01`
|
||||
|
||||
## Size macros
|
||||
|
||||
| Macro | Value |
|
||||
|-----------|-------------|
|
||||
| `tiny` | 0-10 KB |
|
||||
| `small` | 10-100 KB |
|
||||
| `medium` | 100 KB-1 MB |
|
||||
| `large` | 1-16 MB |
|
||||
| `huge` | 16-128 MB |
|
||||
| `gigantic`| >128 MB |
|
||||
|
||||
Size ranges: `size:>100kb size:<10mb`
|
||||
|
||||
## Attribute codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|-------------|
|
||||
| `A` | Archive |
|
||||
| `H` | Hidden |
|
||||
| `S` | System |
|
||||
| `R` | Read-only |
|
||||
| `D` | Directory |
|
||||
| `N` | Not indexed |
|
||||
|
||||
Example: `attrib:!H !S` (not hidden, not system)
|
||||
|
||||
## Practical agent query patterns
|
||||
|
||||
`regex:` can be used inside normal Everything syntax. The CLI also supports
|
||||
`--regex`, which treats the whole query string as a regex by calling the
|
||||
Everything SDK regex mode directly.
|
||||
|
||||
The `Query` value is Everything syntax. The `CLI flags` value is passed as
|
||||
separate command-line options to `everything-agent-find.exe`.
|
||||
|
||||
```
|
||||
# All TypeScript source files modified today
|
||||
Query: ext:ts dm:today
|
||||
CLI flags: --files
|
||||
|
||||
# Config files named "tsconfig" anywhere
|
||||
Query: tsconfig ext:json
|
||||
CLI flags: --files
|
||||
|
||||
# React component files in a project
|
||||
Query: *.tsx *.jsx
|
||||
CLI flags: --workspace D:\project --files
|
||||
|
||||
# Recently changed documentation files
|
||||
Query: ext:md dm:thisweek
|
||||
CLI flags: --sort modified --desc
|
||||
|
||||
# Find node_modules folders
|
||||
Query: node_modules
|
||||
CLI flags: --folders --workspace D:\project
|
||||
|
||||
# Find large log files
|
||||
Query: *.log size:>10mb
|
||||
CLI flags: --files
|
||||
|
||||
# Files with both "test" and "spec" in name
|
||||
Query: test spec ext:ts
|
||||
CLI flags: --files
|
||||
|
||||
# Regex for TypeScript files whose names start with test_
|
||||
Query: ^test_.*\.ts$
|
||||
CLI flags: --regex --files
|
||||
|
||||
# Python files NOT in venv directories
|
||||
Query: ext:py !path:venv
|
||||
|
||||
# Files modified between two dates
|
||||
Query: dc:2026-01-01..2026-05-14 ext:cpp
|
||||
CLI flags: --files
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: teach-me
|
||||
description: >
|
||||
Walk the user through something until they actually understand it — layer by
|
||||
layer, one round at a time, every claim verified before it is taught. Use
|
||||
when the user asks to be walked through a design, codebase, requirement, or
|
||||
decision ("讲懂我", "给我讲讲", "teach me"), or wants to understand what
|
||||
exists from what was built. Also fires on handover from the think-twice
|
||||
skill after a second miss (a rewrite already failed).
|
||||
---
|
||||
|
||||
# Teach Me
|
||||
|
||||
The user is present and can talk back: teaching is a loop, not a lecture. The
|
||||
goal is their understanding, confirmed by them — never declared by you. An
|
||||
explainer that re-explains everything, announces the matter settled, and
|
||||
changes the subject has failed, even if every sentence was true.
|
||||
|
||||
The rule files referenced below — `double-check.md`, `self-contain.md`,
|
||||
`style.md` — are parts of the **think-twice** skill; find them in that
|
||||
skill's own directory. Where think-twice is not installed, the inline clause
|
||||
at each pointer carries the rule's core.
|
||||
|
||||
## Loop
|
||||
|
||||
1. **Map and slice.** Build context from zero — the user starts as a **cold
|
||||
reader**: assume no earlier names or interfaces remembered. Split the subject into layers, ordered so each
|
||||
layer stands on the one before it. One layer per round.
|
||||
2. **Check yourself before you teach.** Every claim in the layer follows
|
||||
`double-check.md` from the think-twice skill: verify it
|
||||
(read the code, run the command, open the source) or label it uncertain.
|
||||
When the stakes warrant, spawn a context-free subagent to blind-check a
|
||||
claim. Never teach a guess.
|
||||
3. **Teach one layer.** Settled things are stated directly — not re-opened as
|
||||
questions. Express under `self-contain.md` from the think-twice skill: every
|
||||
term glossed at first use, examples drawn from material the conversation
|
||||
already holds — real files, real numbers, the user's own work — before any
|
||||
abstraction. Teach the subject, not the method: this skill's own vocabulary
|
||||
(layers, gates, loop) never appears in what the user reads. Chinese prose
|
||||
for a human reader also follows `style.md` from the think-twice skill:
|
||||
plain sentences over bold-label bullets, bold at most twice per paragraph,
|
||||
no filler connectives, no emoji.
|
||||
4. **Route real decisions only.** Put to the user only what changes behavior,
|
||||
scope, or a public interface; digest implementation detail yourself. When a
|
||||
proper decision interview emerges, hand over to the grilling skill where
|
||||
installed; otherwise ask the decisions yourself, one per message, each with
|
||||
a recommended answer.
|
||||
5. **The user gates the loop.** A round ends with one line — 这段讲了:plus a
|
||||
one-clause recap — then stop. No question attached, no preview of what
|
||||
comes next: the user's natural reply is the gate. Advance on "懂了"; stay
|
||||
on anything else. Never ask "clear?" — it gets a polite yes, and politeness
|
||||
is not information. On "没懂": first locate the stuck point — one question,
|
||||
with candidate options so answering is cheap (options belong here and only
|
||||
here) — then re-teach that same layer from a different angle: a concrete
|
||||
scenario walked start to end, a contrast with the thing it is most often
|
||||
confused with, the technical terms reattached last. Never change the subject
|
||||
yourself — when the user changes it, follow them, and pick the layer back
|
||||
up when they return.
|
||||
|
||||
The final round closes differently: a short summary of everything taught,
|
||||
conclusion first — not a completion announcement.
|
||||
|
||||
## Done when
|
||||
|
||||
The user says they understand, or says it is enough. Nothing else ends the
|
||||
loop — not your own judgement that the matter is settled.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Multi-session curriculum learning (workspace, lessons, learning records) is
|
||||
the `teach` skill, where installed. Rewriting one outgoing text is
|
||||
`think-twice`. This skill is the live loop that gets a present user from
|
||||
"没懂" to "懂了".
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: think-twice
|
||||
description: >
|
||||
Rebuild output so a reader with only this text can understand or act, with
|
||||
every claim verified. Use when the user doesn't understand your last
|
||||
output, asks to expand / explain / rewrite plainly / make it
|
||||
self-contained; when shipping prose that must stand alone (handoff,
|
||||
PR/issue body, doc, subagent brief); or when another skill needs a clarity
|
||||
or evidence pass.
|
||||
---
|
||||
|
||||
# Think Twice
|
||||
|
||||
Do not polish the last draft with more jargon. Rebuild from **only what will
|
||||
appear in the output you send**, on two pillars:
|
||||
|
||||
- **Self-contained** — the words work alone for the **cold reader**: someone
|
||||
with this text and only what is guaranteed in front of them. Rules in
|
||||
[self-contain.md](self-contain.md).
|
||||
- **Double-checked** — every claim in them is verified or labeled. Rules in
|
||||
[double-check.md](double-check.md).
|
||||
|
||||
Skip trivial one-liners the user already follows.
|
||||
|
||||
## Second miss
|
||||
|
||||
A rewrite already shipped and they still don't get it, or ask the same
|
||||
question in other words → invoke the **teach-me** skill (needs a reader who
|
||||
can talk back); never a third rewrite. Where teach-me is not installed, do the
|
||||
minimum it would: locate the stuck point with one question, then re-explain
|
||||
that point through a concrete example.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Diagnose** — one primary blocker: **concept** | **scope** | **steps**
|
||||
| **token** (undefined word or dangling reference). Fix the highest one
|
||||
first. For proactive prose where no one is stuck yet, diagnose the blocker
|
||||
the reader is most likely to hit.
|
||||
2. **Three questions**, answered silently: what does the reader have right now
|
||||
(default: the cold reader)? Can they understand or act from that
|
||||
alone? Which facts must the text pin — conclusion, scope, evidence, next
|
||||
step?
|
||||
3. **Double-check** — verify every claim the text will make, per
|
||||
[double-check.md](double-check.md).
|
||||
4. **Rebuild — do not patch.** Substance order: conclusion → what it is in
|
||||
plain terms → in/out of scope when partial work matters → evidence when
|
||||
trust matters → minimal path when action matters (success shape; on
|
||||
failure, the next move, not only an error name). Every line obeys
|
||||
[self-contain.md](self-contain.md).
|
||||
5. **Deliver.** Match the shape to the reader and the scene. Only two rules are
|
||||
fixed: conclusion first, and drop anything that does not change
|
||||
understanding or action. For a chat reply to a person, short is the
|
||||
default. This draft shape is scaffolding, not a mould; use it, reshape it,
|
||||
or discard it:
|
||||
|
||||
1. **Takeaway** — 1–3 sentences
|
||||
2. **Need-to-know** — only terms blocking this message (omit if none)
|
||||
3. **Next step** — only if boundaries or action matter
|
||||
|
||||
The slots name the substance and its order, not literal headings — they can
|
||||
be plain sentences. Chinese prose for a human reader additionally loads
|
||||
[style.md](style.md), an optional part.
|
||||
|
||||
## Done when
|
||||
|
||||
- [ ] Passes the [self-contain.md](self-contain.md) final check
|
||||
- [ ] Passes the [double-check.md](double-check.md) final check
|
||||
- [ ] Conclusion first; everything left changes understanding or action
|
||||
- [ ] Chinese human-facing output: every [style.md](style.md) rule swept, rule
|
||||
by rule
|
||||
- [ ] A second miss went to teach-me — or, where it is not installed, to its
|
||||
minimum — not a third rewrite
|
||||
|
||||
## Out of scope
|
||||
|
||||
This skill rebuilds the words; tracker/PR mechanics (linking, closing,
|
||||
labels) belong to whatever process owns them.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Double-check — the claims hold
|
||||
|
||||
A claim that reaches the reader unverified is a guess wearing a conclusion's
|
||||
clothes. The first mismatch the reader finds costs the whole text its credit.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Verified or labeled.** Every claim either carries evidence you actually
|
||||
checked — code read, command run, file opened, source quoted — or an
|
||||
explicit uncertainty label ("unverified", "low confidence, no repro").
|
||||
Never write a guess in a conclusion's voice.
|
||||
- **Evidence is concrete.** The file and line, the command and its real
|
||||
output, the exact quote. "I checked" is not evidence.
|
||||
- **Quotes are evidence.** A decision, constraint, or priority is quoted
|
||||
verbatim, in its original language, with who said it and when. A paraphrase
|
||||
is your judgement of their words; presented as fact, it turns a reversible
|
||||
decision into a law of physics for the reader.
|
||||
- **Fact vs judgement.** A verifiable fact gets checked. Someone's decision
|
||||
gets attributed. Keep the two visibly distinct in the text.
|
||||
- **Pre-commit, then look.** When a claim can be tested (a command, a check),
|
||||
write the expected result before looking at the actual one. Matching after
|
||||
the fact is **back-fitting** — the output would have "confirmed" whatever
|
||||
you wrote.
|
||||
- **Already-run work is evidence.** Something executed earlier in this session
|
||||
proved itself then — copy its real output; re-running can repeat side
|
||||
effects. Pre-commit still applies: write the success condition from the
|
||||
step's original goal before inspecting that old output, or you are
|
||||
back-fitting.
|
||||
- **Blocked legwork is stated, not papered over.** If verification is
|
||||
impossible from here, write what is unknown, why, and what would resolve
|
||||
it. That sentence is itself the label.
|
||||
|
||||
## Final check
|
||||
|
||||
Strip every sentence you could not defend if the reader asked "how do you
|
||||
know?". What survives is the text.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Self-contain — the words work alone
|
||||
|
||||
The **cold reader** has only this text, plus whatever is guaranteed to be in
|
||||
front of them at the moment they read it. Every reference must resolve inside
|
||||
that set. An LLM cold reader is worse off than a human: it does not error on
|
||||
an undefined reference — it silently invents a plausible meaning for it.
|
||||
|
||||
## Rules
|
||||
|
||||
- **No dangling references.** Undefined unless the body supplies them: "as
|
||||
discussed", "per the design", "as usual"; "see above" with no findable
|
||||
target; a private path or id as the *only* source of a required fact.
|
||||
Restate the fact, or give a resolvable locator **and** what to take from it.
|
||||
- **Gloss on first use.** Acronyms, nicknames, codenames, team shorthand — one
|
||||
clause, at the first appearance, nowhere else.
|
||||
- **One term, one meaning.** A thing keeps its name for the whole text; a
|
||||
synonym midway reads as a second thing.
|
||||
- **Mechanism → observable consequence.** What the reader can see or do, not
|
||||
internal names first. Plain words first; technical name second.
|
||||
- **Define before you label.** Why it matters → what it does and does not do →
|
||||
this text's scope. The label comes after the reader can already picture it.
|
||||
- **Pin partial work.** What this can do, what it deliberately does not,
|
||||
whether a larger goal is finished or only advanced.
|
||||
- **Match detail to what the reader can use.** If they have only the text, put
|
||||
boundaries and failure next-moves in the text. If they can open or run
|
||||
things, say exactly how.
|
||||
- **Examples fit the reader.** Output that ships beyond this conversation gets
|
||||
generic, domain-neutral examples. Inside a conversation, the reader's own
|
||||
material is shared context and the best example there is.
|
||||
|
||||
## Final check
|
||||
|
||||
Hand the text to the cold reader. If they must ask "what is X / how far does
|
||||
this go / what's out / what next?", not done.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Style — 中文表述 (optional part)
|
||||
|
||||
Load only when the output is Chinese prose for a human reader; even then,
|
||||
skip code-heavy text. This file is a guardrail against model-flavoured
|
||||
prose; when model writing no longer needs it, delete the file and every
|
||||
pointer to it (grep both skills for style.md) — neither core depends on it.
|
||||
|
||||
Every rule is countable. Before delivery, sweep the list once: each rule
|
||||
passes, or the text changes until it does.
|
||||
|
||||
1. **删空转连接词**:此外 / 然而 / 值得注意的是 / 需要指出的是。句子没了它们照样成立。
|
||||
2. **删填充开场**:为了实现这一目标 → 为了这一点;在这个时间点 → 现在;系统具有处理的能力 → 系统可以处理。
|
||||
3. **禁 `**小标题:** 一句话` 式 bullet 列表**。每项都以加粗标签加冒号开头的列表是模型腔;写成句子。(输出文档里操作步骤内的字段标签,如「操作:」「预期结果:」,是结构,保留。)
|
||||
4. **禁强行三段式**。三个并列项逐个做删除测试:删掉后不改变动作、判断或风险的,删。两项或四项都正常。
|
||||
5. **加粗每段最多两处**。加粗是"读者不能错过",多了等于没有。
|
||||
6. **连续三句同长,破一句**。
|
||||
7. **删中文破折号(——)**,改逗号或句号。
|
||||
8. **无 emoji**。
|
||||
Reference in New Issue
Block a user