AI搜索排名跟踪系统搭建指南:从可见性指标到巡检实践
Setting up AI search rank tracking easily 这个需求看起来只是把传统 SEO 的排名跟踪从搜索引擎搬到 AI 搜索真正动手后会发现采集对象、指标口径和存储模型全都得重新设计。传统搜索返回的是十条蓝色链接站点可以明确知道自己排在第几位AI 搜索返回的是一段模型综合生成的回答它可能引用十个来源也可能只给一个直接结论你的品牌或产品有没有进入这一段回答才是今天更需要关注的问题。这篇文章要做的不是套用某个 SEO 工具的按钮而是从工程角度搭一套可以日常巡检的 AI 搜索可见度跟踪系统。你会得到一套清晰的数据模型、一个可以在本地跑通的最小闭环、一个能接入真实 AI 搜索页面的采集器结构、一套定时报警机制以及后续排查问题时的检查顺序。AI 搜索的形态还在快速变化包括通用问答型搜索、浏览器内置 AI 搜索、电商平台内部的 AI 导购与搜索结果。比如近期《leaps: an llm-empowered adaptive plugin in taobao ai search》这类工作也说明AI 搜索已经不只是搜索引擎的事业务系统内部同样在出现“由模型组织搜索结果”的场景。因此这篇文章讲的方法尽量不做成某个站点的专用抓取脚本而是做成一个可以替换目标源、可以扩展指标的自建巡检系统。1. AI 搜索里的“排名”和传统搜索结果里的排名不是一回事1.1 AI 搜索的结果形态决定了指标口径传统 SEO 排名跟踪的核心指标很直接关键词、URL、排名位置、变化趋势。AI 搜索结果没有这个结构。当用户向 AI 搜索提出一个问题模型会先检索候选内容再对这些内容进行摘要、综合和重写。最后用户看到的内容可以拆成几层模型直接生成的文字通常是一段连贯回答。回答里可能出现的品牌名、产品名、公司名。引用来源链接有些是链接列表有些是引用角标。后续追问时的补充答案。在这种情况下一个站点可能被模型阅读了但回答里完全没有出现品牌词也可能品牌词出现了但引用链接里放的是友商内容。这些问题如果只统计“有没有排名”根本解释不了业务价值。这里要建立的第一原则是AI 搜索里值得跟踪的不是“位置”而是“可见性”。可见性的粒度包括是否被提及、出现在哪句语境中、是否被列为引用来源、这种状态在连续时间窗内是否稳定。1.2 从传统排名到 AI 可见度的指标迁移先把两种场景的监测差异列出来后面设计表结构和报警规则时都要围绕这些差异展开。对比维度传统 SEO 排名AI 搜索可见性结果形态固定数量的链接列表模型生成的回答文本加引用核心观测对象域名、URL、排名位置品牌词、产品词、回答语境、引用 URL可直接回答的问题关键词排第几回答里有没有提到我竞品分析方式比较同一个关键词下谁排在前面比较回答里提到了谁、谁被引用了数据波动原因页面质量、外链、算法更新模型版本、Prompt 模板、时间、地域、知识库更新常见跟踪指标排名、收录、点击率、曝光提及次数、引用出现率、语境变化、连续消失天数这张表解释了为什么不能照搬旧系统的表结构。旧系统记录“keyword 和 position”新系统至少要记录“提问关键词、采集时间、当时的完整回答、从回答中抽取的提及结果、当时的引用 URL 列表”。只有保留完整回答后续才能在模型迭代后重新解析历史数据。如果只存一个“是否出现”的布尔值将来想分析“以前提到过但语境是否正面”这类问题就无从下手。1.3 什么业务场景需要这套系统典型场景包括三类第一类是内容品牌监测。运营团队想知道自家网站是否被 AI 搜索推荐例如用户问“推荐三个开源日志采集工具”自己的项目有没有出现在回答里。第二类是竞品对比监测。同一批问题中持续记录 A 品牌和 B 品牌的提及情况能得到品牌在 AI 回答中的相对存在感不过这更多是趋势参考不是精确的市场份额。第三类是电商或业务内 AI 搜索监测。比如电商平台的 AI 导购推荐摘要是否包含自家商品或者企业内部的智能助手是否会返回某个业务系统的入口。第三类场景往往比公共 AI 搜索更容易落地因为目标是自己的业务系统有查询权限也能拿到更结构化的日志。这也是把采集层做成可替换策略的原因之一。2. 先想清楚要存什么再写第一个爬虫2.1 最小模块划分一个能持续运行的 AI 排名跟踪服务拆成模块后并不复杂每个模块都只做一件事任务管理负责维护要跟踪的关键词、目标对象、品牌关键词、跟踪策略。采集执行向某个 AI 搜索源发起一次查询拿到原始回答和引用 URL。解析入库把原始回答解析成结构化字段包括提到的品牌、上下文片段、引用链接。分析报警对比历史结果在可见性出现波动时产生提醒。调度监控定时触发任务记录失败原因保存每次运行状态。这里的边界值得注意采集和解析必须分开。AI 搜索页面结构不稳定采集层今天能用明天可能因为页面改版就抓不到内容。如果解析逻辑和采集逻辑混在一起一次页面调整会导致整个链路不可用而且历史数据也很难重新解析。2.2 技术选型尽量贴近学习成本整套系统不需要一开始就上微服务先本地一个进程跑通再接真实数据源最后根据数据量决定是否需要拆开部署。推荐的学习环境组合组件选择说明语言Python 3.10社区案例多适合快速写抓取和解析脚本采集Playwright能处理需要浏览器渲染的 AI 搜索页面存储SQLite单机小规模足够后续可平滑迁移到 PostgreSQL调度APScheduler进程内调度适合单机定时任务无需额外中间件解析Python 标准库 正则规则模板先跑通再考虑引入大模型做高级语义判断生产环境要考虑的差异更大会涉及任务队列、PostgreSQL、监控白名单、采集限流和更多的异常兜底。本文后续会在最佳实践部分说明差异不建议直接把学习环境代码原样部署。2.3 数据表设计一次查询对应一次快照数据建模的核心思路是把每次 AI 搜索当作一次不可变的快照。先设计任务表用来记录要监控的关键词CREATE TABLE search_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT NOT NULL, biz_type TEXT NOT NULL DEFAULT brand, engine_code TEXT NOT NULL DEFAULT mock_ai, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime(now)) );engine_code很重要它表示这个任务应该走哪套采集策略。同一个关键词可能同时存在于通用 AI 搜索、电商 AI 搜索等多个目标未来接多少个源都可以通过这张表控制。再设计运行结果表每次采集产生一条记录CREATE TABLE search_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT running, full_answer TEXT, cited_urls TEXT, started_at TEXT NOT NULL, finished_at TEXT, error_code TEXT, error_message TEXT, FOREIGN KEY(task_id) REFERENCES search_tasks(id) );full_answer保存完整回答文本cited_urls可以保存 JSON 数组。为了便于排错状态字段要区分 running、success、failed、timeout不能把所有异常都归成失败。最后是品牌提及表负责保存真正要统计的结果CREATE TABLE brand_mentions ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL, brand_keyword TEXT NOT NULL, is_mentioned INTEGER NOT NULL DEFAULT 0, context_snippet TEXT, matched_flag INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now)), FOREIGN KEY(run_id) REFERENCES search_runs(id) );context_snippet保存命中的上下文片段后续做语境分析和人工复盘时会非常有用。matched_flag用来记录这次命中是关键词精确命中还是经过语义模型判断后的命中。这样可以为不同的匹配策略保留回退余地。3. 先用 Mock 目标跑通“搜索、匹配、入库”最小闭环3.1 没有真实目标时怎么验证分析链路接入真实 AI 搜索页面之前比较稳妥的办法是先写一个 Mock 采集器。它的目标是返回一段模拟回答让后端的“匹配、入库、查询”链路先转起来。这样做的价值在于当后续接入真实采集源时不会同时面临采集问题、解析问题和数据库问题可以先分离验证。定义统一的回答结构# schemas.py from dataclasses import dataclass, field dataclass class AIAnswer: text: str cited_urls: list field(default_factorylist) raw: object None无论未来对接哪个 AI 搜索源采集结果尽量统一成这个结构。raw字段可以保存原始响应方便后续排查。3.2 Mock 采集器Mock 采集器通过模板返回模拟回答这样不需要等待真实搜索引擎响应适合先跑通本地流程。# fetchers/mock_ai_search.py from schemas import AIAnswer class MockAISearchFetcher: def __init__(self, answer_template: str): self.answer_template answer_template def search(self, query: str) - AIAnswer: answer_text self.answer_template.replace({query}, query) return AIAnswer( textanswer_text, cited_urls[ https://example.com/docs/ai-search, https://example.net/blog/keyword-research, ], )模板里可以用{query}作为占位符模仿模型回答中包含查询词的情况。实际项目中Mock 数据可以来自真实产品早期的人工对话记录这样更接近未来要处理的内容。3.3 从回答中提取品牌提及先采用规则型匹配逻辑是查找品牌关键词在完整回答中的位置并截取前后文。# analyzer.py def find_mentions(answer_text: str, brands: list[str]) - list[dict]: results [] for brand in brands: idx answer_text.lower().find(brand.lower()) is_mentioned idx ! -1 snippet if idx ! -1: left max(0, idx - 60) right min(len(answer_text), idx len(brand) 60) snippet answer_text[left:right] results.append( { brand: brand, is_mentioned: is_mentioned, context_snippet: snippet, matched_flag: 1 if is_mentioned else 0, } ) return results这里只做了文本包含判断它的局限在中文场景下尤其明显。比如词根变化、英文大小写、简繁体、中英文品牌名混用、同义改写等都可能漏判。规则匹配是第一版生产环境建议在规则匹配之后再接一层语义判断。3.4 入库代码入库时把一次运行和多个品牌结果写入两张表是一个典型的事务操作。# storage.py import json import sqlite3 from datetime import datetime, timezone def save_run(db_path, task_id, answer, mentions): conn sqlite3.connect(db_path) try: now datetime.now(timezone.utc).isoformat() cur conn.execute( INSERT INTO search_runs (task_id, status, full_answer, cited_urls, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?) , ( task_id, success, answer.text, json.dumps(answer.cited_urls, ensure_asciiFalse), now, now, ), ) run_id cur.lastrowid for m in mentions: conn.execute( INSERT INTO brand_mentions (run_id, brand_keyword, is_mentioned, context_snippet, matched_flag) VALUES (?, ?, ?, ?, ?) , ( run_id, m[brand], 1 if m[is_mentioned] else 0, m[context_snippet], m[matched_flag], ), ) conn.commit() return run_id finally: conn.close()注意这里使用了datetime.now(timezone.utc)避免本地时区变化导致历史时间错乱。实际展示给业务方时再在查询层转换时区。跑通后的验证方式很简单连续执行几次查看search_runs里的full_answer确认完整回答被保存再查看brand_mentions确认每一条品牌记录都有对应的上下文片段。4. 接入真实 AI 搜索页面从 Playwright 到可替换的采集层4.1 采集边界要先定清楚代码才有长期价值任何关于 AI 搜索采集的实践文章都应该把目标站点服务条款、robots.txt、接口付费策略、反爬机制讲清楚。自建脚本不是用于绕过限制而是用于在合规的前提下做低频品牌巡检或者采集自己业务内部有权限访问的 AI 搜索服务。在动手前建议先完成三项检查查看目标站点robots.txt确认是否存在禁止访问的路径。阅读目标站点服务条款确认自动化查询是否被允许。确认采集频率在目标服务的可接受范围内低频、小批量、单账号是基础要求。如果目标站点提供官方 API优先使用 API。API 返回的数据结构稳定不会被页面改版影响只是可能需要付费并且有配额限制。常见项目中也可以把多个目标源统一封装成同一个接口这是后面所有采集器结构设计的核心。4.2 使用 Playwright 采集页面型 AI 搜索页面型 AI 搜索适合用浏览器自动化方式处理因为它需要等待 JavaScript 加载且答案经常是流式输出的。下面代码展示的是一个通用结构不能直接用于所有网站因为输入框选择器、回答容器选择器会随着站点不同而变化。# fetchers/web_ai_search.py from playwright.sync_api import sync_playwright from schemas import AIAnswer class PlaywrightAISearchFetcher: 页面型 AI 搜索采集器。 不同站点的 selector 差异很大建议把 selector 放到配置文件中 不要硬编码在采集逻辑里。 def __init__(self, engine_url, input_selector, result_selector, timeout_ms30000): self.engine_url engine_url self.input_selector input_selector self.result_selector result_selector self.timeout_ms timeout_ms def search(self, query: str) - AIAnswer: with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(self.engine_url, wait_untildomcontentloaded) page.fill(self.input_selector, query) page.keyboard.press(Enter) try: page.wait_for_selector(self.result_selector, timeoutself.timeout_ms) except Exception: page.screenshot(pathai_search_timeout.png, full_pageTrue) raise text page.inner_text(self.result_selector) cited_urls self._extract_urls(text) browser.close() return AIAnswer(texttext, cited_urlscited_urls) def _extract_urls(self, text: str) - list: # 简单 URL 提取仅供原型使用 import re return list(set(re.findall(rhttps?://[^\s)\]\], text)))这里有一个非常重要的注意点wait_for_selector等待的是结果容器出现并不代表 AI 回答已经流式输出结束。页面可能先出现一个“正在回答”的占位区域实际内容还在逐字生成。更稳妥的方式是等待一个特定的结束标记比如“参考来源”区域出现或者判断回答区域的文本长度在一段时间内不再变化。不要固定等待 10 秒或者 20 秒这种方案在慢网络环境里会经常失败在快网络环境里又会浪费大量时间。4.3 优先使用 API 或内部网关如果有权访问内部 AI 搜索网关或者目标服务提供官方搜索 API采集层要简单得多。此时不需要维护浏览器选择器只需要处理 HTTP 客户端、超时和重试。# fetchers/api_ai_search.py import requests from schemas import AIAnswer class ApiAISearchFetcher: def __init__(self, api_url: str, token: str): self.api_url api_url self.token token def search(self, query: str) - AIAnswer: resp requests.post( self.api_url, json{query: query, top_n: 10}, headers{Authorization: fBearer {self.token}}, timeout60, ) resp.raise_for_status() data resp.json() return AIAnswer( textdata.get(answer, ), cited_urlsdata.get(cited_urls, []), rawdata, )上面的请求参数是示意性的真实接口的字段名称要以目标服务文档为准。核心设计原则是不管底层是 Playwright 还是 requests对外都返回统一的AIAnswer。4.4 把采集策略做成可配置替换在主程序中可以根据任务的engine_code选择不同采集器避免写大量if分支。# executor.py from fetchers.mock_ai_search import MockAISearchFetcher from fetchers.web_ai_search import PlaywrightAISearchFetcher from fetchers.api_ai_search import ApiAISearchFetcher def build_fetcher(engine_code: str, config: dict): if engine_code mock: return MockAISearchFetcher(config[answer_template]) if engine_code web: return PlaywrightAISearchFetcher( engine_urlconfig[engine_url], input_selectorconfig[input_selector], result_selectorconfig[result_selector], ) if engine_code api: return ApiAISearchFetcher(config[api_url], config[token]) raise ValueError(funsupported engine_code: {engine_code})扩展新目标时只需要新增一个 fetcher 类并在工厂函数里注册。这样不会破坏已有目标源的稳定性。5. 把一次性脚本变成定时巡检服务5.1 定时调度与随机抖动一旦开始运行真实 AI 搜索页面采集频率就不能设置得太高。个人或小团队巡检建议每个关键词每天 1 到 4 次并且不要让任务在同一秒集中执行。APScheduler 可以很好地胜任单机调度任务# main.py import datetime as dt import logging from apscheduler.schedulers.blocking import BlockingScheduler from executor import build_fetcher from storage import save_run, load_tasks from analyzer import find_mentions logging.basicConfig(levellogging.INFO) logger logging.getLogger(ai_rank_tracker) def run_all(): tasks load_tasks(db_pathtracker.db) for task in tasks: if not task[enabled]: continue try: fetcher build_fetcher(task[engine_code], task[config]) answer fetcher.search(task[keyword]) mentions find_mentions(answer.text, task[brand_keywords]) run_id save_run( db_pathtracker.db, task_idtask[id], answeranswer, mentionsmentions, ) logger.info(task %s, run %s finished, task[id], run_id) except Exception: logger.exception(task %s failed, task[id]) scheduler BlockingScheduler() scheduler.add_job( run_all, triggercron, hour8,20, minute15, jitter300, max_instances1, ) scheduler.start()max_instances1防止上一次任务还没结束下一次任务就启动。jitter300表示在 300 秒内随机偏移执行时间避免多个任务同时打向目标服务。5.2 报警规则要围绕“变化”设计比单次是否提及更有价值的是变化趋势。最常见的报警规则包括某个品牌昨天在回答里出现今天消失了。连续 3 次运行都没有出现某个品牌词。某个品牌词的上下文从介绍型变成推荐其他竞品。引用 URL 列表里自家域名消失或新增。判断“连续消失”是最容易实现的规则之一可以在每次入库后查询最近历史记录。def should_alert(run_id, task_id, brand_keyword): recent get_recent_mentions(db_path, task_id, brand_keyword, limit5) if len(recent) 3 and sum(r[is_mentioned] for r in recent) 0: return { level: warning, message: f{brand_keyword} 已经连续 {len(recent)} 次未在 AI 回答中出现, } return None报警渠道不需要在一开始就做得很复杂最简单的做法是发一个 Webhook JSON 到企业微信、钉钉或 Slack。如果团队没有 Webhook也可以退而求其次先写一个本地日志文件连续运行几天后再决定是否要接即时通知。5.3 用 SQL 做每日可见度报表入库之后应该形成稳定的统计口径。下面这个查询可以统计每个品牌每天的出现率SELECT date(r.finished_at) AS day, b.brand_keyword, COUNT(*) AS run_count, SUM(b.is_mentioned) AS mention_count, ROUND(100.0 * SUM(b.is_mentioned) / COUNT(*), 2) AS mention_rate FROM search_runs r JOIN brand_mentions b ON b.run_id r.id GROUP BY day, b.brand_keyword ORDER BY day DESC, b.brand_keyword;这里的口径先定义为“当天所有运行中有多少比例出现了该品牌”。实际业务中还需要考虑同一个关键词的同一天多次运行会因为模型版本、服务端缓存导致结果不一致因此建议用较长的时间窗口观察趋势而不是用单次结果下结论。6. 常见现象排查按这条链路逐层找根因自建 AI 搜索跟踪系统故障不只在代码层。目标服务页面改版、接口更新、账号状态异常都会导致采集失败。以下排查顺序值得固定下来确认任务参数是否正确关键词和品牌词是否拼写一致。确认采集目标是页面型还是 API 型页面型先看回答容器是否找到。查看原始响应内容判断是采集失败还是页面返回了空内容。对比最近一次成功运行的数据观察是不是目标模型输出格式变化。检查是否触发频率限制、登录要求或验证码。最后再看代码逻辑和数据解析逻辑是否有 bug。在实际运行过程中最常见的问题可以整理成下表问题现象常见原因检查方式处理建议Status: timeout页面结构改版回答容器选择器失效手动打开页面检查 selector更新配置文件中的 selector回答内容只有半截AI 回答是流式输出等待时机不对查看full_answer是否为截断文本改为等待固定结束标记或者等文本稳定后结束页面有内容但full_answer为空回答在 iframe 或 shadow DOM 中打开开发者工具确认内容层级使用正确的 frame 或 shadow root 查找方式同一关键词不同时间结果不一致模型版本、地域、登录态导致检查两次运行的时间和回答全文不要用一次运行判断按天或按周看趋势关键词被匹配到其他噪声内容规则匹配对同义词、简繁体、中英文不敏感抽查context_snippet增加品牌词表或后期接入语义判断模型多次请求后出现异常采集频率过高触发目标服务限制查看 HTTP 状态码和返回消息降低频率加随机延迟优先使用官方 API历史数据无法对比没有保存完整回答只保存了是否出现查看早期search_runs.full_answer从修改代码开始保留完整原始数据其中“历史数据无法对比”是最难修复的坑。很多系统一开始只保存布尔值后来想往前分析语境变化时发现根本没有原始文本。这也是本文在第 2 章反复强调保存full_answer的原因。一个值得推荐的排查手段是每次失败都把页面截图和当前 HTML 保存到本地目录。这样即使选择器失效也能通过截图快速判断是页面本身出了问题还是采集代码出了问题。7. 让这套系统在真实环境中稳定运行7.1 学习环境和生产环境的边界学习环境的核心目标是验证链路可以用 Mock 数据可以手动运行可以分析失败的整页截图。生产环境则需要额外考虑以下内容关注点学习环境做法生产环境建议存储SQLite 单文件PostgreSQL 或 MySQL开启备份任务量手工维护少量关键词使用 Redis 队列 Worker支持扩容采集调度APScheduler 单机运行独立调度服务配合分布式任务队列失败处理打印异常日志记录结构化错误码接报警配置管理代码里写死或本地 yaml配置中心或环境变量敏感信息加密监控无对任务成功率、平均耗时、存储量做监控数据重新解析不关心保留原始数据支持解析器升级后回填生产环境还需要特别关注采集目标的服务波动。如果目标服务本身不稳定任务失败会成片出现。此时不要用提高并发来解决而是要用退避重试、错峰执行和失败队列来控制冲击。7.2 把“自建逻辑”和“第三方能力”结合自建全套系统没必要也不建议。采集端如果真的没有合法稳定的获取通道可以评估第三方搜索 API 或商业级排名跟踪服务的接口因为这些服务通常解决了登录态、频率限制、数据格式兼容问题。但如果只是接第三方接口然后打印结果就把系统做得太薄了。更有价值的是把第三方数据接到自己的数据模型里再叠加品牌词、口径定义、报警规则这样每次业务方问“为什么这个品牌这周的出现率下降了”都有历史数据可查。7.3 上线前的可复用检查清单每次新增一个 AI 搜索目标源都应该照着下面这个清单过一遍目标站点或 API 是否有合法使用边界是否已经确认服务条款和频率限制。是否使用统一的AIAnswer结构返回结果有没有跳过统一封装直接改调用方。是否保存了完整回答原文而不是只保存布尔值。每个任务的engine_code是否能落在已有工厂函数里。是否记录了开始时间、结束时间、状态、错误码。品牌关键词表是否覆盖大小写、简繁体、中英文常用别名。同一天多次采集是否设置了足够的采集间隔。报警规则是否在高频失败时能够自动静默避免告警风暴。是否具备失败现场保存例如截图、页面 HTML 或原始响应。是否已经确认 SQLite 在当前任务数量下不会频繁触发锁等待。7.4 下一步可以怎么扩展当规则型匹配无法满足需求时可以把上下文片段交给 LLM 做统一分类比如判断“答案推荐了我们的品牌”还是“答案只是顺带提到了我们的品牌”。此时要注意的是不要把 LLM API 调用放在采集过程中最好做成异步的批量解析任务这样即使模型服务不稳定也不会阻塞采集链路。更复杂的方向是接入用户真实提问日志。如果有权访问用户提问脱敏数据可以从高频提问中反向生成需要监控的关键词列表让追踪系统从“人工配关键词”走向“根据真实提问自动扩充”。完成这套系统后最先要培养的使用习惯是不要因为某次回答里没有出现品牌就紧张也不要因为某一次出现就判定效果很好。AI 搜索内容存在模型迭代、地域差异、随机性等多重因素只有持续积累数据在周、月级别观察变化才能得出对业务有参考价值的判断。