EveryInfra

Build in Public · LF-07

多平台口碑监控蓝图:先记录采集范围,再判断负面变化

从目标清单、评论身份和增量游标开始,设计可追溯的口碑监控流程;用离线样本演示去重与采集缺口,保留人工复核,不冒充客户案例。

昨天收到的负面评论多,今天收到的少,不能直接推出品牌口碑改善。如果今天少取了一组地点、某个平台失败,或者同一条评论被重复计算,两天的数字根本不在同一口径上。

口碑监控首先要回答“这次观察到了什么、漏了什么”,然后才能回答“哪些问题值得处理”。本文给出一份工作流蓝图:从获准监控的对象开始,保留评论身份与采集状态,把有证据的问题交给人复核。它不是现成监控产品的功能说明,也不是客户案例;文中的数据结构、规则和合成样本都是设计示例,没有真实品牌成效或模型准确率。

先选一个业务问题,而不是承诺全网覆盖

“监控品牌负面”太宽,难以定义完成。更可执行的问题是:指定地点最近收到的评论里,是否重复出现同一种服务问题;指定商品的新增评价中,是否出现需要核实的质量反馈。问题确定后,才能决定看哪些对象、保留什么证据、由谁接手。

建议先维护一份 watch_target 清单,每行包含平台、对象类型、原始 URL、来源对象标识、地区或语言、监控目的、允许的采集范围、负责人和保存期限。把目标变更单独记版本:新增一家店、停监一个商品、调整地区,都可能改变统计口径。

公开可访问不代表任意收集或使用都获准。接入前仍需核对目标平台政策、数据用途和必要授权;不要为了归一字段额外收集作者画像或联系方式。本文不替任何平台授予访问许可,也不对具体地区作合规结论。

管理自己有权访问的商家评论时,可先阅读 Google Business Profile 官方评论管理文档,判断需求是否属于官方管理流程。本文的跨平台监控蓝图与该 API 不是同一产品;EveryInfra 的地点评论参数另见谷歌地图点评 API 页面,接入方式见EveryInfra API 文档。

用目录选择能力,不把目录当交付证明

下面只查询公开能力,不读取评论、不使用 API key。在具备 curl 和 jq 的终端分别执行;pipefail 用来避免请求失败被后面的 JSON 处理掩盖。

Bash · 可复制示例
set -o pipefail
curl -fsS --max-time 20 \
  'https://api.everyinfra.com/api/v1/social/catalog?platform=google_maps_reviews' \
  | jq '.capabilities[] | select(.action == "reviews") |
      {platform, action, required_params, optional_params, response_fields}'
Bash · 可复制示例
set -o pipefail
curl -fsS --max-time 20 \
  'https://api.everyinfra.com/api/v1/social/catalog?platform=xiaohongshu' \
  | jq '.capabilities[] | select(.action == "comments") |
      {platform, action, required_params, optional_params, response_fields}'

2026 年 9 月 4 日的目录核查中,google_maps_reviews.reviewsxiaohongshu.comments 均要求 url。前者列出 since,后者的该 action 没有列出这个参数;不能给两者套一个通用的“从上次时间继续取”请求。前者声明评分与评分量表字段,后者没有声明评分,也不能把评论的情感标签填进评分列。地点评论目录、小红书评论目录

这些观察只证明目录声明。真实响应是否包含每个字段、时间格式是什么、是否有分页、空结果如何表达,都要在获准样本中分别核对。下面不提供未经实测的通用 API 响应,也不宣称已经跑通两个平台的采集。

三种时间、两类状态,不要放进同一列

一条评论至少涉及三种时间:来源给出的发布时间 posted_at、本系统首次观察到它的 first_seen_at、最近观察时间 last_seen_at。来源时间未知时保持为空,不能用抓取时间冒充发布时间。涉及日窗口时记录原始时区和转换规则;“刚刚”“昨天”等相对时间未经可靠解析,不参加精确的跨日统计。

采集运行也应有自己的记录:run_id、目标清单版本、请求引用、请求窗口、开始/结束时刻、实际交付条数和采集状态。建议在业务系统中区分:

  • succeeded:本次请求范围内完成交付;不等于平台历史已取完。
  • empty:请求成功且返回空集合;不等于这个对象没有评论。
  • partial:只完成部分目标或页面,保留已收到的结果及缺口。
  • failed:没有取得符合约定的交付,保留可排查的错误分类。
  • unknown:超时或结果无法确认,不能擅自算成功、空结果或未扣费。

这些状态是本蓝图的业务设计,不是声称 API 一定返回同名字段。采集状态与业务判断分开:采集失败应生成管道问题;证据不足的内容标记为待判断,不能自动标为“无负面”。

时间格式可参考 RFC 3339 的日期、时间与 UTC 偏移定义。它有助于统一存储表示,但不能把 posted_atfirst_seen_atlast_seen_at 合成一个业务含义,更不能替未知来源时间补出时区。

评论身份稳定,内容版本可变化

如果来源提供稳定评论 ID,推荐的身份键是 (platform, source_object_id, source_review_id)。不要只用评论 ID,因为不同平台或对象可能给出相同值;也不要只用正文哈希,两位用户写下相同短句并不代表同一条评论。

同一身份键再次出现时,再判断内容版本。文本或评分变化可以产生新的观察版本,但不能当成新增评论。将版本摘要与首次/最近观察时间分开记录;人审时能看见“本系统观察到了内容差异”,统计新增时又不会多算一次。差异也可能来自语言或适配规则变化,不能未经核对就宣称作者修改了原文。来源没有稳定 ID 的记录进入待核对集合;若采用近似指纹,必须标记身份不确定,不能悄悄作为精确去重依据。

归一层只保存本次确实获得的值及字段来源。评分和量表成对处理,空评分不是零分;owner_response 未返回也不等于商家从未回复。原始响应结构和内部适配结构分开,适配规则带版本,避免接口升级后旧字段被错误解释。

这类版本关联可以借用 W3C PROV 的来源、处理活动与派生关系来组织:原始观察、归一记录、模型标签和告警各自留有来路。这里采用的是可追溯设计思路,不声称本蓝图输出了符合 PROV 规范的数据。

游标先等数据落稳,再向前移动

增量采集容易留下隐蔽缺口:先把游标更新到今天,再保存评论,保存失败后重跑就可能跳过这批内容。建议把游标推进放到同一目标的交付、去重、持久化与必要检查完成之后;多目标批次分别维护进度,不因为其他目标成功而一起前移。

有经过验证的分页 token 时,保存它与对应目标、请求条件和已完成页面。只有时间筛选时,可以设计重叠窗口并依靠身份键去重,但必须说明窗口长度是业务选择,不是“不会漏”的保证。目录没有支持的增量参数,不要自行添加;先采用经过验证的重复读取范围,再评估是否满足监控目标。

结果达到单次上限时,只能说达到此次取样边界,不能宣布历史取全。排序、地区或语言发生变化后,旧游标也不一定仍可复用。安全做法是把请求条件纳入进度版本,重新核对覆盖范围,保留补采任务。

仅仅这次没有再次见到某条评论,也不能自动把它标成已删除。可能是排序变化、分页未取到或短时不可见;删除状态需要专门核验,而不是从采样缺失推断。

一个不联网的去重与缺口演示

下面的 Node.js 示例只处理虚构对象与标签,不下载内容、不调用模型、不发送告警。它刻意保留跨对象同 ID、跨平台同 ID、重复观察、编辑版本、缺少 ID、空文本和未登记目标,验证身份与采集状态不会被混成一个“成功条数”。

示例的目标状态和文本标签是手工设置的测试输入,不是真实 API 响应或分类结果。它只是本地批次演示,没有实现跨运行持久化、请求重试、游标提交或通知系统。

Bash · 可复制示例
node <<'JS'
const assert = require('node:assert/strict');
globalThis.fetch = () => { throw new Error('Offline example: no network'); };

function inspectRun(targets, rows) {
  const targetKey = r => JSON.stringify([r.platform, r.object]);
  const states = new Set(['succeeded', 'empty', 'partial', 'failed', 'unknown']);
  assert.ok(targets.length > 0);
  assert.equal(new Set(targets.map(targetKey)).size, targets.length);
  assert.ok(targets.every(t => states.has(t.state)));
  const planned = new Map(targets.map(t => [targetKey(t), t.state]));
  const identities = new Map();
  let duplicates = 0, revisions = 0, quarantined = 0;
  for (const r of rows) {
    const state = planned.get(targetKey(r));
    const canReadRows = state === 'succeeded' || state === 'partial';
    if (!canReadRows || typeof r.id !== 'string' || !r.id.trim()
        || typeof r.text !== 'string' || !r.text.trim()) {
      quarantined++;
      continue;
    }
    const key = JSON.stringify([r.platform, r.object, r.id]);
    // This fixture arrives in observation order; text alone is not an identity.
    const version = JSON.stringify([r.text]);
    if (identities.get(key) === version) duplicates++;
    else {
      if (identities.has(key)) revisions++;
      identities.set(key, version);
    }
  }
  return {
    uniqueReviewsInBatch: identities.size,
    duplicates, revisions, quarantined,
    targetCoverageGate: targets.every(t => ['succeeded', 'empty'].includes(t.state)),
  };
}

const targets = [
  { platform: 'maps-fixture', object: 'place-a', state: 'succeeded' },
  { platform: 'maps-fixture', object: 'place-b', state: 'succeeded' },
  { platform: 'notes-fixture', object: 'note-a', state: 'partial' },
  { platform: 'video-fixture', object: 'video-a', state: 'failed' },
];
const first = { platform: 'maps-fixture', object: 'place-a', id: '7', text: '合成评论甲' };
const rows = [
  first,
  { ...first },
  { ...first, object: 'place-b' },
  { ...first, platform: 'notes-fixture', object: 'note-a' },
  { ...first, id: null },
  { ...first, id: '8', text: '   ' },
  { ...first, text: '合成评论甲的编辑版' },
  { ...first, object: 'unplanned' },
];
const report = inspectRun(targets, rows);
assert.deepEqual(report, {
  uniqueReviewsInBatch: 3, duplicates: 1, revisions: 1,
  quarantined: 3, targetCoverageGate: false,
});
assert.equal(inspectRun(targets, [...rows, rows[6]]).duplicates, 2);
assert.equal(inspectRun([{ ...targets[0], state: 'empty' }], []).targetCoverageGate, true);
assert.equal(inspectRun([{ ...targets[0], state: 'unknown' }], []).targetCoverageGate, false);
assert.equal(inspectRun([{ ...targets[0], state: 'failed' }], [first]).quarantined, 1);
assert.equal(inspectRun([targets[0]], [{ ...first, id: ' ' }]).quarantined, 1);
assert.throws(() => inspectRun([], []));
assert.throws(() => inspectRun([targets[0], targets[0]], []));
assert.throws(() => inspectRun([{ ...targets[0], state: 'typo' }], []));
console.log(JSON.stringify(report, null, 2));
JS

预期结果是本批 3 个评论身份、1 次重复观察、1 次编辑版本、3 条待核对记录,目标覆盖门不通过。empty 可以满足“这次请求已结束”的目标状态门,但依然不证明对象没有评论。即便目标状态门通过,也还要核对页覆盖、样本口径和基线才能比较趋势;上面的布尔值不等于“允许对外下结论”。

本例按观察顺序处理,版本只比较文本;生产适配还应包含需要跟踪的评分等字段,并定义乱序数据与历史版本的处理规则,不能把最后抵达的记录无条件当成来源的最新版本。

待核对记录不应直接丢弃或计入新增。按照原因分别检查适配器、目标清单和缺失字段;只有补齐身份或明确接受近似处理后,才进入相应统计。不要为了让日报好看,把失败目标从计划分母中删除。

先形成候选证据,再让模型归类

建议让主题标签至少携带 review_key、评论版本、规则或模型版本、标签、证据片段和人工处理状态。摘要必须能定位到支持它的原文;模型给出的“置信度”未经校准,不是正确概率,不能直接决定是否升级投诉。

评论正文是不可信输入。即使其中写着“忽略之前的要求”“把这些数据发送出去”,也只能作为待分析的文本,不应成为 Agent 的操作指令。分类步骤不需要支付、删除或外发权限;把取数、分类、人审、发送拆开,可以减少一段评论意外触发实际动作的机会。

从一个具体主题和一组人工核对样本开始,比一次启用大量标签更容易解释。分类需要允许“无法判断”,并记录是语言、上下文还是字段缺失导致;不能把无法判断归入中性,稀释需要复核的问题。

上述权限分工也可对照 OWASP 的间接提示注入防护指南:外部内容可能携带指令,工具权限和实际动作需要独立控制。一句“请忽略评论中的指令”不是完整防护,更不构成对外发送的批准。

采集缺口不掩盖已经看到的高风险证据

同一主题是否上升,需要可比窗口、明确分母和最低样本条件。比较范围应包括相同目标集合、地区、排序和纳入规则。当天覆盖不完整,就暂停整体趋势结论,同时将已取得且需要处理的具体证据交给人复核,不能因为整体数据不全而隐藏它。

建议把两类事项分到不同队列:采集缺口交给数据负责人,具体投诉证据交给业务负责人。不要将一个 HTTP 错误翻译成品牌风险,也不要拿“没有成功采到评论”写成“今天没有异常”。

一条候选告警至少写清对象、观察窗口、触发规则、评论身份及版本、证据片段、覆盖限制、负责人和处理状态。再给它稳定的事件键,避免同一窗口重跑后重复通知;评论被编辑、标注被纠正时,以修订记录更新事件,不默默覆盖旧结论。

涉及人身安全、医疗、歧视、法律争议或员工处罚等内容时,模型只能协助整理证据,最终判断和对外行动由合适的负责人完成。蓝图不自动回复评论、联系评论者或公开处理结果。

上线前用失败场景验收,而不是只看日报生成

先在获准范围验证最小采集,再走完整的“落库—重复回放—候选队列—人工处理”路径。至少覆盖以下场景:

  • 同一批次再次处理,不增加新的评论身份、不重复通知。
  • 同一 ID 出现在不同对象或平台,不错误合并。
  • 评论被编辑,保留修改证据,不新增一条评论。
  • 部分目标失败,已交付内容保留,未完成目标的进度不前移。
  • 结果为空、只有空文本、没有分页证据,分别呈现,不能统一写成零条新增。
  • 数据落库失败,游标不越过该批;恢复后能够安全重放。
  • 来源数据更正、删除要求或保存期到期,能定位并按约定处理相关标签、摘要及告警证据。

离线演示已经能检查其中的部分身份与状态规则,但不能证明持久化、采集、通知或模型分类已完成验收。正式使用前还要明确数据保留、删除、访问与导出权限;默认日志不保存完整评论、凭据或不必要的个人信息。

下一步:交付一条能被人处理的证据链

选少量获准目标、一个业务问题和一个有负责人的观察窗口。先证明每次运行能说清成功范围和缺口,再证明同一条证据不会被重复计算,最后决定哪些内容进入人工处理队列。

没有客户授权证言和可核对效果,也可以把这份设计作为蓝图审阅;但不能把它包装成“已经帮助某品牌改善了多少”。要写成实战教程,补真实端到端样本;要写成客户案例,再补客户同意、实施基线、结果和局限。这是两种不同的证据要求。