本文将介绍如何使用 Python 和 Tkinter 创建一个闲鱼商品爬虫的图形用户界面(GUI)应用。这个爬虫不仅能够抓取闲鱼网站的商品信息,还能展示商品的图片、价格、发布时间等数据,并且支持图片预览、数据导出等功能。为了实现这些功能,我们将结合使用以下技术和库:
下面是程序的完整代码:
-
Tkinter:Python的标准GUI库,用于创建用户界面。
-
DrissionPage:用于驱动浏览器进行爬取。
-
loguru:简洁易用的日志库,方便记录日志。
-
Pillow (PIL):用于处理和显示图片。
-
requests:用于发送HTTP请求下载商品图片。
-
concurrent.futures:用于管理并发任务,实现图片下载的异步处理。
-
1. 环境准备
首先,确保你已经安装了以下Python库:
bash
复制编辑
pip install requests Pillow loguru drission
-
2. 项目结构
项目的主要功能分为几个部分:
-
GUI部分:使用Tkinter构建用户界面。
-
爬虫部分:利用DrissionPage库爬取闲鱼商品的数据。
-
数据处理部分:将爬取的数据保存并导出为CSV文件。
-
完整代码:
-
根据代码: import time import tkinter as tk from tkinter import ttk, messagebox, filedialog from DrissionPage import ChromiumPage from loguru import logger as log from datetime import datetime from PIL import Image, ImageTk import requests from io import BytesIO import threading import csv import os from concurrent.futures import ThreadPoolExecutor class GoofishSpiderGUI: def **init**(self, root): self.root = root self.root.title("闲鱼商品爬虫") self.root.geometry("1000x600") ``` # 初始化浏览器实例 self.page = ChromiumPage() # 图片缓存字典 self.image_cache = {} # 创建界面组件 self.create_widgets() # 设置样式 style = ttk.Style() style.configure("Treeview", rowheight=80) # 调整行高适应图片 style.configure("Treeview.Heading", font=('Arial', 10, 'bold')) # 存储采集的数据 self.items_data = [] # 图片下载目录 self.image_dir = "downloaded_images" if not os.path.exists(self.image_dir): os.makedirs(self.image_dir) # 创建线程池 self.executor = ThreadPoolExecutor(max_workers=5) def create_widgets(self): # 顶部搜索框区域 search_frame = tk.Frame(self.root, bg="#f0f0f0", padx=10, pady=10) search_frame.pack(fill=tk.X) tk.Label(search_frame, text="搜索关键词:", bg="#f0f0f0").pack(side=tk.LEFT, padx=5) self.keyword_entry = tk.Entry(search_frame, width=40, font=('Arial', 12)) self.keyword_entry.pack(side=tk.LEFT, padx=5) self.search_btn = tk.Button(search_frame, text="开始搜索", command=self.start_spider, bg="#4CAF50", fg="white", font=('Arial', 10, 'bold')) self.search_btn.pack(side=tk.LEFT, padx=5) # 添加"发起对话"按钮到顶部 self.dialogue_btn = tk.Button(search_frame, text="发起对话", command=self.start_dialogue_with_selected, bg="#2196F3", fg="white", font=('Arial', 10, 'bold')) self.dialogue_btn.pack(side=tk.LEFT, padx=5) self.dialogue_btn.config(state=tk.DISABLED) # 初始不可用 self.login_btn = tk.Button(search_frame, text="扫码登录", command=self.login, bg="#FF9800", fg="white", font=('Arial', 10, 'bold')) self.login_btn.pack(side=tk.RIGHT, padx=5) # 添加导出按钮 self.export_btn = tk.Button(search_frame, text="导出数据", command=self.export_data, bg="#795548", fg="white", font=('Arial', 10, 'bold')) self.export_btn.pack(side=tk.RIGHT, padx=5) # 添加预览图片按钮 self.preview_btn = tk.Button(search_frame, text="预览图片", command=self.preview_image, bg="#9C27B0", fg="white", font=('Arial', 10, 'bold')) self.preview_btn.pack(side=tk.LEFT, padx=5) self.preview_btn.config(state=tk.DISABLED) # 中间表格区域 self.tree_frame = tk.Frame(self.root) self.tree_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) # 创建表格 self.create_table() # 底部状态栏 self.status_var = tk.StringVar() self.status_var.set("就绪") status_bar = tk.Label(self.root, textvariable=self.status_var, bd=1, relief=tk.SUNKEN, anchor=tk.W, bg="#e0e0e0", font=('Arial', 10)) status_bar.pack(fill=tk.X, padx=5, pady=5) def create_table(self): # 创建Treeview表格 self.tree = ttk.Treeview(self.tree_frame, columns=('title', 'price', 'publish_time', 'seller_id', 'image_url'), show='headings') # 配置首图列(#0列) self.tree.heading('#0', text='首图', anchor=tk.CENTER) self.tree.column('#0', width=160, anchor=tk.CENTER) # 其他列配置 columns = { 'title': ('商品标题', 300), 'price': ('价格', 100), 'publish_time': ('发布时间', 150), 'seller_id': ('卖家ID', 100), 'image_url': ('图片链接', 0) # 隐藏列,用于存储图片链接 } for col, (text, width) in columns.items(): self.tree.heading(col, text=text) self.tree.column(col, width=width, anchor=tk.CENTER) self.tree.column('image_url', width=0, stretch=tk.NO) # 隐藏图片链接列 # 添加垂直滚动条 vsb = ttk.Scrollbar(self.tree_frame, orient="vertical", command=self.tree.yview) vsb.pack(side=tk.RIGHT, fill=tk.Y) self.tree.configure(yscrollcommand=vsb.set) self.tree.pack(fill=tk.BOTH, expand=True) # 绑定选择事件 self.tree.bind('<<TreeviewSelect>>', self.on_tree_select) def on_tree_select(self, event): # 当选择行时启用"发起对话"和"预览图片"按钮 if self.tree.selection(): self.dialogue_btn.config(state=tk.NORMAL) self.preview_btn.config(state=tk.NORMAL) else: self.dialogue_btn.config(state=tk.DISABLED) self.preview_btn.config(state=tk.DISABLED) def login(self): try: self.page.get('https://www.goofish.com/') messagebox.showinfo("提示", "请扫码登录,登录完成后点击确定继续...") self.status_var.set("登录成功") except Exception as e: log.error(f"登录失败: {e}") messagebox.showerror("错误", f"登录失败: {e}") def timestamp_to_readable(self, timestamp): try: timestamp_seconds = int(timestamp) / 1000 dt = datetime.fromtimestamp(timestamp_seconds) return dt.strftime("%Y-%m-%d %H:%M:%S") except: return "未知时间" def update_info(self, item_id, row_id): try: tab = self.page.new_tab() tab.listen.start('mtop.taobao.idle.pc.detail') tab.get(f'https://www.goofish.com/item?&id={item_id}') res = tab.listen.wait() data = res.response.body sellerId = data['data']['itemDO']['trackParams']['sellerId'] dialogue_link = f'https://www.goofish.com/im?&itemId={item_id}&peerUserId={sellerId}' tab.close() # 更新表格中的卖家ID self.tree.set(row_id, 'seller_id', sellerId) # 更新内存中的数据 idx = int(row_id) self.items_data[idx]['seller_id'] = sellerId self.items_data[idx]['dialogue_link'] = dialogue_link return dialogue_link except Exception as e: log.error(f"获取卖家信息失败: {e}") messagebox.showerror("错误", f"获取卖家信息失败: {e}") return None def start_dialogue_with_selected(self): # 获取当前选中的行 selected_items = self.tree.selection() if not selected_items: messagebox.showwarning("警告", "请先选择一条商品记录") return row_id = selected_items[0] idx = int(row_id) item = self.items_data[idx] # 如果没有卖家ID,先获取 if not item.get('seller_id'): dialogue_link = self.update_info(item['item_id'], row_id) if not dialogue_link: return else: dialogue_link = item['dialogue_link'] # 在新标签页打开对话链接 try: tab = self.page.new_tab() tab.get(dialogue_link) self.status_var.set(f"已打开与卖家 {item['seller_id']} 的对话") except Exception as e: log.error(f"打开对话失败: {e}") messagebox.showerror("错误", f"打开对话失败: {e}") def download_image(self, url, row_id): """增强版图片下载方法""" try: headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Referer': 'https://www.goofish.com/' } response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() # 检查HTTP错误 # 验证确实是图片 if not response.headers.get('Content-Type', '').startswith('image/'): raise ValueError("响应不是图片类型") img_data = BytesIO(response.content) # 尝试多种方式打开图片 try: img = Image.open(img_data) except Exception as e: # 尝试修复可能的JPEG文件头问题 img_data.seek(0) if not img_data.read(2) == b'\xff\xd8': # JPEG文件头 img_data.seek(0) fixed_data = b'\xff\xd8' + img_data.read() # 添加JPEG文件头 img = Image.open(BytesIO(fixed_data)) else: img_data.seek(0) img = Image.open(img_data) img.thumbnail((120, 120)) photo = ImageTk.PhotoImage(img) self.root.after(0, lambda: self.update_tree_image(row_id, photo)) except Exception as e: log.error(f"图片处理失败: {url} - {e}") # 提供默认占位图 default_img = Image.new('RGB', (120, 120), color=(240, 240, 240)) photo = ImageTk.PhotoImage(default_img) self.root.after(0, lambda: self.update_tree_image(row_id, photo)) def update_tree_image(self, row_id, photo): """更新Treeview中的图片显示""" self.image_cache[row_id] = photo # 缓存防止回收 self.tree.item(row_id, image=photo) def start_spider(self): keyword = self.keyword_entry.get().strip() if not keyword: messagebox.showwarning("警告", "请输入搜索关键词") return self.status_var.set(f"正在搜索: {keyword}...") self.root.update() try: # 清空表格 for i in self.tree.get_children(): self.tree.delete(i) self.items_data = [] # 执行搜索 self.page.get(f'https://www.goofish.com/search?&q={keyword}') time.sleep(1) self.page.listen.start('') # 点击"最新发布"筛选 self.page.run_js(''' document.querySelector("#content > div.search-container--eigqxPi6 > div.search-filter-up-container--IKSFALsr > div.search-filter-select-container--aC4t18zS > div:nth-child(3) > div.search-select-items-container--pWk5bY4P > div:nth-child(1)").click() ''') # 获取数据 res = self.page.listen.wait() data = res.response.body items = data['data']['resultList'] # 解析并显示数据 for idx, item in enumerate(items): try: item_data = { 'title': item['data']['item']['main']['exContent']['title'], 'price': item['data']['item']['main']['exContent']['detailParams']['soldPrice'], 'publish_time': self.timestamp_to_readable(item['data']['item']['main']['clickParam']['args']['publishTime']), 'avatar_url': item['data']['item']['main']['exContent']['picUrl'], 'item_id': item['data']['item']['main']['clickParam']['args']['item_id'], 'seller_id': '', 'dialogue_link': '' } # 添加到内存数据 self.items_data.append(item_data) # 生成Treeview的行ID row_id = str(idx) # 插入带空图片的行 self.tree.insert('', 'end', iid=row_id, values=( item_data['title'], item_data['price'], item_data['publish_time'], item_data['seller_id'], item_data['avatar_url'] # 存储图片链接 )) # 启动图片下载线程 if item_data['avatar_url']: self.executor.submit(self.download_image, item_data['avatar_url'], row_id) except Exception as e: log.error(f"解析商品数据失败: {e}") continue self.status_var.set(f"搜索完成,共找到 {len(self.items_data)} 条结果") except Exception as e: log.error(f"搜索失败: {e}") messagebox.showerror("错误", f"搜索失败: {e}") self.status_var.set("搜索失败") def export_data(self): """导出数据到CSV文件""" if not self.items_data: messagebox.showwarning("警告", "没有数据可导出") return # 打开文件对话框,让用户选择保存路径 filepath = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV Files", "*.csv"), ("All Files", "*.*")]) if not filepath: return # 用户取消了保存 try: with open(filepath, 'w', newline='', encoding='utf-8') as csvfile: # 定义CSV写入器 writer = csv.writer(csvfile) # 写入表头 header = ['商品标题', '价格', '发布时间', '卖家ID', '图片链接'] writer.writerow(header) # 写入数据 for item in self.items_data: row = [item['title'], item['price'], item['publish_time'], item['seller_id'], item['avatar_url']] writer.writerow(row) self.status_var.set(f"数据已导出到: {filepath}") messagebox.showinfo("提示", f"数据已成功导出到: {filepath}") except Exception as e: log.error(f"导出数据失败: {e}") messagebox.showerror("错误", f"导出数据失败: {e}") self.status_var.set("导出失败") def preview_image(self): """预览选定商品的首图""" selected_items = self.tree.selection() if not selected_items: messagebox.showwarning("警告", "请先选择一条商品记录") return row_id = selected_items[0] idx = int(row_id) item = self.items_data[idx] image_url = item['avatar_url'] # 创建新窗口 preview_window = tk.Toplevel(self.root) preview_window.title("图片预览") # 创建标签用于显示图片 image_label = tk.Label(preview_window) image_label.pack(padx=10, pady=10) # 下载并显示图片 try: headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Referer': 'https://www.goofish.com/' } response = requests.get(image_url, headers=headers, timeout=10) response.raise_for_status() img_data = BytesIO(response.content) img = Image.open(img_data) # 调整图片大小以适应窗口 max_size = (500, 500) img.thumbnail(max_size) photo = ImageTk.PhotoImage(img) image_label.config(image=photo) image_label.image = photo # 保持引用 except Exception as e: log.error(f"预览图片失败: {e}") messagebox.showerror("错误", f"预览图片失败: {e}") image_label.config(text="无法加载图片") def on_closing(self): """关闭窗口时关闭线程池""" if messagebox.askokcancel("退出", "确定要退出吗?"): self.executor.shutdown(wait=False) # 不等待任务完成 self.root.destroy() ``` if **name** == '**main**': root = tk.Tk() app = GoofishSpiderGUI(root) root.protocol("WM\_DELETE\_WINDOW", app.on\_closing) # 绑定窗口关闭事件 root.mainloop()
3. 代码解析
3.1 GUI部分:Tkinter窗口
程序的图形界面部分使用了Tkinter库。我们创建了一个主窗口,其中包含了以下组件:
-
搜索框:允许用户输入搜索关键词来查找商品。
-
按钮:包括“开始搜索”、“发起对话”、“扫码登录”、“导出数据”和“预览图片”等按钮,用户可以通过点击这些按钮进行操作。
-
表格(Treeview):显示爬取到的商品数据,包括商品标题、价格、发布时间、卖家ID等信息。
-
状态栏:显示当前程序的状态,如“就绪”或“正在搜索”等。
-
3.2 浏览器控制:DrissionPage
为了获取闲鱼商品信息,我们使用了DrissionPage库,它是一个结合了Selenium和requests的轻量级爬虫框架,提供了便捷的浏览器控制功能。
我们通过以下代码初始化浏览器实例,并打开闲鱼搜索页面:
python
复制编辑
self.page = ChromiumPage() self.page.get(f'https://www.goofish.com/search?&q={keyword}')
-
3.3 数据解析与展示
爬取到的商品数据通过DrissionPage解析,获取的商品信息包括商品标题、价格、发布时间、图片链接等。我们将这些数据保存到一个字典中,并展示在Treeview表格中。
每当爬取到商品数据时,我们会调用
tree.insert()
方法将商品数据插入到表格中:python
复制编辑
self.tree.insert('', 'end', iid=row_id, values=(item_data['title'], item_data['price'], item_data['pub
-
3.4 图片下载与显示
为了能够显示商品的图片,我们在后台启动了一个线程池来处理图片下载任务。使用
requests
库发送HTTP请求获取图片,并使用Pillow库对图片进行处理,最终显示在Treeview中。python
复制编辑
def download_image(self, url, row_id): response = requests.get(url) img = Image.open(BytesIO(response.content)) img.thumbnail((120, 120)) photo = ImageTk.PhotoImage(img) self.root.after(0, lambda: self.update_tree_image(row_id, photo))
-
3.5 对话功能
为了增加爬虫的实用性,我们还提供了“发起对话”功能。当用户点击某个商品行时,程序会自动获取卖家ID并生成与卖家对话的链接。点击“发起对话”按钮时,会在新标签页中打开该链接,让用户直接与卖家进行交流。
python
复制编辑
dialogue_link = f'https://www.goofish.com/im?&itemId={item_id}&peerUserId={sellerId}'
-
3.6 数据导出
爬取到的商品数据可以通过点击“导出数据”按钮导出为CSV文件。程序会通过
csv.writer()
将数据写入CSV文件中。python
复制编辑
with open(filepath, 'w', newline='', encoding='utf-8') as csvfile: writer = csv.writer(csvfile) header = ['商品标题', '价格', '发布时间', '卖家ID', '图片链接'] writer.writerow(header) for item in self.items_data: writer.writerow([item['title'], item['price'], item['publish_time'], item['seller_id'], item['avatar_url']])
3.7 多线程支持
为了提高程序的效率,我们使用了线程池来并发处理图片下载任务。通过
concurrent.futures.ThreadPoolExecutor
类,我们可以将图片下载任务提交给线程池处理,从而避免阻塞主线程。python
复制编辑
self.executor = ThreadPoolExecutor(max_workers=5)
3.8 关闭程序时释放资源
为了确保程序正常退出,我们在窗口关闭时调用
executor.shutdown()
来释放线程池资源,确保所有任务得以正确终止。python
复制编辑
def on_closing(self): if messagebox.askokcancel("退出", "确定要退出吗?"): self.executor.shutdown(wait=False) self.root.destroy()
4. 运行效果
运行程序后,用户可以在GUI中输入商品关键词并点击“开始搜索”按钮,程序会自动搜索闲鱼并展示相关商品的信息。用户还可以预览商品图片、发起与卖家的对话,并导出商品数据。