理论成功
This commit is contained in:
@@ -29,3 +29,7 @@ FLATTEN_OUTPUT=False # 若为True,所有转换结果平铺
|
|||||||
AGREED_TOS=False # 手动确认已同意协议(若为False则首次运行时需邮件确认)
|
AGREED_TOS=False # 手动确认已同意协议(若为False则首次运行时需邮件确认)
|
||||||
AGREEMENT_FILE=./AGREED # 协议同意标记文件路径
|
AGREEMENT_FILE=./AGREED # 协议同意标记文件路径
|
||||||
ADMIN_EMAIL=admin@example.com # 接收协议请求的管理员邮箱(通常同MAIL_USER)
|
ADMIN_EMAIL=admin@example.com # 接收协议请求的管理员邮箱(通常同MAIL_USER)
|
||||||
|
|
||||||
|
# 循环监听配置
|
||||||
|
POLL_INTERVAL_SECONDS=60 # 每次轮询间隔(秒),设为0则只运行一次
|
||||||
|
RUN_FOREVER=true # 是否无限循环运行
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class Config:
|
|||||||
AGREEMENT_FILE = os.getenv("AGREEMENT_FILE", "./AGREED")
|
AGREEMENT_FILE = os.getenv("AGREEMENT_FILE", "./AGREED")
|
||||||
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", MAIL_USER)
|
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
|
@classmethod
|
||||||
def validate(cls):
|
def validate(cls):
|
||||||
assert cls.MAIL_USER and cls.MAIL_PASS, "邮箱账号密码不能为空"
|
assert cls.MAIL_USER and cls.MAIL_PASS, "邮箱账号密码不能为空"
|
||||||
|
|||||||
+29
-5
@@ -4,6 +4,17 @@ from config import Config
|
|||||||
from logger import logger
|
from logger import logger
|
||||||
|
|
||||||
class ImageProcessor:
|
class ImageProcessor:
|
||||||
|
# 格式映射:将常见的小写格式名转换为 Pillow 可识别的格式名
|
||||||
|
FORMAT_MAP = {
|
||||||
|
'jpg': 'JPEG',
|
||||||
|
'jpeg': 'JPEG',
|
||||||
|
'png': 'PNG',
|
||||||
|
'webp': 'WEBP',
|
||||||
|
'gif': 'GIF',
|
||||||
|
'bmp': 'BMP',
|
||||||
|
'tiff': 'TIFF',
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resize_by_ratio(img, ratio_width, ratio_height):
|
def resize_by_ratio(img, ratio_width, ratio_height):
|
||||||
"""等比例缩放至目标比例(最长边适配)"""
|
"""等比例缩放至目标比例(最长边适配)"""
|
||||||
@@ -31,18 +42,31 @@ class ImageProcessor:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def convert_image(input_path, output_path, target_format, quality=None):
|
def convert_image(input_path, output_path, target_format, quality=None):
|
||||||
"""转换图片格式,可选缩放(尺寸解析在外部处理)"""
|
"""
|
||||||
|
转换图片格式
|
||||||
|
target_format: 小写字符串,如 'jpg', 'png', 'webp'
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with Image.open(input_path) as img:
|
with Image.open(input_path) as img:
|
||||||
# 转换模式(RGBA转RGB对于JPEG)
|
# 获取 Pillow 标准格式名
|
||||||
if target_format.lower() in ['jpg', 'jpeg'] and img.mode in ('RGBA', 'P'):
|
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 = Image.new('RGB', img.size, (255, 255, 255))
|
||||||
rgb_img.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
|
rgb_img.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
|
||||||
img = rgb_img
|
img = rgb_img
|
||||||
|
|
||||||
save_kwargs = {}
|
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
|
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}")
|
logger.info(f"转换成功: {input_path} -> {output_path}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+110
-34
@@ -6,8 +6,9 @@ from email.mime.text import MIMEText
|
|||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.base import MIMEBase
|
from email.mime.base import MIMEBase
|
||||||
from email import encoders
|
from email import encoders
|
||||||
|
from email.header import decode_header
|
||||||
import os
|
import os
|
||||||
import re
|
import time
|
||||||
from email.utils import parseaddr
|
from email.utils import parseaddr
|
||||||
from config import Config
|
from config import Config
|
||||||
from logger import logger
|
from logger import logger
|
||||||
@@ -20,7 +21,6 @@ class MailHandler:
|
|||||||
self.smtp_port = Config.SMTP_PORT
|
self.smtp_port = Config.SMTP_PORT
|
||||||
|
|
||||||
def connect_inbox(self):
|
def connect_inbox(self):
|
||||||
"""根据协议返回收信连接对象"""
|
|
||||||
if Config.MAIL_PROTOCOL == "IMAP":
|
if Config.MAIL_PROTOCOL == "IMAP":
|
||||||
conn = imaplib.IMAP4_SSL(Config.IMAP_SERVER, Config.IMAP_PORT)
|
conn = imaplib.IMAP4_SSL(Config.IMAP_SERVER, Config.IMAP_PORT)
|
||||||
conn.login(self.user, self.password)
|
conn.login(self.user, self.password)
|
||||||
@@ -33,12 +33,10 @@ class MailHandler:
|
|||||||
return conn, "POP3"
|
return conn, "POP3"
|
||||||
|
|
||||||
def fetch_unread_emails(self):
|
def fetch_unread_emails(self):
|
||||||
"""获取未读邮件列表,返回列表 [ (msg_id, raw_email) ]"""
|
|
||||||
conn, proto = self.connect_inbox()
|
conn, proto = self.connect_inbox()
|
||||||
emails = []
|
emails = []
|
||||||
try:
|
try:
|
||||||
if proto == "IMAP":
|
if proto == "IMAP":
|
||||||
# 搜索未读邮件
|
|
||||||
typ, data = conn.search(None, 'UNSEEN')
|
typ, data = conn.search(None, 'UNSEEN')
|
||||||
if typ != 'OK':
|
if typ != 'OK':
|
||||||
logger.error("IMAP搜索未读邮件失败")
|
logger.error("IMAP搜索未读邮件失败")
|
||||||
@@ -51,8 +49,7 @@ class MailHandler:
|
|||||||
emails.append((num, raw_email))
|
emails.append((num, raw_email))
|
||||||
if len(emails) >= Config.MAX_EMAILS_PER_RUN:
|
if len(emails) >= Config.MAX_EMAILS_PER_RUN:
|
||||||
break
|
break
|
||||||
else: # POP3
|
else:
|
||||||
# POP3 不支持未读标志,获取所有邮件列表
|
|
||||||
msg_count = len(conn.list()[1])
|
msg_count = len(conn.list()[1])
|
||||||
for i in range(msg_count, 0, -1):
|
for i in range(msg_count, 0, -1):
|
||||||
raw_email = b'\n'.join(conn.retr(i)[1])
|
raw_email = b'\n'.join(conn.retr(i)[1])
|
||||||
@@ -66,25 +63,39 @@ class MailHandler:
|
|||||||
return []
|
return []
|
||||||
finally:
|
finally:
|
||||||
if proto == "IMAP":
|
if proto == "IMAP":
|
||||||
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
conn.logout()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
conn.quit()
|
conn.quit()
|
||||||
|
|
||||||
def mark_as_deleted(self, msg_id, proto_conn=None):
|
def delete_email(self, msg_id, proto_conn=None, proto_type=None):
|
||||||
"""标记邮件为已删除(仅IMAP支持删除,POP3需额外实现)"""
|
try:
|
||||||
if Config.MAIL_PROTOCOL == "IMAP" and proto_conn:
|
if proto_type == "IMAP" and proto_conn:
|
||||||
proto_conn.store(msg_id, '+FLAGS', '\\Deleted')
|
proto_conn.store(msg_id, '+FLAGS', '\\Deleted')
|
||||||
logger.info(f"标记邮件 {msg_id} 为已删除")
|
logger.info(f"IMAP标记删除邮件 {msg_id}")
|
||||||
# POP3 删除需要重新连接并执行DELE,这里简化,留痕已足够
|
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):
|
def is_domain_allowed(self, sender_email):
|
||||||
"""检查发件人域名是否在白名单中"""
|
|
||||||
if not Config.ALLOWED_DOMAINS:
|
if not Config.ALLOWED_DOMAINS:
|
||||||
return True
|
return True
|
||||||
domain = sender_email.split('@')[-1].lower()
|
domain = sender_email.split('@')[-1].lower()
|
||||||
for allowed in Config.ALLOWED_DOMAINS:
|
for allowed in Config.ALLOWED_DOMAINS:
|
||||||
if allowed.startswith('*.'):
|
if allowed.startswith('*.'):
|
||||||
# 子域名匹配
|
suffix = allowed[1:]
|
||||||
suffix = allowed[1:] # .example.com
|
|
||||||
if domain.endswith(suffix):
|
if domain.endswith(suffix):
|
||||||
return True
|
return True
|
||||||
elif domain == allowed.lower():
|
elif domain == allowed.lower():
|
||||||
@@ -92,42 +103,112 @@ class MailHandler:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def download_attachments(self, raw_email, temp_dir):
|
def download_attachments(self, raw_email, temp_dir):
|
||||||
"""解析邮件,下载附件(图片、压缩包、manifest.txt)到temp_dir,返回附件列表"""
|
"""
|
||||||
|
解析邮件,提取所有附件(图片、压缩包、manifest.txt),包括内联图片。
|
||||||
|
同时输出邮件结构到日志,便于调试。
|
||||||
|
"""
|
||||||
msg = email.message_from_bytes(raw_email)
|
msg = email.message_from_bytes(raw_email)
|
||||||
attachments = []
|
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():
|
for part in msg.walk():
|
||||||
if part.get_content_maintype() == 'multipart':
|
part_count += 1
|
||||||
continue
|
content_type = part.get_content_type()
|
||||||
|
content_disposition = str(part.get("Content-Disposition", ""))
|
||||||
filename = part.get_filename()
|
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:
|
if not filename:
|
||||||
continue
|
continue
|
||||||
# 判断是否为支持的类型
|
|
||||||
|
# 判断是否为支持的文件类型
|
||||||
ext = os.path.splitext(filename)[1].lower()
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
supported = False
|
||||||
if ext in ['.txt'] and filename == 'manifest.txt':
|
if ext in ['.txt'] and filename == 'manifest.txt':
|
||||||
pass
|
supported = True
|
||||||
elif ext in ['.zip', '.7z']:
|
elif ext in ['.zip', '.7z']:
|
||||||
pass
|
supported = True
|
||||||
elif ext[1:] in Config.SUPPORTED_INPUT_FORMATS:
|
elif ext[1:] in Config.SUPPORTED_INPUT_FORMATS:
|
||||||
pass
|
supported = True
|
||||||
else:
|
|
||||||
|
if not supported:
|
||||||
|
logger.debug(f"跳过不支持的文件: {filename} (扩展名 {ext})")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 下载附件
|
||||||
filepath = os.path.join(temp_dir, filename)
|
filepath = os.path.join(temp_dir, filename)
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, 'wb') as f:
|
||||||
f.write(part.get_payload(decode=True))
|
f.write(payload)
|
||||||
attachments.append(filepath)
|
attachments.append(filepath)
|
||||||
# 提取邮件正文作为指令备用
|
logger.info(f"成功提取附件: {filename} -> {filepath}")
|
||||||
body = ""
|
else:
|
||||||
|
logger.warning(f"附件 {filename} payload 为空,跳过")
|
||||||
|
|
||||||
|
# 如果仍未获取到正文,尝试直接获取
|
||||||
|
if not body:
|
||||||
if msg.is_multipart():
|
if msg.is_multipart():
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
if part.get_content_type() == 'text/plain':
|
if part.get_content_type() == 'text/plain':
|
||||||
|
try:
|
||||||
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
||||||
break
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
body = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
|
body = msg.get_payload(decode=True).decode('utf-8', errors='ignore') if msg.get_payload() else ""
|
||||||
return attachments, body
|
|
||||||
|
logger.info(f"附件提取完成,共 {len(attachments)} 个,正文长度 {len(body)} 字符")
|
||||||
|
return attachments, body, msg
|
||||||
|
|
||||||
def send_result(self, recipient, subject, attachments, split_volume_mb=0):
|
def send_result(self, recipient, subject, attachments, split_volume_mb=0):
|
||||||
"""发送结果邮件,支持分卷附件"""
|
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg['From'] = self.user
|
msg['From'] = self.user
|
||||||
msg['To'] = recipient
|
msg['To'] = recipient
|
||||||
@@ -135,23 +216,19 @@ class MailHandler:
|
|||||||
text = MIMEText("图片转换完成,请查收附件。", 'plain', 'utf-8')
|
text = MIMEText("图片转换完成,请查收附件。", 'plain', 'utf-8')
|
||||||
msg.attach(text)
|
msg.attach(text)
|
||||||
|
|
||||||
# 处理分卷
|
from zip_handler import ZipHandler
|
||||||
final_attachments = []
|
final_attachments = []
|
||||||
for att in attachments:
|
for att in attachments:
|
||||||
if os.path.isdir(att):
|
if os.path.isdir(att):
|
||||||
# 如果是目录,打包成ZIP
|
|
||||||
zip_path = att + ".zip"
|
zip_path = att + ".zip"
|
||||||
from zip_handler import ZipHandler
|
|
||||||
ZipHandler.pack_to_zip(att, zip_path)
|
ZipHandler.pack_to_zip(att, zip_path)
|
||||||
att = zip_path
|
att = zip_path
|
||||||
if split_volume_mb > 0 and os.path.getsize(att) > split_volume_mb * 1024 * 1024:
|
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)
|
parts = ZipHandler.split_zip(att, split_volume_mb)
|
||||||
final_attachments.extend(parts)
|
final_attachments.extend(parts)
|
||||||
else:
|
else:
|
||||||
final_attachments.append(att)
|
final_attachments.append(att)
|
||||||
|
|
||||||
# 检查每个附件大小是否超过SMTP限制
|
|
||||||
max_bytes = Config.MAX_ATTACHMENT_SIZE_MB * 1024 * 1024
|
max_bytes = Config.MAX_ATTACHMENT_SIZE_MB * 1024 * 1024
|
||||||
for att in final_attachments:
|
for att in final_attachments:
|
||||||
if os.path.getsize(att) > max_bytes:
|
if os.path.getsize(att) > max_bytes:
|
||||||
@@ -180,7 +257,6 @@ class MailHandler:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def send_error_report(self, recipient, error_msg):
|
def send_error_report(self, recipient, error_msg):
|
||||||
"""发送脱敏后的错误报告"""
|
|
||||||
subject = "图片转换工具 - 处理出错"
|
subject = "图片转换工具 - 处理出错"
|
||||||
body = f"处理您的邮件时发生错误,错误信息(已脱敏):\n\n{error_msg}\n\n请检查指令格式或图片格式。"
|
body = f"处理您的邮件时发生错误,错误信息(已脱敏):\n\n{error_msg}\n\n请检查指令格式或图片格式。"
|
||||||
msg = MIMEText(body, 'plain', 'utf-8')
|
msg = MIMEText(body, 'plain', 'utf-8')
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
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 config import Config
|
||||||
from logger import logger
|
from logger import logger
|
||||||
from temp_manager import TempManager
|
from temp_manager import TempManager
|
||||||
@@ -9,49 +18,217 @@ from image_processor import ImageProcessor
|
|||||||
from zip_handler import ZipHandler
|
from zip_handler import ZipHandler
|
||||||
from agreement import AgreementManager
|
from agreement import AgreementManager
|
||||||
|
|
||||||
|
|
||||||
class MailConverter:
|
class MailConverter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.temp_mgr = TempManager()
|
self.temp_mgr = TempManager()
|
||||||
self.mail = MailHandler()
|
self.mail = MailHandler()
|
||||||
self.img_proc = ImageProcessor()
|
self.img_proc = ImageProcessor()
|
||||||
self.zip_proc = ZipHandler()
|
self.zip_proc = ZipHandler()
|
||||||
|
self.parser = ManifestParser()
|
||||||
|
|
||||||
def run(self):
|
# ==================== 规则匹配核心 ====================
|
||||||
# 清理遗留临时文件
|
def match_rules_for_file(
|
||||||
TempManager.cleanup_stale()
|
self,
|
||||||
# 检查协议同意
|
filename: str,
|
||||||
if not AgreementManager.is_agreed():
|
file_ext: str,
|
||||||
logger.info("首次使用,需要用户同意协议")
|
tasks: List[Dict],
|
||||||
# 发送协议请求给管理员(或发件人?通常发给MAIL_USER)
|
current_archive: Optional[str] = None
|
||||||
recipient, subject, body = AgreementManager.request_agreement(Config.ADMIN_EMAIL, self.mail)
|
) -> List[Dict]:
|
||||||
self.mail.send_result(recipient, subject, []) # 发送纯文本邮件
|
base_name = os.path.splitext(filename)[0]
|
||||||
# 但这里我们还需监听回复?为简化,要求用户在.env中设置AGREED_TOS=True,或程序检测特定回复邮件
|
results = []
|
||||||
# 简单起见,输出日志提示管理员手动确认
|
|
||||||
logger.info(f"已向 {recipient} 发送协议同意请求,请在 .env 中设置 AGREED_TOS=True 后重新运行")
|
|
||||||
return
|
|
||||||
logger.info("协议已同意,开始处理邮件")
|
|
||||||
|
|
||||||
# 初始化临时会话目录
|
applicable = []
|
||||||
session_dir = self.temp_mgr.init_session()
|
for task in tasks:
|
||||||
# 获取未读邮件
|
task_scope = task.get('scope')
|
||||||
emails = self.mail.fetch_unread_emails()
|
task_inline = task.get('inline_archive')
|
||||||
if not emails:
|
effective_scope = task_inline if task_inline else task_scope
|
||||||
logger.info("没有待处理邮件")
|
if current_archive:
|
||||||
self.temp_mgr.cleanup()
|
if effective_scope is None or effective_scope == current_archive:
|
||||||
return
|
applicable.append(task)
|
||||||
|
else:
|
||||||
|
if effective_scope is None:
|
||||||
|
applicable.append(task)
|
||||||
|
|
||||||
for msg_id, raw_email in emails:
|
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
|
||||||
|
|
||||||
|
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)
|
msg = email.message_from_bytes(raw_email)
|
||||||
sender = msg.get('From')
|
sender = msg.get('From')
|
||||||
sender_email = parseaddr(sender)[1]
|
sender_email = parseaddr(sender)[1]
|
||||||
if not self.mail.is_domain_allowed(sender_email):
|
subject = msg.get('Subject', '无主题')
|
||||||
logger.warning(f"域名不在白名单: {sender_email},跳过处理")
|
logger.info(f"开始处理邮件: {subject} from {sender_email}")
|
||||||
continue
|
|
||||||
|
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]}")
|
||||||
|
|
||||||
# 下载附件和正文
|
|
||||||
attachments, body = self.mail.download_attachments(raw_email, session_dir)
|
|
||||||
# 合并 manifest.txt 内容
|
|
||||||
manifest_path = None
|
manifest_path = None
|
||||||
for att in attachments:
|
for att in attachments:
|
||||||
if os.path.basename(att) == 'manifest.txt':
|
if os.path.basename(att) == 'manifest.txt':
|
||||||
@@ -60,144 +237,193 @@ class MailConverter:
|
|||||||
if manifest_path:
|
if manifest_path:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
rule_content = f.read()
|
rule_content = f.read()
|
||||||
|
logger.info("使用 manifest.txt 规则")
|
||||||
else:
|
else:
|
||||||
rule_content = body # 使用邮件正文
|
rule_content = body
|
||||||
|
logger.info("使用邮件正文规则")
|
||||||
|
|
||||||
if not rule_content.strip():
|
logger.info(f"规则原始内容:\n{rule_content}")
|
||||||
logger.warning("无转换规则,跳过")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 解析规则
|
if not rule_content or not rule_content.strip():
|
||||||
parser = ManifestParser()
|
logger.warning("规则内容为空,删除原邮件")
|
||||||
tasks = parser.parse(rule_content)
|
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:
|
for att in attachments:
|
||||||
if os.path.basename(att) == 'manifest.txt':
|
if os.path.basename(att) == 'manifest.txt':
|
||||||
continue
|
continue
|
||||||
if ZipHandler.is_archive(att):
|
att_basename = os.path.basename(att)
|
||||||
# 处理压缩包
|
att_ext = os.path.splitext(att)[1][1:].lower()
|
||||||
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)))
|
|
||||||
|
|
||||||
# 构建返回附件列表(根据原样式规则)
|
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:
|
||||||
|
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 = []
|
return_attachments = []
|
||||||
for output, original_name in result_items:
|
for info in original_info:
|
||||||
if Config.FLATTEN_OUTPUT:
|
if len(info['output_files']) == 1:
|
||||||
# 平铺所有文件
|
return_attachments.append(info['output_files'][0])
|
||||||
if os.path.isdir(output):
|
|
||||||
for f in os.listdir(output):
|
|
||||||
return_attachments.append(os.path.join(output, f))
|
|
||||||
else:
|
else:
|
||||||
return_attachments.append(output)
|
zip_name = f"{os.path.splitext(info['original_name'])[0]}_result.zip"
|
||||||
else:
|
zip_path = os.path.join(mail_work_dir, zip_name)
|
||||||
# 保持原附件数量:如果输出是一个目录,且目录内文件数==1,则直接返回该文件;否则打包为ZIP
|
ZipHandler.pack_to_zip(info['output_dir'], zip_path)
|
||||||
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)
|
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:
|
else:
|
||||||
return_attachments.append(output)
|
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(
|
||||||
try:
|
recipient=sender_email,
|
||||||
self.mail.send_result(sender_email, subject, return_attachments, Config.SPLIT_VOLUME_SIZE_MB)
|
subject=result_subject,
|
||||||
# 删除原邮件(留痕已在日志中)
|
attachments=return_attachments,
|
||||||
# 对于IMAP,我们可以删除原邮件
|
split_volume_mb=Config.SPLIT_VOLUME_SIZE_MB
|
||||||
# 注意:需要重新连接,因为之前的连接已关闭。简单起见,不在这里实现删除,留痕已足够
|
)
|
||||||
# 留痕记录
|
logger.info(f"结果已发送至 {sender_email}")
|
||||||
logger.info(f"成功处理邮件 from {sender_email}, msg_id={msg_id}")
|
|
||||||
|
self.mail.delete_email(msg_id, proto_conn, proto_type)
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"发送结果失败: {str(e)}"
|
error_msg = f"处理邮件时异常: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg, exc_info=True)
|
||||||
|
try:
|
||||||
self.mail.send_error_report(sender_email, 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:
|
if not Config.KEEP_TEMP_FILES:
|
||||||
self.temp_mgr.cleanup()
|
shutil.rmtree(mail_work_dir, ignore_errors=True)
|
||||||
|
|
||||||
def process_single_image(self, img_path, tasks, base_dir):
|
# ==================== 循环监听 ====================
|
||||||
"""处理单张图片,返回输出目录(存放所有转换结果)"""
|
def run_forever(self):
|
||||||
# 根据tasks匹配规则,生成输出文件列表
|
if not AgreementManager.is_agreed():
|
||||||
output_dir = os.path.join(base_dir, f"out_{os.path.basename(img_path)}")
|
logger.info("首次使用,需要同意协议")
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
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
|
||||||
filename = os.path.splitext(os.path.basename(img_path))[0]
|
|
||||||
# 找是否有匹配的from.name规则
|
logger.info(f"启动监听循环,轮询间隔 {Config.POLL_INTERVAL_SECONDS} 秒,{'无限循环' if Config.RUN_FOREVER else '单次运行'}")
|
||||||
matched = False
|
|
||||||
for task in tasks:
|
while True:
|
||||||
if task['type'] == 'by_name' and task['src_name'] == filename:
|
TempManager.cleanup_stale()
|
||||||
for fmt, size in task['targets']:
|
conn = None
|
||||||
out_path = os.path.join(output_dir, f"{filename}.{fmt}")
|
proto_type = None
|
||||||
# 缩放
|
try:
|
||||||
img = Image.open(img_path)
|
conn, proto_type = self.mail.connect_inbox()
|
||||||
if size:
|
if proto_type == "IMAP":
|
||||||
if size[0] == 'ratio':
|
typ, data = conn.search(None, 'UNSEEN')
|
||||||
img = ImageProcessor.resize_by_ratio(img, size[1], size[2])
|
if typ != 'OK':
|
||||||
elif size[0] == 'pixel':
|
logger.error("IMAP搜索失败")
|
||||||
img = ImageProcessor.resize_by_pixel(img, size[1], size[2])
|
continue
|
||||||
img.save(out_path, format=fmt.upper(), quality=Config.DEFAULT_QUALITY)
|
msg_ids = data[0].split()
|
||||||
else:
|
else:
|
||||||
ImageProcessor.convert_image(img_path, out_path, fmt)
|
msg_count = len(conn.list()[1])
|
||||||
matched = True
|
msg_ids = list(range(1, msg_count + 1))
|
||||||
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
|
|
||||||
|
|
||||||
def process_archive(self, archive_path, tasks, base_dir):
|
if not msg_ids:
|
||||||
"""处理压缩包,返回输出目录"""
|
logger.debug("无新邮件")
|
||||||
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:
|
else:
|
||||||
return None
|
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
|
||||||
|
time.sleep(Config.POLL_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
converter = MailConverter()
|
||||||
|
converter.run_forever()
|
||||||
|
|
||||||
# 遍历提取出的所有图片,应用规则(规则作用域可能指定该压缩包)
|
|
||||||
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
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
converter = MailConverter()
|
main()
|
||||||
converter.run()
|
|
||||||
|
|||||||
+65
-24
@@ -1,19 +1,31 @@
|
|||||||
import re
|
import re
|
||||||
from typing import List, Dict, Tuple, Optional
|
from typing import List, Dict, Tuple, Optional
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
class ManifestParser:
|
class ManifestParser:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.tasks = [] # 存储解析后的任务指令
|
self.tasks = []
|
||||||
|
|
||||||
def parse(self, content: str):
|
def parse(self, content: str) -> List[Dict]:
|
||||||
"""解析 manifest.txt 或邮件正文,生成内部指令结构"""
|
"""
|
||||||
|
解析 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('#')]
|
lines = [line.strip() for line in content.splitlines() if line.strip() and not line.strip().startswith('#')]
|
||||||
self.tasks = []
|
self.tasks = []
|
||||||
current_scope = None # 当前压缩包作用域,None表示根目录
|
current_scope = None # 当前压缩包作用域
|
||||||
|
pending_rename = None # 等待多行 to.name 的 from.name 任务
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(lines):
|
while i < len(lines):
|
||||||
line = lines[i]
|
line = lines[i]
|
||||||
|
|
||||||
# 处理压缩包作用域开始
|
# 处理压缩包作用域开始
|
||||||
if line.startswith('in ') and line.endswith(':'):
|
if line.startswith('in ') and line.endswith(':'):
|
||||||
archive_name = line[3:-1].strip()
|
archive_name = line[3:-1].strip()
|
||||||
@@ -25,22 +37,19 @@ class ManifestParser:
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 检查行内是否包含 "in xxx" 临时作用域
|
# 检查行内临时作用域
|
||||||
inline_archive = None
|
inline_archive = None
|
||||||
if ' in ' in line:
|
if ' in ' in line and not line.startswith('in '):
|
||||||
parts = line.split(' in ')
|
parts = line.split(' in ')
|
||||||
cmd_part = parts[0].strip()
|
cmd_part = parts[0].strip()
|
||||||
archive_part = parts[1].strip()
|
archive_part = parts[1].strip()
|
||||||
# 如果archive_part还有更多内容(如"to: xxx"),需重新组合?假设格式严格:"指令 in archive.zip"
|
# 确保 archive_part 是单纯的文件名(不含空格)
|
||||||
if ' ' in archive_part:
|
if ' ' not in archive_part:
|
||||||
# 复杂情况,暂不支持,要求用户使用单独行
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
inline_archive = archive_part
|
inline_archive = archive_part
|
||||||
line = cmd_part
|
line = cmd_part
|
||||||
|
|
||||||
# 解析指令
|
# 解析指令
|
||||||
if line.startswith('to:'):
|
if line.startswith('to:') and not line.startswith('to.name:'):
|
||||||
# 全局默认
|
# 全局默认
|
||||||
targets = self._parse_targets(line[3:])
|
targets = self._parse_targets(line[3:])
|
||||||
self.tasks.append({
|
self.tasks.append({
|
||||||
@@ -63,14 +72,13 @@ class ManifestParser:
|
|||||||
'inline_archive': inline_archive
|
'inline_archive': inline_archive
|
||||||
})
|
})
|
||||||
elif line.startswith('from.name:'):
|
elif line.startswith('from.name:'):
|
||||||
# 精准文件名匹配
|
# 精准文件名匹配,可能后跟多行 to.name:
|
||||||
rest = line[10:].strip()
|
rest = line[10:].strip()
|
||||||
if ' to.name:' in rest:
|
if ' to.name:' in rest:
|
||||||
# 重命名
|
# 单行重命名
|
||||||
parts = rest.split(' to.name:')
|
parts = rest.split(' to.name:')
|
||||||
src_name = parts[0].strip()
|
src_name = parts[0].strip()
|
||||||
dst_spec = parts[1].strip()
|
dst_spec = parts[1].strip()
|
||||||
# 解析 dst_spec: "newname.ext(size)"
|
|
||||||
dst_name, size = self._parse_dst_with_size(dst_spec)
|
dst_name, size = self._parse_dst_with_size(dst_spec)
|
||||||
self.tasks.append({
|
self.tasks.append({
|
||||||
'type': 'rename',
|
'type': 'rename',
|
||||||
@@ -81,7 +89,21 @@ class ManifestParser:
|
|||||||
'inline_archive': inline_archive
|
'inline_archive': inline_archive
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# to: 格式
|
# 可能后跟多行 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:')
|
parts = rest.split(' to:')
|
||||||
src_name = parts[0].strip()
|
src_name = parts[0].strip()
|
||||||
targets = self._parse_targets(parts[1])
|
targets = self._parse_targets(parts[1])
|
||||||
@@ -92,19 +114,39 @@ class ManifestParser:
|
|||||||
'scope': current_scope,
|
'scope': current_scope,
|
||||||
'inline_archive': inline_archive
|
'inline_archive': inline_archive
|
||||||
})
|
})
|
||||||
elif line.startswith('to.name:'):
|
else:
|
||||||
# 多版本导出中的 to.name 行,需要结合前面的 from.name 处理
|
# 只有 from.name: xxx,没有规则,无效
|
||||||
# 我们在主流程中采用累积方式,这里简单处理,实际在main中会连续读取
|
logger.warning(f"无效的 from.name 指令: {line}")
|
||||||
# 为简化,不在此处实现多行组合,而是在主程序中按行处理上下文
|
elif line.startswith('to.name:') and pending_rename:
|
||||||
# 让主程序负责收集连续的 to.name
|
# 多版本导出中的一行
|
||||||
pass
|
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:
|
else:
|
||||||
logger.warning(f"无法识别的指令: {line}")
|
logger.warning(f"无法识别的指令: {line}")
|
||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
return self.tasks
|
return self.tasks
|
||||||
|
|
||||||
def _parse_targets(self, target_str: str) -> List[Tuple[str, Optional[Tuple]]]:
|
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(',')]
|
items = [t.strip() for t in target_str.split(',')]
|
||||||
result = []
|
result = []
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -113,7 +155,6 @@ class ManifestParser:
|
|||||||
fmt, size_part = item.split('(', 1)
|
fmt, size_part = item.split('(', 1)
|
||||||
size_str = size_part.rstrip(')')
|
size_str = size_part.rstrip(')')
|
||||||
if ':' in size_str:
|
if ':' in size_str:
|
||||||
# 比例
|
|
||||||
w_ratio, h_ratio = map(int, size_str.split(':'))
|
w_ratio, h_ratio = map(int, size_str.split(':'))
|
||||||
size = ('ratio', w_ratio, h_ratio)
|
size = ('ratio', w_ratio, h_ratio)
|
||||||
elif 'x' in size_str:
|
elif 'x' in size_str:
|
||||||
|
|||||||
Reference in New Issue
Block a user