Files
cnfinancialscraper/scripts/report_stdlib_fallbacks.py
T

900 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
标准库文档生成器(零 pip 依赖)
使用 zipfile + xml.etree.ElementTree 生成 .docx / .pptx / .xlsx 文件。
当 python-docx / python-pptx / openpyxl 未安装时作为 fallback。
依赖:Python 标准库 only(zipfile, xml.etree.ElementTree, io, os, datetime)
OOXML 格式说明:
.docx / .pptx / .xlsx 均为 ZIP 打包的 XML 文件(Office Open XML / ECMA-376)
无需 lxml,标准库 xml.etree.ElementTree 可完全满足。
"""
from __future__ import annotations
import zipfile
import io
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, List, Optional
try:
from report_exporter import EXPORT_DIR
except Exception:
EXPORT_DIR = Path(__file__).parent.parent / "data" / "exports"
# ─── 辅助工具 ────────────────────────────────────────────────────────────────
def _esc(s: Any) -> str:
"""XML 文本转义"""
if s is None:
return ""
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def _attr(obj: Any, key: str, default: Any = "") -> Any:
"""兼容 object.attr 和 dict['attr'] 两种访问方式"""
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def _row_esc(s: Any) -> str:
"""单元格文本(允许换行)"""
return _esc(str(s) if s is not None else "")
# ─── DOCX 生成器 ────────────────────────────────────────────────────────────
class StdlibDocxWriter:
"""用标准库生成最小化 .docx(Word 2007+ 兼容)"""
def export_comprehensive_report(self, data: Dict[str, Any],
stock_code: str,
output_path: str = "") -> str:
"""
生成 Word 文档(.docx)
结构:
[Content_Types].xml
_rels/.rels
word/_rels/document.xml.rels
word/document.xml ← 标题 + 概览表 + 列表
word/styles.xml
"""
if not output_path:
os.makedirs(EXPORT_DIR, exist_ok=True)
output_path = str(EXPORT_DIR / f"{stock_code}_report.docx")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
summary = data.get("summary", {})
stock_name = summary.get("stock_name", stock_code)
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# ── 构建 document.xml ────────────────────────────────────────────────
body_parts: List[str] = []
# 标题
body_parts.append(
f'<w:p><w:pPr><w:pStyle w:val="Heading"/><w:jc w:val="center"/></w:pPr>'
f'<w:r><w:rPr><w:b/><w:sz w:val="36"/></w:rPr>'
f'<w:t>{_esc(stock_name)} ({stock_code}) 综合分析报告</w:t></w:r></w:p>'
)
# 概览标题
body_parts.append(
f'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>'
f'<w:r><w:rPr><w:b/></w:rPr><w:t>报告概览</w:t></w:r></w:p>'
)
# 概览表格(2列,7行)
info_rows = [
("股票代码", stock_code),
("股票名称", stock_name),
("定期报告", f"{summary.get('periodic_count', 0)} 份"),
("券商研报", f"{summary.get('broker_count', 0)} 份"),
("公告", f"{summary.get('announcement_count', 0)} 份"),
("买入评级研报", "是" if summary.get('has_buy_rating') else "否"),
("生成时间", now),
]
tbl_xml = (
'<w:tbl>'
'<w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="5000" w:type="dxa"/>'
'<w:tblBorders>'
'<w:top w:val="single" w:sz="4" w:color="auto"/><w:left w:val="single" w:sz="4" w:color="auto"/>'
'<w:bottom w:val="single" w:sz="4" w:color="auto"/><w:right w:val="single" w:sz="4" w:color="auto"/>'
'<w:insideH w:val="single" w:sz="4" w:color="auto"/><w:insideV w:val="single" w:sz="4" w:color="auto"/>'
'</w:tblBorders>'
'</w:tblPr>'
)
for k, v in info_rows:
tbl_xml += (
'<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr>'
f'<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>{_esc(k)}</w:t></w:r></w:p></w:tc>'
'<w:tc><w:tcPr><w:tcW w:w="3000" w:type="dxa"/></w:tcPr>'
f'<w:p><w:r><w:t>{_esc(v)}</w:t></w:r></w:p></w:tc></w:tr>'
)
tbl_xml += '</w:tbl>'
body_parts.append(tbl_xml)
# 定期报告
periodic = data.get("periodic_reports", [])
if periodic:
body_parts.append(
f'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>'
f'<w:r><w:rPr><w:b/></w:rPr><w:t>定期报告 ({len(periodic)} 份)</w:t></w:r></w:p>'
)
for p in periodic:
title = _row_esc(_attr(p, 'title', ''))
date = str(_attr(p, 'publish_date', ''))[:10]
url = _row_esc(_attr(p, 'url', ''))
body_parts.append(
# v1.1 修复:补 </w:pPr> 闭合标签(之前漏了,导致下载保存时 Word 严格解析失败)
f'<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr>'
f'<w:r><w:t xml:space="preserve">{date} - {title}</w:t></w:r></w:p>'
)
if url:
body_parts.append(
f'<w:p><w:pPr><w:ind w:left="360"/></w:pPr>'
f'<w:r><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr>'
f'<w:t xml:space="preserve">链接: {url}</w:t></w:r></w:p>'
)
# 券商研报
broker = data.get("broker_reports", [])
if broker:
rating_stats = {"买入": 0, "增持": 0, "中性": 0, "减持": 0}
for b in broker:
r = _attr(b, 'rating', '')
if r in rating_stats:
rating_stats[r] += 1
body_parts.append(
f'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>'
f'<w:r><w:rPr><w:b/></w:rPr><w:t>券商研报 ({len(broker)} 份)</w:t></w:r></w:p>'
)
body_parts.append(
f'<w:p><w:r><w:t>评级分布: '
f"买入{rating_stats['买入']} 增持{rating_stats['增持']} "
f"中性{rating_stats['中性']} 减持{rating_stats['减持']}</w:t></w:r></w:p>"
)
for b in broker:
title = _row_esc(_attr(b, 'title', ''))
date = str(_attr(b, 'publish_date', ''))[:10]
broker_name = _row_esc(_attr(b, 'broker_name', ''))
analyst = _row_esc(_attr(b, 'analyst', ''))
rating = _row_esc(_attr(b, 'rating', ''))
tp = _attr(b, 'target_price', 0)
url = _row_esc(_attr(b, 'url', ''))
body_parts.append(
f'<w:p><w:r><w:rPr><w:b/></w:rPr>'
f'<w:t xml:space="preserve">{date} [{rating}] {broker_name}</w:t></w:r>'
f'<w:r><w:t xml:space="preserve">:{title}</w:t></w:r></w:p>'
)
if analyst:
body_parts.append(
f'<w:p><w:pPr><w:ind w:left="360"/></w:pPr>'
f'<w:r><w:t xml:space="preserve">分析师: {analyst}</w:t></w:r></w:p>'
)
if tp and float(tp) > 0:
body_parts.append(
f'<w:p><w:pPr><w:ind w:left="360"/></w:pPr>'
f'<w:r><w:t xml:space="preserve">目标价: ¥{float(tp):.2f}</w:t></w:r></w:p>'
)
# v4.4.0: 添加原文链接
if url:
body_parts.append(
f'<w:p><w:pPr><w:ind w:left="360"/></w:pPr>'
f'<w:r><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr>'
f'<w:t xml:space="preserve">链接: {url}</w:t></w:r></w:p>'
)
# 公告
announcements = data.get("announcements", [])
if announcements:
body_parts.append(
f'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>'
f'<w:r><w:rPr><w:b/></w:rPr><w:t>公告 ({len(announcements)} 条)</w:t></w:r></w:p>'
)
for a in announcements[:30]:
title = _row_esc(_attr(a, 'title', ''))
date = str(_attr(a, 'publish_date', ''))[:10]
ann_type = _row_esc(_attr(a, 'announcement_type', ''))
url = _row_esc(_attr(a, 'url', ''))
body_parts.append(
f'<w:p><w:r><w:rPr><w:b/></w:rPr>'
f'<w:t xml:space="preserve">{date} [{ann_type}]</w:t></w:r>'
f'<w:r><w:t xml:space="preserve"> {title}</w:t></w:r></w:p>'
)
if url:
body_parts.append(
f'<w:p><w:pPr><w:ind w:left="360"/></w:pPr>'
f'<w:r><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr>'
f'<w:t xml:space="preserve">链接: {url}</w:t></w:r></w:p>'
)
body_xml = '\n'.join(body_parts)
# ── 构建各文件 ────────────────────────────────────────────────────────
ct_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'<Override PartName="/word/styles.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
'</Types>'
)
rels_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="word/document.xml"/>'
'</Relationships>'
)
doc_rels_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
'Target="styles.xml"/>'
'</Relationships>'
)
styles_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:docDefaults>'
# v1.1 修复:完整字体定义(4 个属性齐全 + hint + lang)
# - ascii/hAnsi: 拉丁字符(用 universal 的 Times New Roman)
# - eastAsia: 东亚字符(用 universal 的 宋体 SimSun,几乎所有 Windows/Mac 都有)
# - cs: 复杂脚本(阿拉伯/泰文等)
# - hint: 告诉 Word 优先按 eastAsia 处理 CJK 字符
# - lang: 显式语言,让 Word 知道这段是中文
'<w:rPrDefault><w:rPr>'
'<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" '
'w:eastAsia="宋体" w:cs="Times New Roman" w:hint="eastAsia"/>'
'<w:sz w:val="24"/><w:szCs w:val="24"/>'
'<w:lang w:val="en-US" w:eastAsia="zh-CN" w:bidi="ar-SA"/>'
'</w:rPr></w:rPrDefault>'
'<w:pPrDefault><w:pPr><w:spacing w:after="160" w:line="312" w:lineRule="auto"/></w:pPr></w:pPrDefault>'
'</w:docDefaults>'
'<w:style w:type="paragraph" w:styleId="Normal" w:default="1">'
'<w:name w:val="Normal"/><w:qFormat/>'
'<w:pPr><w:spacing w:after="160"/></w:pPr>'
'</w:style>'
'<w:style w:type="paragraph" w:styleId="Heading">'
'<w:name w:val="heading"/><w:basedOn w:val="Normal"/><w:qFormat/>'
'<w:pPr><w:spacing w:before="240" w:after="120"/><w:jc w:val="center"/></w:pPr>'
'<w:rPr><w:b/><w:sz w:val="32"/></w:rPr>'
'</w:style>'
'<w:style w:type="paragraph" w:styleId="Heading1">'
'<w:name w:val="Heading1"/><w:basedOn w:val="Normal"/><w:qFormat/>'
'<w:pPr><w:spacing w:before="300" w:after="120"/></w:pPr>'
'<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>'
'</w:style>'
'<w:style w:type="table" w:styleId="TableGrid">'
'<w:name w:val="Table Grid"/><w:tblPr>'
'<w:tblBorders>'
'<w:top w:val="single" w:sz="4" w:color="auto"/><w:left w:val="single" w:sz="4" w:color="auto"/>'
'<w:bottom w:val="single" w:sz="4" w:color="auto"/><w:right w:val="single" w:sz="4" w:color="auto"/>'
'<w:insideH w:val="single" w:sz="4" w:color="auto"/><w:insideV w:val="single" w:sz="4" w:color="auto"/>'
'</w:tblBorders>'
'</w:tblPr>'
'</w:style>'
'<w:style w:type="numbering" w:styleId="ListParagraph">'
'<w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:qFormat/>'
'<w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr>'
'</w:style>'
'</w:styles>'
)
numbering_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:abstractNum w:abstractNumId="0">'
'<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/>'
'<w:lvlText w:val="•"/><w:lvlJc w:val="left"/>'
'<w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr>'
'</w:lvl>'
'</w:abstractNum>'
'<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>'
'</w:numbering>'
)
doc_body = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
'<w:body>'
f'{body_xml}'
'<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" '
'w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>'
'</w:sectPr>'
'</w:body></w:document>'
)
numbering_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" '
'Target="numbering.xml"/>'
'</Relationships>'
)
# ── 写入 ZIP ─────────────────────────────────────────────────────────
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('[Content_Types].xml', ct_xml)
zf.writestr('_rels/.rels', rels_xml)
zf.writestr('word/_rels/document.xml.rels', doc_rels_xml)
zf.writestr('word/document.xml', doc_body)
zf.writestr('word/styles.xml', styles_xml)
zf.writestr('word/numbering.xml', numbering_xml)
zf.writestr('word/_rels/numbering.xml.rels', numbering_rels)
print(f"[标准库 Docx] 已生成: {output_path}")
return output_path
# ─── PPTX 生成器 ────────────────────────────────────────────────────────────
class StdlibPptxWriter:
"""用标准库生成最小化 .pptx(PowerPoint 2007+ 兼容)"""
def export_comprehensive_report(self, data: Dict[str, Any],
stock_code: str,
output_path: str = "") -> str:
"""
生成 PowerPoint 文档(.pptx)
结构:
[Content_Types].xml
_rels/.rels
ppt/_rels/presentation.xml.rels
ppt/presentation.xml
ppt/slideLayouts/slideLayout1.xml (title)
ppt/slideLayouts/slideLayout2.xml (title+body)
ppt/slides/slide1.xml (标题页)
ppt/slides/slide2.xml (概览)
ppt/slides/slide3.xml (定期报告)
ppt/slides/slide4.xml (券商研报)
ppt/slides/slide5.xml (公告)
ppt/theme/theme1.xml
"""
if not output_path:
os.makedirs(EXPORT_DIR, exist_ok=True)
output_path = str(EXPORT_DIR / f"{stock_code}_report.pptx")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
summary = data.get("summary", {})
stock_name = summary.get("stock_name", stock_code)
now = datetime.now().strftime('%Y-%m-%d %H:%M')
slides: List[str] = []
# ── Slide 1: 标题页 ────────────────────────────────────────────────
slides.append(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
'<p:cSld><p:spTree>'
'<p:sp><p:nvSpPr><p:cNvPr id="1" name="Title"/><p:cNvSpPr txBox="1"/>'
'<p:nvPr/></p:nvSpPr>'
'<p:spPr><a:xfrm><a:off x="457200" y="2746380"/><a:ext cx="8229600" cy="1143000"/>'
'</a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
'<a:noFill/></p:spPr>'
'<p:txBody><a:bodyPr anchor="ctr"/><a:lstStyle/><a:p>'
'<a:pPr algn="ctr"/><a:r><a:rPr lang="zh-CN" sz="4400" b="1">'
'<a:solidFill><a:schemeClr val="tx1"/></a:solidFill>'
'<a:latin typeface="宋体"/><a:ea typeface="宋体"/>'
'</a:rPr><a:t>{}</a:t></a:r></a:p>'
'<a:p><a:pPr algn="ctr"/><a:r><a:rPr lang="zh-CN" sz="2000">'
'<a:solidFill><a:schemeClr val="tx1"/></a:solidFill>'
'<a:latin typeface="宋体"/><a:ea typeface="宋体"/>'
'</a:rPr><a:t>生成时间: {}</a:t></a:r></a:p>'
'</p:txBody></p:sp></p:spTree></p:cSld>'
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>'.format(
_esc(f"{stock_name} ({stock_code}) 综合报告"), _esc(now)
)
)
# ── Slide 2: 概览 ─────────────────────────────────────────────────
overview_lines = [
f"定期报告: {summary.get('periodic_count', 0)} 份",
f"券商研报: {summary.get('broker_count', 0)} 份",
f"公告: {summary.get('announcement_count', 0)} 份",
f"买入评级研报: {'是' if summary.get('has_buy_rating') else '否'}",
f"最新定期报告: {summary.get('latest_periodic_date', 'N/A')}",
f"最新券商研报: {summary.get('latest_broker_date', 'N/A')}",
f"最新公告: {summary.get('latest_announcement_date', 'N/A')}",
]
body_content = "\n".join(
f'<a:t xml:space="preserve">{_esc(line)}</a:t>'
for line in overview_lines
)
slides.append(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
'<p:cSld><p:spTree>'
'<p:sp><p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>'
'<p:spPr><a:xfrm><a:off x="457200" y="274638"/><a:ext cx="8229600" cy="857250"/>'
'</a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>'
'<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr algn="ctr"/>'
'<a:r><a:rPr lang="zh-CN" sz="3200" b="1"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill>'
'<a:latin typeface="宋体"/><a:ea typeface="宋体"/>'
'</a:rPr><a:t>报告概览</a:t></a:r></a:p></p:txBody></p:sp>'
'<p:sp><p:nvSpPr><p:cNvPr id="3" name="Content"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>'
'<p:spPr><a:xfrm><a:off x="457200" y="1371600"/><a:ext cx="8229600" cy="4114800"/>'
'</a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>'
'<p:txBody><a:bodyPr/><a:lstStyle/><a:p>'
+ body_content +
'</a:p></p:txBody></p:sp></p:spTree></p:cSld>'
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>'
)
# ── Slide 3: 定期报告 ───────────────────────────────────────────────
periodic = data.get("periodic_reports", [])
if periodic:
p_lines = [f"共 {len(periodic)} 份定期报告", ""]
for p in periodic[:10]:
p_url = str(_attr(p, 'url', ''))
p_entry = f"• {str(_attr(p,'publish_date',''))[:10]} - {_esc(_attr(p,'title',''))[:50]}"
if p_url:
p_entry += f" [{p_url}]" # v4.4.0: PPT 添加原文链接
p_lines.append(p_entry)
content = "\n".join(f'<a:t xml:space="preserve">{_esc(l)}</a:t>' for l in p_lines)
slides.append(_make_content_slide("定期报告", content, 3))
# ── Slide 4: 券商研报 ───────────────────────────────────────────────
broker = data.get("broker_reports", [])
if broker:
r_stats = {"买入": 0, "增持": 0, "中性": 0, "减持": 0}
for b in broker:
r = _attr(b, 'rating', '')
if r in r_stats:
r_stats[r] += 1
b_lines = [
f"共 {len(broker)} 份研报",
f"评级分布: 买入{r_stats['买入']} 增持{r_stats['增持']} 中性{r_stats['中性']} 减持{r_stats['减持']}",
""
]
for b in broker[:8]:
b_url = str(_attr(b, 'url', ''))
b_entry = f"• [{_attr(b,'rating','')}] {_esc(_attr(b,'broker_name',''))}: {_esc(_attr(b,'title',''))[:40]}"
if b_url:
b_entry += f" [{b_url}]" # v4.4.0: PPT 添加原文链接
b_lines.append(b_entry)
content = "\n".join(f'<a:t xml:space="preserve">{_esc(l)}</a:t>' for l in b_lines)
slides.append(_make_content_slide("券商研报", content, 4))
# ── Slide 5: 公告 ───────────────────────────────────────────────────
announcements = data.get("announcements", [])
if announcements:
a_lines = [f"共 {len(announcements)} 条公告", ""]
for a in announcements[:10]:
a_url = str(_attr(a, 'url', ''))
a_entry = f"• {str(_attr(a,'publish_date',''))[:10]} - {_esc(_attr(a,'title',''))[:50]}"
if a_url:
a_entry += f" [{a_url}]" # v4.4.0: PPT 添加原文链接
a_lines.append(a_entry)
content = "\n".join(f'<a:t xml:space="preserve">{_esc(l)}</a:t>' for l in a_lines)
slides.append(_make_content_slide("公告", content, 5))
# ── 构建 PPTX ───────────────────────────────────────────────────────
slide_xmls = "\n".join(slides)
slide_ids = "".join(
f'<p:sldId id="256{i + 1}" r:id="rId{i + 1}"/>'
for i in range(len(slides))
)
slide_rels = "".join(
f'<p:cSld id="256{i + 1}" r:id="rId{i + 1}"/>'
for i in range(len(slides))
)
ct_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/ppt/presentation.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
'<Override PartName="/ppt/slideMasters/slideMaster1.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>'
'<Override PartName="/ppt/slideLayouts/slideLayout1.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>'
'<Override PartName="/ppt/slideLayouts/slideLayout2.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>'
'<Override PartName="/ppt/theme/theme1.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>'
)
for i in range(len(slides)):
ct_xml += (
f'<Override PartName="/ppt/slides/slide{i+1}.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
)
ct_xml += '</Types>'
pres_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId0" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" '
'Target="slideMasters/slideMaster1.xml"/>'
+ "".join(
f'<Relationship Id="rId{i+1}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
f'Target="slides/slide{i+1}.xml"/>'
for i in range(len(slides))
) +
'<Relationship Id="rId100" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" '
'Target="theme/theme1.xml"/>'
'</Relationships>'
)
pres_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'saveSubsetFonts="1">'
'<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId0"/></p:sldMasterIdLst>'
'<p:sldIdLst>' + slide_ids + '</p:sldIdLst>'
'<p:sldSz cx="9144000" cy="6858000" type="screen4x3"/>'
'<p:notesSz cx="6858000" cy="9144000"/>'
'</p:presentation>'
)
master_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
'<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/>'
'<p:nvPr/></p:nvGrpSpPr><p:grpSpPr>'
'<a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
'</a:xfrm></p:grpSpPr></p:spTree></p:cSld>'
'<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" '
'accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>'
'</p:sldMaster>'
)
layout_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'type="titleOnly" preserve="1">'
'<p:cSld name="Title Only"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/>'
'<p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr>'
'<a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></a:xfrm>'
'</p:grpSpPr></p:spTree></p:cSld>'
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>'
)
theme_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'name="Office Theme"><a:themeElements>'
'<a:clrScheme name="Office">'
'<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>'
'<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>'
'<a:dk2><a:srgbClr val="1F497D"/></a:dk2>'
'<a:lt2><a:srgbClr val="EEECE1"/></a:lt2>'
'<a:accent1><a:srgbClr val="4F81BD"/></a:accent1>'
'<a:accent2><a:srgbClr val="C0504D"/></a:accent2>'
'<a:accent3><a:srgbClr val="9BBB59"/></a:accent3>'
'<a:accent4><a:srgbClr val="8064A2"/></a:accent4>'
'<a:accent5><a:srgbClr val="4BACC6"/></a:accent5>'
'<a:accent6><a:srgbClr val="F79646"/></a:accent6>'
'<a:hlink><a:srgbClr val="0000FF"/></a:hlink>'
'<a:folHlink><a:srgbClr val="800080"/></a:folHlink>'
'</a:clrScheme>'
# v1.1 修复:CJK 字体从空改为"宋体"(universal),避免 Mac/Linux/旧 Windows 下载后乱码
'<a:fontScheme name="Office">'
'<a:majorFont><a:latin typeface="Calibri"/><a:ea typeface="宋体"/><a:cs typeface="Calibri"/>'
'</a:majorFont>'
'<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface="宋体"/><a:cs typeface="Calibri"/>'
'</a:minorFont></a:fontScheme>'
'<a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill/><a:gradFill rotWithShape="1">'
'<a:gsLst><a:gs pos="0"/><a:gs pos="1"/></a:gsLst>'
'<a:lin><a:gradFill rotWithShape="1"/></a:lin></a:gradFill></a:fillStyleLst>'
'<a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="accent1"/></a:solidFill>'
'<a:ln w="25400"><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></a:ln>'
'<a:ln w="38100"><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></a:ln>'
'</a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle>'
'<a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle>'
'</a:effectStyleLst><a:bgFillStyleLst><a:solidFill/><a:gradFill rotWithShape="1">'
'<a:gsLst><a:gs pos="0"/><a:gs pos="1"/></a:gsLst>'
'<a:lin rotWithShape="1"/></a:gradFill></a:bgFillStyleLst>'
'</a:fmtScheme>'
'</a:themeElements><a:objectDefaults/><a:extraClrSchemeLst/></a:theme>'
)
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('[Content_Types].xml', ct_xml)
zf.writestr('_rels/.rels', (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="ppt/presentation.xml"/>'
'</Relationships>'
))
zf.writestr('ppt/_rels/presentation.xml.rels', pres_rels)
zf.writestr('ppt/presentation.xml', pres_xml)
zf.writestr('ppt/slideMasters/slideMaster1.xml', master_xml)
zf.writestr('ppt/slideLayouts/slideLayout1.xml', layout_xml)
zf.writestr('ppt/slideLayouts/slideLayout2.xml', layout_xml)
zf.writestr('ppt/theme/theme1.xml', theme_xml)
for i, slide_xml in enumerate(slides):
zf.writestr(f'ppt/slides/slide{i+1}.xml', slide_xml)
zf.writestr('ppt/_rels/slideLayout1.xml.rels', (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" '
'Target="../slideMasters/slideMaster1.xml"/>'
'</Relationships>'
))
zf.writestr('ppt/_rels/slideLayout2.xml.rels', (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" '
'Target="../slideMasters/slideMaster1.xml"/>'
'</Relationships>'
))
print(f"[标准库 Pptx] 已生成: {output_path}")
return output_path
def _make_content_slide(title: str, content_xml: str, slide_id: int) -> str:
"""生成内容页幻灯片 XML"""
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
'<p:cSld><p:spTree>'
'<p:sp><p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>'
'<p:spPr><a:xfrm><a:off x="457200" y="274638"/><a:ext cx="8229600" cy="857250"/>'
'</a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>'
'<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr algn="ctr"/>'
'<a:r><a:rPr lang="zh-CN" sz="3200" b="1"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill>'
'<a:latin typeface="宋体"/><a:ea typeface="宋体"/>'
'</a:rPr><a:t>{}</a:t></a:r></a:p></p:txBody></p:sp>'
'<p:sp><p:nvSpPr><p:cNvPr id="3" name="Content"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>'
'<p:spPr><a:xfrm><a:off x="457200" y="1371600"/><a:ext cx="8229600" cy="4800600"/>'
'</a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>'
'<p:txBody><a:bodyPr/><a:lstStyle/><a:p>'
+ content_xml +
'</a:p></p:txBody></p:sp>'
'</p:spTree></p:cSld>'
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>'.format(_esc(title))
)
# ─── XLSX 生成器 ────────────────────────────────────────────────────────────
class StdlibXlsxWriter:
"""用标准库生成最小化 .xlsx(Excel 2007+ 兼容)"""
def export_comprehensive_report(self, data: Dict[str, Any],
stock_code: str,
output_path: str = "") -> str:
"""
生成 Excel 文档(.xlsx)
结构:
[Content_Types].xml
_rels/.rels
xl/workbook.xml
xl/_rels/workbook.xml.rels
xl/worksheets/sheet1.xml (概览)
xl/worksheets/sheet2.xml (定期报告)
xl/worksheets/sheet3.xml (券商研报)
xl/worksheets/sheet4.xml (公告)
xl/sharedStrings.xml
xl/styles.xml
"""
if not output_path:
os.makedirs(EXPORT_DIR, exist_ok=True)
output_path = str(EXPORT_DIR / f"{stock_code}_report.xlsx")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
summary = data.get("summary", {})
stock_name = summary.get("stock_name", stock_code)
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
sheets: List[Dict] = []
# ── Sheet 1: 报告概览 ───────────────────────────────────────────────
s1_rows = [
["项目", "内容"],
["股票代码", stock_code],
["股票名称", stock_name],
["定期报告", f"{summary.get('periodic_count', 0)} 份"],
["券商研报", f"{summary.get('broker_count', 0)} 份"],
["公告", f"{summary.get('announcement_count', 0)} 份"],
["买入评级研报", "是" if summary.get('has_buy_rating') else "否"],
["最新定期报告", str(summary.get('latest_periodic_date', 'N/A'))],
["最新券商研报", str(summary.get('latest_broker_date', 'N/A'))],
["最新公告", str(summary.get('latest_announcement_date', 'N/A'))],
["生成时间", now],
]
sheets.append(("报告概览", s1_rows))
# ── Sheet 2: 定期报告 ───────────────────────────────────────────────
periodic = data.get("periodic_reports", [])
s2_rows = [["日期", "标题", "报告类型", "URL"]]
for p in periodic:
s2_rows.append([
str(_attr(p, 'publish_date', ''))[:10],
_row_esc(_attr(p, 'title', '')),
_row_esc(_attr(p, 'report_type', '')),
_row_esc(_attr(p, 'url', '')),
])
sheets.append(("定期报告", s2_rows if len(s2_rows) > 1 else [["无数据"]]))
# ── Sheet 3: 券商研报 ───────────────────────────────────────────────
broker = data.get("broker_reports", [])
s3_rows = [["日期", "标题", "券商", "分析师", "评级", "目标价", "目标涨幅", "URL"]]
for b in broker:
s3_rows.append([
str(_attr(b, 'publish_date', ''))[:10],
_row_esc(_attr(b, 'title', '')),
_row_esc(_attr(b, 'broker_name', '')),
_row_esc(_attr(b, 'analyst', '')),
_row_esc(_attr(b, 'rating', '')),
f"{float(_attr(b, 'target_price', 0)):.2f}" if _attr(b, 'target_price', 0) else "",
f"{float(_attr(b, 'target_change_pct', 0)):.2f}%" if _attr(b, 'target_change_pct', 0) else "",
_row_esc(_attr(b, 'url', '')),
])
sheets.append(("券商研报", s3_rows if len(s3_rows) > 1 else [["无数据"]]))
# ── Sheet 4: 公告 ───────────────────────────────────────────────────
announcements = data.get("announcements", [])
s4_rows = [["日期", "标题", "公告类型", "URL"]]
for a in announcements:
s4_rows.append([
str(_attr(a, 'publish_date', ''))[:10],
_row_esc(_attr(a, 'title', '')),
_row_esc(_attr(a, 'announcement_type', '')),
_row_esc(_attr(a, 'url', '')),
])
sheets.append(("公告", s4_rows if len(s4_rows) > 1 else [["无数据"]]))
# ── 构建共享字符串 ─────────────────────────────────────────────────
all_strings: List[str] = []
str_map: Dict[str, int] = {}
def _si(s: str) -> str:
nonlocal all_strings, str_map
s = str(s)
if s not in str_map:
str_map[s] = len(all_strings)
all_strings.append(s)
return str(str_map[s])
def _cell_row(vals: List[Any], row_idx: int, style: int = 0) -> str:
cells = ""
for col_idx, v in enumerate(vals):
c = chr(65 + col_idx) # A, B, C...
sv = _si(v) if isinstance(v, str) else str(v)
if isinstance(v, str):
cells += f'<c r="{c}{row_idx}" t="s"><v>{sv}</v></c>'
else:
cells += f'<c r="{c}{row_idx}"><v>{sv}</v></c>'
return f'<row r="{row_idx}">{cells}</row>'
sheet_xmls: List[str] = []
for sheet_idx, (sheet_name, rows) in enumerate(sheets):
sheet_data = ""
for ri, row in enumerate(rows, 1):
sheet_data += _cell_row(row, ri, 1 if ri == 1 else 0)
sheet_xmls.append(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<sheetData>' + sheet_data + '</sheetData></worksheet>'
)
shared_strings_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
f'count="{len(all_strings)}" uniqueCount="{len(all_strings)}">'
+ "".join(f'<si><t>{_esc(s)}</t></si>' for s in all_strings)
+ '</sst>'
)
styles_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<fonts><font><sz val="11"/><name val="宋体"/></font>'
'<font><sz val="11"/><b/><name val="宋体"/></font></fonts>'
'<fills><fill><patternFill patternType="none"/></fill>'
'<fill><patternFill patternType="gray125"/></fill>'
'<fill><patternFill patternType="solid"><fgColor rgb="FFD9E1F2"/></patternFill></fill></fills>'
'<borders><border><left/><right/><top/><bottom/><diagonal/></border>'
'<border><left val="thin"><color rgb="FF000000"/></left>'
'<right val="thin"><color rgb="FF000000"/></right>'
'<top val="thin"><color rgb="FF000000"/></top>'
'<bottom val="thin"><color rgb="FF000000"/></bottom><diagonal/></border>'
'</borders>'
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
'<cellXfs>'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
'<xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1"/>'
'</cellXfs></styleSheet>'
)
sheet_rels = "".join(
f'<Relationship Id="rId{i+1}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
f'Target="worksheets/sheet{i+1}.xml"/>'
for i in range(len(sheets))
) + '<Relationship Id="rId99" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>'
workbook_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
'<sheets>'
+ "".join(
f'<sheet name="{_esc(name)}" sheetId="{i+1}" r:id="rId{i+1}"/>'
for i, (name, _) in enumerate(sheets)
)
+ '</sheets></workbook>'
)
ct_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/sharedStrings.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>'
'<Override PartName="/xl/styles.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
+ "".join(
f'<Override PartName="/xl/worksheets/sheet{i+1}.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
for i in range(len(sheets))
)
+ '</Types>'
)
# ── 写入 ZIP ─────────────────────────────────────────────────────────
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('[Content_Types].xml', ct_xml)
zf.writestr('_rels/.rels', (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="xl/workbook.xml"/>'
'</Relationships>'
))
zf.writestr('xl/_rels/workbook.xml.rels', (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ sheet_rels +
'</Relationships>'
))
zf.writestr('xl/workbook.xml', workbook_xml)
zf.writestr('xl/styles.xml', styles_xml)
zf.writestr('xl/sharedStrings.xml', shared_strings_xml)
for i, sheet_xml in enumerate(sheet_xmls):
zf.writestr(f'xl/worksheets/sheet{i+1}.xml', sheet_xml)
print(f"[标准库 Xlsx] 已生成: {output_path}")
return output_path