1- """Export all summaries for a course as an email or PDF attachment .
1+ """Export all summaries for one or more courses as emails or PDF attachments .
22
33Usage:
44 python scripts/export_course.py --course-id 30004
5- python scripts/export_course.py --course-id 30004 --pdf
5+ python scripts/export_course.py --course-id 30004,30005
6+ python scripts/export_course.py --course-id 30004,30005 --pdf
67
78Options:
8- --course-id Course ID to export (required).
9+ --course-id Comma-separated course IDs to export (required).
910 --pdf Convert summaries to PDF and send as attachment.
10- Without this flag the summaries are sent as an HTML email .
11+ Without this flag the summaries are sent as HTML emails .
1112 --db Database path (default: data/icourse.db).
13+
14+ When multiple course IDs are given:
15+ - PDF mode: each course becomes a separate PDF; all PDFs are sent in
16+ a single email as attachments.
17+ - Email mode: each course is sent as a separate HTML email.
1218"""
1319
1420import argparse
1723import sys
1824from email import encoders
1925from email .mime .base import MIMEBase
26+ from email .mime .image import MIMEImage
2027from email .mime .multipart import MIMEMultipart
2128from email .mime .text import MIMEText
2229from email .utils import formataddr
3340# WeasyPrint maps CSS px to physical size at 96 DPI, which makes the
3441# pre-scaled latex images appear too small. This CSS lets the renderer
3542# size them naturally based on the image's intrinsic dimensions instead.
36- _PDF_LATEX_CSS = "img { max-width: 100% !important; height: auto !important; }"
43+ _PDF_LATEX_CSS = (
44+ "img { max-width: 100% !important; height: auto !important; }\n "
45+ 'body { font-family: "SimSun", "Noto Serif CJK SC", "AR PL SungtiL GB", serif; }'
46+ )
3747
3848
3949def _build_html (course_title : str , teacher : str , lectures : list [dict ],
40- pdf : bool = False ) -> str :
41- """Build a complete styled HTML document from course summaries."""
50+ pdf : bool = False , cid_images : dict | None = None ) -> str :
51+ """Build a complete styled HTML document from course summaries.
52+
53+ Args:
54+ cid_images: When provided (dict), LaTeX images are downloaded and
55+ embedded via CID references. The dict is populated with
56+ ``{cid_name: png_bytes}`` entries for the caller to attach
57+ to the MIME message.
58+ """
4259 body_parts = [
4360 f"<h1>{ escape (course_title )} </h1>" ,
4461 f"<p>任课教师:{ escape (teacher )} </p>" ,
@@ -49,7 +66,7 @@ def _build_html(course_title: str, teacher: str, lectures: list[dict],
4966 f"<h2>{ escape (lec ['sub_title' ])} "
5067 f"<small>({ escape (lec ['date' ])} )</small></h2>"
5168 )
52- body_parts .append (_md_to_html (lec ["summary" ]))
69+ body_parts .append (_md_to_html (lec ["summary" ], cid_images = cid_images ))
5370 body_parts .append ("<hr>" )
5471
5572 extra_css = f"\n { _PDF_LATEX_CSS } " if pdf else ""
@@ -85,61 +102,75 @@ def _smtp_connect():
85102 return server
86103
87104
88- def _send_html_email (subject : str , html : str , plain : str ) -> None :
89- """Send a multipart HTML email."""
90- msg = MIMEMultipart ("alternative" )
105+ def _send_html_email (subject : str , html : str , plain : str ,
106+ cid_images : dict [str , bytes ] | None = None ) -> None :
107+ """Send a multipart HTML email with CID-embedded LaTeX images.
108+
109+ Uses ``multipart/related`` wrapping ``multipart/alternative`` so that
110+ CID image references in the HTML resolve correctly, matching the MIME
111+ structure used in the main programme (``src/emailer.py``).
112+ """
113+ msg = MIMEMultipart ("related" )
91114 msg ["Subject" ] = subject
92115 msg ["From" ] = formataddr (("iCourse Subscriber" , config .SMTP_EMAIL ))
93116 msg ["To" ] = config .RECEIVER_EMAIL
94- msg .attach (MIMEText (plain , "plain" , "utf-8" ))
95- msg .attach (MIMEText (html , "html" , "utf-8" ))
117+
118+ msg_alt = MIMEMultipart ("alternative" )
119+ msg_alt .attach (MIMEText (plain , "plain" , "utf-8" ))
120+ msg_alt .attach (MIMEText (html , "html" , "utf-8" ))
121+ msg .attach (msg_alt )
122+
123+ if cid_images :
124+ for cid , png_data in cid_images .items ():
125+ img_part = MIMEImage (png_data , "png" )
126+ img_part .add_header ("Content-ID" , f"<{ cid } >" )
127+ img_part .add_header ("Content-Disposition" , "inline" ,
128+ filename = f"{ cid } .png" )
129+ msg .attach (img_part )
96130
97131 with _smtp_connect () as server :
98132 server .sendmail (config .SMTP_EMAIL , config .RECEIVER_EMAIL , msg .as_string ())
99133
100134
101- def _send_pdf_email (subject : str , pdf_bytes : bytes , filename : str ) -> None :
102- """Send an email with a PDF file attached."""
135+ def _send_pdf_email (subject : str ,
136+ attachments : list [tuple [bytes , str ]]) -> None :
137+ """Send an email with one or more PDF files attached.
138+
139+ Args:
140+ attachments: List of ``(pdf_bytes, filename)`` tuples.
141+ """
103142 msg = MIMEMultipart ()
104143 msg ["Subject" ] = subject
105144 msg ["From" ] = formataddr (("iCourse Subscriber" , config .SMTP_EMAIL ))
106145 msg ["To" ] = config .RECEIVER_EMAIL
107146
108- part = MIMEBase ("application" , "pdf" , name = filename )
109- part .set_payload (pdf_bytes )
110- encoders .encode_base64 (part )
111- part .add_header ("Content-Disposition" , "attachment" , filename = filename )
112- msg .attach (part )
147+ for pdf_bytes , filename in attachments :
148+ part = MIMEBase ("application" , "pdf" , name = filename )
149+ part .set_payload (pdf_bytes )
150+ encoders .encode_base64 (part )
151+ part .add_header ("Content-Disposition" , "attachment" , filename = filename )
152+ msg .attach (part )
113153
114154 with _smtp_connect () as server :
115155 server .sendmail (config .SMTP_EMAIL , config .RECEIVER_EMAIL , msg .as_string ())
116156
117157
118- def main ():
119- parser = argparse .ArgumentParser (description = "Export course summaries." )
120- parser .add_argument ("--course-id" , required = True , help = "Course ID to export" )
121- parser .add_argument (
122- "--pdf" ,
123- action = "store_true" ,
124- help = "Export as PDF attachment instead of inline HTML email" ,
125- )
126- parser .add_argument (
127- "--db" , default = "data/icourse.db" , help = "Database path (default: data/icourse.db)"
128- )
129- args = parser .parse_args ()
158+ def _safe_filename (title : str ) -> str :
159+ """Sanitise a course title for use as a filename."""
160+ return "" .join (c if c .isalnum () or c in " _-" else "_" for c in title )
130161
131- if not os .path .isfile (args .db ):
132- print (f"Database not found: { args .db } " )
133- sys .exit (1 )
134162
135- db = Database (args .db )
163+ def _query_course (db : Database , course_id : str ) -> tuple [str , str , list [dict ]] | None :
164+ """Return ``(course_title, teacher, lectures)`` for *course_id*.
136165
166+ Returns ``None`` if the course is missing or has no summaries.
167+ """
137168 course = db .conn .execute (
138- "SELECT * FROM courses WHERE course_id = ?" , (args . course_id ,)
169+ "SELECT * FROM courses WHERE course_id = ?" , (course_id ,)
139170 ).fetchone ()
140171 if not course :
141- print (f"Course { args . course_id } not found in database." )
142- sys . exit ( 1 )
172+ print (f"Course { course_id } not found in database – skipping ." )
173+ return None
143174
144175 course_title = course ["title" ]
145176 teacher = course ["teacher" ]
@@ -149,42 +180,110 @@ def main():
149180 FROM lectures
150181 WHERE course_id = ? AND summary IS NOT NULL
151182 ORDER BY CAST(sub_id AS INTEGER) ASC""" ,
152- (args . course_id ,),
183+ (course_id ,),
153184 ).fetchall ()
154185 lectures = [dict (row ) for row in rows ]
155186
156187 if not lectures :
157- print (f"No summaries found for course { args . course_id } ({ course_title } )." )
158- sys . exit ( 0 )
188+ print (f"No summaries found for course { course_id } ({ course_title } ) – skipping ." )
189+ return None
159190
160191 print (f"Found { len (lectures )} summarized lecture(s) for { course_title } ." )
192+ return course_title , teacher , lectures
193+
194+
195+ def main ():
196+ parser = argparse .ArgumentParser (description = "Export course summaries." )
197+ parser .add_argument (
198+ "--course-id" , required = True ,
199+ help = "Comma-separated course IDs to export (e.g. 30004 or 30004,30005)" ,
200+ )
201+ parser .add_argument (
202+ "--pdf" ,
203+ action = "store_true" ,
204+ help = "Export as PDF attachment instead of inline HTML email" ,
205+ )
206+ parser .add_argument (
207+ "--db" , default = "data/icourse.db" ,
208+ help = "Database path (default: data/icourse.db)" ,
209+ )
210+ args = parser .parse_args ()
211+
212+ if not os .path .isfile (args .db ):
213+ print (f"Database not found: { args .db } " )
214+ sys .exit (1 )
215+
216+ db = Database (args .db )
217+
218+ # Parse comma-separated course IDs
219+ course_ids = [cid .strip () for cid in args .course_id .split ("," ) if cid .strip ()]
220+ if not course_ids :
221+ print ("No valid course IDs provided." )
222+ sys .exit (1 )
161223
162224 if not config .SMTP_EMAIL or not config .SMTP_PASSWORD or not config .RECEIVER_EMAIL :
163225 print ("Email configuration incomplete. Set SMTP_EMAIL, SMTP_PASSWORD, RECEIVER_EMAIL." )
164226 sys .exit (1 )
165227
166- html = _build_html (course_title , teacher , lectures , pdf = args .pdf )
167- subject = f"[iCourse 课程摘要导出] { course_title } "
168-
169228 if args .pdf :
170229 try :
171230 import weasyprint # noqa: PLC0415
172231 except ImportError :
173232 print ("weasyprint is required for PDF export. Install it with: pip install weasyprint" )
174233 sys .exit (1 )
175234
176- print ("Generating PDF..." )
177- pdf_bytes = weasyprint .HTML (string = html ).write_pdf ()
178- safe_title = "" .join (c if c .isalnum () or c in " _-" else "_" for c in course_title )
179- filename = f"{ safe_title } _summaries.pdf"
180- print (f"Sending PDF email ({ len (pdf_bytes )} bytes)..." )
181- _send_pdf_email (subject , pdf_bytes , filename )
182- else :
183- plain = _build_plain (course_title , teacher , lectures )
184- print ("Sending HTML email..." )
185- _send_html_email (subject , html , plain )
235+ # PDF mode: one PDF per course, all PDFs in one email
236+ attachments : list [tuple [bytes , str ]] = []
237+ titles : list [str ] = []
238+ for cid in course_ids :
239+ result = _query_course (db , cid )
240+ if result is None :
241+ continue
242+ course_title , teacher , lectures = result
243+ titles .append (course_title )
244+
245+ html = _build_html (course_title , teacher , lectures , pdf = True )
246+ print (f"Generating PDF for { course_title } ..." )
247+ pdf_bytes = weasyprint .HTML (string = html ).write_pdf ()
248+ filename = f"{ _safe_filename (course_title )} _summaries.pdf"
249+ attachments .append ((pdf_bytes , filename ))
250+ print (f" PDF ready ({ len (pdf_bytes )} bytes): { filename } " )
251+
252+ if not attachments :
253+ print ("No courses with summaries found – nothing to send." )
254+ sys .exit (0 )
255+
256+ subject = "[iCourse 课程摘要导出] " + ", " .join (titles )
257+ total_bytes = sum (len (b ) for b , _ in attachments )
258+ print (f"Sending email with { len (attachments )} PDF(s) ({ total_bytes } bytes)..." )
259+ _send_pdf_email (subject , attachments )
260+ print (f"[OK] Sent: { subject } " )
186261
187- print (f"[OK] Sent: { subject } " )
262+ else :
263+ # Email mode: one CID-embedded HTML email per course
264+ sent = 0
265+ for cid in course_ids :
266+ result = _query_course (db , cid )
267+ if result is None :
268+ continue
269+ course_title , teacher , lectures = result
270+
271+ cid_images : dict [str , bytes ] = {}
272+ html = _build_html (course_title , teacher , lectures ,
273+ cid_images = cid_images )
274+ plain = _build_plain (course_title , teacher , lectures )
275+ subject = f"[iCourse 课程摘要导出] { course_title } "
276+
277+ print (f"Sending HTML email for { course_title } ..." )
278+ if cid_images :
279+ print (f" Embedded { len (cid_images )} LaTeX image(s) as CID" )
280+ _send_html_email (subject , html , plain , cid_images = cid_images )
281+ print (f"[OK] Sent: { subject } " )
282+ sent += 1
283+
284+ if sent == 0 :
285+ print ("No courses with summaries found – nothing to send." )
286+ sys .exit (0 )
188287
189288
190289if __name__ == "__main__" :
0 commit comments