让 AI Hermes Agent 给图片加水印(保姆级教程)
大家好,分享一点最近使用 Hermes Agent 的成果。
最近在做内容运营的时候,经常会遇到需要给大量图片添加水印的情况。说实话,单张图片加水印不难,但如果每天都要处理十几张甚至几十张,这个动作就变得非常枯燥、机械,极其消耗耐心。
这几天刚好稍微闲下来一点,我就在想:既然现在 AI 这么强大,能不能让 AI 来帮我干这个苦力活呢?
于是,我尝试让我的 AI 助手(Hermes Agent)帮我写了一个专属的自动化工具(我们称之为 Skill),完美解决了这个问题。今天,我就把整个过程拆解开来,手把手教大家怎么做。
如果你是个技术小白也完全不用担心,我会用最通俗的大白话把每一个步骤解释清楚。
💡 核心原理解析:这是怎么实现的?
在动手之前,我们先弄明白 AI 是怎么帮我们干活的。其实整个过程就像是在开一家餐厅:
- Hermes Agent(大堂经理): 这是一个可以帮你执行各种任务的智能 AI 助手。你只要用大白话下达命令,它就会去安排执行。
- Skill(专属工具/技能): 我们要教这位“大堂经理”学会一个叫“加水印”的新技能。学会之后,以后你只要喊一声,它就能自己去干了。
- API 接口(后厨加工厂): 真正给图片加上水印的,是一个在线的图片处理服务(JCJC Pics)。API 接口就像是后厨的传菜窗口,AI 把原图递进去,后厨加工好,再把带水印的图递出来。
- API Key(通行证): 因为后厨加工是需要消耗资源的,为了证明你是合法用户,你需要一把“钥匙”来表明身份,这就是 API Key。
总结一下:我们指挥 Hermes Agent 写一段 Python 代码,通过调用外部的图片处理服务,自动完成加水印的工作。
🛠️ 第一步:准备工作(获取“通行证”)
要想让 AI 帮我们调用图片处理服务,首先得去申请一个账号和 API Key。
- 打开浏览器,访问获取 API Key 的地址: 👉
https://www.cuobiezi.net/user/api_keys/list - 注册并登录后,生成一串长长的字符,这就是你的 API Key。
- 在你的电脑系统里,把这串字符保存为一个全局变量(类似于把密码存到电脑的保险柜里),变量名设置好:
JCJC_APIKEY。
(注:本文使用的是 JCJC Pics 的接口,你可以通过 https://cuobiezi.net/jcjcpics/index 查看他们的完整文档。)
🧠 第二步:向 AI 下达指令(创建 Skill)
准备好通行证后,我们打开 Hermes Agent(你的 AI 助手),开始给它布置任务。
在对话框里,我们输入以下这段“提示词(Prompt)”。这段话的关键在于要求明确,既要告诉它参考什么说明书,也要告诉它最终文件怎么命名:
给 AI 的提示词:
你的任务是创建一个给图片增加水印的 skill , skill 的名字叫:
my-image-watermark请参考这篇文档来写代码:https://www.cuobiezi.net/jcjcpics/docs/llms.txt调用 API 接口需要 Bearer 的认证 token ,请直接读取全局变量JCJC_APIKEY获取。 增加水印后的文件名称,请严格遵循这个规范:{original_name}_watermark_{yyyymmdd}.{ext}(也就是在原名后加上_watermark_和当天的日期)。 使用my-image-watermarkskill 的时候,用户可以提供一张或者多张图片,请确保代码能够逐一处理。
⏳ 第三步:见证 AI 写代码的过程
把提示词发给 Hermes Agent 后,神奇的事情就发生了。你不需要自己去写哪怕一行代码,AI 会自动阅读你提供的文档,然后开始编写 Python 脚本。
下面是 AI 正在制作这个技能的界面截图:

AI 正在思考并编写底层的 Python 代码:

很快,AI 就会告诉你:“报告老板,技能已经创建完毕并且测试通过了!”

同时,它会给你一份总结报告(这里我帮大家翻译成大白话):
- 新技能名称:
my-image-watermark - 它做了什么: 写了一个不需要安装任何额外插件的纯净脚本,调用了后厨的
/watermark接口。 - 功能特点: 支持单张或批量处理;自动把
猫.jpg改名为猫_watermark_20260609.jpg。 - 测试结果: 没找到文件会报错提示、没有 API 密钥会报错提示、成功调用测试完毕。全部 OK!
🎉 第四步:实战演练!给图片加个水印看看
既然工具做好了,我们马上来试一试效果。
我们可以直接让 Hermes 帮我们调用刚刚写好的技能,并附上我们的要求。比如,我想给一张刚刚上传的图片,在右下角加上“HTML网页托管”这几个字。

AI 接收到指令后,会执行如下操作:
原图路径:
/tmp/showshow_上传文件_20260608_1228_1451.png水印文字 (--text):"HTML网页托管" 水印位置 (--position):右下角 (bottom-right) 水印字体 (--font):sans bold 32
只花了不到 1 秒钟,AI 就处理完了,并且向我汇报了结果:

AI 的工作汇报:
水印添加完成 ✅
- 原图: /tmp/showshow_上传文件_20260608_1228_1451.png
- 输出: /tmp/showshow_上传文件_20260608_1228_1451_watermark_20260609.png
- 水印文字: HTML网页托管
- 位置: 右下角 (bottom-right)
- 字体: sans bold 32
- 透明度: 0.95(默认值)
这是任务执行完成的系统界面:

最终效果图来啦! 大家可以看看右下角,是不是非常完美地加上了我们要的水印?而且连文件重命名都按照我们的规范自动做好了。

SKILL.md 文件内容:
---
name: my-image-watermark
description: 给图片添加文字水印,调用 JCJC Image Processing API。支持单张或批量处理,输出文件名格式为 {原文件名}_watermark_{日期}.{扩展名}。
category: my-jcjc
tags: [image, watermark, image-processing, api, jcjc]
---
# my-image-watermark — 图片水印工具
调用 JCJC Image Processing API (`/watermark` endpoint) 为图片添加文字水印。
支持单张或批量处理,每张图片独立调用 API,输出到原图同目录。
## 触发条件
用户说"加水印"、"打水印"、"watermark"、"给图片加水印",或提供图片路径并要求添加水印文字。
## 工作流程
1. **收集参数** — 确认图片路径、水印文字、可选样式参数
2. **检查环境** — 确认 `JCJC_APIKEY` 环境变量已设置
3. **逐张处理** — 对每张图片调用 Python 脚本调用 API
4. **报告结果** — 输出每张图片的处理结果和保存路径
## 必需参数
| 参数 | 说明 | 示例 |
|------|------|------|
| 图片路径 | 一张或多张图片的绝对或相对路径 | `~/photos/cat.jpg` |
| 水印文字 | 显示在图片上的文字 | `@john_doe` |
## 可选参数(有默认值)
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `position` | `bottom-right` | 水印位置:`bottom-right`(右下), `bottom-left`(左下), `bottom-center`(底部居中), `top-left`(左上), `top-right`(右上), `center`(正中间)。也支持罗盘方位:`southeast`, `southwest`, `south`, `northwest`, `northeast`, `center`, `north`, `east`, `west` |
| `opacity` | `0.35` | 透明度 0.0–1.0 |
| `margin` | `15` | 边距(像素) |
| `font` | `sans bold 28` | 字体描述字符串,格式:`{family} {style} {size}` |
## 输出文件命名{原文件名(不含扩展名)}watermark.
示例:`cat.jpg` → `cat_watermark_20260609.jpg`
输出文件保存在原图同目录。
## 使用方法
### 简化版(直接传参给脚本)
当用户需求明确时,直接用 `terminal` 调用脚本:
```bash
python3 /opt/usr/MyAgentSkills/my-image-watermark/scripts/watermark.py \
--file /path/to/image.jpg \
--text "@myhandle" \
--position bottom-right \
--opacity 0.35批量处理
python3 /opt/usr/MyAgentSkills/my-image-watermark/scripts/watermark.py \
--files /path/to/img1.jpg /path/to/img2.png /path/to/img3.webp \
--text "© 2026 MyBrand"用户输入示例
- "给 ~/Pictures/photo.jpg 加水印 @john_doe"
- "把这些图片都打上 ©2026 的水印,位置放右下角"
- "批量加水印,文字是 CONFIDENTIAL,透明度0.5,字体sans bold 36"
Python 脚本
脚本位置:scripts/watermark.py
纯标准库实现,无外部依赖。
功能:
- 调用
POST /api/v4/image-tools/watermark(multipart/form-data) - 从
JCJC_GUANLIYUAN_APIKEY取 Bearer Token - 流式下载处理后的图片
- 按命名规范保存
API 参考
- 文档:https://www.cuobiezi.net/jcjcpics/docs/llms.txt
- Base URL:
https://www.cuobiezi.net/api/v4/image-tools - Watermark endpoint:
/watermark - 鉴权:
Authorization: Bearer {JCJC_APIKEY}
注意事项
- 环境变量:运行前确保
JCJC_APIKEY已设置 - 图片格式:支持 JPEG、PNG、WebP、AVIF 等常见格式
- 文件覆盖:同日多次处理同名图片会覆盖之前的输出
- API 限流:批量处理时脚本内置 0.5 秒间隔
- 错误处理:单张失败不中断批量处理,最终汇总报告
故障排查
| 问题 | 原因 | 解决 |
|---|---|---|
JCJC_APIKEY is not set | 环境变量未配置 | 运行 export JCJC_APIKEY="your-token" 或在 ~/.hermes/.env 中设置 |
| HTTP 401 | Token 无效或过期 | 到 https://www.cuobiezi.net/user/api_keys/list 重新申请 |
| HTTP 413 | 图片太大 | 先用 /resize 缩小或压缩 |
| 水印位置不对 | position 参数错误 | 检查值是否为有效位置(见可选参数表) |
```python
#!/usr/bin/env python3
"""
JCJC Image Watermark Tool — adds text watermarks via JCJC Image Processing API.
Usage:
# Single image
python3 watermark.py --file cat.jpg --text "@myhandle"
# Multiple images
python3 watermark.py --files cat.jpg dog.png bird.webp --text "© 2026"
# With custom style
python3 watermark.py --file photo.jpg --text "CONFIDENTIAL" \\
--position northwest --opacity 0.5 --margin 20 --font "sans bold 36"
Environment:
JCJC_APIKEY — Bearer token for API authentication (required)
"""
import argparse
import json
import mimetypes
import os
import sys
import time
import urllib.request
import urllib.error
from datetime import datetime, timezone, timedelta
from pathlib import Path
# ── constants ──────────────────────────────────────────────────────────
API_BASE = "https://www.cuobiezi.net/api/v4/image-tools"
WATERMARK_ENDPOINT = f"{API_BASE}/watermark"
# Shanghai timezone (UTC+8)
TZ_SHANGHAI = timezone(timedelta(hours=8))
DEFAULTS = {
"position": "bottom-right",
"opacity": 0.95,
"margin": 15,
"font": "sans bold 32",
}
VALID_POSITIONS = {
"northwest", "north", "northeast",
"west", "center", "east",
"southwest", "south", "southeast",
}
# Chinese-friendly aliases → API positions
POSITION_ALIASES = {
"bottom-right": "southeast",
"bottom-left": "southwest",
"bottom-center": "south",
"top-left": "northwest",
"top-right": "northeast",
"center": "center",
}
# ── helpers ────────────────────────────────────────────────────────────
def _date_str() -> str:
"""Return today's date as YYYYMMDD in Shanghai timezone."""
return datetime.now(TZ_SHANGHAI).strftime("%Y%m%d")
def _output_path(original: Path) -> Path:
"""Build output filename: {stem}_watermark_{yyyymmdd}.{suffix}"""
stem = original.stem
suffix = original.suffix # includes the dot, e.g. ".jpg"
return original.parent / f"{stem}_watermark_{_date_str()}{suffix}"
def _check_env() -> str:
"""Ensure JCJC_APIKEY is set; return the token."""
token = os.environ.get("JCJC_APIKEY", "").strip()
if not token:
print("ERROR: JCJC_APIKEY is not set.", file=sys.stderr)
print(" Run: export JCJC_APIKEY=***", file=sys.stderr)
print(" Or add it to ~/.hermes/.env", file=sys.stderr)
sys.exit(1)
return token
def _resolve_files(paths: list[str]) -> list[Path]:
"""Resolve and validate image file paths. Expand ~ and check existence."""
resolved = []
for raw in paths:
p = Path(raw).expanduser().resolve()
if not p.exists():
print(f"WARNING: {raw} does not exist — skipping", file=sys.stderr)
continue
if not p.is_file():
print(f"WARNING: {raw} is not a regular file — skipping", file=sys.stderr)
continue
resolved.append(p)
if not resolved:
print("ERROR: no valid image files found.", file=sys.stderr)
sys.exit(1)
return resolved
def _build_multipart(file_path: Path, params: dict) -> tuple[bytes, str]:
"""
Build a multipart/form-data body.
Returns (body_bytes, content_type_header_value).
"""
boundary = "----JCJCWatermarkBoundary" + str(int(time.time() * 1000))
lines: list[bytes] = []
encoder = lambda s: s.encode("utf-8") # noqa: E731
# -- file part
mime_type, _ = mimetypes.guess_type(str(file_path))
if mime_type is None:
mime_type = "application/octet-stream"
lines.append(encoder(f"--{boundary}"))
lines.append(encoder(f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"'))
lines.append(encoder(f"Content-Type: {mime_type}"))
lines.append(b"")
lines.append(file_path.read_bytes())
# -- text fields
for key, value in params.items():
lines.append(encoder(f"--{boundary}"))
lines.append(encoder(f'Content-Disposition: form-data; name="{key}"'))
lines.append(b"")
lines.append(encoder(str(value)))
# -- closing boundary
lines.append(encoder(f"--{boundary}--"))
body = b"\r\n".join(lines)
content_type = f"multipart/form-data; boundary={boundary}"
return body, content_type
def watermark_one(file_path: Path, text: str, position: str,
opacity: float, margin: int, font: str,
token: str) -> Path | None:
"""
Call the JCJC watermark API for a single image.
Returns the output Path on success, None on failure.
"""
if position not in VALID_POSITIONS:
# Try Chinese-friendly alias
position = POSITION_ALIASES.get(position, position)
if position not in VALID_POSITIONS:
print(f" WARNING: invalid position '{position}', using 'southeast'", file=sys.stderr)
position = "southeast"
params = {
"text": text,
"position": position,
"opacity": opacity,
"margin": margin,
"font": font,
}
body, content_type = _build_multipart(file_path, params)
req = urllib.request.Request(
WATERMARK_ENDPOINT,
data=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": content_type,
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
image_data = resp.read()
except urllib.error.HTTPError as e:
body_snippet = ""
try:
body_snippet = e.read().decode("utf-8", errors="replace")[:300]
except Exception:
pass
print(f" HTTP {e.code} for {file_path.name}: {body_snippet}", file=sys.stderr)
return None
except urllib.error.URLError as e:
print(f" Connection error for {file_path.name}: {e.reason}", file=sys.stderr)
return None
except Exception as e:
print(f" Unexpected error for {file_path.name}: {e}", file=sys.stderr)
return None
out = _output_path(file_path)
out.write_bytes(image_data)
return out
def main():
parser = argparse.ArgumentParser(
description="Add text watermarks to images via JCJC API",
)
# --file for single, --files for batch; at least one required
parser.add_argument("--file", help="Single image path")
parser.add_argument("--files", nargs="+", help="One or more image paths")
parser.add_argument("--text", required=True, help="Watermark text")
parser.add_argument("--position", default=DEFAULTS["position"],
help=f"Position: bottom-right, bottom-left, bottom-center, "
f"top-left, top-right, center, or compass (southeast/southwest/...). "
f"(default: {DEFAULTS['position']})")
parser.add_argument("--opacity", type=float, default=DEFAULTS["opacity"],
help=f"Opacity 0.0-1.0 (default: {DEFAULTS['opacity']})")
parser.add_argument("--margin", type=int, default=DEFAULTS["margin"],
help=f"Margin in pixels (default: {DEFAULTS['margin']})")
parser.add_argument("--font", default=DEFAULTS["font"],
help=f"Font string (default: {DEFAULTS['font']})")
parser.add_argument("--output-dir", help="Output directory (default: same as input)")
args = parser.parse_args()
# collect paths
if args.files:
raw_paths = args.files
elif args.file:
raw_paths = [args.file]
else:
parser.print_help()
print("\nERROR: --file or --files is required.", file=sys.stderr)
sys.exit(1)
token = _check_env()
files = _resolve_files(raw_paths)
print(f"Watermarking {len(files)} image(s) with text: '{args.text}'")
print(f" position={args.position} opacity={args.opacity} margin={args.margin} font={args.font}")
print(f" output dir: {args.output_dir or '(same as source)'}")
print()
results: list[tuple[str, str]] = [] # (status, path/error)
for i, fp in enumerate(files):
print(f"[{i + 1}/{len(files)}] {fp.name} …", end=" ", flush=True)
# Resolve output dir if needed
out_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else fp.parent
out_dir.mkdir(parents=True, exist_ok=True)
# Monkey-patch _output_path temporarily if custom dir
# (simpler: watermark_one returns the default path; we move after)
out = watermark_one(fp, args.text, args.position,
args.opacity, args.margin, args.font, token)
if out is None:
print("FAILED")
results.append(("FAILED", str(fp)))
else:
# Move to custom dir if specified
if args.output_dir:
dest = out_dir / out.name
out.rename(dest)
out = dest
print(f"→ {out.name}")
results.append(("OK", str(out)))
# Polite rate-limit between images
if i < len(files) - 1:
time.sleep(0.6)
# ── summary ───────────────────────────────────────────────────
print()
ok_count = sum(1 for s, _ in results if s == "OK")
fail_count = len(results) - ok_count
print(f"Done: {ok_count} success, {fail_count} failed")
if ok_count > 0:
print("\nWatermarked files:")
for status, path in results:
if status == "OK":
print(f" {path}")
if fail_count > 0:
print("\nFailed:")
for status, path in results:
if status != "OK":
print(f" {path}")
sys.exit(0 if fail_count == 0 else 1)
if __name__ == "__main__":
main()🌟 写在最后
看完整个过程,你会发现,真正的门槛不在于你懂不懂编程,而在于你能不能把自己的需求清晰地表达给 AI。
以前我们需要到处找加水印的软件,忍受各种满天飞的广告,甚至还要花钱买会员。现在,只要几句大白话,AI 就能为你量身定制一个完全免费、没有任何广告、甚至能批量处理的自动化小工具。
把枯燥的重复劳动交给 AI,把宝贵的时间留给生活和思考。快去试试吧!如果你在操作过程中遇到任何问题,欢迎在评论区留言,我们一起探讨!
