理论成功

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
+4
View File
@@ -29,3 +29,7 @@ FLATTEN_OUTPUT=False # 若为True,所有转换结果平铺
AGREED_TOS=False # 手动确认已同意协议(若为False则首次运行时需邮件确认)
AGREEMENT_FILE=./AGREED # 协议同意标记文件路径
ADMIN_EMAIL=admin@example.com # 接收协议请求的管理员邮箱(通常同MAIL_USER)
# 循环监听配置
POLL_INTERVAL_SECONDS=60 # 每次轮询间隔(秒),设为0则只运行一次
RUN_FOREVER=true # 是否无限循环运行
+3
View File
@@ -36,6 +36,9 @@ class Config:
AGREEMENT_FILE = os.getenv("AGREEMENT_FILE", "./AGREED")
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", MAIL_USER)
POLL_INTERVAL_SECONDS = int(os.getenv("POLL_INTERVAL_SECONDS", 60))
RUN_FOREVER = os.getenv("RUN_FOREVER", "true").lower() == "true"
@classmethod
def validate(cls):
assert cls.MAIL_USER and cls.MAIL_PASS, "邮箱账号密码不能为空"
+29 -5
View File
@@ -4,6 +4,17 @@ from config import Config
from logger import logger
class ImageProcessor:
# 格式映射:将常见的小写格式名转换为 Pillow 可识别的格式名
FORMAT_MAP = {
'jpg': 'JPEG',
'jpeg': 'JPEG',
'png': 'PNG',
'webp': 'WEBP',
'gif': 'GIF',
'bmp': 'BMP',
'tiff': 'TIFF',
}
@staticmethod
def resize_by_ratio(img, ratio_width, ratio_height):
"""等比例缩放至目标比例(最长边适配)"""
@@ -31,18 +42,31 @@ class ImageProcessor:
@staticmethod
def convert_image(input_path, output_path, target_format, quality=None):
"""转换图片格式,可选缩放(尺寸解析在外部处理)"""
"""
转换图片格式
target_format: 小写字符串,如 'jpg', 'png', 'webp'
"""
try:
with Image.open(input_path) as img:
# 转换模式(RGBA转RGB对于JPEG
if target_format.lower() in ['jpg', 'jpeg'] and img.mode in ('RGBA', 'P'):
# 获取 Pillow 标准格式名
pillow_format = ImageProcessor.FORMAT_MAP.get(target_format.lower())
if pillow_format is None:
logger.error(f"不支持的输出格式: {target_format}")
return False
# 对于 JPEG 格式,需要处理透明通道
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 target_format.lower() in ['jpg', 'jpeg', 'webp']:
if pillow_format in ('JPEG', 'WEBP'):
save_kwargs['quality'] = quality if quality is not None else Config.DEFAULT_QUALITY
img.save(output_path, format=target_format.upper(), **save_kwargs)
elif pillow_format == 'PNG':
save_kwargs['compress_level'] = 6
img.save(output_path, format=pillow_format, **save_kwargs)
logger.info(f"转换成功: {input_path} -> {output_path}")
return True
except Exception as e:
+121 -45
View File
@@ -6,8 +6,9 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from email.header import decode_header
import os
import re
import time
from email.utils import parseaddr
from config import Config
from logger import logger
@@ -20,7 +21,6 @@ class MailHandler:
self.smtp_port = Config.SMTP_PORT
def connect_inbox(self):
"""根据协议返回收信连接对象"""
if Config.MAIL_PROTOCOL == "IMAP":
conn = imaplib.IMAP4_SSL(Config.IMAP_SERVER, Config.IMAP_PORT)
conn.login(self.user, self.password)
@@ -33,12 +33,10 @@ class MailHandler:
return conn, "POP3"
def fetch_unread_emails(self):
"""获取未读邮件列表,返回列表 [ (msg_id, raw_email) ]"""
conn, proto = self.connect_inbox()
emails = []
try:
if proto == "IMAP":
# 搜索未读邮件
typ, data = conn.search(None, 'UNSEEN')
if typ != 'OK':
logger.error("IMAP搜索未读邮件失败")
@@ -51,8 +49,7 @@ class MailHandler:
emails.append((num, raw_email))
if len(emails) >= Config.MAX_EMAILS_PER_RUN:
break
else: # POP3
# POP3 不支持未读标志,获取所有邮件列表
else:
msg_count = len(conn.list()[1])
for i in range(msg_count, 0, -1):
raw_email = b'\n'.join(conn.retr(i)[1])
@@ -66,25 +63,39 @@ class MailHandler:
return []
finally:
if proto == "IMAP":
conn.close()
conn.quit()
try:
conn.close()
conn.logout()
except:
pass
else:
conn.quit()
def mark_as_deleted(self, msg_id, proto_conn=None):
"""标记邮件为已删除(仅IMAP支持删除,POP3需额外实现)"""
if Config.MAIL_PROTOCOL == "IMAP" and proto_conn:
proto_conn.store(msg_id, '+FLAGS', '\\Deleted')
logger.info(f"标记邮件 {msg_id} 为已删除")
# POP3 删除需要重新连接并执行DELE,这里简化,留痕已足够
def delete_email(self, msg_id, proto_conn=None, proto_type=None):
try:
if proto_type == "IMAP" and proto_conn:
proto_conn.store(msg_id, '+FLAGS', '\\Deleted')
logger.info(f"IMAP标记删除邮件 {msg_id}")
elif proto_type == "POP3" and proto_conn:
proto_conn.dele(msg_id)
logger.info(f"POP3删除邮件 {msg_id}")
except Exception as e:
logger.error(f"删除邮件失败 {msg_id}: {e}")
def expunge_imap(self, conn):
try:
conn.expunge()
logger.info("IMAP已执行expunge,永久删除标记邮件")
except Exception as e:
logger.error(f"expunge失败: {e}")
def is_domain_allowed(self, sender_email):
"""检查发件人域名是否在白名单中"""
if not Config.ALLOWED_DOMAINS:
return True
domain = sender_email.split('@')[-1].lower()
for allowed in Config.ALLOWED_DOMAINS:
if allowed.startswith('*.'):
# 子域名匹配
suffix = allowed[1:] # .example.com
suffix = allowed[1:]
if domain.endswith(suffix):
return True
elif domain == allowed.lower():
@@ -92,42 +103,112 @@ class MailHandler:
return False
def download_attachments(self, raw_email, temp_dir):
"""解析邮件,下载附件(图片、压缩包、manifest.txt)到temp_dir,返回附件列表"""
"""
解析邮件,提取所有附件(图片、压缩包、manifest.txt),包括内联图片。
同时输出邮件结构到日志,便于调试。
"""
msg = email.message_from_bytes(raw_email)
attachments = []
body = ""
# 打印邮件基本信息
subject = msg.get('Subject', '无主题')
from_addr = msg.get('From', '未知')
logger.info(f"解析邮件: 主题={subject}, 发件人={from_addr}")
# 遍历所有 part
part_count = 0
for part in msg.walk():
if part.get_content_maintype() == 'multipart':
continue
part_count += 1
content_type = part.get_content_type()
content_disposition = str(part.get("Content-Disposition", ""))
filename = part.get_filename()
# 解码文件名
if filename:
decoded_filename = decode_header(filename)
real_filename = ""
for text, enc in decoded_filename:
if isinstance(text, bytes):
try:
real_filename += text.decode(enc or 'utf-8', errors='ignore')
except:
real_filename += text.decode('utf-8', errors='ignore')
else:
real_filename += text
filename = real_filename
else:
filename = None
# 输出每个 part 的详细信息
logger.debug(
f"Part {part_count}: Content-Type={content_type}, "
f"Disposition={content_disposition}, Filename={filename}, "
f"Content-Transfer-Encoding={part.get('Content-Transfer-Encoding', 'none')}"
)
# 提取正文
if content_type == "text/plain" and filename is None:
try:
payload = part.get_payload(decode=True)
if payload:
body = payload.decode('utf-8', errors='ignore')
logger.debug(f"获取到正文,长度 {len(body)}")
except Exception as e:
logger.warning(f"解析正文失败: {e}")
continue
# 如果没有文件名,但 Content-Type 是图片类型,尝试生成一个文件名
if not filename and content_type.startswith('image/'):
ext = content_type.split('/')[-1]
# 使用时间戳和随机数
filename = f"inline_image_{int(time.time())}_{part_count}.{ext}"
logger.debug(f"内联图片无文件名,自动命名为 {filename}")
if not filename:
continue
# 判断是否为支持的类型
# 判断是否为支持的文件类型
ext = os.path.splitext(filename)[1].lower()
supported = False
if ext in ['.txt'] and filename == 'manifest.txt':
pass
supported = True
elif ext in ['.zip', '.7z']:
pass
supported = True
elif ext[1:] in Config.SUPPORTED_INPUT_FORMATS:
pass
else:
supported = True
if not supported:
logger.debug(f"跳过不支持的文件: {filename} (扩展名 {ext})")
continue
# 下载附件
filepath = os.path.join(temp_dir, filename)
with open(filepath, 'wb') as f:
f.write(part.get_payload(decode=True))
attachments.append(filepath)
# 提取邮件正文作为指令备用
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == 'text/plain':
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
break
else:
body = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
return attachments, body
payload = part.get_payload(decode=True)
if payload:
with open(filepath, 'wb') as f:
f.write(payload)
attachments.append(filepath)
logger.info(f"成功提取附件: {filename} -> {filepath}")
else:
logger.warning(f"附件 {filename} payload 为空,跳过")
# 如果仍未获取到正文,尝试直接获取
if not body:
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == 'text/plain':
try:
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
break
except:
pass
else:
body = msg.get_payload(decode=True).decode('utf-8', errors='ignore') if msg.get_payload() else ""
logger.info(f"附件提取完成,共 {len(attachments)} 个,正文长度 {len(body)} 字符")
return attachments, body, msg
def send_result(self, recipient, subject, attachments, split_volume_mb=0):
"""发送结果邮件,支持分卷附件"""
msg = MIMEMultipart()
msg['From'] = self.user
msg['To'] = recipient
@@ -135,23 +216,19 @@ class MailHandler:
text = MIMEText("图片转换完成,请查收附件。", 'plain', 'utf-8')
msg.attach(text)
# 处理分卷
from zip_handler import ZipHandler
final_attachments = []
for att in attachments:
if os.path.isdir(att):
# 如果是目录,打包成ZIP
zip_path = att + ".zip"
from zip_handler import ZipHandler
ZipHandler.pack_to_zip(att, zip_path)
att = zip_path
if split_volume_mb > 0 and os.path.getsize(att) > split_volume_mb * 1024 * 1024:
from zip_handler import ZipHandler
parts = ZipHandler.split_zip(att, split_volume_mb)
final_attachments.extend(parts)
else:
final_attachments.append(att)
# 检查每个附件大小是否超过SMTP限制
max_bytes = Config.MAX_ATTACHMENT_SIZE_MB * 1024 * 1024
for att in final_attachments:
if os.path.getsize(att) > max_bytes:
@@ -180,7 +257,6 @@ class MailHandler:
raise
def send_error_report(self, recipient, error_msg):
"""发送脱敏后的错误报告"""
subject = "图片转换工具 - 处理出错"
body = f"处理您的邮件时发生错误,错误信息(已脱敏):\n\n{error_msg}\n\n请检查指令格式或图片格式。"
msg = MIMEText(body, 'plain', 'utf-8')
+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()
+75 -34
View File
@@ -1,19 +1,31 @@
import re
from typing import List, Dict, Tuple, Optional
from logger import logger
class ManifestParser:
def __init__(self):
self.tasks = [] # 存储解析后的任务指令
self.tasks = []
def parse(self, content: str):
"""解析 manifest.txt 或邮件正文,生成内部指令结构"""
def parse(self, content: str) -> List[Dict]:
"""
解析 manifest.txt 或邮件正文,返回任务列表。
支持:
- 全局默认: to: fmt(size), fmt...
- 批量格式: from: fmt to: fmt(size)...
- 精准文件名: from.name: name to: fmt(size)...
- 重命名(多版本): from.name: name 后跟多行 to.name: newname.ext(size)
- 压缩包作用域: in archive.zip: / in .
- 临时作用域: 指令 in archive.zip
"""
lines = [line.strip() for line in content.splitlines() if line.strip() and not line.strip().startswith('#')]
self.tasks = []
current_scope = None # 当前压缩包作用域None表示根目录
current_scope = None # 当前压缩包作用域
pending_rename = None # 等待多行 to.name 的 from.name 任务
i = 0
while i < len(lines):
line = lines[i]
# 处理压缩包作用域开始
if line.startswith('in ') and line.endswith(':'):
archive_name = line[3:-1].strip()
@@ -25,22 +37,19 @@ class ManifestParser:
i += 1
continue
# 检查行内是否包含 "in xxx" 临时作用域
# 检查行内临时作用域
inline_archive = None
if ' in ' in line:
if ' in ' in line and not line.startswith('in '):
parts = line.split(' in ')
cmd_part = parts[0].strip()
archive_part = parts[1].strip()
# 如果archive_part还有更多内容(如"to: xxx"),需重新组合?假设格式严格:"指令 in archive.zip"
if ' ' in archive_part:
# 复杂情况,暂不支持,要求用户使用单独行
pass
else:
# 确保 archive_part 是单纯的文件名(不含空格)
if ' ' not in archive_part:
inline_archive = archive_part
line = cmd_part
# 解析指令
if line.startswith('to:'):
if line.startswith('to:') and not line.startswith('to.name:'):
# 全局默认
targets = self._parse_targets(line[3:])
self.tasks.append({
@@ -63,14 +72,13 @@ class ManifestParser:
'inline_archive': inline_archive
})
elif line.startswith('from.name:'):
# 精准文件名匹配
# 精准文件名匹配,可能后跟多行 to.name:
rest = line[10:].strip()
if ' to.name:' in rest:
# 重命名
# 单行重命名
parts = rest.split(' to.name:')
src_name = parts[0].strip()
dst_spec = parts[1].strip()
# 解析 dst_spec: "newname.ext(size)"
dst_name, size = self._parse_dst_with_size(dst_spec)
self.tasks.append({
'type': 'rename',
@@ -81,30 +89,64 @@ class ManifestParser:
'inline_archive': inline_archive
})
else:
# to: 格式
parts = rest.split(' to:')
src_name = parts[0].strip()
targets = self._parse_targets(parts[1])
self.tasks.append({
'type': 'by_name',
'src_name': src_name,
'targets': targets,
'scope': current_scope,
'inline_archive': inline_archive
})
elif line.startswith('to.name:'):
# 多版本导出中的 to.name 行,需要结合前面的 from.name 处理
# 我们在主流程中采用累积方式,这里简单处理,实际在main中会连续读取
# 为简化,不在此处实现多行组合,而是在主程序中按行处理上下文
# 让主程序负责收集连续的 to.name
pass
# 可能后跟多行 to.name:(多版本导出)
src_name = rest.split(' to:')[0].strip() if ' to:' in rest else rest
# 检查下一行是否以 to.name: 开头
if i + 1 < len(lines) and lines[i+1].startswith('to.name:'):
pending_rename = {
'src_name': src_name,
'targets': [],
'scope': current_scope,
'inline_archive': inline_archive
}
# 跳过当前 from.name 行,后续循环处理 to.name 行
i += 1
continue
elif ' to:' in rest:
# 普通精准转换
parts = rest.split(' to:')
src_name = parts[0].strip()
targets = self._parse_targets(parts[1])
self.tasks.append({
'type': 'by_name',
'src_name': src_name,
'targets': targets,
'scope': current_scope,
'inline_archive': inline_archive
})
else:
# 只有 from.name: xxx,没有规则,无效
logger.warning(f"无效的 from.name 指令: {line}")
elif line.startswith('to.name:') and pending_rename:
# 多版本导出中的一行
dst_spec = line[8:].strip()
dst_name, size = self._parse_dst_with_size(dst_spec)
pending_rename['targets'].append({
'dst_name': dst_name,
'size': size
})
# 检查下一行是否还是 to.name:
if i + 1 >= len(lines) or not lines[i+1].startswith('to.name:'):
# 结束,将 pending_rename 转为多个 rename 任务
for tgt in pending_rename['targets']:
self.tasks.append({
'type': 'rename',
'src_name': pending_rename['src_name'],
'dst_name': tgt['dst_name'],
'size': tgt['size'],
'scope': pending_rename['scope'],
'inline_archive': pending_rename['inline_archive']
})
pending_rename = None
else:
logger.warning(f"无法识别的指令: {line}")
i += 1
return self.tasks
def _parse_targets(self, target_str: str) -> List[Tuple[str, Optional[Tuple]]]:
"""解析 "webp, png(16:9), jpg(800x600)" -> [('webp',None), ('png',('16:9')), ('jpg',('800x600'))]"""
"""解析 "webp, png(16:9), jpg(800x600)" -> [('webp',None), ('png',('ratio',16,9)), ...]"""
items = [t.strip() for t in target_str.split(',')]
result = []
for item in items:
@@ -113,7 +155,6 @@ class ManifestParser:
fmt, size_part = item.split('(', 1)
size_str = size_part.rstrip(')')
if ':' in size_str:
# 比例
w_ratio, h_ratio = map(int, size_str.split(':'))
size = ('ratio', w_ratio, h_ratio)
elif 'x' in size_str: