migration: preserve market data and search slice

This commit is contained in:
leefer
2026-07-31 01:03:43 +08:00
parent 4002f096f4
commit a4264326bd
26 changed files with 5035 additions and 4634 deletions
+8 -1
View File
@@ -1,4 +1,3 @@
from .gateway import DataGateway, build_data_gateway
from .policy import DataPolicyError, DataSourcePolicy
from .quality import DataQualityError, DataQualityGate, QualityEvidence, QualityReport
@@ -12,3 +11,11 @@ __all__ = [
"QualityReport",
"build_data_gateway",
]
def __getattr__(name: str):
if name in {"DataGateway", "build_data_gateway"}:
from .gateway import DataGateway, build_data_gateway
return {"DataGateway": DataGateway, "build_data_gateway": build_data_gateway}[name]
raise AttributeError(name)
+4 -4
View File
@@ -8,10 +8,10 @@ from backend.data.contracts import DataUsage
from backend.data.policy import DataSourcePolicy
from backend.data.providers import IfindProvider, TushareProvider
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
from chart_data_provider import EastmoneyChartClient, MarketChartClient
from ifind_client import IfindHttpClient
from realtime_aggregator import WebRealtimeAggregator
from tushare_client import TushareClient
from backend.data.providers.ifind_client import IfindHttpClient
from backend.data.providers.tushare_client import TushareClient
from backend.data.realtime import WebRealtimeAggregator
from backend.features.market.charts import EastmoneyChartClient, MarketChartClient
@dataclass(frozen=True)
+1 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from ifind_client import IfindHttpClient
from backend.data.providers.ifind_client import IfindHttpClient
class IfindProvider:
+385
View File
@@ -0,0 +1,385 @@
from __future__ import annotations
import copy
import json
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta
from typing import Any
class IfindError(RuntimeError):
pass
class IfindHttpClient:
BASE_URL = "https://quantapi.51ifind.com/api/v1"
AUTH_ENDPOINT = "get_access_token"
AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303}
def __init__(
self,
refresh_token: str = "",
access_token: str = "",
timeout: int = 15,
) -> None:
self.timeout = max(3, int(timeout))
self._refresh_token = str(refresh_token or "").strip()
self._access_token = str(access_token or "").strip()
self._access_expires_at: datetime | None = None
self._token_lock = threading.Lock()
self._cache_lock = threading.Lock()
self._cache: dict[str, dict[str, Any]] = {}
@property
def configured(self) -> bool:
return bool(self._refresh_token or self._access_token)
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
refresh_token = str(refresh_token or "").strip()
access_token = str(access_token or "").strip()
with self._token_lock:
refresh_changed = refresh_token != self._refresh_token
self._refresh_token = refresh_token
if access_token or refresh_changed:
self._access_token = access_token
self._access_expires_at = None
if refresh_changed:
with self._cache_lock:
self._cache.clear()
def status(self) -> dict[str, Any]:
return {
"configured": self.configured,
"access_ready": bool(self._access_token),
"access_expires_at": (
self._access_expires_at.isoformat(timespec="seconds")
if self._access_expires_at
else ""
),
}
def test_connection(self) -> dict[str, Any]:
payload = self.real_time(
"000001.SH",
["open", "high", "low", "latest", "preClose"],
cache_ttl=0,
)
return {
"ok": bool(payload),
"sample_time": str(payload[0].get("time") or "") if payload else "",
}
def real_time(
self,
codes: str | list[str],
indicators: list[str],
cache_ttl: int = 10,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"real_time_quotation",
{"codes": code_text, "indicators": ",".join(indicators)},
cache_key=f"rq:{code_text}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def history(
self,
codes: str | list[str],
indicators: list[str],
start_date: str,
end_date: str,
cache_ttl: int = 300,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"cmd_history_quotation",
{
"codes": code_text,
"indicators": ",".join(indicators),
"startdate": self._display_date(start_date),
"enddate": self._display_date(end_date),
"functionpara": {"CPS": "forward1", "Fill": "Omit"},
},
cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def intraday(
self,
code: str,
start_time: str,
end_time: str,
cache_ttl: int = 20,
) -> list[dict[str, Any]]:
indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"]
payload = self._request(
"high_frequency",
{
"codes": self._codes(code),
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
"functionpara": {
"CPS": "forward1",
"Fill": "Previous",
"Timeformat": "LocalTime",
"Interval": "1",
"Limitstart": "09:30:00",
"Limitend": "15:00:00",
},
},
cache_key=f"hf:{code}:{start_time}:{end_time}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def snapshots(
self,
codes: str | list[str],
indicators: list[str],
start_time: str,
end_time: str,
cache_ttl: int = 8,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"snap_shot",
{
"codes": code_text,
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
},
cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
normalized = " ".join(str(query or "").split())
if not normalized:
raise IfindError("问财查询不能为空。")
payload = self._request(
"smart_stock_picking",
{"searchstring": normalized, "searchtype": search_type},
cache_key=f"wc:{search_type}:{normalized}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def report_query(
self,
codes: str | list[str],
begin_date: str,
end_date: str,
cache_ttl: int = 300,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"report_query",
{
"codes": code_text,
"beginrDate": self._display_date(begin_date),
"endrDate": self._display_date(end_date),
"outputpara": (
"reportDate:Y,thscode:Y,secName:Y,ctime:Y,"
"reportTitle:Y,pdfURL:Y,seq:Y"
),
},
cache_key=f"report:{code_text}:{begin_date}:{end_date}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def _request(
self,
endpoint: str,
body: dict[str, Any],
cache_key: str = "",
cache_ttl: int = 0,
) -> dict[str, Any]:
if not self.configured:
raise IfindError("iFinD 尚未配置。")
if cache_key and cache_ttl > 0:
cached = self._cached(cache_key, cache_ttl)
if cached is not None:
return cached
payload = self._post(endpoint, body, self._ensure_access_token())
if self._is_auth_error(payload) and self._refresh_token:
self._invalidate_access_token()
payload = self._post(endpoint, body, self._ensure_access_token(force=True))
self._validate_payload(payload)
if cache_key and cache_ttl > 0:
with self._cache_lock:
self._cache[cache_key] = {
"created_at": time.time(),
"payload": copy.deepcopy(payload),
}
return payload
def _ensure_access_token(self, force: bool = False) -> str:
with self._token_lock:
now = datetime.now().astimezone().replace(tzinfo=None)
token_valid = bool(self._access_token) and (
self._access_expires_at is None
or self._access_expires_at > now + timedelta(minutes=2)
)
if token_valid and not force:
return self._access_token
if not self._refresh_token:
if self._access_token:
return self._access_token
raise IfindError("iFinD Refresh Token 尚未配置。")
payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token)
self._validate_payload(payload)
data = payload.get("data") or {}
token = str(data.get("access_token") or "").strip()
if not token:
raise IfindError("iFinD 未返回 Access Token。")
expires_at = self._parse_datetime(data.get("expired_time"))
self._access_token = token
self._access_expires_at = expires_at
return token
def _post(
self,
endpoint: str,
body: dict[str, Any],
access_token: str,
refresh_token: str = "",
) -> dict[str, Any]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "XiaobaiReviewWeb/1.0",
"ifindlang": "cn",
}
if access_token:
headers["access_token"] = access_token
if refresh_token:
headers["refresh_token"] = refresh_token
request = urllib.request.Request(
f"{self.BASE_URL}/{endpoint}",
data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = ""
try:
detail_payload = json.loads(exc.read().decode("utf-8", errors="replace"))
detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "")
except (json.JSONDecodeError, OSError):
pass
raise IfindError(f"iFinD HTTP {exc.code}{f'{detail[:160]}' if detail else ''}") from exc
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise IfindError("iFinD 数据请求失败。") from exc
if not isinstance(payload, dict):
raise IfindError("iFinD 返回格式不正确。")
return payload
def _cached(self, key: str, ttl: int) -> dict[str, Any] | None:
with self._cache_lock:
cached = self._cache.get(key)
if not cached:
return None
if time.time() - float(cached.get("created_at") or 0) > ttl:
self._cache.pop(key, None)
return None
return copy.deepcopy(cached["payload"])
def _invalidate_access_token(self) -> None:
with self._token_lock:
self._access_token = ""
self._access_expires_at = None
@classmethod
def _validate_payload(cls, payload: dict[str, Any]) -> None:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = -1
if error_code != 0:
message = str(payload.get("errmsg") or "未知错误")
raise IfindError(f"iFinD 返回错误:{message[:200]}")
@classmethod
def _is_auth_error(cls, payload: dict[str, Any]) -> bool:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = 0
message = str(payload.get("errmsg") or "").casefold()
return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message
@staticmethod
def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
tables = payload.get("tables") or []
if isinstance(tables, dict):
tables = [tables]
rows: list[dict[str, Any]] = []
for block in tables if isinstance(tables, list) else []:
if not isinstance(block, dict):
continue
table = block.get("table") or {}
if not isinstance(table, dict):
continue
times = block.get("time") or []
codes = block.get("thscode") or block.get("thscodes") or []
if isinstance(codes, str):
codes = [codes]
lengths = [len(value) for value in table.values() if isinstance(value, list)]
row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0])
for index in range(row_count):
row: dict[str, Any] = {}
if isinstance(times, list) and index < len(times):
row["time"] = times[index]
if codes:
row["thscode"] = codes[index] if index < len(codes) else codes[0]
for field, values in table.items():
if isinstance(values, list):
row[field] = values[index] if index < len(values) else None
elif index == 0:
row[field] = values
rows.append(row)
return rows
@staticmethod
def _codes(codes: str | list[str]) -> str:
if isinstance(codes, list):
values = [str(code or "").strip().upper() for code in codes]
else:
values = [part.strip().upper() for part in str(codes or "").split(",")]
values = [value for value in values if value]
if not values:
raise IfindError("iFinD 证券代码不能为空。")
if len(values) > 100:
raise IfindError("iFinD 单次证券代码过多。")
return ",".join(values)
@staticmethod
def _display_date(value: str) -> str:
compact = str(value or "").replace("-", "")
if len(compact) != 8 or not compact.isdigit():
raise IfindError("iFinD 日期格式不正确。")
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text)
except ValueError:
return None
+1 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Callable
from tushare_client import TushareClient
from backend.data.providers.tushare_client import TushareClient
class TushareProvider:
File diff suppressed because it is too large Load Diff
+426
View File
@@ -0,0 +1,426 @@
from __future__ import annotations
import copy
import http.client
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
from typing import Any, ClassVar
class RealtimeAggregateError(RuntimeError):
pass
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool"
XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail"
BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/138.0.0.0 Safari/537.36"
)
@dataclass
class WebRealtimeAggregator:
timeout: int = 8
retry_attempts: int = 3
retry_delay_seconds: float = 0.2
response_cache_ttl_seconds: int = 90
_sector_cache: ClassVar[dict[str, Any]] = {}
_sector_cache_lock: ClassVar[Lock] = Lock()
_response_cache: ClassVar[dict[str, dict[str, Any]]] = {}
_response_cache_lock: ClassVar[Lock] = Lock()
def health_snapshot(self, sector: str = "") -> dict[str, Any]:
started = time.perf_counter()
sources: dict[str, dict[str, Any]] = {}
indices: list[dict[str, Any]] = []
sector_payload: dict[str, Any] | None = None
indices, sources["eastmoney_indices"] = self._capture(self.eastmoney_indices)
if sector.strip():
sector_payload, sources["eastmoney_sector"] = self._capture(
lambda: self.eastmoney_sector(sector)
)
ths_observation, sources["ths_limit_pool"] = self._capture(self.ths_limit_pool)
xgb_observation, sources["xgb_limit_pool"] = self._capture(self.xgb_limit_pool)
index_times = [int(item.get("quote_time_epoch") or 0) for item in indices or []]
now = datetime.now().astimezone()
max_skew = 120 if now.hour >= 15 else 15
index_consistent = bool(index_times) and max(index_times) - min(index_times) <= max_skew
ready = (
bool(indices)
and len(indices) == 3
and index_consistent
and (not sector.strip() or bool(sector_payload))
)
return {
"ready": ready,
"isolated": True,
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"elapsed_ms": round((time.perf_counter() - started) * 1000),
"indices": indices or [],
"index_consistent": index_consistent,
"sector": sector_payload,
"sources": sources,
"observations": {
"ths_limit_pool": ths_observation,
"xgb_limit_pool": xgb_observation,
},
"policy": {
"integration": "heaven_realtime_fallback",
"max_index_time_skew_seconds": max_skew,
"notice": "聚合源仅作为盘中观势的实时指数与板块外显,主行情快照仍由Tushare维护。",
},
}
def eastmoney_indices(self) -> list[dict[str, Any]]:
try:
payload = self._get_json(
EASTMONEY_INDEX_URL,
{
"secids": "1.000001,0.399001,0.399006",
"fltt": "2",
"invt": "2",
"fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f124",
},
referer="https://quote.eastmoney.com/",
)
except RealtimeAggregateError:
return self.tencent_indices()
cache_meta = payload.get("_aggregate_cache") or {}
rows = list((payload.get("data") or {}).get("diff") or [])
result = []
for row in rows:
code = str(row.get("f12") or "")
if code not in {"000001", "399001", "399006"}:
continue
epoch = int(_number(row.get("f124")))
result.append(
{
"code": code,
"name": row.get("f14") or code,
"price": _number(row.get("f2")),
"change": _number(row.get("f3")),
"change_amount": _number(row.get("f4")),
"open": _number(row.get("f17")),
"high": _number(row.get("f15")),
"low": _number(row.get("f16")),
"previous_close": _number(row.get("f18")),
"amount_billion": round(_number(row.get("f6")) / 100000000, 2),
"quote_time_epoch": epoch,
"quote_time": (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch else ""
),
"source": (
"eastmoney_push2_cache" if cache_meta else "eastmoney_push2"
),
"cache_age_seconds": cache_meta.get("age_seconds", 0),
}
)
if len(result) != 3:
raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices")
return result
def tencent_indices(self) -> list[dict[str, Any]]:
raw, cache_age = self._get_text(
TENCENT_INDEX_URL,
referer="https://gu.qq.com/",
encoding="gb18030",
)
result = []
for line in raw.splitlines():
if '="' not in line:
continue
fields = line.split('="', 1)[1].rsplit('";', 1)[0].split("~")
if len(fields) < 38:
continue
code = fields[2]
if code not in {"000001", "399001", "399006"}:
continue
try:
quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S").astimezone()
except ValueError as exc:
raise RealtimeAggregateError(
f"Tencent returned invalid quote time for {code}"
) from exc
result.append(
{
"code": code,
"name": fields[1] or code,
"price": _number(fields[3]),
"change": _number(fields[32]),
"change_amount": _number(fields[31]),
"open": _number(fields[5]),
"high": _number(fields[33]),
"low": _number(fields[34]),
"previous_close": _number(fields[4]),
"amount_billion": round(_number(fields[37]) / 10000, 2),
"quote_time_epoch": int(quote_time.timestamp()),
"quote_time": quote_time.isoformat(timespec="seconds"),
"source": "tencent_qt_cache" if cache_age else "tencent_qt",
"cache_age_seconds": cache_age,
}
)
if len(result) != 3:
raise RealtimeAggregateError(f"Tencent returned {len(result)}/3 indices")
return result
def eastmoney_sector(self, query: str) -> dict[str, Any]:
target = _normalize_sector(query)
candidates = self._eastmoney_sector_catalog()
matched = _match_sector(candidates, target)
if not matched:
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
epoch = int(_number(matched.get("f124")))
return {
"code": matched.get("f12") or "",
"name": matched.get("f14") or query,
"price": _number(matched.get("f2")),
"change": _number(matched.get("f3")),
"change_amount": _number(matched.get("f4")),
"turnover_rate": _number(matched.get("f8")),
"up_count": int(_number(matched.get("f104"))),
"down_count": int(_number(matched.get("f105"))),
"leader": matched.get("f128") or "--",
"leader_code": matched.get("f140") or "",
"leading_pct": _number(matched.get("f136")),
"quote_time_epoch": epoch,
"quote_time": (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch else ""
),
"source": "eastmoney_push2",
"match_query": query,
}
def _eastmoney_sector_catalog(self) -> list[dict[str, Any]]:
now = time.time()
with self._sector_cache_lock:
cached = self._sector_cache.get("eastmoney")
if cached and now - float(cached.get("created_at") or 0) < 600:
return list(cached.get("rows") or [])
def load_page(page: int) -> list[dict[str, Any]]:
payload = self._get_json(
EASTMONEY_SECTOR_URL,
{
"pn": str(page),
"pz": "100",
"po": "1",
"np": "1",
"fltt": "2",
"invt": "2",
"fid": "f3",
"fs": "m:90+t:2",
"fields": "f12,f14,f2,f3,f4,f8,f104,f105,f128,f136,f140,f124",
},
referer="https://quote.eastmoney.com/center/boardlist.html",
)
return list((payload.get("data") or {}).get("diff") or [])
with ThreadPoolExecutor(max_workers=5) as executor:
pages = list(executor.map(load_page, range(1, 6)))
rows = [row for page in pages for row in page]
if not rows:
raise RealtimeAggregateError("Eastmoney sector catalog is empty")
with self._sector_cache_lock:
self._sector_cache["eastmoney"] = {"created_at": now, "rows": rows}
return rows
def ths_limit_pool(self) -> dict[str, Any]:
payload = self._get_json(
THS_LIMIT_URL,
{"page": "1", "limit": "3", "field": "199112"},
referer="https://data.10jqka.com.cn/limit_up/",
)
data = payload.get("data") or payload
return {
"available": True,
"keys": sorted(str(key) for key in data.keys()) if isinstance(data, dict) else [],
"source": "ths_web_dataapi",
}
def xgb_limit_pool(self) -> dict[str, Any]:
payload = self._get_json(
XGB_POOL_URL,
{"pool_name": "limit_up"},
referer="https://xuangubao.cn/",
)
data = payload.get("data") or {}
rows = data if isinstance(data, list) else data.get("pool") or data.get("list") or []
return {
"available": True,
"count": len(rows) if isinstance(rows, list) else 0,
"source": "xuangubao_web_api",
}
def _capture(self, operation):
started = time.perf_counter()
try:
value = operation()
return value, {
"ok": True,
"elapsed_ms": round((time.perf_counter() - started) * 1000),
"error": "",
}
except Exception as exc:
return None, {
"ok": False,
"elapsed_ms": round((time.perf_counter() - started) * 1000),
"error": str(exc)[:500],
}
def _get_json(
self,
url: str,
params: dict[str, str],
referer: str,
) -> dict[str, Any]:
request_url = f"{url}?{urllib.parse.urlencode(params)}"
last_error: Exception | None = None
attempts = max(1, int(self.retry_attempts))
for attempt in range(attempts):
request = urllib.request.Request(
request_url,
headers={
"Accept": "application/json,text/plain,*/*",
"Connection": "close",
"Referer": referer,
"User-Agent": BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
content_type = response.headers.get("Content-Type", "")
raw = response.read().decode("utf-8", errors="replace")
if "json" not in content_type.lower() and not raw.lstrip().startswith(("{", "[")):
raise RealtimeAggregateError(
f"non-JSON response: {raw[:120].strip()}"
)
payload = json.loads(raw)
if not isinstance(payload, dict):
raise RealtimeAggregateError("unexpected response shape")
if payload.get("rc") not in (None, 0):
raise RealtimeAggregateError(f"provider rc={payload.get('rc')}")
with self._response_cache_lock:
self._response_cache[request_url] = {
"created_at": time.time(),
"payload": copy.deepcopy(payload),
}
return payload
except (
urllib.error.URLError,
TimeoutError,
ConnectionError,
OSError,
http.client.HTTPException,
json.JSONDecodeError,
RealtimeAggregateError,
) as exc:
last_error = exc
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
time.sleep(self.retry_delay_seconds * (attempt + 1))
now = time.time()
with self._response_cache_lock:
cached = self._response_cache.get(request_url)
cache_age = now - float((cached or {}).get("created_at") or 0)
if cached and cache_age <= self.response_cache_ttl_seconds:
payload = copy.deepcopy(cached.get("payload") or {})
payload["_aggregate_cache"] = {"age_seconds": round(cache_age, 1)}
return payload
raise RealtimeAggregateError(f"request failed after {attempts} attempts: {last_error}") from last_error
def _get_text(
self,
request_url: str,
referer: str,
encoding: str = "utf-8",
) -> tuple[str, float]:
cache_key = f"text:{request_url}"
last_error: Exception | None = None
attempts = max(1, int(self.retry_attempts))
for attempt in range(attempts):
request = urllib.request.Request(
request_url,
headers={
"Accept": "text/plain,*/*",
"Connection": "close",
"Referer": referer,
"User-Agent": BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read().decode(encoding, errors="replace")
if not raw.strip():
raise RealtimeAggregateError("empty text response")
with self._response_cache_lock:
self._response_cache[cache_key] = {
"created_at": time.time(),
"payload": raw,
}
return raw, 0
except (
urllib.error.URLError,
TimeoutError,
ConnectionError,
OSError,
http.client.HTTPException,
RealtimeAggregateError,
) as exc:
last_error = exc
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
time.sleep(self.retry_delay_seconds * (attempt + 1))
now = time.time()
with self._response_cache_lock:
cached = self._response_cache.get(cache_key)
cache_age = now - float((cached or {}).get("created_at") or 0)
if cached and cache_age <= self.response_cache_ttl_seconds:
return str(cached.get("payload") or ""), round(cache_age, 1)
raise RealtimeAggregateError(
f"text request failed after {attempts} attempts: {last_error}"
) from last_error
def _normalize_sector(value: Any) -> str:
text = str(value or "").strip().replace(" ", "")
for suffix in ("板块", "概念", "行业", "", "", "(A股)", "A股)"):
text = text.replace(suffix, "")
aliases = {"元器件": "元件", "电子元器件": "元件"}
return aliases.get(text, text)
def _match_sector(rows: list[dict[str, Any]], target: str) -> dict[str, Any] | None:
exact = [row for row in rows if _normalize_sector(row.get("f14")) == target]
if exact:
return min(exact, key=lambda row: len(str(row.get("f14") or "")))
fuzzy = [
row for row in rows
if target and (
target in _normalize_sector(row.get("f14"))
or _normalize_sector(row.get("f14")) in target
)
]
return min(fuzzy, key=lambda row: len(_normalize_sector(row.get("f14")))) if fuzzy else None
def _number(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default