文章拆解从品牌域名生成业务简报的抓取实现,重点是筛选页面、控制模型输入以及区分访问失败原因。通过 HTTPS、HTTP 回退代码说明,协议降级应依据错误类型决定。
在 Nakodo 中设置营销活动,只需要先填一个字段:你的域名。我们会据此读取你的网站,撰写一份 brief,产品后续的工作都以它为基础。
最简单的实现只需要六行代码:抓取首页、去掉 HTML 标签,再把文本发送给模型。我们上线的版本却接近 370 行,而且多出来的代码几乎都与抓取无关。它们处理的是:哪些内容不该发送,以及网站打不开时该怎么告诉用户。
const urls = [`https://${domain}/`, `https://www.${domain}/`, `http://${domain}/`];
很多网站只会响应其中一种地址。尝试顺序没什么值得讨论的,值得讨论的是这些失败分别意味着什么:
for (const url of urls) {
if (url.startsWith("http:") && failures.some((f) => f.problem !== "error" && f.problem !== "tls")) break;
...
}
const worth = failures.find((f) => f.problem !== "not_found") ?? failures[0];
throw new CrawlError(crawlErrorMessage(domain, worth.problem, worth.detail), worth.problem);
观察真实域名的失败情况后,我们总结出了两条规则:
明文 HTTP 是最后的补救手段,不能把它当作例行的第三次尝试。如果 https://acme.com 返回了 403,而 https://www.acme.com 请求超时,再去尝试 80 端口,也得不到任何值得据此采取行动的信息:网站在线,只是不让你访问。只有连接错误和 TLS 错误值得降级尝试,因为一个确实没有可用 HTTPS 的网站,表现出来的才是这两种错误。
只有每一次尝试都表示“找不到”,才能说网站“找不到”。如果根域名返回 ENOTFOUND,但 www 子域名可以解析、随后拒绝访问,那就是一个已经配置好的网站,不能说是域名拼错了。这时告诉客户“我们找不到 acme.com,请检查拼写”,只会让他们去检查一个本来就正确的拼写。
export type CrawlProblem = "invalid" | "not_found" | "blocked" | "private" | "timeout" | "tls" | "empty" | "error";
每种失败都对应一句能让用户知道该怎么办的提示。下面是其中几个:
case "private":
return `${domain} points to a private network address, so we can't read it.`;
case "tls":
return `${domain} has a security certificate problem, so we couldn't load it safely.`;
case "empty":
return `${domain} has almost no text we can read. It may need JavaScript to show its content.`;
把底层错误映射到这些类型,只用了一个没有任何花活的函数。其中,HTTP 状态码的分类体现了我们的取舍:
if (failure.status !== undefined) {
return [401, 403, 429, 451, 503].includes(failure.status) ? "blocked" : "error";
}
401、403、429、451 和 503 都意味着“服务器确实存在,但选择不向你提供内容”。面对这种情况,和客户沟通的方式应该与遇到 500 时不同。“private”则来自我们自己的 fetch 封装:它会拒绝访问解析到私有网络地址的域名,这值得另写一篇文章。
“empty”是最经常发挥价值的一种:
const MIN_SITE_TEXT = 300; // less readable text than this and the brief would be guesswork
一个由客户端渲染的营销网站,返回的 HTML 里可能只有四个词。没有这项检查,模型拿到这四个词,就会为一家自己一无所知的公司写出一份信心十足的 brief,而客户要再走过三个界面才会发现问题。300 个字符的门槛足够低,能让真正言简意赅的落地页通过;同时又足够高,能识别出空壳页面。
六个正则表达式,按优先级排列,每类最多选取一个页面:
const PAGE_KINDS: RegExp[] = [
/\/(about|about-us|our-story|story|who-we-are|mission)(\/|$|\.)/,
/\/(products?|shop|store|collections?|catalog|menu)(\/|$)/,
/\/(pricing|plans|price)(\/|$)/,
/\/(features?|how-it-works|solutions?|services?|use-cases?|platform)(\/|$)/,
/\/(faq|faqs|help)(\/|$)/,
/\/(shipping|delivery|international-shipping)(\/|$|-)/,
];
候选页面优先来自首页自身的链接,其次来自 sitemap。如果同一类别有多个页面匹配,就按下面的规则决定:
.sort(([pa, a], [pb, b]) =>
a.depth - b.depth || Number(b.onHome) - Number(a.onHome) || pa.length - pb.length)
层级最浅的优先,其次是首页有链接的,最后是路径最短的。这三个启发式规则,是在近似判断“人会点击哪一个”。实际使用中,起主要作用的是路径深度:/pricing 会排在 /en/business/pricing-2024 前面。
最多选五个页面。原因并不是 token 预算,而是第六个页面往往会是招聘页或 Cookie 政策页。它额外提供的那一句公司信息,价值还抵不上模型过度关注它所带来的风险。
同一个网站的每个页面,都会重复出现页头、导航、页脚和 Cookie 横幅。五个页面的原始文本,就意味着五份菜单。而当你给模型五份菜单时,它就会认为菜单很重要。
function pageText(lines: string[], seen: Set<string>, maxChars: number): string {
const out: string[] = [];
let length = 0;
for (const line of lines) {
if (seen.has(line)) continue;
seen.add(line);
if (length + line.length > maxChars) break;
out.push(line);
length += line.length + 1;
}
return out.join("\n");
}
seen 这个 Set 在整个抓取过程中共享,而且首页最先处理。因此,导航只会作为首页的一部分被读取一次,而在首页里,它确实能提供有用信息。之后的每个页面,只保留此前没有出现过的文本行。
首页的预算是 8,000 个字符,每个关键页面是 5,000 个字符。这里还有一个很容易被忽略的连带影响:这些文本会被存储下来,之后每次基于这份调研生成内容时,都会再次发送给模型。所以,一页重复的网站通用界面文本,带来的并非一次性成本,而是今后每次生成都要持续支付的成本。
如果一个页面的独有文本不足 100 个字符,就会被整个丢弃。通常,这类页面是一个满屏图片、只剩重复导航的“/products”页面。
我们也会查找 sitemap,因为网站的结构能透露一些正文无法表达的信息。候选地址首先来自 robots.txt:
const robots = await tryFetch(`${origin}/robots.txt`);
if (robots?.status === 200) {
for (const m of robots.body.matchAll(/^\s*sitemap:\s*(\S+)/gim)) candidates.push(m[1]);
}
candidates.push(`${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`);
如果拿到的是 sitemap 索引,就按子 sitemap 描述业务的可能性排序:
const sitemapPreference = (url: string) =>
/page|product|collection|categor|service/i.test(url) ? 0
: /post|blog|news|image|video|author|tag/i.test(url) ? 2 : 1;
最多读取三个子 sitemap。一家商店的产品 sitemap 能告诉你它卖什么;新闻 sitemap 只能告诉你它有个博客。
接下来,我们会汇总 URL,而不是逐条列出。按路径的第一段分组,展示规模最大的 40 个分区及其 URL 数量,再以轮询方式从各分区抽取最多 150 条路径,每轮从每个分区各取一条:
for (let i = 0; sample.length < SITEMAP_PATH_SAMPLE && lists.some((l) => i < l.length); i++) {
for (const list of lists) if (i < list.length && sample.length < SITEMAP_PATH_SAMPLE) sample.push(list[i]);
}
之所以采用轮询,而不是直接取前 150 条,是因为一个包含 4,000 个 URL、按分区排序的 sitemap,可能会让整个样本都落在 /blog/ 里。
40 个分区的上限有一个明确的来由,代码注释里记录了它:
// The biggest sections only: a site with its posts at the top level
// (/post-slug/) has a "section" per URL, thousands of lines that say
// nothing and are sent again with every proposal written from the research.
如果一个 WordPress 网站把美化后的固定链接直接放在根路径下,每篇文章都会形成一个“分区”。没有这个上限,汇总结果就会变成几千行“/some-post-title: 1 URLs”。这既是噪声,也会再次成为以后每次生成都要支付的成本。
读取 sitemap 和获取品牌 logo 都有超时限制,而且失败时都会返回 null,而不是抛出异常:
const logo = withTimeout(findLogo(home.icons), LOGO_MS, () => new Error("Logo timed out")).catch(() => null);
const sitemap = await withTimeout(readSitemap(origin), SITEMAP_MS, () => new Error("Sitemap timed out")).catch(() => null);
两者各限时八秒。一次调研因为 sitemap 响应慢就彻底失败,这样的产品体验,比悄悄省略 sitemap 汇总要差。要确保始终如此,唯一的办法就是让失败这条路径的代价足够低。
这里也刻意没有立即 await 获取 logo 的 Promise。它在读取 sitemap 之前就启动,到最后才等待结果,中间则去抓取关键页面。这样,获取 logo 就能与其他工作重叠进行,而不会额外拉长总耗时。
最终输出是一份包含各页面及其文本的列表,再加上 sitemap 汇总。这就是生成 brief 的全部输入。上面所有处理都服务于一个想法:模型为一家企业写出的内容质量,几乎在调用模型之前就已经决定了,取决于你选择把什么放到它面前。
如果你想看看最终会生成什么,介绍 Nakodo 工作方式的内容中,brief 部分从产品角度做了说明;而我们的 brief 模板指南,则对应你亲手为创作者撰写 brief 时会用到的版本。调研次数按套餐计量,从 Free 每月 3 次,到 Business 每月 150 次。定价表会把它与其他所有计量项目列在一起,因为抓取别人的网站,对我们双方来说都不是没有成本的操作。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。