| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- """
- QiWei:从「会话消息 / 历史同步 / 回调存档」中间接发现在线文档(提取 docid)。
- 依据 doc.qiweapi.com:
- - 回调 cmd=15000,链接消息 msgType=13,字段 linkUrl(见回调结构说明)
- - 同步历史消息:method=/msg/syncMsg(见 api-344613926)
- 用法:
- # 1) 离线:解析已保存的回调 JSON
- python scripts/qiwei_doc_discovery.py analyze --file scripts/samples/qiwei_callbacks_sample.json
- # 2) 在线:拉历史消息并扫描(需环境变量,见 doc/会话存档-文档发现-测试指南.md)
- python scripts/qiwei_doc_discovery.py sync --guid YOUR_GUID --msg-seq 0
- 环境变量(sync 子命令):
- QIWEI_TOKEN 必填
- QIWEI_BASE_URL 默认 http://manager.qiweapi.com/qiwe
- QIWEI_SYNC_METHOD 默认 /msg/syncMsg
- """
- from __future__ import annotations
- import argparse
- import json
- import os
- import re
- import sys
- from dataclasses import asdict, dataclass
- from typing import Any, Iterator
- from urllib.parse import parse_qs, urlparse
- import requests
- # 企微在线文档 / 微盘链接常见形态
- DOC_URL_PATTERNS = (
- re.compile(r"doc\.weixin\.qq\.com", re.I),
- re.compile(r"doc\.work\.weixin\.qq\.com", re.I),
- re.compile(r"wedoc", re.I),
- re.compile(r"txdoc", re.I),
- )
- DOCID_QUERY_KEYS = ("docid", "doc_id", "docId")
- DOCID_PATH_RE = re.compile(
- r"(?:docid|doc_id)[=/]([A-Za-z0-9_\-]+)", re.I
- )
- # 如 https://doc.weixin.qq.com/doc/DOC_OTHER_room2
- DOCID_PATH_SEGMENT_RE = re.compile(
- r"doc\.weixin\.qq\.com/(?:txdoc/[^/?#]+|doc)/([A-Za-z0-9_\-]+)", re.I
- )
- @dataclass
- class DocRef:
- docid: str | None
- link_url: str
- title: str | None
- msg_type: int | None
- room_id: str | int | None
- guid: str | None
- source: str # callback | sync | file
- def extract_docid_from_url(url: str) -> str | None:
- if not url or not isinstance(url, str):
- return None
- parsed = urlparse(url.strip())
- qs = parse_qs(parsed.query)
- for key in DOCID_QUERY_KEYS:
- if key in qs and qs[key]:
- return qs[key][0]
- m = DOCID_PATH_RE.search(url)
- if m:
- return m.group(1)
- m = DOCID_PATH_SEGMENT_RE.search(url)
- if m:
- return m.group(1)
- # 部分链接 docid 在 fragment
- if parsed.fragment:
- m = DOCID_PATH_RE.search(parsed.fragment)
- if m:
- return m.group(1)
- return None
- def is_probable_doc_url(url: str) -> bool:
- if not url:
- return False
- return any(p.search(url) for p in DOC_URL_PATTERNS)
- def _walk(obj: Any) -> Iterator[tuple[str, Any]]:
- if isinstance(obj, dict):
- for k, v in obj.items():
- yield k, v
- yield from _walk(v)
- elif isinstance(obj, list):
- for item in obj:
- yield from _walk(item)
- def extract_from_link_msgdata(msg_data: dict) -> DocRef | None:
- link = msg_data.get("linkUrl") or msg_data.get("link_url")
- if not link or not is_probable_doc_url(str(link)):
- if link and "doc.weixin" not in str(link).lower():
- return None
- if not link:
- return None
- title = msg_data.get("title")
- if isinstance(title, str) and title.isascii() and len(title) > 40:
- try:
- import base64
- title = base64.b64decode(title).decode("utf-8", errors="replace")
- except Exception:
- pass
- return DocRef(
- docid=extract_docid_from_url(str(link)),
- link_url=str(link),
- title=str(title) if title else None,
- msg_type=13,
- room_id=None,
- guid=None,
- source="msgData",
- )
- def extract_doc_refs_from_message_item(item: dict, source: str) -> list[DocRef]:
- refs: list[DocRef] = []
- msg_type = item.get("msgType")
- room_id = item.get("fromRoomId") or item.get("roomId") or item.get("roomid")
- guid = item.get("guid")
- msg_data = item.get("msgData")
- if isinstance(msg_data, dict):
- if msg_type == 13 or "linkUrl" in msg_data or "link_url" in msg_data:
- ref = extract_from_link_msgdata(msg_data)
- if ref:
- ref.msg_type = msg_type
- ref.room_id = room_id
- ref.guid = guid
- ref.source = source
- refs.append(ref)
- # 兜底:整棵 JSON 里搜 doc.weixin 链接
- for key, val in _walk(item):
- if key.lower() in ("linkurl", "link_url", "url", "link") and isinstance(val, str):
- if is_probable_doc_url(val) or "doc.weixin" in val.lower():
- refs.append(
- DocRef(
- docid=extract_docid_from_url(val),
- link_url=val,
- title=None,
- msg_type=msg_type if isinstance(msg_type, int) else None,
- room_id=room_id,
- guid=guid,
- source=f"{source}:walk",
- )
- )
- return refs
- def extract_doc_refs_from_payload(payload: Any, source: str = "callback") -> list[DocRef]:
- refs: list[DocRef] = []
- if isinstance(payload, dict):
- data = payload.get("data")
- if isinstance(data, list):
- for item in data:
- if isinstance(item, dict):
- refs.extend(extract_doc_refs_from_message_item(item, source))
- else:
- refs.extend(extract_doc_refs_from_message_item(payload, source))
- elif isinstance(payload, list):
- for item in payload:
- refs.extend(extract_doc_refs_from_payload(item, source))
- # 去重(按 link_url)
- seen: set[str] = set()
- unique: list[DocRef] = []
- for r in refs:
- if r.link_url not in seen:
- seen.add(r.link_url)
- unique.append(r)
- return unique
- def qiwei_do_api(
- token: str,
- method: str,
- params: dict,
- base_url: str | None = None,
- ) -> dict:
- base = (base_url or os.environ.get("QIWEI_BASE_URL") or "http://manager.qiweapi.com/qiwe").rstrip("/")
- url = f"{base}/api/qw/doApi"
- headers = {
- "Content-Type": "application/json",
- "X-QIWEI-TOKEN": token,
- }
- body = {"method": method, "params": params}
- resp = requests.post(url, headers=headers, json=body, timeout=60)
- resp.raise_for_status()
- return resp.json()
- def sync_messages_and_scan(
- token: str,
- guid: str,
- msg_seq: int = 0,
- *,
- base_url: str | None = None,
- sync_method: str | None = None,
- extra_params: dict | None = None,
- ) -> tuple[dict, list[DocRef]]:
- method = sync_method or os.environ.get("QIWEI_SYNC_METHOD", "/msg/syncMsg")
- params: dict[str, Any] = {"guid": guid, "msgSeq": msg_seq}
- if extra_params:
- params.update(extra_params)
- raw = qiwei_do_api(token, method, params, base_url)
- refs: list[DocRef] = []
- # 响应里可能是 data.syncMsgList 或 data 为列表
- data = raw.get("data")
- if isinstance(data, dict):
- lst = data.get("syncMsgList") or data.get("list") or []
- if isinstance(lst, list):
- for item in lst:
- if isinstance(item, dict):
- refs.extend(extract_doc_refs_from_message_item(item, "sync"))
- elif isinstance(data, list):
- for item in data:
- if isinstance(item, dict):
- refs.extend(extract_doc_refs_from_message_item(item, "sync"))
- refs.extend(extract_doc_refs_from_payload(raw, "sync:raw"))
- return raw, refs
- def cmd_analyze(args: argparse.Namespace) -> int:
- path = args.file
- with open(path, encoding="utf-8") as f:
- payload = json.load(f)
- refs = extract_doc_refs_from_payload(payload, "file")
- print(f"文件: {path}")
- print(f"发现文档相关消息: {len(refs)} 条\n")
- for i, r in enumerate(refs, 1):
- print(f"--- [{i}] ---")
- print(json.dumps(asdict(r), ensure_ascii=False, indent=2))
- if not refs:
- print("未发现含 doc.weixin / docid 的链接。请确认样本中含 msgType=13 且 linkUrl 指向在线文档。")
- return 1
- missing = [r for r in refs if not r.docid]
- if missing:
- print(f"\n警告: {len(missing)} 条链接未能解析出 docid,需人工从 link_url 查看。")
- return 0
- def cmd_sync(args: argparse.Namespace) -> int:
- token = os.environ.get("QIWEI_TOKEN")
- if not token:
- print("请设置环境变量 QIWEI_TOKEN(控制台租户令牌)", file=sys.stderr)
- return 2
- guid = args.guid or os.environ.get("QIWEI_GUID")
- if not guid:
- print("请传入 --guid 或设置 QIWEI_GUID", file=sys.stderr)
- return 2
- extra = {}
- if args.room_id:
- extra["fromRoomId"] = args.room_id
- raw, refs = sync_messages_and_scan(
- token,
- guid,
- args.msg_seq,
- extra_params=extra or None,
- )
- out_raw = args.out_raw or "output/qiwei_sync_last.json"
- os.makedirs(os.path.dirname(out_raw) or ".", exist_ok=True)
- with open(out_raw, "w", encoding="utf-8") as f:
- json.dump(raw, f, ensure_ascii=False, indent=2)
- print(f"原始响应已保存: {out_raw}")
- print(f"扫描到文档相关消息: {len(refs)} 条\n")
- for r in refs:
- print(json.dumps(asdict(r), ensure_ascii=False))
- return 0 if refs else 1
- def main() -> int:
- parser = argparse.ArgumentParser(description="QiWei 会话消息中间接发现在线文档(docid)")
- sub = parser.add_subparsers(dest="command", required=True)
- p_analyze = sub.add_parser("analyze", help="解析本地回调/存档 JSON")
- p_analyze.add_argument("--file", "-f", required=True, help="JSON 文件路径")
- p_analyze.set_defaults(func=cmd_analyze)
- p_sync = sub.add_parser("sync", help="调用 /msg/syncMsg 拉历史并扫描")
- p_sync.add_argument("--guid", help="账号 guid")
- p_sync.add_argument("--msg-seq", type=int, default=0, help="起始 msgSeq,首次可传 0")
- p_sync.add_argument("--room-id", help="可选:仅关注某群 fromRoomId")
- p_sync.add_argument("--out-raw", help="保存完整 API 响应的路径")
- p_sync.set_defaults(func=cmd_sync)
- args = parser.parse_args()
- return args.func(args)
- if __name__ == "__main__":
- raise SystemExit(main())
|