Repository Wiki
LyraVoid/Mizuki

搜索引擎推送(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 将其实现为一个构建后置脚本,设计要点如下:

  1. 零运行时依赖:脚本只使用 node:fs、node:path、node:url 与全局 fetch(需 Node 18+),不引入 dotenv、XML 解析器或 HTTP 客户端库,loadEnv() 用约 20 行代码自行解析 .env。
  2. 以 sitemap 为唯一数据源:不做页面遍历,直接消费 dist/sitemap-0.xml,用正则提取 <loc> 节点,保证提交集合与站点实际产物一致。
  3. 分块提交:IndexNow API 单次请求上限为 10000 个 URL,脚本按 MAX_URLS_PER_REQUEST = 10000 切片,逐批串行 await 提交。
  4. 失败软化(fail-soft):单个批次失败只记录日志并继续下一批;只有 sitemap 缺失或主流程异常才会 process.exit(1),避免误伤 CI 流水线。
  5. 可选能力:部署文档将两个环境变量均标记为"非必需"。未配置时脚本打印错误并返回(进程退出码仍为 0),因此对不关心 SEO 推送的用户是纯增量选项。

适用场景:每次部署完成后(或内容更新触发重建后)手动/自动执行 npm run submit,将最新 URL 集合推送给搜索引擎。

架构

Loading diagram...

架构说明:

  • 数据上游是 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 调起。完整时序如下:

Loading diagram...

关键步骤解析

1. 环境装载(loadEnv())

javascript
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)

javascript
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() 内)

javascript
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)

javascript
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 请求与状态码分支

javascript
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。部署文档将两者标注为非必需(❌):

text
| `INDEXNOW_KEY` | ❌ | - | IndexNow API 密钥,用于向搜索引擎提交 URL 更新 | | `INDEXNOW_HOST` | ❌ | - | 网站主机地址 |

Source: DEPLOYMENT.md

配置装载的典型形态(.env.example 中的 IndexNow 段):

text
1# ============================================ 2# IndexNow SEO 配置 3# ============================================ 4 5# IndexNow API 密钥,用于向搜索引擎提交 URL 更新 6INDEXNOW_KEY=asdf1213456 7# 网站主机地址 8INDEXNOW_HOST=your.example.com

Source: .env.example

.env 中的值应由 .env.example 复制而来并替换为真实值;README(多语言版本一致)要求将其保存在本地或托管平台的 Secret 中,切勿提交真实值。注意 loadEnv() 的覆盖语义:.env 文件中的值优先级高于 CI 环境变量。

入口与调用方式

package.json 注册了独立的 npm script:

json
"submit": "node scripts/indexnow-submit.js",

Source: package.json

使用序列为:先 npm run build 生成 dist/sitemap-0.xml,再 npm run submit。脚本对 sitemap 缺失的处理如下(这是唯一的 process.exit(1) 非异常路径):

javascript
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 协议字段):

字段来源说明
hostINDEXNOW_HOST站点主机名
keyINDEXNOW_KEYAPI 密钥
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) 分支):

状态码处理方式脚本给出的诊断
200console.log ✅批次提交成功
202console.warn ⚠已受理但仍在处理,"并非标准成功码,请查 API 文档"(通常表示密钥待验证)
400console.error ❌ + 响应体请求格式无效
403console.error ❌ + 响应体API 密钥无效或认证失败
422console.error ❌ + 响应体URL 不属于指定 host 或密钥不匹配
429console.error ❌ + 响应体请求过于频繁,可能被视为垃圾请求
其他非 2xxconsole.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 需要结构化结果,可让它聚合每批状态码后返回。

相关链接

Sources

(3 files)