頁正文提取、清洗與格式轉(zhuǎn)換實(shí)戰(zhàn))
從小說App跳來跳去的那幾年我最大的痛點(diǎn)就是書架散在各處這個平臺有書城權(quán)限那個平臺有獨(dú)家番外等到想離線看的時候又被導(dǎo)出限制和格式兼容折磨到?jīng)]脾氣。后來我一怒之下自己寫了一套下載腳本——輸入單本書或批量書單抓公開頁面的正文內(nèi)容自動清洗廣告和雜質(zhì)再按需輸出TXT、EPUB、HTML表格甚至CSV書單方便在WPS表格里管理和標(biāo)記進(jìn)度。這套東西我管它叫“番茄小說下載器”其實(shí)是個非常樸素的自建工具。它的核心不是“破解”什么而是把網(wǎng)頁正文提取、清洗和格式轉(zhuǎn)換這幾個環(huán)節(jié)串起來省去復(fù)制粘貼的重復(fù)勞動。適用人群也很明確經(jīng)常讀網(wǎng)頁版小說的朋友、需要把書轉(zhuǎn)進(jìn)閱讀器或手機(jī)里離線看的人、以及剛接觸Python想練手爬蟲和文本處理的新手。這篇文章我會把從設(shè)計思路到完整腳本、再到典型問題排查全部攤開講保證你看完能直接上手抄作業(yè)。1. 內(nèi)容整體設(shè)計與思路拆解1.1 下載器的核心定位與功能邊界先明確一件事它叫“下載器”但它不是那種號稱“全網(wǎng)破解無限下載”的灰色工具。我設(shè)計的這個腳本目標(biāo)很單一——把已經(jīng)在瀏覽器里能正常閱讀的公開網(wǎng)頁正文提取下來轉(zhuǎn)成本地文件。也就是說網(wǎng)頁上能看到什么腳本抓的就是什么不繞過登錄、不破解付費(fèi)、不碰加密資源。為什么要做這個定位因?yàn)橐坏┥婕袄@過平臺規(guī)則就同時帶來版權(quán)風(fēng)險和技術(shù)風(fēng)險。版權(quán)風(fēng)險不用多說市面上很多小說下載器隔三差五被刪庫、被維權(quán)甚至開發(fā)者本人背上官司技術(shù)風(fēng)險更實(shí)際平臺的反爬措施會不斷升級你依賴的那些“破解接口”一旦失效整個工具就廢了。而我這個思路只處理公開頁面穩(wěn)定性反而更高因?yàn)槠胀ㄕ捻撁娴慕Y(jié)構(gòu)變動遠(yuǎn)沒有接口那么頻繁。從功能上看我把工具拆成了三層獲取層負(fù)責(zé)訪問網(wǎng)頁并拿到章節(jié)列表和正文內(nèi)容清洗層負(fù)責(zé)把頁面里的廣告、推薦語、腳本標(biāo)簽等噪音過濾掉輸出層負(fù)責(zé)按照TXT、EPUB、HTML、CSV等格式完成轉(zhuǎn)換。三層的設(shè)計邏輯是獨(dú)立的哪一層出問題就單獨(dú)修哪一層不會牽一發(fā)動全身。1.2 為什么不用現(xiàn)成下載器而要自建很多人會問網(wǎng)上不是有一堆現(xiàn)成的“小說下載器”嗎直接下載一個不就行了我的回答是看情況但更建議自己掌握原理。第一是安全問題?,F(xiàn)成下載器質(zhì)量參差不齊不少來路不明的綠色版、破解版里面塞了廣告插件或者挖礦腳本。你把一個不知名程序雙擊運(yùn)行等于把自己的電腦和賬號權(quán)限交出去。我見過有朋友為了下載一本小說安裝之后瀏覽器首頁被篡改靜默裝了三個全家桶最后只能重裝系統(tǒng)。第二是兼容性。平臺網(wǎng)頁結(jié)構(gòu)每隔幾個月就改版一次現(xiàn)成工具更新跟不上你下載到的往往就是一片“404”或者空白章節(jié)。而自建腳本里當(dāng)你發(fā)現(xiàn)抓不到內(nèi)容改一個CSS選擇器就能修好完全不需要等作者更新。第三是格式控制的靈活度。我希望最終的TXT是“書名/作者/正文”的干凈結(jié)構(gòu)希望生成的HTML能直接導(dǎo)入WPS表格做閱讀記錄希望EPUB在手機(jī)閱讀器里目錄不亂——這些定制需求市面上的工具很少能滿足。自建工具可以把轉(zhuǎn)換規(guī)則完全攥在自己手里。1.3 技術(shù)方案選型Python三件套足夠整套腳本我只用了Python標(biāo)準(zhǔn)庫加三個第三方庫Requests做HTTP請求BeautifulSoup4做HTML解析EbookLib負(fù)責(zé)生成EPUB。如果只先跑通文本和HTML標(biāo)準(zhǔn)庫里的urllib和html.parser也夠用但沒必要折磨自己這三個庫都是久經(jīng)考驗(yàn)的成熟方案。有人覺得爬蟲一定要上Scrapy、Playwright這些重型框架其實(shí)對于“抓網(wǎng)頁正文”這種需求殺雞不用牛刀。Scrapy適合大規(guī)模分布式抓取但我們要的是小工具不是爬蟲平臺Playwright適合處理動態(tài)渲染頁面但大部分小說正文頁是服務(wù)端渲染的直接Requests就能拿到完整HTML。除非遇到極少數(shù)用JavaScript異步加載正文的站點(diǎn)再針對性地處理也不遲。選擇這套技術(shù)棧還有一個原因它好解釋、好調(diào)試。Requests掛代理、BeautifulSoup選節(jié)點(diǎn)每一步都能打印出來看中間結(jié)果對新手來說學(xué)習(xí)曲線非常平滑。我下面給的腳本就是按這個思路來你先在命令行跑通再根據(jù)自己的需求改規(guī)則。2. 核心細(xì)節(jié)解析與實(shí)操要點(diǎn)2.1 書籍頁到章節(jié)列表的抓取邏輯下載一本小說通常分兩步先拿到整本書的章節(jié)列表再逐個章節(jié)抓正文。第一步的難點(diǎn)在于不同網(wǎng)站的章節(jié)列表頁結(jié)構(gòu)差異很大。有的是在書籍頁直接輸出所有章節(jié)鏈接的靜態(tài)列表這種最好處理有的則是點(diǎn)擊“展開全部章節(jié)”之后才加載這中間可能涉及一個異步接口還有的會做分頁每頁顯示20個章節(jié)需要翻頁后再拼接。實(shí)操里的通用策略是先打印頁面HTML看看結(jié)構(gòu)。用Requests拿到HTML之后一次性打印前5000個字符用肉眼判斷章節(jié)鏈接是直接寫在HTML里還是需要額外請求接口。如果是靜態(tài)列表那么用BeautifulSoup查找所有a標(biāo)簽再按href屬性中包含章節(jié)關(guān)鍵詞比如“chapter”、“c1”、“detail”等來過濾即可。拿到章節(jié)鏈接之后一定要做URL拼接?,F(xiàn)在很多頁面的href是相對路徑比如/book/123/456.html直接拿它去請求會報錯。正確的做法是用urllib.parse.urljoin讓基礎(chǔ)URL和相對路徑自動拼接成完整鏈接。這個細(xì)節(jié)看著小但實(shí)際運(yùn)行中一半的“抓不到章節(jié)”問題都出在這里。2.2 獲取正文與內(nèi)容清洗的細(xì)節(jié)正文頁面比列表頁簡單但也有很多坑。我建議直接用CSS選擇器定位正文容器比如查找div標(biāo)簽里面id或class包含content、article、chapter-content的那些節(jié)點(diǎn)。找到之后只用.get_text()提取文本就行HTML標(biāo)簽會被自動剝離。清洗是決定下載體驗(yàn)的關(guān)鍵環(huán)節(jié)也是最值得花心思的部分。頁面正文里往往混著這些噪音章節(jié)標(biāo)題、本站推薦語、作者有話說、廣告錨文本、上一章下一章的導(dǎo)航文字。清洗規(guī)則我按優(yōu)先級排序先把script、style、ins這類標(biāo)簽直接移除再用正則去掉形如“手機(jī)閱讀請訪問xxx”和“最新網(wǎng)址xxx”的一整行之后把連續(xù)兩個及以上的換行符壓縮成一個把全角空格、nbsp;統(tǒng)一替換成空字符串最后對每一行做一次長度判斷少于10個字且不含句號的孤立行直接丟棄。清洗規(guī)則不要一次寫得太狠否則容易誤刪正文段落。我的經(jīng)驗(yàn)是寧少刪勿多刪先跑一章看看效果再一條一條加過濾規(guī)則。每次規(guī)則改動之后最好對著同一章正文跑一次diff確認(rèn)沒有把有效內(nèi)容刪掉。2.3 輸出格式轉(zhuǎn)換的基本思路格式轉(zhuǎn)換這塊看起來花樣多本質(zhì)都是“把清洗后的結(jié)構(gòu)化文本重新包裝”。TXT最簡單把章節(jié)標(biāo)題和正文段落按順序?qū)懭胍粋€文本文件中間用空行隔開再加個UTF-8編碼聲明就好。需要注意Windows記事本對UTF-8的BOM識別問題如果直接在記事本打開看到中文亂碼就改成UTF-8 with BOM編碼。HTML轉(zhuǎn)換這里要多說一句因?yàn)楹芏嗳瞬恢馈癏TML格式轉(zhuǎn)換WPS表格”是什么需求。其實(shí)就是把下載記錄或書單生成成一個結(jié)構(gòu)化的HTML表格里面包含書名、作者、最新章節(jié)、下載時間、閱讀進(jìn)度這些字段然后用WPS表格直接打開這個HTML文件表格數(shù)據(jù)就會被自動識別成行列結(jié)構(gòu)方便篩選和排序。我實(shí)際使用中這個場景非常高頻。EPUB轉(zhuǎn)換稍微復(fù)雜一點(diǎn)它本質(zhì)上是一個ZIP壓縮包里面有mimetype、META-INF/container.xml、OEBPS/content.opf、OEBPS/toc.ncx這幾個核心文件外加若干HTML章節(jié)文件。用EbookLib庫可以省掉手寫XML的麻煩你只需要添加章節(jié)、添加元數(shù)據(jù)最后調(diào)用epub.write()就能生成。如果你還需要MOBI或AZW3建議轉(zhuǎn)換完EPUB之后用Calibre的命令行工具做二次轉(zhuǎn)換因?yàn)镸OBI的編碼和元數(shù)據(jù)處理比較特殊純Python做容易生成畸形文件。這個組合方案在電子書愛好者的圈子里非常成熟。3. 實(shí)操過程與核心環(huán)節(jié)實(shí)現(xiàn)3.1 環(huán)境準(zhǔn)備在命令行里依次執(zhí)行以下操作python -m venv novel_env source novel_env/bin/activate # Windows 下執(zhí)行 novel_env\Scripts\activate pip install requests beautifulsoup4 ebooklib lxml這里創(chuàng)建虛擬環(huán)境是為了防止依賴沖突強(qiáng)烈不建議直接往全局Python環(huán)境里裝。如果你平時用Anaconda也可以直接用conda create -n novel python3.9創(chuàng)建獨(dú)立環(huán)境。安裝完成之后建議先寫一個最小請求測試確認(rèn)網(wǎng)絡(luò)沒問題import requests url https://example.com/book/12345/ headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/ } resp requests.get(url, headersheaders, timeout10) print(resp.status_code) print(resp.encoding) print(resp.text[:200])這里設(shè)置timeout的作用是防止某個章節(jié)請求卡死導(dǎo)致整個腳本掛起。請求頭里的Referer要填網(wǎng)站的首頁或者書籍頁很多輕量級反爬會校驗(yàn)這個字段缺了它可能返回403。3.2 下載核心腳本目錄、正文、保存下面這個腳本是我實(shí)際在用的精簡版覆蓋了“獲取章節(jié)列表-抓取正文-保存TXT”的完整流程import re import time import requests from bs4 import BeautifulSoup from urllib.parse import urljoin BASE_URL https://example.com/book/12345/ HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/, } def get_chapter_list(url): resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, lxml) chapters [] for a in soup.select(a[href*chapter]): title a.get_text(stripTrue) if not title: continue link urljoin(url, a[href]) chapters.append((title, link)) seen set() unique_chapters [] for item in chapters: if item[1] not in seen: seen.add(item[1]) unique_chapters.append(item) return unique_chapters def clean_content(html): soup BeautifulSoup(html, lxml) for tag in soup.find_all([script, style, ins]): tag.decompose() content_div soup.find(div, class_re.compile(rcontent|article, re.I)) if not content_div: text soup.get_text() else: text content_div.get_text(\n) lines [re.sub(r\s, , line).strip() for line in text.split(\n)] lines [line for line in lines if len(line) 2] return \n.join(lines) def download_book(chapter_list, save_path): with open(save_path, w, encodingutf-8) as f: for idx, (title, link) in enumerate(chapter_list, 1): resp requests.get(link, headersHEADERS, timeout10) resp.encoding utf-8 body clean_content(resp.text) f.write(f\n\n{title}\n\n) f.write(body) print(f[{idx}/{len(chapter_list)}] 下載完成: {title}) time.sleep(1) if __name__ __main__: chapters get_chapter_list(BASE_URL) print(f共找到 {len(chapters)} 章) download_book(chapters, output.txt)為什么clean_content里用正則re.compile(rcontent|article, re.I)來匹配class名因?yàn)椴煌W(wǎng)站的正文容器class命名五花八門有chapter-content、article-content、rich_media_content等等用正則包含匹配比寫死某一個類名更實(shí)用。這里用了re.I忽略大小寫算是個很老練的經(jīng)驗(yàn)點(diǎn)。time.sleep(1)的作用是控制請求頻率。你不想因?yàn)閮擅腌娬埱笫畮讖堩撁姘丫W(wǎng)站服務(wù)器搞得有壓力更不想被限流。如果書很長建議把這個間隔提到2到3秒雖然慢一點(diǎn)但穩(wěn)定。3.3 轉(zhuǎn)換腳本一鍵輸出HTML表格適配WPS下載完之后要做一個書單管理功能把下載記錄輸出成HTML表格方便WPS表格直接打開。我這里寫了一個獨(dú)立的轉(zhuǎn)換函數(shù)import html import csv from datetime import datetime def generate_html_report(records, output_path): rows [] for rec in records: rows.append( tr ftd{html.escape(rec[title])}/td ftd{html.escape(rec[author])}/td ftd{rec[chapters]}/td ftd{html.escape(rec[status])}/td ftd{rec[time]}/td /tr ) html_content f!DOCTYPE html html headmeta charsetutf-8title我的小說書單/title/head body h2書單記錄/h2 table border1 cellspacing0 cellpadding6 trth書名/thth作者/thth章節(jié)數(shù)/thth狀態(tài)/thth下載時間/th/tr {.join(rows)} /table /body /html with open(output_path, w, encodingutf-8) as f: f.write(html_content)用WPS打開這個HTML文件時它會自動識別table結(jié)構(gòu)并轉(zhuǎn)換成可編輯的單元格。這里有個細(xì)節(jié)meta charsetutf-8必須寫在head最前面否則WPS可能按GBK去解析中文全部變成亂碼。cellspacing和cellpadding參數(shù)是給表格加邊框和留白讓視覺效果更清爽。如果你更希望直接生成CSV文件可以用Python標(biāo)準(zhǔn)庫的csv模塊。需要注意newline這個參數(shù)Windows下如果漏了它每一行后面會多一個空行導(dǎo)入WPS后會看到行間距很夸張def generate_csv(records, output_path): with open(output_path, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[書名, 作者, 章節(jié)數(shù), 狀態(tài), 下載時間]) writer.writeheader() for rec in records: writer.writerow(rec)這里用utf-8-sig編碼就是為了Excel和WPS能直接識別UTF-8中文原理是文件開頭加了BOM標(biāo)識。3.4 批量化用書單CSV批量下載一次只下三五本書還可以手動運(yùn)行腳本但如果你想整理一個五十本的書架就要用書單來驅(qū)動。思路是先維護(hù)一個CSV文件里面有四列書名、書籍URL、目標(biāo)格式、保存路徑。腳本循環(huán)讀取每一行逐本調(diào)用下載和轉(zhuǎn)換邏輯。import csv import os BOOK_LIST_CSV books.csv def download_from_csv(csv_path): with open(csv_path, r, encodingutf-8-sig) as f: rows csv.DictReader(f) for row in rows: title row[書名] url row[書籍URL] fmt row[目標(biāo)格式] save_path fdownloads/{title}.{fmt} print(f開始下載: {title}) chapters get_chapter_list(url) if fmt in (txt, html): merge_to_txt_or_html(chapters, save_path, fmt) elif fmt epub: merge_to_epub(chapters, save_path) time.sleep(2)批量場景下最怕一個章節(jié)請求失敗直接中斷整個任務(wù)。我的做法是給每個章節(jié)的請求包一層try/except失敗時把書名和URL寫到error.log里最后統(tǒng)一處理。腳本跑完幾十本書真正需要人工介入的可能就一兩章重試一下就好。這里也順帶提一個時間成本計算。一本300章的小說每章請求加寫文件大約1.5秒加上sleep 1秒總耗時約750秒也就是12分鐘左右。如果你想讓一個“一鍵獲取”流程更快可以把sleep降到0.3秒并開啟并發(fā)但并發(fā)會顯著增加被封風(fēng)險個人工具不建議。4. 常見問題與排查技巧實(shí)錄4.1 提示“獲取章節(jié)失敗”或列表為空這是最常見的問題九成原因是選擇器沒匹配到。網(wǎng)站改版之后class名可能從chapter變成了catalog-item你原來的過濾條件就失效了。排查方法打印一次頁面HTML搜索“目錄”或“章”這些中文關(guān)鍵詞定位到真實(shí)的章節(jié)列表容器再重新調(diào)整a[href*...]里的關(guān)鍵詞。還有一個隱蔽原因頁面啟用了Cloudflare一類的人機(jī)驗(yàn)證Requests拿到的HTML里會包含cf-challenge字樣這種情況說明站點(diǎn)有比較強(qiáng)的反爬攔截個人設(shè)備訪問同樣會觸發(fā)不建議強(qiáng)行繞過換個源或者手動復(fù)制正文更穩(wěn)妥。4.2 正文內(nèi)容為空只抓到“加載中”正規(guī)站點(diǎn)很少出現(xiàn)這種情況但如果你適配的是SPA單頁應(yīng)用正文是JavaScript動態(tài)渲染的直接Requests就拿到一個空殼容器。判斷方法是看響應(yīng)文本里有沒有類似window.__INITIAL_STATE__的變量如果有說明正文數(shù)據(jù)以JSON形式嵌在HTML里可以解析這個變量而不必啟動瀏覽器。如果想快速解決而不是復(fù)雜化代碼我這里有個曲線方案找到該站點(diǎn)提供的移動端頁面地址很多SPA站點(diǎn)會為移動端保留服務(wù)端渲染版本URL規(guī)則通常是加/wap/或m.前綴。實(shí)際測試下來這招解決了很多動態(tài)加載導(dǎo)致的空白問題。4.3 生成的HTML用WPS表格打開后表格錯亂發(fā)生錯亂幾乎都是標(biāo)簽閉合問題。比如某個字段值里包含未轉(zhuǎn)義的或WPS在解析時把它當(dāng)成了新標(biāo)簽的開始后面的列全部錯位。解決方法是寫HTML時強(qiáng)制對字段內(nèi)容做html.escape()把換成lt;把換成amp;。另外表格的th數(shù)量必須和每行td數(shù)量嚴(yán)格一致WPS對不齊的行會自動補(bǔ)空單元格看起來就像“錯位”。如果你用我上面的generate_html_report函數(shù)它已經(jīng)處理了轉(zhuǎn)義直接踩坑概率會小很多。4.4 生成的EPUB在手機(jī)上目錄亂掉EPUB亂目錄主要是章節(jié)標(biāo)題層級沒有規(guī)范寫入閱讀器無法自動提取目錄。如果你用EbookLib記得給每一章設(shè)置chapter.title并且不要忘了在toc參數(shù)里傳入這些標(biāo)題。別嫌麻煩少了這一步手機(jī)上的閱讀器只能把整本書當(dāng)成一個超長文檔翻頁體驗(yàn)非常差。另外封面圖如果不要就干脆不加加一個損壞的封面反而會讓Apple Books這類嚴(yán)格校驗(yàn)的閱讀器拒絕打開整本書。圖片推薦用JPG或PNG尺寸控制在600x800以上但不至于超過2MB。4.5 網(wǎng)上熱詞的“鼠鼠格式轉(zhuǎn)換”與安全提醒最近在有些社群里會看到“鼠鼠格式轉(zhuǎn)換”這種說法聽著像某款工具其實(shí)更多是網(wǎng)友對“抓取轉(zhuǎn)換”這類腳本的調(diào)侃稱呼。我不去深究這個詞的具體出處但想借它提醒一下越是流傳在小圈子里、來路不明的“格式轉(zhuǎn)換器”越要警惕。你很難知道它除了轉(zhuǎn)換格式還做了什么也許是上傳你的文件也許是捆綁下載廣告。我的原則是能用自己寫的腳本完成的工作絕不隨便運(yùn)行網(wǎng)上下載的exe。如果你有不得不使用的第三方轉(zhuǎn)換器建議放到虛擬機(jī)或者沙箱環(huán)境里先跑一次觀察它是否有可疑的網(wǎng)絡(luò)請求和文件寫入行為。4.6 常見問題速查表問題現(xiàn)象排查優(yōu)先級常用解法章節(jié)列表為空1. 頁面結(jié)構(gòu)是否改版 2. 選擇器是否匹配 3. 是否觸發(fā)人機(jī)驗(yàn)證打印HTML分析調(diào)整CSS選擇器改用移動端URL請求返回4031. User-Agent是否缺失 2. Referer是否正確 3. 請求頻率是否過快補(bǔ)全請求頭降低頻率增加隨機(jī)延時中文亂碼1. 頁面編碼判斷 2. 寫入編碼是否統(tǒng)一resp.encoding按頁面meta設(shè)置寫入統(tǒng)一UTF-8TXT在手機(jī)閱讀器里沒封面閱讀器不支持TXT封面轉(zhuǎn)換EPUB時添加元數(shù)據(jù)和封面HTML導(dǎo)入WPS列錯位1. 字段是否有未轉(zhuǎn)義字符 2. td數(shù)量是否一致對輸出字段做html.escape對齊表頭和單元格我在實(shí)際寫腳本的過程中最大的體會是不要一開始就想著做“全網(wǎng)通用”的下載器那幾乎是維護(hù)無底洞。先把一個你??吹恼军c(diǎn)適配好跑通全流程再考慮抽象出可復(fù)用的函數(shù)。等到哪天網(wǎng)站改版了你改改選擇器幾秒鐘就能恢復(fù)這種掌控感才是自己動手的最大回報。最后再分享一個小技巧如果你只是想快速把網(wǎng)頁正文保存成PDF或Word不必單獨(dú)寫腳本直接用瀏覽器自帶的“打印為PDF”再把PDF轉(zhuǎn)成Word也行。但如果要處理幾十本、上百本或者要在不同閱讀器里保持統(tǒng)一的排版那套Python腳本方案絕對值得你花一晚上時間搭建起來。工具是死的思路是活的你完全可以在我的代碼基礎(chǔ)上改成適合自己習(xí)慣的工作流。