v1.0正式版
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# 配置文件(可能包含敏感信息)
|
||||
config.ini
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
|
||||
# 临时文件
|
||||
temp/
|
||||
~$*
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
|
||||
# 处理记录(因为通常很大且与具体环境相关)
|
||||
processed_files.json
|
||||
|
||||
# 输入输出数据 (可以根据需要调整)
|
||||
# input/
|
||||
# output/
|
||||
|
||||
# Python相关
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# 虚拟环境
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE相关
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
@@ -0,0 +1,332 @@
|
||||
# OCR订单处理系统
|
||||
|
||||
这是一个基于OCR技术的订单处理系统,用于自动识别和处理Excel格式的订单文件。系统支持多种格式的订单处理,包括普通订单和赠品订单的处理。
|
||||
|
||||
## 主要功能
|
||||
|
||||
1. **OCR识别**
|
||||
- 支持图片和PDF文件的文字识别
|
||||
- 支持表格结构识别
|
||||
- 支持多种格式的订单识别
|
||||
|
||||
2. **Excel处理**
|
||||
- 自动处理订单数据
|
||||
- 支持赠品订单处理
|
||||
- 自动提取商品规格和数量信息
|
||||
- 从商品名称智能推断规格信息
|
||||
- 从数量字段提取单位信息
|
||||
- 支持多种格式的订单合并
|
||||
|
||||
3. **日志管理**
|
||||
- 自动记录处理过程
|
||||
- 支持日志文件压缩
|
||||
- 自动清理过期日志
|
||||
- 日志文件自动重建
|
||||
- 支持日志大小限制
|
||||
- 活跃日志文件保护机制
|
||||
|
||||
4. **文件管理**
|
||||
- 自动备份清理的文件
|
||||
- 支持按时间和模式清理文件
|
||||
- 文件统计和状态查看
|
||||
- 支持输入输出目录的独立管理
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Python 3.8+
|
||||
- Windows 10/11
|
||||
|
||||
## 安装说明
|
||||
|
||||
1. 克隆项目到本地:
|
||||
```bash
|
||||
git clone [项目地址]
|
||||
cd orc-order
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. 配置百度OCR API:
|
||||
- 在[百度AI开放平台](https://ai.baidu.com/)注册账号
|
||||
- 创建OCR应用并获取API Key和Secret Key
|
||||
- 将密钥信息填入`config.ini`文件
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 1. OCR处理流程
|
||||
|
||||
1. 运行OCR识别:
|
||||
```bash
|
||||
python run_ocr.py [输入文件路径]
|
||||
```
|
||||
|
||||
2. 使用百度OCR API:
|
||||
```bash
|
||||
python baidu_ocr.py [输入文件路径]
|
||||
```
|
||||
|
||||
3. 处理表格OCR:
|
||||
```bash
|
||||
python baidu_table_ocr.py [输入文件路径]
|
||||
```
|
||||
|
||||
### 2. Excel处理
|
||||
|
||||
```bash
|
||||
python excel_processor_step2.py [输入Excel文件路径]
|
||||
```
|
||||
|
||||
或者不指定输入文件,自动处理output目录中最新的Excel文件:
|
||||
```bash
|
||||
python excel_processor_step2.py
|
||||
```
|
||||
|
||||
#### 2.1 Excel处理逻辑说明
|
||||
|
||||
1. **列名识别与映射**:
|
||||
- 系统首先检查是否存在直接匹配的列(如"商品条码"列)
|
||||
- 如果没有,系统会尝试将多种可能的列名映射到标准列名
|
||||
- 支持特殊表头格式处理(如基本条码、仓库全名等)
|
||||
|
||||
2. **条码识别与处理**:
|
||||
- 验证条码格式(确保长度在8-13位之间)
|
||||
- 对特定的错误条码进行修正(如5开头改为6开头)
|
||||
- 识别特殊条码(如"5321545613")
|
||||
- 跳过条码为"仓库"或"仓库全名"的行
|
||||
|
||||
3. **智能规格推断**:
|
||||
- 当规格信息为空时,从商品名称自动推断规格
|
||||
- 支持多种商品命名模式:
|
||||
- 445水溶C血橙15入纸箱 → 规格推断为 1*15
|
||||
- 500-东方树叶-绿茶1*15-纸箱装 → 规格推断为 1*15
|
||||
- 12.9L桶装水 → 规格推断为 12.9L*1
|
||||
- 900树叶茉莉花茶12入纸箱 → 规格推断为 1*12
|
||||
- 500茶π蜜桃乌龙15纸箱 → 规格推断为 1*15
|
||||
|
||||
4. **单位自动提取**:
|
||||
- 当单位信息为空时,从数量字段中自动提取单位
|
||||
- 支持格式:2箱、5桶、3件、10瓶等
|
||||
- 自动分离数字和单位部分
|
||||
|
||||
5. **赠品识别**:
|
||||
- 通过以下条件识别赠品:
|
||||
- 商品单价为0或为空
|
||||
- 商品金额为0或为空
|
||||
- 单价非有效数字
|
||||
|
||||
6. **数据合并与处理**:
|
||||
- 对同一条码的多个正常商品记录,累加数量
|
||||
- 对同一条码的多个赠品记录,累加赠品数量
|
||||
- 如果同一条码有不同单价,取平均值
|
||||
|
||||
### 3. 订单合并
|
||||
|
||||
`merge_purchase_orders.py`是专门用来合并多个采购单Excel文件的工具,可以高效处理多份采购单并去重。
|
||||
|
||||
#### 3.1 基本用法
|
||||
|
||||
自动合并output目录下的所有采购单文件(以"采购单_"开头的Excel文件):
|
||||
```bash
|
||||
python merge_purchase_orders.py
|
||||
```
|
||||
|
||||
指定要合并的特定文件:
|
||||
```bash
|
||||
python merge_purchase_orders.py --input "output/采购单_1.xls,output/采购单_2.xls"
|
||||
```
|
||||
|
||||
#### 3.2 合并逻辑说明
|
||||
|
||||
1. **数据识别与映射**:
|
||||
- 自动识别Excel文件中的列名(支持多种表头格式)
|
||||
- 将不同格式的列名映射到标准列名(如"条码"、"条码(必填)"等)
|
||||
- 支持特殊表头结构的处理(如表头在第3行的情况)
|
||||
|
||||
2. **相同商品的处理**:
|
||||
- 自动检测相同条码和单价的商品
|
||||
- 对相同商品进行数量累加处理
|
||||
- 保持商品名称、条码和单价不变
|
||||
|
||||
3. **赠送量的处理**:
|
||||
- 自动检测和处理赠送量
|
||||
- 对相同商品的赠送量进行累加
|
||||
- 当原始文件中赠送量为空时,合并后保持为空(不显示为0)
|
||||
|
||||
4. **数据格式保持**:
|
||||
- 保持条码的原始格式(不转换为小数)
|
||||
- 单价保持四位小数格式
|
||||
- 避免"nan"值的显示,空值保持为空
|
||||
|
||||
### 4. 单位处理规则(核心规则)
|
||||
|
||||
系统支持多种单位的智能处理,能够自动识别和转换不同的计量单位。所有开发必须严格遵循以下规则处理单位转换。
|
||||
|
||||
#### 4.1 标准单位处理
|
||||
|
||||
| 单位 | 处理规则 | 示例 |
|
||||
|------|----------|------|
|
||||
| 件 | 数量×包装数量<br>单价÷包装数量<br>单位转换为"瓶" | 1件(规格1*12) → 12瓶<br>单价108元/件 → 9元/瓶 |
|
||||
| 箱 | 数量×包装数量<br>单价÷包装数量<br>单位转换为"瓶" | 2箱(规格1*24) → 48瓶<br>单价120元/箱 → 5元/瓶 |
|
||||
| 包 | 保持原数量和单位不变 | 3包 → 3包 |
|
||||
| 其他单位 | 保持原数量和单位不变 | 5瓶 → 5瓶 |
|
||||
|
||||
#### 4.2 提和盒单位特殊处理
|
||||
|
||||
系统对"提"和"盒"单位有特殊的处理逻辑:
|
||||
|
||||
1. 当规格是三级格式(如1*5*12)时:
|
||||
- 按照件的计算方式处理
|
||||
- 数量 = 原始数量 × 包装数量
|
||||
- 单位转换为"瓶"
|
||||
- 单价 = 原始单价 ÷ 包装数量
|
||||
|
||||
示例:3提(规格1*5*12) → 36瓶
|
||||
|
||||
2. 当规格是二级格式(如1*16)时:
|
||||
- **保持原数量不变**
|
||||
- **保持原单位不变**
|
||||
|
||||
示例:3提(规格1*16) → 仍然是3提
|
||||
|
||||
#### 4.3 特殊条码处理
|
||||
|
||||
系统支持对特定条码进行特殊处理,这些条码的处理规则会覆盖上述的标准单位处理规则:
|
||||
|
||||
1. 特殊条码配置:
|
||||
```python
|
||||
special_barcodes = {
|
||||
'6925019900087': {
|
||||
'multiplier': 10, # 数量乘以10
|
||||
'target_unit': '瓶', # 目标单位
|
||||
'description': '特殊处理:数量*10,单位转换为瓶'
|
||||
}
|
||||
# 可以添加更多特殊条码的配置
|
||||
}
|
||||
```
|
||||
|
||||
2. 处理规则:
|
||||
- 当遇到特殊条码时,无论规格是二级还是三级
|
||||
- 无论单位是提还是盒还是件
|
||||
- 都按照特殊条码配置进行处理
|
||||
- 数量乘以配置的倍数
|
||||
- 单位转换为配置的目标单位
|
||||
- 如果有单价,单价除以配置的倍数
|
||||
|
||||
3. 添加新的特殊条码的正确方法:
|
||||
- 在`ExcelProcessorStep2`类的`__init__`方法中的`special_barcodes`字典中添加新的配置
|
||||
- 每个特殊条码需要配置:
|
||||
- `multiplier`: 数量乘以的倍数
|
||||
- `target_unit`: 转换后的目标单位
|
||||
- `description`: 处理规则的描述
|
||||
|
||||
4. 注意事项:
|
||||
- 特殊条码处理优先级高于标准单位处理
|
||||
- 添加新的特殊条码前,需要确认该条码是否真的需要特殊处理,或者可以使用现有的标准规则
|
||||
- 如果商品单位是"件"且有规格信息,应首先考虑使用标准的"件"单位处理规则
|
||||
|
||||
5. 示例:
|
||||
- 条码6925019900087,单位为"副",原始数量为2:
|
||||
- 无论规格如何
|
||||
- 最终数量 = 2 * 10 = 20,单位为"瓶"
|
||||
- 如原单价为50元/副,则转换后为5元/瓶
|
||||
|
||||
### 5. 开发注意事项
|
||||
|
||||
1. **遵循原有处理逻辑**:
|
||||
- 在进行任何修改前,必须理解并遵循现有的单位处理逻辑
|
||||
- 对于"件"单位,必须按照"数量×包装数量"进行处理
|
||||
- 对于"提"和"盒"单位,必须检查规格是二级还是三级格式,按相应规则处理
|
||||
|
||||
2. **添加特殊条码处理**:
|
||||
- 只有在明确确认某条码无法使用现有规则处理的情况下,才添加特殊处理规则
|
||||
- 添加特殊条码处理前,先咨询相关负责人确认处理逻辑
|
||||
|
||||
3. **代码更改原则**:
|
||||
- 任何代码修改都不应破坏现有的处理逻辑
|
||||
- 添加新功能时,先确保理解并保留现有功能
|
||||
- 如需修改核心处理逻辑,必须详细记录变更并更新本文档
|
||||
|
||||
4. **文档同步更新**:
|
||||
- 当修改代码逻辑时,必须同步更新README文档
|
||||
- 确保文档准确反映当前系统的处理逻辑
|
||||
- 记录所有特殊处理规则和条码
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保输入文件格式正确
|
||||
2. 定期检查日志文件大小
|
||||
3. 及时更新百度OCR API密钥
|
||||
4. 建议定期备份重要数据
|
||||
5. 清理文件前先查看统计信息
|
||||
6. 重要文件建议手动备份后再清理
|
||||
7. 日志文件会自动重建,可以放心清理
|
||||
8. 规格推断功能适用于特定命名格式的商品
|
||||
9. 单位提取功能依赖于数量字段的格式
|
||||
|
||||
## 常见问题
|
||||
|
||||
1. **OCR识别失败**
|
||||
- 检查图片质量
|
||||
- 确认API密钥配置正确
|
||||
- 查看日志文件了解详细错误信息
|
||||
|
||||
2. **Excel处理错误**
|
||||
- 确认Excel文件格式正确
|
||||
- 检查商品信息是否完整
|
||||
- 查看处理日志了解具体错误
|
||||
|
||||
3. **规格推断失败**
|
||||
- 检查商品名称是否符合支持的格式
|
||||
- 尝试手动在Excel中填写规格信息
|
||||
- 查看日志中的推断过程信息
|
||||
|
||||
4. **单位提取失败**
|
||||
- 检查数量字段格式是否为"数字+单位"格式
|
||||
- 确认数量字段没有额外的空格或特殊字符
|
||||
- 尝试手动在Excel中填写单位信息
|
||||
|
||||
5. **文件清理问题**
|
||||
- 清理前使用`--test`参数预览要删除的文件
|
||||
- 清理前检查文件统计信息`--stats`
|
||||
- 对于重要文件,先使用`--test`确认后再实际删除
|
||||
- 对于被占用的文件,尝试关闭占用的程序后再清理
|
||||
- Windows下某些文件无法删除时,可以使用`--force`参数
|
||||
- 日志文件清理时可以使用`--clean-all-logs`参数
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.2.1 (2024-05-04)
|
||||
- 新增日志文件活跃标记保护机制
|
||||
- 修复OCR处理器日志文件被意外删除的问题
|
||||
- 改进日志清理工具,避免删除活跃日志
|
||||
- 优化文件清理逻辑
|
||||
- 更新README文档
|
||||
|
||||
### v1.2.0 (2024-05-03)
|
||||
- 新增对"提"单位的支持(1提=10袋)
|
||||
- 新增对三级规格格式(1*5*10)的解析支持
|
||||
- 优化单位转换逻辑
|
||||
- 改进规格解析能力
|
||||
|
||||
### v1.1.0 (2024-05-02)
|
||||
- 新增从商品名称智能推断规格功能
|
||||
- 新增从数量字段提取单位功能
|
||||
- 优化赠品处理逻辑
|
||||
- 修复缩进错误和代码问题
|
||||
- 改进日志记录内容
|
||||
|
||||
### v1.0.0 (2024-05-01)
|
||||
- 初始版本发布
|
||||
- 支持基本的OCR识别功能
|
||||
- 支持Excel订单处理
|
||||
- 支持日志管理功能
|
||||
- 添加文件清理工具
|
||||
- 优化文件命名和目录结构
|
||||
- 完善日志自动重建功能
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
百度表格OCR识别工具
|
||||
-----------------
|
||||
用于将图片中的表格转换为Excel文件的工具。
|
||||
使用百度云OCR API进行识别。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import datetime
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 默认配置
|
||||
DEFAULT_CONFIG = {
|
||||
'API': {
|
||||
'api_key': '', # 将从配置文件中读取
|
||||
'secret_key': '', # 将从配置文件中读取
|
||||
'timeout': '30',
|
||||
'max_retries': '3',
|
||||
'retry_delay': '2'
|
||||
},
|
||||
'Paths': {
|
||||
'input_folder': 'input',
|
||||
'output_folder': 'output',
|
||||
'temp_folder': 'temp'
|
||||
},
|
||||
'File': {
|
||||
'allowed_extensions': '.jpg,.jpeg,.png,.bmp',
|
||||
'excel_extension': '.xlsx'
|
||||
}
|
||||
}
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理类,负责加载和保存配置"""
|
||||
|
||||
def __init__(self, config_file: str = 'config.ini'):
|
||||
self.config_file = config_file
|
||||
self.config = configparser.ConfigParser()
|
||||
self.load_config()
|
||||
|
||||
def load_config(self) -> None:
|
||||
"""加载配置文件,如果不存在则创建默认配置"""
|
||||
if not os.path.exists(self.config_file):
|
||||
self.create_default_config()
|
||||
|
||||
try:
|
||||
self.config.read(self.config_file, encoding='utf-8')
|
||||
logger.info(f"已加载配置文件: {self.config_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"加载配置文件时出错: {e}")
|
||||
logger.info("使用默认配置")
|
||||
self.create_default_config(save=False)
|
||||
|
||||
def create_default_config(self, save: bool = True) -> None:
|
||||
"""创建默认配置"""
|
||||
for section, options in DEFAULT_CONFIG.items():
|
||||
if not self.config.has_section(section):
|
||||
self.config.add_section(section)
|
||||
|
||||
for option, value in options.items():
|
||||
self.config.set(section, option, value)
|
||||
|
||||
if save:
|
||||
self.save_config()
|
||||
logger.info(f"已创建默认配置文件: {self.config_file}")
|
||||
|
||||
def save_config(self) -> None:
|
||||
"""保存配置到文件"""
|
||||
try:
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
self.config.write(f)
|
||||
except Exception as e:
|
||||
logger.error(f"保存配置文件时出错: {e}")
|
||||
|
||||
def get(self, section: str, option: str, fallback: Any = None) -> Any:
|
||||
"""获取配置值"""
|
||||
return self.config.get(section, option, fallback=fallback)
|
||||
|
||||
def getint(self, section: str, option: str, fallback: int = 0) -> int:
|
||||
"""获取整数配置值"""
|
||||
return self.config.getint(section, option, fallback=fallback)
|
||||
|
||||
def getboolean(self, section: str, option: str, fallback: bool = False) -> bool:
|
||||
"""获取布尔配置值"""
|
||||
return self.config.getboolean(section, option, fallback=fallback)
|
||||
|
||||
def get_list(self, section: str, option: str, fallback: str = "", delimiter: str = ",") -> List[str]:
|
||||
"""获取列表配置值"""
|
||||
value = self.get(section, option, fallback)
|
||||
return [item.strip() for item in value.split(delimiter) if item.strip()]
|
||||
|
||||
class OCRProcessor:
|
||||
"""OCR处理器,用于表格识别"""
|
||||
|
||||
def __init__(self, config_file: str = 'config.ini'):
|
||||
"""
|
||||
初始化OCR处理器
|
||||
|
||||
Args:
|
||||
config_file: 配置文件路径
|
||||
"""
|
||||
self.config_manager = ConfigManager(config_file)
|
||||
|
||||
# 获取配置
|
||||
self.api_key = self.config_manager.get('API', 'api_key')
|
||||
self.secret_key = self.config_manager.get('API', 'secret_key')
|
||||
self.timeout = self.config_manager.getint('API', 'timeout', 30)
|
||||
self.max_retries = self.config_manager.getint('API', 'max_retries', 3)
|
||||
self.retry_delay = self.config_manager.getint('API', 'retry_delay', 2)
|
||||
|
||||
# 设置路径
|
||||
self.input_folder = self.config_manager.get('Paths', 'input_folder', 'input')
|
||||
self.output_folder = self.config_manager.get('Paths', 'output_folder', 'output')
|
||||
self.temp_folder = self.config_manager.get('Paths', 'temp_folder', 'temp')
|
||||
|
||||
# 确保目录存在
|
||||
for dir_path in [self.input_folder, self.output_folder, self.temp_folder]:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
# 设置允许的文件扩展名
|
||||
self.allowed_extensions = self.config_manager.get_list('File', 'allowed_extensions')
|
||||
|
||||
# 验证API配置
|
||||
if not self.api_key or not self.secret_key:
|
||||
logger.warning("API密钥未设置,请在配置文件中设置API密钥")
|
||||
|
||||
def get_access_token(self) -> Optional[str]:
|
||||
"""获取百度API访问令牌"""
|
||||
url = "https://aip.baidubce.com/oauth/2.0/token"
|
||||
params = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.api_key,
|
||||
"client_secret": self.secret_key
|
||||
}
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = requests.post(url, params=params, timeout=10)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if "access_token" in result:
|
||||
return result["access_token"]
|
||||
|
||||
logger.warning(f"获取访问令牌失败 (尝试 {attempt+1}/{self.max_retries}): {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取访问令牌时发生错误 (尝试 {attempt+1}/{self.max_retries}): {e}")
|
||||
|
||||
# 如果不是最后一次尝试,则等待后重试
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(self.retry_delay * (attempt + 1))
|
||||
|
||||
logger.error("无法获取访问令牌")
|
||||
return None
|
||||
|
||||
def rename_image_to_timestamp(self, image_path: str) -> str:
|
||||
"""将图片重命名为时间戳格式(如果需要)"""
|
||||
try:
|
||||
# 获取当前时间戳
|
||||
now = datetime.datetime.now()
|
||||
timestamp = now.strftime("%Y%m%d%H%M%S")
|
||||
|
||||
# 构造新文件名
|
||||
dir_path = os.path.dirname(image_path)
|
||||
ext = os.path.splitext(image_path)[1]
|
||||
new_path = os.path.join(dir_path, f"{timestamp}{ext}")
|
||||
|
||||
# 如果文件名不同,则重命名
|
||||
if image_path != new_path:
|
||||
os.rename(image_path, new_path)
|
||||
logger.info(f"已将图片重命名为: {os.path.basename(new_path)}")
|
||||
return new_path
|
||||
|
||||
return image_path
|
||||
except Exception as e:
|
||||
logger.error(f"重命名图片时出错: {e}")
|
||||
return image_path
|
||||
|
||||
def recognize_table(self, image_path: str) -> Optional[Dict]:
|
||||
"""
|
||||
识别图片中的表格
|
||||
|
||||
Args:
|
||||
image_path: 图片文件路径
|
||||
|
||||
Returns:
|
||||
Dict: 识别结果,失败返回None
|
||||
"""
|
||||
try:
|
||||
# 获取access_token
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
# 请求URL
|
||||
url = f"https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/request?access_token={access_token}"
|
||||
|
||||
# 读取图片内容
|
||||
with open(image_path, 'rb') as f:
|
||||
image_data = f.read()
|
||||
|
||||
# Base64编码
|
||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
||||
|
||||
# 请求参数
|
||||
headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
|
||||
data = {
|
||||
'image': image_base64,
|
||||
'is_sync': 'true',
|
||||
'request_type': 'excel'
|
||||
}
|
||||
|
||||
# 发送请求
|
||||
response = requests.post(url, headers=headers, data=data, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
# 解析结果
|
||||
result = response.json()
|
||||
|
||||
# 检查错误码
|
||||
if 'error_code' in result:
|
||||
logger.error(f"识别表格失败: {result.get('error_msg', '未知错误')}")
|
||||
return None
|
||||
|
||||
# 返回识别结果
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"识别表格时出错: {e}")
|
||||
return None
|
||||
|
||||
def get_excel_result(self, request_id: str, access_token: str) -> Optional[bytes]:
|
||||
"""
|
||||
获取Excel结果
|
||||
|
||||
Args:
|
||||
request_id: 请求ID
|
||||
access_token: 访问令牌
|
||||
|
||||
Returns:
|
||||
bytes: Excel文件内容,失败返回None
|
||||
"""
|
||||
try:
|
||||
# 请求URL
|
||||
url = f"https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result?access_token={access_token}"
|
||||
|
||||
# 请求参数
|
||||
headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
|
||||
data = {
|
||||
'request_id': request_id,
|
||||
'result_type': 'excel'
|
||||
}
|
||||
|
||||
# 最大重试次数
|
||||
max_retries = 10
|
||||
|
||||
# 循环获取结果
|
||||
for i in range(max_retries):
|
||||
# 发送请求
|
||||
response = requests.post(url, headers=headers, data=data, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
# 解析结果
|
||||
result = response.json()
|
||||
|
||||
# 检查错误码
|
||||
if 'error_code' in result:
|
||||
logger.error(f"获取Excel结果失败: {result.get('error_msg', '未知错误')}")
|
||||
return None
|
||||
|
||||
# 检查处理状态
|
||||
result_data = result.get('result', {})
|
||||
status = result_data.get('ret_code')
|
||||
|
||||
if status == 3: # 处理完成
|
||||
# 获取Excel文件URL
|
||||
excel_url = result_data.get('result_data')
|
||||
if not excel_url:
|
||||
logger.error("未获取到Excel结果URL")
|
||||
return None
|
||||
|
||||
# 下载Excel文件
|
||||
excel_response = requests.get(excel_url)
|
||||
excel_response.raise_for_status()
|
||||
|
||||
# 返回Excel文件内容
|
||||
return excel_response.content
|
||||
|
||||
elif status == 1: # 排队中
|
||||
logger.info(f"请求排队中 ({i+1}/{max_retries}),等待后重试...")
|
||||
elif status == 2: # 处理中
|
||||
logger.info(f"正在处理 ({i+1}/{max_retries}),等待后重试...")
|
||||
else:
|
||||
logger.error(f"未知状态码: {status}")
|
||||
return None
|
||||
|
||||
# 等待后重试
|
||||
time.sleep(2)
|
||||
|
||||
logger.error(f"获取Excel结果超时,请稍后再试")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取Excel结果时出错: {e}")
|
||||
return None
|
||||
|
||||
def process_image(self, image_path: str) -> Optional[str]:
|
||||
"""
|
||||
处理单个图片
|
||||
|
||||
Args:
|
||||
image_path: 图片文件路径
|
||||
|
||||
Returns:
|
||||
str: 生成的Excel文件路径,失败返回None
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始处理图片: {image_path}")
|
||||
|
||||
# 验证文件扩展名
|
||||
ext = os.path.splitext(image_path)[1].lower()
|
||||
if self.allowed_extensions and ext not in self.allowed_extensions:
|
||||
logger.error(f"不支持的文件类型: {ext},支持的类型: {', '.join(self.allowed_extensions)}")
|
||||
return None
|
||||
|
||||
# 重命名图片(可选)
|
||||
renamed_path = self.rename_image_to_timestamp(image_path)
|
||||
|
||||
# 获取文件名(不含扩展名)
|
||||
basename = os.path.basename(renamed_path)
|
||||
name_without_ext = os.path.splitext(basename)[0]
|
||||
|
||||
# 获取access_token
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
# 识别表格
|
||||
ocr_result = self.recognize_table(renamed_path)
|
||||
if not ocr_result:
|
||||
return None
|
||||
|
||||
# 获取请求ID
|
||||
request_id = ocr_result.get('result', {}).get('request_id')
|
||||
if not request_id:
|
||||
logger.error("未获取到请求ID")
|
||||
return None
|
||||
|
||||
# 获取Excel结果
|
||||
excel_content = self.get_excel_result(request_id, access_token)
|
||||
if not excel_content:
|
||||
return None
|
||||
|
||||
# 保存Excel文件
|
||||
output_path = os.path.join(self.output_folder, f"{name_without_ext}.xlsx")
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(excel_content)
|
||||
|
||||
logger.info(f"已保存Excel文件: {output_path}")
|
||||
return output_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理图片时出错: {e}")
|
||||
return None
|
||||
|
||||
def process_directory(self) -> List[str]:
|
||||
"""
|
||||
处理输入目录中的所有图片
|
||||
|
||||
Returns:
|
||||
List[str]: 生成的Excel文件路径列表
|
||||
"""
|
||||
results = []
|
||||
|
||||
try:
|
||||
# 获取输入目录中的所有图片文件
|
||||
image_files = []
|
||||
for ext in self.allowed_extensions:
|
||||
image_files.extend(list(Path(self.input_folder).glob(f"*{ext}")))
|
||||
image_files.extend(list(Path(self.input_folder).glob(f"*{ext.upper()}")))
|
||||
|
||||
if not image_files:
|
||||
logger.warning(f"输入目录 {self.input_folder} 中没有找到图片文件")
|
||||
return []
|
||||
|
||||
logger.info(f"在 {self.input_folder} 中找到 {len(image_files)} 个图片文件")
|
||||
|
||||
# 处理每个图片
|
||||
for image_file in image_files:
|
||||
result = self.process_image(str(image_file))
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
logger.info(f"处理完成,成功生成 {len(results)} 个Excel文件")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理目录时出错: {e}")
|
||||
return results
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
import argparse
|
||||
|
||||
# 解析命令行参数
|
||||
parser = argparse.ArgumentParser(description='百度表格OCR识别工具')
|
||||
parser.add_argument('--config', type=str, default='config.ini', help='配置文件路径')
|
||||
parser.add_argument('--input', type=str, help='输入图片路径')
|
||||
parser.add_argument('--debug', action='store_true', help='启用调试模式')
|
||||
args = parser.parse_args()
|
||||
|
||||
# 设置日志级别
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
# 创建OCR处理器
|
||||
processor = OCRProcessor(args.config)
|
||||
|
||||
# 处理单个图片或目录
|
||||
if args.input:
|
||||
if os.path.isfile(args.input):
|
||||
result = processor.process_image(args.input)
|
||||
if result:
|
||||
print(f"处理成功: {result}")
|
||||
return 0
|
||||
else:
|
||||
print("处理失败")
|
||||
return 1
|
||||
elif os.path.isdir(args.input):
|
||||
results = processor.process_directory()
|
||||
print(f"处理完成,成功生成 {len(results)} 个Excel文件")
|
||||
return 0
|
||||
else:
|
||||
print(f"输入路径不存在: {args.input}")
|
||||
return 1
|
||||
else:
|
||||
# 处理默认输入目录
|
||||
results = processor.process_directory()
|
||||
print(f"处理完成,成功生成 {len(results)} 个Excel文件")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,639 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
百度表格OCR识别工具
|
||||
-----------------
|
||||
用于将图片中的表格转换为Excel文件的工具。
|
||||
使用百度云OCR API进行识别,支持批量处理。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import datetime
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union, Any
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('ocr_processor.log', encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 默认配置
|
||||
DEFAULT_CONFIG = {
|
||||
'API': {
|
||||
'api_key': '', # 将从配置文件中读取
|
||||
'secret_key': '', # 将从配置文件中读取
|
||||
'timeout': '30',
|
||||
'max_retries': '3',
|
||||
'retry_delay': '2',
|
||||
'api_url': 'https://aip.baidubce.com/rest/2.0/ocr/v1/table'
|
||||
},
|
||||
'Paths': {
|
||||
'input_folder': 'input',
|
||||
'output_folder': 'output',
|
||||
'temp_folder': 'temp',
|
||||
'processed_record': 'processed_files.json'
|
||||
},
|
||||
'Performance': {
|
||||
'max_workers': '4',
|
||||
'batch_size': '5',
|
||||
'skip_existing': 'true'
|
||||
},
|
||||
'File': {
|
||||
'allowed_extensions': '.jpg,.jpeg,.png,.bmp',
|
||||
'excel_extension': '.xlsx',
|
||||
'max_file_size_mb': '4'
|
||||
}
|
||||
}
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理类,负责加载和保存配置"""
|
||||
|
||||
def __init__(self, config_file: str = 'config.ini'):
|
||||
self.config_file = config_file
|
||||
self.config = configparser.ConfigParser()
|
||||
self.load_config()
|
||||
|
||||
def load_config(self) -> None:
|
||||
"""加载配置文件,如果不存在则创建默认配置"""
|
||||
if not os.path.exists(self.config_file):
|
||||
self.create_default_config()
|
||||
|
||||
try:
|
||||
self.config.read(self.config_file, encoding='utf-8')
|
||||
logger.info(f"已加载配置文件: {self.config_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"加载配置文件时出错: {e}")
|
||||
logger.info("使用默认配置")
|
||||
self.create_default_config(save=False)
|
||||
|
||||
def create_default_config(self, save: bool = True) -> None:
|
||||
"""创建默认配置"""
|
||||
for section, options in DEFAULT_CONFIG.items():
|
||||
if not self.config.has_section(section):
|
||||
self.config.add_section(section)
|
||||
|
||||
for option, value in options.items():
|
||||
self.config.set(section, option, value)
|
||||
|
||||
if save:
|
||||
self.save_config()
|
||||
logger.info(f"已创建默认配置文件: {self.config_file}")
|
||||
|
||||
def save_config(self) -> None:
|
||||
"""保存配置到文件"""
|
||||
try:
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
self.config.write(f)
|
||||
except Exception as e:
|
||||
logger.error(f"保存配置文件时出错: {e}")
|
||||
|
||||
def get(self, section: str, option: str, fallback: Any = None) -> Any:
|
||||
"""获取配置值"""
|
||||
return self.config.get(section, option, fallback=fallback)
|
||||
|
||||
def getint(self, section: str, option: str, fallback: int = 0) -> int:
|
||||
"""获取整数配置值"""
|
||||
return self.config.getint(section, option, fallback=fallback)
|
||||
|
||||
def getfloat(self, section: str, option: str, fallback: float = 0.0) -> float:
|
||||
"""获取浮点数配置值"""
|
||||
return self.config.getfloat(section, option, fallback=fallback)
|
||||
|
||||
def getboolean(self, section: str, option: str, fallback: bool = False) -> bool:
|
||||
"""获取布尔配置值"""
|
||||
return self.config.getboolean(section, option, fallback=fallback)
|
||||
|
||||
def get_list(self, section: str, option: str, fallback: str = "", delimiter: str = ",") -> List[str]:
|
||||
"""获取列表配置值"""
|
||||
value = self.get(section, option, fallback)
|
||||
return [item.strip() for item in value.split(delimiter) if item.strip()]
|
||||
|
||||
class TokenManager:
|
||||
"""令牌管理类,负责获取和刷新百度API访问令牌"""
|
||||
|
||||
def __init__(self, api_key: str, secret_key: str, max_retries: int = 3, retry_delay: int = 2):
|
||||
self.api_key = api_key
|
||||
self.secret_key = secret_key
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
self.access_token = None
|
||||
self.token_expiry = 0
|
||||
|
||||
def get_token(self) -> Optional[str]:
|
||||
"""获取访问令牌,如果令牌已过期则刷新"""
|
||||
if self.is_token_valid():
|
||||
return self.access_token
|
||||
|
||||
return self.refresh_token()
|
||||
|
||||
def is_token_valid(self) -> bool:
|
||||
"""检查令牌是否有效"""
|
||||
return (
|
||||
self.access_token is not None and
|
||||
self.token_expiry > time.time() + 60 # 提前1分钟刷新
|
||||
)
|
||||
|
||||
def refresh_token(self) -> Optional[str]:
|
||||
"""刷新访问令牌"""
|
||||
url = "https://aip.baidubce.com/oauth/2.0/token"
|
||||
params = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.api_key,
|
||||
"client_secret": self.secret_key
|
||||
}
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = requests.post(url, params=params, timeout=10)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if "access_token" in result:
|
||||
self.access_token = result["access_token"]
|
||||
# 设置令牌过期时间(默认30天,提前1小时过期以确保安全)
|
||||
self.token_expiry = time.time() + result.get("expires_in", 2592000) - 3600
|
||||
logger.info("成功获取访问令牌")
|
||||
return self.access_token
|
||||
|
||||
logger.warning(f"获取访问令牌失败 (尝试 {attempt+1}/{self.max_retries}): {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取访问令牌时发生错误 (尝试 {attempt+1}/{self.max_retries}): {e}")
|
||||
|
||||
# 如果不是最后一次尝试,则等待后重试
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(self.retry_delay * (attempt + 1)) # 指数退避
|
||||
|
||||
logger.error("无法获取访问令牌")
|
||||
return None
|
||||
|
||||
class ProcessedRecordManager:
|
||||
"""处理记录管理器,用于跟踪已处理的文件"""
|
||||
|
||||
def __init__(self, record_file: str):
|
||||
self.record_file = record_file
|
||||
self.processed_files = self._load_record()
|
||||
|
||||
def _load_record(self) -> Dict[str, str]:
|
||||
"""加载处理记录"""
|
||||
if os.path.exists(self.record_file):
|
||||
try:
|
||||
with open(self.record_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"加载处理记录时出错: {e}")
|
||||
|
||||
return {}
|
||||
|
||||
def save_record(self) -> None:
|
||||
"""保存处理记录"""
|
||||
try:
|
||||
with open(self.record_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.processed_files, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"保存处理记录时出错: {e}")
|
||||
|
||||
def is_processed(self, image_file: str) -> bool:
|
||||
"""检查文件是否已处理"""
|
||||
return image_file in self.processed_files
|
||||
|
||||
def mark_as_processed(self, image_file: str, output_file: str) -> None:
|
||||
"""标记文件为已处理"""
|
||||
self.processed_files[image_file] = output_file
|
||||
self.save_record()
|
||||
|
||||
def get_output_file(self, image_file: str) -> Optional[str]:
|
||||
"""获取已处理文件对应的输出文件"""
|
||||
return self.processed_files.get(image_file)
|
||||
|
||||
class OCRProcessor:
|
||||
"""OCR处理器核心类,用于识别表格并保存为Excel"""
|
||||
|
||||
def __init__(self, config_manager: ConfigManager):
|
||||
self.config = config_manager
|
||||
|
||||
# 路径配置
|
||||
self.input_folder = self.config.get('Paths', 'input_folder')
|
||||
self.output_folder = self.config.get('Paths', 'output_folder')
|
||||
self.temp_folder = self.config.get('Paths', 'temp_folder')
|
||||
self.processed_record_file = os.path.join(
|
||||
self.config.get('Paths', 'output_folder'),
|
||||
self.config.get('Paths', 'processed_record')
|
||||
)
|
||||
|
||||
# API配置
|
||||
self.api_url = self.config.get('API', 'api_url')
|
||||
self.timeout = self.config.getint('API', 'timeout')
|
||||
self.max_retries = self.config.getint('API', 'max_retries')
|
||||
self.retry_delay = self.config.getint('API', 'retry_delay')
|
||||
|
||||
# 文件配置
|
||||
self.allowed_extensions = self.config.get_list('File', 'allowed_extensions')
|
||||
self.excel_extension = self.config.get('File', 'excel_extension')
|
||||
self.max_file_size_mb = self.config.getfloat('File', 'max_file_size_mb')
|
||||
|
||||
# 性能配置
|
||||
self.max_workers = self.config.getint('Performance', 'max_workers')
|
||||
self.batch_size = self.config.getint('Performance', 'batch_size')
|
||||
self.skip_existing = self.config.getboolean('Performance', 'skip_existing')
|
||||
|
||||
# 初始化其他组件
|
||||
self.token_manager = TokenManager(
|
||||
self.config.get('API', 'api_key'),
|
||||
self.config.get('API', 'secret_key'),
|
||||
self.max_retries,
|
||||
self.retry_delay
|
||||
)
|
||||
self.record_manager = ProcessedRecordManager(self.processed_record_file)
|
||||
|
||||
# 确保文件夹存在
|
||||
for folder in [self.input_folder, self.output_folder, self.temp_folder]:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
logger.info(f"已确保文件夹存在: {folder}")
|
||||
|
||||
def get_unprocessed_images(self) -> List[str]:
|
||||
"""获取待处理的图像文件列表"""
|
||||
all_files = []
|
||||
for ext in self.allowed_extensions:
|
||||
all_files.extend(Path(self.input_folder).glob(f"*{ext}"))
|
||||
|
||||
# 转换为字符串路径
|
||||
file_paths = [str(file_path) for file_path in all_files]
|
||||
|
||||
if self.skip_existing:
|
||||
# 过滤掉已处理的文件
|
||||
return [
|
||||
file_path for file_path in file_paths
|
||||
if not self.record_manager.is_processed(os.path.basename(file_path))
|
||||
]
|
||||
|
||||
return file_paths
|
||||
|
||||
def validate_image(self, image_path: str) -> bool:
|
||||
"""验证图像文件是否有效且符合大小限制"""
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(image_path):
|
||||
logger.error(f"文件不存在: {image_path}")
|
||||
return False
|
||||
|
||||
# 检查是否是文件
|
||||
if not os.path.isfile(image_path):
|
||||
logger.error(f"路径不是文件: {image_path}")
|
||||
return False
|
||||
|
||||
# 检查文件大小
|
||||
file_size_mb = os.path.getsize(image_path) / (1024 * 1024)
|
||||
if file_size_mb > self.max_file_size_mb:
|
||||
logger.error(f"文件过大 ({file_size_mb:.2f}MB > {self.max_file_size_mb}MB): {image_path}")
|
||||
return False
|
||||
|
||||
# 检查文件扩展名
|
||||
_, ext = os.path.splitext(image_path)
|
||||
if ext.lower() not in self.allowed_extensions:
|
||||
logger.error(f"不支持的文件格式 {ext}: {image_path}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def rename_image_to_timestamp(self, image_path: str) -> str:
|
||||
"""将图像文件重命名为时间戳格式"""
|
||||
try:
|
||||
# 获取目录和文件扩展名
|
||||
dir_name = os.path.dirname(image_path)
|
||||
file_ext = os.path.splitext(image_path)[1]
|
||||
|
||||
# 生成时间戳文件名
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
new_filename = f"{timestamp}{file_ext}"
|
||||
|
||||
# 构建新路径
|
||||
new_path = os.path.join(dir_name, new_filename)
|
||||
|
||||
# 如果目标文件已存在,添加毫秒级别的后缀
|
||||
if os.path.exists(new_path):
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S%f")
|
||||
new_filename = f"{timestamp}{file_ext}"
|
||||
new_path = os.path.join(dir_name, new_filename)
|
||||
|
||||
# 重命名文件
|
||||
os.rename(image_path, new_path)
|
||||
logger.info(f"文件已重命名: {os.path.basename(image_path)} -> {new_filename}")
|
||||
return new_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"重命名文件时出错: {e}")
|
||||
return image_path
|
||||
|
||||
def recognize_table(self, image_path: str) -> Optional[Dict]:
|
||||
"""使用百度表格OCR API识别图像中的表格"""
|
||||
# 获取访问令牌
|
||||
access_token = self.token_manager.get_token()
|
||||
if not access_token:
|
||||
logger.error("无法获取访问令牌")
|
||||
return None
|
||||
|
||||
url = f"{self.api_url}?access_token={access_token}"
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
# 读取图像文件并进行base64编码
|
||||
with open(image_path, 'rb') as f:
|
||||
image_data = f.read()
|
||||
|
||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
||||
|
||||
# 设置请求头和请求参数
|
||||
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
|
||||
params = {
|
||||
'image': image_base64,
|
||||
'return_excel': 'true' # 返回Excel文件编码
|
||||
}
|
||||
|
||||
# 发送请求
|
||||
response = requests.post(
|
||||
url,
|
||||
data=params,
|
||||
headers=headers,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
# 检查响应状态
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if 'error_code' in result:
|
||||
error_msg = result.get('error_msg', '未知错误')
|
||||
logger.error(f"表格识别失败: {error_msg}")
|
||||
|
||||
# 如果是授权错误,尝试刷新令牌
|
||||
if result.get('error_code') in [110, 111]: # 授权相关错误码
|
||||
self.token_manager.refresh_token()
|
||||
else:
|
||||
return result
|
||||
else:
|
||||
logger.error(f"表格识别失败: {response.status_code} - {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"表格识别过程中发生错误 (尝试 {attempt+1}/{self.max_retries}): {e}")
|
||||
|
||||
# 如果不是最后一次尝试,则等待后重试
|
||||
if attempt < self.max_retries - 1:
|
||||
wait_time = self.retry_delay * (2 ** attempt) # 指数退避
|
||||
logger.info(f"将在 {wait_time} 秒后重试...")
|
||||
time.sleep(wait_time)
|
||||
|
||||
return None
|
||||
|
||||
def save_to_excel(self, ocr_result: Dict, output_path: str) -> bool:
|
||||
"""将表格识别结果保存为Excel文件"""
|
||||
try:
|
||||
# 检查结果中是否包含表格数据和Excel文件
|
||||
if not ocr_result:
|
||||
logger.error("无法保存结果: 识别结果为空")
|
||||
return False
|
||||
|
||||
# 直接从excel_file字段获取Excel文件的base64编码
|
||||
excel_base64 = None
|
||||
|
||||
if 'excel_file' in ocr_result:
|
||||
excel_base64 = ocr_result['excel_file']
|
||||
elif 'tables_result' in ocr_result and ocr_result['tables_result']:
|
||||
for table in ocr_result['tables_result']:
|
||||
if 'excel_file' in table:
|
||||
excel_base64 = table['excel_file']
|
||||
break
|
||||
|
||||
if not excel_base64:
|
||||
logger.error("无法获取Excel文件编码")
|
||||
logger.debug(f"API返回结果: {json.dumps(ocr_result, ensure_ascii=False, indent=2)}")
|
||||
return False
|
||||
|
||||
# 解码base64并保存Excel文件
|
||||
try:
|
||||
excel_data = base64.b64decode(excel_base64)
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(excel_data)
|
||||
|
||||
logger.info(f"成功保存表格数据到: {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解码Excel数据时出错: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存Excel文件时发生错误: {e}")
|
||||
return False
|
||||
|
||||
def process_image(self, image_path: str) -> Optional[str]:
|
||||
"""处理单个图像文件:验证、重命名、识别和保存"""
|
||||
try:
|
||||
# 获取原始图片文件名(不含扩展名)
|
||||
image_basename = os.path.basename(image_path)
|
||||
image_name_without_ext = os.path.splitext(image_basename)[0]
|
||||
|
||||
# 检查是否已存在对应的Excel文件
|
||||
excel_filename = f"{image_name_without_ext}{self.excel_extension}"
|
||||
excel_path = os.path.join(self.output_folder, excel_filename)
|
||||
|
||||
if os.path.exists(excel_path):
|
||||
logger.info(f"已存在对应的Excel文件,跳过处理: {image_basename} -> {excel_filename}")
|
||||
# 记录处理结果(虽然跳过了处理,但仍标记为已处理)
|
||||
self.record_manager.mark_as_processed(image_basename, excel_path)
|
||||
return excel_path
|
||||
|
||||
# 检查文件是否已经处理过
|
||||
if self.skip_existing and self.record_manager.is_processed(image_basename):
|
||||
output_file = self.record_manager.get_output_file(image_basename)
|
||||
logger.info(f"文件已处理过,跳过: {image_basename} -> {output_file}")
|
||||
return output_file
|
||||
|
||||
# 验证图像文件
|
||||
if not self.validate_image(image_path):
|
||||
logger.warning(f"图像验证失败: {image_path}")
|
||||
return None
|
||||
|
||||
# 识别表格(不再重命名图片)
|
||||
logger.info(f"正在识别表格: {image_basename}")
|
||||
ocr_result = self.recognize_table(image_path)
|
||||
|
||||
if not ocr_result:
|
||||
logger.error(f"表格识别失败: {image_basename}")
|
||||
return None
|
||||
|
||||
# 保存结果到Excel,使用原始图片名
|
||||
if self.save_to_excel(ocr_result, excel_path):
|
||||
# 记录处理结果
|
||||
self.record_manager.mark_as_processed(image_basename, excel_path)
|
||||
return excel_path
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理图像时发生错误: {e}")
|
||||
return None
|
||||
|
||||
def process_images_batch(self, batch_size: int = None, max_workers: int = None) -> Tuple[int, int]:
|
||||
"""批量处理图像文件"""
|
||||
if batch_size is None:
|
||||
batch_size = self.batch_size
|
||||
|
||||
if max_workers is None:
|
||||
max_workers = self.max_workers
|
||||
|
||||
# 获取待处理的图像文件
|
||||
image_files = self.get_unprocessed_images()
|
||||
total_files = len(image_files)
|
||||
|
||||
if total_files == 0:
|
||||
logger.info("没有需要处理的图像文件")
|
||||
return 0, 0
|
||||
|
||||
logger.info(f"找到 {total_files} 个待处理图像文件")
|
||||
|
||||
# 处理所有文件
|
||||
processed_count = 0
|
||||
success_count = 0
|
||||
|
||||
# 如果文件数量很少,直接顺序处理
|
||||
if total_files <= 2 or max_workers <= 1:
|
||||
for image_path in image_files:
|
||||
processed_count += 1
|
||||
|
||||
logger.info(f"处理文件 ({processed_count}/{total_files}): {os.path.basename(image_path)}")
|
||||
output_path = self.process_image(image_path)
|
||||
|
||||
if output_path:
|
||||
success_count += 1
|
||||
logger.info(f"处理成功 ({success_count}/{processed_count}): {os.path.basename(output_path)}")
|
||||
else:
|
||||
logger.warning(f"处理失败: {os.path.basename(image_path)}")
|
||||
else:
|
||||
# 使用线程池并行处理
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
for i in range(0, total_files, batch_size):
|
||||
batch = image_files[i:i+batch_size]
|
||||
batch_results = list(executor.map(self.process_image, batch))
|
||||
|
||||
for j, result in enumerate(batch_results):
|
||||
processed_count += 1
|
||||
if result:
|
||||
success_count += 1
|
||||
logger.info(f"处理成功 ({success_count}/{processed_count}): {os.path.basename(result)}")
|
||||
else:
|
||||
logger.warning(f"处理失败: {os.path.basename(batch[j])}")
|
||||
|
||||
logger.info(f"已处理 {processed_count}/{total_files} 个文件,成功率: {success_count/processed_count*100:.1f}%")
|
||||
|
||||
logger.info(f"处理完成。总共处理 {processed_count} 个文件,成功 {success_count} 个,成功率: {success_count/max(processed_count,1)*100:.1f}%")
|
||||
return processed_count, success_count
|
||||
|
||||
def check_processed_status(self) -> Dict[str, List[str]]:
|
||||
"""检查处理状态,返回已处理和未处理的文件列表"""
|
||||
# 获取输入文件夹中的所有支持格式的图像文件
|
||||
all_images = []
|
||||
for ext in self.allowed_extensions:
|
||||
all_images.extend([str(file) for file in Path(self.input_folder).glob(f"*{ext}")])
|
||||
|
||||
# 获取已处理的文件列表
|
||||
processed_files = list(self.record_manager.processed_files.keys())
|
||||
|
||||
# 对路径进行规范化以便比较
|
||||
all_image_basenames = [os.path.basename(img) for img in all_images]
|
||||
|
||||
# 找出未处理的文件
|
||||
unprocessed_files = [
|
||||
img for img, basename in zip(all_images, all_image_basenames)
|
||||
if basename not in processed_files
|
||||
]
|
||||
|
||||
# 找出已处理的文件及其对应的输出文件
|
||||
processed_with_output = {
|
||||
img: self.record_manager.get_output_file(basename)
|
||||
for img, basename in zip(all_images, all_image_basenames)
|
||||
if basename in processed_files
|
||||
}
|
||||
|
||||
return {
|
||||
'all': all_images,
|
||||
'unprocessed': unprocessed_files,
|
||||
'processed': processed_with_output
|
||||
}
|
||||
|
||||
def main():
|
||||
"""主函数: 解析命令行参数并执行相应操作"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='百度表格OCR识别工具')
|
||||
parser.add_argument('--config', type=str, default='config.ini', help='配置文件路径')
|
||||
parser.add_argument('--batch-size', type=int, help='批处理大小')
|
||||
parser.add_argument('--max-workers', type=int, help='最大工作线程数')
|
||||
parser.add_argument('--force', action='store_true', help='强制处理所有文件,包括已处理的文件')
|
||||
parser.add_argument('--check', action='store_true', help='检查处理状态而不执行处理')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 加载配置
|
||||
config_manager = ConfigManager(args.config)
|
||||
|
||||
# 创建处理器
|
||||
processor = OCRProcessor(config_manager)
|
||||
|
||||
# 根据命令行参数调整配置
|
||||
if args.force:
|
||||
processor.skip_existing = False
|
||||
|
||||
if args.check:
|
||||
# 检查处理状态
|
||||
status = processor.check_processed_status()
|
||||
|
||||
print("\n=== 处理状态 ===")
|
||||
print(f"总共 {len(status['all'])} 个图像文件")
|
||||
print(f"已处理: {len(status['processed'])} 个")
|
||||
print(f"未处理: {len(status['unprocessed'])} 个")
|
||||
|
||||
if status['processed']:
|
||||
print("\n已处理文件:")
|
||||
for img, output in status['processed'].items():
|
||||
print(f" {os.path.basename(img)} -> {os.path.basename(output)}")
|
||||
|
||||
if status['unprocessed']:
|
||||
print("\n未处理文件:")
|
||||
for img in status['unprocessed']:
|
||||
print(f" {os.path.basename(img)}")
|
||||
|
||||
return
|
||||
|
||||
# 处理图像
|
||||
batch_size = args.batch_size if args.batch_size is not None else processor.batch_size
|
||||
max_workers = args.max_workers if args.max_workers is not None else processor.max_workers
|
||||
|
||||
processor.process_images_batch(batch_size, max_workers)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
start_time = time.time()
|
||||
logger.info("开始百度表格OCR识别程序")
|
||||
main()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"百度表格OCR识别程序已完成,耗时: {elapsed_time:.2f}秒")
|
||||
except Exception as e:
|
||||
logger.error(f"程序执行过程中发生错误: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
文件清理工具
|
||||
-----------
|
||||
用于清理输入/输出目录中的旧文件,支持按天数和文件名模式进行清理。
|
||||
默认情况下会清理input目录下的所有图片文件和output目录下的Excel文件。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import logging
|
||||
import argparse
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
import time
|
||||
import glob
|
||||
|
||||
# 配置日志
|
||||
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, 'clean_files.log')
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_file, encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class FileCleaner:
|
||||
"""文件清理工具类"""
|
||||
|
||||
def __init__(self, input_dir="input", output_dir="output"):
|
||||
"""初始化清理工具"""
|
||||
self.input_dir = input_dir
|
||||
self.output_dir = output_dir
|
||||
self.logs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
|
||||
|
||||
# 确保目录存在
|
||||
for directory in [self.input_dir, self.output_dir, self.logs_dir]:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
logger.info(f"确保目录存在: {directory}")
|
||||
|
||||
def get_file_stats(self, directory):
|
||||
"""获取目录的文件统计信息"""
|
||||
if not os.path.exists(directory):
|
||||
logger.warning(f"目录不存在: {directory}")
|
||||
return {}
|
||||
|
||||
stats = {
|
||||
'total_files': 0,
|
||||
'total_size': 0,
|
||||
'oldest_file': None,
|
||||
'newest_file': None,
|
||||
'file_types': {},
|
||||
'files_by_age': {
|
||||
'1_day': 0,
|
||||
'7_days': 0,
|
||||
'30_days': 0,
|
||||
'older': 0
|
||||
}
|
||||
}
|
||||
|
||||
now = datetime.now()
|
||||
one_day_ago = now - timedelta(days=1)
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
|
||||
# 跳过临时文件
|
||||
if file.startswith('~$') or file.startswith('.'):
|
||||
continue
|
||||
|
||||
# 文件信息
|
||||
try:
|
||||
file_stats = os.stat(file_path)
|
||||
file_size = file_stats.st_size
|
||||
mod_time = datetime.fromtimestamp(file_stats.st_mtime)
|
||||
|
||||
# 更新统计信息
|
||||
stats['total_files'] += 1
|
||||
stats['total_size'] += file_size
|
||||
|
||||
# 更新最旧和最新文件
|
||||
if stats['oldest_file'] is None or mod_time < stats['oldest_file'][1]:
|
||||
stats['oldest_file'] = (file_path, mod_time)
|
||||
|
||||
if stats['newest_file'] is None or mod_time > stats['newest_file'][1]:
|
||||
stats['newest_file'] = (file_path, mod_time)
|
||||
|
||||
# 按文件类型统计
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in stats['file_types']:
|
||||
stats['file_types'][ext]['count'] += 1
|
||||
stats['file_types'][ext]['size'] += file_size
|
||||
else:
|
||||
stats['file_types'][ext] = {'count': 1, 'size': file_size}
|
||||
|
||||
# 按年龄统计
|
||||
if mod_time > one_day_ago:
|
||||
stats['files_by_age']['1_day'] += 1
|
||||
elif mod_time > seven_days_ago:
|
||||
stats['files_by_age']['7_days'] += 1
|
||||
elif mod_time > thirty_days_ago:
|
||||
stats['files_by_age']['30_days'] += 1
|
||||
else:
|
||||
stats['files_by_age']['older'] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理文件时出错 {file_path}: {e}")
|
||||
|
||||
return stats
|
||||
|
||||
def print_stats(self):
|
||||
"""打印文件统计信息"""
|
||||
# 输入目录统计
|
||||
input_stats = self.get_file_stats(self.input_dir)
|
||||
output_stats = self.get_file_stats(self.output_dir)
|
||||
|
||||
print("\n===== 文件统计信息 =====")
|
||||
|
||||
# 打印输入目录统计
|
||||
if input_stats:
|
||||
print(f"\n输入目录 ({self.input_dir}):")
|
||||
print(f" 总文件数: {input_stats['total_files']}")
|
||||
print(f" 总大小: {self._format_size(input_stats['total_size'])}")
|
||||
|
||||
if input_stats['oldest_file']:
|
||||
oldest = input_stats['oldest_file']
|
||||
print(f" 最旧文件: {os.path.basename(oldest[0])} ({oldest[1].strftime('%Y-%m-%d %H:%M:%S')})")
|
||||
|
||||
if input_stats['newest_file']:
|
||||
newest = input_stats['newest_file']
|
||||
print(f" 最新文件: {os.path.basename(newest[0])} ({newest[1].strftime('%Y-%m-%d %H:%M:%S')})")
|
||||
|
||||
print(" 文件年龄分布:")
|
||||
print(f" 1天内: {input_stats['files_by_age']['1_day']}个文件")
|
||||
print(f" 7天内(不含1天内): {input_stats['files_by_age']['7_days']}个文件")
|
||||
print(f" 30天内(不含7天内): {input_stats['files_by_age']['30_days']}个文件")
|
||||
print(f" 更旧: {input_stats['files_by_age']['older']}个文件")
|
||||
|
||||
print(" 文件类型分布:")
|
||||
for ext, data in sorted(input_stats['file_types'].items(), key=lambda x: x[1]['count'], reverse=True):
|
||||
print(f" {ext or '无扩展名'}: {data['count']}个文件, {self._format_size(data['size'])}")
|
||||
|
||||
# 打印输出目录统计
|
||||
if output_stats:
|
||||
print(f"\n输出目录 ({self.output_dir}):")
|
||||
print(f" 总文件数: {output_stats['total_files']}")
|
||||
print(f" 总大小: {self._format_size(output_stats['total_size'])}")
|
||||
|
||||
if output_stats['oldest_file']:
|
||||
oldest = output_stats['oldest_file']
|
||||
print(f" 最旧文件: {os.path.basename(oldest[0])} ({oldest[1].strftime('%Y-%m-%d %H:%M:%S')})")
|
||||
|
||||
if output_stats['newest_file']:
|
||||
newest = output_stats['newest_file']
|
||||
print(f" 最新文件: {os.path.basename(newest[0])} ({newest[1].strftime('%Y-%m-%d %H:%M:%S')})")
|
||||
|
||||
print(" 文件年龄分布:")
|
||||
print(f" 1天内: {output_stats['files_by_age']['1_day']}个文件")
|
||||
print(f" 7天内(不含1天内): {output_stats['files_by_age']['7_days']}个文件")
|
||||
print(f" 30天内(不含7天内): {output_stats['files_by_age']['30_days']}个文件")
|
||||
print(f" 更旧: {output_stats['files_by_age']['older']}个文件")
|
||||
|
||||
def _format_size(self, size_bytes):
|
||||
"""格式化文件大小"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} 字节"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes/1024:.2f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes/(1024*1024):.2f} MB"
|
||||
else:
|
||||
return f"{size_bytes/(1024*1024*1024):.2f} GB"
|
||||
|
||||
def clean_files(self, directory, days=None, pattern=None, extensions=None, exclude_patterns=None, force=False, test_mode=False):
|
||||
"""
|
||||
清理指定目录中的文件
|
||||
|
||||
参数:
|
||||
directory (str): 要清理的目录
|
||||
days (int): 保留的天数,超过这个天数的文件将被清理,None表示不考虑时间
|
||||
pattern (str): 文件名匹配模式(正则表达式)
|
||||
extensions (list): 要删除的文件扩展名列表,如['.jpg', '.xlsx']
|
||||
exclude_patterns (list): 要排除的文件名模式列表
|
||||
force (bool): 是否强制清理,不显示确认提示
|
||||
test_mode (bool): 测试模式,只显示要删除的文件而不实际删除
|
||||
|
||||
返回:
|
||||
tuple: (cleaned_count, cleaned_size) 清理的文件数量和总大小
|
||||
"""
|
||||
if not os.path.exists(directory):
|
||||
logger.warning(f"目录不存在: {directory}")
|
||||
return 0, 0
|
||||
|
||||
cutoff_date = None
|
||||
if days is not None:
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
pattern_regex = re.compile(pattern) if pattern else None
|
||||
|
||||
files_to_clean = []
|
||||
|
||||
logger.info(f"扫描目录: {directory}")
|
||||
|
||||
# 查找需要清理的文件
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
|
||||
# 跳过临时文件
|
||||
if file.startswith('~$') or file.startswith('.'):
|
||||
continue
|
||||
|
||||
# 检查是否在排除列表中
|
||||
if exclude_patterns and any(pattern in file for pattern in exclude_patterns):
|
||||
logger.info(f"跳过文件: {file}")
|
||||
continue
|
||||
|
||||
# 检查文件扩展名
|
||||
if extensions and not any(file.lower().endswith(ext.lower()) for ext in extensions):
|
||||
continue
|
||||
|
||||
# 检查修改时间
|
||||
if cutoff_date:
|
||||
try:
|
||||
mod_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
if mod_time >= cutoff_date:
|
||||
logger.debug(f"文件未超过保留天数: {file} - {mod_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"检查文件时间时出错 {file_path}: {e}")
|
||||
continue
|
||||
|
||||
# 检查是否匹配模式
|
||||
if pattern_regex and not pattern_regex.search(file):
|
||||
continue
|
||||
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
files_to_clean.append((file_path, file_size))
|
||||
logger.info(f"找到要清理的文件: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取文件大小时出错 {file_path}: {e}")
|
||||
|
||||
if not files_to_clean:
|
||||
logger.info(f"没有找到需要清理的文件: {directory}")
|
||||
return 0, 0
|
||||
|
||||
# 显示要清理的文件
|
||||
total_size = sum(f[1] for f in files_to_clean)
|
||||
print(f"\n找到 {len(files_to_clean)} 个文件要清理,总大小: {self._format_size(total_size)}")
|
||||
|
||||
if len(files_to_clean) > 10:
|
||||
print("前10个文件:")
|
||||
for file_path, size in files_to_clean[:10]:
|
||||
print(f" {os.path.basename(file_path)} ({self._format_size(size)})")
|
||||
print(f" ...以及其他 {len(files_to_clean) - 10} 个文件")
|
||||
else:
|
||||
for file_path, size in files_to_clean:
|
||||
print(f" {os.path.basename(file_path)} ({self._format_size(size)})")
|
||||
|
||||
# 如果是测试模式,就不实际删除
|
||||
if test_mode:
|
||||
print("\n测试模式:不会实际删除文件。")
|
||||
return len(files_to_clean), total_size
|
||||
|
||||
# 确认清理
|
||||
if not force:
|
||||
confirm = input(f"\n确定要清理这些文件吗?[y/N] ")
|
||||
if confirm.lower() != 'y':
|
||||
print("清理操作已取消。")
|
||||
return 0, 0
|
||||
|
||||
# 执行清理
|
||||
cleaned_count = 0
|
||||
cleaned_size = 0
|
||||
|
||||
for file_path, size in files_to_clean:
|
||||
try:
|
||||
# 删除文件
|
||||
try:
|
||||
# 尝试检查文件是否被其他进程占用
|
||||
if os.path.exists(file_path):
|
||||
# 在Windows系统上,可能需要先关闭可能打开的文件句柄
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
# 尝试重命名文件,如果被占用通常会失败
|
||||
temp_path = file_path + '.temp'
|
||||
os.rename(file_path, temp_path)
|
||||
os.rename(temp_path, file_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"文件可能被占用: {file_path}, 错误: {e}")
|
||||
# 尝试关闭文件句柄(仅Windows)
|
||||
try:
|
||||
import ctypes
|
||||
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
handle = kernel32.CreateFileW(file_path, 0x80000000, 0, None, 3, 0x80, None)
|
||||
if handle != -1:
|
||||
kernel32.CloseHandle(handle)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 使用Path对象删除文件
|
||||
try:
|
||||
Path(file_path).unlink(missing_ok=True)
|
||||
logger.info(f"已删除文件: {file_path}")
|
||||
|
||||
cleaned_count += 1
|
||||
cleaned_size += size
|
||||
except Exception as e1:
|
||||
# 如果Path.unlink失败,尝试使用os.remove
|
||||
try:
|
||||
os.remove(file_path)
|
||||
logger.info(f"使用os.remove删除文件: {file_path}")
|
||||
|
||||
cleaned_count += 1
|
||||
cleaned_size += size
|
||||
except Exception as e2:
|
||||
logger.error(f"删除文件失败 {file_path}: {e1}, 再次尝试: {e2}")
|
||||
else:
|
||||
logger.warning(f"文件不存在或已被删除: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"删除文件时出错 {file_path}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"处理文件时出错 {file_path}: {e}")
|
||||
|
||||
print(f"\n已清理 {cleaned_count} 个文件,总大小: {self._format_size(cleaned_size)}")
|
||||
|
||||
return cleaned_count, cleaned_size
|
||||
|
||||
def clean_image_files(self, force=False, test_mode=False):
|
||||
"""清理输入目录中的图片文件"""
|
||||
print(f"\n===== 清理输入目录图片文件 ({self.input_dir}) =====")
|
||||
image_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif']
|
||||
return self.clean_files(
|
||||
self.input_dir,
|
||||
days=None, # 不考虑天数,清理所有图片
|
||||
extensions=image_extensions,
|
||||
force=force,
|
||||
test_mode=test_mode
|
||||
)
|
||||
|
||||
def clean_excel_files(self, force=False, test_mode=False):
|
||||
"""清理输出目录中的Excel文件"""
|
||||
print(f"\n===== 清理输出目录Excel文件 ({self.output_dir}) =====")
|
||||
excel_extensions = ['.xlsx', '.xls']
|
||||
exclude_patterns = ['processed_files.json'] # 保留处理记录文件
|
||||
return self.clean_files(
|
||||
self.output_dir,
|
||||
days=None, # 不考虑天数,清理所有Excel
|
||||
extensions=excel_extensions,
|
||||
exclude_patterns=exclude_patterns,
|
||||
force=force,
|
||||
test_mode=test_mode
|
||||
)
|
||||
|
||||
def clean_log_files(self, days=None, force=False, test_mode=False):
|
||||
"""清理日志目录中的旧日志文件
|
||||
|
||||
参数:
|
||||
days (int): 保留的天数,超过这个天数的日志将被清理,None表示清理所有日志
|
||||
force (bool): 是否强制清理,不显示确认提示
|
||||
test_mode (bool): 测试模式,只显示要删除的文件而不实际删除
|
||||
"""
|
||||
print(f"\n===== 清理日志文件 ({self.logs_dir}) =====")
|
||||
log_extensions = ['.log']
|
||||
# 排除当前正在使用的日志文件
|
||||
current_log = os.path.basename(log_file)
|
||||
logger.info(f"当前使用的日志文件: {current_log}")
|
||||
|
||||
result = self.clean_files(
|
||||
self.logs_dir,
|
||||
days=days, # 如果days=None,清理所有日志文件
|
||||
extensions=log_extensions,
|
||||
exclude_patterns=[current_log], # 排除当前使用的日志文件
|
||||
force=force,
|
||||
test_mode=test_mode
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def clean_logs(self, days=7, force=False, test=False):
|
||||
"""清理日志目录中的日志文件"""
|
||||
try:
|
||||
logs_dir = self.logs_dir
|
||||
if not os.path.exists(logs_dir):
|
||||
logger.warning(f"日志目录不存在: {logs_dir}")
|
||||
return
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
files_to_delete = []
|
||||
|
||||
# 检查是否有活跃标记文件
|
||||
active_files = set()
|
||||
for marker_file in glob.glob(os.path.join(logs_dir, '*.active')):
|
||||
active_log_name = os.path.basename(marker_file).replace('.active', '.log')
|
||||
active_files.add(active_log_name)
|
||||
logger.info(f"检测到活跃日志文件: {active_log_name}")
|
||||
|
||||
for file_path in glob.glob(os.path.join(logs_dir, '*.log*')):
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
# 跳过活跃的日志文件
|
||||
if file_name in active_files:
|
||||
logger.info(f"跳过活跃日志文件: {file_name}")
|
||||
continue
|
||||
|
||||
mtime = os.path.getmtime(file_path)
|
||||
if datetime.fromtimestamp(mtime) < cutoff_date:
|
||||
files_to_delete.append(file_path)
|
||||
|
||||
if not files_to_delete:
|
||||
logger.info("没有找到需要清理的日志文件")
|
||||
return
|
||||
|
||||
logger.info(f"找到 {len(files_to_delete)} 个过期的日志文件")
|
||||
for file_path in files_to_delete:
|
||||
if test:
|
||||
logger.info(f"测试模式 - 将删除: {os.path.basename(file_path)}")
|
||||
else:
|
||||
if not force:
|
||||
response = input(f"是否删除日志文件 {os.path.basename(file_path)}? (y/n): ")
|
||||
if response.lower() != 'y':
|
||||
logger.info(f"已跳过 {os.path.basename(file_path)}")
|
||||
continue
|
||||
|
||||
try:
|
||||
os.remove(file_path)
|
||||
logger.info(f"已删除日志文件: {os.path.basename(file_path)}")
|
||||
except Exception as e:
|
||||
logger.error(f"删除文件失败: {file_path}, 错误: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理日志文件时出错: {e}")
|
||||
|
||||
def clean_all_logs(self, force=False, test=False, except_current=True):
|
||||
"""清理所有日志文件"""
|
||||
try:
|
||||
logs_dir = self.logs_dir
|
||||
if not os.path.exists(logs_dir):
|
||||
logger.warning(f"日志目录不存在: {logs_dir}")
|
||||
return
|
||||
|
||||
# 检查是否有活跃标记文件
|
||||
active_files = set()
|
||||
for marker_file in glob.glob(os.path.join(logs_dir, '*.active')):
|
||||
active_log_name = os.path.basename(marker_file).replace('.active', '.log')
|
||||
active_files.add(active_log_name)
|
||||
logger.info(f"检测到活跃日志文件: {active_log_name}")
|
||||
|
||||
files_to_delete = []
|
||||
for file_path in glob.glob(os.path.join(logs_dir, '*.log*')):
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
# 跳过当前正在使用的日志文件
|
||||
if except_current and file_name in active_files:
|
||||
logger.info(f"保留活跃日志文件: {file_name}")
|
||||
continue
|
||||
|
||||
files_to_delete.append(file_path)
|
||||
|
||||
if not files_to_delete:
|
||||
logger.info("没有找到需要清理的日志文件")
|
||||
return
|
||||
|
||||
logger.info(f"找到 {len(files_to_delete)} 个日志文件需要清理")
|
||||
for file_path in files_to_delete:
|
||||
if test:
|
||||
logger.info(f"测试模式 - 将删除: {os.path.basename(file_path)}")
|
||||
else:
|
||||
if not force:
|
||||
response = input(f"是否删除日志文件 {os.path.basename(file_path)}? (y/n): ")
|
||||
if response.lower() != 'y':
|
||||
logger.info(f"已跳过 {os.path.basename(file_path)}")
|
||||
continue
|
||||
|
||||
try:
|
||||
os.remove(file_path)
|
||||
logger.info(f"已删除日志文件: {os.path.basename(file_path)}")
|
||||
except Exception as e:
|
||||
logger.error(f"删除文件失败: {file_path}, 错误: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理所有日志文件时出错: {e}")
|
||||
|
||||
def main():
|
||||
"""主程序"""
|
||||
parser = argparse.ArgumentParser(description='文件清理工具')
|
||||
parser.add_argument('--stats', action='store_true', help='显示文件统计信息')
|
||||
parser.add_argument('--clean-input', action='store_true', help='清理输入目录中超过指定天数的文件')
|
||||
parser.add_argument('--clean-output', action='store_true', help='清理输出目录中超过指定天数的文件')
|
||||
parser.add_argument('--clean-images', action='store_true', help='清理输入目录中的所有图片文件')
|
||||
parser.add_argument('--clean-excel', action='store_true', help='清理输出目录中的所有Excel文件')
|
||||
parser.add_argument('--clean-logs', action='store_true', help='清理日志目录中超过指定天数的日志文件')
|
||||
parser.add_argument('--clean-all-logs', action='store_true', help='清理所有日志文件(除当前使用的)')
|
||||
parser.add_argument('--days', type=int, default=30, help='保留的天数,默认30天')
|
||||
parser.add_argument('--log-days', type=int, default=7, help='保留的日志天数,默认7天')
|
||||
parser.add_argument('--pattern', type=str, help='文件名匹配模式(正则表达式)')
|
||||
parser.add_argument('--force', action='store_true', help='强制清理,不显示确认提示')
|
||||
parser.add_argument('--test', action='store_true', help='测试模式,只显示要删除的文件而不实际删除')
|
||||
parser.add_argument('--input-dir', type=str, default='input', help='指定输入目录')
|
||||
parser.add_argument('--output-dir', type=str, default='output', help='指定输出目录')
|
||||
parser.add_argument('--help-only', action='store_true', help='只显示帮助信息,不执行任何操作')
|
||||
parser.add_argument('--all', action='store_true', help='清理所有类型的文件(输入、输出和日志)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
cleaner = FileCleaner(args.input_dir, args.output_dir)
|
||||
|
||||
# 显示统计信息
|
||||
if args.stats:
|
||||
cleaner.print_stats()
|
||||
|
||||
# 如果指定了--help-only,只显示帮助信息
|
||||
if args.help_only:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
# 如果指定了--all,清理所有类型的文件
|
||||
if args.all:
|
||||
cleaner.clean_image_files(args.force, args.test)
|
||||
cleaner.clean_excel_files(args.force, args.test)
|
||||
cleaner.clean_log_files(args.log_days, args.force, args.test)
|
||||
cleaner.clean_all_logs(args.force, args.test)
|
||||
return
|
||||
|
||||
# 清理输入目录中的图片文件
|
||||
if args.clean_images or not any([args.stats, args.clean_input, args.clean_output,
|
||||
args.clean_excel, args.clean_logs, args.clean_all_logs, args.help_only]):
|
||||
cleaner.clean_image_files(args.force, args.test)
|
||||
|
||||
# 清理输出目录中的Excel文件
|
||||
if args.clean_excel or not any([args.stats, args.clean_input, args.clean_output,
|
||||
args.clean_images, args.clean_logs, args.clean_all_logs, args.help_only]):
|
||||
cleaner.clean_excel_files(args.force, args.test)
|
||||
|
||||
# 清理日志文件(按天数)
|
||||
if args.clean_logs:
|
||||
cleaner.clean_log_files(args.log_days, args.force, args.test)
|
||||
|
||||
# 清理所有日志文件
|
||||
if args.clean_all_logs:
|
||||
cleaner.clean_all_logs(args.force, args.test)
|
||||
|
||||
# 清理输入目录(按天数)
|
||||
if args.clean_input:
|
||||
print(f"\n===== 清理输入目录 ({args.input_dir}) =====")
|
||||
cleaner.clean_files(
|
||||
args.input_dir,
|
||||
days=args.days,
|
||||
pattern=args.pattern,
|
||||
force=args.force,
|
||||
test_mode=args.test
|
||||
)
|
||||
|
||||
# 清理输出目录(按天数)
|
||||
if args.clean_output:
|
||||
print(f"\n===== 清理输出目录 ({args.output_dir}) =====")
|
||||
cleaner.clean_files(
|
||||
args.output_dir,
|
||||
days=args.days,
|
||||
pattern=args.pattern,
|
||||
force=args.force,
|
||||
test_mode=args.test
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n程序已被用户中断")
|
||||
except Exception as e:
|
||||
logger.error(f"程序运行出错: {e}", exc_info=True)
|
||||
print(f"程序运行出错: {e}")
|
||||
print("请查看日志文件了解详细信息")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
日志清理脚本
|
||||
-----------
|
||||
用于清理和管理日志文件,包括:
|
||||
1. 清理指定天数之前的日志文件
|
||||
2. 保留最新的N个日志文件
|
||||
3. 清理过大的日志文件
|
||||
4. 支持压缩旧日志文件
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import shutil
|
||||
import logging
|
||||
import argparse
|
||||
from datetime import datetime, timedelta
|
||||
import gzip
|
||||
from pathlib import Path
|
||||
import glob
|
||||
import re
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.handlers:
|
||||
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs', 'clean_logs.log')
|
||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_file, encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 标记该日志文件为活跃
|
||||
active_marker = os.path.join(os.path.dirname(log_file), 'clean_logs.active')
|
||||
with open(active_marker, 'w') as f:
|
||||
f.write(f"Active since: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
def is_log_active(log_file):
|
||||
"""检查日志文件是否处于活跃状态(正在被使用)"""
|
||||
# 检查对应的活跃标记文件是否存在
|
||||
log_name = os.path.basename(log_file)
|
||||
base_name = os.path.splitext(log_name)[0]
|
||||
active_marker = os.path.join(os.path.dirname(log_file), f"{base_name}.active")
|
||||
|
||||
# 如果活跃标记文件存在,说明日志文件正在被使用
|
||||
if os.path.exists(active_marker):
|
||||
logger.info(f"日志文件 {log_name} 正在使用中,不会被删除")
|
||||
return True
|
||||
|
||||
# 检查是否是当前脚本正在使用的日志文件
|
||||
if log_name == os.path.basename(log_file):
|
||||
logger.info(f"当前脚本正在使用 {log_name},不会被删除")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clean_logs(log_dir="logs", max_days=7, max_files=10, max_size=100, force=False):
|
||||
"""
|
||||
清理日志文件
|
||||
|
||||
参数:
|
||||
log_dir: 日志目录
|
||||
max_days: 保留的最大天数
|
||||
max_files: 保留的最大文件数
|
||||
max_size: 日志文件大小上限(MB)
|
||||
force: 是否强制清理
|
||||
"""
|
||||
logger.info(f"开始清理日志目录: {log_dir}")
|
||||
|
||||
# 确保日志目录存在
|
||||
if not os.path.exists(log_dir):
|
||||
logger.warning(f"日志目录不存在: {log_dir}")
|
||||
return
|
||||
|
||||
# 获取所有日志文件
|
||||
log_files = []
|
||||
for ext in ['*.log', '*.log.*']:
|
||||
log_files.extend(glob.glob(os.path.join(log_dir, ext)))
|
||||
|
||||
if not log_files:
|
||||
logger.info(f"没有找到日志文件")
|
||||
return
|
||||
|
||||
logger.info(f"找到 {len(log_files)} 个日志文件")
|
||||
|
||||
# 按修改时间排序
|
||||
log_files.sort(key=lambda x: os.path.getmtime(x), reverse=True)
|
||||
|
||||
# 处理大文件
|
||||
for log_file in log_files:
|
||||
# 跳过活跃的日志文件
|
||||
if is_log_active(log_file):
|
||||
continue
|
||||
|
||||
# 检查文件大小
|
||||
file_size_mb = os.path.getsize(log_file) / (1024 * 1024)
|
||||
if file_size_mb > max_size:
|
||||
logger.info(f"日志文件 {os.path.basename(log_file)} 大小为 {file_size_mb:.2f}MB,超过限制 {max_size}MB")
|
||||
|
||||
# 压缩并重命名大文件
|
||||
compressed_file = f"{log_file}.{datetime.now().strftime('%Y%m%d%H%M%S')}.zip"
|
||||
try:
|
||||
shutil.make_archive(os.path.splitext(compressed_file)[0], 'zip', log_dir, os.path.basename(log_file))
|
||||
logger.info(f"已压缩日志文件: {compressed_file}")
|
||||
|
||||
# 清空原文件内容
|
||||
if not force:
|
||||
confirm = input(f"是否清空日志文件 {os.path.basename(log_file)}? (y/n): ")
|
||||
if confirm.lower() != 'y':
|
||||
logger.info("已取消清空操作")
|
||||
continue
|
||||
|
||||
with open(log_file, 'w') as f:
|
||||
f.write(f"日志已于 {datetime.now()} 清空并压缩\n")
|
||||
logger.info(f"已清空日志文件: {os.path.basename(log_file)}")
|
||||
except Exception as e:
|
||||
logger.error(f"压缩日志文件时出错: {e}")
|
||||
|
||||
# 清理过期的文件
|
||||
cutoff_date = datetime.now() - timedelta(days=max_days)
|
||||
files_to_delete = []
|
||||
|
||||
for log_file in log_files[max_files:]:
|
||||
# 跳过活跃的日志文件
|
||||
if is_log_active(log_file):
|
||||
continue
|
||||
|
||||
mtime = datetime.fromtimestamp(os.path.getmtime(log_file))
|
||||
if mtime < cutoff_date:
|
||||
files_to_delete.append(log_file)
|
||||
|
||||
if not files_to_delete:
|
||||
logger.info("没有需要删除的过期日志文件")
|
||||
return
|
||||
|
||||
logger.info(f"找到 {len(files_to_delete)} 个过期日志文件")
|
||||
|
||||
# 确认删除
|
||||
if not force:
|
||||
print(f"以下 {len(files_to_delete)} 个文件将被删除:")
|
||||
for file in files_to_delete:
|
||||
print(f" - {os.path.basename(file)}")
|
||||
confirm = input("确认删除? (y/n): ")
|
||||
if confirm.lower() != 'y':
|
||||
logger.info("已取消删除操作")
|
||||
return
|
||||
|
||||
# 删除文件
|
||||
deleted_count = 0
|
||||
for file in files_to_delete:
|
||||
try:
|
||||
os.remove(file)
|
||||
logger.info(f"已删除日志文件: {os.path.basename(file)}")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"删除日志文件时出错: {e}")
|
||||
|
||||
logger.info(f"成功删除 {deleted_count} 个日志文件")
|
||||
|
||||
def show_stats(log_dir="logs"):
|
||||
"""显示日志文件统计信息"""
|
||||
if not os.path.exists(log_dir):
|
||||
print(f"日志目录不存在: {log_dir}")
|
||||
return
|
||||
|
||||
log_files = []
|
||||
for ext in ['*.log', '*.log.*']:
|
||||
log_files.extend(glob.glob(os.path.join(log_dir, ext)))
|
||||
|
||||
if not log_files:
|
||||
print("没有找到日志文件")
|
||||
return
|
||||
|
||||
print(f"\n找到 {len(log_files)} 个日志文件:")
|
||||
print("=" * 80)
|
||||
print(f"{'文件名':<30} {'大小':<10} {'最后修改时间':<20} {'状态':<10}")
|
||||
print("-" * 80)
|
||||
|
||||
total_size = 0
|
||||
for file in sorted(log_files, key=lambda x: os.path.getmtime(x), reverse=True):
|
||||
size = os.path.getsize(file)
|
||||
total_size += size
|
||||
|
||||
mtime = datetime.fromtimestamp(os.path.getmtime(file))
|
||||
size_str = f"{size / 1024:.1f} KB" if size < 1024*1024 else f"{size / (1024*1024):.1f} MB"
|
||||
|
||||
# 检查是否是活跃日志
|
||||
status = "活跃" if is_log_active(file) else ""
|
||||
|
||||
print(f"{os.path.basename(file):<30} {size_str:<10} {mtime.strftime('%Y-%m-%d %H:%M:%S'):<20} {status:<10}")
|
||||
|
||||
print("-" * 80)
|
||||
total_size_str = f"{total_size / 1024:.1f} KB" if total_size < 1024*1024 else f"{total_size / (1024*1024):.1f} MB"
|
||||
print(f"总大小: {total_size_str}")
|
||||
print("=" * 80)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="日志文件清理工具")
|
||||
parser.add_argument("--max-days", type=int, default=7, help="日志保留的最大天数")
|
||||
parser.add_argument("--max-files", type=int, default=10, help="保留的最大文件数")
|
||||
parser.add_argument("--max-size", type=float, default=100, help="日志文件大小上限(MB)")
|
||||
parser.add_argument("--force", action="store_true", help="强制清理,不提示确认")
|
||||
parser.add_argument("--stats", action="store_true", help="显示日志统计信息")
|
||||
parser.add_argument("--log-dir", type=str, default="logs", help="日志目录")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.stats:
|
||||
show_stats(args.log_dir)
|
||||
else:
|
||||
clean_logs(args.log_dir, args.max_days, args.max_files, args.max_size, args.force)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 5.4 MiB |
Binary file not shown.
@@ -0,0 +1 @@
|
||||
Active since: 2025-05-01 19:46:44
|
||||
@@ -0,0 +1 @@
|
||||
Active since: 2025-05-01 19:49:19
|
||||
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
合并采购单程序
|
||||
-------------------
|
||||
将多个采购单Excel文件合并成一个文件。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import pandas as pd
|
||||
import xlrd
|
||||
import xlwt
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union, Any
|
||||
from datetime import datetime
|
||||
import random
|
||||
from xlutils.copy import copy as xlcopy
|
||||
import time
|
||||
import json
|
||||
import re
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.handlers:
|
||||
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs', 'merge_purchase_orders.log')
|
||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_file, encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("初始化日志系统")
|
||||
|
||||
class PurchaseOrderMerger:
|
||||
"""
|
||||
采购单合并器:将多个采购单Excel文件合并成一个文件
|
||||
"""
|
||||
|
||||
def __init__(self, output_dir="output"):
|
||||
"""
|
||||
初始化采购单合并器,并设置输出目录
|
||||
"""
|
||||
logger.info("初始化PurchaseOrderMerger")
|
||||
self.output_dir = output_dir
|
||||
|
||||
# 确保输出目录存在
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
logger.info(f"创建输出目录: {output_dir}")
|
||||
|
||||
# 设置路径
|
||||
self.template_path = os.path.join("templets", "银豹-采购单模板.xls")
|
||||
|
||||
# 检查模板文件是否存在
|
||||
if not os.path.exists(self.template_path):
|
||||
logger.error(f"模板文件不存在: {self.template_path}")
|
||||
raise FileNotFoundError(f"模板文件不存在: {self.template_path}")
|
||||
|
||||
# 用于记录已处理的文件
|
||||
self.cache_file = os.path.join(output_dir, "merged_files.json")
|
||||
self.merged_files = self._load_merged_files()
|
||||
|
||||
logger.info(f"初始化完成,模板文件: {self.template_path}")
|
||||
|
||||
def _load_merged_files(self):
|
||||
"""加载已合并文件的缓存"""
|
||||
if os.path.exists(self.cache_file):
|
||||
try:
|
||||
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
||||
cache = json.load(f)
|
||||
logger.info(f"加载已合并文件缓存,共{len(cache)} 条记录")
|
||||
return cache
|
||||
except Exception as e:
|
||||
logger.warning(f"读取缓存文件失败: {e}")
|
||||
return {}
|
||||
|
||||
def _save_merged_files(self):
|
||||
"""保存已合并文件的缓存"""
|
||||
try:
|
||||
with open(self.cache_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.merged_files, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"已更新合并文件缓存,共{len(self.merged_files)} 条记录")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存缓存文件失败: {e}")
|
||||
|
||||
def get_latest_purchase_orders(self):
|
||||
"""
|
||||
获取output目录下最新的采购单Excel文件
|
||||
"""
|
||||
logger.info(f"搜索目录 {self.output_dir} 中的采购单Excel文件")
|
||||
excel_files = []
|
||||
|
||||
for file in os.listdir(self.output_dir):
|
||||
# 只处理以"采购单_"开头的Excel文件
|
||||
if file.lower().endswith('.xls') and file.startswith('采购单_'):
|
||||
file_path = os.path.join(self.output_dir, file)
|
||||
excel_files.append((file_path, os.path.getmtime(file_path)))
|
||||
|
||||
if not excel_files:
|
||||
logger.warning(f"未在 {self.output_dir} 目录下找到采购单Excel文件")
|
||||
return []
|
||||
|
||||
# 按修改时间排序,获取最新的文件
|
||||
sorted_files = sorted(excel_files, key=lambda x: x[1], reverse=True)
|
||||
logger.info(f"找到{len(sorted_files)} 个采购单Excel文件")
|
||||
return [file[0] for file in sorted_files]
|
||||
|
||||
def read_purchase_order(self, file_path):
|
||||
"""
|
||||
读取采购单Excel文件
|
||||
"""
|
||||
try:
|
||||
# 读取Excel文件
|
||||
df = pd.read_excel(file_path)
|
||||
logger.info(f"成功读取采购单文件: {file_path}")
|
||||
|
||||
# 打印列名,用于调试
|
||||
logger.info(f"Excel文件的列名: {df.columns.tolist()}")
|
||||
|
||||
# 检查是否有特殊表头结构(如"武侯环球乐百惠便利店3333.xlsx")
|
||||
# 判断依据:检查第3行是否包含常见的商品表头信息
|
||||
special_header = False
|
||||
if len(df) > 3: # 确保有足够的行
|
||||
row3 = df.iloc[3].astype(str)
|
||||
header_keywords = ['行号', '条形码', '条码', '商品名称', '规格', '单价', '数量', '金额', '单位']
|
||||
# 计算匹配的关键词数量
|
||||
matches = sum(1 for keyword in header_keywords if any(keyword in str(val) for val in row3.values))
|
||||
# 如果匹配了至少3个关键词,认为第3行是表头
|
||||
if matches >= 3:
|
||||
logger.info(f"检测到特殊表头结构,使用第3行作为列名: {row3.values.tolist()}")
|
||||
# 创建新的数据帧,使用第3行作为列名,数据从第4行开始
|
||||
header_row = df.iloc[3]
|
||||
data_rows = df.iloc[4:].reset_index(drop=True)
|
||||
# 为每一列分配一个名称(避免重复的列名)
|
||||
new_columns = []
|
||||
for i, col in enumerate(header_row):
|
||||
col_str = str(col)
|
||||
if col_str == 'nan' or col_str == 'None' or pd.isna(col):
|
||||
new_columns.append(f"Col_{i}")
|
||||
else:
|
||||
new_columns.append(col_str)
|
||||
# 使用新列名创建新的DataFrame
|
||||
data_rows.columns = new_columns
|
||||
df = data_rows
|
||||
special_header = True
|
||||
logger.info(f"重新构建的数据帧列名: {df.columns.tolist()}")
|
||||
|
||||
# 定义可能的列名映射
|
||||
column_mapping = {
|
||||
'条码': ['条码', '条形码', '商品条码', 'barcode', '商品条形码', '条形码', '商品条码', '商品编码', '商品编号', '条形码', '条码(必填)'],
|
||||
'采购量': ['数量', '采购数量', '购买数量', '采购数量', '订单数量', '采购数量', '采购量(必填)'],
|
||||
'采购单价': ['单价', '价格', '采购单价', '销售价', '采购单价(必填)'],
|
||||
'赠送量': ['赠送量', '赠品数量', '赠送数量', '赠品']
|
||||
}
|
||||
|
||||
# 映射实际的列名
|
||||
mapped_columns = {}
|
||||
for target_col, possible_names in column_mapping.items():
|
||||
for col in df.columns:
|
||||
# 移除列名中的空白字符和括号内容以进行比较
|
||||
clean_col = re.sub(r'\s+', '', str(col))
|
||||
clean_col = re.sub(r'(.*?)', '', clean_col) # 移除括号内容
|
||||
for name in possible_names:
|
||||
clean_name = re.sub(r'\s+', '', name)
|
||||
clean_name = re.sub(r'(.*?)', '', clean_name) # 移除括号内容
|
||||
if clean_col == clean_name:
|
||||
mapped_columns[target_col] = col
|
||||
break
|
||||
if target_col in mapped_columns:
|
||||
break
|
||||
|
||||
# 如果找到了必要的列,重命名列
|
||||
if mapped_columns:
|
||||
df = df.rename(columns=mapped_columns)
|
||||
logger.info(f"列名映射结果: {mapped_columns}")
|
||||
|
||||
return df
|
||||
except Exception as e:
|
||||
logger.error(f"读取采购单文件失败: {file_path}, 错误: {str(e)}")
|
||||
return None
|
||||
|
||||
def merge_purchase_orders(self, file_paths):
|
||||
"""
|
||||
合并多个采购单文件
|
||||
"""
|
||||
if not file_paths:
|
||||
logger.warning("没有需要合并的采购单文件")
|
||||
return None
|
||||
|
||||
# 读取所有采购单文件
|
||||
dfs = []
|
||||
for file_path in file_paths:
|
||||
df = self.read_purchase_order(file_path)
|
||||
if df is not None:
|
||||
# 确保条码列是字符串类型
|
||||
df['条码(必填)'] = df['条码(必填)'].astype(str)
|
||||
# 去除可能的小数点和.0
|
||||
df['条码(必填)'] = df['条码(必填)'].apply(lambda x: x.split('.')[0] if '.' in x else x)
|
||||
|
||||
# 处理NaN值,将其转换为空字符串
|
||||
for col in df.columns:
|
||||
df[col] = df[col].apply(lambda x: '' if pd.isna(x) else x)
|
||||
|
||||
dfs.append(df)
|
||||
|
||||
if not dfs:
|
||||
logger.error("没有成功读取任何采购单文件")
|
||||
return None
|
||||
|
||||
# 合并所有数据框
|
||||
merged_df = pd.concat(dfs, ignore_index=True)
|
||||
logger.info(f"合并了{len(dfs)} 个采购单文件,共{len(merged_df)} 条记录")
|
||||
|
||||
# 检查并合并相同条码和单价的数据
|
||||
merged_data = {}
|
||||
for _, row in merged_df.iterrows():
|
||||
# 使用映射后的列名访问数据
|
||||
barcode = str(row['条码(必填)']) # 保持字符串格式
|
||||
# 移除条码中可能的小数点
|
||||
barcode = barcode.split('.')[0] if '.' in barcode else barcode
|
||||
|
||||
unit_price = float(row['采购单价(必填)'])
|
||||
quantity = float(row['采购量(必填)'])
|
||||
|
||||
# 检查赠送量是否为空
|
||||
has_gift = '赠送量' in row and row['赠送量'] != '' and not pd.isna(row['赠送量'])
|
||||
gift_quantity = float(row['赠送量']) if has_gift else ''
|
||||
|
||||
# 商品名称处理,确保不会出现"nan"
|
||||
product_name = row['商品名称']
|
||||
if pd.isna(product_name) or product_name == 'nan' or product_name == 'None':
|
||||
product_name = ''
|
||||
|
||||
# 创建唯一键:条码+单价
|
||||
key = f"{barcode}_{unit_price}"
|
||||
|
||||
if key in merged_data:
|
||||
# 如果已存在相同条码和单价的数据,累加数量
|
||||
merged_data[key]['采购量(必填)'] += quantity
|
||||
|
||||
# 如果当前记录有赠送量且之前的记录也有赠送量,则累加赠送量
|
||||
if has_gift and merged_data[key]['赠送量'] != '':
|
||||
merged_data[key]['赠送量'] += gift_quantity
|
||||
# 如果当前记录有赠送量但之前的记录没有,则设置赠送量
|
||||
elif has_gift:
|
||||
merged_data[key]['赠送量'] = gift_quantity
|
||||
# 其他情况保持原样(为空)
|
||||
|
||||
logger.info(f"合并相同条码和单价的数据: 条码={barcode}, 单价={unit_price}, 数量={quantity}, 赠送量={gift_quantity}")
|
||||
|
||||
# 如果当前商品名称不为空,且原来的为空,则更新商品名称
|
||||
if product_name and not merged_data[key]['商品名称']:
|
||||
merged_data[key]['商品名称'] = product_name
|
||||
else:
|
||||
# 如果是新数据,直接添加
|
||||
merged_data[key] = {
|
||||
'商品名称': product_name,
|
||||
'条码(必填)': barcode, # 使用处理后的条码
|
||||
'采购量(必填)': quantity,
|
||||
'赠送量': gift_quantity,
|
||||
'采购单价(必填)': unit_price
|
||||
}
|
||||
|
||||
# 将合并后的数据转换回DataFrame
|
||||
final_df = pd.DataFrame(list(merged_data.values()))
|
||||
logger.info(f"合并后剩余{len(final_df)} 条唯一记录")
|
||||
|
||||
return final_df
|
||||
|
||||
def create_merged_purchase_order(self, df):
|
||||
"""
|
||||
创建合并后的采购单Excel文件
|
||||
"""
|
||||
try:
|
||||
# 获取当前时间戳
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
|
||||
# 创建输出文件路径
|
||||
output_file = os.path.join(self.output_dir, f"合并采购单_{timestamp}.xls")
|
||||
|
||||
# 打开模板文件
|
||||
workbook = xlrd.open_workbook(self.template_path)
|
||||
workbook = xlcopy(workbook)
|
||||
worksheet = workbook.get_sheet(0)
|
||||
|
||||
# 从第2行开始填充数据
|
||||
row_index = 1
|
||||
|
||||
# 按条码排序
|
||||
df = df.sort_values('条码(必填)')
|
||||
|
||||
# 填充数据
|
||||
for _, row in df.iterrows():
|
||||
# 1. 列A(0): 商品名称
|
||||
product_name = str(row['商品名称'])
|
||||
# 检查并处理nan值
|
||||
if product_name == 'nan' or product_name == 'None':
|
||||
product_name = ''
|
||||
worksheet.write(row_index, 0, product_name)
|
||||
|
||||
# 2. 列B(1): 条码
|
||||
worksheet.write(row_index, 1, str(row['条码(必填)']))
|
||||
|
||||
# 3. 列C(2): 采购量
|
||||
worksheet.write(row_index, 2, float(row['采购量(必填)']))
|
||||
|
||||
# 4. 列D(3): 赠送量
|
||||
# 只有当赠送量不为空且不为0时才写入
|
||||
if '赠送量' in row and row['赠送量'] != '' and not pd.isna(row['赠送量']):
|
||||
# 将赠送量转换为数字
|
||||
try:
|
||||
gift_quantity = float(row['赠送量'])
|
||||
# 只有当赠送量大于0时才写入
|
||||
if gift_quantity > 0:
|
||||
worksheet.write(row_index, 3, gift_quantity)
|
||||
except (ValueError, TypeError):
|
||||
# 如果转换失败,忽略赠送量
|
||||
pass
|
||||
|
||||
# 5. 列E(4): 采购单价
|
||||
style = xlwt.XFStyle()
|
||||
style.num_format_str = '0.0000'
|
||||
worksheet.write(row_index, 4, float(row['采购单价(必填)']), style)
|
||||
|
||||
row_index += 1
|
||||
|
||||
# 保存文件
|
||||
workbook.save(output_file)
|
||||
logger.info(f"合并采购单已保存: {output_file}")
|
||||
|
||||
# 记录已合并文件
|
||||
for file_path in self.get_latest_purchase_orders():
|
||||
file_stat = os.stat(file_path)
|
||||
file_key = f"{os.path.basename(file_path)}_{file_stat.st_size}_{file_stat.st_mtime}"
|
||||
self.merged_files[file_key] = output_file
|
||||
|
||||
self._save_merged_files()
|
||||
|
||||
return output_file
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建合并采购单失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def process(self):
|
||||
"""
|
||||
处理最新的采购单文件
|
||||
"""
|
||||
# 获取最新的采购单文件
|
||||
file_paths = self.get_latest_purchase_orders()
|
||||
if not file_paths:
|
||||
logger.error("未找到可处理的采购单文件")
|
||||
return False
|
||||
|
||||
# 合并采购单
|
||||
merged_df = self.merge_purchase_orders(file_paths)
|
||||
if merged_df is None:
|
||||
logger.error("合并采购单失败")
|
||||
return False
|
||||
|
||||
# 创建合并后的采购单
|
||||
output_file = self.create_merged_purchase_order(merged_df)
|
||||
if output_file is None:
|
||||
logger.error("创建合并采购单失败")
|
||||
return False
|
||||
|
||||
logger.info(f"处理完成,合并采购单已保存至: {output_file}")
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""主程序"""
|
||||
import argparse
|
||||
|
||||
# 解析命令行参数
|
||||
parser = argparse.ArgumentParser(description='合并采购单程序')
|
||||
parser.add_argument('--input', type=str, help='指定输入采购单文件路径,多个文件用逗号分隔')
|
||||
args = parser.parse_args()
|
||||
|
||||
merger = PurchaseOrderMerger()
|
||||
|
||||
# 处理采购单文件
|
||||
try:
|
||||
if args.input:
|
||||
# 使用指定文件处理
|
||||
file_paths = [path.strip() for path in args.input.split(',')]
|
||||
merged_df = merger.merge_purchase_orders(file_paths)
|
||||
if merged_df is not None:
|
||||
output_file = merger.create_merged_purchase_order(merged_df)
|
||||
if output_file:
|
||||
print(f"处理成功!合并采购单已保存至: {output_file}")
|
||||
else:
|
||||
print("处理失败!请查看日志了解详细信息")
|
||||
else:
|
||||
print("处理失败!请查看日志了解详细信息")
|
||||
else:
|
||||
# 使用默认处理流程(查找最新文件)
|
||||
result = merger.process()
|
||||
if result:
|
||||
print("处理成功!已将数据合并并保存")
|
||||
else:
|
||||
print("处理失败!请查看日志了解详细信息")
|
||||
except Exception as e:
|
||||
logger.error(f"处理过程中发生错误: {e}", exc_info=True)
|
||||
print(f"处理过程中发生错误: {e}")
|
||||
print("请查看日志文件了解详细信息")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
logger.error(f"程序执行过程中发生错误: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"采购单_武侯环球乐百惠便利店849.xls_5632_1746098172.9159887": "output\\合并采购单_20250501193931.xls",
|
||||
"采购单_武侯环球乐百惠便利店3333.xls_9728_1746097892.1829922": "output\\合并采购单_20250501193931.xls"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
configparser>=5.0.0
|
||||
numpy>=1.19.0
|
||||
openpyxl>=3.0.0
|
||||
pandas>=1.3.0
|
||||
pathlib>=1.0.1
|
||||
requests>=2.25.0
|
||||
xlrd>=2.0.0,<2.1.0
|
||||
xlutils>=2.0.0
|
||||
xlwt>=1.3.0
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
OCR流程运行脚本
|
||||
-------------
|
||||
整合百度OCR和Excel处理功能的便捷脚本
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# 确保logs目录存在
|
||||
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# 设置日志文件路径
|
||||
log_file = os.path.join(log_dir, 'ocr_processor.log')
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger('ocr_processor')
|
||||
if not logger.handlers:
|
||||
# 创建文件处理器
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
|
||||
# 创建控制台处理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
|
||||
# 设置格式
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
file_handler.setFormatter(formatter)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
# 添加处理器到日志器
|
||||
logger.addHandler(file_handler)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 设置日志级别
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
logger.info("OCR处理器初始化")
|
||||
|
||||
# 标记该日志文件为活跃,避免被清理工具删除
|
||||
try:
|
||||
# 创建一个标记文件,表示该日志文件正在使用中
|
||||
active_marker = os.path.join(log_dir, 'ocr_processor.active')
|
||||
with open(active_marker, 'w') as f:
|
||||
f.write(f"Active since: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"无法创建日志活跃标记: {e}")
|
||||
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(description='OCR流程运行脚本')
|
||||
parser.add_argument('--step', type=int, default=0, help='运行步骤: 1-OCR识别, 2-Excel处理, 0-全部运行 (默认)')
|
||||
parser.add_argument('--config', type=str, default='config.ini', help='配置文件路径')
|
||||
parser.add_argument('--force', action='store_true', help='强制处理所有文件,包括已处理的文件')
|
||||
parser.add_argument('--input', type=str, help='指定输入文件(仅用于单文件处理)')
|
||||
parser.add_argument('--output', type=str, help='指定输出文件(仅用于单文件处理)')
|
||||
return parser.parse_args()
|
||||
|
||||
def check_env():
|
||||
"""检查配置是否有效"""
|
||||
try:
|
||||
# 尝试读取配置文件
|
||||
config = configparser.ConfigParser()
|
||||
if not config.read('config.ini', encoding='utf-8'):
|
||||
logger.warning("未找到配置文件config.ini或文件为空")
|
||||
return
|
||||
|
||||
# 检查API密钥是否已配置
|
||||
if not config.has_section('API'):
|
||||
logger.warning("配置文件中缺少[API]部分")
|
||||
return
|
||||
|
||||
api_key = config.get('API', 'api_key', fallback='')
|
||||
secret_key = config.get('API', 'secret_key', fallback='')
|
||||
|
||||
if not api_key or not secret_key:
|
||||
logger.warning("API密钥未设置或为空,请在config.ini中配置API密钥")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查配置时出错: {e}")
|
||||
|
||||
def run_ocr(args):
|
||||
"""运行OCR识别过程"""
|
||||
logger.info("开始OCR识别过程...")
|
||||
|
||||
# 导入模块
|
||||
try:
|
||||
from baidu_table_ocr import OCRProcessor, ConfigManager
|
||||
|
||||
# 创建配置管理器
|
||||
config_manager = ConfigManager(args.config)
|
||||
|
||||
# 创建处理器
|
||||
processor = OCRProcessor(config_manager)
|
||||
|
||||
# 检查输入目录中是否有图片
|
||||
input_files = processor.get_unprocessed_images()
|
||||
if not input_files and not args.input:
|
||||
logger.warning(f"在{processor.input_folder}目录中没有找到未处理的图片文件")
|
||||
return False
|
||||
|
||||
# 单文件处理或批量处理
|
||||
if args.input:
|
||||
if not os.path.exists(args.input):
|
||||
logger.error(f"输入文件不存在: {args.input}")
|
||||
return False
|
||||
|
||||
logger.info(f"处理单个文件: {args.input}")
|
||||
output_file = processor.process_image(args.input)
|
||||
if output_file:
|
||||
logger.info(f"OCR识别成功,输出文件: {output_file}")
|
||||
return True
|
||||
else:
|
||||
logger.error("OCR识别失败")
|
||||
return False
|
||||
else:
|
||||
# 批量处理
|
||||
batch_size = processor.batch_size
|
||||
max_workers = processor.max_workers
|
||||
|
||||
# 如果需要强制处理,先设置skip_existing为False
|
||||
if args.force:
|
||||
processor.skip_existing = False
|
||||
|
||||
logger.info(f"批量处理文件,批量大小: {batch_size}, 最大线程数: {max_workers}")
|
||||
total, success = processor.process_images_batch(
|
||||
batch_size=batch_size,
|
||||
max_workers=max_workers
|
||||
)
|
||||
|
||||
logger.info(f"OCR识别完成,总计处理: {total},成功: {success}")
|
||||
return success > 0
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"导入OCR模块失败: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"OCR识别过程出错: {e}")
|
||||
return False
|
||||
|
||||
def run_excel_processing(args):
|
||||
"""运行Excel处理过程"""
|
||||
logger.info("开始Excel处理过程...")
|
||||
|
||||
# 导入模块
|
||||
try:
|
||||
from excel_processor_step2 import ExcelProcessorStep2
|
||||
|
||||
# 创建处理器
|
||||
processor = ExcelProcessorStep2()
|
||||
|
||||
# 单文件处理或批量处理
|
||||
if args.input:
|
||||
if not os.path.exists(args.input):
|
||||
logger.error(f"输入文件不存在: {args.input}")
|
||||
return False
|
||||
|
||||
logger.info(f"处理单个Excel文件: {args.input}")
|
||||
result = processor.process_specific_file(args.input)
|
||||
if result:
|
||||
logger.info(f"Excel处理成功")
|
||||
return True
|
||||
else:
|
||||
logger.error("Excel处理失败,请查看日志了解详细信息")
|
||||
return False
|
||||
else:
|
||||
# 检查output目录中最新的Excel文件
|
||||
latest_file = processor.get_latest_excel()
|
||||
if not latest_file:
|
||||
logger.error("未找到可处理的Excel文件,无法进行处理")
|
||||
return False
|
||||
|
||||
# 处理最新的Excel文件
|
||||
logger.info(f"处理最新的Excel文件: {latest_file}")
|
||||
result = processor.process_latest_file()
|
||||
|
||||
if result:
|
||||
logger.info("Excel处理成功")
|
||||
return True
|
||||
else:
|
||||
logger.error("Excel处理失败,请查看日志了解详细信息")
|
||||
return False
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"导入Excel处理模块失败: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Excel处理过程出错: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 解析命令行参数
|
||||
args = parse_args()
|
||||
|
||||
# 检查环境变量
|
||||
check_env()
|
||||
|
||||
# 根据步骤运行相应的处理
|
||||
ocr_success = False
|
||||
|
||||
if args.step == 0 or args.step == 1:
|
||||
ocr_success = run_ocr(args)
|
||||
if not ocr_success:
|
||||
if args.step == 1:
|
||||
logger.error("OCR识别失败,请检查input目录是否有图片或检查API配置")
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.warning("OCR识别未处理任何文件,跳过Excel处理步骤")
|
||||
return
|
||||
else:
|
||||
# 如果只运行第二步,假设OCR已成功完成
|
||||
ocr_success = True
|
||||
|
||||
# 只有当OCR成功或只运行第二步时才执行Excel处理
|
||||
if ocr_success and (args.step == 0 or args.step == 2):
|
||||
excel_result = run_excel_processing(args)
|
||||
if not excel_result and args.step == 2:
|
||||
logger.error("Excel处理失败")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("处理完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
测试OCR处理器日志文件创建
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# 确保logs目录存在
|
||||
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
print(f"日志目录: {log_dir}")
|
||||
|
||||
# 设置日志文件路径
|
||||
log_file = os.path.join(log_dir, 'ocr_processor.log')
|
||||
print(f"日志文件路径: {log_file}")
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger('ocr_processor')
|
||||
if not logger.handlers:
|
||||
# 创建文件处理器
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
|
||||
# 创建控制台处理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
|
||||
# 设置格式
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
file_handler.setFormatter(formatter)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
# 添加处理器到日志器
|
||||
logger.addHandler(file_handler)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 设置日志级别
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 写入测试日志
|
||||
logger.info("这是一条测试日志消息")
|
||||
logger.info(f"测试时间: {datetime.now()}")
|
||||
|
||||
# 标记该日志文件为活跃,避免被清理工具删除
|
||||
try:
|
||||
# 创建一个标记文件,表示该日志文件正在使用中
|
||||
active_marker = os.path.join(log_dir, 'ocr_processor.active')
|
||||
with open(active_marker, 'w') as f:
|
||||
f.write(f"Active since: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"活跃标记文件: {active_marker}")
|
||||
except Exception as e:
|
||||
print(f"无法创建日志活跃标记: {e}")
|
||||
|
||||
# 检查文件是否已创建
|
||||
if os.path.exists(log_file):
|
||||
print(f"日志文件已成功创建: {log_file}")
|
||||
print(f"文件大小: {os.path.getsize(log_file)} 字节")
|
||||
else:
|
||||
print(f"错误: 日志文件创建失败: {log_file}")
|
||||
|
||||
print("测试完成")
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
单位转换处理规则测试
|
||||
-------------------
|
||||
这个脚本用于演示excel_processor_step2.py中的单位转换处理规则,
|
||||
包括件、提、盒单位的处理,以及特殊条码的处理。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_unit_conversion(barcode, unit, quantity, specification, unit_price):
|
||||
"""
|
||||
测试单位转换处理逻辑
|
||||
"""
|
||||
logger.info(f"测试条码: {barcode}, 单位: {unit}, 数量: {quantity}, 规格: {specification}, 单价: {unit_price}")
|
||||
|
||||
# 特殊条码处理
|
||||
special_barcodes = {
|
||||
'6925019900087': {
|
||||
'multiplier': 10, # 数量乘以10
|
||||
'target_unit': '瓶', # 目标单位
|
||||
'description': '特殊处理:数量*10,单位转换为瓶'
|
||||
}
|
||||
}
|
||||
|
||||
# 解析规格
|
||||
package_quantity = None
|
||||
is_tertiary_spec = False
|
||||
|
||||
if specification:
|
||||
import re
|
||||
# 三级规格,如1*5*12
|
||||
match = re.search(r'(\d+)[\*xX×](\d+)[\*xX×](\d+)', specification)
|
||||
if match:
|
||||
package_quantity = int(match.group(3))
|
||||
is_tertiary_spec = True
|
||||
else:
|
||||
# 二级规格,如1*15
|
||||
match = re.search(r'(\d+)[\*xX×](\d+)', specification)
|
||||
if match:
|
||||
package_quantity = int(match.group(2))
|
||||
|
||||
# 初始化结果
|
||||
result_quantity = quantity
|
||||
result_unit = unit
|
||||
result_unit_price = unit_price
|
||||
|
||||
# 处理单位转换
|
||||
if barcode in special_barcodes:
|
||||
# 特殊条码处理
|
||||
special_config = special_barcodes[barcode]
|
||||
result_quantity = quantity * special_config['multiplier']
|
||||
result_unit = special_config['target_unit']
|
||||
|
||||
if unit_price:
|
||||
result_unit_price = unit_price / special_config['multiplier']
|
||||
|
||||
logger.info(f"特殊条码处理: {quantity}{unit} -> {result_quantity}{result_unit}")
|
||||
if unit_price:
|
||||
logger.info(f"单价转换: {unit_price}/{unit} -> {result_unit_price}/{result_unit}")
|
||||
|
||||
elif unit in ['提', '盒']:
|
||||
# 提和盒单位特殊处理
|
||||
if is_tertiary_spec and package_quantity:
|
||||
# 三级规格:按照件的计算方式处理
|
||||
result_quantity = quantity * package_quantity
|
||||
result_unit = '瓶'
|
||||
|
||||
if unit_price:
|
||||
result_unit_price = unit_price / package_quantity
|
||||
|
||||
logger.info(f"{unit}单位三级规格转换: {quantity}{unit} -> {result_quantity}瓶")
|
||||
if unit_price:
|
||||
logger.info(f"单价转换: {unit_price}/{unit} -> {result_unit_price}/瓶")
|
||||
else:
|
||||
# 二级规格或无规格:保持原数量不变
|
||||
logger.info(f"{unit}单位二级规格保持原数量: {quantity}{unit}")
|
||||
|
||||
elif unit == '件' and package_quantity:
|
||||
# 件单位处理:数量×包装数量
|
||||
result_quantity = quantity * package_quantity
|
||||
result_unit = '瓶'
|
||||
|
||||
if unit_price:
|
||||
result_unit_price = unit_price / package_quantity
|
||||
|
||||
logger.info(f"件单位转换: {quantity}件 -> {result_quantity}瓶")
|
||||
if unit_price:
|
||||
logger.info(f"单价转换: {unit_price}/件 -> {result_unit_price}/瓶")
|
||||
|
||||
else:
|
||||
# 其他单位保持不变
|
||||
logger.info(f"保持原单位不变: {quantity}{unit}")
|
||||
|
||||
# 输出处理结果
|
||||
logger.info(f"处理结果 => 数量: {result_quantity}, 单位: {result_unit}, 单价: {result_unit_price}")
|
||||
logger.info("-" * 50)
|
||||
|
||||
return result_quantity, result_unit, result_unit_price
|
||||
|
||||
def run_tests():
|
||||
"""运行一系列测试用例"""
|
||||
|
||||
# 标准件单位测试
|
||||
test_unit_conversion("1234567890123", "件", 1, "1*12", 108)
|
||||
test_unit_conversion("1234567890124", "件", 2, "1*24", 120)
|
||||
|
||||
# 提和盒单位测试 - 二级规格
|
||||
test_unit_conversion("1234567890125", "提", 3, "1*16", 50)
|
||||
test_unit_conversion("1234567890126", "盒", 5, "1*20", 60)
|
||||
|
||||
# 提和盒单位测试 - 三级规格
|
||||
test_unit_conversion("1234567890127", "提", 2, "1*5*12", 100)
|
||||
test_unit_conversion("1234567890128", "盒", 3, "1*6*8", 120)
|
||||
|
||||
# 特殊条码测试
|
||||
test_unit_conversion("6925019900087", "副", 2, "1*10", 50)
|
||||
test_unit_conversion("6925019900087", "提", 1, "1*16", 30)
|
||||
|
||||
# 其他单位测试
|
||||
test_unit_conversion("1234567890129", "包", 4, "1*24", 12)
|
||||
test_unit_conversion("1234567890130", "瓶", 10, "", 5)
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("开始测试单位转换处理规则")
|
||||
run_tests()
|
||||
logger.info("单位转换处理规则测试完成")
|
||||
Reference in New Issue
Block a user