返回博客

使用可追踪请求上下文对Google搜索结果进行分页

Michael Lee
Michael Lee

Expert Network Defense Engineer

15-Sep-2026

TL;DR:

  • Google 搜索 API 分页会改变收集的切片。 保留 start 和每个响应的完整请求,而不是将页面视为可互换的批次。
  • 仅在定义的计划和经过审查的响应合同内继续。 示例在改变偏移量之前检查返回的下一个链接,并在模糊的继续数据上停止。
  • 保持待处理的工作和重复的观察可见。 存储每个原始页面的响应;去重属于派生视图,HTTP 201 不是一个空页面。

分页可能会隐藏实验中的变化。收集器可能在失去国家设置的同时推进偏移量,或者将待处理的响应与已完成的页面结合,并称结果为完成。生成的 URL 列表并未揭示这些差异是如何进入收集中的。

无抓取 Google 搜索 API 通过 start 支持搜索偏移量。这个 Google 搜索 API 分页指南构建了一个故意有限制的收集器,为每个请求记录并提供停止的理由。它展示了应用控制流,而不承诺访问每个结果或不间断的历史排名序列。

前提条件和有限集合计划

该程序使用 Python 的标准库,并需要在 SCRAPELESS_API_KEY 中提供一个帐户 API 密钥以进行真实收集。环境变量名称是此示例的惯例。请将密钥保存在请求体日志和生成的捕获文件之外。

Google 搜索参数 描述 start 作为结果偏移量,示例为 0、10 和 20。该程序将其计划限制在这些偏移量范围内。这是一个应用绑定,不是关于服务最大深度的声明,也不保证每个页面包含固定数量的自然项目。

基础查询是 coffee,带有 gl=ushl=en 和桌面输入。这些设置是一个说明性请求配置。它们并不是作为一个观察到的搜索样本呈现。在决定您自己收集的范围之前,请勿更改配置。

由于未提供帐户密钥,因此未为本文执行经过身份验证的请求。地方测试使用合成响应验证继续和状态逻辑。实时账户运行仍然是验证当前响应行为的前提。

在每个原始捕获旁保持页面偏移量

Google 搜索请求工作流程POST https://api.scrapeless.com/api/v1/scraper/request 提交 scraper.google.search,并在 x-api-token 中进行身份验证。收集器将提交的主体与响应分别记录,并添加客户端时间戳和自己的运行标识符。

HTTP 200 包含任务数据;HTTP 201 表示任务仍在处理。必须在停止之前保存后者的响应。其任务标识符保留在原始响应中以供单独验证的完成工作流程使用;此示例并不虚构任务检索端点。

捕获信封属于应用程序。其 requestresponse、时间戳和错误字段未作为本机 API 封装。将它们分开保持遵循JSON 数据模型,并为后续映射器保留其底层证据。

在推进之前检查继续证据

快速启动响应示例包括 pagination.next,但示例 URL 被缩写。请勿执行该缩写值。代码需要一个实际的、未截断的 HTTPS Google 搜索 URL,然后才能将其偏移量用作继续提示。

此示例仅接受经过审核的 Google 主机,并检查请求的查询、国家和返回链接中的语言是否与基础配置匹配。它保留所有已提交的基础设置,仅更改 start。下一个链接中缺少国家字段会导致审核停止,而不是对服务意图的推断。

这些检查使用URL 组件解析。它们定义了应用程序狭窄的继续政策,而不是每个有效的 Google URL。收集器绝不会直接跟随返回的 URL;它将下一个请求发送到同一个 API 端点,使用经过审核的参数模式主体。

以明确的停止理由运行收集器

将程序保存为 paginate_search.py。设置好环境中的密钥后,在可写目录中运行 python3 paginate_search.py。该脚本创建一个唯一命名的输出目录,顺序发送请求,并保留每个页面捕获加上 collection-summary.json

注意: 实时 API 收集是前提条件,本文章没有执行。代码的本地控制流使用合成响应进行了测试。在依赖下一个链接策略之前,请检查真实账户输出,并在请求返回 HTTP 201 时单独验证任务检索。

python Copy
import json
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, urlsplit
from urllib.request import Request, urlopen

ENDPOINT = 'https://api.scrapeless.com/api/v1/scraper/request'
OFFSETS = (0, 10, 20)
BASE = {'q': 'coffee', 'gl': 'us', 'hl': 'en', 'device': 'desktop'}


def now():
    return datetime.now(timezone.utc).isoformat()


def fetch(body, key):
    request = Request(ENDPOINT, data=json.dumps(body).encode(), method='POST',
                      headers={'Content-Type': 'application/json', 'x-api-token': key})
    try:
        with urlopen(request, timeout=60) as response:
            status, raw = response.status, response.read().decode('utf-8')
    except HTTPError as error:
        status, raw = error.code, error.read().decode('utf-8', errors='replace')
    except (URLError, TimeoutError) as error:
        return None, None, type(error).__name__
    try:
        return status, json.loads(raw), None
    except json.JSONDecodeError:
        return status, {'unparsed_body': raw}, 'response_not_json'


def next_offset(payload, current):
    pagination = payload.get('pagination')
    link = pagination.get('next') if isinstance(pagination, dict) else None
    if not isinstance(link, str) or not link:
        return None, 'next_link_unavailable'
    try:
        parts = urlsplit(link)
        if (parts.scheme != 'https' or parts.hostname not in ('google.com', 'www.google.com')
                or parts.username or parts.password or parts.port or parts.path != '/search'
                or parts.fragment or '...' in link or '…' in link):
            return None, 'next_link_needs_review'
        query = parse_qs(parts.query, keep_blank_values=True)
        if any(query.get(k) != [BASE[k]] for k in ('q', 'gl', 'hl')):
            return None, 'next_context_needs_review'
        values = query.get('start', [])
        if len(values) != 1 or not values[0].isascii() or not values[0].isdigit():
            return None, 'next_offset_needs_review'
        offset = int(values[0])
        if offset <= current or offset not in OFFSETS:
            return None, 'next_offset_outside_plan'
        return offset, None
    except ValueError:
        return None, 'next_link_needs_review'


def collect(directory, key):
    directory = Path(directory)
    directory.mkdir(parents=True, exist_ok=False)
    seen, visited, pages = set(), set(), []
    offset, stop = 0, None
    while offset in OFFSETS and offset not in visited:
        visited.add(offset)
        body = {'actor': 'scraper.google.search', 'input': dict(BASE, start=offset)}
        started = now()
        status, payload, error = fetch(body, key)
        run_id = uuid.uuid4().hex
        capture = {'run_id': run_id, 'requested_at': started, 'received_at': now(),
                   'request': body, 'http_status': status, 'response': payload, 'error': error}
        filename = f'{offset}-{run_id}.json'
        (directory / filename).write_text(json.dumps(capture, ensure_ascii=False, indent=2), encoding='utf-8')
        page = {'start': offset, 'capture': filename, 'new_exact_urls': None}
        pages.append(page)
        if status == 201:
            stop = 'pending'
            break
        if status != 200 or error:
            stop = 'collection_error'
            break
        rows = payload.get('organic_results') if isinstance(payload, dict) else None
        if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
            stop = 'unmapped_organic_results'
            break
        links = {row['link'] for row in rows if isinstance(row.get('link'), str) and row['link']}
        page['new_exact_urls'] = len(links - seen)
        seen.update(links)
        if not rows:
            stop = 'empty_organic_slice'
            break
        if not links:
            stop = 'no_usable_link_strings'
            break
        if page['new_exact_urls'] == 0:
            stop = 'no_new_exact_urls'
            break
        if len(visited) == len(OFFSETS):
            stop = 'planned_page_limit'
            break
        offset, stop = next_offset(payload, offset)
        if stop:
            break
    summary = {'pages': pages, 'stop_reason': stop, 'unique_exact_url_strings': len(seen)}
    (directory / 'collection-summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
    return summary


if __name__ == '__main__':
    key = os.environ['SCRAPELESS_API_KEY']
    output = 'search-pages-' + uuid.uuid4().hex
    print(json.dumps(collect(output, key), indent=2))

超时是本地客户端设置,而不是服务性能声明。传输异常或非 JSON 响应会产生错误记录,并停止收集。该程序保留先前的捕获,而不是将部分运行呈现为完整抓取。

使用 Scrapeless 开始抓取

使用 Scrapeless 让你的网络抓取和自动化工作流程蓬勃发展!
今天注册并获得 5 美元的免费积分无需信用卡

立即在 Scrapeless 控制面板索取你的免费积分。

从搜索证据中分离去重

摘要在每个页面上计算新的确切 URL 字符串,同时保留每个原始响应。重复的 URL 保留在那些捕获中,并带有其页面上下文。这允许审阅者检查重叠,而不是在导出过程中丢失它。

确切字符串去重故意较窄。它不合并跟踪变体、片段、规范 URL 或同一域的不同页面。更广泛的分组策略应在单独版本的转换中进行,并应保留原始链接。

当可用页面未贡献新的确切 URL 字符串时,程序停止。这是一个收集预算的决策,而不是没有进一步相关页面存在的证明。同样,当前存在的空有机数组在没有建立详尽搜索边界的情况下停止此次运行。

诚实地解读部分收集

在使用其 URL 计数之前,请阅读摘要的停止原因。 planned_page_limit 意味着达到了本地边界。 next_link_unavailable 意味着未建立继续。待处理、未映射和收集错误状态描述未完成或不可用的证据,而不是成功的结果结束检测。

每个请求都有其自己的时间戳。顺序页面并不是同时收集的,程序不声称在调用之间共享服务器会话。比较此运行与另一个运行时,请保留收集窗口。

出处模型 有助于分离原始页面观察、收集活动和派生的去重列表。即使是一个小的文件系统布局也可以保留这些关系,只要摘要指向每个捕获。

结论

计划有限的收集,在保留每个提交的请求后,只有在响应支持应用程序的继续规则时才能前进。明确的停止原因和保留的重复项使部分数据集能够被理解,而不声称它包含每一个 Google 结果。

一种 SERP 快照数据集 方法可以帮助在收集后组织保存的观察;在决定哪些记录是可比较时,保持页面偏移可见。

构建你的下一个搜索观察

在本工作流程中使用 Scrapeless Google Search API 获取搜索数据。在计划收集时查看 Scrapeless 定价,并将 Google 搜索参数 保留在你的配置旁边。

DiscordTelegram 上与社区讨论你的实现。

常见问题

问:start=10 是否保证十行有机结果?

不。 start 指定了偏移量。检查实际的有机数组,而不是从请求的偏移量推导其长度。

问:此脚本是否收集每一个 Google 结果?

不。它受到小型应用计划的限制,并在没有继续或可用数据时停止。

问:HTTP 201 会发生什么?
响应已保存,收集停止标记为待处理。在解释最终搜索数据之前,需要一个经过验证的任务完成工作流程。

问:为什么在下一个链接省略上下文字段时停止?

此示例需要明确的查询、国家和语言一致性。缺失的证据会触发审查,而不是对继续进行的无声假设。

问:重复的 URL 是否从原始捕获中移除?

不。仅摘要的确切字符串计数被去重。原始观察仍可供检查。

在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。

最受欢迎的文章

目录