这篇文章是一个爬虫小白三个晚上的成果,由于感觉十分有意义,因此开通博客,将碰到的问题以及如何处理记录下来,希望能帮助到他人以及未来的自己。

动机

由于一些原因购买了某个网站的资源,但该资源获取较为麻烦,不支持搜索,仅支持翻页。遂产生了将其资源全部下载的想法。

考虑到安全以及隐私问题。此处不给出具体的网页名。

由于该资源通过微信公众号下载,因此首先在电脑上将该微信公众号使用chrome浏览器打开并分析。发现有多个难题。
一、 该网页是通过扫码登录,且每次退出需要重新扫码。
二、 网页中的资源列表不是一次放出,而是需要多次点击“查看更多”才可获取列表。获取资源时需要点击该资源名字。而网页元素中并没有该按钮控件,即该网页是通过“点击”动作获取XHR数据。
三、 该网页一旦点击某资源名称,会直接跳转,禁止打开新标签跳转。且跳转后,若想返回资源列表,则原网页所有元素将会重置,即又需要点击多次来获取更多的资源列表。

针对以上问题,考虑seleniumwire库来进行动态网页爬取。

三个问题的解决方案

Seleniumwire是Selenium扩展库,在Selenium的基础上,添加了部分功能,不仅可以模拟点击自动化操作,还可以获取网页交互信息。

对于问题一,在下载好chrome版本对应驱动后,直接打开网页并等待30s,这期间进行手动扫码登录。虽然考虑过记录cookie后,不需要多次扫码,但还未实现。

    options = webdriver.ChromeOptions()
    options.add_argument("--start-maximized")
    service = ChromeService(executable_path=r'C:\Program Files\Google\Chrome\Application\chromedriver.exe')
    driver = webdriver.Chrome(service=service, options=options)

        driver.get(base_url)
        print("Please scan the QR code to log in...")
        time.sleep(30)

对于问题二,经过分析网页数据后发现,该网页所有的可点击按钮都不是以按钮元素,而是图片元素,因此通过文本直接定位该图片所在位置。随后执行点击操作。考虑到点击操作不能过于频繁,因此设置了随机等待2-3秒的策略。

问题三的解决是关键,即,不同资源间的跳转到底有何规律。经过分析发现,每次点击“查看更多,都会传输一个名字为2.0.0的XHR文件,该文件的data中有一个list,其中有一个字典为“resource_id”。通过点击资源并分析网址发现。资源网址主要由以下三个特征构成:源网址+ resource_id+pro_id。其中pro_id是在每次扫码登录后都会获得的一个字符串。因此,只需要获取所有资源的resource_id后,再通过构建网址来跳转到对应id的网页。就可以获得资源。此外,第一次获得是6个resource_id是主页上的,因此删去。
XHR文件分析:在该分析中,出现一些问题,一开始读取数据发现全为乱码,经过分析后,发现需要通过gzip方法解压再使用“utf-8”解码后才能获得详细数据。以及,在一开始以为list是字典,但之后发现,只有将list单独提取后,才可以使用字典方法进行提取。

资源网页分析:该资源形式是多张图片,由于资源网页属于静态网页,滚动到底部等待几秒后,就可以通过定位网页元素获取图片链接,从而得到资源。此外,考虑到网络原因,偶尔会出现图片资源下载失败,或下载图片为空白。因此加入了筛选:如果图片大小低于20kb,则认为下载未成功,会刷新网页重新下载。下载完毕后,将网页标题作为文件夹名称,将该网页下下载的所有图片编号后放在文件夹内。
以上就是分析网页的全部过程。此外,考虑到速度过快可能会被认为是robot,因此采用随机停止策略,在爬取完毕后随机停止2-3秒。并且在测试过程中发现,有时会突然产生错误,这些错误的原因目前还未弄清楚,但为了保证再次执行后能从上一次的resourc_id开始下载,创建了日志。
在执行过程中发现,一旦出现一次图片下载失败,即下载的图片大小小于20kb后。后续的所有资源下载都会出现该情况。目前还没有搞清楚该问题的出现的原因,因此只能采用log日志记录第一次出现问题的resource_id,然后重新执行程序。
以下代码的原型通过chatgpt生成,经过修改、测试和验证后确认可用。完整代码如下所示:

import os
import time
import json
import random
import gzip
from seleniumwire import webdriver  # 用于捕获XHR请求
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.keys import Keys
from io import BytesIO
import requests

def save_strings_to_file(strings, output_file):
    """将提取的字符串保存到文件"""
    strings = strings[6:]  # 忽略前6个无关的字符串
    try:
        with open(output_file, "w", encoding="utf-8") as f:
            for string in strings:
                f.write(string + "\n")
        print(f"Saved {len(strings)} strings to {output_file}.")
    except Exception as e:
        print(f"Failed to save strings to file: {e}")

def save_progress(log_file, last_processed_string):
    """保存当前进度到日志文件"""
    try:
        with open(log_file, "w", encoding="utf-8") as f:
            f.write(last_processed_string)
        print(f"Progress saved: {last_processed_string}")
    except Exception as e:
        print(f"Failed to save progress: {e}")

def load_progress(log_file):
    """从日志文件加载上次处理的进度"""
    if os.path.exists(log_file):
        try:
            with open(log_file, "r", encoding="utf-8") as f:
                last_processed_string = f.read().strip()
                print(f"Resuming from: {last_processed_string}")
                return last_processed_string
        except Exception as e:
            print(f"Failed to load progress: {e}")
    return None

def decompress_gzip(data):
    """解压gzip压缩的字节流"""
    try:
        with gzip.GzipFile(fileobj=BytesIO(data)) as f:
            return f.read()
    except Exception as e:
        print(f"Failed to decompress gzip data: {e}")
        return None

def download_image(img_url, folder_path, filename, min_size=20):
    """下载图片并保存到指定路径"""
    try:
        response = requests.get(img_url, stream=True)
        if response.status_code == 200:
            # 判断图片大小,如果小于 min_size KB,则认为是空白图片
            if len(response.content) < min_size * 1024:
                print(f"Image at {img_url} is too small ({len(response.content)} bytes), retrying...")
                return False  # 返回 False 表示需要重新加载
            with open(os.path.join(folder_path, filename), 'wb') as f:
                for chunk in response.iter_content(1024):
                    f.write(chunk)
            print(f"Downloaded {filename} successfully.")
            return True  # 返回 True 表示下载成功
        else:
            print(f"Failed to download {img_url}. HTTP status code: {response.status_code}")
            return False
    except Exception as e:
        print(f"Error downloading {img_url}: {e}")
        return False

def extract_and_download_images(driver, target_string, base_folder, pro_id):
    """根据目标字符串提取图片并下载"""
    url = f"https://app8ejmhqxv9489.h5.xiaoeknow.com/p/course/text/{target_string}?product_id={pro_id}"
    print(f"Navigating to {url}")

    driver.get(url)
    time.sleep(5)  # 等待页面加载初始内容

    # 滚动到底部,确保页面所有元素加载
    driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
    time.sleep(20)  # 等待 20 秒,确保页面加载完成

    # 提取页面标题作为子文件夹名称
    title_element = WebDriverWait(driver, 15).until(
        EC.presence_of_element_located((By.TAG_NAME, "title"))
    )
    page_title = title_element.get_attribute("textContent").strip()
    print(f"Page title: {page_title}")

    safe_title = "".join(c if c.isalnum() or c in " _-" else "_" for c in page_title)
    page_folder = os.path.join(base_folder, safe_title)
    os.makedirs(page_folder, exist_ok=True)

    # 等待图片容器加载完成
    WebDriverWait(driver, 15).until(
        EC.presence_of_element_located((By.CLASS_NAME, "xe-preview__content"))
    )
    containers = driver.find_elements(By.CLASS_NAME, "xe-preview__content")
    img_urls = []
    for container in containers:
        images = container.find_elements(By.TAG_NAME, "img")
        img_urls.extend([img.get_attribute("src") for img in images if img.get_attribute("src")])

    print(f"Extracted {len(img_urls)} images.")
    for img_index, img_url in enumerate(img_urls):
        filename = f"image_{img_index + 1}.jpg"
        success = download_image(img_url, page_folder, filename)
        if not success:
            print(f"Retrying to download image at {img_url}")
            # 重新加载页面并等待 20 秒
            driver.get(url)
            time.sleep(20)
            success = download_image(img_url, page_folder, filename)
            if success:
                print(f"Downloaded {filename} successfully after retry.")

def extract_data_from_xhr(driver, output_file, max_clicks=1):
    """从XHR响应中提取目标字符串,点击查看更多"""
    extracted_strings = []

    # 模拟点击查看更多
    click_count = 0
    while click_count < max_clicks:
        try:
            load_more_div = WebDriverWait(driver, 10).until(
                EC.presence_of_element_located((By.XPATH, "//div[contains(@class, 'moreInfo') and text()='查看更多']"))
            )
            ActionChains(driver).move_to_element(load_more_div).click().perform()
            click_count += 1
            print(f"Clicked '查看更多' {click_count}/{max_clicks} times.")
            time.sleep(random.uniform(2, 3))
        except Exception as e:
            print("No more '查看更多' elements or an error occurred:", e)
            break

    # 捕获并处理 XHR 请求
    for request in driver.requests:
        if request.response:
            try:
                if "2.0.0" in request.path:
                    response_body = decompress_gzip(request.response.body)
                    if response_body:
                        response_text = response_body.decode('utf-8')
                        data = json.loads(response_text)
                        resources = data.get("data", {}).get("list", [])
                        for resource in resources:
                            target_string = resource.get("resource_id")
                            if target_string:
                                extracted_strings.append(target_string)
                                print(f"Extracted: {target_string}")
            except Exception as e:
                print(f"Error processing request {request.path}: {e}")

    save_strings_to_file(extracted_strings, output_file)
    return extracted_strings

def process_strings(driver, strings_file, base_folder, pro_id, log_file):
    """从保存文件中读取目标字符串并逐一处理"""
    with open(strings_file, "r", encoding="utf-8") as f:
        target_strings = f.read().splitlines()

    last_processed_string = load_progress(log_file)
    start_index = 0
    if last_processed_string:
        # 找到上次处理的字符串,开始从下一个字符串开始处理
        if last_processed_string in target_strings:
            start_index = target_strings.index(last_processed_string) + 1
        print(f"Resuming from index {start_index}...")

    if start_index == 0:  # 如果没有找到进度记录,默认从第70个字符串开始
        start_index = 82
        print(f"Starting from index {start_index} as first run...")

    for idx, target_string in enumerate(target_strings[start_index:], start=start_index + 1):
        try:
            print(f"Processing {idx}/{len(target_strings)}: {target_string}")
            extract_and_download_images(driver, target_string, base_folder, pro_id)
            save_progress(log_file, target_string)

            if idx % 100 == 0:
                print(f"Processed {idx} strings. Pausing for 1 minute...")
                time.sleep(60)
        except Exception as e:
            print(f"Error processing {target_string}: {e}")
            break

def scrape_with_login_and_xhr(base_url, output_file):
    """模拟登录并从XHR响应中提取数据"""
    options = webdriver.ChromeOptions()
    options.add_argument("--start-maximized")
    service = ChromeService(executable_path=r'C:\Program Files\Google\Chrome\Application\chromedriver.exe')
    driver = webdriver.Chrome(service=service, options=options)

    try:
        driver.get(base_url)
        print("Please scan the QR code to log in...")
        time.sleep(30)

        current_url = driver.current_url
        pro_id = current_url.split("pro_id=")[-1].split("&")[0]
        print(f"Extracted pro_id: {pro_id}")

        #如果字符串已经读取完毕,那么该函数可以不被执行
        #、extract_data_from_xhr(driver, output_file, max_clicks=25)

        base_folder = "downloaded_images"
        os.makedirs(base_folder, exist_ok=True)

        # 读取字符串文件并开始爬取图片
        process_strings(driver, output_file, base_folder, pro_id, "progress.log")
    except Exception as e:
        print(f"Error occurred: {e}")
    finally:
        driver.quit()

if __name__ == "__main__":
    base_url = ‘目标网页’
    output_file = "extracted_strings1.txt"
    scrape_with_login_and_xhr(base_url, output_file)