拆分文件、添加单日课表渲染功能

This commit is contained in:
2026-04-02 21:23:09 +08:00
parent fd948ca897
commit 868abb2358
4 changed files with 783 additions and 465 deletions

7
_conf_schema.json Normal file
View File

@@ -0,0 +1,7 @@
{
"hide_void_columns_rows": {
"description": "隐藏课程表图片中的空行空列",
"type": "bool",
"default": true
}
}

523
main.py
View File

@@ -1,18 +1,22 @@
import os
import tempfile
import subprocess
import base64
"""AstrBot 课表渲染插件主模块"""
from typing import Any, AsyncGenerator
from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult
from astrbot.api.star import Context, Star
from astrbot.api import logger
from astrbot.core import AstrBotConfig
from astrbot.core.message.message_event_result import MessageEventResult
from schedule_utils import convert_tuple_schedule_to_dict
from schedule_renderer import ScheduleRenderer
class SuanPlugin(Star):
def __init__(self, context: Context):
def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context)
self.config = config
self.renderer = ScheduleRenderer(config)
async def initialize(self):
"""可选择实现异步的插件初始化方法,当实例化该插件类之后会自动调用该方法。"""
@@ -41,478 +45,67 @@ class SuanPlugin(Star):
try:
# 生成 HTML 内容
html_content = self._generate_schedule_html(schedule_dict, title)
html_content = self.renderer.generate_html(schedule_dict, title)
# 额外保存一份到指定路径,用于样式调试
with open('/home/fanfan/test.html', 'w', encoding='utf-8') as f:
f.write(html_content)
# 创建临时文件
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f:
f.write(html_content)
html_path = f.name
# 生成 PNG 输出路径
png_path = html_path.replace('.html', '.png')
# 使用 wkhtmltoimage 直接生成图片(比 wkhtmltopdf 再转换更方便)
# 如果系统只有 wkhtmltopdf可以先生成 PDF 再转换
try:
# 尝试使用 wkhtmltoimage如果安装了
cmd = [
'wkhtmltoimage',
'--width', '1680',
'--quality', '90',
'--enable-local-file-access',
'--transparent',
html_path,
png_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0 or not os.path.exists(png_path):
# 回退到 wkhtmltopdf + convert 方案
png_path = await self._html_to_png_via_pdf(html_path)
except FileNotFoundError:
# wkhtmltoimage 不存在,使用 pdf 方案
png_path = await self._html_to_png_via_pdf(html_path)
# 读取图片并返回
if os.path.exists(png_path):
with open(png_path, 'rb') as f:
image_data = f.read()
# 清理临时文件
self._cleanup_temp_files(html_path, png_path)
# 返回图片消息
from astrbot.api.message_components import Image
yield event.chain_result([Image.fromBytes(image_data)])
else:
yield event.plain_result("生成课表图片失败,请检查 wkhtmltopdf 是否已安装")
# 渲染为 PNG
image_data = await self.renderer.render_to_png(
html_content,
debug_path='/home/fanfan/test.html'
)
# 返回图片消息
from astrbot.api.message_components import Image
yield event.chain_result([Image.fromBytes(image_data)])
except Exception as e:
logger.error(f"渲染课表失败: {e}")
yield event.plain_result(f"生成课表图片时出错: {str(e)}")
async def _html_to_png_via_pdf(self, html_path: str) -> str:
"""通过 PDF 中转生成 PNG"""
pdf_path = html_path.replace('.html', '.pdf')
png_path = html_path.replace('.html', '.png')
# HTML -> PDF
cmd = [
'wkhtmltopdf',
'--page-width', '297mm', # A4 横向
'--page-height', '210mm',
'--encoding', 'utf-8',
'--enable-local-file-access',
'--no-stop-slow-scripts',
'--javascript-delay', '1000',
html_path,
pdf_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"wkhtmltopdf 失败: {result.stderr}")
# PDF -> PNG (使用 ImageMagick 的 convert)
cmd = [
'convert',
'-density', '150',
pdf_path,
'-quality', '90',
png_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
# 尝试使用 pdftoppm
cmd = [
'pdftoppm',
'-png',
'-r', '150',
'-singlefile',
pdf_path,
png_path.replace('.png', '')
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"PDF 转 PNG 失败: {result.stderr}")
# pdftoppm 生成的文件名可能不同
generated_png = png_path.replace('.png', '-1.png')
if os.path.exists(generated_png):
os.rename(generated_png, png_path)
# 清理 PDF 临时文件
if os.path.exists(pdf_path):
os.unlink(pdf_path)
return png_path
def _cleanup_temp_files(self, html_path: str, png_path: str):
"""清理临时文件"""
for path in [html_path, png_path]:
try:
if os.path.exists(path):
os.unlink(path)
except Exception as e:
logger.warning(f"清理临时文件失败 {path}: {e}")
def _generate_schedule_html(self, schedule: list[dict], title: str) -> str:
"""生成课程表 HTML - 清新薄荷绿风格(静态图片版)"""
weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
weekday_short = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
grid = [[[] for _ in range(7)] for _ in range(12)]
for course in schedule:
name = course.get('name', '未知课程')
teacher = course.get('teacher') or ''
classroom = course.get('classroom') or ''
weeks = course.get('weeks', [])
day = course.get('day', 1)
classes = course.get('classes', [])
day_idx = day - 1
if day_idx < 0 or day_idx > 6:
continue
course_text = f"<b>{name}</b>"
if teacher:
course_text += f"<br><small>{teacher}</small>"
if classroom:
course_text += f"<br><small>@{classroom}</small>"
if weeks:
course_text += f"<br><small>第{self._format_weeks(weeks)}周</small>"
for class_idx in classes:
if 1 <= class_idx <= 12:
grid[class_idx - 1][day_idx].append(course_text)
html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
@filter.llm_tool(name='render_daily_course_schedule_png')
async def render_daily_course_schedule_png(self, event: AstrMessageEvent, schedule: list[tuple], title: str, weekday: int = None) -> \
AsyncGenerator[MessageEventResult, Any]:
"""将单日课程表渲染为 png 格式的图片,以垂直卡片形式展示当天的课程安排。
body {{
font-family: "WenQuanYi Micro Hei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
background: transparent;
padding: 40px;
}}
与 render_course_schedule_png 不同,此工具只展示一天的课程,以卡片列表形式呈现,更适合查看特定日期的详细安排。
.container {{
width: 1600px;
background: #1a1a2e;
border-radius: 20px;
padding: 40px;
}}
Args:
schedule (list[tuple]): 包含数个元组的列表。每个元组的内部是按照顺序的如下元素:
0. str: 课程名称
1. str: 授课教师(可以为 None
2. str: 教室(可以为 None
3. str | list: 形如 `1-17` 表示从第 1 周到第 17 周list[int] 也是受支持的。
4. int: 星期几int (1-7),此参数在单日视图中可选
5. list: 当日第几节课list[int]
title (str): 图片标题,如 "张三的课表"
weekday (int, optional): 星期几 (1-7),用于在副标题显示。如为 None 则不显示星期信息。
"""
# 转换为字典格式
schedule_dict = convert_tuple_schedule_to_dict(schedule)
.header {{
text-align: center;
margin-bottom: 30px;
}}
# 获取星期名称
weekday_names = {1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日'}
weekday_name = weekday_names.get(weekday, '') if weekday else ''
h1 {{
font-size: 32px;
color: #e0f7fa;
letter-spacing: 2px;
}}
table {{
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 10px;
font-size: 14px;
}}
col.time-col {{
width: 90px;
}}
col.day-col {{
width: 14.28%;
}}
th {{
background: #006064;
color: #e0f7fa;
font-weight: bold;
padding: 15px 10px;
border-radius: 12px;
}}
th .weekday-en {{
font-size: 11px;
display: block;
margin-top: 4px;
color: #80deea;
}}
td.time-col {{
background: #00796b;
color: #e0f7fa;
font-weight: bold;
padding: 15px 10px;
border-radius: 12px;
text-align: center;
}}
td.time-col .period-num {{
font-size: 18px;
display: block;
}}
td.time-col .period-time {{
font-size: 11px;
display: block;
margin-top: 4px;
color: #b2ebf2;
}}
td.day-cell {{
background: #263238;
border-radius: 12px;
padding: 10px;
vertical-align: top;
height: 110px;
}}
.course-item {{
background: #00838f;
border-radius: 10px;
padding: 6px;
margin: 2px 0;
color: #e0f7fa;
line-height: 1.1;
font-size: 17px;
height: 180px;
}}
.course-item b {{
display: block;
font-size: 20px;
font-weight: bold;
margin-bottom: 1px;
color: #ffffff;
}}
.course-item small {{
display: block;
font-size: 14px;
color: #b2ebf2;
}}
.course-item small + small {{
margin-top: 0px;
}}
.course-item.type-a {{ background: #0277bd; }}
.course-item.type-b {{ background: #2e7d32; }}
.course-item.type-c {{ background: #7b1fa2; }}
.course-item.type-d {{ background: #c6278e; }}
.course-item.type-e {{ background: #ef6c00; }}
</style>
try:
# 生成单日 HTML 内容
html_content = self.renderer.generate_daily_html(schedule_dict, title, weekday_name)
</head>
<body>
<div class="container">
<div class="header">
<h1>{title}</h1>
</div>
<table>
<colgroup>
<col class="time-col">
<col class="day-col"><col class="day-col"><col class="day-col">
<col class="day-col"><col class="day-col"><col class="day-col">
<col class="day-col">
</colgroup>
<thead>
<tr>
<th>TIME</th>
{''.join(f'<th>{w}<span class="weekday-en">{weekday_short[i]}</span></th>' for i, w in enumerate(weekdays))}
</tr>
</thead>
<tbody>
{self._generate_table_body(grid)}
</tbody>
</table>
<div style="text-align: right; margin-top: 20px; font-size: 12px; color: #b0bec5;">Powered by &lt;芒果酸&gt; suan.mangofanfan.cn</div>
</div>
</body>
</html>'''
return html
def _generate_table_body(self, grid: list) -> str:
"""生成表格主体 HTML"""
rows = []
course_type_counter = 0
type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e']
for period in range(12):
start_time = self._get_period_time(period + 1)
row_html = f'''<tr>
<td class="time-col">
<span class="period-num">{period + 1}</span>
<span class="period-time">{start_time}</span>
</td>'''
for day in range(7):
courses = grid[period][day]
if courses:
cell_content = ''
for c in courses:
type_class = type_classes[course_type_counter % len(type_classes)]
course_type_counter += 1
cell_content += f'<div class="course-item {type_class}">{c}</div>'
else:
cell_content = ''
row_html += f'<td class="day-cell">{cell_content}</td>'
row_html += '</tr>'
rows.append(row_html)
return '\n'.join(rows)
def _get_period_time(self, period: int) -> str:
"""获取节次对应的时间(南邮标准时间)"""
# 南邮标准作息时间,可根据需要调整
time_map = {
1: '08:00',
2: '08:50',
3: '09:50',
4: '10:40',
5: '11:30',
6: '13:45',
7: '14:35',
8: '15:35',
9: '16:25',
10: '18:30',
11: '19:20',
12: '20:10'
}
return time_map.get(period, '')
def _format_weeks(self, weeks: list[int]) -> str:
"""格式化周数显示,如 [1,2,3,5,6,7] -> "1-3,5-7" """
if not weeks:
return ''
weeks = sorted(set(weeks))
ranges = []
start = end = weeks[0]
for w in weeks[1:]:
if w == end + 1:
end = w
else:
if start == end:
ranges.append(str(start))
else:
ranges.append(f"{start}-{end}")
start = end = w
if start == end:
ranges.append(str(start))
else:
ranges.append(f"{start}-{end}")
return ','.join(ranges)
# 渲染为 PNG使用较小的宽度适合单列卡片
image_data = await self.renderer.render_to_png(
html_content,
debug_path='/home/fanfan/test_daily.html',
width=880
)
# 返回图片消息
from astrbot.api.message_components import Image
yield event.chain_result([Image.fromBytes(image_data)])
except Exception as e:
logger.error(f"渲染单日课表失败: {e}")
yield event.plain_result(f"生成单日课表图片时出错: {str(e)}")
async def terminate(self):
"""可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。"""
def convert_tuple_schedule_to_dict(schedule: list[tuple]) -> list[dict]:
"""将元组格式的课表转换为字典格式。
Args:
schedule: list[tuple],每个元组包含 (name, teacher, classroom, weeks, day, classes)
其中 weeks 可以是 list[int] 或 str"1-17"
Returns:
list[dict]: 标准格式的课程数据
"""
result = []
for course in schedule:
# 确保元组长度足够,不足的补 None
padded = list(course) + [None] * (6 - len(course))
name, teacher, classroom, weeks, day, classes = padded[:6]
# 处理 weeks支持 list 或 str如 "1-17", "1,3,5-10"
weeks_list = []
if isinstance(weeks, list):
weeks_list = weeks
elif isinstance(weeks, str):
weeks_list = parse_weeks_string(weeks)
result.append({
"name": name,
"teacher": teacher,
"classroom": classroom,
"weeks": weeks_list,
"day": day if isinstance(day, int) else 1,
"classes": classes if isinstance(classes, list) else []
})
return result
def parse_weeks_string(weeks_str: str) -> list[int]:
"""将周数字符串解析为整数列表。
支持格式:
"1-17" -> [1,2,3,...,17]
"1,3,5" -> [1,3,5]
"1-5,7,9-11" -> [1,2,3,4,5,7,9,10,11]
Args:
weeks_str: 周数字符串
Returns:
list[int]: 周数列表
"""
if not weeks_str or not isinstance(weeks_str, str):
return []
result = []
parts = weeks_str.split(',')
for part in parts:
part = part.strip()
if '-' in part:
# 范围格式1-17
try:
start, end = part.split('-', 1)
start = int(start.strip())
end = int(end.strip())
result.extend(range(start, end + 1))
except ValueError:
continue
else:
# 单个数字
try:
result.append(int(part))
except ValueError:
continue
# 去重并排序
return sorted(set(result))

594
schedule_renderer.py Normal file
View File

@@ -0,0 +1,594 @@
"""课表渲染模块 - 负责生成 HTML 和转换为图片"""
import os
import tempfile
import subprocess
from astrbot.api import logger
from schedule_utils import get_period_time, format_weeks
class ScheduleRenderer:
"""课表渲染器"""
def __init__(self, config: dict = None):
self.config = config or {}
def generate_html(self, schedule: list[dict], title: str) -> str:
"""生成课程表 HTML"""
weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
weekday_short = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
grid = [[[] for _ in range(7)] for _ in range(12)]
for course in schedule:
name = course.get('name', '未知课程')
teacher = course.get('teacher') or ''
classroom = course.get('classroom') or ''
weeks = course.get('weeks', [])
day = course.get('day', 1)
classes = course.get('classes', [])
day_idx = day - 1
if day_idx < 0 or day_idx > 6:
continue
course_text = f"<b>{name}</b>"
if teacher:
course_text += f"<br><small>{teacher}</small>"
if classroom:
course_text += f"<br><small>@{classroom}</small>"
if weeks:
course_text += f"<br><small>第{format_weeks(weeks)}周</small>"
for class_idx in classes:
if 1 <= class_idx <= 12:
grid[class_idx - 1][day_idx].append(course_text)
# 判断是否隐藏空行空列
hide_void = self.config.get('hide_void_columns_rows', True)
# 计算有课的行和列
active_rows = set()
active_cols = set()
for row_idx in range(12):
for col_idx in range(7):
if grid[row_idx][col_idx]:
active_rows.add(row_idx)
active_cols.add(col_idx)
# 如果没有课,默认显示全部
if not active_rows:
active_rows = set(range(12))
if not active_cols:
active_cols = set(range(7))
# 转换为有序列表
active_rows = sorted(active_rows)
active_cols = sorted(active_cols)
# 如果不隐藏,显示全部
if not hide_void:
active_rows = list(range(12))
active_cols = list(range(7))
# 计算列宽时间列90px剩余宽度均分给各天
day_col_width = 14.28 if not hide_void else (100.0 / len(active_cols))
html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: "WenQuanYi Micro Hei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
background: transparent;
padding: 40px;
}}
.container {{
width: 1600px;
background: #1a1a2e;
border-radius: 20px;
padding: 40px;
}}
.header {{
text-align: center;
margin-bottom: 30px;
}}
h1 {{
font-size: 32px;
color: #e0f7fa;
letter-spacing: 2px;
}}
table {{
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 10px;
font-size: 14px;
}}
col.time-col {{
width: 90px;
}}
col.day-col {{
width: {day_col_width}%;
}}
th {{
background: #006064;
color: #e0f7fa;
font-weight: bold;
padding: 15px 10px;
border-radius: 12px;
}}
th .weekday-en {{
font-size: 11px;
display: block;
margin-top: 4px;
color: #80deea;
}}
td.time-col {{
background: #00796b;
color: #e0f7fa;
font-weight: bold;
padding: 15px 10px;
border-radius: 12px;
text-align: center;
}}
td.time-col .period-num {{
font-size: 18px;
display: block;
}}
td.time-col .period-time {{
font-size: 11px;
display: block;
margin-top: 4px;
color: #b2ebf2;
}}
td.day-cell {{
background: #263238;
border-radius: 12px;
padding: 10px;
vertical-align: top;
height: 110px;
}}
.course-item {{
background: #00838f;
border-radius: 10px;
padding: 6px;
margin: 2px 0;
color: #e0f7fa;
line-height: 1.15;
font-size: 17px;
height: 180px;
text-align: center;
display: table;
width: 100%;
}}
.course-item-inner {{
display: table-cell;
vertical-align: middle;
}}
.course-item b {{
display: block;
font-size: 20px;
font-weight: bold;
margin-bottom: 1px;
color: #ffffff;
}}
.course-item small {{
display: block;
font-size: 14px;
color: #b2ebf2;
}}
.course-item small + small {{
margin-top: 0px;
}}
.course-item.type-a {{ background: #0277bd; }}
.course-item.type-b {{ background: #2e7d32; }}
.course-item.type-c {{ background: #7b1fa2; }}
.course-item.type-d {{ background: #c6278e; }}
.course-item.type-e {{ background: #ef6c00; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{title}</h1>
</div>
<table>
<colgroup>
<col class="time-col">
{''.join('<col class="day-col">' for _ in active_cols)}
</colgroup>
<thead>
<tr>
<th>TIME</th>
{''.join(f'<th>{weekdays[i]}<span class="weekday-en">{weekday_short[i]}</span></th>' for i in active_cols)}
</tr>
</thead>
<tbody>
{self._generate_table_body(grid, active_rows, active_cols)}
</tbody>
</table>
<div style="text-align: right; margin-top: 20px; font-size: 12px; color: #b0bec5;">Powered by &lt;芒果酸&gt; suan.mangofanfan.cn</div>
</div>
</body>
</html>'''
return html
def _generate_table_body(self, grid: list, active_rows: list, active_cols: list) -> str:
"""生成表格主体 HTML"""
rows = []
course_type_counter = 0
type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e']
for period in active_rows:
start_time = get_period_time(period + 1)
row_html = f'''<tr>
<td class="time-col">
<span class="period-num">{period + 1}</span>
<span class="period-time">{start_time}</span>
</td>'''
for day in active_cols:
courses = grid[period][day]
if courses:
cell_content = ''
for c in courses:
type_class = type_classes[course_type_counter % len(type_classes)]
course_type_counter += 1
cell_content += f'<div class="course-item {type_class}"><div class="course-item-inner">{c}</div></div>'
else:
cell_content = ''
row_html += f'<td class="day-cell">{cell_content}</td>'
row_html += '</tr>'
rows.append(row_html)
return '\n'.join(rows)
def generate_daily_html(self, schedule: list[dict], title: str, weekday_name: str = "") -> str:
"""生成单日课程表 HTML - 垂直卡片布局"""
# 按节次排序课程
sorted_courses = sorted(schedule, key=lambda x: min(x.get('classes', [99])) if x.get('classes') else 99)
type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e']
cards_html = []
course_type_counter = 0
for course in sorted_courses:
name = course.get('name', '未知课程')
teacher = course.get('teacher') or ''
classroom = course.get('classroom') or ''
weeks = course.get('weeks', [])
classes = course.get('classes', [])
if not classes:
continue
# 获取第一节课的节次和时间
first_period = min(classes)
start_time = get_period_time(first_period)
end_time = get_period_time(max(classes))
# 构建课程信息文本
info_lines = []
if teacher:
info_lines.append(f"<div class='info-line'>👤 {teacher}</div>")
if classroom:
info_lines.append(f"<div class='info-line'>📍 {classroom}</div>")
if weeks:
info_lines.append(f"<div class='info-line'>📅 第{format_weeks(weeks)}周</div>")
type_class = type_classes[course_type_counter % len(type_classes)]
course_type_counter += 1
card_html = f'''
<div class="course-card {type_class}">
<div class="time-badge">
<div class="period-num">{first_period}</div>
<div class="time-range">{start_time}-{end_time}</div>
</div>
<div class="course-content">
<div class="course-name">{name}</div>
{''.join(info_lines)}
</div>
</div>
'''
cards_html.append(card_html)
# 如果没有课程
if not cards_html:
cards_html.append('<div class="empty-message">今日暂无课程安排 🎉</div>')
subtitle = f"{weekday_name} 课程安排" if weekday_name else "课程安排"
html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: "WenQuanYi Micro Hei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
background: transparent;
padding: 40px;
}}
.container {{
width: 800px;
background: #1a1a2e;
border-radius: 20px;
padding: 40px;
}}
.header {{
text-align: center;
margin-bottom: 30px;
}}
h1 {{
font-size: 36px;
color: #e0f7fa;
letter-spacing: 2px;
}}
.subtitle {{
font-size: 20px;
color: #80deea;
margin-top: 8px;
}}
.cards-wrapper {{
display: flex;
flex-direction: column;
gap: 16px;
}}
.course-card {{
display: flex;
align-items: center;
background: #00838f;
border-radius: 16px;
padding: 20px;
min-height: 120px;
}}
.course-card.type-a {{ background: linear-gradient(135deg, #0277bd 0%, #01579b 100%); }}
.course-card.type-b {{ background: linear-gradient(135deg, #2e7d32 0%, #1b5e20 100%); }}
.course-card.type-c {{ background: linear-gradient(135deg, #7b1fa2 0%, #4a148c 100%); }}
.course-card.type-d {{ background: linear-gradient(135deg, #c6278e 0%, #880e4f 100%); }}
.course-card.type-e {{ background: linear-gradient(135deg, #ef6c00 0%, #e65100 100%); }}
.time-badge {{
flex-shrink: 0;
width: 80px;
text-align: center;
padding-right: 20px;
border-right: 2px solid rgba(255, 255, 255, 0.3);
margin-right: 20px;
}}
.time-badge .period-num {{
font-size: 32px;
font-weight: bold;
color: #ffffff;
line-height: 1;
}}
.time-badge .time-range {{
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}}
.course-content {{
flex: 1;
}}
.course-name {{
font-size: 24px;
font-weight: bold;
color: #ffffff;
margin-bottom: 8px;
}}
.info-line {{
font-size: 15px;
color: rgba(255, 255, 255, 0.9);
margin-top: 4px;
}}
.empty-message {{
text-align: center;
font-size: 20px;
color: #80deea;
padding: 60px 20px;
background: #263238;
border-radius: 16px;
}}
.footer {{
text-align: right;
margin-top: 20px;
font-size: 12px;
color: #b0bec5;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
</div>
<div class="cards-wrapper">
{''.join(cards_html)}
</div>
<div class="footer">Powered by &lt;芒果酸&gt; suan.mangofanfan.cn</div>
</div>
</body>
</html>'''
return html
async def html_to_png_via_pdf(self, html_path: str) -> str:
"""通过 PDF 中转生成 PNG"""
pdf_path = html_path.replace('.html', '.pdf')
png_path = html_path.replace('.html', '.png')
# HTML -> PDF
cmd = [
'wkhtmltopdf',
'--page-width', '297mm', # A4 横向
'--page-height', '210mm',
'--encoding', 'utf-8',
'--enable-local-file-access',
'--no-stop-slow-scripts',
'--javascript-delay', '1000',
html_path,
pdf_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"wkhtmltopdf 失败: {result.stderr}")
# PDF -> PNG (使用 ImageMagick 的 convert)
cmd = [
'convert',
'-density', '150',
pdf_path,
'-quality', '90',
png_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
# 尝试使用 pdftoppm
cmd = [
'pdftoppm',
'-png',
'-r', '150',
'-singlefile',
pdf_path,
png_path.replace('.png', '')
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"PDF 转 PNG 失败: {result.stderr}")
# pdftoppm 生成的文件名可能不同
generated_png = png_path.replace('.png', '-1.png')
if os.path.exists(generated_png):
os.rename(generated_png, png_path)
# 清理 PDF 临时文件
if os.path.exists(pdf_path):
os.unlink(pdf_path)
return png_path
def cleanup_temp_files(self, html_path: str, png_path: str):
"""清理临时文件"""
for path in [html_path, png_path]:
try:
if os.path.exists(path):
os.unlink(path)
except Exception as e:
logger.warning(f"清理临时文件失败 {path}: {e}")
async def render_to_png(self, html_content: str, debug_path: str = None, width: int = 1680) -> bytes:
"""将 HTML 内容渲染为 PNG 图片字节
Args:
html_content: HTML 内容字符串
debug_path: 调试文件保存路径(可选)
width: 输出图片宽度,默认 1680
Returns:
PNG 图片字节数据
"""
# 额外保存一份到指定路径,用于样式调试
if debug_path:
try:
with open(debug_path, 'w', encoding='utf-8') as f:
f.write(html_content)
except Exception as e:
logger.warning(f"保存调试文件失败: {e}")
# 创建临时文件
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f:
f.write(html_content)
html_path = f.name
# 生成 PNG 输出路径
png_path = html_path.replace('.html', '.png')
# 使用 wkhtmltoimage 直接生成图片
try:
cmd = [
'wkhtmltoimage',
'--width', str(width),
'--quality', '90',
'--enable-local-file-access',
'--transparent',
html_path,
png_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0 or not os.path.exists(png_path):
# 回退到 wkhtmltopdf + convert 方案
png_path = await self.html_to_png_via_pdf(html_path)
except FileNotFoundError:
# wkhtmltoimage 不存在,使用 pdf 方案
png_path = await self.html_to_png_via_pdf(html_path)
# 读取图片数据
with open(png_path, 'rb') as f:
image_data = f.read()
# 清理临时文件
self.cleanup_temp_files(html_path, png_path)
return image_data

124
schedule_utils.py Normal file
View File

@@ -0,0 +1,124 @@
"""课表数据处理工具函数"""
def convert_tuple_schedule_to_dict(schedule: list[tuple]) -> list[dict]:
"""将元组格式的课表转换为字典格式。
Args:
schedule: list[tuple],每个元组包含 (name, teacher, classroom, weeks, day, classes)
其中 weeks 可以是 list[int] 或 str"1-17"
Returns:
list[dict]: 标准格式的课程数据
"""
result = []
for course in schedule:
# 确保元组长度足够,不足的补 None
padded = list(course) + [None] * (6 - len(course))
name, teacher, classroom, weeks, day, classes = padded[:6]
# 处理 weeks支持 list 或 str如 "1-17", "1,3,5-10"
weeks_list = []
if isinstance(weeks, list):
weeks_list = weeks
elif isinstance(weeks, str):
weeks_list = parse_weeks_string(weeks)
result.append({
"name": name,
"teacher": teacher,
"classroom": classroom,
"weeks": weeks_list,
"day": day if isinstance(day, int) else 1,
"classes": classes if isinstance(classes, list) else []
})
return result
def parse_weeks_string(weeks_str: str) -> list[int]:
"""将周数字符串解析为整数列表。
支持格式:
"1-17" -> [1,2,3,...,17]
"1,3,5" -> [1,3,5]
"1-5,7,9-11" -> [1,2,3,4,5,7,9,10,11]
Args:
weeks_str: 周数字符串
Returns:
list[int]: 周数列表
"""
if not weeks_str or not isinstance(weeks_str, str):
return []
result = []
parts = weeks_str.split(',')
for part in parts:
part = part.strip()
if '-' in part:
# 范围格式1-17
try:
start, end = part.split('-', 1)
start = int(start.strip())
end = int(end.strip())
result.extend(range(start, end + 1))
except ValueError:
continue
else:
# 单个数字
try:
result.append(int(part))
except ValueError:
continue
# 去重并排序
return sorted(set(result))
def format_weeks(weeks: list[int]) -> str:
"""格式化周数显示,如 [1,2,3,5,6,7] -> "1-3,5-7" """
if not weeks:
return ''
weeks = sorted(set(weeks))
ranges = []
start = end = weeks[0]
for w in weeks[1:]:
if w == end + 1:
end = w
else:
if start == end:
ranges.append(str(start))
else:
ranges.append(f"{start}-{end}")
start = end = w
if start == end:
ranges.append(str(start))
else:
ranges.append(f"{start}-{end}")
return ','.join(ranges)
def get_period_time(period: int) -> str:
"""获取节次对应的时间(南邮标准时间)"""
time_map = {
1: '08:00',
2: '08:50',
3: '09:50',
4: '10:40',
5: '11:30',
6: '13:45',
7: '14:35',
8: '15:35',
9: '16:25',
10: '18:30',
11: '19:20',
12: '20:10'
}
return time_map.get(period, '')