Spaces:
Runtime error
Runtime error
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. **📄
|
| 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, "ctruongtan31070901@gmail.com")
|