feat: 工资查询助手首版 v1.0.105
基于 skill-template 脚手架开发,包含: - payroll_config(费率配置,可编辑)+ payslip_snapshots(解析历史,只读)2 张业务表 - 全国默认 + 北上广 4 套费率(社保/公积金/个税起征点/7 级税率表) - 5 个业务 Action:calculate-payslip-simple / calculate-payslip-full / explain-term / view-config / edit-config - 个税累计预扣法计算,支持多月历史累加 - 20 个薪资名词库(社保/公积金/个税/全勤/加班费等) - 完整市场文档(README/TUTORIAL/DEMO/CHANGELOG) - 296 测试全部通过
This commit is contained in:
87
tools/README.md
Normal file
87
tools/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# skill-template 工具
|
||||
|
||||
## UI 拾取(开发期)
|
||||
|
||||
`tools/ui_inspector` 已迁出本模板仓。请使用独立产品仓:
|
||||
|
||||
- Gitea:https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder
|
||||
- 本机常见路径:`D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`
|
||||
- 启动:`python run.py`
|
||||
|
||||
模板仓历史中仍保留删除前的归档提交(可按需从 Git 恢复旧副本);日常开发与运行以独立仓为准。
|
||||
|
||||
## 业务仓也可以直接 clone(来源 A)
|
||||
|
||||
若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接:
|
||||
|
||||
```powershell
|
||||
git clone https://git.jc2009.com/<org>/<query-payslip-default>.git
|
||||
```
|
||||
|
||||
完整说明见 [`../development/DEVELOPMENT.md`](../development/DEVELOPMENT.md) §4「来源 A」。公开公共仓(含本模板、account-manager、platform-kit)登记见 [`../development/SHARED_REPOS.md`](../development/SHARED_REPOS.md)。下面脚手架用于**本地已有 skill-template、要新建尚未灌仓目录**的场景(来源 B)。
|
||||
|
||||
## 推荐:脚手架创建新技能
|
||||
|
||||
**不要**用资源管理器整文件夹复制 `skill-template`(会带走隐藏 `.git`,导致 push 串库)。
|
||||
|
||||
Windows 首选:
|
||||
|
||||
```powershell
|
||||
cd D:\OpenClaw\client-commons\skill-template
|
||||
.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill
|
||||
```
|
||||
|
||||
跨平台(Python 3.10+):
|
||||
|
||||
```bash
|
||||
python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new-skill
|
||||
```
|
||||
|
||||
脚本行为:
|
||||
|
||||
- 复制模板到 `-Destination`(目录末段须与 `-Slug` 一致)
|
||||
- **排除**:`.git`、`.pytest_cache`、`__pycache__`、`.env`、`.env.local`、`*.pyc`
|
||||
- 删除目标中的 `.openclaw-skill-template` 标记
|
||||
- 将文本文件中的模板占位 slug 替换为新 slug:
|
||||
- `query-payslip-default` → 新技能 kebab-case slug(如 `scrape-demo-records`)
|
||||
- `query_payslip_default` → 连字符替换为下划线(如 `scrape_demo_records`,用于 `LOG_LOGGER_NAME` 等)
|
||||
- 覆盖 `assets/actions.json`、`SKILL.md`、`scripts/util/constants.py` 等生成文件;文档中的路径示例可保留占位,但生成后的技能文件不得残留占位符
|
||||
- **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action
|
||||
- **不**移除 Schema 中的 `bind.tables` 支持,**不**把 `bind` 当模板残留清理
|
||||
- **不**自动生成业务表名,**不**给 `health` / `version` / `config-path` 自动加 `toolbar`
|
||||
- **不**生成任何具体平台业务 Action
|
||||
- **不**自动 `git init`;打印后续 Git 与测试命令
|
||||
|
||||
目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。
|
||||
|
||||
## 脚手架复制后必须保留 / 修改 / 可删除的文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 |
|
||||
| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 |
|
||||
| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 |
|
||||
| `tests/test_actions_manifest.py` | 若删除 `actions.json`,应同步删除或改写此测试 |
|
||||
| `tests/test_data_management_contract.py` | 通用契约测试;建议保留,按需扩展本技能表结构测试 |
|
||||
|
||||
`demo_items` 等示例表仅存在于模板测试中,**不会**作为 scaffold 后的默认业务表复制进新技能。
|
||||
|
||||
## 手工复制后的补救
|
||||
|
||||
若已整目录复制,必须先清理再初始化:
|
||||
|
||||
```powershell
|
||||
cd <新技能目录>
|
||||
Remove-Item -Recurse -Force .git
|
||||
Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue
|
||||
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
|
||||
git init
|
||||
git remote add origin <新技能仓库 URL>
|
||||
git remote -v
|
||||
```
|
||||
|
||||
确认 `origin` **不是** skill-template 的远端 URL。
|
||||
|
||||
## 模板标记
|
||||
|
||||
源仓库根目录有 `.openclaw-skill-template`(仅 template 保留)。业务技能不得保留该文件;`tests/test_scaffold_guard.py` 会守护。
|
||||
194
tools/highlight_answers.py
Normal file
194
tools/highlight_answers.py
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Highlight correct answer options in Excel quiz file."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
from openpyxl.cell.rich_text import CellRichText, InlineFont, TextBlock
|
||||
|
||||
# Option letter to index mapping
|
||||
LETTER_TO_IDX = {"A": 0, "B": 1, "C": 2, "D": 3}
|
||||
IDX_TO_LETTER = {0: "A", 1: "B", 2: "C", 3: "D"}
|
||||
|
||||
# Regex to find option boundaries: A/B/C/D followed by separator
|
||||
OPTION_MARKER_RE = re.compile(
|
||||
r"(?=[A-D](?:[\..、::\))]|\s))"
|
||||
)
|
||||
|
||||
NORMAL_FONT = InlineFont(b=False, color="000000")
|
||||
RED_BOLD_FONT = InlineFont(b=True, color="FF0000")
|
||||
|
||||
|
||||
def parse_answer(answer_raw) -> set[int]:
|
||||
"""Parse answer column into set of option indices (0=A, 1=B, ...)."""
|
||||
if answer_raw is None:
|
||||
return set()
|
||||
s = str(answer_raw).strip()
|
||||
if not s:
|
||||
return set()
|
||||
|
||||
indices = set()
|
||||
|
||||
# Pure digits like "13" -> 1 and 3 (not 13)
|
||||
if re.fullmatch(r"[1-4]+", s):
|
||||
for ch in s:
|
||||
indices.add(int(ch) - 1)
|
||||
return indices
|
||||
|
||||
# Split by common separators
|
||||
parts = re.split(r"[,,;;\s|]+", s)
|
||||
for part in parts:
|
||||
part = part.strip().upper()
|
||||
if not part:
|
||||
continue
|
||||
if part in LETTER_TO_IDX:
|
||||
indices.add(LETTER_TO_IDX[part])
|
||||
elif re.fullmatch(r"[1-4]", part):
|
||||
indices.add(int(part) - 1)
|
||||
|
||||
return indices
|
||||
|
||||
|
||||
def find_option_spans(text: str) -> list[tuple[int, int, str]]:
|
||||
"""Find spans for each A/B/C/D option in the options text."""
|
||||
if not text or not isinstance(text, str):
|
||||
return []
|
||||
|
||||
markers = []
|
||||
for m in OPTION_MARKER_RE.finditer(text):
|
||||
letter = text[m.start()]
|
||||
markers.append((m.start(), letter))
|
||||
|
||||
if not markers:
|
||||
return []
|
||||
|
||||
spans = []
|
||||
for i, (start, letter) in enumerate(markers):
|
||||
end = markers[i + 1][0] if i + 1 < len(markers) else len(text)
|
||||
spans.append((start, end, letter))
|
||||
return spans
|
||||
|
||||
|
||||
def build_rich_text(options_text: str, answer_indices: set[int]) -> CellRichText | str:
|
||||
"""Build rich text with correct options in red bold."""
|
||||
spans = find_option_spans(options_text)
|
||||
if not spans:
|
||||
return options_text
|
||||
|
||||
parts = []
|
||||
pos = 0
|
||||
for start, end, letter in spans:
|
||||
if start > pos:
|
||||
parts.append(TextBlock(NORMAL_FONT, options_text[pos:start]))
|
||||
idx = LETTER_TO_IDX.get(letter)
|
||||
font = RED_BOLD_FONT if idx is not None and idx in answer_indices else NORMAL_FONT
|
||||
parts.append(TextBlock(font, options_text[start:end]))
|
||||
pos = end
|
||||
|
||||
if pos < len(options_text):
|
||||
parts.append(TextBlock(NORMAL_FONT, options_text[pos:]))
|
||||
|
||||
return CellRichText(*parts)
|
||||
|
||||
|
||||
def load_workbook_from_xls(xls_path: Path) -> openpyxl.Workbook:
|
||||
"""Load .xls file, converting via pandas/xlrd if needed."""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
xls_data = pd.read_excel(xls_path, sheet_name=None, header=None, dtype=str)
|
||||
wb = openpyxl.Workbook()
|
||||
# Remove default sheet
|
||||
wb.remove(wb.active)
|
||||
|
||||
for sheet_name, df in xls_data.items():
|
||||
ws = wb.create_sheet(title=str(sheet_name))
|
||||
for r_idx, row in enumerate(df.itertuples(index=False), start=1):
|
||||
for c_idx, val in enumerate(row, start=1):
|
||||
if val is not None and str(val) != "nan":
|
||||
ws.cell(row=r_idx, column=c_idx, value=str(val))
|
||||
return wb
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to read .xls file: {e}") from e
|
||||
|
||||
|
||||
def find_header_columns(ws) -> tuple[int, int, int] | None:
|
||||
"""Find header row and column indices for 题目选项 and 题目答案."""
|
||||
for row_idx in range(1, min(ws.max_row + 1, 20)):
|
||||
options_col = None
|
||||
answer_col = None
|
||||
for col_idx in range(1, ws.max_column + 1):
|
||||
val = ws.cell(row=row_idx, column=col_idx).value
|
||||
if val is None:
|
||||
continue
|
||||
s = str(val).strip()
|
||||
if s == "题目选项":
|
||||
options_col = col_idx
|
||||
elif s == "题目答案":
|
||||
answer_col = col_idx
|
||||
if options_col is not None and answer_col is not None:
|
||||
return row_idx, options_col, answer_col
|
||||
return None
|
||||
|
||||
|
||||
def process_sheet(ws, stats: dict) -> bool:
|
||||
"""Process a single worksheet. Returns True if processed."""
|
||||
header_info = find_header_columns(ws)
|
||||
if header_info is None:
|
||||
return False
|
||||
|
||||
header_row, options_col, answer_col = header_info
|
||||
stats["sheets"] += 1
|
||||
sheet_rows = 0
|
||||
sheet_matches = 0
|
||||
|
||||
for row_idx in range(header_row + 1, ws.max_row + 1):
|
||||
options_cell = ws.cell(row=row_idx, column=options_col)
|
||||
answer_cell = ws.cell(row=row_idx, column=answer_col)
|
||||
|
||||
options_text = options_cell.value
|
||||
if options_text is None:
|
||||
continue
|
||||
options_text = str(options_text).strip()
|
||||
if not options_text:
|
||||
continue
|
||||
|
||||
answer_indices = parse_answer(answer_cell.value)
|
||||
if not answer_indices:
|
||||
continue
|
||||
|
||||
sheet_rows += 1
|
||||
sheet_matches += len(answer_indices)
|
||||
|
||||
rich = build_rich_text(options_text, answer_indices)
|
||||
options_cell.value = rich
|
||||
|
||||
stats["rows"] += sheet_rows
|
||||
stats["matches"] += sheet_matches
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
src = Path(r"D:\下载\安全知识竞赛题库(1).xls")
|
||||
dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx")
|
||||
|
||||
if not src.exists():
|
||||
print(f"ERROR: Source file not found: {src}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading: {src}")
|
||||
wb = load_workbook_from_xls(src)
|
||||
|
||||
stats = {"sheets": 0, "rows": 0, "matches": 0}
|
||||
for ws in wb.worksheets:
|
||||
process_sheet(ws, stats)
|
||||
|
||||
wb.save(dst)
|
||||
print(f"Saved: {dst}")
|
||||
print(f"处理结果: {stats['sheets']} 个工作表, {stats['rows']} 行, {stats['matches']} 个匹配选项")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
140
tools/scaffold_skill.ps1
Normal file
140
tools/scaffold_skill.ps1
Normal file
@@ -0,0 +1,140 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Scaffold a new skill directory from skill-template (excludes .git).
|
||||
|
||||
.EXAMPLE
|
||||
.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Slug,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Destination,
|
||||
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Test-KebabSlug([string]$Value) {
|
||||
return $Value -match '^[a-z0-9]+(-[a-z0-9]+)*$'
|
||||
}
|
||||
|
||||
function Write-ScaffoldNextSteps([string]$TargetPath) {
|
||||
Write-Host ""
|
||||
Write-Host "=== 脚手架复制完成 ===" -ForegroundColor Green
|
||||
Write-Host ("目标目录: " + $TargetPath)
|
||||
Write-Host ""
|
||||
Write-Host "请在本机继续执行(未自动 git init,避免误操作):" -ForegroundColor Yellow
|
||||
Write-Host (' cd "' + $TargetPath + '"')
|
||||
Write-Host " Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue"
|
||||
Write-Host " git init"
|
||||
Write-Host " git remote add origin <你的新技能仓库 URL>"
|
||||
Write-Host " git remote -v"
|
||||
Write-Host " # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件"
|
||||
Write-Host " python tests/run_tests.py -v"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
$TemplateRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$TargetPath = $Destination
|
||||
|
||||
if (-not (Test-KebabSlug $Slug)) {
|
||||
throw "Slug must be kebab-case. Got: $Slug"
|
||||
}
|
||||
|
||||
$leaf = Split-Path -Leaf ($TargetPath.TrimEnd('\', '/'))
|
||||
if ($leaf -ne $Slug) {
|
||||
throw "Destination leaf '$leaf' must match -Slug '$Slug'."
|
||||
}
|
||||
|
||||
$parent = Split-Path -Parent $TargetPath
|
||||
if (-not $parent -or -not (Test-Path -LiteralPath $parent)) {
|
||||
throw "Parent directory does not exist: $parent"
|
||||
}
|
||||
|
||||
$gitPath = Join-Path $TargetPath ".git"
|
||||
if (Test-Path -LiteralPath $gitPath) {
|
||||
throw "Target already has .git at $gitPath . Remove it first or use an empty destination."
|
||||
}
|
||||
|
||||
$targetExists = Test-Path -LiteralPath $TargetPath
|
||||
if ($targetExists) {
|
||||
$entries = @(Get-ChildItem -LiteralPath $TargetPath -Force -ErrorAction SilentlyContinue)
|
||||
if ($entries.Count -gt 0 -and -not $Force) {
|
||||
throw "Target exists and is not empty: $TargetPath . Use -Force or pick an empty directory."
|
||||
}
|
||||
}
|
||||
|
||||
$excludeDirs = @(".git", ".pytest_cache", "__pycache__")
|
||||
$excludeFiles = @(".env", ".env.local")
|
||||
$templatePlaceholderSlugKebab = "query-payslip-default"
|
||||
$templatePlaceholderSlugUnderscore = "query_payslip_default"
|
||||
$textFileSuffixes = @(".md", ".py", ".json", ".yaml", ".yml", ".txt", ".ini", ".ps1", ".sample")
|
||||
|
||||
function Copy-TemplateTree {
|
||||
param(
|
||||
[string]$Source,
|
||||
[string]$Dest
|
||||
)
|
||||
if (-not (Test-Path -LiteralPath $Dest)) {
|
||||
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
|
||||
}
|
||||
Get-ChildItem -LiteralPath $Source -Force | ForEach-Object {
|
||||
$name = $_.Name
|
||||
if ($excludeDirs -contains $name) { return }
|
||||
if ($excludeFiles -contains $name) { return }
|
||||
if ($name -like "*.pyc") { return }
|
||||
|
||||
$srcPath = $_.FullName
|
||||
$dstPath = Join-Path $Dest $name
|
||||
if ($_.PSIsContainer) {
|
||||
Copy-TemplateTree -Source $srcPath -Dest $dstPath
|
||||
}
|
||||
else {
|
||||
Copy-Item -LiteralPath $srcPath -Destination $dstPath -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Replace-TemplatePlaceholders {
|
||||
param(
|
||||
[string]$Root,
|
||||
[string]$Slug
|
||||
)
|
||||
$underscoreSlug = $Slug.Replace("-", "_")
|
||||
Get-ChildItem -LiteralPath $Root -Recurse -File -Force | ForEach-Object {
|
||||
$name = $_.Name
|
||||
if ($excludeFiles -contains $name) { return }
|
||||
$ext = $_.Extension.ToLowerInvariant()
|
||||
if ($textFileSuffixes -notcontains $ext -and $name -ne ".env.example") { return }
|
||||
try {
|
||||
$text = [System.IO.File]::ReadAllText($_.FullName)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
if (
|
||||
$text -notlike "*$templatePlaceholderSlugKebab*" -and
|
||||
$text -notlike "*$templatePlaceholderSlugUnderscore*"
|
||||
) { return }
|
||||
$updated = $text.Replace($templatePlaceholderSlugKebab, $Slug)
|
||||
$updated = $updated.Replace($templatePlaceholderSlugUnderscore, $underscoreSlug)
|
||||
[System.IO.File]::WriteAllText($_.FullName, $updated)
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Copy template: $TemplateRoot -> $TargetPath" -ForegroundColor Cyan
|
||||
Copy-TemplateTree -Source $TemplateRoot -Dest $TargetPath
|
||||
|
||||
$marker = Join-Path $TargetPath ".openclaw-skill-template"
|
||||
if (Test-Path -LiteralPath $marker) {
|
||||
Remove-Item -LiteralPath $marker -Force
|
||||
}
|
||||
|
||||
Replace-TemplatePlaceholders -Root $TargetPath -Slug $Slug
|
||||
|
||||
Write-ScaffoldNextSteps -TargetPath (Resolve-Path -LiteralPath $TargetPath).Path
|
||||
161
tools/scaffold_skill.py
Normal file
161
tools/scaffold_skill.py
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""跨平台 skill-template 脚手架(与 scaffold_skill.ps1 行为一致)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"}
|
||||
EXCLUDE_FILE_NAMES = {".env", ".env.local"}
|
||||
KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
TEMPLATE_PLACEHOLDER_SLUG_KEBAB = "query-payslip-default"
|
||||
TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE = "query_payslip_default"
|
||||
TEXT_FILE_SUFFIXES = {
|
||||
".md",
|
||||
".py",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".txt",
|
||||
".ini",
|
||||
".ps1",
|
||||
".sample",
|
||||
".env.example",
|
||||
}
|
||||
|
||||
|
||||
def _ignore_copy(_dir: str, names: list[str]) -> set[str]:
|
||||
ignored: set[str] = set()
|
||||
for name in names:
|
||||
if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES:
|
||||
ignored.add(name)
|
||||
elif name.endswith(".pyc"):
|
||||
ignored.add(name)
|
||||
return ignored
|
||||
|
||||
|
||||
def _should_replace_placeholders(path: Path) -> bool:
|
||||
if path.name in EXCLUDE_FILE_NAMES:
|
||||
return False
|
||||
if path.suffix.lower() in TEXT_FILE_SUFFIXES:
|
||||
return True
|
||||
if path.name == ".env.example":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _slug_to_underscore(slug: str) -> str:
|
||||
return slug.replace("-", "_")
|
||||
|
||||
|
||||
def _replace_template_placeholders(target: Path, slug: str) -> None:
|
||||
"""将模板占位 slug 替换为新技能 slug(不修改 action 业务命令结构)。"""
|
||||
underscore_slug = _slug_to_underscore(slug)
|
||||
for path in target.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name in EXCLUDE_FILE_NAMES:
|
||||
continue
|
||||
if path.suffix.lower() not in TEXT_FILE_SUFFIXES and path.name != ".env.example":
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if (
|
||||
TEMPLATE_PLACEHOLDER_SLUG_KEBAB not in text
|
||||
and TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE not in text
|
||||
):
|
||||
continue
|
||||
updated = text.replace(TEMPLATE_PLACEHOLDER_SLUG_KEBAB, slug)
|
||||
updated = updated.replace(TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE, underscore_slug)
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def _print_next_steps(target: Path) -> None:
|
||||
print()
|
||||
print("=== 脚手架复制完成 ===")
|
||||
print(f"目标目录: {target}")
|
||||
print()
|
||||
print("请在本机继续执行(未自动 git init,避免误操作):")
|
||||
print(f' cd "{target}"')
|
||||
print(" Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue # 若曾手工复制漏删")
|
||||
print(" git init")
|
||||
print(" git remote add origin <你的新技能仓库 URL>")
|
||||
print(" git remote -v # 确认 remote 不是 skill-template")
|
||||
print(" # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件")
|
||||
print(" python tests/run_tests.py -v")
|
||||
print()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="从 skill-template 脚手架复制新技能目录")
|
||||
parser.add_argument("--slug", required=True, help="kebab-case slug,须与目标目录名一致")
|
||||
parser.add_argument("--destination", required=True, help="新技能完整目标路径")
|
||||
parser.add_argument("--force", action="store_true", help="目标非空时允许覆盖(目标不得含 .git)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
slug = args.slug.strip()
|
||||
if not KEBAB_SLUG.match(slug):
|
||||
print(f"ERROR: Slug 必须是 kebab-case,当前: {slug}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
target = Path(args.destination).resolve()
|
||||
if target.name != slug:
|
||||
print(
|
||||
f"ERROR: 目标目录末段 '{target.name}' 与 slug '{slug}' 不一致。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
parent = target.parent
|
||||
if not parent.is_dir():
|
||||
print(f"ERROR: 父目录不存在: {parent}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if (target / ".git").exists():
|
||||
print(
|
||||
f"ERROR: 目标已存在 .git,请先删除: {target / '.git'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
template_root = Path(__file__).resolve().parent.parent
|
||||
if target.exists():
|
||||
entries = list(target.iterdir())
|
||||
if entries and not args.force:
|
||||
print(
|
||||
f"ERROR: 目标目录已存在且非空: {target}\n请加 --force 或换空目录。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
print(f"复制模板: {template_root} -> {target}")
|
||||
for item in template_root.iterdir():
|
||||
name = item.name
|
||||
if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES:
|
||||
continue
|
||||
if name.endswith(".pyc"):
|
||||
continue
|
||||
dest = target / name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, dest, ignore=_ignore_copy, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(item, dest)
|
||||
|
||||
marker = target / ".openclaw-skill-template"
|
||||
if marker.is_file():
|
||||
marker.unlink()
|
||||
|
||||
_replace_template_placeholders(target, slug)
|
||||
|
||||
_print_next_steps(target)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
57
tools/verify_highlight.py
Normal file
57
tools/verify_highlight.py
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify highlighted answer output file."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx")
|
||||
src = Path(r"D:\下载\安全知识竞赛题库(1).xls")
|
||||
|
||||
print(f"Source exists: {src.exists()}")
|
||||
print(f"Output exists: {dst.exists()}")
|
||||
|
||||
wb = openpyxl.load_workbook(dst, rich_text=True)
|
||||
ws = wb.active
|
||||
print(f"Sheet: {ws.title}, rows={ws.max_row}, cols={ws.max_column}")
|
||||
|
||||
# Find header columns
|
||||
header_row = None
|
||||
options_col = None
|
||||
answer_col = None
|
||||
for row_idx in range(1, 20):
|
||||
for col_idx in range(1, ws.max_column + 1):
|
||||
val = ws.cell(row=row_idx, column=col_idx).value
|
||||
if val and str(val).strip() == "题目选项":
|
||||
options_col = col_idx
|
||||
if val and str(val).strip() == "题目答案":
|
||||
answer_col = col_idx
|
||||
if options_col and answer_col:
|
||||
header_row = row_idx
|
||||
break
|
||||
|
||||
print(f"Header row={header_row}, options_col={options_col}, answer_col={answer_col}")
|
||||
|
||||
# Sample verification for first 5 rows with answers 1,2,3
|
||||
samples = []
|
||||
for row_idx in range(header_row + 1, ws.max_row + 1):
|
||||
answer = ws.cell(row=row_idx, column=answer_col).value
|
||||
if answer in ("1", "2", "3", 1, 2, 3):
|
||||
cell = ws.cell(row=row_idx, column=options_col)
|
||||
val = cell.value
|
||||
runs = []
|
||||
if hasattr(val, "__iter__") and not isinstance(val, str):
|
||||
for block in val:
|
||||
font = block.font
|
||||
bold = font.b if font else None
|
||||
color = font.color if font else None
|
||||
runs.append((block.text, bold, color))
|
||||
samples.append((row_idx, answer, runs))
|
||||
if len(samples) >= 5:
|
||||
break
|
||||
|
||||
for row_idx, answer, runs in samples:
|
||||
print(f"\nRow {row_idx}, answer={answer}:")
|
||||
for text, bold, color in runs:
|
||||
marker = "RED_BOLD" if bold and color == "FF0000" else "normal"
|
||||
print(f" [{marker}] {repr(text[:50])}")
|
||||
Reference in New Issue
Block a user