API范例(python)

# -*- coding: utf-8 -*-
"""
项目名称:帕依提提 PayItiti 数据集API调用工具
版权所有:©2026 帕依提提 保留所有权利
安全重要提示:
    1、AK/SK为个人账户付费凭证,泄露会导致Token被盗刷扣费,请勿转发、上传公网、代码托管平台
    2、禁止将AK/SK明文写入代码上传网络,推荐使用.env本地文件或系统环境变量注入密钥
    3、单密钥每分钟最多请求60次,超限将临时封禁5分钟
    4、单次读取条数限制 1~5000,超出会返回参数错误
    5、线上公网环境强制使用HTTPS,HTTP仅允许本地127.0.0.1调试,明文传输会导致密钥劫持
    6、SK仅本地参与签名计算,不会通过网络传输,抓包无法单独盗用密钥
    7、所有接口请求永久留存审计日志,用于对账、风控溯源
调用模式:
模式1:统一limit(支持分页,WANT_MAX_ROWS控制最大获取条数,不会拉全量)
模式2:limit_list多数据集自定义条数(不分页,少量查询使用)
使用指引:
    方式1(推荐本地):项目根目录新建.env填入PAYITITI_AK、PAYITITI_SK、API_BASE_URL
    方式2(服务器):设置系统环境变量 PAYITITI_AK / PAYITITI_SK / API_BASE_URL
"""
import requests
import json
import time
import hashlib
import re
import os
from typing import Dict, List, Any
from dotenv import load_dotenv
# 加载本地.env文件,优先级:.env文件 > 系统全局环境变量
load_dotenv()
# ==================== 【全局配置区 仅修改此处】 ====================
# 密钥:优先.env > 系统环境变量 > 空字符串兜底
API_AK = os.getenv("PAYITITI_AK", "")
API_SK = os.getenv("PAYITITI_SK", "")
# 接口地址:支持.env自定义覆盖,兜底本地调试地址
BASE_URL = os.getenv("API_BASE_URL", "")

# 请求超时时间
TIMEOUT = 30
# 单次最大行数限制
SERVER_MAX_LIMIT = 5000
# 分页单页大小
PAGE_SIZE = 1
# 自定义最多拉取多少条,达到立即停止分页
WANT_MAX_ROWS = 5
# 接口失败重试次数
RETRY_COUNT = 2
# 基础重试间隔,采用指数退避
BASE_RETRY_DELAY = 2

# 【新增开关】True=保存本地JSON文件;False=仅控制台打印完整JSON不存文件
SAVE_TO_LOCAL = False
EXPORT_FILE_NAME = "../dataset_full_data.json"

# 模式开关 True=多数据集自定义条数;False=统一limit分页模式
USE_CUSTOM_LIMIT = False

# 模式1 统一limit配置(分页可用)
TARGET_DATASET_IDS = "10160"
GLOBAL_LIMIT = PAGE_SIZE

# 模式2 多数据集自定义条数(不分页,ID与条数一一对应)
CUSTOM_DATASET_IDS = "10160,10105"
CUSTOM_LIMIT_LIST = "10,20"

# 签名时间戳有效期:服务端允许5分钟误差(300秒)
SIGN_EXPIRE_SECONDS = 300
# =================================================================


class DatasetAPIClient:
    def __init__(self):
        self.all_full_data: List[Dict[str, Any]] = []
        self.dataset_total_map: Dict[int, int] = {}
        self.dataset_deduct_map: Dict[int, int] = {}
        self.want_max = WANT_MAX_ROWS
        self.save_file_switch = SAVE_TO_LOCAL
        self.export_name = EXPORT_FILE_NAME
        # 清理密钥、地址首尾空格
        self.ak = API_AK.strip()
        self.sk = API_SK.strip()
        self.base_url = BASE_URL.strip()
        # 前置安全校验 + 参数合法性校验
        self._security_check()
        self._check_config()

    def _security_check(self):
        """运行时安全自检,拦截高危调用场景"""
        # 公网环境禁止HTTP明文传输
        if not self.base_url.startswith("http://127.0.0.1") and self.base_url.startswith("http://"):
            raise Exception(
                "安全拦截:公网环境禁止使用HTTP明文接口!\n"
                "请替换为平台HTTPS正式域名,明文传输会导致AK/SK被劫持盗刷Token"
            )
        # 密钥为空拦截提示
        if not self.ak or not self.sk:
            raise Exception(
                "密钥未配置!三种配置方式:\n"
                "1. 项目根目录新建.env文件,填写PAYITITI_AK、PAYITITI_SK(本地推荐)\n"
                "2. 设置系统环境变量 PAYITITI_AK、PAYITITI_SK(服务器使用)\n"
                "3. 本地临时填写API_AK/API_SK,使用完成立刻清空,禁止分享代码文件"
            )

    def _check_config(self):
        """配置合法性校验,提前抛出错误阻断运行"""
        # AK 32位十六进制强校验
        if not re.fullmatch(r"[0-9a-fA-F]{32}", self.ak):
            raise Exception("AK格式错误,标准AK为32位十六进制字符串,请重新复制无空格密钥")
        # SK 64位十六进制强校验
        if not re.fullmatch(r"[0-9a-fA-F]{64}", self.sk):
            raise Exception("SK格式错误,标准SK为64位十六进制字符串,请重新复制无空格密钥")
        if PAGE_SIZE <= 0 or PAGE_SIZE > SERVER_MAX_LIMIT:
            raise Exception(f"PAGE_SIZE 范围必须 1~{SERVER_MAX_LIMIT}")
        if WANT_MAX_ROWS <= 0:
            raise Exception("WANT_MAX_ROWS 必须大于0")
        if not self.base_url:
            raise Exception("API接口地址BASE_URL不能为空,请检查.env或代码默认配置")

    def _generate_signature(self, dataset_ids: str, limit: int, offset: int, limit_list: str = None) -> tuple[str, str]:
        """
        生成时间戳 + 请求签名
        return: timestamp, signature
        """
        timestamp = str(int(time.time()))
        # 拼接原始签名字符串,无参数填空字符串
        lmt_str = str(limit) if limit else ""
        off_str = str(offset)
        ll_str = limit_list if limit_list else ""
        raw_sign = f"{timestamp}{dataset_ids}{lmt_str}{off_str}{ll_str}"
        # 拼接SK做sha256
        content = raw_sign + self.sk
        sha256_obj = hashlib.sha256(content.encode("utf-8"))
        signature = sha256_obj.hexdigest().lower()
        return timestamp, signature

    def _build_headers(self, timestamp: str, signature: str) -> Dict[str, str]:
        """构造请求头,仅传输AK、时间戳、签名,SK不上传网络"""
        return {
            "X-AK": self.ak,
            "X-Timestamp": timestamp,
            "X-Signature": signature,
            "User-Agent": "PayItiti-Dataset-Client/2.0 CustomScript"
        }

    def single_page_request(self, dataset_ids: str, limit: int, offset: int = 0, limit_list: str = None) -> Dict[str, Any]:
        """单页请求,指数退避重试,敏感信息不打印防止密钥泄露"""
        params = {
            "dataset_id": dataset_ids,
            "offset": offset
        }
        if limit_list:
            params["limit_list"] = limit_list
        else:
            safe_limit = min(limit, SERVER_MAX_LIMIT)
            params["limit"] = safe_limit

        for retry in range(RETRY_COUNT + 1):
            current_delay = BASE_RETRY_DELAY * (2 ** retry)
            try:
                # 每次请求实时生成全新签名与时间戳,防重放
                ts, sign = self._generate_signature(dataset_ids, safe_limit if not limit_list else 0, offset, limit_list)
                headers = self._build_headers(ts, sign)

                resp = requests.get(
                    url=self.base_url,
                    headers=headers,
                    params=params,
                    timeout=TIMEOUT
                )
                print(f"\n===== 请求 offset={offset} | 重试轮次 {retry}/{RETRY_COUNT} 延迟{current_delay}s =====")
                print(f"HTTP 状态码: {resp.status_code}")

                # 非200状态码仅打印返回文本,屏蔽请求头避免密钥输出控制台
                if resp.status_code != 200:
                    print(f"【服务端拒绝请求】原始返回内容:{resp.text.strip()[:500]}")
                    if retry < RETRY_COUNT:
                        print(f"{current_delay}s后进行下一次重试...")
                        time.sleep(current_delay)
                        continue
                    raise Exception(f"接口返回非200状态码 {resp.status_code},已耗尽重试次数")

                # 正常200解析JSON
                resp_data = resp.json()
                print("【当前分页完整接口返回JSON】")
                print(json.dumps(resp_data, ensure_ascii=False, indent=2))
                return resp_data

            except requests.exceptions.Timeout:
                print(f"【请求超时】{current_delay}s后重试")
                time.sleep(current_delay)
            except json.JSONDecodeError:
                resp_text = resp.text if "resp" in locals() else "无返回内容"
                print(f"【JSON解析失败】接口原始返回:{resp_text[:500]}")
                time.sleep(current_delay)
            except Exception as e:
                # 异常截断打印,不输出底层原始请求对象,杜绝密钥泄露
                print(f"【网络请求异常】{str(e)[:200]},{current_delay}s后重试")
                time.sleep(current_delay)

        raise Exception("全部重试失败,终止任务")

    def fetch_custom_limit_batch(self):
        """模式2:多数据集自定义条数,单次请求不分页"""
        print("===== 进入【多数据集自定义条数】模式(无分页) =====")
        resp_data = self.single_page_request(
            dataset_ids=CUSTOM_DATASET_IDS,
            limit=0,
            offset=0,
            limit_list=CUSTOM_LIMIT_LIST
        )
        self.handle_response(resp_data)
        self.print_statistic()
        self.output_result()

    def fetch_single_dataset_page(self, ds_id: int):
        """模式1:单数据集分页,严格控制最大获取条数"""
        print(f"\n===== 开始读取数据集 ID={ds_id},最大获取 {self.want_max} 条 =====")
        offset = 0
        total_rows = 0
        total_deduct = 0

        while True:
            # 达到设定条数上限,直接停止分页
            if total_rows >= self.want_max:
                print(f"✅ 已到达设定上限 {self.want_max} 条,停止分页请求")
                break

            resp_data = self.single_page_request(str(ds_id), GLOBAL_LIMIT, offset)
            batch_list = resp_data.get("data", {}).get("batch", [])
            batch_info = batch_list[0] if batch_list else {}

            # 单数据集读取失败直接跳出循环,不中断整体任务
            if batch_info.get("status") != "ok":
                print(f"❌ 数据集{ds_id}读取失败:{batch_info.get('msg')}")
                break

            page_data = batch_info.get("list", [])
            page_cost = batch_info.get("deduct_token", 0)
            has_next = batch_info.get("has_more", False)

            # 截断超出最大获取条数的数据
            remain = self.want_max - total_rows
            if len(page_data) > remain:
                page_data = page_data[:remain]

            self.all_full_data.extend(page_data)
            curr_count = len(page_data)
            total_rows += curr_count
            total_deduct += page_cost
            print(f"本页获取 {curr_count} 条 | 累计 {total_rows}/{self.want_max} | 本页扣费 {page_cost} Token")

            # 无下一页直接结束当前数据集分页
            if not has_next:
                print(f"✅ 数据集{ds_id}无更多数据,本次合计读取 {total_rows} 条")
                self.dataset_total_map[ds_id] = total_rows
                self.dataset_deduct_map[ds_id] = total_deduct
                break

            offset += GLOBAL_LIMIT
            # 轻微节流降低限流触发概率
            time.sleep(0.3)

    def fetch_unified_batch(self):
        """模式1:统一limit分页入口,支持多数据集循环读取"""
        ds_str_arr = TARGET_DATASET_IDS.split(",")
        ds_id_arr = [int(ds.strip()) for ds in ds_str_arr if ds.strip().isdigit()]
        if not ds_id_arr:
            raise Exception("TARGET_DATASET_IDS 数据集ID配置非法")

        for ds in ds_id_arr:
            self.fetch_single_dataset_page(ds)

        self.print_statistic()
        self.output_result()

    def handle_response(self, resp_data: Dict[str, Any]):
        """处理不分页批量模式返回结果"""
        code = resp_data.get("code", -1)
        msg = resp_data.get("message", "")
        batch_arr = resp_data.get("data", {}).get("batch", [])
        print(f"接口返回 code={code} 提示:{msg}")

        for item in batch_arr:
            ds_id = item.get("dataset_id")
            status = item.get("status")
            data_list = item.get("list", [])
            cost = item.get("deduct_token", 0)
            if status == "ok":
                self.all_full_data.extend(data_list)
                cnt = len(data_list)
                self.dataset_total_map[ds_id] = self.dataset_total_map.get(ds_id, 0) + cnt
                self.dataset_deduct_map[ds_id] = self.dataset_deduct_map.get(ds_id, 0) + cost
                print(f"✅ 数据集{ds_id}读取成功,获取 {cnt} 条,扣费 {cost} Token")
            else:
                print(f"❌ 数据集{ds_id}读取失败:{item.get('msg')}")

    def print_statistic(self):
        """打印全量读取汇总统计"""
        print("\n==================== 读取汇总统计 ====================")
        print(f"预设最大获取条数:{self.want_max}")
        print(f"本次实际获取总条数:{len(self.all_full_data)}")
        print("分数据集明细:")
        for ds, cnt in self.dataset_total_map.items():
            print(f"  数据集{ds}:{cnt} 条,累计扣费 {self.dataset_deduct_map[ds]} Token")
        print("======================================================")

    def output_result(self):
        """统一输出逻辑:控制台打印 + 可选本地文件导出(覆盖二次确认)"""
        print("\n==================== 全部分页拼接完成,完整数据JSON输出 ====================")
        full_json_str = json.dumps(self.all_full_data, ensure_ascii=False, indent=2)
        # 控制台固定打印拼接后完整数据
        print(full_json_str)

        # 开启导出时增加文件覆盖确认弹窗
        if self.save_file_switch:
            if os.path.exists(self.export_name):
                choice = input(f"警告:文件 {self.export_name} 已存在,是否覆盖?(y/n)").strip().lower()
                if choice != "y":
                    print("取消文件导出")
                    return
            try:
                with open(self.export_name, "w", encoding="utf-8") as f:
                    f.write(full_json_str)
                print(f"\n✅ 已同步保存至本地文件:{self.export_name}")
            except Exception as e:
                print(f"\n❌ 文件保存失败:{str(e)}")
        else:
            print("\nℹ️ 未开启本地保存,仅控制台输出JSON")

    def run(self):
        """程序统一入口"""
        try:
            if USE_CUSTOM_LIMIT:
                self.fetch_custom_limit_batch()
            else:
                self.fetch_unified_batch()
        except Exception as err:
            print(f"\n❌ 任务执行终止:{str(err)}")


if __name__ == "__main__":
    client = DatasetAPIClient()
    client.run()