From 46d5bf3472c089992efbf5725c0938b63449a575 Mon Sep 17 00:00:00 2001 From: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:53:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF=E6=8A=A5=E5=91=8A=E5=88=86=E4=BA=AB=E5=9B=BE=20(#2169?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: enable desktop report share images * fix: include default share image branding * fix(review-feedback-2169): Keep custom branding paired with its QR code * fix: show default share image nickname * fix(review-feedback-2169): Document the atomic branding fallback * fix(review-feedback-2169): Remove the hard-coded social account default * fix(review-feedback-2169): Remove the bundled account QR fallback * fix(review-feedback-2169): update docs/share-images * fix(review-feedback-2169): [Verification blocker] 当前 Head 的阻断型 CI * fix(review-feedback-2169): 补一条回归:用包含超长 fenced-code 行和超长原始 URL 的完整/通用报告分别走 build share image html --- .env.example | 6 +- api/v1/endpoints/history.py | 137 ++++++++++++---- apps/dsa-desktop/main.js | 132 ++++++++++++++++ apps/dsa-desktop/preload.js | 5 + apps/dsa-desktop/tests/main.test.js | 148 ++++++++++++++++++ apps/dsa-desktop/tests/preload.test.js | 5 + .../components/report/ShareImageButton.tsx | 18 ++- .../__tests__/ShareImageButton.test.tsx | 26 ++- docs/CHANGELOG.md | 1 + docs/desktop-package.md | 1 + docs/full-guide.md | 8 +- docs/full-guide_EN.md | 6 +- docs/notifications.md | 2 +- docs/share-images.md | 12 +- src/md2img.py | 15 +- src/share_image.py | 35 ++++- tests/test_history_share_image.py | 59 +++++++ tests/test_md2img_template.py | 20 ++- tests/test_share_image.py | 135 ++++++++++++++++ 19 files changed, 704 insertions(+), 67 deletions(-) diff --git a/.env.example b/.env.example index 56ba8b328..aa82f35bc 100644 --- a/.env.example +++ b/.env.example @@ -697,11 +697,11 @@ DINGTALK_SECRET= # MARKDOWN_TO_IMAGE_CHANNELS=telegram,wechat,custom,email,slack # 逗号分隔 # MARKDOWN_TO_IMAGE_MAX_CHARS=15000 # 超过此长度不转换,避免超大图片 # MD2IMG_ENGINE=wkhtmltoimage # wkhtmltoimage(默认) | markdown-to-file | playwright(需 Web 依赖和 npx playwright install chromium) -# 分享图社交账号品牌(全部留空则不显示小红书区域;fork/私有部署请配置自己的账号与二维码) +# 分享图默认展示仓库内置小红书二维码和昵称 @霸天土小豆;以下配置可整体替换品牌信息 # SHARE_IMAGE_XIAOHONGSHU_URL= -# SHARE_IMAGE_XIAOHONGSHU_HANDLE= +# SHARE_IMAGE_XIAOHONGSHU_HANDLE=@霸天土小豆 # 全部配置留空时显示内置昵称 # SHARE_IMAGE_XIAOHONGSHU_ID= -# SHARE_IMAGE_XIAOHONGSHU_QR_PATH=src/assets/share_image/xiaohongshu_qr.jpg +# SHARE_IMAGE_XIAOHONGSHU_QR_PATH=assets/my-xiaohongshu-qr.png # 全部配置留空时使用内置二维码 # 转图工具:wkhtmltopdf (apt install wkhtmltopdf / brew install wkhtmltopdf),或 markdown-to-file # # 【通知路由策略】(Issue #1200 P3) diff --git a/api/v1/endpoints/history.py b/api/v1/endpoints/history.py index 45d67141b..597d5e152 100644 --- a/api/v1/endpoints/history.py +++ b/api/v1/endpoints/history.py @@ -13,7 +13,7 @@ import logging from typing import Any, Mapping, Optional from fastapi import APIRouter, HTTPException, Query, Depends, Body -from fastapi.responses import Response +from fastapi.responses import HTMLResponse, Response from api.deps import get_database_manager from api.v1.schemas.history import ( @@ -59,6 +59,11 @@ from src.analysis_context_pack_overview import ( from src.market_phase_summary import extract_market_phase_summary from src.config import get_config from src.md2img import markdown_to_image +from src.share_image import ( + ShareImageBranding, + build_share_image_html, + share_image_branding_from_config, +) logger = logging.getLogger(__name__) @@ -80,6 +85,50 @@ def _history_share_image_payload(result: Mapping[str, Any]) -> Optional[Mapping[ return raw_result if isinstance(raw_result, Mapping) else None +def _history_share_image_input( + record_id: str, + db_manager: DatabaseManager, +) -> tuple[Mapping[str, Any], str]: + """Load the shared persisted input used by PNG and desktop HTML renderers.""" + + service = HistoryService(db_manager) + result = service.resolve_and_get_detail(record_id) + if result is None: + raise HTTPException( + status_code=404, + detail={ + "error": "not_found", + "message": f"未找到 id/query_id={record_id} 的分析记录", + }, + ) + + try: + markdown_content = service.get_markdown_report(record_id) + except MarkdownReportGenerationError as exc: + logger.error("Share image report generation failed for %s: %s", record_id, exc.message) + raise HTTPException( + status_code=500, + detail={ + "error": "generation_failed", + "message": f"生成分享图片所需报告失败: {exc.message}", + }, + ) from exc + + if not markdown_content: + raise HTTPException( + status_code=404, + detail={ + "error": "not_found", + "message": f"未找到 id/query_id={record_id} 的报告内容", + }, + ) + return result, markdown_content + + +def _history_share_image_branding(config: object) -> ShareImageBranding: + return share_image_branding_from_config(config) + + def _normalize_code_for_grouping(code: str) -> str: """Normalize stock code for deduplication grouping. @@ -763,6 +812,60 @@ def get_history_news( ) +@router.get( + "/{record_id}/share-image-html", + response_class=HTMLResponse, + responses={ + 200: {"description": "供桌面端内置 Chromium 渲染的分享图 HTML"}, + 404: {"description": "报告不存在", "model": ErrorResponse}, + 413: {"description": "报告内容超过分享图长度上限", "model": ErrorResponse}, + 500: {"description": "报告生成失败", "model": ErrorResponse}, + }, + summary="获取历史报告分享图 HTML", + description="根据历史报告与持久化结构化数据生成只供桌面端本地截图的确定性 HTML", +) +def get_history_share_image_html( + record_id: str, + db_manager: DatabaseManager = Depends(get_database_manager), +) -> HTMLResponse: + result, markdown_content = _history_share_image_input(record_id, db_manager) + config = get_config() + max_chars = getattr(config, "markdown_to_image_max_chars", 15000) + if len(markdown_content) > max_chars: + raise HTTPException( + status_code=413, + detail={ + "error": "share_image_too_large", + "message": f"报告内容超过分享图片上限 {max_chars} 字符", + }, + ) + + try: + html = build_share_image_html( + markdown_content, + structured_payload=_history_share_image_payload(result), + branding=_history_share_image_branding(config), + ) + except Exception as exc: + logger.error("Share image HTML generation failed for %s: %s", record_id, exc) + raise HTTPException( + status_code=500, + detail={ + "error": "generation_failed", + "message": "生成桌面分享图片内容失败", + }, + ) from exc + + return HTMLResponse( + content=html, + headers={ + "Cache-Control": "no-store", + "Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'", + "X-Content-Type-Options": "nosniff", + }, + ) + + @router.get( "/{record_id}/share-image", response_class=Response, @@ -779,37 +882,7 @@ def get_history_share_image( record_id: str, db_manager: DatabaseManager = Depends(get_database_manager), ) -> Response: - service = HistoryService(db_manager) - result = service.resolve_and_get_detail(record_id) - if result is None: - raise HTTPException( - status_code=404, - detail={ - "error": "not_found", - "message": f"未找到 id/query_id={record_id} 的分析记录", - }, - ) - - try: - markdown_content = service.get_markdown_report(record_id) - except MarkdownReportGenerationError as exc: - logger.error("Share image report generation failed for %s: %s", record_id, exc.message) - raise HTTPException( - status_code=500, - detail={ - "error": "generation_failed", - "message": f"生成分享图片所需报告失败: {exc.message}", - }, - ) from exc - - if not markdown_content: - raise HTTPException( - status_code=404, - detail={ - "error": "not_found", - "message": f"未找到 id/query_id={record_id} 的报告内容", - }, - ) + result, markdown_content = _history_share_image_input(record_id, db_manager) config = get_config() image_bytes = markdown_to_image( diff --git a/apps/dsa-desktop/main.js b/apps/dsa-desktop/main.js index 9b7acc3e8..c9f08c855 100644 --- a/apps/dsa-desktop/main.js +++ b/apps/dsa-desktop/main.js @@ -17,6 +17,7 @@ let lastPromptedInstallVersion = ''; let electronAutoUpdater = undefined; let electronAutoUpdaterConfigured = false; let electronUpdateCheckInFlight = false; +let desktopBackendOrigin = ''; function resolveWindowBackgroundColor() { return nativeTheme.shouldUseDarkColors ? '#08080c' : '#f4f7fb'; @@ -33,6 +34,9 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 5000; const DESKTOP_UPDATE_BACKUP_DIR = '.dsa-desktop-update-backup'; const DESKTOP_UPDATE_BACKUP_MANIFEST_FILE = 'runtime-state.json'; const DESKTOP_BACKEND_DEFAULT_HOST = '127.0.0.1'; +const DESKTOP_SHARE_IMAGE_WIDTH = 1080; +const DESKTOP_SHARE_IMAGE_INITIAL_HEIGHT = 720; +const DESKTOP_SHARE_IMAGE_MAX_HEIGHT = 20000; const PUBLIC_BIND_HOSTS = Object.freeze(new Set(['0.0.0.0', '::', '[::]', '*'])); const MAC_DESKTOP_CLI_PATH_ENTRIES = Object.freeze([ '/opt/homebrew/bin', @@ -1338,6 +1342,118 @@ function buildMainPageUrl(port, timestamp = Date.now(), host = DESKTOP_BACKEND_D return url.toString(); } +function buildDesktopShareImageUrl(pageUrl, recordId, expectedBackendOrigin = '') { + if (!Number.isSafeInteger(recordId) || recordId <= 0) { + throw new Error('Invalid share image record ID'); + } + + let page; + try { + page = new URL(pageUrl); + } catch (_error) { + throw new Error('Desktop backend URL is unavailable'); + } + + let expectedOrigin = page.origin; + if (expectedBackendOrigin) { + try { + expectedOrigin = new URL(expectedBackendOrigin).origin; + } catch (_error) { + throw new Error('Desktop backend origin is invalid'); + } + } + if (page.protocol !== 'http:' || !page.port || page.origin !== expectedOrigin) { + throw new Error('Desktop share images require the configured backend origin'); + } + + return new URL( + `/api/v1/history/${recordId}/share-image-html`, + page.origin + ).toString(); +} + +async function renderDesktopShareImage( + recordId, + { + sourceWindow = mainWindow, + BrowserWindowClass = BrowserWindow, + backendOrigin = '', + } = {} +) { + if (!sourceWindow || sourceWindow.isDestroyed() || !sourceWindow.webContents) { + throw new Error('Desktop window is unavailable'); + } + + const targetUrl = buildDesktopShareImageUrl( + sourceWindow.webContents.getURL(), + recordId, + backendOrigin + ); + let renderWindow = null; + try { + renderWindow = new BrowserWindowClass({ + show: false, + width: DESKTOP_SHARE_IMAGE_WIDTH, + height: DESKTOP_SHARE_IMAGE_INITIAL_HEIGHT, + ...(isMac ? { enableLargerThanScreen: true } : {}), + useContentSize: true, + backgroundColor: '#eef4fd', + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + backgroundThrottling: false, + }, + }); + renderWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + renderWindow.webContents.on('will-navigate', (event, navigationUrl) => { + if (navigationUrl !== targetUrl) { + event.preventDefault(); + } + }); + + await renderWindow.loadURL(targetUrl); + const pageMetrics = await renderWindow.webContents.executeJavaScript(`({ + contentType: document.contentType, + width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth)), + height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)) + })`); + if (!pageMetrics || pageMetrics.contentType !== 'text/html') { + throw new Error('Desktop share image source did not return HTML'); + } + if ( + !Number.isFinite(pageMetrics.width) + || pageMetrics.width !== DESKTOP_SHARE_IMAGE_WIDTH + || !Number.isFinite(pageMetrics.height) + || pageMetrics.height < 1 + || pageMetrics.height > DESKTOP_SHARE_IMAGE_MAX_HEIGHT + ) { + throw new Error(`Desktop share image has invalid dimensions: ${pageMetrics.width}x${pageMetrics.height}`); + } + + renderWindow.setContentSize(DESKTOP_SHARE_IMAGE_WIDTH, pageMetrics.height); + await renderWindow.webContents.executeJavaScript( + 'new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))' + ); + const image = await renderWindow.webContents.capturePage({ + x: 0, + y: 0, + width: DESKTOP_SHARE_IMAGE_WIDTH, + height: pageMetrics.height, + }); + if (!image || image.isEmpty()) { + throw new Error('Desktop share image capture returned an empty image'); + } + + const png = image.toPNG(); + return png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength); + } finally { + if (renderWindow && !renderWindow.isDestroyed()) { + renderWindow.destroy(); + } + } +} + function isWindowsNsisInstalledApp() { if (!isWindows || !app.isPackaged) { return false; @@ -1742,8 +1858,21 @@ ipcMain.handle('desktop:open-release-page', async (_event, releaseUrl) => { await shell.openExternal(sanitizeReleaseUrl(releaseUrl)); return true; }); +ipcMain.handle('desktop:render-share-image', async (event, recordId) => { + if (!mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) { + throw new Error('Share image request did not originate from the desktop window'); + } + try { + return await renderDesktopShareImage(recordId, { backendOrigin: desktopBackendOrigin }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logLine(`[share-image] desktop render failed for record=${recordId}: ${message}`); + throw error; + } +}); async function createWindow() { + desktopBackendOrigin = ''; const restoreResult = isWindowsNsisInstalledApp() ? restorePackagedRuntimeStateFromBackup() : null; const macMigrationResult = migrateMacPackagedRuntimeState(); initLogging(); @@ -1843,6 +1972,7 @@ async function createWindow() { const portFindStartedAt = Date.now(); const port = await findAvailablePort(8000, 8100, backendBindHost); logStartup(`Using port ${port} (selected in ${Date.now() - portFindStartedAt}ms)`); + desktopBackendOrigin = new URL(buildBackendUrl(backendConnectHost, port)).origin; logStartup(`App directory=${appDir}`); const dbPath = path.join(appDir, 'data', 'stock_analysis.db'); @@ -1981,6 +2111,7 @@ module.exports = { fetchLatestReleaseJson, findAvailablePort, buildMainPageUrl, + buildDesktopShareImageUrl, migrateMacPackagedRuntimeState, normalizeVersionString, parseSemver, @@ -1988,6 +2119,7 @@ module.exports = { resolveAppDir, resolveBackendBindHost, resolveDesktopConnectHost, + renderDesktopShareImage, restorePackagedRuntimeStateFromBackup, sanitizeReleaseUrl, startBackend, diff --git a/apps/dsa-desktop/preload.js b/apps/dsa-desktop/preload.js index 5ab4b468d..700724801 100644 --- a/apps/dsa-desktop/preload.js +++ b/apps/dsa-desktop/preload.js @@ -5,6 +5,7 @@ const DESKTOP_GET_UPDATE_STATE_CHANNEL = 'desktop:get-update-state'; const DESKTOP_CHECK_FOR_UPDATES_CHANNEL = 'desktop:check-for-updates'; const DESKTOP_INSTALL_DOWNLOADED_UPDATE_CHANNEL = 'desktop:install-downloaded-update'; const DESKTOP_OPEN_RELEASE_PAGE_CHANNEL = 'desktop:open-release-page'; +const DESKTOP_RENDER_SHARE_IMAGE_CHANNEL = 'desktop:render-share-image'; const DESKTOP_UPDATE_STATE_EVENT = 'desktop:update-state'; function readDesktopVersion(argv = process.argv) { @@ -32,6 +33,9 @@ function createDesktopBridge({ openReleasePage(releaseUrl) { return renderer.invoke(DESKTOP_OPEN_RELEASE_PAGE_CHANNEL, releaseUrl); }, + renderShareImage(recordId) { + return renderer.invoke(DESKTOP_RENDER_SHARE_IMAGE_CHANNEL, recordId); + }, onUpdateStateChange(listener) { if (typeof listener !== 'function') { return () => undefined; @@ -55,6 +59,7 @@ module.exports = { DESKTOP_GET_UPDATE_STATE_CHANNEL, DESKTOP_INSTALL_DOWNLOADED_UPDATE_CHANNEL, DESKTOP_OPEN_RELEASE_PAGE_CHANNEL, + DESKTOP_RENDER_SHARE_IMAGE_CHANNEL, DESKTOP_UPDATE_STATE_EVENT, DESKTOP_VERSION_ARG_PREFIX, createDesktopBridge, diff --git a/apps/dsa-desktop/tests/main.test.js b/apps/dsa-desktop/tests/main.test.js index e87f62efb..2ae7bec70 100644 --- a/apps/dsa-desktop/tests/main.test.js +++ b/apps/dsa-desktop/tests/main.test.js @@ -151,6 +151,154 @@ test('buildMainPageUrl uses a connect host when provided', (t) => { ); }); +test('buildDesktopShareImageUrl restricts rendering to the configured backend record', (t) => { + const mainModule = loadMainModule(t); + + assert.equal( + mainModule.buildDesktopShareImageUrl('http://127.0.0.1:8123/?desktop_version=3.30.0', 17), + 'http://127.0.0.1:8123/api/v1/history/17/share-image-html' + ); + assert.throws( + () => mainModule.buildDesktopShareImageUrl( + 'http://example.com:8123/', + 17, + 'http://127.0.0.1:8123' + ), + /configured backend origin/ + ); + assert.equal( + mainModule.buildDesktopShareImageUrl( + 'http://192.168.1.9:8123/', + 18, + 'http://192.168.1.9:8123' + ), + 'http://192.168.1.9:8123/api/v1/history/18/share-image-html' + ); + assert.throws( + () => mainModule.buildDesktopShareImageUrl('http://127.0.0.1:8123/', 0), + /Invalid share image record ID/ + ); +}); + +test('renderDesktopShareImage captures the complete local poster and closes its window', async (t) => { + const windows = []; + function FakeRenderWindow(options) { + const listeners = new Map(); + let destroyed = false; + let executeCount = 0; + const instance = { + options, + loadedUrl: '', + contentSize: null, + captureRect: null, + webContents: { + setWindowOpenHandler: () => undefined, + on: (event, listener) => listeners.set(event, listener), + executeJavaScript: async () => { + executeCount += 1; + return executeCount === 1 + ? { contentType: 'text/html', width: 1080, height: 1840 } + : undefined; + }, + capturePage: async (rect) => { + instance.captureRect = rect; + return { + isEmpty: () => false, + toPNG: () => Buffer.from('png-bytes'), + }; + }, + }, + loadURL: async (url) => { + instance.loadedUrl = url; + }, + setContentSize: (width, height) => { + instance.contentSize = [width, height]; + }, + isDestroyed: () => destroyed, + destroy: () => { + destroyed = true; + }, + }; + windows.push(instance); + return instance; + } + FakeRenderWindow.getAllWindows = () => []; + + const mainModule = loadMainModule(t, { browserWindow: FakeRenderWindow }); + const sourceWindow = { + isDestroyed: () => false, + webContents: { + getURL: () => 'http://127.0.0.1:8123/?desktop_version=3.30.0', + }, + }; + + const bytes = await mainModule.renderDesktopShareImage(29, { + sourceWindow, + BrowserWindowClass: FakeRenderWindow, + }); + + assert.equal(windows.length, 1); + assert.equal('enableLargerThanScreen' in windows[0].options, false); + assert.equal(windows[0].loadedUrl, 'http://127.0.0.1:8123/api/v1/history/29/share-image-html'); + assert.deepEqual(windows[0].contentSize, [1080, 1840]); + assert.deepEqual(windows[0].captureRect, { x: 0, y: 0, width: 1080, height: 1840 }); + assert.equal(windows[0].isDestroyed(), true); + assert.equal(Buffer.from(bytes).toString(), 'png-bytes'); +}); + +test('renderDesktopShareImage enables larger-than-screen capture windows on macOS', async (t) => { + const windows = []; + function FakeRenderWindow(options) { + let destroyed = false; + let executeCount = 0; + const instance = { + options, + webContents: { + setWindowOpenHandler: () => undefined, + on: () => undefined, + executeJavaScript: async () => { + executeCount += 1; + return executeCount === 1 + ? { contentType: 'text/html', width: 1080, height: 4200 } + : undefined; + }, + capturePage: async () => ({ + isEmpty: () => false, + toPNG: () => Buffer.from('mac-png'), + }), + }, + loadURL: async () => undefined, + setContentSize: () => undefined, + isDestroyed: () => destroyed, + destroy: () => { + destroyed = true; + }, + }; + windows.push(instance); + return instance; + } + FakeRenderWindow.getAllWindows = () => []; + + const mainModule = loadMainModule(t, { + browserWindow: FakeRenderWindow, + platform: 'darwin', + }); + const sourceWindow = { + isDestroyed: () => false, + webContents: { + getURL: () => 'http://127.0.0.1:8123/?desktop_version=3.30.0', + }, + }; + + await mainModule.renderDesktopShareImage(31, { + sourceWindow, + BrowserWindowClass: FakeRenderWindow, + }); + + assert.equal(windows.length, 1); + assert.equal(windows[0].options.enableLargerThanScreen, true); +}); + test('resolveDesktopConnectHost keeps desktop navigation local for public binds', (t) => { const mainModule = loadMainModule(t); diff --git a/apps/dsa-desktop/tests/preload.test.js b/apps/dsa-desktop/tests/preload.test.js index 26165eaf1..eeabc6531 100644 --- a/apps/dsa-desktop/tests/preload.test.js +++ b/apps/dsa-desktop/tests/preload.test.js @@ -46,6 +46,7 @@ test('preload exposes desktop version from BrowserWindow additionalArguments', ( assert.equal(typeof exposeInMainWorldCalls[0][1].checkForUpdates, 'function'); assert.equal(typeof exposeInMainWorldCalls[0][1].installDownloadedUpdate, 'function'); assert.equal(typeof exposeInMainWorldCalls[0][1].openReleasePage, 'function'); + assert.equal(typeof exposeInMainWorldCalls[0][1].renderShareImage, 'function'); assert.equal(typeof exposeInMainWorldCalls[0][1].onUpdateStateChange, 'function'); assert.equal( preloadModule.readDesktopVersion([`--dsa-desktop-version=${expectedVersion}`]), @@ -152,6 +153,10 @@ test('createDesktopBridge delegates update actions to ipcRenderer', async (t) => channel: preloadModule.DESKTOP_OPEN_RELEASE_PAGE_CHANNEL, payload: 'https://github.com/ZhuLinsen/daily_stock_analysis/releases/tag/v3.13.0', }); + assert.deepEqual(await desktopBridge.renderShareImage(17), { + channel: preloadModule.DESKTOP_RENDER_SHARE_IMAGE_CHANNEL, + payload: 17, + }); const receivedPayloads = []; const unsubscribe = desktopBridge.onUpdateStateChange((payload) => { diff --git a/apps/dsa-web/src/components/report/ShareImageButton.tsx b/apps/dsa-web/src/components/report/ShareImageButton.tsx index 002b39177..ffb90abb6 100644 --- a/apps/dsa-web/src/components/report/ShareImageButton.tsx +++ b/apps/dsa-web/src/components/report/ShareImageButton.tsx @@ -7,7 +7,9 @@ import { getReportText, normalizeReportLanguage } from '../../utils/reportLangua import { Tooltip } from '../common/Tooltip'; type DesktopWindow = Window & { - dsaDesktop?: unknown; + dsaDesktop?: { + renderShareImage?: (recordId: number) => Promise; + }; }; type ShareState = 'idle' | 'loading' | 'ready' | 'success' | 'error'; @@ -39,8 +41,9 @@ export const ShareImageButton: React.FC = ({ reportLanguage = 'zh', className = '', }) => { - const isDesktopRuntime = typeof window !== 'undefined' && Boolean((window as DesktopWindow).dsaDesktop); - const activeRecordId = isDesktopRuntime ? undefined : recordId; + const desktopRuntime = typeof window !== 'undefined' ? (window as DesktopWindow).dsaDesktop : undefined; + const renderDesktopShareImage = desktopRuntime?.renderShareImage; + const activeRecordId = desktopRuntime && !renderDesktopShareImage ? undefined : recordId; const text = getReportText(normalizeReportLanguage(reportLanguage)); const [stateSnapshot, setStateSnapshot] = useState<{ recordId?: number; @@ -100,7 +103,12 @@ export const ShareImageButton: React.FC = ({ loadTokenRef.current = loadToken; setState('loading'); try { - blob = await historyApi.getShareImage(activeRecordId); + if (renderDesktopShareImage) { + const pngBytes = await renderDesktopShareImage(activeRecordId); + blob = new Blob([pngBytes], { type: 'image/png' }); + } else { + blob = await historyApi.getShareImage(activeRecordId); + } } catch (error) { if (loadTokenRef.current !== loadToken) return; console.error('Generate share image failed:', error); @@ -152,7 +160,7 @@ export const ShareImageButton: React.FC = ({ console.error('Generate share image failed:', error); setState('error'); } - }, [activeRecordId, clearResetTimer, reportTitle, scheduleReset, setState, state]); + }, [activeRecordId, clearResetTimer, renderDesktopShareImage, reportTitle, scheduleReset, setState, state]); if (activeRecordId === undefined) return null; diff --git a/apps/dsa-web/src/components/report/__tests__/ShareImageButton.test.tsx b/apps/dsa-web/src/components/report/__tests__/ShareImageButton.test.tsx index 1dd0d315f..06f16e86f 100644 --- a/apps/dsa-web/src/components/report/__tests__/ShareImageButton.test.tsx +++ b/apps/dsa-web/src/components/report/__tests__/ShareImageButton.test.tsx @@ -130,7 +130,7 @@ describe('ShareImageButton', () => { expect(mockedGetShareImage).toHaveBeenCalledWith(19); }); - it('does not render or prefetch share images during desktop runtime', () => { + it('keeps the button hidden for an older desktop bridge without image rendering', () => { mockedGetShareImage.mockResolvedValue(new Blob(['png'], { type: 'image/png' })); Object.defineProperty(window, 'dsaDesktop', { configurable: true, @@ -149,6 +149,30 @@ describe('ShareImageButton', () => { expect(mockedGetShareImage).not.toHaveBeenCalled(); }); + it('renders and downloads share images through the desktop bridge', async () => { + const renderShareImage = vi.fn().mockResolvedValue( + new TextEncoder().encode('png').buffer, + ); + Object.defineProperty(window, 'dsaDesktop', { + configurable: true, + value: { version: '3.30.0', renderShareImage }, + }); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '分享' })); + await waitFor(() => expect(renderShareImage).toHaveBeenCalledWith(24)); + await waitFor(() => expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled()); + expect(mockedGetShareImage).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: '已生成' })).toBeInTheDocument(); + }); + it('clears the previous success reset timer when switching to another record', async () => { vi.useFakeTimers(); const nativeShare = vi.fn().mockResolvedValue(undefined); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 67887760a..eb0d2fc97 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +- [修复] Electron 桌面端恢复历史个股报告、市场复盘和完整报告的“分享”入口,复用安装包自带 Chromium 从本机受限 HTML 生成 PNG;Web 与桌面端在未配置自定义品牌时统一使用随包分发的小红书二维码,并在二维码下展示昵称 `@霸天土小豆`。 - [新功能] Agent Chat 按会话持久化 Skill 选择,支持刷新和会话切换恢复,并区分省略 `skills`、显式空列表与非空选择;无持久化状态的历史会话继续使用运行时默认且不会被静默转为显式选择,复用分析 `context` 中残留的 legacy `skills` / `strategies` 也不会覆盖顶层三态或会话状态,非空但全部无效的 Skill 请求不会被当成显式空列表并清空既有选择 - [改进] 后端 CI 在不跳过离线测试的前提下按完整测试文件分成三个独立 runner 并行执行,由单一 `backend-gate` 汇总门禁结果;实测文件耗时和首分片静态检查成本共同参与负载平衡,新测试文件自动纳入,现有 pip 安装和测试参数保持不变,避免 xdist 进程内并发的全局状态竞态。 - [测试] 后端 CI 默认覆盖所有非 Web 改动,仅对已证明安全的纯 Web 路径跳过,并将整个 Web public 目录及前端渠道模板、设置帮助视为跨层运行合同;补充纯 Web、共享 Web 资产及 Web/非 Web 混合改动的过滤语义回归,明确 `predicate-quantifier: every` 按单文件匹配全部规则、再以任一匹配文件触发门禁。Docker CI 继续按构建输入过滤。离线测试保留稳定的串行执行与慢用例摘要,并移除重复用例和测试内真实等待。 diff --git a/docs/desktop-package.md b/docs/desktop-package.md index b50eac672..11dce7297 100644 --- a/docs/desktop-package.md +++ b/docs/desktop-package.md @@ -9,6 +9,7 @@ - Windows 便携/安装模式下,用户配置文件 `.env` 和数据库放在 exe 同级目录;macOS 打包版使用 Electron 用户数据目录保存运行时配置 - 桌面端会自动从本机 `8000-8100` 选择可用端口,并把实际选择的端口同步给内置后端;桌面端不依赖 `.env` 里的 `WEBUI_PORT` 来决定窗口连接地址,避免用户改端口后 Electron 仍等待旧端口导致启动超时 - Desktop backend 默认随 `requirements.txt` 安装并冻结 `futu-api==10.8.6808`;Windows/macOS 构建脚本会在源码环境和 PyInstaller 产物中分别执行 `import futu`,防止发布包只安装但未携带 SDK。 +- 报告“分享”按钮使用 Electron 自带的隐藏 Chromium 窗口渲染本地后端输出的受限 HTML,并保存为 PNG;桌面安装包无需额外携带 `wkhtmltoimage`、`markdown-to-file` 或 Playwright 浏览器。 ## 本地开发 diff --git a/docs/full-guide.md b/docs/full-guide.md index 680e5ee77..9061faaed 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -140,9 +140,9 @@ daily_stock_analysis/ | `MARKDOWN_TO_IMAGE_MAX_CHARS` | 超过此长度不转图片,避免超大图片(默认 15000) | 可选 | | `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` | 分享图小红书二维码文件路径;支持绝对路径或相对项目根目录,留空不显示二维码 | 可选 | +| `SHARE_IMAGE_XIAOHONGSHU_HANDLE` | 分享图小红书昵称;全部小红书配置留空时显示内置昵称 `@霸天土小豆` | 可选 | +| `SHARE_IMAGE_XIAOHONGSHU_ID` | 分享图小红书 ID;留空不显示 ID | 可选 | +| `SHARE_IMAGE_XIAOHONGSHU_QR_PATH` | 分享图小红书二维码文件路径;支持绝对路径或相对项目根目录,全部小红书配置留空时使用仓库内置二维码 | 可选 | | `PREFETCH_REALTIME_QUOTES` | 设为 `false` 可禁用实时行情预取,避免 efinance/akshare_em 全市场拉取(默认 true) | 可选 | > 兼容性说明:`REPORT_SHOW_LLM_MODEL` 维持默认 `true` 的原始展示语义,关闭时只影响底部模型文案输出。该配置不会变更 provider/model/Base URL、LiteLLM 路由、模型保存、迁移或清理语义;回退方式为恢复或删除该变量,并设为 `true`。 @@ -1603,6 +1603,8 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。 | `/api/v1/screening/screen/tasks` | POST | 后台提交选股任务(需先开启 `SCREENING_ENABLED`) | | `/api/v1/screening/screen/tasks/{task_id}` | GET | 查询选股任务状态与完成结果 | | `/api/v1/history` | GET | 查询分析历史 | +| `/api/v1/history/{record_id}/share-image` | GET | 生成浏览器版历史报告 PNG 分享图,需要可用的 `MD2IMG_ENGINE` | +| `/api/v1/history/{record_id}/share-image-html` | GET | 生成供 Electron 桌面端内置 Chromium 截图的受限分享图 HTML | | `/api/v1/history/{record_id}/diagnostics` | GET | 查询历史报告运行诊断摘要与脱敏复制文本 | | `/api/v1/history/{record_id}/flow` | GET | 查询历史报告运行流快照,普通个股和 `MARKET/market_review` 大盘复盘复用同一契约 | | `/api/v1/decision-signals` | POST | 显式创建或按同源键去重决策信号,返回 `{ item, created }` | diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index e6b823648..8e71a4480 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -128,9 +128,9 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions` | `MARKDOWN_TO_IMAGE_MAX_CHARS` | Skip image conversion above this Markdown length (default 15000) | Optional | | `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 handle shown in share images; empty hides the handle | 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; empty hides the QR | 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 | | `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional | @@ -1442,6 +1442,8 @@ For this feature, the product behavior is: | `/api/v1/screening/screen/tasks` | POST | Submit a screening task (`SCREENING_ENABLED` must be enabled first); an optional anonymous `variant_seed` samples a bounded near-score combination per run while preserving materially superior candidates, filters, risk controls, and scores | | `/api/v1/screening/screen/tasks/{task_id}` | GET | Query screening task status and completed result | | `/api/v1/history` | GET | Query analysis history | +| `/api/v1/history/{record_id}/share-image` | GET | Generate a historical-report PNG for browsers; requires an available `MD2IMG_ENGINE` | +| `/api/v1/history/{record_id}/share-image-html` | GET | Generate restricted poster HTML for capture by the Electron desktop Chromium runtime | | `/api/v1/history/{record_id}/diagnostics` | GET | Query a historical report run diagnostic summary and sanitized copy text | | `/api/v1/decision-signals` | POST | Explicitly create or deduplicate a decision signal and return `{ item, created }` | | `/api/v1/decision-signals` | GET | Paginated decision-signal query with stock, market, action, phase, profile, source, status, time-range, and cache-only holdings filters | diff --git a/docs/notifications.md b/docs/notifications.md index 4125f6a03..31ebe522a 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -119,7 +119,7 @@ Discord 长报告发送复用现有分片链路:单条 `content` 运行时不 配置 `MARKDOWN_TO_IMAGE_CHANNELS` 后,个股分析、聚合报告与大盘复盘会沿用现有通知路由,在转图阶段套用 1080px 宽的品牌分享模板。单只个股按“结论—点位—技术—风险—持仓”生成决策卡,大盘按“信号—指数—宽度—强弱板块—资金观察—重点跟踪—策略—风险”生成复盘卡;多股报告保留聚合布局。底部展示 GitHub 仓库地址、可选的小红书账号区域,以及“仅供研究交流,不构成投资建议”的风险提示。 -- 小红书 URL、账号、ID 与二维码路径由 `SHARE_IMAGE_XIAOHONGSHU_*` 配置;全部留空时不显示该区域。二维码转图时嵌入 HTML,不依赖外部图片服务或运行时网络。 +- 小红书 URL、昵称、ID 与二维码路径可由 `SHARE_IMAGE_XIAOHONGSHU_*` 配置覆盖;全部留空时显示内置二维码和昵称 `@霸天土小豆`,默认不展示数字 ID。二维码转图时嵌入 HTML,不依赖外部图片服务或运行时网络。 - 模板只展示报告已有的 0–100 评分、八态动作和 `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`。 diff --git a/docs/share-images.md b/docs/share-images.md index 769bd080b..c186dd3d3 100644 --- a/docs/share-images.md +++ b/docs/share-images.md @@ -1,6 +1,6 @@ # 分享图片模板与数据填充 -分享图片用于把个股分析和市场复盘转换为适合社交平台传播的 1080px 长图。个股和大盘使用两套独立的信息结构,但共用 DSA 品牌、仓库标识 `ZhuLinsen/daily_stock_analysis` 和风险声明。GitHub 区不放二维码;小红书区域由部署配置决定,未配置时整块隐藏,避免 fork 或私有部署默认宣传维护者账号。 +分享图片用于把个股分析和市场复盘转换为适合社交平台传播的 1080px 长图。个股和大盘使用两套独立的信息结构,但共用 DSA 品牌、仓库标识 `ZhuLinsen/daily_stock_analysis` 和风险声明。GitHub 区不放二维码;Web 与桌面端分享图默认展示仓库内置小红书二维码及昵称 `@霸天土小豆`,部署配置可替换二维码和账号信息。 ## 运行时如何填充 @@ -22,7 +22,7 @@ `MARKDOWN_TO_IMAGE_CHANNELS`、`MD2IMG_ENGINE`、`MARKDOWN_TO_IMAGE_MAX_CHARS` 继续控制哪些通知渠道转图、使用哪个引擎以及最大输入长度。转换失败时仍回退为文本通知。 -小红书品牌使用以下可选配置,四项全部留空即关闭该区域: +小红书品牌使用以下可选配置。全部留空时展示仓库内置二维码及昵称 `@霸天土小豆`;配置任一自定义值后仅使用这组自定义品牌信息,避免把自定义账号与默认二维码混合: ```dotenv SHARE_IMAGE_XIAOHONGSHU_URL=https://example.com/my-xiaohongshu @@ -31,15 +31,15 @@ SHARE_IMAGE_XIAOHONGSHU_ID=123456789 SHARE_IMAGE_XIAOHONGSHU_QR_PATH=assets/my-xiaohongshu-qr.png ``` -二维码路径支持绝对路径或相对项目根目录路径;冻结桌面后端也会从 PyInstaller 资源目录解析相对路径。账号 URL 只接受 `http://` 或 `https://`。二维码在转图时以内嵌 Data URI 渲染,不依赖运行时网络。 +二维码路径支持绝对路径或相对项目根目录路径;冻结桌面后端也会从 PyInstaller 资源目录解析相对路径。账号 URL 只接受 `http://` 或 `https://`。二维码在转图时以内嵌 Data URI 渲染,不依赖运行时网络。未配置 `SHARE_IMAGE_XIAOHONGSHU_QR_PATH` 时,统一回退到随源码和桌面包分发的 `src/assets/share_image/xiaohongshu_qr.jpg`,因此 Web PNG 与桌面 Electron PNG 都会保留二维码。 ## Web 一键分享 浏览器版历史个股报告、市场复盘和完整报告抽屉右上角都会显示“分享”按钮。页面加载报告时不会生成图片;只有用户点击“分享”后,页面才调用 `GET /api/v1/history/{record_id}/share-image` 按需生成或读取缓存 PNG。支持文件分享的浏览器会在图片准备好后提示“再次点击分享”,由第二次点击同步打开系统分享面板,避免异步生成过程使浏览器的用户激活状态失效;其他浏览器会在首次生成完成后直接下载 PNG。如果系统分享面板打开失败,除用户主动取消外也会自动回退下载已经生成的 PNG。 -Electron 桌面运行时默认不展示该按钮。当前 Windows/macOS 打包版不会随包分发 `wkhtmltoimage`、`markdown-to-file` 或 Playwright/Chromium renderer,避免桌面用户在页面加载时就命中 `share_image_unavailable` 失败态。 +Electron 桌面端同样展示“分享”按钮,但不依赖额外分发 `wkhtmltoimage`、`markdown-to-file` 或 Playwright。用户点击后,桌面 preload 通过受限 IPC 让主进程打开本地 `GET /api/v1/history/{record_id}/share-image-html`,使用 Electron 自带的隐藏 Chromium 窗口按完整页面高度截图为 PNG,随后走与浏览器一致的下载回退。IPC 只接受正整数记录 ID,主进程只允许当前桌面窗口请求本次启动时确定的后端 origin(包括显式配置的局域网 `WEBUI_HOST`),HTML 响应使用 CSP 禁止脚本、外部资源和网络加载。 -Web 手工生成不受 `MARKDOWN_TO_IMAGE_CHANNELS` 限制,但服务端仍需配置可用的 `MD2IMG_ENGINE`。使用 Playwright 时先执行: +Web 手工生成不受 `MARKDOWN_TO_IMAGE_CHANNELS` 限制,但服务端仍需配置可用的 `MD2IMG_ENGINE`。桌面端手工生成复用 Electron,不读取 `MD2IMG_ENGINE`。Web 使用 Playwright 时先执行: ```bash cd apps/dsa-web @@ -151,6 +151,6 @@ png_bytes = markdown_to_image( - 涨跌颜色优先使用结构化 payload 持久化的 `color_scheme`,旧记录则从最终报告颜色标记恢复;模板不按市场地区硬编码涨跌色。 - 分享图中的买入、止损和目标只保留可扫描的价格或“等待企稳”;完整条件始终保留在原报告中。 - 没有真实价格序列时不绘制伪 K 线;顶部仅保留非数据化的品牌光晕。 -- 小红书 URL、账号、ID 和二维码路径来自运行时配置;全部留空时不渲染小红书区域。GitHub 固定展示仓库标识 `ZhuLinsen/daily_stock_analysis`,不生成二维码。 +- 小红书 URL、昵称、ID 和二维码路径可由运行时配置覆盖;全部留空时使用内置昵称 `@霸天土小豆` 和仓库内置二维码,默认不展示数字 ID。GitHub 固定展示仓库标识 `ZhuLinsen/daily_stock_analysis`,不生成二维码。 - 大盘报告在核心模块已成功提取时不重复附加完整 Markdown;额外的详情章节保留在原报告中,分享图只呈现结构化摘要。 - 图片底部固定说明“AI 生成,仅供研究交流,不构成投资建议”。 diff --git a/src/md2img.py b/src/md2img.py index 1155a51d9..077f28959 100644 --- a/src/md2img.py +++ b/src/md2img.py @@ -21,18 +21,17 @@ import tempfile from pathlib import Path from typing import Any, Mapping, Optional -from src.share_image import ShareImageBranding, build_share_image_html +from src.share_image import ( + ShareImageBranding, + build_share_image_html, + share_image_branding_from_config, +) logger = logging.getLogger(__name__) def _share_image_branding(config: object) -> ShareImageBranding: - return ShareImageBranding( - xiaohongshu_url=str(getattr(config, "share_image_xiaohongshu_url", None) or ""), - xiaohongshu_handle=str(getattr(config, "share_image_xiaohongshu_handle", None) or ""), - xiaohongshu_id=str(getattr(config, "share_image_xiaohongshu_id", None) or ""), - xiaohongshu_qr_path=str(getattr(config, "share_image_xiaohongshu_qr_path", None) or ""), - ) + return share_image_branding_from_config(config) def _resolve_playwright_command() -> Optional[str]: @@ -249,7 +248,7 @@ def markdown_to_image( branding = _share_image_branding(config) except Exception: engine = "wkhtmltoimage" - branding = ShareImageBranding() + branding = share_image_branding_from_config(object()) if engine == "markdown-to-file": return _markdown_to_image_m2f(markdown_text, structured_payload, branding) diff --git a/src/share_image.py b/src/share_image.py index 3a7fdc177..3df556b5b 100644 --- a/src/share_image.py +++ b/src/share_image.py @@ -26,6 +26,8 @@ import markdown2 PROJECT_URL = "https://github.com/ZhuLinsen/daily_stock_analysis" PROJECT_REPOSITORY = "ZhuLinsen/daily_stock_analysis" PROJECT_DISPLAY_NAME = "股票智能分析系统" +DEFAULT_XIAOHONGSHU_QR_PATH = "src/assets/share_image/xiaohongshu_qr.jpg" +DEFAULT_XIAOHONGSHU_HANDLE = "@霸天土小豆" _MARKET_RE = re.compile( r"(?:大盘复盘|市场复盘|market\s+(?:review|recap)|시황\s*리뷰)", re.IGNORECASE ) @@ -170,13 +172,33 @@ class ShareImageBranding: @property def has_xiaohongshu(self) -> bool: return any(( - self.xiaohongshu_url, - self.xiaohongshu_handle, - self.xiaohongshu_id, - self.xiaohongshu_qr_path, + self.xiaohongshu_url.strip(), + self.xiaohongshu_handle.strip(), + self.xiaohongshu_id.strip(), + self.xiaohongshu_qr_path.strip(), )) +def share_image_branding_from_config(config: object) -> ShareImageBranding: + """Build poster branding with bundled defaults applied only as an atomic pair.""" + + url = str(getattr(config, "share_image_xiaohongshu_url", None) or "").strip() + handle = str(getattr(config, "share_image_xiaohongshu_handle", None) or "").strip() + 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)): + handle = DEFAULT_XIAOHONGSHU_HANDLE + qr_path = DEFAULT_XIAOHONGSHU_QR_PATH + + return ShareImageBranding( + xiaohongshu_url=url, + xiaohongshu_handle=handle, + xiaohongshu_id=account_id, + xiaohongshu_qr_path=qr_path, + ) + + @dataclass class StockPoster: title: str @@ -2118,7 +2140,7 @@ def build_share_image_html( .index-grid {{ display:table; width:100%; margin:0 0 24px; border-spacing:10px 0; table-layout:fixed; }} .index-card{{display:table-cell;padding:16px 18px;border:1px solid #d0dced;border-radius:18px;background:linear-gradient(160deg,#fff,#f6f9ff);box-shadow:0 8px 22px rgba(25,78,153,.05)}} .index-card span,.index-card small{{display:block}} .index-card span{{font-weight:750}} .index-card strong{{display:block;margin:8px 0 0;font-size:35px}} .index-card strong.red{{color:#ed3f36}} .index-card strong.green{{color:#0a9c58}} .index-card small{{color:#3d506f;font-size:19px}} .breadth-grid .metric{{background:linear-gradient(160deg,#fff,#f7faff)}} .breadth-grid .metric strong{{font-size:29px}} .dimension-grid .metric{{height:94px;background:linear-gradient(145deg,#f7faff,#fff)}} .dimension-grid .metric strong{{font-size:33px}} .market-two-column{{display:table;width:calc(100% - 20px);margin:0 10px 24px;border-spacing:8px 0;table-layout:fixed}} .market-left,.market-right{{display:table-cell;width:50%;vertical-align:top}} .market-two-column .poster-section{{min-height:238px;margin:0;padding:20px 22px;border:1px solid #d3dfef;border-radius:19px;background:linear-gradient(160deg,#fff,#f8fbff)}} .ranking-row{{display:table;width:100%;padding:13px 0;border-bottom:1px solid #e6edf6}} .ranking-row:last-child{{border:0}} .ranking-row>*{{display:table-cell;vertical-align:middle}} .ranking-row b{{width:44px;color:#fff;border-radius:9px;text-align:center;background:linear-gradient(135deg,#1677ff,#6a5cff)}} .ranking-row:nth-child(2) b{{background:linear-gradient(135deg,#ff8a00,#ffb020)}} .ranking-row:nth-child(3) b{{background:linear-gradient(135deg,#12a66a,#37c98a)}} .ranking-row span{{padding-left:13px;font-weight:700}} .ranking-row strong{{text-align:right}} .ranking-row strong.red{{color:#ed3f36}} .ranking-row strong.green{{color:#0a9c58}} .ranking-row.lagging b{{background:linear-gradient(135deg,#64748b,#94a3b8)}} .market-details .poster-section{{min-height:214px}} .focus-row,.fund-row{{display:table;width:100%;padding:10px 0;border-bottom:1px solid #e6edf6}} .focus-row:last-child,.fund-row:last-child{{border:0}} .focus-row b,.focus-row span,.fund-row span,.fund-row strong{{display:table-cell;vertical-align:middle}} .focus-row b{{width:66px;color:#fff;border-radius:8px;text-align:center;background:#1677ff}} .focus-row.avoid b{{background:#ef4444}} .focus-row span{{padding-left:14px;font-weight:700}} .fund-row span{{color:#52647f}} .fund-row strong{{text-align:right;color:#1768e8}} .fund-row.positive strong{{color:#0a9c58}} .fund-row.warning strong{{color:#f59e0b}} .strategy-strip{{padding:16px 22px;border:1px solid #cbdcf4;border-radius:17px;background:linear-gradient(90deg,#f6faff,#fff)}} .strategy-strip ul{{display:table;width:100%;padding-left:25px}} .strategy-strip li{{display:table-cell;width:33.33%;padding-right:20px;font-size:19px;vertical-align:top}} .risk-strip{{padding:16px 22px;border:1px solid #ffc5c5;border-radius:17px;background:linear-gradient(90deg,#fff3f3,#fffafa)}} .risk-strip h2{{color:#e7373f}} .risk-strip ul{{display:table;width:100%;padding-left:25px}} .risk-strip li{{display:table-cell;width:50%;padding-right:24px;font-size:19px}} - .report-fallback {{ margin:0 10px 26px; padding:24px 28px; border:1px solid #d5e1f0; border-radius:18px; background:#fff; }} .report-content h1,.report-content h2,.report-content h3{{color:#153d78}} .report-content h2{{font-size:29px}} .report-content h3{{font-size:25px}} .report-content table{{width:100%;border-collapse:collapse;font-size:19px}} .report-content th,.report-content td{{padding:10px;border:1px solid #dbe4f1}} .report-content th{{background:#eef4fc}} .report-content blockquote{{margin:15px 0;padding:12px 18px;border-left:5px solid #4385ef;background:#f3f7fd}} + .report-fallback {{ margin:0 10px 26px; padding:24px 28px; border:1px solid #d5e1f0; border-radius:18px; background:#fff; }} .report-content{{overflow-wrap:anywhere}} .report-content h1,.report-content h2,.report-content h3{{color:#153d78;overflow-wrap:anywhere;word-break:break-word}} .report-content h2{{font-size:29px}} .report-content h3{{font-size:25px}} .report-content p,.report-content li,.report-content th,.report-content td,.report-content blockquote,.report-content a{{overflow-wrap:anywhere;word-break:break-word}} .report-content table{{width:100%;border-collapse:collapse;font-size:19px;table-layout:fixed}} .report-content th,.report-content td{{padding:10px;border:1px solid #dbe4f1}} .report-content th{{background:#eef4fc}} .report-content pre{{max-width:100%;margin:16px 0;padding:16px 18px;overflow-x:auto;border-radius:14px;background:#f4f7fc;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}} .report-content code{{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}} .report-content blockquote{{margin:15px 0;padding:12px 18px;border-left:5px solid #4385ef;background:#f3f7fd}} .poster-footer {{ display:table; width:100%; margin-top:18px; padding:14px 34px 5px; border-top:1px solid #ccdaec; table-layout:fixed; }} .footer-brand,.qr-card{{display:table-cell;vertical-align:middle}} .footer-brand{{width:74%;padding-left:6px}} .footer-brand.full{{width:100%}} .footer-title{{display:flex;align-items:baseline;gap:15px}} .footer-title strong{{color:#1768e8;font-size:43px;font-style:italic;line-height:1}} .footer-title span{{font-size:24px;font-weight:800}} .footer-brand>small{{display:block;margin-top:4px;color:#536683;font-size:16px}} .repo-line{{display:flex;align-items:center;gap:9px;margin-top:11px;color:#111827}} .repo-line svg{{width:25px;height:25px;flex:none;fill:currentColor}} .repo-line div{{min-width:0}} .repo-line em,.repo-line b{{display:block;font-style:normal}} .repo-line em{{margin-bottom:1px;color:#64748b;font-size:12px;letter-spacing:.6px}} .repo-line b{{font-size:16px;line-height:1.15;white-space:nowrap}} .qr-card{{width:26%;text-align:center;font-size:16px;font-weight:750;line-height:1.2}} .qr-card.text-only{{padding-left:18px}} .qr-card .social-link{{color:inherit;text-decoration:none}} .qr-card span b{{color:#ff2442}} .qr-frame{{width:132px;height:132px;margin:0 auto 5px;padding:4px;border:1px solid #d3deed;border-radius:13px;background:#fff}} .qr-frame img{{display:block;width:122px;height:122px;object-fit:contain}} .disclaimer{{margin:6px -34px -24px;padding:8px 34px;color:#285b9d;font-size:14px;text-align:center;background:#eaf3ff}} @@ -2134,9 +2156,12 @@ def build_share_image_html( __all__ = [ + "DEFAULT_XIAOHONGSHU_HANDLE", + "DEFAULT_XIAOHONGSHU_QR_PATH", "PROJECT_REPOSITORY", "PROJECT_DISPLAY_NAME", "PROJECT_URL", "ShareImageBranding", "build_share_image_html", + "share_image_branding_from_config", ] diff --git a/tests/test_history_share_image.py b/tests/test_history_share_image.py index 1282cafb3..5727b1e7f 100644 --- a/tests/test_history_share_image.py +++ b/tests/test_history_share_image.py @@ -6,6 +6,7 @@ import pytest from fastapi import HTTPException from api.v1.endpoints import history as history_endpoint +from src.share_image import DEFAULT_XIAOHONGSHU_HANDLE, DEFAULT_XIAOHONGSHU_QR_PATH class _FakeHistoryService: @@ -88,6 +89,64 @@ def test_history_share_image_prefers_market_review_payload(monkeypatch): assert captured["structured_payload"] is market_payload +def test_history_share_image_html_returns_desktop_poster_with_restrictive_csp(monkeypatch): + raw_result = {"code": "000657", "name": "中钨高新", "dashboard": {}} + _patch_service( + monkeypatch, + { + "id": 20, + "report_type": "detailed", + "raw_result": raw_result, + "context_snapshot": {}, + }, + ) + captured = {} + + def fake_build_share_image_html(markdown, **kwargs): + captured["markdown"] = markdown + captured.update(kwargs) + return "poster" + + monkeypatch.setattr( + history_endpoint, + "build_share_image_html", + fake_build_share_image_html, + ) + + response = history_endpoint.get_history_share_image_html("20", db_manager=object()) + + assert response.status_code == 200 + assert response.media_type == "text/html" + assert b"poster" in response.body + assert captured["structured_payload"] is raw_result + assert captured["branding"].xiaohongshu_handle == DEFAULT_XIAOHONGSHU_HANDLE + assert captured["branding"].xiaohongshu_id == "" + assert captured["branding"].xiaohongshu_qr_path == DEFAULT_XIAOHONGSHU_QR_PATH + assert response.headers["cache-control"] == "no-store" + assert response.headers["content-security-policy"] == ( + "default-src 'none'; img-src data:; style-src 'unsafe-inline'" + ) + + +def test_history_share_image_html_rejects_reports_over_configured_limit(monkeypatch): + _patch_service( + monkeypatch, + { + "id": 21, + "report_type": "detailed", + "raw_result": {"code": "000657"}, + "context_snapshot": {}, + }, + markdown="x" * 15001, + ) + + with pytest.raises(HTTPException) as exc_info: + history_endpoint.get_history_share_image_html("21", db_manager=object()) + + assert exc_info.value.status_code == 413 + assert exc_info.value.detail["error"] == "share_image_too_large" + + def test_history_share_image_reports_renderer_unavailable(monkeypatch): _patch_service( monkeypatch, diff --git a/tests/test_md2img_template.py b/tests/test_md2img_template.py index 78a8e3ab1..a60846dd4 100644 --- a/tests/test_md2img_template.py +++ b/tests/test_md2img_template.py @@ -11,7 +11,11 @@ from src.md2img import ( _markdown_to_image_wkhtml, markdown_to_image, ) -from src.share_image import ShareImageBranding +from src.share_image import ( + DEFAULT_XIAOHONGSHU_HANDLE, + DEFAULT_XIAOHONGSHU_QR_PATH, + ShareImageBranding, +) TEST_BRANDING = ShareImageBranding( @@ -127,6 +131,20 @@ def test_markdown_to_image_forwards_social_branding_from_config(): ) +def test_markdown_to_image_uses_bundled_qr_when_branding_is_unconfigured(): + config = SimpleNamespace(md2img_engine="wkhtmltoimage") + with ( + patch("src.config.get_config", return_value=config), + patch("src.md2img._markdown_to_image_wkhtml", return_value=b"png") as render, + ): + assert markdown_to_image("# 大盘复盘") == b"png" + + branding = render.call_args.args[2] + assert branding.xiaohongshu_handle == DEFAULT_XIAOHONGSHU_HANDLE + assert branding.xiaohongshu_id == "" + assert branding.xiaohongshu_qr_path == DEFAULT_XIAOHONGSHU_QR_PATH + + def test_wkhtml_renderer_forwards_structured_analysis_payload(): payload = { "name": "中钨高新", diff --git a/tests/test_share_image.py b/tests/test_share_image.py index 545ea24e8..2a7716563 100644 --- a/tests/test_share_image.py +++ b/tests/test_share_image.py @@ -2,14 +2,18 @@ from datetime import date from pathlib import Path +from types import SimpleNamespace import pytest from src.share_image import ( + DEFAULT_XIAOHONGSHU_HANDLE, + DEFAULT_XIAOHONGSHU_QR_PATH, PROJECT_DISPLAY_NAME, PROJECT_REPOSITORY, ShareImageBranding, build_share_image_html, + share_image_branding_from_config, ) XIAOHONGSHU_HANDLE = "@示例账号" @@ -59,6 +63,109 @@ def test_stock_share_image_omits_unconfigured_social_account(): assert 'class="footer-brand full"' in html +def test_runtime_branding_defaults_to_bundled_xiaohongshu_qr(): + branding = share_image_branding_from_config(object()) + + assert branding.xiaohongshu_handle == DEFAULT_XIAOHONGSHU_HANDLE + assert branding.xiaohongshu_id == "" + assert branding.xiaohongshu_qr_path == DEFAULT_XIAOHONGSHU_QR_PATH + html = build_share_image_html( + "# 贵州茅台 600519 分析报告\n\n## 核心判断\n\n- 趋势偏多\n", + generated_on=date(2026, 7, 31), + branding=branding, + ) + + assert html.count('class="qr-frame"') == 1 + assert html.count("data:image/jpeg;base64,") == 1 + assert f"小红书 {DEFAULT_XIAOHONGSHU_HANDLE}" in html + assert " · ID " not in html + + +def test_runtime_branding_does_not_mix_default_qr_with_custom_identity(): + branding = share_image_branding_from_config( + SimpleNamespace( + share_image_xiaohongshu_url="https://example.com/custom", + share_image_xiaohongshu_handle="@自定义账号", + share_image_xiaohongshu_id="custom-id", + ) + ) + + assert branding.xiaohongshu_id == "custom-id" + assert branding.xiaohongshu_qr_path == "" + + html = build_share_image_html( + "# 贵州茅台 600519 分析报告\n\n## 核心判断\n\n- 趋势偏多\n", + generated_on=date(2026, 7, 31), + branding=branding, + ) + + assert "@自定义账号" in html + assert "ID custom-id" in html + assert "data:image/jpeg;base64," not in html + assert DEFAULT_XIAOHONGSHU_HANDLE not in html + + +def test_runtime_branding_does_not_mix_default_pair_with_custom_handle_or_url(): + branding = share_image_branding_from_config( + SimpleNamespace( + share_image_xiaohongshu_url="https://example.com/custom", + share_image_xiaohongshu_handle="@自定义账号", + ) + ) + + assert branding.xiaohongshu_handle == "@自定义账号" + assert branding.xiaohongshu_id == "" + assert branding.xiaohongshu_qr_path == "" + + html = build_share_image_html( + "# 贵州茅台 600519 分析报告\n\n## 核心判断\n\n- 趋势偏多\n", + generated_on=date(2026, 7, 31), + branding=branding, + ) + + assert "@自定义账号" in html + assert 'href="https://example.com/custom"' in html + assert "data:image/jpeg;base64," not in html + assert DEFAULT_XIAOHONGSHU_HANDLE not in html + + +def test_runtime_branding_does_not_mix_default_handle_with_custom_qr(): + branding = share_image_branding_from_config( + SimpleNamespace( + share_image_xiaohongshu_qr_path=str( + Path(__file__).parents[1] / "src" / "assets" / "share_image" / "xiaohongshu_qr.jpg" + ), + ) + ) + + assert branding.xiaohongshu_id == "" + assert branding.xiaohongshu_qr_path + + html = build_share_image_html( + "# 贵州茅台 600519 分析报告\n\n## 核心判断\n\n- 趋势偏多\n", + generated_on=date(2026, 7, 31), + branding=branding, + ) + + assert html.count("data:image/jpeg;base64,") == 1 + assert DEFAULT_XIAOHONGSHU_HANDLE not in html + + +def test_runtime_branding_treats_whitespace_only_values_as_unconfigured(): + branding = share_image_branding_from_config( + SimpleNamespace( + share_image_xiaohongshu_url=" ", + share_image_xiaohongshu_handle=" ", + share_image_xiaohongshu_id=" ", + share_image_xiaohongshu_qr_path=" ", + ) + ) + + assert branding.xiaohongshu_handle == DEFAULT_XIAOHONGSHU_HANDLE + assert branding.xiaohongshu_id == "" + assert branding.xiaohongshu_qr_path == DEFAULT_XIAOHONGSHU_QR_PATH + + def test_stock_share_image_does_not_link_unsafe_social_url(): html = build_share_image_html( "# 贵州茅台 600519 分析报告\n\n## 核心判断\n\n- 趋势偏多\n", @@ -715,6 +822,34 @@ def test_market_share_image_preserves_report_color_scheme_from_index_markers(): assert 'class="metric red">上涨' in html +def test_generic_share_image_wraps_long_preformatted_lines_and_urls_in_fallback(): + html = build_share_image_html( + """# 完整报告 + +## 原始链接 + +https://example.com/this-is-a-very-long-url-with-no-natural-breakpoints/abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ + +## 调试片段 + +```text +TRACEBACK_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz_TRACEBACK_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz +``` +""", + generated_on=date(2026, 8, 1), + ) + + assert 'class="poster dashboard"' in html + assert '
' in html + assert "
" in html
+    assert "" in html
+    assert ".report-content{overflow-wrap:anywhere}" in html
+    assert ".report-content h1,.report-content h2,.report-content h3{color:#153d78;overflow-wrap:anywhere;word-break:break-word}" in html
+    assert ".report-content p,.report-content li,.report-content th,.report-content td,.report-content blockquote,.report-content a{overflow-wrap:anywhere;word-break:break-word}" in html
+    assert ".report-content pre{max-width:100%;" in html
+    assert "white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word" in html
+
+
 def test_real_single_stock_shape_uses_h2_title_score_and_sniper_contract():
     html = build_share_image_html(
         """## 🟢 贵州茅台 (600519)