WorkBuddy 自动化后分页全翻车?7 平台分页代码级修复(URL / AJAX / 虚拟列表 / API 劫持)

一、拾枝杂谈

本文记录 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=10begin=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 < 715:   # None 是假值,条件不成立
    break

page_oldest 是 None,if None 为假,停止条件永远不触发,脚本继续翻第3页、第4页……, 一直翻到空页才停。

更新后,把更新 page_oldest 移到 continue 前面:

更新 page_oldest        # ← 先更新,不管新不新
if 日期 < 715:
    continue

那么现在 10篇文章虽然全是旧的的,但 page_oldest 会被设成其中最老的日期(比如5月25日),就可以进入if语句了。

5.小红书

5.1 简单说

简单说就一句话,别在 window 上滚,直接找到真正滚动的那个内层容器,改它的 scrollTop 再手动派发 scroll 事件。

知乎靠键盘 End/PageDown 能解决,是因为它的懒加载挂在 window 上;小红书挂在内层容器上,所以同样的键盘方案失效,必须直接操作容器本身。

5.2 复杂说

复杂说可以分三步:

  1. 定位容器:先遍历候选选择器([class*=“scroll”]、[class*=“list”]、[class*=“content”] 等),找到 scrollHeight > clientHeight 的那个元素,它才是懒加载真正监听的滚动容器。

  2. 触发加载:设 el.scrollTop = el.scrollHeight,再 el.dispatchEvent(new Event(‘scroll’, {bubbles: true})),让监听器认为发生了真实滚动,从而触发 IntersectionObserver 加载新卡片。

  3. 日期驱动收尾:每次滚动后检查已加载卡片里的最旧发布日期,一旦早于 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-cmdv-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 框架,需找对选择器 + 等对刷新时机

我还针对分页逻辑处理,总结了三条经验:

  1. 最重要的经验:不要让AI猜平台字段,猜前端展示方式。每次遇到分页问题,第一步永远是用 Playwright 检视 DOM 结构和网络请求,搞清楚平台用的是哪种机制,再写代码。我一开始在 CSDN 上就犯了这个毛病 —— 直接猜选择器,结果还TM猜错了。
  2. 第二重要的经验:不要信任固定 sleep,网络情况总会有波动。AJAX 请求的响应时间是不确定的,固定等3秒在网络慢的时候不够。正确做法是找一个可观测的变化指标(行标题变了、页码变了、行数变了,等待),轮询等待它变化。
  3. 第三重要的经验:当 UI 操作太难驱动时(比如搜狐号的 Element UI 日期选择器),考虑直接调底层 API。前提是你能拿到正确的请求头——而 Playwright 的 page.on("request") 就是干这个的。
  • 好的,以上就是本篇文章的全部内容了,感谢阅读
  • 关注迅高AI实验室,学习AI不迷路!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值