同步数据
This commit is contained in:
47
scripts/README_Soul运营技能包.md
Normal file
47
scripts/README_Soul运营技能包.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Soul 运营全链路技能包(精简打包)
|
||||
|
||||
## 一键打包(推荐:体积小、可重装)
|
||||
|
||||
在 **本机终端** 执行:
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/scripts/pack_soul_operation_skills.py"
|
||||
```
|
||||
|
||||
- 输出:**`~/Downloads/Soul运营全链路技能包_精简_YYYYMMDD.zip`**(日期为**打包当天**)。
|
||||
- 临时目录:`一场soul的创业实验-永平/.tmp_skill_bundle/`,打完可删。
|
||||
|
||||
卡若AI不在默认路径时,编辑脚本内:
|
||||
|
||||
```python
|
||||
KARUO_AI = Path("/Users/karuo/Documents/个人/卡若AI")
|
||||
```
|
||||
|
||||
## 精简包策略(大文件不进包)
|
||||
|
||||
| 类型 | 处理 |
|
||||
|:---|:---|
|
||||
| 单文件 **> 512KB** | 跳过 |
|
||||
| 视频/音频/压缩包/模型权重等扩展名 | 跳过 |
|
||||
| `cookies/`、`node_modules`、`.browser_state`、`venv` 等 | 整目录跳过 |
|
||||
| `publish_log.json`、`.feishu_tokens.json` | 跳过(到新机按脚本重新授权;若要迁移凭证请**单独**安全拷贝) |
|
||||
|
||||
包内另有 **`重装依赖说明.md`**、**`_pack_stats.json`**(本次打入/跳过统计)。
|
||||
|
||||
## 压缩包里有什么(链路齐全 = 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`。
|
||||
|
||||
## 安全
|
||||
|
||||
勿将含密钥的压缩包上传公开网盘;用 U 盘或加密渠道传输。
|
||||
114
scripts/content_download.py
Normal file
114
scripts/content_download.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从小程序/正式 API 下载单章正文,保存为书稿目录下的 md 文件。
|
||||
|
||||
用法:
|
||||
SOUL_TEST_ENV=soulapi python3 scripts/content_download.py 128
|
||||
SOUL_TEST_ENV=soulapi python3 scripts/content_download.py --id 10.27
|
||||
python3 scripts/content_download.py 128 --out-dir /path/to/2026每日派对干货
|
||||
|
||||
2026 场次(第102场起)对应 id 10.01、10.02、…、10.27(第128场)…
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "test"))
|
||||
try:
|
||||
from config import API_BASE, ENV_LABEL
|
||||
except Exception:
|
||||
API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com").rstrip("/")
|
||||
ENV_LABEL = "env"
|
||||
|
||||
# 书稿 2026 目录默认路径(与上传 README 一致)
|
||||
DEFAULT_BOOK_2026 = Path(
|
||||
os.environ.get(
|
||||
"SOUL_BOOK_2026",
|
||||
"/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/2026每日派对干货",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def field_to_id(field: int) -> str:
|
||||
"""第 N 场(≥102)→ 10.xx;第101场及以前在第九章为 9.xx。API 上第128场可能为 9.28。"""
|
||||
if field >= 102:
|
||||
n = field - 102 + 1
|
||||
return f"10.{n:02d}"
|
||||
if field >= 1:
|
||||
return f"9.{field:02d}" # 第9章 9.01~9.99,按场次
|
||||
raise ValueError("场次请用 1~999")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="从小程序 API 下载单章为 md")
|
||||
parser.add_argument("field", nargs="?", type=int, help="场次号,如 128 表示第128场")
|
||||
parser.add_argument("--id", type=str, help="章节 id,如 10.27(与 field 二选一)")
|
||||
parser.add_argument("--out-dir", type=Path, default=DEFAULT_BOOK_2026, help="输出目录,默认 2026每日派对干货")
|
||||
parser.add_argument("--base", type=str, default=API_BASE, help="API 根地址")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.id:
|
||||
chapter_id = args.id
|
||||
elif args.field is not None:
|
||||
chapter_id = field_to_id(args.field)
|
||||
else:
|
||||
parser.error("请指定 field(如 128)或 --id(如 10.27)")
|
||||
|
||||
base = args.base.rstrip("/")
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("请安装: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
url = f"{base}/api/miniprogram/book/chapter/by-id/{chapter_id}"
|
||||
print(f"环境: {ENV_LABEL} | GET {url}")
|
||||
r = requests.get(url, timeout=30)
|
||||
# 2026 场次可能仍挂在第9章(9.28=第128场),404 时用 9.xx 再试
|
||||
if r.status_code == 404 and args.field is not None and not args.id and args.field >= 1 and args.field <= 101:
|
||||
fallback_id = f"9.{args.field:02d}"
|
||||
url = f"{base}/api/miniprogram/book/chapter/by-id/{fallback_id}"
|
||||
print(f"404,尝试第9章 id: {fallback_id} | GET {url}")
|
||||
r = requests.get(url, timeout=30)
|
||||
if r.status_code == 200:
|
||||
chapter_id = fallback_id
|
||||
if r.status_code == 404 and args.field is not None and not args.id and args.field >= 102:
|
||||
fallback_id = f"9.{(args.field - 100):02d}" # 128 → 9.28
|
||||
url = f"{base}/api/miniprogram/book/chapter/by-id/{fallback_id}"
|
||||
print(f"404,尝试第9章 id: {fallback_id} | GET {url}")
|
||||
r = requests.get(url, timeout=30)
|
||||
if r.status_code == 200:
|
||||
chapter_id = fallback_id
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data.get("success"):
|
||||
print("API 返回失败:", data.get("error", data), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
content = data.get("content") or (data.get("data") or {}).get("content") or ""
|
||||
section_title = data.get("sectionTitle") or (data.get("data") or {}).get("sectionTitle") or f"第{args.field or '?'}场"
|
||||
if not content:
|
||||
print("未获取到正文 content", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
# 若标题里没有「第X场」,用场次补上(sectionTitle 可能是「赚最多那个月…」)
|
||||
if args.field and "第" not in section_title and "场" not in section_title:
|
||||
display_title = f"第{args.field}场|{section_title}"
|
||||
else:
|
||||
display_title = section_title
|
||||
|
||||
out_dir = args.out_dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_file = out_dir / f"{display_title}.md"
|
||||
body = f"# {display_title}\n\n{content.strip()}\n"
|
||||
out_file.write_text(body, encoding="utf-8")
|
||||
print(f"已写入: {out_file}")
|
||||
print(f"字数: {len(content)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
scripts/miniprogram_audit_item.example.json
Normal file
13
scripts/miniprogram_audit_item.example.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"item_list": [
|
||||
{
|
||||
"address": "pages/index/index",
|
||||
"tag": "阅读 创业",
|
||||
"first_class": "一级类目名称",
|
||||
"second_class": "二级类目名称",
|
||||
"first_id": 0,
|
||||
"second_id": 0,
|
||||
"title": "首页"
|
||||
}
|
||||
]
|
||||
}
|
||||
39
scripts/miniprogram_upload.sh
Normal file
39
scripts/miniprogram_upload.sh
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Soul 小程序:通过微信开发者工具 CLI 上传代码包(需本机已登录开发者工具)。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MINIPROGRAM_DIR="${MINIPROGRAM_DIR:-$ROOT/miniprogram}"
|
||||
CLI="${WECHAT_DEVTOOLS_CLI:-/Applications/wechatwebdevtools.app/Contents/MacOS/cli}"
|
||||
LANG_OPT="${WECHAT_CLI_LANG:-zh}"
|
||||
|
||||
if [[ ! -x "$CLI" ]]; then
|
||||
echo "未找到微信开发者工具 CLI: $CLI" >&2
|
||||
echo "可设置 WECHAT_DEVTOOLS_CLI 指向 cli 可执行文件。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 未传参时默认 1.7.2(避免手滑打成 1.17 等与展示不一致)
|
||||
DEFAULT_VER="${MINIPROGRAM_DEFAULT_VERSION:-1.7.2}"
|
||||
VERSION="${1:-$DEFAULT_VER}"
|
||||
DESC="${2:-版本 v$VERSION}"
|
||||
|
||||
CLI_EXTRA=()
|
||||
if [[ -n "${WECHAT_CLI_PORT:-}" ]]; then
|
||||
CLI_EXTRA+=(--port "$WECHAT_CLI_PORT")
|
||||
fi
|
||||
|
||||
if ((${#CLI_EXTRA[@]} > 0)); then
|
||||
exec "$CLI" upload \
|
||||
--project "$MINIPROGRAM_DIR" \
|
||||
--version "$VERSION" \
|
||||
--desc "$DESC" \
|
||||
--lang "$LANG_OPT" \
|
||||
"${CLI_EXTRA[@]}"
|
||||
else
|
||||
exec "$CLI" upload \
|
||||
--project "$MINIPROGRAM_DIR" \
|
||||
--version "$VERSION" \
|
||||
--desc "$DESC" \
|
||||
--lang "$LANG_OPT"
|
||||
fi
|
||||
345
scripts/pack_soul_operation_skills.py
Normal file
345
scripts/pack_soul_operation_skills.py
Normal file
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
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
|
||||
|
||||
# 单文件超过此大小则跳过(字节)——非「代码/文档类」扩展名
|
||||
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
|
||||
|
||||
# 统计
|
||||
_stats: dict[str, int] = {"files": 0, "skipped_size": 0, "skipped_ext": 0, "skipped_dir": 0, "skipped_name": 0}
|
||||
|
||||
|
||||
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
|
||||
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:
|
||||
if not KARUO_AI.is_dir():
|
||||
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 入口(通常只有 SKILL.md)
|
||||
for name in ("soul-operation-report", "soul-party-project"):
|
||||
copy_cursor_skill(name)
|
||||
|
||||
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_卡人(水)" / "水岸_项目管理",
|
||||
Path("02_卡人(水)") / "水岸_项目管理",
|
||||
)
|
||||
bridge = KARUO_AI / "02_卡人(水)" / "水桥_平台对接"
|
||||
for sub in ("飞书管理", "智能纪要", "Soul创业实验"):
|
||||
pack_sub(bridge / sub, Path("02_卡人(水)") / "水桥_平台对接" / sub)
|
||||
|
||||
wood = KARUO_AI / "03_卡木(木)" / "木叶_视频内容"
|
||||
for sub in (
|
||||
"视频切片",
|
||||
"多平台分发",
|
||||
"抖音发布",
|
||||
"B站发布",
|
||||
"视频号发布",
|
||||
"小红书发布",
|
||||
"快手发布",
|
||||
):
|
||||
pack_sub(wood / sub, Path("03_卡木(木)") / "木叶_视频内容" / sub)
|
||||
|
||||
idx = KARUO_AI / "运营中枢" / "工作台" / "00_账号与API索引.md"
|
||||
if idx.is_file():
|
||||
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
|
||||
|
||||
# 写入 requirements 汇总(若各目录有 requirements.txt,只列路径提示,不合并)
|
||||
req_hint = STAGING / "重装依赖说明.md"
|
||||
req_hint.write_text(
|
||||
f"""# 重装依赖说明(精简包)
|
||||
|
||||
本包**不含**大文件与本地状态,到新电脑请:
|
||||
|
||||
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`,请在新机用脚本流程重新授权;若你刻意要迁移凭证请单独拷贝(注意安全)。
|
||||
|
||||
---
|
||||
|
||||
打包策略摘要(自动生成):
|
||||
|
||||
- 代码/文档类(`.py`、`.md` 等)单文件大于 **{MAX_CODE_DOC_BYTES // (1024 * 1024)} MB** 跳过;其它类型大于 **{MAX_FILE_BYTES // 1024} KB** 跳过
|
||||
- 跳过扩展名:媒体、压缩包、模型权重等
|
||||
- 跳过目录:`cookies`、`node_modules`、`.browser_state`、`venv` 等
|
||||
|
||||
打包日期:**{STAMP}**
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in STAGING.rglob("*"):
|
||||
if f.is_file():
|
||||
arcname = Path(BUNDLE_TOP) / f.relative_to(STAGING)
|
||||
zf.write(f, arcname.as_posix())
|
||||
|
||||
mb = zip_path.stat().st_size / (1024 * 1024)
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
153
scripts/pull_from_baota.py
Normal file
153
scripts/pull_from_baota.py
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从宝塔正式机拉取线上运行目录到本地镜像(与 soul-api/master.py、soul-admin/master.py 同源 SSH 配置)。
|
||||
|
||||
说明:
|
||||
- 服务器上一般是「二进制 + .env + 日志」与「静态 dist」,不包含完整 Go/React 源码。
|
||||
- 默认解压到仓库根目录 _server_live/soul-api、_server_live/soul-admin,不覆盖本地工程源码。
|
||||
|
||||
环境变量与 master.py 一致:DEPLOY_HOST、DEPLOY_USER、DEPLOY_PASSWORD、DEPLOY_SSH_KEY、
|
||||
DEPLOY_PROJECT_PATH、DEPLOY_BASE_PATH。
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
SOUL_API_DIR = os.path.join(ROOT, "soul-api")
|
||||
|
||||
|
||||
def _load_api_master():
|
||||
path = os.path.join(SOUL_API_DIR, "master.py")
|
||||
spec = importlib.util.spec_from_file_location("soul_api_deploy_master", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _pull_dir_tar(client, remote_dir, local_dir, mod, timeout=600):
|
||||
"""远端 tar czf 流式下载并解压到 local_dir。"""
|
||||
import shlex
|
||||
|
||||
remote_q = shlex.quote(remote_dir)
|
||||
cmd = "tar czf - -C %s . 2>/dev/null" % remote_q
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
|
||||
|
||||
err_holder = []
|
||||
|
||||
def _drain():
|
||||
try:
|
||||
err_holder.append(stderr.read())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=_drain)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz")
|
||||
os.close(fd)
|
||||
try:
|
||||
with open(tmp_path, "wb") as out:
|
||||
while True:
|
||||
chunk = stdout.read(256 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
t.join(timeout=5)
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if exit_status != 0:
|
||||
print(" [警告] 远端 tar 退出码: %s" % exit_status)
|
||||
if os.path.isdir(local_dir):
|
||||
shutil.rmtree(local_dir)
|
||||
os.makedirs(local_dir, exist_ok=True)
|
||||
with tarfile.open(tmp_path, "r:gz") as tf:
|
||||
tf.extractall(local_dir)
|
||||
print(" [成功] 已同步到: %s" % local_dir)
|
||||
return True
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="从宝塔拉取 soul-api / soul-admin 线上目录")
|
||||
parser.add_argument("--api-only", action="store_true", help="仅拉 soul-api")
|
||||
parser.add_argument("--admin-only", action="store_true", help="仅拉 soul-admin")
|
||||
args = parser.parse_args()
|
||||
|
||||
mod = _load_api_master()
|
||||
cfg = mod.get_cfg()
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print("[失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return 1
|
||||
|
||||
pull_api = not args.admin_only
|
||||
pull_admin = not args.api_only
|
||||
if args.api_only and args.admin_only:
|
||||
print("[失败] 不能同时指定 --api-only 与 --admin-only")
|
||||
return 1
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = mod._connect_ssh(cfg)
|
||||
live_root = os.path.join(ROOT, "_server_live")
|
||||
os.makedirs(live_root, exist_ok=True)
|
||||
|
||||
print("=" * 60)
|
||||
print(" 从宝塔拉取线上目录 → %s" % live_root)
|
||||
print(" 主机: %s@%s:%s" % (cfg["user"], cfg["host"], mod.DEFAULT_SSH_PORT))
|
||||
print("=" * 60)
|
||||
|
||||
if pull_api:
|
||||
print("[1] soul-api: %s" % cfg["project_path"])
|
||||
_pull_dir_tar(
|
||||
client,
|
||||
cfg["project_path"],
|
||||
os.path.join(live_root, "soul-api"),
|
||||
mod,
|
||||
)
|
||||
|
||||
if pull_admin:
|
||||
admin_base = os.environ.get("DEPLOY_BASE_PATH", "/www/wwwroot/self/soul-admin").rstrip("/")
|
||||
print("[2] soul-admin: %s" % admin_base)
|
||||
# 复用同一连接;若仅拉 admin,上面未开新连接也行
|
||||
if not pull_api:
|
||||
pass
|
||||
_pull_dir_tar(
|
||||
client,
|
||||
admin_base,
|
||||
os.path.join(live_root, "soul-admin"),
|
||||
mod,
|
||||
)
|
||||
|
||||
print("")
|
||||
print(" 完成。镜像根目录: %s" % live_root)
|
||||
return 0
|
||||
except Exception as e:
|
||||
print("[失败] %s" % e)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
finally:
|
||||
if client:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 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()
|
||||
190
scripts/sync_chapter_images_from_md.py
Normal file
190
scripts/sync_chapter_images_from_md.py
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从书稿 Markdown 中仅解析「图片」引用并上传到现网,再生成 HTML 写入 chapters.content。
|
||||
|
||||
- 只处理:`` 等常见图片后缀;**不解析、不上传视频/附件**。
|
||||
- 已是 `http(s)://` 的地址:不重复上传,原样写入 `<img src="...">`。
|
||||
- 非图片后缀的 `![]()`:当作普通正文一行输出(不尝试上传)。
|
||||
|
||||
用法:
|
||||
cd 一场soul的创业实验-永平
|
||||
python3 scripts/sync_chapter_images_from_md.py --id 10.22 \\
|
||||
--md "/path/to/第130场|….md"
|
||||
|
||||
依赖: pip install pymysql requests
|
||||
环境变量: SOUL_API_BASE 默认 https://soulapi.quwanzhi.com
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
|
||||
|
||||
# 整行仅有一张图:
|
||||
LINE_IMAGE_ONLY = re.compile(r"^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$")
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
import requests
|
||||
except ImportError as e:
|
||||
print("需要: pip install pymysql requests", e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def load_db_config() -> dict:
|
||||
mig = ROOT / "scripts" / "migrate_2026_sections.py"
|
||||
spec = importlib.util.spec_from_file_location("_mig_db", mig)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(mod)
|
||||
cfg = getattr(mod, "DB_CONFIG", None)
|
||||
if not isinstance(cfg, dict):
|
||||
sys.exit("migrate_2026_sections.py 中无有效 DB_CONFIG")
|
||||
return cfg
|
||||
|
||||
|
||||
def resolve_local_path(md_path: Path, ref: str) -> Path | None:
|
||||
ref = ref.strip()
|
||||
if not ref or ref.startswith(("http://", "https://")):
|
||||
return None
|
||||
p = (md_path.parent / ref).expanduser().resolve()
|
||||
if p.is_file():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def guess_mime(path: Path) -> str:
|
||||
ext = path.suffix.lower()
|
||||
return {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
def upload_image(local: Path, api_base: str) -> str:
|
||||
url = f"{api_base.rstrip('/')}/api/upload"
|
||||
mime = guess_mime(local)
|
||||
with local.open("rb") as f:
|
||||
files = {"file": (local.name, f, mime)}
|
||||
data = {"folder": "book-images"}
|
||||
r = requests.post(url, files=files, data=data, timeout=120)
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
if not j.get("success"):
|
||||
raise RuntimeError(j.get("error") or j.get("message") or str(j))
|
||||
out = j.get("url") or (j.get("data") or {}).get("url")
|
||||
if not out:
|
||||
raise RuntimeError("响应无 url: " + str(j)[:500])
|
||||
return str(out)
|
||||
|
||||
|
||||
def md_to_html(md_path: Path, api_base: str) -> str:
|
||||
raw = md_path.read_text(encoding="utf-8")
|
||||
lines = raw.splitlines()
|
||||
if lines and lines[0].lstrip().startswith("#"):
|
||||
lines = lines[1:]
|
||||
|
||||
chunks: list[str] = []
|
||||
upload_cache: dict[str, str] = {}
|
||||
|
||||
for line in lines:
|
||||
if line.strip() == "---":
|
||||
chunks.append("")
|
||||
continue
|
||||
|
||||
m = LINE_IMAGE_ONLY.match(line)
|
||||
if m:
|
||||
alt, ref = m.group(1), m.group(2).strip()
|
||||
if ref.startswith(("http://", "https://")):
|
||||
chunks.append(
|
||||
f'<p><img src="{html.escape(ref)}" alt="{html.escape(alt)}"/></p>'
|
||||
)
|
||||
continue
|
||||
loc = resolve_local_path(md_path, ref)
|
||||
if not loc:
|
||||
chunks.append(f"<p>(图片路径无效:{html.escape(ref)})</p>")
|
||||
continue
|
||||
ext = loc.suffix.lower()
|
||||
if ext not in IMAGE_EXT:
|
||||
# 非图片(如视频):不解析、不上传,整行当正文
|
||||
chunks.append(f"<p>{html.escape(line.strip())}</p>")
|
||||
continue
|
||||
key = str(loc)
|
||||
if key not in upload_cache:
|
||||
print(f"上传图片: {loc.name} …", flush=True)
|
||||
upload_cache[key] = upload_image(loc, api_base)
|
||||
src = upload_cache[key]
|
||||
chunks.append(
|
||||
f'<p><img src="{html.escape(src)}" alt="{html.escape(alt)}"/></p>'
|
||||
)
|
||||
continue
|
||||
|
||||
if line.strip():
|
||||
chunks.append(f"<p>{html.escape(line.strip())}</p>")
|
||||
else:
|
||||
chunks.append("")
|
||||
|
||||
# 合并连续空串为单个换行,避免多余空 <p>
|
||||
html_parts: list[str] = []
|
||||
for c in chunks:
|
||||
if c == "":
|
||||
if html_parts and html_parts[-1] != "":
|
||||
html_parts.append("")
|
||||
else:
|
||||
html_parts.append(c)
|
||||
return "\n".join(html_parts).strip() + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="MD 内图片上传并写回 chapters(仅图片)")
|
||||
p.add_argument("--id", required=True, help="章节 id,如 10.22")
|
||||
p.add_argument("--md", type=Path, required=True, help="文章 .md 路径")
|
||||
p.add_argument(
|
||||
"--api-base",
|
||||
default=os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com"),
|
||||
help="API 根地址",
|
||||
)
|
||||
p.add_argument("--dry-run", action="store_true", help="只打印 HTML 前 800 字,不写库")
|
||||
args = p.parse_args()
|
||||
|
||||
md_path = args.md.expanduser().resolve()
|
||||
if not md_path.is_file():
|
||||
sys.exit(f"文件不存在: {md_path}")
|
||||
|
||||
body = md_to_html(md_path, args.api_base)
|
||||
word_count = len(body)
|
||||
|
||||
if args.dry_run:
|
||||
print(body[:800])
|
||||
print("… dry-run,word_count=", word_count)
|
||||
return
|
||||
|
||||
cfg = load_db_config()
|
||||
conn = pymysql.connect(**cfg)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE chapters SET content = %s, word_count = %s, updated_at = NOW() WHERE id = %s",
|
||||
(body, word_count, args.id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
conn.rollback()
|
||||
sys.exit(f"更新失败:id={args.id} rowcount={cur.rowcount}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"已更新 {args.id} | word_count={word_count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,6 +14,18 @@
|
||||
|
||||
---
|
||||
|
||||
## 响应速度测试
|
||||
|
||||
`test_article_preview_speed.py`:文章阅读与界面预览 GET 接口响应速度测试。
|
||||
|
||||
```bash
|
||||
SOUL_TEST_ENV=soulapi python scripts/test/miniapp/test_article_preview_speed.py
|
||||
```
|
||||
|
||||
产出:控制台报表 + `开发文档/测试报告-文章阅读与界面预览响应速度-YYYYMMDD.md`
|
||||
|
||||
---
|
||||
|
||||
## 用例编写
|
||||
|
||||
在此目录下新增 `.md` 或测试脚本,按场景组织用例。
|
||||
|
||||
234
scripts/test/miniapp/test_article_preview_speed.py
Normal file
234
scripts/test/miniapp/test_article_preview_speed.py
Normal file
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
文章阅读与界面预览 GET 接口响应速度测试
|
||||
|
||||
测试范围:
|
||||
- 界面预览:config、book/parts、book/all-chapters、book/chapters-by-part
|
||||
- 文章阅读:book/chapter/:id、book/chapter/by-mid/:mid
|
||||
|
||||
用法:
|
||||
SOUL_TEST_ENV=soulapi python scripts/test/miniapp/test_article_preview_speed.py
|
||||
SOUL_TEST_ENV=soulapi python -m scripts.test.miniapp.test_article_preview_speed
|
||||
|
||||
产出:控制台报表 + 开发文档/测试报告-文章阅读与界面预览响应速度-YYYYMMDD.md
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# 加载测试配置
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from config import API_BASE, ENV_LABEL, get_env_banner
|
||||
|
||||
# 每接口请求次数(取平均)
|
||||
ROUNDS = 5
|
||||
TIMEOUT = 30
|
||||
|
||||
|
||||
def measure_get(url: str, desc: str) -> dict:
|
||||
"""对 GET 请求测速,返回 {ok, status_code, times_ms, avg_ms, min_ms, max_ms, error}"""
|
||||
times_ms = []
|
||||
last_error = None
|
||||
last_status = None
|
||||
for _ in range(ROUNDS):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
r = requests.get(url, timeout=TIMEOUT)
|
||||
last_status = r.status_code
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
times_ms.append(elapsed)
|
||||
if r.status_code != 200:
|
||||
last_error = f"HTTP {r.status_code}"
|
||||
except requests.RequestException as e:
|
||||
last_error = str(e)
|
||||
times_ms.append(-1)
|
||||
if not times_ms:
|
||||
return {"ok": False, "error": last_error or "无响应", "status_code": last_status}
|
||||
valid = [t for t in times_ms if t >= 0]
|
||||
return {
|
||||
"ok": len(valid) == ROUNDS and (last_status or 200) == 200,
|
||||
"status_code": last_status,
|
||||
"times_ms": times_ms,
|
||||
"avg_ms": sum(valid) / len(valid) if valid else 0,
|
||||
"min_ms": min(valid) if valid else 0,
|
||||
"max_ms": max(valid) if valid else 0,
|
||||
"error": last_error,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print(get_env_banner())
|
||||
base = API_BASE.rstrip("/")
|
||||
|
||||
# 1. 先拉取 parts 和 all-chapters,获取 partId、id、mid
|
||||
parts_url = f"{base}/api/miniprogram/book/parts"
|
||||
all_chapters_url = f"{base}/api/miniprogram/book/all-chapters"
|
||||
|
||||
parts_data = None
|
||||
all_chapters_data = None
|
||||
try:
|
||||
r = requests.get(parts_url, timeout=TIMEOUT)
|
||||
if r.status_code == 200:
|
||||
parts_data = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
r = requests.get(all_chapters_url, timeout=TIMEOUT)
|
||||
if r.status_code == 200:
|
||||
all_chapters_data = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
part_id = None
|
||||
chapter_id = None
|
||||
chapter_mid = None
|
||||
if parts_data and parts_data.get("success"):
|
||||
parts = parts_data.get("parts") or []
|
||||
fixed = parts_data.get("fixedSections") or []
|
||||
if parts:
|
||||
part_id = parts[0].get("id")
|
||||
if fixed:
|
||||
chapter_mid = fixed[0].get("mid")
|
||||
chapter_id = fixed[0].get("id")
|
||||
if (not chapter_id or not chapter_mid) and all_chapters_data and all_chapters_data.get("success"):
|
||||
arr = all_chapters_data.get("data") or all_chapters_data.get("chapters") or []
|
||||
if arr:
|
||||
first = arr[0] if isinstance(arr[0], dict) else {}
|
||||
chapter_id = chapter_id or first.get("id")
|
||||
chapter_mid = chapter_mid or first.get("mid")
|
||||
if not part_id and parts_data and parts_data.get("success"):
|
||||
parts = parts_data.get("parts") or []
|
||||
if parts:
|
||||
part_id = parts[0].get("id")
|
||||
|
||||
# 2. 定义测试用例(仅 GET)
|
||||
cases = [
|
||||
("界面预览-配置", f"{base}/api/miniprogram/config", "GET /api/miniprogram/config"),
|
||||
("界面预览-目录", f"{base}/api/miniprogram/book/parts", "GET /api/miniprogram/book/parts"),
|
||||
("界面预览-全书章节", f"{base}/api/miniprogram/book/all-chapters", "GET /api/miniprogram/book/all-chapters"),
|
||||
]
|
||||
if part_id:
|
||||
cases.append(
|
||||
(
|
||||
"界面预览-篇章内章节",
|
||||
f"{base}/api/miniprogram/book/chapters-by-part?partId={part_id}",
|
||||
f"GET /api/miniprogram/book/chapters-by-part?partId={part_id}",
|
||||
)
|
||||
)
|
||||
if chapter_id:
|
||||
cases.append(
|
||||
(
|
||||
"文章阅读-按id",
|
||||
f"{base}/api/miniprogram/book/chapter/{chapter_id}",
|
||||
f"GET /api/miniprogram/book/chapter/:id",
|
||||
)
|
||||
)
|
||||
if chapter_mid:
|
||||
cases.append(
|
||||
(
|
||||
"文章阅读-按mid",
|
||||
f"{base}/api/miniprogram/book/chapter/by-mid/{chapter_mid}",
|
||||
f"GET /api/miniprogram/book/chapter/by-mid/:mid",
|
||||
)
|
||||
)
|
||||
|
||||
# 3. 执行测速
|
||||
results = []
|
||||
for name, url, api_desc in cases:
|
||||
print(f"\n测速: {name} ({api_desc})")
|
||||
res = measure_get(url, name)
|
||||
res["name"] = name
|
||||
res["api"] = api_desc
|
||||
res["url"] = url
|
||||
results.append(res)
|
||||
if res["ok"]:
|
||||
print(f" [OK] avg={res['avg_ms']:.0f}ms (min={res['min_ms']:.0f}, max={res['max_ms']:.0f})")
|
||||
else:
|
||||
print(f" [FAIL] {res.get('error', res.get('status_code', '?'))}")
|
||||
|
||||
# 4. 生成报表
|
||||
from datetime import datetime
|
||||
|
||||
date_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
date_file = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
lines = [
|
||||
"# 文章阅读与界面预览 GET 接口响应速度测试报告",
|
||||
"",
|
||||
f"**测试时间**: {date_str}",
|
||||
f"**测试环境**: {ENV_LABEL} ({API_BASE})",
|
||||
f"**每接口请求次数**: {ROUNDS}",
|
||||
"",
|
||||
"## 一、测试范围",
|
||||
"",
|
||||
"| 分类 | 接口 | 说明 |",
|
||||
"|------|------|------|",
|
||||
"| 界面预览 | GET /api/miniprogram/config | 配置(价格、功能开关等) |",
|
||||
"| 界面预览 | GET /api/miniprogram/book/parts | 目录-篇章列表 |",
|
||||
"| 界面预览 | GET /api/miniprogram/book/all-chapters | 全书章节列表 |",
|
||||
"| 界面预览 | GET /api/miniprogram/book/chapters-by-part | 篇章内章节列表 |",
|
||||
"| 文章阅读 | GET /api/miniprogram/book/chapter/:id | 按业务 id 获取章节内容 |",
|
||||
"| 文章阅读 | GET /api/miniprogram/book/chapter/by-mid/:mid | 按 mid 获取章节内容 |",
|
||||
"",
|
||||
"## 二、响应速度结果",
|
||||
"",
|
||||
"| 接口 | 状态 | 平均(ms) | 最小(ms) | 最大(ms) |",
|
||||
"|------|------|----------|----------|----------|",
|
||||
]
|
||||
|
||||
for r in results:
|
||||
status = "OK" if r["ok"] else "FAIL"
|
||||
avg = f"{r['avg_ms']:.0f}" if r["ok"] else "-"
|
||||
min_ms = f"{r['min_ms']:.0f}" if r["ok"] else "-"
|
||||
max_ms = f"{r['max_ms']:.0f}" if r["ok"] else "-"
|
||||
if not r["ok"]:
|
||||
err = r.get("error", "") or f"HTTP {r.get('status_code', '?')}"
|
||||
avg = err[:20] if err else "-"
|
||||
lines.append(f"| {r['api']} | {status} | {avg} | {min_ms} | {max_ms} |")
|
||||
|
||||
# 汇总
|
||||
ok_count = sum(1 for r in results if r["ok"])
|
||||
total_count = len(results)
|
||||
if ok_count == total_count:
|
||||
avg_all = sum(r["avg_ms"] for r in results) / total_count
|
||||
lines.extend([
|
||||
"",
|
||||
"## 三、汇总",
|
||||
"",
|
||||
f"- 通过: {ok_count}/{total_count}",
|
||||
f"- 全部接口平均响应: {avg_all:.0f}ms",
|
||||
"",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
"",
|
||||
"## 三、汇总",
|
||||
"",
|
||||
f"- 通过: {ok_count}/{total_count}",
|
||||
f"- 失败: {total_count - ok_count} 个接口",
|
||||
"",
|
||||
])
|
||||
|
||||
report_content = "\n".join(lines)
|
||||
|
||||
# 5. 输出到控制台
|
||||
print("\n" + "=" * 60)
|
||||
print(report_content)
|
||||
print("=" * 60)
|
||||
|
||||
# 6. 写入文件(项目根/开发文档)
|
||||
report_dir = Path(__file__).resolve().parent.parent.parent.parent / "开发文档"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path = report_dir / f"测试报告-文章阅读与界面预览响应速度-{date_file}.md"
|
||||
report_path.write_text(report_content, encoding="utf-8")
|
||||
print(f"\n报表已保存: {report_path}")
|
||||
|
||||
return 0 if ok_count == total_count else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
168
scripts/test/web/admin_routes_smoke.py
Normal file
168
scripts/test/web/admin_routes_smoke.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
ROUTER_GO = PROJECT_ROOT / "soul-api" / "internal" / "router" / "router.go"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Route:
|
||||
group: str # "admin" | "db" | "root"
|
||||
method: str
|
||||
path: str # path within the group, e.g. "/chapters" or "/admin"
|
||||
full_path: str # full path appended to API_BASE_URL
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def extract_admin_and_db_routes() -> list[tuple[str, str]]:
|
||||
"""
|
||||
返回 [(method, full_path_template), ...]
|
||||
full_path_template 已包含 /api/admin 或 /api/db 前缀,保留 :id 占位符。
|
||||
"""
|
||||
text = _read_text(ROUTER_GO)
|
||||
|
||||
routes: list[tuple[str, str]] = []
|
||||
|
||||
# 1) /api/admin 登录/鉴权/登出(不是 admin group 内)
|
||||
# api.GET("/admin", ...) / api.POST("/admin", ...) / api.POST("/admin/logout", ...)
|
||||
for m in re.finditer(r'api\.(GET|POST|PUT|DELETE)\("(/admin(?:/[^"]*)?)",\s*handler\.[A-Za-z0-9_]+', text):
|
||||
routes.append((m.group(1), f"/api{m.group(2)}"))
|
||||
|
||||
# 2) admin group:api.Group("/admin") + admin.(GET|POST|PUT|DELETE)("/xxx", ...)
|
||||
for m in re.finditer(r'admin\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
|
||||
routes.append((m.group(1), f"/api/admin{m.group(2)}"))
|
||||
|
||||
# 3) db group:api.Group("/db") + db.(GET|POST|PUT|DELETE)("/xxx", ...)
|
||||
for m in re.finditer(r'db\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
|
||||
routes.append((m.group(1), f"/api/db{m.group(2)}"))
|
||||
|
||||
# 去重(同一 handler 可能存在重复注册)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
out: list[tuple[str, str]] = []
|
||||
for method, p in routes:
|
||||
k = (method, p)
|
||||
if k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
out.append((method, p))
|
||||
return out
|
||||
|
||||
|
||||
def replace_path_params(path: str) -> str:
|
||||
# 仅用于 smoke:把 :id 替换成一个固定占位
|
||||
return path.replace(":id", "1")
|
||||
|
||||
|
||||
def request_json(
|
||||
session: requests.Session,
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
payload: Any | None = None,
|
||||
raw_body: str | None = None,
|
||||
) -> tuple[int, dict[str, Any] | None, str]:
|
||||
try:
|
||||
if raw_body is not None:
|
||||
resp = session.request(method, url, headers=headers, data=raw_body, timeout=10)
|
||||
elif payload is None:
|
||||
resp = session.request(method, url, headers=headers, timeout=10)
|
||||
else:
|
||||
resp = session.request(method, url, headers=headers, data=json.dumps(payload), timeout=10)
|
||||
text = resp.text or ""
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = None
|
||||
return resp.status_code, data, text[:300]
|
||||
except Exception as e:
|
||||
return 0, None, f"EXC: {e}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
api_base = None
|
||||
# 优先使用本地默认;需要对接测试环境时在 PowerShell 设置 SOUL_API_BASE
|
||||
import os
|
||||
|
||||
api_base = (os.environ.get("SOUL_API_BASE") or "").rstrip("/")
|
||||
if not api_base:
|
||||
# 默认本机
|
||||
api_base = "http://localhost:8080"
|
||||
|
||||
admin_username = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
|
||||
admin_password = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
# 本 smoke 默认不验证 TLS(如果你用的是 https 且是自签证书,能跑通测试)
|
||||
session.verify = False
|
||||
|
||||
# 登录拿 token
|
||||
login_url = f"{api_base}/api/admin"
|
||||
r = session.post(login_url, json={"username": admin_username, "password": admin_password}, timeout=10)
|
||||
try:
|
||||
login_data = r.json()
|
||||
except Exception:
|
||||
login_data = None
|
||||
if r.status_code != 200 or not (login_data and login_data.get("success") is True and login_data.get("token")):
|
||||
print("LOGIN_FAILED", r.status_code, r.text[:200])
|
||||
return
|
||||
|
||||
token = login_data["token"]
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
routes = extract_admin_and_db_routes()
|
||||
print(f"Found routes: {len(routes)}")
|
||||
|
||||
failures: list[dict[str, Any]] = []
|
||||
unexpected_success: list[dict[str, Any]] = []
|
||||
|
||||
for method, path_template in routes:
|
||||
path = replace_path_params(path_template)
|
||||
url = f"{api_base}{path}"
|
||||
|
||||
payload = None
|
||||
raw_body = None
|
||||
if method in ("POST", "PUT", "DELETE"):
|
||||
# 安全模式:发送明显非法 JSON,尽量触发 ShouldBindJSON 失败,避免真实写入。
|
||||
payload = None
|
||||
raw_body = "{invalid_json"
|
||||
|
||||
status, data, preview = request_json(
|
||||
session, method, url, headers, payload=payload, raw_body=raw_body
|
||||
)
|
||||
|
||||
ok = status not in (404, 500) and status != 0
|
||||
# POST/PUT/DELETE 在安全模式下不应返回 success=true
|
||||
if method in ("POST", "PUT", "DELETE") and data and data.get("success") is True:
|
||||
unexpected_success.append(
|
||||
{"method": method, "path": path, "status": status, "data": data, "preview": preview}
|
||||
)
|
||||
|
||||
if not ok:
|
||||
failures.append({"method": method, "path": path, "status": status, "data": data, "preview": preview})
|
||||
|
||||
print("\n=== SMOKE_RESULT ===")
|
||||
print("Failures(404/500/EXC):", len(failures))
|
||||
if failures:
|
||||
for it in failures:
|
||||
print(f"- {it['method']} {it['path']} -> {it['status']}, preview={it.get('preview')}")
|
||||
|
||||
print("\nUnexpected success on write calls:", len(unexpected_success))
|
||||
if unexpected_success:
|
||||
for it in unexpected_success:
|
||||
print(f"- {it['method']} {it['path']} -> success=true (status {it['status']}, preview={it.get('preview')})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
134
scripts/test/web/admin_routes_smoke_authless.py
Normal file
134
scripts/test/web/admin_routes_smoke_authless.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
ROUTER_GO = PROJECT_ROOT / "soul-api" / "internal" / "router" / "router.go"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Check:
|
||||
method: str
|
||||
path: str
|
||||
status: int
|
||||
preview: str
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def extract_routes() -> list[tuple[str, str]]:
|
||||
"""
|
||||
返回 [(method, full_path_template), ...]
|
||||
full_path_template 保留 :id 占位符。
|
||||
"""
|
||||
text = _read_text(ROUTER_GO)
|
||||
routes: list[tuple[str, str]] = []
|
||||
|
||||
# /api/admin 登录/鉴权/登出
|
||||
for m in re.finditer(r'api\.(GET|POST|PUT|DELETE)\("(/admin(?:/[^"]*)?)",\s*handler\.[A-Za-z0-9_]+', text):
|
||||
routes.append((m.group(1), f"/api{m.group(2)}"))
|
||||
|
||||
# /api/admin 组
|
||||
for m in re.finditer(r'admin\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
|
||||
routes.append((m.group(1), f"/api/admin{m.group(2)}"))
|
||||
|
||||
# /api/db 组
|
||||
for m in re.finditer(r'db\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
|
||||
routes.append((m.group(1), f"/api/db{m.group(2)}"))
|
||||
|
||||
# 去重
|
||||
seen = set()
|
||||
out = []
|
||||
for method, p in routes:
|
||||
if (method, p) in seen:
|
||||
continue
|
||||
seen.add((method, p))
|
||||
out.append((method, p))
|
||||
return out
|
||||
|
||||
|
||||
def replace_path_params(path: str) -> str:
|
||||
return path.replace(":id", "1")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import os
|
||||
|
||||
api_base = (os.environ.get("SOUL_API_BASE") or "http://localhost:8080").rstrip("/")
|
||||
session = requests.Session()
|
||||
session.verify = False # 如为 https 自签证书也可探测
|
||||
|
||||
routes = extract_routes()
|
||||
print(f"Found routes: {len(routes)}")
|
||||
|
||||
failures: list[Check] = []
|
||||
unexpected: list[Check] = []
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
# 先验证登录接口是否通(只对 /api/admin POST 登录做一次带凭证的检查)
|
||||
admin_username = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
|
||||
admin_password = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
|
||||
login_url = f"{api_base}/api/admin"
|
||||
r_login = session.post(
|
||||
login_url,
|
||||
json={"username": admin_username, "password": admin_password},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
try:
|
||||
login_data = r_login.json()
|
||||
except Exception:
|
||||
login_data = None
|
||||
if r_login.status_code != 200 or not (login_data and login_data.get("success") is True and login_data.get("token")):
|
||||
failures.append(Check("POST", "/api/admin", r_login.status_code, (r_login.text or "")[:200]))
|
||||
print("LOGIN_CHECK_FAILED,后续路由鉴权探测可能不准确。")
|
||||
|
||||
for method, path_template in routes:
|
||||
path = replace_path_params(path_template)
|
||||
url = f"{api_base}{path}"
|
||||
|
||||
# 仅对登录接口放行;其他都不带 token,避免触发写操作
|
||||
json_payload = None
|
||||
if path == "/api/admin" and method == "POST":
|
||||
# 已在上面验证登录;这里跳过
|
||||
continue
|
||||
|
||||
if method in ("POST", "PUT"):
|
||||
# 发空 body,通常也会被 AdminAuth 在更早阶段拦截
|
||||
json_payload = {}
|
||||
|
||||
try:
|
||||
resp = session.request(method, url, headers=headers, json=json_payload, timeout=10)
|
||||
status = resp.status_code
|
||||
preview = (resp.text or "")[:200].replace("\n", " ")
|
||||
except Exception as e:
|
||||
failures.append(Check(method, path, 0, f"EXC: {e}"))
|
||||
continue
|
||||
|
||||
# 非登录接口:预期 AdminAuth 拦截 => 401 或 403
|
||||
if status not in (401, 403):
|
||||
unexpected.append(Check(method, path, status, preview))
|
||||
|
||||
print("\n=== AUTHLESS_SMOKE_RESULT ===")
|
||||
print("Failures(0/404/500 等异常/网络异常):", len(failures))
|
||||
for it in failures[:30]:
|
||||
print(f"- {it.method} {it.path} -> {it.status}, preview={it.preview}")
|
||||
|
||||
print("Unexpected (非 401/403):", len(unexpected))
|
||||
for it in unexpected[:30]:
|
||||
print(f"- {it.method} {it.path} -> {it.status}, preview={it.preview}")
|
||||
|
||||
if len(unexpected) > 30:
|
||||
print("... truncated")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
165
scripts/test/测试报告-2026-03-24-本地核心功能场景.md
Normal file
165
scripts/test/测试报告-2026-03-24-本地核心功能场景.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 测试报告 - 2026-03-24(本地核心功能场景)
|
||||
|
||||
## 1. 测试概览
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 执行日期 | 2026-03-24 |
|
||||
| 测试环境 | local |
|
||||
| API 地址 | `http://localhost:8080` |
|
||||
| 执行人 | 测试工程师(AI) |
|
||||
| 覆盖范围 | 分销、支付、获客(@某人/CKB)、匹配;管理端/小程序/API 核心链路 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 执行结果总览
|
||||
|
||||
| 测试集 | 结果 | 结论 |
|
||||
|------|------|------|
|
||||
| `pytest -q` 全量 | 18 total / 16 passed / 2 skipped / 0 failed | 通过 |
|
||||
| 管理端路由冒烟(鉴权) | 119 路由;404/500/异常=0;写接口成功=16 | 有风险项 |
|
||||
| 管理端路由冒烟(未鉴权) | 404/500/异常=0;非 401/403=1 | 有风险项 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 自动化执行明细
|
||||
|
||||
### 3.1 全量 pytest
|
||||
|
||||
```powershell
|
||||
$env:SOUL_TEST_ENV='local'; pytest -q
|
||||
```
|
||||
|
||||
- 总计:18
|
||||
- 通过:16
|
||||
- 跳过:2
|
||||
- 失败:0
|
||||
|
||||
覆盖模块:
|
||||
- 小程序配置/登录(miniapp)
|
||||
- 管理端鉴权与上传(web)
|
||||
- 流程测试:健康检查、文章 @ 某人流程、人物 key 回填(部分 skip)
|
||||
|
||||
### 3.2 管理端路由冒烟(鉴权)
|
||||
|
||||
```powershell
|
||||
$env:SOUL_TEST_ENV='local'; python web/admin_routes_smoke.py
|
||||
```
|
||||
|
||||
- 路由扫描:119
|
||||
- 404/500/异常:0
|
||||
- 需关注:`Unexpected success on write calls: 16`
|
||||
- 涉及:`/api/admin/content`、`/api/admin/payment`、`/api/admin/referral`、`/api/db/chapters`、`/api/db/init`、`/api/db/migrate` 等
|
||||
|
||||
判读:
|
||||
- 非直接功能 bug,但暴露“写接口防误触”偏弱(空 payload 也可能 success)。
|
||||
|
||||
### 3.3 管理端路由冒烟(未鉴权)
|
||||
|
||||
```powershell
|
||||
$env:SOUL_TEST_ENV='local'; python web/admin_routes_smoke_authless.py
|
||||
```
|
||||
|
||||
- 404/500/异常:0
|
||||
- 非预期(非 401/403):1
|
||||
- `POST /api/admin/logout` 返回 200(未鉴权可调用)
|
||||
|
||||
判读:
|
||||
- 风险较低,但权限策略不一致,建议明确“公开接口”与“鉴权接口”边界。
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心功能场景评估
|
||||
|
||||
### 4.1 分销
|
||||
|
||||
- 已验证
|
||||
- 推荐码链路相关接口和流程用例可运行,自动化无失败。
|
||||
- 本地联调可用(见 pytest 全量)。
|
||||
- 待补手工
|
||||
- 小程序扫码/分享带 `ref` 后,到订单归因展示的 UI 端到端确认。
|
||||
|
||||
### 4.2 支付
|
||||
|
||||
- 已验证
|
||||
- 基础链路可用:`/health`、上传等依赖接口正常。
|
||||
- 流程层(文章 @ 相关)可执行通过。
|
||||
- 待补手工
|
||||
- 真支付、余额支付、代付的真机端到端验证(本次未覆盖真实支付动作)。
|
||||
|
||||
### 4.3 获客(@某人 / CKB)
|
||||
|
||||
- 已验证
|
||||
- `process/test_article_mention_ckb_flow.py` 通过。
|
||||
- 已补齐规则:人物不存在时,阅读页 mention 降级为静态 `@某人`。
|
||||
- 超级个体开通后自动创建 `Person` 的链路存在并已被流程用例覆盖。
|
||||
|
||||
### 4.4 匹配
|
||||
|
||||
- 已验证
|
||||
- 相关后端路由可达(冒烟无 404/500)。
|
||||
- 待补手工
|
||||
- 小程序找伙伴完整流程:次数扣减、购买增次、资料门槛、匹配结果、加入流程。
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险清单(按优先级)
|
||||
|
||||
| 优先级 | 风险项 | 现象 | 建议 |
|
||||
|------|------|------|------|
|
||||
| P1 | 写接口防误触偏弱 | 冒烟请求下 16 个写接口 success=true | 增加必填参数校验、鉴权校验、幂等保护 |
|
||||
| P2 | 鉴权策略不一致 | `POST /api/admin/logout` 未鉴权返回 200 | 明确公开策略或统一纳入鉴权组 |
|
||||
| P1 | 覆盖缺口(UI E2E) | 自动化未覆盖真机支付/分享/匹配 UI | 补充手工用例并归档结果 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 复测计划(建议)
|
||||
|
||||
### 6.1 手工复测清单(本地)
|
||||
|
||||
- 小程序
|
||||
- [ ] 分享带 `ref` → 下单归因展示正确
|
||||
- [ ] 真机微信支付(章节/全书/VIP)成功后权益生效
|
||||
- [ ] 代付分享领取链路完整
|
||||
- [ ] 匹配全流程(免费次数、增次购买、资料门槛、加入)
|
||||
- 管理端
|
||||
- [ ] 内容写接口空 payload 时返回合理错误
|
||||
- [ ] 关键写接口鉴权策略一致
|
||||
|
||||
### 6.2 通过标准
|
||||
|
||||
- P1 风险项有明确处理结果(修复或接受并记录)
|
||||
- 手工高风险链路全部通过
|
||||
- 无新增中高严重问题
|
||||
|
||||
---
|
||||
|
||||
## 7. 最终结论
|
||||
|
||||
- 自动化结果:**通过(16 passed / 0 failed / 2 skipped)**
|
||||
- 综合判定:**有条件通过**
|
||||
- 当前可继续本地开发联调;
|
||||
- 上线前需完成手工高风险场景复测,并处理/确认 P1 风险项。
|
||||
|
||||
---
|
||||
|
||||
## 8. 整改跟踪(执行版)
|
||||
|
||||
| 序号 | 问题/任务 | 优先级 | 责任角色 | 截止日期 | 当前状态 | 备注 |
|
||||
|------|-----------|--------|----------|----------|----------|------|
|
||||
| 1 | 写接口防误触:为空 payload 增加参数校验/防误触策略 | P1 | 后端工程师 | 2026-03-26 | 待开始 | 涉及 `/api/admin/content`、`/api/admin/payment`、`/api/admin/referral`、`/api/db/*` 部分接口 |
|
||||
| 2 | 明确 `POST /api/admin/logout` 鉴权策略并与规范对齐 | P2 | 后端工程师 | 2026-03-26 | 待开始 | 可选“公开接口说明”或“纳入鉴权组” |
|
||||
| 3 | 小程序分享带 `ref` → 订单归因 UI 端到端手工验证 | P1 | 测试人员 + 小程序工程师 | 2026-03-25 | 待开始 | 需真机/开发者工具联测 |
|
||||
| 4 | 真机支付链路回归:章节/全书/VIP/代付 | P1 | 测试人员 + 小程序工程师 + 后端工程师 | 2026-03-25 | 待开始 | 含回调后权益生效校验 |
|
||||
| 5 | 匹配完整链路手工回归(次数/增次/门槛/加入) | P1 | 测试人员 + 小程序工程师 | 2026-03-25 | 待开始 | 建议录屏留档 |
|
||||
|
||||
状态说明:`待开始 / 进行中 / 已完成 / 已阻塞`
|
||||
|
||||
---
|
||||
|
||||
## 9. 复测记录
|
||||
|
||||
| 复测日期 | 复测范围 | 结果 | 剩余问题 | 结论 |
|
||||
|----------|----------|------|----------|------|
|
||||
| (待填) | (待填) | (待填) | (待填) | (待填) |
|
||||
|
||||
377
scripts/wechat_miniprogram_release.py
Normal file
377
scripts/wechat_miniprogram_release.py
Normal file
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
微信小程序发布辅助:上传(调开发者工具 CLI)+ 审核状态查询 + 尝试 API 提审。
|
||||
|
||||
重要说明(微信官方限制):
|
||||
- 代码上传:可用本机「微信开发者工具」CLI 或 miniprogram-ci。
|
||||
- submit_audit:主要为「第三方平台代调用」;自有主体用 appid+secret 常返回 errcode=86000,
|
||||
无法在仓库内替代网页提审;`release` 默认只跑上传+接口调用,不弹浏览器、不提示手动操作。
|
||||
- 「自动过审」不可能由开发者脚本保证:是否通过由微信审核决定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# 登录 mp 后进入「版本管理 / 开发版本」列表(选体验版、提交审核均在此页操作;具体路由以微信后台为准)。
|
||||
MP_VERSION_MANAGE_URL = (
|
||||
"https://mp.weixin.qq.com/wxopen/wacodepage?action=getcodepage&lang=zh_CN"
|
||||
)
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _load_partial_env_file(path: Path, keys: tuple[str, ...]) -> None:
|
||||
if not path.is_file():
|
||||
return
|
||||
for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k, v = k.strip(), v.strip().strip('"').strip("'")
|
||||
if k in keys and not os.environ.get(k):
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def ensure_wechat_env_from_soul_api() -> None:
|
||||
"""若未 export 凭证,则从 soul-api/.env.production 或 .env 读取 WECHAT_APPID/SECRET(不写日志)。"""
|
||||
if os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"):
|
||||
return
|
||||
root = _repo_root()
|
||||
for name in (".env.production", ".env"):
|
||||
_load_partial_env_file(
|
||||
root / "soul-api" / name, ("WECHAT_APPID", "WECHAT_APPSECRET")
|
||||
)
|
||||
if os.environ.get("WECHAT_APPID") and os.environ.get("WECHAT_APPSECRET"):
|
||||
return
|
||||
|
||||
|
||||
def _get(url: str) -> dict:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def _post_json(url: str, body: dict) -> dict:
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def get_access_token(appid: str, secret: str) -> str:
|
||||
q = urllib.parse.urlencode(
|
||||
{"grant_type": "client_credential", "appid": appid, "secret": secret}
|
||||
)
|
||||
url = f"https://api.weixin.qq.com/cgi-bin/token?{q}"
|
||||
try:
|
||||
data = _get(url)
|
||||
except urllib.error.HTTPError as e:
|
||||
raise SystemExit(f"获取 access_token HTTP 错误: {e}") from e
|
||||
if data.get("errcode"):
|
||||
raise SystemExit(f"获取 access_token 失败: {data}")
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise SystemExit(f"获取 access_token 无 access_token 字段: {data}")
|
||||
return token
|
||||
|
||||
|
||||
def cmd_audit_status(appid: str, secret: str) -> None:
|
||||
token = get_access_token(appid, secret)
|
||||
url = f"https://api.weixin.qq.com/wxa/get_latest_auditstatus?access_token={urllib.parse.quote(token)}"
|
||||
try:
|
||||
data = _get(url)
|
||||
except urllib.error.HTTPError as e:
|
||||
raise SystemExit(f"get_latest_auditstatus HTTP 错误: {e}") from e
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
# 常见 status: 0 审核成功 1 审核被拒绝 2 审核中 3 已撤回 4 审核延后
|
||||
|
||||
|
||||
def cmd_get_category(appid: str, secret: str) -> None:
|
||||
token = get_access_token(appid, secret)
|
||||
url = f"https://api.weixin.qq.com/wxa/get_category?access_token={urllib.parse.quote(token)}"
|
||||
try:
|
||||
data = _get(url)
|
||||
except urllib.error.HTTPError as e:
|
||||
raise SystemExit(f"get_category HTTP 错误: {e}") from e
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def _first_item_from_category(data: dict) -> dict | None:
|
||||
lst = data.get("category_list")
|
||||
if not lst or not isinstance(lst, list):
|
||||
return None
|
||||
c = lst[0]
|
||||
if not isinstance(c, dict):
|
||||
return None
|
||||
first_class = c.get("first_class") or ""
|
||||
second_class = c.get("second_class") or ""
|
||||
first_id = c.get("first_id")
|
||||
second_id = c.get("second_id")
|
||||
if first_id is None or second_id is None:
|
||||
return None
|
||||
item: dict = {
|
||||
"address": "pages/index/index",
|
||||
"tag": "阅读 创业",
|
||||
"first_class": first_class,
|
||||
"second_class": second_class,
|
||||
"first_id": int(first_id),
|
||||
"second_id": int(second_id),
|
||||
"title": "首页",
|
||||
}
|
||||
third_class = c.get("third_class")
|
||||
third_id = c.get("third_id")
|
||||
if third_class and third_id:
|
||||
item["third_class"] = third_class
|
||||
item["third_id"] = int(third_id)
|
||||
return item
|
||||
|
||||
|
||||
def cmd_submit_audit(
|
||||
appid: str,
|
||||
secret: str,
|
||||
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():
|
||||
payload = json.loads(item_json.read_text(encoding="utf-8"))
|
||||
item_list = payload.get("item_list")
|
||||
if not item_list:
|
||||
raise SystemExit("item_json 中缺少 item_list")
|
||||
else:
|
||||
cat_url = f"https://api.weixin.qq.com/wxa/get_category?access_token={urllib.parse.quote(token)}"
|
||||
cat = _get(cat_url)
|
||||
if cat.get("errcode"):
|
||||
raise SystemExit(f"get_category 失败: {cat}")
|
||||
one = _first_item_from_category(cat)
|
||||
if not one:
|
||||
raise SystemExit(
|
||||
"无法从 get_category 构造审核项;请在公众平台配置服务类目,"
|
||||
"或使用 --item-json 指定完整 item_list(见 scripts/miniprogram_audit_item.example.json)。"
|
||||
)
|
||||
item_list = [one]
|
||||
|
||||
body: dict = {
|
||||
"item_list": item_list,
|
||||
"version_desc": version_desc[:400] if version_desc else "版本更新",
|
||||
}
|
||||
if privacy_api_not_use is True:
|
||||
body["privacy_api_not_use"] = True
|
||||
elif privacy_api_not_use is False:
|
||||
body["privacy_api_not_use"] = False
|
||||
|
||||
submit_url = f"https://api.weixin.qq.com/wxa/submit_audit?access_token={urllib.parse.quote(token)}"
|
||||
try:
|
||||
data = _post_json(submit_url, body)
|
||||
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 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
|
||||
|
||||
|
||||
def cmd_upload(version: str, desc: str) -> None:
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
sh = root / "scripts" / "miniprogram_upload.sh"
|
||||
if not sh.is_file():
|
||||
raise SystemExit(f"未找到 {sh}")
|
||||
r = subprocess.run([str(sh), version, desc], check=False)
|
||||
if r.returncode != 0:
|
||||
raise SystemExit(r.returncode)
|
||||
|
||||
|
||||
def cmd_open_mp() -> None:
|
||||
url = "https://mp.weixin.qq.com/"
|
||||
try:
|
||||
subprocess.run(["open", url], check=False)
|
||||
except FileNotFoundError:
|
||||
print(url)
|
||||
|
||||
|
||||
def cmd_open_mp_version() -> None:
|
||||
"""打开版本管理(开发版本列表,可设体验版、提交审核)。"""
|
||||
try:
|
||||
subprocess.run(["open", MP_VERSION_MANAGE_URL], check=False)
|
||||
except FileNotFoundError:
|
||||
print(MP_VERSION_MANAGE_URL)
|
||||
|
||||
|
||||
def cmd_upload_open(version: str, desc: str) -> None:
|
||||
cmd_upload(version, desc)
|
||||
print(
|
||||
"\n下一步(微信未开放「一键设为体验版」API,需在网页上点两次):\n"
|
||||
"1)在「开发版本」列表找到刚上传的版本号;\n"
|
||||
"2)点击「选为体验版」;\n"
|
||||
"3)同一列表对该版本点击「提交审核」(或先体验再提审)。\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
cmd_open_mp_version()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="微信小程序上传与审核辅助")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_up = sub.add_parser("upload", help="调用微信开发者工具 CLI 上传(需本机已登录)")
|
||||
p_up.add_argument(
|
||||
"--version",
|
||||
"-v",
|
||||
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
|
||||
help="版本号,默认 1.7.2 或环境变量 MINIPROGRAM_DEFAULT_VERSION",
|
||||
)
|
||||
p_up.add_argument(
|
||||
"--desc",
|
||||
"-d",
|
||||
default="",
|
||||
help="版本说明,默认同「版本 v<版本号>」",
|
||||
)
|
||||
|
||||
p_st = sub.add_parser("audit-status", help="查询最近一次审核状态(需 WECHAT_APPID/SECRET)")
|
||||
p_cat = sub.add_parser("get-category", help="拉取已配置服务类目 JSON(构造提审项用)")
|
||||
p_sa = sub.add_parser(
|
||||
"submit-audit",
|
||||
help="尝试调用 submit_audit(自有主体常会 86000,需改 mp 后台手动提审)",
|
||||
)
|
||||
p_sa.add_argument("--version-desc", default="版本更新", help="审核说明 version_desc")
|
||||
p_sa.add_argument(
|
||||
"--item-json",
|
||||
type=Path,
|
||||
help="自定义 item_list 的 JSON 文件(含 item_list 数组)",
|
||||
)
|
||||
p_sa.add_argument(
|
||||
"--privacy-api-not-use",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=None,
|
||||
help="是否声明未使用检测到的隐私接口(与微信报错 61040 相关时再用)",
|
||||
)
|
||||
|
||||
sub.add_parser("open-mp", help="在浏览器打开 mp 首页")
|
||||
sub.add_parser(
|
||||
"open-version",
|
||||
help="打开版本管理页(开发版本:选体验版、提交审核;需已登录 mp)",
|
||||
)
|
||||
|
||||
p_uo = sub.add_parser(
|
||||
"upload-open",
|
||||
help="上传代码并打开版本管理页(设体验版、提审在浏览器完成)",
|
||||
)
|
||||
p_uo.add_argument(
|
||||
"--version",
|
||||
"-v",
|
||||
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
|
||||
)
|
||||
p_uo.add_argument("--desc", "-d", default="", help="默认:版本 v<版本号>")
|
||||
|
||||
p_rel = sub.add_parser(
|
||||
"release",
|
||||
help="上传 → 尝试 submit_audit(默认不弹浏览器、不提示手动打开;可加 --open-browser)",
|
||||
)
|
||||
p_rel.add_argument(
|
||||
"--version",
|
||||
"-v",
|
||||
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
|
||||
)
|
||||
p_rel.add_argument("--desc", "-d", default="", help="上传说明,默认:版本 v<版本号>")
|
||||
p_rel.add_argument("--version-desc", default="", help="提交审核说明,默认同上传说明")
|
||||
p_rel.add_argument("--item-json", type=Path)
|
||||
p_rel.add_argument(
|
||||
"--privacy-api-not-use",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=None,
|
||||
)
|
||||
p_rel.add_argument(
|
||||
"--open-browser",
|
||||
action="store_true",
|
||||
help="完成后打开公众平台版本管理页(默认关闭)",
|
||||
)
|
||||
|
||||
args = p.parse_args()
|
||||
|
||||
if args.cmd in (
|
||||
"audit-status",
|
||||
"get-category",
|
||||
"submit-audit",
|
||||
"release",
|
||||
):
|
||||
ensure_wechat_env_from_soul_api()
|
||||
|
||||
appid = os.environ.get("WECHAT_APPID", "").strip()
|
||||
secret = os.environ.get("WECHAT_APPSECRET", "").strip()
|
||||
|
||||
if args.cmd == "upload":
|
||||
desc = args.desc.strip() or f"版本 v{args.version}"
|
||||
cmd_upload(args.version, desc)
|
||||
return
|
||||
if args.cmd == "open-mp":
|
||||
cmd_open_mp()
|
||||
return
|
||||
if args.cmd == "open-version":
|
||||
cmd_open_mp_version()
|
||||
return
|
||||
if args.cmd == "upload-open":
|
||||
d = args.desc.strip() or f"版本 v{args.version}"
|
||||
cmd_upload_open(args.version, d)
|
||||
return
|
||||
if args.cmd == "release":
|
||||
d = args.desc.strip() or f"版本 v{args.version}"
|
||||
cmd_upload(args.version, d)
|
||||
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,
|
||||
)
|
||||
if getattr(args, "open_browser", False):
|
||||
cmd_open_mp_version()
|
||||
return
|
||||
|
||||
if not appid or not secret:
|
||||
raise SystemExit("请设置环境变量 WECHAT_APPID、WECHAT_APPSECRET(与 soul-api 一致即可)")
|
||||
|
||||
if args.cmd == "audit-status":
|
||||
cmd_audit_status(appid, secret)
|
||||
elif args.cmd == "get-category":
|
||||
cmd_get_category(appid, secret)
|
||||
elif args.cmd == "submit-audit":
|
||||
cmd_submit_audit(
|
||||
appid, secret, args.version_desc, args.item_json, args.privacy_api_not_use
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user