kgrag/excel_to_json.py
2026-06-30 13:35:52 +08:00

522 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import logging
from datetime import datetime
from typing import Optional
import requests
from collections import defaultdict
import traceback
import logging
import pandas as pd
from openpyxl import load_workbook
import json
import os
# ======================
# 日志
# ======================
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
from openai import OpenAI
import re
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def is_data_row(row):
"""
判断是否为数据行
数据行的特征第1列包含数字
Args:
row (dict): 包含行数据的字典,格式为 {"content": {"列号": "", ...}}
Returns:
bool: 如果是数据行返回True否则返回False
"""
try:
v = row["content"].get("1", "")
result = isinstance(v, str) and v.strip().isdigit()
logger.debug(f"is_data_row: value='{v}', result={result}")
return result
except Exception as e:
logger.error(f"Error in is_data_row: {e}, row: {row}")
return False
def is_field_row(row):
"""
判断是否为字段行
字段行的特征至少有5个值且超过90%的值不是数字,根据环境处理
Args:
row (dict): 包含行数据的字典
Returns:
bool: 如果是字段行返回True否则返回False
"""
try:
values = list(row["content"].values())
if len(values) < 5:
logger.debug(f"is_field_row: less than 5 values, returning False")
return False
non_digit = sum(1 for v in values if isinstance(v, str) and not v.strip().isdigit())
result = non_digit / len(values) > 0.9
logger.debug(f"is_field_row: non_digit_ratio={non_digit / len(values)}, result={result}")
return result
except Exception as e:
logger.error(f"Error in is_field_row: {e}, row: {row}")
return False
def is_header_row(row):
"""
判断是否为表头行
表头行的特征列数不超过4个且不是字段行或数据行根据环境处理
Args:
row (dict): 包含行数据的字典
Returns:
bool: 如果是表头行返回True否则返回False
"""
try:
content_len = len(row["content"])
field_result = is_field_row(row)
data_result = is_data_row(row)
result = content_len <= 4 and not field_result and not data_result
logger.debug(f"is_header_row: content_len={content_len}, is_field={field_result}, is_data={data_result}, result={result}")
return result
except Exception as e:
logger.error(f"Error in is_header_row: {e}, row: {row}")
return False
# =====================================================
# 多级表头
# =====================================================
def collect_field_rows(rows, start_idx):
"""
从指定索引开始收集连续的字段行
Args:
rows (list): 行数据列表
start_idx (int): 开始收集的索引
Returns:
tuple: (字段行列表, 下一个非字段行的索引)
"""
try:
field_rows = []
i = start_idx
initial_i = start_idx
while i < len(rows) and is_field_row(rows[i]):
field_rows.append(rows[i]["content"])
i += 1
logger.info(f"collect_field_rows: collected {len(field_rows)} field rows from index {initial_i} to {i}")
return field_rows, i
except Exception as e:
logger.error(f"Error in collect_field_rows: {e}")
return [], start_idx
def dedup_path(path):
"""
去除路径中的重复元素,保持顺序
Args:
path (list): 路径列表
Returns:
list: 去重后的路径列表
"""
try:
new_path = []
for p in path:
if p and (not new_path or new_path[-1] != p):
new_path.append(p)
logger.debug(f"dedup_path: original={path}, deduplicated={new_path}")
return new_path
except Exception as e:
logger.error(f"Error in dedup_path: {e}, path: {path}")
return []
def build_field_paths(field_rows):
"""
构建字段路径映射,将列号映射到字段路径
Args:
field_rows (list): 字段行列表
Returns:
defaultdict: 列号到字段路径的映射
"""
try:
field_paths = defaultdict(list)
for row_idx, row in enumerate(field_rows):
for col, name in row.items():
if name:
field_paths[col].append(name)
for col in field_paths:
field_paths[col] = dedup_path(field_paths[col])
logger.info(f"build_field_paths: processed {len(field_paths)} columns from {len(field_rows)} rows")
return field_paths
except Exception as e:
logger.error(f"Error in build_field_paths: {e}")
return defaultdict(list)
# =====================================================
# 值 & 结构处理
# =====================================================
def normalize_value(value):
"""
标准化值,将特殊值转换为对应格式
Args:
value: 待处理的值
Returns:
处理后的值
"""
try:
if value == "":
logger.debug(f"normalize_value: converting '' to True")
return True
if value in ("", None):
logger.debug(f"normalize_value: empty value converted to empty string")
return ""
logger.debug(f"normalize_value: value={value} unchanged")
return value
except Exception as e:
logger.error(f"Error in normalize_value: {e}, value: {value}")
return ""
def insert_by_path(target, path, value):
"""
根据路径在目标对象中插入值
Args:
target (dict): 目标字典
path (list): 插入路径
value: 待插入的值
"""
try:
if not path:
logger.warning("insert_by_path: empty path provided")
return
cur = target
for key in path[:-1]:
cur = cur.setdefault(key, {})
cur[path[-1]] = value
logger.debug(f"insert_by_path: inserted value '{value}' at path {path}")
except Exception as e:
logger.error(f"Error in insert_by_path: {e}, target: {target}, path: {path}, value: {value}")
def flatten_singleton_dict(obj):
"""
扁平化单元素字典,如果字典只有一个键且键名与值中的键名相同,则合并
Args:
obj: 待处理的对象
Returns:
处理后的对象
"""
try:
if not isinstance(obj, dict):
return obj
for k in list(obj.keys()):
v = obj[k]
if isinstance(v, dict):
v = flatten_singleton_dict(v)
if len(v) == 1 and k in v:
obj[k] = v[k]
logger.debug(f"flatten_singleton_dict: flattened singleton dict for key '{k}'")
else:
obj[k] = v
return obj
except Exception as e:
logger.error(f"Error in flatten_singleton_dict: {e}, obj: {obj}")
return obj
def flatten_dict(d, parent_key='', sep='_'):
"""
{"A": {"B": "1"}} 转换为 {"A_B": "1"}
"""
items = []
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
# 递归展开
items.extend(flatten_dict(v, new_key, sep=sep).items())
else:
items.append((new_key, v))
return dict(items)
def build_content(field_paths, row_content):
"""
根据字段路径构建内容结构
Args:
field_paths (dict): 字段路径映射
row_content (dict): 行内容数据
Returns:
dict: 构建的内容结构
"""
result = {}
for col, path in field_paths.items():
value = normalize_value(row_content.get(col, ""))
insert_by_path(result, path, value)
# 1. 先执行你原有的单级压缩 (解决 A -> {A: x})
flatten_singleton_dict(result)
# 2. 执行深度展平 (将嵌套字典转为 "键_子键" 格式)
# 我们遍历第一层,如果值是字典,则展开它
final_result = {}
for k, v in result.items():
if isinstance(v, dict):
# 展开子字典,并带上父级的键名作为前缀
flattened_sub = flatten_dict(v, parent_key=k, sep='_')
final_result.update(flattened_sub)
else:
final_result[k] = v
return final_result
# =====================================================
# 主解析逻辑
# =====================================================
def transform_excel_json(rows):
"""
将Excel行数据转换为JSON格式
Args:
rows (list): 行数据列表
Returns:
list: 转换后的JSON数据列表
"""
try:
logger.info(f"Starting transform_excel_json with {len(rows)} rows")
output = []
i = 0
processed_count = 0
while i < len(rows):
row = rows[i]
row_type = None
if is_header_row(row):
output.append({"header": row["content"]})
row_type = "header"
i += 1
elif is_field_row(row):
field_rows, next_idx = collect_field_rows(rows, i)
field_paths = build_field_paths(field_rows)
i = next_idx
row_type = "field"
while i < len(rows) and is_data_row(rows[i]):
content = build_content(field_paths, rows[i]["content"])
output.append({"content": content})
i += 1
processed_count += 1
else:
i += 1
continue # Skip non-header, non-field, non-data rows
logger.debug(f"Processed row {i-1}, type: {row_type}")
logger.info(f"Completed transform_excel_json: {len(output)} items, {processed_count} data rows processed")
return output
except Exception as e:
logger.error(f"Error in transform_excel_json: {e}")
logger.error(traceback.format_exc())
raise
# =====================================================
# Excel 读取(含合并单元格)
# =====================================================
def read_excel_with_merged_cells(file_path: str, sheet_name: Optional[str] = None):
"""
读取Excel文件包括合并单元格的处理
Args:
file_path (str): Excel文件路径
sheet_name (Optional[str]): 工作表名称如果为None则使用默认表
Returns:
pd.DataFrame: 包含Excel数据的DataFrame
"""
try:
_, ext = os.path.splitext(file_path)
ext = ext.lower()
if ext == ".xlsx":
logger.info(f"Reading Excel file: {file_path}, sheet: {sheet_name}")
wb = load_workbook(file_path, data_only=True)
ws = wb[sheet_name] if sheet_name else wb.active
max_row, max_col = ws.max_row, ws.max_column
logger.info(f"Excel sheet dimensions: {max_row} rows x {max_col} columns")
data = [["" for _ in range(max_col)] for _ in range(max_row)]
for r in range(1, max_row + 1):
for c in range(1, max_col + 1):
val = ws.cell(row=r, column=c).value
data[r - 1][c - 1] = "" if val is None else str(val).strip()
# 处理合并单元格
merged_count = 0
for merged in ws.merged_cells.ranges:
tl = data[merged.min_row - 1][merged.min_col - 1]
for r in range(merged.min_row - 1, merged.max_row):
for c in range(merged.min_col - 1, merged.max_col):
data[r][c] = tl
merged_count += 1
logger.info(f"Processed {merged_count} merged cell ranges")
df = pd.DataFrame(data)
logger.info(f"Created DataFrame with shape {df.shape}")
return df
else:
raise ValueError("Unsupported file format")
except Exception as e:
logger.error(f"Error in read_excel_with_merged_cells: {e}")
logger.error(traceback.format_exc())
raise
def dataframe_to_row_json(df: pd.DataFrame):
"""
将DataFrame转换为行JSON格式
Args:
df (pd.DataFrame): 源DataFrame
Returns:
list: 行JSON格式的列表
"""
try:
logger.info(f"Converting DataFrame to row JSON, shape: {df.shape}")
rows = []
for idx, row in df.iterrows():
content = {
str(col_idx + 1): val
for col_idx, val in enumerate(row)
if isinstance(val, str) and val != ""
}
if content:
rows.append({
"row_index": idx + 1,
"content": content
})
logger.info(f"Converted DataFrame to {len(rows)} JSON rows")
return rows
except Exception as e:
logger.error(f"Error in dataframe_to_row_json: {e}")
logger.error(traceback.format_exc())
raise
def json_to_markdown(json_data):
markdown_lines = []
current_table_data = []
for entry in json_data:
# 1. 处理标题行 (Header)
if "header" in entry:
# 如果之前已经有累积的数据,先输出旧表格(防止连续多个表格的情况)
if current_table_data:
markdown_lines.extend(build_table(current_table_data))
current_table_data = []
# 提取 Header 内容并格式化为标题
header_dict = entry["header"]
# 按照您的要求,处理设备名称和型号的拼接显示
title_parts = [v.replace(';', ' ') for v in header_dict.values()]
title = " ".join(title_parts)
markdown_lines.append(f"\n## {title}\n")
# 2. 处理内容行 (Content)
elif "content" in entry:
current_table_data.append(entry["content"])
# 3. 输出最后一个表格
if current_table_data:
markdown_lines.extend(build_table(current_table_data))
return "\n".join(markdown_lines)
def build_table(data_list):
"""将字典列表转换为 Markdown 表格字符串"""
if not data_list:
return []
lines = []
# 提取所有键作为表头
headers = list(data_list[0].keys())
# 构造表头行
header_line = "| " + " | ".join(headers) + " |"
# 构造分割行
separator_line = "| " + " | ".join(["---"] * len(headers)) + " |"
lines.append(header_line)
lines.append(separator_line)
# 构造数据行
for item in data_list:
row_values = []
for h in headers:
val = item.get(h, "")
# 转换布尔值为中文,处理 None 值
if val is True: val = ""
elif val is False or val == "": val = " "
row_values.append(str(val).replace("\n", " ")) # 避免数据内换行破坏表格结构
lines.append("| " + " | ".join(row_values) + " |")
return lines
def excel_to_jsondata(excel_path: str,sheet_name: Optional[str] = None):
logger.info(f"Reading Excel: {excel_path}")
df = read_excel_with_merged_cells(excel_path, sheet_name)
rows = dataframe_to_row_json(df)
table_json = transform_excel_json(rows)
return table_json