🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [📌 上期回顾](#📌 上期回顾)
- [🎯 本期目标](#🎯 本期目标)
- [💡 无限滚动的三大难点](#💡 无限滚动的三大难点)
- [🔧 技术方案拆解](#🔧 技术方案拆解)
- [📝 完整实现](#📝 完整实现)
- [🔍 代码关键点解析](#🔍 代码关键点解析)
-
- [1. 多重终止条件的设计](#1. 多重终止条件的设计)
- [2. 去重键的优先级](#2. 去重键的优先级)
- [3. 断点续传的实现](#3. 断点续传的实现)
- [4. 边采边写的内存控制](#4. 边采边写的内存控制)
- [📊 实战验收](#📊 实战验收)
- [🎨 进阶优化方向](#🎨 进阶优化方向)
-
- [1. 智能滚动节奏](#1. 智能滚动节奏)
- [2. 视口检测优化](#2. 视口检测优化)
- [3. 分批写入优化](#3. 分批写入优化)
- [4. 异常恢复机制](#4. 异常恢复机制)
- [🚨 常见问题与解决](#🚨 常见问题与解决)
-
- [Q1: 滚动了但页面没加载新内容?](#Q1: 滚动了但页面没加载新内容?)
- [Q2: 内存持续增长?](#Q2: 内存持续增长?)
- [Q3: 断点恢复后重复采集?](#Q3: 断点恢复后重复采集?)
- [Q4: 如何处理需要登录的页面?](#Q4: 如何处理需要登录的页面?)
- [🎯 本期总结](#🎯 本期总结)
- [📚 下期预告](#📚 下期预告)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
📌 上期回顾
上一期《Playwright 入门实战:渲染后 HTML + 截图定位问题!》我们掌握了Playwright的基础操作------启动浏览器、等待策略、截图调试。能用真实浏览器渲染动态页面,拿到完整的HTML内容了。
但现实很快给我们出了个难题:打开一个资讯站点,页面只显示20条内容,往下滚动才会加载更多。你需要采集300条,怎么办?
手动滚动15次?太慢。写个死循环滚动?可能永远停不下来,或者重复采集。这就是无限滚动列表的经典难题。
今天咱们就来系统解决这个问题。
🎯 本期目标
这一期你会得到:
- 智能滚动策略:判断何时停止,避免死循环
- 去重机制:同一条数据只采集一次
- 终止条件设计:数量达标、无新内容、超时三重保险
- 中断恢复:采集一半网络断了也能续上
- 性能优化:滚动300次内存占用稳定
验收标准很明确:能稳定采集300条不重复数据,遇到列表底部能自动停止,中途中断能从断点继续📜
💡 无限滚动的三大难点
难点1:何时停止?
python
# ❌ 错误示范:死循环
while True:
scroll_down()
time.sleep(2)
# 永远不会停!
正确思路:
- 检测页面高度不再增长
- 检测新增元素数量为0
- 出现"没有更多"提示
- 达到目标数量
难点2:如何去重?
python
# 问题:滚动加载时,前面的数据还在页面上
items = page.locator('.item').all() # 包含旧数据
方案:
- 记录已采集的ID/URL
- 用set去重
- 只提取新增部分
难点3:内存爆炸
python
# ❌ 危险:所有数据都留在内存
all_items = []
for i in range(300):
items = extract_items(page)
all_items.extend(items) # 越来越大
解决:
- 边采集边写入文件
- 定期清理已提取的DOM元素(如果可能)
- 控制页面保留元素数量
🔧 技术方案拆解
方案一:滚动策略
python
def scroll_with_detection(page, max_scrolls=50):
"""带检测的滚动"""
previous_height = page.evaluate('document.body.scrollHeight')
no_change_count = 0
for i in range(max_scrolls):
# 滚动
page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
page.wait_for_timeout(2000) # 等待加载
# 检测高度变化
new_height = page.evaluate('document.body.scrollHeight')
if new_height == previous_height:
no_change_count += 1
if no_change_count >= 3: # 连续3次无变化
logger.info("已到底部")
return
else:
no_change_count = 0
previous_height = new_height
方案二:去重键设计
python
# 优先级:
1. 数据ID(最可靠)
dedup_key = item['id']
2. URL(常见)
dedup_key = item['url']
3. 标题+时间(兜底)
dedup_key = f"{item['title']}_{item['date']}"
4. 内容哈希(极端情况)
dedup_key = hashlib.md5(item['content'].encode()).hexdigest()
方案三:终止条件
python
# 三重终止条件
def should_stop(collected_count, no_new_count, elapsed_time):
return (
collected_count >= target_count or # 数量达标
no_new_count >= 5 or # 连续5次无新增
elapsed_time > max_time # 超时
)
方案四:断点续传
python
# 保存进度
state = {
'collected_ids': list(seen_ids),
'last_scroll_pos': scroll_position,
'count': len(results)
}
with open('progress.json', 'w') as f:
json.dump(state, f)
# 恢复进度
with open('progress.json') as f:
state = json.load(f)
seen_ids = set(state['collected_ids'])
📝 完整实现
整体架构说明
核心组件:
- ScrollStrategy:滚动策略(检测底部、控制节奏)
- ItemExtractor:数据提取器(从DOM提取结构化数据)
- DeduplicationManager:去重管理器(维护已采集ID集合)
- InfiniteScrollCrawler:无限滚动爬虫(主控逻辑)
数据流向:
json
打开页面 → 滚动 → 提取数据 → 去重 → 保存 → 检查终止条件 → 继续滚动/停止
代码实现
python
"""
无限滚动列表爬虫
功能:智能滚动、去重、终止条件、断点续传
"""
import json
import time
import hashlib
from pathlib import Path
from typing import List, Dict, Any, Set, Optional, Callable
from dataclasses import dataclass, field, asdict
from datetime import datetime
import logging
from playwright.sync_api import Page, sync_playwright
from bs4 import BeautifulSoup
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
logger = logging.getLogger(__name__)
# =============================================================================
# 1. 配置与数据类
# =============================================================================
@dataclass
class ScrollConfig:
"""滚动配置"""
target_count: int = 300 # 目标数量
max_scrolls: int = 100 # 最大滚动次数
max_time: int = 600 # 最大耗时(秒)
scroll_pause: float = 2.0 # 每次滚动后暂停(秒)
scroll_step: int = 0 # 滚动步长(0=滚到底)
no_change_threshold: int = 3 # 高度无变化次数阈值
no_new_threshold: int = 5 # 无新数据次数阈值
enable_checkpoint: bool = True # 启用断点续传
checkpoint_interval: int = 50 # 每N条保存一次进度
@dataclass
class ScrollState:
"""滚动状态"""
collected_count: int = 0
scroll_count: int = 0
no_change_count: int = 0
no_new_count: int = 0
start_time: float = field(default_factory=time.time)
last_height: int = 0
seen_ids: Set[str] = field(default_factory=set)
def elapsed_time(self) -> float:
return time.time() - self.start_time
def should_stop(self, config: ScrollConfig) -> tuple[bool, str]:
"""判断是否应该停止"""
if self.collected_count >= config.target_count:
return True, f"已达目标数量({config.target_count})"
if self.scroll_count >= config.max_scrolls:
return True, f"已达最大滚动次数({config.max_scrolls})"
if self.elapsed_time() >= config.max_time:
return True, f"已超时({config.max_time}秒)"
if self.no_change_count >= config.no_change_threshold:
return True, f"页面高度连续{config.no_change_threshold}次无变化"
if self.no_new_count >= config.no_new_threshold:
return True, f"连续{config.no_new_threshold}次无新数据"
return False, ""
# =============================================================================
# 2. 滚动策略
# =============================================================================
class ScrollStrategy:
"""滚动策略"""
@staticmethod
def scroll_to_bottom(page: Page):
"""滚动到底部"""
page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
@staticmethod
def scroll_by_step(page: Page, pixels: int = 800):
"""按步长滚动"""
page.evaluate(f'window.scrollBy(0, {pixels})')
@staticmethod
def get_scroll_height(page: Page) -> int:
"""获取页面高度"""
return page.evaluate('document.body.scrollHeight')
@staticmethod
def get_scroll_position(page: Page) -> int:
"""获取当前滚动位置"""
return page.evaluate('window.pageYOffset')
@staticmethod
def scroll_to_position(page: Page, position: int):
"""滚动到指定位置"""
page.evaluate(f'window.scrollTo(0, {position})')
@staticmethod
def smooth_scroll(page: Page, step: int = 300, times: int = 3):
"""平滑滚动(分多次小步滚动)"""
for _ in range(times):
ScrollStrategy.scroll_by_step(page, step)
page.wait_for_timeout(500)
# =============================================================================
# 3. 数据提取器
# =============================================================================
class ItemExtractor:
"""
数据提取器
子类需实现extract_items方法
"""
def extract_items(self, html: str) -> List[Dict[str, Any]]:
"""
从HTML提取数据
Args:
html: 页面HTML
Returns:
数据列表,每个item必须包含去重键(如id/url)
"""
raise NotImplementedError("子类需实现extract_items方法")
def get_dedup_key(self, item: Dict[str, Any]) -> str:
"""
获取去重键
优先级:id > url > title+date > content_hash
"""
if 'id' in item and item['id']:
return str(item['id'])
if 'url' in item and item['url']:
return item['url']
if 'title' in item and 'date' in item:
return f"{item['title']}_{item['date']}"
# 兜底:内容哈希
content = json.dumps(item, sort_keys=True, ensure_ascii=False)
return hashlib.md5(content.encode()).hexdigest()
# =============================================================================
# 4. 去重管理器
# =============================================================================
class DeduplicationManager:
"""去重管理器"""
def __init__(self):
self.seen_keys: Set[str] = set()
self.seen_count = 0
self.duplicate_count = 0
def is_duplicate(self, key: str) -> bool:
"""检查是否重复"""
if key in self.seen_keys:
self.duplicate_count += 1
return True
self.seen_keys.add(key)
self.seen_count += 1
return False
def filter_duplicates(
self,
) -> List[Dict[str, Any]]:
"""过滤重复项"""
filtered = []
for item in items:
key = key_func(item)
if not self.is_duplicate(key):
filtered.append(item)
return filtered
def get_stats(self) -> Dict[str, int]:
"""获取统计信息"""
return {
'seen_count': self.seen_count,
'duplicate_count': self.duplicate_count,
'unique_count': len(self.seen_keys)
}
# =============================================================================
# 5. 无限滚动爬虫
# =============================================================================
class InfiniteScrollCrawler:
"""
无限滚动爬虫
功能:
- 智能滚动
- 自动去重
- 多重终止条件
- 断点续传
"""
def __init__(
self,
extractor: ItemExtractor,
config: Optional[ScrollConfig] = None,
output_dir: Path = Path("output/infinite_scroll")
):
self.extractor = extractor
self.config = config or ScrollConfig()
self.output_dir = output_dir
self.output_dir.mkdir(parents=True, exist_ok=True)
self.dedup_manager = DeduplicationManager()
self.state = ScrollState()
# 结果保存
self.results: List[Dict[str, Any]] = []
self.checkpoint_file = self.output_dir / "checkpoint.json"
self.output_file = self.output_dir / f"results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"
def crawl(self, url: str, resume: bool = False) -> List[Dict[str, Any]]:
"""
执行采集
Args:
url: 目标URL
resume: 是否从断点恢复
Returns:
采集结果列表
"""
logger.info(f"开始无限滚动采集 | 目标:{self.config.target_count}条")
# 恢复断点
if resume and self.checkpoint_file.exists():
self._load_checkpoint()
logger.info(f"从断点恢复 | 已采集:{self.state.collected_count}条")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
try:
# 打开页面
page.goto(url, wait_until='domcontentloaded')
logger.info(f"页面已打开: {url}")
# 等待初始内容加载
page.wait_for_timeout(3000)
# 恢复滚动位置(如果有)
if resume and hasattr(self.state, 'last_scroll_pos'):
ScrollStrategy.scroll_to_position(page, self.state.last_scroll_pos)
# 主循环
while True:
# 1. 滚动
self._scroll_page(page)
# 2. 提取数据
new_items = self._extract_and_dedup(page)
# 3. 保存新数据
if new_items:
self.results.extend(new_items)
self.state.collected_count += len(new_items)
self.state.no_new_count = 0
logger.info(
f"✅ 新增{len(new_items)}条 | "
f"累计:{self.state.collected_count}/{self.config.target_count} | "
f"去重:{self.dedup_manager.duplicate_count}"
)
# 写入文件
self._append_to_file(new_items)
# 保存断点
if self.config.enable_checkpoint:
if self.state.collected_count % self.config.checkpoint_interval == 0:
self._save_checkpoint(page)
else:
self.state.no_new_count += 1
logger.info(
f"⚠️ 本次无新数据 | "
f"连续{self.state.no_new_count}次"
)
# 4. 检查终止条件
should_stop, reason = self.state.should_stop(self.config)
if should_stop:
logger.info(f"🛑 停止采集 | 原因:{reason}")
break
# 5. 暂停
page.wait_for_timeout(int(self.config.scroll_pause * 1000))
# 最终截图
page.screenshot(path=str(self.output_dir / "final.png"))
finally:
browser.close()
self._print_summary()
return self.results
def _scroll_page(self, page: Page):
"""执行滚动"""
current_height = ScrollStrategy.get_scroll_height(page)
# 滚动
if self.config.scroll_step > 0:
ScrollStrategy.scroll_by_step(page, self.config.scroll_step)
else:
ScrollStrategy.scroll_to_bottom(page)
self.state.scroll_count += 1
# 等待加载
page.wait_for_timeout(int(self.config.scroll_pause * 1000))
# 检测高度变化
new_height = ScrollStrategy.get_scroll_height(page)
if new_height == current_height:
self.state.no_change_count += 1
else:
self.state.no_change_count = 0
self.state.last_height = new_height
logger.debug(
f"滚动#{self.state.scroll_count} | "
f"高度:{current_height}→{new_height}"
)
def _extract_and_dedup(self, page: Page) -> List[Dict[str, Any]]:
"""提取并去重数据"""
html = page.content()
# 提取数据
items = self.extractor.extract_items(html)
# 去重
new_items = self.dedup_manager.filter_duplicates(
items,
key_func=self.extractor.get_dedup_key
)
return new_items
def _append_to_file(self, items: List[Dict[str, Any]]):
"""追加写入文件"""
with open(self.output_file, 'a', encoding='utf-8') as f:
for item in items:
f.write(json.dumps(item, ensure_ascii=False) + '\n')
def _save_checkpoint(self, page: Page):
"""保存断点"""
checkpoint = {
'collected_count': self.state.collected_count,
'scroll_count': self.state.scroll_count,
'seen_ids': list(self.dedup_manager.seen_keys),
'last_scroll_pos': ScrollStrategy.get_scroll_position(page),
'timestamp': datetime.now().isoformat()
}
with open(self.checkpoint_file, 'w', encoding='utf-8') as f:
json.dump(checkpoint, f, ensure_ascii=False, indent=2)
logger.debug(f"断点已保存: {self.checkpoint_file}")
def _load_checkpoint(self):
"""加载断点"""
with open(self.checkpoint_file, encoding='utf-8') as f:
checkpoint = json.load(f)
self.state.collected_count = checkpoint['collected_count']
self.state.scroll_count = checkpoint['scroll_count']
self.state.last_scroll_pos = checkpoint.get('last_scroll_pos', 0)
self.dedup_manager.seen_keys = set(checkpoint['seen_ids'])
self.dedup_manager.seen_count = len(self.dedup_manager.seen_keys)
def _print_summary(self):
"""打印统计摘要"""
dedup_stats = self.dedup_manager.get_stats()
print("\n" + "="*60)
print("📊 无限滚动采集统计")
print("="*60)
print(f"采集数量: {self.state.collected_count}")
print(f"去重后: {dedup_stats['unique_count']}")
print(f"重复数: {dedup_stats['duplicate_count']}")
print(f"滚动次数: {self.state.scroll_count}")
print(f"耗时: {self.state.elapsed_time():.1f}秒")
print(f"输出文件: {self.output_file}")
print("="*60 + "\n")
# =============================================================================
# 6. 使用示例
# =============================================================================
class DemoExtractor(ItemExtractor):
"""示例提取器(需根据实际页面修改)"""
def extract_items(self, html: str) -> List[Dict[str, Any]]:
"""提取数据"""
soup = BeautifulSoup(html, 'html.parser')
items = []
# 示例:提取文章列表(根据实际DOM结构调整)
for element in soup.select('.article-item'): # 修改为实际选择器
try:
item = {
'title': element.select_one('.title').get_text(strip=True),
'url': element.select_one('a')['href'],
'date': element.select_one('.date').get_text(strip=True),
}
items.append(item)
except (AttributeError, TypeError, KeyError) as e:
logger.debug(f"提取失败: {e}")
continue
return items
def demo_basic_scroll():
"""示例1: 基础滚动采集"""
print("\n🔹 示例1: 基础滚动采集")
config = ScrollConfig(
target_count=50,
max_scrolls=20,
scroll_pause=2.0
)
extractor = DemoExtractor()
crawler = InfiniteScrollCrawler(extractor, config)
# 注意:替换为实际的无限滚动页面URL
results = crawler.crawl("https://example.com/infinite-list")
print(f"采集完成,共{len(results)}条")
def demo_with_checkpoint():
"""示例2: 断点续传"""
print("\n🔹 示例2: 断点续传")
config = ScrollConfig(
target_count=300,
enable_checkpoint=True,
checkpoint_interval=50
)
extractor = DemoExtractor()
crawler = InfiniteScrollCrawler(extractor, config)
# 第一次运行
try:
results = crawler.crawl("https://example.com/list", resume=False)
except KeyboardInterrupt:
print("\n中断!进度已保存")
# 从断点恢复
# crawler2 = InfiniteScrollCrawler(extractor, config)
# results = crawler2.crawl("https://example.com/list", resume=True)
def demo_custom_config():
"""示例3: 自定义配置"""
print("\n🔹 示例3: 自定义配置")
config = ScrollConfig(
target_count=100,
max_scrolls=50,
max_time=300, # 最多跑5分钟
scroll_pause=1.5,
no_change_threshold=5, # 更保守
no_new_threshold=3
)
extractor = DemoExtractor()
crawler = InfiniteScrollCrawler(extractor, config)
results = crawler.crawl("https://example.com/list")
if __name__ == "__main__":
# 运行示例(需要替换为实际URL)
# demo_basic_scroll()
# demo_with_checkpoint()
# demo_custom_config()
print("提示:请修改DemoExtractor的选择器,并替换示例中的URL后运行")
🔍 代码关键点解析
1. 多重终止条件的设计
python
def should_stop(self, config):
# 条件1:数量达标
if self.collected_count >= config.target_count:
return True, "已达目标数量"
# 条件2:滚动次数上限
if self.scroll_count >= config.max_scrolls:
return True, "已达最大滚动次数"
# 条件3:超时
if self.elapsed_time() >= config.max_time:
return True, "已超时"
# 条件4:页面高度无变化
if self.no_change_count >= config.no_change_threshold:
return True, "页面高度不再增长"
# 条件5:无新数据
if self.no_new_count >= config.no_new_threshold:
return True, "连续多次无新数据"
设计原则 :任一条件满足即停止,宁可少采也不死循环。
2. 去重键的优先级
python
def get_dedup_key(self, item):
# 优先级从高到低
if 'id' in item:
return str(item['id']) # 最可靠
if 'url' in item:
return item['url'] # 常见且稳定
if 'title' and 'date' in item:
return f"{item['title']}_{item['date']}" # 组合键
# 兜底:内容哈希
return hashlib.md5(json.dumps(item).encode()).hexdigest()
关键:必须保证去重键的唯一性和稳定性。
3. 断点续传的实现
python
# 保存断点
checkpoint = {
'collected_count': count,
'seen_ids': list(seen_ids), # Set转List才能JSON序列化
'last_scroll_pos': scroll_position
}
# 恢复断点
seen_ids = set(checkpoint['seen_ids']) # List转Set
scroll_to_position(checkpoint['last_scroll_pos'])
技巧:不仅保存数量,还保存seen_ids和滚动位置,恢复时能精确续上。
4. 边采边写的内存控制
python
# ❌ 不推荐:全部留在内存
all_results = []
for item in items:
all_results.append(item) # 300条后内存爆炸
# ✅ 推荐:边采边写
def append_to_file(items):
with open(output_file, 'a') as f:
for item in items:
f.write(json.dumps(item) + '\n')
效果:内存占用始终保持在几MB,采集10000条也不怕。
📊 实战验收
准备工作
- 找一个实际的无限滚动页面(如社交媒体、新闻站点)
- 用Chrome DevTools确认选择器
- 修改DemoExtractor的extract_items方法
运行测试
bash
python infinite_scroll_crawler.py
预期输出
json
2026-01-24 18:30:15 [INFO] __main__: 开始无限滚动采集 | 目标:300条
2026-01-24 18:30:18 [INFO] __main__: 页面已打开: https://example.com/list
2026-01-24 18:30:22 [INFO] __main__: ✅ 新增20条 | 累计:20/300 | 去重:0
2026-01-24 18:30:26 [INFO] __main__: ✅ 新增18条 | 累计:38/300 | 去重:2
...
2026-01-24 18:35:45 [INFO] __main__: ✅ 新增15条 | 累计:300/300 | 去重:45
2026-01-24 18:35:45 [INFO] __main__: 🛑 停止采集 | 原因:已达目标数量(300)
📊 无限滚动采集统计
============================================================
采集数量: 300
去重后: 300
重复数: 45
滚动次数: 18
耗时: 330.2秒
输出文件: output/infinite_scroll/results_20260124_183015.jsonl
============================================================
验收标准
- 能正确滚动加载
- 去重率100%(无重复数据)
- 达到目标数量时自动停止
- 到底部时能检测并停止
- 断点文件正确生成
- 中断后能从断点恢复
- 内存占用稳定(不随采集数量增长)
🎨 进阶优化方向
1. 智能滚动节奏
python
# 根据加载速度动态调整暂停时间
if new_items_count > 10:
pause_time = 1.0 # 加载快,缩短等待
elif new_items_count > 0:
pause_time = 2.0 # 正常速度
else:
pause_time = 3.0 # 可能在加载,多等等
2. 视口检测优化
python
def wait_for_new_content(page, timeout=10):
"""等待新内容出现在视口中"""
page.evaluate('''
() => {
return new Promise((resolve) => {
const observer = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
observer.disconnect();
resolve();
}
});
document.querySelectorAll('.item').forEach(item => {
observer.observe(item);
});
setTimeout(resolve, 10000); // 超时保护
});
}
''')
3. 分批写入优化
python
class BatchWriter:
"""批量写入器"""
def __init__(self, batch_size=50):
self.batch_size = batch_size
self.buffer = []
def add(self, item):
self.buffer.append(item)
if len(self.buffer) >= self.batch_size:
self.flush()
def flush(self):
if self.buffer:
with open(output_file, 'a') as f:
for item in self.buffer:
f.write(json.dumps(item, ensure_ascii=False) + '\n')
self.buffer.clear()
4. 异常恢复机制
python
def crawl_with_retry(self, url, max_retries=3):
"""带重试的采集"""
for attempt in range(max_retries):
try:
return self.crawl(url, resume=(attempt > 0))
except Exception as e:
logger.error(f"采集失败(尝试{attempt + 1}/{max_retries}): {e}")
if attempt < max_retries - 1:
logger.info("30秒后重试...")
time.sleep(30)
else:
raise
🚨 常见问题与解决
Q1: 滚动了但页面没加载新内容?
原因:
- 滚动太快,内容还没加载完
- 页面使用了懒加载触发器
解决:
python
# 增加等待时间
config.scroll_pause = 3.0
# 或者等待特定元素出现
page.wait_for_selector('.new-item', timeout=5000)
Q2: 内存持续增长?
原因:
- results列表保留了所有数据
- 页面DOM元素累积
解决:
python
# 1. 不保留results,只写文件
# self.results.extend(new_items) # ❌ 删除这行
self._append_to_file(new_items) # ✅ 直接写入
# 2. 定期清理DOM(谨慎使用)
if self.state.scroll_count % 20 == 0:
page.evaluate('''
() => {
const items = document.querySelectorAll('.item');
for (let i = 0; i < items.length - 50; i++) {
items[i].remove();
}
}
''')
Q3: 断点恢复后重复采集?
原因:
- seen_ids没正确恢复
- 页面内容顺序变化
解决:
python
# 确保seen_ids完整恢复
def _load_checkpoint(self):
checkpoint = json.load(f)
self.dedup_manager.seen_keys = set(checkpoint['seen_ids'])
# 验证恢复是否成功
logger.info(f"已恢复{len(self.dedup_manager.seen_keys)}个去重键")
Q4: 如何处理需要登录的页面?
python
# 方案1:手动登录后保存cookies
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
# 手动登录...
input("登录完成后按回车...")
# 保存cookies
cookies = context.cookies()
with open('cookies.json', 'w') as f:
json.dump(cookies, f)
# 方案2:使用保存的cookies
with open('cookies.json') as f:
cookies = json.load(f)
context = browser.new_context()
context.add_cookies(cookies)
🎯 本期总结
这一期我们解决了无限滚动列表采集的核心难题:
✅ 智能滚动 :页面高度检测、终止条件判断
✅ 完善去重 :多级去重键、Set集合管理
✅ 稳定性 :超时保护、异常处理、内存控制
✅ 断点续传:进度保存、精确恢复
关键收获:
- 多重终止条件 > 单一条件(避免死循环或提前终止)
- 边采边写 > 全部缓存(内存可控)
- 去重键设计 > 简单判断(保证唯一性)
- 断点机制 > 重头再来(提高容错能力)
📚 下期预告
掌握了无限滚动,下一期我们来攻克另一个难题:
《分页采集的智能化:URL模式识别与并发控制》
预告内容:
- 识别5种常见分页模式(参数式、路径式、POST翻页...)
- 并发采集(10个页面同时跑)
- 去重与合并策略
- 智能限速(避免被封)
到时候你会发现,采集100页数据可以在1分钟内完成!
作业:用今天的代码,采集一个真实的无限滚动网站(如Twitter、Reddit等),目标300条数据,并验证:
- 去重率100%
- 能自动停止
- 断点能正确恢复
完成后在评论区分享你的统计数据!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。