fix: 恢复桌面端报告分享图 (#2169)

* 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
This commit is contained in:
zhulinsen
2026-08-09 13:53:00 +08:00
committed by GitHub
parent ed848da6f0
commit 46d5bf3472
19 changed files with 704 additions and 67 deletions

View File

@@ -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)

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -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<ArrayBuffer>;
};
};
type ShareState = 'idle' | 'loading' | 'ready' | 'success' | 'error';
@@ -39,8 +41,9 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
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<ShareImageButtonProps> = ({
loadTokenRef.current = loadToken;
setState('loading');
try {
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<ShareImageButtonProps> = ({
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;

View File

@@ -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(
<ShareImageButton
recordId={24}
reportTitle="桌面端报告"
reportLanguage="zh"
/>,
);
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);

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
- [修复] Electron 桌面端恢复历史个股报告、市场复盘和完整报告的“分享”入口,复用安装包自带 Chromium 从本机受限 HTML 生成 PNGWeb 与桌面端在未配置自定义品牌时统一使用随包分发的小红书二维码,并在二维码下展示昵称 `@霸天土小豆`
- [新功能] 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 继续按构建输入过滤。离线测试保留稳定的串行执行与慢用例摘要,并移除重复用例和测试内真实等待。

View File

@@ -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 浏览器。
## 本地开发

View File

@@ -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 }` |

View File

@@ -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 |

View File

@@ -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不依赖外部图片服务或运行时网络。
- 模板只展示报告已有的 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

@@ -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 生成,仅供研究交流,不构成投资建议”。

View File

@@ -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)

View File

@@ -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}}
</style>
</head>
@@ -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",
]

View File

@@ -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 "<!DOCTYPE html><html><body>poster</body></html>"
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,

View File

@@ -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": "中钨高新",

View File

@@ -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"<b>小红书</b> {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"><span>上涨</span>' 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 '<section class="report-fallback">' in html
assert "<pre>" in html
assert "<code>" 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)