xhs_feishu.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. """
  2. 飞书企业机器人推送工具
  3. ======================
  4. 通过飞书开放平台(open.feishu.cn API)发送富文本卡片消息 + 图片附件。
  5. 用法:
  6. from xhs_feishu import send_daily_report
  7. send_daily_report(config_path, date, overview, notes, screenshot_path)
  8. 依赖:
  9. requests(第三方库)
  10. 配置 JSON 文件(含 feishu_app_id / feishu_app_secret / feishu_chat_id)
  11. """
  12. import io
  13. import json
  14. import os
  15. import sys
  16. import time
  17. import requests
  18. FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
  19. FEISHU_MSG_URL = "https://open.feishu.cn/open-apis/im/v1/messages"
  20. FEISHU_IMG_URL = "https://open.feishu.cn/open-apis/im/v1/images"
  21. # 进程内 token 缓存:{key: (token, expire_ts)}
  22. _TOKEN_CACHE = {}
  23. def _get_token(app_id: str, app_secret: str) -> str:
  24. """获取 tenant_access_token(带进程内缓存,过期的自动刷新)。
  25. 飞书 token 默认有效期 7200s,这里提前 60s 视为过期。
  26. """
  27. cache_key = f"{app_id}:{app_secret}"
  28. now = time.time()
  29. if cache_key in _TOKEN_CACHE:
  30. token, expire_ts = _TOKEN_CACHE[cache_key]
  31. if expire_ts > now:
  32. return token
  33. resp = requests.post(
  34. FEISHU_TOKEN_URL,
  35. json={"app_id": app_id, "app_secret": app_secret},
  36. timeout=15,
  37. )
  38. resp.raise_for_status()
  39. data = resp.json()
  40. if data.get("code") != 0:
  41. raise RuntimeError(f"获取飞书 token 失败: {data.get('msg')}")
  42. token = data["tenant_access_token"]
  43. expire = data.get("expire", 7200)
  44. _TOKEN_CACHE[cache_key] = (token, now + expire - 60)
  45. return token
  46. def _upload_image(token: str, image_path: str) -> str:
  47. """上传图片到飞书,返回 image_key。
  48. 注意:`image_type` 是 multipart form-data 的字段(不是 URL query 参数)。
  49. 此前误写成 `?image_type=message` 会导致 400 Bad Request(code=234001)。
  50. """
  51. url = FEISHU_IMG_URL
  52. with open(image_path, "rb") as f:
  53. resp = requests.post(
  54. url,
  55. headers={"Authorization": f"Bearer {token}"},
  56. files={"image": (os.path.basename(image_path), f, "image/jpeg")},
  57. data={"image_type": "message"},
  58. timeout=30,
  59. )
  60. resp.raise_for_status()
  61. data = resp.json()
  62. if data.get("code") != 0:
  63. raise RuntimeError(f"上传图片失败: {data.get('msg')}")
  64. return data["data"]["image_key"]
  65. def _build_card_json(date: str, overview: dict, notes: list) -> str:
  66. """构建飞书富文本卡片 JSON 字符串。
  67. overview 字段: fans_count, interact_count, follow_count,
  68. period_7d: {曝光数/观看数/点赞数/评论数/收藏数/分享数/净涨粉...},
  69. period_30d: 同上
  70. """
  71. ov = overview or {}
  72. def _period_lines(period, label):
  73. """格式化一个周期段"""
  74. if not period:
  75. return [f"⚠️ {label}数据暂不可用"]
  76. return [
  77. f"{label} 曝光 {period.get('曝光数', '-')}",
  78. f" 观看 {period.get('观看数', '-')} | 点赞 {period.get('点赞数', '-')} | "
  79. f"评论 {period.get('评论数', '-')} | 收藏 {period.get('收藏数', '-')} | "
  80. f"分享 {period.get('分享数', '-')}",
  81. f" 净涨粉 {period.get('净涨粉', '-')}",
  82. ]
  83. overview_lines = [
  84. f"粉丝数:{ov.get('fans_count', 0)} 关注:{ov.get('follow_count', 0)} "
  85. f"获赞与收藏:{ov.get('interact_count', 0)}",
  86. ]
  87. # 近7日
  88. overview_lines += _period_lines(ov.get("period_7d", {}), "近7日")
  89. # 近30日(如果有)
  90. if ov.get("period_30d"):
  91. overview_lines += _period_lines(ov.get("period_30d", {}), "近30日")
  92. note_lines = []
  93. for i, n in enumerate(notes, 1):
  94. note_lines.append(
  95. f"{i}. 《{n.get('title', '')}》\n"
  96. f" 📅{n.get('publish_date', '')} "
  97. f"👀{n.get('read_count', 0):,} "
  98. f"❤️{n.get('like_count', 0):,} "
  99. f"⭐{n.get('collect_count', 0):,} "
  100. f"💬{n.get('comment_count', 0):,} "
  101. f"🔄{n.get('share_count', 0):,}\n"
  102. f" 📮未回复评论:{n.get('unreplied_comment_count', 0)}"
  103. )
  104. content = "\n\n".join([
  105. "**📈 账号概览**\n" + "\n".join(overview_lines),
  106. f"**📝 笔记列表({len(notes)}篇)**\n" + "\n\n".join(note_lines),
  107. "🖼️ 附:创作者中心页面截图",
  108. ])
  109. card = {
  110. "config": {"wide_screen_mode": True},
  111. "header": {
  112. "template": "blue",
  113. "title": {"tag": "plain_text", "content": f"小红书创作者中心日报 | {date}"},
  114. },
  115. "elements": [
  116. {"tag": "div", "text": {"tag": "lark_md", "content": content}},
  117. ],
  118. }
  119. return json.dumps(card, ensure_ascii=False)
  120. def _send_message(token: str, cfg: dict, msg_type: str, content: str) -> dict:
  121. """发送一条飞书消息,返回响应数据。
  122. 重要:飞书 im/v1/messages 接口的 receive_id_type 是 URL 查询参数,
  123. 不是 JSON body 字段(否则报 99992402 receive_id_type is required)。
  124. """
  125. rtype = cfg.get("receive_id_type", "chat_id")
  126. url = f"{FEISHU_MSG_URL}?receive_id_type={rtype}"
  127. body = {
  128. "receive_id": cfg["feishu_chat_id"],
  129. "msg_type": msg_type,
  130. "content": content,
  131. }
  132. resp = requests.post(
  133. url,
  134. headers={"Authorization": f"Bearer {token}"},
  135. json=body,
  136. timeout=30,
  137. )
  138. resp.raise_for_status()
  139. data = resp.json()
  140. if data.get("code") != 0:
  141. raise RuntimeError(f"发送飞书{msg_type}消息失败: {data.get('msg')}")
  142. return data
  143. def send_daily_report(
  144. config_path: str,
  145. date: str,
  146. overview: dict,
  147. notes: list,
  148. screenshot_path: str = None,
  149. screenshot_paths: list = None,
  150. ):
  151. """发送小红书创作者中心日报到飞书群。
  152. Args:
  153. config_path: xhs_daily_config.json 路径
  154. date: 报告日期 YYYY-MM-DD
  155. overview: 账号总览 dict
  156. notes: 笔记列表 list[dict]
  157. screenshot_path: 单张截图文件路径(可选,向后兼容)
  158. screenshot_paths: 多张截图文件路径列表(可选,优先使用)
  159. """
  160. with open(config_path, "r", encoding="utf-8") as f:
  161. cfg = json.load(f)
  162. required = ["feishu_app_id", "feishu_app_secret", "feishu_chat_id"]
  163. missing = [k for k in required if not cfg.get(k)]
  164. if missing:
  165. raise ValueError(f"配置缺少必填字段: {', '.join(missing)}")
  166. token = _get_token(cfg["feishu_app_id"], cfg["feishu_app_secret"])
  167. card_json = _build_card_json(date, overview, notes)
  168. data = _send_message(token, cfg, "interactive", card_json)
  169. # 收集要附发的截图
  170. shot_list = []
  171. if screenshot_paths:
  172. shot_list = [p for p in screenshot_paths if p and os.path.exists(p)]
  173. elif screenshot_path and os.path.exists(screenshot_path):
  174. shot_list = [screenshot_path]
  175. for shot in shot_list:
  176. try:
  177. image_key = _upload_image(token, shot)
  178. _send_message(
  179. token, cfg, "image",
  180. json.dumps({"image_key": image_key}),
  181. )
  182. except Exception as e:
  183. print(f"[WARN] 发送截图失败,但卡片已推送: {e}", flush=True)
  184. print(f"[OK] 飞书日报已推送,message_id={data['data']['message_id']}", flush=True)
  185. return data["data"]["message_id"]
  186. if __name__ == "__main__":
  187. if not sys.stdout.isatty():
  188. try:
  189. sys.stdout.reconfigure(encoding='utf-8', errors='replace')
  190. except Exception:
  191. pass
  192. print("飞书推送工具模块加载成功")
  193. print(f"token URL: {FEISHU_TOKEN_URL}")