返回

Python Crawler4AI 下载 PDF:修复 wait_for 错误与 JS 点击

python

用 Crawler4AI 下载 PDF?搞定 JavaScript 点击和 wait_for 报错

碰到了个有点意思的问题:想用 Python 配合 Crawler4AI 这个库,从某个网站 (Humdata PDF Repository) 批量下载一堆 PDF 文件。麻烦在于,这些下载链接似乎需要执行点 JavaScript 才能工作,而直接用 Crawler4AI 文档里的代码,改了改 JavaScript 部分,却报了个错。

原代码大概长这样:

from crawl4ai.async_configs import BrowserConfig, CrawlerRunConfig
from crawl4ai import AsyncWebCrawler
import os, asyncio
from pathlib import Path

async def download_multiple_files(url: str, download_path: str):
    # 配置浏览器允许下载,并指定路径
    config = BrowserConfig(accept_downloads=True, downloads_path=download_path)
    async with AsyncWebCrawler(config=config) as crawler:
        # 配置爬虫运行参数
        run_config = CrawlerRunConfig(
            # 注入 JS 代码,找到所有标题为 'Download' 的链接并点击
            js_code="""
                const downloadLinks = document.querySelectorAll(
                  "a[title='Download']",
                  document,
                  null,
                  XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
                  null
                );
                for (const link of downloadLinks) {
                  link.click();
                }
            """,
            wait_for=10  # 等待 10 秒,期望下载开始
        )
        result = await crawler.arun(url=url, config=run_config)

        # 检查下载结果
        if result.downloaded_files:
            print("Downloaded files:")
            for file in result.downloaded_files:
                print(f"- {file}")
        else:
            print("No files downloaded.")

# 设置下载路径
download_path = os.path.join(Path.cwd(), "Downloads")
os.makedirs(download_path, exist_ok=True) # 确保目录存在

# 运行主函数
asyncio.run(download_multiple_files("https://data.humdata.org/dataset/repository-for-pdf-files", download_path))

结果运行时,抛出了这样的错误:

 × Unexpected error in _crawl_web at line 1551 in _crawl_web (.venv\Lib\site-                                          │
│ packages\crawl4ai\async_crawler_strategy.py):                                                                         │
│   Error: Wait condition failed: 'int' object has no attribute 'strip'                                                 │
│                                                                                                                       │
│   Code context:                                                                                                       │
│   1546                   try:                                                                                         │
│   1547                       await self.smart_wait(                                                                   │
│   1548                           page, config.wait_for, timeout=config.page_timeout                                   │
│   1549                       )
│
│   1550                   except Exception as e:
│
│   1551raise RuntimeError(f"Wait condition failed: {str(e)}")
│
│   1552
│
│   1553               # Update image dimensions if needed
│
│   1554               if not self.browser_config.text_mode:
│
│   1555                   update_image_dimensions_js = load_js_script("update_image_dimensions")
│
│   1556                   try:
│

看报错信息 Wait condition failed: 'int' object has no attribute 'strip',问题出在 crawl4ai 内部处理 wait_for 参数的 smart_wait 函数里。它似乎尝试对一个整数(我们设置的 10)执行字符串的 strip() 方法,自然就报错了。

用户也确认了,根据目标网站的 robots.txt 文件,爬取这个页面是被允许的,只需要遵守 Crawl-Delay: 10 的建议。

User-agent: *
Disallow: /dataset/rate/
Disallow: /revision/
Disallow: /dataset/*/history
Disallow: /api/
Disallow: /user/*/api-tokens
Crawl-Delay: 10

目标是下载数据集里的所有 PDF。咱们来看看怎么解决这个 wait_for 的问题,并且让 PDF 下载顺利进行。

问题分析

  1. 核心错误TypeError: 'int' object has no attribute 'strip'。这个错误明确指向 CrawlerRunConfig 中的 wait_for=10 参数。Crawler4AI 内部的 smart_wait 函数在处理这个整数类型的等待条件时,可能预期的是一个字符串(比如 CSS 选择器或 JavaScript 表达式),或者是处理整数等待时存在 bug,误用了字符串方法。
  2. JavaScript 点击策略 :虽然 robots.txt 允许爬取,但通过 JS 模拟点击来触发下载,有时不是最稳妥的方法。浏览器处理多个并发下载、页面状态变化、网络延迟等因素都可能导致行为不如预期。即使 wait_for 参数没问题,这种方法也可能漏掉文件或遇到其他奇怪问题。
  3. 等待机制的意义wait_for=10 的本意是给浏览器一点时间,让 JavaScript 点击生效,然后下载可以开始。但这个机制内部似乎出了岔子。
  4. 更好的下载方式? :模拟点击并非唯一途径。如果我们能直接拿到 PDF 文件的 URL,是不是可以绕过浏览器点击,用更可靠的 HTTP 请求库来下载?这通常更稳定、资源消耗更少。

解决方案

针对上面分析的原因,我们提供两种思路来解决问题。

方案一:调整等待策略,绕开 wait_for 整数问题

既然错误发生在 smart_wait 处理整数 wait_for 时,咱们可以试试不直接用这个功能,而是采用别的等待方式。

原理和作用

这个方案的核心思路是:避免smart_wait 函数直接传递整数 10。我们去掉 CrawlerRunConfig 里的 wait_for 参数,让 crawler.arun 执行 JS 点击动作。然后,我们在 arun 调用 之后,手动加一个异步等待 (asyncio.sleep),给浏览器下载留出足够的时间。

这种方式绕过了 smart_wait 内部对整数参数的处理逻辑,从而避开了那个 TypeError。同时,它仍然尝试给下载操作提供时间窗口。

操作步骤和代码示例

  1. 修改 download_multiple_files 函数。
  2. CrawlerRunConfig 中移除 wait_for=10
  3. result = await crawler.arun(...) 这一行 之后,添加 await asyncio.sleep(...)。等待时间可以根据网络情况和文件数量调整,比如设置 15 或 20 秒试试看。
from crawl4ai.async_configs import BrowserConfig, CrawlerRunConfig
from crawl4ai import AsyncWebCrawler
import os, asyncio
from pathlib import Path

async def download_multiple_files_v1(url: str, download_path: str):
    print("尝试方案一:移除 wait_for,使用 asyncio.sleep")
    config = BrowserConfig(accept_downloads=True, downloads_path=download_path)
    async with AsyncWebCrawler(config=config) as crawler:
        run_config = CrawlerRunConfig(
            js_code="""
                const downloadLinks = document.querySelectorAll("a[title='Download']");
                console.log(`找到 ${downloadLinks.length} 个下载链接。`); // 加点日志输出
                let clickedCount = 0;
                for (const link of downloadLinks) {
                  try {
                    // 稍微模拟下真实点击行为,或许增加点击成功率
                    link.focus(); // 先聚焦
                    await new Promise(resolve => setTimeout(resolve, 50)); // 短暂等待
                    link.click();
                    clickedCount++;
                    console.log(`点击了链接: ${link.href}`);
                    // 每次点击后稍微停顿一下,避免过于频繁触发反爬或浏览器崩溃
                    await new Promise(resolve => setTimeout(resolve, 200)); // 等待 200ms
                  } catch (e) {
                    console.error(`点击链接 ${link.href} 时出错: ${e}`);
                  }
                }
                console.log(`总共尝试点击了 ${clickedCount} 个链接。`);
            """,
            # 不再使用 wait_for 参数
            # wait_for=10
        )

        print("正在执行页面操作和 JS 点击...")
        # 执行 arun,但不在这里等待下载完成
        result_intermediate = await crawler.arun(url=url, config=run_config)

        # 在 arun 完成后,手动等待一段时间让下载开始并进行
        # 时间需要根据实际情况调整,这里设长一点,比如 30 秒
        # 注意:robots.txt 建议 Crawl-Delay 是 10 秒,JS 点击本身可能有间隔,
        # 但下载是并发开始的,这里的 sleep 是为了让后台下载有时间跑完。
        wait_download_time = 30
        print(f"JS 执行完毕,等待 {wait_download_time} 秒让下载进行...")
        await asyncio.sleep(wait_download_time)
        print("等待结束,检查下载文件...")

        # Crawler4AI 在退出 `async with` 时会收集下载信息
        # result.downloaded_files 实际上是在退出上下文管理器时更新的
        # 所以我们需要重新获取最终结果,或者设计让下载状态更明确

    # 退出 `async with` 后,再次检查 crawler 对象的下载列表
    # 注意: result_intermediate 可能没有最新的下载信息,因为下载是异步发生的。
    # Crawler4AI 的下载文件列表通常在浏览器关闭时(即 `async with` 块结束时)确定。
    # 直接检查下载目录可能更直接。

    downloaded_files_in_dir = list(Path(download_path).glob('*.pdf')) # 假设都是PDF
    if downloaded_files_in_dir:
        print("在下载目录中找到的文件:")
        for file_path in downloaded_files_in_dir:
            print(f"- {file_path.name}")
    else:
        print("下载目录中没有找到 PDF 文件。")
        # 也可以尝试访问 result.downloaded_files,但它的更新时机需注意
        # if hasattr(crawler, 'downloaded_files_history'): # 检查是否有记录
        #     print("Crawler 记录的下载历史:")
        #     for f in crawler.downloaded_files_history: print(f"- {f}")


# 设置下载路径
download_path = os.path.join(Path.cwd(), "Downloads_v1")
os.makedirs(download_path, exist_ok=True)

# 运行主函数
asyncio.run(download_multiple_files_v1("https://data.humdata.org/dataset/repository-for-pdf-files", download_path))

说明与提醒

  • 可靠性问题 :这种方法依然依赖浏览器的下载机制,行为可能不稳定。JS 点击是否都成功?点击后下载是否都正常启动?asyncio.sleep 的时间是否足够长?这些都是不确定因素。
  • 资源消耗 :启动和控制一个完整的浏览器实例(即使是无头的)来做下载,比直接 HTTP 请求要消耗更多系统资源。
  • Crawl-Delay :虽然 robots.txtCrawl-Delay: 10 主要是针对连续的页面请求,但我们的 JS 代码里快速连续点击多个链接,也可能给服务器带来短时压力。代码中已加入小的延时 (setTimeout) 模拟更真实的行为,并减缓点击速度。

方案二:提取 PDF 链接,使用 HTTP 库直接下载(推荐)

这个方案跳过了模拟点击的复杂性,是更常用也更稳健的网络爬虫下载方式。

原理和作用

  1. 利用 Crawler4AI 获取链接 :我们依然使用 Crawler4AI,因为它擅长处理需要 JavaScript 渲染的页面。但这次不用它的 js_code 去点击,而是用它的 extraction_schema 功能,精确提取出所有 PDF 下载链接的 href 属性(也就是真实的 URL)。
  2. 独立下载 :拿到 URL 列表后,我们使用专门的异步 HTTP 客户端库(比如 aiohttphttpx)和文件操作库(aiofiles)来发起直接的 HTTP GET 请求下载这些文件。这样控制更精细,错误处理也更方便。

操作步骤和代码示例

  1. 安装依赖库 :如果还没装,需要安装 aiohttpaiofiles
    pip install aiohttp aiofiles
    
  2. 修改 Python 代码
    • 移除 BrowserConfig 中的 accept_downloads=Truedownloads_path (因为我们不用浏览器下载了)。
    • 修改 CrawlerRunConfig:去掉 js_code,添加 extraction_schema 来提取链接。目标链接是 <a> 标签,并且 title 属性为 "Download"。我们要提取它的 href 属性。
    • 编写新的异步下载逻辑:使用 aiohttp 创建 ClientSession,遍历提取到的 URL,发起请求,并将响应内容写入本地文件。使用 aiofiles 进行异步文件写入。
    • 尊重 robots.txtCrawl-Delay: 10:在每次下载请求之间加入 asyncio.sleep(10)
from crawl4ai.async_configs import BrowserConfig, CrawlerRunConfig, ExtractionSchema
from crawl4ai import AsyncWebCrawler
import os, asyncio, aiohttp, aiofiles
from pathlib import Path
from urllib.parse import urlparse, unquote
import logging

# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

async def download_files_directly(url: str, download_path: str):
    logging.info("尝试方案二:提取链接后直接下载")
    # 配置浏览器,这次不需要下载功能
    config = BrowserConfig(headless=True) # 可以保持无头模式

    async with AsyncWebCrawler(config=config) as crawler:
        # 配置提取规则
        run_config = CrawlerRunConfig(
            extraction_schema=ExtractionSchema(
                # 使用 CSS 选择器定位元素
                # 提取所有 title='Download' 的 a 标签的 href 属性
                elements=[
                    {"selector": "a[title='Download']", "attribute": "href", "name": "pdf_links"}
                ]
            ),
            # 等待页面加载完成即可,不需要额外的等待时间给点击
            wait_for=5 # 等待 5 秒确保 JS 可能的渲染完成
        )

        logging.info(f"开始访问 URL: {url} 并提取链接...")
        result = await crawler.arun(url=url, config=run_config)

        if result.extracted_data and 'pdf_links' in result.extracted_data:
            pdf_urls = result.extracted_data['pdf_links']
            logging.info(f"成功提取到 {len(pdf_urls)} 个 PDF 链接。")

            # 使用 aiohttp 下载文件
            async with aiohttp.ClientSession() as session:
                tasks = []
                for i, pdf_url in enumerate(pdf_urls):
                    # 简单的从 URL 末尾提取文件名,并做 URL 解码
                    parsed_url = urlparse(pdf_url)
                    filename = os.path.basename(parsed_url.path)
                    filename = unquote(filename) # 处理 URL 编码的字符,如 %20 -> space

                    # 如果文件名无效或太简单,可以考虑生成一个,或者从页面其他地方提取
                    if not filename:
                        filename = f"downloaded_file_{i+1}.pdf"

                    file_save_path = Path(download_path) / filename
                    logging.info(f"准备下载: {pdf_url}{file_save_path}")

                    # 创建下载任务
                    task = asyncio.create_task(download_single_file(session, pdf_url, file_save_path))
                    tasks.append(task)

                    # 遵守 Crawl-Delay
                    if i < len(pdf_urls) - 1: # 如果不是最后一个链接
                         logging.info(f"遵循 Crawl-Delay,等待 10 秒...")
                         await asyncio.sleep(10) # 在发起下一个请求前等待

                # 等待所有下载任务完成 (注意:上面的 sleep 发生在任务创建之间,所以下载是顺序发起的)
                await asyncio.gather(*tasks)
                logging.info("所有下载任务已尝试完成。")

        else:
            logging.warning("未能提取到 PDF 链接。检查页面结构或提取规则。")
            if result.raw_html:
                 logging.debug(f"页面原始 HTML (前 500 字符): {result.raw_html[:500]}")


async def download_single_file(session: aiohttp.ClientSession, url: str, save_path: Path):
    """使用 aiohttp 下载单个文件"""
    try:
        # 设置合理的 User-Agent
        headers = {'User-Agent': 'MyFriendlyCrawler/1.0 (+http://mywebsite.com/bot_info)'}
        async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as response: # 60秒超时
            response.raise_for_status() # 检查 HTTP 错误 (如 404, 500)

            # 使用 aiofiles 异步写入文件
            async with aiofiles.open(save_path, 'wb') as f:
                while True:
                    chunk = await response.content.read(8192) # 每次读 8KB
                    if not chunk:
                        break
                    await f.write(chunk)
            logging.info(f"成功下载: {url} -> {save_path}")
            return True
    except aiohttp.ClientError as e:
        logging.error(f"下载 {url} 时发生 aiohttp 错误: {e}")
    except asyncio.TimeoutError:
        logging.error(f"下载 {url} 超时。")
    except Exception as e:
        logging.error(f"下载 {url} 时发生未知错误: {e}")
    # 如果下载失败,尝试删除可能创建的不完整文件
    if save_path.exists():
        try:
            os.remove(save_path)
        except OSError:
            pass # 删除失败就算了
    return False


# 设置下载路径
download_path_v2 = os.path.join(Path.cwd(), "Downloads_v2")
os.makedirs(download_path_v2, exist_ok=True)

# 运行主函数
asyncio.run(download_files_directly("https://data.humdata.org/dataset/repository-for-pdf-files", download_path_v2))

进阶使用技巧

  • 并发下载控制 :如果目标网站允许,且你的网络带宽足够,可以考虑使用 asyncio.Semaphore 来限制并发下载的数量,而不是严格的串行下载。这样能加快整体下载速度,同时避免对服务器造成过大负担。

    # 在 download_files_directly 中
    semaphore = asyncio.Semaphore(5) # 比如,最多同时下载 5 个文件
    
    async def download_single_file_with_semaphore(...):
        async with semaphore: # 获取信号量
            # ... 原来的下载逻辑 ...
            await asyncio.sleep(1) # 即使并发,也可以稍微错峰
    
    # ... 创建任务时 ...
    task = asyncio.create_task(download_single_file_with_semaphore(...))
    tasks.append(task)
    # 注意:如果使用并发,Crawl-Delay 的应用方式需要重新考虑。
    # 可能不再需要在创建任务间 sleep,而是整体速率控制。
    # 或者保持串行获取URL,并发下载URL内容。
    
  • 更完善的错误处理 :添加重试逻辑。如果下载失败(比如网络波动或服务器临时错误),可以等待一小段时间后重试几次。

  • 动态文件名 :如果 URL 中没有包含合适的文件名,可能需要从页面的其他文本内容(比如链接旁边的文字)来提取更有意义的文件名。这需要调整 extraction_schema

  • 进度条 :对于大文件下载,可以使用 tqdm 库(及其异步版本)来显示下载进度。

安全建议

  • 遵守 robots.txt :方案二代码中已加入了对 Crawl-Delay 的遵守。务必尊重网站的爬虫规则。
  • 设置 User-Agent :在 HTTP 请求头中设置一个清晰、有代表性的 User-Agent,表明你的爬虫身份和目的。这是一种礼貌,也有助于网站管理员识别流量来源。代码示例中已加入。
  • 不过度请求 :即使并发下载,也要合理设置并发数,避免给目标服务器带来太大压力。

总结来说,虽然方案一尝试修复了原代码的直接错误,但方案二(提取链接 + 直接 HTTP 下载)通常是处理这类下载任务更推荐、更健壮的方法。它避免了模拟用户操作的不确定性,也更符合网络爬虫的最佳实践。