Skill: aiworkskills/aws-wechat-article-assets (version 1.0.24)

--- name: aws-wechat-article-assets description: 公众号素材｜业务资料库｜预设包｜.aws 预设包｜主题包｜品牌包｜aiworkskills.cn — 用户业务资料库与预设包管理：业务资料按产品名组织在 `.aws-article/products/{产品名}/`（介绍 .md 直挂产品根 + 配图归 `images/` 子目录含同名说明 .md），AI 与用户对话产出业务介绍内容时引导用户保存；图片入库走 `product_image_ingest.py --product <产品名> --stem <中文名>`。导入 .aws ZIP 预设包（本地文件或 `https://aiworkskills.cn/**/*.aws` URL）合并主题/配色/字体配置到 `.aws-article/presets/`；`config.yaml` 仅本地不存在时从包内复制，已存在则 stdout 输出差异 JSON 不覆盖。面向内容运营、品牌团队、设计支持岗。触发词：「素材库入库」「stock images」「上传图到素材库」「.aws」「预设包」「导入预设」「主题包」「aiworkskills.cn 链接」「.aws 下载地址」。 homepage: https://aiworkskills.cn url: https://github.com/aiworkskills/wechat-article-skills metadata: openclaw: requires: env: [] bins: - python3 --- # 用户业务资料库与预设（Assets） **业务资料按产品名分目录** — `.aws-article/products/{产品名}/` 直挂业务介绍 `.md`、`images/` 存配图（含同名说明 `.md`）。**写涉及用户自身业务的文章/配图前必须先查这里；新生成的业务介绍内容应引导用户保存到这里**。预设包合并到 `.aws-article/presets/`。 > **套件说明** · 本 skill 属 `aws-wechat-article-*` 一条龙套件（共 9 个 slug，入口 `aws-wechat-article-main`）。跨 skill 的相对引用依赖同一 `skills/` 目录，建议一并 `clawhub install` 全套。源码：<https://github.com/aiworkskills/wechat-article-skills> ## 能力披露（Capabilities） 本 skill 管理本地业务资料库与预设包，可选从 `aiworkskills.cn` 域下载 `.aws` 预设包（ZIP 格式）。 - **凭证**：无 - **网络**：可选 `https://*.aiworkskills.cn/**/*.aws` 下载预设包。**白名单强制**：仅 HTTPS + `aiworkskills.cn` 子域，非白名单会**直接报错退出**。调试参数 `--allow-any-host` 可放宽但不推荐生产使用 - **文件读**：用户指定的本地图片路径或 `.aws` 文件（脚本边界）；AI 在引导业务介绍入库时会先 `ls .aws-article/products/` 看已有产品名 - **文件写**：仓库内 `.aws-article/products/{产品名}/*.md`（业务介绍，AI 用 Write 工具直接落库）、`.aws-article/products/{产品名}/images/*`（图片 + 同名 `.md`，由脚本写）、`.aws-article/presets/<子目录>/*`（预设文件）、`.aws-article/downloads/*.aws`（下载缓存）、`.aws-article/tmp/*`（解压临时目录）；导入 `.aws` 时**仓库根 `aws.env`** 会按映射表增量写入密钥字段（覆盖现有键前自动备份为 `aws.env.bak.{ts}`，stderr 仅打印键名不打印值） - **归档**：解压 `.aws` 扩展名的 ZIP 到 `.aws-article/tmp/`，按白名单子目录合并到 `.aws-article/presets/`。**已内置 ZIP slip 防御**：逐项校验 ZIP 成员路径，拒绝含绝对路径、`..` 段或解析后指向解压目录外的路径，任一违反立即退出不写入任何文件 - **shell**：仅 `python3 {baseDir}/scripts/product_image_ingest.py`、`import_presets_aws.py` 所有写入限制在仓库根下的 `.aws-article/` 内。 ## 配套 skill（informational） 本 skill 是 `aws-wechat-article-*` 一条龙公众号套件的**业务资料库与预设环节**（入口 `aws-wechat-article-main`）。 - **单独安装可用**：业务图入库 / `.aws` 预设导入两个脚本都不依赖兄弟 skill，只要 `.aws-article/` 目录就能工作。 - 其他 skill 读取 `.aws-article/products/{产品名}/`（业务介绍 + `images/`）和 `.aws-article/presets/` 里由本 skill 管理的资源，属套件协同；需结合写稿/配图/排版等 skill 使用。 完整 9 slug 清单见 [源码仓库](https://github.com/aiworkskills/wechat-article-skills)。 | 能力 | 说明 | |------|------| | **业务介绍 .md 入库** | AI 与用户对话产出业务介绍内容时，引导/响应保存到 `products/{产品名}/<文件名>.md`（用 Write 工具直接落库，无脚本） | | **业务图入库** | 用户业务图 → `products/{产品名}/images/`（脚本 `--product` 必填），供其它 skill 引用 | | **预设包 `.aws`** | ZIP 包（本地文件或 `https://aiworkskills.cn/**/*.aws` URL）→ 合并 `presets/` 子目录；`config.yaml` 见下 | --- ## 〇、设计意图（必读）⛔ `.aws-article/products/{产品名}/` 是**用户自家业务**（卖货 / 卖软件 / 卖服务 / 自媒体 IP 等）的**资料库**——既是 AI 写涉及业务的内容时的**底稿来源**，也是 AI 与用户协作产出新介绍时的**保存目的地**。 ### 目录约定 ``` .aws-article/products/{产品名}/ ├─ 项目介绍.md # 业务介绍 .md 直挂产品根（命名按用户行业，如 服务介绍.md / 品牌介绍.md） ├─ (其他业务文档.md) └─ images/ ├─ 配置首页.png └─ 配置首页.md # 图片说明 .md（同名） ``` ### 双向触发 | 方向 | 触发条件 | 行为 | |------|---------|------| | **读** | 当前任务涉及用户自家业务（对外介绍 / 教程 / 案例 / 自家业务安利 / 业务相关配图） | 先 `ls .aws-article/products/`，识别本篇相关产品，读该产品根下 `*.md`、查 `images/` 同名 `.md`，把已有素材作为底稿/配图候选 | | **写** | AI 在会话中刚生成或改写的内容**语义属于用户业务介绍** | 走 [一、业务介绍 .md 入库](#一业务介绍-md-入库product-intro)；用户主动说"保存为产品介绍"等也走此流程 | ### 反例（不要触发） - 主题是行业资讯 / 通用教程 / 与用户业务无关 → 不读、不写 - 用户明确表示内容"还没定型" → 不主动引导保存 - AI 不确定是不是用户自家业务时 → 宁可不主动提，也不要乱塞进 `products/` --- ## 一、业务介绍 .md 入库（Product Intro） 无需脚本，AI 用 Write 工具直接落库。 ### 触发模式 A：AI 主动识别 + 引导 AI 在任意会话中刚生成或刚改写了一段内容，**其语义明确属于用户自家业务介绍**（产品 / 服务 / 品牌 / 项目 / 团队 / 业务范围介绍）时，主动提示用户： > "这段是 [产品名] 的业务介绍，要不要保存到产品资料库？我可以存到 `.aws-article/products/{产品名}/{文件名}.md`，下次写涉及业务的文章时会自动用上。" 用户确认 → 走「保存流程」。 ### 触发模式 B：用户主动指令 用户说类似 "保存为产品介绍 / 业务介绍 / 服务介绍 / 自家介绍 / 入库到产品 / 存到产品资料库" 时，AI 把当前会话中的目标内容（用户指定段落或最近相关产出）走「保存流程」。 ### 保存流程 1. **确认产品名**：`ls .aws-article/products/`，已有目录则提示复用；新产品则向用户拿名字 2. **确认文件名**：默认 `项目介绍.md`；可改为 `产品介绍.md` / `服务介绍.md` / `品牌介绍.md` / `业务介绍.md` 等贴用户行业的命名 3. **创建目录**：`mkdir -p .aws-article/products/{产品名}/images/`（即便暂为空，把骨架建齐） 4. **写入文件**：用 Write 工具落到 `.aws-article/products/{产品名}/{文件名}.md` 5. **反馈**：「已存到 `<完整路径>`，下次涉及 [产品名] 业务的文章会自动用上」 --- ## 二、业务图入库（Product Images） ### 目录 | 路径 | 作用 | |------|------| | `.aws-article/products/{产品名}/images/` | 入库图片 + 同名 `.md`（固定：**图片路径** / **图片描述**） | ### 工作流 1. 用户上传或给出本地图片路径，并指明**所属产品**（与 `products/` 下某个目录对应；新产品则脚本会自动创建）。 2. **Agent 读图**（多模态能力在本对话侧）：确定**中文主文件名**（如 `配置首页`），并写出**客观画面描述**（供 `.md` 与后续配图检索使用）。 3. 在**仓库根**执行（**推荐**带上 `--content`，与第 2 步描述一致）： ```bash python {baseDir}/scripts/product_image_ingest.py <源图片路径> \ --product "公众号AI运营助手" --stem "配置首页" \ --content "客观中文描述，一两句即可" ``` `--product` **必填**；产品目录与 `images/` 子目录不存在时**自动创建**。 4. 生成 `配置首页.png` + `配置首页.md`（格式见下）。 ### 图片描述与占位 ⛔ - **`product_image_ingest.py` 不会读图**：无视觉/多模态，只负责**复制图片**并**按模板写 `.md`**。 - **未传 `--content`（或为空）** 时，「**图片描述**」会写入固定占位句：**「请根据图片补全（客观描述画面内容即可）。」**——这是预期行为，不是脚本故障。 - **要直接得到可用描述**：入库命令必须带 **`--content "……"`**（由 Agent 读图后填写），或入库后**手动/由 Agent 编辑**同名 `.md` 替换占位段。 ### `.md` 固定格式 ```markdown **图片路径**：`.aws-article/products/公众号AI运营助手/images/示例.png` **图片描述**：…… ``` ### 脚本 `product_image_ingest.py` - `source`、`--product`（**必填**）、`--stem`（必填）、`--content`（可选，**强烈建议由 Agent 读图后传入**）、`--repo`（可选） --- ## 三、预设包导入（`.aws`） 扩展名 **`.aws`**，实质为 **ZIP**。解压后根目录应包含与仓库一致的预设文件夹（可多出其它文件，脚本只处理下列目录）： `closing-blocks`、`cover-styles`、`formatting`、`image-styles`、`sticker-styles`、`structures`、`title-styles` 另可有根级 **`config.yaml`**、**`writing-spec.md`**。 ### 输入来源（本地 / URL） `bundle` 参数同时接受两种形态： - **本地路径**：`./brand-a.aws` 或绝对路径 - **HTTPS URL**：仅限 `aiworkskills.cn` 及其子域，必须 `https://` 开头、路径以 `.aws` 结尾 - 示例：`https://aiworkskills.cn/bundles/brand-a.aws` - 下载缓存：`.aws-article/downloads/<原文件名>`（**不在 `tmp/` 内**，不受清空影响，保留供事后核对） - 不在白名单、非 https、或下载内容非 ZIP → **直接报错退出** - 调试放宽：`--allow-any-host` 可跳过域名白名单（仍强制 https）；不建议生产使用 ### 合并规则 - 每个上述目录采用**「替换式」语义**（以服务端为准，避免旧文件残留）： - 若**包内存在**该子目录 → **先清空本地 `.aws-article/presets/<同名>/` 再写入包内内容**（旧包里有、新包里删掉的文件不会残留）； - 若**包内不存在**该子目录 → 本地对应子目录**保持不动**（不受本次导入影响）。 - 包根优先级：若包根下**同时**存在 **`presets/<名>/`** 与 **`<名>/`**，脚本**优先合并前者**；若目录内仅有一层多余 **`<名>/<名>/`**，脚本会自动以内层为合并根。 - **`config.yaml`**：若包内存在且本地**尚无** `.aws-article/config.yaml`，则从包内**复制**；若本地**已有**，则**不覆盖**，按包内字段与本地**同名键**递归比对，将差异以 **JSON 数组** 打印到 **stdout**（`{"key":"点分路径","old":…,"new":…}`），供智能体询问用户后再手改配置；说明日志在 stderr。 - **`writing-spec.md`**：若包内存在，**始终覆盖**写入 **`.aws-article/writing-spec.md`**（与 `config.yaml` 不同，不做差异比对）。 - 解压目录：**`.aws-article/tmp/`**（固定路径；运行前若无 `.aws-article` 会创建）。**每次执行前**若 `tmp` 已存在则**整目录删除后重建**，再解压本次 `.aws`；合并到 `presets/` 后**保留**解压结果便于核对，下次导入会再次清空 `tmp` 并覆盖为新包内容。 ### 密钥与配置 - **`config.yaml` 中的密钥字段会被增量写入仓库根 `aws.env`**。包内 `config.yaml` 顶层 `wechat_appid` / `wechat_appsecret`、嵌套 `writing_model.api_key` / `image_model.api_key` 在导入时按下表映射写入： | `config.yaml` 字段 | `aws.env` 键 | |---|---| | `wechat_appid` | `WECHAT_1_APPID` | | `wechat_appsecret` | `WECHAT_1_APPSECRET` | | `writing_model.api_key` | `WRITING_MODEL_API_KEY` | | `image_model.api_key` | `IMAGE_MODEL_API_KEY` | - 写入策略：包内字段为空 → 不动 `aws.env` 现有键；`aws.env` 无该键 → 追加；已有相同值 → 跳过；已有不同值 → 写入前备份 `aws.env.bak.{ts}` 后覆盖。stderr 仅输出键名清单，不打印密钥值；保留原文件顺序、空行与注释。当前前端导出仅支持单微信账号（固定槽位 1），`aws.env` 中的 `WECHAT_2_*` 等其他键不受导入影响。 - `config.yaml`（运营配置）：本地无则首次复制；本地有则差异 JSON 输出到 stdout 不覆盖（与原行为一致），由 Agent 询问用户确认后手改。 ### 工作流 1. 准备 `*.aws` 来源：**(a)** 本地文件（上传或已有路径），或 **(b)** 符合白名单的 **HTTPS URL**。 2. 可先 **`--dry-run`** 查看将写入的路径（URL 模式下仍会实际下载到 `downloads/` 以便校验 ZIP 结构，但不写入 `presets/` 与 `config.yaml`）。 3. 在**仓库根**执行： ```bash # 本地路径 python {baseDir}/scripts/import_presets_aws.py path/to/bundle.aws python {baseDir}/scripts/import_presets_aws.py path/to/bundle.aws --dry-run # URL（仅 aiworkskills.cn 及子域） python {baseDir}/scripts/import_presets_aws.py https://aiworkskills.cn/bundles/brand-a.aws python {baseDir}/scripts/import_presets_aws.py https://aiworkskills.cn/x/y.aws --dry-run ``` ### 脚本 `import_presets_aws.py` - 参数：`bundle`（`.aws` 路径 或 `https://*.aiworkskills.cn` URL）、`--dry-run`、`--repo`、`--allow-any-host`（调试） --- ## 脚本一览 | 脚本 | 路径 | |------|------| | `product_image_ingest.py` | `{baseDir}/scripts/product_image_ingest.py` | | `import_presets_aws.py` | `{baseDir}/scripts/import_presets_aws.py` | ## 过程文件 | 场景 | 产出 | |------|------| | 业务介绍 .md 入库 | `.aws-article/products/{产品名}/{文件名}.md`（AI 用 Write 工具落库；目录不存在时同时 mkdir 包括 `images/`） | | 业务图入库 | `.aws-article/products/{产品名}/images/*.{png,...}` + 同名 `*.md` | | `.aws` 导入 | 更新 `.aws-article/presets/**`；`config.yaml` 首次复制或 stdout 差异 JSON；密钥增量写入仓库根 `aws.env`（覆盖前备份 `aws.env.bak.{ts}`）；解压缓存在 `.aws-article/tmp/` | | `.aws` URL 导入 | 下载缓存 `.aws-article/downloads/*.aws`；其余同本地导入 |

path: scripts/import_presets_aws.py
content: #!/usr/bin/env python3
"""
导入 `.aws` 预设包（ZIP 格式）：解压到 `.aws-article/tmp/`，合并到 `.aws-article/presets/`；
包根若含 `config.yaml`：
  - 若本地尚无 `.aws-article/config.yaml`，则从包内复制一份；
  - 若本地已存在，则**不覆盖**；按包内字段在本地同名路径上递归比对，将差异以 **JSON 数组** 打印到 **stdout**（供智能体读取后询问用户再手改配置）。
    每条为 {"key": "点分路径", "old": …, "new": …}。其它日志在 stderr。

aws.env 增量写入：
  - 包内 config.yaml 的敏感字段（wechat_appid/wechat_appsecret、writing_model.api_key、
    image_model.api_key）会按 SECRET_FIELD_MAP 增量写入仓库根 `aws.env`：
      * 包内字段为空 → 不动 aws.env 现有键
      * aws.env 无该键 → 追加
      * aws.env 已有相同值 → 跳过
      * aws.env 已有不同值 → 写入前备份 `aws.env.bak.{ts}` 后覆盖
  - stderr 仅打印键名清单，不打印密钥值；保留原文件顺序、空行与注释。

运行前若缺少 `.aws-article` 则创建；若缺少 `.aws-article/tmp` 则创建。每次执行前若 `tmp` 已存在则整目录删除再解压；合并完成后保留解压内容供核对，下次执行会再次清空 `tmp` 再解压。

合并规则（以服务端为准的「替换式」）：
  - 对每个预设子目录，若**包内存在**该子目录，则**先清空本地 `.aws-article/presets/<同名>/`** 再写入包内内容——确保新包移除的旧文件不会残留；
  - 若包内**不存在**某子目录，本地对应子目录**保持不动**（不受本次导入影响）。

bundle 输入支持两种形态：
  - 本地 `.aws` 文件路径；
  - 白名单 HTTPS URL：`https://` 开头，host 为 `aiworkskills.cn` 或其子域，路径以 `.aws` 结尾。
    下载缓存到 `.aws-article/downloads/<原文件名>`（不受 tmp 清空影响，供事后核对）。

用法（仓库根）：
  python skills/aws-wechat-article-assets/scripts/import_presets_aws.py path/to/bundle.aws
  python skills/aws-wechat-article-assets/scripts/import_presets_aws.py path/to/bundle.aws --dry-run
  python skills/aws-wechat-article-assets/scripts/import_presets_aws.py https://aiworkskills.cn/bundles/brand-a.aws
"""

from __future__ import annotations

import argparse
import json
import shutil
import sys
import urllib.error
import urllib.request
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

try:
    import yaml
except ImportError:
    yaml = None  # type: ignore[misc, assignment]

PRESET_SUBDIRS = (
    "closing-blocks",
    "cover-styles",
    "formatting",
    "image-styles",
    "sticker-styles",
    "structures",
    "title-styles",
)

SKIP_NAMES = frozenset({"__MACOSX", ".DS_Store"})

ALLOWED_HOST_EXACT = "aiworkskills.cn"
ALLOWED_HOST_SUFFIX = ".aiworkskills.cn"
DOWNLOAD_TIMEOUT_SEC = 30

# config.yaml 字段（点分路径） → aws.env 键名。
# 当前前端导出仅支持单微信账号，固定映射到槽位 1。
SECRET_FIELD_MAP: tuple[tuple[str, str], ...] = (
    ("wechat_appid", "WECHAT_1_APPID"),
    ("wechat_appsecret", "WECHAT_1_APPSECRET"),
    ("writing_model.api_key", "WRITING_MODEL_API_KEY"),
    ("image_model.api_key", "IMAGE_MODEL_API_KEY"),
)


def _is_url(s: str) -> bool:
    return s.startswith(("http://", "https://"))


def _validate_url(url: str, allow_any_host: bool) -> None:
    p = urlparse(url)
    if p.scheme != "https":
        _err(f"仅允许 https:// 下载预设包，收到: {p.scheme}://")
    host = (p.hostname or "").lower()
    if not allow_any_host:
        if host != ALLOWED_HOST_EXACT and not host.endswith(ALLOWED_HOST_SUFFIX):
            _err(f"域名不在白名单（仅 aiworkskills.cn 及其子域）: {host}")
    if not Path(p.path).name.endswith(".aws"):
        _err("URL 路径须以 .aws 结尾")


def _download_bundle(url: str, repo: Path) -> Path:
    downloads = repo / ".aws-article" / "downloads"
    downloads.mkdir(parents=True, exist_ok=True)
    fname = Path(urlparse(url).path).name or "bundle.aws"
    local = downloads / fname
    tmp_out = local.with_suffix(local.suffix + ".part")
    req = urllib.request.Request(url, headers={"User-Agent": "aws-wechat-article-assets/1.0"})
    _info(f"下载中: {url}")
    try:
        with urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT_SEC) as resp, open(tmp_out, "wb") as out:
            shutil.copyfileobj(resp, out)
    except urllib.error.HTTPError as e:
        _err(f"HTTP {e.code} 下载失败: {url}")
    except urllib.error.URLError as e:
        _err(f"网络错误: {e.reason}")
    shutil.move(str(tmp_out), str(local))
    if not zipfile.is_zipfile(local):
        _err(f"下载内容不是有效 ZIP（已保留 {local} 供排查）")
    _ok(f"已下载: {local.as_posix()}")
    return local


def _err(msg: str) -> None:
    print(f"[ERROR] {msg}", file=sys.stderr)
    sys.exit(1)


def _info(msg: str) -> None:
    print(f"[INFO] {msg}", file=sys.stderr)


def _ok(msg: str) -> None:
    print(f"[OK] {msg}", file=sys.stderr)


def _find_repo_root(start: Path) -> Path:
    """仓库根 = `--repo` 参数指向的目录（默认当前工作目录）。

    不再向上遍历父目录；若传入路径不是预期的仓库根（需要存在 `.aws-article` 或 `.git`），
    直接报错退出，避免对非预期目录进行读写。
    """
    cur = start.resolve()
    if not cur.is_dir():
        _err(f"指定的仓库根不是目录：{cur}")
    if (cur / ".aws-article").is_dir() or (cur / ".git").is_dir():
        return cur
    _err(
        f"{cur} 不像仓库根（未检测到 .aws-article 或 .git 目录）。\n"
        "请传入 --repo 指向真正的仓库根，或在仓库根下运行。"
    )


def _ensure_aws_article_dir(repo: Path) -> None:
    (repo / ".aws-article").mkdir(parents=True, exist_ok=True)


def _should_skip_path(path: Path) -> bool:
    parts = path.parts
    if "__MACOSX" in parts:
        return True
    if path.name == ".DS_Store":
        return True
    return False


def _staging_dir(repo: Path) -> Path:
    """固定解压暂存路径：`.aws-article/tmp`（每次导入前会清空该目录）。"""
    return repo / ".aws-article" / "tmp"


def _reset_staging(staging: Path) -> None:
    """若暂存目录存在则删除并重建为空目录，供本次解压使用。"""
    if staging.exists():
        _info(f"清空暂存目录: {staging.as_posix()}")
        shutil.rmtree(staging, ignore_errors=False)
    staging.mkdir(parents=True, exist_ok=True)


def _safe_extractall(zf: zipfile.ZipFile, dest: Path) -> None:
    """安全解压：防御 ZIP slip（路径穿越）攻击。

    标准 `zf.extractall` 不校验成员路径，恶意 ZIP 可通过 `../` 或绝对路径
    让解压结果落在 `dest` 以外。本实现逐项校验：
      1. 拒绝绝对路径；
      2. 拒绝含 `..` 段的路径；
      3. 解析目标绝对路径后，验证仍位于 `dest` 内。
    任一项违反即立即退出，不写入任何文件。
    """
    dest_resolved = dest.resolve()
    for name in zf.namelist():
        member_path = Path(name)
        if member_path.is_absolute():
            _err(f"ZIP 内含绝对路径（可能的路径穿越攻击），已拒绝解压: {name}")
        if ".." in member_path.parts:
            _err(f"ZIP 内含 '..' 段（可能的路径穿越攻击），已拒绝解压: {name}")
        target = (dest / name).resolve()
        try:
            target.relative_to(dest_resolved)
        except ValueError:
            _err(f"ZIP 内路径指向解压目录外（可能的路径穿越攻击），已拒绝解压: {name}")
    zf.extractall(dest)


def _resolve_package_root(extracted: Path) -> Path:
    """若 ZIP 内仅一层目录且内含预设或 config，则以其为包根。"""
    items = [p for p in extracted.iterdir() if p.name not in SKIP_NAMES and not p.name.startswith(".")]
    if len(items) == 1 and items[0].is_dir():
        candidate = items[0]
        if (candidate / "config.yaml").is_file():
            return candidate
        if any((candidate / d).is_dir() for d in PRESET_SUBDIRS):
            return candidate
    return extracted


def _merge_preset_dir(src: Path, dest: Path, dry_run: bool) -> int:
    n = 0
    for f in src.rglob("*"):
        if not f.is_file() or _should_skip_path(f):
            continue
        rel = f.relative_to(src)
        out = dest / rel
        if dry_run:
            _info(f"  [dry-run] {out.as_posix()}")
        else:
            out.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(f, out)
        n += 1
    return n


def _config_diff(old: dict[str, Any], new: dict[str, Any], prefix: str = "") -> list[dict[str, Any]]:
    """对包内 new 中出现的每个键，若在 old 中存在且值不同则记录；嵌套 dict 递归到叶子。"""
    out: list[dict[str, Any]] = []
    for k, nv in new.items():
        path = f"{prefix}.{k}" if prefix else k
        if k not in old:
            continue
        ov = old[k]
        if isinstance(nv, dict) and isinstance(ov, dict):
            out.extend(_config_diff(ov, nv, path))
        elif nv != ov:
            out.append({"key": path, "old": ov, "new": nv})
    return out


def _load_yaml_mapping(path: Path, label: str) -> dict[str, Any]:
    if yaml is None:
        _err("需要 PyYAML：请 pip install pyyaml")
    raw = path.read_text(encoding="utf-8")
    data = yaml.safe_load(raw)
    if data is None:
        return {}
    if not isinstance(data, dict):
        _err(f"{label} 根节点须为 YAML mapping（字典），实际为 {type(data).__name__}")
    return data


def _print_config_diff_json(diffs: list[dict[str, Any]]) -> None:
    print(json.dumps(diffs, ensure_ascii=False, indent=2))


def _parse_dotenv(content: str) -> dict[str, str]:
    """与 skills/aws-wechat-article-main/scripts/validate_env.py 的 _parse_dotenv 保持同步。"""
    out: dict[str, str] = {}
    for raw_line in content.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            continue
        key, _, val = line.partition("=")
        key = key.strip()
        val = val.strip()
        if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
            val = val[1:-1]
        out[key] = val
    return out


def _get_dotted(d: Any, path: str) -> Any:
    cur = d
    for part in path.split("."):
        if not isinstance(cur, dict) or part not in cur:
            return None
        cur = cur[part]
    return cur


def _serialize_dotenv_line(key: str, value: str) -> str:
    if any(c in value for c in (' ', '\t', '#', '"', "'", '$', '\\', '\n', '\r')):
        escaped = value.replace('\\', '\\\\').replace('"', '\\"')
        return f'{key}="{escaped}"'
    return f"{key}={value}"


def _write_aws_env_incremental(repo: Path, package_config: dict, dry_run: bool) -> None:
    """按 SECRET_FIELD_MAP 把包内 config.yaml 的密钥字段增量写入仓库根 aws.env。

    规则：
      - 包内字段为空字符串/None → 跳过（不会动 aws.env 现有键）
      - 现有 aws.env 无该键 → 新增（追加到末尾）
      - 现有值 == 包内值 → 跳过
      - 现有值 != 包内值 → 标记覆盖；写入前备份 aws.env.bak.{ts}
      - 保留原文件顺序、空行、注释；行内只替换需要覆盖的键
      - stderr 仅打印键名清单，不打印密钥值
    """
    aws_env_path = repo / "aws.env"

    candidates: dict[str, str] = {}
    for cfg_path, env_key in SECRET_FIELD_MAP:
        v = _get_dotted(package_config, cfg_path)
        if isinstance(v, str) and v.strip():
            candidates[env_key] = v

    if not candidates:
        _info("包内未发现可写入 aws.env 的密钥字段，跳过 aws.env 更新")
        return

    if aws_env_path.is_file():
        existing_content = aws_env_path.read_text(encoding="utf-8")
        existing = _parse_dotenv(existing_content)
    else:
        existing_content = ""
        existing = {}

    added: list[str] = []
    overwritten: list[str] = []
    unchanged: list[str] = []
    for k, new_v in candidates.items():
        if k not in existing:
            added.append(k)
        elif existing[k] == new_v:
            unchanged.append(k)
        else:
            overwritten.append(k)

    if not added and not overwritten:
        _info(f"aws.env 无需更新（包内 {len(candidates)} 项与现有一致）")
        return

    if dry_run:
        bits = []
        if added:
            bits.append(f"新增 {len(added)} 项 [{', '.join(added)}]")
        if overwritten:
            bits.append(f"覆盖 {len(overwritten)} 项 [{', '.join(overwritten)}]")
        _info("[dry-run] aws.env " + "，".join(bits))
        return

    if overwritten and aws_env_path.is_file():
        ts = datetime.now().strftime("%Y%m%d-%H%M%S")
        backup = aws_env_path.with_name(f"aws.env.bak.{ts}")
        shutil.copy2(aws_env_path, backup)
        _ok(f"备份: {backup.as_posix()}")

    overwrite_set = set(overwritten)
    lines = existing_content.splitlines() if existing_content else []
    new_lines: list[str] = []
    for line in lines:
        stripped = line.strip()
        if not stripped or stripped.startswith("#") or "=" not in stripped:
            new_lines.append(line)
            continue
        key = line.partition("=")[0].strip()
        if key in overwrite_set:
            new_lines.append(_serialize_dotenv_line(key, candidates[key]))
        else:
            new_lines.append(line)

    for k in added:
        new_lines.append(_serialize_dotenv_line(k, candidates[k]))

    final = "\n".join(new_lines)
    if not final.endswith("\n"):
        final += "\n"
    aws_env_path.write_text(final, encoding="utf-8")

    parts = []
    if added:
        parts.append(f"新增 {len(added)} 项 [{', '.join(added)}]")
    if overwritten:
        parts.append(f"覆盖 {len(overwritten)} 项 [{', '.join(overwritten)}]")
    if unchanged:
        parts.append(f"未变 {len(unchanged)} 项 [{', '.join(unchanged)}]")
    _ok(f"aws.env 写入：{'，'.join(parts)}")


def main() -> None:
    parser = argparse.ArgumentParser(
        description="导入 .aws 预设包到 .aws-article/presets；config 仅首次复制，已有则 stdout 输出差异 JSON"
    )
    parser.add_argument("bundle", help="路径或 URL：本地 .aws 文件，或 https://aiworkskills.cn/**/*.aws")
    parser.add_argument("--dry-run", action="store_true", help="只打印将执行的操作，不写盘")
    parser.add_argument("--repo", default=".", help="仓库根（默认当前目录）")
    parser.add_argument(
        "--allow-any-host",
        action="store_true",
        help="调试用，放宽域名白名单（仍强制 https）；不建议生产使用",
    )
    args = parser.parse_args()

    repo = _find_repo_root(Path(args.repo))
    _ensure_aws_article_dir(repo)

    raw = args.bundle
    if _is_url(raw):
        _validate_url(raw, args.allow_any_host)
        bundle = _download_bundle(raw, repo)
    else:
        bundle = Path(raw).resolve()

    if not bundle.is_file():
        _err(f"文件不存在: {bundle}")
    if not zipfile.is_zipfile(bundle):
        _err(f"不是有效的 ZIP 包（.aws 须为 zip）: {bundle}")

    presets_root = repo / ".aws-article" / "presets"
    config_dest = repo / ".aws-article" / "config.yaml"
    staging = _staging_dir(repo)

    _reset_staging(staging)
    with zipfile.ZipFile(bundle, "r") as zf:
        _safe_extractall(zf, staging)

    root = _resolve_package_root(staging)
    _info(f"解压目录: {staging.as_posix()}")
    _info(f"包根解析为: {root}")

    has_any = (root / "config.yaml").is_file() or any((root / d).is_dir() for d in PRESET_SUBDIRS)
    if not has_any:
        _err("包内未找到 config.yaml 或任一预设目录（closing-blocks / formatting / …），请检查 .aws 内容。")

    presets_root.mkdir(parents=True, exist_ok=True)

    total_files = 0
    for name in PRESET_SUBDIRS:
        src = root / name
        if not src.is_dir():
            # 部分平台导出的包把预设放在包根下 presets/<子目录>/
            src = root / "presets" / name
        if not src.is_dir():
            continue
        dest = presets_root / name
        if args.dry_run:
            if dest.exists():
                _info(f"替换预设目录（先清空后合并）: {name} -> {dest.as_posix()}")
            else:
                _info(f"新增预设目录: {name} -> {dest.as_posix()}")
        else:
            if dest.exists():
                shutil.rmtree(dest)
                _info(f"已清空本地预设目录: {dest.as_posix()}")
            dest.mkdir(parents=True, exist_ok=True)
        n = _merge_preset_dir(src, dest, args.dry_run)
        total_files += n
        if n:
            _ok(f"{name}: {n} 个文件")

    cfg = root / "config.yaml"
    if cfg.is_file():
        new_map = _load_yaml_mapping(cfg, "包内 config.yaml")
        if not config_dest.is_file():
            if args.dry_run:
                _info(f"[dry-run] 将复制包内 config 至 {config_dest.as_posix()}（本地尚无 config.yaml）")
            else:
                shutil.copy2(cfg, config_dest)
                _ok(f"已复制包内配置（本地原无）: {config_dest}")
        else:
            old_map = _load_yaml_mapping(config_dest, "本地 config.yaml")
            diffs = _config_diff(old_map, new_map)
            _print_config_diff_json(diffs)
            _info(
                f"config 差异 {len(diffs)} 项已输出至 stdout（JSON）；未修改 {config_dest.as_posix()}，请智能体根据用户确认再更新"
            )
        _write_aws_env_incremental(repo, new_map, args.dry_run)
    else:
        _info("包内无 config.yaml，跳过全局配置与 aws.env 更新")

    if args.dry_run:
        _ok(f"dry-run 完成（共将写入约 {total_files} 个预设文件 + 如上 config）；解压保留在 {staging.as_posix()}")
    else:
        _ok(f"导入完成（预设文件合计 {total_files}）；解压保留在 {staging.as_posix()} 供核对")


if __name__ == "__main__":
    main()


path: scripts/product_image_ingest.py
content: #!/usr/bin/env python3
"""
将用户上传的图片复制到业务配图库 `.aws-article/products/{产品名}/images/`，
按中文主文件名保存；重名时自动使用 原名2、原名3… 后缀（扩展名前）。

`products/{产品名}/` 是用户业务的资料库根；本脚本仅写入其 `images/` 子目录
（业务介绍 .md 直挂产品根，由 AI 用 Write 工具落库，不走本脚本）。

运行前若缺少 `.aws-article`（仅含 `.git` 的仓库根也会识别）则创建；
产品目录与 `images/` 子目录不存在时一并创建。

与图片同主文件名、扩展名为 `.md` 的说明文件一并生成，固定两行标签（全角冒号）：
  **图片路径**：`相对仓库根路径`
  **图片描述**：…
（可用 --content 传入描述正文；路径由脚本写入。）

用法（在仓库根执行）：
  python skills/aws-wechat-article-assets/scripts/product_image_ingest.py path/to/a.png \
    --product 公众号AI运营助手 --stem 配置首页
  python skills/aws-wechat-article-assets/scripts/product_image_ingest.py a.png \
    --product 公众号AI运营助手 --stem 配置首页 --content "aiworkskills.cn 配置平台首页。"
"""

from __future__ import annotations

import argparse
import re
import shutil
import sys
from pathlib import Path

INVALID_NAME_CHARS = re.compile(r'[\\/:*?"<>|\r\n\t]+')
PRODUCTS_BASE_REL = Path(".aws-article") / "products"


def _err(msg: str) -> None:
    print(f"[ERROR] {msg}", file=sys.stderr)
    sys.exit(1)


def _ok(msg: str) -> None:
    print(f"[OK] {msg}")


def _find_repo_root(start: Path) -> Path:
    """仓库根 = `--repo` 参数指向的目录（默认当前工作目录）。

    不再向上遍历父目录；若传入路径不是预期的仓库根（需要存在 `.aws-article` 或 `.git`），
    直接报错退出，避免对非预期目录进行读写。
    """
    cur = start.resolve()
    if not cur.is_dir():
        _err(f"指定的仓库根不是目录：{cur}")
    if (cur / ".aws-article").is_dir() or (cur / ".git").is_dir():
        return cur
    _err(
        f"{cur} 不像仓库根（未检测到 .aws-article 或 .git 目录）。\n"
        "请传入 --repo 指向真正的仓库根，或在仓库根下运行。"
    )


def _ensure_aws_article_dir(repo: Path) -> None:
    (repo / ".aws-article").mkdir(parents=True, exist_ok=True)


def _sanitize_name(name: str, fallback: str) -> str:
    s = (name or "").strip()
    s = INVALID_NAME_CHARS.sub("", s)
    s = s.strip(" .")
    if not s:
        s = fallback
    return s[:120]


def _unique_dest_paths(dest_dir: Path, stem: str, ext: str) -> tuple[Path, str]:
    """返回 (图片路径, 实际主文件名不含扩展名)。重名时 配置首页 → 配置首页2 → …"""
    ext = ext if ext.startswith(".") else f".{ext}"
    ext = ext.lower()
    base = _sanitize_name(stem, "未命名素材")
    names = [base] + [f"{base}{i}" for i in range(2, 10001)]
    for name in names:
        img = dest_dir / f"{name}{ext}"
        md = dest_dir / f"{name}.md"
        if not img.exists() and not md.exists():
            return img, name
    _err("无法分配唯一文件名（重试次数过多）。")


def main() -> None:
    parser = argparse.ArgumentParser(
        description="业务图入库：写到 .aws-article/products/{产品名}/images/，含同名 .md（图片描述）"
    )
    parser.add_argument("source", help="源图片路径")
    parser.add_argument(
        "--product",
        required=True,
        help="产品名（业务资料库根目录名，如「公众号AI运营助手」）；不存在时自动创建",
    )
    parser.add_argument("--stem", required=True, help="中文主文件名（不含扩展名），由分析内容决定")
    parser.add_argument(
        "--content",
        default="",
        help="写入同名 .md 的正文（纯中文图片描述）；默认写入一行占位提示，由智能体读图后替换",
    )
    parser.add_argument(
        "--repo",
        default=".",
        help="仓库根目录（默认当前目录）",
    )
    args = parser.parse_args()

    src = Path(args.source).resolve()
    if not src.is_file():
        _err(f"源文件不存在: {src}")

    suffix = src.suffix.lower()
    if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
        _err(f"不支持的图片扩展名: {suffix}（允许 png/jpg/jpeg/webp/gif）")

    product = _sanitize_name(args.product, "")
    if not product:
        _err("--product 不能为空（清洗后为空字符串）")

    repo = _find_repo_root(Path(args.repo))
    _ensure_aws_article_dir(repo)
    dest_dir = repo / PRODUCTS_BASE_REL / product / "images"
    dest_dir.mkdir(parents=True, exist_ok=True)

    img_path, final_stem = _unique_dest_paths(dest_dir, args.stem, suffix)
    shutil.copy2(src, img_path)

    rel_posix = img_path.relative_to(repo).as_posix()
    md_path = dest_dir / f"{final_stem}.md"
    path_line = f"**图片路径**：`{rel_posix}`\n\n"
    if (args.content or "").strip():
        body = path_line + "**图片描述**：" + (args.content or "").strip() + "\n"
    else:
        body = path_line + "**图片描述**：请根据图片补全（客观描述画面内容即可）。\n"
    md_path.write_text(body, encoding="utf-8")

    _ok(f"图片: {img_path}")
    _ok(f"图片描述: {md_path}")


if __name__ == "__main__":
    main()


path: skill.json
content: {
  "name": "aws-wechat-article-assets",
  "version": "0.2.0",
  "author": "aiworkskills",
  "description": "用户业务资料库（按产品名分目录于 .aws-article/products/{产品名}/，介绍 .md 直挂产品根 + images/ 配图含同名 .md）+ 预设包管理；业务图入库 product_image_ingest.py（--product 必填）；导入 .aws 预设包合并 presets，config.yaml 仅首次复制、已有则输出差异。",
  "license": "MIT",
  "homepage": "https://github.com/aiworkskills/wechat-article-skills",
  "tags": ["wechat", "公众号", "assets", "products", "business-assets", "presets"],
  "permissions": ["filesystem", "shell"],
  "entryPoint": {
    "type": "natural",
    "path": "SKILL.md"
  },
  "dependencies": []
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)