Python爬虫基于Tkinte+requests采集商品数据(第34节)


在如今互联网数字化时代,数据作为核心资源蕴含着非常重要的经济价值,想要获取网络中的数据,如果通过传统的手动采集方式既费时又费力,而目前获取数据的主要方式就是通过网络爬虫对目标网站进行数据采集。本节教程将介绍如何通过Python的Tkinter(GUI)图形用户界面库创建简单的操作界面,并通过requests库发送HTTP请求后爬取goofish.com网页上的商品数据。

1、什么是网络爬虫?

网络爬虫是一种Python自动化程序,能够按照指定规则自动访问互联网上的网页,并采集我们所需的数据。通过模拟正常网页浏览行为,爬虫可以浏览大量的网页并获取其中的数据。借助网络爬虫,我们可以在短时间内快速采集大量的数据,无须手动操作。

2、Python爬虫采集商品数据介绍

该程序使用Python的Tkinter库创建一个商品爬虫的图形用户界面(GUI)应用。这个爬虫不仅能够自动抓取网页中的商品信息,还能自动保存抓取的商品数据包括图片、商品ID、标题、价格、卖家信息等数据,它不仅能高效爬取商品数据,还拥有直观的图形界面。注意:爬取数据前,请先登录goofish.com后获取Cookie。

核心功能概览

获取Cookie:在本教程的第22章《搭建Web网站 》第4节教程《Python中Session和Cookie的使用》中已经详细介绍了Cookie的使用,Cookie是一种由网站服务器保存在用户浏览器端的一小块数据,用于记录用户的一些状态信息。当用户访问一个网站时,网站服务器会向浏览器发送一个包含Cookie信息的响应头,浏览器接收到这个响应头后,会将Cookie信息保存在本地。在Python爬虫开发中,Cookie管理是实现状态维持和模拟登录的核心技术,所以,在爬取goofish.com数据前,必须先登录goofish.com后获取Cookie。goofish.com获取Cookie的方式:登录goofish.com账号后按F12 -> 网络 -> 点击F5刷新一下页面 -> 点击Fetch/XHR -> 点击jsv -> 找到“标头”中的Cookie -> 复制Cookie后面所有的内容。如下图所示:

Python爬虫基于Tkinte+requests采集商品数据

保存Cookie:打开程序后输入已获取的Cookie,并点击“保存以上Cookie”按钮,程序会在当前脚本工作目录下创建一个cookie_data_file.json的JSON文件,用于保存已获取的Cookie,下次打开程序前将会自动加载保存在本地的Cookie数据。

多线程技术:该程序通过Python的threading多线程库,确保爬取数据过程不阻塞GUI界面,使得爬取过程在后台进行,不会导致GUI界面卡顿或无响应。

保存爬取的数据:通过Python的第三方库openpyxl来保存爬取的数据,数据爬取成功后会在当前脚本工作目录下创建一个Excel商品数据文件,用于保存商品的数据包括图片、商品ID、标题、价格、卖家信息等数据。

商品图片:通过Python的第三方图像处理库Pillow保存爬取的商品图片,商品图片爬取成功后会在当前脚本工作目录下创建一个images_save文件夹,用于保存所有爬取的商品图片。

注意:请遵守网站规则,爬虫在运行过程中必须严格遵守网站所设定的robots.txt协议,仅对robots.txt协议允许抓取的页面及数据进行访问与提取。不要通过非法手段绕过网站所设置的反爬机制,如暴力破解验证码、伪造请求头、通过VPN频繁更换IP地址等方式来突破网站限制。对于网站明确禁止爬虫访问的区域和数据类型,爬虫程序严禁进行尝试访问与获取。

声明:本爬虫程序仅作学习交流用途,使用者在决定使用本爬虫程序之前,应充分了解相关法律法规以及使用该程序可能存在的风险,并确保自身的使用行为符合法律规定和道德准则。若因使用本程序而导致任何法律问题或其他不良后果,使用者需自行承担全部责任,包括但不限于可能面临的民事赔偿、行政处罚甚至刑事处罚。发布者不承担任何因使用者不当使用而引发的连带责任。

3、使用教程

安装依赖:首先,确保你已经安装了requests、openpyxl和pillow库。如果还没有安装,可以通过pip安装:

pip install requests
pip install openpyxl
pip install pillow

输入Cookie:输入已获取的Cookie,这里需要注意的是,Cookie本身有过期时间限制,如果网站长时间没有动作,会导致Cookie过期,必须刷新网站页面后重新获取Cookie。

设置搜索参数:在关键词输入框中输入想要搜索的商品关键词,选择要爬取的页数(默认1页),选择使用的线程数(默认5个线程)。

开始爬取:点击 "开始爬取" 按钮后,如果没有出现错误日志的话,程序会自动开始爬取数据,请耐心等待一小会儿。

查看爬取结果:爬取过程中可以在结果预览区看到实时数据。爬取完成后,数据会自动保存为Excel文件,可以点击 "打开爬取结果文件" 可以直接查看当前脚本工作目录下最新保存的Excel文件,该Excel文件默认以搜索关键词为前缀。

4、运行截图

Python爬虫基于Tkinte+requests采集商品数据

Python爬虫基于Tkinte+requests采集商品数据

Python爬虫基于Tkinte+requests采集商品数据

Python爬虫基于Tkinte+requests采集商品数据

5、Python爬虫采集商品数据,完整代码如下所示:

动手练一练:

import os, json, queue, threading, hashlib, time, requests, webbrowser
from openpyxl import Workbook
from openpyxl.drawing.image import Image
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext
from datetime import datetime

# 获取当前脚本文件所在的目录
work_directory = os.path.dirname(os.path.abspath(__file__))
# 将当前脚本文件所在的目录设置为工作目录
os.chdir(work_directory)

# 初始化常量配置
REQUEST_API = "https://h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/"
REQUEST_KEY = "34839810"
USER_AGENT = "Mozilla/5.0 (Windows; U; Windows NT 5.0) AppleWebKit/534.19.3 (KHTML, like Gecko) Version/5.1 Safari/534.19.3"
INTERVAL_TIME = 1.5  # 请求间隔设置1.5秒
COOKIE_DATA_FILE = "cookie_data_file.json"  # 保存Cookie的文件
MAX_THREADS = 5  # 设置最大工作线程数
IMAGES_SAVE_FOLDER = "images_save"  # 定义保存图片的文件夹名称
IMAGE_TYPES = ['jpg', 'jpeg', 'png', 'gif', 'bmp']  # 定义图片格式列表

# 定义CollectProductsSpider类,用于封装爬虫功能
class CollectProductsSpider:
    def __init__(self, root):
        self.root = root
        self.root.title("Python爬虫采集商品数据")

        # 程序窗口设置居中
        window_width = 900
        window_height = 700
        screen_width = self.root.winfo_screenwidth()
        screen_height = self.root.winfo_screenheight()
        x = (screen_width - window_width) / 2
        y = (screen_height - window_height) / 2
        self.root.geometry('%dx%d+%d+%d' % (window_width, window_height, x, y))
        self.root.resizable(True, True)

        # 创建顶部的菜单栏
        self.spider_menu = tk.Menu(self.root)
        file_menu = tk.Menu(self.spider_menu, tearoff=0)
        self.spider_menu.add_cascade(label='文件', menu=file_menu)
        file_menu.add_command(label='打开原网站', command=self.open_website)
        file_menu.add_command(label='打开爬取结果文件', command=self.open_result_file)
        file_menu.add_command(label='退出', command=self.GUI_window_close)

        # 放置菜单栏
        self.root.config(menu=self.spider_menu)

        # 创建日志记录队列
        self.record_queue = queue.Queue()

        # 将窗口关闭事件与GUI_window_close函数关联
        self.root.protocol("WM_DELETE_WINDOW", self.GUI_window_close)

        # 创建状态变量
        self.is_running = False

        # 初始化cookie和token为空
        self.cookie = ""
        self.token = ""

        # 判断保存图片的文件夹是否存在,如果不存在则在当前工作目录创建该文件夹
        if not os.path.exists(IMAGES_SAVE_FOLDER):
            os.makedirs(IMAGES_SAVE_FOLDER)

        # 初始化加载已保存的Cookie
        self.load_cookie()

        # GUI界面创建
        self.create_widgets()

        # 启动日志更新线程
        threading.Thread(target=self.update_record, daemon=True).start()

    # 创建主界面控件
    def create_widgets(self):
        # 创建主框架
        main_frame = ttk.Frame(self.root, padding=10)
        main_frame.pack(fill=tk.BOTH, expand=True)

        # 定义标题标签
        title_label = ttk.Label(main_frame, text="Python爬虫采集商品数据", font=('宋体', 20, 'bold'))
        title_label.pack(pady=(0, 20))

        # 配置输入区域
        input_frame = ttk.LabelFrame(main_frame, text="爬取设置", padding=10)
        input_frame.pack(fill=tk.X, pady=(0, 10))

        # cookie输入区域
        cookie_frame = tk.Frame(input_frame)
        cookie_frame.pack(fill=tk.X, pady=(0, 10))

        # Cookie输入
        ttk.Label(cookie_frame, text="Cookie:").pack(side=tk.LEFT, padx=(0, 10))
        self.cookie_var = tk.StringVar(value=self.cookie)
        self.cookie_entry = ttk.Entry(cookie_frame, textvariable=self.cookie_var, width=110)
        self.cookie_entry.pack(side=tk.LEFT, padx=(0, 10))

        # 创建备注区域
        description_frame = ttk.Frame(input_frame)
        description_frame.pack(fill=tk.X, pady=(0, 10))
        description_text = '备注:请先通过浏览器登录后获取Cookie'
        ttk.Label(description_frame, text=description_text, font=('宋体', 10), foreground="#1192f4").pack(side=tk.LEFT, padx=5)

        # 输入区域
        set_frame = tk.Frame(input_frame)
        set_frame.pack(fill=tk.X, pady=(0, 10))

        # 输入要搜索的关键词
        ttk.Label(set_frame, text="关键词:").pack(side=tk.LEFT, padx=(0, 5))
        self.keyword_var = tk.StringVar()
        self.keyword_entry = ttk.Entry(set_frame, textvariable=self.keyword_var, width=30)
        self.keyword_entry.pack(side=tk.LEFT, padx=(0, 30))

        # 爬取页数设置
        ttk.Label(set_frame, text="爬取页数:").pack(side=tk.LEFT, padx=(0, 5))
        self.page_var = tk.StringVar(value="1")
        self.page_entry = ttk.Entry(set_frame, textvariable=self.page_var, width=10)
        self.page_entry.pack(side=tk.LEFT, padx=(0, 30))

        # 设置线程数
        ttk.Label(set_frame, text="线程数:").pack(side=tk.LEFT, padx=(0, 5))
        self.thread_var = tk.StringVar(value=str(MAX_THREADS))
        self.thread_entry = ttk.Combobox(set_frame, textvariable=self.thread_var, width=5, state="readonly")
        self.thread_entry['values'] = tuple(str(i) for i in range(1, MAX_THREADS + 1))
        self.thread_entry.pack(side=tk.LEFT, padx=(0, 10))

        # 按钮区域
        button_frame = ttk.Frame(main_frame)
        button_frame.pack(fill=tk.X, pady=(0, 10))

        self.start_button = ttk.Button(button_frame, text="开始爬取", command=self.start_crawling)
        self.start_button.pack(side=tk.LEFT, padx=(0, 10))

        self.stop_button = ttk.Button(button_frame, text="停止", command=self.stop_crawling, state=tk.DISABLED)
        self.stop_button.pack(side=tk.LEFT)

        ttk.Button(button_frame, text="打开爬取结果文件", command=self.open_result_file).pack(side=tk.RIGHT, padx=(0, 15))
        ttk.Button(button_frame, text="清除日志和结果预览", command=self.clear_record).pack(side=tk.RIGHT, padx=(0, 15))
        ttk.Button(button_frame, text="保存以上Cookie", command=self.save_cookie).pack(side=tk.RIGHT, padx=(0, 15))

        # 日志记录区域
        record_frame = ttk.LabelFrame(main_frame, text="日志信息", padding="10")
        record_frame.pack(fill=tk.BOTH)

        self.record_text = scrolledtext.ScrolledText(record_frame, wrap=tk.WORD, state=tk.DISABLED, height=10)
        self.record_text.pack(fill=tk.BOTH)

        # 结果预览区
        result_frame = ttk.LabelFrame(main_frame, text="结果预览", padding="10")
        result_frame.pack(fill=tk.BOTH)

        # 创建Treeview
        columns = ('商品ID', '标题', '价格', '卖家', '标签')
        self.tree = ttk.Treeview(result_frame, columns=columns, show='headings', height=6)

        # 为Treeview定义列
        self.tree.heading('商品ID', text='商品ID')
        self.tree.heading('标题', text='标题')
        self.tree.heading('价格', text='价格')
        self.tree.heading('卖家', text='卖家')
        self.tree.heading('标签', text='标签')

        # 为Treeview设置列宽
        self.tree.column('商品ID', width=80)
        self.tree.column('标题', width=200)
        self.tree.column('价格', width=30)
        self.tree.column('卖家', width=150)
        self.tree.column('标签', width=50)

        # 为Treeview创建滚动条
        tree_scrollbar = ttk.Scrollbar(result_frame, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=tree_scrollbar.set)
        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        tree_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        # 爬取进度显示区
        bottom_frame = ttk.LabelFrame(main_frame, text="爬取进度", padding=5)
        bottom_frame.pack(side=tk.BOTTOM, fill=tk.X)

        self.progress_var = tk.DoubleVar()
        self.progress_bar = ttk.Progressbar(bottom_frame, variable=self.progress_var, maximum=100)
        self.progress_bar.pack(fill=tk.X)
        # 初始化进度条显示为0
        self.progress_var.set(0)
        self.progress_bar.update()

        # 爬取状态栏
        self.status_var = tk.StringVar(value="就绪")
        status_bar = ttk.Label(bottom_frame, textvariable=self.status_var)
        status_bar.pack(fill=tk.X, pady=5)

    # 将消息添加到日志队列
    def record_message(self, message):
        # 以"年-月-日 小时-分钟-秒"的格式获取当前日期
        current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        self.record_queue.put(f"[{current_time}] {message}")

    # 更新日志记录
    def update_record(self):
        try:
            while True:
                # 从队列获取所有可用消息
                messages = []
                while not self.record_queue.empty():
                    messages.append(self.record_queue.get_nowait())

                if messages:
                    self.record_text.config(state=tk.NORMAL)
                    for msg in messages:
                        self.record_text.insert(tk.END, msg + "\n")
                    self.record_text.config(state=tk.DISABLED)
                    self.record_text.yview(tk.END)

                time.sleep(0.1)
        except Exception as e:
            print(f"日志更新线程错误: {e}")

    # 清空日志内容和结果预览内容
    def clear_record(self):
        self.record_text.config(state=tk.NORMAL)
        self.record_text.delete(1.0, tk.END)
        self.record_text.config(state=tk.DISABLED)
        self.progress_var.set(0)
        # 清空Treeview
        for item in self.tree.get_children():
            self.tree.delete(item)

    # 打开爬取结果文件,注意是Excel文件
    def open_result_file(self):
        keyword = self.keyword_entry.get().strip()
        if not keyword:
            messagebox.showinfo("提示", "未找到结果文件")
            return

        # 查找最新的结果文件
        files = [f for f in os.listdir('.') if f.startswith(keyword) and f.endswith('.xlsx')]
        if files:
            latest_file = max(files, key=os.path.getctime)
            try:
                os.startfile(latest_file)
            except:
                # Linux系统使用xdg-open
                os.system(f'xdg-open "{latest_file}"')
        else:
            messagebox.showinfo("提示", "未找到结果文件")

    # 从已保存的文件中加载Cookie
    def load_cookie(self):
        try:
            if os.path.exists(COOKIE_DATA_FILE):
                with open(COOKIE_DATA_FILE, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    self.cookie = data.get('cookie', '')
                    self.record_message(f"已加载保存的Cookie")
        except Exception as e:
            self.record_message(f"加载Cookie失败: {e}")

    # 保存输入的Cookie到本地文件中
    def save_cookie(self):
        self.cookie = self.cookie_var.get().strip()
        if not self.cookie:
            messagebox.showwarning("警告", "输入的Cookie不能为空")
            return

        try:
            with open(COOKIE_DATA_FILE, 'w', encoding='utf-8') as f:
                json.dump({'cookie': self.cookie}, f, ensure_ascii=False, indent=2)
            self.record_message("Cookie保存成功")
        except Exception as e:
            self.record_message(f"保存Cookie失败: {e}")

    # 从cookie字符串中提取token,只提取“_m_h5_tk=”后面的内容
    def extract_token(self):
        cookie = self.cookie_var.get().strip()
        if not cookie:
            self.record_message("Cookie不能为空")
            return None

        try:
            # 查找“_m_h5_tk”在cookie中的位置
            if "_m_h5_tk=" not in cookie:
                self.record_message("Cookie中缺少_m_h5_tk值")
                return None
            start_idx = cookie.find("_m_h5_tk=") + len("_m_h5_tk=")
            end_idx = cookie.find(";", start_idx)

            # 判断如果返回“-1”则表示未找到任何值
            if end_idx == -1:
                end_idx = len(cookie)

            # 获取字符串中“_m_h5_tk=”与其后面“;”之间的数据
            m_h5_tk_value = cookie[start_idx:end_idx]
            token = m_h5_tk_value.split('_')[0]
            return token
        except Exception as e:
            self.record_message(f"提取Token失败: {e}")
            return None

    # 验证用户输入的内容
    def validate_inputs(self):
        # 验证Cookie
        self.cookie = self.cookie_var.get().strip()
        if not self.cookie:
            messagebox.showwarning("警告", "Cookie不能为空")
            return False

        # 提取token
        self.token = self.extract_token()
        if not self.token:
            return False

        # 验证关键词
        keyword = self.keyword_var.get().strip()
        if not keyword:
            messagebox.showwarning("警告", "关键词不能为空")
            return False

        # 验证页数
        try:
            pages = int(self.page_var.get())
            if pages <= 0:
                messagebox.showwarning("警告", "页数必须是正整数")
                return False
        except ValueError:
            messagebox.showwarning("警告", "页数必须是数字")
            return False

        # 验证线程数
        try:
            threads = int(self.thread_var.get())
            if threads <= 0 or threads > MAX_THREADS:
                messagebox.showwarning("警告", f"线程数必须在1-{MAX_THREADS}之间")
                return False
        except ValueError:
            messagebox.showwarning("警告", "线程数必须是数字")
            return False

        return True

    # 开始爬取
    def start_crawling(self):
        if self.is_running:
            return

        if not self.validate_inputs():
            return

        # 更新界面状态
        self.progress_var.set(0)
        self.is_running = True
        self.start_button.config(state=tk.DISABLED)
        self.stop_button.config(state=tk.NORMAL)
        self.status_var.set("数据爬取中...")

        # 清空Treeview
        for item in self.tree.get_children():
            self.tree.delete(item)

        # 获取参数
        keyword = self.keyword_var.get().strip()
        pages = int(self.page_var.get())
        threads = int(self.thread_var.get())

        # 创建任务队列
        self.work_queue = queue.Queue()
        for page in range(1, pages + 1):
            self.work_queue.put(page)

        # 创建结果列表
        self.results = []
        self.failed_pages = []

        # 创建并启动工作线程
        self.workers = []
        for i in range(threads):
            worker = threading.Thread(target=self.thread_task, args=(keyword,))
            worker.daemon = True
            worker.start()
            self.workers.append(worker)
            self.record_message(f"启动工作线程 #{i + 1}")

        # 启动监视线程
        threading.Thread(target=self.monitor_workers).start()

    # 工作线程任务
    def thread_task(self, keyword):
        # 获取当前处理进度,初始化为0
        current_product = 0
        # 设置进度条最大进度为100%
        max_progress = 100.0
        while not self.work_queue.empty() and self.is_running:
            try:
                page = self.work_queue.get_nowait()
                self.record_message(f"线程 {threading.current_thread().name} 开始爬取第 {page} 页")

                # 发送请求
                products = self.fetch_products(keyword, page)

                if products is None:
                    self.failed_pages.append(page)
                    self.record_message(f"第 {page} 页爬取失败")
                else:
                    # 解析商品
                    for product in products:
                        parsed = self.parse_product(product)
                        if parsed:
                            self.results.append(parsed)
                            current_product += 1
                            # 计算当前进度
                            progress = (current_product / len(products)) * max_progress
                            # 更新进度条变量
                            self.progress_var.set(progress)
                            # 更新进度条显示
                            self.progress_bar.update()

                    self.record_message(f"第 {page} 页完成, 获取 {len(products)} 条商品")

                # 任务完成
                self.work_queue.task_done()

                # 请求间隔
                time.sleep(INTERVAL_TIME)

            except queue.Empty:
                break
            except Exception as e:
                self.record_message(f"线程错误: {str(e)}")

    # 对工作线程状态进行监视
    def monitor_workers(self):
        while any(worker.is_alive() for worker in self.workers):
            time.sleep(0.5)

        # 所有线程完成后
        self.root.after(0, self.finish_crawling)

    # 处理爬取完成后的数据
    def finish_crawling(self):
        self.is_running = False

        # 保存结果
        if self.results:
            keyword = self.keyword_var.get().strip()
            self.save_results(keyword)
            self.record_message(f"爬取完成! 共获取 {len(self.results)} 条商品数据")
        else:
            self.record_message("未获取到任何商品数据")

        # 爬取失败页提示
        if self.failed_pages:
            self.record_message(f"以下页爬取失败: {', '.join(map(str, self.failed_pages))}")

        # 更新界面状态
        self.start_button.config(state=tk.NORMAL)
        self.stop_button.config(state=tk.DISABLED)
        self.status_var.set("就绪")

    # 停止爬取
    def stop_crawling(self):
        self.is_running = False
        self.record_message("正在停止爬取...")
        self.status_var.set("正在停止...")

    # 获取商品数据
    def fetch_products(self, keyword, page):
        try:
            # 生成签名和请求参数
            sign, current_time, request_data = self.generate_sign(page, keyword)

            # 构建请求头
            headers = {
                "cookie": self.cookie,
                "origin": "https://www.goofish.com",
                "referer": "https://www.goofish.com/",
                "user-agent": USER_AGENT
            }

            # 构建请求参数
            params = {
                "jsv": "2.7.2",
                "appKey": REQUEST_KEY,
                "t": current_time,
                "sign": sign,
                "v": "1.0",
                "type": "originaljson",
                "accountSite": "xianyu",
                "dataType": "json",
                "timeout": "20000",
                "api": "mtop.taobao.idlemtopsearch.pc.search",
                "sessionOption": "AutoLoginOnly",
                "spm_cnt": "a21ybx.search.0.0",
                "spm_pre": "a21ybx.home.searchSuggest.1.4c053da64Wswaf",
                "log_id": "4c053da64Wswaf"
            }

            # 发送POST请求
            response = requests.post(
                url=REQUEST_API,
                headers=headers,
                params=params,
                data={"data": request_data},
                timeout=15
            )

            # 检查响应状态
            response.raise_for_status()

            # 检查是否Token失效
            result = response.json()
            if "ret" in result and "FAIL_SYS_TOKEN_EXOIRED" in result["ret"][0]:
                self.record_message("Token已过期,请更新Cookie")
                self.root.after(0, self.handle_token_expired)
                return None

            # 检查返回数据是否包含商品列表
            if "data" in result and "resultList" in result["data"]:
                return result["data"]["resultList"]
            else:
                self.record_message(f"第{page}页数据格式异常")
                return None

        except requests.exceptions.RequestException as e:
            self.record_message(f"第{page}页请求失败: {str(e)}")
            return None
        except Exception as e:
            self.record_message(f"第{page}页数据处理错误: {str(e)}")
            return None

    # 处理Token过期
    def handle_token_expired(self):
        self.stop_crawling()
        messagebox.showwarning("Cookie失效", "您的Cookie已过期,请更新Cookie后重试")

    # 生成签名
    def generate_sign(self, page, keyword):
        # 生成当前时间戳(毫秒级)
        current_time = int(time.time() * 1000)

        # 构建请求数据
        request_data = (
            f'{{"pageNumber":{page},"keyword":"{keyword}","fromFilter":false,'
            f'"rowsPerPage":30,"sortValue":"","sortField":"","customDistance":"",'
            f'"gps":"","propValueStr":"","customGps":"","searchReqFromPage":"pcSearch",'
            f'"extraFilterValue":"","userPositionJson":""}}'
        )

        # 构建签名原始字符串
        original_data = f"{self.token}&{current_time}&{REQUEST_KEY}&{request_data}"

        # 计算MD5签名
        md5 = hashlib.md5()
        md5.update(original_data.encode("utf-8"))
        sign = md5.hexdigest()

        return sign, current_time, request_data

    # 解析商品数据(包含图片和URL提取)
    def parse_product(self, product):
        try:
            # 从原始数据中提取核心字段
            item_data = product["data"]["item"]["main"]["exContent"]
            click_params = product["data"]["item"]["main"]["clickParam"]["args"]

            # 提取图片URL
            pic_url = item_data.get("picUrl", "")
            if not pic_url:
                pic_url = click_params.get("picUrl", "无图片链接")

            # 提取卖家信息
            seller = item_data.get("userNickName", "未知用户").strip()

            # 提取标题和包邮信息
            title = item_data.get("title", "").strip()
            tag_text = click_params.get("tagname", "不包邮")

            # 商品类型
            item_type = click_params.get("item_type", "未知类型")

            # 提取商品ID和链接
            item_id = item_data.get("itemId", "")
            product_url = f"https://www.goofish.com/item?id={item_id}"

            # 提取价格
            price = click_params.get("price", "未知价格")

            # 提取地区
            area = item_data.get("area", "未知地区").strip()

            return {
                "seller": seller,
                "title": title,
                "url": product_url,
                "price": price,
                "area": area,
                "pic_url": pic_url,
                "item_id": item_id,
                "tag_text": tag_text, 
                "item_type": item_type, 
            }

        except Exception as e:
            self.record_message(f"商品数据解析异常: {str(e)}")
            return None

    # 下载已爬取的图片到本地,强制使用jpg格式
    def download_image(self, pic_url, item_id):
        try:
            # 跳过无图片链接的情况
            if pic_url == "无图片链接":
                return None

            # 处理URL中的特殊字符,补全协议头
            if not pic_url.startswith(('http://', 'https://')):
                pic_url = f"http:{pic_url}" if pic_url.startswith('//') else f"https://{pic_url}"

            # 提取并验证文件后缀
            file_ext = pic_url.split(".")[-1].split("?")[0].lower()

            # 处理不支持的格式(如.mpo)
            if file_ext not in IMAGE_TYPES:
                self.record_message(f"检测到不支持的图片格式: {file_ext},将自动转换为jpg")
                file_ext = "jpg"  # 强制使用支持的格式

            # 图片文件名,用item_id避免重复
            file_name = f"{IMAGES_SAVE_FOLDER}/{item_id}.{file_ext}"

            # 已下载则直接返回路径
            if os.path.exists(file_name):
                return file_name

            # 发送请求下载图片
            headers = {"User-Agent": USER_AGENT}
            response = requests.get(pic_url, headers=headers, timeout=10)
            response.raise_for_status()

            # 保存图片到本地
            with open(file_name, "wb") as f:
                f.write(response.content)

            # 强制转换图片格式为jpg
            if file_ext == "jpg" and pic_url.lower().endswith(('mpo', 'mpo?')):
                try:
                    from PIL import Image as PILImage
                    # 为了兼容jpg格式,打开图片后转换为RGB模式
                    load_image = PILImage.open(file_name)
                    rgb_load_image = load_image.convert('RGB')
                    # 覆盖保存为jpg
                    rgb_load_image.save(file_name, quality=95)
                    self.record_message(f"特殊图片格式已成功转换为jpg: {item_id}.jpg")
                except Exception as e:
                    self.record_message(f"图片格式转换失败: {str(e)},使用原始文件")

            return file_name

        except Exception as e:
            self.record_message(f"图片下载失败({pic_url}): {str(e)}")
            return None

    # 在Treeview中添加一条记录
    def add_to_treeview(self, item_id, title, price, seller, tagname):
        self.tree.insert("", tk.END, values=(item_id, title, price, seller, tagname))

    # 保存结果到Excel,同时插入图片数据
    def save_results(self, keyword):
        try:
            # 创建Excel工作簿和工作表
            wb = Workbook()
            ws = wb.active
            # 添加表头(包含图片列)
            ws.append(["商品ID", "标题", "价格", "商品链接", "地区", "标签名", "卖家", "商品类型", "图片"])

            # 调整列宽
            ws.column_dimensions["A"].width = 13  # 商品ID
            ws.column_dimensions["B"].width = 30  # 标题
            ws.column_dimensions["C"].width = 10  # 价格
            ws.column_dimensions["D"].width = 15  # 商品链接
            ws.column_dimensions["E"].width = 10  # 地区
            ws.column_dimensions["F"].width = 10  # 标签名
            ws.column_dimensions["G"].width = 10  # 卖家
            ws.column_dimensions["H"].width = 10  # 商品类型
            ws.column_dimensions["I"].width = 20  # 图片

            # 向Excel中写入数据,从第2行开始写入,跳过第1行表头
            for row_idx, data in enumerate(self.results, start=2):  # 
                # 写入文字信息
                ws.cell(row=row_idx, column=1, value=data["item_id"])  # 商品ID
                ws.cell(row=row_idx, column=2, value=data["title"])  # 标题
                ws.cell(row=row_idx, column=3, value=data["price"]) # 价格
                ws.cell(row=row_idx, column=4, value=data["url"])  # 商品链接
                ws.cell(row=row_idx, column=5, value=data["area"])  # 地区
                ws.cell(row=row_idx, column=6, value=data["tag_text"])  # 标签名
                ws.cell(row=row_idx, column=7, value=data["seller"])  # 卖家
                ws.cell(row=row_idx, column=8, value=data["item_type"])  # 商品类型

                # 下载并插入图片
                pic_path = self.download_image(data["pic_url"], data["item_id"])
                if pic_path and os.path.exists(pic_path):
                    try:
                        # 插入图片
                        load_image = Image(pic_path)
                        # 调整图片大小
                        load_image.width = 100
                        load_image.height = 100
                        # 插入到I列当前行
                        ws.add_image(load_image, anchor=f"I{row_idx}")
                        # 调整行高以适应图片
                        ws.row_dimensions[row_idx].height = 80
                    except Exception as e:
                        self.record_message(f"图片插入失败({pic_path}): {str(e)}")

                # 更新界面
                self.add_to_treeview(data["item_id"], data["title"], data["price"], data["seller"], data["tag_text"])

            # 生成文件名
            current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
            filename = f"{keyword}_{current_time}.xlsx"
            wb.save(filename)
            self.record_message(f"数据已保存到 {filename}(含图片)")

            # 提示成功
            messagebox.showinfo("爬取成功", f"共获取 {len(self.results)} 条商品数据")

        except Exception as e:
            self.record_message(f"保存Excel文件失败: {str(e)}")

    # 点击关闭窗口触发函数
    def GUI_window_close(self):
        # 弹出确认对话框
        if messagebox.askokcancel("退出", "你确定要退出吗?"):
            # 点击确定后关闭窗口
            self.root.destroy()

    # 打开原网站
    def open_website(self):
        webbrowser.open("https://www.goofish.com")

# 当前模块直接被执行
if __name__ == "__main__":
    # 创建GUI窗口
    root = tk.Tk()
    app = CollectProductsSpider(root)
    root.mainloop()