import tkinter as tk from tkinter import ttk, filedialog, messagebox, scrolledtext import pandas as pd import requests import json import threading import time import random import re from datetime import datetime import os import shutil from openpyxl import load_workbook from openpyxl.utils import get_column_letter from PIL import Image import io import base64 import zlib class FeishuSubmitApp: def __init__(self, root): self.root = root self.root.title("飞书表格数据提交工具 - 自动提取图片") self.root.geometry("1200x900") # ========== 状态变量 ========== self.is_running = False self.stop_flag = False self.excel_file = "" self.cookie_string = "" # 新增:暂停与阈值状态 self.is_paused = False self.sent_count = 0 # 本次运行已成功发送计数 self.pause_threshold = 0 # 0表示不限制 self.schedule_running = False self.schedule_thread = None # 图片缓存 self.image_cache = {} # {行号: [图片数据列表]} self.image_extract_folder = None self.uploaded_cache = {} # 缓存已上传的图片token {行号: attachment_info} # 从curl提取的默认配置 self.default_headers = { "accept": "application/json, text/plain, */*", "accept-language": "zh-CN,zh;q=0.9", "content-type": "application/json", "f-version": "docs-6-17-1781628518399", "origin": "https://xhd.feishu.cn", "priority": "u=1, i", "referer": "https://xhd.feishu.cn/share/base/form/shrcnJqFUFF0sqlmrfsWpDNf8df", "sec-ch-ua": '"Chromium";v="148", "Google Chrome";v="148", "Not/A)Brand";v="99"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "x-csrftoken": "83aae12269bced10680d3ccf817f82262305e7d0-1781579108", "x-tt-logid": "02178275091573600000000000000000000ffff5bd640caae0cb8", "x-tt-trace": "1" } self.url = "https://xhd.feishu.cn/space/api/bitable/share/content" self.share_token = "shrcnJqFUFF0sqlmrfsWpDNf8df" self.setup_ui() self.load_config() self.start_schedule() # 启动定时调度器 def setup_ui(self): """构建界面""" # 主容器 main_frame = ttk.Frame(self.root, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) # ========== 第一行:文件选择 ========== file_frame = ttk.LabelFrame(main_frame, text="📁 数据文件", padding="10") file_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Label(file_frame, text="Excel文件:").grid(row=0, column=0, sticky=tk.W) self.file_path_var = tk.StringVar() ttk.Entry(file_frame, textvariable=self.file_path_var, width=60).grid(row=0, column=1, padx=(10, 10)) ttk.Button(file_frame, text="浏览...", command=self.select_file).grid(row=0, column=2) ttk.Label(file_frame, text="工作表:").grid(row=1, column=0, sticky=tk.W, pady=(10, 0)) self.sheet_var = tk.StringVar(value="Sheet1") ttk.Entry(file_frame, textvariable=self.sheet_var, width=20).grid(row=1, column=1, sticky=tk.W, padx=(10, 10), pady=(10, 0)) ttk.Label(file_frame, text="(默认: Sheet1)").grid(row=1, column=2, sticky=tk.W, pady=(10, 0)) # ========== 第二行:Cookie配置 ========== cookie_frame = ttk.LabelFrame(main_frame, text="🍪 Cookie 配置", padding="10") cookie_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Label(cookie_frame, text="Cookie:").grid(row=0, column=0, sticky=tk.W) self.cookie_text = tk.Text(cookie_frame, height=4, width=80, wrap=tk.WORD) self.cookie_text.grid(row=0, column=1, padx=(10, 10), pady=(0, 5), sticky=tk.W) cookie_btn_frame = ttk.Frame(cookie_frame) cookie_btn_frame.grid(row=1, column=1, sticky=tk.W, padx=(10, 0)) ttk.Button(cookie_btn_frame, text="从剪贴板粘贴", command=self.paste_cookie).pack(side=tk.LEFT, padx=(0, 10)) ttk.Button(cookie_btn_frame, text="保存配置", command=self.save_config).pack(side=tk.LEFT, padx=(0, 10)) ttk.Button(cookie_btn_frame, text="加载默认", command=self.load_default_cookie).pack(side=tk.LEFT) # ========== 第三行:发送控制 + 定时 + 阈值 ========== control_frame = ttk.LabelFrame(main_frame, text="⚙️ 发送控制 / ⏰ 定时调度 / 🚦 阈值限制", padding="10") control_frame.pack(fill=tk.X, pady=(0, 10)) # --- 3.1 基础间隔配置 --- ttk.Label(control_frame, text="最小间隔(秒):").grid(row=0, column=0, sticky=tk.W) self.min_interval_var = tk.StringVar(value="20") ttk.Entry(control_frame, textvariable=self.min_interval_var, width=8).grid(row=0, column=1, padx=(5, 15), sticky=tk.W) ttk.Label(control_frame, text="最大间隔(秒):").grid(row=0, column=2, sticky=tk.W) self.max_interval_var = tk.StringVar(value="50") ttk.Entry(control_frame, textvariable=self.max_interval_var, width=8).grid(row=0, column=3, padx=(5, 15), sticky=tk.W) ttk.Label(control_frame, text="起始行:").grid(row=0, column=4, sticky=tk.W) self.start_row_var = tk.StringVar(value="2") ttk.Entry(control_frame, textvariable=self.start_row_var, width=8).grid(row=0, column=5, padx=(5, 15), sticky=tk.W) ttk.Label(control_frame, text="(表头在第1行)").grid(row=0, column=6, sticky=tk.W) # --- 3.2 定时自动启停 --- ttk.Separator(control_frame, orient=tk.HORIZONTAL).grid(row=1, column=0, columnspan=7, sticky=tk.EW, pady=(12, 8)) ttk.Label(control_frame, text="⏰ 自动启动时间:").grid(row=2, column=0, sticky=tk.W) self.start_time_var = tk.StringVar(value="") ttk.Entry(control_frame, textvariable=self.start_time_var, width=10).grid(row=2, column=1, padx=(5, 5), sticky=tk.W) ttk.Label(control_frame, text="(HH:MM, 留空则不自动启动)").grid(row=2, column=2, sticky=tk.W, columnspan=2) ttk.Label(control_frame, text="自动关闭时间:").grid(row=2, column=4, sticky=tk.W) self.stop_time_var = tk.StringVar(value="") ttk.Entry(control_frame, textvariable=self.stop_time_var, width=10).grid(row=2, column=5, padx=(5, 5), sticky=tk.W) ttk.Label(control_frame, text="(HH:MM, 留空则不自动关闭)").grid(row=2, column=6, sticky=tk.W) # --- 3.3 阈值限制 --- ttk.Separator(control_frame, orient=tk.HORIZONTAL).grid(row=3, column=0, columnspan=7, sticky=tk.EW, pady=(12, 8)) ttk.Label(control_frame, text="🚦 录入阈值(条):").grid(row=4, column=0, sticky=tk.W) self.threshold_var = tk.StringVar(value="0") ttk.Entry(control_frame, textvariable=self.threshold_var, width=10).grid(row=4, column=1, padx=(5, 5), sticky=tk.W) ttk.Label(control_frame, text="(0=不限制, 达到后自动暂停)").grid(row=4, column=2, sticky=tk.W, columnspan=2) # 已发送计数显示 self.count_display_var = tk.StringVar(value="本次已发送: 0 条") ttk.Label(control_frame, textvariable=self.count_display_var, foreground="green", font=("Microsoft YaHei", 9, "bold")).grid(row=4, column=4, columnspan=3, sticky=tk.W) # --- 3.4 操作按钮 --- btn_frame = ttk.Frame(control_frame) btn_frame.grid(row=5, column=0, columnspan=7, pady=(15, 0), sticky=tk.W) self.start_btn = ttk.Button(btn_frame, text="🚀 开始发送", command=self.start_send) self.start_btn.pack(side=tk.LEFT, padx=(0, 10)) self.stop_btn = ttk.Button(btn_frame, text="⏹ 停止", command=self.stop_send, state=tk.DISABLED) self.stop_btn.pack(side=tk.LEFT, padx=(0, 10)) # 新增:暂停/继续按钮 self.pause_btn = ttk.Button(btn_frame, text="⏸ 暂停", command=self.toggle_pause, state=tk.DISABLED) self.pause_btn.pack(side=tk.LEFT, padx=(0, 10)) ttk.Button(btn_frame, text="清空日志", command=self.clear_log).pack(side=tk.LEFT, padx=(0, 10)) ttk.Button(btn_frame, text="📸 提取图片", command=self.extract_images).pack(side=tk.LEFT) # 状态显示 self.status_var = tk.StringVar(value="就绪 | 定时调度: 运行中") ttk.Label(control_frame, textvariable=self.status_var, foreground="blue").grid(row=6, column=0, columnspan=7, pady=(10, 0), sticky=tk.W) # ========== 日志显示 ========== log_frame = ttk.LabelFrame(main_frame, text="📋 发送日志", padding="10") log_frame.pack(fill=tk.BOTH, expand=True) self.log_text = scrolledtext.ScrolledText(log_frame, height=20, wrap=tk.WORD, font=("Consolas", 9)) self.log_text.pack(fill=tk.BOTH, expand=True) # 日志颜色标签 self.log_text.tag_config("success", foreground="green") self.log_text.tag_config("error", foreground="red") self.log_text.tag_config("info", foreground="blue") self.log_text.tag_config("warning", foreground="orange") self.log_text.tag_config("debug", foreground="purple") self.log_text.tag_config("pause", foreground="#FF6600") self.log_text.tag_config("schedule", foreground="#0066CC") # ========== 底部状态栏 ========== status_bar = ttk.Frame(self.root) status_bar.pack(fill=tk.X, side=tk.BOTTOM) ttk.Label(status_bar, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W).pack(fill=tk.X, padx=5, pady=2) # ==================== 定时调度相关 ==================== def start_schedule(self): """启动后台定时调度线程""" self.schedule_running = True self.schedule_thread = threading.Thread(target=self._schedule_worker, daemon=True) self.schedule_thread.start() self.log("⏰ 定时调度器已启动,将按设定时间自动启停", "schedule") def stop_schedule(self): """停止定时调度线程""" self.schedule_running = False if self.schedule_thread and self.schedule_thread.is_alive(): # 由于线程在sleep,无法直接中断,设置标志即可 pass def _schedule_worker(self): """后台线程:每秒检查一次时间,触发自动启停""" while self.schedule_running: try: now = datetime.now() current_time_str = now.strftime("%H:%M") start_time = self.start_time_var.get().strip() stop_time = self.stop_time_var.get().strip() # 自动启动检查 if start_time and not self.is_running and not self.is_paused: if current_time_str == start_time and now.second < 2: # 在目标时间的0-1秒内触发 self.log(f"⏰ 到达自动启动时间 {start_time},即将开始发送...", "schedule") # 在主线程中调用启动(通过after确保线程安全) self.root.after(0, self._safe_auto_start) # 自动关闭检查 if stop_time and self.is_running: if current_time_str == stop_time and now.second < 2: self.log(f"⏰ 到达自动关闭时间 {stop_time},即将停止发送...", "schedule") self.root.after(0, self._safe_auto_stop) time.sleep(1) except Exception as e: time.sleep(1) def _safe_auto_start(self): """线程安全的自动启动(在主线程执行)""" if not self.is_running: self.start_send() def _safe_auto_stop(self): """线程安全的自动停止(在主线程执行)""" if self.is_running: self.stop_send() # ==================== 暂停/继续 ==================== def toggle_pause(self): """暂停/继续切换""" if not self.is_running: return if self.is_paused: # 继续 self.is_paused = False self.pause_btn.config(text="⏸ 暂停") self.log("▶️ 已继续发送", "pause") self.status_var.set("🔄 发送中... (已继续)") else: # 暂停 self.is_paused = True self.pause_btn.config(text="▶️ 继续") self.log("⏸ 已暂停发送(当前请求完成后暂停)", "pause") self.status_var.set("⏸ 已暂停") def _check_threshold(self): """检查是否达到录入阈值""" threshold_str = self.threshold_var.get().strip() if not threshold_str or threshold_str == "0": return False try: threshold = int(threshold_str) if threshold <= 0: return False if self.sent_count >= threshold: self.is_paused = True self.pause_btn.config(text="▶️ 继续") self.log(f"🚦 已达到录入阈值 {threshold} 条,自动暂停!", "pause") self.status_var.set(f"⏸ 已达阈值 {threshold} 条,已自动暂停") return True except ValueError: pass return False def reset_count(self): """重置已发送计数""" self.sent_count = 0 self.count_display_var.set("本次已发送: 0 条") self.log("🔄 已重置发送计数", "info") # ==================== 原有方法(修改部分标注) ==================== def select_file(self): """选择Excel文件""" file_path = filedialog.askopenfilename( title="选择Excel文件", filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")] ) if file_path: self.file_path_var.set(file_path) self.excel_file = file_path self.log(f"已选择文件: {file_path}", "info") try: xl = pd.ExcelFile(file_path) sheets = xl.sheet_names if sheets: self.sheet_var.set(sheets[0]) self.log(f"自动选择工作表: {sheets[0]}", "info") except Exception as e: self.log(f"读取工作表失败: {str(e)}", "warning") def paste_cookie(self): """从剪贴板粘贴Cookie""" try: clipboard_text = self.root.clipboard_get() if "cookie:" in clipboard_text.lower() or "-H" in clipboard_text: cookie_match = re.search(r'-b\s+"([^"]+)"', clipboard_text) if not cookie_match: cookie_match = re.search(r'-H\s+"cookie:\s*([^"]+)"', clipboard_text, re.IGNORECASE) if cookie_match: self.cookie_text.delete(1.0, tk.END) self.cookie_text.insert(1.0, cookie_match.group(1)) self.log("已从剪贴板提取Cookie", "success") return self.cookie_text.delete(1.0, tk.END) self.cookie_text.insert(1.0, clipboard_text) self.log("已粘贴Cookie", "info") except: messagebox.showwarning("提示", "无法读取剪贴板内容") def load_default_cookie(self): """加载默认Cookie""" default_cookie = "_uuid_hera_ab_path_1=7651649498397052109; __tea__ug__uid=4144561781538482924; _gcl_au=1.1.775810441.1781538483; lgw_csrf_token=9dcbe9c422a1a2df5727bebebf4136760e1ad6dc-1781538485; passport_web_did=7651823325329411260; passport_trace_id=7651823325353757894; QXV0aHpDb250ZXh0=ba539bcfe4a042c8bc1e99d4d03add38; s_v_web_id=verify_mqg251ox_Tp4ofGaR_NCf4_4zII_8Ad3_c3HxGb4PXIuN; session=XN0YXJ0-5b9t9966-1122-4520-890c-e9313b1f818a-WVuZA; session_list=XN0YXJ0-5b9t9966-1122-4520-890c-e9313b1f818a-WVuZA; login_recently=1; is_anonymous_session=; _csrf_token=83aae12269bced10680d3ccf817f82262305e7d0-1781579108; lang=zh; et=c0c63f4480fdf14d7e6e9a21bab2b7e1; ccm_cdn_host=//lf-package-cn.feishucdn.com/obj/feishu-static; locale=zh-CN; landing_url=https://www.feishu.cn/; Hm_lvt_a79616d9322d81f12a92402ac6ae32ea=1781538483,1781579751; Hm_lpvt_a79616d9322d81f12a92402ac6ae32ea=1781579751; HMACCOUNT=5CA5EB76287246C7; _ga_VPYRHN104D=GS2.1.s1781579004$o2$g1$t1781579751$j60$l0$h0; _uetvid=9877555068d111f1992d03b61d301033; _ga=GA1.2.692261332.1781538484; i18n_locale=zh-CN; i18n_locale=zh-CN; _gid=GA1.2.1918132955.1781749497; site_env=pre=0; sl_session=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODE3OTMyNTgsInVuaXQiOiJldV9uYyIsInJhdyI6eyJtZXRhIjoiQVdvYzIrdU1BNHZSYUhkQjl4N2lRQUpxTUx6RjlrN012R293dk1YMlRzeThhakM5WXdRQURMSUNLZ0VBUVVGQlFVRkJRVUZCUVVKeFRVd3hha2h6UkUxNFVUMDkiLCJzdW0iOiI1MWU3ZWM3YzU0NTAzNTljMzRhMjE3ODdlM2U1OGNmZjQzNzZmM2IzYjJjNzU4NWVjMGE3MjQyZDZjNTBiNmNkIiwibG9jIjoiemhfY24iLCJhcGMiOiJSZWxlYXNlIiwiaWF0IjoxNzgxNzUwMDU4LCJzYWMiOnsiVXNlclN0YWZmU3RhdHVzIjoiMSIsIlVzZXJUeXBlIjoiNDIifSwibG9kIjpudWxsLCJjbmYiOnsiamt0Ijoic3VWaTdnNDNJX0lUb0w2ZGo5QXhEWnBacUhkYldzNWJaaVhvV0s4akZrdyJ9LCJucyI6ImxhcmsiLCJuc191aWQiOiI3NjQ2MjI4MDcyMDgxMDk5NzI5IiwibnNfdGlkIjoiNzUyNzU1NzgzMTgwMzk0NDk2MiIsIm90IjoxLCJjdCI6MTc4MTU3OTEwNywicnQiOjE3ODE2Njg4OTMsInRjcyI6MiwidGJ0IjowfX0.6MrIkLX5grSs5PUBr3U-TC5keonxZZDADzbJ0c00y9HsalJhhQ0gbmfk2u2w5RWhMkXt46a50yMc9E7Po6kymg; msToken=Oyg-XUAwfnJlSSSvRBdj1W_LxhRCXLlWMx9mk0Ryxvku6FZLH0ysyeYnmGrIG_yvUClyZT2cLYe5rhC1PtVygRRXOhYTvrDcL8Oidp-ZHHI-LNFKPJk-She-3kUxNTnwb3fYj5FfI36vX-1eFZ_neFVmoTtgD1GhK3M3oNjh5H-Y; swp_csrf_token=6fe8dbf3-4ab2-4f46-b4f3-5c9cba7364cd; t_beda37=a427edf4f48d9fa049a454a58600fa2f188ffacaf053a85e3782d6615e737b03; passport_app_access_token=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODE3OTU2NDcsInVuaXQiOiJldV9uYyIsInJhdyI6eyJtX2FjY2Vzc19pbmZvIjp7IjE0MyI6eyJpYXQiOjE3ODE3NDk1MTksImFjY2VzcyI6dHJ1ZX0sIjIiOnsiaWF0IjoxNzgxNzUyMTY1LCJhY2Nlc3MiOnRydWV9LCIxMDAiOnsiaWF0IjoxNzgxNzUyMjk1LCJhY2Nlc3MiOnRydWV9LCI0Ijp7ImlhdCI6MTc4MTc1MjQ0NywiYWNjZXNzIjp0cnVlfX0sInN1bSI6IjUxZTdlYzdjNTQ1MDM1OWMzNGEyMTc4N2UzZTU4Y2ZmNDM3NmYzYjNiMmM3NTg1ZWMwYTcyNDJkNmM1MGI2Y2QifX0.6o43emSe8iOBsXTPUbx4cShf-DdIpGuTTYDR5NDAAvA6ZByV_biiboF32sk5SjLAuTfEEy1xWpZ_35Fb-nxvJg" self.cookie_text.delete(1.0, tk.END) self.cookie_text.insert(1.0, default_cookie) self.log("已加载默认Cookie", "info") def save_config(self): """保存Cookie配置到文件""" cookie = self.cookie_text.get(1.0, tk.END).strip() if not cookie: messagebox.showwarning("提示", "Cookie不能为空") return try: with open("cookie_config.txt", "w", encoding="utf-8") as f: f.write(cookie) self.log("Cookie配置已保存", "success") messagebox.showinfo("成功", "Cookie配置已保存到 cookie_config.txt") except Exception as e: self.log(f"保存失败: {str(e)}", "error") def load_config(self): """加载保存的Cookie配置""" try: with open("cookie_config.txt", "r", encoding="utf-8") as f: cookie = f.read().strip() if cookie: self.cookie_text.delete(1.0, tk.END) self.cookie_text.insert(1.0, cookie) self.log("已加载保存的Cookie配置", "info") except FileNotFoundError: pass except Exception as e: self.log(f"加载配置失败: {str(e)}", "warning") def log(self, message, tag="info"): """添加日志""" timestamp = datetime.now().strftime("%H:%M:%S") self.log_text.insert(tk.END, f"[{timestamp}] {message}\n", tag) self.log_text.see(tk.END) self.root.update_idletasks() def clear_log(self): """清空日志""" self.log_text.delete(1.0, tk.END) def parse_cookie(self, cookie_string): """解析Cookie字符串为字典""" cookies = {} for item in cookie_string.split(';'): item = item.strip() if '=' in item: key, value = item.split('=', 1) cookies[key.strip()] = value.strip() return cookies def _get_csrf_token(self): """从cookie中提取csrf_token""" cookie_text = self.cookie_text.get(1.0, tk.END).strip() match = re.search(r'_csrf_token=([^;]+)', cookie_text) if match: return match.group(1) match = re.search(r'x-csrftoken[=:]\s*([^;\s]+)', cookie_text, re.IGNORECASE) if match: return match.group(1) return "83aae12269bced10680d3ccf817f82262305e7d0-1781579108" def _get_mime_type(self, filename): """获取文件MIME类型""" ext = os.path.splitext(filename)[1].lower() mime_map = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp' } return mime_map.get(ext, 'image/jpeg') def upload_image(self, image_data, filename="image.jpg"): """完整上传图片到飞书,返回file_token信息""" try: cookie_string = self.cookie_text.get(1.0, tk.END).strip() if not cookie_string: self.log(" ❌ Cookie为空,无法上传图片", "error") return None cookies = self.parse_cookie(cookie_string) csrf_token = self._get_csrf_token() self.log(f" 🔍 使用的CSRF token: {csrf_token[:30] if csrf_token else 'None'}...", "debug") self.log(f" 🔍 图片大小: {len(image_data)} bytes", "debug") base_headers = { "accept": "application/json, text/plain, */*", "accept-language": "zh-CN,zh;q=0.9", "f-version": "docs-6-17-1781628518399", "x-csrftoken": csrf_token, "x-tt-logid": "02178275091573600000000000000000000ffff5bd640caae0cb8", "x-tt-trace": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } # 步骤1: 获取uploadCode self.log(f" 📤 [1/4] 获取上传code...", "info") code_url = "https://xhd.feishu.cn/space/api/bitable/external/share/uploadCode" code_params = { "shareToken": self.share_token, "fileName": filename, "size": len(image_data), "mountPoint": "bitable_tmp_point" } code_response = requests.get(code_url, params=code_params, headers=base_headers, cookies=cookies, timeout=30) if code_response.status_code != 200: self.log(f" ❌ 获取code失败: HTTP {code_response.status_code}", "error") return None try: code_data = code_response.json() except Exception as e: self.log(f" ❌ 解析JSON失败: {str(e)}", "error") return None upload_code = code_data.get('data', {}).get('uploadCode') if not upload_code: self.log(f" ❌ 响应中没有uploadCode字段", "error") return None self.log(f" ✅ 获取uploadCode成功", "success") # 步骤2: 准备上传 self.log(f" 📤 [2/4] 准备上传,获取upload_id...", "info") prepare_url = "https://xhd.feishu.cn/space/api/box/upload/prepare/authcode/" prepare_headers = base_headers.copy() prepare_headers["content-type"] = "application/json" prepare_headers["x-command"] = "space.api.box.upload.prepare.authcode" prepare_body = { "code": upload_code, "mount_point": "bitable_tmp_point", "mount_node_token": self.share_token } prepare_response = requests.post(prepare_url, headers=prepare_headers, cookies=cookies, json=prepare_body, timeout=30) if prepare_response.status_code != 200: self.log(f" ❌ 准备上传失败: HTTP {prepare_response.status_code}", "error") return None try: prepare_data = prepare_response.json() except Exception as e: self.log(f" ❌ 解析JSON失败: {str(e)}", "error") return None if prepare_data.get('code') != 0: self.log(f" ❌ 准备上传失败: code={prepare_data.get('code')}", "error") return None upload_id = prepare_data.get('data', {}).get('upload_id') if not upload_id: self.log(f" ❌ 响应中没有upload_id字段", "error") return None self.log(f" ✅ 获取upload_id成功: {upload_id}", "success") # 步骤3: 上传文件内容 self.log(f" 📤 [3/4] 上传文件内容 ({len(image_data)} bytes)...", "info") checksum = zlib.adler32(image_data) & 0xffffffff upload_url = f"https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/merge_block/?upload_id={upload_id}" upload_headers = base_headers.copy() upload_headers["content-type"] = "application/octet-stream" upload_headers["x-command"] = "space.api.box.stream.upload.merge_block" upload_headers["x-seq-list"] = "0" upload_headers["x-block-list-checksum"] = str(checksum) upload_headers["x-block-origin-size"] = str(len(image_data)) upload_response = requests.post(upload_url, headers=upload_headers, cookies=cookies, data=image_data, timeout=60) if upload_response.status_code != 200: self.log(f" ❌ 上传文件失败: HTTP {upload_response.status_code}", "error") return None try: upload_result = upload_response.json() if upload_result.get('code') != 0: self.log(f" ❌ 上传文件失败: code={upload_result.get('code')}", "error") return None except: pass self.log(f" ✅ 文件上传成功", "success") # 步骤4: 完成上传 self.log(f" 📤 [4/4] 完成上传,获取file_token...", "info") finish_url = "https://xhd.feishu.cn/space/api/box/upload/finish/" finish_headers = base_headers.copy() finish_headers["content-type"] = "application/json" finish_headers["x-command"] = "space.api.box.upload.finish" finish_body = { "upload_id": upload_id, "num_blocks": 1, "push_open_history_record": 0 } finish_response = requests.post(finish_url, headers=finish_headers, cookies=cookies, json=finish_body, timeout=30) if finish_response.status_code != 200: self.log(f" ❌ 完成上传失败: HTTP {finish_response.status_code}", "error") return None try: finish_data = finish_response.json() except Exception as e: self.log(f" ❌ 解析JSON失败: {str(e)}", "error") return None if finish_data.get('code') != 0: self.log(f" ❌ 完成上传失败: code={finish_data.get('code')}", "error") return None file_token = finish_data.get('data', {}).get('file_token') if not file_token: self.log(f" ❌ 响应中没有file_token字段", "error") return None width, height = 0, 0 try: pil_img = Image.open(io.BytesIO(image_data)) width, height = pil_img.size except: pass result = { 'attachmentToken': file_token, 'id': file_token, 'mimeType': self._get_mime_type(filename), 'name': filename, 'size': len(image_data), 'timeStamp': int(time.time() * 1000), 'height': height, 'width': width } self.log(f" ✅ 图片上传完成! file_token: {file_token}", "success") return result except requests.exceptions.Timeout: self.log(f" ❌ 上传超时", "error") return None except Exception as e: self.log(f" ❌ 上传图片异常: {str(e)}", "error") import traceback self.log(f" 详细: {traceback.format_exc()}", "debug") return None def extract_images_to_cache(self, file_path, sheet_name): """提取Excel中的所有图片到缓存""" image_cache = {} try: wb = load_workbook(file_path, data_only=True) ws = wb[sheet_name] if hasattr(ws, '_images') and ws._images: self.log(f" 📸 开始提取 {len(ws._images)} 张图片到缓存...", "info") for idx, img in enumerate(ws._images): try: if hasattr(img, 'anchor') and hasattr(img.anchor, '_from'): anchor_from = img.anchor._from row = anchor_from.row + 1 col = anchor_from.col + 1 else: row = idx + 1 col = 0 image_data = None if hasattr(img, '_data') and callable(img._data): image_data = img._data() elif hasattr(img, '_data') and not callable(img._data): image_data = img._data elif hasattr(img, 'image'): if hasattr(img.image, '_data') and callable(img.image._data): image_data = img.image._data() elif hasattr(img.image, '_data'): image_data = img.image._data if image_data is None: continue img_format = 'jpg' filename = f"image_{row}_{idx}.jpg" try: pil_img = Image.open(io.BytesIO(image_data)) img_format = pil_img.format if pil_img.format else 'jpg' filename = f"image_{row}_{idx}.{img_format.lower()}" except: pass if row not in image_cache: image_cache[row] = [] image_cache[row].append({ 'data': image_data, 'col': col, 'format': img_format, 'filename': filename }) except Exception as e: self.log(f" ⚠️ 提取图片失败: {str(e)}", "warning") wb.close() total = sum(len(imgs) for imgs in image_cache.values()) self.log(f" ✅ 成功提取 {total} 张图片到缓存,分布在 {len(image_cache)} 行", "success") except Exception as e: self.log(f" ❌ 提取图片失败: {str(e)}", "error") return image_cache def build_request_body(self, row_dict, row_index): """根据Excel行数据构建请求体""" try: name = str(row_dict.get("姓名", "")) if pd.notna(row_dict.get("姓名")) else "" gender = str(row_dict.get("性别", "")) if pd.notna(row_dict.get("性别")) else "" phone = str(row_dict.get("手机号码", "")) if pd.notna(row_dict.get("手机号码")) else "" remark = str(row_dict.get("备注", "")) if pd.notna(row_dict.get("备注")) else "" institution = str(row_dict.get("机构", "")) if pd.notna(row_dict.get("机构")) else "" target_school_text = str(row_dict.get("意向学校", "")) if pd.notna(row_dict.get("意向学校")) else "" consulting_text = str(row_dict.get("咨询项目", "")) if pd.notna(row_dict.get("咨询项目")) else "" product_text = str(row_dict.get("产品", "")) if pd.notna(row_dict.get("产品")) else "" source = str(row_dict.get("线索来源", "")) if pd.notna(row_dict.get("线索来源")) else "" market_person = str(row_dict.get("市场人员", "")) if pd.notna(row_dict.get("市场人员")) else "" userId = str(row_dict.get("用户ID", "")) if pd.notna(row_dict.get("用户ID")) else "" channel_code = str(row_dict.get("渠道代码", "")) if pd.notna(row_dict.get("渠道代码")) else "" userImg = str(row_dict.get("人员头像", "")) if pd.notna(row_dict.get("人员头像")) else "" phone = phone.split('.')[0] self.log(f" 📝 提取数据: 姓名={name}, 手机={phone}, 渠道代码={channel_code}, 意向学校={target_school_text}, 咨询项目={consulting_text}, 产品={product_text}", "debug") school_mapping = { "上海": "optqw2jaJ5", "徐汇": "optTD4UQsM", "人广": "opt7hQjSZQ", "长宁": "opt7A8WBtE", "浦东": "optnyoxnTK", "杨浦": "optgArj17r", "闵行": "optCIwo1J1", "松江": "optuZzXsYm", "锦秋个人学校": "optLQYDW3S", "封闭学院": "optitJpd5G", "大学生-市区": "optHlvKsKy", "大学生-曹崇杨": "optdcoQXBt", "大学生-临港": "opt3i4fOKJ", "大学生-松江": "optG7eGFUg", "大学生-奉贤": "optJJqnLgY", "常州": "opt5wuiYjW" } consulting_mapping = { "语培": "opt96MRele", "留学": "optQFQsXIe", "考研": "opti3wbRF7", "AF艺术": "optaZC2VCL", "美行音乐": "optzSqwgLp", "锦秋个人学校": "opt9N4AGbJ", "研学考团": "optdYBLknN", "尚思": "optf8lTDcV" } product_mapping = { "ACT": "optlGkVm5n", "AEAS": "optqX3b1rJ", "ALEVEL": "optgMNcV8D", "AP": "optWKWYQgo", "BC": "opt8huX4Nv", "DSE": "optxFBYmUs", "ESL": "optaYj9RZM", "GMAT": "optgwu03Nf", "GRE": "optEOyf5TR", "IB": "optBhWIw7x", "IGCSE": "optIuoUFpo", "KET": "optjcLdrsB", "PET": "optY42DNFo", "PTE": "optOrHJvTX", "SAT": "optTxT8NEu", "SSAT": "optJ2a0tkB", "Toefl Junior": "optEGInE0d", "国际备考": "optgwn1azI", "国际研学及考团": "optAmxtqGN", "国家地理": "opttXZvoj3", "剑桥英语": "optQkkhzD5", "锦秋竞赛": "optUgXXmpF", "考研": "optgQrGHTS", "留学预备": "optWxtOU8b", "美高": "opt36E5u0t", "硕果": "optNKeSnho", "硕果竞赛": "optMzk3TpL", "腾飞计划": "optYTD9h04", "团企培": "optW5UDF6G", "托福": "optKuZ2zCq", "雅思": "optWsal1MB", "用英语讲中国故事": "optinda27s", "在线": "opt2X07PY4", "浙大国际本科": "optZFmVTTC", "语培待定/未知": "optTNitLA5", "大学四级": "optAqYPFmN", "大学六级": "optpdfKgdl", "菁英树": "optBFhBHFX", "其它": "opt698ww1H" } target_school_id = None if target_school_text: if target_school_text in school_mapping: target_school_id = school_mapping[target_school_text] else: for school_name, school_id in school_mapping.items(): if school_name in target_school_text or target_school_text in school_name: target_school_id = school_id break consulting_ids = [] if consulting_text: consulting_items = re.split(r'[,,、/ ]+', consulting_text) for item in consulting_items: item = item.strip() if item and item in consulting_mapping: consulting_ids.append(consulting_mapping[item]) product_ids = [] if product_text: product_items = re.split(r'[,,、/ ]+', product_text) for item in product_items: item = item.strip() if item: if item in product_mapping: product_ids.append(product_mapping[item]) else: item_lower = item.lower() for product_name, product_id in product_mapping.items(): if product_name.lower() == item_lower or product_name in item or item in product_name: product_ids.append(product_id) break remark_text = f"姓名:{name},性别:{gender},手机号码:{phone}" if remark: remark_text += f",{remark}" data_obj = { "fld43Khnuw": { "type": 1, "value": [{"type": "text", "text": remark_text}] }, "fldexDPwcE": {"type": 3, "value": "optmlZt00z"} if institution else {"type": 3}, "fldWt6JrgK": {"type": 3, "value": target_school_id} if target_school_id else {"type": 3}, "fldirDDoLV": {"type": 4, "value": consulting_ids} if consulting_ids else {"type": 4}, "fldudfcNVO": {"type": 4, "value": product_ids} if product_ids else {"type": 4}, "fldS90QCXo": {"type": 1, "value": [{"type": "text", "text": name}]} if name else {"type": 1}, "fldsA4YdS6": {"type": 3}, "fldSSElgkA": {"type": 13, "value": {"fullPhoneNum": phone}} if phone else {"type": 13}, "fldZPL52gS": {"type": 1}, "fldPIaD4UH": {"type": 13}, "fldUGeDVDF": {"type": 13}, "fldKZ5WKyN": {"type": 1}, "fld0MHPEaW": {"type": 3}, "fldOLvk3zZ": {"type": 3}, "fldkSVVnuu": {"type": 1}, "fldCfRL3qM": {"type": 3, "value": "optTWIvAFb"} if source else {"type": 3}, "fldodpd0fR": { "type": 11, "value": { "users": [{ "userId": userId, "name": market_person, "avatarUrl": userImg, "enName": market_person, "notify": False, }] } } if market_person else {"type": 11}, "flde7thxT7": {"type": 3, "value": channel_code}, "fldaxavu52": {"type": 3, "value": "optHsn4zKU"}, "fldMpwPaI1": {"type": 17} } if self.image_cache and row_index in self.image_cache: images = self.image_cache[row_index] if images: img_info = images[0] if row_index in self.uploaded_cache: img_value = self.uploaded_cache[row_index] else: uploaded = self.upload_image(img_info['data'], img_info['filename']) if uploaded: self.uploaded_cache[row_index] = uploaded img_value = uploaded else: img_value = None if img_value: data_obj["fldMpwPaI1"] = { "type": 17, "value": [img_value] } body = { "shareToken": self.share_token, "data": json.dumps(data_obj, ensure_ascii=False), "preUploadEnable": True } return body except Exception as e: self.log(f"构建请求体失败: {str(e)}", "error") import traceback self.log(f"详细错误: {traceback.format_exc()}", "debug") return None def send_request(self, row_data, row_index): """发送单条请求""" try: self.log(f"\n{'='*50}", "info") self.log(f"📤 准备发送第 {row_index} 行数据", "info") cookie_string = self.cookie_text.get(1.0, tk.END).strip() if not cookie_string: self.log("❌ Cookie为空,请先配置Cookie", "error") return False, "Cookie为空" body = self.build_request_body(row_data, row_index) if body is None: self.log("❌ 构建请求体失败", "error") return False, "构建请求体失败" body_str = json.dumps(body, ensure_ascii=False) self.log(f"📦 请求体大小: {len(body_str)} 字节", "debug") headers = self.default_headers.copy() cookies = self.parse_cookie(cookie_string) headers["x-csrftoken"] = self._get_csrf_token() headers["f-version"] = "docs-6-17-1781628518399" response = requests.post( self.url, headers=headers, cookies=cookies, json=body, timeout=30 ) try: resp_data = response.json() resp_text = json.dumps(resp_data, ensure_ascii=False, indent=2) except: resp_text = response.text if response.status_code == 200: self.log(f"✅ 第{row_index}行 发送成功", "success") return True, resp_text else: self.log(f"❌ 第{row_index}行 发送失败 (HTTP {response.status_code})", "error") return False, resp_text except requests.exceptions.Timeout: self.log(f"⏱️ 第{row_index}行 请求超时", "error") return False, "请求超时" except requests.exceptions.ConnectionError: self.log(f"🔌 第{row_index}行 连接失败", "error") return False, "连接失败" except Exception as e: self.log(f"💥 第{row_index}行 异常: {str(e)}", "error") return False, str(e) finally: self.log(f"{'='*50}\n", "info") def send_all(self): """发送所有数据的主循环(已增加暂停和阈值检查)""" try: excel_file = self.file_path_var.get() sheet_name = self.sheet_var.get() min_interval = float(self.min_interval_var.get()) max_interval = float(self.max_interval_var.get()) start_row = int(self.start_row_var.get()) - 1 if not excel_file: self.log("❌ 请先选择Excel文件", "error") self.status_var.set("❌ 请先选择Excel文件") return # 初始化计数 self.sent_count = 0 self.count_display_var.set("本次已发送: 0 条") self.uploaded_cache = {} self.log(f"🔍 正在自动提取Excel中的嵌入图片...", "info") self.image_cache = self.extract_images_to_cache(excel_file, sheet_name) if self.image_cache: total_images = sum(len(imgs) for imgs in self.image_cache.values()) self.log(f"📊 共提取 {total_images} 张图片,分布在 {len(self.image_cache)} 行", "info") else: self.log(f"📷 未检测到任何嵌入图片", "info") try: df = pd.read_excel(excel_file, sheet_name=sheet_name) self.log(f"📊 成功读取Excel: {len(df)} 行数据", "info") except Exception as e: self.log(f"❌ 读取Excel失败: {str(e)}", "error") self.status_var.set(f"❌ 读取失败: {str(e)}") return if len(df) == 0: self.log("⚠️ Excel中没有数据", "warning") return df = df.iloc[start_row:] total = len(df) success_count = 0 fail_count = 0 self.log(f"🚀 开始发送,共 {total} 条数据", "info") self.status_var.set(f"🔄 发送中... 0/{total}") for idx, (_, row) in enumerate(df.iterrows()): # ========== 暂停检查(核心新增)========== while self.is_paused and not self.stop_flag: self.status_var.set(f"⏸ 已暂停 | 成功:{success_count} 失败:{fail_count}") time.sleep(0.5) if self.stop_flag: self.log("⏹️ 已停止发送", "warning") self.status_var.set("⏹️ 已停止") break excel_row_number = idx + 2 + start_row if self.image_cache and excel_row_number in self.image_cache: img_count = len(self.image_cache[excel_row_number]) self.log(f" 🖼️ 第{excel_row_number}行有 {img_count} 个嵌入图片", "info") success, resp_text = self.send_request(row.to_dict(), excel_row_number) if success: success_count += 1 self.sent_count += 1 self.count_display_var.set(f"本次已发送: {self.sent_count} 条") # ========== 阈值检查(核心新增)========== if self._check_threshold(): self.log(f"⏸ 因达到阈值暂停,等待手动继续...", "pause") # 进入暂停等待循环 while self.is_paused and not self.stop_flag: self.status_var.set(f"⏸ 已达阈值暂停 | 成功:{success_count} 失败:{fail_count}") time.sleep(0.5) if self.stop_flag: break # 继续后重置计数(可选:如果想累计不清零,注释掉下面这行) # self.sent_count = 0 # self.count_display_var.set(f"本次已发送: {self.sent_count} 条") else: fail_count += 1 self.status_var.set(f"🔄 发送中... {idx+1}/{total} (成功:{success_count} 失败:{fail_count})") if idx < total - 1 and not self.stop_flag: interval = random.uniform(min_interval, max_interval) self.log(f"⏳ 等待 {interval:.1f} 秒...", "info") time.sleep(interval) self.log(f"🏁 发送完成!成功: {success_count}, 失败: {fail_count}", "success" if fail_count == 0 else "warning") self.status_var.set(f"✅ 完成 成功:{success_count} 失败:{fail_count}") except Exception as e: self.log(f"💥 程序异常: {str(e)}", "error") import traceback self.log(f"详细错误: {traceback.format_exc()}", "debug") self.status_var.set(f"❌ 异常: {str(e)}") finally: self.is_running = False self.is_paused = False self.pause_btn.config(text="⏸ 暂停", state=tk.DISABLED) self.start_btn.config(state=tk.NORMAL) self.stop_btn.config(state=tk.DISABLED) def start_send(self): """启动发送线程""" if self.is_running: return if not self.file_path_var.get(): messagebox.showwarning("提示", "请先选择Excel文件") return cookie = self.cookie_text.get(1.0, tk.END).strip() if not cookie: messagebox.showwarning("提示", "请先配置Cookie") return try: float(self.min_interval_var.get()) float(self.max_interval_var.get()) int(self.start_row_var.get()) threshold_str = self.threshold_var.get().strip() if threshold_str and threshold_str != "0": int(threshold_str) except ValueError: messagebox.showerror("错误", "间隔、行号或阈值请输入有效数字") return # 验证时间格式 for time_var, name in [(self.start_time_var, "启动时间"), (self.stop_time_var, "关闭时间")]: t = time_var.get().strip() if t: if not re.match(r'^([01]\d|2[0-3]):([0-5]\d)$', t): messagebox.showerror("错误", f"{name} 格式错误,请使用 HH:MM 格式") return self.is_running = True self.stop_flag = False self.is_paused = False self.start_btn.config(state=tk.DISABLED) self.stop_btn.config(state=tk.NORMAL) self.pause_btn.config(text="⏸ 暂停", state=tk.NORMAL) self.status_var.set("🔄 正在发送...") thread = threading.Thread(target=self.send_all, daemon=True) thread.start() def stop_send(self): """停止发送""" self.stop_flag = True self.is_paused = False self.log("⏹️ 正在停止...", "warning") self.stop_btn.config(state=tk.DISABLED) self.pause_btn.config(state=tk.DISABLED) def extract_images_to_folder(self, file_path, sheet_name, output_folder=None): """提取Excel中的所有图片并保存到文件夹""" try: if output_folder is None: base_dir = os.path.dirname(file_path) base_name = os.path.splitext(os.path.basename(file_path))[0] output_folder = os.path.join(base_dir, f"{base_name}_提取的图片") if not os.path.exists(output_folder): os.makedirs(output_folder) mapping_file = os.path.join(output_folder, "图片映射表.txt") wb = load_workbook(file_path, data_only=True) ws = wb[sheet_name] image_count = 0 error_count = 0 with open(mapping_file, 'w', encoding='utf-8') as f: f.write("="*60 + "\n") f.write("Excel图片提取映射表\n") f.write(f"源文件: {file_path}\n") f.write(f"工作表: {sheet_name}\n") f.write(f"提取时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") f.write("="*60 + "\n\n") if hasattr(ws, '_images') and ws._images: for idx, img in enumerate(ws._images): try: if hasattr(img, 'anchor') and hasattr(img.anchor, '_from'): anchor_from = img.anchor._from row = anchor_from.row + 1 col = anchor_from.col + 1 col_letter = get_column_letter(col) else: row = idx + 1 col = 0 col_letter = '?' image_data = None if hasattr(img, '_data') and callable(img._data): image_data = img._data() elif hasattr(img, '_data') and not callable(img._data): image_data = img._data elif hasattr(img, 'image'): if hasattr(img.image, '_data') and callable(img.image._data): image_data = img.image._data() elif hasattr(img.image, '_data'): image_data = img.image._data if image_data is None: error_count += 1 continue img_format = 'jpg' width, height = 0, 0 try: pil_img = Image.open(io.BytesIO(image_data)) img_format = pil_img.format if pil_img.format else 'jpg' width, height = pil_img.size filename = f"第{row}行_列{col_letter}_{idx+1}.{img_format.lower()}" filepath = os.path.join(output_folder, filename) pil_img.save(filepath) except: filename = f"第{row}行_列{col_letter}_{idx+1}.jpg" filepath = os.path.join(output_folder, filename) with open(filepath, 'wb') as f_out: f_out.write(image_data) image_count += 1 except Exception as e: error_count += 1 wb.close() self.log(f"✅ 成功提取 {image_count} 张图片到: {output_folder}", "success") return output_folder except Exception as e: self.log(f"❌ 提取图片失败: {str(e)}", "error") return None def extract_images(self): """手动提取图片的入口方法""" excel_file = self.file_path_var.get() if not excel_file: messagebox.showwarning("提示", "请先选择Excel文件") return sheet_name = self.sheet_var.get() if not messagebox.askyesno("确认", f"即将提取Excel中的所有图片\n\n" f"文件: {os.path.basename(excel_file)}\n" f"工作表: {sheet_name}\n\n" f"是否继续?"): return def extract_thread(): self.log("📸 开始提取图片...", "info") self.status_var.set("🔄 正在提取图片...") self.start_btn.config(state=tk.DISABLED) output_folder = self.extract_images_to_folder(excel_file, sheet_name) if output_folder: self.status_var.set(f"✅ 图片已提取到: {output_folder}") messagebox.showinfo("提取成功", f"图片已成功提取!\n\n" f"保存位置: {output_folder}\n\n" f"请查看文件夹中的 '图片映射表.txt' 文件") else: self.status_var.set("❌ 提取失败") messagebox.showerror("提取失败", "图片提取失败,请查看日志了解详情。") self.start_btn.config(state=tk.NORMAL) thread = threading.Thread(target=extract_thread, daemon=True) thread.start() if __name__ == "__main__": root = tk.Tk() app = FeishuSubmitApp(root) root.mainloop()