搜索引擎推送(IndexNow)
Mizuki 通过一个零依赖的 Node.js CLI 脚本,在站点构建后将 sitemap 中的全部 URL 批量提交到 IndexNow 协议端点(api.indexnow.org),让 Bing 等参与 IndexNow 的搜索引擎尽快发现并重新抓取页面。该能力由环境变量 INDEXNOW_KEY 与 INDEXNOW_HOST 驱动,通过 npm run submit 手动或 CI 触发。
目的与范围
本页覆盖 IndexNow 搜索引擎推送能力的完整实现,包括:
- 入口脚本
scripts/indexnow-submit.js的完整控制流(sitemap 解析 → host 过滤 → 分块 → HTTP 提交 → 状态码处理) - 配置装载机制(
scripts/load-env.js的零依赖.env解析) - 环境变量
INDEXNOW_KEY/INDEXNOW_HOST的语义、默认值与校验行为 - 各类失败模式(HTTP 状态码、网络异常、退出码语义)与运维注意事项
以下内容属于兄弟页面,不在本页展开:
- 站点构建流水线本身(
astro build、pagefind 索引、字体检查等)——构建产物dist/sitemap-0.xml是本能力的上游输入,但其生成过程属于构建相关页面 - 内容仓库同步与自动构建触发(
ENABLE_CONTENT_SYNC、Repository Dispatch)——见.env.example与docs/AUTO_BUILD_TRIGGER.md - Bilibili 会话配置(
BILI_SESSDATA)——仅与内容展示相关,与推送无关
概述
IndexNow 是一个开放的协议:站点主动告知搜索引擎"这些 URL 已更新",搜索引擎无需等待被动抓取。Mizuki 将其实现为一个构建后置脚本,设计要点如下:
- 零运行时依赖:脚本只使用
node:fs、node:path、node:url与全局fetch(需 Node 18+),不引入dotenv、XML 解析器或 HTTP 客户端库,loadEnv()用约 20 行代码自行解析.env。 - 以 sitemap 为唯一数据源:不做页面遍历,直接消费
dist/sitemap-0.xml,用正则提取<loc>节点,保证提交集合与站点实际产物一致。 - 分块提交:IndexNow API 单次请求上限为 10000 个 URL,脚本按
MAX_URLS_PER_REQUEST = 10000切片,逐批串行await提交。 - 失败软化(fail-soft):单个批次失败只记录日志并继续下一批;只有 sitemap 缺失或主流程异常才会
process.exit(1),避免误伤 CI 流水线。 - 可选能力:部署文档将两个环境变量均标记为"非必需"。未配置时脚本打印错误并返回(进程退出码仍为 0),因此对不关心 SEO 推送的用户是纯增量选项。
适用场景:每次部署完成后(或内容更新触发重建后)手动/自动执行 npm run submit,将最新 URL 集合推送给搜索引擎。
架构
架构说明:
- 数据上游是 Astro 构建输出的
dist/sitemap-0.xml。脚本以path.join(__dirname, "../dist", "sitemap-0.xml")定位该文件,若不存在则直接终止(process.exit(1)),因此必须先npm run build再npm run submit。 - 配置上游是项目根目录的
.env文件。脚本入口处调用loadEnv(),它逐行解析KEY=VALUE、跳过注释与空行、去除值两侧引号后写入process.env。 - 推送目标是协议官方聚合端点
https://api.indexnow.org/IndexNow,请求体为 JSON(host、key、keyLocation、urlList四个字段),keyLocation固定拼接为https://${host}/${apiKey}.txt。 - 编排关系:
package.json中的"submit": "node scripts/indexnow-submit.js"是唯一入口。submit并未串联在build命令之后(build只包含update-anime→astro build→ 样式/字体检查 → pagefind),因此推送是构建之后的独立步骤,需要在部署流程中显式调用。
核心流程
脚本是一个顺序执行的 ESM 顶层入口:await main() 直接运行(无 shebang),由 npm scripts 调起。完整时序如下:
关键步骤解析
1. 环境装载(loadEnv())
1export function loadEnv() {
2 const envPath = path.join(rootDir, ".env");
3 if (fs.existsSync(envPath)) {
4 const envContent = fs.readFileSync(envPath, "utf-8");
5 envContent.split("\n").forEach((line) => {
6 const line_ = line.trim();
7 // 跳过注释和空行
8 if (!line_ || line_.startsWith("#")) return;
9
10 const match = line_.match(/^([^=]+)=(.*)$/);
11 if (match) {
12 const key = match[1].trim();
13 let value = match[2].trim();
14 // 移除引号
15 value = value.replace(/^["']|["']$/g, "");
16 process.env[key] = value;
17 }
18 });
19 }
20}Source: load-env.js
设计意图:脚本在 ES Module 下运行,需自行解析 .env。该实现无条件覆盖已存在的 process.env 条目——这意味着 CI 中注入的 Secret 会被 .env 文件中的同名值覆盖。文件不存在时静默跳过,此时依赖外部(宿主/CI)环境变量。
2. sitemap 解析(parseSitemap)
1function parseSitemap(sitemapPath) {
2 const sitemapContent = fs.readFileSync(sitemapPath, "utf-8");
3
4 // 使用正则表达式提取 URL
5 const urlMatches = sitemapContent.match(/<loc>(.*?)<\/loc>/g);
6
7 if (!urlMatches) {
8 console.error("❌ No URLs found in sitemap");
9 return [];
10 }
11
12 const urls = urlMatches.map((match) => {
13 const url = match.replace(/<loc>|<\/loc>/g, "").trim();
14 return url;
15 });
16
17 console.log(`✓ Parsed ${urls.length} URLs from sitemap`);
18 return urls;
19}Source: indexnow-submit.js
采用正则而非 XML 解析器,换取零依赖;<loc> 出现在 sitemap 主文件与 sitemap index 文件两种上下文中均可命中。未匹配时不抛异常,返回空数组,由 main() 以日志 ⚠ No URLs found in sitemap, skipping submission 提前返回。
3. 主机过滤(main() 内)
1const host = process.env.INDEXNOW_HOST;
2const filteredUrls = urls.filter(
3 (url) =>
4 url.startsWith(`https://${host}/`) || url.startsWith(`http://${host}/`),
5);Source: indexnow-submit.js
意图:IndexNow 要求提交的 URL 必须属于声明的 host,否则会触发 422。此处提前在客户端做一致性过滤,把不属于本站(例如 sitemap 中意外混入的跨域 URL 或 host 未配置时的 https://undefined/ 前缀)剔除,减少无效请求。注意匹配要求主机后紧跟 /,因此站点根 URL https://host(无尾斜杠)会被过滤掉。
4. 分块与提交(submitToIndexNow)
1async function submitToIndexNow(urls) {
2 if (!urls || urls.length === 0) {
3 console.log("⚠ No URLs to submit");
4 return;
5 }
6
7 // 限制每次提交的 URL 数量(IndexNow API 有数量限制)
8 const MAX_URLS_PER_REQUEST = 10000; // IndexNow API 限制最大 10000 个URL
9 const urlChunks = [];
10
11 for (let i = 0; i < urls.length; i += MAX_URLS_PER_REQUEST) {
12 urlChunks.push(urls.slice(i, i + MAX_URLS_PER_REQUEST));
13 }
14
15 const apiKey = process.env.INDEXNOW_KEY;
16 const host = process.env.INDEXNOW_HOST;
17 const keyLocation = `https://${host}/${apiKey}.txt`;
18
19 if (!apiKey || !host) {
20 console.error(
21 "❌ Missing required environment variables: INDEXNOW_KEY or INDEXNOW_HOST",
22 );
23 console.error(" Please configure these variables in the .env file");
24 return;
25 }Source: indexnow-submit.js
要点:分块逻辑在配置校验之前执行,但校验失败直接 return,因此不会发起任何网络请求。keyLocation 遵循 IndexNow 协议的密钥文件约定——站点必须在 https://{host}/{key}.txt 处提供与 key 一致的纯文本文件,供搜索引擎回验站点所有权。
5. HTTP 请求与状态码分支
1const response = await fetch("https://api.indexnow.org/IndexNow", {
2 method: "POST",
3 headers: {
4 "Content-Type": "application/json; charset=utf-8",
5 },
6 body: JSON.stringify({
7 host: host,
8 key: apiKey,
9 keyLocation: keyLocation,
10 urlList: chunk,
11 }),
12});
13
14if (response.status === 200) {
15 console.log(`✅ Batch ${i + 1} URLs submitted successfully`);
16} else if (response.status === 202) {
17 console.warn(
18 `⚠ Batch ${i + 1} request accepted but still processing (Status code: ${response.status})`,
19 );
20 console.warn(
21 "This is not a standard success status code, you may need to check API documentation",
22 );
23} else {
24 console.error(
25 `❌ Batch ${i + 1} URLs submission failed, Status code: ${response.status}`,
26 );
27 const responseBody = await response.text();
28 console.error(` Response body: ${responseBody}`);
29 // ... switch (response.status) 针对性提示
30}Source: indexnow-submit.js
意图:IndexNow 官方规范中,POST 请求的正常响应是 200(已接受)或 202(已受理、密钥待验证)。脚本对 202 额外给出 console.warn 提示而非当作完全成功,便于运维发现密钥验证未通过的隐性问题;非 2xx 时才读取响应体并按状态码输出针对性诊断。
配置项
| 环境变量 | 是否必需 | 默认值 | 说明 |
|---|---|---|---|
INDEXNOW_KEY | 否(可选 SEO 能力) | 无 | IndexNow API 密钥。需同时在站点 https://{host}/{key}.txt 处放置相同内容。.env.example 示例值为 asdf1213456(16 位以内小写字母数字) |
INDEXNOW_HOST | 否(可选 SEO 能力) | 无 | 站点主机地址(不含协议前缀),示例 your.example.com。用于拼接 keyLocation、构造 URL 过滤前缀 |
两个变量均未配置时脚本打印错误并 return,进程退出码为 0。部署文档将两者标注为非必需(❌):
| `INDEXNOW_KEY` | ❌ | - | IndexNow API 密钥,用于向搜索引擎提交 URL 更新 |
| `INDEXNOW_HOST` | ❌ | - | 网站主机地址 |Source: DEPLOYMENT.md
配置装载的典型形态(.env.example 中的 IndexNow 段):
1# ============================================
2# IndexNow SEO 配置
3# ============================================
4
5# IndexNow API 密钥,用于向搜索引擎提交 URL 更新
6INDEXNOW_KEY=asdf1213456
7# 网站主机地址
8INDEXNOW_HOST=your.example.comSource: .env.example
.env 中的值应由 .env.example 复制而来并替换为真实值;README(多语言版本一致)要求将其保存在本地或托管平台的 Secret 中,切勿提交真实值。注意 loadEnv() 的覆盖语义:.env 文件中的值优先级高于 CI 环境变量。
入口与调用方式
package.json 注册了独立的 npm script:
"submit": "node scripts/indexnow-submit.js",Source: package.json
使用序列为:先 npm run build 生成 dist/sitemap-0.xml,再 npm run submit。脚本对 sitemap 缺失的处理如下(这是唯一的 process.exit(1) 非异常路径):
1if (!fs.existsSync(sitemapPath)) {
2 console.error(`❌ Sitemap file not found: ${sitemapPath}`);
3 console.error(
4 " Please ensure the project is built before running this script",
5 );
6 process.exit(1);
7}Source: indexnow-submit.js
API 参考
脚本内部由三个函数组成,均为模块私有(未 export,loadEnv 除外)。
parseSitemap(sitemapPath: string): string[]
同步读取 sitemap XML 文件,用正则 /<loc>(.*?)<\/loc>/g 提取全部 <loc> 节点并返回去标签、去首尾空白的 URL 数组。
参数:
sitemapPath(string): sitemap 文件的绝对路径,main()中固定为<repo>/dist/sitemap-0.xml
返回值: URL 字符串数组;无任何匹配时打印错误日志并返回 []
异常: 文件不存在时 fs.readFileSync 会抛出,但 main() 在调用前已用 fs.existsSync 拦截并 process.exit(1)
submitToIndexNow(urls: string[]): Promise<void>
对 URL 集合按 10000 个/批切片后串行 POST 到 IndexNow 端点。
参数:
urls(string[]): 已经过 host 前缀过滤的 URL 列表;空数组时打印警告并直接返回
返回值: Promise<void>——不返回成功/失败结果,成功与否只体现在控制台日志中
依赖的环境变量: INDEXNOW_KEY、INDEXNOW_HOST(缺失时打印错误并 return,不抛异常、不退出)
请求体结构(IndexNow 协议字段):
| 字段 | 来源 | 说明 |
|---|---|---|
host | INDEXNOW_HOST | 站点主机名 |
key | INDEXNOW_KEY | API 密钥 |
keyLocation | 拼接 https://${host}/${apiKey}.txt | 密钥验证文件地址 |
urlList | 当前批次 URL 数组 | 每批最多 10000 条 |
main(): Promise<void>
编排函数:定位 sitemap → parseSitemap → 按 INDEXNOW_HOST 前缀过滤 → submitToIndexNow → 打印完成日志。仅在 sitemap 缺失或流程抛异常时 process.exit(1)。
loadEnv(): void(来自 scripts/load-env.js)
读取项目根目录 .env,逐行解析 KEY=VALUE(跳过 # 注释与空行,去除值两侧单/双引号)并写入 process.env。文件不存在时静默返回。
失败模式、边界情况与并发
HTTP 状态码处理矩阵(switch (response.status) 分支):
| 状态码 | 处理方式 | 脚本给出的诊断 |
|---|---|---|
| 200 | console.log ✅ | 批次提交成功 |
| 202 | console.warn ⚠ | 已受理但仍在处理,"并非标准成功码,请查 API 文档"(通常表示密钥待验证) |
| 400 | console.error ❌ + 响应体 | 请求格式无效 |
| 403 | console.error ❌ + 响应体 | API 密钥无效或认证失败 |
| 422 | console.error ❌ + 响应体 | URL 不属于指定 host 或密钥不匹配 |
| 429 | console.error ❌ + 响应体 | 请求过于频繁,可能被视为垃圾请求 |
| 其他非 2xx | console.error ❌ + 响应体 | 通用其他错误 |
边界与健壮性要点:
- 批次隔离:每批的
fetch都包裹在独立的try/catch中,网络层异常(DNS、超时、连接重置)只打印error.message并继续下一批,不做重试。 - 无退避/限流:脚本未实现指数退避或请求间隔。触发 429 时依赖运维侧观察日志;对大型站点建议在 CI 中控制调用频率。
- 退出码语义:
0= 流程走完(即使 HTTP 层全部失败也是 0);1= sitemap 缺失或main()抛异常。CI 若需严格感知提交失败,需解析日志而非退出码。 - 环境变量覆盖顺序:
loadEnv()直接process.env[key] = value,.env文件值会覆盖 CI 注入的同名变量;仅靠.env时要求文件在仓库根目录(该文件通常不入库)。 - 根 URL 被过滤:过滤条件要求
https://${host}/前缀(含斜杠),站点根地址(无尾斜杠形式)不会出现在提交集合中。 - 空 sitemap:
parseSitemap返回[]或过滤后为空时,均以⚠日志 +return结束,不发起网络请求。 - 并发模型:批与批之间是串行
await,单批次内 URL 一次提交——实现简单、对 API 友好,代价是大站点提交耗时线性增长。 - Node 版本要求:依赖全局
fetch(Node ≥ 18),脚本内未做特性检测。
性能与运维注意事项
- 上游强依赖构建:
npm run submit必须在npm run build之后执行,否则dist/sitemap-0.xml不存在并退出码 1。适合放在部署流水线的构建步骤之后。 - 密钥验证文件需自行部署:脚本只负责提交;
https://{host}/{key}.txt必须由站点本身提供(例如放在public/目录随构建产物发布),否则 IndexNow 会持续返回 202 而非 200。 - 幂等性:重复提交相同 URL 是安全的(搜索引擎自行去重),因此该脚本可在每次部署后无条件执行。
- 多语言 README 的安全提示一致:真实凭据只保存在本地或托管平台 Secret 中,不要提交到仓库。
扩展点
- 更换端点:
fetch("https://api.indexnow.org/IndexNow", ...)是唯一的网络目标地址。如需直连 Bing(https://www.bing.com/indexnow)或其他参与方端点,只需替换该 URL,请求体结构遵循同一协议。 - 更换数据源:
parseSitemap与后续流程解耦,main()只要求得到urls: string[]。可将其替换为从内容集合(如 Astro content collections)直接生成 URL 列表。 - 接入自动触发:
.env.example提到"内容仓库更新时自动构建"的 Repository Dispatch 机制(详见docs/AUTO_BUILD_TRIGGER.md),可在触发后的工作流中追加npm run submit,实现"内容更新 → 重建 → 推送"闭环。 - 结果回传:
submitToIndexNow目前返回void,日志是唯一输出通道;若 CI 需要结构化结果,可让它聚合每批状态码后返回。
相关链接
- scripts/indexnow-submit.js — IndexNow 提交脚本完整实现
- scripts/load-env.js — 零依赖
.env装载器 - .env.example — 环境变量配置样例
- package.json —
submitnpm script 注册 - docs/DEPLOYMENT.md — 部署环境变量清单(含
INDEXNOW_KEY/INDEXNOW_HOST) - IndexNow 官方协议文档 — 状态码语义、密钥文件与 10000 URL 上限