ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python Lofter爬虫实战:从标签页到详情页的完整数据采集方案

Python Lofter爬虫实战:从标签页到详情页的完整数据采集方案 简介本资源是一套面向Python初学者与网络数据采集爱好者的Lofter平台内容批量爬取实战方案解决轻博客平台公开内容自动化采集、结构化解析与多维度分类归档的实际需求。压缩包共76个文件含12个核心Python脚本如作者图文提取、标签解析、登录模拟等模块、53张示例截图与4张实测图片辅以README.md、小白教程.md、更新记录计划等说明文档整体6.41MB目录结构清晰模块职责分明便于按功能快速定位与调试。目前已有108人学习下载资源提供完整可运行的爬虫工程骨架涵盖反爬应对策略User-Agent轮换、请求间隔控制、HTML解析逻辑lxmlCSS选择器、本地分类存储机制按作者/标签/日期自动建目录以及常见异常处理与日志记录实践适合边学边练、二次开发与教学演示。1. 项目整体设计与技术选型1.1 这个项目到底解决了什么问题我最早接触Lofter爬虫是因为有个朋友做同人圈子的资料整理需要把某个标签下几百篇图文按作者、按CP、按完结状态归档。人工保存显然不现实一个标签几千条内容光翻页就能把人逼疯。后来陆陆续续帮几个编辑、画手、策展朋友做过类似的内容迁移和备份才发现“从博客平台批量抓取内容并按规则分类保存”是特别常见却很少被系统讲清楚的需求。这个项目的核心目标非常明确用Python实现一个针对Lofter内容的批量爬虫拿到指定标签页或指定用户主页下的文章列表然后逐篇抓取正文、图片、标签、发布时间等关键字段最后按自定义规则保存到本地目录并且做好增量更新。说白了就是让“备份某个博主所有图文”这件事从手工操作变成一行命令的事。1.2 为什么选Python而不是其他语言很多人会问爬虫不是用什么语言都能写吗Node.js、Go、Java都能做到为什么行业里普遍首选Python核心原因有三点。第一是生态。Python的requests、BeautifulSoup、lxml、Scrapy这些库几乎把HTTP请求、HTML解析、异步抓取的所有细节都封装好了代码量可以压缩到其他语言的五分之一甚至更少。对一个以“快速实现数据采集和整理”为目标的场景来说开发效率就是一切。第二是容错和调试。爬虫本质上是跟不可控的网页结构打交道页面改版、接口返回异常、请求频率被封是家常便饭。Python的动态特性和交互式调试Jupyter或者Python shell里直接试让排查这类问题轻松得多。第三是数据后处理。抓下来只是第一步后面的分类、去重、重命名、生成索引文件这些用Python的os、re、json、pandas模块处理起来非常顺手。整个流程可以在同一个语言环境里闭环完成不需要跨语言传数据。当然如果你的目标量级到了每天几千万条那肯定要考虑Go或者分布式方案。但Lofter内容抓取这种场景单机Python配合合理的请求频率控制完全够用维护成本也低。1.3 整体架构和模块划分我实现这个项目的时候把整个流程拆成了五个模块请求模块、解析模块、调度模块、存储模块、去重模块。每个模块职责单一后期改起来非常方便。请求模块负责发HTTP请求处理headers、cookies、超时、重试。解析模块负责从HTML或接口返回的JSON中提取正文、标题、图片地址、发布时间。调度模块控制抓取队列决定先抓列表页还是先抓详情页。存储模块负责目录创建、文件写入、图片二进制保存。去重模块通过URL hash和标题比对避免重复抓取。模块之间通过一个简单的队列传递数据列表页解析出的详情页URL被放入队列详情页抓取完成后提取出的图片URL再被放入另一个队列。这样设计的好处是任何一个环节出了问题只需要单独重跑那个模块不用从头再来。2. 核心细节解析与实操要点2.1 Lofter页面结构分析你绕不开的一步任何爬虫项目的第一个硬骨头都是目标站点的页面结构分析。Lofter虽然是个博客平台但它的页面结构有几个特点直接决定你后续怎么写解析代码。Lofter的列表页可以走两种方式。一种是直接在浏览器里访问标签页比如https://www.lofter.com/tag/某某标签页面返回的是服务端渲染的HTML文章列表以固定DOM结构存在。另一种是通过内部的Ajax接口翻页返回JSON数据。两种方式我都试过实际推荐走接口方式因为接口返回的JSON里字段非常规整省去大量正则匹配的工作。以标签页接口为例它的请求URL大概是这样的结构https://www.lofter.com/tag/某某标签?page2返回的内容里包含了当前页面的文章摘要和详情页链接。这里的页码是明文的不存在加密签名所以翻页逻辑非常简单——循环递增页码就行。详情页的结构相对复杂一点。正文内容被包在div.txt里面图片地址分散在img标签的src或>headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, Referer: https://www.lofter.com/ }这里有个容易被新手忽略的点Referer必须设置。Lofter的详情页会校验来源如果Referer为空或者指向其他域名服务端可能返回403或者一个验证跳转页。另外如果你需要抓取某个登录用户才能看到的“仅好友可见”内容就需要带上登录后的Cookie。Cookie的获取方式很简单浏览器里登录Lofter打开开发者工具从任意请求里复制Cookie字段的值粘贴到代码里即可。Cookie一般有两到三周的存活期过期后重新复制就行。2.3 图片地址的完整提取规则Lofter的图片处理逻辑可以说是我在这个项目里踩坑最多的地方。图片在原页面中通常以img src...出现但实际原图的URL藏在>def classify_article(tags, title): if any(连载 in tag or 更新 in tag for tag in tags): return serial elif any(随笔 in tag or 日常 in tag for tag in tags): return diary elif 教程 in tags: return tutorial else: return default这段逻辑看起来简单但实际执行中你会发现有些作者标标签很随意一个教程帖可能只打了图相关标签没打教程标签。这时候就需要再加一道正文关键词判断比如正文中频繁出现“步骤”“素材”“笔刷”这类词就归到教程类。分类规则本身不是技术难点难的是怎么用最少的规则覆盖最多的情况这需要在跑了真实数据之后反复调优。2.5 增量抓取怎么判断“这篇文章我抓过了”批量抓取只有一次性需求更常见的是定期同步某个博主的新内容。这时候就需要增量抓取。我这里的方案是维护一个本地JSON文件作为抓取记录每抓取一篇文章就把它的详情页URL和标题摘要存进去。下一次跑任务时先读取这个记录文件队列里如果出现重复URL就直接丢弃。def load_seen(): if not os.path.exists(seen.json): return set() with open(seen.json, r, encodingutf-8) as f: return set(json.load(f))这个方案的优点是没有额外依赖一个JSON文件就搞定了。缺点是当文章数量达到几万篇的时候每次都全部加载到内存会比较臃肿。这时候可以改成SQLite但绝大多数场景下JSON完全够用。还有一个小技巧存储记录时同时保存发布时间戳这样增量任务可以按时间范围做二次过滤避免因为详情页URL变化导致漏抓。3. 实操过程与核心环节实现3.1 环境准备开始写代码之前先把环境搭好。我用的Python版本是3.9如果你用的是3.11或更高版本也完全没问题代码里没有用到版本相关的特性。依赖库只需要三样requests、beautifulsoup4、lxml。pip install requests beautifulsoup4 lxml三句话解释一下这三个库的分工requests负责跟服务器对话把网页内容拿回来BeautifulSoup负责把拿回来的HTML按标签结构拆开方便提取数据lxml是BeautifulSoup底层的解析引擎比Python自带的html.parser快很多而且对不规范的标签容错能力更强。3.2 实现请求模块import requests import time import random session requests.Session() def fetch(url, headersNone, retries3): if headers is None: headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36, Referer: https://www.lofter.com/ } for i in range(retries): try: resp session.get(url, headersheaders, timeout10) if resp.status_code 200: return resp elif resp.status_code 403: time.sleep(random.uniform(5, 10)) continue else: time.sleep(2) continue except requests.RequestException: time.sleep(random.uniform(3, 6)) continue return None这段代码里有几个细节要说明。第一我用requests.Session()创建了一个会话对象它会自动帮你保存Cookie和连接状态连续请求同一个域名时效率更高。第二重试不是无限循环三次之后就放弃了失败的URL记录下来后续再手动补抓。第三每个请求之间要加随机延时这是对所有爬虫都适用的一条黄金法则——固定频率的请求是最容易被识破的。3.3 列表页解析与翻页循环from bs4 import BeautifulSoup import json def parse_tag_page(html): soup BeautifulSoup(html, lxml) posts [] for item in soup.select(div.post): link_tag item.select_one(a.tit) if not link_tag: continue title link_tag.get_text(stripTrue) url link_tag.get(href) posts.append({title: title, url: url}) return posts这里的选择器div.post和a.tit是根据Lofter列表页的DOM结构来的。不同时期Lofter改版可能会变化如果你跑的时候发现匹配不到任何内容用浏览器开发者工具查看一下列表项的实际class名替换上去就行。翻页循环的逻辑是从第一页开始请求如果当前页解析出的文章列表为空或者请求返回了空页面就停止循环。还有一种情况是返回的页面内容和上一页完全一样说明触发了Lofter的重复内容缓存这时候也应当终止不然会陷入死循环。def crawl_tag(tag, max_pages50): base_url fhttps://www.lofter.com/tag/{tag} all_posts [] for page in range(1, max_pages 1): url f{base_url}?page{page} resp fetch(url) if resp is None: break posts parse_tag_page(resp.text) if not posts: break all_posts.extend(posts) time.sleep(random.uniform(2, 5)) return all_postsmax_pages的默认值给到50页是因为有些热门标签确实有几千篇文章。如果你有自己的需求可以自行调整。3.4 详情页抓取和字段提取def parse_detail(html): soup BeautifulSoup(html, lxml) title soup.select_one(h1.title).get_text(stripTrue) content_div soup.select_one(div.txt) content content_div.get_text(\n, stripTrue) if content_div else images [] for img in soup.select(div.img img): src img.get(data-original) or img.get(src) if src: images.append(src.split(?)[0]) tags [tag.get_text(stripTrue) for tag in soup.select(div.tags a)] pub_time soup.select_one(span.time) pub_time pub_time.get_text(stripTrue) if pub_time else return { title: title, content: content, images: images, tags: tags, pub_time: pub_time, url: current_url }这里提到一个实践上的优化current_url参数要提前定义好因为详情页有些字段比如发布时间可能为空但你仍然需要知道这篇文章是从哪个URL来的方便排查。图片提取时我特意写了img.get(data-original) or img.get(src)这条逻辑。很多懒加载页面的src是占位图真实图片地址在>import os from urllib.parse import urlparse def save_article(article, base_dir): author_dir os.path.join(base_dir, sanitize_filename(article.get(author, unknown))) category_dir os.path.join(author_dir, classify_article(article[tags], article[title])) os.makedirs(category_dir, exist_okTrue) filename sanitize_filename(article[title]) .txt filepath os.path.join(category_dir, filename) with open(filepath, w, encodingutf-8) as f: f.write(f标题: {article[title]}\n) f.write(f时间: {article[pub_time]}\n) f.write(f标签: {, .join(article[tags])}\n) f.write(f来源: {article[url]}\n) f.write(- * 50 \n) f.write(article[content]) for img_url in article[images]: img_name os.path.basename(urlparse(img_url).path) img_path os.path.join(category_dir, img_name) download_image(img_url, img_path)sanitize_filename函数我习惯自己写因为Windows和Linux对非法字符的限制不一样而且文章标题里经常出现\/:*?|这些字符不处理直接作为文件名会报错。我通常的做法是正则替换import re def sanitize_filename(name): return re.sub(r[\\/:*?|], _, name)[:100]截断到100个字符是因为有些文章标题实在太长超过文件系统的路径长度限制就会出现各种诡异的问题。3.6 图片下载函数def download_image(url, path): if os.path.exists(path): return resp fetch(url) if resp is not None: with open(path, wb) as f: f.write(resp.content)这里有个关键优化if os.path.exists(path)图片下载前先检查本地存不存在。这个处理配合前面提到的去重机制可以做到中断后继续跑不用从头再来。下载失败时不用立刻处理写一个failed_images.txt日志全部跑完后再统一补下载。3.7 主调度逻辑def main(): tag 某某标签 base_dir ./lofter_data posts crawl_tag(tag, max_pages50) seen load_seen() for post in posts: url post[url] if url in seen: continue resp fetch(url) if resp is None: continue article parse_detail(resp.text) if not article[title]: continue article[author] extract_author(resp.text) save_article(article, base_dir) seen.add(url) save_seen(list(seen)) time.sleep(random.uniform(2, 4))extract_author是从详情页里提取作者名的函数通常是页面顶部一个a标签的文本。这个字段在按作者分类时是必用的。主调度的逻辑看起来简单但有两个点值得展开讲。第一seen集合每次循环都更新并且通过save_seen实时写回JSON文件这样即使程序中途崩溃已经抓过的文章也不会重复抓。第二每次请求之间都sleep详情页的间隔我控制在2到4秒列表页的间隔控制在2到5秒。别嫌慢爬虫不是抢票稳定比速度重要得多。实测下来这个频率完全不会触发Lofter的风控一天跑几万条也没问题。4. 常见问题与排查技巧实录4.1 返回403状态码怎么处理403是最常见的状态码触发原因无非两种一是请求头缺失或不符合要求二是请求频率过高触发了风控。排查步骤很简单先确认headers是否完整特别是User-Agent和Referer再检查自己的请求间隔是否太短如果少于1秒大概率就是被限流了。我遇到403后的处理方式是先停掉任务等5到10分钟让临时封禁自动解除然后把请求间隔从2秒调整到5秒以上并且在每次请求前打印日志方便观察最终是哪个环节触发的封禁。如果短时间封禁太频繁可以考虑在代码里加入代理池但对Lofter这种体量的站点代理池属于过度设计我建议先用最简单的方式解决。4.2 翻页到某几页时抓不到数据翻页循环里有时候前几页抓得好好的到某页开始解析列表为空。这种情况大概率不是页面结构变了而是Lofter对标签页做了动态加载超过一定页码后走的是接口逻辑而不是静态页面。这时候直接用requests拿不到后面的内容。解决方案有两种。第一种简单粗暴在浏览器里打开那一页手动滚动加载全部文章后打断点看网络请求找到真正的数据接口然后直接在代码里请求接口而不是请求HTML页面。第二种是用Selenium驱动浏览器翻页但这种方式太重我一般不推荐。实际上Lofter的标签接口基本是公开的URL拼上页码就能拿数据最有可能出问题的是页码过大时返回了空JSON这种情况直接终止就行。4.3 图片下载失败或下载了缩略图图片问题几乎每个爬虫项目都会遇到。Lofter的图片存储在不同域名下部分图片来源域名可能有过期签名导致直接请求URL返回404。我遇到过一次整个任务有几十张图下载失败逐一检查发现是这些图片来自旧文章外链域名已经迁移。解决方法是从文章HTML里重新获取最新图片地址而不是依赖之前缓存的URL。如果下载下来的图片是缩略图多半是URL里的裁剪参数没有清干净。Lofter的图片URL带?imageView2/1/w/500/h/500这类参数只要出现这种参数都会被视为裁剪后的版本。我的做法是写一个规则凡是URL包含imageView2或/imgrot/关键字一律按规则转换成原图地址规则def to_original_url(img_url): if imageView2 in img_url: return img_url.split(?)[0] if /imgrot/ in img_url: return img_url.replace(/imgrot/, /img/) return img_url4.4 文章正文提取出来是空的正文为空大概率是正文内容不在div.txt里面。Lofter的文章有纯文字、图文混排、音频、视频几种类型纯音频或视频帖没有文字内容很正常。图文混排的文章文字可能被拆成多个段落分布在不同的p标签下我用get_text(\n)把段落之间用换行连接就可以。还有一类情况是文章正文被折叠了需要点击“阅读全文”才能展开。这是Lofter的“长文”功能正文的完整内容在详情页的div.text里可以取到但如果你的代码看的是列表页的摘要那肯定只有截断文本。解决方案就是抓取详情页而不是列表页这两个页面本身是独立的URL。4.5 Cookie失效导致部分内容抓不了如果你抓取的是需要登录才能看的内容比如“仅粉丝可见”或者“仅好友可见”Cookie是必须的。Cookie失效的典型表现是列表页能抓但点进详情页时返回登录跳转页。排查的时候打印返回的URL或者HTML标题就能看出来跳转页的标题通常是“登录”或“请先登录”。Cookie失效没有一劳永逸的解决办法我一般是在代码里加一个Cookie快过期前的提醒比如把Cookie的过期时间写进配置每天跑任务前先检查一次。另外尽量不要把个人Cookie分享给别人一旦被风控牵连你自己的账号也会被限制。5. 踩坑实录与运行经验5.1 目录结构设计的前车之鉴我最早一版代码把所有文章塞进同一个目录用文件名区分不同作者。结果抓了几千篇文章之后单个目录里文件数量太多在Windows资源管理器里浏览都卡。后来改成按作者分目录再后来发现有重名作者的又加了一层作者ID目录。现在的目录结构是根目录/作者名_作者ID/分类/文章名.txt。作者ID从详情页URL里提取URL是https://作者ID.lofter.com/post/文章ID这种格式把域名前缀和路径拆开就能拿到。加了作者ID之后即使两个博主使用相同昵称也不会混淆。5.2 防盗链和图片下载的取舍Lofter的图片是有防盗链设置的直接下载有时候可以成功但间歇性失败。真正稳妥的方案是图片请求时带上跟详情页相同的Referer。我一度因为这个细节栽了跟头下载的图片偶尔有一半是损坏的之前以为是网络问题后来才发现是给图片发请求时没有设Referer被服务器部分拒绝。解决方案就是在download_image里给fetch函数传一个带Referer的headers参数。这个细节如果你自己从头排查可能要折腾一两个小时我贴出来就是帮大家省这个时间。5.3 清洗数据的意外收获你可能觉得抓下来的正文既然是从HTML里提取的里面一定有很多标签残留。用BeautifulSoup的get_text()方法提取之后HTML标签确实被去掉了但文本里还会残留nbsp;、amp;这些HTML实体以及连续多个换行符。我一开始没有做清洗保存下来的文本里到处都是乱码般的实体符号后来加了一个统一的文本处理函数import html as html_lib def clean_text(text): text html_lib.unescape(text) text re.sub(r\n{3,}, \n\n, text) text re.sub(r[ \t], , text) return text.strip()这个处理函数在保存前调用一次文本质量立刻提升一个档次不管是后续人工阅读还是做文本分析都干净很多。5.4 并发要不要做这个项目初期我用了单线程加循环抓5000篇文章大概需要5到7个小时。后来我试过用requests加ThreadPoolExecutor做并发请求线程数开到5速度确实提上去了但403出现的频率也明显增加。最后我还是理性地回到单线程只是把请求间隔从3秒降到2秒总耗时缩短了一部分同时保持了稳定性。如果一定要做并发我建议控制在线程数3以内并且在线程之间共享一个全局限速器确保所有线程加起来每秒不超过0.5个请求。这个速度抓完几千篇不会太慢也不会被封。记得给线程池加优雅退出机制主进程收到中断信号时能把已抓到的内容保存下来。5.5 从个人脚本到可复用工具跑通一轮抓取之后你会发现如果能把代码里写死的标签名、目标目录、分类规则都抽出来放到配置文件里这个脚本就从一个一次性工具变成了一个可以被反复使用的个人数据采集器。我的做法是把配置放在config.json里{ tags: [原创, 同人], base_dir: ./lofter_data, max_pages: 50, request_interval: [2, 5], categories: { serial: [连载, 更新], tutorial: [教程, 素材] } }这样改动需求的时候只需要改配置文件不用动代码。而且把代码发给你朋友的时候对方也不需要理解你的代码逻辑直接改JSON就行降低使用门槛。6. 合规提醒与收尾心得爬虫这个领域一直处于灰色地带我自己写完这个项目后也一直在提醒自己技术本身没有对错但使用场景必须合规。做Lofter内容备份只处理自己有权访问的内容不抓取隐私信息不用于商业用途控制请求频率不给服务器造成压力。尤其不要拿这个工具去爬别人的付费内容或者私密内容这是底线。我自己实际使用中这个脚本用得最多的场景反而是帮自己备份写了多年的博客。等某一天想回头翻翻以前写的东西发现平台改版或者账号异常本地还有一份完整的备份那种安心感是值得为之付出的。最后再分享一个经验不要小看“给每篇文章保存原始URL”这个小动作。我当时留了个心眼把详情页URL一并存进了文章的元信息里后来有一次需要对照网站原文做校对直接打开本地文件复制URL就能访问省了大量查找时间。在这个项目里包含URL的完整元信息甚至比正文本身还重要因为它让整份本地数据随时可以和线上内容呼应起来形成一条可追溯的链路。本文还有配套的精品资源点击获取
返回列表