统一技能安装链路并提交当前skills变更。

新增 public 的 skill_auto_install 工具并移除 catalog 旧实现,同时按当前工作区状态提交 skills 精简与 skill-creator 新增内容。

Made-with: Cursor
This commit is contained in:
oliver 2026-05-01 04:23:24 +08:00
parent 1c71a29ec1
commit 329fb2239a
45 changed files with 1291 additions and 6100 deletions

View file

@ -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) |

View file

@ -1,6 +0,0 @@
{
"ownerId": "kn791cyx98pcsezkh5088g8jxn84c7mm",
"slug": "skillscan",
"version": "1.1.6",
"publishedAt": 1776650587310
}

View file

@ -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()

View file

@ -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 文档,支持多种格式和输出选项。

View file

@ -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 了!

View file

@ -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

View file

@ -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 |
![图片描述](image1.png)
## 图片列表
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)
- 段落样式
- 列表项目
- 页眉页脚
- 文档属性
### 性能优化
- 大文件流式处理
- 内存使用优化
- 进度显示(批量模式)

View file

@ -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": []
}

View file

@ -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' 为实际的文档路径"

View file

@ -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"

View file

@ -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()

View file

@ -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",
"大文档处理可能需要较长时间",
"图片提取仅获取元数据,不包含实际图片数据"
]
}

View file

@ -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()
```
## 测试完成
如果这个技能能够正确读取并解析上述内容,说明功能正常。