Spaces:
Sleeping
Sleeping
feat: add news report
Browse files- .gitignore +1 -0
- Home.py +9 -9
- env-example +5 -1
- modules/email_sender.py +226 -0
- modules/news_pipeline.py +539 -0
- pages/Daily_News_Report.py +140 -0
- pages/{stock_report.py → Stock_Analysis.py} +0 -0
- pages/{chat_app.py → Stock_Chatbot.py} +2 -1
- requirements.txt +6 -0
- test.py +235 -0
.gitignore
CHANGED
@@ -1,3 +1,4 @@
|
|
1 |
.env
|
2 |
PLAN.md
|
|
|
3 |
__pycache__/
|
|
|
1 |
.env
|
2 |
PLAN.md
|
3 |
+
PLAN_UPDATE.md
|
4 |
__pycache__/
|
Home.py
CHANGED
@@ -46,20 +46,20 @@ if hasattr(st, 'empty'):
|
|
46 |
|
47 |
### Main Features:
|
48 |
|
49 |
-
1.
|
50 |
-
-
|
51 |
-
-
|
52 |
-
-
|
53 |
|
54 |
-
2. **📄 In-depth
|
55 |
- Comprehensive analysis of a specific stock
|
56 |
- Data collection from multiple sources
|
57 |
- Generate in-depth reports with AI evaluation
|
58 |
|
59 |
-
3.
|
60 |
-
-
|
61 |
-
-
|
62 |
-
-
|
63 |
|
64 |
### How to Use:
|
65 |
|
|
|
46 |
|
47 |
### Main Features:
|
48 |
|
49 |
+
1. **📰 Daily News Report** - Summary of the latest financial news:
|
50 |
+
- Compilation of latest financial news
|
51 |
+
- Categorized by topic
|
52 |
+
- Daily market updates
|
53 |
|
54 |
+
2. **📄 Stock Analysis Report** - In-depth analysis of a specific stock:
|
55 |
- Comprehensive analysis of a specific stock
|
56 |
- Data collection from multiple sources
|
57 |
- Generate in-depth reports with AI evaluation
|
58 |
|
59 |
+
3. **💬 Stock Chatbot** - Chat with AI Financial Analyst:
|
60 |
+
- Search for stock information
|
61 |
+
- View price charts
|
62 |
+
- Convert currencies
|
63 |
|
64 |
### How to Use:
|
65 |
|
env-example
CHANGED
@@ -9,4 +9,8 @@ NGROK_STATIC_DOMAIN=
|
|
9 |
# Invest insight app
|
10 |
ALPHA_VANTAGE_API_KEY=
|
11 |
NEWS_API_KEY=
|
12 |
-
MARKETAUX_API_KEY=
|
|
|
|
|
|
|
|
|
|
9 |
# Invest insight app
|
10 |
ALPHA_VANTAGE_API_KEY=
|
11 |
NEWS_API_KEY=
|
12 |
+
MARKETAUX_API_KEY=
|
13 |
+
|
14 |
+
# Email
|
15 |
+
SENDER_APP_PASSWORD=
|
16 |
+
SENDER_EMAIL=<email_address>@gmail.com
|
modules/email_sender.py
ADDED
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import smtplib
|
3 |
+
import tempfile
|
4 |
+
from email.mime.multipart import MIMEMultipart
|
5 |
+
from email.mime.text import MIMEText
|
6 |
+
from email.mime.application import MIMEApplication
|
7 |
+
from dotenv import load_dotenv
|
8 |
+
from markdown_it import MarkdownIt
|
9 |
+
from datetime import datetime
|
10 |
+
import weasyprint
|
11 |
+
|
12 |
+
# Tải biến môi trường
|
13 |
+
load_dotenv()
|
14 |
+
|
15 |
+
# Thông tin email từ biến môi trường
|
16 |
+
SENDER_EMAIL = os.getenv("SENDER_EMAIL")
|
17 |
+
SENDER_APP_PASSWORD = os.getenv("SENDER_APP_PASSWORD")
|
18 |
+
|
19 |
+
def _markdown_to_html(markdown_string):
|
20 |
+
"""Convert Markdown to HTML"""
|
21 |
+
md = MarkdownIt()
|
22 |
+
html_content = md.render(markdown_string)
|
23 |
+
|
24 |
+
# Format current date
|
25 |
+
current_date = datetime.now().strftime("%d/%m/%Y")
|
26 |
+
|
27 |
+
# Create complete HTML with CSS for nice formatting
|
28 |
+
full_html = f"""
|
29 |
+
<!DOCTYPE html>
|
30 |
+
<html>
|
31 |
+
<head>
|
32 |
+
<meta charset="UTF-8">
|
33 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
34 |
+
<title>Daily Market Report - {current_date}</title>
|
35 |
+
<style>
|
36 |
+
@page {{
|
37 |
+
size: A4;
|
38 |
+
margin: 2cm;
|
39 |
+
}}
|
40 |
+
body {{
|
41 |
+
font-family: Arial, Helvetica, sans-serif;
|
42 |
+
line-height: 1.5;
|
43 |
+
color: #333;
|
44 |
+
max-width: 800px;
|
45 |
+
margin: 0 auto;
|
46 |
+
}}
|
47 |
+
.report-header {{
|
48 |
+
text-align: center;
|
49 |
+
margin-bottom: 30px;
|
50 |
+
}}
|
51 |
+
.report-date {{
|
52 |
+
font-style: italic;
|
53 |
+
color: #666;
|
54 |
+
margin-bottom: 10px;
|
55 |
+
}}
|
56 |
+
.report-title {{
|
57 |
+
font-size: 24pt;
|
58 |
+
margin-bottom: 5px;
|
59 |
+
color: #2c3e50;
|
60 |
+
}}
|
61 |
+
.report-subtitle {{
|
62 |
+
font-size: 14pt;
|
63 |
+
color: #7f8c8d;
|
64 |
+
margin-top: 0;
|
65 |
+
}}
|
66 |
+
.report-body {{
|
67 |
+
text-align: justify;
|
68 |
+
}}
|
69 |
+
h1, h2, h3, h4, h5, h6 {{
|
70 |
+
color: #2c3e50;
|
71 |
+
margin-top: 20px;
|
72 |
+
}}
|
73 |
+
h1 {{ font-size: 20pt; }}
|
74 |
+
h2 {{ font-size: 18pt; }}
|
75 |
+
h3 {{ font-size: 16pt; }}
|
76 |
+
h4 {{ font-size: 14pt; }}
|
77 |
+
h5 {{ font-size: 12pt; }}
|
78 |
+
h6 {{ font-size: 10pt; }}
|
79 |
+
|
80 |
+
p {{
|
81 |
+
margin-bottom: 10px;
|
82 |
+
}}
|
83 |
+
|
84 |
+
a {{
|
85 |
+
color: #3498db;
|
86 |
+
text-decoration: none;
|
87 |
+
}}
|
88 |
+
|
89 |
+
a:hover {{
|
90 |
+
text-decoration: underline;
|
91 |
+
}}
|
92 |
+
|
93 |
+
ul, ol {{
|
94 |
+
margin: 10px 0 10px 20px;
|
95 |
+
}}
|
96 |
+
|
97 |
+
li {{
|
98 |
+
margin-bottom: 5px;
|
99 |
+
}}
|
100 |
+
|
101 |
+
blockquote {{
|
102 |
+
border-left: 4px solid #eee;
|
103 |
+
padding-left: 10px;
|
104 |
+
margin-left: 0;
|
105 |
+
color: #777;
|
106 |
+
}}
|
107 |
+
|
108 |
+
.section {{
|
109 |
+
margin-bottom: 30px;
|
110 |
+
}}
|
111 |
+
|
112 |
+
.footer {{
|
113 |
+
text-align: center;
|
114 |
+
margin-top: 40px;
|
115 |
+
padding-top: 20px;
|
116 |
+
font-size: 12px;
|
117 |
+
color: #777;
|
118 |
+
border-top: 1px solid #eee;
|
119 |
+
}}
|
120 |
+
|
121 |
+
/* Custom styling for bullet points */
|
122 |
+
ul {{
|
123 |
+
list-style-type: disc;
|
124 |
+
}}
|
125 |
+
ul ul {{
|
126 |
+
list-style-type: circle;
|
127 |
+
}}
|
128 |
+
ul ul ul {{
|
129 |
+
list-style-type: square;
|
130 |
+
}}
|
131 |
+
</style>
|
132 |
+
</head>
|
133 |
+
<body>
|
134 |
+
<div class="report-header">
|
135 |
+
<div class="report-date">Date: {current_date}</div>
|
136 |
+
<h1 class="report-title">Daily Market Report</h1>
|
137 |
+
<h2 class="report-subtitle">AI Financial Dashboard</h2>
|
138 |
+
</div>
|
139 |
+
|
140 |
+
<div class="report-body">
|
141 |
+
{html_content}
|
142 |
+
</div>
|
143 |
+
|
144 |
+
<div class="footer">
|
145 |
+
This report was automatically generated by AI Financial Dashboard. Information is for reference only.
|
146 |
+
</div>
|
147 |
+
</body>
|
148 |
+
</html>
|
149 |
+
"""
|
150 |
+
|
151 |
+
return full_html
|
152 |
+
|
153 |
+
def _generate_pdf_from_markdown(markdown_string):
|
154 |
+
"""Generate PDF from Markdown using WeasyPrint"""
|
155 |
+
# Convert markdown to HTML
|
156 |
+
html_content = _markdown_to_html(markdown_string)
|
157 |
+
|
158 |
+
# Create temporary HTML file
|
159 |
+
with tempfile.NamedTemporaryFile(suffix='.html', delete=False) as temp_html:
|
160 |
+
temp_html_path = temp_html.name
|
161 |
+
temp_html.write(html_content.encode('utf-8'))
|
162 |
+
|
163 |
+
# Create PDF from HTML
|
164 |
+
try:
|
165 |
+
# Create temporary PDF filename
|
166 |
+
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as temp_pdf:
|
167 |
+
temp_pdf_path = temp_pdf.name
|
168 |
+
|
169 |
+
# Generate PDF
|
170 |
+
weasyprint.HTML(filename=temp_html_path).write_pdf(temp_pdf_path)
|
171 |
+
|
172 |
+
# Read PDF content
|
173 |
+
with open(temp_pdf_path, 'rb') as f:
|
174 |
+
pdf_data = f.read()
|
175 |
+
|
176 |
+
# Delete temporary files
|
177 |
+
os.unlink(temp_html_path)
|
178 |
+
os.unlink(temp_pdf_path)
|
179 |
+
|
180 |
+
return pdf_data
|
181 |
+
except Exception as e:
|
182 |
+
# Handle errors and ensure temporary files are deleted
|
183 |
+
if os.path.exists(temp_html_path):
|
184 |
+
os.unlink(temp_html_path)
|
185 |
+
raise e
|
186 |
+
|
187 |
+
def send_report_via_email(report_markdown, recipient_email):
|
188 |
+
"""Send market report via email"""
|
189 |
+
try:
|
190 |
+
# Generate PDF from markdown
|
191 |
+
pdf_data = _generate_pdf_from_markdown(report_markdown)
|
192 |
+
|
193 |
+
# Create message
|
194 |
+
message = MIMEMultipart()
|
195 |
+
message["From"] = SENDER_EMAIL
|
196 |
+
message["To"] = recipient_email
|
197 |
+
message["Subject"] = f"AI Financial Dashboard - Daily Market Report {datetime.now().strftime('%d/%m/%Y')}"
|
198 |
+
|
199 |
+
# Add content with UTF-8 encoding
|
200 |
+
body = """
|
201 |
+
Dear User,
|
202 |
+
|
203 |
+
Attached is today's financial market report, automatically generated by AI Financial Dashboard.
|
204 |
+
|
205 |
+
Best regards,
|
206 |
+
AI Financial Dashboard Team
|
207 |
+
"""
|
208 |
+
message.attach(MIMEText(body, "plain", "utf-8"))
|
209 |
+
|
210 |
+
# Attach PDF file
|
211 |
+
attachment = MIMEApplication(pdf_data, _subtype="pdf")
|
212 |
+
attachment.add_header(
|
213 |
+
"Content-Disposition", "attachment",
|
214 |
+
filename=f"Market_Report_{datetime.now().strftime('%Y%m%d')}.pdf"
|
215 |
+
)
|
216 |
+
message.attach(attachment)
|
217 |
+
|
218 |
+
# Connect to SMTP server and send email
|
219 |
+
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
|
220 |
+
server.login(SENDER_EMAIL, SENDER_APP_PASSWORD)
|
221 |
+
server.send_message(message)
|
222 |
+
|
223 |
+
return True, "Email sent successfully!"
|
224 |
+
|
225 |
+
except Exception as e:
|
226 |
+
return False, f"Error sending email: {str(e)}"
|
modules/news_pipeline.py
ADDED
@@ -0,0 +1,539 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import asyncio
|
3 |
+
import aiohttp
|
4 |
+
import time
|
5 |
+
import re
|
6 |
+
import random
|
7 |
+
from datetime import datetime, timedelta
|
8 |
+
import google.generativeai as genai
|
9 |
+
from dotenv import load_dotenv
|
10 |
+
from concurrent.futures import ThreadPoolExecutor
|
11 |
+
from groq import Groq, AsyncGroq
|
12 |
+
from threading import Lock
|
13 |
+
|
14 |
+
# Tải biến môi trường
|
15 |
+
load_dotenv()
|
16 |
+
|
17 |
+
# Cấu hình Gemini AI
|
18 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
19 |
+
genai.configure(api_key=GEMINI_API_KEY)
|
20 |
+
MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
21 |
+
|
22 |
+
# Cấu hình Groq AI
|
23 |
+
GROQ_API_KEYS_STR = os.getenv("GROQ_API_KEY", "")
|
24 |
+
GROQ_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct" # Model Llama4 qua Groq
|
25 |
+
|
26 |
+
# Quản lý nhiều API keys cho Groq
|
27 |
+
class GroqClientManager:
|
28 |
+
"""Quản lý pool các Groq clients với logic round-robin"""
|
29 |
+
|
30 |
+
def __init__(self, api_keys_str):
|
31 |
+
# Tách chuỗi API keys (key1,key2,key3) thành list
|
32 |
+
self.api_keys = [key.strip() for key in api_keys_str.split(",") if key.strip()]
|
33 |
+
if not self.api_keys:
|
34 |
+
raise ValueError("Không tìm thấy API key hợp lệ cho Groq")
|
35 |
+
|
36 |
+
print(f"Khởi tạo {len(self.api_keys)} Groq API clients")
|
37 |
+
|
38 |
+
# Tạo pool của các client
|
39 |
+
self.clients = [AsyncGroq(api_key=key) for key in self.api_keys]
|
40 |
+
self.current_index = 0
|
41 |
+
self.lock = Lock()
|
42 |
+
|
43 |
+
# Thống kê sử dụng
|
44 |
+
self.usage_stats = {i: 0 for i in range(len(self.api_keys))}
|
45 |
+
|
46 |
+
def get_next_client(self):
|
47 |
+
"""Lấy client tiếp theo theo cơ chế round-robin"""
|
48 |
+
with self.lock:
|
49 |
+
client = self.clients[self.current_index]
|
50 |
+
# Cập nhật thống kê
|
51 |
+
self.usage_stats[self.current_index] += 1
|
52 |
+
# Di chuyển đến key tiếp theo
|
53 |
+
self.current_index = (self.current_index + 1) % len(self.clients)
|
54 |
+
return client
|
55 |
+
|
56 |
+
def print_usage_stats(self):
|
57 |
+
"""In thống kê sử dụng của từng API key"""
|
58 |
+
with self.lock:
|
59 |
+
print("\n--- Thống kê sử dụng Groq API keys ---")
|
60 |
+
total_calls = sum(self.usage_stats.values())
|
61 |
+
for idx, count in self.usage_stats.items():
|
62 |
+
key_preview = f"{self.api_keys[idx][:8]}..." if len(self.api_keys[idx]) > 10 else self.api_keys[idx]
|
63 |
+
percentage = (count / total_calls * 100) if total_calls > 0 else 0
|
64 |
+
print(f"Key {idx+1} ({key_preview}): {count} lần gọi ({percentage:.1f}%)")
|
65 |
+
print(f"Tổng số lần gọi API: {total_calls}")
|
66 |
+
print("---------------------------------------\n")
|
67 |
+
|
68 |
+
# Khởi tạo singleton manager
|
69 |
+
groq_client_manager = GroqClientManager(GROQ_API_KEYS_STR)
|
70 |
+
|
71 |
+
# Giữ lại một client đơn cho compatibility
|
72 |
+
groq_client = Groq(api_key=groq_client_manager.api_keys[0] if groq_client_manager.api_keys else "")
|
73 |
+
|
74 |
+
MAX_ARTICLES = 30 # 5 for testing, 30 for production
|
75 |
+
|
76 |
+
# Các API keys
|
77 |
+
NEWS_API_KEY = os.getenv("NEWS_API_KEY")
|
78 |
+
MARKETAUX_API_KEY = os.getenv("MARKETAUX_API_KEY")
|
79 |
+
ALPHA_VANTAGE_API_KEY = os.getenv("ALPHA_VANTAGE_API_KEY")
|
80 |
+
|
81 |
+
# Cấu hình kiểm soát tốc độ gọi API
|
82 |
+
MAX_REQUESTS_PER_MINUTE = 10 # Để an toàn, giữ dưới 10
|
83 |
+
BATCH_SIZE = 10 # Số lượng bài báo được xử lý cùng lúc
|
84 |
+
DELAY_BETWEEN_BATCHES = 10 # Thời gian chờ giữa các batch (giây)
|
85 |
+
RETRY_DELAY_BASE = 5 # Thời gian cơ sở cho retry (giây)
|
86 |
+
MAX_RETRIES = 3 # Số lần thử lại tối đa
|
87 |
+
|
88 |
+
# Cấu hình riêng cho Groq API
|
89 |
+
GROQ_BATCH_SIZE = 10 # Số lượng bài báo được xử lý cùng lúc khi dùng Groq (cao hơn Gemini)
|
90 |
+
GROQ_DELAY_BETWEEN_REQUESTS = 2 # Thời gian chờ giữa các request riêng lẻ với Groq (giây)
|
91 |
+
GROQ_DELAY_BETWEEN_BATCHES = 5 # Thời gian chờ giữa các batch với Groq (giây)
|
92 |
+
|
93 |
+
class NewsArticle:
|
94 |
+
"""Lớp tiêu chuẩn hóa cho các bài báo từ các nguồn khác nhau"""
|
95 |
+
def __init__(self, title, description, content, source_name, url, published_at):
|
96 |
+
self.title = title
|
97 |
+
self.description = description
|
98 |
+
self.content = content
|
99 |
+
self.source_name = source_name
|
100 |
+
self.url = url
|
101 |
+
self.published_at = published_at
|
102 |
+
|
103 |
+
def __str__(self):
|
104 |
+
return f"{self.title} ({self.source_name})"
|
105 |
+
|
106 |
+
def __eq__(self, other):
|
107 |
+
if not isinstance(other, NewsArticle):
|
108 |
+
return False
|
109 |
+
# So sánh bằng URL hoặc tiêu đề
|
110 |
+
return self.url == other.url or self.title == other.title
|
111 |
+
|
112 |
+
def __hash__(self):
|
113 |
+
# Sử dụng URL làm hash
|
114 |
+
return hash(self.url)
|
115 |
+
|
116 |
+
async def fetch_from_newsapi():
|
117 |
+
"""Lấy tin tức từ NewsAPI"""
|
118 |
+
url = "https://newsapi.org/v2/everything"
|
119 |
+
yesterday = datetime.now() - timedelta(days=1)
|
120 |
+
yesterday_str = yesterday.strftime('%Y-%m-%d')
|
121 |
+
|
122 |
+
params = {
|
123 |
+
'q': 'finance OR economy OR stock market OR investing',
|
124 |
+
'from': yesterday_str,
|
125 |
+
'language': 'en',
|
126 |
+
'sortBy': 'publishedAt',
|
127 |
+
'pageSize': 50,
|
128 |
+
'apiKey': NEWS_API_KEY
|
129 |
+
}
|
130 |
+
|
131 |
+
async with aiohttp.ClientSession() as session:
|
132 |
+
async with session.get(url, params=params) as response:
|
133 |
+
if response.status == 200:
|
134 |
+
data = await response.json()
|
135 |
+
articles = []
|
136 |
+
if data.get('status') == 'ok' and 'articles' in data:
|
137 |
+
for article in data['articles']:
|
138 |
+
articles.append(
|
139 |
+
NewsArticle(
|
140 |
+
title=article.get('title', ''),
|
141 |
+
description=article.get('description', ''),
|
142 |
+
content=article.get('content', ''),
|
143 |
+
source_name=article.get('source', {}).get('name', 'Unknown'),
|
144 |
+
url=article.get('url', ''),
|
145 |
+
published_at=article.get('publishedAt', '')
|
146 |
+
)
|
147 |
+
)
|
148 |
+
return articles
|
149 |
+
return []
|
150 |
+
|
151 |
+
async def fetch_from_marketaux():
|
152 |
+
"""Lấy tin tức từ Marketaux API"""
|
153 |
+
url = "https://api.marketaux.com/v1/news/all"
|
154 |
+
|
155 |
+
params = {
|
156 |
+
'symbols': 'AAPL,MSFT,TSLA,AMZN,NVDA,META', # Một số mã chứng khoán phổ biến
|
157 |
+
'filter_entities': 'true',
|
158 |
+
'language': 'en',
|
159 |
+
'published_after': (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%dT%H:%M'),
|
160 |
+
'limit': 50,
|
161 |
+
'api_token': MARKETAUX_API_KEY
|
162 |
+
}
|
163 |
+
|
164 |
+
async with aiohttp.ClientSession() as session:
|
165 |
+
async with session.get(url, params=params) as response:
|
166 |
+
if response.status == 200:
|
167 |
+
data = await response.json()
|
168 |
+
articles = []
|
169 |
+
if 'data' in data:
|
170 |
+
for article in data['data']:
|
171 |
+
articles.append(
|
172 |
+
NewsArticle(
|
173 |
+
title=article.get('title', ''),
|
174 |
+
description=article.get('description', ''),
|
175 |
+
content=article.get('snippet', ''),
|
176 |
+
source_name=article.get('source', ''),
|
177 |
+
url=article.get('url', ''),
|
178 |
+
published_at=article.get('published_at', '')
|
179 |
+
)
|
180 |
+
)
|
181 |
+
return articles
|
182 |
+
return []
|
183 |
+
|
184 |
+
def _normalize_and_deduplicate(articles_list):
|
185 |
+
"""Chuẩn hóa và loại bỏ các bài báo trùng lặp"""
|
186 |
+
# Loại bỏ trùng lặp bằng cách chuyển thành set
|
187 |
+
unique_articles = set()
|
188 |
+
|
189 |
+
for article in articles_list:
|
190 |
+
if article.title and article.content: # Bỏ qua bài viết không có tiêu đề hoặc nội dung
|
191 |
+
# Loại bỏ các bài quảng cáo và không liên quan
|
192 |
+
skip_keywords = ["advertisement", "sponsored", "promotion"]
|
193 |
+
if not any(keyword in article.title.lower() for keyword in skip_keywords):
|
194 |
+
unique_articles.add(article)
|
195 |
+
|
196 |
+
# Sắp xếp theo thời gian xuất bản (mới nhất trước)
|
197 |
+
sorted_articles = sorted(
|
198 |
+
unique_articles,
|
199 |
+
key=lambda x: datetime.fromisoformat(x.published_at.replace('Z', '+00:00').replace('T', ' ')),
|
200 |
+
reverse=True
|
201 |
+
)
|
202 |
+
|
203 |
+
# Giới hạn số lượng bài viết
|
204 |
+
max_articles = min(len(sorted_articles), MAX_ARTICLES) # Giảm số lượng bài từ 30 xuống 20
|
205 |
+
return sorted_articles[:max_articles]
|
206 |
+
|
207 |
+
async def _call_ai_with_retry(prompt, model, retries=MAX_RETRIES):
|
208 |
+
"""Gọi Gemini API với cơ chế retry và exponential backoff"""
|
209 |
+
attempt = 0
|
210 |
+
last_exception = None
|
211 |
+
|
212 |
+
while attempt <= retries:
|
213 |
+
try:
|
214 |
+
# Thêm jitter vào delay để tránh đồng bộ hóa các yêu cầu
|
215 |
+
jitter = random.uniform(0.5, 1.5)
|
216 |
+
|
217 |
+
if attempt > 0:
|
218 |
+
# Exponential backoff với jitter
|
219 |
+
delay = (RETRY_DELAY_BASE * (2 ** (attempt - 1))) * jitter
|
220 |
+
print(f"Retry lần {attempt}, đợi {delay:.2f} giây...")
|
221 |
+
await asyncio.sleep(delay)
|
222 |
+
|
223 |
+
response = await model.generate_content_async(prompt)
|
224 |
+
return response.text
|
225 |
+
|
226 |
+
except Exception as e:
|
227 |
+
last_exception = e
|
228 |
+
print(f"Lỗi khi gọi AI (lần {attempt+1}/{retries+1}): {str(e)}")
|
229 |
+
|
230 |
+
# Nếu đây là lỗi quota, thêm thời gian chờ dài hơn
|
231 |
+
if "429" in str(e) or "quota" in str(e).lower():
|
232 |
+
# Thêm thời gian chờ dài hơn cho lỗi quota (60-90 giây)
|
233 |
+
quota_delay = random.uniform(60, 90)
|
234 |
+
print(f"Phát hiện lỗi quota, đợi {quota_delay:.2f} giây...")
|
235 |
+
await asyncio.sleep(quota_delay)
|
236 |
+
|
237 |
+
attempt += 1
|
238 |
+
|
239 |
+
# Nếu đã hết số lần thử lại, ném ngoại lệ
|
240 |
+
if last_exception:
|
241 |
+
raise last_exception
|
242 |
+
else:
|
243 |
+
raise Exception("Không thể gọi API Gemini sau nhiều lần thử lại")
|
244 |
+
|
245 |
+
async def _call_groq_with_retry(prompt, retries=MAX_RETRIES):
|
246 |
+
"""Gọi Groq API với cơ chế retry và round-robin API keys"""
|
247 |
+
attempt = 0
|
248 |
+
last_exception = None
|
249 |
+
|
250 |
+
while attempt <= retries:
|
251 |
+
try:
|
252 |
+
# Thêm jitter vào delay để tránh đồng bộ hóa các yêu cầu
|
253 |
+
jitter = random.uniform(0.5, 1.5)
|
254 |
+
|
255 |
+
if attempt > 0:
|
256 |
+
# Exponential backoff với jitter
|
257 |
+
delay = (RETRY_DELAY_BASE * (2 ** (attempt - 1))) * jitter
|
258 |
+
print(f"Retry Groq lần {attempt}, đợi {delay:.2f} giây...")
|
259 |
+
await asyncio.sleep(delay)
|
260 |
+
|
261 |
+
# Lấy client tiếp theo từ round-robin pool
|
262 |
+
client = groq_client_manager.get_next_client()
|
263 |
+
|
264 |
+
response = await client.chat.completions.create(
|
265 |
+
model=GROQ_MODEL,
|
266 |
+
messages=[
|
267 |
+
{"role": "user", "content": prompt}
|
268 |
+
],
|
269 |
+
temperature=0.3, # Thấp hơn để tóm tắt chính xác
|
270 |
+
max_tokens=500, # Giới hạn độ dài tóm tắt
|
271 |
+
top_p=0.95,
|
272 |
+
stream=False
|
273 |
+
)
|
274 |
+
|
275 |
+
return response.choices[0].message.content
|
276 |
+
|
277 |
+
except Exception as e:
|
278 |
+
last_exception = e
|
279 |
+
print(f"Lỗi khi gọi Groq API (lần {attempt+1}/{retries+1}): {str(e)}")
|
280 |
+
|
281 |
+
# Nếu đây là lỗi rate limit hoặc quota
|
282 |
+
if "429" in str(e) or "rate" in str(e).lower() or "limit" in str(e).lower():
|
283 |
+
# Thêm thời gian chờ dài hơn
|
284 |
+
rate_limit_delay = random.uniform(30, 45)
|
285 |
+
print(f"Phát hiện lỗi rate limit, đợi {rate_limit_delay:.2f} giây...")
|
286 |
+
await asyncio.sleep(rate_limit_delay)
|
287 |
+
|
288 |
+
attempt += 1
|
289 |
+
|
290 |
+
# Nếu đã hết số lần thử lại, ném ngoại lệ
|
291 |
+
if last_exception:
|
292 |
+
raise last_exception
|
293 |
+
else:
|
294 |
+
raise Exception("Không thể gọi API Groq sau nhiều lần thử lại")
|
295 |
+
|
296 |
+
async def _summarize_article_with_groq(article):
|
297 |
+
"""Tạo tóm tắt cho một bài báo sử dụng Groq API"""
|
298 |
+
prompt = f"""
|
299 |
+
Summarize the most important information from this financial article in a concise paragraph (no more than 2-3 sentences).
|
300 |
+
Focus on key events, figures, trends, or information valuable to investors.
|
301 |
+
Provide only the summary without any introduction or conclusion.
|
302 |
+
|
303 |
+
TITLE: {article.title}
|
304 |
+
DESCRIPTION: {article.description}
|
305 |
+
CONTENT: {article.content}
|
306 |
+
SOURCE: {article.source_name}
|
307 |
+
"""
|
308 |
+
|
309 |
+
try:
|
310 |
+
# Sử dụng hàm gọi Groq API có retry
|
311 |
+
summary = await _call_groq_with_retry(prompt)
|
312 |
+
|
313 |
+
# Loại bỏ các ký tự đặc biệt và chuẩn hóa
|
314 |
+
summary = re.sub(r'[\n\r]+', ' ', summary)
|
315 |
+
summary = re.sub(r'\s{2,}', ' ', summary).strip()
|
316 |
+
|
317 |
+
return {
|
318 |
+
'title': article.title,
|
319 |
+
'source': article.source_name,
|
320 |
+
'summary': summary,
|
321 |
+
'url': article.url
|
322 |
+
}
|
323 |
+
except Exception as e:
|
324 |
+
print(f"Lỗi khi tóm tắt bài báo với Groq: {str(e)}")
|
325 |
+
return {
|
326 |
+
'title': article.title,
|
327 |
+
'source': article.source_name,
|
328 |
+
'summary': "Unable to summarize this article due to API limitations.",
|
329 |
+
'url': article.url
|
330 |
+
}
|
331 |
+
|
332 |
+
async def _summarize_article(article, model):
|
333 |
+
"""Tạo tóm tắt cho một bài báo với Gemini (giữ lại để dự phòng)"""
|
334 |
+
prompt = f"""
|
335 |
+
Summarize the most important information from this financial article in a concise paragraph (no more than 2-3 sentences).
|
336 |
+
Focus on key events, figures, trends, or information valuable to investors.
|
337 |
+
Provide only the summary without any introduction or conclusion.
|
338 |
+
|
339 |
+
TITLE: {article.title}
|
340 |
+
DESCRIPTION: {article.description}
|
341 |
+
CONTENT: {article.content}
|
342 |
+
SOURCE: {article.source_name}
|
343 |
+
URL: {article.url}
|
344 |
+
"""
|
345 |
+
|
346 |
+
try:
|
347 |
+
# Sử dụng hàm gọi API có retry
|
348 |
+
summary = await _call_ai_with_retry(prompt, model)
|
349 |
+
|
350 |
+
# Loại bỏ các ký tự đặc biệt và chuẩn hóa
|
351 |
+
summary = re.sub(r'[\n\r]+', ' ', summary)
|
352 |
+
summary = re.sub(r'\s{2,}', ' ', summary).strip()
|
353 |
+
|
354 |
+
return {
|
355 |
+
'title': article.title,
|
356 |
+
'source': article.source_name,
|
357 |
+
'summary': summary,
|
358 |
+
'url': article.url
|
359 |
+
}
|
360 |
+
except Exception as e:
|
361 |
+
print(f"Lỗi khi tóm tắt bài báo với Gemini: {str(e)}")
|
362 |
+
return {
|
363 |
+
'title': article.title,
|
364 |
+
'source': article.source_name,
|
365 |
+
'summary': "Unable to summarize this article due to API limitations.",
|
366 |
+
'url': article.url
|
367 |
+
}
|
368 |
+
|
369 |
+
async def _summarize_articles_with_groq(articles):
|
370 |
+
"""Tóm tắt các bài báo bằng Groq API"""
|
371 |
+
all_summaries = []
|
372 |
+
|
373 |
+
# Chia bài viết thành các batch nhỏ
|
374 |
+
for i in range(0, len(articles), GROQ_BATCH_SIZE):
|
375 |
+
batch = articles[i:i+GROQ_BATCH_SIZE]
|
376 |
+
print(f"Đang xử lý batch {i//GROQ_BATCH_SIZE + 1}/{(len(articles) + GROQ_BATCH_SIZE - 1)//GROQ_BATCH_SIZE} ({len(batch)} bài) với Groq")
|
377 |
+
|
378 |
+
# Tạo danh sách các coroutines để xử lý song song
|
379 |
+
tasks = [_summarize_article_with_groq(article) for article in batch]
|
380 |
+
|
381 |
+
# Chờ tất cả các task hoàn thành
|
382 |
+
batch_summaries = await asyncio.gather(*tasks)
|
383 |
+
all_summaries.extend(batch_summaries)
|
384 |
+
|
385 |
+
# Nếu còn batch tiếp theo, đợi để tránh vượt quá quota
|
386 |
+
if i + GROQ_BATCH_SIZE < len(articles):
|
387 |
+
wait_time = random.uniform(GROQ_DELAY_BETWEEN_BATCHES * 0.9, GROQ_DELAY_BETWEEN_BATCHES * 1.1)
|
388 |
+
print(f"Hoàn thành batch. Đang đợi {wait_time:.2f}s trước batch tiếp theo để tránh quá tải quota...")
|
389 |
+
await asyncio.sleep(wait_time)
|
390 |
+
|
391 |
+
# In thống kê sử dụng API keys
|
392 |
+
groq_client_manager.print_usage_stats()
|
393 |
+
|
394 |
+
return all_summaries
|
395 |
+
|
396 |
+
async def _summarize_articles_in_batches(articles, model):
|
397 |
+
"""Tóm tắt các bài báo theo batch với Gemini API (giữ lại để dự phòng)"""
|
398 |
+
all_summaries = []
|
399 |
+
|
400 |
+
# Chia bài viết thành các batch nhỏ
|
401 |
+
for i in range(0, len(articles), BATCH_SIZE):
|
402 |
+
batch = articles[i:i+BATCH_SIZE]
|
403 |
+
print(f"Đang xử lý batch {i//BATCH_SIZE + 1}/{(len(articles) + BATCH_SIZE - 1)//BATCH_SIZE} ({len(batch)} bài)")
|
404 |
+
|
405 |
+
# Xử lý các bài trong batch một cách tuần tự để tránh quá tải API
|
406 |
+
batch_summaries = []
|
407 |
+
for article in batch:
|
408 |
+
# Thêm jitter vào delay để tránh đồng bộ hóa các yêu cầu
|
409 |
+
delay = random.uniform(1.0, 3.0)
|
410 |
+
await asyncio.sleep(delay)
|
411 |
+
|
412 |
+
summary = await _summarize_article(article, model)
|
413 |
+
batch_summaries.append(summary)
|
414 |
+
|
415 |
+
all_summaries.extend(batch_summaries)
|
416 |
+
|
417 |
+
# Nếu còn batch tiếp theo, đợi để tránh vượt quá quota
|
418 |
+
if i + BATCH_SIZE < len(articles):
|
419 |
+
wait_time = random.uniform(DELAY_BETWEEN_BATCHES * 0.9, DELAY_BETWEEN_BATCHES * 1.1)
|
420 |
+
print(f"Hoàn thành batch. Đang đợi {wait_time:.2f}s trước batch tiếp theo để tránh quá tải quota...")
|
421 |
+
await asyncio.sleep(wait_time)
|
422 |
+
|
423 |
+
return all_summaries
|
424 |
+
|
425 |
+
async def _synthesize_newsletter(summaries, model):
|
426 |
+
"""Reduce phase: Tổng hợp các tóm tắt thành một bản tin hoàn chỉnh sử dụng Gemini API"""
|
427 |
+
# Giới hạn số lượng tóm tắt để đảm bảo không vượt quá token limit
|
428 |
+
# if len(summaries) > 15:
|
429 |
+
# print(f"Giới hạn số lượng tóm tắt từ {len(summaries)} xuống 15 để phù hợp với giới hạn token")
|
430 |
+
# summaries = summaries[:15]
|
431 |
+
|
432 |
+
# Chuẩn bị dữ liệu đầu vào cho prompt
|
433 |
+
summaries_text = ""
|
434 |
+
for i, s in enumerate(summaries, 1):
|
435 |
+
summaries_text += f"{i}. **{s['title']}** ({s['source']}): {s['summary']} [Link]({s['url']})\n\n"
|
436 |
+
|
437 |
+
# Prompt để tạo bản tin
|
438 |
+
prompt = f"""
|
439 |
+
Below are summaries from {len(summaries)} financial articles published in the last 24 hours:
|
440 |
+
|
441 |
+
{summaries_text}
|
442 |
+
|
443 |
+
Write a comprehensive market report based on these summaries. The report should:
|
444 |
+
|
445 |
+
1. Have an overall headline for the entire report
|
446 |
+
2. Be organized into clear sections by topic, such as:
|
447 |
+
- Macroeconomic Developments
|
448 |
+
- Corporate News
|
449 |
+
- Stock Market Performance
|
450 |
+
- Cryptocurrency and Fintech
|
451 |
+
- Expert Opinions and Forecasts
|
452 |
+
3. Each section should have at least 3-5 concise news points, with sources cited
|
453 |
+
4. End with a brief conclusion about the overall market trends
|
454 |
+
|
455 |
+
Format the report in Markdown. Ensure clear numbering and proper Markdown syntax.
|
456 |
+
"""
|
457 |
+
|
458 |
+
try:
|
459 |
+
# Thêm jitter vào delay trước khi gọi API tổng hợp
|
460 |
+
await asyncio.sleep(random.uniform(3.0, 5.0))
|
461 |
+
|
462 |
+
# Sử dụng hàm gọi API có retry
|
463 |
+
newsletter = await _call_ai_with_retry(prompt, model)
|
464 |
+
|
465 |
+
# Thêm phần thông tin về ngày tạo
|
466 |
+
today = datetime.now().strftime("%d/%m/%Y")
|
467 |
+
newsletter = f"# DAILY MARKET REPORT - {today}\n\n" + newsletter
|
468 |
+
|
469 |
+
# Thêm phần footer
|
470 |
+
footer = """
|
471 |
+
---
|
472 |
+
|
473 |
+
*This report was automatically generated by AI Financial Dashboard based on data from multiple reliable financial news sources.*
|
474 |
+
*Note: This is not investment advice.*
|
475 |
+
"""
|
476 |
+
newsletter += footer
|
477 |
+
|
478 |
+
return newsletter
|
479 |
+
except Exception as e:
|
480 |
+
print(f"Lỗi khi tạo bản tin: {str(e)}")
|
481 |
+
return """# DAILY MARKET REPORT
|
482 |
+
|
483 |
+
We're sorry, but the market report could not be automatically generated due to API limitations. Please try again later.
|
484 |
+
|
485 |
+
---
|
486 |
+
|
487 |
+
*Note: The system has collected data but could not generate the report due to API quota limitations.*
|
488 |
+
"""
|
489 |
+
|
490 |
+
async def run_news_summary_pipeline():
|
491 |
+
"""Hàm chính để chạy toàn bộ pipeline tạo bản tin"""
|
492 |
+
start_time = time.time()
|
493 |
+
|
494 |
+
# 1. Khởi tạo model
|
495 |
+
model = genai.GenerativeModel(MODEL_NAME)
|
496 |
+
|
497 |
+
# 2. Thu thập tin tức từ nhiều nguồn song song
|
498 |
+
print("Đang thu thập tin tức từ các nguồn...")
|
499 |
+
tasks = [
|
500 |
+
fetch_from_newsapi(),
|
501 |
+
fetch_from_marketaux()
|
502 |
+
]
|
503 |
+
|
504 |
+
# Chờ tất cả các task hoàn thành
|
505 |
+
results = await asyncio.gather(*tasks)
|
506 |
+
|
507 |
+
# Gộp kết quả từ các nguồn
|
508 |
+
all_articles = []
|
509 |
+
for articles in results:
|
510 |
+
if articles:
|
511 |
+
all_articles.extend(articles)
|
512 |
+
|
513 |
+
print(f"Đã thu thập {len(all_articles)} bài báo từ các nguồn.")
|
514 |
+
|
515 |
+
# 3. Chuẩn hóa và loại bỏ trùng lặp
|
516 |
+
articles = _normalize_and_deduplicate(all_articles)
|
517 |
+
print(f"Sau khi lọc: {len(articles)} bài báo duy nhất.")
|
518 |
+
|
519 |
+
# 4. Tóm tắt từng bài báo sử dụng Groq API (nhanh hơn, quota cao hơn)
|
520 |
+
print("Bắt đầu tóm tắt các bài báo với Groq API...")
|
521 |
+
try:
|
522 |
+
summaries = await _summarize_articles_with_groq(articles)
|
523 |
+
print(f"Đã tóm tắt {len(summaries)} bài báo với Groq API.")
|
524 |
+
except Exception as e:
|
525 |
+
print(f"Lỗi khi sử dụng Groq API: {str(e)}. Chuyển sang sử dụng Gemini API...")
|
526 |
+
# Fallback sang Gemini nếu có lỗi với Groq
|
527 |
+
summaries = await _summarize_articles_in_batches(articles, model)
|
528 |
+
print(f"Đã tóm tắt {len(summaries)} bài báo với Gemini API.")
|
529 |
+
|
530 |
+
# 5. Tổng hợp bản tin sử dụng Gemini API (tốt hơn với việc viết nội dung dài)
|
531 |
+
print("Đang tạo bản tin tổng hợp với Gemini API...")
|
532 |
+
newsletter = await _synthesize_newsletter(summaries, model)
|
533 |
+
|
534 |
+
# Thống kê thời gian thực hiện
|
535 |
+
end_time = time.time()
|
536 |
+
duration = end_time - start_time
|
537 |
+
print(f"Pipeline hoàn tất trong {duration:.2f} giây.")
|
538 |
+
|
539 |
+
return newsletter
|
pages/Daily_News_Report.py
ADDED
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import streamlit as st
|
3 |
+
import asyncio
|
4 |
+
from datetime import datetime
|
5 |
+
import sys
|
6 |
+
import traceback
|
7 |
+
import re
|
8 |
+
from email_validator import validate_email, EmailNotValidError
|
9 |
+
|
10 |
+
# Add project root to path for imports
|
11 |
+
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
12 |
+
|
13 |
+
# Import required modules
|
14 |
+
from modules.news_pipeline import run_news_summary_pipeline
|
15 |
+
from modules.email_sender import send_report_via_email
|
16 |
+
|
17 |
+
# Page setup
|
18 |
+
st.set_page_config(
|
19 |
+
page_title="Daily Market News Report",
|
20 |
+
page_icon="📰",
|
21 |
+
layout="wide",
|
22 |
+
initial_sidebar_state="expanded"
|
23 |
+
)
|
24 |
+
|
25 |
+
# Initialize session state
|
26 |
+
if "news_report_initialized" not in st.session_state:
|
27 |
+
st.session_state.news_report_initialized = True
|
28 |
+
if "newsletter_content" not in st.session_state:
|
29 |
+
st.session_state.newsletter_content = None
|
30 |
+
if "last_generated" not in st.session_state:
|
31 |
+
st.session_state.last_generated = None
|
32 |
+
|
33 |
+
# Create main container
|
34 |
+
main_container = st.container()
|
35 |
+
|
36 |
+
with main_container:
|
37 |
+
# Page title
|
38 |
+
st.title("📰 Daily Market News Report")
|
39 |
+
|
40 |
+
# Description
|
41 |
+
st.markdown("""
|
42 |
+
This feature uses AI to automatically scan, analyze, and summarize the latest financial news from multiple reliable sources.
|
43 |
+
The newsletter is updated daily, helping you quickly grasp market developments.
|
44 |
+
""")
|
45 |
+
|
46 |
+
# Create columns for generate button and timestamp
|
47 |
+
col1, col2 = st.columns([4, 8])
|
48 |
+
|
49 |
+
with col1:
|
50 |
+
# Button to create a new newsletter
|
51 |
+
if st.button("🔄 Generate Today's Report", use_container_width=True):
|
52 |
+
# Show spinner while processing
|
53 |
+
with st.spinner("AI is analyzing news from multiple sources..."):
|
54 |
+
try:
|
55 |
+
# Call newsletter pipeline
|
56 |
+
newsletter = asyncio.run(run_news_summary_pipeline())
|
57 |
+
|
58 |
+
# Save results to session state
|
59 |
+
st.session_state.newsletter_content = newsletter
|
60 |
+
st.session_state.last_generated = datetime.now()
|
61 |
+
|
62 |
+
# Display success message
|
63 |
+
st.success("Report successfully generated!")
|
64 |
+
except Exception as e:
|
65 |
+
st.error(f"An error occurred: {str(e)}")
|
66 |
+
traceback.print_exc()
|
67 |
+
|
68 |
+
with col2:
|
69 |
+
# Display last generation time
|
70 |
+
if st.session_state.last_generated:
|
71 |
+
st.info(f"Last updated: {st.session_state.last_generated.strftime('%d/%m/%Y %H:%M')}")
|
72 |
+
|
73 |
+
# Display newsletter if available
|
74 |
+
if st.session_state.newsletter_content:
|
75 |
+
# Create tabs to display newsletter and email form
|
76 |
+
tab1, tab2 = st.tabs(["Report", "Email Report"])
|
77 |
+
|
78 |
+
with tab1:
|
79 |
+
# Display newsletter content
|
80 |
+
st.markdown(st.session_state.newsletter_content)
|
81 |
+
|
82 |
+
with tab2:
|
83 |
+
# Form to send email
|
84 |
+
st.markdown("### Send Report via Email")
|
85 |
+
|
86 |
+
with st.form(key="email_form"):
|
87 |
+
# Email input
|
88 |
+
email = st.text_input("Enter email address to receive the report")
|
89 |
+
|
90 |
+
# Submit button
|
91 |
+
submit_button = st.form_submit_button(label="📩 Send Report via Email")
|
92 |
+
|
93 |
+
if submit_button:
|
94 |
+
# Validate email
|
95 |
+
if not email:
|
96 |
+
st.error("Please enter an email address.")
|
97 |
+
else:
|
98 |
+
try:
|
99 |
+
# Validate email format
|
100 |
+
validate_email(email)
|
101 |
+
|
102 |
+
# Show spinner while sending
|
103 |
+
with st.spinner("Sending email..."):
|
104 |
+
# Call send email function
|
105 |
+
success, message = send_report_via_email(
|
106 |
+
st.session_state.newsletter_content,
|
107 |
+
email
|
108 |
+
)
|
109 |
+
|
110 |
+
# Display result
|
111 |
+
if success:
|
112 |
+
st.success(message)
|
113 |
+
else:
|
114 |
+
st.error(message)
|
115 |
+
except EmailNotValidError:
|
116 |
+
st.error("Invalid email address.")
|
117 |
+
except Exception as e:
|
118 |
+
st.error(f"An error occurred: {str(e)}")
|
119 |
+
|
120 |
+
# Add privacy note
|
121 |
+
st.info("📝 **Note:** Your email is only used to send this report and is not stored.")
|
122 |
+
else:
|
123 |
+
# Display note if no newsletter available
|
124 |
+
st.info("👆 Click 'Generate Today's Report' to start analyzing the news.")
|
125 |
+
|
126 |
+
# Add explanation about process
|
127 |
+
with st.expander("How it works"):
|
128 |
+
st.markdown("""
|
129 |
+
### Report Generation Process:
|
130 |
+
|
131 |
+
1. **Data Collection**: The system automatically scans the latest financial news from multiple reliable sources.
|
132 |
+
|
133 |
+
2. **Analysis & Filtering**: AI removes duplicate and irrelevant news, keeping only the most important information.
|
134 |
+
|
135 |
+
3. **Summarization**: Each article is summarized into key points, helping you quickly grasp the information.
|
136 |
+
|
137 |
+
4. **Synthesis**: AI organizes information by topics and writes it into a well-structured report.
|
138 |
+
|
139 |
+
5. **Display & Share**: The final report is displayed on the web and can be sent via email upon request.
|
140 |
+
""")
|
pages/{stock_report.py → Stock_Analysis.py}
RENAMED
File without changes
|
pages/{chat_app.py → Stock_Chatbot.py}
RENAMED
@@ -13,6 +13,7 @@ from datetime import datetime
|
|
13 |
|
14 |
# --- 1. INITIAL CONFIGURATION & STATE INITIALIZATION ---
|
15 |
load_dotenv()
|
|
|
16 |
|
17 |
# Set page config consistent with other pages
|
18 |
st.set_page_config(
|
@@ -159,7 +160,7 @@ def get_model_and_tools():
|
|
159 |
get_ts_func = glm.FunctionDeclaration(name="get_smart_time_series", description="Get price history data after knowing the official stock symbol.", parameters=glm.Schema(type=glm.Type.OBJECT, properties={'symbol': glm.Schema(type=glm.Type.STRING), 'time_period': glm.Schema(type=glm.Type.STRING, enum=["intraday", "1_week", "1_month", "6_months", "1_year"])}, required=['symbol', 'time_period']))
|
160 |
currency_func = glm.FunctionDeclaration(name="perform_currency_conversion", description="Convert currency after knowing the 3-letter code of source/target currency pair, e.g., USD/VND, JPY/EUR", parameters=glm.Schema(type=glm.Type.OBJECT, properties={'amount': glm.Schema(type=glm.Type.NUMBER), 'symbol': glm.Schema(type=glm.Type.STRING)}, required=['amount', 'symbol']))
|
161 |
finance_tool = glm.Tool(function_declarations=[find_stock_func, get_ts_func, currency_func])
|
162 |
-
model = genai.GenerativeModel(model_name=
|
163 |
return model
|
164 |
model = get_model_and_tools()
|
165 |
if st.session_state.chat_session is None:
|
|
|
13 |
|
14 |
# --- 1. INITIAL CONFIGURATION & STATE INITIALIZATION ---
|
15 |
load_dotenv()
|
16 |
+
MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
17 |
|
18 |
# Set page config consistent with other pages
|
19 |
st.set_page_config(
|
|
|
160 |
get_ts_func = glm.FunctionDeclaration(name="get_smart_time_series", description="Get price history data after knowing the official stock symbol.", parameters=glm.Schema(type=glm.Type.OBJECT, properties={'symbol': glm.Schema(type=glm.Type.STRING), 'time_period': glm.Schema(type=glm.Type.STRING, enum=["intraday", "1_week", "1_month", "6_months", "1_year"])}, required=['symbol', 'time_period']))
|
161 |
currency_func = glm.FunctionDeclaration(name="perform_currency_conversion", description="Convert currency after knowing the 3-letter code of source/target currency pair, e.g., USD/VND, JPY/EUR", parameters=glm.Schema(type=glm.Type.OBJECT, properties={'amount': glm.Schema(type=glm.Type.NUMBER), 'symbol': glm.Schema(type=glm.Type.STRING)}, required=['amount', 'symbol']))
|
162 |
finance_tool = glm.Tool(function_declarations=[find_stock_func, get_ts_func, currency_func])
|
163 |
+
model = genai.GenerativeModel(model_name=MODEL_NAME, tools=[finance_tool], system_instruction=SYSTEM_INSTRUCTION)
|
164 |
return model
|
165 |
model = get_model_and_tools()
|
166 |
if st.session_state.chat_session is None:
|
requirements.txt
CHANGED
@@ -16,8 +16,13 @@ click==8.2.1
|
|
16 |
comm==0.2.3
|
17 |
cssselect2==0.8.0
|
18 |
decorator==5.2.1
|
|
|
|
|
|
|
|
|
19 |
executing==2.2.0
|
20 |
fonttools==4.59.0
|
|
|
21 |
frozenlist==1.7.0
|
22 |
gitdb==4.0.12
|
23 |
GitPython==3.1.45
|
@@ -29,6 +34,7 @@ google-auth-httplib2==0.2.0
|
|
29 |
google-genai==1.27.0
|
30 |
google-generativeai==0.8.5
|
31 |
googleapis-common-protos==1.70.0
|
|
|
32 |
grpcio==1.74.0
|
33 |
grpcio-status==1.71.2
|
34 |
h11==0.16.0
|
|
|
16 |
comm==0.2.3
|
17 |
cssselect2==0.8.0
|
18 |
decorator==5.2.1
|
19 |
+
defusedxml==0.7.1
|
20 |
+
distro==1.9.0
|
21 |
+
dnspython==2.7.0
|
22 |
+
email_validator==2.2.0
|
23 |
executing==2.2.0
|
24 |
fonttools==4.59.0
|
25 |
+
fpdf==1.7.2
|
26 |
frozenlist==1.7.0
|
27 |
gitdb==4.0.12
|
28 |
GitPython==3.1.45
|
|
|
34 |
google-genai==1.27.0
|
35 |
google-generativeai==0.8.5
|
36 |
googleapis-common-protos==1.70.0
|
37 |
+
groq==0.30.0
|
38 |
grpcio==1.74.0
|
39 |
grpcio-status==1.71.2
|
40 |
h11==0.16.0
|
test.py
ADDED
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import smtplib
|
3 |
+
import tempfile
|
4 |
+
from email.mime.multipart import MIMEMultipart
|
5 |
+
from email.mime.text import MIMEText
|
6 |
+
from email.mime.application import MIMEApplication
|
7 |
+
from dotenv import load_dotenv
|
8 |
+
from markdown_it import MarkdownIt
|
9 |
+
from datetime import datetime
|
10 |
+
import weasyprint
|
11 |
+
|
12 |
+
# Tải biến môi trường
|
13 |
+
load_dotenv()
|
14 |
+
|
15 |
+
# Thông tin email từ biến môi trường
|
16 |
+
SENDER_EMAIL = os.getenv("SENDER_EMAIL")
|
17 |
+
SENDER_APP_PASSWORD = os.getenv("SENDER_APP_PASSWORD")
|
18 |
+
|
19 |
+
def _markdown_to_html(markdown_string):
|
20 |
+
"""Chuyển đổi Markdown thành HTML"""
|
21 |
+
md = MarkdownIt()
|
22 |
+
html_content = md.render(markdown_string)
|
23 |
+
|
24 |
+
# Định dạng ngày hiện tại
|
25 |
+
current_date = datetime.now().strftime("%d/%m/%Y")
|
26 |
+
|
27 |
+
# Tạo HTML hoàn chỉnh với CSS để định dạng đẹp
|
28 |
+
full_html = f"""
|
29 |
+
<!DOCTYPE html>
|
30 |
+
<html>
|
31 |
+
<head>
|
32 |
+
<meta charset="UTF-8">
|
33 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
34 |
+
<title>Bản tin Thị trường Ngày {current_date}</title>
|
35 |
+
<style>
|
36 |
+
@page {{
|
37 |
+
size: A4;
|
38 |
+
margin: 2cm;
|
39 |
+
}}
|
40 |
+
body {{
|
41 |
+
font-family: Arial, Helvetica, sans-serif;
|
42 |
+
line-height: 1.5;
|
43 |
+
color: #333;
|
44 |
+
max-width: 800px;
|
45 |
+
margin: 0 auto;
|
46 |
+
}}
|
47 |
+
.report-header {{
|
48 |
+
text-align: center;
|
49 |
+
margin-bottom: 30px;
|
50 |
+
}}
|
51 |
+
.report-date {{
|
52 |
+
font-style: italic;
|
53 |
+
color: #666;
|
54 |
+
margin-bottom: 10px;
|
55 |
+
}}
|
56 |
+
.report-title {{
|
57 |
+
font-size: 24pt;
|
58 |
+
margin-bottom: 5px;
|
59 |
+
color: #2c3e50;
|
60 |
+
}}
|
61 |
+
.report-subtitle {{
|
62 |
+
font-size: 14pt;
|
63 |
+
color: #7f8c8d;
|
64 |
+
margin-top: 0;
|
65 |
+
}}
|
66 |
+
.report-body {{
|
67 |
+
text-align: justify;
|
68 |
+
}}
|
69 |
+
h1, h2, h3, h4, h5, h6 {{
|
70 |
+
color: #2c3e50;
|
71 |
+
margin-top: 20px;
|
72 |
+
}}
|
73 |
+
h1 {{ font-size: 20pt; }}
|
74 |
+
h2 {{ font-size: 18pt; }}
|
75 |
+
h3 {{ font-size: 16pt; }}
|
76 |
+
h4 {{ font-size: 14pt; }}
|
77 |
+
h5 {{ font-size: 12pt; }}
|
78 |
+
h6 {{ font-size: 10pt; }}
|
79 |
+
|
80 |
+
p {{
|
81 |
+
margin-bottom: 10px;
|
82 |
+
}}
|
83 |
+
|
84 |
+
a {{
|
85 |
+
color: #3498db;
|
86 |
+
text-decoration: none;
|
87 |
+
}}
|
88 |
+
|
89 |
+
a:hover {{
|
90 |
+
text-decoration: underline;
|
91 |
+
}}
|
92 |
+
|
93 |
+
ul, ol {{
|
94 |
+
margin: 10px 0 10px 20px;
|
95 |
+
}}
|
96 |
+
|
97 |
+
li {{
|
98 |
+
margin-bottom: 5px;
|
99 |
+
}}
|
100 |
+
|
101 |
+
blockquote {{
|
102 |
+
border-left: 4px solid #eee;
|
103 |
+
padding-left: 10px;
|
104 |
+
margin-left: 0;
|
105 |
+
color: #777;
|
106 |
+
}}
|
107 |
+
|
108 |
+
.section {{
|
109 |
+
margin-bottom: 30px;
|
110 |
+
}}
|
111 |
+
|
112 |
+
.footer {{
|
113 |
+
text-align: center;
|
114 |
+
margin-top: 40px;
|
115 |
+
padding-top: 20px;
|
116 |
+
font-size: 12px;
|
117 |
+
color: #777;
|
118 |
+
border-top: 1px solid #eee;
|
119 |
+
}}
|
120 |
+
|
121 |
+
/* Custom styling for bullet points */
|
122 |
+
ul {{
|
123 |
+
list-style-type: disc;
|
124 |
+
}}
|
125 |
+
ul ul {{
|
126 |
+
list-style-type: circle;
|
127 |
+
}}
|
128 |
+
ul ul ul {{
|
129 |
+
list-style-type: square;
|
130 |
+
}}
|
131 |
+
</style>
|
132 |
+
</head>
|
133 |
+
<body>
|
134 |
+
<div class="report-header">
|
135 |
+
<div class="report-date">Ngày: {current_date}</div>
|
136 |
+
<h1 class="report-title">Bản tin Thị trường</h1>
|
137 |
+
<h2 class="report-subtitle">AI Financial Dashboard</h2>
|
138 |
+
</div>
|
139 |
+
|
140 |
+
<div class="report-body">
|
141 |
+
{html_content}
|
142 |
+
</div>
|
143 |
+
|
144 |
+
<div class="footer">
|
145 |
+
Bản tin được tạo tự động bởi AI Financial Dashboard. Thông tin chỉ mang tính chất tham khảo.
|
146 |
+
</div>
|
147 |
+
</body>
|
148 |
+
</html>
|
149 |
+
"""
|
150 |
+
|
151 |
+
return full_html
|
152 |
+
|
153 |
+
def _generate_pdf_from_markdown(markdown_string):
|
154 |
+
"""Tạo PDF từ Markdown sử dụng WeasyPrint"""
|
155 |
+
# Chuyển đổi markdown sang HTML
|
156 |
+
html_content = _markdown_to_html(markdown_string)
|
157 |
+
|
158 |
+
# Tạo file HTML tạm thời
|
159 |
+
with tempfile.NamedTemporaryFile(suffix='.html', delete=False) as temp_html:
|
160 |
+
temp_html_path = temp_html.name
|
161 |
+
temp_html.write(html_content.encode('utf-8'))
|
162 |
+
|
163 |
+
# Tạo file PDF từ HTML
|
164 |
+
try:
|
165 |
+
# Tạo tên file PDF tạm thời
|
166 |
+
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as temp_pdf:
|
167 |
+
temp_pdf_path = temp_pdf.name
|
168 |
+
|
169 |
+
# Tạo PDF
|
170 |
+
weasyprint.HTML(filename=temp_html_path).write_pdf(temp_pdf_path)
|
171 |
+
|
172 |
+
# Đọc nội dung PDF
|
173 |
+
with open(temp_pdf_path, 'rb') as f:
|
174 |
+
pdf_data = f.read()
|
175 |
+
|
176 |
+
# Xóa các file tạm
|
177 |
+
os.unlink(temp_html_path)
|
178 |
+
os.unlink(temp_pdf_path)
|
179 |
+
|
180 |
+
return pdf_data
|
181 |
+
except Exception as e:
|
182 |
+
# Xử lý lỗi và đảm bảo xóa file tạm
|
183 |
+
if os.path.exists(temp_html_path):
|
184 |
+
os.unlink(temp_html_path)
|
185 |
+
raise e
|
186 |
+
|
187 |
+
def send_report_via_email(report_markdown, recipient_email):
|
188 |
+
"""Gửi báo cáo thị trường qua email"""
|
189 |
+
try:
|
190 |
+
# Tạo PDF từ markdown
|
191 |
+
pdf_data = _generate_pdf_from_markdown(report_markdown)
|
192 |
+
|
193 |
+
# Tạo message
|
194 |
+
message = MIMEMultipart()
|
195 |
+
message["From"] = SENDER_EMAIL
|
196 |
+
message["To"] = recipient_email
|
197 |
+
message["Subject"] = f"AI Financial Dashboard - Bản tin Thị trường {datetime.now().strftime('%d/%m/%Y')}"
|
198 |
+
|
199 |
+
# Thêm phần nội dung với encoding UTF-8
|
200 |
+
body = """
|
201 |
+
Kính gửi Quý khách,
|
202 |
+
|
203 |
+
Đính kèm là bản tin thị trường tài chính hôm nay, được tổng hợp tự động bởi AI Financial Dashboard.
|
204 |
+
|
205 |
+
Trân trọng,
|
206 |
+
AI Financial Dashboard Team
|
207 |
+
"""
|
208 |
+
message.attach(MIMEText(body, "plain", "utf-8"))
|
209 |
+
|
210 |
+
# Đính kèm file PDF
|
211 |
+
attachment = MIMEApplication(pdf_data, _subtype="pdf")
|
212 |
+
attachment.add_header(
|
213 |
+
"Content-Disposition", "attachment",
|
214 |
+
filename=f"Market_Report_{datetime.now().strftime('%Y%m%d')}.pdf"
|
215 |
+
)
|
216 |
+
message.attach(attachment)
|
217 |
+
|
218 |
+
# Kết nối đến server SMTP và gửi email
|
219 |
+
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
|
220 |
+
server.login(SENDER_EMAIL, SENDER_APP_PASSWORD)
|
221 |
+
server.send_message(message)
|
222 |
+
|
223 |
+
return True, "Email đã được gửi thành công!"
|
224 |
+
|
225 |
+
except Exception as e:
|
226 |
+
return False, f"Lỗi khi gửi email: {str(e)}"
|
227 |
+
|
228 |
+
if __name__ == "__main__":
|
229 |
+
report_markdown = """
|
230 |
+
# Bản tin Thị trường
|
231 |
+
## Ngày 29/07/2025
|
232 |
+
- Cổ phiếu A tăng 10%
|
233 |
+
- Cổ phiếu B giảm 5%
|
234 |
+
"""
|
235 |
+
send_report_via_email(report_markdown, "[email protected]")
|