【附完整代码】Python爬取古筝网曲谱图片一键生成PDF(下·PDF生成与GUI篇)

文章目录

上篇讲了古筝网的页面结构分析、网络请求层的三个大坑(SSL代理冲突、图片尺寸过滤、不同年代URL格式差异),以及完整的爬虫代码。这篇我们进入 PDF生成和GUI界面 的部分,把整套工具串起来做成一个开箱即用的桌面小工具。


一、图片转PDF的三种方案对比

1.1 Pillow直接save:最简单的方案

把多张图片合并成一个PDF,最简单的方法就是用Pillow的Image.save()方法,传入save_all=Trueappend_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握手失败,清华镜像也不稳定。最终检查系统已安装的库时发现pypdfium2Pillow都在,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-originaldata-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标志位

如果你也在做类似的网络资源爬取或图片处理项目,或者想给这个工具加更多功能(比如支持其他乐谱网站、批量导出、加水印等),欢迎在评论区讨论。这套代码是完全开源的,随便拿去改。

相关推荐
lunareclipse2 小时前
Python 填坑:消失的信号点 —— 详解“可变默认参数”陷阱
python
光之后裔2 小时前
Numpy以及Pytorch中多维数组的维度数与维度值以及轴axis理解
pytorch·python·numpy
代码中介商2 小时前
C语言操作符深度解析:从基础到高级应用
c语言·开发语言
z小天才b2 小时前
Java 设计模式完全指南:从入门到精通
java·开发语言·设计模式
玛卡巴卡ldf2 小时前
【Springboot9】将业务模块数据导出为PDF
pdf·springboot
tangweiguo030519872 小时前
RAG 从零到一:让大模型读懂你的文档
python·langchain
挖AI金矿2 小时前
(六)文件与搜索 - 信息处理的正确姿势
人工智能·python·开源·个人开发·ai编程
zs宝来了2 小时前
网络篇15-网络收发包应用之iptable
开发语言·网络·php
烤麻辣烫2 小时前
算法--二分搜索
java·开发语言·学习·算法·intellij-idea