背景与痛点
做海外餐饮的朋友都知道,每天盯着 Uber Eats、Yelp 这些平台的评论是件很头疼的事。评论分散在不同平台,要一个个手动去看,漏掉差评就可能错过问题信号。
更麻烦的是,这些平台基本上没有开放的评论 API:
- Uber Eats 没有公开的商家评论 API
- Yelp 虽然有官方 API,但免费版只能拿到 3 条评价,付费版只有 7 条,价格还不便宜
- DoorDash 同样没有开放评论接口
面对这个问题,常见的思路有几种:
人工查看:效率低,容易漏,不适合多店管理。
RPA 工具(如 UiPath、Playwright):可以模拟浏览器操作,但部署复杂,维护成本高,脚本也容易随页面改版失效。
Claude Code / Codex 等 AI 编程工具:能写出可用的爬虫脚本,但每次调用都有 Token 消耗,长期下来有一定成本,而且还是需要人来维护运行环境。
我们选择了另一条路:用 n8n 搭建自动化工作流。
n8n 是一个可以自部署的开源自动化工具,界面可视化,节点式操作,不需要写大量代码就能跑起来。关键是它可以部署在自己的服务器上(比如 Zeabur),定时运行,完全无人值守。
这篇文章就来完整讲一下这个流程是怎么搭的:每天早上自动抓取 Uber Eats 和 Yelp 前一天的评论,同步写入飞书多维表格,如果平台认证失效还会自动发飞书告警。
整体思路
在动手之前,先理解一下核心思路,这样看后面的配置步骤会更清晰。
1.Uber Eats 的方案:虽然没有公开 API,但商家后台(merchants.ubereats.com)的前端页面是通过内部 GraphQL 接口加载数据的。我们通过浏览器开发者工具抓包,找到这个接口的地址、请求参数和认证方式,然后直接在 n8n 里模拟这个请求。
2.Yelp 的方案:Yelp for Business 的评价页面是服务端渲染(SSR)的,评论数据直接写在返回的 HTML 里。具体来说,Yelp 用的是 Apollo GraphQL 客户端,会把数据缓存在页面的一个 标签里。我们用 GET 请求拿到整个 HTML 页面,然后在 Code 节点里解析出评论数据。
认证方式:两个平台都不需要反复登录,只需要把浏览器里的登录态 Cookie 复制出来,放进 n8n 的 HTTP 请求头里。平台看到这些 Cookie,就认为是你本人在操作,正常返回数据。Cookie 一般几个月才过期一次,维护成本很低。
前置准备
开始配置之前,需要准备以下几样东西:
1.n8n 运行环境 可以用 Zeabur、Railway 等平台一键部署 n8n,也可以自己用 Docker 部署。本文假设你已经有一个可以访问的 n8n 实例。
2.飞书应用凭据 在飞书开放平台(open.feishu.cn)创建一个企业自建应用,开通多维表格的读写权限,记下 app_id 和 app_secret。
3.飞书多维表格 创建一张多维表格,建好以下字段(名称要和配置完全一致):
| 字段名 | 类型 |
|---|---|
| 平台 | 文本 |
| 店铺名称 | 文本 |
| 评论人 | 文本 |
| 评论内容 | 文本 |
| 评价星级 | 文本 |
| 评论时间 | 文本 |
记下多维表格的 AppToken(URL 里 /base/ 后面的字符串)和 TableID(在表格右上角「...」→「API」里可以复制)。
1.飞书机器人 Webhook 在飞书群里添加一个自定义机器人,复制它的 Webhook URL,用于收告警通知。
2.Chrome 插件 Cookie-Editor 在 Chrome 应用商店搜索安装「Cookie-Editor」,后面用它来导出登录 Cookie。
获取平台 Cookie(关键步骤)
这是整个方案最核心的一步,也是很多人不知道怎么做的地方。
理解 Cookie 的作用
当你在浏览器里登录 Uber Eats 或 Yelp 后,网站会在你的浏览器里存储一组 Cookie。之后每次你访问这个网站,浏览器都自动带上这些 Cookie,网站通过验证 Cookie 来确认你的登录状态。
我们要做的,就是把这些 Cookie 复制出来,放进 n8n 的 HTTP 请求里。n8n 的请求带上这些 Cookie,平台服务器就以为是你本人在操作,正常返回数据。
获取 Uber Eats Cookie
1. 用 Chrome 打开并登录 https://merchants.ubereats.com 2. 确认已经能看到店铺后台的评价页面 3. 点击 Cookie-Editor 插件图标,选择「Export」→「Export as JSON」 4. 把导出的 JSON 内容保存好
从导出的 JSON 里,把所有 Cookie 拼成一行字符串,格式是 name1=value1; name2=value2; ...。重点关注以下几个:
sid:主要的 session 认证 Cookie,最重要jwt-session:JWT 令牌,有效期约 7 天jwt-session-uem:商家后台专用 JWT_cc:注意这个值,它同时是x-csrf-token请求头的值
特别说明:Uber Eats 的 GraphQL 接口需要一个 x-csrf-token 请求头。通过实际抓包发现,这个值其实就是字母 x——Uber Eats 只检查这个 Header 是否存在,不验证具体内容。这是他们 CSRF 防护机制的一个特点。
获取 Yelp Cookie
1. 用 Chrome 打开并登录 https://biz.yelp.com 2. 导航到你的店铺评价页面 3. 同样用 Cookie-Editor 导出
Yelp 的关键 Cookie 是:
biz_session:商家后台 session,最重要,有效期约 6 个月bse:session 令牌datadome:DataDome 反爬虫验证 Cookie,必须带上,缺少它会被拦截
把这几个拼成 Cookie 字符串即可,完整的建议格式:
biz_session=xxx; bse=xxx; datadome=xxx; bsi=xxx; yuc=xxx; wdi=xxx; zss=xxx
找到各平台的店铺 ID
Uber Eats:登录商家后台后,URL 里会有一串 UUID,类似 5186476b-661a-588b-b8bd-6eee1624e08a,这就是店铺 UUID。如果有多家店,切换门店后 URL 里的 UUID 就会变,逐一记录下来。
Yelp:在 biz.yelp.com 后台,切换到不同店铺后,点击左侧「Reviews」菜单,URL 会变成 https://biz.yelp.com/r2r/xxxx,r2r/ 后面的字符串就是该店铺的 Business ID。
---
n8n 工作流完整配置
下面逐个节点说明配置方法。整个工作流的数据流向是:
Daily Trigger(定时触发)
→ Dates Setup(计算昨天日期)
→ Uber Eats Fetch(调用 GraphQL 接口)
→ Yelp Fetch YGF → Yelp Fetch Fish → Yelp Fetch Ten(依次获取三家店 HTML)
→ Parse Reviews(解析所有评论,检测认证是否失效)
→ Has Errors?(分支:有错误 → 飞书告警;无错误 → 继续)
→ Has Reviews?(分支:有评论 → 写入飞书;无评论 → 结束)
→ Feishu Auth(获取飞书访问令牌)
→ Expand Reviews(将评论数组展开为单条)
→ Split Reviews(逐条循环处理)
→ Feishu Write(写入多维表格)
配置后的流程图如下

我们挨个讲讲每个节点的类型、作用和具体配置
节点 1:Daily Trigger(定时触发)
类型:Cron
作用:每天早上 9 点自动触发整个工作流。
配置:
- Trigger Times → Mode: Every Day
- Hour: 9,Minute: 0
这个节点不需要其他配置。
节点 2:Dates Setup(计算日期)
类型:Code
作用:计算「昨天」的日期,生成 Uber Eats 接口需要的 ISO 格式时间范围,以及 Yelp 页面上显示的日期格式。同时在这里构建 Uber Eats 的完整请求体,避免在 HTTP 节点里写复杂表达式。
为什么要单独做一个 Code 节点来构建请求体:n8n 的 HTTP 节点支持用 {{ }} 模板表达式写请求体,但 GraphQL 的 query 字符串里包含大量 { 和 },会和 n8n 的模板语法冲突,导致请求被截断。把请求体构建放在 Code 节点里用原生 JavaScript 处理,就没有这个问题。
代码:
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const pad = n => String(n).padStart(2, '0');
const y = yesterday.getFullYear();
const m = yesterday.getMonth() + 1;
const d = yesterday.getDate();
// 洛杉矶时区:夏令时(3月到11月)用 -07:00,冬令时用 -08:00
const tzOffset = '-07:00';
const startISO = `${y}-${pad(m)}-${pad(d)}T00:00:00${tzOffset}`;
const endISO = `${y}-${pad(m)}-${pad(d)}T23:59:59${tzOffset}`;
const dateStr = `${y}-${pad(m)}-${pad(d)}`;
const yelpDateStr = `${m}/${d}/${String(y).slice(-2)}`;
const ueBodyObj = {
operationName: "EaterReviews",
variables: {
includeObjection: false,
includePublicReplies: false,
restaurantUUIDs: [
"你的店铺UUID_1",
"你的店铺UUID_2",
"你的店铺UUID_3"
],
lastTimestamp: null,
lastWorkflowUUID: null,
filters: {
starRating: null,
tagsValue: null,
dateRange: { start: startISO, end: endISO },
comments: null,
reply: null
},
limit: 50,
filterAIGeneratedTags: true
},
query: "fragment eaterReview on EaterReview {\n uuid\n timestamp\n rating\n comment\n tags\n eater {\n uuid\n name\n profileURL\n __typename\n }\n eaterTotalOrders\n order {\n workflowUUID\n deliveredAt\n orderTotal\n currencyCode\n appVariant\n restaurant {\n uuid\n name\n __typename\n }\n __typename\n }\n isReplyScheduled\n reply {\n uuid\n moderationStatus @include(if: $includePublicReplies)\n moderationDecisionReason @include(if: $includePublicReplies)\n promotion {\n uuid\n flatValue\n __typename\n }\n __typename\n }\n objection @include(if: $includeObjection) {\n objectionUuid\n status\n submittedAt\n __typename\n }\n __typename\n}\n\nquery EaterReviews($restaurantUUIDs: [ID!]!, $limit: Int, $lastTimestamp: String, $lastWorkflowUUID: String, $filters: EaterReviewFilterInput, $filterAIGeneratedTags: Boolean, $includeObjection: Boolean = false, $includePublicReplies: Boolean = false) {\n eaterReviews(\n restaurantUUIDs: $restaurantUUIDs\n limit: $limit\n lastTimestamp: $lastTimestamp\n lastWorkflowUUID: $lastWorkflowUUID\n filters: $filters\n filterAIGeneratedTags: $filterAIGeneratedTags\n ) {\n ...eaterReview\n __typename\n }\n}\n"
};
return [{ json: { startISO, endISO, dateStr, yelpDateStr, ueBodyObj } }];
代码逻辑说明:
代码分两部分。第一部分是日期计算:获取当前时间,往前推一天得到「昨天」,然后分别生成三种格式——startISO/endISO 是 Uber Eats GraphQL 接口要求的 ISO 8601 格式时间范围(带时区偏移,精确到当天 0 点和 23:59),dateStr 是 YYYY-MM-DD 格式用于过滤评论,yelpDateStr 是 M/D/YY 格式用于匹配 Yelp 页面上显示的日期。
第二部分是构建 ueBodyObj:这是一个完整的 GraphQL 请求体对象,包含操作名(EaterReviews)、变量(店铺 UUID 列表、日期范围等过滤条件)和查询语句。把它作为 JavaScript 对象而不是字符串来准备,是为了让 n8n HTTP 节点能直接用 {{ $json.ueBodyObj }} 引用,避免在 HTTP 节点里手写 JSON 时因 {{ }} 模板冲突导致 query 字符串被截断的问题。
最后 return 语句将所有计算结果打包成一个 item 输出,供下游所有节点按名称引用。
注意:restaurantUUIDs 数组里填入你所有店铺的 UUID,Uber Eats 一次请求就能返回所有店的评论。
节点 3:Uber Eats Fetch(获取 Uber Eats 评论)
类型:HTTP Request
作用:用发现的内部 GraphQL 接口,以已登录用户的身份获取评论数据。
关于如何找到这个接口:打开 Uber Eats 商家后台评价页面,按 F12 打开开发者工具,切到 Network 面板,在筛选栏里输入「graphql」,然后切换一下日期筛选器,就能看到一个 POST 请求。点开这个请求,在 Payload 标签里就能看到 operationName: "EaterReviews",这就是抓取评论的接口。
配置:
- Method: POST
- URL:
https://merchants.ubereats.com/manager/graphql - Headers(添加以下 4 个):
- Content-Type = application/json - x-csrf-token = x(就是字母 x,Uber Eats 只检查此 Header 是否存在) - Cookie = 你从浏览器导出的完整 Cookie 字符串 - User-Agent = Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36
- Send Body: 开启
- Body Content Type: JSON
- Specify Body: Using JSON
- JSON:
={{ $json.ueBodyObj }}(引用 Dates Setup 节点准备好的请求体) - 勾选「Continue On Fail」(让后续节点能检测并报告此节点的失败)
节点 4、5、6:Yelp Fetch YGF / Fish / Ten(获取 Yelp 评论页面)
类型:HTTP Request,(节点名字自己起,我的名称是店铺的简称)
作用:模拟已登录的浏览器,GET 请求 Yelp 商家后台的评论页面,拿到包含完整评论数据的 HTML。
为什么用 GET 而不是 API:Yelp 的评价页面是服务端渲染的,评论数据直接嵌在 HTML 里,不需要额外的 API 调用。
三个节点的配置方式完全相同,只有 URL 不同:
以 YGF Malatang 为例:
- Method: GET
- URL:
https://biz.yelp.com/r2r/你的店铺BusinessID - Headers(添加以下 3 个):
- Cookie = 你从 Yelp 导出的 Cookie 字符串 - User-Agent = Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36 - Accept = text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
- Options → Response → Response Format: Text(必须设成 Text,否则 n8n 会尝试把 HTML 当 JSON 解析然后报错)
- 勾选「Continue On Fail」
三个 Yelp 节点串联:Yelp Fetch YGF → Yelp Fetch Fish → Yelp Fetch Ten
串联而不是并联的原因是 n8n 对并联分支的合并支持有限,串联更稳定。每个节点只负责发自己的 GET 请求,不依赖上一个节点的输出内容,所以串联不影响结果。
节点 7:Parse Reviews(解析评论数据)
类型:Code
作用:从各平台的返回数据里提取昨天的评论,检测认证是否失效,把所有评论统一格式化后输出。
关于 Yelp HTML 的解析思路:Yelp 页面里有一个体积约 150KB 的 标签,里面存着页面所有数据的 Apollo GraphQL 缓存。这个 JSON 对象里,每条评论以 Review:xxxid 为 key,用户信息以 User:xxxid 为 key。日期信息藏在 Review.createdAt.localDateTime({"forBusiness":"..."}) 这样的嵌套路径里。解析时要注意:localDateTime 字段不在 Review 对象的顶层,而是在 createdAt 这个子对象里面。
代码:
const dates = $('Dates Setup').first().json;
const yesterday = dates.dateStr; // "2026-06-08"
const errors = [];
const reviews = [];
// ── Uber Eats 解析 ─────────────────────────────────────────
try {
const ueJson = $('Uber Eats Fetch').first().json;
const ueReviews = ueJson && ueJson.data && ueJson.data.eaterReviews;
if (!Array.isArray(ueReviews)) {
errors.push("❌ Uber Eats Cookie/Token 已过期,请重新获取");
} else {
const storeMap = {
"你的店铺UUID_1": "店铺1名称",
"你的店铺UUID_2": "店铺2名称",
"你的店铺UUID_3": "店铺3名称"
};
for (const r of ueReviews) {
const rDate = r.timestamp ? r.timestamp.substring(0, 10) : "";
if (rDate !== yesterday) continue;
reviews.push({
"平台": "Uber Eats",
"店铺名称": storeMap[r.restaurantUUID] || r.restaurantUUID,
"评论人": (r.eater && r.eater.name) ? r.eater.name : "匿名用户",
"评论内容": r.comment || "",
"评价星级": r.rating + " star rating",
"评论时间": r.timestamp ? r.timestamp.replace("T", " ").substring(0, 19) : yesterday
});
}
}
} catch(e) {
errors.push("❌ Uber Eats 解析异常: " + e.message);
}
// ── Yelp 解析工具函数 ──────────────────────────────────────
function parseYelpHTML(html, storeName) {
if (!html || html.length < 1000 || html.indexOf("/login?return_url") > -1) {
return { error: "❌ Yelp [" + storeName + "] Cookie 已过期", reviews: [] };
}
try {
// 找最大的 application/json script 标签(Apollo Cache)
const scriptRe = /<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/g;
let apolloCache = null, maxLen = 0, m;
while ((m = scriptRe.exec(html)) !== null) {
if (m[1].length > maxLen) { maxLen = m[1].length; apolloCache = m[1]; }
}
if (!apolloCache) return { error: "❌ Yelp [" + storeName + "] 页面结构变化", reviews: [] };
// HTML 实体解码
const decoded = apolloCache
.replace(/"/g, '"').replace(/&/g, '&')
.replace(/</g, '<').replace(/>/g, '>')
.replace(/<!--/g, '').replace(/-->/g, '').trim();
const cache = JSON.parse(decoded);
// 建立 User 名称映射
const userMap = {};
for (const [key, val] of Object.entries(cache)) {
if (key.startsWith('User:') && val && val.displayName) {
userMap[key] = val.displayName;
}
}
// 遍历所有 Review:xxx 对象
const result = [];
for (const [key, val] of Object.entries(cache)) {
if (!key.startsWith('Review:') || !val || !val.rating || val.isInactive) continue;
// 关键:日期在 createdAt 的子字段里,不在 Review 顶层
let reviewDate = "", reviewTime = "";
const createdAt = val.createdAt;
if (createdAt && typeof createdAt === 'object') {
for (const [k, v] of Object.entries(createdAt)) {
if (k.startsWith('localDateTime') && typeof v === 'string') {
reviewDate = v.substring(0, 10);
reviewTime = v.replace("T", " ").substring(0, 19);
break;
}
}
}
if (reviewDate !== yesterday) continue;
const authorRef = val.author && val.author.__ref;
const reviewer = authorRef ? (userMap[authorRef] || "匿名用户") : "匿名用户";
const comment = (val.text && val.text.full) ? val.text.full : "";
result.push({
"平台": "Yelp",
"店铺名称": storeName,
"评论人": reviewer,
"评论内容": comment,
"评价星级": val.rating + " star rating",
"评论时间": reviewTime
});
}
return { error: null, reviews: result };
} catch(e) {
return { error: "❌ Yelp [" + storeName + "] 解析异常: " + e.message, reviews: [] };
}
}
// ── Yelp 三家店依次解析 ────────────────────────────────────
const yelpStores = [
{ nodeName: "Yelp Fetch YGF", storeName: "YGF Malatang" },
{ nodeName: "Yelp Fetch Fish", storeName: "Fish With You Ktown" },
{ nodeName: "Yelp Fetch Ten", storeName: "Ten Seconds Yunnan Rice Noodles" }
];
for (const store of yelpStores) {
try {
const html = $(store.nodeName).first().json.data || "";
const { error, reviews: storeReviews } = parseYelpHTML(html, store.storeName);
if (error) errors.push(error);
reviews.push(...storeReviews);
} catch(e) {
errors.push("❌ Yelp [" + store.storeName + "] 节点访问异常: " + e.message);
}
}
return [{
json: {
hasErrors: errors.length > 0,
errorMsg: errors.join("\n"),
hasReviews: reviews.length > 0,
reviewCount: reviews.length,
reviews: reviews
}
}];
代码逻辑说明:
整体分为三个部分。
第一部分(Uber Eats 解析):通过 $('Uber Eats Fetch').first().json 取到上游 HTTP 节点的返回数据,检查 data.eaterReviews 是否是数组——如果不是,说明请求失败(Cookie 失效或接口报错),把错误信息写入 errors 数组;如果是数组,就遍历每条评论,用 timestamp.substring(0, 10) 截取日期部分与「昨天」对比,只保留昨天的评论,并按统一格式(平台、店铺名称、评论人、评论内容、评价星级、评论时间)推入 reviews 数组。
第二部分(parseYelpHTML 函数):接收一段 HTML 字符串和店铺名称,返回 { error, reviews }。函数先做安全检查(长度是否正常、是否包含登录跳转),然后用正则找到页面中最大的 标签——这是 Yelp 页面嵌入的 Apollo GraphQL 缓存,体积约 150KB,包含了页面上所有评论的结构化数据。提取出来之后做 HTML 实体解码(把 " 还原成 " 等),再用 JSON.parse 解析。解析后遍历所有 key,把 User:xxx 开头的条目建成用户名映射表,把 Review:xxx 开头的条目逐一处理:从 val.createdAt 这个子对象里找 localDateTime 开头的字段(这是日期的实际存储位置,不在 Review 顶层),提取日期并与昨天对比,匹配的评论按统一格式收集。
第三部分(循环调用):定义三家店的配置数组,依次对每个 Yelp Fetch 节点的 HTML 输出调用 parseYelpHTML,把结果合并到全局 reviews 数组。
最后将汇总结果封装为一个 item 输出:hasErrors 标记是否有错误,reviews 是当天所有评论的列表,供下游节点使用。
节点 8:Has Errors?(错误检测分支)
类型:IF
作用:判断上游是否有认证失败或解析错误。有错误走 True Branch(发告警),无错误走 False Branch(继续写入)。
配置:
- Conditions → Boolean
- Value 1:
{{ $json.hasErrors }} - Operation: Equal
- Value 2: toggle 开启(ON = true)
当 hasErrors 为 true 时条件成立,走 True Branch;为 false 时走 False Branch。
注意:测试时不要只执行这个单独节点,因为它的输出窗口可能显示的是上次运行的缓存数据(会有 ⚠️ 图标提示)。要验证路由是否正确,需要从 Daily Trigger 开始运行完整流程,或者临时改成 Manual Trigger 触发。
节点 9:Feishu Alert(飞书告警通知)
类型:HTTP Request
作用:当有平台 Cookie 失效时,通过飞书机器人发送告警消息。这个节点连接在 Has Errors? 的 True Branch 上。
配置:
- Method: POST
- URL:
https://open.feishu.cn/open-apis/bot/v2/hook/你的WebhookToken - Send Body: 开启
- Body Content Type: JSON
- JSON:
{
"msg_type": "text",
"content": {
"text": "⚠️ 外卖评价监控告警\n\n{{ $json.errorMsg }}\n\n请重新导出 Cookie 并更新 n8n 配置。"
}
}
节点 10:Has Reviews?(是否有评论分支)
类型:IF
作用:判断昨天是否有新评论。有评论才执行写入操作,没有则正常结束,避免无意义的 API 调用。这个节点连接在 Has Errors? 的 False Branch 上。
配置:
- Conditions → Boolean
- Value 1:
{{ $json.hasReviews }} - Operation: Equal
- Value 2: toggle 开启(ON = true)
True Branch → Feishu Auth(有评论,继续写入) False Branch → No Reviews(无评论,结束)
节点 11:No Reviews(无评论结束)
类型:NoOp(空操作)
作用:当昨天没有新评论时,作为流程的正常结束点。不需要任何配置。
节点 12:Feishu Auth(获取飞书访问令牌)
类型:HTTP Request
作用:调用飞书 API 获取 tenant_access_token,有效期 2 小时,用于后续写入多维表格。
配置:
- Method: POST
- URL:
https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal(中国区) 或https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal(国际区) - Send Body: 开启
- Body Parameters(使用 Key-Value 模式):
- app_id = 你的飞书应用 App ID - app_secret = 你的飞书应用 App Secret
节点 13:Expand Reviews(展开评论列表)
类型:Code
作用:Parse Reviews 输出的是一个包含所有评论的数组,而后续的 Split Reviews 节点需要每条评论是独立的 item。这个节点把数组展开,同时把飞书 Token 附加到每条评论上。
代码:
const token = $('Feishu Auth').first().json.tenant_access_token;
const reviews = $('Parse Reviews').first().json.reviews || [];
return reviews.map(function(r) {
return { json: Object.assign({}, r, { feishu_token: token }) };
});
代码逻辑说明:
这段代码解决了一个 n8n 数据结构的问题:Parse Reviews 节点输出的是一个 item,其中 reviews 字段是包含多条评论的数组。而后续的 Split Reviews 节点要处理的是多个独立 item,每个 item 对应一条评论。
代码做了两件事:一是从 Feishu Auth 节点拿到飞书的访问令牌(tenant_access_token),这个 token 每次调用飞书 API 都要用到;二是把 Parse Reviews 输出的 reviews 数组展开,用 map 把每条评论变成一个独立的 n8n item,同时用 Object.assign 把 token 附加到每条评论里,这样后续的 Feishu Write 节点就能直接用 $json.feishu_token 拿到 token 而不需要再单独查询。
节点 14:Split Reviews(循环分批处理)
类型:Split In Batches
作用:将多条评论拆分成单条,逐条发送给飞书写入节点,确保每次只写一条记录,避免批量写入时因格式问题失败。
配置:
- Batch Size: 1(每次处理一条)
- 其余默认
连接方式:
- Split Reviews 的 Output 1(Done)→ Feishu Write
- Feishu Write → Split Reviews(形成循环)
每次 Feishu Write 写完一条,就返回 Split Reviews,Split Reviews 再取下一条,直到全部处理完。
节点 15:Feishu Write(写入飞书多维表格)
类型:HTTP Request
作用:将每条评论写入飞书多维表格的一行记录。
配置:
- Method: POST
- URL:
https://open.feishu.cn/open-apis/bitable/v1/apps/你的AppToken/tables/你的TableID/records - Headers:
- Authorization = =Bearer {{ $json.feishu_token }} - Content-Type = application/json
- Send Body: 开启
- Body Content Type: JSON
- JSON:
={{ JSON.stringify({ fields: {"平台":$json["平台"],"店铺名称":$json["店铺名称"],"评论人":$json["评论人"],"评论内容":$json["评论内容"],"评价星级":$json["评价星级"],"评论时间":$json["评论时间"]} }) }}
说明:字段名称必须和多维表格里实际创建的列名完全一致。
这里用 JSON.stringify 而不是直接写对象字面量,原因和 Dates Setup 一样:飞书的字段名是中文,直接写在 n8n 模板表达式里可能引起解析问题,用 JSON.stringify 包一层能确保输出的是标准 JSON 字符串,飞书 API 可以正确识别中文字段名。$json["平台"] 这种方括号写法是为了访问含有中文的属性名,等效于 $json.平台 但更安全。
测试与上线
配置完成后,先不要直接开启定时任务,建议按以下顺序测试:
第一步:单独测试 Uber Eats Fetch 和 Yelp Fetch 节点,确认能正常返回数据(状态码 200)。
第二步:从 Dates Setup 开始,依次手动执行每个节点,观察每一步的 Output,确认数据格式正确。
第三步:测试 Parse Reviews,看输出里 reviewCount 是否正确,hasErrors 是否为 false。
第四步:人工把 Parse Reviews 里的 dateStr 临时改成一个有评论的已知日期(比如你知道某天有评论),触发完整流程,确认数据写入飞书。
第五步:确认数据写入正确后,把 Daily Trigger 改回定时模式,激活工作流。
---
后续运维
Cookie 的有效期和更新频率
这是日常维护最主要的工作:
| Cookie | 平台 | 有效期 | 关键程度 |
|---|---|---|---|
| jwt-session | Uber Eats | 约 7 天 | 高 |
| jwt-session-uem | Uber Eats | 约 7 天 | 高 |
| sid | Uber Eats | 约 6 个月 | 高 |
| biz_session | Yelp | 约 6 个月 | 高 |
| datadome | Yelp | 约 1 年 | 高 |
Uber Eats 的 JWT Cookie 有效期较短(约一周),这是目前维护频率最高的地方。Yelp 的 Cookie 相对稳定,几个月才需要更新一次。
当流程报错或飞书收到「Cookie 已过期」告警时,按以下步骤更新:
1. 打开 Chrome,登录对应平台(Uber Eats 或 Yelp) 2. 确认能正常看到评价页面 3. 点击 Cookie-Editor 插件 → Export → Export as JSON 4. 把新的 Cookie 拼成字符串,格式:name1=value1; name2=value2; ... 5. 打开 n8n,找到对应的 HTTP 节点,更新 Cookie Header 的值 6. 保存后手动触发一次流程验证是否正常
整个过程 5 分钟内可以完成,不需要技术背景。
时区注意事项
代码里有这一行:
const tzOffset = '-07:00'; // 洛杉矶夏令时
洛杉矶(太平洋时区):
- 夏令时(每年3月第二个周日到11月第一个周日):UTC-7,填
-07:00 - 冬令时:UTC-8,填
-08:00
如果你的店铺在其他时区,或者发现日期过滤不准确,检查并修改这里的时区偏移。
Yelp 页面结构可能变化
Yelp 的 HTML 结构在大版本更新时可能改变。如果某天 Yelp 的评论突然抓不到,但 Cookie 是新的,可能是 Yelp 页面结构变了。这时需要重新分析 HTML 里的数据结构,调整 Parse Reviews 里的解析代码。发生频率较低,一年一次以内。
整体成本
- 服务器费用:n8n 部署在 Zeabur 或 Railway 上,最低配置每月约 5 美元
- 飞书费用:飞书多维表格和机器人是免费功能
- Cookie 维护:每月约 5-10 分钟的人工操作
- 平台爬取风险:只抓自己店铺、每天一次、频率极低,被封号的风险几乎可以忽略
小结
这套方案的核心思路是:用浏览器的已登录状态(Cookie)来代替 API 密钥,让服务端程序模拟人工浏览器行为。这条路在很多没有开放 API 的平台上都适用。
搭建过程中最复杂的部分是找到正确的接口地址和请求格式——Uber Eats 要通过抓包找到 GraphQL 接口,Yelp 要理解页面里 Apollo Cache 的数据结构。一旦搞清楚数据在哪、怎么拿到,剩下的 n8n 配置工作反而相对直接。
日常运维的核心就是定期更新 Cookie,用 Cookie-Editor 插件操作非常简单,团队里不懂技术的同事也能独立完成。
📋 这套评论抓取相关模板,我整理成模板了
跟着上面步骤搭要花点时间。想直接拿现成的改,加我微信备注「评论抓取」,我发你。
🤔 卡在某一步了?
搭建过程里如果哪一步卡住了,如果你照着做没跑通,或者你们公司情况不太一样,可以找我聊聊,我免费帮你看看怎么调。不合适我也会直说。
扫码加我,备注「评论抓取」领模板
想看更多这类方案,我都整理在 吨师傅工具箱 里了:客户管理、进销存、工单报修……按场景分好类,可以直接抄。


