From f6e94cfcf56bec98232bcd0ecf6f9d3c1878c655 Mon Sep 17 00:00:00 2001 From: zlt Date: Fri, 18 Sep 2026 11:50:45 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=84=E6=9E=90=E6=8A=A5=E5=91=8A=E6=94=B9?= =?UTF-8?q?=E9=A1=B5=E5=86=85=E6=8E=92=E7=89=88=E3=80=81=E7=BB=84=E5=90=88?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E4=B8=8E=E7=BA=AA=E5=BE=8B=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E8=BF=90=E7=BB=B4=E8=A7=86=E5=9B=BE=E3=80=81?= =?UTF-8?q?=E9=A1=B6=E6=A0=8F=E6=94=B9=E6=88=90=E5=8D=95=E8=A1=8C=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E5=8A=A0=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、个股深度评析报告原来是把上游链接开成新标签, 浏览器直接显示 markdown 源码。现在后端代取 (/api/research/{code}/report, 链接只认上游给的, 按链接缓存十分钟), 页面用 marked 排版成 同款样式的抽屉: 持仓表、提议来源详情、个股详情三处入口都改成页内打开, 抽屉里保留「原文」。 二、组合操作 (暂停买入、暂停执行、调整仓位、清仓某行业、一键清仓) 与纪律设置 (资金与仓位、 浮亏补仓、自主档位) 属管理员操作, 从交易员右栏移到运维视图新页「组合操作」, 两栏布局。 三、顶栏改单行: 七枚状态胶囊改成短标签, 说明进悬停; 角色切换、选股计划、日报、导出、运维操作、 自动刷新、退出收进右侧一个菜单, 菜单名写当前用户与视图。1180 到 1920 宽度都是一行、高 46。 第七批加两例 (链接只认上游、缓存与失败不缓存), 路由哨兵登记, 现为 904 例。 Co-Authored-By: Claude Fable 5.1 --- app/services/company_report.py | 131 +++++++++ app/web/main.py | 11 + app/web/static/index.html | 407 +++++++++++++++++++--------- app/web/static/vendor/marked.min.js | 69 +++++ scripts/run_tests.py | 7 +- scripts/test_batch7_units.py | 44 +++ scripts/test_wiring.py | 1 + 7 files changed, 541 insertions(+), 129 deletions(-) create mode 100644 app/services/company_report.py create mode 100644 app/web/static/vendor/marked.min.js diff --git a/app/services/company_report.py b/app/services/company_report.py new file mode 100644 index 0000000..0765386 --- /dev/null +++ b/app/services/company_report.py @@ -0,0 +1,131 @@ +# -*- 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() diff --git a/app/web/main.py b/app/web/main.py index 29c0612..7735b08 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -804,6 +804,17 @@ def _research_st(code): return None, "none" +@app.get("/api/research/{ts_code}/report") +def api_research_report(ts_code: str): + """个股深度评析全文 (2026-09-18): 上游给的 markdown 由后端代取, 页面自己排版成同款样式。 + 点击时调, 不进轮询; 按链接缓存十分钟。链接只认上游给的, 不收页面传入的地址。""" + from app.services import company_report + try: + return company_report.get(cs.normalize_code(ts_code)) + except Exception as e: # noqa: BLE001 + return {"ok": False, "error": f"评析报告读取失败: {type(e).__name__}: {e}"} + + @app.get("/api/research/{ts_code}") def api_research(ts_code: str): """单票研究面 · 三源合议 (点击时调, 只读, 绝不进轮询)。六块: 公司质地 / 基本面 / 技术面 / diff --git a/app/web/static/index.html b/app/web/static/index.html index 0c3d7f8..2335c78 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -8,6 +8,7 @@ +