qiwei_doc_discovery.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. """
  2. QiWei:从「会话消息 / 历史同步 / 回调存档」中间接发现在线文档(提取 docid)。
  3. 依据 doc.qiweapi.com:
  4. - 回调 cmd=15000,链接消息 msgType=13,字段 linkUrl(见回调结构说明)
  5. - 同步历史消息:method=/msg/syncMsg(见 api-344613926)
  6. 用法:
  7. # 1) 离线:解析已保存的回调 JSON
  8. python scripts/qiwei_doc_discovery.py analyze --file scripts/samples/qiwei_callbacks_sample.json
  9. # 2) 在线:拉历史消息并扫描(需环境变量,见 doc/会话存档-文档发现-测试指南.md)
  10. python scripts/qiwei_doc_discovery.py sync --guid YOUR_GUID --msg-seq 0
  11. 环境变量(sync 子命令):
  12. QIWEI_TOKEN 必填
  13. QIWEI_BASE_URL 默认 http://manager.qiweapi.com/qiwe
  14. QIWEI_SYNC_METHOD 默认 /msg/syncMsg
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import json
  19. import os
  20. import re
  21. import sys
  22. from dataclasses import asdict, dataclass
  23. from typing import Any, Iterator
  24. from urllib.parse import parse_qs, urlparse
  25. import requests
  26. # 企微在线文档 / 微盘链接常见形态
  27. DOC_URL_PATTERNS = (
  28. re.compile(r"doc\.weixin\.qq\.com", re.I),
  29. re.compile(r"doc\.work\.weixin\.qq\.com", re.I),
  30. re.compile(r"wedoc", re.I),
  31. re.compile(r"txdoc", re.I),
  32. )
  33. DOCID_QUERY_KEYS = ("docid", "doc_id", "docId")
  34. DOCID_PATH_RE = re.compile(
  35. r"(?:docid|doc_id)[=/]([A-Za-z0-9_\-]+)", re.I
  36. )
  37. # 如 https://doc.weixin.qq.com/doc/DOC_OTHER_room2
  38. DOCID_PATH_SEGMENT_RE = re.compile(
  39. r"doc\.weixin\.qq\.com/(?:txdoc/[^/?#]+|doc)/([A-Za-z0-9_\-]+)", re.I
  40. )
  41. @dataclass
  42. class DocRef:
  43. docid: str | None
  44. link_url: str
  45. title: str | None
  46. msg_type: int | None
  47. room_id: str | int | None
  48. guid: str | None
  49. source: str # callback | sync | file
  50. def extract_docid_from_url(url: str) -> str | None:
  51. if not url or not isinstance(url, str):
  52. return None
  53. parsed = urlparse(url.strip())
  54. qs = parse_qs(parsed.query)
  55. for key in DOCID_QUERY_KEYS:
  56. if key in qs and qs[key]:
  57. return qs[key][0]
  58. m = DOCID_PATH_RE.search(url)
  59. if m:
  60. return m.group(1)
  61. m = DOCID_PATH_SEGMENT_RE.search(url)
  62. if m:
  63. return m.group(1)
  64. # 部分链接 docid 在 fragment
  65. if parsed.fragment:
  66. m = DOCID_PATH_RE.search(parsed.fragment)
  67. if m:
  68. return m.group(1)
  69. return None
  70. def is_probable_doc_url(url: str) -> bool:
  71. if not url:
  72. return False
  73. return any(p.search(url) for p in DOC_URL_PATTERNS)
  74. def _walk(obj: Any) -> Iterator[tuple[str, Any]]:
  75. if isinstance(obj, dict):
  76. for k, v in obj.items():
  77. yield k, v
  78. yield from _walk(v)
  79. elif isinstance(obj, list):
  80. for item in obj:
  81. yield from _walk(item)
  82. def extract_from_link_msgdata(msg_data: dict) -> DocRef | None:
  83. link = msg_data.get("linkUrl") or msg_data.get("link_url")
  84. if not link or not is_probable_doc_url(str(link)):
  85. if link and "doc.weixin" not in str(link).lower():
  86. return None
  87. if not link:
  88. return None
  89. title = msg_data.get("title")
  90. if isinstance(title, str) and title.isascii() and len(title) > 40:
  91. try:
  92. import base64
  93. title = base64.b64decode(title).decode("utf-8", errors="replace")
  94. except Exception:
  95. pass
  96. return DocRef(
  97. docid=extract_docid_from_url(str(link)),
  98. link_url=str(link),
  99. title=str(title) if title else None,
  100. msg_type=13,
  101. room_id=None,
  102. guid=None,
  103. source="msgData",
  104. )
  105. def extract_doc_refs_from_message_item(item: dict, source: str) -> list[DocRef]:
  106. refs: list[DocRef] = []
  107. msg_type = item.get("msgType")
  108. room_id = item.get("fromRoomId") or item.get("roomId") or item.get("roomid")
  109. guid = item.get("guid")
  110. msg_data = item.get("msgData")
  111. if isinstance(msg_data, dict):
  112. if msg_type == 13 or "linkUrl" in msg_data or "link_url" in msg_data:
  113. ref = extract_from_link_msgdata(msg_data)
  114. if ref:
  115. ref.msg_type = msg_type
  116. ref.room_id = room_id
  117. ref.guid = guid
  118. ref.source = source
  119. refs.append(ref)
  120. # 兜底:整棵 JSON 里搜 doc.weixin 链接
  121. for key, val in _walk(item):
  122. if key.lower() in ("linkurl", "link_url", "url", "link") and isinstance(val, str):
  123. if is_probable_doc_url(val) or "doc.weixin" in val.lower():
  124. refs.append(
  125. DocRef(
  126. docid=extract_docid_from_url(val),
  127. link_url=val,
  128. title=None,
  129. msg_type=msg_type if isinstance(msg_type, int) else None,
  130. room_id=room_id,
  131. guid=guid,
  132. source=f"{source}:walk",
  133. )
  134. )
  135. return refs
  136. def extract_doc_refs_from_payload(payload: Any, source: str = "callback") -> list[DocRef]:
  137. refs: list[DocRef] = []
  138. if isinstance(payload, dict):
  139. data = payload.get("data")
  140. if isinstance(data, list):
  141. for item in data:
  142. if isinstance(item, dict):
  143. refs.extend(extract_doc_refs_from_message_item(item, source))
  144. else:
  145. refs.extend(extract_doc_refs_from_message_item(payload, source))
  146. elif isinstance(payload, list):
  147. for item in payload:
  148. refs.extend(extract_doc_refs_from_payload(item, source))
  149. # 去重(按 link_url)
  150. seen: set[str] = set()
  151. unique: list[DocRef] = []
  152. for r in refs:
  153. if r.link_url not in seen:
  154. seen.add(r.link_url)
  155. unique.append(r)
  156. return unique
  157. def qiwei_do_api(
  158. token: str,
  159. method: str,
  160. params: dict,
  161. base_url: str | None = None,
  162. ) -> dict:
  163. base = (base_url or os.environ.get("QIWEI_BASE_URL") or "http://manager.qiweapi.com/qiwe").rstrip("/")
  164. url = f"{base}/api/qw/doApi"
  165. headers = {
  166. "Content-Type": "application/json",
  167. "X-QIWEI-TOKEN": token,
  168. }
  169. body = {"method": method, "params": params}
  170. resp = requests.post(url, headers=headers, json=body, timeout=60)
  171. resp.raise_for_status()
  172. return resp.json()
  173. def sync_messages_and_scan(
  174. token: str,
  175. guid: str,
  176. msg_seq: int = 0,
  177. *,
  178. base_url: str | None = None,
  179. sync_method: str | None = None,
  180. extra_params: dict | None = None,
  181. ) -> tuple[dict, list[DocRef]]:
  182. method = sync_method or os.environ.get("QIWEI_SYNC_METHOD", "/msg/syncMsg")
  183. params: dict[str, Any] = {"guid": guid, "msgSeq": msg_seq}
  184. if extra_params:
  185. params.update(extra_params)
  186. raw = qiwei_do_api(token, method, params, base_url)
  187. refs: list[DocRef] = []
  188. # 响应里可能是 data.syncMsgList 或 data 为列表
  189. data = raw.get("data")
  190. if isinstance(data, dict):
  191. lst = data.get("syncMsgList") or data.get("list") or []
  192. if isinstance(lst, list):
  193. for item in lst:
  194. if isinstance(item, dict):
  195. refs.extend(extract_doc_refs_from_message_item(item, "sync"))
  196. elif isinstance(data, list):
  197. for item in data:
  198. if isinstance(item, dict):
  199. refs.extend(extract_doc_refs_from_message_item(item, "sync"))
  200. refs.extend(extract_doc_refs_from_payload(raw, "sync:raw"))
  201. return raw, refs
  202. def cmd_analyze(args: argparse.Namespace) -> int:
  203. path = args.file
  204. with open(path, encoding="utf-8") as f:
  205. payload = json.load(f)
  206. refs = extract_doc_refs_from_payload(payload, "file")
  207. print(f"文件: {path}")
  208. print(f"发现文档相关消息: {len(refs)} 条\n")
  209. for i, r in enumerate(refs, 1):
  210. print(f"--- [{i}] ---")
  211. print(json.dumps(asdict(r), ensure_ascii=False, indent=2))
  212. if not refs:
  213. print("未发现含 doc.weixin / docid 的链接。请确认样本中含 msgType=13 且 linkUrl 指向在线文档。")
  214. return 1
  215. missing = [r for r in refs if not r.docid]
  216. if missing:
  217. print(f"\n警告: {len(missing)} 条链接未能解析出 docid,需人工从 link_url 查看。")
  218. return 0
  219. def cmd_sync(args: argparse.Namespace) -> int:
  220. token = os.environ.get("QIWEI_TOKEN")
  221. if not token:
  222. print("请设置环境变量 QIWEI_TOKEN(控制台租户令牌)", file=sys.stderr)
  223. return 2
  224. guid = args.guid or os.environ.get("QIWEI_GUID")
  225. if not guid:
  226. print("请传入 --guid 或设置 QIWEI_GUID", file=sys.stderr)
  227. return 2
  228. extra = {}
  229. if args.room_id:
  230. extra["fromRoomId"] = args.room_id
  231. raw, refs = sync_messages_and_scan(
  232. token,
  233. guid,
  234. args.msg_seq,
  235. extra_params=extra or None,
  236. )
  237. out_raw = args.out_raw or "output/qiwei_sync_last.json"
  238. os.makedirs(os.path.dirname(out_raw) or ".", exist_ok=True)
  239. with open(out_raw, "w", encoding="utf-8") as f:
  240. json.dump(raw, f, ensure_ascii=False, indent=2)
  241. print(f"原始响应已保存: {out_raw}")
  242. print(f"扫描到文档相关消息: {len(refs)} 条\n")
  243. for r in refs:
  244. print(json.dumps(asdict(r), ensure_ascii=False))
  245. return 0 if refs else 1
  246. def main() -> int:
  247. parser = argparse.ArgumentParser(description="QiWei 会话消息中间接发现在线文档(docid)")
  248. sub = parser.add_subparsers(dest="command", required=True)
  249. p_analyze = sub.add_parser("analyze", help="解析本地回调/存档 JSON")
  250. p_analyze.add_argument("--file", "-f", required=True, help="JSON 文件路径")
  251. p_analyze.set_defaults(func=cmd_analyze)
  252. p_sync = sub.add_parser("sync", help="调用 /msg/syncMsg 拉历史并扫描")
  253. p_sync.add_argument("--guid", help="账号 guid")
  254. p_sync.add_argument("--msg-seq", type=int, default=0, help="起始 msgSeq,首次可传 0")
  255. p_sync.add_argument("--room-id", help="可选:仅关注某群 fromRoomId")
  256. p_sync.add_argument("--out-raw", help="保存完整 API 响应的路径")
  257. p_sync.set_defaults(func=cmd_sync)
  258. args = parser.parse_args()
  259. return args.func(args)
  260. if __name__ == "__main__":
  261. raise SystemExit(main())