域名被污染后批量清理 Google 索引
买了个域名发现被外链攻击,Google Search Console 的 Page Indexing 里堆了几万条垃圾记录。通过 Cloudflare Worker 全站返回 410 Gone + GCP 多项目批量创建服务账号 + Google Indexing API 多账号轮换的方式来批量清理这些索引。
背景
之前买了个域名,丢到 Google Search Console 里准备提交索引,妈的结果打开 Page Indexing 页面一看,几万条记录,全是垃圾 URL,但是很奇怪的点是WebArchive类的相关网站我都看了,没有任何历史记录。所以有点怀疑这个域名之前被人拿去做过 SEO 外链攻击,或者说域名本身就是被污染过的。
Google 那边的 URL Removal Tool 一次只能删一条,几万条一条条删?我寿命不够用😓。所以只能另辟蹊径。
整体思路
- Cloudflare Worker 全站 410:让所有访问这个域名的请求都返回
410 Gone,告诉 Google 爬虫”这些页面永久不存在了” - Google Indexing API 批量通知:主动通知 Google 这些 URL 已删除,加速去索引
- GCP 多项目扩容配额:每个账号下单个项目每天只有 200 次 API 调用额度,不够用就多开几个项目
Cloudflare Worker:全站 410
这一步很简单,在 Cloudflare 上创建一个 Worker,让所有请求都返回 410 状态码。410 和 404 的区别在于,410 是明确告诉搜索引擎”这个资源已经永久消失了,不会再回来”。虽然 Google 的 John Mueller 说过两者处理差异 “so minimal”,但 410 语义上更明确——你主动声明”我故意删了”,而不是”不知道为啥找不到了”。在批量清理污染域名这种场景下,用 410 更合理。
export default { async fetch(request) { return new Response( '<!DOCTYPE html><html><head><title>410 Gone</title></head><body><h1>410 Gone</h1><p>This page no longer exists.</p></body></html>', { status: 410, headers: { 'Content-Type': 'text/html; charset=utf-8' }, } ); },};
部署完之后:
- 新增一个DNS Record
- 把域名的路由指向这个 Worker,所有流量都会被拦截并返回 410。这一步主要是配合 Google 爬虫自然爬取时的去索引,但光靠爬虫被动发现太慢了,所以就有了后续的步骤。

CleanShot 2026-03-17 at 14.54.52@2x
GCP 批量创建项目
Google Indexing API 每个项目每天只有 200 次调用配额,几万条 URL 靠一个项目得跑好久。解决办法也简单粗暴——多开项目,每个项目 200/天,开 N 个就是 N×200/天。
下面这个脚本在 GCP Cloud Shell 里运行,并发创建多个项目,每个项目自动启用 Indexing API、创建服务账号、下载密钥文件:
#!/bin/bash# 在 GCP Cloud Shell 中运行,并发创建多个项目 + 服务账号## 使用方法:# 1. 打开 Cloud Shell (https://shell.cloud.google.com)# 2. 修改下方配置变量# 3. bash setup-gcp-projects.sh# 4. 下载生成的 JSON 文件放到本地 scripts/service_accounts/
# ===== 配置区 (必须修改) =====NUM_PROJECTS=4 # 要创建的项目数 (每个项目 200/天)ORG_ID="" # 组织数字 ID (运行 gcloud organizations list 查看)BILLING_ACCOUNT="" # 账单账号 ID (运行 gcloud billing accounts list 查看)# ===========================
if [ -z "$ORG_ID" ] || [ -z "$BILLING_ACCOUNT" ]; then echo "请先填写 ORG_ID 和 BILLING_ACCOUNT" echo "" echo "查看组织 ID:" gcloud organizations list echo "" echo "查看账单账号 ID:" gcloud billing accounts list exit 1fi
OUTPUT_DIR="./service_accounts"mkdir -p "$OUTPUT_DIR"
# 单个项目的完整创建流程setup_project() { local idx=$1 local project_id="gsc-idx-${idx}-$(date +%s)" local sa_email="gsc-sa@${project_id}.iam.gserviceaccount.com" local log_prefix="[项目 $idx]"
echo "$log_prefix 开始创建: $project_id"
# 1. 创建项目 echo "$log_prefix [1/6] 创建项目..." if ! gcloud projects create "$project_id" --organization="$ORG_ID" --quiet 2>&1; then echo "$log_prefix 创建项目失败,终止" return 1 fi
# 2. 关联账单 echo "$log_prefix [2/6] 关联账单..." gcloud billing projects link "$project_id" --billing-account="$BILLING_ACCOUNT" --quiet 2>&1
# 3. 关闭策略限制 (允许创建 SA Key) echo "$log_prefix [3/6] 关闭 SA Key 策略限制..." cat > "/tmp/policy-${idx}-a.yaml" << EOFname: projects/${project_id}/policies/iam.disableServiceAccountKeyCreationspec: rules: - enforce: falseEOF gcloud org-policies set-policy "/tmp/policy-${idx}-a.yaml" --project="$project_id" --quiet 2>&1 || true
cat > "/tmp/policy-${idx}-b.yaml" << EOFname: projects/${project_id}/policies/iam.managed.disableServiceAccountKeyCreationspec: rules: - enforce: falseEOF gcloud org-policies set-policy "/tmp/policy-${idx}-b.yaml" --project="$project_id" --quiet 2>&1 || true
# 4. 启用 Indexing API echo "$log_prefix [4/6] 启用 Indexing API..." gcloud services enable indexing.googleapis.com --project="$project_id" --quiet 2>&1
# 5. 创建服务账号 echo "$log_prefix [5/6] 创建服务账号..." gcloud iam service-accounts create gsc-sa --project="$project_id" --display-name="GSC Indexing SA" --quiet 2>&1
# 6. 下载密钥 (无限重试直到策略生效) echo "$log_prefix [6/6] 下载密钥 (等待策略生效)..." local attempt=0 while true; do attempt=$((attempt + 1)) if gcloud iam service-accounts keys create "${OUTPUT_DIR}/sa-${idx}.json" \ --iam-account="$sa_email" --project="$project_id" --quiet 2>&1; then echo "$log_prefix 密钥下载成功 (第 ${attempt} 次尝试)" break fi echo "$log_prefix 策略未生效,等待 15 秒后重试 (第 ${attempt} 次)..." sleep 15 done
echo "$sa_email" >> "${OUTPUT_DIR}/.emails.txt" echo "$log_prefix 完成! -> sa-${idx}.json ($sa_email)"}
rm -f "${OUTPUT_DIR}/.emails.txt"
echo "=========================================="echo " 并发创建 $NUM_PROJECTS 个项目"echo "=========================================="
# 并发启动pids=()for i in $(seq 1 $NUM_PROJECTS); do setup_project "$i" & pids+=($!)done
# 等待完成fail_count=0for pid in "${pids[@]}"; do if ! wait "$pid"; then fail_count=$((fail_count + 1)) fidone
echo ""echo "=========================================="echo " 全部完成! 成功: $((NUM_PROJECTS - fail_count))/$NUM_PROJECTS"echo "=========================================="echo ""echo "JSON 密钥文件:"ls -la "$OUTPUT_DIR"/sa-*.json 2>/dev/nullecho ""echo "=========================================="echo " 重要: 将以下邮箱全部添加为 GSC 属性 Owner"echo "=========================================="if [ -f "${OUTPUT_DIR}/.emails.txt" ]; then sort "${OUTPUT_DIR}/.emails.txt" | while read -r email; do echo " $email" done rm -f "${OUTPUT_DIR}/.emails.txt"fiecho ""echo "操作步骤:"echo "1. 下载 ${OUTPUT_DIR}/ 目录下所有 sa-*.json 文件"echo "2. 放到本地项目 scripts/service_accounts/ 目录"echo "3. 进入 GSC → 设置 → 用户和权限"echo "4. 把上面每个邮箱都添加为 Owner"echo "5. 运行 pnpm gsc:remove"脚本里的 ORG_ID 和 BILLING_ACCOUNT 必须填你自己的。没有组织的个人账号需要先创建一个组织,或者去掉 --organization 参数手动创建项目。另外 GCP 免费账号有项目数量限制,创建太多可能会被拒绝。
脚本跑完后会在 service_accounts/ 目录下生成一堆 sa-*.json 密钥文件,同时输出所有服务账号的邮箱地址。这些邮箱必须逐一添加到 GSC 属性的 Owner 权限里,否则 API 调用会报权限错误。
批量调用 Indexing API 清理索引
前面两步都准备完成后,开始调用Indexing API。流程:
- 从 GSC 导出垃圾 URL 的 CSV 文件,放到
scripts/gsc-data/目录- 记得需要点进具体的
Why pages aren’t indexed页面,才能看到 URL 列表 - 点右上角的
Export,选择Download CSV
- 记得需要点进具体的
- 把前面下载的服务账号 JSON 放到
scripts/service_accounts/目录 - 运行脚本,自动轮换多个服务账号发送
URL_DELETED通知
/** * GSC Bulk URL Removal via Google Indexing API (Multi-Account) * * 从 GSC 导出的 CSV 中读取垃圾 URL,通过多个服务账号轮换批量发送 URL_DELETED 通知。 * 每个服务账号 200/天配额,N 个账号 = N*200/天。 * * 使用方法: * 1. 将 GSC 导出的 CSV 放到 scripts/gsc-data/ * 2. 将服务账号 JSON 放到 scripts/service_accounts/ * 3. 运行: node scripts/gsc-bulk-remove.js [--dry-run] */
import { JWT } from 'google-auth-library';import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';import { join, dirname } from 'path';import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);const __dirname = dirname(__filename);
const INDEXING_API_URL = 'https://indexing.googleapis.com/v3/urlNotifications:publish';const INDEXING_SCOPE = 'https://www.googleapis.com/auth/indexing';const PER_ACCOUNT_QUOTA = 200;const REQUEST_DELAY_MS = 1000;const DATA_DIR = join(__dirname, 'gsc-data');const SERVICE_ACCOUNTS_DIR = join(__dirname, 'service_accounts');const LEGACY_SA_FILE = join(__dirname, 'service_account.json');const PROGRESS_FILE = join(__dirname, 'gsc-data', 'progress.json');
/** 发现所有可用的服务账号 JSON 文件 */function discoverServiceAccounts() { const files = []; if (existsSync(SERVICE_ACCOUNTS_DIR)) { const saFiles = readdirSync(SERVICE_ACCOUNTS_DIR) .filter((f) => f.endsWith('.json')) .sort() .map((f) => join(SERVICE_ACCOUNTS_DIR, f)); files.push(...saFiles); } if (files.length === 0 && existsSync(LEGACY_SA_FILE)) { files.push(LEGACY_SA_FILE); } return files;}
/** 创建认证客户端 */async function createAuthClient(keyFilePath) { const keys = JSON.parse(readFileSync(keyFilePath, 'utf-8')); const client = new JWT({ email: keys.client_email, key: keys.private_key, scopes: [INDEXING_SCOPE], }); await client.authorize(); return { client, email: keys.client_email };}
/** 解析 GSC 导出的 CSV 文件 */function parseCsvUrls(filePath) { const content = readFileSync(filePath, 'utf-8'); const lines = content.split('\n').filter((line) => line.trim()); return lines .slice(1) .map((line) => { const delimiter = line.includes('\t') ? '\t' : ','; return line.split(delimiter)[0].trim().replace(/^["']|["']$/g, ''); }) .filter((url) => url.startsWith('http'));}
/** 加载/保存进度 */function loadProgress() { if (existsSync(PROGRESS_FILE)) { const data = JSON.parse(readFileSync(PROGRESS_FILE, 'utf-8')); if (!data.accounts) { data.accounts = {}; } return data; } return { processed: [], lastDate: '', accounts: {} };}
function saveProgress(progress) { writeFileSync(PROGRESS_FILE, JSON.stringify(progress, null, 2));}
/** 发送 URL_DELETED 通知 */async function notifyUrlDeleted(client, url) { try { const res = await client.fetch(INDEXING_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, type: 'URL_DELETED' }), }); if (res.status === 200) return { success: true, status: 200 }; const errorBody = typeof res.data === 'string' ? res.data : JSON.stringify(res.data); return { success: false, status: res.status, error: errorBody }; } catch (err) { return { success: false, error: err.message }; }}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() { const isDryRun = process.argv.includes('--dry-run'); console.log('=== GSC Bulk URL Removal Tool (Multi-Account) ===\n');
if (isDryRun) console.log('DRY RUN 模式 - 不会发送实际请求\n');
// 发现服务账号 const saFiles = discoverServiceAccounts(); if (saFiles.length === 0) { console.error('未找到服务账号文件。'); console.error(`请将 JSON 文件放到: ${SERVICE_ACCOUNTS_DIR}/`); process.exit(1); } console.log(`找到 ${saFiles.length} 个服务账号 (总配额: ${saFiles.length * PER_ACCOUNT_QUOTA}/天)\n`);
// 解析 CSV if (!existsSync(DATA_DIR)) { mkdirSync(DATA_DIR, { recursive: true }); console.log(`已创建数据目录: ${DATA_DIR}`); console.log('请将 GSC 导出的 CSV 文件放入该目录后重新运行。'); return; }
const csvFiles = readdirSync(DATA_DIR).filter((f) => f.endsWith('.csv')); if (csvFiles.length === 0) { console.log(`在 ${DATA_DIR} 中未找到 CSV 文件。`); return; }
let allUrls = []; for (const file of csvFiles) { const urls = parseCsvUrls(join(DATA_DIR, file)); console.log(`${file}: ${urls.length} 个 URL`); allUrls = allUrls.concat(urls); } allUrls = [...new Set(allUrls)]; console.log(`\n总计去重后: ${allUrls.length} 个 URL`);
// 加载进度 const progress = loadProgress(); const today = new Date().toISOString().split('T')[0]; if (progress.lastDate !== today) { progress.lastDate = today; progress.accounts = {}; }
const processedSet = new Set(progress.processed); const pendingUrls = allUrls.filter((url) => !processedSet.has(url)); console.log(`已处理: ${progress.processed.length} 个`); console.log(`待处理: ${pendingUrls.length} 个\n`);
if (pendingUrls.length === 0) { console.log('所有 URL 已处理完毕!'); return; }
// 构建账号队列 const accountQueue = []; for (const saFile of saFiles) { const saKey = saFile.split('/').pop(); const used = progress.accounts[saKey] || 0; const remaining = PER_ACCOUNT_QUOTA - used; if (remaining > 0) accountQueue.push({ file: saFile, key: saKey, used, remaining }); }
const totalRemaining = accountQueue.reduce((sum, a) => sum + a.remaining, 0); if (totalRemaining <= 0) { console.log('今日所有账号配额已用完。请明天再运行。'); return; }
console.log('账号配额:'); for (const acc of accountQueue) { console.log(` ${acc.key}: ${acc.remaining}/${PER_ACCOUNT_QUOTA} 剩余`); }
const batch = pendingUrls.slice(0, totalRemaining); console.log(`\n本次将处理: ${batch.length} 个 URL\n`);
if (isDryRun) { console.log('将要处理的 URL (前 20 个):'); batch.slice(0, 20).forEach((url, i) => console.log(` ${i + 1}. ${url}`)); if (batch.length > 20) console.log(` ... 还有 ${batch.length - 20} 个`); return; }
// 认证 console.log('正在认证...'); const clients = []; for (const acc of accountQueue) { try { const { client, email } = await createAuthClient(acc.file); clients.push({ ...acc, client, email }); console.log(` ${acc.key} (${email}): OK`); } catch (err) { console.log(` ${acc.key}: 认证失败 - ${err.message}`); } }
if (clients.length === 0) { console.error('\n所有账号认证失败,终止。'); process.exit(1); }
// 处理 URL,轮换账号 let urlIndex = 0; let successCount = 0; let failCount = 0;
for (const acc of clients) { if (urlIndex >= batch.length) break; const accBatch = batch.slice(urlIndex, urlIndex + acc.remaining); console.log(`\n--- ${acc.key} (${acc.email}) ---`);
let processed = 0; for (let i = 0; i < accBatch.length; i++) { const url = accBatch[i]; const result = await notifyUrlDeleted(acc.client, url); const globalIdx = urlIndex + i + 1;
if (result.success) { successCount++; processed++; progress.processed.push(url); progress.accounts[acc.key] = (progress.accounts[acc.key] || 0) + 1; console.log(` [${globalIdx}/${batch.length}] OK ${url}`); } else { const errMsg = result.error || `HTTP ${result.status}`; if (result.status === 429 || errMsg.includes('Quota exceeded')) { console.log(`\n ${acc.key} 配额已用完,切换下一个账号`); break; } failCount++; processed++; console.log(` [${globalIdx}/${batch.length}] FAIL ${url}`); console.log(` ${errMsg}`); }
if (globalIdx % 10 === 0) saveProgress(progress); if (i < accBatch.length - 1) await sleep(REQUEST_DELAY_MS); } urlIndex += processed; }
saveProgress(progress);
console.log('\n=== 完成 ==='); console.log(`成功: ${successCount}`); console.log(`失败: ${failCount}`); console.log(`累计已处理: ${progress.processed.length}`);
const left = allUrls.length - progress.processed.length; if (left > 0) { const dailyCapacity = saFiles.length * PER_ACCOUNT_QUOTA; console.log(`\n还剩 ${left} 个,按 ${dailyCapacity}/天 配额预计 ${Math.ceil(left / dailyCapacity)} 天完成`); }}
main().catch(console.error);脚本支持断点续传,每处理 10 条自动保存进度。第二天再跑会自动跳过已处理的 URL 并重置每日配额计数。先用 --dry-run 跑一下看看数据对不对,没问题再去掉参数正式跑。
完整操作流程
总结一下从头到尾的操作步骤:
- 部署 Cloudflare Worker:创建一个 Worker 返回 410,把域名路由指过去
- GCP 批量开项目:打开 Cloud Shell,填好配置跑
setup-gcp-projects.sh - 下载密钥文件:把生成的
sa-*.json下载到本地scripts/service_accounts/目录 - 添加 GSC 权限:进 GSC → 设置 → 用户和权限,把每个服务账号邮箱都加为 Owner
- 导出垃圾 URL:在 GSC 的 Page Indexing 页面导出 CSV,放到
scripts/gsc-data/ - 跑脚本:
node scripts/gsc-bulk-remove.js --dry-run先看看,没问题就node scripts/gsc-bulk-remove.js - 每天重复第 6 步:直到所有垃圾 URL 清理完毕
假设你开了 4 个项目,每天就有 800 次配额。10 万条垃圾 URL 大概需要 125 天。如果嫌慢可以多开几个项目,但注意 GCP 的项目数量限制。实际上 Cloudflare Worker 的 410 也在持续生效,Google 爬虫自己爬到 410 也会逐渐去索引,两边同时进行会快很多。
效果
跑了几天之后,GSC 的 Page Indexing 数字开始明显下降。410 + Indexing API 双管齐下,比单纯等 Google 爬虫自己发现要快不少。不过几万条数据量还是需要耐心,急不来的。

文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
Lim's Blog