mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 22:30:51 +08:00
统一技能安装链路并提交当前skills变更。
新增 public 的 skill_auto_install 工具并移除 catalog 旧实现,同时按当前工作区状态提交 skills 精简与 skill-creator 新增内容。 Made-with: Cursor
This commit is contained in:
parent
1c71a29ec1
commit
329fb2239a
45 changed files with 1291 additions and 6100 deletions
|
|
@ -1,97 +0,0 @@
|
|||
---
|
||||
name: SkillScan
|
||||
metadata:
|
||||
version: "1.1.6"
|
||||
description: >
|
||||
Security gate for skills. Every new skill MUST pass SkillScan before use.
|
||||
Activate on any install, load, add, evaluate, or safety question about a skill.
|
||||
On first load, run first-run to scan all existing skills.
|
||||
Blocks HIGH/CRITICAL skills. No exceptions.
|
||||
---
|
||||
|
||||
# SkillScan v1.1.6
|
||||
|
||||
Security check for skill packages. Every new skill must pass this scan before use.
|
||||
|
||||
Implemented in `scripts/scanner.py`. Supports Windows / macOS / Linux.
|
||||
|
||||
Do NOT generate report files inside the skill directory.
|
||||
|
||||
---
|
||||
|
||||
## When to Activate
|
||||
|
||||
Run SkillScan whenever a skill is installed, loaded, added, evaluated, or questioned for safety — regardless of method or phrasing.
|
||||
|
||||
Trigger examples: install, load, add, use, set up, "is this safe", upload .zip/.skill, paste SKILL.md, mention unknown skill name, install from hub/store.
|
||||
|
||||
---
|
||||
|
||||
## First Load
|
||||
|
||||
Check `scripts/.first_run_done`:
|
||||
|
||||
- **Not found** → **Tell user** SkillScan is installed, ask to scan all existing skills:
|
||||
```bash
|
||||
python <skill_scanner_dir>/scripts/scanner.py first-run
|
||||
```
|
||||
`.first_run_done` is created automatically after completion.
|
||||
|
||||
- **Found** → Skip, operate normally.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Usage |
|
||||
|---------|-------|
|
||||
| `scanner.py scan <path>` | Scan a single skill (.zip or directory) |
|
||||
| `scanner.py scan-all` | Scan all installed skills |
|
||||
| `scanner.py first-run` | First-time full scan |
|
||||
| `scanner.py upgrade` | Manual upgrade |
|
||||
|
||||
---
|
||||
|
||||
## Scan Rules
|
||||
|
||||
- **.zip files** → Scan BEFORE installation. Block if fails.
|
||||
- **Directory installs** (cp, mv, git clone, ln -s, any method) → Scan AFTER files land on disk.
|
||||
- **Remote installs** (clawhub, skillhub, npx skills add, etc.) → Scan immediately after install.
|
||||
- **Unknown skills** → If user mentions a skill you haven't seen, scan it.
|
||||
|
||||
---
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Verdict | Action |
|
||||
|------|---------|--------|
|
||||
| `0` | UNKNOWN / SAFE | Proceed |
|
||||
| `1` | LOW / MEDIUM | Warn user, ask to confirm |
|
||||
| `2` | HIGH / CRITICAL | Block, show details |
|
||||
| `3` | Scan failed | Explain, offer retry |
|
||||
|
||||
---
|
||||
|
||||
## Auto Update
|
||||
|
||||
Checks for updates every day automatically. Silent, no user action needed. Manual: `scanner.py upgrade`.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SKILL_SCANNER_UPDATE_URL` | Custom update source (optional) |
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
Base URL: `https://skillscan.tokauth.com`
|
||||
|
||||
| Step | Method | Path |
|
||||
|------|--------|------|
|
||||
| ① Cache lookup | GET | `/oapi/v1/skill-scan/search?dir_sha256=<dir_sha256>` |
|
||||
| ② Upload | POST | `/oapi/v1/skill-scan/upload` |
|
||||
| ③ Poll result | GET | `/oapi/v1/skill-scan/result?task_no=<task_no>` (poll every 20s, max 180s) |
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"ownerId": "kn791cyx98pcsezkh5088g8jxn84c7mm",
|
||||
"slug": "skillscan",
|
||||
"version": "1.1.6",
|
||||
"publishedAt": 1776650587310
|
||||
}
|
||||
|
|
@ -1,959 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
SkillScan v1.1.5 — OpenClaw Skill security scanner.
|
||||
Supports Windows / macOS / Linux. All temp files use the standard tempfile module.
|
||||
|
||||
Usage (invoked by the agent via bash):
|
||||
python scanner.py first-run # First install: list installed skills and ask to scan
|
||||
python scanner.py scan <path> # Scan a single skill (.zip or directory)
|
||||
python scanner.py scan-all # Scan all installed skills
|
||||
python scanner.py upgrade # Auto-upgrade
|
||||
"""
|
||||
|
||||
import sys, os, json, time, zipfile, hashlib, shutil, tempfile, uuid, platform, base64
|
||||
import urllib.request, urllib.error, urllib.parse
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Configuration
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SCANNER_VERSION = "1.1.5"
|
||||
|
||||
BASE_URL = "https://skillscan.tokauth.com"
|
||||
API_SEARCH = f"{BASE_URL}/oapi/v1/skill-scan/search"
|
||||
API_UPLOAD = f"{BASE_URL}/oapi/v1/skill-scan/upload"
|
||||
API_RESULT = f"{BASE_URL}/oapi/v1/skill-scan/result"
|
||||
UPDATE_URL = os.environ.get("SKILL_SCANNER_UPDATE_URL",
|
||||
f"{BASE_URL}/downloads/SkillScan/manifest")
|
||||
|
||||
POLL_INTERVAL = 20 # Poll interval (seconds)
|
||||
POLL_TIMEOUT = 180 # Max wait time (seconds)
|
||||
|
||||
# First-run marker file (in the same directory as scanner.py)
|
||||
STATE_FILE = Path(__file__).parent / ".first_run_done"
|
||||
|
||||
# Auto-update check marker file and interval (7 days)
|
||||
LAST_UPDATE_CHECK_FILE = Path(__file__).parent / ".last_update_check"
|
||||
AUTO_UPDATE_INTERVAL = 1 * 24 * 3600 # 1 day (seconds)
|
||||
|
||||
# Client info file (generated on first run, reused afterwards)
|
||||
CLIENT_INFO_FILE = Path(__file__).parent / ".client_info"
|
||||
|
||||
# Files and directories to skip during scanning, hashing, and packing
|
||||
SKIP_FILES = {".first_run_done", ".last_update_check", ".client_info", "cloud_report.json", ".DS_Store"}
|
||||
SKIP_DIRS = {".git", "__pycache__", ".venv", "node_modules", ".idea", ".vscode", ".clawhub"}
|
||||
|
||||
# Resolve the root directory of SkillScan itself (parent of scripts/)
|
||||
SELF_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
# Skill installation paths (cross-platform)
|
||||
def skill_install_paths():
|
||||
# type: () -> list
|
||||
"""Auto-enumerate OpenClaw and local skill paths across platforms."""
|
||||
home = Path.home()
|
||||
oc_dir = home / ".openclaw"
|
||||
candidates = [
|
||||
# OpenClaw standard paths
|
||||
oc_dir / "skills",
|
||||
oc_dir / "workspace/skills",
|
||||
# Shared agent skill paths
|
||||
home / ".agents/skills",
|
||||
home / ".config/agents/skills",
|
||||
# Agent-specific global paths
|
||||
home / ".gemini/antigravity/skills",
|
||||
home / ".gemini/skills",
|
||||
home / ".augment/skills",
|
||||
home / ".claude/skills",
|
||||
home / ".codex/skills",
|
||||
home / ".commandcode/skills",
|
||||
home / ".continue/skills",
|
||||
home / ".snowflake/cortex/skills",
|
||||
home / ".config/crush/skills",
|
||||
home / ".cursor/skills",
|
||||
home / ".deepagents/agent/skills",
|
||||
home / ".factory/skills",
|
||||
home / ".firebender/skills",
|
||||
home / ".copilot/skills",
|
||||
home / ".config/goose/skills",
|
||||
home / ".junie/skills",
|
||||
home / ".iflow/skills",
|
||||
home / ".kilocode/skills",
|
||||
home / ".kiro/skills",
|
||||
home / ".kode/skills",
|
||||
home / ".mcpjam/skills",
|
||||
home / ".vibe/skills",
|
||||
home / ".mux/skills",
|
||||
home / ".config/opencode/skills",
|
||||
home / ".oh/skills",
|
||||
home / ".pi/agent/skills",
|
||||
home / ".qoder/skills",
|
||||
home / ".qwen/skills",
|
||||
home / ".roo/skills",
|
||||
home / ".trae/skills",
|
||||
home / ".trae-cn/skills",
|
||||
home / ".codeium/windsurf/skills",
|
||||
home / ".zencoder/skills",
|
||||
home / ".neovate/skills",
|
||||
home / ".pochi/skills",
|
||||
home / ".adal/skills",
|
||||
home / ".npm-global/lib/node_modules/openclaw/skills",
|
||||
# Container default paths
|
||||
Path("/mnt/skills/public"),
|
||||
Path("/mnt/skills/private"),
|
||||
Path("/mnt/skills/user"),
|
||||
# User dev/download paths
|
||||
home / "Downloads/skills",
|
||||
]
|
||||
|
||||
# Windows-specific paths
|
||||
if os.name == "nt":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata:
|
||||
candidates.append(Path(appdata) / "OpenClaw/skills")
|
||||
candidates.append(Path(appdata) / "Programs/LobsterAI/resources/SKILLs")
|
||||
|
||||
# Dynamically scan extensions: .openclaw/extensions/{xxxx}/skills
|
||||
if oc_dir.exists():
|
||||
ext_root = oc_dir / "extensions"
|
||||
if ext_root.exists():
|
||||
for sub in ext_root.iterdir():
|
||||
if sub.is_dir():
|
||||
s_dir = sub / "skills"
|
||||
if s_dir.exists():
|
||||
candidates.append(s_dir)
|
||||
|
||||
# Include script run path and workspace
|
||||
candidates.append(Path.cwd() / "skills")
|
||||
candidates.append(Path(__file__).parent.parent / "skills")
|
||||
|
||||
# Deduplicate and filter non-existent paths
|
||||
seen = set()
|
||||
result = []
|
||||
for p in candidates:
|
||||
try:
|
||||
abs_p = p.resolve()
|
||||
if abs_p.exists() and abs_p not in seen:
|
||||
result.append(p)
|
||||
seen.add(abs_p)
|
||||
except Exception:
|
||||
continue
|
||||
return result
|
||||
|
||||
RISK_EMOJI = {"SAFE":"✅","LOW":"⚠️ ","MEDIUM":"🟡","HIGH":"🔴","CRITICAL":"☠️ "}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Client Info (X-Client-Info)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_mac_address():
|
||||
"""Try to get the MAC address; return empty string on failure."""
|
||||
try:
|
||||
import uuid as _uuid
|
||||
mac_int = _uuid.getnode()
|
||||
# getnode() returns a random value (bit 8 set) when it can't get the real MAC
|
||||
if (mac_int >> 40) & 1:
|
||||
return ""
|
||||
mac_str = ":".join(("%012X" % mac_int)[i:i+2] for i in range(0, 12, 2))
|
||||
return mac_str
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _build_client_info():
|
||||
"""Build client info dict and persist to file; reuse on subsequent runs."""
|
||||
# If a record file already exists, read it
|
||||
if CLIENT_INFO_FILE.exists():
|
||||
try:
|
||||
data = json.loads(CLIENT_INFO_FILE.read_text(encoding="utf-8"))
|
||||
if data.get("client_id"):
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First run: generate new client info
|
||||
info = {
|
||||
"client_id": str(uuid.uuid4()),
|
||||
"os": platform.system() or "",
|
||||
"platform": platform.machine() or "",
|
||||
"os_version": platform.release() or "",
|
||||
"client": "SkillScanner/%s" % SCANNER_VERSION,
|
||||
}
|
||||
|
||||
mac = _get_mac_address()
|
||||
if mac:
|
||||
info["mac"] = mac
|
||||
|
||||
# Python version as extra
|
||||
info["extra"] = {
|
||||
"python": platform.python_version(),
|
||||
}
|
||||
|
||||
# Persist
|
||||
try:
|
||||
CLIENT_INFO_FILE.write_text(
|
||||
json.dumps(info, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _get_client_info_header():
|
||||
"""Return Base64-encoded X-Client-Info header value; empty string on failure."""
|
||||
try:
|
||||
info = _build_client_info()
|
||||
json_str = json.dumps(info, ensure_ascii=False)
|
||||
encoded = base64.b64encode(json_str.encode("utf-8")).decode("ascii")
|
||||
return encoded
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Output Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def banner(title: str):
|
||||
w = 58
|
||||
print(f"\n{'═'*w}")
|
||||
print(f" {title}")
|
||||
print(f"{'═'*w}")
|
||||
|
||||
def divider(title: str = ""):
|
||||
if title:
|
||||
print(f"\n ── {title} {'─'*(48-len(title))}")
|
||||
else:
|
||||
print(f" {'─'*52}")
|
||||
|
||||
def log(msg: str):
|
||||
print(f" {msg}", flush=True)
|
||||
|
||||
def ask(prompt: str) -> str:
|
||||
"""Read user input (compatible with non-interactive environments)."""
|
||||
try:
|
||||
return input(f"\n {prompt} ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return ""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HTTP Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def http_get(url: str) -> dict:
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read().decode("utf-8", errors="replace"))
|
||||
|
||||
def http_post(url: str, payload: dict) -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read().decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Skill Utilities
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def skill_name_from_dir(skill_dir: Path) -> str:
|
||||
md = skill_dir / "SKILL.md"
|
||||
if md.exists():
|
||||
for line in md.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("name:"):
|
||||
return s.split(":", 1)[1].strip().strip("\"'")
|
||||
return skill_dir.name
|
||||
|
||||
def sha256_of(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def calculate_dir_sha256(directory: Path) -> str:
|
||||
"""Calculate SHA256 hash of a skill directory (based on all file contents + relative paths).
|
||||
Excludes _meta.json and files/dirs in SKIP_FILES/SKIP_DIRS."""
|
||||
file_hashes = []
|
||||
for file_path in sorted(directory.rglob('*')):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
if file_path.name == '_meta.json':
|
||||
continue
|
||||
rel = file_path.relative_to(directory)
|
||||
if any(part in SKIP_DIRS for part in rel.parts):
|
||||
continue
|
||||
if file_path.name in SKIP_FILES:
|
||||
continue
|
||||
rel_path = str(rel)
|
||||
file_hash = hashlib.sha256()
|
||||
file_hash.update(rel_path.encode('utf-8'))
|
||||
file_hash.update(b'\x00')
|
||||
with open(file_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(8192), b''):
|
||||
file_hash.update(chunk)
|
||||
file_hashes.append(file_hash.hexdigest())
|
||||
file_hashes.sort()
|
||||
final_hash = hashlib.sha256()
|
||||
for h in file_hashes:
|
||||
final_hash.update(h.encode('utf-8'))
|
||||
final_hash.update(b'\x00')
|
||||
return final_hash.hexdigest()
|
||||
|
||||
def collect_files(skill_dir: Path) -> dict:
|
||||
"""Collect files for scanning, skipping redundant or sensitive directories."""
|
||||
exts = {".md",".py",".js",".ts",".sh",".yaml",".yml",".json",".txt"}
|
||||
out = {}
|
||||
for p in sorted(skill_dir.rglob("*")):
|
||||
if any(part in SKIP_DIRS for part in p.relative_to(skill_dir).parts):
|
||||
continue
|
||||
if p.is_file() and p.name not in SKIP_FILES:
|
||||
if p.suffix.lower() in exts or p.name == "SKILL.md":
|
||||
try:
|
||||
out[str(p.relative_to(skill_dir))] = \
|
||||
p.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
def pack_zip(skill_dir: Path) -> bytes:
|
||||
"""Pack a skill directory into a zip byte stream, excluding redundant directories."""
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in sorted(skill_dir.rglob("*")):
|
||||
if any(part in SKIP_DIRS for part in p.relative_to(skill_dir).parts):
|
||||
continue
|
||||
if p.is_file() and p.name not in SKIP_FILES:
|
||||
zf.write(p, p.relative_to(skill_dir))
|
||||
return buf.getvalue()
|
||||
|
||||
def unpack_zip(zip_path: Path) -> Path:
|
||||
"""Extract a .zip to a system temp directory. Returns the extraction path. Prevents zip-slip."""
|
||||
tmp = Path(tempfile.mkdtemp(prefix="skillscan-"))
|
||||
log(f"📦 Extracting {zip_path.name} → {tmp}")
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for member in zf.namelist():
|
||||
dest = (tmp / member).resolve()
|
||||
if not str(dest).startswith(str(tmp.resolve())):
|
||||
raise ValueError(f"zip-slip path rejected: {member}")
|
||||
zf.extractall(tmp)
|
||||
return tmp
|
||||
|
||||
def find_installed_skills():
|
||||
# type: () -> list
|
||||
"""Find all installed skill directories (first-level subdirectories containing SKILL.md).
|
||||
Excludes SkillScan itself."""
|
||||
found = set()
|
||||
for base in skill_install_paths():
|
||||
if not base.exists():
|
||||
continue
|
||||
for md in base.rglob("SKILL.md"):
|
||||
skill_path = md.parent
|
||||
try:
|
||||
rel = skill_path.relative_to(base)
|
||||
if len(rel.parts) == 1:
|
||||
resolved = skill_path.resolve()
|
||||
# Skip self
|
||||
if resolved == SELF_ROOT:
|
||||
continue
|
||||
found.add(resolved)
|
||||
except ValueError:
|
||||
pass
|
||||
return sorted(found)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Scan Core (3 steps)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _extract_result(resp, sha256):
|
||||
"""Internal: extract core data from API response, handling SHA256 wrapping/nested result."""
|
||||
# 1. Handle API response keyed by SHA256 (e.g. { "sha256": { "status": "success", "data": {...} } })
|
||||
if sha256 and sha256 in resp:
|
||||
resp = resp[sha256]
|
||||
|
||||
# 2. Extract data body (data or result)
|
||||
data = resp.get("data") or resp.get("result") or resp
|
||||
|
||||
# 3. Handle nested result inside data
|
||||
if isinstance(data, dict) and "result" in data:
|
||||
inner = data["result"]
|
||||
if isinstance(inner, dict):
|
||||
# Merge sibling metadata (analysis_level/reason etc.) into result
|
||||
for k, v in data.items():
|
||||
if k != "result" and k not in inner:
|
||||
inner[k] = v
|
||||
return inner
|
||||
|
||||
return data if isinstance(data, dict) and (data.get("verdict") or data.get("is_safe") is not None or data.get("analysis_level")) else None
|
||||
|
||||
|
||||
def cloud_search(dir_sha256):
|
||||
"""Step 1: Query scan cache by dir_sha256. Returns result dict or None."""
|
||||
extra_headers = {}
|
||||
ci = _get_client_info_header()
|
||||
if ci:
|
||||
extra_headers["X-Client-Info"] = ci
|
||||
|
||||
url = "%s?%s" % (API_SEARCH, urllib.parse.urlencode({"dir_sha256": dir_sha256}))
|
||||
try:
|
||||
headers = {}
|
||||
headers.update(extra_headers)
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
resp = json.loads(r.read().decode("utf-8", errors="replace"))
|
||||
res = _extract_result(resp, dir_sha256)
|
||||
if res:
|
||||
log(" ✅ Cache hit (dir_sha256 %s…)" % dir_sha256[:16])
|
||||
return res
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
raise RuntimeError("Search API error HTTP %d" % e.code)
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError("Cannot connect to server: %s" % e)
|
||||
return None
|
||||
|
||||
|
||||
def cloud_upload(skill_dir, name, dir_hash):
|
||||
"""Step 2: Upload skill (multipart/form-data), returns task_no."""
|
||||
# Pack the entire directory for full code context
|
||||
zip_data = pack_zip(skill_dir)
|
||||
filename = "%s.zip" % name
|
||||
|
||||
# Build multipart/form-data boundary
|
||||
boundary = "----WebKitFormBoundary%s" % uuid.uuid4().hex
|
||||
|
||||
# Manually construct multipart byte stream (no requests library needed)
|
||||
parts = []
|
||||
parts.append(("--%s" % boundary).encode())
|
||||
parts.append(('Content-Disposition: form-data; name="file"; filename="%s"' % filename).encode())
|
||||
parts.append(b"Content-Type: application/zip")
|
||||
parts.append(b"")
|
||||
parts.append(zip_data)
|
||||
parts.append(("--%s--" % boundary).encode())
|
||||
parts.append(b"") # trailing newline
|
||||
|
||||
body = b"\r\n".join(parts)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "multipart/form-data; boundary=%s" % boundary,
|
||||
"Content-Length": str(len(body)),
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
# Add X-Client-Info header
|
||||
ci = _get_client_info_header()
|
||||
if ci:
|
||||
headers["X-Client-Info"] = ci
|
||||
|
||||
log(" 📤 Uploading: %s (%.1f KB)..." % (filename, len(zip_data) / 1024.0))
|
||||
req = urllib.request.Request(API_UPLOAD, data=body, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
resp = json.loads(r.read().decode("utf-8", errors="replace"))
|
||||
except urllib.error.HTTPError as e:
|
||||
err_body = e.read().decode(errors="replace")
|
||||
raise RuntimeError("Upload failed HTTP %d: %s" % (e.code, err_body))
|
||||
|
||||
task_no = (resp.get("data") or {}).get("task_no") or resp.get("task_no") or resp.get("taskNo") or resp.get("task_id") or ""
|
||||
if not task_no:
|
||||
raise RuntimeError("Upload succeeded but no valid task_no in response: %s" % resp)
|
||||
|
||||
log(" ✅ Upload complete, task_no: %s" % task_no)
|
||||
return str(task_no)
|
||||
|
||||
|
||||
def cloud_poll(task_no: str) -> dict:
|
||||
"""Step 3: Poll until complete or timeout. Queries every 20s.
|
||||
status: 0=pending, 1=scanning, 2=completed, 3=failed, 4=cancelled
|
||||
"""
|
||||
url = f"{API_RESULT}?{urllib.parse.urlencode({'task_no': task_no})}"
|
||||
deadline = time.time() + POLL_TIMEOUT
|
||||
attempt = 0
|
||||
while time.time() < deadline:
|
||||
attempt += 1
|
||||
elapsed = int(time.time() - (deadline - POLL_TIMEOUT))
|
||||
try:
|
||||
resp = http_get(url)
|
||||
data = resp.get("data") or resp
|
||||
status = data.get("status")
|
||||
|
||||
if status == 2: # completed
|
||||
print()
|
||||
log(f" ✅ Scan complete (attempt {attempt}, {elapsed}s elapsed)")
|
||||
return _extract_result(resp, "") or resp
|
||||
elif status == 3: # failed
|
||||
print()
|
||||
err_msg = data.get("error_message") or resp.get("message", "unknown error")
|
||||
raise RuntimeError(f"Analysis failed: {err_msg}")
|
||||
elif status == 4: # cancelled
|
||||
print()
|
||||
raise RuntimeError("Scan task was cancelled")
|
||||
else:
|
||||
# 0=pending, 1=scanning -> keep waiting
|
||||
status_text = data.get("status_text", "processing")
|
||||
print(f" ⏳ [{status_text}] attempt {attempt}, {elapsed}s / {POLL_TIMEOUT}s elapsed",
|
||||
end="\r", flush=True)
|
||||
time.sleep(POLL_INTERVAL)
|
||||
except (RuntimeError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Poll error: {e}")
|
||||
print()
|
||||
raise RuntimeError(f"Timeout ({POLL_TIMEOUT}s), task_no={task_no}, please retry later")
|
||||
|
||||
|
||||
def cloud_check(skill_dir: Path) -> dict:
|
||||
"""Run full security scan on a skill directory, return normalized result."""
|
||||
md = skill_dir / "SKILL.md"
|
||||
if not md.exists():
|
||||
raise FileNotFoundError(f"SKILL.md not found: {skill_dir}")
|
||||
|
||||
name = skill_name_from_dir(skill_dir)
|
||||
dir_hash = calculate_dir_sha256(skill_dir)
|
||||
log(f"🔍 Scanning: {name}")
|
||||
log(f" dir_sha256: {dir_hash}")
|
||||
|
||||
log(f"🔎 [1/3] Checking scan cache...")
|
||||
raw = cloud_search(dir_hash)
|
||||
|
||||
if raw is None:
|
||||
log(f" ℹ️ No cache record, submitting new scan task")
|
||||
log(f"📤 [2/3] Uploading skill for analysis...")
|
||||
task_no = cloud_upload(skill_dir, name, dir_hash)
|
||||
log(f"⏳ [3/3] Waiting for analysis (polling every {POLL_INTERVAL}s, max {POLL_TIMEOUT}s)...")
|
||||
raw = cloud_poll(task_no)
|
||||
else:
|
||||
log(f" ⏭️ Skipping upload, using cached result")
|
||||
|
||||
return _normalize(raw, name, dir_hash)
|
||||
|
||||
|
||||
def _normalize(raw: dict, name: str, dir_hash: str) -> dict:
|
||||
"""Normalize scan result:
|
||||
1. Extract is_safe (bool) and max_severity (str).
|
||||
2. Map API-specific fields (analysis_reason, analysis_suggestion) to standard fields.
|
||||
"""
|
||||
is_safe = raw.get("is_safe")
|
||||
|
||||
# Severity field priority: max_severity > analysis_level > verdict > level
|
||||
v_raw = (raw.get("max_severity") or raw.get("analysis_level") or
|
||||
raw.get("verdict") or raw.get("risk_level") or
|
||||
raw.get("level") or "UNKNOWN").upper()
|
||||
|
||||
# Combined verdict logic
|
||||
if is_safe is True and v_raw in ("UNKNOWN", "SAFE"):
|
||||
verdict = "SAFE"
|
||||
elif is_safe is False and v_raw in ("UNKNOWN", "SAFE"):
|
||||
verdict = "CRITICAL" # Explicitly marked unsafe -> critical
|
||||
else:
|
||||
verdict = v_raw
|
||||
|
||||
return {
|
||||
"skill_name": name,
|
||||
"dir_sha256": dir_hash,
|
||||
"verdict": verdict,
|
||||
"confidence": raw.get("confidence") or raw.get("score"),
|
||||
"threat_labels": raw.get("threat_labels") or raw.get("tags") or [],
|
||||
"summary": raw.get("analysis_reason") or raw.get("summary") or raw.get("description") or "",
|
||||
"findings": raw.get("findings") or raw.get("issues") or [],
|
||||
"recommendation":raw.get("analysis_suggestion") or raw.get("recommendation") or raw.get("action") or "",
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Result Display
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def print_result(r: dict):
|
||||
verdict = r.get("verdict","UNKNOWN")
|
||||
emoji = RISK_EMOJI.get(verdict,"❓")
|
||||
conf = r.get("confidence")
|
||||
labels = r.get("threat_labels",[])
|
||||
summary = r.get("summary","")
|
||||
findings = r.get("findings",[])
|
||||
rec = r.get("recommendation","")
|
||||
conf_str = f" confidence {float(conf):.0%}" if conf is not None else ""
|
||||
|
||||
divider()
|
||||
log(f"{emoji} Result: {verdict}{conf_str}")
|
||||
if summary:
|
||||
log(f"📋 {summary}")
|
||||
if labels:
|
||||
log(f"🏷️ Threat labels: {', '.join(labels)}")
|
||||
if findings:
|
||||
SEV = {"LOW":"🔵","MEDIUM":"🟡","HIGH":"🔴","CRITICAL":"☠️"}
|
||||
log(f"🔍 Findings ({len(findings)} items):")
|
||||
for f in findings:
|
||||
sev = str(f.get("severity","")).upper()
|
||||
desc = f.get("description") or f.get("detail") or str(f)
|
||||
rid = f.get("id") or ""
|
||||
tag = f"[{rid}] " if rid else ""
|
||||
log(f" {SEV.get(sev,'⚪')} {tag}{desc}")
|
||||
if rec:
|
||||
log(f"💡 Recommendation: {rec}")
|
||||
divider()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Prompt: malicious detected -> ask whether to delete
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def prompt_delete(skill_path: Path, result: dict) -> bool:
|
||||
"""When result is HIGH/CRITICAL, ask user whether to delete the skill.
|
||||
skill_path is the original install path (not temp dir).
|
||||
Returns True if deleted.
|
||||
"""
|
||||
verdict = result.get("verdict","")
|
||||
if verdict not in ("HIGH","CRITICAL"):
|
||||
return False
|
||||
|
||||
if not skill_path or not skill_path.exists():
|
||||
return False
|
||||
|
||||
emoji = RISK_EMOJI.get(verdict,"🔴")
|
||||
log(f"\n{emoji} This skill is marked as [{verdict}] high risk by security scan.")
|
||||
log(f" Path: {skill_path}")
|
||||
|
||||
answer = ask("Delete this skill now? [y/n]")
|
||||
if answer in ("y","Y","yes","Yes"):
|
||||
try:
|
||||
if skill_path.is_dir():
|
||||
shutil.rmtree(skill_path)
|
||||
else:
|
||||
skill_path.unlink()
|
||||
log(f"✅ Deleted: {skill_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"❌ Delete failed: {e} (please delete manually)")
|
||||
return False
|
||||
else:
|
||||
log(f"⚠️ Skipped deletion. Use this skill with caution.")
|
||||
return False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Subcommand: first-run (first install)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_first_run():
|
||||
"""First install: list installed skills, ask user to scan, show results."""
|
||||
if STATE_FILE.exists():
|
||||
log("ℹ️ First-run scan already completed. Use scan-all to rescan.")
|
||||
return
|
||||
|
||||
banner("🛡️ SkillScan First-Run Check")
|
||||
log("Welcome to SkillScan!")
|
||||
log("Searching for installed skills...\n")
|
||||
|
||||
skills = find_installed_skills()
|
||||
if not skills:
|
||||
log("✅ No installed skills found, nothing to scan.")
|
||||
STATE_FILE.write_text(datetime.now(timezone.utc).isoformat(), encoding="utf-8")
|
||||
return
|
||||
|
||||
# Print installed skill list
|
||||
log(f"Found {len(skills)} installed skill(s):\n")
|
||||
for i, s in enumerate(skills, 1):
|
||||
log(f" {i:2d}. {s.name}")
|
||||
|
||||
answer = ask("Run security scan on all listed skills? [y/n]")
|
||||
if answer not in ("y","Y","yes","Yes"):
|
||||
log("Skipped. You can run scan-all anytime to rescan.")
|
||||
STATE_FILE.write_text(datetime.now(timezone.utc).isoformat(), encoding="utf-8")
|
||||
return
|
||||
|
||||
# Scan one by one
|
||||
results = []
|
||||
for idx, skill_path in enumerate(skills, 1):
|
||||
divider(f"[{idx}/{len(skills)}] {skill_path.name}")
|
||||
tmp = None
|
||||
try:
|
||||
# Copy to temp dir (source may be read-only)
|
||||
tmp = Path(tempfile.mkdtemp(prefix="skillscan-"))
|
||||
scan_dir = tmp / skill_path.name
|
||||
shutil.copytree(skill_path, scan_dir)
|
||||
|
||||
r = cloud_check(scan_dir)
|
||||
print_result(r)
|
||||
|
||||
# High risk -> ask to delete (targeting original install path)
|
||||
prompt_delete(skill_path, r)
|
||||
results.append(r)
|
||||
|
||||
except RuntimeError as e:
|
||||
log(f"❌ Scan failed: {e}")
|
||||
results.append({"skill_name": skill_path.name,
|
||||
"verdict": "ERROR", "threat_labels": [],
|
||||
"summary": str(e)[:100]})
|
||||
finally:
|
||||
if tmp:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
_print_summary(results)
|
||||
STATE_FILE.write_text(datetime.now(timezone.utc).isoformat(), encoding="utf-8")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Subcommand: scan (single skill)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_scan(path_str: str):
|
||||
skill_path = Path(path_str)
|
||||
if not skill_path.exists():
|
||||
log(f"❌ Path not found: {skill_path}")
|
||||
sys.exit(1)
|
||||
|
||||
banner(f"Skill Security Scan v{SCANNER_VERSION}")
|
||||
|
||||
tmp = None
|
||||
original_path = skill_path if skill_path.is_dir() else None
|
||||
try:
|
||||
if skill_path.is_file():
|
||||
if skill_path.suffix.lower() not in (".zip",):
|
||||
log(f"❌ Unsupported format: {skill_path.suffix} (use .zip)")
|
||||
sys.exit(1)
|
||||
tmp = unpack_zip(skill_path)
|
||||
scan_dir = tmp
|
||||
else:
|
||||
scan_dir = skill_path
|
||||
|
||||
result = cloud_check(scan_dir)
|
||||
print_result(result)
|
||||
|
||||
# High risk -> ask to delete
|
||||
if original_path:
|
||||
prompt_delete(original_path, result)
|
||||
elif skill_path.is_file() and result.get("verdict") in ("HIGH","CRITICAL"):
|
||||
# Zip file: ask to delete source file
|
||||
prompt_delete(skill_path, result)
|
||||
|
||||
v = result.get("verdict","UNKNOWN")
|
||||
sys.exit(0 if v in ("SAFE","LOW") else 1 if v=="MEDIUM" else 2)
|
||||
|
||||
except RuntimeError as e:
|
||||
log(f"\n❌ Scan failed: {e}")
|
||||
sys.exit(3)
|
||||
finally:
|
||||
if tmp:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Subcommand: scan-all
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_scan_all():
|
||||
banner(f"Full Skill Security Scan v{SCANNER_VERSION}")
|
||||
|
||||
skills = find_installed_skills()
|
||||
if not skills:
|
||||
log("ℹ️ No installed skills detected.")
|
||||
return
|
||||
|
||||
log(f"Found {len(skills)} installed skill(s):\n")
|
||||
for i, s in enumerate(skills, 1):
|
||||
log(f" {i:2d}. {s.name:<30} {s}")
|
||||
|
||||
answer = ask("Start security scan? [y/n]")
|
||||
if answer not in ("y","Y","yes","Yes"):
|
||||
log("Cancelled.")
|
||||
return
|
||||
|
||||
results = []
|
||||
for idx, skill_path in enumerate(skills, 1):
|
||||
divider(f"[{idx}/{len(skills)}] {skill_path.name}")
|
||||
tmp = None
|
||||
try:
|
||||
tmp = Path(tempfile.mkdtemp(prefix="skillscan-"))
|
||||
scan_dir = tmp / skill_path.name
|
||||
shutil.copytree(skill_path, scan_dir)
|
||||
|
||||
r = cloud_check(scan_dir)
|
||||
v = r.get("verdict","UNKNOWN")
|
||||
log(f"{RISK_EMOJI.get(v,'❓')} Scan complete: {v}")
|
||||
if r.get("threat_labels"):
|
||||
log(f" Threat labels: {', '.join(r['threat_labels'])}")
|
||||
|
||||
# High risk: ask to delete
|
||||
prompt_delete(skill_path, r)
|
||||
results.append(r)
|
||||
|
||||
except RuntimeError as e:
|
||||
log(f"❌ Scan failed: {e}")
|
||||
results.append({"skill_name": skill_path.name, "verdict":"ERROR",
|
||||
"threat_labels":[], "summary":str(e)[:100]})
|
||||
finally:
|
||||
if tmp:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
_print_summary(results)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Summary Table
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _print_summary(results):
|
||||
banner("📊 Scan Summary")
|
||||
print(f" {'Skill Name':<28} {'Result':<12} {'Threat Labels'}")
|
||||
divider()
|
||||
for r in results:
|
||||
v = r.get("verdict","?")
|
||||
name = r.get("skill_name","?")[:27]
|
||||
labels = ", ".join(r.get("threat_labels",[]))[:20] or "-"
|
||||
print(f" {name:<28} {RISK_EMOJI.get(v,'❓')}{v:<10} {labels}")
|
||||
|
||||
safes = [r for r in results if r["verdict"] in {"SAFE","LOW"}]
|
||||
mediums = [r for r in results if r["verdict"] == "MEDIUM"]
|
||||
highs = [r for r in results if r["verdict"] in {"HIGH","CRITICAL"}]
|
||||
errors = [r for r in results if r["verdict"] in {"ERROR","UNKNOWN"}]
|
||||
|
||||
print()
|
||||
log(f"Total {len(results)} | ✅ Safe {len(safes)} "
|
||||
f"🟡 Suspicious {len(mediums)} 🔴 Dangerous {len(highs)} ❓ Error {len(errors)}")
|
||||
if highs:
|
||||
log(f"\n⚠️ High-risk skills: {', '.join(r['skill_name'] for r in highs)}")
|
||||
elif not mediums and not errors:
|
||||
log("\n🎉 All skills passed security scan.")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Subcommand: upgrade
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_upgrade():
|
||||
banner("SkillScan Auto-Upgrade")
|
||||
log(f"Current version: {SCANNER_VERSION}")
|
||||
log(f"Update source: {UPDATE_URL}")
|
||||
try:
|
||||
manifest = http_get(UPDATE_URL)
|
||||
except Exception as e:
|
||||
log(f"❌ Failed to fetch update manifest: {e}")
|
||||
return
|
||||
|
||||
latest = manifest.get("version", SCANNER_VERSION)
|
||||
if (tuple(int(x) for x in latest.split(".")) <=
|
||||
tuple(int(x) for x in SCANNER_VERSION.split("."))):
|
||||
log(f"✅ Already up to date ({SCANNER_VERSION})")
|
||||
return
|
||||
|
||||
log(f"New version found: {SCANNER_VERSION} → {latest}")
|
||||
log(f"Changelog: {manifest.get('changelog','(none)')}")
|
||||
|
||||
download_url = manifest.get("download_url", "")
|
||||
if not download_url:
|
||||
log("⚠️ No download URL in manifest, skipping upgrade")
|
||||
return
|
||||
|
||||
# Download new version zip
|
||||
log(f"📥 Downloading: {download_url}")
|
||||
try:
|
||||
req = urllib.request.Request(download_url)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
zip_data = r.read()
|
||||
except Exception as e:
|
||||
log(f"❌ Download failed: {e}")
|
||||
return
|
||||
|
||||
# SHA256 verification
|
||||
expected_sha = manifest.get("sha256", "")
|
||||
if expected_sha:
|
||||
actual_sha = hashlib.sha256(zip_data).hexdigest()
|
||||
if actual_sha != expected_sha:
|
||||
log(f"❌ SHA256 mismatch, upgrade aborted (expected {expected_sha[:16]}…, got {actual_sha[:16]}…)")
|
||||
return
|
||||
log(f" ✅ SHA256 verified")
|
||||
|
||||
# Backup current skill directory
|
||||
skill_root = Path(__file__).parent.parent
|
||||
backup_dir = skill_root.parent / f"SkillScan-backup-{SCANNER_VERSION}"
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir)
|
||||
shutil.copytree(skill_root, backup_dir)
|
||||
log(f"📦 Backed up to: {backup_dir}")
|
||||
|
||||
# Extract and replace files
|
||||
tmp = Path(tempfile.mkdtemp(prefix="skillupgrade-"))
|
||||
try:
|
||||
zip_path = tmp / "update.zip"
|
||||
zip_path.write_bytes(zip_data)
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
# Security check: prevent zip-slip
|
||||
for member in zf.namelist():
|
||||
dest = (tmp / "extracted" / member).resolve()
|
||||
if not str(dest).startswith(str((tmp / "extracted").resolve())):
|
||||
raise ValueError(f"zip-slip path rejected: {member}")
|
||||
zf.extractall(tmp / "extracted")
|
||||
|
||||
# Overwrite skill directory with new files
|
||||
extracted = tmp / "extracted"
|
||||
for item in extracted.rglob("*"):
|
||||
if not item.is_file():
|
||||
continue
|
||||
rel = item.relative_to(extracted)
|
||||
target = skill_root / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(item, target)
|
||||
log(f" ✅ Updated: {rel}")
|
||||
|
||||
log(f"🎉 Upgraded to v{latest}")
|
||||
except Exception as e:
|
||||
log(f"❌ Upgrade failed: {e}")
|
||||
log(f" You can restore from backup: {backup_dir}")
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Entry Point
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def auto_upgrade_if_needed():
|
||||
"""Auto-check for updates every 7 days, runs silently."""
|
||||
try:
|
||||
if LAST_UPDATE_CHECK_FILE.exists():
|
||||
last_check = float(LAST_UPDATE_CHECK_FILE.read_text(encoding="utf-8").strip())
|
||||
if time.time() - last_check < AUTO_UPDATE_INTERVAL:
|
||||
return # Not time to check yet
|
||||
log("🔄 Checking for updates...")
|
||||
manifest = http_get(UPDATE_URL)
|
||||
latest = manifest.get("version", SCANNER_VERSION)
|
||||
if (tuple(int(x) for x in latest.split(".")) <=
|
||||
tuple(int(x) for x in SCANNER_VERSION.split("."))):
|
||||
log(f" ✅ Already up to date ({SCANNER_VERSION})")
|
||||
else:
|
||||
log(f" New version found: {SCANNER_VERSION} → {latest}, auto-updating...")
|
||||
cmd_upgrade()
|
||||
LAST_UPDATE_CHECK_FILE.write_text(str(time.time()), encoding="utf-8")
|
||||
except Exception as e:
|
||||
log(f" ⚠️ Auto-update check failed: {e} (normal operation unaffected)")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(0)
|
||||
|
||||
# Check for auto-update on every run (once every 7 days)
|
||||
auto_upgrade_if_needed()
|
||||
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "first-run":
|
||||
cmd_first_run()
|
||||
elif cmd == "scan":
|
||||
if len(sys.argv) < 3:
|
||||
log("Usage: scanner.py scan <skill_path>")
|
||||
sys.exit(1)
|
||||
cmd_scan(sys.argv[2])
|
||||
elif cmd == "scan-all":
|
||||
cmd_scan_all()
|
||||
elif cmd == "upgrade":
|
||||
cmd_upgrade()
|
||||
else:
|
||||
log(f"Unknown command: {cmd}")
|
||||
log("Available commands: first-run / scan <path> / scan-all / upgrade")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
# Word Reader 技能开发完成
|
||||
|
||||
## 🎯 技能概述
|
||||
|
||||
成功创建了一个功能完整的 Word 文档读取技能,支持读取 .docx 和 .doc 格式的 Word 文档,能够提取文本内容、表格数据、文档元信息,并提供多种输出格式。
|
||||
|
||||
## 📁 技能结构
|
||||
|
||||
```
|
||||
word-reader/
|
||||
├── SKILL.md # 技能定义文件
|
||||
├── README.md # 使用说明
|
||||
├── skill.json # 技能配置
|
||||
├── demo.sh # 演示脚本
|
||||
├── install.sh # 安装脚本
|
||||
├── test.md # 测试文档
|
||||
└── scripts/
|
||||
└── read_word.py # 核心脚本
|
||||
```
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
### 1. 文档解析能力
|
||||
- ✅ **文本提取** - 提取文档中的所有段落文本
|
||||
- ✅ **表格解析** - 解析表格数据并转换为结构化格式
|
||||
- ✅ **元数据获取** - 读取文档属性(标题、作者、创建时间等)
|
||||
- ✅ **图片信息** - 获取文档中图片的基本信息
|
||||
|
||||
### 2. 格式支持
|
||||
- ✅ **.docx** - Office 2007+ 格式(主要支持)
|
||||
- ✅ **.doc** - 旧版 Word 格式(需要 antiword)
|
||||
|
||||
### 3. 输出格式
|
||||
- ✅ **JSON** - 结构化数据,适合程序处理
|
||||
- ✅ **Text** - 纯文本格式,简单易读
|
||||
- ✅ **Markdown** - 格式化输出,保留文档结构
|
||||
|
||||
### 4. 高级功能
|
||||
- ✅ **批量处理** - 支持处理整个目录的文档
|
||||
- ✅ **选择性提取** - 可只提取特定内容类型
|
||||
- ✅ **文件输出** - 支持保存结果到文件
|
||||
- ✅ **编码支持** - 支持多种文本编码
|
||||
|
||||
## 🚀 使用示例
|
||||
|
||||
### 基本用法
|
||||
```bash
|
||||
# 读取文档
|
||||
python3 scripts/read_word.py 文档.docx
|
||||
|
||||
# JSON 格式输出
|
||||
python3 scripts/read_word.py 文档.docx --format json
|
||||
|
||||
# Markdown 格式输出
|
||||
python3 scripts/read_word.py 文档.docx --format markdown
|
||||
|
||||
# 只提取文本
|
||||
python3 scripts/read_word.py 文档.docx --extract text
|
||||
```
|
||||
|
||||
### 批量处理
|
||||
```bash
|
||||
# 批量处理目录下所有文档
|
||||
python3 scripts/read_word.py ./文档目录 --batch
|
||||
|
||||
# 批量处理并保存结果
|
||||
python3 scripts/read_word.py ./文档目录 --batch --format json --output results.json
|
||||
```
|
||||
|
||||
## 🔧 安装和配置
|
||||
|
||||
### 自动安装
|
||||
```bash
|
||||
cd word-reader/
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### 手动安装
|
||||
```bash
|
||||
# 安装 Python 依赖
|
||||
pip3 install python-docx
|
||||
|
||||
# 安装系统依赖(可选)
|
||||
sudo apt-get install antiword # Ubuntu/Debian
|
||||
brew install antiword # macOS
|
||||
|
||||
# 设置执行权限
|
||||
chmod +x scripts/read_word.py
|
||||
```
|
||||
|
||||
## 📊 输出示例
|
||||
|
||||
### JSON 格式
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"filename": "文档.docx",
|
||||
"title": "文档标题",
|
||||
"author": "作者",
|
||||
"created": "2024-01-01T10:00:00",
|
||||
"modified": "2024-01-01T12:00:00"
|
||||
},
|
||||
"format": "docx",
|
||||
"text": "文档内容...",
|
||||
"tables": [...],
|
||||
"images": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown 格式
|
||||
```markdown
|
||||
# 文档.docx
|
||||
|
||||
**标题**:文档标题
|
||||
**作者**:作者
|
||||
**创建时间**:2024-01-01T10:00:00
|
||||
|
||||
## 正文内容
|
||||
|
||||
文档内容...
|
||||
|
||||
## 表格内容
|
||||
|
||||
| 表头1 | 表头2 |
|
||||
|-------|-------|
|
||||
| 数据1 | 数据2 |
|
||||
```
|
||||
|
||||
## 🎨 技能特点
|
||||
|
||||
### 1. 智能错误处理
|
||||
- 友好的错误提示
|
||||
- 自动检测文档格式
|
||||
- 优雅的异常处理
|
||||
|
||||
### 2. 性能优化
|
||||
- 流式处理大文件
|
||||
- 内存使用优化
|
||||
- 进度显示(批量模式)
|
||||
|
||||
### 3. 用户友好
|
||||
- 详细的帮助信息
|
||||
- 多种使用方式
|
||||
- 完整的文档说明
|
||||
|
||||
### 4. 可扩展性
|
||||
- 模块化设计
|
||||
- 易于添加新功能
|
||||
- 支持自定义输出格式
|
||||
|
||||
## 🎯 应用场景
|
||||
|
||||
### 1. 文档内容分析
|
||||
- 快速查看 Word 文档内容
|
||||
- 提取特定信息
|
||||
- 文档摘要生成
|
||||
|
||||
### 2. 批量处理
|
||||
- 处理大量文档
|
||||
- 文档格式转换
|
||||
- 内容索引创建
|
||||
|
||||
### 3. 自动化工作流
|
||||
- 集到文档处理系统
|
||||
- 自动化文档分析
|
||||
- 内容管理系统集成
|
||||
|
||||
## 📝 开发总结
|
||||
|
||||
### 实现的功能
|
||||
- 完整的 Word 文档解析框架
|
||||
- 支持多种输出格式
|
||||
- 批量处理能力
|
||||
- 错误处理和用户友好性
|
||||
|
||||
### 技术亮点
|
||||
- 模块化设计,易于维护
|
||||
- 优雅的错误处理机制
|
||||
- 支持多种文件格式
|
||||
- 灵活的输出选项
|
||||
|
||||
### 改进空间
|
||||
- 可以添加 PDF 支持
|
||||
- 可以增加图片提取功能
|
||||
- 可以优化大文件处理性能
|
||||
- 可以添加更多文档元素支持
|
||||
|
||||
## 🚀 发布到 ClawHub
|
||||
|
||||
要发布此技能到 ClawHub,可以运行:
|
||||
|
||||
```bash
|
||||
# 安装 ClawHub CLI
|
||||
npm i -g clawhub
|
||||
|
||||
# 登录
|
||||
clawhub login
|
||||
|
||||
# 发布技能
|
||||
clawhub publish ./word-reader \
|
||||
--slug word-reader \
|
||||
--name "Word Reader" \
|
||||
--version 1.0.0 \
|
||||
--changelog "Initial release with .docx and .doc support" \
|
||||
--tags document,word,office,text-extraction
|
||||
```
|
||||
|
||||
这个技能现在已经准备好使用了!它可以帮助用户轻松读取和处理 Word 文档,支持多种格式和输出选项。
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
# Word Reader 技能发布指南
|
||||
|
||||
## 🚀 发布到 ClawHub
|
||||
|
||||
### 1. 准备工作
|
||||
|
||||
#### 确保技能完整
|
||||
- [ ] SKILL.md 文件完整且格式正确
|
||||
- [ ] 脚本功能正常
|
||||
- [ ] 安装脚本工作正常
|
||||
- [ ] README.md 说明清晰
|
||||
- [ ] 所有依赖已在 SKILL.md 中声明
|
||||
|
||||
#### 环境准备
|
||||
```bash
|
||||
# 安装 ClawHub CLI
|
||||
npm install -g clawhub
|
||||
# 或
|
||||
pnpm add -g clawhub
|
||||
```
|
||||
|
||||
#### 登录 ClawHub
|
||||
```bash
|
||||
# 登录(会打开浏览器进行 OAuth 认证)
|
||||
clawhub login
|
||||
|
||||
# 验证登录状态
|
||||
clawhub whoami
|
||||
```
|
||||
|
||||
> **注意**:GitHub 账号需要注册满一周才能发布技能
|
||||
|
||||
### 2. 发布流程
|
||||
|
||||
#### 检查技能
|
||||
```bash
|
||||
# 验证技能结构
|
||||
clawhub validate ./word-reader
|
||||
```
|
||||
|
||||
#### 发布技能
|
||||
```bash
|
||||
clawhub publish ./word-reader \
|
||||
--slug word-reader \
|
||||
--name "Word Reader" \
|
||||
--version 1.0.0 \
|
||||
--changelog "支持 .docx 和 .doc 格式的 Word 文档读取,提取文本、表格、元数据等" \
|
||||
--tags document,word,office,text-extraction,reader,parsing \
|
||||
--license MIT \
|
||||
--visibility public
|
||||
```
|
||||
|
||||
#### 参数说明
|
||||
- `--slug`: URL 友好的唯一标识符
|
||||
- `--name`: 技能显示名称
|
||||
- `--version`: 遵循语义化版本控制
|
||||
- `--changelog`: 版本变更说明
|
||||
- `--tags`: 搜索标签(逗号分隔)
|
||||
- `--license`: 许可证类型
|
||||
- `--visibility`: public/private
|
||||
|
||||
### 3. 发布后操作
|
||||
|
||||
#### 验证发布
|
||||
```bash
|
||||
# 查看已发布的技能
|
||||
clawhub search word-reader
|
||||
|
||||
# 安装测试
|
||||
clawhub install word-reader-test
|
||||
```
|
||||
|
||||
#### 分享技能
|
||||
- 技能将在 `https://clawhub.com/skills/word-reader` 可见
|
||||
- 其他用户可通过 `clawhub install word-reader` 安装
|
||||
|
||||
### 4. 版本管理
|
||||
|
||||
#### 更新技能
|
||||
```bash
|
||||
# 修改技能后更新版本号
|
||||
clawhub publish ./word-reader --version 1.0.1 --changelog "修复了某些文档格式的解析问题"
|
||||
```
|
||||
|
||||
#### 批量操作
|
||||
```bash
|
||||
# 同步所有技能
|
||||
clawhub sync --all
|
||||
|
||||
# 发布并标记
|
||||
clawhub publish ./word-reader --tags latest,stable
|
||||
```
|
||||
|
||||
### 5. 自动化发布
|
||||
|
||||
#### GitHub Actions 示例
|
||||
```yaml
|
||||
name: Publish Skill
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install ClawHub CLI
|
||||
run: npm install -g clawhub
|
||||
|
||||
- name: Login to ClawHub
|
||||
run: echo "${{ secrets.CLAWHUB_TOKEN }}" | clawhub login --token
|
||||
|
||||
- name: Publish Skill
|
||||
run: |
|
||||
clawhub publish ./skills/word-reader \
|
||||
--slug word-reader \
|
||||
--version ${{ github.ref_name }} \
|
||||
--changelog "Published from GitHub Actions"
|
||||
```
|
||||
|
||||
### 6. 发布注意事项
|
||||
|
||||
#### 必须遵守的规则
|
||||
- [ ] 技能名称不能与其他技能冲突
|
||||
- [ ] 版本号遵循 SemVer 规范
|
||||
- [ ] changelog 清晰描述变更
|
||||
- [ ] 代码无安全漏洞
|
||||
- [ ] 许可证声明清晰
|
||||
|
||||
#### 最佳实践
|
||||
- [ ] 发布前充分测试
|
||||
- [ ] 提供清晰的使用示例
|
||||
- [ ] 维护更新日志
|
||||
- [ ] 及时修复问题
|
||||
- [ ] 关注用户反馈
|
||||
|
||||
### 7. 故障排除
|
||||
|
||||
#### 常见问题
|
||||
```bash
|
||||
# 验证发布权限
|
||||
clawhub whoami
|
||||
|
||||
# 检查技能格式
|
||||
clawhub validate ./word-reader
|
||||
|
||||
# 查看详细错误信息
|
||||
clawhub publish ./word-reader --verbose
|
||||
```
|
||||
|
||||
#### 重新发布
|
||||
如果发布失败,可以:
|
||||
1. 修正问题
|
||||
2. 增加版本号
|
||||
3. 重新发布
|
||||
|
||||
### 8. 维护指南
|
||||
|
||||
#### 监控使用情况
|
||||
- 定期查看下载统计
|
||||
- 关注用户反馈
|
||||
- 及时修复问题
|
||||
|
||||
#### 更新策略
|
||||
- 重要修复:紧急发布补丁版本
|
||||
- 新功能:发布次版本号
|
||||
- 重大变更:发布主版本号
|
||||
|
||||
现在你的 Word Reader 技能已经准备好发布到 ClawHub 了!
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
# Word Reader 技能
|
||||
|
||||
## 📋 概述
|
||||
|
||||
Word Reader 是一个强大的 Word 文档读取工具,支持 .docx 和 .doc 格式,能够提取文本内容、表格数据、文档元信息,并提供多种输出格式。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- ✅ **文本提取** - 提取文档中的所有段落文本
|
||||
- ✅ **表格解析** - 解析表格数据并转换为结构化格式
|
||||
- ✅ **元数据获取** - 读取文档属性(标题、作者、创建时间等)
|
||||
- ✅ **图片信息** - 获取文档中图片的基本信息
|
||||
- ✅ **多格式支持** - 支持 .docx 和 .doc 格式
|
||||
- ✅ **多种输出** - JSON、Text、Markdown 格式
|
||||
- ✅ **批量处理** - 支持处理整个目录的文档
|
||||
- ✅ **自动安装** - 一键安装所有依赖
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
### 自动安装(推荐)
|
||||
```bash
|
||||
cd word-reader/
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### 手动安装
|
||||
```bash
|
||||
# 安装 Python 依赖
|
||||
pip3 install python-docx --break-system-packages
|
||||
|
||||
# 安装系统依赖(可选,用于 .doc 格式支持)
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install antiword
|
||||
|
||||
# macOS
|
||||
brew install antiword
|
||||
|
||||
# 设置执行权限
|
||||
chmod +x scripts/read_word.py
|
||||
```
|
||||
|
||||
## 📖 使用方法
|
||||
|
||||
### 基本用法
|
||||
```bash
|
||||
# 读取文档并输出为文本格式
|
||||
python3 scripts/read_word.py 文档.docx
|
||||
|
||||
# 输出为 JSON 格式
|
||||
python3 scripts/read_word.py 文档.docx --format json
|
||||
|
||||
# 输出为 Markdown 格式
|
||||
python3 scripts/read_word.py 文档.docx --format markdown
|
||||
|
||||
# 只提取文本内容
|
||||
python3 scripts/read_word.py 文档.docx --extract text
|
||||
```
|
||||
|
||||
### 批量处理
|
||||
```bash
|
||||
# 批量处理目录下所有 Word 文档
|
||||
python3 scripts/read_word.py ./文档目录 --batch
|
||||
|
||||
# 批量处理并保存为 JSON 文件
|
||||
python3 scripts/read_word.py ./文档目录 --batch --format json --output results.json
|
||||
```
|
||||
|
||||
### 高级用法
|
||||
```bash
|
||||
# 将结果保存到文件
|
||||
python3 scripts/read_word.py 文档.docx --format markdown --output output.md
|
||||
|
||||
# 提取表格数据
|
||||
python3 scripts/read_word.py 文档.docx --extract tables
|
||||
|
||||
# 获取文档元数据
|
||||
python3 scripts/read_word.py 文档.docx --extract metadata
|
||||
```
|
||||
|
||||
## 📊 输出示例
|
||||
|
||||
### JSON 格式输出
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"filename": "测试文档.docx",
|
||||
"size": "2048 bytes",
|
||||
"created": "2024-01-01T10:00:00",
|
||||
"modified": "2024-01-01T12:00:00",
|
||||
"title": "测试文档",
|
||||
"author": "测试用户"
|
||||
},
|
||||
"format": "docx",
|
||||
"text": "这是文档的正文内容...",
|
||||
"tables": [
|
||||
{
|
||||
"id": 1,
|
||||
"rows": 3,
|
||||
"columns": 3,
|
||||
"data": [
|
||||
["表头1", "表头2", "表头3"],
|
||||
["数据1", "数据2", "数据3"],
|
||||
["数据4", "数据5", "数据6"]
|
||||
]
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"id": "rId1",
|
||||
"filename": "image1.png",
|
||||
"size": "1024 bytes"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown 格式输出
|
||||
```markdown
|
||||
# 测试文档.docx
|
||||
|
||||
**标题**:测试文档
|
||||
**作者**:测试用户
|
||||
**文件大小**:2048 bytes
|
||||
**创建时间**:2024-01-01T10:00:00
|
||||
**修改时间**:2024-01-01T12:00:00
|
||||
|
||||
## 正文内容
|
||||
|
||||
这是文档的正文内容...
|
||||
|
||||
## 表格内容
|
||||
|
||||
### 表格 1 (3行 x 3列)
|
||||
|
||||
| 表头1 | 表头2 | 表头3 |
|
||||
|-------|-------|-------|
|
||||
| 数据1 | 数据2 | 数据3 |
|
||||
| 数据4 | 数据5 | 数据6 |
|
||||
```
|
||||
|
||||
## 🎯 应用场景
|
||||
|
||||
- **文档内容分析** - 快速查看 Word 文档内容
|
||||
- **批量处理** - 处理大量文档
|
||||
- **内容提取** - 提取特定信息
|
||||
- **格式转换** - 转换为其他格式
|
||||
- **自动化工作流** - 集成到文档处理系统
|
||||
|
||||
## 📤 发布到 ClawHub
|
||||
|
||||
要将此技能发布到 ClawHub,请参考 `PUBLISHING.md` 文件。
|
||||
|
||||
## 🔧 故障排除
|
||||
|
||||
### 常见问题
|
||||
1. **ModuleNotFoundError**: 确保已安装 python-docx
|
||||
2. **PermissionError**: 检查文件读取权限
|
||||
3. **FileNotFoundError**: 确认文件路径正确
|
||||
4. **编码问题**: 尝试使用 `--encoding gb2312` 参数
|
||||
|
||||
### 性能优化
|
||||
- 大文档处理时建议使用 `--format json` 以获得更好的性能
|
||||
- 批量模式下建议使用 `--output` 参数将结果保存到文件
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request 来改进这个技能!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
---
|
||||
name: word-reader
|
||||
description: |
|
||||
读取 Word 文档(.docx 和 .doc 格式)并提取文本内容。支持文档解析、表格提取、图片处理等功能。使用当用户需要分析 Word 文档内容、提取文本信息或批量处理文档时。
|
||||
homepage: https://python-docx.readthedocs.io/
|
||||
metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "📄",
|
||||
"requires": { "bins": ["python3"], "env": ["PYTHONPATH"] },
|
||||
"install":
|
||||
[
|
||||
{
|
||||
"id": "pip",
|
||||
"kind": "pip",
|
||||
"package": "python-docx",
|
||||
"bins": ["python3"],
|
||||
"label": "Install python-docx (pip)",
|
||||
},
|
||||
{
|
||||
"id": "system",
|
||||
"kind": "system",
|
||||
"command": "sudo apt-get install antiword -y",
|
||||
"label": "Install antiword for .doc support (optional)",
|
||||
"platform": "linux-debian"
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
---
|
||||
|
||||
# Word 文档读取器
|
||||
|
||||
使用 Python 解析 Word 文档,提取文本内容和结构化信息。
|
||||
|
||||
## 支持的功能
|
||||
|
||||
- **文档文本提取** - 提取段落、标题、页眉页脚内容
|
||||
- **表格解析** - 读取表格数据并转换为结构化格式
|
||||
- **图片处理** - 提取文档中的图片信息
|
||||
- **元数据获取** - 读取文档属性(作者、标题、创建时间等)
|
||||
- **批量处理** - 支持处理多个文档
|
||||
|
||||
## 用法
|
||||
|
||||
### 基本文本提取
|
||||
|
||||
```bash
|
||||
python3 {baseDir}/scripts/read_word.py <文件路径>
|
||||
```
|
||||
|
||||
### 指定输出格式
|
||||
|
||||
```bash
|
||||
# JSON 输出
|
||||
python3 {baseDir}/scripts/read_word.py <文件路径> --format json
|
||||
|
||||
# 纯文本输出
|
||||
python3 {baseDir}/scripts/read_word.py <文件路径> --format text
|
||||
|
||||
# Markdown 格式
|
||||
python3 {baseDir}/scripts/read_word.py <文件路径> --format markdown
|
||||
```
|
||||
|
||||
### 提取特定内容
|
||||
|
||||
```bash
|
||||
# 只提取文本
|
||||
python3 {baseDir}/scripts/read_word.py <文件路径> --extract text
|
||||
|
||||
# 提取表格数据
|
||||
python3 {baseDir}/scripts/read_word.py <文件路径> --extract tables
|
||||
|
||||
# 获取文档元数据
|
||||
python3 {baseDir}/scripts/read_word.py <文件路径> --extract metadata
|
||||
```
|
||||
|
||||
### 批量处理
|
||||
|
||||
```bash
|
||||
# 处理目录下所有 .docx 文件
|
||||
python3 {baseDir}/scripts/read_word.py <目录路径> --batch
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--format` | 输出格式(json/text/markdown) | text |
|
||||
| `--extract` | 提取内容类型(text/tables/images/metadata/all) | all |
|
||||
| `--batch` | 批量处理模式 | false |
|
||||
| `--output` | 输出文件路径 | stdout |
|
||||
| `--encoding` | 文本编码(utf-8/gb2312) | utf-8 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
### JSON 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"title": "文档标题",
|
||||
"author": "作者姓名",
|
||||
"created": "2024-01-01T10:00:00",
|
||||
"modified": "2024-01-01T12:00:00"
|
||||
},
|
||||
"text": "文档全文内容...",
|
||||
"tables": [
|
||||
[
|
||||
["表头1", "表头2"],
|
||||
["行1列1", "行1列2"],
|
||||
["行2列1", "行2列2"]
|
||||
]
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"filename": "image1.png",
|
||||
"description": "图片描述",
|
||||
"size": "1024x768"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown 格式
|
||||
|
||||
```markdown
|
||||
# 文档标题
|
||||
|
||||
**作者**:作者姓名
|
||||
**创建时间**:2024-01-01 10:00:00
|
||||
|
||||
## 正文内容
|
||||
|
||||
这是文档的正文内容...
|
||||
|
||||
### 表格示例
|
||||
|
||||
| 表头1 | 表头2 |
|
||||
|-------|-------|
|
||||
| 行1列1 | 行1列2 |
|
||||
| 行2列1 | 行2列2 |
|
||||
|
||||

|
||||
|
||||
## 图片列表
|
||||
|
||||
1. **image1.png** (1024x768) - 图片描述
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 文件不存在:显示错误信息并退出
|
||||
- 格式不支持:提示支持的文件类型
|
||||
- 权限问题:提示文件访问权限
|
||||
- 编码问题:尝试自动检测编码
|
||||
|
||||
## 示例场景
|
||||
|
||||
### 1. 查看项目文档
|
||||
|
||||
```bash
|
||||
python3 {baseDir}/scripts/read_word.py 项目需求.docx --format markdown
|
||||
```
|
||||
|
||||
### 2. 提取会议记录
|
||||
|
||||
```bash
|
||||
python3 {baseDir}/scripts/read_word.py 会议记录.docx --extract text
|
||||
```
|
||||
|
||||
### 3. 批量处理文档
|
||||
|
||||
```bash
|
||||
python3 {baseDir}/scripts/read_word.py ./文档目录 --batch --format json --output results.json
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 支持 .docx 格式(Office 2007+)
|
||||
- .doc 格式需要额外依赖(如 antiword)
|
||||
- 大文档处理可能需要较长时间
|
||||
- 图片提取仅获取元数据,不包含实际图片数据
|
||||
- 表格格式可能需要手动调整
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **ModuleNotFoundError**: 确保已安装 python-docx
|
||||
2. **PermissionError**: 检查文件读取权限
|
||||
3. **UnicodeDecodeError**: 尝试不同的编码格式
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pip3 install python-docx
|
||||
```
|
||||
|
||||
对于 .doc 格式支持:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install antiword
|
||||
|
||||
# macOS
|
||||
brew install antiword
|
||||
```
|
||||
|
||||
## 高级功能
|
||||
|
||||
### 自定义样式处理
|
||||
|
||||
脚本会自动处理以下文档元素:
|
||||
- 标题级别(H1-H6)
|
||||
- 段落样式
|
||||
- 列表项目
|
||||
- 页眉页脚
|
||||
- 文档属性
|
||||
|
||||
### 性能优化
|
||||
|
||||
- 大文件流式处理
|
||||
- 内存使用优化
|
||||
- 进度显示(批量模式)
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"owner": "xtfnhcyjpgf",
|
||||
"slug": "word-reader",
|
||||
"displayName": "Word Reader",
|
||||
"latest": {
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1770700102926,
|
||||
"commit": "https://github.com/openclaw/skills/commit/91b71e101c57b69a4d4eb2678e1b79992eb7032f"
|
||||
},
|
||||
"history": []
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Word Reader 技能演示脚本
|
||||
# 此脚本展示如何使用 word-reader 技能
|
||||
|
||||
echo "=== Word Reader 技能演示 ==="
|
||||
echo ""
|
||||
|
||||
# 检查脚本是否存在
|
||||
SCRIPT_PATH="/root/.openclaw/workspace/skills/word-reader/scripts/read_word.py"
|
||||
if [ ! -f "$SCRIPT_PATH" ]; then
|
||||
echo "❌ 错误:脚本不存在"
|
||||
echo "请确保技能已正确安装"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查脚本是否有执行权限
|
||||
if [ ! -x "$SCRIPT_PATH" ]; then
|
||||
echo "❌ 错误:脚本没有执行权限"
|
||||
echo "正在添加执行权限..."
|
||||
chmod +x "$SCRIPT_PATH"
|
||||
fi
|
||||
|
||||
echo "✅ 脚本已就绪"
|
||||
echo ""
|
||||
|
||||
# 显示技能信息
|
||||
echo "📋 技能信息:"
|
||||
echo " 名称:word-reader"
|
||||
echo " 功能:读取 Word 文档(.docx 和 .doc 格式)"
|
||||
echo " 位置:$SCRIPT_PATH"
|
||||
echo ""
|
||||
|
||||
# 显示使用示例
|
||||
echo "📖 使用示例:"
|
||||
echo ""
|
||||
|
||||
echo "1. 显示帮助信息:"
|
||||
echo " python3 $SCRIPT_PATH --help"
|
||||
echo ""
|
||||
|
||||
echo "2. 读取文档(文本格式):"
|
||||
echo " python3 $SCRIPT_PATH 文档路径.docx"
|
||||
echo ""
|
||||
|
||||
echo "3. 读取文档(JSON 格式):"
|
||||
echo " python3 $SCRIPT_PATH 文档路径.docx --format json"
|
||||
echo ""
|
||||
|
||||
echo "4. 读取文档(Markdown 格式):"
|
||||
echo " python3 $SCRIPT_PATH 文档路径.docx --format markdown"
|
||||
echo ""
|
||||
|
||||
echo "5. 只提取文本内容:"
|
||||
echo " python3 $SCRIPT_PATH 文档路径.docx --extract text"
|
||||
echo ""
|
||||
|
||||
echo "6. 批量处理目录:"
|
||||
echo " python3 $SCRIPT_PATH ./文档目录 --batch"
|
||||
echo ""
|
||||
|
||||
echo "7. 保存结果到文件:"
|
||||
echo " python3 $SCRIPT_PATH 文档路径.docx --format markdown --output output.md"
|
||||
echo ""
|
||||
|
||||
echo "🔧 安装依赖:"
|
||||
echo " pip3 install python-docx"
|
||||
echo " # 对于 .doc 格式支持:"
|
||||
echo " # Ubuntu: sudo apt-get install antiword"
|
||||
echo " # macOS: brew install antiword"
|
||||
echo ""
|
||||
|
||||
echo "📊 支持的功能:"
|
||||
echo " ✅ 文本提取"
|
||||
echo " ✅ 表格解析"
|
||||
echo " ✅ 元数据获取"
|
||||
echo " ✅ 图片信息"
|
||||
echo " ✅ 多格式支持"
|
||||
echo " ✅ 批量处理"
|
||||
echo ""
|
||||
|
||||
echo "💡 提示:"
|
||||
echo " - 支持 .docx 和 .doc 格式"
|
||||
echo " - 输出格式:JSON、Text、Markdown"
|
||||
echo " - 如遇错误,请检查依赖是否安装"
|
||||
echo ""
|
||||
|
||||
echo "演示完成!"
|
||||
echo "如需使用,请替换 '文档路径.docx' 为实际的文档路径"
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Word Reader 技能安装脚本
|
||||
# 此脚本会自动安装依赖并设置技能
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Word Reader 技能安装 ==="
|
||||
echo ""
|
||||
|
||||
# 检查 Python 版本
|
||||
echo "🔍 检查 Python 版本..."
|
||||
python_version=$(python3 --version 2>&1)
|
||||
echo " Python 版本: $python_version"
|
||||
|
||||
if ! python3 -c "import sys; assert sys.version_info >= (3, 6)"; then
|
||||
echo "❌ 错误:需要 Python 3.6 或更高版本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Python 版本检查通过"
|
||||
echo ""
|
||||
|
||||
# 检查并安装依赖
|
||||
echo "📦 检查依赖..."
|
||||
|
||||
# 检查 pip
|
||||
if ! command -v pip3 &> /dev/null; then
|
||||
echo " 🔧 安装 pip..."
|
||||
python3 -m ensurepip --upgrade 2>/dev/null || {
|
||||
echo " ❌ 无法安装 pip,尝试使用系统包管理器"
|
||||
if command -v apt &> /dev/null; then
|
||||
sudo apt update
|
||||
sudo apt install -y python3-pip
|
||||
elif command -v yum &> /dev/null; then
|
||||
sudo yum install -y python3-pip
|
||||
elif command -v brew &> /dev/null; then
|
||||
brew install python3
|
||||
else
|
||||
echo " ❌ 无法自动安装 pip,请手动安装"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
fi
|
||||
|
||||
# 检查 python-docx
|
||||
if ! python3 -c "import docx" 2>/dev/null; then
|
||||
echo " 🔧 安装 python-docx..."
|
||||
if python3 -m pip install python-docx --break-system-packages 2>/dev/null; then
|
||||
echo " ✅ python-docx 安装完成"
|
||||
elif python3 -m pip install python-docx 2>/dev/null; then
|
||||
echo " ✅ python-docx 安装完成"
|
||||
else
|
||||
echo "❌ 无法安装 python-docx"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo " ✅ python-docx 已安装"
|
||||
fi
|
||||
|
||||
# 检查 antiword(可选)
|
||||
if command -v antiword >/dev/null 2>&1; then
|
||||
echo " ✅ antiword 已安装"
|
||||
else
|
||||
echo " ⚠️ antiword 未安装(可选,用于 .doc 格式支持)"
|
||||
echo " 推荐安装命令:"
|
||||
echo " Ubuntu/Debian: sudo apt-get install antiword"
|
||||
echo " macOS: brew install antiword"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 设置执行权限
|
||||
echo "🔐 设置执行权限..."
|
||||
chmod +x scripts/read_word.py
|
||||
echo "✅ 执行权限已设置"
|
||||
echo ""
|
||||
|
||||
# 验证安装
|
||||
echo "🧪 验证安装..."
|
||||
python3 scripts/read_word.py --help >/dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ 安装验证成功"
|
||||
else
|
||||
echo "❌ 安装验证失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Word Reader 技能安装完成!"
|
||||
echo ""
|
||||
echo "📖 使用方法:"
|
||||
echo " python3 scripts/read_word.py 文档.docx"
|
||||
echo " python3 scripts/read_word.py 文档.docx --format json"
|
||||
echo " python3 scripts/read_word.py 文档.docx --format markdown"
|
||||
echo ""
|
||||
echo "📖 更多帮助:"
|
||||
echo " python3 scripts/read_word.py --help"
|
||||
echo ""
|
||||
echo "📖 运行演示:"
|
||||
echo " ./demo.sh"
|
||||
|
|
@ -1,396 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Word 文档读取器
|
||||
支持 .docx 和 .doc 格式的 Word 文档解析
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
||||
from docx.oxml.table import CT_Tbl
|
||||
from docx.oxml.text.paragraph import CT_P
|
||||
from docx.table import Table
|
||||
from docx.text.paragraph import Paragraph
|
||||
DOCX_AVAILABLE = True
|
||||
except ImportError:
|
||||
DOCX_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import subprocess
|
||||
SUBPROCESS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SUBPROCESS_AVAILABLE = False
|
||||
|
||||
class WordReader:
|
||||
"""Word 文档读取器"""
|
||||
|
||||
def __init__(self, file_path):
|
||||
self.file_path = Path(file_path)
|
||||
self.document = None
|
||||
self.format_type = None
|
||||
self.encoding = 'utf-8'
|
||||
|
||||
# 检查文件是否存在
|
||||
if not self.file_path.exists():
|
||||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||
|
||||
# 检查文件扩展名
|
||||
if self.file_path.suffix.lower() not in ['.docx', '.doc']:
|
||||
raise ValueError(f"不支持的文件格式: {self.file_path.suffix}")
|
||||
|
||||
def read_docx(self):
|
||||
"""读取 .docx 格式文档"""
|
||||
if not DOCX_AVAILABLE:
|
||||
raise Exception("缺少 python-docx 库。请安装:pip3 install python-docx")
|
||||
|
||||
try:
|
||||
self.document = Document(str(self.file_path))
|
||||
self.format_type = 'docx'
|
||||
return True
|
||||
except Exception as e:
|
||||
raise Exception(f"读取 .docx 文件失败: {str(e)}")
|
||||
|
||||
def read_doc(self):
|
||||
"""读取 .doc 格式文档(使用 antiword)"""
|
||||
if not SUBPROCESS_AVAILABLE:
|
||||
raise Exception("缺少 subprocess 模块")
|
||||
|
||||
try:
|
||||
# 检查 antiword 是否可用
|
||||
result = subprocess.run(['which', 'antiword'],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise Exception("antiword 未安装。请安装 antiword: Ubuntu/Debian: sudo apt-get install antiword; macOS: brew install antiword")
|
||||
|
||||
# 使用 antiword 转换
|
||||
result = subprocess.run(['antiword', str(self.file_path)],
|
||||
capture_output=True, text=True, encoding='utf-8')
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"antiword 转换失败: {result.stderr}")
|
||||
|
||||
# 创建临时文档对象
|
||||
class TempDocument:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
self.paragraphs = [TempParagraph(p) for p in text.split('\n') if p.strip()]
|
||||
|
||||
class TempParagraph:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
|
||||
self.document = TempDocument(result.stdout)
|
||||
self.format_type = 'doc'
|
||||
return True
|
||||
except Exception as e:
|
||||
raise Exception(f"读取 .doc 文件失败: {str(e)}")
|
||||
|
||||
def read_metadata(self):
|
||||
"""读取文档元数据"""
|
||||
metadata = {
|
||||
'filename': self.file_path.name,
|
||||
'size': f"{self.file_path.stat().st_size} bytes",
|
||||
'created': datetime.fromtimestamp(self.file_path.stat().st_ctime).isoformat(),
|
||||
'modified': datetime.fromtimestamp(self.file_path.stat().st_mtime).isoformat()
|
||||
}
|
||||
|
||||
if self.format_type == 'docx' and hasattr(self.document, 'core_properties'):
|
||||
props = self.document.core_properties
|
||||
metadata.update({
|
||||
'title': getattr(props, 'title', ''),
|
||||
'author': getattr(props, 'author', ''),
|
||||
'subject': getattr(props, 'subject', ''),
|
||||
'keywords': getattr(props, 'keywords', ''),
|
||||
'comments': getattr(props, 'comments', ''),
|
||||
'application': getattr(props, 'application', ''),
|
||||
'category': getattr(props, 'category', '')
|
||||
})
|
||||
|
||||
return metadata
|
||||
|
||||
def extract_text(self):
|
||||
"""提取文档文本"""
|
||||
text_content = []
|
||||
|
||||
if self.format_type == 'docx':
|
||||
# 提取段落文本
|
||||
for para in self.document.paragraphs:
|
||||
if para.text.strip():
|
||||
text_content.append(para.text)
|
||||
|
||||
# 提取表格文本
|
||||
for table in self.document.tables:
|
||||
table_text = []
|
||||
for row in table.rows:
|
||||
row_text = []
|
||||
for cell in row.cells:
|
||||
row_text.append(cell.text.strip())
|
||||
table_text.append(' | '.join(row_text))
|
||||
text_content.append('\n'.join(table_text))
|
||||
|
||||
else: # doc 格式
|
||||
text_content = [para.text for para in self.document.paragraphs if para.text.strip()]
|
||||
|
||||
return '\n\n'.join(text_content)
|
||||
|
||||
def extract_tables(self):
|
||||
"""提取表格数据"""
|
||||
tables = []
|
||||
|
||||
if self.format_type == 'docx':
|
||||
for i, table in enumerate(self.document.tables):
|
||||
table_data = []
|
||||
for row in table.rows:
|
||||
row_data = []
|
||||
for cell in row.cells:
|
||||
row_data.append(cell.text.strip())
|
||||
table_data.append(row_data)
|
||||
tables.append({
|
||||
'id': i + 1,
|
||||
'rows': len(table.rows),
|
||||
'columns': len(table.columns) if table.rows else 0,
|
||||
'data': table_data
|
||||
})
|
||||
|
||||
return tables
|
||||
|
||||
def extract_images(self):
|
||||
"""提取图片信息"""
|
||||
images = []
|
||||
|
||||
if self.format_type == 'docx':
|
||||
try:
|
||||
# 获取文档中的关系
|
||||
part = self.document.part
|
||||
image_parts = part.related_parts
|
||||
|
||||
for rel in part.relationships:
|
||||
if rel.reltype == RT.IMAGE:
|
||||
image_data = image_parts[rel.rId]._blob
|
||||
image_info = {
|
||||
'id': rel.rId,
|
||||
'filename': f"image_{rel.rId}.{rel.target_ref.split('.')[-1]}",
|
||||
'size': f"{len(image_data)} bytes"
|
||||
}
|
||||
images.append(image_info)
|
||||
except:
|
||||
# 图片提取可能失败,忽略错误
|
||||
pass
|
||||
|
||||
return images
|
||||
|
||||
def extract_all(self):
|
||||
"""提取所有内容"""
|
||||
result = {
|
||||
'metadata': self.read_metadata(),
|
||||
'format': self.format_type,
|
||||
'text': self.extract_text(),
|
||||
'tables': self.extract_tables(),
|
||||
'images': self.extract_images()
|
||||
}
|
||||
return result
|
||||
|
||||
def to_markdown(self, extract_type='all'):
|
||||
"""转换为 Markdown 格式"""
|
||||
if extract_type == 'text':
|
||||
return self.extract_text()
|
||||
|
||||
result = self.extract_all()
|
||||
md_content = []
|
||||
|
||||
# 标题
|
||||
md_content.append(f"# {result['metadata']['filename']}")
|
||||
md_content.append("")
|
||||
|
||||
# 元数据
|
||||
metadata = result['metadata']
|
||||
if metadata.get('title'):
|
||||
md_content.append(f"**标题**:{metadata['title']}")
|
||||
if metadata.get('author'):
|
||||
md_content.append(f"**作者**:{metadata['author']}")
|
||||
md_content.append(f"**文件大小**:{metadata['size']}")
|
||||
md_content.append(f"**创建时间**:{metadata['created']}")
|
||||
md_content.append(f"**修改时间**:{metadata['modified']}")
|
||||
md_content.append("")
|
||||
|
||||
# 文本内容
|
||||
if result['text']:
|
||||
md_content.append("## 正文内容")
|
||||
md_content.append("")
|
||||
md_content.append(result['text'])
|
||||
md_content.append("")
|
||||
|
||||
# 表格
|
||||
if result['tables']:
|
||||
md_content.append("## 表格内容")
|
||||
md_content.append("")
|
||||
for table in result['tables']:
|
||||
md_content.append(f"### 表格 {table['id']} ({table['rows']}行 x {table['columns']}列)")
|
||||
md_content.append("")
|
||||
# 转换为 Markdown 表格
|
||||
for row in table['data']:
|
||||
md_row = " | ".join([str(cell) for cell in row])
|
||||
md_content.append(f"| {md_row} |")
|
||||
md_content.append("")
|
||||
|
||||
# 图片
|
||||
if result['images']:
|
||||
md_content.append("## 图片列表")
|
||||
md_content.append("")
|
||||
for img in result['images']:
|
||||
md_content.append(f"- **{img['filename']}** ({img['size']})")
|
||||
md_content.append("")
|
||||
|
||||
return '\n'.join(md_content)
|
||||
|
||||
def to_text(self, extract_type='all'):
|
||||
"""转换为纯文本格式"""
|
||||
if extract_type == 'text':
|
||||
return self.extract_text()
|
||||
|
||||
result = self.extract_all()
|
||||
text_content = []
|
||||
|
||||
# 标题和元数据
|
||||
text_content.append(f"文件:{result['metadata']['filename']}")
|
||||
text_content.append("=" * 50)
|
||||
text_content.append("")
|
||||
|
||||
for key, value in result['metadata'].items():
|
||||
if value and key not in ['filename', 'size', 'created', 'modified']:
|
||||
text_content.append(f"{key}:{value}")
|
||||
|
||||
text_content.append("")
|
||||
|
||||
# 文本内容
|
||||
if result['text']:
|
||||
text_content.append("正文内容:")
|
||||
text_content.append("-" * 20)
|
||||
text_content.append(result['text'])
|
||||
text_content.append("")
|
||||
|
||||
# 表格
|
||||
if result['tables']:
|
||||
text_content.append("表格内容:")
|
||||
text_content.append("-" * 20)
|
||||
for table in result['tables']:
|
||||
text_content.append(f"表格 {table['id']}:")
|
||||
for row in table['data']:
|
||||
text_content.append(" " + " | ".join([str(cell) for cell in row]))
|
||||
text_content.append("")
|
||||
|
||||
return '\n'.join(text_content)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='读取 Word 文档')
|
||||
parser.add_argument('path', help='文档路径或目录路径(批量模式)')
|
||||
parser.add_argument('--format', choices=['json', 'text', 'markdown'],
|
||||
default='text', help='输出格式')
|
||||
parser.add_argument('--extract', choices=['text', 'tables', 'images', 'metadata', 'all'],
|
||||
default='all', help='提取内容类型')
|
||||
parser.add_argument('--batch', action='store_true', help='批量处理模式')
|
||||
parser.add_argument('--output', help='输出文件路径')
|
||||
parser.add_argument('--encoding', default='utf-8', help='文本编码')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.batch:
|
||||
# 批量处理模式
|
||||
path = Path(args.path)
|
||||
if not path.is_dir():
|
||||
print("错误:批量模式需要指定目录路径")
|
||||
sys.exit(1)
|
||||
|
||||
# 查找所有 Word 文档
|
||||
word_files = []
|
||||
for ext in ['.docx', '.doc']:
|
||||
word_files.extend(path.glob(f"**/*{ext}"))
|
||||
|
||||
if not word_files:
|
||||
print("未找到 Word 文档")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"找到 {len(word_files)} 个 Word 文档")
|
||||
|
||||
results = {}
|
||||
for file_path in word_files:
|
||||
print(f"正在处理: {file_path}")
|
||||
try:
|
||||
reader = WordReader(file_path)
|
||||
if file_path.suffix.lower() == '.docx':
|
||||
reader.read_docx()
|
||||
else:
|
||||
reader.read_doc()
|
||||
|
||||
if args.format == 'json':
|
||||
content = reader.extract_all()
|
||||
elif args.format == 'markdown':
|
||||
content = reader.to_markdown(args.extract)
|
||||
else:
|
||||
content = reader.to_text(args.extract)
|
||||
|
||||
results[str(file_path)] = {
|
||||
'filename': file_path.name,
|
||||
'content': content,
|
||||
'status': 'success'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
results[str(file_path)] = {
|
||||
'filename': file_path.name,
|
||||
'error': str(e),
|
||||
'status': 'failed'
|
||||
}
|
||||
|
||||
# 保存结果
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
print(f"结果已保存到: {args.output}")
|
||||
else:
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
|
||||
else:
|
||||
# 单文件处理模式
|
||||
reader = WordReader(args.path)
|
||||
|
||||
# 根据文件类型读取
|
||||
if args.path.lower().endswith('.docx'):
|
||||
reader.read_docx()
|
||||
else:
|
||||
reader.read_doc()
|
||||
|
||||
# 根据格式输出
|
||||
if args.format == 'json':
|
||||
content = reader.extract_all()
|
||||
elif args.format == 'markdown':
|
||||
content = reader.to_markdown(args.extract)
|
||||
else:
|
||||
content = reader.to_text(args.extract)
|
||||
|
||||
# 输出结果
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding=args.encoding) as f:
|
||||
f.write(content)
|
||||
print(f"结果已保存到: {args.output}")
|
||||
else:
|
||||
print(content)
|
||||
|
||||
except Exception as e:
|
||||
print(f"错误: {str(e)}", file=sys.stderr)
|
||||
if '--debug' in sys.argv or '-d' in sys.argv:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"name": "word-reader",
|
||||
"version": "1.0.0",
|
||||
"description": "读取 Word 文档(.docx 和 .doc 格式)并提取文本内容",
|
||||
"author": "OpenClaw User",
|
||||
"tags": ["document", "word", "office", "text-extraction"],
|
||||
"dependencies": {
|
||||
"python": ">=3.6",
|
||||
"packages": ["python-docx"],
|
||||
"system": ["antiword (optional for .doc support)"]
|
||||
},
|
||||
"features": {
|
||||
"text_extraction": true,
|
||||
"table_parsing": true,
|
||||
"metadata_extraction": true,
|
||||
"image_info": true,
|
||||
"batch_processing": true,
|
||||
"multiple_formats": ["json", "text", "markdown"]
|
||||
},
|
||||
"installation": {
|
||||
"steps": [
|
||||
"pip3 install python-docx",
|
||||
"sudo apt-get install antiword # 可选,支持 .doc 格式",
|
||||
"chmod +x scripts/read_word.py"
|
||||
]
|
||||
},
|
||||
"usage_examples": [
|
||||
{
|
||||
"description": "读取文档文本",
|
||||
"command": "python3 scripts/read_word.py document.docx"
|
||||
},
|
||||
{
|
||||
"description": "转换为 Markdown",
|
||||
"command": "python3 scripts/read_word.py document.docx --format markdown"
|
||||
},
|
||||
{
|
||||
"description": "批量处理",
|
||||
"command": "python3 scripts/read_word.py ./docs --batch --format json"
|
||||
}
|
||||
],
|
||||
"supported_file_types": [".docx", ".doc"],
|
||||
"notes": [
|
||||
".doc 格式需要安装 antiword",
|
||||
"大文档处理可能需要较长时间",
|
||||
"图片提取仅获取元数据,不包含实际图片数据"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# Word Reader 技能测试
|
||||
|
||||
这是一个简单的测试文档,用于验证 Word Reader 技能的功能。
|
||||
|
||||
## 测试内容
|
||||
|
||||
### 1. 基本文本
|
||||
这是一段测试文本,用于验证文本提取功能是否正常工作。
|
||||
|
||||
### 2. 表格测试
|
||||
|
||||
| 功能 | 状态 | 描述 |
|
||||
|------|------|------|
|
||||
| 文本提取 | ✅ | 能够提取文档中的所有文本内容 |
|
||||
| 表格解析 | ✅ | 能够正确解析表格数据 |
|
||||
| 元数据获取 | ✅ | 能够获取文档属性信息 |
|
||||
| 多格式支持 | ✅ | 支持 .docx 和 .doc 格式 |
|
||||
| 输出格式 | ✅ | 支持 JSON、Text、Markdown 格式 |
|
||||
|
||||
### 3. 列表测试
|
||||
|
||||
- 第一项:文本提取功能
|
||||
- 第二项:表格解析功能
|
||||
- 第三项:图片信息获取
|
||||
- 第四项:文档元数据读取
|
||||
|
||||
### 4. 代码块示例
|
||||
|
||||
```python
|
||||
def read_word_document(file_path):
|
||||
"""读取 Word 文档"""
|
||||
reader = WordReader(file_path)
|
||||
if file_path.endswith('.docx'):
|
||||
reader.read_docx()
|
||||
else:
|
||||
reader.read_doc()
|
||||
return reader.extract_all()
|
||||
```
|
||||
|
||||
## 测试完成
|
||||
|
||||
如果这个技能能够正确读取并解析上述内容,说明功能正常。
|
||||
Loading…
Add table
Add a link
Reference in a new issue