524 lines
19 KiB
Python
524 lines
19 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
|
from fastapi.responses import HTMLResponse, FileResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
from app.database import get_db
|
|
from app.schemas import StockSearchRequest, StockSearchResponse, AnalysisRequest, ReportResponse, AnalysisStatus, ConfigUpdateRequest
|
|
from app.models import Report, Setting
|
|
from app.services import analysis_service
|
|
import os
|
|
import markdown
|
|
from weasyprint import HTML
|
|
import io
|
|
import tempfile
|
|
from urllib.parse import quote
|
|
from bs4 import BeautifulSoup
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/health")
|
|
def health_check():
|
|
return {"status": "healthy"}
|
|
|
|
@router.post("/search", response_model=list[StockSearchResponse])
|
|
async def search_stock(request: StockSearchRequest, db: AsyncSession = Depends(get_db)):
|
|
setting = await db.get(Setting, "GEMINI_API_KEY")
|
|
api_key = setting.value if setting else os.getenv("GEMINI_API_KEY")
|
|
|
|
if not api_key:
|
|
raise HTTPException(status_code=500, detail="API Key not configured")
|
|
|
|
# Get AI model setting
|
|
model_setting = await db.get(Setting, "AI_MODEL")
|
|
model = model_setting.value if model_setting else "gemini-2.0-flash"
|
|
|
|
result = await analysis_service.search_stock(request.query, api_key, model)
|
|
if isinstance(result, dict) and "error" in result:
|
|
if isinstance(result, str) and "```json" in result:
|
|
pass
|
|
raise HTTPException(status_code=400, detail=str(result))
|
|
return result
|
|
|
|
@router.post("/analyze", response_model=ReportResponse)
|
|
async def start_analysis(request: AnalysisRequest, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)):
|
|
# Get AI model setting
|
|
model_setting = await db.get(Setting, "AI_MODEL")
|
|
model = model_setting.value if model_setting else "gemini-2.0-flash"
|
|
|
|
new_report = Report(
|
|
market=request.market,
|
|
symbol=request.symbol,
|
|
company_name=request.company_name,
|
|
status=AnalysisStatus.PENDING,
|
|
ai_model=model
|
|
)
|
|
db.add(new_report)
|
|
await db.commit()
|
|
await db.refresh(new_report)
|
|
|
|
setting = await db.get(Setting, "GEMINI_API_KEY")
|
|
api_key = setting.value if setting else os.getenv("GEMINI_API_KEY")
|
|
|
|
if not api_key:
|
|
new_report.status = AnalysisStatus.FAILED
|
|
await db.commit()
|
|
raise HTTPException(status_code=500, detail="API Key not configured")
|
|
|
|
# Trigger background task
|
|
background_tasks.add_task(
|
|
analysis_service.run_analysis_task,
|
|
new_report.id,
|
|
request.market,
|
|
request.symbol,
|
|
api_key
|
|
)
|
|
|
|
# Re-fetch with selectinload to avoid lazy loading issues
|
|
result = await db.execute(select(Report).options(selectinload(Report.sections)).where(Report.id == new_report.id))
|
|
report_with_sections = result.scalar_one()
|
|
|
|
return report_with_sections
|
|
|
|
@router.get("/reports", response_model=list[ReportResponse])
|
|
async def get_reports(db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Report).options(selectinload(Report.sections)).order_by(Report.created_at.desc()))
|
|
return result.scalars().all()
|
|
|
|
@router.get("/reports/{report_id}", response_model=ReportResponse)
|
|
async def get_report(report_id: int, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Report).options(selectinload(Report.sections)).where(Report.id == report_id))
|
|
report = result.scalar_one_or_none()
|
|
if not report:
|
|
raise HTTPException(status_code=404, detail="Report not found")
|
|
return report
|
|
|
|
@router.get("/reports/{report_id}/html", response_class=HTMLResponse)
|
|
async def get_report_html(report_id: int, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Report).options(selectinload(Report.sections)).where(Report.id == report_id))
|
|
report = result.scalar_one_or_none()
|
|
if not report:
|
|
raise HTTPException(status_code=404, detail="Report not found")
|
|
|
|
# Get Financial HTML (Charts)
|
|
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
|
|
base_dir = os.path.join(root_dir, "data", report.market)
|
|
symbol_dir = os.path.join(base_dir, report.symbol)
|
|
|
|
# Fuzzy match logic
|
|
financial_html = ""
|
|
try:
|
|
if not os.path.exists(symbol_dir) and os.path.exists(base_dir):
|
|
candidates = [d for d in os.listdir(base_dir) if d.startswith(report.symbol) and os.path.isdir(os.path.join(base_dir, d))]
|
|
if candidates:
|
|
symbol_dir = os.path.join(base_dir, candidates[0])
|
|
|
|
start_html_path = os.path.join(symbol_dir, "report.html")
|
|
if os.path.exists(start_html_path):
|
|
with open(start_html_path, 'r', encoding='utf-8') as f:
|
|
financial_html = f.read()
|
|
else:
|
|
financial_html = "<p>财务图表尚未生成,数据获取可能仍在进行中。</p>"
|
|
except Exception as e:
|
|
financial_html = f"<p>加载财务图表时出错: {str(e)}</p>"
|
|
|
|
# Only return financial charts, no analysis sections
|
|
final_html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{report.company_name} - 财务数据</title>
|
|
<style>
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 20px; line-height: 1.6; max-width: 1200px; margin: 0 auto; }}
|
|
table {{ border-collapse: collapse; width: 100%; margin: 20px 0; }}
|
|
th, td {{ border: 1px solid #ddd; padding: 12px; }}
|
|
th {{ background-color: #f5f5f5; }}
|
|
img {{ max-width: 100%; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{financial_html}
|
|
<script>
|
|
// Send height to parent window
|
|
function sendHeight() {{
|
|
const height = Math.max(
|
|
document.body.scrollHeight,
|
|
document.body.offsetHeight,
|
|
document.documentElement.clientHeight,
|
|
document.documentElement.scrollHeight,
|
|
document.documentElement.offsetHeight
|
|
);
|
|
window.parent.postMessage({{ type: 'resize', height: height }}, '*');
|
|
}}
|
|
|
|
// Send height on load and when window resizes
|
|
window.addEventListener('load', sendHeight);
|
|
window.addEventListener('resize', sendHeight);
|
|
|
|
// Also send immediately in case load already fired
|
|
sendHeight();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return final_html
|
|
|
|
@router.get("/config")
|
|
async def get_config(db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Setting))
|
|
settings = result.scalars().all()
|
|
|
|
config_map = {s.key: s.value for s in settings}
|
|
|
|
if "GEMINI_API_KEY" in config_map:
|
|
config_map["GEMINI_API_KEY"] = "********" + config_map["GEMINI_API_KEY"][-4:]
|
|
elif os.getenv("GEMINI_API_KEY"):
|
|
val = os.getenv("GEMINI_API_KEY")
|
|
config_map["GEMINI_API_KEY"] = "********" + val[-4:]
|
|
else:
|
|
config_map["GEMINI_API_KEY"] = ""
|
|
|
|
return config_map
|
|
|
|
@router.post("/config")
|
|
async def update_config(request: ConfigUpdateRequest, db: AsyncSession = Depends(get_db)):
|
|
setting = await db.get(Setting, request.key)
|
|
if not setting:
|
|
setting = Setting(key=request.key, value=request.value)
|
|
db.add(setting)
|
|
else:
|
|
setting.value = request.value
|
|
|
|
await db.commit()
|
|
return {"status": "updated", "key": request.key}
|
|
|
|
@router.get("/reports/{report_id}/pdf")
|
|
async def download_report_pdf(report_id: int, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Report).options(selectinload(Report.sections)).where(Report.id == report_id))
|
|
report = result.scalar_one_or_none()
|
|
if not report:
|
|
raise HTTPException(status_code=404, detail="Report not found")
|
|
|
|
# Get Financial HTML (Charts)
|
|
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
|
|
base_dir = os.path.join(root_dir, "data", report.market)
|
|
symbol_dir = os.path.join(base_dir, report.symbol)
|
|
|
|
# Fuzzy match logic
|
|
financial_html = ""
|
|
try:
|
|
if not os.path.exists(symbol_dir) and os.path.exists(base_dir):
|
|
candidates = [d for d in os.listdir(base_dir) if d.startswith(report.symbol) and os.path.isdir(os.path.join(base_dir, d))]
|
|
if candidates:
|
|
symbol_dir = os.path.join(base_dir, candidates[0])
|
|
|
|
start_html_path = os.path.join(symbol_dir, "report.html")
|
|
if os.path.exists(start_html_path):
|
|
with open(start_html_path, 'r', encoding='utf-8') as f:
|
|
financial_html = f.read()
|
|
|
|
# Parse and clean HTML - keep table styles but remove containers and backgrounds
|
|
soup = BeautifulSoup(financial_html, 'html.parser')
|
|
|
|
# Modify style tags to remove background colors
|
|
for style_tag in soup.find_all('style'):
|
|
style_content = style_tag.string
|
|
if style_content:
|
|
# Remove CSS variables for backgrounds
|
|
style_content = '\n'.join([line for line in style_content.split('\n')
|
|
if not any(bg in line.lower() for bg in ['--bg:', '--card-bg:', '--header-bg:', '--section-bg:'])])
|
|
# Remove background properties from body
|
|
style_content = style_content.replace('background: var(--bg);', 'background: white;')
|
|
style_content = style_content.replace('background: var(--card-bg);', '')
|
|
style_content = style_content.replace('background: var(--header-bg);', '')
|
|
style_tag.string = style_content
|
|
|
|
# Remove container divs but keep content
|
|
for div in soup.find_all('div', class_='report-container'):
|
|
div.unwrap()
|
|
|
|
# Remove inline styles from container divs only, preserve td/th styles (including background colors)
|
|
for div in soup.find_all('div'):
|
|
if div.get('style'):
|
|
del div['style']
|
|
|
|
# Limit table columns to first 6
|
|
for table in soup.find_all('table'):
|
|
for row in table.find_all('tr'):
|
|
cells = row.find_all(['th', 'td'])
|
|
if len(cells) > 6:
|
|
for cell in cells[6:]:
|
|
cell.decompose()
|
|
|
|
financial_html = str(soup)
|
|
else:
|
|
financial_html = "<p>财务图表尚未生成,数据获取可能仍在进行中。</p>"
|
|
except Exception as e:
|
|
financial_html = f"<p>加载财务图表时出错: {str(e)}</p>"
|
|
|
|
# Build analysis sections HTML
|
|
sections_html = ""
|
|
section_names = {
|
|
'company_profile': '公司简介',
|
|
'fundamental_analysis': '基本面分析',
|
|
'insider_analysis': '内部人士分析',
|
|
'bullish_analysis': '看涨分析',
|
|
'bearish_analysis': '看跌分析'
|
|
}
|
|
|
|
|
|
import re
|
|
for section in sorted(report.sections, key=lambda s: list(section_names.keys()).index(s.section_name) if s.section_name in section_names else 999):
|
|
# Pre-process markdown to fix list formatting
|
|
content = section.content
|
|
|
|
# 1. First, find cases where numbered lists are clumped in one line like "1. xxx 2. yyy"
|
|
# and split them into multiple lines.
|
|
content = re.sub(r'(\s)(\d+\.\s)', r'\n\2', content)
|
|
|
|
# 2. Ensure list items have proper spacing
|
|
lines = content.split('\n')
|
|
fixed_lines = []
|
|
in_list = False
|
|
|
|
for i, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
# Check if this line is a list item (bullet or numbered)
|
|
# Regex \d+\.\s matches "1. ", "2. ", etc.
|
|
is_list_item = stripped.startswith('* ') or stripped.startswith('- ') or re.match(r'^\d+\.\s', stripped)
|
|
|
|
if is_list_item:
|
|
# Add blank line before first list item
|
|
if not in_list and fixed_lines and fixed_lines[-1].strip():
|
|
fixed_lines.append('')
|
|
in_list = True
|
|
fixed_lines.append(line)
|
|
else:
|
|
# Add blank line after last list item
|
|
if in_list and stripped:
|
|
fixed_lines.append('')
|
|
in_list = False
|
|
fixed_lines.append(line)
|
|
|
|
content = '\n'.join(fixed_lines)
|
|
|
|
section_content = markdown.markdown(content, extensions=['tables', 'fenced_code'])
|
|
sections_html += f"""
|
|
<div class="section">
|
|
<div class="section-content">
|
|
{section_content}
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
# Complete PDF HTML
|
|
complete_html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{report.company_name} - 分析报告</title>
|
|
<style>
|
|
@page {{
|
|
size: A4;
|
|
margin: 2.5cm 1cm;
|
|
}}
|
|
body {{
|
|
font-family: "PingFang SC", "Microsoft YaHei", "SimHei", sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
font-size: 11pt;
|
|
background-color: white;
|
|
margin: 0;
|
|
padding: 0;
|
|
}}
|
|
.cover {{
|
|
page-break-after: always;
|
|
text-align: center;
|
|
padding-top: 30%;
|
|
}}
|
|
.cover h1 {{
|
|
font-size: 32pt;
|
|
margin-bottom: 20px;
|
|
color: #1a1a1a;
|
|
}}
|
|
.cover .meta {{
|
|
font-size: 14pt;
|
|
color: #666;
|
|
margin: 10px 0;
|
|
}}
|
|
h1 {{
|
|
font-size: 20pt;
|
|
color: #1a1a1a;
|
|
margin-top: 20px;
|
|
margin-bottom: 12px;
|
|
border-bottom: 2px solid #4a90e2;
|
|
padding-bottom: 8px;
|
|
}}
|
|
h2 {{
|
|
font-size: 16pt;
|
|
color: #2c3e50;
|
|
margin-top: 16px;
|
|
margin-bottom: 8px;
|
|
}}
|
|
h3 {{
|
|
font-size: 13pt;
|
|
color: #34495e;
|
|
margin-top: 12px;
|
|
margin-bottom: 6px;
|
|
}}
|
|
p {{
|
|
margin: 4px 0;
|
|
text-align: justify;
|
|
font-size: 10pt;
|
|
}}
|
|
/* Prevent double spacing in lists */
|
|
li p {{
|
|
margin: 0;
|
|
}}
|
|
table {{
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
margin: 10px 0;
|
|
font-size: 6pt;
|
|
table-layout: fixed;
|
|
line-height: 1.2;
|
|
}}
|
|
th, td {{
|
|
border: 1px solid #ddd;
|
|
padding: 2px 2px;
|
|
text-align: left;
|
|
word-wrap: break-word;
|
|
overflow-wrap: break-word;
|
|
hyphens: auto;
|
|
white-space: normal;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}}
|
|
/* First column for tables starting from the 2nd table (财务指标表) - narrower */
|
|
table:nth-of-type(n+2) th:first-child,
|
|
table:nth-of-type(n+2) td:first-child {{
|
|
width: 150px;
|
|
min-width: 150px;
|
|
max-width: 150px;
|
|
}}
|
|
/* PE and PB columns in the first table (Basic Info) - narrower */
|
|
table:first-of-type th:nth-child(4),
|
|
table:first-of-type td:nth-child(4),
|
|
table:first-of-type th:nth-child(5),
|
|
table:first-of-type td:nth-child(5) {{
|
|
width: 90px;
|
|
}}
|
|
th {{
|
|
background-color: #f3f4f6;
|
|
color: #374151;
|
|
font-weight: bold;
|
|
font-size: 6pt;
|
|
padding: 5px 2px;
|
|
}}
|
|
tr:nth-child(even) {{
|
|
background-color: #f9f9f9;
|
|
}}
|
|
/* Handle tables with many columns */
|
|
.table-wrapper {{
|
|
overflow: hidden;
|
|
max-width: 100%;
|
|
}}
|
|
img {{
|
|
max-width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
margin: 10px auto;
|
|
}}
|
|
.section {{
|
|
page-break-before: always;
|
|
}}
|
|
.section:first-of-type {{
|
|
page-break-before: auto;
|
|
}}
|
|
.section-title {{
|
|
font-size: 22pt;
|
|
color: #1a1a1a;
|
|
margin-bottom: 15px;
|
|
border-bottom: 3px solid #4a90e2;
|
|
padding-bottom: 8px;
|
|
}}
|
|
.section-content {{
|
|
margin-top: 10px;
|
|
}}
|
|
ul, ol {{
|
|
margin: 0 0 6px 0;
|
|
padding-left: 30px;
|
|
line-height: 1.8;
|
|
}}
|
|
li {{
|
|
margin: 2px 0;
|
|
font-size: 10pt;
|
|
list-style-position: outside;
|
|
}}
|
|
code {{
|
|
background-color: #f4f4f4;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-family: "Courier New", monospace;
|
|
font-size: 10pt;
|
|
}}
|
|
pre {{
|
|
background-color: #f4f4f4;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
overflow-x: auto;
|
|
font-size: 9pt;
|
|
}}
|
|
blockquote {{
|
|
border-left: 4px solid #4a90e2;
|
|
padding-left: 15px;
|
|
margin: 15px 0;
|
|
color: #555;
|
|
font-style: italic;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="cover">
|
|
<h1>{report.company_name}</h1>
|
|
<div class="meta">{report.market} {report.symbol}</div>
|
|
<div class="meta">分析日期: {report.created_at.strftime('%Y年%m月%d日')}</div>
|
|
{f'<div class="meta">AI模型: {report.ai_model}</div>' if report.ai_model else ''}
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-content">
|
|
{financial_html}
|
|
</div>
|
|
</div>
|
|
|
|
{sections_html}
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# Generate PDF
|
|
try:
|
|
# Create a temporary file for the PDF
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file:
|
|
pdf_path = tmp_file.name
|
|
HTML(string=complete_html).write_pdf(pdf_path)
|
|
|
|
# Return the PDF file
|
|
filename = f"{report.company_name}_{report.symbol}_分析报告.pdf"
|
|
# Use RFC 5987 encoding for non-ASCII filenames
|
|
filename_encoded = quote(filename)
|
|
|
|
return FileResponse(
|
|
path=pdf_path,
|
|
media_type='application/pdf',
|
|
headers={
|
|
'Content-Disposition': f"attachment; filename*=UTF-8''{filename_encoded}"
|
|
}
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"生成PDF失败: {str(e)}")
|