build-catalog.py 7.3 KB

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