| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256 |
- """Standalone QCC GET proxy diagnostic. No dependencies or company data output."""
- import argparse
- from datetime import datetime, timezone
- from email.utils import parsedate_to_datetime
- import hashlib
- import html
- from http.client import HTTPConnection, HTTPSConnection, IncompleteRead
- import json
- import os
- import re
- from pathlib import Path
- import time
- from urllib.parse import parse_qsl, quote, quote_plus, urlencode, urlsplit
- URL_KEYS = {'search': 'QCC_SEARCH_URL', 'basic': 'QCC_BASIC_URL', 'honor': 'QCC_HONOR_URL'}
- CONFIG_KEYS = set(URL_KEYS.values()) | {'QCC_KEY', 'QCC_SECRET_KEY', 'QCC_TIMEOUT_SECONDS'}
- def redact(text, secrets):
- text = html.unescape(str(text))
- for secret in sorted({s for s in secrets if s}, key=len, reverse=True):
- variants = {secret, quote(secret, safe=''), quote_plus(secret),
- json.dumps(secret, ensure_ascii=True)[1:-1],
- json.dumps(secret, ensure_ascii=False)[1:-1]}
- for variant in sorted(variants, key=len, reverse=True):
- text = re.sub(re.escape(variant), '[REDACTED]', text, flags=re.IGNORECASE)
- 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)
- return text
- def error_body(body, secrets):
- text = body.decode('utf-8', errors='replace')
- try:
- value = json.loads(text)
- def sanitize(item):
- if isinstance(item, dict):
- return {k: '[OMITTED]' if k.lower() in {'result', 'data'} else sanitize(v)
- for k, v in item.items()}
- if isinstance(item, list):
- return [sanitize(v) for v in item]
- return item
- text = json.dumps(sanitize(value), ensure_ascii=False)
- except (ValueError, RecursionError):
- pass
- text = redact(text, secrets)
- return {'error_body_preview': text[:2000], 'error_body_truncated': len(text) > 2000}
- def load_settings(path, *, env_file_only=False):
- values = {}
- if path.is_file():
- for line in path.read_text(encoding='utf-8-sig').splitlines():
- line = line.strip()
- if line.startswith('export '):
- line = line[7:]
- key, sep, value = line.partition('=')
- if not sep or key.strip() not in CONFIG_KEYS:
- continue
- value = value.strip()
- if value.startswith(('"', "'")):
- end = value.find(value[0], 1)
- if end < 0 or (value[end + 1:].strip() and not value[end + 1:].strip().startswith('#')):
- raise ValueError('invalid_env_file')
- value = value[1:end]
- else:
- value = value.split(' #', 1)[0].strip()
- values[key.strip()] = value
- if not env_file_only:
- values.update({k: os.environ[k] for k in CONFIG_KEYS if k in os.environ})
- return values
- def clock_diagnostics(timestamp, date_values, elapsed):
- """Date is an intermediary's clock, not a trusted time synchronization source."""
- result = {'request_time_utc': datetime.fromtimestamp(int(timestamp), timezone.utc).isoformat()}
- if len(date_values) != 1:
- return result
- try:
- remote = parsedate_to_datetime(date_values[0])
- if remote.tzinfo is None:
- return result
- delta = remote.timestamp() - int(timestamp)
- except (TypeError, ValueError, OverflowError):
- return result
- result.update(response_date_minus_request_seconds=round(delta, 3),
- clock_skew_suspected=abs(delta) > elapsed + 60)
- return result
- def validate_payload(api, payload):
- """Check the current project's QCC response contracts, never dump results."""
- if not isinstance(payload, dict):
- return {'ok': False, 'code': 'invalid_response_schema'}
- status = str(payload.get('Status', ''))
- details = {'provider_status': status if status.isascii() and status.isdigit() and len(status) <= 8 else 'unknown'}
- result = payload.get('Result')
- if api == 'search' and status == '201' and result in (None, []):
- return {'ok': True, 'code': 'no_matches', 'records': 0, **details}
- if status != '200':
- return {'ok': False, 'code': 'provider_failure', **details}
- if api == 'search':
- valid = isinstance(result, list) and all(isinstance(r, dict) and
- isinstance(r.get('Name'), str) and bool(r['Name'].strip()) and
- isinstance(r.get('KeyNo'), str) and bool(r['KeyNo'].strip()) for r in result)
- count = len(result) if valid else 0
- elif api == 'basic':
- valid = isinstance(result, dict) and bool(result) and all(
- isinstance(result.get(k), str) and bool(result[k].strip()) for k in ('Name', 'KeyNo'))
- count = int(valid)
- else:
- valid = isinstance(result, dict) and type(result.get('VerifyResult')) is int and result['VerifyResult'] in (0, 1)
- rows = result.get('Data') if valid else None
- if valid and result['VerifyResult'] == 0 and rows is None:
- rows = []
- valid = valid and isinstance(rows, list) and all(isinstance(r, dict) for r in rows)
- valid = valid and not (result['VerifyResult'] == 0 and rows)
- count = len(rows) if valid else 0
- if not valid:
- return {'ok': False, 'code': 'invalid_result', **details}
- return {'ok': True, 'code': 'usable' if count else 'no_records', 'records': count, **details}
- def probe(api, values, company, timeout, mode='proxy'):
- report = {'ok': False, 'method': 'GET', 'transport': 'http.client',
- 'request_connection': 'keep-alive', 'use_environment_proxy': False,
- 'url_setting': URL_KEYS[api], 'mode': mode,
- 'page_scope': 'first_page' if api != 'basic' else 'single_result'}
- if mode not in {'direct', 'proxy'}:
- return {**report, 'code': 'invalid_mode'}
- key, secret = values.get('QCC_KEY', ''), values.get('QCC_SECRET_KEY', '')
- if not key or not secret:
- return {**report, 'code': 'missing_qcc_credentials'}
- params = ({'searchKey': company, 'pageIndex': 1} if api == 'search' else
- {'keyword': company} if api == 'basic' else
- {'searchKey': company, 'pageSize': 20, 'pageIndex': 1})
- try:
- url = values.get(URL_KEYS[api], '')
- if not url or any(c.isspace() for c in url) or '\\' in url:
- raise ValueError
- parts = urlsplit(url)
- if (parts.scheme not in {'http', 'https'} or not parts.hostname or
- parts.username is not None or parts.password is not None or parts.fragment):
- raise ValueError
- _ = parts.port
- query = parse_qsl(parts.query, keep_blank_values=True)
- except ValueError:
- return {**report, 'code': 'invalid_proxy_url', 'hint': 'URL须单行HTTP(S)地址,不含空格、反斜杠或用户密码'}
- names = [k for k, _ in query]
- if mode == 'direct' and query:
- return {**report, 'code': 'direct_url_must_not_have_query'}
- if mode == 'proxy' and (names.count('servertype') != 1 or names.count('proxyToken') != 1
- or not dict(query).get('servertype') or not dict(query).get('proxyToken')):
- return {**report, 'code': 'missing_proxy_params', 'hint': '须使用servertype和proxyToken,区分大小写,以&分隔、以=赋值'}
- if any(k in {'key', *params} for k in names):
- return {**report, 'code': 'duplicate_business_params'}
- # Preserve the gateway query bytes and routing value; do not guess/correct its spelling.
- target = (parts.path or '/') + '?' + (parts.query + '&' if parts.query else '') + urlencode({'key': key, **params})
- timestamp = str(int(time.time()))
- token = hashlib.md5((key + timestamp + secret).encode()).hexdigest().upper()
- secrets = [key, secret, token, company, *[v for k, v in query if k != 'servertype']]
- report.update(endpoint=redact(f'{parts.scheme}://{parts.netloc}{parts.path}', secrets),
- servertype=redact(dict(query).get('servertype', ''), secrets),
- query_keys=sorted(set(names) | {'key', *params}), request_timespan=timestamp)
- def failure(code, body=b'', **details):
- report.update(code=code, **details)
- report.update(error_body(body, secrets))
- return report
- headers = {'Token': token, 'Timespan': timestamp, 'Accept': '*/*', 'Connection': 'keep-alive'}
- connection = None
- started = time.perf_counter()
- try:
- connection = (HTTPSConnection if parts.scheme == 'https' else HTTPConnection)(parts.hostname, parts.port, timeout=timeout)
- connection.request('GET', target, headers=headers)
- with connection.getresponse() as response:
- report.update(http_status=response.status, response_complete=False)
- report.update(clock_diagnostics(timestamp, response.headers.get_all('Date', []),
- time.perf_counter() - started))
- report['response_headers'] = {
- name: [redact(value, secrets)[:500] for value in response.headers.get_all(name, [])][:4]
- for name in ('Content-Type', 'Server', 'Date', 'Content-Length', 'Transfer-Encoding',
- 'Connection', 'X-Request-ID', 'X-Correlation-ID', 'X-DS-Trace-ID', 'EO-LOG-UUID')
- if response.headers.get_all(name)
- }
- try:
- body = response.read(4_000_001)
- except IncompleteRead as exc:
- report['received_bytes'] = len(exc.partial)
- return failure('response_incomplete', exc.partial, error_type='IncompleteRead')
- report['received_bytes'] = len(body)
- length = response.headers.get('Content-Length', '')
- if not response.headers.get('Transfer-Encoding') and length.isascii() and length.isdigit() and len(body) < int(length):
- return failure('response_incomplete', body)
- if len(body) > 4_000_000:
- return failure('response_too_large')
- report['response_complete'] = True
- if not 200 <= response.status < 300:
- return failure('http_error', body)
- try:
- payload = json.loads(body)
- except (ValueError, UnicodeError):
- return failure('invalid_response_json', body)
- report.update(validate_payload(api, payload))
- if not report['ok']:
- report.update(error_body(body, secrets))
- if report.get('provider_status') == '115':
- report['hint'] = ('本次Token已用运行机器当前Unix秒数实时生成。'
- '请核对机器UTC时间、QCC_KEY与QCC_SECRET_KEY配对及环境变量覆盖,'
- '并检查代理透传Token/Timespan/key。Date偏差仅供诊断,'
- '可能来自代理或缓存;60秒是诊断阈值,不是企查查签名有效期。')
- except TimeoutError:
- report['code'] = 'timeout'
- except OSError as exc:
- report.update(code='connection_failed', error_type=type(exc).__name__)
- except Exception as exc:
- report.update(code='request_failed', error_type=type(exc).__name__)
- finally:
- if connection is not None:
- connection.close()
- report['elapsed_seconds'] = round(time.perf_counter() - started, 3)
- return report
- def main():
- parser = argparse.ArgumentParser(description='企查查三接口代理GET诊断,不输出密钥或公司正文')
- parser.add_argument('--company', required=True, help='要测试的准确企业全称')
- parser.add_argument('--api', choices=['all', *URL_KEYS], default='all')
- parser.add_argument('--mode', choices=['proxy', 'direct'], default='proxy',
- help='默认proxy要求代理参数;direct使用无query的直连URL,传输和签名不变')
- parser.add_argument('--timeout', type=float, help='每次请求超时秒数,默认QCC_TIMEOUT_SECONDS或20')
- parser.add_argument('--env-file-only', action='store_true', help='仅读取项目.env,忽略同名环境变量')
- args = parser.parse_args()
- if not args.company.strip():
- parser.error('--company不能为空')
- path = Path(__file__).resolve().parents[1] / '.env'
- try:
- settings = load_settings(path, env_file_only=args.env_file_only)
- timeout = args.timeout if args.timeout is not None else float(settings.get('QCC_TIMEOUT_SECONDS', '20'))
- if not 0 < timeout <= 120:
- raise ValueError
- except (OSError, ValueError):
- print(json.dumps({'ok': False, 'code': 'invalid_env_or_timeout'}))
- return 1
- checks = {api: probe(api, settings, args.company.strip(), timeout, mode=args.mode)
- for api in (URL_KEYS if args.api == 'all' else [args.api])}
- ok = all(check['ok'] for check in checks.values())
- print(json.dumps({'ok': ok, 'env_file_exists': path.is_file(),
- 'env_file_only': args.env_file_only,
- 'environment_override_keys': [] if args.env_file_only else sorted(CONFIG_KEYS & os.environ.keys()),
- 'checks': checks}, ensure_ascii=False, indent=2))
- return 0 if ok else 1
- if __name__ == '__main__':
- raise SystemExit(main())
|