
Software Copyright Materials
- 188 installs
- 4.7k repo stars
- Updated July 18, 2026
- fokkyp/softwarecopyright-skill
Turn a real codebase in the current directory into draft China software copyright (软著) application Word and TXT materials with官网-aligned field limits.
About
software-copyright-materials is an agent skill for developers filing Chinese software copyright (软件著作权) from an existing repository. It reads the project in the working directory, drafts application content against the official form field sequence—classification, development and publication dates, rights scope, hardware and OS environments, language, total source lines, purpose, industry, main functions, technical characteristics, and page counts—and asks the builder to explicitly confirm name, version, copyright holder, and publication status before generating Word and TXT deliverables. Solo founders use it to avoid retyping boilerplate and blowing field length limits while still keeping legally sensitive answers human-verified. The skill defaults sensible choices (e.g. 应用软件, 单独开发, 原创) but never substitutes user confirmation on titles, versions, or identity documents. It is optimized for mainland registration workflows, not generic OSS licensing paperwork.
- Maps outputs to官网软著申请表 field order and naming (软件全称, 版本号, 著作权人, etc.)
- Enforces character limits (e.g. 主要功能 500–1300, 技术特点 ≤100) and date format YYYY-MM-DD
- Warns when project version is below V1.0 and prompts confirm V1.0 vs current version for first filing
- Derives programming language, source line count, modules, and tech tags from project analysis
- Exports confirmed drafts to Word and TXT including code identification materials with consistent 软件全称 in headers
Software Copyright Materials by the numbers
- 188 all-time installs (skills.sh)
- Ranked #255 of 688 Office & Documents skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/fokkyp/softwarecopyright-skill --skill software-copyright-materialsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 188 |
|---|---|
| repo stars | ★ 4.7k |
| Security audit | 1 / 3 scanners passed |
| Last updated | July 18, 2026 |
| Repository | fokkyp/softwarecopyright-skill ↗ |
What it does
Turn a real codebase in the current directory into draft China software copyright (软著) application Word and TXT materials with官网-aligned field limits.
Files
软著申请资料生成
这个 skill 生成可审阅、可追溯的软著申请资料。核心原则:
- 固定输出目录:当前工作目录下的
软件著作权申请资料/。不要默认写到/tmp、/private/tmp或其他临时目录。 - 只有测试 skill 自身时才允许显式指定临时目录;面向用户生成材料时必须写入当前目录。
- 先生成 Markdown 草稿,用户确认后再生成正式 Word/TXT。
- 正式 Word/TXT 只能写入
软件著作权申请资料/正式资料/,不要散落在输出目录根部。 - 正式 Word/TXT 的文字一律使用默认黑色字体,不生成蓝色超链接、主题色标题或其他彩色文字;Markdown 链接写入 Word 时必须转成普通文本。
- 正式资料中的软件名称必须与
草稿/申请表信息.md的“软件全称”字段一致;正式生成时以已确认的申请表软件全称为准。 - 正式代码 Word 页眉中的版本号必须与
草稿/申请表信息.md的“版本号”字段一致;正式生成时以已确认的申请表版本号为准。 - 代码材料必须来自真实项目源码,禁止 AI 编造代码。
- 写申请表和操作手册前,必须先形成模型研判后的
草稿/业务理解.md/json,理解软件业务、行业、目标用户、核心价值和操作流程。 - 脚本只能收集项目证据、校验字段和生成文件;行业判断、功能抽取、代码抽取选择、操作手册结构必须由模型阅读项目后决定,不得依赖脚本关键字表或固定范本。
- 优先抽取前端代码:入口、路由、页面、核心组件、接口封装、状态管理、工具函数。
- 生成代码材料前,必须先生成代码文件候选清单;模型理解项目后填写抽取文件和选择理由,再让用户确认或修改。
- 代码优先抽取模型和用户确认的、最能体现软件真实功能和运行逻辑的源码;不足 60 页时,从其他相关源码文件补充到 60 页;候选源码仍不足 60 页时,才生成全部代码文档。
- 操作手册成稿应像真实软件随附的操作说明,而不是研发说明、功能清单或 AI 生成的汇总文。
- 操作手册草稿必须按传统软著操作手册骨架组织:相关文档、说明、功能特点、系统要求、按真实页面/流程逐章操作、常见问题解答、术语表。一级章节标题使用中文大写序号,例如
一、相关文档,不得使用(1)、相关文档。相关文档必须用表格指向总体设计、详细设计、测试案例等配套文档。正文尽量使用连续段落,不使用项目符号列表或1. 2. 3.编号列表。 - 每个核心页面都要用普通用户视角说明页面用途、进入位置、用户可见内容、用户动作、输入限制或异常提示、结果反馈和截图预留。不得把章节写成“进入方式:/页面内容:/操作步骤:/操作规则:/操作结果与反馈:”这种字段模板;这些信息要自然合并到段落里。避免代码、框架、接口、状态管理、异步任务等技术化表达;撰写过程中由 agent 自行循环检查、扩写和修正,完整草稿完成后只向用户发起一次整体确认。
- 操作手册必须去除明显“AI 味”:避免空泛赞美、营销口号、万能句式、每章同一结构、头中尾固定结构、过度对称的排比、没有项目细节的正确废话、频繁使用“旨在、赋能、一站式、智能化、高效便捷、显著提升、强大能力、丰富功能”等套话。每段都应能回答“这个项目里这个功能具体做什么、用户看见什么、操作后有什么结果”。
- 操作手册生成必须同步输出
草稿/操作手册自检记录.md和草稿/操作手册自检记录.json,记录初稿、按项目流程扩写、去制式表达等自检轮次;如果前 3 轮仍发现问题,必须继续补写修正,直到问题清零或记录无法自动修复的原因后再停止。 - 截图方式必须先让用户选择:Chrome DevTools MCP、Codex Computer Use、用户自行截图。用户选完后,再检查当前 MCP / Computer Use 能力是否可用;如果用户说现在不截图、先跳过截图或截图失败,操作手册仍必须保留清晰可见的截图预留位置,正式 Word 中也要能看到。
- 申请表信息中的硬件/系统环境必须让用户确认或填写,不能硬编码。
- Word 生成能力必须使用本 skill 内置的
vendor/docx-toolkit;不得引用外部 DOCX 目录。
强制人工门禁
凡是涉及用户选择、确认或补充信息的阶段,必须先停止当前执行,不得继续调用下一步脚本。即使处于自动审核、自动继续或无人值守模式,也必须把 STOP_FOR_USER 和 NEXT_ACTION 原样告知用户,并等待用户输入后再继续。
禁止使用“用户未选择则默认继续”的逻辑。用户回复确认后,先用确认脚本记录对应门禁,再进入下一阶段:
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py --workdir 软件著作权申请资料 --stage <阶段名> --note "<用户确认内容>"必须停住的门禁:
environment:完整 DOCX 环境缺失时,用户必须选择“安装完整环境”或“使用基础 DOCX 兜底继续”。project:存在多个项目候选目录时,用户必须指定项目目录。business:草稿/业务理解.md生成后,用户必须确认行业、目标用户、核心功能和申请口径。application-fields:草稿/申请表信息.md生成后,用户必须补全并确认硬件、系统环境、著作权人、日期等字段。code-selection:草稿/代码文件选择.json生成后,用户必须确认或修改抽取文件。screenshot-method:操作手册截图前,用户必须在 Chrome DevTools MCP、Codex Computer Use、用户自行截图三种方式中选择一种;如果用户明确说“现在不截图/先跳过截图”,记录为skip。markdown:全部 Markdown 草稿完成后,用户必须确认可以进入 Word/TXT 生成。
工作流
1. 启动环境检查
一开始先在当前工作目录创建输出目录并检查运行能力:
python3 ${CLAUDE_SKILL_DIR}/scripts/check_environment.py \
--out-dir 软件著作权申请资料输出:
软件著作权申请资料/环境检查.md软件著作权申请资料/环境检查.json
环境检查必须告诉用户:
- 当前会在“当前目录/软件著作权申请资料”下生成材料。
- Markdown 草稿、TXT、基础 DOCX 是否可用。
- 内置
vendor/docx-toolkit的完整 OpenXML 环境是否可用。 - 如
.NET SDK缺失,询问用户是否安装完整环境。
用户选择:
- 如果用户愿意安装完整环境,按
${CLAUDE_SKILL_DIR}/vendor/docx-toolkit/scripts/setup.sh的要求安装依赖,再继续。完整环境生成和校验更规范。 - 如果用户不安装,继续使用兜底方案生成 Markdown、TXT 和基础 DOCX。
- 如果完整 DOCX 环境缺失,必须停止并等待用户选择;不得自动继续。
用户回复后记录门禁:
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
--workdir 软件著作权申请资料 \
--stage environment \
--note "<用户选择>"不要等到最后验证阶段才发现完整 DOCX 环境不可用;这个信息必须在流程开始时给出。
2. 定位项目
用户通常会把项目放在当前文件夹下。先扫描当前目录,避开本 skill、自身输出目录、node_modules、构建产物和隐藏目录,找到最可能的项目根目录。
如果有多个候选项目,必须停止并询问用户选择;如果只有一个明显候选项目,可以直接使用。
3. 分析项目
运行:
python3 ${CLAUDE_SKILL_DIR}/scripts/analyze_project.py \
--project <项目目录> \
--out 软件著作权申请资料/analysis/project.json分析内容包括:
package.json、README、脚本命令、依赖- 前端框架和主要编程语言
- 入口文件、路由、页面、组件、接口、状态管理
- 源码文件数量和源程序行数
- 软件名称候选、主要功能候选、运行命令候选
4. 形成业务理解
在写申请表和操作手册前,先让脚本收集项目证据:
python3 ${CLAUDE_SKILL_DIR}/scripts/generate_business_context.py \
--project <项目目录> \
--analysis 软件著作权申请资料/analysis/project.json \
--software-name "<软件全称>" \
--out-dir 软件著作权申请资料/草稿输出:
草稿/业务理解证据.md草稿/业务理解证据.json草稿/业务理解模型稿模板.json
这一步只收集证据,不决定最终业务口径。接下来必须由模型阅读 业务理解证据.md/json、README、PRD/BRD、页面文案、路由、接口、必要源码和用户补充资料,自行判断:
- 应该重点读取哪些文档和源码
- 软件属于什么行业 / 领域
- 目标用户是谁
- 核心价值是什么
- 哪些功能应写入软著申请资料
- 典型操作流程如何组织
- 操作手册适合采用什么章节结构
- 申请表建议口径如何表达
模型不得用脚本关键字表决定行业、功能和结构;不得把用户给的范本文案、测试项目名称、测试项目流程写成通用规则。
模型完成研判后,生成一个业务理解模型稿 JSON,字段至少包含:
product_positioningindustrytarget_userscore_valuebusiness_featuresbusiness_feature_detailsoperation_flowapplication_purposemain_functionstechnical_characteristicsmanual_sectionsmanual_modulessystem_requirementsfaqglossary
其中 manual_modules 是操作手册的核心输入,必须按真实页面、导航入口或业务流程填写。脚本不得按 auth/query/form 等分类模板自动补入口、步骤或反馈;缺少 manual_modules 或关键字段时必须停止让模型回到项目证据中补写。每个模块必须包含:
title:页面或流程名称。evidence:对应页面、路由、组件、需求文档或 README 证据。purpose:该页面在软件中的用途。usage或usage_scenario:用户在什么业务场景下会使用该页面,正在处理什么具体事务。缺少时不得生成操作手册。entry:用户从哪里进入该页面。visible_elements:用户实际能看到的输入框、按钮、列表、标签、状态或结果区域。operation_steps:按真实页面顺序写用户动作,不能写代码实现。validation_rules:必填项、长度限制、权限、额度、状态、异常提示等规则;没有则留空数组。feedback:操作完成后用户能看到的结果、提示或状态变化。screenshot:截图预留说明。
manual_sections 只允许补充当前软件本身的用途、业务场景、页面组织或用户流程,不要写“本操作手册用于……”“面向软著审核……”“不描述代码实现……”这类解释文档写作方式的元话语。最终操作手册应像真实软件说明书,而不是生成过程说明。
然后运行:
python3 ${CLAUDE_SKILL_DIR}/scripts/generate_business_context.py \
--project <项目目录> \
--analysis 软件著作权申请资料/analysis/project.json \
--software-name "<软件全称>" \
--out-dir 软件著作权申请资料/草稿 \
--model-context <模型生成的业务理解JSON>输出:
草稿/业务理解.md草稿/业务理解.json
最终业务理解必须覆盖:
- 产品定位
- 面向领域 / 行业
- 目标用户
- 核心价值
- 主要业务功能
- 典型操作流程
- 申请表建议口径
- 证据来源
- 操作手册结构建议
如果项目材料不足、业务类型较新,或用户明确希望参考竞品,可联网搜索相近产品和行业资料;外部调研只用于理解行业表达,不能编造项目不存在的功能。调研摘要应写入业务理解草稿,并区分“项目证据”和“行业参考”。
生成 业务理解.md/json 后必须停止,等待用户确认或修改。业务理解确认前,不得生成申请表和操作手册。如果业务理解仍不充分,先请用户补充产品说明。用户确认后运行:
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
--workdir 软件著作权申请资料 \
--stage business \
--note "<用户确认内容>"5. 引导用户确认字段
根据分析结果,向用户确认以下字段(按官网实际表单顺序):
- 软件全称
- 软件简称(可选)
- 版本号
- 软件分类(应用软件/嵌入式软件/中间件/系统软件/其他)
- 开发完成日期(YYYY-MM-DD 格式)
- 开发方式(单独开发/合作开发/委托开发/下达任务开发)
- 软件说明(原创 / 修改(含翻译软件、合成软件))
- 发表状态(已发表/未发表)
- 首次发表日期(已发表时填写,YYYY-MM-DD 格式;未发表则留空)
- 著作权人(复合字段:国家/省市/类型[自然人/法人]/姓名/证件类型/证件号)
- 权利范围(全部权利/部分权利)
- 权利取得方式(原始取得/继受取得)
- 开发的硬件环境(≤50字符)
- 运行的硬件环境(≤50字符)
- 开发该软件的操作系统(≤50字符)
- 软件开发环境 / 开发工具(≤50字符,格式:开发环境: xxx/开发工具: xxx)
- 该软件的运行平台 / 操作系统(≤50字符)
- 软件运行支撑环境 / 支持软件(≤50字符)
- 编程语言(预设按钮选择 + 自定义输入≤120字符)
- 源程序量(纯数字,单位为行,指全部源程序总行数)
- 开发目的(≤50字符)
- 面向领域 / 行业(≤50字符)
- 软件的主要功能(500~1300字符)
- 软件的技术特点(多选标签 + 文本描述≤100字符;标签:APP/游戏软件/教育软件/金融软件/医疗软件/地理信息软件/云计算软件/信息安全软件/大数据软件/人工智能软件/VR软件/5G软件/小程序/物联网软件/智慧城市软件,都不符合时可不选)
- 页数(代码鉴别材料实际页数)
项目可推断字段可以先给建议值;硬件/系统环境必须允许用户选择建议值或手动填写。字段口径必须区分清楚:
- 软件全称:必须由用户确认。最终正式资料文件名、代码 Word 页眉、操作手册标题和正文中的软件名称,都必须与
申请表信息.md的"软件全称"字段一致。 - 软件简称:可选字段,如有常用简称则填写。
- 版本号:必须由用户确认。优先读取项目配置中的版本号作为证据;如果项目版本号小于 V1.0(例如 V0.1.0、V0.9.0),必须明确询问用户"软著首次提交通常写 V1.0,本次填写 V1.0 还是项目当前版本号"。最终
申请表信息.md的"版本号"字段就是正式资料版本号。 - 软件分类:应用软件/嵌入式软件/中间件/系统软件/其他,默认为应用软件。
- 开发完成日期、首次发表日期:必须使用 YYYY-MM-DD 格式。
- 开发方式:单独开发/合作开发/委托开发/下达任务开发,默认单独开发。
- 软件说明:原创 / 修改(含翻译软件、合成软件),默认原创。
- 发表状态:已发表或未发表;已发表需附首次发表日期,未发表则首次发表日期留空。
- 软件开发环境 / 开发工具:≤50字符,格式为
开发环境: <操作系统>/开发工具: <IDE名称>,例如开发环境: Windows 11/开发工具: Visual Studio Code;不要把 React、Next.js、Vite、TypeScript 等技术栈写到此字段。 - 开发该软件的操作系统:≤50字符,填写实际开发电脑的操作系统版本,例如 Windows 10、Windows 11、macOS 14、macOS 15。
- 该软件的运行平台 / 操作系统:≤50字符,填写软件运行所在的操作系统或浏览器环境。
- 软件运行支撑环境 / 支持软件:≤50字符,直接列出运行依赖(如 Node.js、npm、浏览器),不加格式前缀。
- 开发的硬件环境:≤50字符,优先读取当前电脑 CPU、内存、硬盘配置作为建议值;读取不到时让用户填写。
- 运行的硬件环境:≤50字符,默认可沿用开发硬件环境建议值,也可以按实际部署或运行设备修改。
- 源程序量:纯数字(不含"行"字),指登记软件全部源程序的总行数。
- 开发目的:≤50字符,用一句话说明软件开发目的,不能只写软件名称。
- 面向领域 / 行业:≤50字符。
- 软件的主要功能:500~1300字符,详细描述软件核心功能。
- 软件的技术特点:多选标签(APP/游戏软件/教育软件等)+ 文本描述≤100字符,简述技术架构和关键技术;标签都不符合时可不选。
此阶段需要先停止等待用户输入;收到用户回复后,可整理为 answers JSON 传入申请表草稿生成。申请表字段的最终门禁在 草稿/申请表信息.md 生成后记录。
6. 确认代码文件选择
生成代码材料前,先运行候选文件分析:
python3 ${CLAUDE_SKILL_DIR}/scripts/propose_code_selection.py \
--project <项目目录> \
--analysis 软件著作权申请资料/analysis/project.json \
--out-dir 软件著作权申请资料/草稿输出:
草稿/代码文件候选清单.md:给用户看的候选说明。草稿/代码文件选择.json:可编辑的选择文件。
脚本生成的候选清单只列证据,不默认选择文件。模型必须先阅读业务理解、候选文件、入口文件、页面文件和必要源码,判断哪些源码最能体现软件真实功能和运行逻辑,然后修改 代码文件选择.json:
selected: true表示抽取该文件。selected: false表示不抽取该文件。model_reason必须说明为什么选择该文件。
模型选择通常优先考虑前端入口、页面、核心组件、业务交互、数据请求、状态处理等能给审核员看懂软件功能的代码;如果相关前端代码不足 60 页,再补充后端服务、业务处理等相关源码。补充文件同样必须写入 代码文件选择.json 并由用户确认。不要默认抽取全量代码库。代码材料按完整文件原样复制,不支持只抽取某个文件的中间行段。用户确认并记录 code-selection 门禁后,代码抽取只读取 代码文件选择.json 中选中的完整文件。用户确认后运行:
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
--workdir 软件著作权申请资料 \
--stage code-selection \
--note "<用户确认内容>"7. 生成 Markdown 草稿
运行代码材料抽取:
python3 ${CLAUDE_SKILL_DIR}/scripts/extract_code_material.py \
--project <项目目录> \
--analysis 软件著作权申请资料/analysis/project.json \
--selection 软件著作权申请资料/草稿/代码文件选择.json \
--software-name "<软件全称>" \
--version "<版本号>" \
--out-dir 软件著作权申请资料/草稿代码分页规则:
- 每页默认 50 行,并在 Word 中使用紧凑固定行距,尽量减少长行折行造成的页面溢出。
- 总页数
>= 60:生成代码-前30页.md和代码-后30页.md。 - 总页数
< 60且候选源码已用尽:只生成代码-全部.md。 - 总页数
< 60但候选清单还有可补充源码:停止并要求用户在代码文件选择.json中继续选择补充文件。 - 不为大项目生成超大“全量备份 Word”。
- 同时生成
代码提取清单.md和代码提取清单.json,用于追溯代码来源。
生成申请表信息草稿:
python3 ${CLAUDE_SKILL_DIR}/scripts/generate_application_info.py \
--analysis 软件著作权申请资料/analysis/project.json \
--code-manifest 软件著作权申请资料/草稿/代码提取清单.json \
--business-context 软件著作权申请资料/草稿/业务理解.json \
--software-name "<软件全称>" \
--version "<版本号>" \
--out-dir 软件著作权申请资料/草稿生成后必须停止,让用户检查并补全 草稿/申请表信息.md。字段补全并确认后运行:
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
--workdir 软件著作权申请资料 \
--stage application-fields \
--note "<用户确认内容>"生成操作手册草稿:
python3 ${CLAUDE_SKILL_DIR}/scripts/generate_manual_draft.py \
--analysis 软件著作权申请资料/analysis/project.json \
--business-context 软件著作权申请资料/草稿/业务理解.json \
--software-name "<软件全称>" \
--version "<版本号>" \
--out-dir 软件著作权申请资料/草稿操作手册草稿不得照抄用户提供的范本文案或旧项目内容,但应吸收其结构特点:先写相关文档、说明、功能特点和系统要求,再按真实页面或核心流程逐章说明操作,最后写常见问题解答和术语表。一级章节标题使用中文大写序号;相关文档章节必须是表格;功能特点和页面操作章节必须以段落展开,不用项目符号和编号列表堆信息。必须基于模型写入 草稿/业务理解.json 的 manual_modules 组织章节;manual_sections 只用于补充说明性段落,不应用来反复插入同一批功能模块。各功能章节必须写清页面用途、进入位置、用户看到的控件和数据、实际操作、输入限制或异常提示、操作结果和截图预留。语言要面向普通用户,说明“这个页面是干嘛的、用户怎么进入、用户点什么/填什么、操作后看到什么”,不要写代码实现、框架名称、接口封装、状态管理、异步队列等技术细节。撰写时由 agent 自行检查章节是否完整、内容是否过薄、语言是否过于技术化,并在草稿内部完成必要补写;完整草稿完成后只让用户做一次整体确认,确认前不得进入正式 Word/TXT 生成。
生成脚本必须同时写出 草稿/操作手册自检记录.md 和 草稿/操作手册自检记录.json。自检记录至少包含:
- 第 1 轮:初稿生成,检查章节完整性、截图预留、模块内容厚度和技术化表达。
- 第 2 轮:按项目真实运行流程扩写模块说明,补足上下游衔接关系。
- 第 3 轮:去除制式表达和 AI 味,重点检查重复句式、统一套话、空泛赞美、营销口号、过度整齐的排比和没有项目细节的正确废话。
- 后续轮次:如果仍有问题,继续补写、去重、改写,不能把未修正的问题直接交给用户。
操作手册的模块写作必须从 草稿/业务理解.json 的行业、目标用户、核心价值、业务功能、典型操作流程和 manual_modules 出发。不同模块要写出各自的业务作用、入口、控件、规则和反馈,不能统一套用“进入页面、填写内容、提交按钮、查看结果”的固定句式,也不能使用“进入方式:/页面内容:/操作步骤:/操作规则:/操作结果与反馈:”这类字段标题;相近模块也要结合项目真实业务区分各自的操作目的和结果。自检时必须检查是否把同一批模块在多个章节中重复展开;如发现重复,改为每个真实页面或流程独立成章。不得把测试项目的功能名称、业务流程或示例文案写成通用规则。
8. 选择并获取截图
操作手册草稿完成后,先停止并让用户选择截图方式,必须给出三种选项:
1. Chrome DevTools MCP:适合已在浏览器中打开的 Web 项目,优先用于网页全页截图。 2. Codex Computer Use:适合需要通过桌面应用或浏览器界面点击、切换、查看状态后截图的场景。 3. 用户自行截图:用户自己把 PNG/JPG/JPEG/WebP 图片放入 软件著作权申请资料/用户截图/,agent 只负责整理和引用。
如果用户明确说“现在不截图”“先跳过截图”“这次不截图”,也必须记录截图方式门禁,方法填 skip。跳过截图不阻塞正式资料生成,但操作手册中每个核心功能模块必须保留可见的截图预留文字,例如:【截图预留:请在此处插入“项目管理”页面或操作结果截图。】。不要使用 HTML 注释作为截图占位,因为正式 Word 中看不到。
用户选择后,先记录门禁:
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
--workdir 软件著作权申请资料 \
--stage screenshot-method \
--method <chrome-devtools|computer-use|user-supplied|skip> \
--note "<用户选择>"然后按用户选择检查当前能力并执行:
- 选择 Chrome DevTools MCP:先用工具发现能力检查当前环境是否有
mcp__chrome_devtools__的list_pages、take_snapshot、take_screenshot。可用时,先list_pages确认当前浏览器页面,再按页面/路由截图保存到软件著作权申请资料/截图/;不可用时停止,告知用户需要重新选择截图方式或手动提供截图。 - 选择 Codex Computer Use:先用工具发现能力检查当前环境是否有
mcp__computer_use__的get_app_state、click、press_key。可用时,先get_app_state查看目标应用或浏览器当前状态,再按操作手册需要导航和截图;如果当前 Computer Use 只能返回会话内截图而不能直接保存图片文件,则说明限制,并让用户改选 Chrome DevTools MCP 或把截图放入用户截图/。 - 选择用户自行截图:创建
软件著作权申请资料/用户截图/,提示用户把截图文件放入该目录;用户放入后运行下面的整理命令,把图片复制到软件著作权申请资料/截图/并生成截图清单.json。 - 选择跳过截图:不运行截图工具,继续保留操作手册中的可见截图预留文字;在生成报告中说明用户选择暂不截图,正式操作手册已预留截图位置。
python3 ${CLAUDE_SKILL_DIR}/scripts/capture_screenshots.py \
--manual-dir 软件著作权申请资料/用户截图 \
--out-dir 软件著作权申请资料/截图截图成功后,把截图引用补入 草稿/操作手册.md;截图失败或用户选择暂不提供截图时,继续生成带截图预留位的文字版,并在报告中说明“操作手册截图未生成或未插入,已保留截图预留位置”。
9. 用户确认 Markdown
生成 Word 前,必须让用户确认 软件著作权申请资料/草稿/ 下的 Markdown。
重点检查:
- 软件名称和版本号是否一致
- 代码材料前30页、后30页页眉软件名称是否与
申请表信息.md的“软件全称”一致 - 代码材料前30页、后30页页眉版本号是否与
申请表信息.md的“版本号”一致 - 操作手册 Word 页眉是否与代码材料页眉一致,均使用
申请表信息.md的“软件全称”和“版本号” 业务理解.md是否准确反映软件真实业务、行业和目标用户申请表信息.md中“待用户确认”的字段是否已确认- 代码材料是否只来自用户确认的完整文件
- 操作手册是否符合审核员阅读场景,普通读者是否能看懂模块用途和操作方式
- 操作手册每个章节是否有段落内容,核心模块是否写清模块用途、操作过程和结果反馈,是否避免过度技术化语言
- 截图是否正确;若用户跳过截图,正式操作手册是否保留可见截图预留位置
用户确认后,必须记录 markdown 门禁;未记录时不得生成正式 Word/TXT。
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
--workdir 软件著作权申请资料 \
--stage markdown \
--note "<用户确认内容>"10. 生成正式 Word/TXT
用户确认后运行:
python3 ${CLAUDE_SKILL_DIR}/scripts/build_docx_from_md.py \
--workdir 软件著作权申请资料 \
--software-name "<软件全称>" \
--version "<版本号>"正式生成脚本必须重新读取 草稿/申请表信息.md 中已确认的“软件全称”和“版本号”,并用它们生成正式资料文件名、代码 Word 页眉和操作手册 Word 页眉。操作手册页眉必须与代码材料页眉格式一致:左侧为“软件全称 版本号”,右侧为“第 <页码> 页”。若命令参数 --software-name / --version 与申请表字段不同,以申请表字段为准,并在 正式资料/生成报告.md 中记录提示。
输出:
正式资料/申请表信息.txt- 代码达到或超过 60 页:
正式资料/<软件全称>-代码(前30页).docx正式资料/<软件全称>-代码(后30页).docx- 代码不足 60 页:
正式资料/<软件全称>-代码(全部).docx正式资料/<软件全称>_操作手册.docx正式资料/生成报告.md
11. 三轮验证
至少执行三轮验证并修复发现的问题:
1. 文件完整性:目标 Word/TXT 是否存在且非空。 2. 代码真实性:抽样检查代码片段能回溯到项目源码。 3. 业务真实性:申请表和操作手册中的行业、目标用户、主要功能、操作流程能回溯到 业务理解.md 和项目文档。 4. 一致性和格式:软件名称、版本号、页数规则、申请表字段、操作手册标题和截图引用是否一致。
可用命令:
python3 -m py_compile ${CLAUDE_SKILL_DIR}/scripts/*.py
bash ${CLAUDE_SKILL_DIR}/vendor/docx-toolkit/scripts/docx_preview.sh <生成的docx>完整 DOCX 环境检查和安装必须直接恢复/构建 ${CLAUDE_SKILL_DIR}/vendor/docx-toolkit/scripts/dotnet/DocxToolkit.Cli/DocxToolkit.Cli.csproj,不要对 vendor/docx-toolkit/scripts/dotnet 目录或 .slnx 文件执行隐式 restore/build。
如果 环境检查.md 或 ${CLAUDE_SKILL_DIR}/vendor/docx-toolkit/scripts/env_check.sh 显示 .NET SDK 缺失,说明完整 DOCX OpenXML 校验环境未就绪。用户明确选择不安装并记录 environment 门禁后,继续生成 Markdown、TXT 和基础 DOCX,并在报告中说明当前使用兜底路径。
何时询问用户
以下场景必须询问并停止,等待用户输入后再继续:
- 多个项目候选目录需要选择。
- 启动环境检查发现完整 DOCX 环境缺失时,询问用户是否安装完整环境。
- 业务理解草稿生成后,请用户确认软件用途、行业、目标用户、核心功能和申请口径。
- 软件全称、著作权人、日期、硬件/系统环境等登记字段需要确认。
- 代码文件候选清单生成后,需要用户确认或修改
代码文件选择.json。 - 操作手册截图前,需要用户在 Chrome DevTools MCP、Codex Computer Use、用户自行截图三种方式中选择一种;选择后再检查对应工具是否可用。
- 用户是否确认 Markdown 草稿并进入 Word 生成。
display_name: 软著申请资料生成
short_description: 从真实项目生成软著申请 Word 和 TXT 材料
default_prompt: 读取当前目录中的项目,生成软件著作权申请资料草稿,确认后输出 Word 和 TXT。
申请表信息字段
按官网实际表单顺序和字段名:
1. 软件全称 2. 软件简称(可选) 3. 版本号 4. 软件分类(应用软件/嵌入式软件/中间件/系统软件/其他) 5. 开发完成日期(YYYY-MM-DD) 6. 开发方式(单独开发/合作开发/委托开发/下达任务开发) 7. 软件说明(原创 / 修改(含翻译软件、合成软件)) 8. 发表状态(已发表/未发表) 9. 首次发表日期(已发表时填写,YYYY-MM-DD) 10. 著作权人(复合字段:国家/省市/类型[自然人/法人]/姓名/证件类型/证件号) 11. 权利范围(全部权利/部分权利) 12. 权利取得方式(原始取得/继受取得) 13. 开发的硬件环境(≤50字符) 14. 运行的硬件环境(≤50字符) 15. 开发该软件的操作系统(≤50字符) 16. 软件开发环境 / 开发工具(≤50字符,格式:开发环境: xxx/开发工具: xxx) 17. 该软件的运行平台 / 操作系统(≤50字符) 18. 软件运行支撑环境 / 支持软件(≤50字符) 19. 编程语言(预设按钮选择 + 自定义输入≤120字符) 20. 源程序量(纯数字,单位行,指全部源程序总行数) 21. 开发目的(≤50字符,不能只写软件名称) 22. 面向领域 / 行业(≤50字符) 23. 软件的主要功能(500~1300字符) 24. 软件的技术特点(多选标签 + 文本描述≤100字符;标签:APP/游戏软件/教育软件/金融软件/医疗软件/地理信息软件/云计算软件/信息安全软件/大数据软件/人工智能软件/VR软件/5G软件/小程序/物联网软件/智慧城市软件,都不符合时可不选) 25. 页数(代码鉴别材料实际页数)
字段来源与填写口径
- 软件全称、版本号、著作权人、日期:由用户确认。
- 软件全称必须显式确认;正式资料文件名、代码 Word 页眉、操作手册标题和正文中的软件名称均以申请表信息中的"软件全称"为准。
- 软件简称:可选;如有常用简称则填写。
- 版本号必须显式确认;如果项目配置中的版本号小于 V1.0,需提醒用户软著首次提交通常写 V1.0,并让用户确认填写 V1.0 还是项目当前版本号。
- 软件分类:默认选"应用软件"。
- 开发完成日期和首次发表日期:必须使用 YYYY-MM-DD 格式。
- 开发方式:默认"单独开发",多人合作项目选"合作开发"。
- 软件说明:默认"原创"。
- 发表状态:用户确认已发表或未发表;已发表需附首次发表日期。
- 编程语言、源程序量、功能模块、技术特点:根据项目分析生成。编程语言官网为预设按钮选择 + 自定义输入(≤120字符),预设选项包括 Assembly language、C、C#、C++、Delphi/Object Pascal、Go、HTML、Java、JavaScript、MATLAB、Objective-C、PHP、PL/SQL、Perl、Python、R、Ruby、SQL、Swift、Visual Basic、Visual Basic .Net。
- 源程序量:只填纯数字(不含"行"字),指登记软件全部源程序的总行数(非仅代码材料抽取行数)。
- 软件开发环境 / 开发工具:≤50字符,格式
开发环境: <OS>/开发工具: <IDE>,例如开发环境: Windows 11/开发工具: Visual Studio Code;不要填写 React、Next.js、Vite、TypeScript 等技术栈。 - 开发该软件的操作系统:≤50字符,填写实际开发电脑的操作系统版本。
- 该软件的运行平台 / 操作系统:≤50字符,填写软件运行所在的操作系统或浏览器环境。
- 软件运行支撑环境 / 支持软件:≤50字符,直接列出运行依赖(如 Node.js、npm、浏览器),不加格式前缀。
- 开发的硬件环境:≤50字符,优先读取当前电脑 CPU、内存、硬盘配置作为建议值。
- 运行的硬件环境:≤50字符,默认可沿用开发硬件建议值,也可按实际运行设备填写。
- 开发目的:≤50字符,用一句话说明软件开发目的,不能只写软件名称。
- 面向领域 / 行业:≤50字符。
- 软件的主要功能:500~1300字符,详细描述软件核心功能。
- 软件的技术特点:多选标签(APP/游戏软件/教育软件等)+ 文本描述≤100字符;标签都不符合时可不选。
一致性要求
- 软件全称和版本号必须与代码材料、操作手册一致。
- 正式代码 Word 页眉软件名称必须与申请表信息中的“软件全称”一致,生成 Word 时以申请表软件全称为准。
- 正式代码 Word 页眉版本号必须与申请表信息中的“版本号”一致,生成 Word 时以申请表版本号为准。
- 主要功能必须来自当前项目,不得沿用范本中的旧项目描述。
待用户确认字段在正式输出前应尽量替换为确认值;如仍存在,必须写入生成报告。
业务理解规则
申请表信息和操作手册不能只根据代码结构泛泛生成,必须先理解软件业务。
证据收集
先用脚本收集证据,输出 草稿/业务理解证据.md/json 和 草稿/业务理解模型稿模板.json。证据通常包括:
README.mddocs/*PRD*.mddocs/*BRD*.mddocs/*ARCHITECTURE*.md- 产品说明、需求文档、设计文档
- 前端页面标题、按钮文案、路由、核心组件名
- 后端 API 路由和模型名称
这些只是候选证据,不代表最终行业、功能和手册结构。
输出业务理解草稿
模型必须阅读证据和必要源码,自行判断应该抽取哪些业务信息,再生成业务理解模型稿 JSON。不得用关键字表或固定模板决定行业、功能和结构。
模型稿经脚本校验后生成 草稿/业务理解.md 和 草稿/业务理解.json,至少包含:
- 产品定位
- 面向领域 / 行业
- 目标用户
- 用户痛点和核心价值
- 主要业务功能
- 典型操作流程
- 操作手册结构建议
- 操作手册页面/流程模块,必须说明每个真实页面或核心流程的使用场景、进入位置、用户可见元素、用户动作、输入/状态规则、结果反馈和截图预留
- 申请表建议口径
- 证据来源
- 待用户确认项
外部调研
如果项目材料不足、业务类型较新,或用户明确希望参考竞品,可联网搜索相近产品和行业资料。
外部调研只用于帮助理解行业表达,不能编造项目不存在的功能。需要把调研结论写入业务理解草稿,并区分“项目证据”和“行业参考”。
生成材料约束
申请表信息.md/txt的开发目的、行业、主要功能、技术特点必须优先来自业务理解。操作手册.md/docx的说明、功能特点、系统要求、核心页面/流程、常见问题、术语表和章节结构必须优先来自模型确认后的业务理解。- 操作手册不应只生成抽象“功能列表”。模型应把路由、页面、按钮、输入框、列表、弹窗、状态提示、额度或权限规则等用户可见证据整理到
manual_modules,供脚本按通用操作手册骨架排版。最终成稿应是段落化用户手册,不是“进入方式/页面内容/操作步骤/结果反馈”的字段列表。 - 如果缺少
manual_modules、system_requirements、faq或glossary,应回到业务理解阶段补充真实内容;脚本不得用分类模板兜底生成。 - 如果业务理解仍不充分,先提示用户补充产品说明,而不是直接生成泛泛描述。
代码抽取规则
选择方式
脚本只生成候选源码清单,不默认决定抽取文件。模型需要先理解项目业务、页面入口和源码职责,再决定抽取哪些文件或行段,并在 代码文件选择.json 中填写:
selectedstart_lineend_linemodel_reason
选择时通常优先考虑审核员能看懂软件功能和运行逻辑的源码,例如入口、页面、业务组件、数据交互、状态处理、业务服务等。具体选择由模型根据项目实际判断,不能用固定路径规则直接拍板。
排除项
排除以下内容:
node_modulesdist、build、.next、.nuxt、coverage- lock 文件
- 图片、字体、二进制文件
- sourcemap、minified 文件
- 自动生成文件
- 过短且无业务意义的配置文件
真实性要求
- 保留原始代码文本。
- 可添加文件路径标记用于追溯。
- 不改写业务逻辑。
- 不使用 AI 补齐代码。
用户确认要求
- 代码抽取前必须先生成
代码文件候选清单.md和代码文件选择.json。 - 模型必须先填写抽取选择和
model_reason,再让用户确认或手动调整selected。 - 用户可以通过
start_line/end_line只抽取某个文件的指定行段。 - 抽取脚本必须以
代码文件选择.json为准,不能绕过确认步骤直接抽全量代码库。 代码提取清单.md必须记录每个文件的抽取行段,便于回溯。
软著材料规则
鉴别材料
根据《计算机软件著作权登记办法》第十条,软件鉴别材料包括程序和文档的鉴别材料。
执行规则:
- 源程序和文档一般由前、后各连续 30 页组成。
- 整个程序或文档不足 60 页时,提交全部。
- 除特定情况外,程序每页不少于 50 行。
- 除特定情况外,文档每页不少于 30 行。
本 skill 的落地规则
- 代码分页默认每页 50 行。
- 总页数
>= 60时,只输出前 30 页和后 30 页代码材料。 - 总页数
< 60时,只输出全部代码材料。 - 不为大项目输出全量代码 Word,避免文件过大且不符合常规提交需求。
- 代码材料必须来自项目源文件,不能由 AI 生成。
- 文件页眉或页首必须包含软件全称和版本号。
- 页码必须连续且清晰。
操作手册结构
操作手册应像真实软件随附的操作说明,目标是让读者知道软件用途、功能和基本操作。
推荐采用软著审核友好的通用骨架,类似传统操作手册:
1. 相关文档:用表格指向总体设计、详细设计、测试案例等配套资料。 2. 说明:说明软件定位、目标用户、业务场景和整体流程。 3. 功能特点:按当前项目真实功能概括 4-8 项特点,每项说明业务作用和用户可见结果。 4. 系统要求:用表格说明最低配置和推荐配置;Web 项目可写浏览器、网络和服务访问要求,桌面项目可写操作系统、处理器、内存、存储和分辨率。 5. 具体页面 / 功能操作:从第 5 章开始,按真实页面、导航入口或核心流程逐章说明,每章写使用场景、页面用途、进入位置、页面内容、用户动作、输入限制或异常提示、操作结果和截图预留。 6. 典型使用流程:如项目存在清晰串联流程,可单独写一章串起从进入软件到完成核心任务的过程。 7. 常见问题解答:写 3-5 个与当前软件真实使用相关的问题和解决方法。 8. 术语表:解释软件名称、核心业务对象、页面模块和用户可能不熟悉的术语。
以上是通用骨架,不是旧项目内容。正式章节标题使用中文大写序号,例如 一、相关文档,不要使用 (1)、相关文档。生成时必须根据当前项目业务、页面入口、功能关系和用户可见控件填充内容,不要照抄用户提供的范本文案。 具体页面和流程必须来自 草稿/业务理解.json 中模型写入的 manual_modules。如果该字段为空,不能根据 business_features 生成兜底模块,应停止并要求模型阅读真实页面和项目资料后补全。
写作口径
- 使用通用、客观、简洁的中文。
- 不写面向终端用户的复杂教程。
- 每个章节必须有段落化说明,不能只写项目符号列表;正文不要用
-、*、1. 2. 3.堆信息。 - 每个核心页面或功能模块必须覆盖“使用场景 + 页面用途 + 进入位置 + 页面内容 + 用户动作 + 输入/状态规则 + 系统反馈 + 截图预留”,但这些信息要合并成自然段落,不能直接输出成字段表单。
- 优先写用户真实能看到和操作的内容,例如输入框、按钮、下拉框、标签页、列表、卡片、弹窗、错误提示、状态栏、导入导出入口、额度或权限提示。
- 补充说明段落只能写当前软件的用途、业务场景、页面组织和用户流程,不写“本操作手册用于……”“面向软著审核……”“不描述代码实现……”这类解释文档写作方式的元话语。
- 禁止在脚本中按 auth、query、form、workflow 等分类自动生成入口、步骤或结果反馈。入口和步骤必须来自模型对当前项目真实页面的阅读。
- 功能特点不要写成“开头一句总述 + 编号列表 + 结论”的头中尾结构。每个特点用段落展开,说明该功能解决什么业务问题、用户在页面上看到什么、完成操作后得到什么结果。不同功能的说明要有差异,避免每条都使用相同句式。
- 页面章节不要输出“进入方式:”“页面内容:”“操作步骤:”“操作规则:”“操作结果与反馈:”这类模板小标题。
- 操作手册语言要让审核员和普通读者能看懂,重点说明模块是做什么的、怎么操作、操作后看到什么。避免代码、框架、接口、状态管理、异步任务等技术化表达。
- “AI 味”主要表现为空泛、整齐、万能、没有项目现场感:例如每个模块都用同一种句式,反复写“提升效率、优化体验、提供支持”,使用“旨在、赋能、一站式、智能化、高效便捷、显著提升、强大能力、丰富功能”等口号,却没有说明当前项目的真实页面、真实对象、真实动作和真实反馈。发现这类内容时必须改写成朴素、具体、可回溯到项目证据的表达。
- 截图前必须先让用户在 Chrome DevTools MCP、Codex Computer Use、用户自行截图三种方式中选择;选择后检查对应能力是否可用。用户说现在不截图或先跳过截图时,记录为
skip,并在每个需要截图的位置保留正式 Word 中可见的截图预留文字。 - 不夸大不存在的功能。
- 功能名称和章节组织由模型根据项目证据判断;路由、页面、README、接口和组件命名只是证据来源,不是固定抽取规则。
- Markdown 草稿生成前由 agent 自行检查章节完整性、内容厚度、项目流程一致性和语言自然度,发现章节过薄、模块套话、AI 味、技术化表达或相邻模块含义混淆时先循环补写和修正;完整草稿生成后只向用户发起一次整体确认,再进入 Word 生成。
- 操作手册生成时必须同步输出
操作手册自检记录.md/json。记录中至少包含初稿生成、按项目流程扩写、去除制式表达和 AI 味三轮;如果第三轮仍发现问题,要继续自动修正并追加轮次记录。 - 操作手册必须基于已确认的业务理解写作。相近功能应结合项目真实业务分别说明各自的操作目的、用户动作和结果反馈,不能用同一段话替换不同模块。
- 自检时必须检查是否生成了“功能操作说明”大章下反复套同一批模块的情况;如果出现同一模块重复多次,必须改为每个真实页面独立成章。
- 禁止把测试项目中的行业、角色、流程、功能名称或示例文案写成通用规则;范本只能帮助理解软著手册需要“通顺、具体、能给审核员看懂”,不能作为固定内容来源。
#!/usr/bin/env python3
"""Analyze a project and produce facts used by the copyright material workflow."""
from __future__ import annotations
import argparse
import re
from collections import Counter
from pathlib import Path
from typing import Any
from common import COPYRIGHT_CODE_EXTS, FRONTEND_EXTS, count_text_lines, is_known_config_file, iter_project_files, normalize_title, read_json, read_text, rel, write_json
DEPENDENCY_FRAMEWORKS = {
"vue": "Vue",
"@vue/runtime-core": "Vue",
"react": "React",
"next": "Next.js",
"nuxt": "Nuxt",
"svelte": "Svelte",
"astro": "Astro",
"@angular/core": "Angular",
"vite": "Vite",
"uni-app": "UniApp",
"@dcloudio/uni-app": "UniApp",
"electron": "Electron",
"@tauri-apps/api": "Tauri",
}
ENTRY_NAMES = {
"main.ts",
"main.js",
"main.tsx",
"main.jsx",
"index.tsx",
"index.jsx",
"app.vue",
"App.vue",
"app.tsx",
}
def load_package(project: Path) -> tuple[dict[str, Any] | None, Path | None]:
candidates = [
project / "package.json",
project / "frontend/package.json",
project / "client/package.json",
project / "web/package.json",
project / "app/package.json",
]
for package_path in candidates:
if not package_path.exists():
continue
try:
return read_json(package_path), package_path
except Exception:
continue
return None, None
def detect_frameworks(package: dict[str, Any] | None, files: list[Path], project: Path) -> list[str]:
found: set[str] = set()
deps: dict[str, Any] = {}
if package:
for key in ("dependencies", "devDependencies", "peerDependencies"):
deps.update(package.get(key) or {})
for dep in deps:
if dep in DEPENDENCY_FRAMEWORKS:
found.add(DEPENDENCY_FRAMEWORKS[dep])
suffixes = {p.suffix.lower() for p in files}
if ".vue" in suffixes:
found.add("Vue")
if ".tsx" in suffixes or ".jsx" in suffixes:
if "Vue" not in found:
found.add("React")
if (project / "vite.config.ts").exists() or (project / "vite.config.js").exists():
found.add("Vite")
if (project / "next.config.js").exists() or (project / "next.config.ts").exists() or any(p.name in {"next.config.js", "next.config.ts"} for p in files):
found.add("Next.js")
return sorted(found)
def classify(path: Path, project: Path) -> str:
r = rel(path, project).lower()
name = path.name
if name in ENTRY_NAMES or r in {"src/app/page.tsx", "src/app/layout.tsx", "app/page.tsx", "app/layout.tsx"} or r.endswith("/src/app/page.tsx") or r.endswith("/src/app/layout.tsx"):
return "entry"
if path.suffix.lower() in {".css", ".scss", ".sass", ".less"}:
return "style"
if any(part in r for part in ("/router/", "/routes/", "router.", "routes.")):
return "route"
if any(part in r for part in ("/pages/", "/views/", "/app/", "/screens/")):
return "page"
if "/components/" in r:
return "component"
if any(part in r for part in ("/api/", "/apis/", "/services/", "request.", "/request/", "/controllers/", "/handlers/", "/views.py")):
return "api"
if any(part in r for part in ("/models/", "/schemas/", "/entities/", "/repositories/", "/dao/")):
return "model"
if any(part in r for part in ("/store/", "/stores/", "/pinia/", "/redux/", "/zustand/")):
return "state"
if any(part in r for part in ("/utils/", "/lib/", "/hooks/", "/composables/", "/helpers/")):
return "utility"
return "source"
def extract_route_paths(path: Path) -> list[str]:
try:
text = read_text(path, limit=200_000)
except Exception:
return []
patterns = [
r"path\s*:\s*['\"]([^'\"]+)['\"]",
r"<Route[^>]+path=['\"]([^'\"]+)['\"]",
r"href=['\"](/[^'\"]*)['\"]",
]
routes: list[str] = []
for pattern in patterns:
for match in re.findall(pattern, text):
if match.startswith("/") and len(match) < 120 and "*" not in match:
routes.append(match)
return routes
def summarize_readme(project: Path) -> str:
for name in ("README.md", "README.zh.md", "readme.md", "Readme.md"):
path = project / name
if path.exists():
text = read_text(path, limit=4000)
return "\n".join(line.strip() for line in text.splitlines()[:60] if line.strip())
return ""
def analyze(project: Path) -> dict[str, Any]:
project = project.resolve()
package, package_path = load_package(project)
source_files = [p for p in iter_project_files(project, COPYRIGHT_CODE_EXTS) if not is_known_config_file(p)]
frontend_files = [p for p in source_files if p.suffix.lower() in FRONTEND_EXTS]
class_counts: Counter[str] = Counter()
extension_counts: Counter[str] = Counter()
source_lines = 0
total_source_lines = 0
categorized: dict[str, list[str]] = {
"entry": [],
"route": [],
"page": [],
"component": [],
"api": [],
"model": [],
"state": [],
"utility": [],
"style": [],
"source": [],
}
route_paths: list[str] = ["/"]
for path in source_files:
category = classify(path, project)
class_counts[category] += 1
extension_counts[path.suffix.lower()] += 1
categorized[category].append(rel(path, project))
source_lines += count_text_lines(path, skip_blank=False)
if path.suffix.lower() in FRONTEND_EXTS and category in {"route", "page", "entry"}:
route_paths.extend(extract_route_paths(path))
total_source_lines = source_lines
package_name = ""
scripts: dict[str, str] = {}
dependencies: dict[str, str] = {}
if package:
package_name = str(package.get("name") or "")
scripts = {k: str(v) for k, v in (package.get("scripts") or {}).items()}
for key in ("dependencies", "devDependencies"):
dependencies.update({k: str(v) for k, v in (package.get(key) or {}).items()})
frameworks = detect_frameworks(package, frontend_files, project)
language = infer_language(extension_counts, frameworks)
route_paths = sorted(set(route_paths), key=lambda x: (x.count("/"), x))
return {
"project_root": str(project),
"project_name": project.name,
"software_name_candidate": normalize_title(package_name or project.name),
"package": {
"name": package_name,
"path": rel(package_path, project) if package_path else "",
"version": str(package.get("version") or "V1.0") if package else "V1.0",
"scripts": scripts,
"dependency_names": sorted(dependencies),
},
"frameworks": frameworks,
"language": language,
"source": {
"file_count": len(source_files),
"line_count": source_lines,
"total_file_count": len(source_files),
"total_line_count": total_source_lines,
"extension_counts": dict(sorted(extension_counts.items())),
"category_counts": dict(sorted(class_counts.items())),
"categorized_files": {k: v[:80] for k, v in categorized.items() if v},
},
"routes": route_paths[:80],
"readme_excerpt": summarize_readme(project),
"run_command_candidates": infer_run_commands(scripts),
"feature_candidates": infer_features(categorized, route_paths),
}
def infer_workdir(out: Path) -> Path:
if out.parent.name == "analysis":
return out.parent.parent
return out.parent
def check_environment_gate(out: Path) -> None:
workdir = infer_workdir(out)
env_path = workdir / "环境检查.json"
if not env_path.exists():
return
env = read_json(env_path)
if not env.get("requires_user_input"):
return
confirmation_path = workdir / "环境确认.json"
confirmed = False
if confirmation_path.exists():
confirmed = bool(read_json(confirmation_path).get("environment_confirmed"))
if not confirmed:
raise SystemExit(
"STOP_FOR_USER\n"
"NEXT_ACTION: 完整 DOCX 环境未确认。请先让用户选择安装完整环境或使用基础 DOCX 兜底继续,"
"然后运行 `python3 <SKILL_DIR>/scripts/confirm_stage.py --workdir 软件著作权申请资料 --stage environment --note \"<用户选择>\"`。"
)
def infer_language(extension_counts: Counter[str], frameworks: list[str]) -> str:
langs: list[str] = []
if extension_counts.get(".ts") or extension_counts.get(".tsx"):
langs.append("TypeScript")
if extension_counts.get(".js") or extension_counts.get(".jsx"):
langs.append("JavaScript")
language_by_ext = {
".py": "Python",
".java": "Java",
".go": "Go",
".rs": "Rust",
".cs": "C#",
".php": "PHP",
".rb": "Ruby",
".kt": "Kotlin",
".swift": "Swift",
".sql": "SQL",
".sh": "Shell",
}
for ext, label in language_by_ext.items():
if extension_counts.get(ext):
langs.append(label)
if not langs:
langs = [ext.lstrip(".").upper() for ext, _ in extension_counts.most_common(3) if ext]
return "、".join(dict.fromkeys(langs)) or "待用户确认"
def infer_run_commands(scripts: dict[str, str]) -> list[str]:
preferred = ["dev", "start", "serve", "preview"]
commands = []
for name in preferred:
if name in scripts:
commands.append(f"npm run {name}")
return commands
def infer_features(categorized: dict[str, list[str]], routes: list[str]) -> list[str]:
stop = {
"index",
"main",
"app",
"layout",
"page",
"globals",
"providers",
"loading",
"error",
"not-found",
"template",
"default",
"button",
"input",
"label",
"avatar",
"card",
"textarea",
"scroll area",
}
names: list[str] = []
for route in routes:
cleaned = route.strip("/").replace("-", " ").replace("_", " ")
if cleaned and not cleaned.startswith(":") and cleaned.lower() not in stop:
names.append(cleaned)
for file in categorized.get("page", [])[:60]:
route_name = feature_from_page_path(file)
if route_name and route_name.lower() not in stop:
names.append(route_name)
for category in ("api", "component"):
for file in categorized.get(category, [])[:30]:
lowered = file.lower()
if "/ui/" in lowered or "/components/ui/" in lowered:
continue
stem = Path(file).stem
normalized = stem.replace("-", " ").replace("_", " ").strip()
if normalized.lower() not in stop:
names.append(normalized)
unique: list[str] = []
for name in names:
normalized = re.sub(r"\s+", " ", name).strip()
if normalized and normalized not in unique:
unique.append(normalized)
return unique[:30]
def feature_from_page_path(file: str) -> str:
parts = Path(file).parts
useful: list[str] = []
for part in parts:
if part in {"src", "app", "pages", "views", "screens", "frontend", "client", "web"}:
continue
if part.startswith("(") and part.endswith(")"):
continue
stem = Path(part).stem
if stem in {"page", "layout", "index", "route", "loading", "error", "globals", "providers"}:
continue
if stem.startswith("[") and stem.endswith("]"):
continue
useful.append(stem)
return " ".join(useful[-2:]).replace("-", " ").replace("_", " ").strip()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--project", required=True, help="Project root directory")
parser.add_argument("--out", default="软件著作权申请资料/analysis/project.json")
args = parser.parse_args()
project = Path(args.project)
if not project.exists():
raise SystemExit(f"Project not found: {project}")
out = Path(args.out)
check_environment_gate(out)
result = analyze(project)
write_json(out, result)
print(f"OK analysis: {out}")
print(f"Project: {result['project_name']}")
print(f"Frameworks: {', '.join(result['frameworks']) or 'unknown'}")
print(f"Language: {result['language']}")
print(f"Source files: {result['source']['file_count']}")
print(f"Source lines: {result['source']['line_count']}")
print(f"Total source files: {result['source']['total_file_count']}")
print(f"Total source lines: {result['source']['total_line_count']}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Build final DOCX/TXT files from confirmed Markdown drafts."""
from __future__ import annotations
import argparse
import html
import re
import shutil
import subprocess
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from common import ensure_dir, read_json, safe_filename
try:
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Inches, Pt, RGBColor
DOCX_AVAILABLE = True
except Exception:
DOCX_AVAILABLE = False
BLACK_RGB = "000000"
def strip_markdown_links(text: str) -> str:
text = re.sub(r"(?<!!)\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
text = re.sub(r"<(https?://[^>]+)>", r"\1", text)
return text
def parse_application_lines(md_path: Path) -> tuple[list[str], list[str]]:
lines = md_path.read_text(encoding="utf-8").splitlines()
fields = [line.strip() for line in lines if line.strip().startswith("➤")]
warnings = [line for line in fields if "待用户确认" in line]
return fields, warnings
def parse_application_field(md_path: Path, field_name: str) -> str:
if not md_path.exists():
return ""
prefix = f"➤{field_name}:"
for line in md_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith(prefix):
return stripped[len(prefix) :].strip()
return ""
def application_version(draft_dir: Path) -> str:
version = parse_application_field(draft_dir / "申请表信息.md", "版本号")
if "待用户确认" in version:
return ""
return version
def application_software_name(draft_dir: Path) -> str:
name = parse_application_field(draft_dir / "申请表信息.md", "软件全称")
if "待用户确认" in name:
return ""
return name
def write_application_txt(draft_dir: Path, out_dir: Path) -> tuple[Path | None, list[str]]:
md_path = draft_dir / "申请表信息.md"
if not md_path.exists():
return None, ["缺少草稿/申请表信息.md"]
fields, warnings = parse_application_lines(md_path)
out_path = out_dir / "申请表信息.txt"
out_path.write_text("\n".join(fields) + "\n", encoding="utf-8")
return out_path, warnings
def read_json_if_exists(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return read_json(path)
def confirmation_issues(workdir: Path) -> list[str]:
draft_dir = workdir / "草稿"
issues: list[str] = []
business = read_json_if_exists(draft_dir / "业务理解.json")
if not business or not business.get("user_confirmed"):
issues.append("业务理解尚未确认:请确认 草稿/业务理解.md 后记录 `business` 门禁")
selection = read_json_if_exists(draft_dir / "代码文件选择.json")
if not selection or not selection.get("user_confirmed"):
issues.append("代码文件选择尚未确认:请确认 草稿/代码文件选择.json 后记录 `code-selection` 门禁")
screenshot = read_json_if_exists(workdir / "截图方式确认.json")
if not screenshot.get("screenshot_method_confirmed"):
issues.append("截图方式尚未确认:请选择截图方式后记录 `screenshot-method` 门禁")
app_md = draft_dir / "申请表信息.md"
if app_md.exists():
_, warnings = parse_application_lines(app_md)
if warnings:
issues.append("申请表信息仍包含“待用户确认”字段")
else:
issues.append("缺少 草稿/申请表信息.md")
app_confirmation = read_json_if_exists(draft_dir / "申请表字段确认.json")
if not app_confirmation.get("application_fields_confirmed"):
issues.append("申请表字段尚未确认:请补全字段后记录 `application-fields` 门禁")
markdown_confirmation = read_json_if_exists(draft_dir / "最终生成确认.json")
if not markdown_confirmation.get("markdown_confirmed"):
issues.append("Markdown 草稿尚未最终确认:请确认全部草稿后记录 `markdown` 门禁")
return issues
def parse_code_pages(md_path: Path) -> list[tuple[int, list[str]]]:
pages: list[tuple[int, list[str]]] = []
current_no: int | None = None
current_lines: list[str] = []
in_fence = False
for raw in md_path.read_text(encoding="utf-8").splitlines():
page_match = re.match(r"^##\s+第\s*(\d+)\s*页", raw.strip())
if page_match:
if current_no is not None:
pages.append((current_no, current_lines))
current_no = int(page_match.group(1))
current_lines = []
in_fence = False
continue
if raw.strip().startswith("```"):
in_fence = not in_fence
continue
if current_no is not None and in_fence:
current_lines.append(raw)
if current_no is not None:
pages.append((current_no, current_lines))
return pages
def set_run_font(run: Any, name: str, size_pt: float) -> None:
run.font.name = name
run.font.size = Pt(size_pt)
try:
run.font.color.rgb = RGBColor(0, 0, 0)
except Exception:
pass
try:
run._element.rPr.rFonts.set(qn("w:eastAsia"), name)
except Exception:
pass
def set_normal_font(document: Any, name: str = "SimSun", size_pt: float = 10.5) -> None:
style = document.styles["Normal"]
style.font.name = name
style.font.size = Pt(size_pt)
try:
style.font.color.rgb = RGBColor(0, 0, 0)
except Exception:
pass
try:
style._element.rPr.rFonts.set(qn("w:eastAsia"), name)
except Exception:
pass
def set_style_black(document: Any) -> None:
for style_name in ("Normal", "Heading 1", "Heading 2", "Heading 3", "List Bullet", "List Number"):
try:
document.styles[style_name].font.color.rgb = RGBColor(0, 0, 0)
except Exception:
pass
def force_black_document(document: Any) -> None:
set_style_black(document)
containers = [document]
for section in document.sections:
containers.extend([section.header, section.footer])
for container in containers:
for paragraph in container.paragraphs:
for run in paragraph.runs:
try:
run.font.color.rgb = RGBColor(0, 0, 0)
except Exception:
pass
for table in container.tables:
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
for run in paragraph.runs:
try:
run.font.color.rgb = RGBColor(0, 0, 0)
except Exception:
pass
def configure_a4(document: Any) -> None:
section = document.sections[0]
section.page_width = Cm(21)
section.page_height = Cm(29.7)
section.top_margin = Cm(2.54)
section.bottom_margin = Cm(2.54)
section.left_margin = Cm(3.17)
section.right_margin = Cm(2.54)
def configure_code_a4(document: Any) -> None:
section = document.sections[0]
section.page_width = Cm(21)
section.page_height = Cm(29.7)
section.top_margin = Cm(2.0)
section.bottom_margin = Cm(2.0)
section.left_margin = Cm(2.5)
section.right_margin = Cm(2.0)
def add_page_field(paragraph: Any) -> None:
begin = OxmlElement("w:fldChar")
begin.set(qn("w:fldCharType"), "begin")
instr = OxmlElement("w:instrText")
instr.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
instr.text = " PAGE "
separate = OxmlElement("w:fldChar")
separate.set(qn("w:fldCharType"), "separate")
result = OxmlElement("w:t")
result.text = "1"
end = OxmlElement("w:fldChar")
end.set(qn("w:fldCharType"), "end")
for element in (begin, instr, separate, result, end):
run = paragraph.add_run()
run._r.append(element)
set_run_font(run, "SimSun", 8)
def set_code_header(document: Any, software_name: str, version: str) -> None:
section = document.sections[0]
section.header.is_linked_to_previous = False
header = section.header
header.paragraphs[0].text = "" if header.paragraphs else None
# Build a two-column header: software name on the left, page number on the right.
table = header.add_table(rows=1, cols=2, width=Cm(17.5))
table.autofit = True
left_cell = table.rows[0].cells[0]
right_cell = table.rows[0].cells[1]
left_para = left_cell.paragraphs[0]
left_para.alignment = WD_ALIGN_PARAGRAPH.LEFT
left_para.paragraph_format.space_before = Pt(0)
left_para.paragraph_format.space_after = Pt(0)
left_para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
left_para.paragraph_format.line_spacing = Pt(12)
left_run = left_para.add_run(f"{software_name} {version}")
set_run_font(left_run, "SimSun", 8)
right_para = right_cell.paragraphs[0]
right_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
right_para.paragraph_format.space_before = Pt(0)
right_para.paragraph_format.space_after = Pt(0)
right_para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
right_para.paragraph_format.line_spacing = Pt(12)
prefix = right_para.add_run("第 ")
set_run_font(prefix, "SimSun", 8)
add_page_field(right_para)
suffix = right_para.add_run(" 页")
set_run_font(suffix, "SimSun", 8)
# Remove borders from the header table
for cell in table.rows[0].cells:
tc_pr = cell._tc.get_or_add_tcPr()
tc_borders = OxmlElement("w:tcBorders")
for border_name in ("top", "left", "bottom", "right"):
border = OxmlElement(f"w:{border_name}")
border.set(qn("w:val"), "nil")
tc_borders.append(border)
tc_pr.append(tc_borders)
def build_code_docx_python(md_path: Path, out_path: Path, software_name: str, version: str) -> None:
pages = parse_code_pages(md_path)
if not pages:
raise RuntimeError(f"No code pages parsed from {md_path}")
document = Document()
configure_code_a4(document)
set_normal_font(document, "Consolas", 7.2)
set_style_black(document)
set_code_header(document, software_name, version)
# 后30页文档页码从31开始,实现前后文档连续编号1-60
start_page_no = pages[0][0] if pages else 1
if start_page_no != 1:
pg_num_type = OxmlElement("w:pgNumType")
pg_num_type.set(qn("w:start"), str(start_page_no))
document.sections[0]._sectPr.append(pg_num_type)
for index, (page_no, lines) in enumerate(pages):
for line in lines:
p = document.add_paragraph()
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
p.paragraph_format.line_spacing = Pt(14)
run = p.add_run(line if line else " ")
set_run_font(run, "Consolas", 7.2)
if index != len(pages) - 1:
# 嵌入式分页符:避免 add_page_break() 产生多余空段落导致空白页
run = p.add_run()
br = OxmlElement('w:br')
br.set(qn('w:type'), 'page')
run._r.append(br)
force_black_document(document)
document.save(out_path)
def paragraph_xml(text: str, font: str = "SimSun", size_half_points: int = 21, align: str | None = None, line_twips: int = 240) -> str:
align_xml = f'<w:jc w:val="{align}"/>' if align else ""
escaped = html.escape(text)
return (
"<w:p>"
f"<w:pPr>{align_xml}<w:spacing w:after=\"0\" w:line=\"{line_twips}\" w:lineRule=\"exact\"/></w:pPr>"
"<w:r>"
f"<w:rPr><w:rFonts w:ascii=\"{font}\" w:hAnsi=\"{font}\" w:eastAsia=\"{font}\"/>"
f"<w:color w:val=\"{BLACK_RGB}\"/>"
f"<w:sz w:val=\"{size_half_points}\"/><w:szCs w:val=\"{size_half_points}\"/></w:rPr>"
f"<w:t xml:space=\"preserve\">{escaped}</w:t>"
"</w:r>"
"</w:p>"
)
def page_break_xml() -> str:
return '<w:p><w:r><w:br w:type="page"/></w:r></w:p>'
def page_field_runs_xml() -> str:
return (
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:fldChar w:fldCharType="begin"/></w:r>'
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>'
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:fldChar w:fldCharType="separate"/></w:r>'
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:t>1</w:t></w:r>'
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:fldChar w:fldCharType="end"/></w:r>'
)
def header_xml(header_text: str) -> str:
"""Build a two-column header: software name left, page number right."""
escaped = html.escape(header_text)
# Use a borderless table for left/right alignment in header
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:tbl>
<w:tblPr>
<w:tblW w:w="5000" w:type="pct"/>
<w:tblBorders>
<w:top w:val="nil"/><w:left w:val="nil"/><w:bottom w:val="nil"/><w:right w:val="nil"/><w:insideH w:val="nil"/><w:insideV w:val="nil"/>
</w:tblBorders>
</w:tblPr>
<w:tr>
<w:tc>
<w:p>
<w:pPr><w:jc w:val="left"/><w:spacing w:after="0" w:line="240" w:lineRule="exact"/></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr><w:t xml:space="preserve">{escaped}</w:t></w:r>
</w:p>
</w:tc>
<w:tc>
<w:p>
<w:pPr><w:jc w:val="right"/><w:spacing w:after="0" w:line="240" w:lineRule="exact"/></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr><w:t xml:space="preserve">第 </w:t></w:r>
{page_field_runs_xml()}
<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr><w:t xml:space="preserve"> 页</w:t></w:r>
</w:p>
</w:tc>
</w:tr>
</w:tbl>
</w:hdr>"""
def minimal_docx(out_path: Path, body_xml: str, header_text: str | None = None, start_page: int = 1) -> None:
content_types = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
</Types>"""
rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>"""
header_rel = (
'<Relationship Id="rIdHeader1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>'
if header_text
else ""
)
doc_rels = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{header_rel}</Relationships>"""
styles = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="21"/></w:rPr>
</w:style>
</w:styles>"""
document = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<w:body>
{body_xml}
<w:sectPr>
{'<w:headerReference w:type="default" r:id="rIdHeader1"/>' if header_text else ''}
{'<w:pgNumType w:start="' + str(start_page) + '"/>' if start_page != 1 else ''}
<w:pgSz w:w="11906" w:h="16838"/>
<w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1418" w:header="283" w:footer="283" w:gutter="0"/>
</w:sectPr>
</w:body>
</w:document>"""
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", content_types)
zf.writestr("_rels/.rels", rels)
zf.writestr("word/_rels/document.xml.rels", doc_rels)
zf.writestr("word/styles.xml", styles)
zf.writestr("word/document.xml", document)
if header_text:
zf.writestr("word/header1.xml", header_xml(header_text))
def force_black_xml(xml: str) -> str:
xml = re.sub(r"<w:hyperlink\b[^>]*>", "", xml)
xml = xml.replace("</w:hyperlink>", "")
xml = re.sub(r"<w:color\b[^>]*/>", f'<w:color w:val="{BLACK_RGB}"/>', xml)
def ensure_rpr_color(match: re.Match[str]) -> str:
value = match.group(0)
if "<w:color" in value:
return value
return value.replace("</w:rPr>", f'<w:color w:val="{BLACK_RGB}"/></w:rPr>')
xml = re.sub(r"<w:rPr\b[^>]*>.*?</w:rPr>", ensure_rpr_color, xml, flags=re.S)
xml = re.sub(r"<w:r>(?!<w:rPr>)", f'<w:r><w:rPr><w:color w:val="{BLACK_RGB}"/></w:rPr>', xml)
return xml
def normalize_docx_text_color(docx_path: Path) -> None:
tmp_path = docx_path.with_suffix(docx_path.suffix + ".tmp")
color_xml_parts = (
"word/document.xml",
"word/styles.xml",
"word/numbering.xml",
"word/header",
"word/footer",
)
with zipfile.ZipFile(docx_path, "r") as src, zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as dst:
for item in src.infolist():
data = src.read(item.filename)
if item.filename.endswith(".xml") and item.filename.startswith(color_xml_parts):
text = data.decode("utf-8")
data = force_black_xml(text).encode("utf-8")
elif item.filename.endswith(".rels"):
text = data.decode("utf-8", errors="ignore")
if "hyperlink" in text:
text = re.sub(r'\s*<Relationship\b[^>]*Type="[^"]*/hyperlink"[^>]*/>', "", text)
data = text.encode("utf-8")
dst.writestr(item, data)
tmp_path.replace(docx_path)
def next_header_part(names: set[str]) -> tuple[str, str]:
index = 1
while f"word/header{index}.xml" in names:
index += 1
return f"word/header{index}.xml", f"header{index}.xml"
def unique_relationship_id(rels_xml: str, base: str = "rIdManualHeader") -> str:
if f'Id="{base}"' not in rels_xml:
return base
index = 2
while f'Id="{base}{index}"' in rels_xml:
index += 1
return f"{base}{index}"
def add_header_to_existing_docx(docx_path: Path, header_text: str) -> None:
"""Add the same two-column header used by code materials to an existing DOCX."""
tmp_path = docx_path.with_suffix(docx_path.suffix + ".tmp")
with zipfile.ZipFile(docx_path, "r") as src:
names = set(src.namelist())
header_part, header_target = next_header_part(names)
rels_xml = src.read("word/_rels/document.xml.rels").decode("utf-8")
rel_id = unique_relationship_id(rels_xml)
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as dst:
for item in src.infolist():
data = src.read(item.filename)
if item.filename == "[Content_Types].xml":
text = data.decode("utf-8")
override = (
f'<Override PartName="/{header_part}" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>'
)
if f'PartName="/{header_part}"' not in text:
text = text.replace("</Types>", f"{override}</Types>")
data = text.encode("utf-8")
elif item.filename == "word/_rels/document.xml.rels":
text = data.decode("utf-8")
relationship = (
f'<Relationship Id="{rel_id}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" '
f'Target="{header_target}"/>'
)
text = text.replace("</Relationships>", f"{relationship}</Relationships>")
data = text.encode("utf-8")
elif item.filename == "word/document.xml":
text = data.decode("utf-8")
if "xmlns:r=" not in text:
text = text.replace(
"<w:document ",
'<w:document xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ',
1,
)
header_ref = f'<w:headerReference w:type="default" r:id="{rel_id}"/>'
if "<w:headerReference" in text:
text = re.sub(r"<w:headerReference\b[^>]*/>", header_ref, text, count=1)
else:
text = re.sub(r"(<w:sectPr\b[^>]*>)", rf"\1{header_ref}", text, count=1)
data = text.encode("utf-8")
dst.writestr(item, data)
dst.writestr(header_part, header_xml(header_text))
tmp_path.replace(docx_path)
def build_code_docx_ooxml(md_path: Path, out_path: Path, software_name: str, version: str) -> None:
pages = parse_code_pages(md_path)
if not pages:
raise RuntimeError(f"No code pages parsed from {md_path}")
start_page_no = pages[0][0] if pages else 1
body: list[str] = []
for index, (page_no, lines) in enumerate(pages):
for line in lines:
body.append(paragraph_xml(line if line else " ", font="Consolas", size_half_points=14, line_twips=280))
if index != len(pages) - 1:
# 嵌入式分页符:嵌入最后一段的 run 避免多余空段落
last = body.pop()
last = last.replace('</w:r></w:p>', '<w:br w:type="page"/></w:r></w:p>')
body.append(last)
minimal_docx(out_path, "\n".join(body), header_text=f"{software_name} {version}", start_page=start_page_no)
def add_markdown_table(document: Any, rows: list[list[str]]) -> None:
if not rows:
return
table = document.add_table(rows=1, cols=len(rows[0]))
table.style = "Table Grid"
for idx, text in enumerate(rows[0]):
table.rows[0].cells[idx].text = strip_markdown_links(text)
for row in rows[1:]:
cells = table.add_row().cells
for idx, text in enumerate(row[: len(cells)]):
cells[idx].text = strip_markdown_links(text)
def parse_table_line(line: str) -> list[str]:
return [cell.strip() for cell in line.strip().strip("|").split("|")]
def add_image(document: Any, image_path: Path) -> None:
if not image_path.exists():
p = document.add_paragraph()
run = p.add_run(f"[截图缺失:{image_path}]")
set_run_font(run, "SimSun", 10.5)
return
try:
document.add_picture(str(image_path), width=Inches(5.8))
except Exception:
p = document.add_paragraph()
run = p.add_run(f"[截图无法插入:{image_path}]")
set_run_font(run, "SimSun", 10.5)
def build_manual_docx_python(md_path: Path, out_path: Path, base_dir: Path, software_name: str, version: str) -> None:
document = Document()
configure_a4(document)
set_normal_font(document, "SimSun", 10.5)
set_style_black(document)
set_code_header(document, software_name, version)
lines = md_path.read_text(encoding="utf-8").splitlines()
table_buf: list[list[str]] = []
in_fence = False
def flush_table() -> None:
nonlocal table_buf
if table_buf:
data = [row for row in table_buf if not all(re.fullmatch(r":?-{3,}:?", cell) for cell in row)]
add_markdown_table(document, data)
table_buf = []
for line in lines:
stripped = line.strip()
stripped = strip_markdown_links(stripped)
if stripped.startswith("```"):
flush_table()
in_fence = not in_fence
continue
if in_fence:
continue
if stripped.startswith("<!--") and "截图" in stripped:
stripped = "【截图预留:请在此处插入当前功能页面或操作结果截图。】"
if stripped.startswith("|") and stripped.endswith("|"):
table_buf.append(parse_table_line(stripped))
continue
flush_table()
if not stripped:
continue
image_match = re.search(r"!\[[^\]]*\]\(([^)]+)\)", stripped)
if image_match:
add_image(document, (base_dir / image_match.group(1)).resolve())
continue
heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
if heading:
level = min(len(heading.group(1)), 3)
p = document.add_heading(heading.group(2), level=level)
for run in p.runs:
try:
run.font.color.rgb = RGBColor(0, 0, 0)
except Exception:
pass
continue
if re.match(r"^[-*+]\s+", stripped):
p = document.add_paragraph(style="List Bullet")
run = p.add_run(re.sub(r"^[-*+]\s+", "", stripped))
set_run_font(run, "SimSun", 10.5)
continue
if re.match(r"^\d+\.\s+", stripped):
p = document.add_paragraph(style="List Number")
run = p.add_run(re.sub(r"^\d+\.\s+", "", stripped))
set_run_font(run, "SimSun", 10.5)
continue
p = document.add_paragraph()
run = p.add_run(stripped)
set_run_font(run, "SimSun", 10.5)
flush_table()
force_black_document(document)
document.save(out_path)
def pandoc_available() -> bool:
return shutil.which("pandoc") is not None
def build_with_pandoc(md_path: Path, out_path: Path, code_mode: bool = False) -> None:
if not pandoc_available():
raise RuntimeError("python-docx is unavailable and pandoc is not installed")
source = md_path
tmp_name: str | None = None
original_text = md_path.read_text(encoding="utf-8")
text = original_text
text = re.sub(r"```text\s*\nSTOP_FOR_USER\n.*?```", "", text, flags=re.S)
text = re.sub(r"<!--[^>]*截图[^>]*-->", "【截图预留:请在此处插入当前功能页面或操作结果截图。】", text)
text = strip_markdown_links(text)
if code_mode:
text = re.sub(r"(?=^##\s+第\s*\d+\s*页)", r"\n\\newpage\n", text, flags=re.M)
if code_mode or "STOP_FOR_USER" in original_text:
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as tmp:
tmp.write(text)
tmp_name = tmp.name
source = Path(tmp_name)
try:
subprocess.run(["pandoc", "-f", "markdown", "-t", "docx", str(source), "-o", str(out_path)], check=True)
finally:
if tmp_name:
Path(tmp_name).unlink(missing_ok=True)
def build_code_docx(md_path: Path, out_path: Path, software_name: str, version: str) -> None:
if DOCX_AVAILABLE:
build_code_docx_python(md_path, out_path, software_name, version)
else:
build_code_docx_ooxml(md_path, out_path, software_name, version)
normalize_docx_text_color(out_path)
def build_manual_docx(md_path: Path, out_path: Path, base_dir: Path, software_name: str, version: str) -> None:
if DOCX_AVAILABLE:
build_manual_docx_python(md_path, out_path, base_dir, software_name, version)
else:
build_with_pandoc(md_path, out_path, code_mode=False)
add_header_to_existing_docx(out_path, f"{software_name} {version}")
normalize_docx_text_color(out_path)
def run_command(command: list[str], cwd: Path | None = None, timeout: int = 60) -> tuple[int, str]:
try:
completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=timeout)
return completed.returncode, (completed.stdout + completed.stderr).strip()
except Exception as exc:
return 99, str(exc)
def docx_checks(skill_dir: Path, outputs: list[Path]) -> list[str]:
notes: list[str] = []
env_script = skill_dir / "vendor/docx-toolkit/scripts/env_check.sh"
preview_script = skill_dir / "vendor/docx-toolkit/scripts/docx_preview.sh"
if env_script.exists():
code, output = run_command(["bash", str(env_script)], cwd=env_script.parent.parent, timeout=30)
status = "READY" if code == 0 else "NOT READY"
first_lines = "\n".join(output.splitlines()[:12])
notes.append(f"DOCX env: {status}\n\n```text\n{first_lines}\n```")
else:
notes.append("DOCX env: vendor script missing")
if preview_script.exists():
for out in outputs:
code, output = run_command(["bash", str(preview_script), str(out)], timeout=45)
first_lines = "\n".join(output.splitlines()[:8])
notes.append(f"Preview {out.name}: exit={code}\n\n```text\n{first_lines}\n```")
return notes
def build_all(workdir: Path, software_name: str, version: str, skip_preview: bool) -> dict[str, Any]:
workdir = ensure_dir(workdir)
draft_dir = workdir / "草稿"
final_dir = ensure_dir(workdir / "正式资料")
app_name = application_software_name(draft_dir)
app_version = application_version(draft_dir)
final_software_name = app_name or software_name
final_version = app_version or version
safe_name = safe_filename(final_software_name)
outputs: list[Path] = []
warnings: list[str] = []
if app_name and app_name != software_name:
warnings.append(f"命令参数软件名称为 {software_name},正式资料已按申请表信息软件名称 {app_name} 生成")
if app_version and app_version != version:
warnings.append(f"命令参数版本号为 {version},正式资料已按申请表信息版本号 {app_version} 生成")
screenshot_confirmation = read_json_if_exists(workdir / "截图方式确认.json")
screenshot_method = screenshot_confirmation.get("screenshot_method")
screenshot_manifest = workdir / "截图/截图清单.json"
if screenshot_method == "skip":
warnings.append("用户选择暂不截图;操作手册已保留截图预留位置")
elif screenshot_method and not screenshot_manifest.exists():
warnings.append("操作手册截图未生成或未插入;操作手册应保留截图预留位置")
elif screenshot_manifest.exists():
screenshots = read_json_if_exists(screenshot_manifest).get("screenshots") or []
if not screenshots:
warnings.append("操作手册截图清单为空;操作手册应保留截图预留位置")
app_txt, app_warnings = write_application_txt(draft_dir, final_dir)
if app_txt:
outputs.append(app_txt)
warnings.extend(app_warnings)
code_specs = [
("代码-前30页.md", f"{safe_name}-代码(前30页).docx"),
("代码-后30页.md", f"{safe_name}-代码(后30页).docx"),
("代码-全部.md", f"{safe_name}-代码(全部).docx"),
]
for md_name, docx_name in code_specs:
md_path = draft_dir / md_name
if md_path.exists():
out_path = final_dir / docx_name
build_code_docx(md_path, out_path, final_software_name, final_version)
outputs.append(out_path)
manual_md = draft_dir / "操作手册.md"
if manual_md.exists():
manual_out = final_dir / f"{safe_name}_操作手册.docx"
manual_source = manual_md
tmp_manual: Path | None = None
if app_name and app_name != software_name:
text = manual_md.read_text(encoding="utf-8").replace(software_name, app_name)
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as tmp:
tmp.write(text)
tmp_manual = Path(tmp.name)
manual_source = tmp_manual
try:
build_manual_docx(manual_source, manual_out, draft_dir, final_software_name, final_version)
finally:
if tmp_manual:
tmp_manual.unlink(missing_ok=True)
outputs.append(manual_out)
else:
warnings.append("缺少草稿/操作手册.md")
skill_dir = Path(__file__).resolve().parents[1]
notes = [] if skip_preview else docx_checks(skill_dir, [p for p in outputs if p.suffix.lower() == ".docx"])
report = write_report(final_dir, outputs, warnings, notes)
return {"outputs": [str(p) for p in outputs], "warnings": warnings, "report": str(report)}
def write_report(workdir: Path, outputs: list[Path], warnings: list[str], notes: list[str]) -> Path:
report = workdir / "生成报告.md"
lines = ["# 生成报告", "", "## 输出文件", ""]
for path in outputs:
size = path.stat().st_size if path.exists() else 0
lines.append(f"- `{path.name}` ({size} bytes)")
lines.extend(["", "## 警告", ""])
if warnings:
lines.extend(f"- {warning}" for warning in warnings)
else:
lines.append("- 无")
lines.extend(["", "## DOCX 校验", ""])
if notes:
lines.extend(notes)
else:
lines.append("- 已跳过预览校验")
report.write_text("\n".join(lines) + "\n", encoding="utf-8")
return report
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--workdir", default="软件著作权申请资料")
parser.add_argument("--software-name", required=True)
parser.add_argument("--version", default="V1.0")
parser.add_argument("--skip-preview", action="store_true")
args = parser.parse_args()
workdir = Path(args.workdir)
issues = confirmation_issues(workdir)
if issues:
print("STOP_FOR_USER")
print("NEXT_ACTION: 正式 Word/TXT 生成前必须完成以下确认:")
for issue in issues:
print(f"- {issue}")
raise SystemExit(2)
result = build_all(workdir, args.software_name, args.version, args.skip_preview)
print(f"OK final materials: {Path(args.workdir) / '正式资料'}")
for output in result["outputs"]:
print(output)
if result["warnings"]:
print("Warnings:")
for warning in result["warnings"]:
print(f"- {warning}")
print(f"Report: {result['report']}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Best-effort screenshot helpers for operation manuals."""
from __future__ import annotations
import argparse
import json
import shutil
import re
from pathlib import Path
from urllib.parse import urljoin
from common import ensure_dir, read_json, write_json
def safe_name(path: str) -> str:
value = path.strip("/") or "home"
value = re.sub(r"[^A-Za-z0-9._-]+", "_", value)
return value[:80] or "page"
def collect_manual_screenshots(input_dir: Path, out_dir: Path) -> dict[str, object]:
out_dir = ensure_dir(out_dir)
screenshots = []
errors = []
allowed = {".png", ".jpg", ".jpeg", ".webp"}
for index, path in enumerate(sorted(input_dir.iterdir()), start=1):
if path.suffix.lower() not in allowed or not path.is_file():
continue
target = out_dir / f"{index:02d}-{safe_name(path.stem)}{path.suffix.lower()}"
if path.resolve() != target.resolve():
shutil.copy2(path, target)
screenshots.append({"route": "", "url": "", "path": str(target), "source": str(path)})
if not screenshots:
errors.append({"error": f"no screenshot images found in {input_dir}"})
manifest = {
"status": "ok" if screenshots else "empty",
"method": "user-supplied",
"screenshots": screenshots,
"errors": errors,
}
write_json(out_dir / "截图清单.json", manifest)
return manifest
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url")
parser.add_argument("--analysis")
parser.add_argument("--out-dir", default="软件著作权申请资料/截图")
parser.add_argument("--max-pages", type=int, default=8)
parser.add_argument("--manual-dir", help="Collect user-supplied screenshots from this directory")
args = parser.parse_args()
if args.manual_dir:
manifest = collect_manual_screenshots(Path(args.manual_dir), Path(args.out_dir))
print(json.dumps(manifest, ensure_ascii=False, indent=2))
if not manifest["screenshots"]:
raise SystemExit(3)
return
if not args.base_url or not args.analysis:
raise SystemExit("Missing --base-url and --analysis unless --manual-dir is provided")
try:
from playwright.sync_api import sync_playwright
except Exception as exc:
print(json.dumps({"status": "error", "reason": f"playwright unavailable: {exc}"}, ensure_ascii=False))
raise SystemExit(2)
analysis = read_json(Path(args.analysis))
paths = analysis.get("routes") or ["/"]
clean_paths = []
for path in paths:
if isinstance(path, str) and path.startswith("/") and path not in clean_paths:
clean_paths.append(path)
clean_paths = clean_paths[: args.max_pages] or ["/"]
out_dir = ensure_dir(Path(args.out_dir))
screenshots = []
errors = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 1000})
for route in clean_paths:
url = urljoin(args.base_url.rstrip("/") + "/", route.lstrip("/"))
file_path = out_dir / f"{safe_name(route)}.png"
try:
page.goto(url, wait_until="networkidle", timeout=15_000)
page.screenshot(path=str(file_path), full_page=True)
screenshots.append({"route": route, "url": url, "path": str(file_path)})
except Exception as exc:
errors.append({"route": route, "url": url, "error": str(exc)})
browser.close()
manifest = {"status": "ok" if screenshots else "partial", "screenshots": screenshots, "errors": errors}
write_json(out_dir / "截图清单.json", manifest)
print(json.dumps(manifest, ensure_ascii=False, indent=2))
if not screenshots:
raise SystemExit(3)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Check runtime capabilities at the beginning of the workflow."""
from __future__ import annotations
import argparse
import importlib.util
import shutil
import subprocess
from pathlib import Path
from typing import Any
from common import ensure_dir, write_json
def command_version(command: list[str]) -> tuple[bool, str]:
if not shutil.which(command[0]):
return False, "not found"
try:
completed = subprocess.run(command, text=True, capture_output=True, timeout=20)
output = (completed.stdout or completed.stderr).strip().splitlines()
return completed.returncode == 0, output[0] if output else "available"
except Exception as exc:
return False, str(exc)
def run_docx_env(skill_dir: Path) -> tuple[bool, str]:
env_script = skill_dir / "vendor/docx-toolkit/scripts/env_check.sh"
if not env_script.exists():
return False, "vendor/docx-toolkit/scripts/env_check.sh not found"
try:
completed = subprocess.run(["bash", str(env_script)], text=True, capture_output=True, timeout=40)
return completed.returncode == 0, (completed.stdout + completed.stderr).strip()
except Exception as exc:
return False, str(exc)
def module_available(name: str) -> bool:
return importlib.util.find_spec(name) is not None
def check_environment(skill_dir: Path) -> dict[str, Any]:
python_docx = module_available("docx")
pandoc_ok, pandoc_version = command_version(["pandoc", "--version"])
dotnet_ok, dotnet_version = command_version(["dotnet", "--version"])
docx_ready, docx_output = run_docx_env(skill_dir)
final_docx_mode = "docx-openxml" if docx_ready else ("python-docx" if python_docx else "basic-ooxml")
requires_user_input = not docx_ready
next_action = (
"请选择:1) 安装完整 DOCX 环境;2) 使用基础 DOCX 兜底继续。回复选择后再进入项目分析。"
if requires_user_input
else "完整 DOCX 环境可用,可以进入项目分析。"
)
return {
"output_directory": "当前目录/软件著作权申请资料",
"capabilities": {
"markdown_drafts": True,
"application_txt": True,
"basic_docx": python_docx or True,
"python_docx": python_docx,
"pandoc_preview": pandoc_ok,
"docx_openxml_full": docx_ready,
"dotnet_sdk": dotnet_ok,
},
"versions": {
"pandoc": pandoc_version,
"dotnet": dotnet_version,
},
"final_docx_mode": final_docx_mode,
"recommendation": (
"完整 DOCX OpenXML 环境已就绪,建议使用完整 Word 生成和校验流程。"
if docx_ready
else "完整 DOCX OpenXML 环境未就绪。可以继续使用兜底 DOCX 生成;如需更规范的 Word 结构和校验,请先安装 .NET SDK 并运行 vendor/docx-toolkit/scripts/setup.sh。"
),
"install_prompt": (
"是否安装完整 DOCX 环境?安装后文档生成和校验更规范;不安装也可以继续生成 Markdown、TXT 和基础 DOCX。"
if not docx_ready
else "无需安装,完整环境可用。"
),
"requires_user_input": requires_user_input,
"confirmation_stage": "environment" if requires_user_input else None,
"next_action": next_action,
"docx_env_output": docx_output,
}
def write_markdown(path: Path, data: dict[str, Any]) -> None:
caps = data["capabilities"]
lines = [
"# 软著申请资料生成环境检查",
"",
f"- 输出目录:`{data['output_directory']}`",
f"- 最终 Word 模式:`{data['final_docx_mode']}`",
"",
"## 能力状态",
"",
f"- Markdown 草稿:{'可用' if caps['markdown_drafts'] else '不可用'}",
f"- 申请表 TXT:{'可用' if caps['application_txt'] else '不可用'}",
f"- 基础 DOCX 生成:{'可用' if caps['basic_docx'] else '不可用'}",
f"- python-docx:{'可用' if caps['python_docx'] else '不可用'}",
f"- pandoc 预览:{'可用' if caps['pandoc_preview'] else '不可用'}({data['versions']['pandoc']})",
f"- .NET SDK:{'可用' if caps['dotnet_sdk'] else '不可用'}({data['versions']['dotnet']})",
f"- DOCX OpenXML 完整环境:{'可用' if caps['docx_openxml_full'] else '不可用'}",
"",
"## 建议",
"",
data["recommendation"],
"",
"## 用户选择",
"",
data["install_prompt"],
"",
"如果完整 DOCX 环境不可用,必须先等待用户选择,并记录 `environment` 门禁后再继续。",
"",
"```text" if data.get("requires_user_input") else "",
"STOP_FOR_USER" if data.get("requires_user_input") else "",
f"NEXT_ACTION: {data['next_action']}" if data.get("requires_user_input") else "",
"```" if data.get("requires_user_input") else "",
"",
"## DOCX 环境输出摘要",
"",
"```text",
"\n".join(data["docx_env_output"].splitlines()[:40]),
"```",
"",
]
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--out-dir", default="软件著作权申请资料")
args = parser.parse_args()
skill_dir = Path(__file__).resolve().parents[1]
out_dir = ensure_dir(Path(args.out_dir))
data = check_environment(skill_dir)
write_json(out_dir / "环境检查.json", data)
write_markdown(out_dir / "环境检查.md", data)
print(f"OK environment check: {out_dir}")
print(f"Final DOCX mode: {data['final_docx_mode']}")
print(data["recommendation"])
if data.get("requires_user_input"):
print("STOP_FOR_USER")
print(f"NEXT_ACTION: {data['next_action']}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Shared helpers for the software copyright materials skill."""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any, Iterable
EXCLUDE_DIRS = {
".git",
".hg",
".svn",
".idea",
".vscode",
"__pycache__",
"node_modules",
"dist",
"build",
".next",
".nuxt",
".output",
"coverage",
"target",
"vendor",
"软件著作权申请资料",
"software-copyright-materials",
}
CODE_EXTS = {
".vue",
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".css",
".scss",
".sass",
".less",
".html",
".svelte",
".astro",
".json",
".md",
}
KNOWN_CONFIG_FILES = {
".babelrc",
".eslintrc",
".eslintrc.json",
".eslintrc.yaml",
".eslintrc.yml",
".prettierrc",
".prettierrc.json",
".prettierrc.yaml",
".prettierrc.yml",
".swcrc",
"angular.json",
"app.json",
"astro.config.mjs",
"astro.config.ts",
"babel.config.js",
"babel.config.json",
"Cargo.lock",
"Cargo.toml",
"composer.json",
"docker-compose.yaml",
"docker-compose.yml",
"eslint.config.cjs",
"eslint.config.js",
"eslint.config.mjs",
"go.mod",
"go.sum",
"jsconfig.json",
"lerna.json",
"manifest.json",
"next.config.js",
"next.config.mjs",
"next.config.ts",
"nuxt.config.js",
"nuxt.config.ts",
"nx.json",
"package-lock.json",
"package.json",
"playwright.config.js",
"playwright.config.ts",
"postcss.config.cjs",
"postcss.config.js",
"prettier.config.cjs",
"prettier.config.js",
"prettier.config.mjs",
"project.json",
"pyproject.toml",
"rollup.config.js",
"rollup.config.mjs",
"rollup.config.ts",
"svelte.config.js",
"stylelintrc.json",
"tailwind.config.js",
"tailwind.config.ts",
"tsconfig.app.json",
"tsconfig.json",
"tsconfig.node.json",
"tslint.json",
"turbo.json",
"vite.config.js",
"vite.config.mjs",
"vite.config.ts",
"vitest.config.js",
"vitest.config.ts",
"webpack.config.js",
"webpack.config.ts",
"workspace.json",
}
FRONTEND_EXTS = {
".vue",
".ts",
".tsx",
".js",
".jsx",
".mjs",
".css",
".scss",
".sass",
".less",
".html",
".svelte",
".astro",
}
SUPPLEMENT_CODE_EXTS = {
".py",
".java",
".go",
".rs",
".cs",
".php",
".rb",
".kt",
".swift",
".sql",
".sh",
".json",
}
COPYRIGHT_CODE_EXTS = FRONTEND_EXTS | SUPPLEMENT_CODE_EXTS
LOCK_FILES = {
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"bun.lockb",
"bun.lock",
}
def repo_root_from_script() -> Path:
return Path(__file__).resolve().parents[3]
def is_excluded(path: Path) -> bool:
parts = set(path.parts)
if parts & EXCLUDE_DIRS:
return True
name = path.name
if name.startswith(".") and name not in {".env.example"}:
return True
if name in LOCK_FILES:
return True
if name.endswith(".map") or name.endswith(".min.js") or name.endswith(".min.css"):
return True
return False
def iter_project_files(project: Path, exts: set[str] | None = None) -> Iterable[Path]:
project = project.resolve()
for root, dirs, files in os.walk(project):
root_path = Path(root)
dirs[:] = [d for d in dirs if not is_excluded(root_path / d)]
for filename in files:
path = root_path / filename
if is_excluded(path):
continue
if exts is not None and path.suffix.lower() not in exts:
continue
yield path
def rel(path: Path, root: Path) -> str:
return path.resolve().relative_to(root.resolve()).as_posix()
def read_text(path: Path, limit: int | None = None) -> str:
data = path.read_bytes()
if limit is not None:
data = data[:limit]
for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="replace")
def read_json(path: Path) -> dict[str, Any]:
return json.loads(read_text(path))
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def count_text_lines(path: Path, skip_blank: bool = True) -> int:
try:
text = read_text(path)
except Exception:
return 0
if not text:
return 0
if skip_blank:
return sum(1 for line in text.splitlines() if line.strip())
return len(text.splitlines())
def is_known_config_file(path: Path) -> bool:
"""Return True for well-known config files that shouldn't count as source code."""
return path.name in KNOWN_CONFIG_FILES
def looks_binary(path: Path) -> bool:
try:
chunk = path.read_bytes()[:4096]
except Exception:
return True
return b"\x00" in chunk
def normalize_title(value: str) -> str:
value = re.sub(r"[-_]+", " ", value).strip()
value = re.sub(r"\s+", " ", value)
return value or "待命名软件"
def safe_filename(value: str) -> str:
value = re.sub(r'[\\/:*?"<>|]+', "_", value).strip()
return value or "软件"
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
#!/usr/bin/env python3
"""Record explicit user confirmations for gated workflow stages."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from common import read_json, write_json
def timestamp() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def load_json_or_empty(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return read_json(path)
def write_confirmation(path: Path, data: dict[str, Any], key: str, note: str) -> None:
data[key] = True
data["confirmation_note"] = note
data["confirmed_at"] = timestamp()
write_json(path, data)
def pending_application_fields(md_path: Path) -> list[str]:
if not md_path.exists():
return [f"缺少 {md_path}"]
return [line.strip() for line in md_path.read_text(encoding="utf-8").splitlines() if "待用户确认" in line]
def confirm_environment(workdir: Path, note: str) -> Path:
out_path = workdir / "环境确认.json"
data = load_json_or_empty(out_path)
write_confirmation(out_path, data, "environment_confirmed", note)
return out_path
def confirm_project(workdir: Path, note: str) -> Path:
out_path = workdir / "项目确认.json"
data = load_json_or_empty(out_path)
write_confirmation(out_path, data, "project_confirmed", note)
return out_path
def confirm_business(workdir: Path, note: str) -> Path:
path = workdir / "草稿/业务理解.json"
if not path.exists():
raise SystemExit("Missing 草稿/业务理解.json")
data = read_json(path)
write_confirmation(path, data, "user_confirmed", note)
return path
def confirm_code_selection(workdir: Path, note: str) -> Path:
path = workdir / "草稿/代码文件选择.json"
if not path.exists():
raise SystemExit("Missing 草稿/代码文件选择.json")
data = read_json(path)
files = data.get("files") if isinstance(data, dict) else []
selected = [item for item in files if isinstance(item, dict) and item.get("selected")]
if not selected:
raise SystemExit(
"STOP_FOR_USER\n"
"NEXT_ACTION: 代码文件选择尚未由模型填写。请先选择至少一个源码文件并填写选择理由,再让用户确认。"
)
missing_reason = [item.get("path") for item in selected if not str(item.get("model_reason") or "").strip()]
if data.get("model_selection_required") and missing_reason:
raise SystemExit(
"STOP_FOR_USER\n"
"NEXT_ACTION: 已选源码缺少模型选择理由,请补全 `model_reason` 后再确认。\n"
+ "\n".join(f"- {item}" for item in missing_reason[:20])
)
write_confirmation(path, data, "user_confirmed", note)
return path
def parse_screenshot_method(method: str, note: str) -> str:
value = (method or note or "").lower()
if any(key in value for key in ("skip", "no-screenshot", "none", "不截图", "跳过", "暂不", "先不", "不要截图", "无需截图")):
return "skip"
if any(key in value for key in ("chrome", "devtools", "mcp")):
return "chrome-devtools"
if any(key in value for key in ("computer", "use", "电脑", "桌面")):
return "computer-use"
if any(key in value for key in ("user", "manual", "self", "手动", "自己", "用户")):
return "user-supplied"
raise SystemExit(
"STOP_FOR_USER\n"
"NEXT_ACTION: 请明确截图方式:chrome-devtools、computer-use、user-supplied 或 skip。"
)
def confirm_screenshot_method(workdir: Path, note: str, method: str) -> Path:
selected = parse_screenshot_method(method, note)
out_path = workdir / "截图方式确认.json"
data = load_json_or_empty(out_path)
data["screenshot_method"] = selected
write_confirmation(out_path, data, "screenshot_method_confirmed", note)
return out_path
def confirm_application_fields(workdir: Path, note: str) -> Path:
pending = pending_application_fields(workdir / "草稿/申请表信息.md")
if pending:
raise SystemExit(
"STOP_FOR_USER\n"
"NEXT_ACTION: 申请表信息仍包含“待用户确认”。请先补全字段,再重新确认。\n"
+ "\n".join(f"- {item}" for item in pending[:20])
)
out_path = workdir / "草稿/申请表字段确认.json"
data = load_json_or_empty(out_path)
write_confirmation(out_path, data, "application_fields_confirmed", note)
return out_path
def confirm_markdown(workdir: Path, note: str) -> Path:
issues = []
business = workdir / "草稿/业务理解.json"
selection = workdir / "草稿/代码文件选择.json"
screenshot = workdir / "截图方式确认.json"
fields = workdir / "草稿/申请表字段确认.json"
if not business.exists() or not read_json(business).get("user_confirmed"):
issues.append("业务理解尚未确认")
if not selection.exists() or not read_json(selection).get("user_confirmed"):
issues.append("代码文件选择尚未确认")
if not screenshot.exists() or not read_json(screenshot).get("screenshot_method_confirmed"):
issues.append("截图方式尚未确认")
if not fields.exists() or not read_json(fields).get("application_fields_confirmed"):
issues.append("申请表字段尚未确认")
pending = pending_application_fields(workdir / "草稿/申请表信息.md")
if pending:
issues.append("申请表信息仍包含“待用户确认”")
if issues:
raise SystemExit(
"STOP_FOR_USER\n"
"NEXT_ACTION: Markdown 草稿确认前需要先处理以下事项:\n"
+ "\n".join(f"- {item}" for item in issues)
)
out_path = workdir / "草稿/最终生成确认.json"
data = load_json_or_empty(out_path)
write_confirmation(out_path, data, "markdown_confirmed", note)
return out_path
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--workdir", default="软件著作权申请资料")
parser.add_argument(
"--stage",
required=True,
choices=[
"environment",
"project",
"business",
"code-selection",
"screenshot-method",
"application-fields",
"markdown",
],
)
parser.add_argument("--note", default="用户已确认")
parser.add_argument(
"--method",
choices=["chrome-devtools", "computer-use", "user-supplied", "skip"],
help="Screenshot capture method when --stage screenshot-method",
)
args = parser.parse_args()
workdir = Path(args.workdir)
if args.stage == "environment":
path = confirm_environment(workdir, args.note)
elif args.stage == "project":
path = confirm_project(workdir, args.note)
elif args.stage == "business":
path = confirm_business(workdir, args.note)
elif args.stage == "code-selection":
path = confirm_code_selection(workdir, args.note)
elif args.stage == "screenshot-method":
path = confirm_screenshot_method(workdir, args.note, args.method or "")
elif args.stage == "application-fields":
path = confirm_application_fields(workdir, args.note)
else:
path = confirm_markdown(workdir, args.note)
print(f"OK confirmation recorded: {args.stage}")
print(path)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Create an editable source-file evidence list before code extraction."""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any
from common import COPYRIGHT_CODE_EXTS, FRONTEND_EXTS, ensure_dir, is_known_config_file, iter_project_files, read_json, rel, write_json
from extract_code_material import LINES_PER_PAGE, SPLIT_THRESHOLD_PAGES, category_weight, should_skip_file
DEFAULT_MAX_FILES = 0
def evidence_for(path: Path, project: Path) -> str:
priority, _ = category_weight(path, project)
if priority == 0:
return "入口文件证据"
if priority == 10:
return "路由文件证据"
if priority == 20:
return "页面文件证据"
if priority == 30:
return "数据交互文件证据"
if priority == 40:
return "状态或数据文件证据"
if priority == 50:
return "页面组成文件证据"
if priority == 60:
return "通用能力文件证据"
if priority == 90:
return "样式文件证据"
if path.suffix.lower() not in FRONTEND_EXTS:
return "补充源码证据"
return "普通源码文件"
def build_candidates(project: Path) -> list[dict[str, Any]]:
files = [p for p in iter_project_files(project, COPYRIGHT_CODE_EXTS) if not should_skip_file(p) and not is_known_config_file(p)]
files.sort(key=lambda p: category_weight(p, project))
candidates: list[dict[str, Any]] = []
for path in files:
try:
line_count = len(path.read_text(encoding="utf-8", errors="replace").splitlines())
except Exception:
line_count = 0
priority, _ = category_weight(path, project)
candidates.append(
{
"path": rel(path, project),
"selected": False,
"line_count": line_count,
"priority": priority,
"selection_tier": "frontend" if path.suffix.lower() in FRONTEND_EXTS else "supplement",
"evidence": evidence_for(path, project),
"model_reason": "",
}
)
return candidates
def selected_line_estimate(item: dict[str, Any]) -> int:
return int(item.get("line_count") or 0) + 2
def selection_stats(candidates: list[dict[str, Any]]) -> dict[str, int]:
selected_items = [item for item in candidates if item.get("selected")]
return {
"selected_count": len(selected_items),
"selected_lines": sum(selected_line_estimate(item) for item in selected_items),
}
def all_candidate_lines(candidates: list[dict[str, Any]]) -> int:
return sum(selected_line_estimate(item) for item in candidates)
def write_selection_md(path: Path, data: dict[str, Any]) -> None:
lines = [
"# 代码文件候选清单",
"",
"请先确认要抽取哪些源码文件,再运行代码材料抽取。",
"",
"本清单只列出候选源码证据,不默认决定抽取文件。",
"模型需要先理解项目业务、页面入口和源码职责,再填写 `selected/model_reason`。",
f"当前已选约 {data['estimated_selected_pages']} 页,全部候选源码约 {data['estimated_all_candidate_pages']} 页。",
"",
"```text",
"STOP_FOR_USER",
"NEXT_ACTION: 请由模型先填写 草稿/代码文件选择.json 的抽取选择和选择理由,再让用户确认;确认后运行 confirm_stage.py --stage code-selection。",
"```",
"",
"确认方式:",
"",
"1. 模型根据项目业务和代码入口选择最能体现软件功能的文件。",
"2. 把需要抽取的文件设为 `selected: true`,并填写 `model_reason`。",
"3. 代码材料按完整文件原样复制,不支持只抽取某个文件的中间行段。",
"4. 用户确认模型选择后,再记录 `code-selection` 门禁。",
"",
"## 默认选中文件",
"",
"| 文件 | 行数 | 模型选择理由 |",
"| --- | ---: | --- |",
]
for item in data["files"]:
if item.get("selected"):
lines.append(f"| `{item['path']}` | {item['line_count']} | {item.get('model_reason') or '待模型填写'} |")
lines.extend(["", "## 未选候选文件", "", "| 文件 | 行数 | 证据类型 |", "| --- | ---: | --- |"])
for item in data["files"]:
if not item.get("selected"):
lines.append(f"| `{item['path']}` | {item['line_count']} | {item['evidence']} |")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--project", required=True)
parser.add_argument("--analysis", help="Optional project analysis JSON; retained for workflow traceability")
parser.add_argument("--out-dir", default="软件著作权申请资料/草稿")
parser.add_argument("--max-files", type=int, default=DEFAULT_MAX_FILES, help="Only limits candidate inventory size; does not auto-select files")
parser.add_argument("--target-pages", type=int, default=SPLIT_THRESHOLD_PAGES)
parser.add_argument("--lines-per-page", type=int, default=LINES_PER_PAGE)
args = parser.parse_args()
project = Path(args.project)
if not project.exists():
raise SystemExit(f"Project not found: {project}")
if args.analysis and not Path(args.analysis).exists():
raise SystemExit(f"Analysis JSON not found: {args.analysis}")
out_dir = ensure_dir(Path(args.out_dir))
candidates = build_candidates(project)
target_lines = max(1, args.target_pages) * max(1, args.lines_per_page)
if args.max_files:
candidates = candidates[: args.max_files]
stats = selection_stats(candidates)
candidate_lines = all_candidate_lines(candidates)
selected_pages = (stats["selected_lines"] + args.lines_per_page - 1) // args.lines_per_page if stats["selected_lines"] else 0
all_pages = (candidate_lines + args.lines_per_page - 1) // args.lines_per_page if candidate_lines else 0
data = {
"project_root": str(project.resolve()),
"selection_required": True,
"model_selection_required": True,
"confirmation_required": True,
"user_confirmed": False,
"target_pages": args.target_pages,
"lines_per_page": args.lines_per_page,
"target_lines": target_lines,
"estimated_selected_lines": stats["selected_lines"],
"estimated_selected_pages": selected_pages,
"estimated_all_candidate_lines": candidate_lines,
"estimated_all_candidate_pages": all_pages,
"supplement_rule": "模型优先选择能体现软件核心功能和真实运行逻辑的源码;不足60页时再从其他相关源码中补充;候选源码仍不足时才生成全部代码材料。",
"confirmation_stage": "code-selection",
"next_action": "请由模型填写 草稿/代码文件选择.json 的抽取选择和选择理由,再让用户确认;确认后运行 confirm_stage.py --stage code-selection。",
"instructions": "The script only inventories source files. The model must choose selected/model_reason before user confirmation. Selected files are copied in full.",
"files": candidates,
}
write_json(out_dir / "代码文件选择.json", data)
write_selection_md(out_dir / "代码文件候选清单.md", data)
selected_count = sum(1 for item in candidates if item.get("selected"))
print(f"OK code selection draft: {out_dir}")
print(f"Candidates: {len(candidates)}")
print(f"Model selected: {selected_count}")
print(f"Estimated selected pages: {selected_pages}")
print(f"Estimated all candidate pages: {all_pages}")
print("STOP_FOR_USER")
print(f"NEXT_ACTION: {data['next_action']}")
if __name__ == "__main__":
main()
obj/
bin/
*.user
MIT License
Copyright (c) 2026 DocxToolkit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") <file.doc> [output_directory]"
echo "Convert .doc to .docx using LibreOffice."
exit 1
}
if [ $# -lt 1 ]; then
usage
fi
INPUT="$1"
OUTDIR="${2:-.}"
if [ ! -f "$INPUT" ]; then
echo "Error: File not found: $INPUT"
exit 1
fi
if ! command -v soffice &>/dev/null; then
echo "Error: soffice (LibreOffice) is required for .doc conversion but not found."
echo "Install LibreOffice: brew install --cask libreoffice"
exit 1
fi
BASENAME=$(basename "$INPUT" .doc)
mkdir -p "$OUTDIR"
echo "Converting: $INPUT -> $OUTDIR/$BASENAME.docx"
soffice --headless --convert-to docx --outdir "$OUTDIR" "$INPUT" >/dev/null 2>&1
OUTPUT="$OUTDIR/$BASENAME.docx"
if [ ! -f "$OUTPUT" ]; then
echo "Error: Conversion failed. Output file not created: $OUTPUT"
exit 1
fi
echo "Success: $OUTPUT"
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") <file.docx>"
echo "Preview DOCX content as plain text."
exit 1
}
if [ $# -lt 1 ]; then
usage
fi
INPUT="$1"
if [ ! -f "$INPUT" ]; then
echo "Error: File not found: $INPUT"
exit 1
fi
FILE_SIZE=$(du -h "$INPUT" | cut -f1)
echo "=== DOCX Preview: $(basename "$INPUT") ==="
echo "File size: $FILE_SIZE"
if command -v pandoc &>/dev/null; then
CONTENT=$(pandoc -f docx -t plain "$INPUT" 2>/dev/null)
WORD_COUNT=$(echo "$CONTENT" | wc -w | tr -d ' ')
EST_PAGES=$(( (WORD_COUNT + 249) / 250 ))
echo "Word count: $WORD_COUNT"
echo "Estimated pages: $EST_PAGES"
echo "---"
echo "$CONTENT"
else
echo "(pandoc not available, falling back to raw XML extract)"
echo "---"
unzip -p "$INPUT" word/document.xml 2>/dev/null | head -100
fi
Related skills
FAQ
Is Software Copyright Materials safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.