截图识别飞书个人任务清单

This commit is contained in:
2026-03-18 15:15:52 +08:00
parent cb5efbe3ca
commit 69f92205e9
23 changed files with 5835 additions and 0 deletions

54
utils/__init__.py Normal file
View File

@@ -0,0 +1,54 @@
"""
工具模块
"""
from .config_loader import (
load_config,
validate_config,
save_config,
create_default_config,
create_sample_config,
get_config_summary
)
from .logger import (
setup_logger,
get_logger,
log_function_call,
log_function_result,
log_error_with_context,
create_log_rotation_handler
)
from .notifier import (
send_notification,
send_notification_with_icon,
play_sound,
notify_task_processed,
notify_batch_result
)
__all__ = [
# 配置加载器
'load_config',
'validate_config',
'save_config',
'create_default_config',
'create_sample_config',
'get_config_summary',
# 日志记录器
'setup_logger',
'get_logger',
'log_function_call',
'log_function_result',
'log_error_with_context',
'create_log_rotation_handler',
# 通知器
'send_notification',
'send_notification_with_icon',
'play_sound',
'notify_task_processed',
'notify_batch_result'
]

200
utils/config_loader.py Normal file
View File

@@ -0,0 +1,200 @@
"""
配置文件加载和验证工具
"""
import yaml
from pathlib import Path
from typing import Dict, Any
def load_config(config_path: str) -> Dict[str, Any]:
"""
加载配置文件
Args:
config_path: 配置文件路径
Returns:
配置字典
Raises:
FileNotFoundError: 配置文件不存在
yaml.YAMLError: 配置文件格式错误
"""
config_file = Path(config_path)
if not config_file.exists():
raise FileNotFoundError(f"配置文件不存在: {config_path}")
try:
with open(config_file, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
return config
except yaml.YAMLError as e:
raise yaml.YAMLError(f"配置文件格式错误: {str(e)}")
def validate_config(config: Dict[str, Any]) -> None:
"""
验证配置文件的完整性和有效性
Args:
config: 配置字典
Raises:
ValueError: 配置验证失败
"""
# 检查必需的顶级配置块
required_sections = ['ai', 'feishu', 'system']
for section in required_sections:
if section not in config:
raise ValueError(f"缺少必需的配置块: {section}")
# 验证AI配置
ai_config = config['ai']
if not ai_config.get('api_key'):
raise ValueError("AI配置缺少 api_key")
if not ai_config.get('base_url'):
raise ValueError("AI配置缺少 base_url")
if not ai_config.get('model'):
raise ValueError("AI配置缺少 model")
# 验证飞书配置
feishu_config = config['feishu']
if not feishu_config.get('app_id'):
raise ValueError("飞书配置缺少 app_id")
if not feishu_config.get('app_secret'):
raise ValueError("飞书配置缺少 app_secret")
if not feishu_config.get('app_token'):
raise ValueError("飞书配置缺少 app_token")
if not feishu_config.get('table_id'):
raise ValueError("飞书配置缺少 table_id")
# 验证系统配置
system_config = config['system']
if not system_config.get('watch_folder'):
raise ValueError("系统配置缺少 watch_folder")
# 验证后处理配置
post_process = system_config.get('post_process', 'keep')
valid_post_process = ['delete', 'move', 'keep']
if post_process not in valid_post_process:
raise ValueError(f"post_process 必须是 {valid_post_process} 之一,当前值: {post_process}")
if post_process == 'move' and not system_config.get('processed_folder'):
raise ValueError("当 post_process 为 'move' 时,必须配置 processed_folder")
# 验证路径格式
watch_folder = Path(system_config['watch_folder'])
if not watch_folder.is_absolute():
# 如果是相对路径,转换为绝对路径
watch_folder = Path.cwd() / watch_folder
# 检查监控文件夹是否存在(允许不存在,会在运行时创建)
if watch_folder.exists() and not watch_folder.is_dir():
raise ValueError(f"watch_folder 必须是一个目录: {watch_folder}")
# 如果配置了移动目标文件夹,检查其路径
if post_process == 'move':
processed_folder = Path(system_config['processed_folder'])
if not processed_folder.is_absolute():
processed_folder = Path.cwd() / processed_folder
if processed_folder.exists() and not processed_folder.is_dir():
raise ValueError(f"processed_folder 必须是一个目录: {processed_folder}")
def save_config(config: Dict[str, Any], config_path: str) -> None:
"""
保存配置文件
Args:
config: 配置字典
config_path: 配置文件路径
"""
config_file = Path(config_path)
# 确保目录存在
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, 'w', encoding='utf-8') as f:
yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
def create_default_config() -> Dict[str, Any]:
"""
创建默认配置
Returns:
默认配置字典
"""
return {
'ai': {
'api_key': 'sk-xxxxxxxxxxxxxxxxxxxxxxxx',
'base_url': 'https://api.siliconflow.cn/v1',
'model': 'gemini-2.5-pro'
},
'feishu': {
'app_id': 'cli_xxxxxxxxxxxx',
'app_secret': 'xxxxxxxxxxxxxxxxxxxxxxxx',
'app_token': 'bascnxxxxxxxxxxxxxxx',
'table_id': 'tblxxxxxxxxxxxx'
},
'system': {
'watch_folder': './monitor_images',
'post_process': 'move',
'processed_folder': './processed_images'
}
}
def create_sample_config(config_path: str = 'config.example.yaml') -> None:
"""
创建示例配置文件
Args:
config_path: 示例配置文件路径
"""
default_config = create_default_config()
save_config(default_config, config_path)
print(f"示例配置文件已创建: {config_path}")
def get_config_summary(config: Dict[str, Any]) -> str:
"""
获取配置摘要信息
Args:
config: 配置字典
Returns:
配置摘要字符串
"""
summary = []
summary.append("配置摘要:")
summary.append(f" AI模型: {config['ai']['model']}")
summary.append(f" AI服务: {config['ai']['base_url']}")
summary.append(f" 监控文件夹: {config['system']['watch_folder']}")
summary.append(f" 后处理方式: {config['system'].get('post_process', 'keep')}")
if config['system'].get('post_process') == 'move':
summary.append(f" 处理后文件夹: {config['system']['processed_folder']}")
return '\n'.join(summary)
if __name__ == "__main__":
# 测试配置验证
try:
# 创建示例配置
create_sample_config('config.example.yaml')
print("示例配置创建成功!")
# 加载并验证示例配置
config = load_config('config.example.yaml')
validate_config(config)
print("配置验证通过!")
print(get_config_summary(config))
except Exception as e:
print(f"配置测试失败: {e}")

185
utils/logger.py Normal file
View File

@@ -0,0 +1,185 @@
"""
日志记录工具
"""
import logging
import sys
from pathlib import Path
from typing import Optional
def setup_logger(
name: str = 'screen2feishu',
log_file: Optional[str] = None,
level: str = 'INFO',
format: str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
) -> logging.Logger:
"""
设置日志记录器
Args:
name: 日志记录器名称
log_file: 日志文件路径如果为None则只输出到控制台
level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
format: 日志格式
Returns:
配置好的日志记录器
"""
# 创建日志记录器
logger = logging.getLogger(name)
# 避免重复配置
if logger.handlers:
return logger
# 设置日志级别
level_map = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL
}
log_level = level_map.get(level.upper(), logging.INFO)
logger.setLevel(log_level)
# 创建格式化器
formatter = logging.Formatter(format, datefmt='%Y-%m-%d %H:%M:%S')
# 创建控制台处理器
# 在Windows系统上确保使用UTF-8编码输出
if sys.platform == 'win32':
import io
console_handler = logging.StreamHandler(io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8'))
else:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# 创建文件处理器(如果指定了日志文件)
if log_file:
log_path = Path(log_file)
# 确保日志目录存在
log_path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_path, encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def get_logger(name: str = 'screen2feishu') -> logging.Logger:
"""
获取已配置的日志记录器
Args:
name: 日志记录器名称
Returns:
日志记录器
"""
return logging.getLogger(name)
def log_function_call(logger: logging.Logger, func_name: str, *args, **kwargs):
"""
记录函数调用日志
Args:
logger: 日志记录器
func_name: 函数名
*args: 位置参数
**kwargs: 关键字参数
"""
args_str = ', '.join(repr(arg) for arg in args)
kwargs_str = ', '.join(f'{k}={repr(v)}' for k, v in kwargs.items())
all_args = ', '.join(filter(None, [args_str, kwargs_str]))
logger.debug(f"调用函数: {func_name}({all_args})")
def log_function_result(logger: logging.Logger, func_name: str, result):
"""
记录函数返回结果日志
Args:
logger: 日志记录器
func_name: 函数名
result: 函数返回结果
"""
logger.debug(f"函数 {func_name} 返回: {repr(result)}")
def log_error_with_context(logger: logging.Logger, error: Exception, context: str = ""):
"""
记录错误日志,包含上下文信息
Args:
logger: 日志记录器
error: 异常对象
context: 错误上下文信息
"""
if context:
logger.error(f"{context}: {str(error)}")
else:
logger.error(f"错误: {str(error)}")
def create_log_rotation_handler(
log_file: str,
max_bytes: int = 10 * 1024 * 1024, # 10MB
backup_count: int = 5
) -> logging.Handler:
"""
创建支持日志轮转的文件处理器
Args:
log_file: 日志文件路径
max_bytes: 单个日志文件最大字节数
backup_count: 备份文件数量
Returns:
文件处理器
"""
from logging.handlers import RotatingFileHandler
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_file,
maxBytes=max_bytes,
backupCount=backup_count,
encoding='utf-8'
)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler.setFormatter(formatter)
return handler
if __name__ == "__main__":
# 测试日志功能
logger = setup_logger(
name='test_logger',
log_file='test.log',
level='DEBUG'
)
logger.debug("这是一条调试信息")
logger.info("这是一条普通信息")
logger.warning("这是一条警告信息")
logger.error("这是一条错误信息")
# 测试函数调用日志
log_function_call(logger, "test_function", "arg1", "arg2", key="value")
log_function_result(logger, "test_function", {"result": "success"})
log_error_with_context(logger, Exception("测试错误"), "处理数据时")

306
utils/notifier.py Normal file
View File

@@ -0,0 +1,306 @@
"""
通知工具 - 支持桌面通知、声音提示等
"""
import platform
import subprocess
import logging
from typing import Optional
def send_notification(title: str, message: str, urgency: str = "normal") -> bool:
"""
发送桌面通知
Args:
title: 通知标题
message: 通知内容
urgency: 通知紧急程度 (low, normal, high)
Returns:
是否成功发送
"""
system = platform.system()
try:
if system == "Windows":
# Windows 使用 PowerShell 发送通知
ps_script = f"""
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
$template.SelectSingleNode("//text[@id='1']").AppendChild($template.CreateTextNode('{title}')) > $null
$template.SelectSingleNode("//text[@id='2']").AppendChild($template.CreateTextNode('{message}')) > $null
$toast = [Windows.UI.Notifications.ToastNotification]::new($template)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Screen2Feishu").Show($toast)
"""
# 使用 PowerShell 执行
subprocess.run(
["powershell", "-Command", ps_script],
capture_output=True,
timeout=5
)
return True
elif system == "Darwin": # macOS
# macOS 使用 osascript 发送通知
script = f'display notification "{message}" with title "{title}"'
subprocess.run(
["osascript", "-e", script],
capture_output=True,
timeout=5
)
return True
elif system == "Linux":
# Linux 使用 notify-send
try:
subprocess.run(
["notify-send", "-u", urgency, title, message],
capture_output=True,
timeout=5
)
return True
except FileNotFoundError:
# 如果 notify-send 不可用,尝试使用 zenity
try:
subprocess.run(
["zenity", "--info", "--title", title, "--text", message],
capture_output=True,
timeout=5
)
return True
except FileNotFoundError:
return False
else:
return False
except Exception as e:
logging.error(f"发送通知失败: {str(e)}")
return False
def send_notification_with_icon(title: str, message: str, icon_path: Optional[str] = None) -> bool:
"""
发送带图标的桌面通知
Args:
title: 通知标题
message: 通知内容
icon_path: 图标文件路径
Returns:
是否成功发送
"""
system = platform.system()
try:
if system == "Linux" and icon_path:
# Linux 支持图标
subprocess.run(
["notify-send", "-u", "normal", "-i", icon_path, title, message],
capture_output=True,
timeout=5
)
return True
else:
# 其他系统使用普通通知
return send_notification(title, message)
except Exception as e:
logging.error(f"发送带图标通知失败: {str(e)}")
return False
def play_sound(sound_type: str = "success") -> bool:
"""
播放系统声音
Args:
sound_type: 声音类型 (success, error, warning, info)
Returns:
是否成功播放
"""
system = platform.system()
try:
if system == "Windows":
# Windows 使用 PowerShell 播放系统声音
sound_map = {
"success": "SystemAsterisk",
"error": "SystemHand",
"warning": "SystemExclamation",
"info": "SystemDefault"
}
sound = sound_map.get(sound_type, "SystemDefault")
ps_script = f'[System.Media.SystemSounds]::{sound}.Play()'
subprocess.run(
["powershell", "-Command", ps_script],
capture_output=True,
timeout=5
)
return True
elif system == "Darwin": # macOS
# macOS 使用 afplay 播放系统声音
sound_map = {
"success": "/System/Library/Sounds/Purr.aiff",
"error": "/System/Library/Sounds/Basso.aiff",
"warning": "/System/Library/Sounds/Funk.aiff",
"info": "/System/Library/Sounds/Glass.aiff"
}
sound_file = sound_map.get(sound_type, "/System/Library/Sounds/Glass.aiff")
# 异步播放声音
subprocess.Popen(
["afplay", sound_file],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
return True
elif system == "Linux":
# Linux 使用 paplay 或 aplay 播放声音
# 这里使用简单的 beep 声音
try:
# 尝试使用 beep
subprocess.run(
["beep"],
capture_output=True,
timeout=5
)
return True
except FileNotFoundError:
# 如果 beep 不可用,尝试使用 paplay
try:
# 创建临时的声音文件
import tempfile
import wave
import struct
# 创建简单的正弦波声音
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
# 写入 WAV 文件头
sample_rate = 44100
duration = 0.2
num_samples = int(sample_rate * duration)
wav_file = wave.open(f.name, 'w')
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
# 生成正弦波
for i in range(num_samples):
value = int(32767 * 0.3 * (1 if i < num_samples // 2 else 0))
wav_file.writeframes(struct.pack('<h', value))
wav_file.close()
# 播放声音
subprocess.run(
["paplay", f.name],
capture_output=True,
timeout=5
)
# 删除临时文件
import os
os.unlink(f.name)
return True
except Exception:
return False
else:
return False
except Exception as e:
logging.error(f"播放声音失败: {str(e)}")
return False
def notify_task_processed(success: bool, file_name: str, count: int = 1) -> bool:
"""
通知任务处理结果
Args:
success: 是否成功
file_name: 文件名
count: 处理数量
Returns:
是否成功发送通知
"""
if success:
title = " 任务处理成功"
message = f"已成功处理 {count} 个文件: {file_name}"
sound_type = "success"
else:
title = " 任务处理失败"
message = f"处理文件失败: {file_name}"
sound_type = "error"
# 发送桌面通知
notification_sent = send_notification(title, message)
# 播放声音提示
sound_played = play_sound(sound_type)
return notification_sent or sound_played
def notify_batch_result(results: dict) -> bool:
"""
通知批量处理结果
Args:
results: 处理结果字典 {文件名: 是否成功}
Returns:
是否成功发送通知
"""
success_count = sum(1 for v in results.values() if v)
total_count = len(results)
if success_count == total_count:
title = " 批量处理完成"
message = f"所有 {total_count} 个文件处理成功"
sound_type = "success"
elif success_count == 0:
title = " 批量处理失败"
message = f"所有 {total_count} 个文件处理失败"
sound_type = "error"
else:
title = "⚠️ 批量处理部分成功"
message = f"{success_count}/{total_count} 个文件处理成功"
sound_type = "warning"
# 发送桌面通知
notification_sent = send_notification(title, message)
# 播放声音提示
sound_played = play_sound(sound_type)
return notification_sent or sound_played
if __name__ == "__main__":
# 测试通知功能
import logging
logging.basicConfig(level=logging.INFO)
print("测试桌面通知...")
success = send_notification("测试通知", "这是一个测试通知消息")
print(f"通知发送: {'成功' if success else '失败'}")
print("测试声音播放...")
success = play_sound("success")
print(f"声音播放: {'成功' if success else '失败'}")
print("测试任务处理通知...")
success = notify_task_processed(True, "test_image.png")
print(f"任务通知: {'成功' if success else '失败'}")

211
utils/user_cache.py Normal file
View File

@@ -0,0 +1,211 @@
import json
import time
import random
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import logging
class UserCacheManager:
"""用户缓存管理器,用于管理飞书用户信息和最近联系人"""
def __init__(self, cache_file: str = "./data/user_cache.json"):
"""
初始化用户缓存管理器
Args:
cache_file: 缓存文件路径
"""
self.cache_file = Path(cache_file)
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
# 缓存数据结构
self.users: List[Dict] = [] # 用户列表
self.recent_contacts: List[str] = [] # 最近联系人名字列表
self.last_update_time: float = 0 # 最后更新时间
# 加载缓存
self._load_cache()
def _load_cache(self):
"""从文件加载缓存"""
try:
if self.cache_file.exists():
with open(self.cache_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.users = data.get('users', [])
self.recent_contacts = data.get('recent_contacts', [])
self.last_update_time = data.get('last_update_time', 0)
logging.info(f"已加载用户缓存,共 {len(self.users)} 个用户")
else:
logging.info("未找到用户缓存文件,将创建新的缓存")
except Exception as e:
logging.error(f"加载用户缓存失败: {str(e)}")
self.users = []
self.recent_contacts = []
self.last_update_time = 0
def _save_cache(self):
"""保存缓存到文件"""
try:
data = {
'users': self.users,
'recent_contacts': self.recent_contacts,
'last_update_time': time.time()
}
with open(self.cache_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logging.info(f"已保存用户缓存到 {self.cache_file}")
except Exception as e:
logging.error(f"保存用户缓存失败: {str(e)}")
def update_users(self, users: List[Dict]):
"""
更新用户列表
Args:
users: 飞书用户列表,每个用户包含 name, user_id 等字段
"""
self.users = users
self.last_update_time = time.time()
self._save_cache()
logging.info(f"已更新用户列表,共 {len(users)} 个用户")
def add_recent_contact(self, name: str):
"""
添加最近联系人
Args:
name: 联系人姓名
"""
if not name or name.strip() == "":
return
name = name.strip()
# 如果已存在,先移除
if name in self.recent_contacts:
self.recent_contacts.remove(name)
# 添加到列表开头
self.recent_contacts.insert(0, name)
# 限制列表长度最多保留50个
if len(self.recent_contacts) > 50:
self.recent_contacts = self.recent_contacts[:50]
self._save_cache()
logging.debug(f"已添加最近联系人: {name}")
def match_user_by_name(self, name: str) -> Optional[Dict]:
"""
根据名字匹配用户
Args:
name: 要匹配的名字
Returns:
匹配的用户信息如果未找到则返回None
"""
if not name or not self.users:
return None
name = name.strip()
# 精确匹配
for user in self.users:
if user.get('name') == name:
return user
# 模糊匹配(包含关系)
for user in self.users:
user_name = user.get('name', '')
if user_name and (name in user_name or user_name in name):
return user
return None
def get_random_recent_contact(self) -> Optional[str]:
"""
从最近联系人中随机选择一个
Returns:
随机选择的联系人名字如果没有则返回None
"""
if not self.recent_contacts:
return None
# 随机选择一个
return random.choice(self.recent_contacts)
def get_user_suggestions(self, name: str) -> List[Dict]:
"""
获取名字相似的用户建议
Args:
name: 要匹配的名字
Returns:
相似的用户列表
"""
if not name or not self.users:
return []
name = name.strip().lower()
suggestions = []
for user in self.users:
user_name = user.get('name', '')
user_name_lower = user_name.lower()
# 计算相似度(简单的包含关系)
if name in user_name_lower or user_name_lower in name:
suggestions.append(user)
elif name[0] == user_name_lower[0] if user_name_lower else False:
suggestions.append(user)
# 限制返回数量
return suggestions[:5]
def is_cache_expired(self, max_age_hours: int = 24) -> bool:
"""
检查缓存是否过期
Args:
max_age_hours: 最大缓存时间(小时)
Returns:
是否过期
"""
if self.last_update_time == 0:
return True
age_hours = (time.time() - self.last_update_time) / 3600
return age_hours > max_age_hours
def clear_cache(self):
"""清除缓存"""
self.users = []
self.recent_contacts = []
self.last_update_time = 0
self._save_cache()
logging.info("已清除用户缓存")
def get_cache_stats(self) -> Dict:
"""
获取缓存统计信息
Returns:
缓存统计信息
"""
age_hours = 0
if self.last_update_time > 0:
age_hours = (time.time() - self.last_update_time) / 3600
return {
'user_count': len(self.users),
'recent_contact_count': len(self.recent_contacts),
'last_update_time': self.last_update_time,
'cache_age_hours': round(age_hours, 2),
'is_expired': self.is_cache_expired()
}