1. 项目缘起:一个看似简单却暗藏玄机的需求

最近在整理团队过往的项目资料,发现大量历史文档都沉淀在金山文档的协作空间里。这些文档格式不一,有表格、有文字,零零散散加起来有几百个。领导一句话:“把这些都下载下来,本地备份一份,方便归档和离线查阅。” 听起来是个简单的“批量下载”任务,对吧?我一开始也是这么想的,心想着用浏览器的“另存为”或者金山文档自带的导出功能,一个个点过去,顶多费点时间。

但实际操作起来,才发现自己太天真了。金山文档的在线编辑体验很流畅,但其批量下载功能,尤其是针对非表格类文档(如金山文字),在网页端的支持并不友好。你无法像操作本地文件夹一样,按住Shift或Ctrl键多选然后一键下载。更棘手的是,当文档数量庞大时,手动操作不仅效率低下,还极易出错,比如漏掉某个版本,或者下载了错误的格式。

于是,这个“体力活”自然而然地转化成了一个技术需求:如何自动化、批量化地将金山文档空间里的文件下载到本地?这个需求的核心痛点在于“批量”和“自动化”,目标是将人力从重复、机械的点击操作中解放出来,并确保操作的准确性和一致性。这不仅仅是“下载”,更是一个小型的数据归档与迁移工程。

2. 技术路径选择:为什么是Python + JS的组合拳?

面对这个需求,技术选型是第一步。浏览器的自动化?专门的下载器?还是自己写脚本?我评估了几种常见方案:

方案一:纯浏览器自动化工具(如Selenium、Playwright) 这类工具可以模拟真人操作浏览器,点击、跳转、等待、下载。理论上能解决所有问题。但缺点也很明显:速度慢、资源占用高、稳定性受网页结构变化影响大。对于需要登录、且页面交互复杂的金山文档来说,脚本会变得非常臃肿,维护成本高。更重要的是,下载动作通常需要处理浏览器的原生下载对话框,这在不同浏览器和环境下的处理方式各异,增加了不确定性。

方案二:调用官方API 最理想的方式。如果金山文档提供了完善的开放API,我们可以直接通过API获取文件列表、文件ID,并调用下载接口。然而,经过一番调研(查阅金山办公开放平台文档),我发现其API主要面向深度集成和开发,对于普通用户批量下载自己空间内的文档,权限申请、OAuth认证流程较为复杂,且可能有频率限制。对于一次性或偶发性的归档需求,显得有些“杀鸡用牛刀”。

方案三:基于网络请求分析的“轻量爬虫” 这是最终选择的方案,也是本文重点。其核心思想是:直接分析金山文档网页在加载和下载时,浏览器向服务器发送了哪些HTTP请求。然后,我们用脚本(Python)去模拟这些请求,从而绕过浏览器界面,直接获取文件数据。但这里有一个关键前提:你需要先获得所有目标文档的链接列表。如何自动获取这个列表呢?这就是JavaScript(JS)出场的时候了。

为什么是Python + JS? 这是一个非常巧妙的组合,分工明确:

  1. JS(运行于浏览器控制台) :负责在 已登录的金山文档页面内部 执行,利用浏览器已有的登录态(Cookie、Session),获取我们肉眼可见的文档列表数据。因为同源策略,浏览器中的JS可以轻松访问当前页面的DOM和网络数据,这是外部Python脚本难以直接做到的。
  2. Python(运行于本地) :负责“脏活累活”。接收JS收集到的文档链接和元信息,然后模拟网络请求,进行并发下载、错误重试、文件重命名、本地存储管理等一系列自动化操作。Python在数据处理、网络请求和文件操作方面的库非常强大且易用。

简单说, JS是“内应”,负责在堡垒内部收集情报(链接列表);Python是“主力部队”,根据情报执行批量下载任务。 两者结合,既利用了浏览器的登录状态,又发挥了Python自动化的强大能力。

3. 实战第一步:用JS在浏览器内部收集文档链接

我们的首要任务是拿到所有待下载文档的“门牌号”——也就是它们的唯一访问链接,甚至是直接的文件下载链接。我们假设你已经登录了金山文档,并进入了包含所有目标文档的某个页面,比如“我的文档”首页,或者一个共享文件夹。

注意:以下操作请在金山文档的网页版进行。不同时期金山文档的页面结构可能微调,需要灵活应对。

3.1 理解金山文档页面的数据加载方式

打开浏览器开发者工具(F12),切换到“网络(Network)”标签页,然后刷新或滚动你的金山文档页面。你会看到大量网络请求。其中,最关键的是那些返回文档列表数据的请求,通常是XHR或Fetch请求,响应体是JSON格式。

你需要找到那个负责加载文档列表的请求。可以通过过滤 XHR / Fetch 请求,并观察 Preview Response 内容来判断。这个请求的URL可能包含 list files items 等关键词。找到它后,记录下它的 Request URL Request Method (通常是GET)以及重要的 Request Headers (如 Authorization , Cookie 等)。

然而,直接让Python去模拟这个请求可能比较复杂,因为它依赖于当前浏览器的完整登录态。更简单的方法是: 直接让浏览器里的JS帮我们提取页面上已经渲染出来的链接。

3.2 编写并执行文档链接抓取脚本

以下是一个增强版的JS脚本,你可以在浏览器开发者工具的“控制台(Console)”标签页中直接粘贴运行。它做了几件事:获取当前页面所有文档卡片、提取链接和标题、处理滚动加载(懒加载)、并生成一个便于Python处理的输出。

(function() {
    // 配置:要收集的文档类型对应的选择器(根据实际页面结构调整)
    const docItemSelector = '.docs-list-item, .file-item, [role="listitem"]'; // 多个可能的选择器
    // 目标域名,用于过滤非金山文档的链接(如果有)
    const targetDomain = 'kdocs.cn';

    let allItems = [];
    let retryCount = 0;
    const maxRetry = 5;

    /**
     * 滚动页面以触发懒加载
     */
    function scrollToLoad() {
        return new Promise((resolve) => {
            const scrollHeight = document.documentElement.scrollHeight;
            const clientHeight = document.documentElement.clientHeight;
            const scrollStep = clientHeight * 0.8;
            let scrolledHeight = 0;

            function scroll() {
                window.scrollBy(0, scrollStep);
                scrolledHeight += scrollStep;

                // 等待一小段时间让新内容加载
                setTimeout(() => {
                    const newScrollHeight = document.documentElement.scrollHeight;
                    // 如果还能继续滚动,或者页面高度增加了(说明有新内容加载)
                    if (scrolledHeight < scrollHeight || newScrollHeight > scrollHeight) {
                        scroll();
                    } else {
                        // 如果滚动到底且高度没变化,尝试等待再检查一次,防止网络延迟
                        setTimeout(() => {
                            const finalScrollHeight = document.documentElement.scrollHeight;
                            if (finalScrollHeight > scrollHeight) {
                                // 高度又变了,继续滚
                                scrollHeight = finalScrollHeight;
                                scroll();
                            } else {
                                resolve();
                            }
                        }, 1000);
                    }
                }, 500); // 滚动后等待时间,可根据网络调整
            }
            scroll();
        });
    }

    /**
     * 主收集函数
     */
    async function collectLinks() {
        console.log('开始收集文档链接...');
        // 先滚动加载所有可能的内容
        await scrollToLoad();
        console.log('页面滚动加载完成。');

        // 获取所有文档元素
        const items = document.querySelectorAll(docItemSelector);
        console.log(`当前找到 ${items.length} 个文档元素。`);

        if (items.length === 0 && retryCount < maxRetry) {
            console.warn(`未找到文档元素,尝试调整选择器或页面。第${retryCount + 1}次重试...`);
            // 可以尝试其他常见选择器
            const alternativeSelectors = [
                'div[data-testid="file-list-item"]',
                'a[href*="/l/"]',
                '.list-item'
            ];
            for (let selector of alternativeSelectors) {
                const altItems = document.querySelectorAll(selector);
                if (altItems.length > 0) {
                    console.log(`使用备选选择器 "${selector}" 找到 ${altItems.length} 个元素。`);
                    items = altItems;
                    break;
                }
            }
            retryCount++;
        }

        for (let item of items) {
            try {
                // 寻找链接:优先找<a>标签,其次找包含onclick或者data-link属性的元素
                let linkElement = item.querySelector('a');
                let href = '';
                let title = '';

                if (linkElement && linkElement.href) {
                    href = linkElement.href;
                    // 提取标题:从链接的title属性、内部文本、或者相邻的标题元素中获取
                    title = linkElement.title || linkElement.textContent.trim() || item.querySelector('.title, .name, [data-testid="file-name"]')?.textContent.trim();
                } else {
                    // 如果没有<a>标签,可能链接是通过JS触发的,尝试从data属性或onclick中解析
                    const dataLink = item.getAttribute('data-link') || item.getAttribute('data-url');
                    if (dataLink) {
                        href = dataLink.startsWith('http') ? dataLink : `https://${targetDomain}${dataLink.startsWith('/') ? dataLink : '/' + dataLink}`;
                    } else {
                        // 尝试解析onclick事件中的链接(常见于SPA应用)
                        const onclickAttr = item.getAttribute('onclick');
                        if (onclickAttr && onclickAttr.includes('/l/')) {
                            const match = onclickAttr.match(/(https:\/\/[^\s'"]*\/l\/[^\s'"]*)/);
                            if (match) href = match[0];
                        }
                    }
                    title = item.querySelector('.title, .name, .file-name')?.textContent.trim() || item.textContent.trim().split('\n')[0];
                }

                // 过滤和清洗
                if (!href || !href.includes(targetDomain) || !href.includes('/l/')) {
                    continue; // 不是目标文档链接
                }
                // 清洗标题,移除多余空白和换行
                title = title.replace(/\s+/g, ' ').trim();
                // 生成一个安全的文件名
                const safeFileName = title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100); // 限制长度

                allItems.push({
                    url: href,
                    title: title,
                    safeName: safeFileName
                });

            } catch (e) {
                console.error('处理单个元素时出错:', e, item);
            }
        }

        // 去重(根据URL)
        const uniqueItems = [];
        const seenUrls = new Set();
        for (const item of allItems) {
            // 标准化URL,去除可能的查询参数和哈希
            const normalizedUrl = new URL(item.url).origin + new URL(item.url).pathname;
            if (!seenUrls.has(normalizedUrl)) {
                seenUrls.add(normalizedUrl);
                uniqueItems.push(item);
            }
        }

        console.log(`收集完成,共获得 ${uniqueItems.length} 个唯一文档链接。`);
        // 将结果以JSON格式输出到控制台,并复制到剪贴板
        const outputJson = JSON.stringify(uniqueItems, null, 2);
        console.log('文档列表JSON:');
        console.log(outputJson);

        // 尝试复制到剪贴板(需要用户交互,这里仅提供提示)
        navigator.clipboard.writeText(outputJson).then(() => {
            console.log('文档列表已复制到剪贴板。');
        }).catch(err => {
            console.log('自动复制失败,请手动复制上面的JSON数据。');
        });

        return uniqueItems;
    }

    // 执行并返回结果
    return collectLinks();
})();

脚本使用要点与避坑指南:

  1. 选择器是关键 docItemSelector 变量中的选择器需要根据金山文档的实际页面HTML结构进行调整。如果运行后 items.length 为0,你需要打开开发者工具的“元素(Elements)”面板,仔细查看一个文档卡片对应的HTML结构,找到其最外层的、具有唯一性的CSS选择器。
  2. 懒加载处理 :现代网页大量使用滚动懒加载。脚本中的 scrollToLoad 函数会模拟滚动到底部,触发更多内容加载。等待时间( setTimeout 中的500ms和1000ms)可能需要根据你的网络速度调整。
  3. 链接提取逻辑 :脚本尝试了多种方式提取链接( <a> 标签、 data-* 属性、 onclick 事件),以应对不同的页面渲染方式。你需要观察金山文档点击文档名称后的跳转链接模式,通常是包含 /l/ (代表link)的路径。
  4. 结果处理 :脚本最终会将去重后的文档信息以JSON格式打印在控制台,并尝试复制到剪贴板。这个JSON数组就是交给Python的“情报”。

4. 实战第二步:用Python构建稳健的批量下载器

拿到JSON格式的文档列表后,我们就可以在本地用Python大展身手了。Python脚本的核心任务是:解析JSON,遍历每个文档链接,模拟访问并提取出真实的文件下载地址,最后并发地下载到本地。

4.1 环境准备与依赖安装

首先,确保你的Python环境(建议3.8+)已安装必要的库。我们将使用 requests 处理网络请求, beautifulsoup4 解析HTML, tqdm 显示进度条, aiohttp asyncio 用于异步并发下载(可选,用于大幅提升速度)。

pip install requests beautifulsoup4 tqdm
# 如果需要异步高速下载,额外安装
pip install aiohttp aiodns

4.2 核心脚本解析:同步下载版本

我们先实现一个逻辑清晰、易于调试的同步版本。这个版本会按顺序下载文件,适合理解整个流程。

import os
import json
import time
import requests
from urllib.parse import urlparse, unquote
from bs4 import BeautifulSoup
from tqdm import tqdm
import re

class KdocsBatchDownloader:
    def __init__(self, list_json_path, output_dir='./downloaded_kdocs'):
        """
        初始化下载器
        :param list_json_path: 从浏览器JS脚本获取的JSON文件路径或JSON字符串
        :param output_dir: 文件输出目录
        """
        self.output_dir = output_dir
        os.makedirs(self.output_dir, exist_ok=True)

        # 加载文档列表
        if os.path.exists(list_json_path):
            with open(list_json_path, 'r', encoding='utf-8') as f:
                self.doc_list = json.load(f)
        else:
            # 假设传入的是JSON字符串
            self.doc_list = json.loads(list_json_path)

        # 配置请求头,模拟浏览器
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        }
        # 初始化一个Session,可以保持部分状态(如Cookies)
        self.session = requests.Session()
        self.session.headers.update(self.headers)

    def _extract_download_url(self, doc_url):
        """
        核心函数:从文档分享页中解析出真实的下载链接。
        金山文档的下载链接通常隐藏在页面JS或特定的网络请求中。
        这里采用解析HTML和模拟点击的思路。
        """
        try:
            print(f"正在解析: {doc_url}")
            resp = self.session.get(doc_url, timeout=15)
            resp.raise_for_status()
            soup = BeautifulSoup(resp.text, 'html.parser')

            # 方法1: 查找包含下载信息的meta标签或JS变量(常见于SPA)
            download_url = None
            for script in soup.find_all('script'):
                if script.string and 'downloadUrl' in script.string:
                    # 尝试匹配类似 `downloadUrl: "https://..."`
                    match = re.search(r'downloadUrl["\']?\s*:\s*["\']([^"\']+)["\']', script.string)
                    if match:
                        download_url = match.group(1)
                        break

            # 方法2: 查找“下载”按钮的链接(传统页面)
            if not download_url:
                download_btn = soup.find('a', href=True, text=re.compile(r'下载|导出|Download', re.I))
                if download_btn:
                    download_url = download_btn['href']
                    # 处理相对路径
                    if download_url.startswith('/'):
                        parsed_url = urlparse(doc_url)
                        download_url = f"{parsed_url.scheme}://{parsed_url.netloc}{download_url}"

            # 方法3: 直接匹配常见的下载接口模式(通过分析网络请求获得)
            # 例如,金山文档的下载接口可能形如:`/api/v3/documents/{file_id}/export`
            if not download_url:
                # 尝试从页面URL中提取文件ID
                file_id_match = re.search(r'/l/([a-zA-Z0-9]+)', doc_url)
                if file_id_match:
                    file_id = file_id_match.group(1)
                    # 这是一个假设的接口,实际需要抓包分析确认
                    potential_api_url = f"https://www.kdocs.cn/api/v3/documents/{file_id}/export?type=pdf" # 假设导出PDF
                    # 可以尝试请求这个API,但可能需要额外的认证头
                    # 这里仅作为思路展示,不直接请求
                    pass

            if download_url:
                print(f"  找到下载链接: {download_url[:100]}...")
                return download_url
            else:
                print(f"  警告:未在页面中找到明确的下载链接。")
                # 可以尝试返回文档的打印页或预览页,有时可以直接保存
                return None

        except requests.exceptions.RequestException as e:
            print(f"  请求出错: {e}")
            return None
        except Exception as e:
            print(f"  解析过程出错: {e}")
            return None

    def _download_file(self, url, file_path):
        """下载文件到指定路径"""
        try:
            # 流式下载,适合大文件
            with self.session.get(url, stream=True, timeout=30) as r:
                r.raise_for_status()
                total_size = int(r.headers.get('content-length', 0))
                with open(file_path, 'wb') as f:
                    if total_size == 0:
                        f.write(r.content)
                    else:
                        # 使用tqdm显示进度条
                        with tqdm(total=total_size, unit='B', unit_scale=True, desc=os.path.basename(file_path), leave=False) as pbar:
                            for chunk in r.iter_content(chunk_size=8192):
                                if chunk:
                                    f.write(chunk)
                                    pbar.update(len(chunk))
            return True
        except Exception as e:
            print(f"  下载失败: {e}")
            return False

    def run_sync(self):
        """同步执行下载任务"""
        success_count = 0
        fail_list = []

        for idx, doc_info in enumerate(self.doc_list, 1):
            doc_url = doc_info.get('url')
            doc_title = doc_info.get('title', f'doc_{idx}')
            safe_name = doc_info.get('safeName', doc_title)

            if not doc_url:
                print(f"[{idx}/{len(self.doc_list)}] 跳过:无有效URL")
                continue

            print(f"\n[{idx}/{len(self.doc_list)}] 处理: {doc_title}")
            # 步骤1: 获取真实下载地址
            download_url = self._extract_download_url(doc_url)

            if not download_url:
                print(f"  跳过,无法获取下载地址。")
                fail_list.append({'title': doc_title, 'url': doc_url, 'reason': '无法解析下载链接'})
                continue

            # 步骤2: 确定文件扩展名和保存路径
            # 从下载链接或Content-Type推断文件类型
            parsed_download_url = urlparse(download_url)
            path = unquote(parsed_download_url.path)
            # 尝试从路径中获取扩展名
            ext_match = re.search(r'\.(pdf|docx?|xlsx?|pptx?|txt|md)$', path, re.I)
            if ext_match:
                file_ext = ext_match.group(1).lower()
            else:
                # 默认扩展名,可以根据需要修改
                file_ext = 'pdf' # 假设默认下载为PDF

            # 构建文件名,避免重复
            base_filename = f"{safe_name}.{file_ext}"
            file_path = os.path.join(self.output_dir, base_filename)
            counter = 1
            while os.path.exists(file_path):
                base_filename = f"{safe_name}_{counter}.{file_ext}"
                file_path = os.path.join(self.output_dir, base_filename)
                counter += 1

            # 步骤3: 执行下载
            print(f"  开始下载 -> {base_filename}")
            if self._download_file(download_url, file_path):
                print(f"  下载成功: {base_filename}")
                success_count += 1
            else:
                print(f"  下载失败: {base_filename}")
                fail_list.append({'title': doc_title, 'url': download_url, 'reason': '下载请求失败'})

            # 礼貌性延迟,避免请求过快被封
            time.sleep(1)

        # 总结报告
        print(f"\n{'='*50}")
        print(f"下载完成!")
        print(f"成功: {success_count} / 总数: {len(self.doc_list)}")
        if fail_list:
            print(f"失败列表:")
            for fail in fail_list:
                print(f"  - {fail['title']}: {fail['reason']}")
        print(f"文件保存在: {os.path.abspath(self.output_dir)}")

# 使用示例
if __name__ == '__main__':
    # 方式1: 从文件读取JS脚本输出的JSON
    downloader = KdocsBatchDownloader('./doc_list.json')
    # 方式2: 直接传入JSON字符串(从剪贴板粘贴过来)
    # json_str = '[{"url": "https://...", "title": "...", "safeName": "..."}, ...]'
    # downloader = KdocsBatchDownloader(json_str)

    downloader.run_sync()

4.3 核心脚本解析:异步高速下载版本

当文档数量成百上千时,同步下载的等待时间是不可接受的。我们可以使用 asyncio aiohttp 进行异步并发下载,效率提升十倍不止。

import aiohttp
import asyncio
from aiohttp import ClientTimeout, TCPConnector
import aiofiles

class KdocsBatchDownloaderAsync(KdocsBatchDownloader):
    """继承同步下载器,重写下载部分为异步"""

    def __init__(self, list_json_path, output_dir='./downloaded_kdocs', max_concurrent=5):
        super().__init__(list_json_path, output_dir)
        self.max_concurrent = max_concurrent # 最大并发数
        self.semaphore = asyncio.Semaphore(max_concurrent)

    async def _async_download_file(self, session, url, file_path, pbar):
        """异步下载单个文件"""
        async with self.semaphore: # 控制并发量
            try:
                timeout = ClientTimeout(total=60, connect=30) # 设置超时
                async with session.get(url, timeout=timeout) as response:
                    response.raise_for_status()
                    total_size = int(response.headers.get('content-length', 0))

                    async with aiofiles.open(file_path, 'wb') as f:
                        if total_size == 0:
                            content = await response.read()
                            await f.write(content)
                            if pbar:
                                pbar.update(len(content))
                        else:
                            downloaded = 0
                            async for chunk in response.content.iter_chunked(8192):
                                if chunk:
                                    await f.write(chunk)
                                    downloaded += len(chunk)
                                    if pbar:
                                        pbar.update(len(chunk))
                return True, None
            except Exception as e:
                return False, str(e)

    async def _process_single_doc(self, session, doc_info, idx, total, pbar):
        """异步处理单个文档:解析链接并下载"""
        doc_url = doc_info.get('url')
        doc_title = doc_info.get('title', f'doc_{idx}')
        safe_name = doc_info.get('safeName', doc_title)

        if not doc_url:
            return {'success': False, 'title': doc_title, 'reason': '无URL'}

        # 解析下载链接(这部分目前是同步的,可以后续也改为异步,但解析通常很快)
        download_url = self._extract_download_url(doc_url) # 注意:这里调用了同步方法
        if not download_url:
            return {'success': False, 'title': doc_title, 'reason': '无法解析下载链接'}

        # 确定文件名
        parsed_url = urlparse(download_url)
        path = unquote(parsed_url.path)
        ext_match = re.search(r'\.(pdf|docx?|xlsx?|pptx?|txt|md)$', path, re.I)
        file_ext = ext_match.group(1).lower() if ext_match else 'pdf'
        base_filename = f"{safe_name}.{file_ext}"
        file_path = os.path.join(self.output_dir, base_filename)
        counter = 1
        while os.path.exists(file_path):
            base_filename = f"{safe_name}_{counter}.{file_ext}"
            file_path = os.path.join(self.output_dir, base_filename)
            counter += 1

        # 异步下载
        success, error_msg = await self._async_download_file(session, download_url, file_path, pbar)
        if success:
            return {'success': True, 'title': doc_title, 'file': base_filename}
        else:
            return {'success': False, 'title': doc_title, 'reason': f'下载失败: {error_msg}'}

    async def run_async(self):
        """异步执行主函数"""
        connector = TCPConnector(limit=self.max_concurrent, ssl=False) # 限制总连接数
        timeout = ClientTimeout(total=300) # 总超时时间
        async with aiohttp.ClientSession(headers=self.headers, connector=connector, timeout=timeout) as session:
            tasks = []
            results = []
            fail_list = []
            success_count = 0

            print(f"开始异步下载 {len(self.doc_list)} 个文档,并发数: {self.max_concurrent}")

            # 创建总进度条
            with tqdm(total=len(self.doc_list), desc="总进度") as pbar_total:
                # 为每个文档创建处理任务
                for idx, doc_info in enumerate(self.doc_list, 1):
                    task = asyncio.create_task(self._process_single_doc(session, doc_info, idx, len(self.doc_list), pbar_total))
                    tasks.append(task)

                # 等待所有任务完成,并收集结果
                for task in asyncio.as_completed(tasks):
                    result = await task
                    results.append(result)
                    if result['success']:
                        success_count += 1
                    else:
                        fail_list.append(result)

            # 输出报告
            print(f"\n{'='*50}")
            print(f"异步下载完成!")
            print(f"成功: {success_count} / 总数: {len(self.doc_list)}")
            if fail_list:
                print(f"失败列表:")
                for fail in fail_list:
                    print(f"  - {fail['title']}: {fail['reason']}")

# 异步使用示例
async def main_async():
    downloader = KdocsBatchDownloaderAsync('./doc_list.json', max_concurrent=10)
    await downloader.run_async()

if __name__ == '__main__':
    # 运行异步版本
    asyncio.run(main_async())

4.4 关键环节的深度解析与避坑

1. 下载链接解析的“黑盒”挑战 _extract_download_url 函数是整个脚本最脆弱的部分。金山文档的前端技术栈可能变化,下载按钮的定位方式、真实下载地址的隐藏位置都可能不同。上述脚本提供了三种解析思路:

  • 分析JS变量 :最有效。在页面HTML的 <script> 标签里,搜索 downloadUrl fileUrl exportUrl 等关键词,用正则表达式提取。这需要你仔细查看页面源码。
  • 模拟点击按钮 :较通用。找到“下载”或“导出”按钮的 <a> 标签或 <button> ,获取其 href 属性或 onclick 事件里的URL。但按钮可能被动态生成。
  • 网络请求抓包 :最可靠。在开发者工具的“网络(Network)”面板,手动点击一个文档的下载按钮,观察哪个请求最终返回了文件流( Content-Type application/pdf application/octet-stream 等)。然后让Python脚本直接模拟这个请求。这需要分析请求的URL、Headers(尤其是 Authorization Referer 等)和可能的请求体。

提示:如果遇到无法解析的情况,一个退而求其次的方案是,直接请求文档的“打印页”或“预览页”,然后保存为PDF。很多浏览器的“打印”功能可以生成PDF。但这需要更复杂的模拟(如使用 pyppeteer playwright 控制无头浏览器),超出了本文“轻量”的范畴。

2. 会话(Session)与请求头(Headers)的重要性 使用 requests.Session() aiohttp.ClientSession() 可以自动管理Cookies,在连续请求中保持登录状态。此外,务必设置合理的 User-Agent Accept 等请求头,让服务器认为请求来自真实的浏览器,降低被反爬机制拦截的风险。

3. 并发控制与礼貌延迟 即使是异步版本,也通过 Semaphore 限制了最大并发数( max_concurrent )。过高的并发请求会对服务器造成压力,可能导致IP被暂时限制。在同步版本的循环中加入了 time.sleep(1) ,也是出于“礼貌”的考虑。对于公开服务,建议将并发数设置在5-10,延迟设置在0.5-1秒。

4. 文件名处理与重复规避 从网页提取的标题可能包含Windows/Linux文件名禁止的字符(如 \/:*?"<>| )。脚本中的 safeName 生成逻辑和 _download_file 方法里的文件名清洗( replace(/[<>:"/\\|?*]/g, '_') )至关重要。同时,检查本地是否已存在同名文件并自动添加后缀( _1 , _2 ),可以避免文件被意外覆盖。

5. 错误处理与日志记录 脚本中对网络请求、解析、下载等各个环节都进行了 try...except 捕获。将失败的任务记录到 fail_list ,并在最后统一输出,方便后续手动重试或排查问题。在生产环境中,可以考虑将日志写入文件,而不是仅仅打印到控制台。

5. 进阶策略与疑难排错

即使有了上面的脚本,在实际操作中你仍可能遇到各种问题。这里分享一些进阶策略和常见问题的排查思路。

问题1:JS脚本无法获取到文档列表, items.length 始终为0。

  • 原因 :页面结构已更新,选择器失效。
  • 解决
    1. 打开开发者工具,使用元素选择器(Ctrl+Shift+C)点击一个文档,查看其HTML结构。
    2. 找到能唯一标识文档列表项的最外层元素,观察其 class data-* 属性。
    3. 更新JS脚本中的 docItemSelector 变量。例如,可能变成了 'div[data-testid="file-list-item"]' '.list-container .item'
    4. 如果页面是无限滚动,确保 scrollToLoad 函数执行完毕,可以适当增加等待时间。

问题2:Python脚本能获取到分享页,但解析不出下载链接( download_url 为None)。

  • 原因 :下载链接的生成逻辑改变,或者需要额外的认证/参数。
  • 解决
    1. 手动抓包分析 :这是最根本的方法。在浏览器中手动完成一次下载操作,同时在开发者工具“网络(Network)”面板中,仔细查看从点击“下载”到文件开始下载之间,浏览器发送了哪些请求。重点关注:
      • 请求URL :通常是一个包含 export download file 等关键词的API端点。
      • 请求方法 :GET还是POST?
      • 请求头 :特别是 Authorization (Bearer token)、 Cookie Referer X-CSRF-Token 等。
      • 请求参数/请求体 :GET请求看URL的查询参数( ? 后面),POST请求看 Payload
    2. 模拟该请求 :在Python脚本中,用 session 直接向这个API URL发送请求,并带上你抓包看到的Headers和参数。成功的话,响应体可能就是文件流,或者是一个包含临时下载链接的JSON。
    3. 使用无头浏览器 :如果上述方法过于复杂(比如涉及动态Token、复杂JS计算),可以考虑使用 playwright selenium 直接控制浏览器点击下载按钮,并监听下载事件。这更稳定,但资源消耗大。

问题3:下载下来的文件损坏,或者不是预期的格式(如下载的是HTML而不是PDF)。

  • 原因 :下载链接可能只是一个中间页或错误页。或者服务器返回的不是文件流,而是重定向(302)到另一个地址。
  • 解决
    1. _download_file 函数中,检查响应的 Content-Type 头。如果是 text/html ,说明下错了。可以打印出响应内容的前几百个字符看看是什么。
    2. 确保你的下载请求设置了正确的 stream=True ,并且正确处理了重定向( requests 默认会处理,但有时需要手动设置 allow_redirects=True 并检查历史记录)。
    3. 从抓包获取的真实下载请求,通常响应头里会有 Content-Disposition: attachment; filename="xxx.pdf" ,这是一个很好的判断依据。

问题4:脚本运行一段时间后,请求开始返回403或429错误。

  • 原因 :触发了服务器的反爬机制(频率过高、行为异常)。
  • 解决
    1. 降低频率 :增加请求间隔( time.sleep ),减少并发数( max_concurrent )。
    2. 完善请求头 :确保 User-Agent 是常见的浏览器字符串,添加 Accept-Encoding , Accept-Language , Referer (通常设置为文档分享页的URL)等头。
    3. 使用代理IP池 :如果文档数量极大,可以考虑轮换使用不同的IP地址,但这需要额外的代理服务。
    4. 模拟更真实的行为 :在请求之间加入随机延迟,模拟人类操作的不确定性。

6. 项目总结与个人心得

回顾整个“金山文档批量下载”项目,它从一个简单的用户需求出发,演变成了一场涉及前端逆向、网络协议分析和后端自动化的综合实践。技术方案的选择——用JS在浏览器内部收集链接,再用Python进行外部批量处理——完美地结合了两种语言的优势,规避了各自在特定场景下的短板。

这个过程让我再次深刻体会到,面对一个具体的、看似简单的自动化需求时,最重要的不是急于写代码,而是 先花时间做“侦察” 。手动在浏览器里走一遍流程,用开发者工具观察每一个网络请求、每一个DOM变化,理解数据是如何加载、如何交互的。这半小时的“侦察”时间,能节省后面数小时的盲目试错。

对于 _extract_download_url 这个核心函数,我的经验是: 永远准备一个B计划 。正则表达式匹配JS变量可能今天有效,明天页面一改版就失效。所以,在脚本里设计多种解析策略(正则、CSS选择器、API模拟),并做好详细的错误日志记录,当主策略失败时,至少能知道失败在哪里,而不是无声无息地跳过。

最后,关于异步并发,我想说的是, “快”不是唯一目标,“稳”才是 。一开始我为了追求极致速度,将并发数设置到50,结果很快就收到了429(请求过多)错误。后来将并发数控制在5-10,并加上随机延迟,脚本反而能稳定运行数小时,完成上千个文件的下载。在自动化任务中,对目标服务器保持“礼貌”,是保证任务长期稳定运行的基本素养。

这个脚本的代码远非完美,金山文档的接口也可能随时变化。但它提供了一套完整的问题解决框架和可扩展的代码结构。当你下次遇到类似“批量下载XX网盘/在线文档”的需求时,希望这份记录能帮你快速找到思路,那就是它最大的价值了。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐