feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本
- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整 - soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新 - soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL - .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle Made-with: Cursor
This commit is contained in:
@@ -1,48 +1,47 @@
|
||||
# Soul 运营全链路技能包(本机一键打包)
|
||||
# Soul 运营全链路技能包(精简打包)
|
||||
|
||||
## 你要做的事(复制到另一台电脑前)
|
||||
## 一键打包(推荐:体积小、可重装)
|
||||
|
||||
在 **本机终端** 执行(路径按你实际安装调整):
|
||||
在 **本机终端** 执行:
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/scripts/pack_soul_operation_skills.py"
|
||||
```
|
||||
|
||||
- 会在 **`~/Downloads/Soul运营全链路技能包_20260320.zip`** 生成压缩包(日期戳见脚本内 `STAMP`,可自行改)。
|
||||
- 临时文件在永平项目下 **`.tmp_skill_bundle/`**,打包完成后可整目录删除。
|
||||
- 输出:**`~/Downloads/Soul运营全链路技能包_精简_YYYYMMDD.zip`**(日期为**打包当天**)。
|
||||
- 临时目录:`一场soul的创业实验-永平/.tmp_skill_bundle/`,打完可删。
|
||||
|
||||
若卡若AI不在默认路径,请先编辑 `pack_soul_operation_skills.py` 里的:
|
||||
卡若AI不在默认路径时,编辑脚本内:
|
||||
|
||||
```python
|
||||
KARUO_AI = Path("/Users/karuo/Documents/个人/卡若AI")
|
||||
```
|
||||
|
||||
## 压缩包里有什么(保证链路齐全)
|
||||
## 精简包策略(大文件不进包)
|
||||
|
||||
| 内容 | 说明 |
|
||||
| 类型 | 处理 |
|
||||
|:---|:---|
|
||||
| `.cursor/skills/soul-operation-report` | Cursor 入口:运营报表 |
|
||||
| `.cursor/skills/soul-party-project` | Cursor 入口:水岸项目管理 |
|
||||
| `卡若AI/02_卡人(水)/水岸_项目管理/` | 水岸总纲 + 卡若创业派对 README |
|
||||
| `卡若AI/.../水桥_平台对接/飞书管理/` | 运营报表、妙记相关脚本与 SKILL |
|
||||
| `卡若AI/.../水桥_平台对接/智能纪要/` | 妙记下载、纪要 SKILL + 脚本 |
|
||||
| `卡若AI/.../水桥_平台对接/Soul创业实验/` | 写作/上传/环境与 TOKEN 说明 |
|
||||
| `卡若AI/03_卡木(木)/木叶_视频内容/` 下 | `视频切片`、`多平台分发`、`抖音/B站/视频号/小红书/快手发布` |
|
||||
| `卡若AI/运营中枢/工作台/00_账号与API索引.md` | 若本机存在则一并打入(凭证速查) |
|
||||
| `解压后必读.md` | 在另一台电脑上的合并步骤与环境说明 |
|
||||
| 单文件 **> 512KB** | 跳过 |
|
||||
| 视频/音频/压缩包/模型权重等扩展名 | 跳过 |
|
||||
| `cookies/`、`node_modules`、`.browser_state`、`venv` 等 | 整目录跳过 |
|
||||
| `publish_log.json`、`.feishu_tokens.json` | 跳过(到新机按脚本重新授权;若要迁移凭证请**单独**安全拷贝) |
|
||||
|
||||
## 「可直接运作」在另一台机上的含义
|
||||
包内另有 **`重装依赖说明.md`**、**`_pack_stats.json`**(本次打入/跳过统计)。
|
||||
|
||||
- **Skill 与脚本文件**会齐;但要真正跑通,仍需在新电脑上:
|
||||
- 安装 **Python、依赖、FFmpeg、conda/mlx-whisper**(见各 SKILL);
|
||||
- 配置 **飞书/妙记/各平台 Cookie、小程序与永平项目 `.env`**(见 `Soul创业实验/上传/环境与TOKEN配置.md` 与 `00_账号与API索引.md`);
|
||||
- 把文档里原机的 **`/Users/karuo/...`** 改成新机器路径。
|
||||
## 压缩包里有什么(链路齐全 = SKILL + 脚本 + 小配置)
|
||||
|
||||
- `.cursor/skills/`:`soul-operation-report`、`soul-party-project`
|
||||
- `卡若AI/02_卡人(水)/水岸_项目管理/`
|
||||
- `卡若AI/.../水桥_平台对接/飞书管理/`、`智能纪要/`、`Soul创业实验/`
|
||||
- `卡若AI/03_卡木(木)/木叶_视频内容/`:`视频切片`、`多平台分发`、各平台发布(仅小文件)
|
||||
- `卡若AI/运营中枢/工作台/00_账号与API索引.md`(若存在且小于体积限制)
|
||||
|
||||
## 另一台电脑
|
||||
|
||||
1. 解压 → 合并 `卡若AI/` → 安装 Cursor skills。
|
||||
2. 按 **`重装依赖说明.md`**:`pip`/`conda`/`ffmpeg`/`playwright` 等。
|
||||
3. 重新配置飞书 Token、妙记 Cookie、各平台 Cookie、永平 `.env`。
|
||||
|
||||
## 安全
|
||||
|
||||
压缩包可能含 **密钥与 Cookie 说明**,请勿上传公开网盘;用 U 盘或加密渠道传输。
|
||||
|
||||
## 未打入的内容(属正常)
|
||||
|
||||
- **`飞书管理/脚本/.browser_state/`**:Playwright/Chrome 本地状态,常含断链或套接字文件,打包会失败且不宜迁移;**到新电脑需按各脚本说明重新登录/生成状态**。
|
||||
- 体积约 **260MB+**(含脚本与分发相关文件);若需更小体积,可自行从包内删掉用不到的平台目录后再压缩。
|
||||
勿将含密钥的压缩包上传公开网盘;用 U 盘或加密渠道传输。
|
||||
|
||||
@@ -1,48 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Soul 运营全链路技能包:将 SKILL + 脚本 + Cursor 入口打成 zip,默认输出到用户「下载」文件夹。
|
||||
Soul 运营全链路技能包(精简版):只打包 SKILL / 脚本 / 小配置,便于另一台机 pip/conda 重装。
|
||||
大文件、媒体、Cookie、日志等一律不入包。
|
||||
|
||||
用法:
|
||||
python3 scripts/pack_soul_operation_skills.py
|
||||
输出:
|
||||
~/Downloads/Soul运营全链路技能包_精简_YYYYMMDD.zip
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
STAMP = "20260320"
|
||||
BUNDLE_TOP = f"Soul运营全链路技能包_{STAMP}"
|
||||
# 单文件超过此大小则跳过(字节)——非「代码/文档类」扩展名
|
||||
MAX_FILE_BYTES = 512 * 1024 # 512KB
|
||||
|
||||
# 脚本与文档类可放宽(避免误跳过大 .py/.md;仍远小于整包 200MB+)
|
||||
CODE_DOC_EXT = frozenset(
|
||||
{
|
||||
".py",
|
||||
".md",
|
||||
".mdc",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".txt",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".cfg",
|
||||
".ini",
|
||||
".sql",
|
||||
".html",
|
||||
".css",
|
||||
".js",
|
||||
".ts",
|
||||
".tsx",
|
||||
".jsx",
|
||||
".svg",
|
||||
".xml",
|
||||
}
|
||||
)
|
||||
MAX_CODE_DOC_BYTES = 8 * 1024 * 1024 # 8MB
|
||||
|
||||
# 整段目录名匹配则不进包(walk 时不进入)
|
||||
SKIP_DIR_NAMES = frozenset(
|
||||
{
|
||||
"__pycache__",
|
||||
".git",
|
||||
".svn",
|
||||
".browser_state",
|
||||
"chromium_data",
|
||||
"node_modules",
|
||||
"venv",
|
||||
".venv",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".tox",
|
||||
"dist",
|
||||
"build",
|
||||
"eggs",
|
||||
".eggs",
|
||||
"htmlcov",
|
||||
".ruff_cache",
|
||||
# Cookie 到新机需重新登录导出,不入包
|
||||
"cookies",
|
||||
}
|
||||
)
|
||||
|
||||
# 扩展名一律跳过(媒体/模型/压缩包等)
|
||||
SKIP_EXTENSIONS = frozenset(
|
||||
{
|
||||
".mp4",
|
||||
".mov",
|
||||
".mkv",
|
||||
".avi",
|
||||
".webm",
|
||||
".m4v",
|
||||
".flv",
|
||||
".wmv",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".tgz",
|
||||
".bz2",
|
||||
".xz",
|
||||
".rar",
|
||||
".7z",
|
||||
".dmg",
|
||||
".iso",
|
||||
".img",
|
||||
".pt",
|
||||
".pth",
|
||||
".onnx",
|
||||
".ckpt",
|
||||
".safetensors",
|
||||
".bin",
|
||||
".exe",
|
||||
".dll",
|
||||
".so",
|
||||
".dylib",
|
||||
".wav",
|
||||
".mp3",
|
||||
".flac",
|
||||
".aac",
|
||||
".m4a",
|
||||
".npz",
|
||||
".npy",
|
||||
".pkl",
|
||||
".pickle",
|
||||
".whl",
|
||||
".parquet",
|
||||
".arrow",
|
||||
}
|
||||
)
|
||||
|
||||
# 文件名(不含路径)强制跳过
|
||||
SKIP_FILE_NAMES = frozenset(
|
||||
{
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"publish_log.json", # 分发日志可能巨大
|
||||
".feishu_tokens.json", # 凭证,到新机用脚本重新获取更安全;若需带走可自行拷贝
|
||||
}
|
||||
)
|
||||
|
||||
# 卡若AI 根目录(按你本机实际修改)
|
||||
KARUO_AI = Path("/Users/karuo/Documents/个人/卡若AI")
|
||||
CURSOR_SKILLS = Path.home() / ".cursor" / "skills"
|
||||
DOWNLOADS = Path.home() / "Downloads"
|
||||
|
||||
# 在永平项目下临时组装(本仓库内,便于工具写入)
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
STAMP = _dt.date.today().strftime("%Y%m%d")
|
||||
BUNDLE_TOP = f"Soul运营全链路技能包_精简_{STAMP}"
|
||||
STAGING_PARENT = REPO_ROOT / ".tmp_skill_bundle"
|
||||
STAGING = STAGING_PARENT / BUNDLE_TOP
|
||||
|
||||
|
||||
def ignore_copy(dirpath: str, names: list[str]) -> list[str]:
|
||||
"""排除缓存、浏览器运行时目录(含断链/套接字,会导致 copytree 失败)。"""
|
||||
skip_dirs = {"__pycache__", ".browser_state", "chromium_data"}
|
||||
skip_files = {".DS_Store"}
|
||||
ignored: list[str] = []
|
||||
for n in names:
|
||||
if n in skip_dirs or n in skip_files or n.endswith(".pyc"):
|
||||
ignored.append(n)
|
||||
return ignored
|
||||
# 统计
|
||||
_stats: dict[str, int] = {"files": 0, "skipped_size": 0, "skipped_ext": 0, "skipped_dir": 0, "skipped_name": 0}
|
||||
|
||||
|
||||
def copytree(src: Path, dst: Path) -> None:
|
||||
if not src.exists():
|
||||
print(f"SKIP 不存在: {src}", file=sys.stderr)
|
||||
def should_skip_file(path: Path) -> tuple[bool, str]:
|
||||
name = path.name
|
||||
if name in SKIP_FILE_NAMES:
|
||||
return True, "name"
|
||||
ext = path.suffix.lower()
|
||||
if ext in SKIP_EXTENSIONS:
|
||||
return True, "ext"
|
||||
try:
|
||||
sz = path.stat().st_size
|
||||
except OSError:
|
||||
return True, "stat"
|
||||
limit = MAX_CODE_DOC_BYTES if ext in CODE_DOC_EXT else MAX_FILE_BYTES
|
||||
if sz > limit:
|
||||
return True, "size"
|
||||
return False, ""
|
||||
|
||||
|
||||
def copy_tree_selective(src: Path, dst_root: Path, rel_base: Path) -> None:
|
||||
"""将 src 下文件复制到 dst_root / rel_base,遵守跳过规则。"""
|
||||
if not src.is_dir():
|
||||
print(f"SKIP 非目录: {src}", file=sys.stderr)
|
||||
return
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True, ignore=ignore_copy)
|
||||
for root, dirnames, filenames in os_walk_topdown(src):
|
||||
root_path = Path(root)
|
||||
# 过滤要进入的子目录
|
||||
for d in list(dirnames):
|
||||
if d in SKIP_DIR_NAMES:
|
||||
dirnames.remove(d)
|
||||
_stats["skipped_dir"] += 1
|
||||
rel = root_path.relative_to(src)
|
||||
for fname in filenames:
|
||||
fp = root_path / fname
|
||||
skip, reason = should_skip_file(fp)
|
||||
if skip:
|
||||
if reason == "size":
|
||||
_stats["skipped_size"] += 1
|
||||
elif reason == "ext":
|
||||
_stats["skipped_ext"] += 1
|
||||
elif reason == "name":
|
||||
_stats["skipped_name"] += 1
|
||||
continue
|
||||
dest_dir = dst_root / rel_base / rel
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / fname
|
||||
shutil.copy2(fp, dest)
|
||||
_stats["files"] += 1
|
||||
|
||||
|
||||
def os_walk_topdown(src: Path):
|
||||
"""与 os.walk 相同,但用 Path。"""
|
||||
for r, dnames, fnames in os.walk(str(src), topdown=True):
|
||||
yield Path(r), dnames, fnames
|
||||
|
||||
|
||||
def copy_cursor_skill(name: str) -> None:
|
||||
src = CURSOR_SKILLS / name
|
||||
if not src.is_dir():
|
||||
print(f"SKIP 无 Cursor skill: {src}", file=sys.stderr)
|
||||
return
|
||||
copy_tree_selective(src, STAGING, Path(".cursor") / "skills" / name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -50,29 +215,35 @@ def main() -> int:
|
||||
print(f"ERROR: 未找到卡若AI目录: {KARUO_AI}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
global _stats
|
||||
_stats = {k: 0 for k in _stats}
|
||||
|
||||
if STAGING.exists():
|
||||
shutil.rmtree(STAGING)
|
||||
STAGING.mkdir(parents=True)
|
||||
|
||||
# Cursor 入口
|
||||
csk = STAGING / ".cursor" / "skills"
|
||||
csk.mkdir(parents=True, exist_ok=True)
|
||||
# Cursor 入口(通常只有 SKILL.md)
|
||||
for name in ("soul-operation-report", "soul-party-project"):
|
||||
p = CURSOR_SKILLS / name
|
||||
if p.is_dir():
|
||||
copytree(p, csk / name)
|
||||
copy_cursor_skill(name)
|
||||
|
||||
kai = STAGING / "卡若AI"
|
||||
copytree(
|
||||
kai_rel = Path("卡若AI")
|
||||
|
||||
def pack_sub(src_under_karuo: Path, rel_under_kai: Path) -> None:
|
||||
"""src_under_karuo 为卡若AI下的绝对路径;打入包内 卡若AI/rel_under_kai"""
|
||||
if not src_under_karuo.exists():
|
||||
print(f"SKIP 不存在: {src_under_karuo}", file=sys.stderr)
|
||||
return
|
||||
copy_tree_selective(src_under_karuo, STAGING, kai_rel / rel_under_kai)
|
||||
|
||||
pack_sub(
|
||||
KARUO_AI / "02_卡人(水)" / "水岸_项目管理",
|
||||
kai / "02_卡人(水)" / "水岸_项目管理",
|
||||
Path("02_卡人(水)") / "水岸_项目管理",
|
||||
)
|
||||
bridge = KARUO_AI / "02_卡人(水)" / "水桥_平台对接"
|
||||
for sub in ("飞书管理", "智能纪要", "Soul创业实验"):
|
||||
copytree(bridge / sub, kai / "02_卡人(水)" / "水桥_平台对接" / sub)
|
||||
pack_sub(bridge / sub, Path("02_卡人(水)") / "水桥_平台对接" / sub)
|
||||
|
||||
wood = KARUO_AI / "03_卡木(木)" / "木叶_视频内容"
|
||||
wdst = kai / "03_卡木(木)" / "木叶_视频内容"
|
||||
for sub in (
|
||||
"视频切片",
|
||||
"多平台分发",
|
||||
@@ -82,45 +253,74 @@ def main() -> int:
|
||||
"小红书发布",
|
||||
"快手发布",
|
||||
):
|
||||
copytree(wood / sub, wdst / sub)
|
||||
pack_sub(wood / sub, Path("03_卡木(木)") / "木叶_视频内容" / sub)
|
||||
|
||||
idx = KARUO_AI / "运营中枢" / "工作台" / "00_账号与API索引.md"
|
||||
if idx.is_file():
|
||||
(kai / "运营中枢" / "工作台").mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(idx, kai / "运营中枢" / "工作台" / idx.name)
|
||||
skip, _ = should_skip_file(idx)
|
||||
if not skip:
|
||||
dest = STAGING / kai_rel / "运营中枢" / "工作台" / idx.name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(idx, dest)
|
||||
_stats["files"] += 1
|
||||
|
||||
readme = STAGING / "解压后必读.md"
|
||||
readme.write_text(
|
||||
"""# Soul 运营全链路技能包
|
||||
# 写入 requirements 汇总(若各目录有 requirements.txt,只列路径提示,不合并)
|
||||
req_hint = STAGING / "重装依赖说明.md"
|
||||
req_hint.write_text(
|
||||
f"""# 重装依赖说明(精简包)
|
||||
|
||||
## 包含内容
|
||||
本包**不含**大文件与本地状态,到新电脑请:
|
||||
|
||||
- `.cursor/skills/`:`soul-operation-report`、`soul-party-project`
|
||||
- `卡若AI/02_卡人(水)/水岸_项目管理/`
|
||||
- `卡若AI/02_卡人(水)/水桥_平台对接/飞书管理/`、`智能纪要/`、`Soul创业实验/`
|
||||
- `卡若AI/03_卡木(木)/木叶_视频内容/`:视频切片、多平台分发、各平台发布
|
||||
- `卡若AI/运营中枢/工作台/00_账号与API索引.md`(若源机存在)
|
||||
1. **Python**:建议 3.10+;进入各含 `requirements.txt` 的脚本目录执行 `pip install -r requirements.txt`(以各 SKILL 为准)。
|
||||
2. **系统**:`ffmpeg`、`ffprobe`(视频切片);视频转录见 SKILL 中的 **conda mlx-whisper** 环境说明。
|
||||
3. **Playwright**(若飞书脚本需要):`playwright install` 并按脚本说明登录;**`.browser_state` 未打包**。
|
||||
4. **多平台分发**:包内**不含 `cookies/` 目录**,需在新机各平台重新登录导出 Cookie(见多平台分发 SKILL)。
|
||||
5. **飞书 Token**:精简包默认**不含** `.feishu_tokens.json`,请在新机用脚本流程重新授权;若你刻意要迁移凭证请单独拷贝(注意安全)。
|
||||
|
||||
## 另一台电脑怎么用
|
||||
---
|
||||
|
||||
1. 解压后,将 `卡若AI/` **合并**到你本机卡若AI根目录(先备份)。
|
||||
2. 将 `.cursor/skills/` 下两个目录复制到 `~/.cursor/skills/`。
|
||||
3. 安装 Python/FFmpeg/conda 等依赖,按各 SKILL 与 `Soul创业实验/上传/环境与TOKEN配置.md` 配置 Token、Cookie、永平项目 `.env`。
|
||||
4. 文档或脚本里的 `/Users/karuo/...` 需改成本机路径。
|
||||
打包策略摘要(自动生成):
|
||||
|
||||
**安全**:包内可能有凭证说明,勿上传公开网盘。
|
||||
- 代码/文档类(`.py`、`.md` 等)单文件大于 **{MAX_CODE_DOC_BYTES // (1024 * 1024)} MB** 跳过;其它类型大于 **{MAX_FILE_BYTES // 1024} KB** 跳过
|
||||
- 跳过扩展名:媒体、压缩包、模型权重等
|
||||
- 跳过目录:`cookies`、`node_modules`、`.browser_state`、`venv` 等
|
||||
|
||||
打包日期:**{STAMP}**
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
for p in list(STAGING.rglob("__pycache__")):
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
for p in STAGING.rglob("*.pyc"):
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
readme = STAGING / "解压后必读.md"
|
||||
readme.write_text(
|
||||
f"""# Soul 运营全链路技能包(精简版)
|
||||
|
||||
## 本包特点
|
||||
|
||||
- **体积小**:不含视频/大日志/模型/Cookie 目录等;到新机器按 `重装依赖说明.md` **重装环境与凭证**。
|
||||
- **日期**:{STAMP}
|
||||
|
||||
## 包含
|
||||
|
||||
- `.cursor/skills/`:`soul-operation-report`、`soul-party-project`
|
||||
- `卡若AI/` 下水岸、飞书管理、智能纪要、Soul创业实验、视频切片、多平台分发与各平台发布目录中的 **SKILL、脚本、小配置**(受大小与类型过滤)
|
||||
|
||||
## 合并步骤
|
||||
|
||||
1. 解压后把 `卡若AI/` **合并**进你的卡若AI根目录(先备份)。
|
||||
2. 将 `.cursor/skills/` 下两个文件夹复制到 `~/.cursor/skills/`。
|
||||
3. 阅读 **`重装依赖说明.md`**,安装 Python 依赖、FFmpeg、conda 环境等。
|
||||
4. 配置飞书、妙记、各平台 Cookie、永平 `.env`(见各 SKILL 与 `Soul创业实验/上传/环境与TOKEN配置.md`)。
|
||||
|
||||
**安全**:勿将含密钥的压缩包上传公开网盘。
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# 打包统计写入 JSON(便于核对)
|
||||
(STAGING / "_pack_stats.json").write_text(
|
||||
json.dumps({**_stats, "max_file_bytes": MAX_FILE_BYTES, "stamp": STAMP}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
DOWNLOADS.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = DOWNLOADS / f"{BUNDLE_TOP}.zip"
|
||||
@@ -132,7 +332,11 @@ def main() -> int:
|
||||
zf.write(f, arcname.as_posix())
|
||||
|
||||
mb = zip_path.stat().st_size / (1024 * 1024)
|
||||
print(f"完成: {zip_path} ({mb:.2f} MB)")
|
||||
print(f"完成: {zip_path}")
|
||||
print(f"大小: {mb:.2f} MB | 打入文件数: {_stats['files']}")
|
||||
print(
|
||||
f"跳过: 超体积 {_stats['skipped_size']} | 扩展名 {_stats['skipped_ext']} | 文件名 {_stats['skipped_name']} | 目录 {_stats['skipped_dir']}"
|
||||
)
|
||||
print(f"临时目录(可删): {STAGING}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
生成章节海报(标题=章节标题、摘要+小程序码),上传到飞书并发送到 Soul 彩民团队飞书群(默认 webhook)。
|
||||
生成章节海报(标题=章节标题、摘要+小程序码),上传到飞书并发送到开发群(默认 webhook,见 FEISHU_DEV_GROUP_WEBHOOK)。
|
||||
海报样式:深蓝背景、顶部装饰条、主标题为章节标题、摘要、底部「长按识别小程序码」+ 二维码。
|
||||
用法:
|
||||
python3 send_chapter_poster_to_feishu.py 9.24 "第112场|一个人起头,维权挣了大半套房"
|
||||
@@ -30,8 +30,11 @@ except ImportError:
|
||||
|
||||
# 与 post_to_feishu 保持一致
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
# 默认发到 Soul 彩民团队飞书群
|
||||
WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1"
|
||||
# 默认:Soul 开发群(派对 AI / 卡若 AI 与项目复盘统一入口,见 .cursor/docs/feishu_开发群与项目复盘.md)
|
||||
WEBHOOK = os.environ.get(
|
||||
"FEISHU_DEV_GROUP_WEBHOOK",
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494",
|
||||
)
|
||||
BACKEND_QRCODE_URL = "https://soulapi.quwanzhi.com/api/miniprogram/qrcode"
|
||||
MINIPROGRAM_READ_BASE = "https://soul.quwanzhi.com/read"
|
||||
|
||||
|
||||
138
scripts/send_feishu_text_and_images.py
Normal file
138
scripts/send_feishu_text_and_images.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
向开发群 webhook 发送一条长文本 + 若干本地 PNG(先上传飞书再发 image_key)。
|
||||
依赖:与 send_chapter_poster_to_feishu.py 相同,需 scripts/.env.feishu 内 FEISHU_APP_ID / FEISHU_APP_SECRET。
|
||||
|
||||
用法:
|
||||
python3 send_feishu_text_and_images.py --text-file recap.txt \\
|
||||
--images a.png b.png
|
||||
python3 send_feishu_text_and_images.py -t "单行文本" --images x.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
DEFAULT_WEBHOOK = os.environ.get(
|
||||
"FEISHU_DEV_GROUP_WEBHOOK",
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494",
|
||||
)
|
||||
|
||||
|
||||
def load_env_feishu():
|
||||
p = SCRIPT_DIR / ".env.feishu"
|
||||
if not p.is_file():
|
||||
return
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def tenant_token() -> str | None:
|
||||
load_env_feishu()
|
||||
app_id = os.environ.get("FEISHU_APP_ID", "")
|
||||
sec = os.environ.get("FEISHU_APP_SECRET", "")
|
||||
if not app_id or not sec:
|
||||
print("缺少 FEISHU_APP_ID / FEISHU_APP_SECRET(.env.feishu)", file=sys.stderr)
|
||||
return None
|
||||
r = requests.post(
|
||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
||||
json={"app_id": app_id, "app_secret": sec},
|
||||
timeout=15,
|
||||
)
|
||||
data = r.json() or {}
|
||||
if data.get("code") != 0:
|
||||
print("token 失败:", data, file=sys.stderr)
|
||||
return None
|
||||
return data.get("tenant_access_token")
|
||||
|
||||
|
||||
def send_text(webhook: str, text: str) -> bool:
|
||||
r = requests.post(webhook, json={"msg_type": "text", "content": {"text": text}}, timeout=15)
|
||||
d = r.json() or {}
|
||||
if d.get("code") != 0:
|
||||
print("文本发送失败:", d, file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def upload_png(token: str, path: Path) -> str | None:
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/images"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
with path.open("rb") as f:
|
||||
r = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
files={"image": (path.name, f, "image/png")},
|
||||
data={"image_type": "message"},
|
||||
timeout=60,
|
||||
)
|
||||
out = r.json() or {}
|
||||
if out.get("code") != 0:
|
||||
print("上传失败", path, out, file=sys.stderr)
|
||||
return None
|
||||
return (out.get("data") or {}).get("image_key")
|
||||
|
||||
|
||||
def send_image(webhook: str, image_key: str) -> bool:
|
||||
r = requests.post(
|
||||
webhook,
|
||||
json={"msg_type": "image", "content": {"image_key": image_key}},
|
||||
timeout=15,
|
||||
)
|
||||
d = r.json() or {}
|
||||
if d.get("code") != 0:
|
||||
print("图片消息失败:", d, file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("-t", "--text", default="", help="直接传入文本")
|
||||
ap.add_argument("--text-file", type=Path, help="从文件读文本(utf-8)")
|
||||
ap.add_argument("--webhook", "-w", default=DEFAULT_WEBHOOK)
|
||||
ap.add_argument("--images", "-i", nargs="*", default=[], help="PNG 路径列表")
|
||||
args = ap.parse_args()
|
||||
|
||||
body = args.text.strip()
|
||||
if args.text_file:
|
||||
body = args.text_file.read_text(encoding="utf-8").strip()
|
||||
if not body:
|
||||
ap.error("需要 -t 或 --text-file")
|
||||
|
||||
if not send_text(args.webhook, body[:20000]):
|
||||
sys.exit(1)
|
||||
print("已发文本")
|
||||
|
||||
if not args.images:
|
||||
return
|
||||
|
||||
tok = tenant_token()
|
||||
if not tok:
|
||||
sys.exit(1)
|
||||
for p in args.images:
|
||||
path = Path(p).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
print("跳过(不存在):", path, file=sys.stderr)
|
||||
continue
|
||||
key = upload_png(tok, path)
|
||||
if key and send_image(args.webhook, key):
|
||||
print("已发图:", path.name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,9 +4,8 @@
|
||||
|
||||
重要说明(微信官方限制):
|
||||
- 代码上传:可用本机「微信开发者工具」CLI 或 miniprogram-ci。
|
||||
- submit_audit:开放平台文档标明主要为「第三方平台代调用」;自有主体使用小程序 appid+secret
|
||||
换取的 access_token 调用时,常见返回 errcode=86000(仅允许第三方代调用),此时必须在
|
||||
mp 后台手动点「提交审核」。
|
||||
- submit_audit:主要为「第三方平台代调用」;自有主体用 appid+secret 常返回 errcode=86000,
|
||||
无法在仓库内替代网页提审;`release` 默认只跑上传+接口调用,不弹浏览器、不提示手动操作。
|
||||
- 「自动过审」不可能由开发者脚本保证:是否通过由微信审核决定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -149,6 +148,8 @@ def cmd_submit_audit(
|
||||
version_desc: str,
|
||||
item_json: Path | None,
|
||||
privacy_api_not_use: bool | None,
|
||||
*,
|
||||
quiet: bool = False,
|
||||
) -> dict:
|
||||
token = get_access_token(appid, secret)
|
||||
if item_json and item_json.is_file():
|
||||
@@ -184,18 +185,19 @@ def cmd_submit_audit(
|
||||
except urllib.error.HTTPError as e:
|
||||
raise SystemExit(f"submit_audit HTTP 错误: {e}") from e
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
if data.get("errcode") == 86000:
|
||||
print(
|
||||
"\n说明 errcode=86000:该接口仅支持「第三方平台」代小程序调用。"
|
||||
"自有主体请在浏览器打开公众平台 → 管理 → 版本管理 → 提交审核。\n"
|
||||
"可先运行: python3 scripts/wechat_miniprogram_release.py open-mp",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif data.get("errcode") == 61039:
|
||||
print(
|
||||
"\n说明 errcode=61039:上传后隐私/代码检测任务未完成,请等待数分钟后再提交审核。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not quiet:
|
||||
if data.get("errcode") == 86000:
|
||||
print(
|
||||
"\n说明 errcode=86000:该接口仅支持「第三方平台」代小程序调用。"
|
||||
"自有主体请在浏览器打开公众平台 → 管理 → 版本管理 → 提交审核。\n"
|
||||
"可先运行: python3 scripts/wechat_miniprogram_release.py open-version",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif data.get("errcode") == 61039:
|
||||
print(
|
||||
"\n说明 errcode=61039:上传后隐私/代码检测任务未完成,请等待数分钟后再提交审核。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -291,7 +293,10 @@ def main() -> None:
|
||||
)
|
||||
p_uo.add_argument("--desc", "-d", default="", help="默认:版本 v<版本号>")
|
||||
|
||||
p_rel = sub.add_parser("release", help="先 upload 再 submit-audit(提审失败仍可到后台操作)")
|
||||
p_rel = sub.add_parser(
|
||||
"release",
|
||||
help="上传 → 尝试 submit_audit(默认不弹浏览器、不提示手动打开;可加 --open-browser)",
|
||||
)
|
||||
p_rel.add_argument(
|
||||
"--version",
|
||||
"-v",
|
||||
@@ -305,6 +310,11 @@ def main() -> None:
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=None,
|
||||
)
|
||||
p_rel.add_argument(
|
||||
"--open-browser",
|
||||
action="store_true",
|
||||
help="完成后打开公众平台版本管理页(默认关闭)",
|
||||
)
|
||||
|
||||
args = p.parse_args()
|
||||
|
||||
@@ -336,18 +346,17 @@ def main() -> None:
|
||||
if args.cmd == "release":
|
||||
d = args.desc.strip() or f"版本 v{args.version}"
|
||||
cmd_upload(args.version, d)
|
||||
if not appid or not secret:
|
||||
print(
|
||||
"未设置 WECHAT_APPID / WECHAT_APPSECRET,跳过 submit-audit。",
|
||||
file=sys.stderr,
|
||||
if appid and secret:
|
||||
vd = (args.version_desc or "").strip() or d
|
||||
cmd_submit_audit(
|
||||
appid,
|
||||
secret,
|
||||
vd,
|
||||
args.item_json,
|
||||
args.privacy_api_not_use,
|
||||
quiet=True,
|
||||
)
|
||||
cmd_open_mp_version()
|
||||
return
|
||||
vd = (args.version_desc or "").strip() or d
|
||||
res = cmd_submit_audit(
|
||||
appid, secret, vd, args.item_json, args.privacy_api_not_use
|
||||
)
|
||||
if res.get("errcode") == 86000:
|
||||
if getattr(args, "open_browser", False):
|
||||
cmd_open_mp_version()
|
||||
return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user