文章目录
-
- 一、图片转PDF的三种方案对比
-
- [1.1 Pillow直接save:最简单的方案](#1.1 Pillow直接save:最简单的方案)
- [1.2 为什么不用reportlab和fpdf2](#1.2 为什么不用reportlab和fpdf2)
- [1.3 无边距铺满的实现原理](#1.3 无边距铺满的实现原理)
- 二、带UI的桌面工具:tkinter实现的完整GUI
-
- [2.1 界面布局设计](#2.1 界面布局设计)
- [2.2 多线程防止界面卡死](#2.2 多线程防止界面卡死)
- [2.3 "一键"模式的轮询实现](#2.3 "一键"模式的轮询实现)
- 三、命令行模式:不需要GUI的轻量方案
-
- [3.1 交互式命令行界面](#3.1 交互式命令行界面)
- [3.2 启动模式判断](#3.2 启动模式判断)
- 四、完整源码:一个文件搞定全部功能
-
- [4.1 文件结构和依赖说明](#4.1 文件结构和依赖说明)
- [4.2 完整源码](#4.2 完整源码)
- 五、调试全过程回顾与踩坑总结
-
- [5.1 从第一行代码到最终成品的完整时间线](#5.1 从第一行代码到最终成品的完整时间线)
- [5.2 踩坑速查表](#5.2 踩坑速查表)
上篇讲了古筝网的页面结构分析、网络请求层的三个大坑(SSL代理冲突、图片尺寸过滤、不同年代URL格式差异),以及完整的爬虫代码。这篇我们进入 PDF生成和GUI界面 的部分,把整套工具串起来做成一个开箱即用的桌面小工具。
一、图片转PDF的三种方案对比
1.1 Pillow直接save:最简单的方案
把多张图片合并成一个PDF,最简单的方法就是用Pillow的Image.save()方法,传入save_all=True和append_images参数:
python
from PIL import Image
def images_to_pdf(images, output_path, resolution=150):
if not images:
raise ValueError("图片列表为空,无法生成PDF")
first = images[0]
rest = images[1:] if len(images) > 1 else []
first.save(
output_path, "PDF",
save_all=True,
append_images=rest,
resolution=resolution
)
这个方案的好处是零额外依赖------Pillow本身就支持PDF输出,不需要装reportlab、fpdf2、img2pdf等第三方库。对于我们的场景(纯图片合成PDF,不需要添加文字水印或表格),Pillow完全够用。
resolution参数控制DPI,默认150。DPI越高,PDF中的图片显示越清晰,但文件也越大。150DPI适合屏幕阅读,300DPI适合打印。定风波五线谱的原始图片已经是2480×3508像素(约等于300DPI的A4),用150DPI保存反而会压缩分辨率,所以遇到高清图片时可以手动调到300。
有一个容易忽略的点:Pillow的PDF输出要求所有图片是RGB模式。如果抓到的图片是RGBA(带透明通道)或P模式(调色板),直接save会报错。在下载阶段就需要统一转换:
python
if img.mode != "RGB":
img = img.convert("RGB")
1.2 为什么不用reportlab和fpdf2
最初我尝试安装reportlab和fpdf2来做PDF生成,但遇到了网络问题------这台机器的pip走代理时SSL握手失败,清华镜像也不稳定。最终检查系统已安装的库时发现pypdfium2和Pillow都在,Pillow自带PDF输出能力,完全不需要额外装任何东西。
这也引出一个实践建议:在受限网络环境下开发时,先检查系统已有库的能力范围,不要一上来就pip install。Pillow的PDF输出虽然不支持文字排版,但对于"图片铺满整页"这种需求来说已经是最优解。
1.3 无边距铺满的实现原理
用户要求"不留页边距直接铺满"。Pillow的Image.save("PDF")有一个很棒的特性:它会以图片的实际像素尺寸作为PDF页面尺寸,不会自动添加任何margin或padding。
这意味着,如果抓到的图片是728×1300像素,生成的PDF每一页就是728×1300点(pt),图片完全铺满整页,没有任何白边。这正是古筝谱PDF需要的排版效果------打印出来后可以直接装订,不用裁切。
二、带UI的桌面工具:tkinter实现的完整GUI
2.1 界面布局设计
用Python标准库tkinter搭建GUI界面,布局分为四个区域:标题栏、输入区、参数区、操作按钮、日志区。
标题栏显示工具名称和简介。输入区包含曲名搜索框(支持回车触发搜索)和搜索结果下拉列表。参数区可以调整最小宽度、最小高度、DPI、输出目录四个参数,默认值分别是600、800、150和桌面路径。
操作区有三个按钮:
"抓取图片"------先选中搜索结果中的一个条目,点击后开始下载该页面的图片,日志区实时显示进度。
"生成PDF"------把已抓取的图片列表合成PDF并保存到输出目录。
"一键抓取+生成PDF"------合并上面两步,自动完成从抓取到保存的全流程。
日志区用ScrolledText组件,设置了深色背景(#1e1e1e)和浅灰前景(#d4d4d4),模拟VSCode的终端风格,方便查看下载进度和报错信息。
2.2 多线程防止界面卡死
这是GUI开发中的一个常见问题:requests.get()是阻塞操作,如果直接在主线程中调用,界面会完全冻结,用户看不到任何进度更新直到下载完成。
解决方案是用threading.Thread把下载逻辑放到子线程中执行,主线程只负责UI更新。子线程通过root.after()方法安全地更新UI控件状态:
python
def _fetch_and_store(self, url):
if self._running:
return
self._running = True
self.fetch_btn.config(state="disabled")
self.pdf_btn.config(state="disabled")
self.all_btn.config(state="disabled")
def worker():
self._log(f"\n正在抓取:{url}")
try:
infos = self.fetcher.get_image_urls(
url, min_width=min_w, min_height=min_h
)
self._cached_images = self.fetcher.download_images(
infos,
callback=lambda i, n, u: self._log(
f" 下载 {i}/{n}: {u.split('/')[-1]}")
)
self._log(f"抓取完成,共 {len(self._cached_images)} 张有效图片")
except Exception as e:
self._log(f"抓取失败:{e}")
self._cached_images = []
self._running = False
self.root.after(0, lambda: (
self.fetch_btn.config(state="normal"),
self.pdf_btn.config(state="normal"),
self.all_btn.config(state="normal"),
))
threading.Thread(target=worker, daemon=True).start()
注意self._running标志位的作用------防止用户在下载过程中重复点击按钮。三个按钮在抓取开始时全部禁用,抓取完成后通过root.after(0, callback)恢复。daemon=True确保子线程在主窗口关闭时自动退出,不会造成僵尸进程。
2.3 "一键"模式的轮询实现
"一键抓取+生成PDF"按钮需要等抓取完成后自动触发PDF生成。由于抓取在子线程中执行,主线程无法直接用join()等待(会卡死UI)。我用了轮询 的方式:每200毫秒检查一次self._running标志位,变为False时说明抓取结束,立即触发_do_pdf():
python
def _do_all(self):
url = self._get_selected_url()
if not url or self._running:
return
self._fetch_and_store(url)
self._poll_fetch(self._do_pdf)
def _poll_fetch(self, callback):
if not self._running:
callback()
return
self.root.after(200, lambda: self._poll_fetch(callback))
root.after(200, ...)相当于JavaScript的setTimeout(fn, 200),不会阻塞UI线程。200ms的轮询间隔足够快,用户几乎感觉不到延迟。
三、命令行模式:不需要GUI的轻量方案
3.1 交互式命令行界面
不是所有场景都需要GUI------在服务器上批量跑、或者嵌入到其他脚本中时,命令行模式更实用。通过python guzheng_pdf_maker.py --cli启动命令行模式:
python
def cli_mode():
fetcher = GuzhengFetcher()
out_dir = os.path.join(os.path.expanduser("~"), "Desktop")
while True:
url = input("请输入曲谱URL(或曲名搜索关键词,或 q 退出):").strip()
if url.lower() in ("q", "quit", "exit"):
break
# 如果不是URL,当作搜索关键词
if not url.startswith("http"):
results = fetcher.search(url)
if not results:
print(f"未找到「{url}」相关曲谱")
continue
print(f"\n搜索到 {len(results)} 个结果:")
for i, (t, u, d) in enumerate(results, 1):
print(f" [{i}] {t}({d})")
choice = input("请选择编号:").strip()
idx = int(choice) - 1
url = results[idx][1]
infos = fetcher.get_image_urls(url)
images = fetcher.download_images(infos)
name = input(f"文件名(默认:曲谱):").strip() or "曲谱"
out_path = os.path.join(out_dir, f"{name}.pdf")
images_to_pdf(images, out_path)
print(f"生成成功:{out_path}({len(images)}页)")
命令行模式支持两种输入方式:直接粘贴URL,或者输入曲名关键词自动搜索。输入曲名后会列出搜索结果让用户选择编号,然后自动抓取并生成PDF到桌面。整个流程不需要打开任何窗口,适合快速批量处理。
3.2 启动模式判断
在脚本入口处通过命令行参数判断启动哪种模式:
python
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--cli":
cli_mode()
else:
root = Tk()
app = GuzhengPDFApp(root)
root.mainloop()
双击运行默认启动GUI,加--cli参数启动命令行交互模式。
四、完整源码:一个文件搞定全部功能
4.1 文件结构和依赖说明
整个工具就一个Python文件guzheng_pdf_maker.py,大约560行代码。依赖只有两个:requests(网络请求)和Pillow(图片处理和PDF生成)。tkinter是Python标准库自带的不用装。
guzheng_pdf_maker.py
├── GuzhengFetcher # 网络请求层(搜索、提取URL、下载图片)
├── images_to_pdf() # PDF生成函数
├── GuzhengPDFApp # GUI界面类
├── cli_mode() # 命令行交互模式
└── 入口判断 # 根据参数选择GUI或CLI模式
安装依赖:
bash
pip install requests Pillow
4.2 完整源码
python
"""
古筝曲谱PDF生成器 v3.0 - 支持专题页自动提取子曲谱
从中国古筝网(guzheng.cn)自动抓取曲谱图片并生成无边距PDF
"""
import os
import io
import re
import sys
import threading
import urllib3
import requests
from PIL import Image
from tkinter import (
Tk, Frame, Label, Entry, Button, Text, StringVar,
filedialog, messagebox, ttk, scrolledtext, END
)
urllib3.disable_warnings()
class GuzhengFetcher:
"""中国古筝网曲谱抓取器"""
BASE_URL = "https://www.guzheng.cn"
def __init__(self):
self.session = requests.Session()
self.session.trust_env = False
for k in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy",
"https_proxy", "ALL_PROXY", "all_proxy"]:
os.environ.pop(k, None)
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/124.0 Safari/537.36",
"Referer": "https://www.guzheng.cn/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
}
def search(self, keyword):
"""搜索曲谱,返回 [(曲名, URL, 类型描述), ...]"""
encoded_keyword = requests.utils.quote(keyword.encode('utf-8'))
url = f"{self.BASE_URL}/search/?q={encoded_keyword}&typeid=pu&b=qupu"
try:
r = self.session.get(url, headers=self.headers,
timeout=15, verify=False)
r.encoding = 'utf-8'
html = r.text
except Exception as e:
print(f"请求失败: {e}")
return []
results = []
# 匹配曲谱详情页链接
pattern1 = r'<a\s+href="(/qupu/\d+\.html)"[^>]*>([^<]+)</a>'
matches = re.findall(pattern1, html)
for href, title in matches:
title = title.strip()
if title and len(title) < 50 and not any(x in title for x in ['首页', '搜索', '注册', '登录']):
full_url = self.BASE_URL + href
desc = "五线谱" if "五线谱" in html[max(0, html.find(href)-500):html.find(href)+500] else "曲谱"
results.append((title, full_url, desc))
# 去重
seen = set()
unique_results = []
for r in results:
if r[1] not in seen:
seen.add(r[1])
unique_results.append(r)
return unique_results[:20]
def extract_qupu_links_from_entry(self, entry_url):
"""从 entry 专题页提取所有曲谱详情页链接"""
try:
r = self.session.get(entry_url, headers=self.headers,
timeout=15, verify=False)
r.encoding = 'utf-8'
html = r.text
except Exception as e:
print(f"获取专题页失败: {e}")
return []
qupu_links = []
# 匹配曲谱详情页链接:/qupu/数字.html
pattern = r'<a\s+href="(/qupu/\d+\.html)"[^>]*>([^<]+)</a>'
matches = re.findall(pattern, html)
for href, title in matches:
title = title.strip()
if title and len(title) < 50:
if not any(x in title for x in ['首页', '上一页', '下一页', '返回', '评论', '分享', '收藏']):
full_url = self.BASE_URL + href
qupu_links.append((title, full_url))
# 去重
seen = set()
unique_links = []
for title, url in qupu_links:
if url not in seen:
seen.add(url)
unique_links.append((title, url))
return unique_links
def get_image_urls(self, page_url, min_width=600, min_height=800,
min_filesize=10000):
"""从曲谱详情页提取图片URL"""
try:
r = self.session.get(page_url, headers=self.headers,
timeout=15, verify=False)
r.encoding = 'utf-8'
html = r.text
except Exception as e:
print(f"获取页面失败: {e}")
return []
# 多种图片属性匹配
img_attrs = ['src', 'data-src', 'data-original', 'data-url']
all_imgs = []
for attr in img_attrs:
pattern = rf'{attr}\s*=\s*["\']([^"\']+\.(?:jpg|jpeg|png|gif|webp))["\']'
matches = re.findall(pattern, html, re.I)
all_imgs.extend(matches)
# 过滤曲谱相关图片
qupu_imgs = []
for url in all_imgs:
clean_url = re.sub(r'\?.*$', '', url)
# 曲谱图片特征
if any(x in clean_url.lower() for x in ['/qupu/', '/img/pic/', '/upload/', 'pic']):
if not any(x in clean_url.lower() for x in ['logo', 'icon', 'avatar', 'banner', 'ad', 'btn']):
if clean_url.startswith('/'):
clean_url = self.BASE_URL + clean_url
elif not clean_url.startswith('http'):
clean_url = self.BASE_URL + '/' + clean_url
qupu_imgs.append(clean_url)
# 去重
seen = set()
unique_urls = []
for u in qupu_imgs:
if u not in seen:
seen.add(u)
unique_urls.append(u)
# 验证图片尺寸和大小
valid_images = []
for url in unique_urls:
try:
r = self.session.get(url, headers=self.headers,
timeout=30, verify=False, stream=True)
if r.status_code != 200:
continue
content_length = r.headers.get('content-length')
if content_length and int(content_length) < min_filesize:
continue
img_data = r.content
img = Image.open(io.BytesIO(img_data))
w, h = img.size
if w >= min_width and h >= min_height:
valid_images.append((url, w, h))
except Exception:
continue
return valid_images
def get_all_images_from_entry(self, entry_url, min_width=600, min_height=800, min_filesize=10000):
"""从专题页抓取所有子曲谱的图片"""
all_images = []
# 先提取所有子曲谱链接
qupu_list = self.extract_qupu_links_from_entry(entry_url)
if not qupu_list:
# 如果不是专题页,尝试直接作为曲谱页处理
return self.get_image_urls(entry_url, min_width, min_height, min_filesize)
print(f"从专题页找到 {len(qupu_list)} 个子曲谱")
for title, qupu_url in qupu_list:
print(f" 处理:{title} - {qupu_url}")
try:
images = self.get_image_urls(qupu_url, min_width, min_height, min_filesize)
all_images.extend(images)
except Exception as e:
print(f" 抓取 {title} 失败:{e}")
continue
return all_images
def download_images(self, image_infos, callback=None):
"""下载图片列表"""
images = []
total = len(image_infos)
for i, (url, _, _) in enumerate(image_infos):
if callback:
callback(i + 1, total, url)
try:
r = self.session.get(url, headers=self.headers,
timeout=30, verify=False)
if r.status_code != 200:
continue
img = Image.open(io.BytesIO(r.content))
if img.mode != "RGB":
img = img.convert("RGB")
images.append(img)
except Exception:
continue
return images
def images_to_pdf(images, output_path, resolution=600):
"""将图片列表合并为PDF"""
if not images:
raise ValueError("图片列表为空")
first = images[0]
rest = images[1:] if len(images) > 1 else []
first.save(
output_path, "PDF",
save_all=True,
append_images=rest,
resolution=resolution
)
class GuzhengPDFApp:
"""古筝曲谱PDF生成器 GUI"""
def __init__(self, root):
self.root = root
self.root.title("古筝曲谱PDF生成器 v3.0 --- 支持专题页")
self.root.geometry("850x700")
self.root.minsize(700, 550)
self.fetcher = GuzhengFetcher()
self._running = False
self._cached_images = []
self._search_results = []
self._build_ui()
def _build_ui(self):
style = ttk.Style()
style.configure("Title.TLabel", font=("Microsoft YaHei UI", 11, "bold"))
style.configure("Hint.TLabel", font=("Microsoft YaHei UI", 9))
# 标题栏
title_frame = Frame(self.root, padx=10, pady=8)
title_frame.pack(fill="x")
ttk.Label(title_frame, text="古筝曲谱PDF生成器 v3.0",
style="Title.TLabel").pack(side="left")
ttk.Label(title_frame,
text=" 支持专题页(entry)自动提取所有子曲谱",
style="Hint.TLabel").pack(side="left", padx=(8, 0))
# 输入区
input_frame = ttk.LabelFrame(self.root, text="输入", padding=10)
input_frame.pack(fill="x", padx=10, pady=(0, 5))
# 搜索行
row1 = Frame(input_frame)
row1.pack(fill="x", pady=2)
ttk.Label(row1, text="曲名搜索:").pack(side="left")
self.keyword_var = StringVar()
self.keyword_entry = Entry(row1, textvariable=self.keyword_var, width=40)
self.keyword_entry.pack(side="left", padx=5)
self.keyword_entry.bind("<Return>", lambda e: self._do_search())
ttk.Button(row1, text="搜索", command=self._do_search, width=8).pack(side="left")
# 搜索结果行
row2 = Frame(input_frame)
row2.pack(fill="x", pady=2)
ttk.Label(row2, text="选择曲谱:").pack(side="left")
self.result_combo = ttk.Combobox(row2, width=60, state="readonly")
self.result_combo.pack(side="left", padx=5)
# URL输入行
row3 = Frame(input_frame)
row3.pack(fill="x", pady=2)
ttk.Label(row3, text="或直接粘贴URL:").pack(side="left")
self.url_var = StringVar()
self.url_entry = Entry(row3, textvariable=self.url_var, width=60)
self.url_entry.pack(side="left", padx=5)
ttk.Button(row3, text="加载URL", command=self._load_url, width=8).pack(side="left")
# 提示标签
hint_label = ttk.Label(input_frame, text="💡 提示:支持 qupu(详情页)和 entry(专题页)两种链接",
foreground="gray", font=("Microsoft YaHei UI", 8))
hint_label.pack(anchor="w", pady=(5, 0))
# 参数区
param_frame = ttk.LabelFrame(self.root, text="参数", padding=10)
param_frame.pack(fill="x", padx=10, pady=(0, 5))
row4 = Frame(param_frame)
row4.pack(fill="x", pady=2)
ttk.Label(row4, text="最小宽度(px):").pack(side="left")
self.min_width_var = StringVar(value="400")
Entry(row4, textvariable=self.min_width_var, width=8).pack(side="left", padx=(0, 15))
ttk.Label(row4, text="最小高度(px):").pack(side="left")
self.min_height_var = StringVar(value="500")
Entry(row4, textvariable=self.min_height_var, width=8).pack(side="left", padx=(0, 15))
ttk.Label(row4, text="最小文件大小(KB):").pack(side="left")
self.min_size_var = StringVar(value="10")
Entry(row4, textvariable=self.min_size_var, width=8).pack(side="left", padx=(0, 15))
ttk.Label(row4, text="DPI:").pack(side="left")
self.dpi_var = StringVar(value="150")
Entry(row4, textvariable=self.dpi_var, width=6).pack(side="left", padx=(0, 15))
ttk.Label(row4, text="输出目录:").pack(side="left")
self.output_var = StringVar(value=os.path.join(os.path.expanduser("~"), "Desktop"))
Entry(row4, textvariable=self.output_var, width=25).pack(side="left", padx=5)
ttk.Button(row4, text="浏览", command=self._browse_dir, width=5).pack(side="left")
# 操作按钮
btn_frame = Frame(self.root, padx=10, pady=5)
btn_frame.pack(fill="x")
self.fetch_btn = ttk.Button(btn_frame, text="抓取图片",
command=self._do_fetch, width=14)
self.fetch_btn.pack(side="left", padx=2)
self.pdf_btn = ttk.Button(btn_frame, text="生成PDF",
command=self._do_pdf, width=14)
self.pdf_btn.pack(side="left", padx=2)
self.all_btn = ttk.Button(btn_frame, text="一键抓取+生成PDF",
command=self._do_all, width=18)
self.all_btn.pack(side="left", padx=2)
# 状态标签
self.status_var = StringVar(value="就绪")
status_label = ttk.Label(btn_frame, textvariable=self.status_var,
relief="sunken", anchor="w")
status_label.pack(side="right", fill="x", expand=True, padx=(20, 0))
# 日志区
log_frame = ttk.LabelFrame(self.root, text="日志", padding=5)
log_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self.log_text = scrolledtext.ScrolledText(
log_frame, height=18, font=("Consolas", 9),
bg="#1e1e1e", fg="#d4d4d4", insertbackground="white"
)
self.log_text.pack(fill="both", expand=True)
self._log("古筝曲谱PDF生成器 v3.0 已启动")
self._log("✨ 新功能:支持专题页(entry)自动提取所有子曲谱")
self._log("")
self._log("使用说明:")
self._log(" 1. 直接粘贴曲谱URL(支持 qupu 详情页 或 entry 专题页)")
self._log(" 2. 点击「抓取图片」自动识别类型并下载")
self._log(" 3. 点击「生成PDF」输出文件")
self._log("")
self._log("示例URL:")
self._log(" - 详情页: https://www.guzheng.cn/qupu/595.html")
self._log(" - 专题页: https://www.guzheng.cn/entry/9035.html")
def _log(self, msg):
self.log_text.insert(END, msg + "\n")
self.log_text.see(END)
self.root.update_idletasks()
def _browse_dir(self):
d = filedialog.askdirectory(title="选择输出目录", initialdir=self.output_var.get())
if d:
self.output_var.set(d)
def _do_search(self):
keyword = self.keyword_var.get().strip()
if not keyword:
messagebox.showwarning("提示", "请输入曲名关键词")
return
self._log(f"正在搜索:{keyword} ...")
self.status_var.set("搜索中...")
try:
results = self.fetcher.search(keyword)
except Exception as e:
self._log(f"搜索失败:{e}")
self.status_var.set("搜索失败")
return
if not results:
self._log("未找到相关曲谱")
self._log("建议:直接在「粘贴URL」框输入曲谱页面链接")
self.status_var.set("未找到结果")
return
display_list = [f"{t}({d}) - {u}" for t, u, d in results]
self.result_combo["values"] = display_list
self.result_combo.current(0)
self._search_results = results
self._log(f"找到 {len(results)} 个结果:")
for t, u, d in results[:5]:
self._log(f" · {t}({d})")
if len(results) > 5:
self._log(f" ... 共{len(results)}条")
self.status_var.set(f"找到{len(results)}个结果")
def _load_url(self):
url = self.url_var.get().strip()
if not url:
messagebox.showwarning("提示", "请输入曲谱页面URL")
return
if not url.startswith("http"):
url = "https://" + url
self._log(f"已设置URL: {url}")
self.status_var.set("URL已加载")
if messagebox.askyesno("提示", "是否立即抓取该URL的图片?"):
self._fetch_and_store(url)
def _get_selected_url(self):
url = self.url_var.get().strip()
if url:
if not url.startswith("http"):
url = "https://" + url
return url
val = self.result_combo.get()
if not val:
messagebox.showwarning("提示", "请先搜索并选择一个结果,或直接粘贴URL")
return None
match = re.search(r'https?://[^\s]+', val)
if match:
return match.group(0)
return None
def _do_fetch(self):
url = self._get_selected_url()
if not url:
return
self._fetch_and_store(url)
def _fetch_and_store(self, url):
if self._running:
self._log("抓取任务进行中,请稍后...")
return
self._running = True
self.fetch_btn.config(state="disabled")
self.pdf_btn.config(state="disabled")
self.all_btn.config(state="disabled")
self.status_var.set("抓取中...")
min_w = int(self.min_width_var.get())
min_h = int(self.min_height_var.get())
min_size = int(self.min_size_var.get()) * 1024
def worker():
self._log(f"\n正在抓取:{url}")
try:
# 自动判断是 entry 专题页还是 qupu 详情页
if '/entry/' in url:
self._log("🔍 检测到专题页(entry),将自动提取所有子曲谱...")
infos = self.fetcher.get_all_images_from_entry(
url, min_width=min_w, min_height=min_h,
min_filesize=min_size
)
else:
self._log("🔍 检测到详情页(qupu),直接抓取图片...")
infos = self.fetcher.get_image_urls(
url, min_width=min_w, min_height=min_h,
min_filesize=min_size
)
if not infos:
self._log("⚠️ 未找到符合条件的曲谱图片")
self._log("建议:")
self._log(" 1. 降低最小宽度/高度要求(当前:{}x{})".format(min_w, min_h))
self._log(" 2. 检查网页是否正常加载")
self._cached_images = []
else:
self._log(f"📷 找到 {len(infos)} 张图片,开始下载...")
self._cached_images = self.fetcher.download_images(
infos,
callback=lambda i, n, u: self._log(
f" 下载 {i}/{n}: {os.path.basename(u)}")
)
self._log(f"✅ 下载完成,共 {len(self._cached_images)} 张有效图片")
for i, img in enumerate(self._cached_images, 1):
self._log(f" 第{i}页尺寸: {img.size[0]}x{img.size[1]}")
except Exception as e:
self._log(f"❌ 抓取失败:{e}")
import traceback
self._log(traceback.format_exc())
self._cached_images = []
self._running = False
self.root.after(0, lambda: (
self.fetch_btn.config(state="normal"),
self.pdf_btn.config(state="normal"),
self.all_btn.config(state="normal"),
self.status_var.set("就绪")
))
threading.Thread(target=worker, daemon=True).start()
def _do_pdf(self):
if not hasattr(self, "_cached_images") or not self._cached_images:
messagebox.showwarning("提示", "请先抓取图片(图片列表为空)")
return
out_dir = self.output_var.get().strip()
if not out_dir:
out_dir = os.path.expanduser("~\\Desktop")
os.makedirs(out_dir, exist_ok=True)
# 获取曲谱名称
sel = self.result_combo.get()
name_match = re.match(r'^([^(]+)', sel)
song_name = name_match.group(1).strip() if name_match else "古筝曲谱"
# 如果用了手动URL,默认取URL中的数字ID或entry ID
if not sel and self.url_var.get():
url = self.url_var.get()
id_match = re.search(r'/(\d+)\.', url)
if id_match:
song_name = f"古筝曲谱_{id_match.group(1)}"
else:
song_name = "古筝曲谱"
out_path = os.path.join(out_dir, f"{song_name}.pdf")
dpi = int(self.dpi_var.get())
try:
self.status_var.set("生成PDF中...")
images_to_pdf(self._cached_images, out_path, resolution=dpi)
self._log(f"\n✅ PDF已生成:{out_path}")
self._log(f" 共 {len(self._cached_images)} 页,DPI {dpi}")
self.status_var.set("PDF生成完成")
messagebox.showinfo("完成",
f"PDF生成成功!\n{out_path}\n"
f"共 {len(self._cached_images)} 页")
except Exception as e:
self._log(f"❌ PDF生成失败:{e}")
self.status_var.set("生成失败")
messagebox.showerror("错误", str(e))
def _do_all(self):
url = self._get_selected_url()
if not url or self._running:
return
self._fetch_and_store(url)
self._poll_fetch(self._do_pdf)
def _poll_fetch(self, callback):
if not self._running:
callback()
return
self.root.after(200, lambda: self._poll_fetch(callback))
def cli_mode():
"""命令行模式"""
print("=" * 55)
print(" 古筝曲谱PDF生成器 v3.0 --- 命令行模式")
print(" 支持 qupu 详情页 和 entry 专题页")
print("=" * 55)
print()
fetcher = GuzhengFetcher()
out_dir = os.path.join(os.path.expanduser("~"), "Desktop")
while True:
try:
user_input = input("\n请输入曲谱URL或曲名(q退出):").strip()
except (EOFError, KeyboardInterrupt):
break
if user_input.lower() in ("q", "quit", "exit"):
break
if not user_input.startswith("http"):
print(f"搜索:{user_input}")
results = fetcher.search(user_input)
if not results:
print(f"未找到「{user_input}」相关曲谱")
continue
print(f"\n搜索到 {len(results)} 个结果:")
for i, (t, u, d) in enumerate(results, 1):
print(f" [{i}] {t}({d})")
print(f" {u}")
try:
choice = input("请选择编号(或直接输入URL):").strip()
if choice.startswith("http"):
url = choice
else:
idx = int(choice) - 1
url = results[idx][1]
except (ValueError, IndexError):
print("无效选择,跳过")
continue
else:
url = user_input
print(f"\n正在抓取:{url}")
try:
if '/entry/' in url:
print("检测到专题页,将提取所有子曲谱...")
infos = fetcher.get_all_images_from_entry(url, min_width=300, min_height=400)
else:
infos = fetcher.get_image_urls(url, min_width=300, min_height=400)
if not infos:
print("未找到有效曲谱图片")
continue
print(f"找到 {len(infos)} 张图片,正在下载...")
images = fetcher.download_images(
infos,
callback=lambda i, n, u: print(f" [{i}/{n}] {os.path.basename(u)}")
)
if not images:
print("下载失败,没有获取到图片")
continue
name = input(f"文件名(默认:曲谱):").strip() or "曲谱"
out_path = os.path.join(out_dir, f"{name}.pdf")
images_to_pdf(images, out_path)
print(f"\n✅ 生成成功:{out_path}({len(images)}页)")
except Exception as e:
print(f"失败:{e}")
print("再见!")
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--cli":
cli_mode()
else:
root = Tk()
app = GuzhengPDFApp(root)
root.mainloop()

五、调试全过程回顾与踩坑总结
5.1 从第一行代码到最终成品的完整时间线
整个项目的调试过程大概经历了这几个阶段:
第一阶段:摸索页面结构 。先用web_fetch工具访问古筝网首页和搜索页,确认搜索URL格式和结果页的HTML结构。找到曲谱详情页后发现图片是通过JS动态加载的,requests拿不到。
第二阶段:尝试多种方案提取图片URL 。先后试了猜URL格式(只对旧版谱子有效)、Playwright浏览器自动化(依赖太重)、分析HTML data属性(最终方案)。发现data-original和data-src里存着真实地址,用正则提取即可。
第三阶段:SSL代理问题 。requests.get()在配了代理的机器上报SSL错误,通过session.trust_env = False和清除代理环境变量解决。
第四阶段:图片过滤。发现溟山和西域随想混入了200-352px的封面图,西部主题畅想曲混入了1280×961的指法示范图。通过设定最小宽高阈值(600×800)和文件大小阈值(10KB)过滤。
第五阶段:URL格式差异 。溟山简谱路径误写为/gzkj/(实际是/12shou/),定风波简谱月份写错(实际是202302和202303不是202401)。通过逐个检查HTTP状态码定位正确的URL。
第六阶段:封装成完整工具。把所有逻辑整合到一个文件里,加GUI界面和命令行模式,做成可以分发的工具。
5.2 踩坑速查表
| 问题 | 现象 | 解决方案 |
|---|---|---|
| SSL代理冲突 | SSLError: WRONG_VERSION_NUMBER |
session.trust_env = False + 清除代理环境变量 |
| 图片未加载 | HTML中找不到<img src> |
提取data-original/data-src属性 |
| 混入封面图 | PDF中出现200px小图 | 设min_width=600, min_height=800过滤 |
| 混入横幅图 | PDF中出现1280×961横幅 | 高度阈值已过滤,或检查宽高比 |
| URL路径写错 | 404 Not Found | 先用脚本检查每个URL的HTTP状态码 |
| OSS缩略图 | 下载到被压缩的图 | 去掉?x-oss-process=参数 |
| RGBA图片保存PDF报错 | OSError |
统一转RGB:img.convert("RGB") |
| GUI下载时卡死 | 界面无响应 | 子线程下载 + root.after()更新UI |
| 一键模式等待 | 抓取未完成就生成PDF | 轮询_running标志位 |
如果你也在做类似的网络资源爬取或图片处理项目,或者想给这个工具加更多功能(比如支持其他乐谱网站、批量导出、加水印等),欢迎在评论区讨论。这套代码是完全开源的,随便拿去改。