理论成功

This commit is contained in:
2026-04-18 23:58:27 +08:00
parent 626a4ab2dc
commit 540bb96e55
6 changed files with 616 additions and 242 deletions
+379 -153
View File
@@ -1,5 +1,14 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import shutil
import time
import email
from email.utils import parseaddr
from typing import List, Dict, Optional, Tuple
from PIL import Image
from config import Config
from logger import logger
from temp_manager import TempManager
@@ -9,49 +18,217 @@ from image_processor import ImageProcessor
from zip_handler import ZipHandler
from agreement import AgreementManager
class MailConverter:
def __init__(self):
self.temp_mgr = TempManager()
self.mail = MailHandler()
self.img_proc = ImageProcessor()
self.zip_proc = ZipHandler()
self.parser = ManifestParser()
def run(self):
# 清理遗留临时文件
TempManager.cleanup_stale()
# 检查协议同意
if not AgreementManager.is_agreed():
logger.info("首次使用,需要用户同意协议")
# 发送协议请求给管理员(或发件人?通常发给MAIL_USER)
recipient, subject, body = AgreementManager.request_agreement(Config.ADMIN_EMAIL, self.mail)
self.mail.send_result(recipient, subject, []) # 发送纯文本邮件
# 但这里我们还需监听回复?为简化,要求用户在.env中设置AGREED_TOS=True,或程序检测特定回复邮件
# 简单起见,输出日志提示管理员手动确认
logger.info(f"已向 {recipient} 发送协议同意请求,请在 .env 中设置 AGREED_TOS=True 后重新运行")
return
logger.info("协议已同意,开始处理邮件")
# ==================== 规则匹配核心 ====================
def match_rules_for_file(
self,
filename: str,
file_ext: str,
tasks: List[Dict],
current_archive: Optional[str] = None
) -> List[Dict]:
base_name = os.path.splitext(filename)[0]
results = []
# 初始化临时会话目录
session_dir = self.temp_mgr.init_session()
# 获取未读邮件
emails = self.mail.fetch_unread_emails()
if not emails:
logger.info("没有待处理邮件")
self.temp_mgr.cleanup()
return
applicable = []
for task in tasks:
task_scope = task.get('scope')
task_inline = task.get('inline_archive')
effective_scope = task_inline if task_inline else task_scope
if current_archive:
if effective_scope is None or effective_scope == current_archive:
applicable.append(task)
else:
if effective_scope is None:
applicable.append(task)
for msg_id, raw_email in emails:
# 解析发件人
msg = email.message_from_bytes(raw_email)
sender = msg.get('From')
sender_email = parseaddr(sender)[1]
if not self.mail.is_domain_allowed(sender_email):
logger.warning(f"域名不在白名单: {sender_email},跳过处理")
logger.debug(f"文件 {filename} 在作用域 {current_archive} 下匹配到 {len(applicable)} 条规则")
# 1. 重命名
rename_tasks = [t for t in applicable if t.get('type') == 'rename' and t.get('src_name') == base_name]
if rename_tasks:
for task in rename_tasks:
dst_name = task['dst_name']
dst_ext = os.path.splitext(dst_name)[1][1:].lower()
size = task.get('size')
results.append({
'output_name': dst_name,
'format': dst_ext,
'size': size
})
return results
# 2. 精准文件名
name_tasks = [t for t in applicable if t.get('type') == 'by_name' and t.get('src_name') == base_name]
if name_tasks:
for task in name_tasks:
for fmt, size in task.get('targets', []):
output_name = f"{base_name}.{fmt}"
results.append({
'output_name': output_name,
'format': fmt,
'size': size
})
return results
# 3. 按格式批量
format_tasks = [t for t in applicable if t.get('type') == 'by_format' and t.get('src_format') == file_ext.lower()]
if format_tasks:
for task in format_tasks:
for fmt, size in task.get('targets', []):
output_name = f"{base_name}.{fmt}"
results.append({
'output_name': output_name,
'format': fmt,
'size': size
})
return results
# 4. 全局默认
global_tasks = [t for t in applicable if t.get('type') == 'global']
if global_tasks:
for task in global_tasks:
for fmt, size in task.get('targets', []):
output_name = f"{base_name}.{fmt}"
results.append({
'output_name': output_name,
'format': fmt,
'size': size
})
return results
# 无规则,原样保留
results.append({
'output_name': filename,
'format': file_ext,
'size': None
})
return results
# ==================== 单张图片处理 ====================
def process_single_image(
self,
input_path: str,
output_dir: str,
tasks: List[Dict],
current_archive: Optional[str] = None
) -> List[str]:
filename = os.path.basename(input_path)
file_ext = os.path.splitext(filename)[1][1:].lower()
output_files = []
output_tasks = self.match_rules_for_file(filename, file_ext, tasks, current_archive)
logger.info(f"图片 {filename} 将生成 {len(output_tasks)} 个输出任务")
for task in output_tasks:
out_name = task['output_name']
out_fmt = task['format']
out_size = task['size']
out_path = os.path.join(output_dir, out_name)
try:
if out_size:
with Image.open(input_path) as img:
if out_size[0] == 'ratio':
img = ImageProcessor.resize_by_ratio(img, out_size[1], out_size[2])
elif out_size[0] == 'pixel':
img = ImageProcessor.resize_by_pixel(img, out_size[1], out_size[2])
pillow_format = ImageProcessor.FORMAT_MAP.get(out_fmt.lower())
if not pillow_format:
logger.error(f"不支持的输出格式: {out_fmt}")
continue
if pillow_format == 'JPEG' and img.mode in ('RGBA', 'P'):
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
rgb_img.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = rgb_img
save_kwargs = {}
if pillow_format in ('JPEG', 'WEBP'):
save_kwargs['quality'] = Config.DEFAULT_QUALITY
img.save(out_path, format=pillow_format, **save_kwargs)
logger.info(f"转换+缩放成功: {input_path} -> {out_path}")
else:
success = ImageProcessor.convert_image(input_path, out_path, out_fmt, Config.DEFAULT_QUALITY)
if not success:
logger.error(f"转换失败: {input_path} -> {out_path}")
continue
output_files.append(out_path)
except Exception as e:
logger.error(f"处理图片异常 {input_path}: {e}", exc_info=True)
continue
# 下载附件和正文
attachments, body = self.mail.download_attachments(raw_email, session_dir)
# 合并 manifest.txt 内容
return output_files
# ==================== 压缩包处理 ====================
def process_archive(
self,
archive_path: str,
output_base_dir: str,
tasks: List[Dict]
) -> List[str]:
archive_name = os.path.basename(archive_path)
extract_dir = os.path.join(output_base_dir, f"ext_{archive_name}")
os.makedirs(extract_dir, exist_ok=True)
ext = os.path.splitext(archive_path)[1].lower()
try:
if ext == '.zip':
ZipHandler.extract_zip(archive_path, extract_dir)
elif ext == '.7z':
ZipHandler.extract_7z(archive_path, extract_dir)
else:
logger.warning(f"不支持的压缩包格式: {archive_path}")
return []
except Exception as e:
logger.error(f"解压失败 {archive_path}: {e}")
return []
all_output_files = []
for root, _, files in os.walk(extract_dir):
for file in files:
file_path = os.path.join(root, file)
file_ext_lower = os.path.splitext(file)[1][1:].lower()
if file_ext_lower not in Config.SUPPORTED_INPUT_FORMATS:
continue
output_files = self.process_single_image(
file_path,
output_base_dir,
tasks,
current_archive=archive_name
)
all_output_files.extend(output_files)
if not Config.KEEP_TEMP_FILES:
shutil.rmtree(extract_dir, ignore_errors=True)
return all_output_files
# ==================== 邮件处理主流程 ====================
def process_one_email(self, msg_id, raw_email, proto_conn, proto_type) -> bool:
msg = email.message_from_bytes(raw_email)
sender = msg.get('From')
sender_email = parseaddr(sender)[1]
subject = msg.get('Subject', '无主题')
logger.info(f"开始处理邮件: {subject} from {sender_email}")
if not self.mail.is_domain_allowed(sender_email):
logger.warning(f"域名不在白名单: {sender_email},删除原邮件")
self.mail.delete_email(msg_id, proto_conn, proto_type)
return False
mail_work_dir = os.path.join(Config.TEMP_DIR, f"mail_{msg_id}_{int(time.time())}")
os.makedirs(mail_work_dir, exist_ok=True)
try:
attachments, body, _ = self.mail.download_attachments(raw_email, mail_work_dir)
logger.info(f"下载到附件: {[os.path.basename(a) for a in attachments]}")
manifest_path = None
for att in attachments:
if os.path.basename(att) == 'manifest.txt':
@@ -60,144 +237,193 @@ class MailConverter:
if manifest_path:
with open(manifest_path, 'r', encoding='utf-8') as f:
rule_content = f.read()
logger.info("使用 manifest.txt 规则")
else:
rule_content = body # 使用邮件正文
rule_content = body
logger.info("使用邮件正文规则")
if not rule_content.strip():
logger.warning("无转换规则,跳过")
continue
logger.info(f"规则原始内容:\n{rule_content}")
# 解析规则
parser = ManifestParser()
tasks = parser.parse(rule_content)
if not rule_content or not rule_content.strip():
logger.warning("规则内容为空,删除原邮件")
self.mail.delete_email(msg_id, proto_conn, proto_type)
return False
tasks = self.parser.parse(rule_content)
logger.info(f"解析出 {len(tasks)} 条规则指令")
for idx, t in enumerate(tasks):
logger.debug(f"规则{idx}: {t}")
all_output_files = []
original_info = []
# 处理每个附件(图片或压缩包)
result_items = [] # 每个元素为 (output_path_or_dir, original_attachment_name)
for att in attachments:
if os.path.basename(att) == 'manifest.txt':
continue
if ZipHandler.is_archive(att):
# 处理压缩包
output_dir = self.process_archive(att, tasks, session_dir)
if output_dir:
# 压缩包处理结果可能是一个目录(内含多个图片结果),或者单个文件?
# 为保持原样式,如果输出目录内只有一个文件,则返回该文件;否则打包目录
result_items.append((output_dir, os.path.basename(att)))
else:
# 普通图片附件
output_dir = self.process_single_image(att, tasks, session_dir)
if output_dir:
result_items.append((output_dir, os.path.basename(att)))
att_basename = os.path.basename(att)
att_ext = os.path.splitext(att)[1][1:].lower()
# 构建返回附件列表(根据原样式规则)
return_attachments = []
for output, original_name in result_items:
if Config.FLATTEN_OUTPUT:
# 平铺所有文件
if os.path.isdir(output):
for f in os.listdir(output):
return_attachments.append(os.path.join(output, f))
else:
return_attachments.append(output)
if ZipHandler.is_archive(att):
archive_output_dir = os.path.join(mail_work_dir, f"out_{att_basename}")
os.makedirs(archive_output_dir, exist_ok=True)
output_files = self.process_archive(att, archive_output_dir, tasks)
all_output_files.extend(output_files)
original_info.append({
'original_name': att_basename,
'output_dir': archive_output_dir,
'output_files': output_files
})
elif att_ext in Config.SUPPORTED_INPUT_FORMATS:
single_output_dir = os.path.join(mail_work_dir, f"out_{att_basename}")
os.makedirs(single_output_dir, exist_ok=True)
output_files = self.process_single_image(att, single_output_dir, tasks, current_archive=None)
all_output_files.extend(output_files)
original_info.append({
'original_name': att_basename,
'output_dir': single_output_dir,
'output_files': output_files
})
else:
# 保持原附件数量:如果输出是一个目录,且目录内文件数==1,则直接返回该文件;否则打包为ZIP
if os.path.isdir(output):
files = [f for f in os.listdir(output) if os.path.isfile(os.path.join(output, f))]
if len(files) == 1:
return_attachments.append(os.path.join(output, files[0]))
else:
# 打包目录
zip_path = os.path.join(session_dir, f"{os.path.splitext(original_name)[0]}_result.zip")
ZipHandler.pack_to_zip(output, zip_path)
return_attachments.append(zip_path)
else:
return_attachments.append(output)
logger.warning(f"跳过不支持的文件: {att_basename} (扩展名: {att_ext})")
logger.info(f"总共生成 {len(all_output_files)} 个输出文件")
if not all_output_files:
logger.warning("没有生成任何转换结果,删除原邮件")
self.mail.delete_email(msg_id, proto_conn, proto_type)
return False
# 保持原附件样式打包
return_attachments = []
for info in original_info:
if len(info['output_files']) == 1:
return_attachments.append(info['output_files'][0])
else:
zip_name = f"{os.path.splitext(info['original_name'])[0]}_result.zip"
zip_path = os.path.join(mail_work_dir, zip_name)
ZipHandler.pack_to_zip(info['output_dir'], zip_path)
return_attachments.append(zip_path)
if Config.FLATTEN_OUTPUT:
return_attachments = all_output_files
# ========== 构建详细主题 ==========
conversion_details = []
for info in original_info:
orig_name = info['original_name']
out_files = info['output_files']
if len(out_files) == 1:
out_name = os.path.basename(out_files[0])
out_ext = os.path.splitext(out_name)[1][1:].upper()
conversion_details.append(f"{orig_name}{out_ext}")
else:
conversion_details.append(f"{orig_name}{len(out_files)}个文件")
if len(conversion_details) > 2:
detail_str = f"{', '.join(conversion_details[:2])}{len(conversion_details)}"
else:
detail_str = ', '.join(conversion_details)
time_str = time.strftime("%Y-%m-%d %H:%M:%S")
result_subject = f"MailC[{time_str}]{detail_str}"
# 发送结果
subject = f"转换结果: {msg.get('Subject', '无主题')}"
self.mail.send_result(
recipient=sender_email,
subject=result_subject,
attachments=return_attachments,
split_volume_mb=Config.SPLIT_VOLUME_SIZE_MB
)
logger.info(f"结果已发送至 {sender_email}")
self.mail.delete_email(msg_id, proto_conn, proto_type)
return True
except Exception as e:
error_msg = f"处理邮件时异常: {str(e)}"
logger.error(error_msg, exc_info=True)
try:
self.mail.send_result(sender_email, subject, return_attachments, Config.SPLIT_VOLUME_SIZE_MB)
# 删除原邮件(留痕已在日志中)
# 对于IMAP,我们可以删除原邮件
# 注意:需要重新连接,因为之前的连接已关闭。简单起见,不在这里实现删除,留痕已足够
# 留痕记录
logger.info(f"成功处理邮件 from {sender_email}, msg_id={msg_id}")
except Exception as e:
error_msg = f"发送结果失败: {str(e)}"
logger.error(error_msg)
self.mail.send_error_report(sender_email, error_msg)
except Exception as send_err:
logger.error(f"发送错误报告失败: {send_err}")
self.mail.delete_email(msg_id, proto_conn, proto_type)
return False
finally:
if not Config.KEEP_TEMP_FILES:
shutil.rmtree(mail_work_dir, ignore_errors=True)
# 清理会话临时文件(若配置)
if not Config.KEEP_TEMP_FILES:
self.temp_mgr.cleanup()
# ==================== 循环监听 ====================
def run_forever(self):
if not AgreementManager.is_agreed():
logger.info("首次使用,需要同意协议")
recipient, subject, body = AgreementManager.request_agreement(Config.ADMIN_EMAIL, self.mail)
self.mail.send_result(recipient, subject, [])
logger.info(f"协议请求已发送至 {recipient},请在 .env 中设置 AGREED_TOS=True")
return
def process_single_image(self, img_path, tasks, base_dir):
"""处理单张图片,返回输出目录(存放所有转换结果)"""
# 根据tasks匹配规则,生成输出文件列表
output_dir = os.path.join(base_dir, f"out_{os.path.basename(img_path)}")
os.makedirs(output_dir, exist_ok=True)
# 简化匹配逻辑:只实现全局规则和文件名匹配(实际可扩展)
# 此处需要完整实现优先级匹配,为简洁,示例只做基础
# 实际项目中应实现完整的规则引擎,这里模拟
filename = os.path.splitext(os.path.basename(img_path))[0]
# 找是否有匹配的from.name规则
matched = False
for task in tasks:
if task['type'] == 'by_name' and task['src_name'] == filename:
for fmt, size in task['targets']:
out_path = os.path.join(output_dir, f"{filename}.{fmt}")
# 缩放
img = Image.open(img_path)
if size:
if size[0] == 'ratio':
img = ImageProcessor.resize_by_ratio(img, size[1], size[2])
elif size[0] == 'pixel':
img = ImageProcessor.resize_by_pixel(img, size[1], size[2])
img.save(out_path, format=fmt.upper(), quality=Config.DEFAULT_QUALITY)
else:
ImageProcessor.convert_image(img_path, out_path, fmt)
matched = True
logger.info(f"启动监听循环,轮询间隔 {Config.POLL_INTERVAL_SECONDS} 秒,{'无限循环' if Config.RUN_FOREVER else '单次运行'}")
while True:
TempManager.cleanup_stale()
conn = None
proto_type = None
try:
conn, proto_type = self.mail.connect_inbox()
if proto_type == "IMAP":
typ, data = conn.search(None, 'UNSEEN')
if typ != 'OK':
logger.error("IMAP搜索失败")
continue
msg_ids = data[0].split()
else:
msg_count = len(conn.list()[1])
msg_ids = list(range(1, msg_count + 1))
if not msg_ids:
logger.debug("无新邮件")
else:
logger.info(f"发现 {len(msg_ids)} 封待处理邮件")
for msg_id in msg_ids[:Config.MAX_EMAILS_PER_RUN]:
try:
if proto_type == "IMAP":
typ, msg_data = conn.fetch(msg_id, '(RFC822)')
if typ != 'OK':
continue
raw_email = msg_data[0][1]
else:
raw_email = b'\n'.join(conn.retr(msg_id)[1])
self.process_one_email(msg_id, raw_email, conn, proto_type)
except Exception as e:
logger.error(f"处理邮件 {msg_id} 崩溃: {e}", exc_info=True)
try:
self.mail.delete_email(msg_id, conn, proto_type)
except:
pass
if proto_type == "IMAP":
try:
conn.expunge()
except:
pass
except Exception as e:
logger.error(f"主循环异常: {e}", exc_info=True)
finally:
if conn:
try:
if proto_type == "IMAP":
conn.close()
conn.logout()
else:
conn.quit()
except:
pass
if not Config.RUN_FOREVER:
break
if not matched:
# 全局规则
for task in tasks:
if task['type'] == 'global':
for fmt, size in task['targets']:
out_path = os.path.join(output_dir, f"{filename}.{fmt}")
ImageProcessor.convert_image(img_path, out_path, fmt)
matched = True
break
if not matched:
# 无规则,原样复制
shutil.copy(img_path, os.path.join(output_dir, os.path.basename(img_path)))
return output_dir
time.sleep(Config.POLL_INTERVAL_SECONDS)
def process_archive(self, archive_path, tasks, base_dir):
"""处理压缩包,返回输出目录"""
extract_dir = os.path.join(base_dir, f"ext_{os.path.basename(archive_path)}")
os.makedirs(extract_dir, exist_ok=True)
ext = os.path.splitext(archive_path)[1].lower()
if ext == '.zip':
ZipHandler.extract_zip(archive_path, extract_dir)
elif ext == '.7z':
ZipHandler.extract_7z(archive_path, extract_dir)
else:
return None
# 遍历提取出的所有图片,应用规则(规则作用域可能指定该压缩包)
output_root = os.path.join(base_dir, f"out_{os.path.basename(archive_path)}")
os.makedirs(output_root, exist_ok=True)
for root, _, files in os.walk(extract_dir):
for file in files:
if file.split('.')[-1].lower() in Config.SUPPORTED_INPUT_FORMATS:
img_path = os.path.join(root, file)
# 对每个图片应用规则(同上,简化)
out_subdir = self.process_single_image(img_path, tasks, output_root)
# 合并输出目录
for f in os.listdir(out_subdir):
shutil.move(os.path.join(out_subdir, f), output_root)
return output_root
def main():
converter = MailConverter()
converter.run_forever()
if __name__ == "__main__":
converter = MailConverter()
converter.run()
main()