v1.19 全面改版:VIP会员系统、我的收益、创业老板排行、阅读量排序
- 后端: users表新增VIP字段, 4个VIP API (purchase/status/profile/members) - 后端: hot接口改按user_tracks阅读量排序 - 后端: orders表支持vip产品类型, migrate新增vip_fields迁移 - 小程序「我的」: 推广中心改为我的收益, 头像VIP标识, VIP入口卡片 - 小程序「我的」: 最近阅读显示真实章节名称 - 小程序首页: 去掉内容概览, 新增创业老板排行(4列网格) - 小程序首页: 精选推荐从hot接口获取, goToRead增加track记录 - 新增页面: VIP详情页, 会员详情页 - 开发文档精简为10个标准目录, 创建SKILL.md, 需求日志规范化 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4
scripts/.env.feishu.example
Normal file
4
scripts/.env.feishu.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# 飞书应用凭证,用于上传海报图片到飞书群
|
||||
# 复制为 .env.feishu 并填写(需与 webhook 同租户的应用)
|
||||
FEISHU_APP_ID=your_app_id
|
||||
FEISHU_APP_SECRET=your_app_secret
|
||||
@@ -21,6 +21,14 @@ mkdir -p "$EXT_DIR"
|
||||
API_CONF="$EXT_DIR/api-proxy.conf"
|
||||
cat > "$API_CONF" << 'NGX'
|
||||
location /api/ {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
|
||||
add_header Access-Control-Allow-Credentials "true";
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
proxy_pass https://souldev.quwanzhi.com/api/;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
271
scripts/send_poster_to_feishu.py
Normal file
271
scripts/send_poster_to_feishu.py
Normal file
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Soul 文章海报发飞书:生成海报图片(含小程序码)并发送,不发链接
|
||||
|
||||
流程:解析文章 → 调 Soul API 获取小程序码 → 合成海报图 → 上传飞书 → 以图片形式发送
|
||||
|
||||
用法:
|
||||
python3 scripts/send_poster_to_feishu.py <文章md路径>
|
||||
python3 scripts/send_poster_to_feishu.py --id 9.15
|
||||
|
||||
环境: pip install Pillow requests
|
||||
飞书图片上传需配置: FEISHU_APP_ID, FEISHU_APP_SECRET(与 webhook 同租户的飞书应用)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 可选:从脚本同目录 .env.feishu 加载 FEISHU_APP_ID, FEISHU_APP_SECRET
|
||||
_env = Path(__file__).resolve().parent / ".env.feishu"
|
||||
if _env.exists():
|
||||
for line in _env.read_text().strip().split("\n"):
|
||||
if "=" in line and not line.strip().startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/8b7f996e-2892-4075-989f-aa5593ea4fbc"
|
||||
SOUL_API = "https://soul.quwanzhi.com/api/miniprogram/qrcode"
|
||||
POSTER_W, POSTER_H = 600, 900 # 2x 小程序 300x450
|
||||
|
||||
|
||||
def parse_article(filepath: Path) -> dict:
|
||||
"""从 md 解析:标题、金句、日期、section id"""
|
||||
text = filepath.read_text(encoding="utf-8")
|
||||
lines = [l.strip() for l in text.split("\n")]
|
||||
|
||||
title = ""
|
||||
date_line = ""
|
||||
quote = ""
|
||||
section_id = ""
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("# "):
|
||||
title = line.lstrip("# ").strip()
|
||||
m = re.match(r"^(\d+\.\d+)\s", title)
|
||||
if m:
|
||||
section_id = m.group(1)
|
||||
elif re.match(r"^\d{4}年\d{1,2}月\d{1,2}日", line):
|
||||
date_line = line
|
||||
elif title and not quote and line and not line.startswith("-") and "---" not in line:
|
||||
if 10 <= len(line) <= 120:
|
||||
quote = line
|
||||
|
||||
if not section_id and filepath.stem:
|
||||
m = re.match(r"^(\d+\.\d+)", filepath.stem)
|
||||
if m:
|
||||
section_id = m.group(1)
|
||||
|
||||
return {
|
||||
"title": title or filepath.stem,
|
||||
"quote": quote or title or "来自 Soul 创业派对的真实故事",
|
||||
"date": date_line,
|
||||
"section_id": section_id or "9.1",
|
||||
}
|
||||
|
||||
|
||||
def fetch_qrcode(section_id: str) -> bytes | None:
|
||||
"""从 Soul 后端获取小程序码图片"""
|
||||
scene = f"id={section_id}"
|
||||
body = json.dumps({"scene": scene, "page": "pages/read/read", "width": 280}).encode()
|
||||
req = Request(SOUL_API, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
if not data.get("success") or not data.get("image"):
|
||||
return None
|
||||
b64 = data["image"].split(",", 1)[-1] if "," in data["image"] else data["image"]
|
||||
return base64.b64decode(b64)
|
||||
except Exception as e:
|
||||
print("获取小程序码失败:", e)
|
||||
return None
|
||||
|
||||
|
||||
def draw_poster(data: dict, qr_bytes: bytes | None) -> bytes:
|
||||
"""合成海报图,返回 PNG bytes"""
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("需要安装 Pillow: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
img = Image.new("RGB", (POSTER_W, POSTER_H), color=(26, 26, 46))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 顶部装饰条
|
||||
draw.rectangle([0, 0, POSTER_W, 8], fill=(0, 206, 209))
|
||||
|
||||
# 字体(系统 fallback)
|
||||
try:
|
||||
font_sm = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||||
font_md = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 32)
|
||||
font_lg = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 40)
|
||||
except OSError:
|
||||
font_sm = font_md = font_lg = ImageFont.load_default()
|
||||
|
||||
y = 50
|
||||
draw.text((40, y), "Soul创业派对", fill=(255, 255, 255), font=font_sm)
|
||||
y += 60
|
||||
|
||||
# 标题(多行)
|
||||
title = data["title"]
|
||||
for i in range(0, len(title), 18):
|
||||
draw.text((40, y), title[i : i + 18], fill=(255, 255, 255), font=font_lg)
|
||||
y += 50
|
||||
y += 20
|
||||
|
||||
draw.line([(40, y), (POSTER_W - 40, y)], fill=(255, 255, 255, 40), width=1)
|
||||
y += 40
|
||||
|
||||
# 金句
|
||||
quote = data["quote"]
|
||||
for i in range(0, min(len(quote), 80), 20):
|
||||
draw.text((40, y), quote[i : i + 20], fill=(255, 255, 255, 200), font=font_md)
|
||||
y += 40
|
||||
if data["date"]:
|
||||
draw.text((40, y), data["date"], fill=(255, 255, 255, 180), font=font_sm)
|
||||
y += 40
|
||||
y += 20
|
||||
|
||||
# 底部提示 + 小程序码
|
||||
draw.text((40, POSTER_H - 120), "长按识别小程序码", fill=(255, 255, 255), font=font_sm)
|
||||
draw.text((40, POSTER_H - 90), "阅读全文", fill=(255, 255, 255, 180), font=font_sm)
|
||||
|
||||
if qr_bytes:
|
||||
qr_img = Image.open(io.BytesIO(qr_bytes))
|
||||
qr_img = qr_img.resize((160, 160))
|
||||
img.paste(qr_img, (POSTER_W - 200, POSTER_H - 180))
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def get_feishu_token() -> str | None:
|
||||
app_id = os.environ.get("FEISHU_APP_ID")
|
||||
app_secret = os.environ.get("FEISHU_APP_SECRET")
|
||||
if not app_id or not app_secret:
|
||||
return None
|
||||
req = Request(
|
||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
||||
data=json.dumps({"app_id": app_id, "app_secret": app_secret}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
return data.get("tenant_access_token")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def upload_image_to_feishu(png_bytes: bytes, token: str) -> str | None:
|
||||
"""上传图片到飞书,返回 image_key"""
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("需要安装: pip install requests")
|
||||
return None
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
files = {"image": ("poster.png", png_bytes, "image/png")}
|
||||
data = {"image_type": "message"}
|
||||
try:
|
||||
r = requests.post(
|
||||
"https://open.feishu.cn/open-apis/im/v1/images",
|
||||
headers=headers,
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=15,
|
||||
)
|
||||
j = r.json()
|
||||
if j.get("code") == 0 and j.get("data", {}).get("image_key"):
|
||||
return j["data"]["image_key"]
|
||||
except Exception as e:
|
||||
print("上传飞书失败:", e)
|
||||
return None
|
||||
|
||||
|
||||
def send_image_to_feishu(image_key: str) -> bool:
|
||||
payload = {"msg_type": "image", "content": {"image_key": image_key}}
|
||||
req = Request(
|
||||
FEISHU_WEBHOOK,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
r = json.loads(resp.read().decode())
|
||||
return r.get("code") == 0
|
||||
except Exception as e:
|
||||
print("发送失败:", e)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("file", nargs="?", help="文章 md 路径")
|
||||
parser.add_argument("--id", help="章节 id")
|
||||
parser.add_argument("--save", help="仅保存海报到本地路径,不发飞书")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = Path("/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例")
|
||||
|
||||
if args.id:
|
||||
candidates = list(base.glob(f"{args.id}*.md"))
|
||||
if not candidates:
|
||||
print(f"未找到 id={args.id} 的文章")
|
||||
sys.exit(1)
|
||||
filepath = candidates[0]
|
||||
elif args.file:
|
||||
filepath = Path(args.file)
|
||||
if not filepath.exists():
|
||||
print("文件不存在:", filepath)
|
||||
sys.exit(1)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
data = parse_article(filepath)
|
||||
print("生成海报:", data["title"])
|
||||
|
||||
qr_bytes = fetch_qrcode(data["section_id"])
|
||||
png_bytes = draw_poster(data, qr_bytes)
|
||||
|
||||
if args.save:
|
||||
Path(args.save).write_bytes(png_bytes)
|
||||
print("已保存:", args.save)
|
||||
return
|
||||
|
||||
token = get_feishu_token()
|
||||
if not token:
|
||||
out = Path(tempfile.gettempdir()) / f"soul_poster_{data['section_id']}.png"
|
||||
out.write_bytes(png_bytes)
|
||||
print("未配置 FEISHU_APP_ID / FEISHU_APP_SECRET,无法上传。海报已保存到:", out)
|
||||
sys.exit(1)
|
||||
|
||||
image_key = upload_image_to_feishu(png_bytes, token)
|
||||
if not image_key:
|
||||
print("上传图片失败")
|
||||
sys.exit(1)
|
||||
|
||||
if send_image_to_feishu(image_key):
|
||||
print("已发送到飞书(图片)")
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
42
scripts/upload_soul_article.sh
Executable file
42
scripts/upload_soul_article.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Soul 第9章文章上传脚本:写好文章后直接上传,id 已存在则更新(不重复)
|
||||
# 用法: ./scripts/upload_soul_article.sh <文章md文件路径>
|
||||
# 例: ./scripts/upload_soul_article.sh "/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.18 第105场|创业社群、直播带货与程序员.md"
|
||||
|
||||
set -e
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FILE="${1:?请提供文章 md 文件路径}"
|
||||
|
||||
if [[ ! -f "$FILE" ]]; then
|
||||
echo "错误: 文件不存在: $FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 从文件名提取 id,如 9.18 第105场|xxx.md -> 9.18
|
||||
BASENAME=$(basename "$FILE" .md)
|
||||
ID=$(echo "$BASENAME" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/')
|
||||
if [[ ! "$ID" =~ ^[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "错误: 无法从文件名提取 id,格式应为: 9.xx 第X场|标题.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 从第一行 # 9.xx 第X场|标题 提取 title
|
||||
TITLE=$(head -1 "$FILE" | sed 's/^# [[:space:]]*//')
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
TITLE="$BASENAME"
|
||||
fi
|
||||
|
||||
echo "上传: id=$ID title=$TITLE"
|
||||
python3 "$ROOT/content_upload.py" \
|
||||
--id "$ID" \
|
||||
--title "$TITLE" \
|
||||
--content-file "$FILE" \
|
||||
--part part-4 \
|
||||
--chapter chapter-9 \
|
||||
--price 1.0
|
||||
|
||||
# 上传成功后,按海报格式发到飞书群
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "发海报到飞书..."
|
||||
python3 "$ROOT/scripts/send_poster_to_feishu.py" "$FILE" 2>/dev/null || true
|
||||
fi
|
||||
Reference in New Issue
Block a user