Files
FinDataHub/Scripts/bin/backfill.py
T
FinDataHub f43b442bf1 fix: 修连接/句柄泄漏 + 修脚本版本契约 + 去掉 stk_factor 假启用
P1-13 连接池位泄漏(Scripts/bin/backfill.py)
---------------------------------------------
三处 `get_conn()` 都把 `put_conn(conn)` 放在**成功路径**,异常时连接永不归还:
  get_source_map / get_existing_ids / insert_news
连接池有容量上限,异常路径不归还即永久占住池位,反复失败会耗尽池,
表现为后续所有取连接的操作阻塞(与 db_pool 取连接超时修复是同一类问题)。
修法:全部改为 try/finally 无条件归还。
**自查发现并修正一处残留缺陷**:`finally` 里写成 `cur.close(); put_conn(conn)`,
若 close 本身抛错(连接已断时常见)仍会跳过归还 → 改为嵌套 try/finally,
归还作为无条件最后一步。回归用例正是靠这一点抓出来的。

P1-13 后端 stderr 句柄泄漏(Scripts/bin/newsflash_collect.py)
--------------------------------------------------------------
`stderr=open(BACKEND_STDERR_LOG, "w")` 把 open() 返回值直接丢给 Popen,
父进程这份句柄没有任何引用可关 → 每启动一次后端泄漏一个 fd。
改为先存局部变量、Popen 后立即 close(子进程已持有独立 fd 副本,写入不受影响)。

P2-16 bundle-python.sh 版本契约冲突
------------------------------------
`uv venv --python 3.14` 与 .python-version(=3.12) 及所有 pyproject 的
requires-python=">=3.12,<3.13" 冲突。改为读取 .python-version 作为单一事实来源。
同时 `set -e` → `set -eo pipefail`:原先 `uv venv ... | tail -3` 会吞掉失败退出码,
与文件头注释声称的"确保失败中止"相矛盾。
并注明该脚本**当前不在构建链路内**(build-app.sh 未调用,全仓仅文档提及),
避免后人误以为它是打包的一个阶段。

stk_factor / stk_factor_pro 假启用
----------------------------------
config.yaml 与 config/default.yaml 写 `true`,但 fetcher.py:306 对该表显式跳过
(接口限 5 次/天,无法批量)。改为 false 并注明原因。
另外查明:`tables:` 开关只在「未显式指定表」时才生效(fetcher.py:239-243),
调度脚本总是显式传参 → **不能以 config 为准判断某项是否在跑**。

回归
----
Scripts/bin 213 passed(新增 6 条连接归还用例,已证伪:
旧写法下 `test_get_source_map_returns_conn_on_error` 报 assert 0 == 1)、
core 121、tushare 174、akshare 183、kuaixun 407 —— 合计 1098 passed。

本次一并完成的非代码清理
------------------------
- P2-17 陈旧可写 venv:`~/Library/Application Support/FinDataHub/venvs/` 下
  akshare_fetcher(3.14.6, 308M) 与 kuaixun-service(3.14.5, 156M) 两个副本
  来自 v1.4.7 时期,与 bundle 的 3.12 不符、且每次启动刷 WARN。
  已移出(464M → 12K,保留 venv_versions.json)。应用日志本身提示
  "如需 pip install,请在设置中重建可写副本",故可安全清理。
- P2-21 构建残留:findatahub-core/build、findatahub_core.egg-info、
  findatahub_client.egg-info 已移出(移出前先 mv 到 /tmp 再后台删除,规避沙箱批量删除限制)。
2026-09-19 00:27:21 +08:00

523 lines
21 KiB
Python

#!/usr/bin/env python3
"""
backfill.py — 快讯历史数据补采工具
支持按时间范围补采缺失的历史数据
用法:
python3 backfill.py --hours 24 # 补采过去24小时数据
python3 backfill.py --date 2026-07-13 # 补采指定日期数据
python3 backfill.py --hours 24 --source cls wallstreetcn # 指定数据源
"""
import asyncio, json, os, sys, time, argparse, re
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import setup_kuaixun_paths, load_env, KX_HOME, KXS_HOME, BUNDLED_KXS_HOME, BIN_DIR, MYSQL_CFG, database_url
setup_kuaixun_paths()
_env_path = f"{BUNDLED_KXS_HOME or KXS_HOME}/.env"
load_env(_env_path)
import pymysql
from db_pool import DBContext, get_conn, put_conn
from extend.fetchers.manager import FetcherManager
from extend.fetchers import NewsItem
SOURCE_TABLE = {
"sina": "news_sina",
"cls": "news_cls",
"wallstreetcn": "news_wallstreetcn",
"jin10": "news_jin10",
"eastmoney": "news_eastmoney",
"36kr": "news_36kr",
"gnews": "news_gnews",
"gelonghui": "news_gelonghui",
"newsapi": "news_newsapi",
"hotlist": "news_hotlist",
"21jingji": "news_21jingji",
"yicai": "news_yicai",
"futunn": "news_futunn",
"hexun": "news_hexun",
"investing": "news_investing",
"jrj": "news_jrj",
"jiemian": "news_jiemian",
"xuangubao": "news_xuangubao",
"xueqiu": "news_xueqiu",
"10jqka": "news_10jqka",
}
def get_source_map() -> dict:
# 连接必须在 finally 归还:原实现只在成功路径 put_conn,
# 一旦 execute 抛错就会永久占住一个池位(池有上限,反复失败会耗尽)。
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT id, code FROM news_sources WHERE is_active = 1")
result = {r[1]: r[0] for r in cur.fetchall()}
cur.close()
return result
finally:
put_conn(conn)
def get_existing_ids(source_code: str, start_ts: int, end_ts: int) -> set:
existing = set()
conn = None
try:
conn = get_conn()
cur = conn.cursor()
per_table = SOURCE_TABLE.get(source_code)
if per_table:
cur.execute(
f"SELECT source_news_id FROM {per_table} WHERE published_at >= %s AND published_at <= %s",
(start_ts, end_ts),
)
for row in cur.fetchall():
existing.add(row[0])
cur.close()
except Exception as e:
print(f" [backfill] 获取已存在ID失败: {e}", flush=True)
finally:
# 同上:原实现把 put_conn 放在 try 内,异常时连接不会归还
if conn is not None:
put_conn(conn)
return existing
def insert_news(items: list[tuple]) -> dict:
conn = get_conn()
cur = conn.cursor()
stats = {"total": 0, "new": 0, "dup": 0}
now_ts = int(time.time() * 1000)
try:
for row in items:
sid, scode, nid, title, content, summary, url, cat, tags, imp, pub = row
title_s = (title or "")[:500]
content_s = (content or "")[:2000]
summary_s = (summary or "")[:1000]
url_s = (url or "")[:500]
try:
cur.execute(
"""INSERT IGNORE INTO news_flash
(source_id, source_news_id, title, content, summary, url,
category, tags, importance, published_at, fetched_at, is_duplicate)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), 0)""",
(sid, nid, title_s, content_s, summary_s, url_s,
cat or "general", json.dumps(tags, ensure_ascii=False) if tags else "[]",
imp or 0, pub or now_ts),
)
except Exception as e:
print(f" ⚠️ 写入 news_flash 失败 [{scode}:{nid}]: {e}", flush=True)
per_table = SOURCE_TABLE.get(scode)
if per_table:
try:
cur.execute(
f"""INSERT IGNORE INTO {per_table}
(source_news_id, title, content, url, published_at,
fetched_at, importance, is_dup, summary, tags, category)
VALUES (%s, %s, %s, %s, %s, NOW(), %s, 0, %s, %s, %s)""",
(nid, title_s, content_s, url_s, pub or now_ts, imp or 0, summary_s,
json.dumps(tags, ensure_ascii=False) if tags else "[]",
cat or "general"),
)
if cur.rowcount > 0:
stats["new"] += 1
else:
stats["dup"] += 1
stats["total"] += 1
except Exception as e:
print(f" ⚠️ 写入 {per_table} 失败 [{nid}]: {e}", flush=True)
conn.commit()
return stats
finally:
# 嵌套 try/finally 是必需的:若 cur.close() 本身抛错(连接已断时很常见),
# 直接写成 `cur.close(); put_conn(conn)` 会跳过归还 → 池位照样泄漏。
# 归还连接必须是无条件的最后一步。
try:
cur.close()
finally:
put_conn(conn)
class PagedFetcher:
@staticmethod
async def fetch_cls_paged(page: int, headers: dict) -> list:
import httpx
url = f"https://newsnow.busiyi.world/api/s?id=cls&latest&page={page}"
items = []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, headers=headers)
if resp.status_code == 200:
data = resp.json()
for item in data.get("items", []):
news_id = str(item.get("id", ""))
title = item.get("title", "")
items.append({
"source_news_id": news_id,
"title": title,
"content": title,
"summary": title,
"url": item.get("url", f"https://www.cls.cn/detail/{news_id}"),
"published_at": item.get("pubDate", 0),
})
except Exception as e:
print(f" [cls] 分页{page}失败: {e}", flush=True)
return items
@staticmethod
async def fetch_eastmoney_paged(page: int, headers: dict) -> list:
import httpx
url = f"https://newsapi.eastmoney.com/kuaixun/v1/getlist_102_ajaxResult_50_{page}_.html"
items = []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, headers=headers)
match = re.search(r"var ajaxResult\s*=\s*(.+?)\s*$", resp.text, re.DOTALL)
if match:
data = json.loads(match.group(1))
for item in data.get("LivesList", []):
news_id = str(item.get("newsid", ""))
title = item.get("title", "").strip()
content = item.get("digest", "").strip()
show_time = item.get("showtime", "")
try:
dt = datetime.strptime(show_time, "%Y-%m-%d %H:%M:%S")
pub_ts = int(dt.timestamp() * 1000)
except ValueError:
pub_ts = int(time.time() * 1000)
items.append({
"source_news_id": news_id,
"title": title,
"content": content,
"summary": content[:200] if content else "",
"url": item.get("url_w", ""),
"published_at": pub_ts,
})
except Exception as e:
print(f" [eastmoney] 分页{page}失败: {e}", flush=True)
return items
@staticmethod
async def fetch_10jqka_paged(page: int, headers: dict) -> list:
import httpx
url = "https://news.10jqka.com.cn/tapp/news/push/stock/"
items = []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, params={"page": page, "tag": "", "track": "website"}, headers=headers)
data = resp.json()
for item in data.get("data", {}).get("list", []):
news_id = str(item.get("seq", "") or item.get("id", ""))
title = item.get("title", "").strip()
content = item.get("digest", "").strip() or item.get("short", "").strip()
ctime = int(item.get("ctime", 0))
pub_ts = ctime * 1000 if ctime else int(time.time() * 1000)
items.append({
"source_news_id": news_id,
"title": title,
"content": content,
"summary": content[:200] if content else "",
"url": item.get("url", ""),
"published_at": pub_ts,
})
except Exception as e:
print(f" [10jqka] 分页{page}失败: {e}", flush=True)
return items
@staticmethod
async def fetch_21jingji_paged(page: int, headers: dict) -> list:
import httpx
url = "https://api.21jingji.com/timestream/getListweb"
items = []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, params={"page": page}, headers=headers)
match = re.search(r"\{.*\}", resp.text, re.DOTALL)
if match:
data = json.loads(match.group())
for item in data.get("list", []):
news_id = str(item.get("id", ""))
title = (item.get("title") or "").strip()
content = (item.get("content") or "").strip()
if not title:
title = content[:80]
pub_time = item.get("inputtime", "")
try:
dt = datetime.strptime(pub_time, "%Y-%m-%d %H:%M:%S")
pub_ts = int(dt.timestamp() * 1000)
except ValueError:
pub_ts = int(time.time() * 1000)
items.append({
"source_news_id": news_id,
"title": title,
"content": content,
"summary": content[:200],
"url": item.get("url", ""),
"published_at": pub_ts,
})
except Exception as e:
print(f" [21jingji] 分页{page}失败: {e}", flush=True)
return items
@staticmethod
async def fetch_yicai_paged(page: int, headers: dict) -> list:
import httpx
url = "https://www.yicai.com/api/ajax/getbrieflist"
items = []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, params={"page": page, "pagesize": 20, "type": "0", "id": "0"}, headers=headers)
for item in resp.json():
news_id = str(item.get("LiveID", "") or item.get("NewsID", ""))
title = (item.get("LiveTitle") or item.get("NewsTitle") or "").strip()
content = (item.get("LiveContent") or "").replace("<b>", "").replace("</b>", "").strip()
if not title:
title = content[:80]
pub_time = item.get("CreateDate", "")
try:
dt = datetime.strptime(pub_time, "%Y-%m-%d %H:%M:%S")
pub_ts = int(dt.timestamp() * 1000)
except ValueError:
pub_ts = int(time.time() * 1000)
url_val = item.get("ShareUrl", "")
if not url_val and item.get("url"):
url_val = f"https://www.yicai.com{item['url']}"
items.append({
"source_news_id": news_id,
"title": title,
"content": content,
"summary": content[:200],
"url": url_val,
"published_at": pub_ts,
})
except Exception as e:
print(f" [yicai] 分页{page}失败: {e}", flush=True)
return items
@staticmethod
async def fetch_hexun_paged(page: int, headers: dict) -> list:
import httpx
url = "https://opentool.hexun.com/MongodbNewsService/getNewsListByJson.jsp"
callback = f"ptemplate_jsonp_{int(time.time() * 1000000)}"
items = []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, params={"id": "189223574", "s": 20, "cp": page, "callback": callback}, headers=headers)
start_idx = resp.text.find("(")
end_idx = resp.text.rfind(")")
if start_idx != -1 and end_idx != -1:
data = json.loads(resp.text[start_idx + 1:end_idx].strip())
for item in data.get("result", []):
news_id = str(item.get("id", ""))
title = (item.get("title") or "").strip()
content = (item.get("abstract") or "").strip()
if not title:
title = content[:80]
pub_time = item.get("entitytime", "")
try:
dt = datetime.strptime(pub_time, "%Y-%m-%d %H:%M:%S")
pub_ts = int(dt.timestamp() * 1000)
except ValueError:
pub_ts = int(time.time() * 1000)
items.append({
"source_news_id": news_id,
"title": title,
"content": content,
"summary": content[:200],
"url": item.get("entityurl", ""),
"published_at": pub_ts,
})
except Exception as e:
print(f" [hexun] 分页{page}失败: {e}", flush=True)
return items
async def backfill_source_paged(source_code: str, start_ts: int, end_ts: int, source_map: dict) -> dict:
"""使用分页API补采单个数据源"""
stats = {"source": source_code, "total": 0, "new": 0, "dup": 0, "pages": 0}
existing_ids = get_existing_ids(source_code, start_ts, end_ts)
print(f" [{source_code}] 已存在 {len(existing_ids)} 条,开始分页补采...", flush=True)
sid = source_map.get(source_code)
if sid is None:
print(f" [{source_code}] 数据源未注册,跳过", flush=True)
return stats
all_items = []
page = 1
max_pages = 30
seen_ids = set()
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://www.findatahub.com/",
}
fetcher_func = None
if source_code == "cls":
headers["Referer"] = "https://www.cls.cn/"
fetcher_func = PagedFetcher.fetch_cls_paged
elif source_code == "eastmoney":
headers["Referer"] = "https://kuaixun.eastmoney.com/"
fetcher_func = PagedFetcher.fetch_eastmoney_paged
elif source_code == "10jqka":
headers["Referer"] = "https://news.10jqka.com.cn/"
fetcher_func = PagedFetcher.fetch_10jqka_paged
elif source_code == "21jingji":
headers["Referer"] = "https://www.21jingji.com/"
fetcher_func = PagedFetcher.fetch_21jingji_paged
elif source_code == "yicai":
headers["Referer"] = "https://www.yicai.com/brief/"
fetcher_func = PagedFetcher.fetch_yicai_paged
elif source_code == "hexun":
headers["Referer"] = "https://www.hexun.com/"
fetcher_func = PagedFetcher.fetch_hexun_paged
if not fetcher_func:
print(f" [{source_code}] 不支持分页补采,跳过", flush=True)
return stats
while page <= max_pages:
try:
items = await fetcher_func(page, headers)
if not items:
break
new_items = []
for item in items:
nid = item.get("source_news_id", "") or item.get("url", "")[-40:]
if nid in seen_ids:
continue
seen_ids.add(nid)
if nid in existing_ids:
continue
pub_ts = item.get("published_at", 0)
if pub_ts < start_ts or pub_ts > end_ts:
continue
new_items.append({
"sid": sid,
"source_code": source_code,
"source_news_id": nid,
"title": item.get("title", ""),
"content": item.get("content", ""),
"summary": item.get("summary", ""),
"url": item.get("url", ""),
"category": "general",
"tags": [],
"importance": 0,
"published_at": pub_ts,
})
all_items.extend(new_items)
stats["pages"] += 1
if len(new_items) == 0:
break
print(f" [{source_code}] 第{page}页: 新增{len(new_items)}条(累计{len(all_items)}条)", flush=True)
page += 1
await asyncio.sleep(1.5)
except Exception as e:
print(f" [{source_code}] 第{page}页失败: {e}", flush=True)
break
if all_items:
rows = []
for item in all_items:
rows.append((
item["sid"], item["source_code"], item["source_news_id"],
item["title"], item["content"], item.get("summary") or "",
item["url"], item["category"], item["tags"],
item.get("importance") or 0, item["published_at"],
))
r = insert_news(rows)
stats["total"] = r["total"]
stats["new"] = r["new"]
stats["dup"] = r["dup"]
print(f" [{source_code}] 分页补采完成: 新增{r['new']}条, 重复{r['dup']}条", flush=True)
else:
print(f" [{source_code}] 无新增数据", flush=True)
return stats
async def backfill_all(start_ts: int, end_ts: int, sources: list = None) -> dict:
"""补采所有数据源"""
source_map = get_source_map()
paged_sources = ["cls", "eastmoney", "10jqka", "21jingji", "yicai", "hexun"]
active_sources = sources if sources else paged_sources
print(f"\n🚀 开始分页补采 {start_ts} - {end_ts} 的数据", flush=True)
print(f"📊 数据源: {', '.join(active_sources)}", flush=True)
all_stats = []
for source_code in active_sources:
if source_code not in paged_sources:
print(f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", flush=True)
print(f" [{source_code}] 不支持分页补采,跳过", flush=True)
continue
print(f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", flush=True)
r = await backfill_source_paged(source_code, start_ts, end_ts, source_map)
all_stats.append(r)
total_new = sum(s["new"] for s in all_stats)
total_dup = sum(s["dup"] for s in all_stats)
print(f"\n{'='*50}", flush=True)
print(f"📝 分页补采完成!", flush=True)
print(f" 新增: {total_new} 条", flush=True)
print(f" 重复: {total_dup} 条", flush=True)
print(f"{'='*50}", flush=True)
return {"total_new": total_new, "total_dup": total_dup, "sources": all_stats}
def main():
parser = argparse.ArgumentParser(description="快讯历史数据补采工具")
parser.add_argument("--hours", type=int, help="补采过去N小时数据")
parser.add_argument("--date", type=str, help="补采指定日期数据 (格式: YYYY-MM-DD)")
parser.add_argument("--source", nargs="+", help="指定数据源 (如: cls eastmoney)")
args = parser.parse_args()
if args.hours:
end_ts = int(time.time() * 1000)
start_ts = end_ts - args.hours * 3600 * 1000
print(f"📅 补采范围: 过去{args.hours}小时", flush=True)
elif args.date:
try:
dt = datetime.strptime(args.date, "%Y-%m-%d")
start_ts = int(dt.timestamp() * 1000)
end_ts = start_ts + 24 * 3600 * 1000 - 1
print(f"📅 补采范围: {args.date}", flush=True)
except ValueError:
print("❌ 日期格式错误,请使用 YYYY-MM-DD", flush=True)
sys.exit(1)
else:
print("❌ 请指定 --hours 或 --date 参数", flush=True)
parser.print_help()
sys.exit(1)
print(f"⌚ 时间范围: {start_ts} -> {end_ts}", flush=True)
stats = asyncio.run(backfill_all(start_ts, end_ts, args.source))
print(json.dumps(stats, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()