fix: stabilize Xiaohongshu share image caption (#2179)

This commit is contained in:
zhulinsen
2026-08-09 21:40:13 +08:00
committed by GitHub
parent 40b8c6c3cd
commit 071c5aa3c7
9 changed files with 23 additions and 25 deletions

View File

@@ -700,7 +700,6 @@ DINGTALK_SECRET=
# 分享图默认展示仓库内置小红书二维码和昵称 @霸天土小豆;以下配置可整体替换品牌信息
# SHARE_IMAGE_XIAOHONGSHU_URL=
# SHARE_IMAGE_XIAOHONGSHU_HANDLE=@霸天土小豆 # 全部配置留空时显示内置昵称
# SHARE_IMAGE_XIAOHONGSHU_ID=
# SHARE_IMAGE_XIAOHONGSHU_QR_PATH=assets/my-xiaohongshu-qr.png # 全部配置留空时使用内置二维码
# 转图工具wkhtmltopdf (apt install wkhtmltopdf / brew install wkhtmltopdf),或 markdown-to-file
#

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
- [修复] 固定分享图小红书二维码下方的账号文案为“小红书@昵称”,不再渲染数字 ID历史 ID 配置不会改变分享图模板
- [修复] 首页移动端侧栏的历史、自选与今日列表恢复纵向触摸滚动:外层卡片不再覆盖移动端滚动壳层的 `overflow`,实际滚动视口明确启用纵向手势与边界约束,桌面端继续保留卡片裁剪
- [修复] Electron 桌面端恢复历史个股报告、市场复盘和完整报告的“分享”入口,复用安装包自带 Chromium 从本机受限 HTML 生成 PNGWeb 与桌面端在未配置自定义品牌时统一使用随包分发的小红书二维码,并在二维码下展示昵称 `@霸天土小豆`
- [新功能] Agent Chat 按会话持久化 Skill 选择,支持刷新和会话切换恢复,并区分省略 `skills`、显式空列表与非空选择;无持久化状态的历史会话继续使用运行时默认且不会被静默转为显式选择,复用分析 `context` 中残留的 legacy `skills` / `strategies` 也不会覆盖顶层三态或会话状态,非空但全部无效的 Skill 请求不会被当成显式空列表并清空既有选择

View File

@@ -141,7 +141,6 @@ daily_stock_analysis/
| `MD2IMG_ENGINE` | 转图引擎:`wkhtmltoimage`(默认)、`markdown-to-file``playwright`(需安装 Web 依赖与 Chromium | 可选 |
| `SHARE_IMAGE_XIAOHONGSHU_URL` | 分享图小红书主页 URL留空可不显示链接 | 可选 |
| `SHARE_IMAGE_XIAOHONGSHU_HANDLE` | 分享图小红书昵称;全部小红书配置留空时显示内置昵称 `@霸天土小豆` | 可选 |
| `SHARE_IMAGE_XIAOHONGSHU_ID` | 分享图小红书 ID留空不显示 ID | 可选 |
| `SHARE_IMAGE_XIAOHONGSHU_QR_PATH` | 分享图小红书二维码文件路径;支持绝对路径或相对项目根目录,全部小红书配置留空时使用仓库内置二维码 | 可选 |
| `PREFETCH_REALTIME_QUOTES` | 设为 `false` 可禁用实时行情预取,避免 efinance/akshare_em 全市场拉取(默认 true | 可选 |

View File

@@ -129,7 +129,6 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions`
| `MD2IMG_ENGINE` | Image renderer: `wkhtmltoimage` (default), `markdown-to-file`, or `playwright` | Optional |
| `SHARE_IMAGE_XIAOHONGSHU_URL` | Xiaohongshu profile URL shown in share images; empty disables the link | Optional |
| `SHARE_IMAGE_XIAOHONGSHU_HANDLE` | Xiaohongshu nickname shown in share images; when all Xiaohongshu settings are empty, uses bundled nickname `@霸天土小豆` | Optional |
| `SHARE_IMAGE_XIAOHONGSHU_ID` | Xiaohongshu account ID shown in share images; empty hides the ID | Optional |
| `SHARE_IMAGE_XIAOHONGSHU_QR_PATH` | QR image path, absolute or relative to the project root; when all Xiaohongshu settings are empty, uses the bundled QR | Optional |
| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |
| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |

View File

@@ -119,7 +119,7 @@ Discord 长报告发送复用现有分片链路:单条 `content` 运行时不
配置 `MARKDOWN_TO_IMAGE_CHANNELS` 后,个股分析、聚合报告与大盘复盘会沿用现有通知路由,在转图阶段套用 1080px 宽的品牌分享模板。单只个股按“结论—点位—技术—风险—持仓”生成决策卡,大盘按“信号—指数—宽度—强弱板块—资金观察—重点跟踪—策略—风险”生成复盘卡;多股报告保留聚合布局。底部展示 GitHub 仓库地址、可选的小红书账号区域,以及“仅供研究交流,不构成投资建议”的风险提示。
- 小红书 URL、昵称、ID 与二维码路径可由 `SHARE_IMAGE_XIAOHONGSHU_*` 配置覆盖;全部留空时显示内置二维码和昵称 `@霸天土小豆`,默认不展示数字 ID。二维码转图时嵌入 HTML不依赖外部图片服务或运行时网络。
- 小红书 URL、昵称与二维码路径可由 `SHARE_IMAGE_XIAOHONGSHU_*` 配置覆盖;二维码下方只显示昵称,不显示数字 ID。全部留空时显示内置二维码和昵称 `@霸天土小豆`。二维码转图时嵌入 HTML不依赖外部图片服务或运行时网络。
- 模板只展示报告已有的 0100 评分、八态动作和 `battle_plan.sniper_points` 点位;理想/次优买入点、止损位和目标位会使用专门的高对比交易卡片,不生成额外评分或多空占比。
- 结构化字段、缺失值行为、个股/大盘映射和本地预览示例见 [分享图片模板与数据填充](share-images.md)。
- `wkhtmltoimage``markdown-to-file``playwright` 使用同一份海报 HTML现有 `MD2IMG_ENGINE``MARKDOWN_TO_IMAGE_MAX_CHARS` 和转换失败后回退文本的行为不变。Playwright 模式需先安装 Web 依赖并执行 `npx playwright install chromium`

View File

@@ -27,11 +27,10 @@
```dotenv
SHARE_IMAGE_XIAOHONGSHU_URL=https://example.com/my-xiaohongshu
SHARE_IMAGE_XIAOHONGSHU_HANDLE=@我的账号
SHARE_IMAGE_XIAOHONGSHU_ID=123456789
SHARE_IMAGE_XIAOHONGSHU_QR_PATH=assets/my-xiaohongshu-qr.png
```
二维码路径支持绝对路径或相对项目根目录路径;冻结桌面后端也会从 PyInstaller 资源目录解析相对路径。账号 URL 只接受 `http://``https://`。二维码在转图时以内嵌 Data URI 渲染,不依赖运行时网络。未配置 `SHARE_IMAGE_XIAOHONGSHU_QR_PATH` 时,统一回退到随源码和桌面包分发的 `src/assets/share_image/xiaohongshu_qr.jpg`,因此 Web PNG 与桌面 Electron PNG 都会保留二维码。
二维码路径支持绝对路径或相对项目根目录路径;冻结桌面后端也会从 PyInstaller 资源目录解析相对路径。账号 URL 只接受 `http://``https://`。二维码在转图时以内嵌 Data URI 渲染,不依赖运行时网络。未配置 `SHARE_IMAGE_XIAOHONGSHU_QR_PATH` 时,统一回退到随源码和桌面包分发的 `src/assets/share_image/xiaohongshu_qr.jpg`,因此 Web PNG 与桌面 Electron PNG 都会保留二维码。二维码下方固定显示小红书昵称,例如 `小红书@霸天土小豆`;历史配置中的数字 ID 不参与分享图渲染。
## Web 一键分享
@@ -151,6 +150,6 @@ png_bytes = markdown_to_image(
- 涨跌颜色优先使用结构化 payload 持久化的 `color_scheme`,旧记录则从最终报告颜色标记恢复;模板不按市场地区硬编码涨跌色。
- 分享图中的买入、止损和目标只保留可扫描的价格或“等待企稳”;完整条件始终保留在原报告中。
- 没有真实价格序列时不绘制伪 K 线;顶部仅保留非数据化的品牌光晕。
- 小红书 URL、昵称、ID 和二维码路径可由运行时配置覆盖;全部留空时使用内置昵称 `@霸天土小豆` 和仓库内置二维码,默认不展示数字 ID。GitHub 固定展示仓库标识 `ZhuLinsen/daily_stock_analysis`,不生成二维码。
- 小红书 URL、昵称和二维码路径可由运行时配置覆盖二维码下只显示昵称,不显示数字 ID。全部留空时使用内置昵称 `@霸天土小豆` 和仓库内置二维码。GitHub 固定展示仓库标识 `ZhuLinsen/daily_stock_analysis`,不生成二维码。
- 大盘报告在核心模块已成功提取时不重复附加完整 Markdown额外的详情章节保留在原报告中分享图只呈现结构化摘要。
- 图片底部固定说明“AI 生成,仅供研究交流,不构成投资建议”。

View File

@@ -166,6 +166,8 @@ class ShareImageBranding:
xiaohongshu_url: str = ""
xiaohongshu_handle: str = ""
# Kept for compatibility with persisted configs. The poster deliberately
# renders only the public nickname/handle below the QR code.
xiaohongshu_id: str = ""
xiaohongshu_qr_path: str = ""
@@ -174,7 +176,6 @@ class ShareImageBranding:
return any((
self.xiaohongshu_url.strip(),
self.xiaohongshu_handle.strip(),
self.xiaohongshu_id.strip(),
self.xiaohongshu_qr_path.strip(),
))
@@ -187,7 +188,7 @@ def share_image_branding_from_config(config: object) -> ShareImageBranding:
account_id = str(getattr(config, "share_image_xiaohongshu_id", None) or "").strip()
qr_path = str(getattr(config, "share_image_xiaohongshu_qr_path", None) or "").strip()
if not any((url, handle, account_id, qr_path)):
if not any((url, handle, qr_path)):
handle = DEFAULT_XIAOHONGSHU_HANDLE
qr_path = DEFAULT_XIAOHONGSHU_QR_PATH
@@ -1995,11 +1996,8 @@ def _xiaohongshu_card(branding: ShareImageBranding, language: str) -> str:
return ""
label = _poster_text(language, "xiaohongshu")
account_parts = [part for part in (
branding.xiaohongshu_handle.strip(),
f"ID {branding.xiaohongshu_id.strip()}" if branding.xiaohongshu_id.strip() else "",
) if part]
account = " · ".join(account_parts) or branding.xiaohongshu_url.strip()
handle = branding.xiaohongshu_handle.strip()
account = handle or branding.xiaohongshu_url.strip()
qr_data_uri = _asset_data_uri(branding.xiaohongshu_qr_path)
qr_alt = f"{label}二维码" if language == "zh" else f"{label} QR"
image = (
@@ -2009,9 +2007,8 @@ def _xiaohongshu_card(branding: ShareImageBranding, language: str) -> str:
url = _safe_web_url(branding.xiaohongshu_url)
if image and url:
image = f'<a href="{_escape(url)}">{image}</a>'
account_markup = (
f'<span><b>{_escape(label)}</b>{(" " + _escape(account)) if account else ""}</span>'
)
separator = "" if handle.startswith("@") else (" " if account else "")
account_markup = f'<span><b>{_escape(label)}</b>{separator}{_escape(account)}</span>'
if url:
account_markup = f'<a class="social-link" href="{_escape(url)}">{account_markup}</a>'
return (

View File

@@ -41,7 +41,8 @@ def test_wkhtml_renderer_uses_share_poster_dimensions_and_qr_template():
assert 'class="poster market"' in html
assert "项目主页二维码" not in html
assert "ZhuLinsen/daily_stock_analysis" in html
assert "@示例账号" in html
assert "<b>小红书</b>@示例账号" in html
assert "123456" not in html
assert options["width"] == 1080
assert options["disable-smart-width"] == ""
@@ -69,7 +70,8 @@ def test_markdown_to_file_renderer_receives_the_same_share_poster(tmp_path, monk
assert 'class="poster stock"' in captured["html"]
assert "项目主页二维码" not in captured["html"]
assert "ZhuLinsen/daily_stock_analysis" in captured["html"]
assert "@示例账号" in captured["html"]
assert "<b>小红书</b>@示例账号" in captured["html"]
assert "123456" not in captured["html"]
def test_playwright_renderer_receives_the_same_share_poster(tmp_path, monkeypatch):
@@ -102,6 +104,8 @@ def test_playwright_renderer_receives_the_same_share_poster(tmp_path, monkeypatc
assert "--full-page" in captured["args"]
assert 'class="poster stock"' in captured["html"]
assert "ZhuLinsen/daily_stock_analysis" in captured["html"]
assert "<b>小红书</b>@示例账号" in captured["html"]
assert "123456" not in captured["html"]
def test_config_accepts_playwright_image_engine():

View File

@@ -45,8 +45,8 @@ def test_stock_share_image_uses_configured_xiaohongshu_branding():
assert PROJECT_REPOSITORY in html
assert "小红书二维码" in html
assert PROJECT_DISPLAY_NAME in html
assert XIAOHONGSHU_HANDLE in html
assert f"<b>小红书</b> {XIAOHONGSHU_HANDLE} · ID 123456" in html
assert f"<b>小红书</b>{XIAOHONGSHU_HANDLE}" in html
assert "123456" not in html
assert 'href="https://example.com/xiaohongshu"' in html
assert "2026-07-31" in html
assert html.count("<h1>") == 1
@@ -77,8 +77,8 @@ def test_runtime_branding_defaults_to_bundled_xiaohongshu_qr():
assert html.count('class="qr-frame"') == 1
assert html.count("data:image/jpeg;base64,") == 1
assert f"<b>小红书</b> {DEFAULT_XIAOHONGSHU_HANDLE}" in html
assert " · ID " not in html
assert f"<b>小红书</b>{DEFAULT_XIAOHONGSHU_HANDLE}" in html
assert " ID " not in html
def test_runtime_branding_does_not_mix_default_qr_with_custom_identity():
@@ -99,8 +99,8 @@ def test_runtime_branding_does_not_mix_default_qr_with_custom_identity():
branding=branding,
)
assert "@自定义账号" in html
assert "ID custom-id" in html
assert "<b>小红书</b>@自定义账号" in html
assert "custom-id" not in html
assert "data:image/jpeg;base64," not in html
assert DEFAULT_XIAOHONGSHU_HANDLE not in html