一、拾枝杂谈
本文记录 WorkBuddy 自动化任务在 7 平台数据采集中分页全翻车的代码级修复过程。
之前 up 用 WorkBuddy 设置了7个自动化任务,统计我7个平台的文章数据,然后通过 WorkBuddy 的 Feishu 连接器写入飞书表格,方便我进行统一查看。
这些自动化任务我基本工作日每天都跑一遍,结果才跑了一个月,就出问题了呜呜┭┮﹏┭┮。
起因是我发的文章越来越多了,导致很多平台的后台都可以进行分页统计了。问题是我之前的脚本压根没处理"分页"逻辑,所以后果可想而知了😂。

二、分页问题的不同表现形式
0.百里不同风,千里不同俗
7个平台看起来都叫"分页问题",但实际上表现各不相同,有的是 URL 参数翻页,有的是 AJAX 局部刷新,有的压根没有分页 —— 而是滚动加载。这里 up 先把每个平台的问题表现和产生的后果列一遍,如下图所示:

1.CSDN 展现量分页
CSDN 的文章列表页本身是一次性全加载的,不存在分页问题,这一点非常友好。但麻烦点在于:展现量这个字段不在文章列表页上,需要跳转到数据中心"单篇文章分析"页面单独获取。
而那个分析表格,每5篇文章就会自动分页。
一开始的脚本压根没处理这个分页,导致自动化任务跑出来的结果就是:所有文章的展现量都是 0,因为脚本只读了第一页的5篇,后面的全漏了。
2.微信公众号文章分页
微信公众号后台的发表记录页,URL 携带的参数是这么一回事滴:?sub=list&begin=0&count=10&token=xxx。
其中,begin 是偏移量,count 是每页条数,点击页码按钮时,URL 会变成 begin=10、begin=20……
但原来的脚本只处理了 begin=0 的第一页(count=10),也就是说只要文章超过10篇,后面的就全丢了。我当时一共发了12篇文章,某一次突然发现少了两篇文章,才发现分页问题。
3.知乎号滚动逻辑
知乎后台没有分页控件,看起来不存在分页问题,但它用的是虚拟列表 + 懒加载:页面初始其实只渲染10张卡片,需要往下滚动才会继续加载。
原来的滚动逻辑是 window.scrollTo(0, document.body.scrollHeight),即结果滚动30次,但是实操下来卡片数始终是10 —— 因为根本没触发懒加载。
更隐蔽的是,日期检查的正则要求4位年份(\d{4}),但知乎的 tooltip 格式是 发布于 08-06 18:18(没有年份),导致最老日期永远是 N/A,日期驱动的终止条件完全失效。
那么这个滚动逻辑会导致什么后果,你们都懂的。
4.今日头条分页
今日头条用的是 ByteDance 自家的 byte-pagination 分页控件。点击页码时 URL 不变,列表通过 AJAX 局部刷新。
原来的脚本只解析了第一页的 DOM,后面的页码根本没点。平台显示"共156条内容",每页10条,共16页 —— 但脚本其实只拿到了前10篇。
5.小红书滚动逻辑
小红书和知乎类似,没有分页控件,靠滚动加载。但比知乎更难搞:window.scrollTo 不行,键盘 End/PageDown 也不行。
根因是小红书的懒加载不监听 window 的 scroll 事件,而是监听内层滚动容器的 scroll 事件。你在 window 上怎么滚都没用。
后果就是,30次滚动后卡片数始终是10,GEO-001~003 全部遗漏。
6.百家号分页
百家号的 URL 带有 currentPage 参数,大概长这样: ?currentPage=1&pageSize=10&type=news...
点击页码时 currentPage 从1变成2、3……URL 是会变的,比头条友好多了。
但原来的脚本只抓了 currentPage=1,208篇文章只拿到了前10篇。
7.搜狐号分页
搜狐号用的是 Element UI 的 el-pagination 控件,点击页码时 URL 不变,表格局部刷新。
但是更麻烦的是日期范围选择器也是 Element UI 的 el-range-input,通过 JavaScript 设值后不会触发查询请求 —— 事件没派发对。
原来的脚本只拿到了默认日期范围(最近7天)内的几篇文章,其他全漏了。
三、分页逻辑的不同处理方式
1.CSDN
1.1 找对选择器
CSDN 展现量分页的修复分两步:找对选择器 和 等对时机。一开始我让 WorkBuddy 直接改,它猜了个 .el-pagination .btn-next,结果完全不对。被我说了一顿之后,WorkBuddy 老老实实用 Playwright 分析 DOM,发现 CSDN 数据中心用的不是标准 Element UI,而是自家的 el_mcm 前缀。正确的选择器是:
selectors = [
".dataanalysisArticle .el_mcm-pagination .btn-next.is-last",
".dataanalysisArticle .el_mcm-pagination .btn-next",
".el_mcm-pagination .btn-next.is-last",
".el_mcm-pagination .btn-next",
]
注意 .dataanalysisArticle 这个容器负责限定,代码先定位到目标区域的容器 .dataanalysisArticle 上,因为一个页面上可能有多个表格,不加限定会匹配到别的表格的行。
1.2 等对时机
CSDN 数据中心的表格不是立刻就有数据的,有两处需要等待 AJAX。
第一处:首次加载等待。
每次我们点击下一页后,表格不是立刻刷新的,而是发一个 AJAX 请求重新拉数据。如果用固定 sleep(3000),表头立即渲染,但数据行通过后台 AJAX 异步加载,网络慢的时候还没加载完就解析了,无法正常拿到数据。
解决方案是 轮询等待直到容器内至少有一行的展现量(第3列)解析出大于0的整数:
async def _wait_for_impression_table_load(self, timeout_ms=30000):
while 超时时间内:
has_data = await self.page.evaluate("""
() => {
const container = document.querySelector('.dataanalysisArticle');
const rows = container.querySelectorAll('.el_mcm-table__row');
for (const row of rows) {
const cells = row.querySelectorAll('.el_mcm-table__cell');
const v = cells[2].innerText.replace(/,/g, '').trim();
if (parseInt(v, 10) > 0) return true;
}
return false;
}
""")
if has_data:
return # 真实数据行出现了
第二处:翻页后等待。
之后,每当我们点击下一页后,表格里还残留着上一页的旧数据,需要等 AJAX 刷新。这里的判断方式略有变化 —— 对比翻页前后的行标题,一旦发现变了就说明新数据到了。代码如下:
async def _wait_for_table_refresh(self, timeout_ms=10000):
initial_titles = await self.page.evaluate("""
() => {
const container = document.querySelector('.dataanalysisArticle');
const rows = container ? container.querySelectorAll('.el_mcm-table__row') : [];
return Array.from(rows).map(r => {
const first = r.querySelector('.el_mcm-table__cell');
return first ? first.innerText.trim() : '';
}).filter(t => t);
}
""") or []
start = asyncio.get_event_loop().time()
while (asyncio.get_event_loop().time() - start) * 1000 < timeout_ms:
await self.page.wait_for_timeout(500)
current_titles = await self.page.evaluate("""...同上...""") or []
if current_titles != initial_titles:
return # 表格已刷新
原理是翻页前拍一张"快照"(所有行的标题列表),翻页后轮询对比,一旦不一样了就说明新数据到了。
1.3 完整的翻页循环
代码如下:
while page_num <= max_pages:
articles = await self._extract_impressions_js()
all_articles.extend(articles)
has_next = await self._click_next_page() # 点下一页
if not has_next:
break
await self._wait_for_table_refresh() # 等新数据
page_num += 1
2.微信公众号
2.1 展示逻辑
微信公众号的分页是最干净的,URL 参数直接控制,不需要点任何按钮。页面里有个全局 JavaScript 对象 publish_page,其中 total_count 是文章总数:
publish_page = {
total_count: 12,
publish_list: [...],
...
}
这意味着脚本不需要盲目翻页到空页才知道结束了,直接读 total_count 就能算出总页数。
2.2 翻页逻辑
代码如下:
page_size = 10
while page_index < max_pages:
begin = page_index * page_size
url = (f"链接"
f"&begin={begin}&count={page_size}&token={token}&lang=zh_CN")
await self.page.goto(url, wait_until="domcontentloaded")
await self.page.wait_for_timeout(3000)
articles = await self._extract_weixin_articles_js()
if not articles:
break
all_articles.extend(articles)
page_index += 1
# total_count 覆盖完了就停
if expected_total and page_index >= (expected_total + page_size - 1) // page_size:
break
其中,begin 是偏移量,每次加 page_size(10),直接 goto 新 URL 就能加载下一页。不需要点击任何 DOM 元素,比头条那种 AJAX 局部刷新简单多了。
3.知乎号
3.1 滚动方式
知乎的修复改了两处:滚动方式 和 日期解析。传统的 window.scrollTo 无法触发知乎的虚拟列表懒加载,但键盘 End + PageDown 可以,于是补充了如下的代码逻辑:
for i in range(max_scrolls):
await self.page.keyboard.press("End")
await self.page.keyboard.press("PageDown")
await self.page.wait_for_timeout(1200)
为什么键盘可以但 window.scrollTo 不行?因为知乎的虚拟列表用 IntersectionObserver 监听一个 sentinel 元素(一个空的 <div role="listitem">),当它进入视口时才触发加载。window.scrollTo 虽然改变了滚动位置,但 sentinel 的 getBoundingClientRect() 没有正确更新(可能是虚拟列表的实现细节),而键盘事件走的是浏览器的原生滚动路径,能正确触发 IntersectionObserver。
3.2 日期解析
知乎的 tooltip 格式是 发布于 MM-DD HH:MM,没有年份,而原来的正则要求4位年份,导致永远匹配不上,修复方法就是复用已有的 _parse_publish_datetime。代码如下:
@staticmethod
def _parse_publish_datetime(tooltip):
m = re.search(r"发布于\s*(\d{1,2})[-/月.](\d{1,2})\s*(\d{1,2}):(\d{2})", tooltip)
if not m:
return None
month, day, hh, mm = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
return datetime(2026, month, day, hh, mm) # 假设年份是2026
3.3 终止条件
代码如下:
if min_dt and min_dt.date() < cutoff:
break # 最老文章已经早于 START_DATE,可以停了
if card_count == last_card_count:
no_change_count += 1
if no_change_count >= 8:
break # 连续8次没有新卡片,到底了
4.今日头条
4.1 找到下一页按钮
今日头条的难点在于 URL 不变,纯靠 AJAX 局部刷新。好在我们之前已经有过CSDN的经验了。先通过 Playwright 分析 DOM,发现分页控件的结构是这样的:
<li class="byte-pagination-item byte-pagination-item-icon">
<svg class="byte-icon byte-icon-right">...</svg>
</li>
注意有两个 icon 按钮(左箭头和右箭头),要找的是包含 byte-icon-right 的那个,还要检查 disabled 类名,最后一页时它会变成 disabled。
async def _click_next_page(self):
return await self.page.evaluate(r"""
() => {
const icons = Array.from(
document.querySelectorAll('.byte-pagination-item-icon'));
for (const el of icons) {
if (el.classList.contains('disabled')) continue;
const svg = el.querySelector('svg');
if (svg) {
const cls = svg.getAttribute('class') || '';
if (cls.includes('right')) {
el.click();
return true;
}
}
}
return false;
}
""")
4.2 等待页码变化
点击下一页后,要等 AJAX 刷新完再解析数据。判断方法是对比 .byte-pagination-item-active 的页码是否变了。代码如下:
async def _wait_for_page_change(self, old_page, timeout_ms=10000):
for _ in range(timeout_ms // 500):
await self.page.wait_for_timeout(500)
new_page = await self._get_current_page()
if new_page != old_page and new_page > 0:
return new_page
return old_page
4.3 踩过的坑
一开始 page_oldest(当前页最老文章日期)只统计了符合日期条件的文章,导致全是旧文章的页面 page_oldest 仍为 None,无法触发停止。后来把日期更新移到过滤之前,所有文章都参与 oldest 计算。
这个我举个例子来帮助大家理解。假设 START_DATE = 2026-07-15,头条第2页有10篇文章,全部是5月份的(都早于7-15),导致 page_oldest = None 始终成立。但是在判断停止的时候:
if page_oldest and page_oldest < 7月15日: # None 是假值,条件不成立
break
page_oldest 是 None,if None 为假,停止条件永远不触发,脚本继续翻第3页、第4页……, 一直翻到空页才停。
更新后,把更新 page_oldest 移到 continue 前面:
更新 page_oldest # ← 先更新,不管新不新
if 日期 < 7月15日:
continue
那么现在 10篇文章虽然全是旧的的,但 page_oldest 会被设成其中最老的日期(比如5月25日),就可以进入if语句了。
5.小红书
5.1 简单说
简单说就一句话,别在 window 上滚,直接找到真正滚动的那个内层容器,改它的 scrollTop 再手动派发 scroll 事件。
知乎靠键盘 End/PageDown 能解决,是因为它的懒加载挂在 window 上;小红书挂在内层容器上,所以同样的键盘方案失效,必须直接操作容器本身。
5.2 复杂说
复杂说可以分三步:
-
定位容器:先遍历候选选择器([class*=“scroll”]、[class*=“list”]、[class*=“content”] 等),找到 scrollHeight > clientHeight 的那个元素,它才是懒加载真正监听的滚动容器。
-
触发加载:设 el.scrollTop = el.scrollHeight,再 el.dispatchEvent(new Event(‘scroll’, {bubbles: true})),让监听器认为发生了真实滚动,从而触发 IntersectionObserver 加载新卡片。
-
日期驱动收尾:每次滚动后检查已加载卡片里的最旧发布日期,一旦早于 START_DATE 就停;再加一个「连续 8 次卡片数无变化就停」的兜底,避免死循环。
6.百家号
6.1 构造分页 URL
百家号和微信公众号一样是 URL 参数分页,处理方式最直白。代码如下:
def _build_page_url(self, page_num):
return (
"链接"
f"?currentPage={page_num}&pageSize=10&search=&type=news"
"&collection=&startDate=&endDate=&clearBeforeFetch=false"
)
6.2 翻页循环
代码如下:
for page_num in range(1, max_pages + 1):
if page_num > 1:
await self._load_page(page_num) # goto 新 URL
cards = await self._extract_articles_from_page()
if not cards:
break
# 统计本页最老日期
page_oldest = None
for c in cards:
pub_dt = datetime.strptime(c["dateStr"], "%Y-%m-%d")
if page_oldest is None or pub_dt.date() < page_oldest:
page_oldest = pub_dt.date()
# 过滤 + 收集
...
if page_oldest and page_oldest < self.start_date:
break # 本页已经翻到旧文章了,可以停
7.搜狐号
7.1 思路
搜狐号是最特殊的一个,因为 Element UI 的日期选择器和分页控件都很难通过 Playwright 驱动,即便设了值也不触发查询,并且点击页码也要等局部刷新。最终我放弃了 UI 操作,改用API 直连。搜狐号的数据分析页面,底层是通过 AJAX 调用一个 API。这里我就不放了,懂得都懂。
但这个 API 有设备指纹校验,裸 fetch 会返回 code 1301 "获取客户端失败"。需要带上 sp-cm、dv-id 等请求头。
7.2 捕获请求头
先在页面导航前注册 page.on("request"),捕获 UI 发出的第一个 single/list 请求的完整请求头:
captured = {}
captured_event = asyncio.Event()
def on_request(request):
if captured:
return
if "single/list" in request.url:
captured["url"] = request.url
captured["headers"] = request.headers
captured_event.set()
self.page.on("request", on_request)
# ... 导航到数据分析页 ...
await asyncio.wait_for(captured_event.wait(), timeout=30)
7.3 用捕获的请求头循环调 API
代码如下:
account_id = re.search(r"accountId=(\d+)", captured["url"]).group(1)
base_headers = captured["headers"]
while page_num <= max_pages:
url = (
f"链接"
f"?page={page_num}&type=0&startDate={start}&endDate={end}"
f"&accountId={account_id}"
)
resp = await self.page.evaluate(r"""
async (args) => {
const r = await fetch(args.url, {
method: 'GET',
credentials: 'include',
headers: args.headers
});
return {status: r.status, json: await r.json()};
}
""", {"url": url, "headers": base_headers})
news = resp["json"].get("data", {}).get("news", [])
if not news:
break
all_news.extend(news)
if len(news) < 10:
break
page_num += 1
7.4 字段映射
API 返回的 JSON 字段和飞书表格的列名不一样,需要映射,映射关系如下表所示:
| API 字段 | 飞书字段 |
|---|---|
pv | 阅读数 |
pvValid | 访问数 |
likeCount | 点赞数 |
commentCount | 评论数 |
shareCount | 分享数 |
voteCount | 投票数 |
这个方案绕过了所有 UI 操作的痛点,直接拿到底层数据,稳定性和速度都比模拟点击好得多。
四、总结
7个平台,看起来都是"分页问题",但实际上可以分成4类,如下表所示:
| 类型 | 平台 | 核心思路 |
|---|---|---|
| URL 参数分页 | 微信公众号、百家号 | 直接 goto 新 URL,读全局变量或解析 DOM |
| AJAX 局部刷新 | 今日头条、搜狐号 | 找到分页按钮/API,点击或直接调接口 |
| 滚动懒加载 | 知乎、小红书 | 触发正确的 scroll 事件(键盘/内层容器) |
| 特殊分页(本质也是Ajax) | CSDN 展现量 | 数据中心表格用自家 UI 框架,需找对选择器 + 等对刷新时机 |
我还针对分页逻辑处理,总结了三条经验:
- 最重要的经验:不要让AI猜平台字段,猜前端展示方式。每次遇到分页问题,第一步永远是用 Playwright 检视 DOM 结构和网络请求,搞清楚平台用的是哪种机制,再写代码。我一开始在 CSDN 上就犯了这个毛病 —— 直接猜选择器,结果还TM猜错了。
- 第二重要的经验:不要信任固定
sleep,网络情况总会有波动。AJAX 请求的响应时间是不确定的,固定等3秒在网络慢的时候不够。正确做法是找一个可观测的变化指标(行标题变了、页码变了、行数变了,等待),轮询等待它变化。 - 第三重要的经验:当 UI 操作太难驱动时(比如搜狐号的 Element UI 日期选择器),考虑直接调底层 API。前提是你能拿到正确的请求头——而 Playwright 的
page.on("request")就是干这个的。
- 好的,以上就是本篇文章的全部内容了,感谢阅读!
- 关注迅高AI实验室,学习AI不迷路!
&spm=1001.2101.3001.5002&articleId=164062603&d=1&t=3&u=741fbb603ff24efbaf6b3a6d32601596)
1628

被折叠的 条评论
为什么被折叠?



