Files
astrbot_plugin_njupt_suan/main.py
2026-04-02 16:52:17 +08:00

443 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import tempfile
import subprocess
import base64
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.message.message_event_result import MessageEventResult
class SuanPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
async def initialize(self):
"""可选择实现异步的插件初始化方法,当实例化该插件类之后会自动调用该方法。"""
@filter.llm_tool(name='render_course_schedule_png')
async def render_course_schedule_png(self, event: AstrMessageEvent, schedule: list[tuple], title: str) -> \
AsyncGenerator[MessageEventResult, Any]:
"""将完整的每周课表渲染为 png 格式的图片,或者说是得到一张课程表图片。
在调用之前可能需要先对 schedule 进行格式转换,本工具接收的数据类型是 list[tuple]。此外,应当尽可能将代表周数的元素从列表压缩为字符串以节约工具参数长度。
Args:
schedule (list[tuple]): 包含数个元组的列表。每个元组的内部是按照顺序的如下元素:
0. str: 课程名称
1. str: 授课教师(可以为 None
2. str: 教室(可以为 None
3. str | list: 形如 `1-17` 表示从第 1 周到第 17 周;`1-3,5,7-17` 也是受支持的list[int] 也是受支持的。
4. int: 星期几int (1-7)
5. list: 当日第几节课一般是相邻的两节list[int]
title (str): 图片名称,会被渲染在图片的顶部,格式上作为课程表标题。
"""
# 转换为字典格式
schedule_dict = convert_tuple_schedule_to_dict(schedule)
try:
# 生成 HTML 内容
html_content = self._generate_schedule_html(schedule_dict, title)
# 创建临时文件
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', '1200',
'--quality', '90',
'--enable-local-file-access',
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 是否已安装")
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 = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
# 初始化课表网格: 12节课 x 7天
# grid[节次][星期] = 课程列表
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) # 1-7
classes = course.get('classes', [])
# 调整 day 为 0-based 索引
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
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: "Microsoft YaHei", "SimHei", sans-serif;
padding: 20px;
background: #fff;
}}
.container {{
width: 100%;
max-width: 1200px;
margin: 0 auto;
}}
h1 {{
text-align: center;
font-size: 24px;
margin-bottom: 20px;
color: #333;
}}
table {{
width: 100%;
border-collapse: collapse;
font-size: 12px;
}}
th, td {{
border: 1px solid #ccc;
padding: 8px;
text-align: center;
vertical-align: middle;
}}
th {{
background: #f5f5f5;
font-weight: bold;
color: #333;
}}
.time-col {{
background: #f9f9f9;
font-weight: bold;
width: 60px;
}}
.course-cell {{
min-height: 60px;
}}
.course-item {{
background: #e3f2fd;
border-radius: 4px;
padding: 4px;
margin: 2px 0;
line-height: 1.4;
}}
.course-item b {{
color: #1976d2;
font-size: 11px;
}}
.course-item small {{
color: #666;
font-size: 9px;
}}tr:nth-child(even) td {{
background: #fafafa;
}}
</style>
</head>
<body>
<div class="container">
<h1>{title}</h1>
<table>
<thead>
<tr>
<th>节次</th>
{''.join(f'<th>{w}</th>' for w in weekdays)}
</tr>
</thead>
<tbody>
{self._generate_table_body(grid)}
</tbody>
</table>
</div>
</body>
</html>'''
return html
def _generate_table_body(self, grid: list) -> str:
"""生成表格主体 HTML"""
rows = []
for period in range(12):
# 计算时间显示
start_time = self._get_period_time(period + 1)
row_html = f'<tr><td class="time-col">{period + 1}<br><small>{start_time}</small></td>'
for day in range(7):
courses = grid[period][day]
if courses:
cell_content = ''.join(
f'<div class="course-item">{c}</div>'
for c in courses
)
else:
cell_content = ''
row_html += f'<td class="course-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)
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))