前阵子遇到一个挺典型的场景有同学需要把文库页面上能看到的正文整理成Word发给大家但页面上的文本既选不中、也复制不了直接下载又提示要会员。折腾了一晚上之后我搞定了两条路线——一条是js脚本下载百度文库生成word文本在浏览器里点一下就能拿到整体正文另一条是python爬取百度文库适合一次处理多篇自己权限范围内能看的内容。这篇就把两条路线的原理、完整代码和踩过的坑一次性说清楚。提前说明一个底线本文只讨论“页面上已经能正常阅读的内容”付费墙、防盗版机制不在讨论范围内也不会提供绕过方案。1. 文库文档的特殊形态为什么“另存为Word”这件事天生很难1.1 你看到的并不是一个完整文件很多人以为文库页面背后挂着一个原始Word或者PDF文件浏览器把那个文件渲染出来所以只要拿到文件地址就能下载。实际上大部分文库页面的文档在上传之后会被平台转换成网页端专用的切片格式再按页面分割、按文字坐标重新排版。你在浏览器里看到的内容本质是若干文字图层叠加在背景图片上的结果而不是一份连续的文档。这也解释了为什么“另存为”“打印”“下载原文档”这些按钮都是权限控制的状态。平台在服务端判断当前登录账号是否有下载权限有权限才把原始文件地址返回给浏览器没有权限时就算你在开发者工具里翻遍网络请求也找不到那份原始文件。所以技术上的第一课是与其找文件不如从已经渲染出来的页面文本下手。1.2 文本分散、懒加载与图层叠加真正进到网页源码层面你会发现正文被拆得非常碎。一段话往往不是一整行文字而是拆成几十个甚至上百个带坐标定位的文本节点每个节点可能只有一个字或者一个词。这样做一方面是为了复刻原文档的排版另一方面也让“选中-复制”变得很难连贯操作。懒加载是另一个麻烦。很多文库页面在第一次打开时只渲染当前视口附近的几行文字随着鼠标滚轮往下滚页面才继续向服务器请求后面的内容。如果你用普通的抓包工具或者直接请求网页地址拿到的HTML里往往只有前面一小部分正文后面的内容根本不在初始响应里。想完整抓下来要么模拟滚动要么直接操作浏览器把整个页面滚到底再提取。另外页面里还混着大量导航、推荐、评论、翻页按钮这类噪音节点。直接粗暴地把body.innerText复制出来会得到一堆互不相关的碎片顺序还经常是错乱的。1.3 页面保护机制的三层递进文库类站点的内容保护大体有三层第一层是禁止选中和复制。用CSS属性user-select: none屏蔽鼠标选择配合网页脚本拦截复制事件。这是最薄的一层因为这层保护只针对“普通用户操作”并不影响程序读取DOM里的文本节点。第二层是文本映射变换。页面在传输时并不直接下发真正的文字而是下发一组特殊字符编码再通过自定义字体把这些特殊字符渲染成正常文字。也就是说浏览器内存里DOM中的文本可能是一堆看起来毫无意义的符号而不是你肉眼看到的正文。这种情况比第一层麻烦得多因为即使提取到了文本节点得到的也不是真正的文字。第三层是图像化。整个页面用图片展示文字全部“画”在图片里面需要OCR才能转成文本速度慢、准确率也看脸。我的建议是只处理第一层的情况。页面文本在DOM里是真实可读的提取没有任何障碍遇到第二层和第三层说明平台已经采取了内容保护措施脚本直接停止并提示用户而不是去尝试破解字体映射或者批量OCR。1.4 明确边界哪些页面可以放心处理我给自己定了一个判断表每次写脚本前先核对一遍页面情况是否可以提取说明自己上传的文档可以权利在自己手里整理、转格式都没有问题平台明确标注免费阅读、允许复制的页面可以前提是按页面提供的方式使用已购买单篇或会员后能正常预览的页面可以在权限范围内提取但不要二次分发付费文档的收费部分不可以未授权内容脚本直接不处理需要登录才能查看的页面视情况已有合法登录态时可以但不要绕过登录保护边界想清楚了后面所有代码就都好写因为你的目标变得很清晰把已经真实呈现在浏览器里的文本整理成一份干净的Word。2. 思路一浏览器里执行JS脚本直接提取页面正文生成Word2.1 为什么先试JS而不是Python这是很多人的第一反应也是我自己最常用的方式。原因在于浏览器里已经帮你完成了几件麻烦事登录态已经带着、页面已经渲染完、懒加载已经触发了一部分、字体文件也已经加载。你只需要在现有页面上执行一段脚本从DOM里把文本捞出来再生成一个Word兼容格式的文件。相比PythonJS方案最大的优势是不需要处理Cookie、不需要模拟浏览器环境、代码也更短。缺点是一次只能处理一个页面不太适合大批量操作。2.2 脚本设计从DOM里拿文本的逻辑核心逻辑只有三步第一确定正文区域。页面上有大量噪音节点直接全页面遍历会把导航、推荐、页脚全部捞进来。我一般优先查找包含reader、content特征的容器找不到再退化到article、main标签最后才考虑整页遍历。第二递归收集文本节点。遍历DOM树只收集TEXT_NODE类型的节点同时过滤掉不可见元素。判断不可见要看display:none、visibility:hidden、opacity:0以及元素尺寸是否为零。很多人会漏掉getBoundingClientRect()返回宽高都为0的情况这类隐藏节点在导航栏里很常见。第三去重和拼接。文本节点会存在大量重复尤其是懒加载后同一段文字可能被不同的容器反复引用。用Set做去重同时把连续的空白字符压缩成单个空格避免Word里出现一大片空白缩进。遇到字体混淆页面时脚本需要做一个判断如果提取出来的文本中包含大量无法对应正常汉字的私有码位字符就说明这页文字做了映射变换应当立即停止而不是再去破解映射关系。2.3 一个可直接改的脚本示例下面这段脚本基于用户脚本扩展运行作用是将当前页面的正文提取为Word兼容的.doc文件。match里的域名需要替换成你实际要处理的文库页面域名。// UserScript // name 文库页面正文导出为Word仅限可公开阅读页面 // namespace 个人工具 // match https://你的文库域名/任意路径/* // grant none // /UserScript (function () { use strict; function isVisible(el) { const style window.getComputedStyle(el); if (style.display none || style.visibility hidden || style.opacity 0) { return false; } const rect el.getBoundingClientRect(); return rect.width 0 rect.height 0; } function collectTextNodes(root, parts, seen) { if (!root || typeof root.childNodes undefined) { return; } root.childNodes.forEach(function (node) { if (node.nodeType Node.TEXT_NODE) { const text node.textContent.replace(/\s/g, ).trim(); if (text !seen.has(text)) { seen.add(text); parts.push(text); } } else if (node.nodeType Node.ELEMENT_NODE) { if (isVisible(node)) { collectTextNodes(node, parts, seen); } } }); } function extract() { const seen new Set(); const parts []; const containers document.querySelectorAll( [class*reader], [class*content], article, main ); if (containers.length) { containers.forEach(function (c) { collectTextNodes(c, parts, seen); }); } else { collectTextNodes(document.body, parts, seen); } return parts; } function escapeHtml(text) { return text .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;); } function buildWord(texts, title) { const safeTitle title.replace(/[\\/:*?|]/g, _); const bodyHtml texts .map(function (t) { return p escapeHtml(t) /p; }) .join(); const html html xmlns:ourn:schemas-microsoft-com:office:office xmlns:wurn:schemas-microsoft-com:office:word xmlnshttp://www.w3.org/1999/xhtmlheadmeta charsetutf-8 title safeTitle /title/headbody bodyHtml /body/html; const blob new Blob([\ufeff, html], { type: application/msword }); const a document.createElement(a); a.href URL.createObjectURL(blob); a.download safeTitle .doc; document.body.appendChild(a); a.click(); setTimeout(function () { URL.revokeObjectURL(a.href); }, 3000); } function main() { const texts extract(); const title document.title.replace(/-文库.*$/, ); if (!texts.length) { alert(没有提取到可用的正文文本可能页面是图片版或内容尚未加载。); return; } buildWord(texts, title); } main(); })();这段脚本生成的.doc文件实际上是一个带Word命名空间的HTML文件。Word和WPS都能正常打开排版虽然不是严格意义上的官方.docx但对于“把页面文字整理出来”这个需求来说已经足够干净。2.4 实际使用时的三个细节第一先滚动完再执行脚本。懒加载页面必须让后端把所有文字都吐出来你才可能抓全。我建议在页面上快速滚动到最底部再滚回顶部等几秒钟然后触发脚本。脚本本身不负责滚动因为滚动节奏和页面加载速度有关写在脚本里反而容易出错。第二一次只处理一个文档。虽然match可以设置成匹配整个文库域名但我个人不建议做“站点内一键抓全部”这种事。一方面请求量太大会被平台限制另一方面也容易越过合规边界。手动进入一个文档页执行一次拿到一份Word这是最稳妥的用法。第三字体混淆页面直接放弃。如果脚本提取出来的文本里有大量不可读字符说明页面已经不是“用户可自由复制的文本形态”这时候不要试图去解码字体映射那不是正当技术需求继续操作只会带来版权风险。3. 思路二Python自动抓取页面文本并生成Word文档3.1 两条技术路线怎么选Python方案适合批量处理。想对一批同类页面做文本整理、建索引、做内容比对手动开浏览器一个一个点脚本就不太现实了。Python这边有两条常见路线选型依据其实很简单技术路线适用场景环境要求核心风险requests直接请求正文直接渲染在HTML里或藏在页面内嵌JSON中Python加requests、BeautifulSoup页面结构变化快、容易遇到登录限制Selenium渲染后提取页面需要执行大量JavaScript、内容动态加载浏览器驱动资源占用高、运行速度慢我通常先用requests试不行再上Selenium。毕竟Selenium要启动一个完整浏览器内核速度慢很多能在HTML层面解决的问题没必要上重型工具。3.2 先看页面源码再决定解析策略不管用哪种Python方案动手写代码之前都建议先做一件事打开目标文库页面右键查看网页源代码然后搜索页面里肉眼能看到的一句话。根据搜索结果基本能判断出三种情况第一种搜到的文本直接出现在HTML标签里。这是最理想的情况说明正文就在响应中可以用BeautifulSoup按标签和类名提取。第二种搜到的文本出现在一段密集的JavaScript代码里通常是某个JSON字段的值。这说明页面是把数据注入到前端脚本变量中再由JavaScript动态渲染。这时候要写正则把JSON段抠出来再用json.loads解析。第三种搜不到任何正文文本只有>import re import json import requests from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } def extract_by_html(html): soup BeautifulSoup(html, html.parser) lines [] # 这里的选择器只是示例需要根据目标页面源码里的实际结构替换 for node in soup.select([class*reader-word-layer], [class*readerWordLayer]): text node.get_text(, stripTrue) if text: lines.append(text) return lines def extract_by_json(html): lines [] pattern rwindow\.pageData\s*\s*({.*?}); match re.search(pattern, html, re.S) if not match: return lines try: data json.loads(match.group(1)) except json.JSONDecodeError: return lines # data字段结构以实际页面为准这里只写一个通用遍历方式 for block in data.get(content, []): if isinstance(block, dict): text re.sub(r[^], , block.get(text, )) if text: lines.append(text) return lines def fetch(url): resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding lines extract_by_html(resp.text) if len(.join(lines)) 200: lines extract_by_json(resp.text) return lines if __name__ __main__: result fetch(https://你的目标文库页面地址) for line in result: print(line)fetch函数里做了一个兜底如果HTML标签提取到的内容太少就尝试从页面内嵌JSON里再找一遍。这个兜底逻辑在实践中非常有用因为很多文库页面两者都有。需要注意页面结构调整是一个非常常见的事。类名一旦改变上面的选择器就会失效。所以写这类脚本时一定要把选择器集中放在函数头部方便下次改。3.4 Selenium渲染方案示例如果页面内容需要不断滚动加载requests拿不到完整内容就得用Selenium。下面的示例会打开一个页面、滚动触发懒加载、然后提取文本节点import time from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By def fetch_with_selenium(url): options Options() options.add_argument(--headlessnew) options.add_argument(--disable-gpu) options.page_load_strategy eager driver webdriver.Chrome(optionsoptions) driver.set_page_load_timeout(30) driver.get(url) driver.implicitly_wait(5) # 模拟滚动触发懒加载 for _ in range(15): driver.execute_script(window.scrollBy(0, 800);) time.sleep(0.8) lines [] # 实际选择器需要根据页面结构调整 for element in driver.find_elements(By.CSS_SELECTOR, p, [class*reader-word-layer]): text element.text.strip() if text and text not in lines: lines.append(text) text_all .join(lines) if len(text_all) 200: print(提取内容过少可能遇到登录、验证或页面结构调整) driver.quit() return lines滚动次数支持配置遇到内容很长的文档就调大次数。如果页面弹出登录框导致正文根本没渲染出来提取结果会非常少这时候脚本会打印提示。我不建议绕过登录正确的做法是检查自己的账号状态、Cookie或访问权限。3.5 用python-docx生成排版干净的Word文本拿到之后生成Word是最后一公里。python-docx是一个很成熟的库生成.docx文件非常稳定。下面是一个带中文字体设置的示例from docx import Document from docx.shared import Pt from docx.oxml.ns import qn def set_run_font(run, font_name微软雅黑, size12): run.font.name font_name run.font.size Pt(size) run._element.rPr.rFonts.set(qn(w:eastAsia), font_name) def lines_to_docx(lines, title, output_pathoutput.docx): doc Document() doc.add_heading(title, level1) for line in lines: if not line: continue paragraph doc.add_paragraph(line) for run in paragraph.runs: set_run_font(run) doc.save(output_path) return output_path这里有个小坑python-docx默认的字体设置只对西文生效中文需要额外用qn(w:eastAsia)设置东亚字体否则生成的Word在别人的电脑上打开可能会变成默认字体正文看起来会比较乱。如果你的提取结果是“整页文本”而不是“段落列表”可以先按空行切段raw_text \n.join(lines) paragraphs [p.strip() for p in raw_text.split(\n\n) if p.strip()]这样得到的段落结构更接近原文档的阅读顺序。4. 两套方案怎么选实测下来我这样判断4.1 方案对比JS脚本适合单篇、需要登录态、页面已经渲染完整的场景。环境要求是浏览器加脚本扩展提取粒度是渲染后的DOM主要风险是页面结构调整时选择器需要微调。Python requests适合正文直接渲染在HTML或JSON中的场景。环境要求是Python库提取粒度是HTML层面主要风险是登录限制、请求频率限制。Python Selenium适合动态渲染、懒加载强依赖浏览器的场景。环境要求是浏览器驱动提取粒度接近浏览器结果主要风险是资源占用高、运行速度慢。三者不是替代关系而是互补关系。我最常用的组合是先看一眼页面源码结构能用requests就用requests页面太动态就上Selenium需要人工判断的个性化文档直接开浏览器执行JS脚本。4.2 我实测中踩到的几个坑第一个坑是提取到了JavaScript变量而不是正文。有一次我写正则匹配页面内嵌数据匹配范围没控制好结果把一大段JS代码当成文本提取了出来。后来我在解析函数里加了一个过滤器如果文本行里包含function、var、这类特征直接丢弃。节省了大量清理时间。第二个坑是正文顺序乱掉。DOM遍历顺序有时候不等于阅读顺序特别是页面里带了大量绝对定位的文本节点时先抓到的是页面底部的内容再抓到顶部的内容。我的经验是优先找行容器而不是单词容器按行容器提取后再拼接顺序会稳定很多。第三个坑是中文乱码和Word文件打不开。requests请求时如果不设置resp.encoding resp.apparent_encoding中文很容易乱码。生成Word兼容HTML时如果漏了\ufeff这个BOM字符Word打开可能识别成错误的编码格式显示一堆乱码。两个问题都是细节但踩一次就会记住。第四个坑是重复内容。懒加载页面滚动之后之前抓到的文本还可能再次出现在新的容错节点里导致段落重复。我的解决方法是维护一个Set每提取到一句就去重一次。第五个坑是被平台限制访问。请求频率过高的确会触发对方限制尤其是不带登录态、同一IP短时间连续访问几十个页面的时候。我处理批量需求时会在两次请求之间加随机延迟比如time.sleep(2 random.random() * 3)并且控制单个批次的数量。被限制之后立刻停止不要换IP硬闯。4.3 我的选择标准纯自用一篇文档我会直接浏览器执行JS脚本因为最省事登录态和页面渲染都不用管。批量处理、页面结构简单用Python requests加BeautifulSoup速度快、内存占用小。页面高度动态、内容需要滚动加载用Selenium配合滚动和隐式等待。遇到要登录、要验证、要付费的情况直接停下。这不是技术上的成败问题是使用边界问题。5. 合规前提比技术细节更重要5.1 先想清楚“提取之后打算干什么”同样是“把网页文本保存成Word”这个动作用在不同的地方性质完全不一样。自己上传到文库的文档你本来就有完整权利怎么转格式都行平台明确标注可免费阅读、允许复制的页面你提取到本地整理、做学习笔记这也是正常需求购买了单篇下载权限之后在权限范围内使用再把文字整理成方便阅读的格式也说得通。但如果提取的是付费文档的收费部分或者把整站内容批量采集下来再做二次传播这就明显越界了。脚本本身不会判断你的目的是什么所以这个边界只能由使用者自己控制。5.2 可以安心做的几种用途我实际工作中用这套东西最多的场景是整理技术方案和做学习笔记。遇到一篇有价值的公开文档页面排版很乱没法直接使用但文字内容是对我有帮助的就提取成Word后重新排版、标注重点、补充自己的备注。整个过程只在自己的电脑里进行不涉及任何传播行为。另一个合法用途是处理自己上传的文档。早期知识库不完善有一些文件只传到了文库而没有保留本地原件后来需要把内容迁移出来这时候用脚本从前台页面把正文提取回来再生成Word存档。既不需要找平台要原始文件也不会碰任何人的版权。5.3 我个人给自己定的三条底线第一只处理浏览器里已经能完整看到的内容。看到多少就处理多少权限之外的部分绝不参与。第二不破解字体混淆、不绕过VIP下载限制、不批量扫站。这三类行为无论目的是什么都会带来麻烦。第三控制访问频率尊重平台限制。被拦截就停下来换时间再访问绝不继续对抗。守住这三条之后JS脚本和Python脚本都只是日常效率工具不会变成麻烦的源头。最后再说一点个人体会。这两套方案的技术难度其实不大真正花时间的往往是把边界理清楚。如果你也遇到“页面看得见、复制选不中、又确实需要整理成Word”的文档先装上用户脚本把单篇流程跑通再根据需求决定要不要上Python批量方案。别图快脚本写得干净一点用途划得明确一点这套组合长期用下来会非常顺手。