Mobile wallpaper
3076 字
15 分钟

域名被污染后批量清理 Google 索引

2026-03-17
浏览量 加载中...
本文主要内容

买了个域名发现被外链攻击,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 一次只能删一条,几万条一条条删?我寿命不够用😓。所以只能另辟蹊径。

整体思路#

  1. Cloudflare Worker 全站 410:让所有访问这个域名的请求都返回 410 Gone,告诉 Google 爬虫”这些页面永久不存在了”
  2. Google Indexing API 批量通知:主动通知 Google 这些 URL 已删除,加速去索引
  3. GCP 多项目扩容配额:每个账号下单个项目每天只有 200 次 API 调用额度,不够用就多开几个项目

Cloudflare Worker:全站 410#

这一步很简单,在 Cloudflare 上创建一个 Worker,让所有请求都返回 410 状态码。410 和 404 的区别在于,410 是明确告诉搜索引擎”这个资源已经永久消失了,不会再回来”。虽然 Google 的 John Mueller 说过两者处理差异 “so minimal”,但 410 语义上更明确——你主动声明”我故意删了”,而不是”不知道为啥找不到了”。在批量清理污染域名这种场景下,用 410 更合理。

gone-handler (Cloudflare Worker)
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' },
}
);
},
};

CleanShot 2026-03-17 at 14.53.44@2x

部署完之后:

  1. 新增一个DNS Record
  2. 把域名的路由指向这个 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、创建服务账号、下载密钥文件:

setup-gcp-projects.sh
#!/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 1
fi
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" << EOF
name: projects/${project_id}/policies/iam.disableServiceAccountKeyCreation
spec:
rules:
- enforce: false
EOF
gcloud org-policies set-policy "/tmp/policy-${idx}-a.yaml" --project="$project_id" --quiet 2>&1 || true
cat > "/tmp/policy-${idx}-b.yaml" << EOF
name: projects/${project_id}/policies/iam.managed.disableServiceAccountKeyCreation
spec:
rules:
- enforce: false
EOF
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=0
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
fail_count=$((fail_count + 1))
fi
done
echo ""
echo "=========================================="
echo " 全部完成! 成功: $((NUM_PROJECTS - fail_count))/$NUM_PROJECTS"
echo "=========================================="
echo ""
echo "JSON 密钥文件:"
ls -la "$OUTPUT_DIR"/sa-*.json 2>/dev/null
echo ""
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"
fi
echo ""
echo "操作步骤:"
echo "1. 下载 ${OUTPUT_DIR}/ 目录下所有 sa-*.json 文件"
echo "2. 放到本地项目 scripts/service_accounts/ 目录"
echo "3. 进入 GSC → 设置 → 用户和权限"
echo "4. 把上面每个邮箱都添加为 Owner"
echo "5. 运行 pnpm gsc:remove"
注意

脚本里的 ORG_IDBILLING_ACCOUNT 必须填你自己的。没有组织的个人账号需要先创建一个组织,或者去掉 --organization 参数手动创建项目。另外 GCP 免费账号有项目数量限制,创建太多可能会被拒绝。

脚本跑完后会在 service_accounts/ 目录下生成一堆 sa-*.json 密钥文件,同时输出所有服务账号的邮箱地址。这些邮箱必须逐一添加到 GSC 属性的 Owner 权限里,否则 API 调用会报权限错误。

批量调用 Indexing API 清理索引#

前面两步都准备完成后,开始调用Indexing API。流程:

  1. 从 GSC 导出垃圾 URL 的 CSV 文件,放到 scripts/gsc-data/ 目录
    • 记得需要点进具体的 Why pages aren’t indexed 页面,才能看到 URL 列表
    • 点右上角的 Export,选择 Download CSV
  2. 把前面下载的服务账号 JSON 放到 scripts/service_accounts/ 目录
  3. 运行脚本,自动轮换多个服务账号发送 URL_DELETED 通知
gsc-bulk-remove.js
/**
* 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 跑一下看看数据对不对,没问题再去掉参数正式跑。

完整操作流程#

总结一下从头到尾的操作步骤:

  1. 部署 Cloudflare Worker:创建一个 Worker 返回 410,把域名路由指过去
  2. GCP 批量开项目:打开 Cloud Shell,填好配置跑 setup-gcp-projects.sh
  3. 下载密钥文件:把生成的 sa-*.json 下载到本地 scripts/service_accounts/ 目录
  4. 添加 GSC 权限:进 GSC → 设置 → 用户和权限,把每个服务账号邮箱都加为 Owner
  5. 导出垃圾 URL:在 GSC 的 Page Indexing 页面导出 CSV,放到 scripts/gsc-data/
  6. 跑脚本node scripts/gsc-bulk-remove.js --dry-run 先看看,没问题就 node scripts/gsc-bulk-remove.js
  7. 每天重复第 6 步:直到所有垃圾 URL 清理完毕
配额计算

假设你开了 4 个项目,每天就有 800 次配额。10 万条垃圾 URL 大概需要 125 天。如果嫌慢可以多开几个项目,但注意 GCP 的项目数量限制。实际上 Cloudflare Worker 的 410 也在持续生效,Google 爬虫自己爬到 410 也会逐渐去索引,两边同时进行会快很多。

效果#

跑了几天之后,GSC 的 Page Indexing 数字开始明显下降。410 + Indexing API 双管齐下,比单纯等 Google 爬虫自己发现要快不少。不过几万条数据量还是需要耐心,急不来的。

CleanShot 2026-03-17 at 15.04.48@2x

文章分享

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

评论区

目录