故障(2026-09-19 15:40 实测)
------------------------------
`tushare_sync.sh daily` **一张表都没抓**,日志只有一行:
── T0 每日同步 (日线 + 资金面 + 事件公告) ──
table_group: daily (6 tables)
error: Request failed after 3 retries in 4.5s
cause: Failed to fetch: `https://pypi.tuna.tsinghua.edu.cn/simple/structlog/`
cause: dns error
cause: failed to lookup address information: nodename nor servname provided
根因
----
两个调度脚本都用 `uv run <entrypoint>` 调用。**`uv run` 会在运行时同步/重建依赖
环境**,需要联网下载缺失包 —— 当天该请求 DNS 解析失败;又因脚本带
`set -eo pipefail`,这条 error 直接**中止整条任务**。
手动复现同一条命令还看到额外危害:
Building tushare-fetcher @ file:///...
Downloading mypy (13.0MiB)
Installed 14 packages in 31ms
即**生产运行期会联网改写自身 venv 的依赖**(把 mypy 装进生产环境)。
改动
----
`tushare_sync.sh`(17 处)与 `akshare_fetcher/scripts/sync_all.sh`(14 处)
全部改为直调 bundle 内的可执行文件:
TUSHARE_BIN="$PROJECT_DIR/.venv/bin/tushare-fetcher" # 原 uv run tushare-fetcher
AKSHARE_BIN="$PROJECT_DIR/.venv/bin/akshare-fetcher" # 原 uv run akshare-fetcher
VENV_PY="$PROJECT_DIR/.venv/bin/python" # 原 uv run python3 / --no-sync python
并在使用前校验可执行文件存在,缺失即明确报错退出(而不是让 uv 去"修好它")。
→ 不联网、不重建、也不依赖 uv 是否在 PATH 上(launchd 环境 PATH 极简,这本身也是风险)。
验证(端到端实跑)
------------------
- 当日日志现为:15:40 那次(第 1–8 行,含 dns error)之后,17:46:50 起新运行
至 17:48:45 **完成,exit 0,464 行,0 error**
- 6 个 table_group 全部执行到(含本批新增的「事件公告」与「补排期-日频」两组)
- 银层迁移同批完成:11,246 行写入 fin_data_clean.stock_monthly,blocked=False
- akshare 路径(18:00 任务)冒烟通过,无 uv 报错
说明:今天为周六(非交易日),故本次故障的**数据**影响有限(无新行情可采),
但该缺陷在每个工作日都会造成**整日零采集**,属最高优先级修复。
附带提交(同日稍早完成、一并入库)
----------------------------------
- `tables_registry.json`:`us_basic` 唯一键由 ["ts_code"] 修正为
["ts_code","list_date","name"]。同一 ticker 会被不同公司复用(AEM/ABX/AGI 等),
旧键导致静态表 replace 写入报 (1062, Duplicate entry),整批失败。
已按 MySQL NULL 语义实测验证为 0 冲突,DB 侧唯一索引同步重建。
- `fut_wsr` 追加警告:接口返回行在 (trade_date,symbol,warehouse) 下可重复且
**无自然唯一键**(同仓库同日多条仓单,vol 不同),按唯一键 upsert 会静默覆盖丢行。
- 新增 `Scripts/bin/verify_unique_keys.py`(校验 registry 声明的唯一键在真实数据上
是否唯一,含 MySQL NULL 语义与组合搜索建议)与 `Scripts/bin/inspect_dup_rows.py`
(打印重复行以确定真实唯一键)。
92 lines
3.4 KiB
Python
92 lines
3.4 KiB
Python
#!/usr/bin/env python3
|
||
"""inspect_dup_rows.py — 打印某张表在声明唯一键下的重复行,用于确定真实唯一键
|
||
|
||
用法(在 tushare_fetcher 目录下,用其 venv 运行):
|
||
set -a; . ~/.config/findatahub/env; set +a
|
||
unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy
|
||
./.venv/bin/python <此脚本> fut_wsr us_basic
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pandas as pd
|
||
|
||
FD = Path("/Applications/FinDataHub.app/Contents/Resources/tushare_fetcher")
|
||
|
||
|
||
def api_of(key: str) -> str | None:
|
||
src = (FD / "src/tushare_fetcher/core/fetcher.py").read_text(encoding="utf-8")
|
||
blk = src[src.index("TABLE_MAP"):]
|
||
m = re.search(r'^\s{8}"' + key + r'":\s*\(([^)]*)\)', blk, re.M)
|
||
if not m:
|
||
return None
|
||
quoted = re.findall(r'"([A-Za-z0-9_]+)"', m.group(1))
|
||
return quoted[1] if len(quoted) > 1 else (quoted[0] if quoted else None)
|
||
|
||
|
||
def declared_keys(key: str) -> list[str]:
|
||
entries = json.loads((FD / "tables_registry.json").read_text(encoding="utf-8"))["tables"]
|
||
for e in entries:
|
||
if e.get("key") == key:
|
||
return e.get("unique_keys") or []
|
||
return []
|
||
|
||
|
||
def main() -> int:
|
||
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"):
|
||
os.environ.pop(k, None)
|
||
import tushare as ts
|
||
ts.set_token(os.environ["TUSHARE_TOKEN"])
|
||
pro = ts.pro_api()
|
||
|
||
pd.set_option("display.width", 200)
|
||
pd.set_option("display.max_columns", 30)
|
||
|
||
for key in sys.argv[1:]:
|
||
api = api_of(key)
|
||
uk = declared_keys(key)
|
||
df = None
|
||
for params in ({}, {"trade_date": "20260918"}, {"ts_code": "000001.SZ"},
|
||
{"start_date": "20260901", "end_date": "20260918"}):
|
||
try:
|
||
cand = getattr(pro, api)(**params)
|
||
if cand is not None and not cand.empty:
|
||
df, used = cand, params
|
||
break
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
time.sleep(0.5)
|
||
print(f"\n{'=' * 78}\n{key} 接口={api} 入参={used if df is not None else '取样失败'}")
|
||
if df is None:
|
||
print(" 取样失败"); continue
|
||
print(f" 声明唯一键={uk} 样本 {len(df)} 行 全部列={list(df.columns)}")
|
||
if not uk or any(c not in df.columns for c in uk):
|
||
print(" 声明的唯一键列不完整,跳过"); continue
|
||
dup_mask = df.duplicated(subset=uk, keep=False)
|
||
dups = df[dup_mask].sort_values(by=uk)
|
||
print(f" 参与重复的行数: {len(dups)}(其中判定为重复的多余行 {int(df.duplicated(subset=uk).sum())})")
|
||
if len(dups):
|
||
print(" 重复样例(最多 8 行):")
|
||
print(dups.head(8).to_string(index=False))
|
||
# 对重复组内每列取值个数做统计,找出"能把组内区分开"的列
|
||
grp = dups.groupby(uk, dropna=False)
|
||
varying = []
|
||
for col in df.columns:
|
||
if col in uk:
|
||
continue
|
||
if (grp[col].nunique(dropna=False) > 1).any():
|
||
varying.append(col)
|
||
print(f" 组内取值有差异的列(候选真实唯一键成分): {varying}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|