# -*- coding: utf-8 -*- """个股深度评析报告的代取 (2026-09-18)。 报告由数据基座每晚生成, 上游计划与逻辑状态里只带一条链接 (company_review.report_url), 链接打开是一份 markdown 纯文本。原来页面把链接开成新标签, 浏览器按纯文本显示, 用户看到的是一屏 markdown 源码。现在由持仓系统后端代取一次, 页面拿到正文后在自己的 抽屉里排版, 与整页同一套样式。 只做三件事: 一, 按代码找链接。持仓票从逻辑状态映射取, 候选票从当日计划主榜与观察档取, 与 /api/research 的公司评析来源一致。链接只认上游给的, 不接受页面传入的任意地址。 二, 带超时拉取正文。 三, 按链接缓存十分钟。报告每晚生成一次, 盘中不会变; 点开抽屉不该每次都去打数据基座。 取不到就把原因写进 error 返回, 不抛异常 (页面按失败态显示并给重试)。 """ from __future__ import annotations import logging import time import requests logger = logging.getLogger(__name__) CACHE_SEC = 600 # 正文缓存秒数 TIMEOUT_SEC = 8 # 拉取超时 MAX_BYTES = 2_000_000 # 超过这个长度当异常处理, 不往页面塞 SRC_HELD, SRC_CAND, SRC_NONE = "held", "candidate", "none" _cache: dict = {} # url -> {"at": 时刻, "text": 正文} def _url_of(cr) -> str | None: if not isinstance(cr, dict): return None u = cr.get("report_url") u = str(u).strip() if u else "" return u if u.startswith(("http://", "https://")) else None def report_url_for(code: str, *, state_map=None, plan=None) -> tuple[str | None, str]: """返回 (链接, 来源)。来源: held (持仓逻辑状态) / candidate (当日计划) / none。 state_map 与 plan 可注入 (单测用); 缺省时现取, 任一路取失败只记日志不抛。""" if state_map is None: try: from app.services import logic_state_service state_map = logic_state_service.state_map() except Exception as e: # noqa: BLE001 logger.warning("[评析报告] 取持仓逻辑状态失败 %s: %s", code, e) state_map = {} st = (state_map or {}).get(code) u = _url_of((st or {}).get("company_review")) if isinstance(st, dict) else None if u: return u, SRC_HELD if plan is None: try: from app.services import plan_feed plan = plan_feed.get_plan() except Exception as e: # noqa: BLE001 logger.warning("[评析报告] 取候选计划失败 %s: %s", code, e) plan = {} for r in (plan or {}).get("main") or []: if r.get("ts_code") == code: u = _url_of(r.get("company_review")) if u: return u, SRC_CAND for r in (plan or {}).get("observe") or []: if r.get("ts_code") == code: u = _url_of(r.get("company_review")) if u: return u, SRC_CAND return None, SRC_NONE def _http_get_text(url: str, timeout: int) -> str: r = requests.get(url, timeout=timeout) r.raise_for_status() if len(r.content) > MAX_BYTES: raise ValueError(f"报告过大 ({len(r.content)} 字节)") r.encoding = r.encoding or "utf-8" return r.text def fetch_text(url: str, *, now=None, timeout: int = TIMEOUT_SEC, getter=None) -> tuple[str, bool]: """取正文, 返回 (正文, 是否命中缓存)。失败抛异常, 由 get 兜成 error。""" now = time.time() if now is None else now hit = _cache.get(url) if hit and now - hit["at"] < CACHE_SEC: return hit["text"], True text = (getter or _http_get_text)(url, timeout) if not isinstance(text, str) or not text.strip(): raise ValueError("报告内容为空") _cache[url] = {"at": now, "text": text} return text, False def _fail_why(e: Exception, timeout: int) -> str: if isinstance(e, requests.exceptions.Timeout): return f"数据基座没有回应,等了 {timeout} 秒" if isinstance(e, requests.exceptions.ConnectionError): return "连不上数据基座" if isinstance(e, requests.exceptions.HTTPError): code = getattr(getattr(e, "response", None), "status_code", None) return f"数据基座回了 {code}" if code else f"数据基座回了错误: {e}" return f"{type(e).__name__}: {e}" def get(code: str, *, now=None, timeout: int = TIMEOUT_SEC, getter=None, state_map=None, plan=None) -> dict: """页面用的一站式取法。返回固定键: ok / ts_code / url / source / markdown / cached / fetched_at / error。""" url, src = report_url_for(code, state_map=state_map, plan=plan) out = {"ok": False, "ts_code": code, "url": url, "source": src, "markdown": "", "cached": False, "fetched_at": None, "error": None} if not url: out["error"] = ("这只票没有个股深度评析报告的链接" + ("(不在持仓也不在当日选股计划里)" if src == SRC_NONE else "")) return out try: text, cached = fetch_text(url, now=now, timeout=timeout, getter=getter) except Exception as e: # noqa: BLE001 out["error"] = f"评析报告读取失败: {_fail_why(e, timeout)}" logger.warning("[评析报告] %s %s: %s", code, url, out["error"]) return out at = _cache.get(url, {}).get("at") out.update({"ok": True, "markdown": text, "cached": cached, "fetched_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(at)) if at else None}) return out def invalidate(): _cache.clear()