做一个关键词排名跟踪工具:每天用 Bright Data SERP API 查询 Google 搜索结果,取回结构化 JSON,记录你的网站在每个关键词下排第几、第一名是谁,追加到 CSV,用 Excel 就能画出排名变化曲线。
先看清成品
成品是
rank.py加一个keywords.json配置。运行后终端显示每个关键词的排名,ranks.csv每天追加一行。本文代码已用一份按官方 Light JSON 格式编写的样例响应实际测试,4 个测试全部通过:查询参数顺序正确、子域名计入但相似域名不计入、正确找出排名、CSV 表头正确。本文环境没有 Bright Data 账号,真实的 SERP API 请求没有发送。
注意:采集前先读目标网站的服务条款和 robots.txt,只采集公开、非个人的数据,控制频率,不绕过登录和付费墙,不采集个人信息。本文示例使用专门供练习的网站 toscrape.com。用于商业用途前请咨询法律意见,并遵守所在地关于数据和个人信息保护的法律。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 关键词配置 | 域名、地区、语言、关键词列表 |
| SERP 查询 | brd_json=parsed_light 返回结构化结果 |
| 排名判断 | 主域名和子域名都算 |
| 历史记录 | ranks.csv,带 BOM,Excel 直接打开 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Bright Data 账号 | 新账号每月有 5,000 免费额度 | 以官方免费额度页面为准,额度用完会停止,不会自动扣费 |
| Python | 3.10 或更高 | 建议使用虚拟环境 |
| 依赖 | requests、pytest |
第一步:怎样准备 SERP API?
1. 创建区域
控制台添加一个 SERP API 区域,记下区域名(如 serp_api1)和 API 密钥。
2. 先用 curl 测试
curl -X POST https://api.brightdata.com/request \
-H "Content-Type: application/json" -H "Authorization: Bearer 你的API密钥" \
-d '{"zone":"serp_api1","url":"https://www.google.com/search?q=pomodoro+timer&gl=us&hl=en&brd_json=parsed_light","format":"raw"}'
| 参数 | 作用 |
|---|---|
gl |
搜索地区 |
hl |
界面语言 |
brd_json=parsed_light |
只返回自然结果等主要部分,官方说明比完整 JSON 快约一倍 |
注意:官方文档特别说明
q要放在最前面,gl写在q之前属于错误写法。
第二步:代码怎么写?
1. keywords.json
{
"domain": "example.com",
"gl": "us",
"hl": "en",
"keywords": ["pomodoro timer online", "focus timer for studying"]
}
2. rank.py
"""关键词排名跟踪:用 Bright Data SERP API 查询 Google,记录你的网站在前 N 条自然结果中的排名。
用法:python rank.py # 查询 keywords.json 里的所有关键词,追加到 ranks.csv
"""
import csv
import json
import os
import sys
import time
from datetime import date
from pathlib import Path
from urllib.parse import quote_plus, urlparse
import requests
API = "https://api.brightdata.com/request"
HERE = Path(__file__).parent
def serp_url(keyword: str, gl: str, hl: str) -> str:
# 注意顺序:q 必须放在最前面,gl、hl 跟在后面
return f"https://www.google.com/search?q={quote_plus(keyword)}&gl={gl}&hl={hl}&brd_json=parsed_light"
def search(keyword: str, gl: str = "us", hl: str = "en") -> dict:
r = requests.post(
API,
headers={"Authorization": f"Bearer {os.environ['BRIGHTDATA_API_KEY']}"},
json={"zone": os.environ["BRIGHTDATA_SERP_ZONE"], "url": serp_url(keyword, gl, hl), "format": "raw"},
timeout=90,
)
r.raise_for_status()
return r.json()
def same_site(link: str, domain: str) -> bool:
host = (urlparse(link).hostname or "").lower()
return host == domain or host.endswith("." + domain)
def find_rank(result: dict, domain: str):
"""返回 (排名, 链接);不在结果里返回 (None, None)。"""
for item in result.get("organic", []):
if same_site(item.get("link", ""), domain):
return item.get("global_rank") or item.get("rank"), item["link"]
return None, None
def run(config: dict, searcher=search, out: Path = HERE / "ranks.csv", delay: float = 2.0):
rows = []
new = not out.exists()
with out.open("a", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
if new:
w.writerow(["日期", "关键词", "地区", "排名", "链接", "第一名"])
for i, kw in enumerate(config["keywords"]):
if i:
time.sleep(delay)
try:
res = searcher(kw, config.get("gl", "us"), config.get("hl", "en"))
except Exception as e:
print(f"✗ {kw}:{e}")
continue
rank, link = find_rank(res, config["domain"])
top = (res.get("organic") or [{}])[0].get("link", "")
row = [date.today().isoformat(), kw, config.get("gl", "us"), rank or "未进入", link or "", top]
w.writerow(row)
rows.append(row)
print(f"{kw}:{rank if rank else '未进入前列'}")
return rows
if __name__ == "__main__":
cfg = json.loads((HERE / "keywords.json").read_text(encoding="utf-8"))
run(cfg)
sys.exit(0)
| 要点 | 说明 |
|---|---|
organic 数组 |
官方说明这是唯一保证存在的字段 |
global_rank |
结果在页面上的位置 |
same_site |
blog.example.com 算你的,notexample.com 不算 |
searcher 参数 |
测试时替换成假函数,不消耗额度 |
3. fixtures/serp_light.json(测试样例)
{
"general": { "query": "pomodoro timer online" },
"organic": [
{ "global_rank": 1, "title": "Competitor timer", "link": "https://pomofocus.example.net/" },
{ "global_rank": 2, "title": "Another one", "link": "https://www.othertimer.example.org/app" },
{ "global_rank": 3, "title": "My Focus Timer", "link": "https://blog.example.com/focus-timer" }
]
}
第三步:怎样测试和运行?
1. 测试
import json
from pathlib import Path
from rank import find_rank, run, same_site, serp_url
FIX = json.loads((Path(__file__).parent / "fixtures/serp_light.json").read_text())
def test_query_param_order():
u = serp_url("pomodoro timer", "us", "en")
assert u.startswith("https://www.google.com/search?q=pomodoro+timer&gl=us")
assert u.endswith("brd_json=parsed_light")
def test_subdomain_counts_but_lookalike_does_not():
assert same_site("https://blog.example.com/x", "example.com")
assert not same_site("https://notexample.com/x", "example.com")
def test_find_rank():
assert find_rank(FIX, "example.com") == (3, "https://blog.example.com/focus-timer")
assert find_rank(FIX, "absent.com") == (None, None)
def test_run_writes_csv(tmp_path):
out = tmp_path / "r.csv"
rows = run({"domain": "example.com", "keywords": ["a", "b"]}, searcher=lambda *a: FIX, out=out, delay=0)
assert [r[3] for r in rows] == [3, 3]
assert out.read_text(encoding="utf-8-sig").splitlines()[0] == "日期,关键词,地区,排名,链接,第一名"
pip install requests pytest
python -m pytest -q
本文实际输出 4 passed。
2. 真实运行
export BRIGHTDATA_API_KEY=你的API密钥
export BRIGHTDATA_SERP_ZONE=serp_api1
python rank.py
期望输出每个关键词一行,例如 pomodoro timer online:7,数字取决于真实搜索结果。
3. 每天运行并查看趋势
用 cron 每天运行一次,一段时间后用 Excel 打开 ranks.csv,以日期为横轴、排名为纵轴插入折线图。排名数字越小越好,可以把纵轴设为逆序。
出问题时该怎么调?
1. 排名一直“未进入”
| 原因 | 解决方法 |
|---|---|
| 确实不在前 10 | Light JSON 只含 10 条自然结果,需要更深时改用完整 JSON 并研究翻页参数 |
域名写成了带 www |
domain 填主域名,代码会匹配子域名 |
| 地区不对 | 检查 gl、hl |
2. 返回的是 HTML 不是 JSON
URL 里没有带 brd_json 参数,或者写错了值。
3. 不同天排名波动很大
搜索结果本身会因地区、时间、个性化而变化。看一周以上的趋势,不要只看单日。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
KeyError: BRIGHTDATA_API_KEY |
没设置环境变量 | 先 export |
| 401 | 密钥错误 | 重新复制 |
| 结果地区不对 | gl 放在了 q 前面 |
按文档顺序 |
| CSV 中文乱码 | 编码 | 文件已用 utf-8-sig 写入 |
| 请求太慢 | 完整 JSON 体积大 | 用 parsed_light |
| 关键词很多 | 逐个查询 | 官方提供异步模式,可按文档改造 |
项目资源
建议先跑 pytest 理解排名判断逻辑,再用 curl 手动查一个关键词,确认返回的 JSON 里有 organic。
之后只放两三个关键词运行一周,观察额度消耗,再增加关键词。