无间隙、无循环地浏览 URL 反向链接检查器
每次调用的行数、每次查询的页数以及 50,000 行的硬性上限。这里有一个尊重所有这三个条件并告知何时停止的循环。

每次调用最多返回 1,000 行。每次调用仅计费一次,无论包含多少行。深度分页上限为 50,000 行,即 50 次调用,每次 1,000 行。
为什么排序在分页之前
对未排序列表进行分页是出现重复项的原因。始终将 sort 和 order 与 page 和 per 一起发送,以便第 3 页在每次请求中具有相同的含义。
refdomains 拉取每次调用花费 2 个积分,因此 50 次调用共需 100 个积分,即 1 美分。即使是最坏情况也很便宜;真正的成本是一个无法终止的循环。
选择排序列
按哪一列排序取决于问题。对于审计,按最大值排序通常是首选,因为这样上限会截断长尾,而不是截断你关心的部分。
如果按与问题无关的字段排序,截断可能会移除你需要的行。先按每个候选排序各拉取十行,看看哪些切片能保留下来。
将选择写入报告,以便下一个人知道截断点为何如此设置。
运行期间变动的记录
除非月度重建在运行期间发生,否则数据不会变动。这种情况罕见,但确实存在。跨越月初的夜间任务可能会从旧索引返回一页,从新索引返回下一页,因此请在日志中记录开始时间并进行比较。
在日志顶部记录开始时间,并在每一行记录页码。这样,输出中的不匹配可以指向时间问题,而不是解析错误。
如果任务已调度,避免在预计重建前的时间开始。你的日志会显示两次运行之间总数的变化作为重建的标志,因此请在每一行日志中保留总数。
跨越重建的运行默认并非错误。它只是不一致,因此在日志中标记这些运行,并在报告需要时重新运行受影响的切片。
一个能终止的分页循环
用于 Node 20 worker 的 TypeScript。它在遇到短页、达到上限或任何 ok 为 false 时停止。
const BASE = "https://seomasters.dev/api.php";
const CAP = 50_000;
const PER = 1000;
export async function pullAll(domain: string, key: string) {
const rows: unknown[] = [];
for (let page = 1; rows.length < CAP; page++) {
const u = new URL(BASE);
u.search = new URLSearchParams({
action: "refdomains", q: domain,
page: String(page), per: String(PER),
sort: "dr", order: "desc",
}).toString();
const res = await fetch(u, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401 || res.status === 402) throw new Error(`stop: HTTP ${res.status}`);
const body = await res.json();
if (!body.ok) throw new Error(JSON.stringify(body).slice(0, 300));
const chunk = findRows(body); // see note below
rows.push(...chunk);
if (chunk.length < PER) break; // short page means we are done
}
return rows;
}排查分页运行
| 你看到的现象 | 可能的原因 | 应对措施 |
|---|---|---|
| 第 51 页返回空或错误 | 达到了 50,000 上限 | 通过其他操作或不同的 sort 进行缩小,然后按 id 合并 |
| 同一域名出现在两页 | 未排序的查询,或索引在运行期间重建 | 发送 sort 和 order;按域名键去重 |
| 行数少于仪表板总数 | 总数是针对整个图的,拉取已受限 | 在 UI 中显示“前 50,000” |
| 循环永不结束 | 条件仅检查 rows.length | 在短页时中断,如上所述 |
| 账单高于预期 | 对每个非 200 状态进行重试 | 仅对网络故障进行重试,参见 errors |

无需猜测字段名即可读取行
上面的 findRows 助手故意保持开放。进行一次真实调用,查看 JSON,并根据你看到的内容编写访问器,而不是根据教程的承诺。
对于这个任务的更便宜版本,一次性对许多域名进行评分且无需分页,请阅读 bulk DR script。
冻结一个响应作为测试用例
然后通过测试冻结它。将一个响应保存为测试用例文件,并对行数和第一行的键进行断言,以便未来的更改在 CI 中失败,而不是在客户的图表中失败。
将测试用例保存在版本控制中,与测试文件放在一起,以便审查者无需运行任何代码就能看到代码期望的形状。
当 API 稍后添加字段时,测试用例差异会告诉你访问器是注意到了还是静默忽略了它。
根据查询来源命名测试用例,例如操作、域名和排序。名为 response1.json 的文件对下一个读者来说没有任何关于它证明了什么的提示。
统计存储的行数
将保存的行与循环报告的总数进行比较。差距意味着丢失了一页,重复意味着获取了两次页面并将其 upsert 为一行。
在每次测试拉取后运行此检查,而不仅仅是第一次。计数的漂移通常是排序更改或重建在运行期间落下的第一个症状。
将计数与页码保存在同一行日志中,以便一目了然地同时读取两者。
超过上限时拆分工作
一些问题超出了上限。对于超过上限的列表,拆分问题而不是更努力地分页。按一种排序拉取前 50,000 行,然后针对其他操作询问重负载域名,因为这些操作也花费 2 个积分,但回答不同的问题。更努力地分页永远无法突破截止点。
按一种排序拉取前 50,000 行
将上限视为 API 的事实。按一种排序拉取前 50,000 行,存储它们,并在任何报告中将结果标记为顶部切片。
将受限计数显示为完整总数的仪表板会误导阅读者。标签成本为零,带有它的 URL 反向链接检查器报告读起来更诚实。
在运行之前决定切片大小并将其写在报告标题中,这样数字看起来就不会比实际更大。标题行成本为零,稍后发现缺失行的读者会感谢你。
向重负载域名提出不同的问题
anchors 和 toppages 回答其他问题,例如哪个锚文本占主导地位或哪些页面吸引链接。对于超过上限的域名,这些答案可能比同一列表的下一页更重要。
将它们的结果保留在各自的表中,以域名为键,以便后续拉取同一域名时可以替换它们,而不影响顶部切片行。
每个额外操作都有其自己的积分成本,因此在运行之前定价,而不是在账单到达后。
每月拉取一次的最重域名短列表,通常比每晚拉取的长列表提供更有用的上下文。
按域名键合并
当两次拉取重叠时,按域名键合并它们并保留较新的时间戳。这样合并就不取决于哪次拉取先完成。
记录每行哪次拉取获胜。如果数字在两份报告之间发生变化,该日志会告诉你是指索引还是合并发生了变化。
将合并保留在一个带有测试的单个函数中,因为这是两次拉取相遇的唯一地方。
崩溃后恢复
崩溃会发生。部署重启 worker,笔记本电脑休眠,VPN 断开,循环在第 31 页(共 50 页)死亡。
每次调用后保存页码
每次调用后持久化最后完成的页码。从该数字加一开始重启,而不是从头开始,这样你只需为每页支付 2 个积分,而不是重新购买整个拉取。
在解析响应后保存,而不是之前。未能读取的页面应再次获取,保存的页码必须始终意味着你拥有的行。
每分钟保存一次而不是每次调用保存一次,会留下一个窗口,崩溃可能会重复积分支出。保持写入小巧,以便每次执行都很便宜。
记录每次保存的耗时以及每次调用的耗时。如果保存时间与调用时间相当,则指向存储层,这在循环增长之前值得修复。
按域名键 upsert
保持幂等写入。按域名键 upsert 每一行,这样获取两次的页面只留下一行,而不是两行。
在所有写入路径上使用相同的键,包括手动修复脚本。两个写入路径使用两个键是重复项在恢复后幸存的原因。
修复脚本通常在运行失败后匆忙编写。这时第二个键可能会混入,因此要像审查主循环一样仔细地审查它。
排序更改时拒绝恢复
将排序和顺序与页码一起记录。如果有人在崩溃和重启之间更改了 sort,保存的页码就不再代表相同的切片。拒绝恢复并重新开始。
存储一个包含排序、顺序、页码和时间的简短恢复记录。循环首先读取该记录,如果任何字段不再匹配即将发出的请求,它将拒绝继续。
拒绝恢复不是失败。这是循环在说保存的页码不再代表相同的行,重新开始是更便宜的错误。
在生产环境中运行循环
- 1
从一个小域名开始
针对拥有几百行的域名运行循环,以便短页停止触发,并观察退出路径是否正常工作。
- 2
人工检查一页
打开一个存储的响应并确认访问器仍然能读取行。不要信任你未查看过的解析。
- 3
将限制保留在配置中
将上限和页面大小放在一个配置文件中,以便更改在审查中显示,而不是隐藏在函数内部。
- 4
记录循环结束的原因
记录运行是因短页、上限还是错误结束。这三种结束方式对阅读日志的人来说意味着不同的事情。