周报的价值在于"同一组指标、每周同一时间、一眼看出变化"。数据你已经在存了(前面写过定时采集入库),这篇补上后半段:三个 SQL 加一个 Markdown 模板,把库里的原始 JSON 变成一页能发给团队的报告。
先定指标,再写 SQL
周报只放五个数字,多了没人看:
| 指标 | 定义 | 变了说明什么 |
|---|---|---|
| 平均最好位次 | 每个词取你站最好位次,再求平均 | 整体盘子的移动方向 |
| Top3 / Top10 占比 | 最好位次 ≤3 / ≤10 的词占比 | 头部阵地的得失 |
| 本周新进榜 | 上周没有、本周进前 10 的词 | 新内容的起量 |
| 本周跌出 | 上周在前 10、本周掉出去的词 | 需要排查的对象 |
| 采集成功率 | 成功词数 / 总词数 | 数据本身可不可信 |
口径说明写在报告末尾:位次取 rank(接口文档定义为"本条响应内的 1 起算位次"),同一关键词多个页面命中时取最好的一条;采集失败的词单列,不参与均值。
三个 SQL
假设沿用前面的表结构:serp_daily(day, keyword, organic JSON, credits, elapsed_ms),organic 是结果数组。
sql
-- 1) 每天的每词最好位次
CREATE VIEW IF NOT EXISTS daily_best AS
SELECT day, keyword,
MIN(CAST(json_extract(value, '$.rank') AS INTEGER)) AS best_rank
FROM serp_daily, json_each(serp_daily.organic)
GROUP BY day, keyword;
-- 2) 本周 vs 上周同一天(懒得管自然周就用 -7 天)
WITH latest AS (SELECT MAX(day) AS d FROM daily_best)
SELECT a.keyword,
a.best_rank AS this_week,
b.best_rank AS last_week,
b.best_rank - a.best_rank AS delta
FROM daily_best a
JOIN daily_best b ON a.keyword = b.keyword
WHERE a.day = (SELECT d FROM latest)
AND b.day = date((SELECT d FROM latest), '-7 day')
ORDER BY delta DESC;
-- 3) 采集成功率
SELECT day,
COUNT(*) AS total,
SUM(CASE WHEN organic IS NOT NULL AND organic != '[]' THEN 1 ELSE 0 END) AS ok
FROM serp_daily GROUP BY day ORDER BY day DESC LIMIT 7;
如果你的采集只跑工作日,把 -7 day 换成 -5 day,或者干脆按周聚合------关键是同一个对比口径每周保持一致。
渲染成报告
python
import pathlib, sqlite3
from datetime import date
db = sqlite3.connect("serp.db")
day = db.execute("SELECT MAX(day) FROM daily_best").fetchone()[0]
summary = db.execute("""
SELECT COUNT(*), AVG(best_rank),
SUM(CASE WHEN best_rank <= 3 THEN 1 ELSE 0 END),
SUM(CASE WHEN best_rank <= 10 THEN 1 ELSE 0 END)
FROM daily_best WHERE day = ?
""", (day,)).fetchone()
movers = db.execute("""
WITH latest AS (SELECT MAX(day) AS d FROM daily_best)
SELECT a.keyword, a.best_rank, b.best_rank, b.best_rank - a.best_rank AS delta
FROM daily_best a JOIN daily_best b ON a.keyword = b.keyword
WHERE a.day = (SELECT d FROM latest)
AND b.day = date((SELECT d FROM latest), '-7 day')
ORDER BY ABS(b.best_rank - a.best_rank) DESC LIMIT 10
""").fetchall()
total, avg_rank, top3, top10 = summary
lines = [
f"# SEO 周报 · {day}",
"",
f"- 跟踪词数:{total}",
f"- 平均最好位次:{avg_rank:.1f}",
f"- Top3 占比:{top3 / total:.0%} · Top10 占比:{top10 / total:.0%}",
"",
"## 位次变化 Top 10",
"",
"| 关键词 | 上周 | 本周 | 变化 |",
"|---|---|---|---|",
]
for kw, now, before, delta in movers:
lines.append(f"| {kw} | {before} | {now} | {delta:+d} |")
lines += ["", "## 异常与待办", "", "- (例)词 X 连续两周下滑,检查页面是否被改版影响", ""]
pathlib.Path(f"report_{day}.md").write_text("\n".join(lines), encoding="utf-8")
print(f"报告已生成 report_{day}.md")
跑完就是一份结构化的报告(结构示例):
| 段落 | 内容 |
|---|---|
| 概览 | 三个数字:平均位次、Top3 占比、Top10 占比 |
| 变化 Top10 | 位次变动最大的十个词,带方向 |
| 异常与待办 | 人工补,只写需要动作的条目 |
| 数据口径 | 位次定义、失败词处理、对比周期 |
周报里的位次口径和计费说明,分别以接口文档和 SerpBase 官方文档的计费与 credits 说明 为准------报告是给人看的,口径写清楚才不会每周被问一遍。
和采集层的分工
采集脚本负责"每天把数据拿回来、幂等入库"(那是另一篇的主题);周报脚本只读库、不发起请求,所以生成报告本身零成本,成本都发生在采集环节(search 端点每次成功请求 1 credit,失败自动退款)。
FAQ
为什么取"最好位次"而不是平均位次? 同一关键词下你可能有多个页面进榜,取最好的那条最直观;要更严格,可以改成只看指定 URL 的位次。
周报没人看怎么办? 砍到三个数字、五条变化、三条待办。一屏放得下,才会有人看。
数据对不上怎么排查? 先看采集成功率,再看当天 credits 合计是否等于成功请求数;失败词会在表里缺失,周报里单列说明。
把两个 SQL 存成视图,渲染脚本挂到周末的任务计划里,下周开始就能自动收到报告。