build-catalog.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. #!/usr/bin/env python3
  2. """Build the enterprise WeChat endpoint catalog from markdown OpenAPI docs."""
  3. import re, yaml, json, html, os, sys, glob
  4. DOCS = sys.argv[1] if len(sys.argv) > 1 else os.environ.get('QIWEI_DOCS_DIR')
  5. OUT = sys.argv[2] if len(sys.argv) > 2 else os.path.join(os.path.dirname(__file__), '..', 'mcp', 'catalog', 'qiwei-endpoints.json')
  6. if not DOCS:
  7. raise SystemExit('请通过第一个参数或 QIWEI_DOCS_DIR 指定接口文档目录')
  8. def strip_html(s):
  9. if not s: return ''
  10. s = re.sub(r'<[^>]+>', '', str(s))
  11. s = html.unescape(s)
  12. return re.sub(r'\s+', ' ', s).strip()
  13. PUBLIC_HIDDEN_PARAMS = {'aid'}
  14. def public_param_description(name, description):
  15. if name == 'clientVersion' and re.search(r'iPad\s*最新版.*Windows\s*最新版', description, re.I):
  16. return '客户端版本由 Fmode 服务自动选择,通常无需填写。'
  17. return description
  18. def simplify_props(schema, required=None, prefix=''):
  19. out = []
  20. props = (schema or {}).get('properties') or {}
  21. req = set((schema or {}).get('required') or [])
  22. for name, spec in props.items():
  23. name = str(name).strip()
  24. if name.lower() in PUBLIC_HIDDEN_PARAMS:
  25. continue
  26. desc = public_param_description(name, strip_html(spec.get('description') or spec.get('title') or ''))
  27. typ = spec.get('type', 'string')
  28. entry = {'name': prefix + name, 'in': 'body', 'type': typ, 'required': name in req}
  29. if desc: entry['desc'] = desc[:600]
  30. if typ == 'object' and spec.get('properties'):
  31. entry['children'] = simplify_props(spec)
  32. if typ == 'array' and isinstance(spec.get('items'), dict) and spec['items'].get('properties'):
  33. entry['children'] = simplify_props(spec['items'])
  34. out.append(entry)
  35. return out
  36. def response_hint(post):
  37. try:
  38. schema = post['responses']['200']['content']['application/json']['schema']
  39. except Exception:
  40. return ''
  41. data = (schema.get('properties') or {}).get('data') or {}
  42. if data.get('type') == 'array':
  43. data = data.get('items') or {}
  44. fields = []
  45. for k, v in (data.get('properties') or {}).items():
  46. d = strip_html(v.get('description') or v.get('title') or '')
  47. fields.append(f"{str(k).strip()}{'('+d[:120]+')' if d else ''}")
  48. return ('data 字段:' + ';'.join(fields[:15])) if fields else ''
  49. endpoints = []
  50. skipped = []
  51. for path in sorted(glob.glob(os.path.join(DOCS, '*.md'))):
  52. fname = os.path.basename(path)
  53. text = open(path, encoding='utf-8').read()
  54. m = re.search(r'```yaml\n(.*?)\n```', text, re.S)
  55. if not m:
  56. skipped.append(fname); continue
  57. try:
  58. spec = yaml.safe_load(m.group(1))
  59. paths = spec['paths']
  60. api_path = next(p for p in ('/api/qw/doApi', '/qiwei/api/qw/doApi', '/api/qw/doFileApi') if p in paths)
  61. post = paths[api_path]['post']
  62. except Exception:
  63. skipped.append(fname); continue
  64. is_file_api = 'doFileApi' in api_path
  65. content = post['requestBody']['content']
  66. content_type = 'application/json' if 'application/json' in content else next(iter(content))
  67. body = content[content_type].get('schema') or {}
  68. props = body.get('properties') or {}
  69. method_desc = (props.get('method') or {}).get('description', '')
  70. mm = re.search(r'(/[a-z]+/[A-Za-z0-9_]+)', strip_html(method_desc))
  71. if not mm:
  72. mm = re.search(r'(/[a-z]+/[A-Za-z0-9_]+)', method_desc)
  73. if not mm and (props.get('method') or {}).get('example'):
  74. mm = re.search(r'(/[a-z]+/[A-Za-z0-9_]+)', str(props['method']['example']))
  75. if not mm:
  76. mm = re.search(r'method[\'"::\s]*[\'"]?(/[a-z]+/[A-Za-z0-9_]+)', text)
  77. if not mm:
  78. skipped.append(fname); continue
  79. method_path = mm.group(1)
  80. title = strip_html(post.get('summary') or fname[:-3])
  81. tags_raw = post.get('tags') or []
  82. category = tags_raw[0].split('/') if tags_raw else []
  83. params_schema = props.get('params')
  84. if params_schema:
  85. params = simplify_props(params_schema)
  86. else:
  87. flat = {'properties': {k: v for k, v in props.items() if k not in ('method', 'tokenId')},
  88. 'required': [k for k in (body.get('required') or []) if k not in ('method', 'tokenId')]}
  89. params = simplify_props(flat)
  90. module = method_path.split('/')[1]
  91. ep = {
  92. 'id': f"{module}.{method_path.split('/')[2]}",
  93. 'module': module,
  94. 'title': title,
  95. 'summary': strip_html(re.sub(r'(?s)<details.*?</details>', '', post.get('description') or ''))[:300],
  96. 'method': method_path,
  97. 'category': [c for c in category if c],
  98. 'docFile': fname,
  99. 'tags': list({module, title, *[c for c in category if c]}),
  100. 'params': params,
  101. }
  102. if is_file_api:
  103. ep['apiPath'] = '/api/qw/doFileApi'
  104. ep['contentType'] = content_type
  105. ep['flatParams'] = True
  106. hint = response_hint(post)
  107. if hint: ep['responseHint'] = hint[:800]
  108. endpoints.append(ep)
  109. # 官方文档未公布 method 的接口,手工登记以保证清单全量可检索
  110. MANUAL_ENDPOINTS = [
  111. {
  112. 'id': 'cloud.bigFileDownloadAsync',
  113. 'module': 'cloud',
  114. 'title': '企微大文件异步下载',
  115. 'summary': '文件大于 20M 使用此接口;下载地址为临时云资源(7–15 天不定期清理);异步结果通过回调通知,根据 requestId 匹配。当前未登记 method 路由值,qiwei_api_call 暂不可调用。',
  116. 'method': None,
  117. 'category': ['API参考', '媒体与运营', '文件与媒体(下载/上传)'],
  118. 'docFile': '企微大文件异步下载.md',
  119. 'tags': ['cloud', '企微大文件异步下载', '媒体与运营', 'API参考', '大文件', '下载'],
  120. 'params': [
  121. {'name': 'guid', 'in': 'body', 'type': 'string', 'required': True, 'desc': '设备唯一标识 guid'},
  122. {'name': 'filekey', 'in': 'body', 'type': 'string', 'required': True, 'desc': '当 fileId 以 * 开头时为空;否则不为空'},
  123. {'name': 'fileId', 'in': 'body', 'type': 'string', 'required': True, 'desc': '文件 Id'},
  124. {'name': 'fileSize', 'in': 'body', 'type': 'integer', 'required': True, 'desc': '文件大小'},
  125. {'name': 'fileType', 'in': 'body', 'type': 'integer', 'required': True, 'desc': '22 大视频 35 文件'},
  126. {'name': 'filename', 'in': 'body', 'type': 'string', 'required': True, 'desc': '文件名'}
  127. ],
  128. 'responseHint': 'data 字段:requestId(请求 Id,用于在回调中匹配异步下载结果)'
  129. }
  130. ]
  131. endpoints.extend(MANUAL_ENDPOINTS)
  132. # de-dup ids
  133. seen = {}
  134. for ep in endpoints:
  135. if ep['id'] in seen:
  136. ep['id'] = ep['id'] + '.' + re.sub(r'\W+', '_', ep['title'])[:24]
  137. seen[ep['id']] = True
  138. catalog = {
  139. 'version': '0.2.0',
  140. 'updatedAt': '2026-07-10',
  141. 'source': '企业微信开放接口资料',
  142. 'gateway': {
  143. 'baseUrl': 'https://server.fmode.cn/api/qiwei',
  144. 'endpoint': 'POST {baseUrl}/doApi',
  145. 'auth': 'Authorization: Bearer <Fmode token>',
  146. 'envelope': 'Fmode 网关请求信封:{"uid": "<本地稳定设备标识>", "method": "<接口 method 路径>", "params": {...}};企业微信接口访问凭据和设备上下文由 Fmode 网关管理。',
  147. 'note': '所有业务请求均通过 Fmode 网关转发的企业微信接口执行。清单未登记的普通 JSON 接口可用 qiwei_api_call 传 rawMethod + params;登录和设备接口必须使用 qiwei_login_* 专用工具。'
  148. },
  149. 'modules': sorted({ep['module'] for ep in endpoints}),
  150. 'endpoints': endpoints,
  151. }
  152. os.makedirs(os.path.dirname(OUT), exist_ok=True)
  153. json.dump(catalog, open(OUT, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
  154. print('endpoints:', len(endpoints))
  155. print('modules:', catalog['modules'])
  156. print('skipped:', skipped)