
Activity Push
- 4 installs
- Updated July 27, 2026
- zuoa/aj-skills
Filters WeChat article feeds for real events, extracts structured activity JSON, renders review Markdown and push text, then pushes to WeCom groups via webhook.
About
A skill that reads WeChat public-account feeds, uses semantic judgment to identify activity/event articles, emits raw/activity/structured JSON plus review Markdown and push text, and sends updates to WeCom internal groups via webhook robots. A developer uses it as a repeatable feed-to-activity bash/curl pipeline.
- Model judges whether each article is an activity by semantics, not keyword rules
- All fetch/push actions run via auditable bash + curl + jq; Amap geocoding for coordinates
Activity Push by the numbers
- 4 all-time installs (skills.sh)
- Ranked #1,780 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/zuoa/aj-skills --skill activity-pushAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| Last updated | July 27, 2026 |
| Repository | zuoa/aj-skills ↗ |
What it does
Filters WeChat article feeds for real events, extracts structured activity JSON, renders review Markdown and push text, then pushes to WeCom groups via webhook.
Files
Activity Push Skill
用于从公众号文章源中筛选活动类文章,提取结构化活动信息,并通过企业微信群机器人 Webhook 推送到内部群。
这个 skill 适合以下任务:
- "根据 feeds.md 拉取最近 24 小时公众号文章并筛活动"
- "把活动文章提取成结构化 JSON"
- "把活动信息整理成适合群发送的内容"
- "通过 webhook 推送到企业微信内部群"
Why this skill
参考 Claude 的 skill 最佳实践,这个 skill 将工作拆成两层:
SKILL.md负责触发条件、判断标准、步骤、质量要求和异常处理- 所有实际抓取与推送动作都用 bash +
curl+jq执行 - "这是不是活动"由模型阅读文章后做语义判断,不用关键字脚本筛选
- 内部群推送使用 webhook 机器人,简单直接
- 复杂细节下沉到脚本和参考文档
- 地址坐标补全走高德地理编码 CLI
这样做的好处是:
- 避免把"活动判断"硬编码成脆弱关键字规则
- 保持执行动作可审计,所有请求都能落回 bash 和
curl - 输出路径和文件格式稳定
- 区分"审阅用 Markdown"和"API 发送用纯文本",避免消息体格式不兼容
- 用户以后只要给
feeds.md、.env和执行目录,就能复用整条链路
Compatibility
默认依赖这些命令:
bashcurljqdateawksed
如果缺少 jq,应先明确告诉用户当前环境不满足 skill 依赖,不要改用 Python 兜底。
复杂推送逻辑使用:
- `fetch_recent_feeds.sh` - 拉取 feed
curl- webhook 推送
可选工具:
- `amap_geocode_wgs84.py` - 地理编码
- `render_activity_image.py` - 渲染图片
复杂 API 说明见:
- `wecom-group-types-guide.md` - 群类型与 API 支持完整说明
使用规则:
SKILL.md先给出主流程- 只有在调试 webhook 推送问题时,才去读
references/wecom-group-types-guide.md
Required Inputs
执行前确认以下输入存在:
- 执行目录
EXEC_DIR,默认使用当前工作目录 ~/.aj-skills/.envfeeds.md
~/.aj-skills/.env 建议包含这些变量:
MP_API_HOST=...
MP_API_KEY=...
AMAP_WEB_SERVICE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
WE_COM_WEBHOOK_KEYS=2449ebfdd2b2a1f20d88f797e3627d8fc6其中:
MP_API_HOST:必填MP_API_KEY:可选;feed 接口需要鉴权时再提供AMAP_WEB_SERVICE_KEY:可选;需要把活动地址补全为坐标时提供WE_COM_WEBHOOK_KEYS:可选;需要执行 webhook 推送时提供。支持多群,逗号分隔
feeds.md 约定:
- 每行一个公众号
- 第一列是公众号
MP_ID - 后续内容视为公众号名称
- 空行和
#注释行会被忽略 - 若
MP_ID末尾误带英文逗号,脚本会自动清理 - 若同一个
MP_ID重复出现,脚本只按第一次出现处理
示例:
gh_1234567890 科技早知道
gh_abcdef123456 AI 产品观察Default Workflow
按下面顺序执行,不要跳步:
1. 预检环境
EXEC_DIR="${EXEC_DIR:-$PWD}"
ENV_FILE="${HOME}/.aj-skills/.env"
FEEDS_FILE="${EXEC_DIR}/feeds.md"
TODAY="$(date +%Y%m%d)"
OUT_DIR="${EXEC_DIR}/activity-push/${TODAY}"
test -f "${ENV_FILE}" || { echo "缺少 ${ENV_FILE}"; exit 1; }
test -f "${FEEDS_FILE}" || { echo "缺少 ${FEEDS_FILE}"; exit 1; }
command -v curl >/dev/null || { echo "缺少 curl"; exit 1; }
command -v jq >/dev/null || { echo "缺少 jq"; exit 1; }
set -a
source "${ENV_FILE}"
set +a
test -n "${MP_API_HOST}" || { echo "缺少 MP_API_HOST"; exit 1; }
mkdir -p "${OUT_DIR}"检查项:
~/.aj-skills/.env是否存在MP_API_HOST是否存在MP_API_KEY是否存在取决于 feed 接口是否需要鉴权WE_COM_WEBHOOK_KEYS只有在需要执行第 6 步推送时才检查feeds.md是否存在且至少包含一个公众号
注意:
- 不要在回复里泄露完整密钥
- 只显示 secret 的掩码或目标群数量
2. 拉取 feed 并生成 raw.json
FETCH_ARGS=(
--feeds-file "${FEEDS_FILE}"
--output-file "${OUT_DIR}/raw.json"
--api-host "${MP_API_HOST}"
--hours 24
)
if [ -n "${MP_API_KEY:-}" ]; then
FETCH_ARGS+=(--api-key "${MP_API_KEY}")
fi
bash /Users/yujian/Code/py/aj-skills/skills/activity-push/scripts/fetch_recent_feeds.sh "${FETCH_ARGS[@]}"说明:
- 统一使用
fetch_recent_feeds.sh,不要在会话里临时手写while read抓取循环 - 这个脚本会用独立文件描述符读取
feeds.md,避免循环体里的命令意外影响后续 feed 读取,解决"只抓到第一个 feed"的问题 CUTOFF_EPOCH由脚本内部计算,无需在外层重复维护
默认输出目录:
{EXEC_DIR}/activity-push/{yyyyMMdd}/raw.json{EXEC_DIR}/activity-push/{yyyyMMdd}/activity.json{EXEC_DIR}/activity-push/{yyyyMMdd}/activity-structured.json{EXEC_DIR}/activity-push/{yyyyMMdd}/activity-structured-geo.json{EXEC_DIR}/activity-push/{yyyyMMdd}/activity-summary.png{EXEC_DIR}/activity-push/{yyyyMMdd}/activity-structured.md{EXEC_DIR}/activity-push/{yyyyMMdd}/activity-push.txt
注意:
- 不要再用
jq fromdateiso8601解析带时区偏移的时间;它对+08:00这类格式并不可靠。 - 当前推荐做法是让
jq只负责拆出文章,再由 bash 的date解析时间。 - 如果某个 feed 的
updated仍然无法被date解析,保留已抓取结果并在回复中明确说明该 feed 的时间格式不兼容当前 bash 过滤逻辑。 - 如果没有任何文章,也要生成
raw.json,内容必须是[]。 - 如果公开 feed 不需要鉴权,应允许在未设置
MP_API_KEY的情况下继续执行。 - 如果接口返回 401 / 403,再明确提示用户补充
MP_API_KEY。
3. 由模型判断哪些文章真的是活动,并生成 activity.json
这里不要做关键字筛选。你要直接阅读 raw.json 里的文章内容,按语义判断。
判定为"活动"的标准是:
- 文章的主要目的,是邀请用户在某个时间段参与一个具体安排,而不是单纯传递资讯
- 文中存在明确或隐含的参与动作,例如报名、预约、到场、线上进入、提交申请、加入议程
- 文章围绕一次具体事件展开,通常能对应到时间、地点、参与方式、人数、议程、对象或组织方中的若干项
- 活动主题必须明确,用户能清楚知道这是围绕什么主题、什么内容展开的活动
- 即便没有写出"活动"二字,只要本质上是在组织一次可参与的时间性事件,也算活动
不要判定为"活动"的内容:
- 活动复盘、会后总结、现场回顾
- 行业资讯、观点评论、融资新闻、产品公告
- 招聘、招生、课程长期售卖页,除非它明确对应一个具体场次或时间段
- 纯资料下载、白皮书发布、功能上线通知
- 主题模糊、内容空泛、看不出核心议题或实际安排的活动通知
- 招聘会、宣讲招聘、岗位双选会、人才招募会
- 有奖征集、征文征集、作品征集、评选征集、抽奖征集等拉新型活动
边界情况按下面处理:
- "征集 / 招募 / 训练营 / 路演 / Demo Day / Webinar / 闭门会":如果用户需要在某个时间窗口内参与,通常算活动
- "长期社群招募 / 常年报名":如果没有明确场次或时间边界,通常不算活动
- "直播预告":如果有具体播出时间和参与入口,算活动
- 但如果活动主题本身不明确,或本质是招聘 / 有奖征集,即使存在时间窗口,也不要算活动
生成 activity.json 时:
- 文件内容必须是
raw.json子集组成的 JSON 数组 - 保留原文章对象,不要先结构化
- 只保留你确信属于活动的文章
- 无活动时写入
[]
activity.json 推荐保持这种形式:
[
{
"mpId": "gh_xxx",
"mpName": "某公众号",
"sourceTitle": "活动原文标题",
"sourceUrl": "https://example.com/post/1",
"sourceUpdated": "2026-03-11T10:00:00+08:00",
"summary": "原文摘要",
"content": "原文正文或正文摘要"
}
]如果 raw.json 中原始文章对象字段更多:
- 可以原样保留
- 但不要在
activity.json中发明新的结构化字段 activity.json的角色只是"被判定为活动的原文集合"
复核时优先保证这些字段:
{
"activityName": "活动名称",
"activityType": "活动类型",
"activityAddress": "活动地址",
"activityStartTime": "活动开始时间",
"activityEndTime": "活动结束时间",
"activityLimitNum": "活动限制人数",
"activityDescription": "活动说明",
"activityImages": ["活动图片1", "活动图片2"]
}4. 提取结构化活动信息并生成 activity-structured.json
这一步仍由模型阅读 activity.json 后完成,不要用关键字脚本推断。
要求:
- 按文章语义提取活动名称、类型、地址、开始时间、结束时间、限制人数、说明、图片
- 字段缺失时使用空字符串
"",图片缺失时使用空数组[] - 如果同一活动重复出现在多篇文章中,按"同一事件实体"去重,而不是只看标题是否完全一致
- 去重时综合活动名称、时间、地点、组织方、议程内容判断
- 如果活动主题不明确,或属于招聘会 / 有奖征集这类明确排除项,不要进入
activity-structured.json - 每条活动都要做内部价值评分,并按评分结果排序
activity-structured.json必须按活动价值从高到低输出,价值最高的活动排在最前面- 输出必须是 JSON 数组
为保证汇总图里一定能生成二维码,每条活动都必须保留这些来源字段:
sourceUrlsourceTitlesourceMpIdsourceMpName
activity-structured.json 必须尽量贴近下面的结构:
[
{
"activityName": "活动名称",
"activityType": "活动类型",
"activityAddress": "活动地址",
"activityStartTime": "2026-03-12 14:00",
"activityEndTime": "2026-03-12 17:00",
"activityLimitNum": "50",
"activityDescription": "活动说明",
"activityImages": [
"https://example.com/image-1.jpg"
],
"sourceUrl": "https://example.com/post/1",
"sourceTitle": "活动原文标题",
"sourceMpId": "gh_xxx",
"sourceMpName": "某公众号"
}
]字段约束:
activityName:必须是用户能直接识别的活动名,不要只写"报名通知"activityType:例如讲座、分享会、工作坊、训练营、路演、直播、闭门会activityAddress:线下写详细地点,线上写"线上"或具体参与方式activityStartTime/activityEndTime:尽量统一成YYYY-MM-DD HH:mmactivityLimitNum:仅保留数字;未知时写空字符串activityDescription:1 到 3 句,不要整段照抄原文activityImages:必须是数组sourceUrl:必须保留原文链接,供汇总图把二维码直接渲染进图片;缺失时不允许静默省略二维码
活动价值评分时优先考虑:
- 对目标用户是否有直接价值,是否值得立即行动
- 主办方、嘉宾、合作方是否可靠,资源是否稀缺
- 时间、地点、报名方式、门槛、截止时间是否明确
- 活动是否具体可执行,而不是泛泛宣传
- 是否有明确名额、报名窗口或时效性
评分字段只用于内部排序,不要默认写入最终面向用户的 activity-structured.json、Markdown、图片或推送文本。
排序规则:
- 先按内部评分降序排列
- 若分数相同,优先开始时间更近且信息更完整的活动
- 若仍然相同,优先主办方更可靠、参与门槛更清晰的活动
落盘时优先保证 JSON 合法。推荐用下面方式写入:
printf '%s\n' "${STRUCTURED_JSON}" | jq '.' > "${OUT_DIR}/activity-structured.json"4.5 使用高德地理编码补全坐标和静态地图 URL,并生成 activity-structured-geo.json
如果某条活动存在 activityAddress,应继续补全坐标。
执行入口:
python3 /Users/yujian/Code/py/aj-skills/skills/activity-push/scripts/amap_geocode_wgs84.py \
--input "${OUT_DIR}/activity-structured.json" \
--output "${OUT_DIR}/activity-structured-geo.json" \
--amap-key "${AMAP_WEB_SERVICE_KEY}"本地 fixture 验证可用:
python3 /Users/yujian/Code/py/aj-skills/skills/activity-push/scripts/amap_geocode_wgs84.py \
--input "${OUT_DIR}/activity-structured.json" \
--output "${OUT_DIR}/activity-structured-geo.json" \
--fixture-file /Users/yujian/Code/py/aj-skills/skills/activity-push/tests/fixtures/amap-geocode/responses.json补全规则:
- 若
activityAddress为空,坐标字段和静态图 URL 置空,activityGeoStatus/activityStaticMapStatus设为skipped - 若地址过于模糊、没有精确到可落图的地点,直接跳过地理编码,
activityGeoStatus/activityStaticMapStatus设为skipped_vague - 若高德未命中地址,坐标字段和静态图 URL 置空,
activityGeoStatus/activityStaticMapStatus设为not_found - 若命中地址,保留高德返回坐标为 GCJ-02,并额外补出 WGS84
- 若提供了
AMAP_WEB_SERVICE_KEY,同时拼出不带 marker 的高德静态地图 URL,供最终 Markdown 直接引用
这里的"模糊地址"包括但不限于:
- 只有"线上""腾讯会议""直播间"这类非线下地点
- 只有"报名后通知""另行通知""详见海报"这类未给出实体位置的描述
- 只有城区、附近、周边等大范围位置,没有具体门牌、楼宇或明确 POI
推荐追加这些字段:
activityLongitudeGCJ02activityLatitudeGCJ02activityLongitudeWGS84activityLatitudeWGS84activityGeoProvideractivityGeoStatusactivityStaticMapUrlactivityStaticMapStatus
坐标系说明:
- 高德地理编码结果按高德坐标处理
- 根据高德坐标系说明,这里把返回的
location视为 GCJ-02 - 若用户需要 WGS84,则由本地转换公式补出
- 更详细说明见 `amap-geocode-wgs84.md`
5. 使用 Python CLI + PIL 基于 activity-structured-geo.json 渲染汇总图片
在推送前,先把结构化活动信息渲染为一张图片,便于人工审阅、归档或后续接入图片消息链路。
执行入口:
python3 /Users/yujian/Code/py/aj-skills/skills/activity-push/scripts/render_activity_image.py \
--input "${OUT_DIR}/activity-structured-geo.json" \
--output "${OUT_DIR}/activity-summary.png" \
--title "活动情报速递" \
--subtitle "$(date +%F)"要求:
- 输入优先使用
activity-structured-geo.json - 输出固定为单张 PNG
- 图片中只渲染实际存在的数据字段;缺失字段直接省略,不要写"未说明""待补充"等占位词
- 只有在活动具备有效经纬度时才显示地图预览;没有经纬度时不要渲染地图占位块
- 若存在
activityStaticMapUrl且有有效经纬度,可把静态地图贴进图片;地图图面不要再额外叠加 marker、十字线或高亮点 - 只要图片里渲染了活动卡片,就必须在每个活动卡片内生成原文链接二维码;二维码不能作为可选项被省略
- 若某条活动缺少
sourceUrl,应视为数据不完整并直接报错,而不是继续产出一个没有二维码的活动图片 - 二维码区域不要使用红色强调条,也不要写"链接已转为二维码"这类无效说明
- 无活动时也要生成空结果图片,便于归档
推荐输出:
{EXEC_DIR}/{yyyyMMdd}/activity-summary.png
5.5 生成审阅用 Markdown 和 API 推送用纯文本
优先使用 activity-structured-geo.json 作为输入;若未执行坐标补全,再回退到 activity-structured.json。
将结构化结果保存为:
{EXEC_DIR}/{yyyyMMdd}/activity-structured.md{EXEC_DIR}/{yyyyMMdd}/activity-push.txt
activity-structured.md 要求:
- 标题简洁,适合群消息
- 每个活动单独一节
- 活动顺序必须与
activity-structured.json保持一致,按价值从高到低展示 - 优先展示:活动名称、时间、地点、人数、活动说明
- 缺失字段直接省略,不要输出"待补充""未说明"
- 若存在
activityStaticMapUrl,直接展示静态地图图片或图片链接 - 不要在最终 Markdown 里展示经纬度字段
- 若内容过长,先在文件中拆成多个二级标题段,便于人工审阅
- 无活动时明确写"最近 24 小时未发现新的活动文章"
activity-structured.md 推荐使用这个模板:
# 活动情报速递(YYYY-MM-DD)
> 最近 24 小时筛选出的活动信息如下。
## 1. 活动名称
- 类型:活动类型
- 时间:2026-03-12 14:00 - 2026-03-12 17:00
- 地点:活动地址
- 地图:
- 人数:50
- 说明:活动说明
- 来源:某公众号 / https://example.com/post/1
## 2. 活动名称
- 类型:活动类型
- 地点:线上
- 说明:活动说明
- 来源:某公众号 / https://example.com/post/2无活动时固定写成:
# 活动情报速递(YYYY-MM-DD)
最近 24 小时未发现新的活动文章。推荐用下面方式落盘:
cat > "${OUT_DIR}/activity-structured.md" <<'EOF'
${ACTIVITY_STRUCTURED_MARKDOWN}
EOFactivity-push.txt 是用于 webhook 推送的文本内容,要求:
- 纯文本,不使用 Markdown 语法
- 适当压缩长度,避免过长导致成员端不愿发送
- 活动顺序必须与
activity-structured.json保持一致,按价值从高到低排列 - 第一屏优先给出最值得推送的 1 到 3 个活动
- 缺失字段直接省略,不要输出"待补充""未说明"
- 每条活动建议控制在 2 到 5 行
- 无活动时不生成此文件或内容为空,不推送消息到群
推荐模板:
活动情报速递(YYYY-MM-DD)
1. 活动名称
类型:活动类型
时间:2026-03-12 14:00 - 2026-03-12 17:00
地点:活动地址
说明:活动说明
链接:https://example.com/post/1
2. 活动名称
类型:活动类型
地点:线上
说明:活动说明
链接:https://example.com/post/2推荐用下面方式落盘:
cat > "${OUT_DIR}/activity-push.txt" <<'EOF'
${ACTIVITY_PUSH_TEXT}
EOF6. 使用 Webhook 推送到内部群
这一步是可选步骤,用于将活动信息推送到企业微信内部群。
特点:
- ✅ 无需 access_token,只需 webhook key
- ✅ 即时发送,无需成员确认
- ❌ 仅支持内部群,外部群不支持
添加群机器人: 1. 企业微信客户端 → 进入内部群 → 群设置 2. 群机器人 → 添加机器人 3. 复制 Webhook 地址中的 key 部分:xxxxxx
环境变量配置(~/.aj-skills/.env):
# 单群
WE_COM_WEBHOOK_KEYS=2449ebfdd2b2a1f20d88f797e3627d8fc6
# 多群(逗号分隔)
WE_COM_WEBHOOK_KEYS=key1,key2,key3推送命令:
# 检查是否有活动(activity.json 不为空数组)
ACTIVITY_COUNT=$(jq 'length' "${OUT_DIR}/activity.json")
if [ "$ACTIVITY_COUNT" -eq 0 ]; then
echo "No activities found, skipping push"
elif [ -n "${WE_COM_WEBHOOK_KEYS:-}" ]; then
# 读取消息内容并转义为 JSON 字符串
MESSAGE=$(cat "${OUT_DIR}/activity-push.txt" | jq -Rs '.')
# 遍历所有 key 进行推送(兼容 bash/zsh)
echo "$WE_COM_WEBHOOK_KEYS" | tr ',' '\n' | while IFS= read -r key; do
key=$(echo "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') # 去除空格
[ -z "$key" ] && continue
echo "Pushing to webhook: ${key:0:8}..."
curl -s "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=$key" \
-H 'Content-Type: application/json' \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":$MESSAGE}}" | jq .
done
else
echo "skip push: WE_COM_WEBHOOK_KEYS not configured"
fi限制说明:
- 仅内部群支持 webhook,外部群不支持
- 详见 `wecom-group-types-guide.md`
6.1 推送图片
Webhook 也支持推送图片,但需要先上传图片获取 media_id。
上传图片获取 media_id:
# 上传图片(key 是机器人的 webhook key)
curl -F "key=YOUR_KEY" \
-F "media=@activity-summary.png" \
"https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?type=image" | jq -r '.media_id'推送图片消息:
# 使用获取到的 media_id 推送图片
curl -s "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY" \
-H 'Content-Type: application/json' \
-d '{
"msgtype": "image",
"image": {
"media_id": "MEDIA_ID_FROM_UPLOAD"
}
}' | jq .同时推送文字+图片:
# 先推送文字
MESSAGE=$(cat "${OUT_DIR}/activity-push.txt" | jq -Rs '.')
curl -s "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY" \
-H 'Content-Type: application/json' \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":$MESSAGE}}"
# 再推送图片
MEDIA_ID=$(curl -s -F "key=YOUR_KEY" -F "media=@${OUT_DIR}/activity-summary.png" \
"https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?type=image" | jq -r '.media_id')
curl -s "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY" \
-H 'Content-Type: application/json' \
-d "{\"msgtype\":\"image\",\"image\":{\"media_id\":\"$MEDIA_ID\"}}" | jq .注意:
- 图片大小不能超过 2MB
- media_id 3 天内有效
- 支持的格式:png, jpg, jpeg, gif, bmp, webp
Output Rules
raw.json必须是最近 24 小时文章组成的 JSON 数组activity.json必须是活动候选文章组成的 JSON 数组activity-structured.json必须是去重后的活动对象数组activity-structured.json必须按内部评分结果降序排列activity-structured-geo.json必须在有地址时尽量补全 GCJ-02、WGS84 和静态地图 URLactivity-summary.png必须由activity-structured-geo.json渲染生成;空结果也要产出activity-summary.png在存在活动时,每个活动卡片都必须包含二维码activity-structured.md必须用于人工审阅,默认中文activity-structured.md不展示坐标字段,优先展示静态地图activity-push.txt必须用于 webhook 推送的文本内容
如果没有活动:
- 仍然生成所有目标文件
activity.json与activity-structured.json为空数组[]activity-structured-geo.json也应为空数组[]activity-summary.png仍应生成空结果图片activity-structured.md写明"最近 24 小时未发现新的活动文章"- `activity-push.txt` 为空文件或不生成,不推送"无活动"消息到群
Semantic Judgment Rules
判断"是否是活动"时,始终用下面的思路,而不是查词:
1. 先看文章的核心意图 如果文章的中心是在组织一次参与行为,它更可能是活动;如果中心只是表达观点、传递新闻或做总结,就不是活动。
2. 再看是否存在"参与闭环" 活动通常会形成一个闭环:谁可以参加、何时参加、在哪里参加、怎样参加、参加后发生什么。即使其中某些字段缺失,只要闭环大体成立,就可判为活动。
3. 再看时间性和事件性 活动是一个具体事件,通常有场次、时间窗口或明确排期。长期存在、没有明确场次的介绍页,通常不应视为活动。
4. 最后看文章是否要求读者采取行动 如果读者被要求报名、预约、进群、到场、观看直播、提交资料、参与议程,这通常说明它是活动。
WeCom Push Rules
- 使用群机器人 webhook 推送到内部群
- 消息正文使用纯文本,不要把 Markdown 原样发送
- 支持多群同时推送(逗号分隔多个 key)
- 只有在
WE_COM_WEBHOOK_KEYS配置存在时才执行第 6 步
Quality Checklist
- 已完成环境预检
- 已用 bash +
curl+jq完成抓取 - 已按
updated过滤最近 24 小时文章 - 已产出
raw.json - 已由模型按语义判断活动候选并产出
activity.json - 已提取结构化字段并去重
- 已产出
activity-structured.json - 已完成活动价值评分并按高到低排序
- 如地址存在,已产出
activity-structured-geo.json - 已产出
activity-summary.png - 已确认所有活动卡片都带有二维码
- 已产出审阅用
activity-structured.md - 已产出推送用
activity-push.txt - 若推送配置完整,已通过 webhook 完成推送
- 回复中明确给出所有输出文件路径
- 如处于开发或调试阶段,已至少运行一次 CLI dry-run 或本地测试
Failure Handling
如果发生错误,按下面顺序处理:
1. 先定位是输入缺失、网络失败、字段不兼容,还是 webhook 调用失败 2. 能继续的步骤继续执行,不要因为单个公众号失败就中止全部流程 3. 在终端输出中保留失败原因摘要 4. 如果 feed JSON 结构不兼容,先保存原始结果,再说明使用了什么字段假设 5. 如果 webhook 返回错误,检查 key 是否有效、群是否还存在 6. 如果企业微信推送配置缺失,直接跳过第 6 步,不要把整个 skill 判定为失败
Optimization Notes
相对你原始草案,这里做了这些优化:
- 不再把"是不是活动"交给关键字规则,而是交给模型做语义判断
- 全流程回到 bash +
curl+jq,更贴近你要求的执行方式 feeds.md支持空行和注释- 活动去重改为事件实体判断,而不是字符串硬匹配
- 推送链路使用 webhook 机器人,简单直接
- 审阅内容与发送内容分离,降低 API 消息体格式不兼容风险
- 活动地址支持补全高德坐标、WGS84 坐标和静态地图 URL,便于 Markdown 展示或下游系统使用
- 推送前增加 PIL 汇总图,方便人工快速过目,也为后续图片消息链路留出稳定产物
- 所有步骤都落盘,便于二次检查和重跑
Test Prompts
可用这些提示词测试 skill 是否会正确触发:
1. "根据当前目录的 feeds.md,把最近 24 小时公众号文章里的活动提取出来,并通过 webhook 推送到企业微信群。" 2. "读取 ~/.aj-skills/.env 和 feeds.md,输出 raw.json、activity.json、activity-structured.json、activity-structured-geo.json、activity-summary.png、activity-structured.md、activity-push.txt,再通过 webhook 推送。" 3. "帮我做一个活动推送流水线:从公众号 feed 抓文章,筛活动,结构化提取,补全高德地址坐标并转成 WGS84,顺手生成静态地图 URL,再用 PIL 渲染一张活动汇总图,生成审阅 Markdown 和群发送文本,并通过 webhook 推送到企业微信群。"
{
"skill_name": "activity-push",
"evals": [
{
"id": 1,
"prompt": "读取当前目录 feeds.md 和 ~/.aj-skills/.env,把最近 24 小时公众号文章里的活动提取出来;如果有活动地址,用高德地理编码补全 WGS84 经纬度并生成静态地图 URL;再基于 activity-structured-geo.json 用 PIL 渲染一张活动汇总图;保存 raw.json、activity.json、activity-structured.json、activity-structured-geo.json、activity-summary.png、activity-structured.md、activity-push.txt,并通过企业微信客户群 API 推送到外部群。activity-structured.json 里的活动需要打分,并按价值高低排序。",
"expected_output": "Skill 应使用 bash + curl + jq 完成抓取与落盘,并用 bundled Python CLI 封装高德地理编码、静态地图 URL 生成、PIL 图片渲染和客户联系/客户群 API 推送;模型仍需按文章语义判断是否为活动,并在 activity-structured.json 中给每条活动打分、按价值降序排序,最终产出 8 个以上输出文件并创建群发任务。",
"files": []
},
{
"id": 2,
"prompt": "帮我把公众号 feed 里的活动类文章筛出来,结构化为活动信息;如果有地址,用高德地理编码转成 WGS84,并生成静态地图 URL;再基于 activity-structured-geo.json 用 PIL 渲染一张活动汇总图,最后生成审阅 Markdown 和客户群 API 用的纯文本,但先不要推送。activity-structured.json 里的活动需要打分,并按价值高低排序;主题不明确的活动、招聘会、有奖征集不要保留。",
"expected_output": "Skill 应至少完成 feed 抓取、语义筛选、结构化提取、活动价值评分与排序、地址地理编码、静态地图 URL 生成、PIL 汇总图渲染、Markdown 生成和纯文本生成,并明确排除主题不明确的活动、招聘会和有奖征集,并保留 bundled Python CLI 推送为可选最后一步。",
"files": []
},
{
"id": 4,
"prompt": "读取 feeds.md 提取活动,生成结构化结果和推送文本,但当前没有配置企业微信参数,所以不要执行推送。activity-structured.json 里的活动需要打分,并按价值高低排序。",
"expected_output": "Skill 应正常完成抓取、提取、活动价值评分与排序、坐标与静态地图 URL 补全、PIL 汇总图渲染和文本生成,并明确跳过第 6 步推送,不把缺失的企业微信参数视为整体失败。",
"files": []
},
{
"id": 3,
"prompt": "根据 feeds.md 批量抓文章,如果今天没有活动也要生成空结果文件,并把结果通过企业微信客户群 API 发到外部群。",
"expected_output": "Skill 应在空结果情况下仍产出空 JSON、空结果图片、Markdown 和纯文本文件,并继续通过 bundled Python CLI 执行客户群群发任务创建。",
"files": []
}
]
}
AMap Geocode, WGS84, and Static Map Notes
This reference supports the activity-push skill when an extracted activity has an address and needs coordinates or a static map image URL.
Read this file when:
- you need to turn
activityAddressinto coordinates - you need to generate a static map URL for the final Markdown
- you need to explain why the workflow uses AMap geocoding plus a local conversion step
- you need to debug address-to-coordinate enrichment
Core model
The workflow is: 1. call AMap geocode API with the structured address 2. read the returned location 3. treat that location as AMap coordinates 4. convert AMap coordinates to WGS84 locally 5. build an AMap static map URL from the AMap coordinates 6. append GCJ-02, WGS84, and static-map fields to the structured activity data
Why there is a local conversion step
The AMap geocode API returns AMap coordinates for the address lookup result.
AMap's own JS documentation states:
- WGS84 is the international GPS coordinate system
- GCJ-02 is the coordinate system used by AMap in China
convertFrom()converts non-AMap coordinates into AMap coordinates
From that, this skill infers:
- the geocode
locationreturned by AMap should be treated as GCJ-02 / AMap coordinates - if the user wants WGS84, the conversion must happen locally after geocoding
This is an inference from AMap coordinate-system documentation, not a separate AMap Web Service endpoint that directly returns WGS84.
Required key
Typical variable:
AMAP_WEB_SERVICE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxUse a Web Service key for:
https://restapi.amap.com/v3/geocode/geohttps://restapi.amap.com/v3/staticmap
Output fields
Recommended fields appended to each activity:
activityLongitudeGCJ02activityLatitudeGCJ02activityLongitudeWGS84activityLatitudeWGS84activityGeoProvideractivityGeoStatusactivityStaticMapUrlactivityStaticMapStatus
Statuses:
oknot_foundskippedmissing_key
Bundled CLI
Use:
The CLI:
- reads
activity-structured.json - geocodes
activityAddress - converts GCJ-02 to WGS84 locally
- builds a static map URL with the same AMap key
- writes a geo-enriched JSON file
It also supports offline testing through:
--fixture-file
Sources
Geocode API reference:
- https://amap.apifox.cn/api-14546468
Static map API reference:
- https://amap.apifox.cn/api-14554037
AMap coordinate-system explanation:
- https://a.amap.com/jsapi/static/doc/20230922/index.html#convertFrom
WeCom Customer Group API Notes
This reference supports the activity-push skill when pushing to WeCom external customer groups.
Read this file when:
- the user wants to push to 企业微信外部群 instead of robot webhooks
- you need to debug
add_msg_template,groupchat/list,groupchat/get,remind_groupmsg_send, orget_groupmsg_send_result - you need to explain why a call created a task but did not instantly reach the group
Core model
The push flow is not:
- create markdown
- call webhook
- group instantly receives content
The push flow is: 1. get an access token with the customer-contact capable secret 2. discover or confirm the target customer-group chat_id 3. create a group-message task with externalcontact/add_msg_template 4. optionally remind the sender with externalcontact/remind_groupmsg_send 5. check task status with externalcontact/get_groupmsg_send_result 6. the member still needs to complete or confirm the send inside WeCom
This distinction matters. A successful API call usually means:
- the task was created
- not that the external group already received the content
Required credentials
Typical variables:
WE_COM_CORP_ID=wwxxxxxxxxxxxxxxxx
WE_COM_CONTACT_SECRET=xxxxxxxxxxxxxxxx
WE_COM_GROUPMSG_SENDER_USERIDS=zhangsan
WE_COM_TARGET_CHAT_IDS=wrOgQhDgAA...,wrOgQhDgBB...Notes:
- all WeCom push variables are optional at the skill level; they only become required when the user actually wants to execute the push step
WE_COM_CONTACT_SECRETshould be the secret that is allowed to call customer-contact endpointsWE_COM_GROUPMSG_SENDER_USERIDSmust contain at least one internal member who will own or send the group message task- one sender is enough; use commas only when you intentionally want to create tasks for multiple senders
WE_COM_TARGET_CHAT_IDSis the safest targeting method when the exact groups are already known
API sequence
1. Get token
Endpoint:
GET /cgi-bin/gettoken
Purpose:
- obtain
access_token
Failure patterns:
- wrong
corp_id - wrong secret
- secret lacks the needed scope
2. Discover groups
Endpoint:
POST /cgi-bin/externalcontact/groupchat/list
Purpose:
- list customer groups owned by the selected members
Important behavior:
- paginated via
next_cursor - if the tenant has many groups, you must continue paging until
next_cursoris empty
Useful follow-up:
POST /cgi-bin/externalcontact/groupchat/get
Use it when:
- you need the group name
- you need member or owner details
- you want to filter by group name keywords instead of sending to every discovered group
Group-message task creation
Endpoint:
POST /cgi-bin/externalcontact/add_msg_template
Purpose:
- create a customer-contact group-message task
Current skill assumptions:
chat_typeis"group"senderis one ofWE_COM_GROUPMSG_SENDER_USERIDSallow_selectisfalsechat_id_listis used when the target groups are explicit- message body uses plain
text.content
If the API rejects chat_id_list:
- do not silently expand the target scope
- inspect the returned payload
- compare the payload against the current official doc for this tenant/version
Reminder and result
Reminder endpoint:
POST /cgi-bin/externalcontact/remind_groupmsg_send
Result endpoint:
POST /cgi-bin/externalcontact/get_groupmsg_send_result
Operational notes:
- reminders do not send the content by themselves
- result APIs can lag behind task creation
- poll again later if the first status is incomplete or stale
Why the skill separates Markdown and push text
activity-structured.md is for:
- review
- audit
- manual inspection
activity-push.txt is for:
text.content- compact messages that members are more likely to send
- avoiding markdown formatting assumptions in customer-group APIs
Recommended debugging outputs
Keep these files:
wecom-token.jsoncustomer-groups.jsoncustomer-group-details.jsongroupmsg-create-result.jsonpush-result.md
They help answer:
- did token creation fail?
- which groups were targeted?
- which sender created which task?
- did remind succeed?
- what status came back from the result API?
Dry-run and fixtures
The bundled CLI supports local replay:
--dry-run--fixture-dir <dir>
Use it when:
- you want to validate pagination and filtering logic without live credentials
- you need to regression-test the CLI after editing the skill
- you want deterministic outputs for review
Fixture directory used in this repo:
/Users/yujian/Code/py/aj-skills/skills/activity-push/tests/fixtures/wecom-push
Sources
Official docs used for this reference:
- https://developer.work.weixin.qq.com/document/path/92135
- https://developer.work.weixin.qq.com/document/path/92113
- https://developer.work.weixin.qq.com/document/path/92114
- https://developer.work.weixin.qq.com/document/path/93338
Additional structure cross-checks:
- https://www.apifox.cn/apidoc/docs-site/406014/doc-1776833
- https://pkg.go.dev/github.com/ArtisanCloud/PowerWeChat/v3/src/work/externalContact/message
企业微信群类型与 API 机制完整指南
本文档详细说明企业微信中各种群类型的区别、API 支持情况以及开发注意事项。
---
一、群类型总览
企业微信中有三种主要的群类型:
┌─────────────────────────────────────────────────────────────┐
│ 企业微信群聊体系 │
├─────────────────────────┬───────────────────────────────────┤
│ 内部群 (Internal) │ 外部群 (External) │
│ (仅企业成员) │ (含非企业成员) │
├─────────────────────────┼───────────────────────────────────┤
│ • 全员群 │ • 普通外部群 │
│ • 部门群 │ • 客户群 (Customer Group) │
│ • 普通内部群 │ │
└─────────────────────────┴───────────────────────────────────┘核心关系
重要概念:所有客户群都是外部群,但并非所有外部群都是客户群。
外部群 (External Group)
├── 普通外部群 (Plain External)
│ └── 功能受限,不支持客户联系 API
└── 客户群 (Customer Group)
└── 功能完整,支持客户联系 API---
二、各群类型详解
2.1 内部群 (Internal Group)
定义:仅包含企业内部成员的群聊
子类型:
| 类型 | 说明 | 自动创建 |
|---|---|---|
| 全员群 | 包含企业所有成员 | ✅ 自动 |
| 部门群 | 对应组织架构部门 | ✅ 自动 |
| 普通内部群 | 手动创建 | ❌ 手动 |
API 支持:
- ✅ 群机器人 Webhook
- ✅ 应用消息推送
- ✅ 会话内容存档(需开通)
---
2.2 普通外部群 (Plain External Group)
定义:包含企业成员 + 外部联系人(微信用户/其他企业用户),但不是通过客户联系功能创建的群
创建方式: 1. 从微信迁移过来的群("接受微信中的工作消息") 2. 发起群聊时直接选择外部联系人 3. 通过微信用户邀请进入的群
关键特征:
- ❌ 不支持群机器人 Webhook
- ❌ 不支持客户联系 API (
externalcontact/groupchat/*) - ❌ 无入群欢迎语
- ❌ 无防骚扰功能
- ❌ 无群活码
- ✅ 仅支持基础聊天
API 限制:
# 尝试获取普通外部群详情会失败或返回空
POST /cgi-bin/externalcontact/groupchat/get
# 返回:errcode: 0, 但群不在列表中---
2.3 客户群 (Customer Group)
定义:通过【工作台】→【客户联系】→【客户群】功能创建的外部群
创建前提: 1. 成员具有"客户联系"应用权限 2. 企业已开通客户联系功能 3. 通过官方入口创建
专属功能:
| 功能 | 说明 |
|---|---|
| 入群欢迎语 | 自动发送欢迎消息(支持文字/图片/小程序/链接) |
| 群自动回复 | 关键词自动回复 |
| 防骚扰 | 自动踢出发广告、刷屏成员 |
| 客服助理 | 添加客服名片到群 |
| 群活码 | 永久有效,满人自动建群(最多关联5个群) |
| 群去重 | 自动检测重复进群客户 |
| 禁止互加 | 防止同行偷粉 |
API 支持:
- ✅
externalcontact/groupchat/list- 获取客户群列表 - ✅
externalcontact/groupchat/get- 获取群详情 - ✅
externalcontact/add_msg_template- 创建群发任务 - ✅
externalcontact/remind_groupmsg_send- 提醒成员发送 - ✅
externalcontact/get_groupmsg_result- 获取群发结果 - ✅ 离职继承/在职继承
---
三、群人数上限对比
| 群类型 | 人数上限 | 备注 |
|---|---|---|
| 内部群 | 3000人 | 全员群可能更大 |
| 客户群(纯企业微信用户) | 500人 | - |
| 客户群(含微信用户) | 500人 | 3.1版本后从200人提升 |
| 普通外部群(含微信用户) | 200-500人 | 视创建方式而定 |
| API创建含微信用户的群 | 40人 | 重要限制! |
⚠️ 关键限制:
"如果创建的会话有微信联系人,群成员人数不能超过40人。"
>
—— 企业微信开发者官方文档
这意味着通过 APP_CHAT_CREATE 等接口创建外部群时,如果包含微信用户,人数上限仅为40人。
---
四、API 支持矩阵
4.1 群机器人 Webhook
| 群类型 | 支持情况 | 说明 |
|---|---|---|
| 内部群 | ✅ 支持 | 群设置 → 添加机器人 → 获取 Webhook |
| 普通外部群 | ❌ 不支持 | 群设置中无"添加机器人"入口 |
| 客户群 | ❌ 不支持 | 即使客户群也无法添加 Webhook 机器人 |
官方说明:
"外部客户群不支持添加机器人"
>
—— 企业微信开发者社区官方回复
4.2 客户联系 API
| 接口 | 内部群 | 普通外部群 | 客户群 |
|---|---|---|---|
externalcontact/groupchat/list | ❌ | ❌ | ✅ |
externalcontact/groupchat/get | ❌ | ❌ | ✅ |
externalcontact/add_msg_template | ❌ | ❌ | ✅ |
externalcontact/remind_groupmsg_send | ❌ | ❌ | ✅ |
externalcontact/get_groupmsg_result | ❌ | ❌ | ✅ |
externalcontact/group_welcome_template | ❌ | ❌ | ✅ |
4.3 应用消息 API
| 接口 | 内部群 | 普通外部群 | 客户群 |
|---|---|---|---|
message/send (touser) | ✅ | ✅ | ✅ |
message/send (toparty) | ✅ | ❌ | ❌ |
message/send (totag) | ✅ | ❌ | ❌ |
appchat/create | ✅ | ⚠️ | ⚠️ |
appchat/send | ✅ | ⚠️ | ⚠️ |
⚠️ appchat 创建外部群时,含微信用户则人数上限40人。
---
五、数据归属与权限
5.1 已服务的外部联系人
企业微信将外部联系人分为两类:
| 类型 | 定义 | API管理 | 客户群关联 |
|---|---|---|---|
| 客户 | 具有客户联系权限的成员添加的联系人 | ✅ 完整支持 | ✅ 是 |
| 其他外部联系人 | 无权限成员添加的联系人 | ❌ 不支持 | ❌ 否 |
官方文档:关于已服务的外部联系人
5.2 群数据归属
普通外部群数据
├── 归属:个人(创建者)
├── 企业可查看:有限
├── API 获取:不支持
└── 离职后:群可能失效
客户群数据
├── 归属:企业
├── 企业可查看:完整
├── API 获取:完整支持
└── 离职后:可继承给其他成员---
六、转换与升级
6.1 普通外部群 → 客户群
能否转换:不能直接转换
原因:
- 两种群的数据模型不同
- 客户群有额外的企业级元数据
- 微信侧迁移的群无法获得企业微信的管理能力
变通方案: 1. 创建新的客户群 2. 生成群活码 3. 在旧群发布迁移公告和二维码 4. 引导用户主动迁移
6.2 标记为客户群
部分普通外部群可以通过以下方式标记为客户群:
条件:
- 群由具有客户联系权限的成员创建
- 不是通过"接受微信中的工作消息"迁移的群
操作: 企业微信客户端 → 群设置 → 标记为客户群
限制:
- 仅改变群的管理归属
- 历史消息和成员关系不变
- 部分功能仍受限
---
七、开发建议与最佳实践
7.1 选择合适的群类型
| 场景 | 推荐群类型 | 原因 |
|---|---|---|
| 企业内部协作 | 内部群 | 功能最全,支持机器人 |
| 客户服务(需 API 管理) | 客户群 | 支持完整客户联系 API |
| 临时外部沟通 | 普通外部群 | 创建简单,但功能受限 |
| 大规模客户运营 | 客户群 + 群活码 | 支持自动分流,API 完善 |
7.2 推送方案选择
| 目标 | 可行方案 | 注意事项 |
|---|---|---|
| 推送到内部群 | Webhook 机器人、应用消息 | 推荐 Webhook,最简单 |
| 推送到客户群 | add_msg_template API | 需要客户联系 Secret |
| 推送到普通外部群 | ❌ 无官方方案 | 需人工转发或 RPA(有风险) |
7.3 权限配置检查清单
使用客户联系 API 前确认:
- [ ] 企业已开通"客户联系"功能
- [ ] 应用具有
externalcontact权限范围 - [ ] 成员在客户联系功能的可见范围内
- [ ] 成员具有客户群创建权限
- [ ] 使用的 Secret 具有客户联系调用权限
---
八、常见问题 FAQ
Q1: 为什么我有外部群,但 groupchat/list 返回空?
A: 只有客户群会出现在 groupchat/list 结果中。普通外部群不支持此接口。
Q2: 我是群主,为什么找不到"添加群机器人"选项?
A: 外部群(含微信用户的群)不支持群机器人。仅内部群支持。
Q3: 普通外部群能升级为客户群吗?
A: 不能。需要创建新的客户群并引导用户迁移。
Q4: API 创建外部群有什么限制?
A: 通过 appchat/create 创建含微信用户的外部群,人数上限为 40 人。
Q5: 客户群和外部群在客户端怎么区分?
A:
- 客户群:群设置中有"入群欢迎语"、"自动回复"等选项
- 普通外部群:仅基础聊天功能
Q6: 为什么客户群 API 返回 40003(不合法的用户)?
A:
- 用户不在应用可见范围
- 用户没有客户联系权限
- 用户 ID 拼写错误
Q7: 如何测试客户群 API 是否正常?
A: 按以下顺序验证: 1. gettoken - 确认密钥有效 2. groupchat/list - 确认有客户群数据 3. add_msg_template - 确认能创建群发任务
---
九、参考链接
官方文档
开发者社区
---
十、版本历史
| 日期 | 版本 | 说明 |
|---|---|---|
| 2026-03-16 | 1.0 | 初始版本,整理群类型与 API 机制 |
---
本文档基于企业微信官方文档和开发者社区讨论整理,如有更新请以官方最新文档为准。
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib import parse, request
AMAP_GEOCODE_URL = "https://restapi.amap.com/v3/geocode/geo"
AMAP_STATICMAP_URL = "https://restapi.amap.com/v3/staticmap"
VAGUE_ADDRESS_PATTERNS = (
"线上",
"直播间",
"腾讯会议",
"会议号",
"待定",
"待通知",
"另行通知",
"报名后通知",
"群内通知",
"详见原文",
"详见海报",
"见海报",
"见文内",
"以通知为准",
"地点另行",
"活动现场",
"附近",
"周边",
"某地",
"门店",
"各门店",
"全国",
"全市",
"全省",
)
PRECISE_ADDRESS_RE = re.compile(
r"(\d|路|街|道|巷|弄|号|栋|幢|座|楼|层|室|单元|大厦|广场|中心|酒店|剧院|体育馆|会展中心|会议中心|大学|学院|校区|图书馆|美术馆|博物馆|科技馆|产业园|写字楼)"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Geocode activity addresses with AMap and convert GCJ-02 to WGS84.")
parser.add_argument("--input", required=True, help="Path to activity-structured.json.")
parser.add_argument("--output", required=True, help="Path to geo-enriched JSON output.")
parser.add_argument("--amap-key", default="", help="AMap Web Service key.")
parser.add_argument("--city-hint-field", default="", help="Optional field name to use as city hint.")
parser.add_argument("--fixture-file", default="", help="Optional fixture JSON for offline tests.")
return parser.parse_args()
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 read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def is_precise_address(address: str) -> bool:
normalized = re.sub(r"\s+", "", address)
if not normalized or len(normalized) < 6:
return False
if any(pattern in normalized for pattern in VAGUE_ADDRESS_PATTERNS):
return False
return bool(PRECISE_ADDRESS_RE.search(normalized))
def build_static_map_url(
amap_key: str,
lng: float,
lat: float,
*,
zoom: int = 15,
size: str = "750*300",
scale: int = 2,
) -> str:
if not amap_key:
return ""
query = {
"key": amap_key,
"location": f"{lng:.6f},{lat:.6f}",
"zoom": str(zoom),
"size": size,
"scale": str(scale),
}
return f"{AMAP_STATICMAP_URL}?{parse.urlencode(query)}"
def out_of_china(lng: float, lat: float) -> bool:
return not (73.66 < lng < 135.05 and 3.86 < lat < 53.55)
def transform_lat(lng: float, lat: float) -> float:
ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * math.sqrt(abs(lng))
ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lat * math.pi) + 40.0 * math.sin(lat / 3.0 * math.pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(lat / 12.0 * math.pi) + 320 * math.sin(lat * math.pi / 30.0)) * 2.0 / 3.0
return ret
def transform_lng(lng: float, lat: float) -> float:
ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * math.sqrt(abs(lng))
ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lng * math.pi) + 40.0 * math.sin(lng / 3.0 * math.pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(lng / 12.0 * math.pi) + 300.0 * math.sin(lng / 30.0 * math.pi)) * 2.0 / 3.0
return ret
def gcj02_to_wgs84(lng: float, lat: float) -> tuple[float, float]:
if out_of_china(lng, lat):
return lng, lat
a = 6378245.0
ee = 0.00669342162296594323
dlat = transform_lat(lng - 105.0, lat - 35.0)
dlng = transform_lng(lng - 105.0, lat - 35.0)
radlat = lat / 180.0 * math.pi
magic = math.sin(radlat)
magic = 1 - ee * magic * magic
sqrt_magic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrt_magic) * math.pi)
dlng = (dlng * 180.0) / (a / sqrt_magic * math.cos(radlat) * math.pi)
mg_lat = lat + dlat
mg_lng = lng + dlng
return lng * 2 - mg_lng, lat * 2 - mg_lat
class AMapClient:
def __init__(self, amap_key: str, fixture_file: Optional[Path] = None) -> None:
self.amap_key = amap_key
self.fixture_file = fixture_file
self.fixture_map = read_json(fixture_file) if fixture_file else {}
def geocode(self, address: str, city_hint: str = "") -> Dict[str, Any]:
if self.fixture_file:
return self.fixture_map.get(address, {"status": "0", "count": "0", "geocodes": []})
if not self.amap_key:
raise RuntimeError("amap key is required unless fixture-file is provided")
query = {"key": self.amap_key, "address": address, "output": "JSON"}
if city_hint:
query["city"] = city_hint
url = f"{AMAP_GEOCODE_URL}?{parse.urlencode(query)}"
req = request.Request(url, headers={"Accept": "application/json", "User-Agent": "activity-push/1.0"})
with request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def enrich_record(record: Dict[str, Any], client: AMapClient, city_hint_field: str = "") -> Dict[str, Any]:
item = dict(record)
address = str(item.get("activityAddress", "")).strip()
if not address:
item.update(
{
"activityLongitudeGCJ02": "",
"activityLatitudeGCJ02": "",
"activityLongitudeWGS84": "",
"activityLatitudeWGS84": "",
"activityGeoProvider": "amap",
"activityGeoStatus": "skipped",
"activityStaticMapUrl": "",
"activityStaticMapStatus": "skipped",
}
)
return item
if not is_precise_address(address):
item.update(
{
"activityLongitudeGCJ02": "",
"activityLatitudeGCJ02": "",
"activityLongitudeWGS84": "",
"activityLatitudeWGS84": "",
"activityGeoProvider": "amap",
"activityGeoStatus": "skipped_vague",
"activityStaticMapUrl": "",
"activityStaticMapStatus": "skipped_vague",
}
)
return item
city_hint = str(item.get(city_hint_field, "")).strip() if city_hint_field else ""
payload = client.geocode(address, city_hint=city_hint)
geocodes = payload.get("geocodes", []) if isinstance(payload, dict) else []
if not geocodes:
item.update(
{
"activityLongitudeGCJ02": "",
"activityLatitudeGCJ02": "",
"activityLongitudeWGS84": "",
"activityLatitudeWGS84": "",
"activityGeoProvider": "amap",
"activityGeoStatus": "not_found",
"activityStaticMapUrl": "",
"activityStaticMapStatus": "not_found",
}
)
return item
location = str(geocodes[0].get("location", ""))
try:
gcj_lng_str, gcj_lat_str = location.split(",", 1)
gcj_lng = float(gcj_lng_str)
gcj_lat = float(gcj_lat_str)
except ValueError as exc:
raise RuntimeError(f"invalid geocode location: {location}") from exc
wgs_lng, wgs_lat = gcj02_to_wgs84(gcj_lng, gcj_lat)
static_map_url = build_static_map_url(client.amap_key, gcj_lng, gcj_lat)
item.update(
{
"activityLongitudeGCJ02": round(gcj_lng, 6),
"activityLatitudeGCJ02": round(gcj_lat, 6),
"activityLongitudeWGS84": round(wgs_lng, 6),
"activityLatitudeWGS84": round(wgs_lat, 6),
"activityGeoProvider": "amap",
"activityGeoStatus": "ok",
"activityStaticMapUrl": static_map_url,
"activityStaticMapStatus": "ok" if static_map_url else "missing_key",
}
)
return item
def run_geocode(args: argparse.Namespace) -> List[Dict[str, Any]]:
input_path = Path(args.input).expanduser().resolve()
output_path = Path(args.output).expanduser().resolve()
fixture_path = Path(args.fixture_file).expanduser().resolve() if args.fixture_file else None
data = read_json(input_path)
if not isinstance(data, list):
raise RuntimeError("input must be a JSON array")
client = AMapClient(amap_key=args.amap_key, fixture_file=fixture_path)
enriched = [enrich_record(item, client, city_hint_field=args.city_hint_field) for item in data]
write_json(output_path, enriched)
return enriched
def main() -> int:
args = parse_args()
run_geocode(args)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # noqa: BLE001
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
raise SystemExit(1)
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
bash fetch_recent_feeds.sh \
--feeds-file /path/to/feeds.md \
--output-file /path/to/raw.json \
--api-host https://example.com \
[--api-key secret] \
[--hours 24]
EOF
}
FEEDS_FILE=""
OUTPUT_FILE=""
MP_API_HOST=""
MP_API_KEY="${MP_API_KEY:-}"
HOURS=24
SEEN_IDS_FILE=""
while [ "$#" -gt 0 ]; do
case "$1" in
--feeds-file)
FEEDS_FILE="${2:-}"
shift 2
;;
--output-file)
OUTPUT_FILE="${2:-}"
shift 2
;;
--api-host)
MP_API_HOST="${2:-}"
shift 2
;;
--api-key)
MP_API_KEY="${2:-}"
shift 2
;;
--hours)
HOURS="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
test -n "${FEEDS_FILE}" || { echo "missing --feeds-file" >&2; exit 1; }
test -n "${OUTPUT_FILE}" || { echo "missing --output-file" >&2; exit 1; }
test -n "${MP_API_HOST}" || { echo "missing --api-host" >&2; exit 1; }
test -f "${FEEDS_FILE}" || { echo "feeds file not found: ${FEEDS_FILE}" >&2; exit 1; }
command -v curl >/dev/null || { echo "missing curl" >&2; exit 1; }
command -v jq >/dev/null || { echo "missing jq" >&2; exit 1; }
mkdir -p "$(dirname "${OUTPUT_FILE}")"
CUTOFF_EPOCH="$(date -v-"${HOURS}"H +%s 2>/dev/null || date -d "${HOURS} hours ago" +%s)"
RAW_TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${RAW_TMP_DIR}"' EXIT
SEEN_IDS_FILE="${RAW_TMP_DIR}/seen-mp-ids.txt"
: > "${SEEN_IDS_FILE}"
parse_epoch() {
local raw="${1:-}"
local normalized=""
local epoch=""
test -n "${raw}" || { echo 0; return; }
normalized="$(printf '%s' "${raw}" | sed -E 's/([+-][0-9]{2}):([0-9]{2})$/\1\2/')"
for fmt in \
"%Y-%m-%dT%H:%M:%S%z" \
"%Y-%m-%dT%H:%M%z" \
"%Y-%m-%d %H:%M:%S%z" \
"%Y-%m-%d %H:%M%z" \
"%Y-%m-%dT%H:%M:%SZ" \
"%Y-%m-%dT%H:%MZ" \
"%Y-%m-%d %H:%M:%S" \
"%Y-%m-%d %H:%M" \
"%Y/%m/%d %H:%M:%S" \
"%Y/%m/%d %H:%M" \
"%Y-%m-%d" \
"%Y/%m/%d"
do
epoch="$(date -j -f "${fmt}" "${normalized}" +%s 2>/dev/null)" && {
echo "${epoch}"
return
}
done
epoch="$(date -d "${raw}" +%s 2>/dev/null)" && {
echo "${epoch}"
return
}
epoch="$(date -d "${normalized}" +%s 2>/dev/null)" && {
echo "${epoch}"
return
}
echo 0
}
# Read feeds from a dedicated descriptor so loop body commands cannot consume it.
exec 3< "${FEEDS_FILE}"
while IFS= read -r line <&3 || [ -n "${line}" ]; do
case "${line}" in
""|\#*) continue ;;
esac
MP_ID="$(printf '%s\n' "${line}" | awk '{print $1}')"
MP_ID="${MP_ID%,}"
test -n "${MP_ID}" || continue
MP_NAME="$(printf '%s\n' "${line}" | sed -E 's/^[^[:space:]]+[[:space:]]*//')"
if [ "${MP_NAME}" = "${line}" ]; then
MP_NAME=""
fi
MP_NAME="$(printf '%s' "${MP_NAME}" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
if grep -Fqx "${MP_ID}" "${SEEN_IDS_FILE}"; then
continue
fi
printf '%s\n' "${MP_ID}" >> "${SEEN_IDS_FILE}"
FEED_URL="${MP_API_HOST%/}/feed/${MP_ID}.json"
OUT_FILE="${RAW_TMP_DIR}/${MP_ID}.json"
JSONL_FILE="${RAW_TMP_DIR}/${MP_ID}.jsonl"
FILTERED_FILE="${RAW_TMP_DIR}/${MP_ID}.filtered"
CURL_ARGS=(-fsSL -H "Accept: application/json")
if [ -n "${MP_API_KEY}" ]; then
CURL_ARGS+=(
-H "Authorization: Bearer ${MP_API_KEY}"
-H "X-API-Key: ${MP_API_KEY}"
)
fi
if ! curl "${CURL_ARGS[@]}" "${FEED_URL}" -o "${OUT_FILE}"; then
echo "warning: 拉取失败 ${MP_ID} ${MP_NAME}" >&2
continue
fi
jq -c \
--arg mpId "${MP_ID}" \
--arg mpName "${MP_NAME}" \
--arg feedUrl "${FEED_URL}" '
def article_list:
if type == "array" then .
elif .items? then .items
elif .articles? then .articles
elif .entries? then .entries
elif (.data? | type) == "array" then .data
elif (.data? | type) == "object" then (.data.items // .data.articles // .data.entries // [])
else []
end;
[
article_list[]
| . + {
mpId: $mpId,
mpName: $mpName,
feedUrl: $feedUrl,
sourceUpdated: (.updated // .publish_time // .published // .pubDate // ""),
sourceUrl: (.url // .link // .permalink // ""),
sourceTitle: (.title // .name // ""),
sourceSummary: (.summary // .description // .excerpt // .digest // "")
}
][]
' "${OUT_FILE}" > "${JSONL_FILE}"
: > "${FILTERED_FILE}"
while IFS= read -r article_json; do
UPDATED_RAW="$(printf '%s' "${article_json}" | jq -r '.sourceUpdated // empty')"
UPDATED_EPOCH="$(parse_epoch "${UPDATED_RAW}")"
if [ "${UPDATED_EPOCH}" -ge "${CUTOFF_EPOCH}" ] 2>/dev/null; then
printf '%s\n' "${article_json}" >> "${FILTERED_FILE}"
fi
done < "${JSONL_FILE}"
done
exec 3<&-
FILTERED_FILES=()
while IFS= read -r filtered_file; do
FILTERED_FILES+=("${filtered_file}")
done < <(find "${RAW_TMP_DIR}" -type f -name '*.filtered' | sort)
if [ "${#FILTERED_FILES[@]}" -gt 0 ]; then
jq -s '.' "${FILTERED_FILES[@]}" > "${OUTPUT_FILE}"
else
printf '[]\n' > "${OUTPUT_FILE}"
fi
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
import os
import platform
import shutil
import subprocess
from io import BytesIO
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
from urllib import request
from PIL import Image, ImageDraw, ImageFont, ImageOps
import qrcode
DEFAULT_WIDTH = 1280
PAGE_PADDING = 56
CARD_PADDING = 36
CARD_GAP = 32
MAP_WIDTH = 340
MAP_HEIGHT = 200
QR_SIZE = 100
# 活泼年轻配色方案
BACKGROUND = "#FFF9F0" # 温暖的奶油白
CARD_BG = "#FFFFFF" # 纯白卡片
TEXT = "#1A1A2E" # 深蓝黑,更年轻
MUTED = "#6B7280" # 现代灰
ACCENT = "#FF6B6B" # 珊瑚红,活力强调色
ACCENT_SECONDARY = "#4ECDC4" # 青绿色,辅助强调
ACCENT_TERTIARY = "#FFE66D" # 明亮黄,装饰用
BORDER = "#E8E8E8" # 浅灰边框
PLACEHOLDER_BG = "#F3F4F6"
# 类型标签配色
TYPE_COLORS = {
"讲座": ("#FF6B6B", "#FFE8E8"),
"分享会": ("#4ECDC4", "#E0F7F5"),
"工作坊": ("#95E1D3", "#E8FAF7"),
"训练营": ("#F38181", "#FFE8E8"),
"路演": ("#AA96DA", "#F0EBF8"),
"直播": ("#FCBAD3", "#FEF0F5"),
"闭门会": ("#A8D8EA", "#E8F6FC"),
"线上": ("#6C5CE7", "#E8E6F9"),
}
# 图标颜色
ICON_COLORS = {
"time": "#FF6B6B",
"location": "#4ECDC4",
"people": "#F9CA24",
"description": "#6C5CE7",
}
def hex_to_rgb(hex_color: str) -> tuple:
"""将十六进制颜色转换为 RGB 元组"""
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Render activity summary image from activity-structured-geo.json.")
parser.add_argument("--input", required=True, help="Path to activity-structured-geo.json.")
parser.add_argument("--output", required=True, help="Path to output PNG.")
parser.add_argument("--title", default="活动情报速递", help="Poster title.")
parser.add_argument("--subtitle", default="", help="Optional subtitle.")
parser.add_argument("--watermark", default="潮匠里", help="Watermark text shown at top-right.")
parser.add_argument("--width", type=int, default=DEFAULT_WIDTH, help="Canvas width in pixels.")
parser.add_argument("--download-timeout", type=float, default=10.0, help="Timeout for remote images.")
return parser.parse_args()
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _existing_paths(paths: Sequence[str]) -> List[str]:
result: List[str] = []
seen = set()
for item in paths:
expanded = str(Path(item).expanduser())
if expanded in seen:
continue
if Path(expanded).exists():
result.append(expanded)
seen.add(expanded)
return result
def _fc_match_font(family: str) -> str:
if not shutil.which("fc-match"):
return ""
try:
result = subprocess.run(
["fc-match", "-f", "%{file}\n", family],
capture_output=True,
check=True,
text=True,
)
except (OSError, subprocess.SubprocessError):
return ""
path = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
return path if path and Path(path).exists() else ""
@lru_cache(maxsize=4)
def font_candidates(bold: bool = False) -> Tuple[str, ...]:
candidates: List[str] = []
env_keys = ["ACTIVITY_PUSH_FONT_PATH"]
env_keys.append("ACTIVITY_PUSH_FONT_BOLD_PATH" if bold else "ACTIVITY_PUSH_FONT_REGULAR_PATH")
for key in env_keys:
value = os.environ.get(key, "").strip()
if value:
candidates.append(value)
system = platform.system().lower()
if system == "darwin":
candidates.extend(
[
"/System/Library/Fonts/Supplemental/PingFang.ttc",
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/Supplemental/Songti.ttc",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
]
)
else:
candidates.extend(
[
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" if bold else "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Bold.otf" if bold else "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
"/usr/share/fonts/opentype/noto/NotoSansSC-Bold.otf" if bold else "/usr/share/fonts/opentype/noto/NotoSansSC-Regular.otf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc" if bold else "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansSC-Bold.ttf" if bold else "/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/arphic/uming.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
)
family_names = [
"Noto Sans CJK SC:bold" if bold else "Noto Sans CJK SC",
"Noto Sans SC:bold" if bold else "Noto Sans SC",
"Source Han Sans SC Bold" if bold else "Source Han Sans SC",
"WenQuanYi Zen Hei",
"DejaVu Sans:style=Bold" if bold else "DejaVu Sans",
"sans-serif:style=Bold" if bold else "sans-serif",
]
candidates.extend(path for path in (_fc_match_font(name) for name in family_names) if path)
return tuple(_existing_paths(candidates))
def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for candidate in font_candidates(bold):
try:
return ImageFont.truetype(candidate, size=size)
except OSError:
continue
return ImageFont.load_default()
def text_height(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> int:
if not text:
return 0
box = draw.multiline_textbbox((0, 0), text, font=font, spacing=6)
return int(math.ceil(box[3] - box[1]))
def wrap_text(
draw: ImageDraw.ImageDraw,
text: str,
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
max_width: int,
) -> List[str]:
if not text:
return []
lines: List[str] = []
current = ""
for char in text:
candidate = current + char
box = draw.textbbox((0, 0), candidate, font=font)
if box[2] - box[0] <= max_width or not current:
current = candidate
continue
lines.append(current)
current = char
if current:
lines.append(current)
return lines
def wrap_labeled_text(
draw: ImageDraw.ImageDraw,
label: str,
value: str,
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
max_width: int,
) -> str:
prefix = f"{label}:"
if not value:
return ""
lines = wrap_text(draw, value, font, max_width - int(draw.textbbox((0, 0), prefix, font=font)[2]))
if not lines:
return ""
wrapped = [f"{prefix}{lines[0]}"]
indent = " " * len(prefix)
wrapped.extend(f"{indent}{line}" for line in lines[1:])
return "\n".join(wrapped)
def build_meta_lines(
activity: Dict[str, Any],
draw: ImageDraw.ImageDraw,
body_font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
content_width: int,
) -> List[str]:
time_value = build_time_text(activity)
fields = [
("类型", str(activity.get("activityType", "")).strip()),
("时间", time_value),
("地点", str(activity.get("activityAddress", "")).strip()),
("人数", str(activity.get("activityLimitNum", "")).strip()),
("说明", str(activity.get("activityDescription", "")).strip()),
]
lines = [wrap_labeled_text(draw, label, value, body_font, content_width) for label, value in fields if value]
return [line for line in lines if line]
def build_time_text(activity: Dict[str, Any]) -> str:
start = str(activity.get("activityStartTime", "")).strip()
end = str(activity.get("activityEndTime", "")).strip()
if start and end:
return f"{start} - {end}"
if start:
return start
if end:
return end
return ""
def build_qr_image(content: str, size: int = QR_SIZE) -> Optional[Image.Image]:
if not content:
return None
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=2,
)
qr.add_data(content)
qr.make(fit=True)
image = qr.make_image(fill_color=TEXT, back_color="white").convert("RGB")
return ImageOps.fit(image, (size, size), method=Image.Resampling.NEAREST)
def load_image(source: str, timeout: float) -> Optional[Image.Image]:
if not source:
return None
try:
if source.startswith("file://"):
return Image.open(source[7:]).convert("RGB")
local_path = Path(source)
if local_path.exists():
return Image.open(local_path).convert("RGB")
with request.urlopen(source, timeout=timeout) as resp:
return Image.open(BytesIO(resp.read())).convert("RGB")
except Exception:
return None
def has_coordinates(activity: Dict[str, Any]) -> bool:
lng = str(activity.get("activityLongitudeGCJ02", "")).strip()
lat = str(activity.get("activityLatitudeGCJ02", "")).strip()
if not lng or not lat:
return False
try:
float(lng)
float(lat)
except ValueError:
return False
return True
def require_source_url(activity: Dict[str, Any], index: int) -> str:
source_url = str(activity.get("sourceUrl", "")).strip()
if source_url:
return source_url
activity_name = str(activity.get("activityName", "")).strip() or f"第 {index} 条活动"
raise RuntimeError(f"activity {index} missing sourceUrl, cannot render required QR code: {activity_name}")
def draw_qr_block(
image: Image.Image,
draw: ImageDraw.ImageDraw,
link_url: str,
left: int,
top: int,
fonts: Dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont],
) -> int:
"""绘制更年轻的二维码区块"""
qr = build_qr_image(link_url, QR_SIZE)
if qr is None:
return 0
# 绘制装饰性背景圆
bg_size = QR_SIZE + 16
bg_left = left - 8
bg_top = top - 8
draw.ellipse(
(bg_left, bg_top, bg_left + bg_size, bg_top + bg_size),
fill="#F3F4F6", outline=BORDER, width=1
)
# 粘贴二维码
qr_box = (left, top, left + QR_SIZE, top + QR_SIZE)
image.paste(qr, (left, top))
# 绘制小装饰点
dot_color = ACCENT_SECONDARY
draw.ellipse((bg_left - 4, bg_top + bg_size // 2 - 4, bg_left + 4, bg_top + bg_size // 2 + 4), fill=dot_color)
draw.ellipse((bg_left + bg_size - 4, bg_top + 8, bg_left + bg_size + 4, bg_top + 16), fill=ACCENT_TERTIARY)
return QR_SIZE + 16
def draw_decorative_elements(
draw: ImageDraw.ImageDraw,
width: int,
top: int,
) -> None:
"""绘制装饰性元素(圆点、线条等)"""
# 左上角装饰圆点
draw.ellipse((PAGE_PADDING - 20, top - 10, PAGE_PADDING, top + 10), fill=ACCENT)
draw.ellipse((PAGE_PADDING + 8, top + 5, PAGE_PADDING + 16, top + 13), fill=ACCENT_SECONDARY)
draw.ellipse((PAGE_PADDING + 22, top - 5, PAGE_PADDING + 30, top + 3), fill=ACCENT_TERTIARY)
# 右上角装饰线条
line_y = top + 20
for i, color in enumerate([ACCENT, ACCENT_SECONDARY, ACCENT_TERTIARY]):
offset = i * 12
draw.line((width - PAGE_PADDING - 100 + offset, line_y - offset,
width - PAGE_PADDING - 40 + offset, line_y - offset),
fill=color, width=4)
def draw_watermark(
image: Image.Image,
width: int,
watermark: str,
fonts: Dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont],
) -> None:
if not watermark.strip():
return
overlay = Image.new("RGBA", (200, 120), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# 更年轻的水印设计 - 圆形徽章风格
center_x, center_y = 100, 60
radius = 50
# 外圈渐变效果(用同心圆模拟)
for i in range(3):
alpha = 180 - i * 40
r = radius - i * 3
draw.ellipse((center_x - r, center_y - r, center_x + r, center_y + r),
outline=(*hex_to_rgb(ACCENT), alpha), width=2)
# 背景圆形
draw.ellipse((center_x - radius + 8, center_y - radius + 8,
center_x + radius - 8, center_y + radius - 8),
fill=(*hex_to_rgb(CARD_BG), 200))
# 文字
text_box = draw.textbbox((0, 0), watermark, font=fonts["watermark"])
text_x = center_x - (text_box[2] - text_box[0]) / 2
text_y = center_y - (text_box[3] - text_box[1]) / 2
draw.text((text_x, text_y), watermark, fill=(*hex_to_rgb(ACCENT), 220), font=fonts["watermark"])
# 装饰小点
draw.ellipse((center_x - radius - 5, center_y - 3, center_x - radius + 3, center_y + 5), fill=ACCENT_SECONDARY)
draw.ellipse((center_x + radius - 3, center_y - 8, center_x + radius + 5, center_y), fill=ACCENT_TERTIARY)
rotated = overlay.rotate(-12, resample=Image.Resampling.BICUBIC, expand=True)
paste_left = width - PAGE_PADDING - rotated.size[0] + 20
image.paste(rotated, (paste_left, -10), rotated)
def get_type_color(activity_type: str) -> tuple:
"""获取活动类型的颜色配置"""
for type_key, colors in TYPE_COLORS.items():
if type_key in activity_type:
return colors
return (ACCENT, "#FFE8E8") # 默认颜色
def draw_shadow_rounded_rectangle(
draw: ImageDraw.ImageDraw,
bbox: tuple,
radius: int,
fill: str,
shadow_color: str = "#00000010",
shadow_offset: int = 4,
) -> None:
"""绘制带阴影的圆角矩形"""
x1, y1, x2, y2 = bbox
# 阴影
shadow_bbox = (x1 + shadow_offset, y1 + shadow_offset, x2 + shadow_offset, y2 + shadow_offset)
draw.rounded_rectangle(shadow_bbox, radius=radius, fill=shadow_color)
# 主体
draw.rounded_rectangle(bbox, radius=radius, fill=fill, outline=BORDER, width=1)
def draw_type_tag(
draw: ImageDraw.ImageDraw,
activity_type: str,
left: int,
top: int,
fonts: Dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont],
) -> int:
"""绘制活动类型标签,返回标签宽度"""
if not activity_type:
return 0
text_color, bg_color = get_type_color(activity_type)
text_box = draw.textbbox((0, 0), activity_type, font=fonts["small"])
text_width = text_box[2] - text_box[0]
tag_width = text_width + 20
tag_height = 28
draw.rounded_rectangle(
(left, top, left + tag_width, top + tag_height),
radius=14, fill=bg_color
)
draw.text(
(left + 10, top + 4),
activity_type, fill=text_color, font=fonts["small"]
)
return tag_width + 8 # 返回标签宽度 + 间距
def draw_icon_and_text(
draw: ImageDraw.ImageDraw,
icon_type: str,
text: str,
left: int,
top: int,
max_width: int,
fonts: Dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont],
) -> int:
"""绘制带图标的文本行,返回下一行的y坐标"""
if not text:
return top
icon_color = ICON_COLORS.get(icon_type, MUTED)
icon_size = 16
# 绘制简单图标(圆形背景 + 符号)
icon_center_x = left + icon_size // 2
icon_center_y = top + icon_size // 2 + 2
draw.ellipse(
(icon_center_x - icon_size // 2, icon_center_y - icon_size // 2,
icon_center_x + icon_size // 2, icon_center_y + icon_size // 2),
fill=icon_color
)
# 文字区域
text_left = left + icon_size + 8
available_width = max_width - icon_size - 8
# 换行处理
lines = wrap_text(draw, text, fonts["body"], available_width)
line_height = text_height(draw, "测试", fonts["body"]) + 8
for i, line in enumerate(lines):
draw.text((text_left, top + i * line_height), line, fill=TEXT, font=fonts["body"])
return top + len(lines) * line_height + 4
def render_card(
image: Image.Image,
draw: ImageDraw.ImageDraw,
activity: Dict[str, Any],
index: int,
top: int,
width: int,
timeout: float,
fonts: Dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont],
) -> int:
card_left = PAGE_PADDING
card_right = width - PAGE_PADDING
card_inner_width = card_right - card_left - CARD_PADDING * 2
title = str(activity.get('activityName', '')).strip() or '未命名活动'
activity_type = str(activity.get('activityType', '')).strip()
source_url = require_source_url(activity, index)
show_map = has_coordinates(activity) and bool(str(activity.get("activityStaticMapUrl", "")).strip())
show_qr = True
show_side_column = show_map or show_qr
text_width = card_inner_width - MAP_WIDTH - 32 if show_side_column else card_inner_width
# 计算标题高度(考虑序号圆圈)
title_prefix_width = 36 # 序号圆圈的宽度 + 间距
title_lines = wrap_text(draw, title, fonts["title"], text_width - title_prefix_width)
title_height = text_height(draw, "\n".join(title_lines), fonts["title"])
# 计算元信息高度
meta_start_y = 0 # 稍后计算
meta_total_height = 0
# 收集需要显示的信息
time_value = build_time_text(activity)
address = str(activity.get("activityAddress", "")).strip()
limit_num = str(activity.get("activityLimitNum", "")).strip()
description = str(activity.get("activityDescription", "")).strip()
# 估算元信息区域高度
line_height = text_height(draw, "测试", fonts["body"]) + 12
meta_items_count = sum([bool(time_value), bool(address), bool(limit_num), bool(description)])
meta_total_height = meta_items_count * line_height + 20
# 计算右侧区域高度
right_column_height = 0
if show_map:
right_column_height += MAP_HEIGHT
if show_qr:
if right_column_height:
right_column_height += 20
right_column_height += QR_SIZE
# 计算总高度
type_tag_height = 32 if activity_type else 0
content_height = max(type_tag_height + title_height + 24 + meta_total_height, right_column_height + 20)
card_height = CARD_PADDING * 2 + content_height
# 绘制带阴影的卡片
card_box = (card_left, top, card_right, top + card_height)
draw_shadow_rounded_rectangle(draw, card_box, radius=32, fill=CARD_BG)
content_left = card_left + CARD_PADDING
content_top = top + CARD_PADDING
side_left = card_right - CARD_PADDING - MAP_WIDTH
side_top = content_top + 10
# 绘制序号圆圈
circle_radius = 14
circle_x = content_left + circle_radius
circle_y = content_top + circle_radius
draw.ellipse(
(circle_x - circle_radius, circle_y - circle_radius,
circle_x + circle_radius, circle_y + circle_radius),
fill=ACCENT
)
index_text = str(index)
text_box = draw.textbbox((0, 0), index_text, font=fonts["small"])
text_width_actual = text_box[2] - text_box[0]
text_height_actual = text_box[3] - text_box[1]
draw.text(
(circle_x - text_width_actual / 2, circle_y - text_height_actual / 2 - 1),
index_text, fill="white", font=fonts["small"]
)
# 绘制类型标签
current_x = content_left + 36
if activity_type:
tag_width = draw_type_tag(draw, activity_type, current_x, content_top + 2, fonts)
current_x += tag_width
# 绘制标题
title_y = content_top + (36 if activity_type else 0)
draw.multiline_text(
(content_left + 36, title_y),
"\n".join(title_lines),
fill=TEXT,
font=fonts["title"],
spacing=8,
)
# 绘制元信息(带图标)
meta_y = title_y + title_height + 20
if time_value:
meta_y = draw_icon_and_text(draw, "time", f"时间:{time_value}", content_left, meta_y, text_width, fonts)
if address:
meta_y = draw_icon_and_text(draw, "location", f"地点:{address}", content_left, meta_y, text_width, fonts)
if limit_num:
meta_y = draw_icon_and_text(draw, "people", f"人数:{limit_num}人", content_left, meta_y, text_width, fonts)
if description:
meta_y = draw_icon_and_text(draw, "description", description, content_left, meta_y, text_width, fonts)
# 绘制右侧地图
if show_map:
map_box = (side_left, side_top, side_left + MAP_WIDTH, side_top + MAP_HEIGHT)
static_map = load_image(str(activity.get("activityStaticMapUrl", "")).strip(), timeout)
if static_map is not None:
fitted = ImageOps.fit(static_map, (MAP_WIDTH, MAP_HEIGHT), method=Image.Resampling.LANCZOS)
# 添加圆角遮罩
mask = Image.new('L', (MAP_WIDTH, MAP_HEIGHT), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle((0, 0, MAP_WIDTH, MAP_HEIGHT), radius=20, fill=255)
image.paste(fitted, (side_left, side_top), mask)
draw.rounded_rectangle(map_box, radius=20, outline=BORDER, width=1)
# 绘制二维码
if show_qr:
qr_top = side_top + (MAP_HEIGHT + 20 if show_map else 0)
qr_left = side_left + (MAP_WIDTH - QR_SIZE) // 2 # 居中
draw_qr_block(image, draw, source_url, qr_left, qr_top, fonts)
return top + card_height + CARD_GAP
def build_empty_state(
image: Image.Image,
draw: ImageDraw.ImageDraw,
width: int,
top: int,
fonts: Dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont],
) -> int:
card_box = (PAGE_PADDING, top, width - PAGE_PADDING, top + 240)
draw_shadow_rounded_rectangle(draw, card_box, radius=32, fill=CARD_BG)
# 绘制装饰图标(用圆形和线条模拟)
icon_center_x = PAGE_PADDING + CARD_PADDING + 30
icon_center_y = top + 70
# 大圆背景
draw.ellipse(
(icon_center_x - 30, icon_center_y - 30, icon_center_x + 30, icon_center_y + 30),
fill="#FFE8E8"
)
# 小圆装饰
draw.ellipse(
(icon_center_x - 15, icon_center_y - 15, icon_center_x + 15, icon_center_y + 15),
fill=ACCENT
)
# 表情符号位置的小点
draw.ellipse(
(icon_center_x - 5, icon_center_y - 5, icon_center_x + 5, icon_center_y + 5),
fill="white"
)
title = "最近 24 小时未发现新的活动文章"
body = "可保留这张图作为当天空结果的归档凭证。"
draw.text((PAGE_PADDING + CARD_PADDING + 80, top + 50), title, fill=TEXT, font=fonts["title"])
draw.text((PAGE_PADDING + CARD_PADDING + 80, top + 100), body, fill=MUTED, font=fonts["body"])
# 底部装饰
decor_y = top + 180
for i, color in enumerate([ACCENT, ACCENT_SECONDARY, ACCENT_TERTIARY]):
x = PAGE_PADDING + CARD_PADDING + i * 40
draw.ellipse((x, decor_y, x + 12, decor_y + 12), fill=color)
return top + 240
def estimate_total_height(
activities: Sequence[Dict[str, Any]],
width: int,
fonts: Dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont],
) -> int:
probe = Image.new("RGB", (width, 10), BACKGROUND)
draw = ImageDraw.Draw(probe)
top = PAGE_PADDING + 160 # 增加顶部空间以适应新的 header 设计
if not activities:
return build_empty_state(probe, draw, width, top, fonts) + PAGE_PADDING
for index, activity in enumerate(activities, start=1):
top = render_card(probe, draw, activity, index, top, width, timeout=0, fonts=fonts)
return top + PAGE_PADDING
def render_header(
image: Image.Image,
draw: ImageDraw.ImageDraw,
width: int,
title: str,
subtitle: str,
count: int,
watermark: str,
fonts: Dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont],
) -> None:
# 绘制装饰元素
draw_decorative_elements(draw, width, PAGE_PADDING + 30)
# 标题使用渐变色效果(通过多层文字模拟)
draw.text((PAGE_PADDING, PAGE_PADDING + 25), title, fill=TEXT, font=fonts["headline"])
# 副标题带图标
meta = subtitle.strip() if subtitle.strip() else f"共 {count} 条活动"
draw.text((PAGE_PADDING, PAGE_PADDING + 85), meta, fill=MUTED, font=fonts["body"])
# 活动数量徽章
if count > 0:
badge_text = f"{count}"
badge_font = fonts["section"]
text_box = draw.textbbox((0, 0), badge_text, font=badge_font)
badge_width = text_box[2] - text_box[0] + 24
badge_height = text_box[3] - text_box[1] + 12
badge_x = PAGE_PADDING + 320
badge_y = PAGE_PADDING + 20
# 圆角徽章
draw.rounded_rectangle(
(badge_x, badge_y, badge_x + badge_width, badge_y + badge_height),
radius=16, fill=ACCENT
)
draw.text(
(badge_x + 12, badge_y + 6),
badge_text, fill="white", font=badge_font
)
draw_watermark(image, width, watermark, fonts)
# 底部装饰线 - 使用渐变效果
line_y = PAGE_PADDING + 135
line_colors = [ACCENT, ACCENT_SECONDARY, ACCENT_TERTIARY]
segment_width = (width - PAGE_PADDING * 2) // len(line_colors)
for i, color in enumerate(line_colors):
x1 = PAGE_PADDING + i * segment_width
x2 = x1 + segment_width - 4
draw.line((x1, line_y, x2, line_y), fill=color, width=4)
def run_render(args: argparse.Namespace) -> Path:
input_path = Path(args.input).expanduser().resolve()
output_path = Path(args.output).expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
data = read_json(input_path)
if not isinstance(data, list):
raise RuntimeError("input must be a JSON array")
fonts = {
"headline": load_font(48, bold=True),
"title": load_font(28, bold=True),
"section": load_font(22, bold=True),
"body": load_font(18),
"small": load_font(14),
"watermark": load_font(32, bold=True),
}
height = estimate_total_height(data, args.width, fonts)
image = Image.new("RGB", (args.width, height), BACKGROUND)
draw = ImageDraw.Draw(image)
render_header(
image,
draw,
args.width,
args.title,
args.subtitle,
len(data),
getattr(args, "watermark", "潮匠里"),
fonts,
)
top = PAGE_PADDING + 160 # 增加顶部空间以适应新的 header 设计
if not data:
build_empty_state(image, draw, args.width, top, fonts)
else:
for index, activity in enumerate(data, start=1):
top = render_card(image, draw, activity, index, top, args.width, args.download_timeout, fonts)
image.save(output_path, format="PNG")
return output_path
def main() -> int:
args = parse_args()
run_render(args)
return 0
if __name__ == "__main__":
raise SystemExit(main())
{
"上海市徐汇区漕溪北路398号": {
"status": "1",
"info": "OK",
"infocode": "10000",
"count": "1",
"geocodes": [
{
"formatted_address": "上海市徐汇区漕溪北路398号",
"location": "121.436525,31.194729",
"level": "门址"
}
]
}
}
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from argparse import Namespace
from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parents[1]
if str(SKILL_ROOT) not in sys.path:
sys.path.insert(0, str(SKILL_ROOT))
from scripts.amap_geocode_wgs84 import build_static_map_url, gcj02_to_wgs84, is_precise_address, run_geocode
FIXTURE_FILE = Path(__file__).parent / "fixtures" / "amap-geocode" / "responses.json"
class AMapGeocodeWgs84Tests(unittest.TestCase):
def test_build_static_map_url_returns_v3_staticmap_url(self) -> None:
url = build_static_map_url("test-key", 121.436525, 31.194729)
self.assertIn("https://restapi.amap.com/v3/staticmap?", url)
self.assertIn("key=test-key", url)
self.assertIn("location=121.436525%2C31.194729", url)
self.assertNotIn("markers=", url)
def test_gcj02_to_wgs84_returns_reasonable_offset(self) -> None:
lng, lat = gcj02_to_wgs84(121.473701, 31.230416)
self.assertAlmostEqual(lng, 121.469177, places=3)
self.assertAlmostEqual(lat, 31.232342, places=3)
def test_is_precise_address_rejects_vague_locations(self) -> None:
self.assertFalse(is_precise_address(""))
self.assertFalse(is_precise_address("线上"))
self.assertFalse(is_precise_address("上海市徐汇区,报名后通知具体地点"))
self.assertFalse(is_precise_address("浦东新区某地"))
self.assertTrue(is_precise_address("上海市徐汇区漕溪北路398号"))
self.assertTrue(is_precise_address("上海中心大厦"))
def test_run_geocode_with_fixture(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
input_path = Path(tmpdir) / "activity-structured.json"
output_path = Path(tmpdir) / "activity-structured-geo.json"
input_path.write_text(
json.dumps(
[
{
"activityName": "AI 工作坊",
"activityAddress": "上海市徐汇区漕溪北路398号",
},
{
"activityName": "线上分享会",
"activityAddress": "",
},
{
"activityName": "模糊地址活动",
"activityAddress": "上海市徐汇区,报名后通知具体地点",
},
],
ensure_ascii=False,
),
encoding="utf-8",
)
args = Namespace(
input=str(input_path),
output=str(output_path),
amap_key="test-key",
city_hint_field="",
fixture_file=str(FIXTURE_FILE),
)
result = run_geocode(args)
self.assertEqual(result[0]["activityGeoStatus"], "ok")
self.assertEqual(result[1]["activityGeoStatus"], "skipped")
self.assertEqual(result[2]["activityGeoStatus"], "skipped_vague")
self.assertIn("activityLongitudeWGS84", result[0])
self.assertEqual(result[0]["activityStaticMapStatus"], "ok")
self.assertIn("restapi.amap.com/v3/staticmap", result[0]["activityStaticMapUrl"])
self.assertEqual(result[1]["activityStaticMapStatus"], "skipped")
self.assertEqual(result[2]["activityStaticMapStatus"], "skipped_vague")
self.assertTrue(output_path.exists())
if __name__ == "__main__":
unittest.main()
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parents[1]
SCRIPT_PATH = SKILL_ROOT / "scripts" / "fetch_recent_feeds.sh"
class FetchRecentFeedsTests(unittest.TestCase):
def test_fetches_multiple_feeds(self) -> None:
if shutil.which("bash") is None or shutil.which("curl") is None or shutil.which("jq") is None:
self.skipTest("bash, curl, and jq are required")
now = datetime.now(timezone.utc)
recent = now.isoformat(timespec="seconds")
older = (now - timedelta(hours=72)).isoformat(timespec="seconds")
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
feed_dir = tmp_path / "feed"
feed_dir.mkdir()
(feed_dir / "gh_first.json").write_text(
json.dumps(
{
"items": [
{
"title": "First feed recent article",
"url": "https://example.com/1",
"updated": recent,
}
]
}
),
encoding="utf-8",
)
(feed_dir / "gh_second.json").write_text(
json.dumps(
{
"items": [
{
"title": "Second feed recent article",
"url": "https://example.com/2",
"updated": recent,
},
{
"title": "Second feed old article",
"url": "https://example.com/3",
"updated": older,
},
]
}
),
encoding="utf-8",
)
feeds_file = tmp_path / "feeds.md"
output_file = tmp_path / "raw.json"
feeds_file.write_text(
"gh_first 第一条\n"
"gh_second 第二条\n",
encoding="utf-8",
)
subprocess.run(
[
"bash",
str(SCRIPT_PATH),
"--feeds-file",
str(feeds_file),
"--output-file",
str(output_file),
"--api-host",
f"file://{tmp_path}",
],
check=True,
)
articles = json.loads(output_file.read_text(encoding="utf-8"))
self.assertEqual(len(articles), 2)
self.assertEqual({article["mpId"] for article in articles}, {"gh_first", "gh_second"})
self.assertEqual(
{article["sourceTitle"] for article in articles},
{"First feed recent article", "Second feed recent article"},
)
def test_tolerates_trailing_commas_and_duplicate_feed_ids(self) -> None:
if shutil.which("bash") is None or shutil.which("curl") is None or shutil.which("jq") is None:
self.skipTest("bash, curl, and jq are required")
now = datetime.now(timezone.utc)
recent = now.isoformat(timespec="seconds")
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
feed_dir = tmp_path / "feed"
feed_dir.mkdir()
(feed_dir / "gh_first.json").write_text(
json.dumps(
{
"items": [
{
"title": "First deduped article",
"url": "https://example.com/first",
"updated": recent,
}
]
}
),
encoding="utf-8",
)
(feed_dir / "gh_second.json").write_text(
json.dumps(
{
"items": [
{
"title": "Second comma article",
"url": "https://example.com/second",
"updated": recent,
}
]
}
),
encoding="utf-8",
)
feeds_file = tmp_path / "feeds.md"
output_file = tmp_path / "raw.json"
feeds_file.write_text(
"gh_first 第一条\n"
"gh_second, 第二条\n"
"gh_first 重复条目\n",
encoding="utf-8",
)
subprocess.run(
[
"bash",
str(SCRIPT_PATH),
"--feeds-file",
str(feeds_file),
"--output-file",
str(output_file),
"--api-host",
f"file://{tmp_path}",
],
check=True,
)
articles = json.loads(output_file.read_text(encoding="utf-8"))
self.assertEqual(len(articles), 2)
by_id = {article["mpId"]: article for article in articles}
self.assertEqual(set(by_id), {"gh_first", "gh_second"})
self.assertEqual(by_id["gh_first"]["sourceTitle"], "First deduped article")
self.assertEqual(by_id["gh_first"]["mpName"], "第一条")
self.assertEqual(by_id["gh_second"]["sourceTitle"], "Second comma article")
self.assertEqual(by_id["gh_second"]["mpName"], "第二条")
if __name__ == "__main__":
unittest.main()
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from argparse import Namespace
from pathlib import Path
from unittest import mock
from PIL import Image, ImageDraw, ImageFont
SKILL_ROOT = Path(__file__).resolve().parents[1]
if str(SKILL_ROOT) not in sys.path:
sys.path.insert(0, str(SKILL_ROOT))
from scripts.render_activity_image import (
build_meta_lines,
draw_qr_block,
font_candidates,
has_coordinates,
require_source_url,
run_render,
)
class RenderActivityImageTests(unittest.TestCase):
def tearDown(self) -> None:
font_candidates.cache_clear()
def test_font_candidates_accept_env_override_on_linux(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
custom_font = tmp_path / "custom.ttf"
custom_font.write_bytes(b"")
with (
mock.patch.dict(
"os.environ",
{"ACTIVITY_PUSH_FONT_REGULAR_PATH": str(custom_font)},
clear=False,
),
mock.patch("scripts.render_activity_image.platform.system", return_value="Linux"),
mock.patch("scripts.render_activity_image.shutil.which", return_value=None),
):
self.assertEqual(font_candidates(False)[0], str(custom_font))
def test_run_render_generates_png_for_activity_list(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
map_path = tmp_path / "map.png"
Image.new("RGB", (600, 400), "#BFD7EA").save(map_path)
input_path = tmp_path / "activity-structured-geo.json"
output_path = tmp_path / "activity-summary.png"
input_path.write_text(
json.dumps(
[
{
"activityName": "AI 工作坊",
"activityType": "工作坊",
"activityAddress": "上海市徐汇区漕溪北路398号",
"activityStartTime": "2026-03-12 14:00",
"activityEndTime": "2026-03-12 17:00",
"activityLimitNum": "50",
"activityDescription": "面向产品和工程团队,集中讨论 AI 应用落地与评估方法。",
"activityLongitudeGCJ02": 121.436525,
"activityLatitudeGCJ02": 31.194729,
"sourceMpName": "某公众号",
"sourceTitle": "AI 工作坊报名通知",
"sourceUrl": "https://example.com/post/1",
"activityStaticMapUrl": map_path.as_uri(),
}
],
ensure_ascii=False,
),
encoding="utf-8",
)
run_render(
Namespace(
input=str(input_path),
output=str(output_path),
title="活动情报速递",
subtitle="2026-03-11",
watermark="潮匠里",
width=1280,
download_timeout=1.0,
)
)
self.assertTrue(output_path.exists())
with Image.open(output_path) as rendered:
self.assertEqual(rendered.size[0], 1280)
self.assertGreater(rendered.size[1], 400)
def test_build_meta_lines_omits_missing_values(self) -> None:
image = Image.new("RGB", (1280, 720), "white")
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()
lines = build_meta_lines(
{
"activityType": "闭门会",
"activityDescription": "只保留已有字段",
"activityScore": 92,
"activityScoreReason": "主题和目标用户高度匹配",
},
draw,
font,
600,
)
rendered = "\n".join(lines)
self.assertIn("类型:", rendered)
self.assertIn("说明:", rendered)
self.assertNotIn("时间:", rendered)
self.assertNotIn("地点:", rendered)
self.assertNotIn("人数:", rendered)
self.assertNotIn("评分:", rendered)
self.assertNotIn("未说明", rendered)
self.assertNotIn("待补充", rendered)
def test_has_coordinates_requires_both_lng_and_lat(self) -> None:
self.assertFalse(has_coordinates({}))
self.assertFalse(has_coordinates({"activityLongitudeGCJ02": 121.4}))
self.assertFalse(has_coordinates({"activityLatitudeGCJ02": 31.1}))
self.assertFalse(has_coordinates({"activityLongitudeGCJ02": "abc", "activityLatitudeGCJ02": 31.1}))
self.assertTrue(has_coordinates({"activityLongitudeGCJ02": 121.4, "activityLatitudeGCJ02": 31.1}))
def test_require_source_url_rejects_missing_qr_source(self) -> None:
with self.assertRaisesRegex(RuntimeError, "missing sourceUrl"):
require_source_url({"activityName": "AI 工作坊"}, 1)
def test_draw_qr_block_uses_neutral_colors(self) -> None:
image = Image.new("RGB", (500, 220), "white")
draw = ImageDraw.Draw(image)
fonts = {
"section": ImageFont.load_default(),
"small": ImageFont.load_default(),
}
height = draw_qr_block(image, draw, "https://example.com/post/1", 20, 20, fonts)
self.assertEqual(height, 116)
self.assertNotEqual(image.getpixel((24, 76)), (184, 92, 56))
def test_run_render_generates_empty_state_png(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
input_path = tmp_path / "activity-structured-geo.json"
output_path = tmp_path / "activity-summary-empty.png"
input_path.write_text("[]", encoding="utf-8")
run_render(
Namespace(
input=str(input_path),
output=str(output_path),
title="活动情报速递",
subtitle="",
watermark="潮匠里",
width=1280,
download_timeout=1.0,
)
)
self.assertTrue(output_path.exists())
with Image.open(output_path) as rendered:
self.assertEqual(rendered.size[0], 1280)
self.assertGreater(rendered.size[1], 250)
def test_run_render_requires_source_url_for_activity_cards(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
input_path = tmp_path / "activity-structured-geo.json"
output_path = tmp_path / "activity-summary.png"
input_path.write_text(
json.dumps(
[
{
"activityName": "AI 工作坊",
"activityType": "工作坊",
}
],
ensure_ascii=False,
),
encoding="utf-8",
)
with self.assertRaisesRegex(RuntimeError, "missing sourceUrl"):
run_render(
Namespace(
input=str(input_path),
output=str(output_path),
title="活动情报速递",
subtitle="2026-03-11",
watermark="潮匠里",
width=1280,
download_timeout=1.0,
)
)
if __name__ == "__main__":
unittest.main()