Spaces:
Runtime error
Runtime error
File size: 14,661 Bytes
7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 feef6eb 7e94d17 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | """
Google Sheets 回填模块
- 模糊匹配公司名
- 智能覆盖策略(优先级)
- 写入 AA/AB/AC/AD 列
所有配置值均通过 src.config 模块读取(支持运行时动态更新)。
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
import gspread
from google.oauth2.service_account import Credentials
from rapidfuzz import fuzz, process
from . import config as cfg
logger = logging.getLogger(__name__)
SCOPES = [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive.file",
]
@dataclass
class MatchResult:
"""公司名匹配结果"""
status: str # exact | fuzzy | ambiguous | not_found
row_index: int = -1 # 1-based 行号(-1 表示未找到)
matched_name: str = "" # 表格中实际的公司名
score: float = 0.0 # 匹配分数 (0-100)
sheet_name: str = "" # 所在工作表名称(如 West、South 等)
candidates: list = field(default_factory=list) # 候选列表(ambiguous 时)
message: str = ""
@dataclass
class WriteResult:
"""写入结果"""
success: bool
row_index: int = -1
action: str = "" # written | skipped | overwritten
reason: str = ""
error: str = ""
def _get_priority(category: str) -> int:
"""获取分类优先级(数值越大优先级越高)"""
if category in cfg.HIGH_PRIORITY_TYPES:
return 2
if category in cfg.LOW_PRIORITY_TYPES:
return 1
return 0
class SheetsUpdater:
"""Google Sheets 更新器(跨多个区域工作表查询与写入)"""
def __init__(self):
self._client = None
self._spreadsheet = None
# 工作表缓存 {sheet_name: worksheet_object}
self._worksheets: dict = {}
# 公司列表:每条记录包含 (company_name, row_index, sheet_name)
self._company_list: list[tuple[str, int, str]] = []
self._initialized = False
def _get_credentials(self) -> Credentials:
"""获取 Google 凭证(每次调用读取最新配置)"""
sa_info = cfg.get_service_account_info()
if not sa_info:
raise ValueError(
"❌ 未配置 Google Service Account 凭证。\n"
"请在设置面板中粘贴 Service Account JSON 内容。"
)
return Credentials.from_service_account_info(sa_info, scopes=SCOPES)
def initialize(self) -> bool:
"""初始化连接并加载所有区域工作表的公司列表"""
try:
creds = self._get_credentials()
self._client = gspread.authorize(creds)
sheet_id = cfg.get_google_sheet_id()
if not sheet_id:
raise ValueError("❌ 未配置 Google Sheets ID。\n请在设置面板中填写 Google Sheet ID。")
self._spreadsheet = self._client.open_by_key(sheet_id)
self._worksheets = {}
# 加载所有区域工作表
sheet_names = cfg.get_google_sheet_names()
loaded = []
skipped = []
for name in sheet_names:
try:
ws = self._spreadsheet.worksheet(name)
self._worksheets[name] = ws
loaded.append(name)
except gspread.exceptions.WorksheetNotFound:
skipped.append(name)
logger.warning(f"⚠️ 工作表 '{name}' 不存在,已跳过")
if not self._worksheets:
raise ValueError(
f"❌ 未能加载任何区域工作表。\n"
f"已尝试: {sheet_names},请确认工作表名称正确。"
)
self._load_company_list()
self._initialized = True
logger.info(
f"✅ Google Sheets 连接成功,已加载工作表: {loaded},"
f"共 {len(self._company_list)} 家公司"
+ (f"(跳过不存在的工作表: {skipped})" if skipped else "")
)
return True
except Exception as e:
logger.error(f"Google Sheets 初始化失败: {e}")
raise
def _load_company_list(self):
"""加载所有区域工作表中的公司名(用于跨表模糊匹配)"""
indices = cfg.get_sheet_col_indices()
col_idx = indices["company"] + 1 # gspread 用 1-based
self._company_list = []
for sheet_name, ws in self._worksheets.items():
try:
col_values = ws.col_values(col_idx)
count = 0
for row_idx, name in enumerate(col_values, start=1):
name = name.strip()
if name and row_idx > 1: # 跳过表头
self._company_list.append((name, row_idx, sheet_name))
count += 1
logger.debug(f" [{sheet_name}] 已加载 {count} 家公司")
except Exception as e:
logger.warning(f"加载工作表 '{sheet_name}' 公司列表失败: {e}")
logger.debug(f"全部公司列表已加载: {len(self._company_list)} 条(跨 {len(self._worksheets)} 张工作表)")
def refresh_company_list(self):
"""刷新公司列表缓存"""
self._load_company_list()
def fuzzy_match_company(self, company_name: str) -> MatchResult:
"""模糊匹配公司名(跨所有区域工作表)"""
if not self._company_list:
return MatchResult(
status="not_found",
message="表格中无公司数据,请确认 Google Sheets 已正确配置"
)
# 提取名称列表,构建查找字典(同名公司保留首个——不同表可能重名)
names = [name for name, _, _ in self._company_list]
# 名称 -> (row, sheet_name),优先保留首次出现的
name_to_info: dict[str, tuple[int, str]] = {}
for name, row, sheet in self._company_list:
if name not in name_to_info:
name_to_info[name] = (row, sheet)
matches = process.extract(
company_name,
list(name_to_info.keys()),
scorer=fuzz.token_sort_ratio,
limit=5,
)
if not matches:
return MatchResult(status="not_found", message=f"未找到与 '{company_name}' 匹配的公司")
best_name, best_score, _ = matches[0]
best_row, best_sheet = name_to_info[best_name]
# 完全匹配
if company_name.lower() == best_name.lower():
return MatchResult(
status="exact",
row_index=best_row,
matched_name=best_name,
score=100.0,
sheet_name=best_sheet,
message=f"精确匹配: '{best_name}' ({best_sheet} 表, 第 {best_row} 行)"
)
threshold = cfg.get_fuzzy_threshold()
if best_score < threshold:
return MatchResult(
status="not_found",
message=f"未找到匹配项 (最高相似度 {best_score:.1f}% < 阈值 {threshold}%)"
)
# 收集所有达标候选
candidates = []
for name, score, _ in matches:
if score >= threshold:
row, sheet = name_to_info[name]
candidates.append({
"name": name,
"row": row,
"sheet": sheet,
"score": score
})
if len(candidates) >= 2:
gap = candidates[0]["score"] - candidates[1]["score"]
ambiguous_gap = cfg.get_fuzzy_ambiguous_gap()
if gap < ambiguous_gap:
return MatchResult(
status="ambiguous",
matched_name=best_name,
score=best_score,
sheet_name=best_sheet,
candidates=candidates[:5],
message=f"发现多个相似公司(差异 < {ambiguous_gap}%),需人工确认"
)
return MatchResult(
status="fuzzy",
row_index=best_row,
matched_name=best_name,
score=best_score,
sheet_name=best_sheet,
message=f"模糊匹配: '{best_name}' ({best_sheet} 表, 相似度 {best_score:.1f}%, 第 {best_row} 行)"
)
def _col_letter_to_1based(self, col_letter: str) -> int:
col = col_letter.upper().strip()
result = 0
for ch in col:
result = result * 26 + (ord(ch) - ord('A') + 1)
return result
def _get_worksheet(self, sheet_name: str):
"""根据工作表名获取 worksheet 对象,默认回退到第一个可用工作表"""
if sheet_name and sheet_name in self._worksheets:
return self._worksheets[sheet_name]
# 回退:使用第一个可用工作表
if self._worksheets:
fallback = next(iter(self._worksheets))
logger.warning(f"工作表 '{sheet_name}' 未找到,回退到 '{fallback}'")
return self._worksheets[fallback]
raise RuntimeError("没有可用的工作表,请重新初始化连接")
def _get_cell_value(self, row: int, col_letter: str, sheet_name: str = "") -> str:
try:
ws = self._get_worksheet(sheet_name)
col_idx = self._col_letter_to_1based(col_letter)
cell = ws.cell(row, col_idx)
return (cell.value or "").strip()
except Exception as e:
logger.warning(f"读取单元格失败 (sheet={sheet_name}, row={row}, col={col_letter}): {e}")
return ""
def _write_cells(self, row: int, data: dict, sheet_name: str = "") -> None:
"""批量写入单元格 {col_letter: value},写入指定工作表"""
ws = self._get_worksheet(sheet_name)
updates = []
for col_letter, value in data.items():
col_idx = self._col_letter_to_1based(col_letter)
cell = gspread.utils.rowcol_to_a1(row, col_idx)
updates.append({
"range": cell,
"values": [[str(value) if value is not None else ""]]
})
if updates:
ws.batch_update(updates, value_input_option="USER_ENTERED")
def write_email_data(
self,
row_index: int,
category: str,
body_text: str,
date_str: str,
force_overwrite: bool = False,
sheet_name: str = ""
) -> WriteResult:
"""按优先级策略写入邮件数据到指定工作表"""
try:
cfg_dict = cfg.get_config_dict()
existing_type = self._get_cell_value(
row_index, cfg_dict["sheet_email_type_col"], sheet_name
)
if existing_type and not force_overwrite:
new_priority = _get_priority(category)
existing_priority = _get_priority(existing_type)
if new_priority > existing_priority:
action_reason = f"覆盖({existing_type} -> {category},新邮件优先级更高)"
elif new_priority <= existing_priority and existing_type == category:
return WriteResult(
success=True, row_index=row_index,
action="skipped",
reason=f"相同类型 '{category}' 已存在,保留原数据"
)
elif new_priority < existing_priority:
return WriteResult(
success=True, row_index=row_index,
action="skipped",
reason=f"已有高优先级数据 '{existing_type}',新邮件 '{category}' 被忽略"
)
else:
return WriteResult(
success=True, row_index=row_index,
action="skipped",
reason=f"同优先级数据已存在,保留原数据 '{existing_type}'"
)
else:
action_reason = "首次写入" if not existing_type else "强制覆盖(人工确认)"
body_truncated = body_text[:cfg.get_email_body_max_chars()] if body_text else ""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
write_data = {
cfg_dict["sheet_email_type_col"]: category,
cfg_dict["sheet_email_body_col"]: body_truncated,
cfg_dict["sheet_email_date_col"]: date_str,
cfg_dict["sheet_timestamp_col"]: timestamp,
}
self._write_cells(row_index, write_data, sheet_name)
target = f"[{sheet_name}] " if sheet_name else ""
logger.info(f"✅ {target}Row {row_index}: 写入成功 [{category}] - {action_reason}")
return WriteResult(
success=True,
row_index=row_index,
action="overwritten" if existing_type else "written",
reason=action_reason
)
except Exception as e:
logger.error(f"写入 Google Sheets 失败 (sheet={sheet_name}, row={row_index}): {e}", exc_info=True)
return WriteResult(
success=False,
row_index=row_index,
action="error",
error=str(e)
)
def is_connected(self) -> bool:
return self._initialized and bool(self._worksheets)
def get_company_count(self) -> int:
return len(self._company_list)
def get_candidates_for_display(self, candidates: list) -> list[list]:
return [
[c["name"], c.get("sheet", ""), f"{c['score']:.1f}%", f"第 {c['row']} 行"]
for c in candidates
]
_updater_instance = None
def get_sheets_updater(auto_init: bool = True):
"""获取 SheetsUpdater 单例(惰性初始化)"""
global _updater_instance
if _updater_instance is None:
_updater_instance = SheetsUpdater()
if auto_init and not _updater_instance.is_connected():
try:
_updater_instance.initialize()
except Exception as e:
logger.warning(f"Sheets 自动初始化失败(将在使用时重试): {e}")
return _updater_instance
def reset_sheets_updater():
"""重置单例(配置变更后调用,下次使用时会重新初始化)"""
global _updater_instance
_updater_instance = None
|