Skip to content

Commit e55a88a

Browse files
committed
修改了公式渲染、LLM限额问题、音频下载等一系列问题
1 parent 3d05f2b commit e55a88a

6 files changed

Lines changed: 302 additions & 110 deletions

File tree

main.py

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"""iCourse Subscriber — main orchestration.
22
3-
Runs a single check: login → detect new lectures → download → transcribe
3+
Runs a single check: login → detect new lectures → stream audio → transcribe
44
→ summarize → email. Designed to be triggered by GitHub Actions cron.
55
"""
66

7-
import os
87
import time
98
import traceback
109

@@ -28,6 +27,9 @@ def process_lecture(
2827
) -> str | None:
2928
"""Download, transcribe, and summarize a single lecture.
3029
30+
Supports stage-skipping: if a previous run already produced a transcript
31+
or summary, that stage is not repeated.
32+
3133
Returns the summary string, or None if no summary was produced.
3234
"""
3335
sub_id = str(lecture["sub_id"])
@@ -37,41 +39,50 @@ def process_lecture(
3739
print(f"\n -- Processing: {sub_title} ({date})")
3840
t_start = time.time()
3941

40-
# 1) Download video
41-
video_url = client.get_video_url(course_id, sub_id)
42-
if not video_url:
43-
print(f" No video URL for {sub_id}, skipping.")
44-
return None
45-
46-
video_dir = os.path.join(config.VIDEO_DIR, course_id)
47-
video_path = os.path.join(video_dir, f"{sub_id}.mp4")
48-
os.makedirs(video_dir, exist_ok=True)
42+
# Check existing progress for stage-skipping
43+
existing = db.get_lecture(sub_id)
44+
has_transcript = existing and existing.get("transcript")
45+
has_summary = existing and existing.get("summary")
46+
47+
# 1) Transcribe (stream audio directly from CDN — no video download)
48+
if has_transcript:
49+
print(f" Transcript exists, skipping transcription.")
50+
transcript = existing["transcript"]
51+
else:
52+
video_url = client.get_video_url(course_id, sub_id)
53+
if not video_url:
54+
print(f" No video URL for {sub_id}, skipping.")
55+
return None
4956

50-
if not os.path.exists(video_path):
51-
print(f" Downloading video...")
52-
client.download_video(video_url, video_path)
53-
54-
# 2) Transcribe via ffmpeg pipe + SenseVoice
55-
print(f" Transcribing...")
56-
transcript = transcriber.transcribe_video(video_path)
57-
db.update_transcript(sub_id, transcript)
58-
59-
# 3) Delete video to save disk
60-
if os.path.exists(video_path):
61-
os.remove(video_path)
62-
print(f" Video deleted.")
57+
try:
58+
print(f" Streaming audio & transcribing...")
59+
transcript = transcriber.transcribe_url(video_url)
60+
db.update_transcript(sub_id, transcript)
61+
except Exception as e:
62+
db.update_error(sub_id, "transcribe", str(e))
63+
raise
6364

64-
# 4) Summarize
65+
# 2) Summarize
6566
if not transcript.strip():
6667
print(f" Empty transcript, skipping summary.")
6768
db.mark_processed(sub_id)
69+
db.clear_error(sub_id)
6870
return None
6971

70-
print(f" Generating summary...")
71-
summary = summarizer.summarize(course_title, transcript)
72-
db.update_summary(sub_id, summary)
72+
if has_summary:
73+
print(f" Summary exists, skipping summarization.")
74+
summary = existing["summary"]
75+
else:
76+
try:
77+
print(f" Generating summary...")
78+
summary, model_used = summarizer.summarize(course_title, transcript)
79+
db.update_summary_with_model(sub_id, summary, model_used)
80+
except Exception as e:
81+
db.update_error(sub_id, "summarize", str(e))
82+
raise
7383

7484
db.mark_processed(sub_id)
85+
db.clear_error(sub_id)
7586
elapsed = time.time() - t_start
7687
print(f" Done: {sub_title} (total {elapsed:.0f}s)")
7788
return summary
@@ -191,12 +202,29 @@ def run():
191202
print(f" ERROR processing course {course_id}:")
192203
traceback.print_exc()
193204

205+
# Recover any previously processed-but-unsent lectures
206+
unsent = db.get_unsent_lectures()
207+
if unsent:
208+
seen_sub_ids = {item["sub_id"] for item in email_items}
209+
for row in unsent:
210+
if row["sub_id"] not in seen_sub_ids:
211+
email_items.append({
212+
"sub_id": row["sub_id"],
213+
"course_title": row["course_title"],
214+
"sub_title": row["sub_title"],
215+
"date": row["date"],
216+
"summary": row["summary"],
217+
})
218+
print(f"[Email] Including {len(unsent)} previously unsent lecture(s).")
219+
194220
# Send one email with all summaries
195221
if emailer and email_items:
196222
try:
197223
print(f"\n[Email] Sending summary for {len(email_items)} lecture(s)...")
198-
emailer.send(email_items)
199-
db.mark_emailed_batch([item["sub_id"] for item in email_items])
224+
if emailer.send(email_items):
225+
db.mark_emailed_batch([item["sub_id"] for item in email_items])
226+
else:
227+
print("[Email] Send failed, lectures will be retried next run.")
200228
except Exception:
201229
print("[Email] Failed to send:")
202230
traceback.print_exc()

src/config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
# LLM (ModelScope OpenAI-compatible API)
2323
DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
2424
LLM_BASE_URL = "https://api-inference.modelscope.cn/v1/"
25-
LLM_MODEL = "ZhipuAI/GLM-5"
25+
LLM_MODELS = [
26+
"ZhipuAI/GLM-5",
27+
"MiniMax/MiniMax-M2.5",
28+
"deepseek-ai/DeepSeek-V3.2",
29+
"ZhipuAI/GLM-4.7",
30+
]
2631

2732
# QQ SMTP
2833
SMTP_EMAIL = os.environ.get("SMTP_EMAIL", "")

src/database.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ def _init_tables(self):
3939
FOREIGN KEY (course_id) REFERENCES courses(course_id)
4040
)
4141
""")
42+
# Migrate: add error tracking and summary_model columns
43+
existing = {
44+
row[1]
45+
for row in self.conn.execute("PRAGMA table_info(lectures)").fetchall()
46+
}
47+
for col, typedef in [
48+
("error_msg", "TEXT"),
49+
("error_count", "INTEGER DEFAULT 0"),
50+
("error_stage", "TEXT"),
51+
("summary_model", "TEXT"),
52+
]:
53+
if col not in existing:
54+
self.conn.execute(f"ALTER TABLE lectures ADD COLUMN {col} {typedef}")
4255

4356
def upsert_course(self, course_id: str, title: str, teacher: str):
4457
with self.conn:
@@ -120,3 +133,51 @@ def mark_emailed_batch(self, sub_ids: list[str]):
120133
"UPDATE lectures SET emailed_at = ? WHERE sub_id = ?",
121134
[(now, sid) for sid in sub_ids],
122135
)
136+
137+
def update_error(self, sub_id: str, stage: str, error_msg: str):
138+
"""Record a processing error for a lecture."""
139+
with self.conn:
140+
self.conn.execute(
141+
"""UPDATE lectures
142+
SET error_stage = ?, error_msg = ?,
143+
error_count = COALESCE(error_count, 0) + 1
144+
WHERE sub_id = ?""",
145+
(stage, error_msg, sub_id),
146+
)
147+
148+
def clear_error(self, sub_id: str):
149+
"""Clear error state after successful processing."""
150+
with self.conn:
151+
self.conn.execute(
152+
"""UPDATE lectures
153+
SET error_stage = NULL, error_msg = NULL, error_count = 0
154+
WHERE sub_id = ?""",
155+
(sub_id,),
156+
)
157+
158+
def update_summary_with_model(self, sub_id: str, summary: str, model: str):
159+
"""Save summary and the model that produced it."""
160+
with self.conn:
161+
self.conn.execute(
162+
"UPDATE lectures SET summary = ?, summary_model = ? WHERE sub_id = ?",
163+
(summary, model, sub_id),
164+
)
165+
166+
def get_lecture(self, sub_id: str) -> dict | None:
167+
"""Get a single lecture row by sub_id."""
168+
row = self.conn.execute(
169+
"SELECT * FROM lectures WHERE sub_id = ?", (sub_id,)
170+
).fetchone()
171+
return dict(row) if row else None
172+
173+
def get_unsent_lectures(self) -> list[dict]:
174+
"""Find lectures that are processed but not yet emailed."""
175+
rows = self.conn.execute(
176+
"""SELECT l.*, c.title AS course_title, c.teacher
177+
FROM lectures l
178+
JOIN courses c ON l.course_id = c.course_id
179+
WHERE l.processed_at IS NOT NULL
180+
AND l.emailed_at IS NULL
181+
AND l.summary IS NOT NULL""",
182+
).fetchall()
183+
return [dict(row) for row in rows]

src/emailer.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Email notification via QQ SMTP."""
22

3+
import re
34
import smtplib
5+
import time
46
from collections import OrderedDict
57
from email.mime.multipart import MIMEMultipart
68
from email.mime.text import MIMEText
79
from html import escape
810
from email.utils import formataddr
11+
from urllib.parse import quote
912

1013
import markdown
1114

@@ -95,6 +98,41 @@ def _md_to_html(md_text: str) -> str:
9598
return markdown.markdown(md_text, extensions=_MD_EXTENSIONS)
9699

97100

101+
def _latex_to_img_html(html: str) -> str:
102+
"""Replace LaTeX math expressions with rendered <img> tags.
103+
104+
Skips content inside <code> and <pre> tags.
105+
$$...$$ → centered block image, $...$ → inline image.
106+
"""
107+
# Split HTML to skip code/pre blocks
108+
parts = re.split(r"(<code>.*?</code>|<pre>.*?</pre>)", html, flags=re.DOTALL)
109+
for i, part in enumerate(parts):
110+
if part.startswith("<code>") or part.startswith("<pre>"):
111+
continue
112+
# Block math: $$...$$
113+
part = re.sub(
114+
r"\$\$(.+?)\$\$",
115+
lambda m: (
116+
f'<div style="text-align:center;margin:12px 0">'
117+
f'<img src="https://latex.codecogs.com/svg.latex?{quote(m.group(1))}"'
118+
f' alt="{escape(m.group(1))}" style="vertical-align:middle"></div>'
119+
),
120+
part,
121+
flags=re.DOTALL,
122+
)
123+
# Inline math: $...$
124+
part = re.sub(
125+
r"\$(.+?)\$",
126+
lambda m: (
127+
f'<img src="https://latex.codecogs.com/svg.latex?\\inline%20{quote(m.group(1))}"'
128+
f' alt="{escape(m.group(1))}" style="vertical-align:middle">'
129+
),
130+
part,
131+
)
132+
parts[i] = part
133+
return "".join(parts)
134+
135+
98136
class Emailer:
99137
"""Send course summary emails via QQ SMTP SSL."""
100138

@@ -105,15 +143,18 @@ def __init__(self):
105143
self.password = config.SMTP_PASSWORD
106144
self.receiver = config.RECEIVER_EMAIL
107145

108-
def send(self, items: list[dict]):
146+
def send(self, items: list[dict]) -> bool:
109147
"""Send a single email containing all lecture summaries.
110148
111149
Args:
112150
items: List of dicts, each with keys:
113151
course_title, sub_title, date, summary
152+
153+
Returns:
154+
True if email was sent successfully, False otherwise.
114155
"""
115156
if not items:
116-
return
157+
return True
117158

118159
# Group by course (preserve insertion order)
119160
courses: OrderedDict[str, list[dict]] = OrderedDict()
@@ -137,7 +178,7 @@ def send(self, items: list[dict]):
137178
plain_sections.append(lec["summary"])
138179
plain = "\n".join(plain_sections)
139180

140-
# HTML (Markdown → styled HTML)
181+
# HTML (Markdown → styled HTML with LaTeX rendering)
141182
body_parts = []
142183
for course_title, lectures in courses.items():
143184
body_parts.append(f"<h2>{escape(course_title)}</h2>")
@@ -146,7 +187,7 @@ def send(self, items: list[dict]):
146187
f"<h3>{escape(lec['sub_title'])} "
147188
f"<small>({escape(lec['date'])})</small></h3>"
148189
)
149-
body_parts.append(_md_to_html(lec["summary"]))
190+
body_parts.append(_latex_to_img_html(_md_to_html(lec["summary"])))
150191
body_parts.append("<hr>")
151192

152193
html = (
@@ -165,8 +206,18 @@ def send(self, items: list[dict]):
165206
msg.attach(MIMEText(plain, "plain", "utf-8"))
166207
msg.attach(MIMEText(html, "html", "utf-8"))
167208

168-
with smtplib.SMTP_SSL(self.host, self.port) as server:
169-
server.login(self.sender, self.password)
170-
server.sendmail(self.sender, self.receiver, msg.as_string())
171-
172-
print(f"[Emailer] Sent: {subject}")
209+
# Retry with exponential backoff
210+
for attempt in range(3):
211+
try:
212+
with smtplib.SMTP_SSL(self.host, self.port) as server:
213+
server.login(self.sender, self.password)
214+
server.sendmail(self.sender, self.receiver, msg.as_string())
215+
print(f"[Emailer] Sent: {subject}")
216+
return True
217+
except Exception as e:
218+
print(f"[Emailer] Attempt {attempt + 1}/3 failed: {e}")
219+
if attempt < 2:
220+
time.sleep(2 ** attempt)
221+
222+
print("[Emailer] All send attempts failed.")
223+
return False

0 commit comments

Comments
 (0)