test_qcc_proxy.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. """Standalone QCC GET proxy diagnostic. No dependencies or company data output."""
  2. import argparse
  3. from datetime import datetime, timezone
  4. from email.utils import parsedate_to_datetime
  5. import hashlib
  6. import html
  7. from http.client import HTTPConnection, HTTPSConnection, IncompleteRead
  8. import json
  9. import os
  10. import re
  11. from pathlib import Path
  12. import time
  13. from urllib.parse import parse_qsl, quote, quote_plus, urlencode, urlsplit
  14. URL_KEYS = {'search': 'QCC_SEARCH_URL', 'basic': 'QCC_BASIC_URL', 'honor': 'QCC_HONOR_URL'}
  15. CONFIG_KEYS = set(URL_KEYS.values()) | {'QCC_KEY', 'QCC_SECRET_KEY', 'QCC_TIMEOUT_SECONDS'}
  16. def redact(text, secrets):
  17. text = html.unescape(str(text))
  18. for secret in sorted({s for s in secrets if s}, key=len, reverse=True):
  19. variants = {secret, quote(secret, safe=''), quote_plus(secret),
  20. json.dumps(secret, ensure_ascii=True)[1:-1],
  21. json.dumps(secret, ensure_ascii=False)[1:-1]}
  22. for variant in sorted(variants, key=len, reverse=True):
  23. text = re.sub(re.escape(variant), '[REDACTED]', text, flags=re.IGNORECASE)
  24. text = re.sub(r'(?i)((?:proxyToken|QCC_SECRET_KEY|QCC_KEY|Token|searchKey|keyword|key)[\"\s\x27]*[:=][\"\s\x27]*)[^&\s\"\x27<>]+', r'\1[REDACTED]', text)
  25. return text
  26. def error_body(body, secrets):
  27. text = body.decode('utf-8', errors='replace')
  28. try:
  29. value = json.loads(text)
  30. def sanitize(item):
  31. if isinstance(item, dict):
  32. return {k: '[OMITTED]' if k.lower() in {'result', 'data'} else sanitize(v)
  33. for k, v in item.items()}
  34. if isinstance(item, list):
  35. return [sanitize(v) for v in item]
  36. return item
  37. text = json.dumps(sanitize(value), ensure_ascii=False)
  38. except (ValueError, RecursionError):
  39. pass
  40. text = redact(text, secrets)
  41. return {'error_body_preview': text[:2000], 'error_body_truncated': len(text) > 2000}
  42. def load_settings(path, *, env_file_only=False):
  43. values = {}
  44. if path.is_file():
  45. for line in path.read_text(encoding='utf-8-sig').splitlines():
  46. line = line.strip()
  47. if line.startswith('export '):
  48. line = line[7:]
  49. key, sep, value = line.partition('=')
  50. if not sep or key.strip() not in CONFIG_KEYS:
  51. continue
  52. value = value.strip()
  53. if value.startswith(('"', "'")):
  54. end = value.find(value[0], 1)
  55. if end < 0 or (value[end + 1:].strip() and not value[end + 1:].strip().startswith('#')):
  56. raise ValueError('invalid_env_file')
  57. value = value[1:end]
  58. else:
  59. value = value.split(' #', 1)[0].strip()
  60. values[key.strip()] = value
  61. if not env_file_only:
  62. values.update({k: os.environ[k] for k in CONFIG_KEYS if k in os.environ})
  63. return values
  64. def clock_diagnostics(timestamp, date_values, elapsed):
  65. """Date is an intermediary's clock, not a trusted time synchronization source."""
  66. result = {'request_time_utc': datetime.fromtimestamp(int(timestamp), timezone.utc).isoformat()}
  67. if len(date_values) != 1:
  68. return result
  69. try:
  70. remote = parsedate_to_datetime(date_values[0])
  71. if remote.tzinfo is None:
  72. return result
  73. delta = remote.timestamp() - int(timestamp)
  74. except (TypeError, ValueError, OverflowError):
  75. return result
  76. result.update(response_date_minus_request_seconds=round(delta, 3),
  77. clock_skew_suspected=abs(delta) > elapsed + 60)
  78. return result
  79. def validate_payload(api, payload):
  80. """Check the current project's QCC response contracts, never dump results."""
  81. if not isinstance(payload, dict):
  82. return {'ok': False, 'code': 'invalid_response_schema'}
  83. status = str(payload.get('Status', ''))
  84. details = {'provider_status': status if status.isascii() and status.isdigit() and len(status) <= 8 else 'unknown'}
  85. result = payload.get('Result')
  86. if api == 'search' and status == '201' and result in (None, []):
  87. return {'ok': True, 'code': 'no_matches', 'records': 0, **details}
  88. if status != '200':
  89. return {'ok': False, 'code': 'provider_failure', **details}
  90. if api == 'search':
  91. valid = isinstance(result, list) and all(isinstance(r, dict) and
  92. isinstance(r.get('Name'), str) and bool(r['Name'].strip()) and
  93. isinstance(r.get('KeyNo'), str) and bool(r['KeyNo'].strip()) for r in result)
  94. count = len(result) if valid else 0
  95. elif api == 'basic':
  96. valid = isinstance(result, dict) and bool(result) and all(
  97. isinstance(result.get(k), str) and bool(result[k].strip()) for k in ('Name', 'KeyNo'))
  98. count = int(valid)
  99. else:
  100. valid = isinstance(result, dict) and type(result.get('VerifyResult')) is int and result['VerifyResult'] in (0, 1)
  101. rows = result.get('Data') if valid else None
  102. if valid and result['VerifyResult'] == 0 and rows is None:
  103. rows = []
  104. valid = valid and isinstance(rows, list) and all(isinstance(r, dict) for r in rows)
  105. valid = valid and not (result['VerifyResult'] == 0 and rows)
  106. count = len(rows) if valid else 0
  107. if not valid:
  108. return {'ok': False, 'code': 'invalid_result', **details}
  109. return {'ok': True, 'code': 'usable' if count else 'no_records', 'records': count, **details}
  110. def probe(api, values, company, timeout, mode='proxy'):
  111. report = {'ok': False, 'method': 'GET', 'transport': 'http.client',
  112. 'request_connection': 'keep-alive', 'use_environment_proxy': False,
  113. 'url_setting': URL_KEYS[api], 'mode': mode,
  114. 'page_scope': 'first_page' if api != 'basic' else 'single_result'}
  115. if mode not in {'direct', 'proxy'}:
  116. return {**report, 'code': 'invalid_mode'}
  117. key, secret = values.get('QCC_KEY', ''), values.get('QCC_SECRET_KEY', '')
  118. if not key or not secret:
  119. return {**report, 'code': 'missing_qcc_credentials'}
  120. params = ({'searchKey': company, 'pageIndex': 1} if api == 'search' else
  121. {'keyword': company} if api == 'basic' else
  122. {'searchKey': company, 'pageSize': 20, 'pageIndex': 1})
  123. try:
  124. url = values.get(URL_KEYS[api], '')
  125. if not url or any(c.isspace() for c in url) or '\\' in url:
  126. raise ValueError
  127. parts = urlsplit(url)
  128. if (parts.scheme not in {'http', 'https'} or not parts.hostname or
  129. parts.username is not None or parts.password is not None or parts.fragment):
  130. raise ValueError
  131. _ = parts.port
  132. query = parse_qsl(parts.query, keep_blank_values=True)
  133. except ValueError:
  134. return {**report, 'code': 'invalid_proxy_url', 'hint': 'URL须单行HTTP(S)地址,不含空格、反斜杠或用户密码'}
  135. names = [k for k, _ in query]
  136. if mode == 'direct' and query:
  137. return {**report, 'code': 'direct_url_must_not_have_query'}
  138. if mode == 'proxy' and (names.count('servertype') != 1 or names.count('proxyToken') != 1
  139. or not dict(query).get('servertype') or not dict(query).get('proxyToken')):
  140. return {**report, 'code': 'missing_proxy_params', 'hint': '须使用servertype和proxyToken,区分大小写,以&分隔、以=赋值'}
  141. if any(k in {'key', *params} for k in names):
  142. return {**report, 'code': 'duplicate_business_params'}
  143. # Preserve the gateway query bytes and routing value; do not guess/correct its spelling.
  144. target = (parts.path or '/') + '?' + (parts.query + '&' if parts.query else '') + urlencode({'key': key, **params})
  145. timestamp = str(int(time.time()))
  146. token = hashlib.md5((key + timestamp + secret).encode()).hexdigest().upper()
  147. secrets = [key, secret, token, company, *[v for k, v in query if k != 'servertype']]
  148. report.update(endpoint=redact(f'{parts.scheme}://{parts.netloc}{parts.path}', secrets),
  149. servertype=redact(dict(query).get('servertype', ''), secrets),
  150. query_keys=sorted(set(names) | {'key', *params}), request_timespan=timestamp)
  151. def failure(code, body=b'', **details):
  152. report.update(code=code, **details)
  153. report.update(error_body(body, secrets))
  154. return report
  155. headers = {'Token': token, 'Timespan': timestamp, 'Accept': '*/*', 'Connection': 'keep-alive'}
  156. connection = None
  157. started = time.perf_counter()
  158. try:
  159. connection = (HTTPSConnection if parts.scheme == 'https' else HTTPConnection)(parts.hostname, parts.port, timeout=timeout)
  160. connection.request('GET', target, headers=headers)
  161. with connection.getresponse() as response:
  162. report.update(http_status=response.status, response_complete=False)
  163. report.update(clock_diagnostics(timestamp, response.headers.get_all('Date', []),
  164. time.perf_counter() - started))
  165. report['response_headers'] = {
  166. name: [redact(value, secrets)[:500] for value in response.headers.get_all(name, [])][:4]
  167. for name in ('Content-Type', 'Server', 'Date', 'Content-Length', 'Transfer-Encoding',
  168. 'Connection', 'X-Request-ID', 'X-Correlation-ID', 'X-DS-Trace-ID', 'EO-LOG-UUID')
  169. if response.headers.get_all(name)
  170. }
  171. try:
  172. body = response.read(4_000_001)
  173. except IncompleteRead as exc:
  174. report['received_bytes'] = len(exc.partial)
  175. return failure('response_incomplete', exc.partial, error_type='IncompleteRead')
  176. report['received_bytes'] = len(body)
  177. length = response.headers.get('Content-Length', '')
  178. if not response.headers.get('Transfer-Encoding') and length.isascii() and length.isdigit() and len(body) < int(length):
  179. return failure('response_incomplete', body)
  180. if len(body) > 4_000_000:
  181. return failure('response_too_large')
  182. report['response_complete'] = True
  183. if not 200 <= response.status < 300:
  184. return failure('http_error', body)
  185. try:
  186. payload = json.loads(body)
  187. except (ValueError, UnicodeError):
  188. return failure('invalid_response_json', body)
  189. report.update(validate_payload(api, payload))
  190. if not report['ok']:
  191. report.update(error_body(body, secrets))
  192. if report.get('provider_status') == '115':
  193. report['hint'] = ('本次Token已用运行机器当前Unix秒数实时生成。'
  194. '请核对机器UTC时间、QCC_KEY与QCC_SECRET_KEY配对及环境变量覆盖,'
  195. '并检查代理透传Token/Timespan/key。Date偏差仅供诊断,'
  196. '可能来自代理或缓存;60秒是诊断阈值,不是企查查签名有效期。')
  197. except TimeoutError:
  198. report['code'] = 'timeout'
  199. except OSError as exc:
  200. report.update(code='connection_failed', error_type=type(exc).__name__)
  201. except Exception as exc:
  202. report.update(code='request_failed', error_type=type(exc).__name__)
  203. finally:
  204. if connection is not None:
  205. connection.close()
  206. report['elapsed_seconds'] = round(time.perf_counter() - started, 3)
  207. return report
  208. def main():
  209. parser = argparse.ArgumentParser(description='企查查三接口代理GET诊断,不输出密钥或公司正文')
  210. parser.add_argument('--company', required=True, help='要测试的准确企业全称')
  211. parser.add_argument('--api', choices=['all', *URL_KEYS], default='all')
  212. parser.add_argument('--mode', choices=['proxy', 'direct'], default='proxy',
  213. help='默认proxy要求代理参数;direct使用无query的直连URL,传输和签名不变')
  214. parser.add_argument('--timeout', type=float, help='每次请求超时秒数,默认QCC_TIMEOUT_SECONDS或20')
  215. parser.add_argument('--env-file-only', action='store_true', help='仅读取项目.env,忽略同名环境变量')
  216. args = parser.parse_args()
  217. if not args.company.strip():
  218. parser.error('--company不能为空')
  219. path = Path(__file__).resolve().parents[1] / '.env'
  220. try:
  221. settings = load_settings(path, env_file_only=args.env_file_only)
  222. timeout = args.timeout if args.timeout is not None else float(settings.get('QCC_TIMEOUT_SECONDS', '20'))
  223. if not 0 < timeout <= 120:
  224. raise ValueError
  225. except (OSError, ValueError):
  226. print(json.dumps({'ok': False, 'code': 'invalid_env_or_timeout'}))
  227. return 1
  228. checks = {api: probe(api, settings, args.company.strip(), timeout, mode=args.mode)
  229. for api in (URL_KEYS if args.api == 'all' else [args.api])}
  230. ok = all(check['ok'] for check in checks.values())
  231. print(json.dumps({'ok': ok, 'env_file_exists': path.is_file(),
  232. 'env_file_only': args.env_file_only,
  233. 'environment_override_keys': [] if args.env_file_only else sorted(CONFIG_KEYS & os.environ.keys()),
  234. 'checks': checks}, ensure_ascii=False, indent=2))
  235. return 0 if ok else 1
  236. if __name__ == '__main__':
  237. raise SystemExit(main())