Sources: extracted from two upstream archives (skill-repo, skills-main),
merged with the following policy:
- 15 broken symlinks (pointing to /Users/jameslee/.cc-switch/skills or
../../.agents/skills on a foreign machine) discarded
- 3 real name collisions with identical content (ai-pair, ifind-http-api,
zhipu-websearch) kept as one copy
- Functional overlaps deduped keeping the strongest variant:
- docx family: kept docx (official, full toolchain) + docx-cn
(GB/T 9704 Chinese official-document constants),
dropped docx_writer (no scripts, name collided with docx)
- humanizer family: kept humanizer-zh (6 zh reference docs),
dropped humanizer (en, redundant for CN workflow)
- Skills that only ran in a foreign environment removed:
ablemind-ops, app-publish, hlb-design-system, openclaw-adj-skill,
claude-driver
- alphapai excluded from this public repo because its SKILL.md hard-coded
live credentials
Result: 25 skills, 572 files, ~7.5 MB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
381 lines
14 KiB
Python
381 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
养老金产品 SQLite 数据库构建脚本
|
||
|
||
从 iFinD HTTP API 批量获取养老金产品基础信息并存入 SQLite 数据库。
|
||
数据源:ifind/基础信息数据.md 中的 1722 个 .YLJ 产品代码。
|
||
|
||
用法:
|
||
# 使用环境变量
|
||
export IFIND_REFRESH_TOKEN="your_refresh_token"
|
||
python build_pension_db.py
|
||
|
||
# 或直接传参
|
||
python build_pension_db.py --refresh-token "your_refresh_token"
|
||
|
||
# 指定输出路径
|
||
python build_pension_db.py --db-path /path/to/pension_products.db
|
||
|
||
# 从自定义源文件提取代码
|
||
python build_pension_db.py --source /path/to/基础信息数据.md
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import requests
|
||
|
||
# ---------- 配置 ----------
|
||
|
||
BASE_URL = "https://quantapi.51ifind.com/api/v1"
|
||
BATCH_SIZE = 50 # 每批查询的代码数量,避免请求过大
|
||
RETRY_MAX = 3
|
||
RETRY_DELAY = 2 # 秒
|
||
|
||
# 管理人前缀映射
|
||
MANAGER_MAP = {
|
||
"00": "全国社保基金理事会", "02": "博时基金", "05": "大成基金",
|
||
"07": "工银瑞信", "10": "海富通基金", "11": "华夏基金",
|
||
"15": "嘉实基金", "18": "南方基金", "20": "鹏华基金",
|
||
"48": "易方达基金", "51": "银华基金",
|
||
"CJ": "长江养老", "CL": "长城人寿", "HT": "华泰资产",
|
||
"JX": "建信养老金", "LC": "长量基金", "PA": "平安养老",
|
||
"RB": "人保资产", "TP": "太平养老", "TZ": "泰康资产",
|
||
"XH": "新华养老", "ZJ": "中金公司", "ZX": "中信证券",
|
||
}
|
||
|
||
# 产品类型映射
|
||
TYPE_MAP = {
|
||
"A": "权益型", "B": "混合型", "C": "债券型", "D": "货币型",
|
||
}
|
||
|
||
BOND_SUBTYPE_MAP = {
|
||
"Ca": "普通债券", "Cb": "信用债/定制类", "Cc": "债券C子类",
|
||
"Cd": "债券D子类", "Ce": "债券E子类",
|
||
"Cf": "定制固收F", "Cg": "定制固收G", "Ch": "债券H子类",
|
||
}
|
||
|
||
|
||
# ---------- 工具函数 ----------
|
||
|
||
def parse_code(code: str) -> dict:
|
||
"""解析养老金产品代码,提取管理人前缀、产品类型等。"""
|
||
base = code.replace(".YLJ", "")
|
||
|
||
# 尝试匹配:先匹配2字母前缀(CJ/CL/HT等),再匹配2数字前缀
|
||
manager_prefix = None
|
||
product_type = None
|
||
bond_subtype = None
|
||
|
||
for prefix in sorted(MANAGER_MAP.keys(), key=len, reverse=True):
|
||
if base.startswith(prefix):
|
||
manager_prefix = prefix
|
||
remainder = base[len(prefix):]
|
||
break
|
||
else:
|
||
# 未匹配到已知前缀,取前2字符
|
||
manager_prefix = base[:2]
|
||
remainder = base[2:]
|
||
|
||
# 从remainder提取产品类型
|
||
if remainder:
|
||
first_char = remainder[0].upper()
|
||
if first_char in TYPE_MAP:
|
||
product_type = first_char
|
||
if first_char == "C" and len(remainder) > 1:
|
||
second_char = remainder[1]
|
||
if second_char.isalpha():
|
||
bond_subtype = f"C{second_char}"
|
||
else:
|
||
product_type = "未知"
|
||
|
||
return {
|
||
"manager_prefix": manager_prefix,
|
||
"manager_name": MANAGER_MAP.get(manager_prefix, "未知"),
|
||
"product_type": product_type or "未知",
|
||
"product_type_name": TYPE_MAP.get(product_type, "未知"),
|
||
"bond_subtype": bond_subtype,
|
||
"bond_subtype_name": BOND_SUBTYPE_MAP.get(bond_subtype),
|
||
}
|
||
|
||
|
||
def extract_codes_from_md(filepath: str) -> list[str]:
|
||
"""从基础信息数据.md中提取所有 .YLJ 产品代码。"""
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
codes = sorted(set(re.findall(r"[A-Za-z0-9]+\.YLJ", content)))
|
||
print(f"从 {filepath} 提取到 {len(codes)} 个产品代码")
|
||
return codes
|
||
|
||
|
||
def get_access_token(refresh_token: str) -> str:
|
||
"""获取 iFinD access_token。"""
|
||
resp = requests.post(
|
||
f"{BASE_URL}/get_access_token",
|
||
headers={"Content-Type": "application/json", "refresh_token": refresh_token},
|
||
)
|
||
data = resp.json()
|
||
if "data" in data and "access_token" in data["data"]:
|
||
print("access_token 获取成功")
|
||
return data["data"]["access_token"]
|
||
raise RuntimeError(f"获取 access_token 失败: {data}")
|
||
|
||
|
||
def fetch_basic_data(access_token: str, codes: list[str], indicators: list[dict]) -> dict:
|
||
"""调用 basic_data_service 获取基础数据,带重试。"""
|
||
headers = {"Content-Type": "application/json", "access_token": access_token}
|
||
params = {
|
||
"codes": ",".join(codes),
|
||
"indipara": indicators,
|
||
}
|
||
|
||
for attempt in range(RETRY_MAX):
|
||
try:
|
||
resp = requests.post(
|
||
f"{BASE_URL}/basic_data_service",
|
||
json=params,
|
||
headers=headers,
|
||
timeout=30,
|
||
)
|
||
data = resp.json()
|
||
if data.get("errorcode") == 0:
|
||
return data
|
||
print(f" API 错误 {data.get('errorcode')}: {data.get('errmsg', '')}")
|
||
if data.get("errorcode") in (-1020, -1021):
|
||
# 流量限制,等待后重试
|
||
time.sleep(RETRY_DELAY * (attempt + 1))
|
||
continue
|
||
return data
|
||
except requests.exceptions.RequestException as e:
|
||
print(f" 请求异常 (尝试 {attempt+1}/{RETRY_MAX}): {e}")
|
||
time.sleep(RETRY_DELAY * (attempt + 1))
|
||
|
||
return {"errorcode": -9999, "errmsg": "max retries exceeded"}
|
||
|
||
|
||
# ---------- 数据库操作 ----------
|
||
|
||
def create_db(db_path: str) -> sqlite3.Connection:
|
||
"""创建 SQLite 数据库和表结构。"""
|
||
conn = sqlite3.connect(db_path)
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS pension_products (
|
||
code TEXT PRIMARY KEY,
|
||
name TEXT,
|
||
official_name TEXT,
|
||
manager_prefix TEXT,
|
||
manager_name TEXT,
|
||
product_type TEXT,
|
||
product_type_name TEXT,
|
||
bond_subtype TEXT,
|
||
bond_subtype_name TEXT,
|
||
establishment_date TEXT,
|
||
expiry_date TEXT,
|
||
last_updated TEXT DEFAULT (datetime('now', 'localtime'))
|
||
)
|
||
""")
|
||
conn.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_name ON pension_products(name)
|
||
""")
|
||
conn.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_manager ON pension_products(manager_prefix)
|
||
""")
|
||
conn.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_type ON pension_products(product_type)
|
||
""")
|
||
# 全文搜索虚拟表
|
||
conn.execute("""
|
||
CREATE VIRTUAL TABLE IF NOT EXISTS pension_fts USING fts5(
|
||
code, name, official_name, manager_name, product_type_name,
|
||
content='pension_products',
|
||
content_rowid='rowid'
|
||
)
|
||
""")
|
||
conn.execute("""
|
||
CREATE TRIGGER IF NOT EXISTS pension_ai AFTER INSERT ON pension_products BEGIN
|
||
INSERT INTO pension_fts(rowid, code, name, official_name, manager_name, product_type_name)
|
||
VALUES (new.rowid, new.code, new.name, new.official_name, new.manager_name, new.product_type_name);
|
||
END
|
||
""")
|
||
conn.execute("""
|
||
CREATE TRIGGER IF NOT EXISTS pension_ad AFTER DELETE ON pension_products BEGIN
|
||
INSERT INTO pension_fts(pension_fts, rowid, code, name, official_name, manager_name, product_type_name)
|
||
VALUES ('delete', old.rowid, old.code, old.name, old.official_name, old.manager_name, old.product_type_name);
|
||
END
|
||
""")
|
||
conn.execute("""
|
||
CREATE TRIGGER IF NOT EXISTS pension_au AFTER UPDATE ON pension_products BEGIN
|
||
INSERT INTO pension_fts(pension_fts, rowid, code, name, official_name, manager_name, product_type_name)
|
||
VALUES ('delete', old.rowid, old.code, old.name, old.official_name, old.manager_name, old.product_type_name);
|
||
INSERT INTO pension_fts(rowid, code, name, official_name, manager_name, product_type_name)
|
||
VALUES (new.rowid, new.code, new.name, new.official_name, new.manager_name, new.product_type_name);
|
||
END
|
||
""")
|
||
conn.commit()
|
||
return conn
|
||
|
||
|
||
def upsert_products(conn: sqlite3.Connection, records: list[dict]):
|
||
"""批量插入或更新产品记录。"""
|
||
conn.executemany("""
|
||
INSERT INTO pension_products (
|
||
code, name, official_name, manager_prefix, manager_name,
|
||
product_type, product_type_name, bond_subtype, bond_subtype_name,
|
||
establishment_date, expiry_date, last_updated
|
||
) VALUES (
|
||
:code, :name, :official_name, :manager_prefix, :manager_name,
|
||
:product_type, :product_type_name, :bond_subtype, :bond_subtype_name,
|
||
:establishment_date, :expiry_date, datetime('now', 'localtime')
|
||
)
|
||
ON CONFLICT(code) DO UPDATE SET
|
||
name = excluded.name,
|
||
official_name = excluded.official_name,
|
||
establishment_date = excluded.establishment_date,
|
||
expiry_date = excluded.expiry_date,
|
||
last_updated = excluded.last_updated
|
||
""", records)
|
||
conn.commit()
|
||
|
||
|
||
# ---------- 主流程 ----------
|
||
|
||
def build_database(codes: list[str], access_token: str, db_path: str, batch_size: int = BATCH_SIZE):
|
||
"""主构建流程:批量获取数据并写入数据库。"""
|
||
conn = create_db(db_path)
|
||
|
||
# 指标列表
|
||
indicators = [
|
||
{"indicator": "ths_fund_short_name_fund", "indiparams": [""]},
|
||
{"indicator": "ths_fund_official_short_name_fund", "indiparams": [""]},
|
||
{"indicator": "ths_fund_establishment_date_fund", "indiparams": [""]},
|
||
{"indicator": "ths_fund_expiry_date_fund", "indiparams": [""]},
|
||
]
|
||
|
||
total = len(codes)
|
||
success_count = 0
|
||
error_count = 0
|
||
|
||
for i in range(0, total, batch_size):
|
||
batch = codes[i:i + batch_size]
|
||
batch_num = i // batch_size + 1
|
||
total_batches = (total + batch_size - 1) // batch_size
|
||
print(f"[{batch_num}/{total_batches}] 获取 {len(batch)} 个产品数据...")
|
||
|
||
data = fetch_basic_data(access_token, batch, indicators)
|
||
|
||
if data.get("errorcode") != 0:
|
||
print(f" 批次失败: {data.get('errmsg', '未知错误')}")
|
||
error_count += len(batch)
|
||
continue
|
||
|
||
# 解析返回数据
|
||
tables = data.get("tables", [])
|
||
records = []
|
||
for table in tables:
|
||
code = table.get("thscode", "")
|
||
row = table.get("table", {})
|
||
|
||
# basic_data_service 返回的 table 是 {indicator: [value]} 格式
|
||
name = _extract_value(row, "ths_fund_short_name_fund")
|
||
official_name = _extract_value(row, "ths_fund_official_short_name_fund")
|
||
est_date = _extract_value(row, "ths_fund_establishment_date_fund")
|
||
exp_date = _extract_value(row, "ths_fund_expiry_date_fund")
|
||
|
||
parsed = parse_code(code)
|
||
records.append({
|
||
"code": code,
|
||
"name": name,
|
||
"official_name": official_name,
|
||
"establishment_date": est_date,
|
||
"expiry_date": exp_date,
|
||
**parsed,
|
||
})
|
||
|
||
if records:
|
||
upsert_products(conn, records)
|
||
success_count += len(records)
|
||
print(f" 写入 {len(records)} 条记录")
|
||
|
||
# 请求间隔,避免触发限流
|
||
if i + batch_size < total:
|
||
time.sleep(0.5)
|
||
|
||
conn.close()
|
||
print(f"\n构建完成: {db_path}")
|
||
print(f" 成功: {success_count}, 失败: {error_count}, 总计: {total}")
|
||
|
||
|
||
def _extract_value(table: dict, indicator: str):
|
||
"""从 basic_data_service 返回的 table 中提取指标值。"""
|
||
vals = table.get(indicator, [])
|
||
if vals and len(vals) > 0:
|
||
v = vals[0]
|
||
if v and str(v).strip() and str(v).strip() != "--":
|
||
return str(v).strip()
|
||
return None
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="构建养老金产品 SQLite 数据库")
|
||
parser.add_argument("--refresh-token", default=os.environ.get("IFIND_REFRESH_TOKEN"),
|
||
help="iFinD refresh_token (或设置 IFIND_REFRESH_TOKEN 环境变量)")
|
||
parser.add_argument("--access-token", default=os.environ.get("IFIND_ACCESS_TOKEN"),
|
||
help="直接传入 access_token 跳过认证 (或设置 IFIND_ACCESS_TOKEN)")
|
||
parser.add_argument("--source", default=None,
|
||
help="基础信息数据.md 文件路径")
|
||
parser.add_argument("--db-path", default=None,
|
||
help="SQLite 数据库输出路径")
|
||
parser.add_argument("--batch-size", type=int, default=BATCH_SIZE,
|
||
help=f"每批查询代码数 (默认 {BATCH_SIZE})")
|
||
args = parser.parse_args()
|
||
|
||
# 确定源文件路径
|
||
if args.source:
|
||
source_path = args.source
|
||
else:
|
||
# 默认查找路径
|
||
candidates = [
|
||
Path(__file__).parent.parent.parent.parent / "ifind" / "基础信息数据.md",
|
||
Path.home() / "Space" / "ifind" / "基础信息数据.md",
|
||
]
|
||
source_path = next((str(p) for p in candidates if p.exists()), None)
|
||
if not source_path:
|
||
print("错误: 找不到 基础信息数据.md,请使用 --source 指定路径")
|
||
sys.exit(1)
|
||
|
||
# 确定数据库路径
|
||
if args.db_path:
|
||
db_path = args.db_path
|
||
else:
|
||
db_path = str(Path(__file__).parent.parent / "data" / "pension_products.db")
|
||
|
||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||
|
||
# 提取代码
|
||
codes = extract_codes_from_md(source_path)
|
||
if not codes:
|
||
print("错误: 未提取到任何产品代码")
|
||
sys.exit(1)
|
||
|
||
# 获取 access_token
|
||
access_token = args.access_token
|
||
if not access_token:
|
||
if not args.refresh_token:
|
||
print("错误: 需要提供 --refresh-token 或设置 IFIND_REFRESH_TOKEN 环境变量")
|
||
sys.exit(1)
|
||
access_token = get_access_token(args.refresh_token)
|
||
|
||
# 构建数据库
|
||
build_database(codes, access_token, db_path, batch_size=args.batch_size)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|