Katana 是 ProjectDiscovery 的端点发现爬虫——一个采用 Go 编写、MIT 许可的二进制工具。你给它一个目标,它就会返回 URLs 和端点,供流水线里的下一个工具继续处理。它既可以用无浏览器的 HTTP 模式抓取,也可以加上 -headless 驱动 Chromium。官方文档通常把 headless 描述为覆盖率更高的选择;而这个测试样例恰好说明,端点的“类型”和“数量”一样重要。
我搭了一个小站点,刻意放入三类截然不同的端点,并在 v1.6.1 上、使用 -d 4 测试每种模式能发现哪些内容。普通 HTML 在四种配置里都能拿到 4/4 链接,以及完整的三跳链路。真正拉开差距的,是通过 JavaScript 源码暴露的端点和运行时 DOM 变化生成的端点。
在这个样例里,headless 找到了无浏览器模式漏掉的运行时 DOM 类端点;而带 -jc 的标准模式则找到了两个 headless 运行都漏掉的 JavaScript 文件字面量。四种命令组合里,没有任何一行能同时覆盖这两类。除此之外,scope、resume 和 known-files 行为还揭示了其他几个实用边界。
Katana 到底是什么
Katana 爬虫 —— GitHub 上的 projectdiscovery/katana —— 采用 Go 编写,并使用 MIT 许可。我在 2026 年 7 月 27 日测试的是 v1.6.1;版本之所以重要,是因为下面关于覆盖率和 known-files 的发现,都属于特定构建版本的观察结果。
这里的分类标签比平时更关键。端点发现爬虫不是字段抽取器。如果你想要一个能把商品名和价格整理成结构化 JSON 的 Go 网页爬虫,Katana 根本不是那个方向——它只会告诉你 /products/1138 这个路径存在,却不会告诉你页面里有什么。这是它的设计目标;拿抽取能力来评价它,就像拿金属探测器去评估珠宝价值。
它真正擅长的是攻防安全中的侦察和自动化流水线:STDIN 进,URLs 出,再把结果接到下一个工具。也正因为如此,先把最重要的限制摆在前面——我这里的所有测量都跑在我自己写的、位于 127.0.0.1 的本地样例站点上。请只把 Katana 用在你拥有或被明确授权测试的主机上,除此之外不要碰。本文不是在讨论如何绕过防御,而是在衡量某个命令究竟能枚举出多少站点端点表面。
三种模式,各自能看到什么
标准模式本质上是一个 Go HTTP 客户端。它负责请求、解析 HTML、跟踪 href,但不会启动浏览器。速度快、开销低,但对那些只有在 JavaScript 执行后才出现的内容完全看不见。
-jc(-js-crawl)是在这条无浏览器路径上再叠加一个 JavaScript 解析器。它会下载关联的 .js 文件,并从源码里提取看起来像 URL 的字符串字面量。它不执行脚本,只是读取源码。还有一个 -jsl(jsluice),README 里说它是更重、内存占用更高的解析器——我没测它,所以不能评价它会不会改变覆盖结果。

-headless 则会驱动 Chromium 并执行页面脚本。在这个样例里,它是我测试的所有 Katana 模式中,唯一能恢复那个由片段拼接、并注入到运行时 DOM 中的路径的模式。这个结果并不代表所有解析器或未来的 Katana 模式都会有同样表现。
接下来是 scope 模型,这部分我建议在把任何东西塞进生产环境前先彻底理解。
| 标志 | 控制什么 | 取值 / 默认值 |
|---|---|---|
-fs(field scope) | 参与抓取的主机范围 | dn、rdn、fqdn,或自定义正则——默认是 rdn |
-cs 和 -cos | 在该主机范围内进一步筛选 URL 的正则 | — |
-kf | 已知文件:robots.txt 和 sitemap.xml | README 里说它至少需要深度 3 |
-d | 深度 | 默认 3 |
-resume | 接着中断处继续抓取 | — |
这个顺序不是装饰性的:它决定了一个主机正则到底是在扩大抓取范围,还是会悄悄把抓取结果清空。
环境搭建:一个二进制,一个例外
有三种接入方式,其中只有一种需要你提前准备工具链:
| 安装方式 | 前置条件 |
|---|---|
从源码安装:go install github.com/projectdiscovery/katana/cmd/katana@latest | 官方要求 Go 1.25 或更高版本 |
| Release 页面上的预编译二进制 | 不需要工具链 |
| Docker 镜像 | 不需要工具链 |
我这边安装后位于 ~/go/bin/katana,每次运行都显示 Current version: v1.6.1。到这里为止,还是标准而讨喜的 Go 体验:单文件、无需运行时。
例外出在 headless 模式上,因为浏览器是二进制之外的另一个前置条件:
-headless 的运行位置 | 需要什么 |
|---|---|
| 我的机器 | Katana 自动检测到已安装的 Chromium;我没有记录浏览器构建版本,也没有手动提供浏览器路径 |
| 项目文档里给出的裸机 Ubuntu 环境 | 在 headless 生效前,需要先执行 apt install google-chrome-stable |
| Docker 路线 | 使用 -system-chrome 运行 headless |
到了裸机服务器上,这种便利性就会消失。只要命令里出现 -headless,你就得同时预算浏览器,而不只是一个二进制。
还有一个在 CI 里尤其值得注意的小点:Katana 启动时会主动向 GitHub 发一个版本检查请求。-duc 可以禁掉它。对笔记本来说这只是噪音;对离线或限速的 runner 来说,这意味着每次运行都会多一次你并不想要的外部网络往返。我的计时测试都加了 -duc,这样数字测的是抓取本身,而不是“回家报到”的时间。
我是怎么测的
我专门设计了三类端点,就是为了把不同模式分开。所有内容都放在一个本地样例服务里,而且 ground truth 是在任何抓取开始前就写死的,所以召回率是对照固定集合来算的,而不是对照 Katana 恰好打印了什么。
- A 类——普通 HTML。
/page/a、/page/b、/page/c,外加一个三跳链路/depth/1 → /depth/2 → /depth/3。任何爬虫都应该能抓到这些。 - B 类——JavaScript 文件字面量。
/api/js-endpoint-7和/api/js-endpoint-8只作为字符串字面量存在于一个关联的/static/app.js里。如果有工具愿意读 JS,它在不启动浏览器的情况下也能看见。 - C 类——仅运行时 DOM。 一个路径在运行时由片段拼出来(
'endpoint' + (6 * 7)),再通过脚本注入到 DOM 中。字符串/runtime-only/endpoint42在服务器发送的任何字节里都不会连续出现——HTML 里没有,JS 源码里也没有。只有执行脚本,才能把它暴露出来。
另外还包括 robots.txt、一个包含两个 <loc> 端点的 sitemap.xml(这些端点在别处都不存在)、一个返回 500 的路由、一个死链,以及一个指向不同 hostname 上第二台服务器的 scope 外链接。
测量工具和样例本身一样重要:服务端会统计实际被请求过的内容,所以 scope 和 resume 的结论都基于命中事实,而不是 Katana 自己的 stdout。如果你想核对我的计算,原始运行结果都提交在了benchmark 仓库里。
很少有人量化的覆盖率分裂

在 -d 4 下,不同模式对各类端点的覆盖矩阵如下:
| 模式 | HTML 链接(A) | 深度链路(A) | JS 文件字面量(B) | 运行时 DOM(C) |
|---|---|---|---|---|
| standard | 4/4 | 3/3 | 0/2 | 未发现 |
standard -jc | 4/4 | 3/3 | 2/2 | 未发现 |
-headless | 4/4 | 3/3 | 0/2 | 发现 |
-headless -jc | 4/4 | 3/3 | 0/2 | 发现 |
把最后两列并排看,问题一下就出来了。B 类只被一种配置发现:带 -jc 的标准模式。C 类只被两种配置发现:两个 headless 运行。没有任何一行能同时命中这两列。完整矩阵可以在discovery-summary.json里看到,其中计算字段 headless_jc_covers_both 的值是 false。
实际意义就是:“直接用 headless,覆盖率会更好”这句话,对这个测试来说并不完整。headless 并没有在标准结果基础上额外带来 B 类;它恢复了 C 类,但漏掉了 B 类。要在这个样例里把所有植入端点都覆盖到,需要两次抓取再合并:
katana -u https://target.example -jc -d 4 -silent -o pass-jc.txt
katana -u https://target.example -headless -d 4 -silent -o pass-headless.txt
sort -u pass-jc.txt pass-headless.txt > endpoints.txt
-headless -jc 这一行,是我最希望 upstream 能给个解释的组合。把 JavaScript 解析器加到 headless 运行里,没有带来任何新增结果——B 类仍然是 0/2,在每次 headless 运行里都一样,包括一次新的复现。我这里只是在描述现象,并没有声称已经追到机制;我没有去给 Katana 内部埋点确认为什么浏览器路径不再贡献 JS 文件字面量。请把它当成一个可复现的观察和一个很适合提 GitHub issue 的问题,而不是一份诊断报告。(顺便一提:在 v1.6.1 的 macOS ARM 上,-hl -jc 组合能正常完成并返回 0,这一点在历史上并不总是这样。)
官方文档把 headless 描述为能提供更好覆盖率,而在这里它确实对运行时渲染类端点有帮助。可文档并没有把这种“源代码字面量 / 运行时 DOM”的分裂讲清楚,所以这张矩阵更适合被当成“请针对自己的端点类型分别测试两条路径”的理由,而不是一套放之四海而皆准的分类法。
headless 的时间成本有多高
在一台基本空闲的机器上,每种模式连续跑三次:
| 模式 | p50 | 最小值–最大值 | 均值 |
|---|---|---|---|
| standard | 13.08s | 13.07–13.17s | 13.11s |
-headless | 66.82s | 66.78–67.68s | 67.09s |
这相当于 5.1 倍 的时间差,而且区间完全没有重叠——我最慢的 standard 运行(13.17s)仍然比我最快的 headless 运行(66.78s)快了 53 秒以上(见 cost-summary.json)。这不是测量噪声。
关于这 13 秒,有个需要说明的点:我的样例里刻意包含了一个 500 路由和一个死链,而 standard 模式会在这两个地方都走完默认的 -timeout 10 重试尾部。我并没有为了让快模式“看起来更好”去调短 timeout,这意味着如果把 standard 调优,差距大概率会更大,而不是更小。
这个倍率反映的是本地容量信号,不是生产环境的预测。真实目标的延迟、失败率、脚本开销和调度情况都不同,而这个样例还包含默认 timeout 尾部。你可以拿这里测得的 5.1 倍差异来判断 headless 是否值得单独预算、单独选目标子集;然后再在代表性、且已获授权的主机上验证这个方案。
scope 保住了,但有一个标志悄悄没起作用
这次 scope 测试用了两台服务器:主机在 127.0.0.1,另一台则能通过不同端口上的 localhost 访问,并且提供了一个只有那边才存在的路径。所以,只要这个路径被命中,就能证明 scope 外主机确实被请求过,而不是只是被打印出来。
| 配置 | scope 外主机是否被请求? | 第二台服务器上的命中数 |
|---|---|---|
默认(-fs rdn) | 否 | 0 |
-fs fqdn | 否 | 0 |
-cs localhost | 否 | 0 |
| `-fs '(127.0.0.1 | localhost)'` | 是 |
scope 的保守性是好消息:默认情况下 Katana 会老老实实待在本地,只有你明确要求,它才会扩大范围。对一个会被指向别人基础设施的工具来说,这正是正确的默认行为。
最有意思的是 -cs localhost 这一行。它并没有把抓取范围扩到第二台主机——而且它还输出了零个 URL。因为 -cs 只是在 field scope 内做过滤,而 field scope 仍然是主机本身,正则什么都没匹配上,于是抓取结果不是报错,而是空集。如果你曾经写过一个 scope 正则,明明想把某个主机包含进去,却盯着空输出文件发呆,那就是这个机制在起作用(见 scope-summary.json)。要新增主机,请设 -fs。要在已有主机里进一步收窄,请用 -cs/-cos。
resume 比标志字面意思更粗粒度
README 把这个标志描述成 -resume string resume scan using resume.cfg,看起来像是会在当前工作目录里放一个文件。但事实并不是这样。在我的机器上,检查点被写到了 ~/.config/katana/resume-<xid>.cfg——这是测出来的,不是从文档里抄的,因为文档并没有写明路径。
更重要的惊喜在文件内容里。这个文件只保存了一个 InFlightUrls map,里面只有一件事:种子 URL。没有已访问集合,也没有前沿队列。于是当我在抓取三秒后用 SIGINT 中断,再继续 resume 时,发生了下面的情况:
| 运行 | 不同路径数 |
|---|---|
| 完整基线抓取 | 11 |
| 中断前已抓取 | 10 |
| resume 运行重新抓取的内容 | 全部 11 个,包括已经完成的 10 个 |
resume 最终到达了同样的端点集合,所以功能并没坏。但检查点粒度是按输入种子,不是按 URL——内存里的去重过滤器并没有持久化,所以对单种子的恢复会把这个种子从头重新爬一遍(见 resume-summary.json)。如果你给 Katana 喂的是 500 个主机的列表,resume 理论上可以帮你跳过那些已经完整跑完的主机;这种多种子行为是状态存储方式推出来的,但我这里只测了单种子场景。如果你正在处理一个极大的站点,resume 买回来的更多是正确性,而不是时间。
known files:确实请求了,但随后又丢了

-kf all -d 3 确实请求了两个文件——robots.txt 和 sitemap.xml 都出现在服务端命中日志里——然后却只恢复了 sitemap 里 <loc> 元素列出的 2 个里 0 个 端点。召回率 0.0。
在把它归结为工具限制之前,我先试着排除是不是我自己用错了。所有变体的结果都一样:
| 尝试的变体 | 恢复的 sitemap <loc> 端点 |
|---|---|
-kf all | 0/2,召回率 0.0 |
-kf sitemapxml | 0/2,召回率 0.0 |
-kf robotstxt | 0/2,召回率 0.0 |
| 深度 3 | 0/2,召回率 0.0 |
| 深度 4 | 0/2,召回率 0.0 |
| 深度 5 | 0/2,召回率 0.0 |
额外加上 -jc | 0/2,召回率 0.0 |
直接以 /sitemap.xml 作为种子 | 0/2,召回率 0.0 |
文档里写的要求——使用 -kf,并且至少抓到 3 层深度——每次都满足了。所以这不是“漏了某个标志”的故事。
真正有用的结论得先说:在这个 IP literal 样例上,不要默认“请求了 known files”就等于 sitemap 里的 <loc> URL 也一起进了抓取。请验证召回率,或者自己把这些 URL 提取出来并作为种子喂给 Katana。
v1.6.1 的代码路径与这个观察结果是吻合的,但我在运行时没有给它打点。根据 v1.6.1 的 sitemapxml.go,NewNavigationRequestURLFromResponse 会基于响应构造 <loc> 导航请求,但没有带上已填充的 RootHostname。随后这个请求会进入 ValidateScope;在 v1.6.1 的 scope.go 里,IP literal 分支会拿 URL 主机和那个空的 root 去比较,于是可能把它拒掉。单独的 -fs '(127.0.0.1|localhost)' 命令在另一个 scope 测试里走了不同的分支,所以它更像是源代码预期中的“救援路径”,而不是我在 -kf 场景下实测出来的 workaround。可惜当时这个主机上的 known-files 客户端偶发拨号失败,导致验证尝试被挡住了;因此最终结果仍然是 0/2。
如果是我在生产环境里会怎么做,在有人确认这个标志之前:先自己抓 sitemap,提取 <loc> URLs,然后把它们直接作为种子交给 Katana。两行 shell 就够,而且完全不依赖 scope 验证。
有一件事则完全符合预期,而且值得单独说一句:500 路由和死链都被请求了、被记录了,然后被顺利跳过。所有无浏览器运行最后都以 return code 0 结束。一个会在第一个坏响应上直接死掉的爬虫,没人敢无人值守地跑;Katana 不会这样。
上线前,先做一次针对目标的覆盖检查
这个样例矩阵最大的价值,是可以拿来测试你自己的、且已获授权的目标。运行 Katana 之前,先定义端点类别:普通链接、关联脚本里的字面量、只有执行后才出现的路由、以及 known-file 条目,这四类是很合理的起点。每一类都保留一小份 ground truth 样本。没有这份先验列表时,一个更大的 stdout 文件很容易看起来像覆盖率更高,即使某一类其实已经消失了。
先把无浏览器路径和 headless 路径作为两次独立测量来跑。保存准确的命令、Katana 版本、浏览器构建版本、return code 和输出。不要只比行数,要对端点集合做归一化和 diff。如果标准模式加 -jc 在你的样本上没有带来任何独有结果,那么 headless-only 策略可能就够了;如果结果集像这里一样分叉,那就把两次抓取分开,收集后再合并。不要默认把两个标志都加在一个命令里,就等于覆盖了两者并集——除非针对你的目标做过 diff 证明。
scope 验证要拿 Katana 输出之外的证据。给一个本应被排除的主机放一个 canary URL,并查看那台服务器的请求日志。如果你的抓取本来就应该扩到第二台主机,也要对它做一次测试。这里的 -cs localhost 之所以输出为空,是因为 content-scope 过滤并没有扩展 field scope;而自定义的 -fs '(127.0.0.1|localhost)' 确实联系到了第二台服务器。记录精确的正则表达式很重要,因为只改一个字符,改变的可能是正则本身,而不只是显示效果。
resume 和 known files 要和发现召回分开测试。resume 方面,先让一个代表性种子跑到几页后中断,保存生成的检查点路径,再数一数有多少已完成 URL 被重新抓了一遍。-kf 方面,要确认 robots/sitemap 确实被请求了,同时还要确认植入的 <loc> URLs 有没有真的被加入调度。这是两件不同的断言。在这个样例里,文件确实被请求了,但 sitemap 里的两个端点都没有出现,因此必须同时看请求日志和端点输出,才能看清边界。
最后,在一台空闲机器上做顺序运行,建立本地成本基线,然后再在代表性主机上重复测试。保存最小值、最大值和中位数,不要只记一个倍率。这里的 5.1 倍包含了样例中的失败和 timeout 行为;它告诉你 headless 值不值得单独预算,而不是告诉你生产环境的清点要花多久。
优点与缺点
优点:
- 在所有模式下,普通 HTML 的召回都很完整——4/4 链接和完整的 3/3 深度链路,无需额外配置。
-jc在无浏览器模式下确实有效:能从关联 JS 文件里的字符串字面量中恢复 2/2 个端点,而且没有浏览器成本。-headless是唯一能发现运行时拼接端点的模式——这种端点按构造方式对源码解析天然不可见。- scope 默认值很保守。默认、
-fs fqdn或-cs都不会请求 scope 外主机。 - 单个 Go 二进制、MIT 许可、预编译构建和 Docker 镜像,I/O 也很适合流水线。
- 对失败很稳:500 和死链不会把抓取打断。
缺点:
- 没有任何单次运行能同时覆盖 JS 文件端点和运行时 DOM 端点。要拿全覆盖,必须两次运行并合并结果。
- 在
-headless下,-jc没带来任何额外价值——每次 headless 运行里,B 类都是 0/2。 - headless 的墙钟时间成本高 5.1 倍(p50 为 66.82s 对 13.08s,区间完全不重叠)。
-resume会把同一个 seed 下已经完成的页面再爬一遍。它恢复的是端点集合,不是你已经花掉的时间。- known files 确实请求了 robots.txt 和 sitemap.xml,但面对 IP 目标时,sitemap
<loc>端点的召回是 0/2。 - headless 还隐含要求机器上已经装了 Chromium;“一个二进制搞定”的故事,到浏览器这一步就结束了。
- 只能做发现,不能做结构化抽取、内容转换或字段建模。
以下内容没有测试,因此也不在这些数字的覆盖范围内:-jsluice、在 -d 1/-d 2 下的专门深度截断测试、多种子 resume、自动表单填充,以及任何真正 JS 重度依赖或有防护的生产站点。所有数字都来自一台机器(macOS arm64)上的本地样例。
适合谁,不适合谁
如果你的工作是为你有权限触碰的基础设施生成端点清单,那 Katana 的流水线形态是对口的:STDIN/STDOUT 管道、可分发二进制,以及无浏览器和带浏览器两种模式。对于这个样例里的植入类端点,两次抓取再合并是必要的;你的目标是否也需要两次抓取,得先用代表性页面验证。
如果你想要的是数据,而不是地址,那就别选它。Katana 永远不会给你一张商品表;它只会给你商品可能所在的 URL,真正的抽取要交给别的工具。若你希望一个命令就能一次完成,也别选它——两段式合并在流水线里没问题,但在交互提示符下就会很烦。还有,如果你的枚举依赖 sitemap <loc> 端点,而目标又是 IP,请先验证实际拿到了什么再信输出,因为在我的样例里,这条路径什么都没带回来。
替代方案,以及抽取边界
Katana 是免费、MIT 许可、可自托管的。它把发现、模式选择、浏览器部署和结果合并,全部留在你的边界内处理。
在开源工具里,更有意义的比较方式不是按语言,而是按任务。Colly 是另一个 Go 选项,但它本质上是一个库,需要你自己写回调,而且根本不渲染 JavaScript。Crawl4AI 会运行真实浏览器,并为 LLM 流水线输出 Markdown,输出目标完全不同。如果你正同时评估多个工具,我们的开源爬虫综述把这些类别并排列得很清楚。
披露: Thunderbit 是发布方自己的产品,在这次 Katana 样例里没有测试。它属于后续的托管抽取类别,把页面转换成文本或结构化记录,而不是枚举授权目标的端点表面。某些流程可能会同时用到这两类工具,但这篇评测只提供了 Katana 发现行为的证据。
结论
如果你的交付物是一个端点清单,而且目标是你有权限抓取的站点,并且你还能针对这些目标验证模式覆盖率,那就用 Katana。在这个样例里,标准 -jc 恢复了植入的 JavaScript 文件字面量,而 headless 恢复了运行时 DOM 端点;Katana 测试中的无浏览器模式没有恢复那个运行时路径。默认 scope 也确保第二台主机没有被请求,而无浏览器运行则继续跨过了 500 和死链。
需要注意的操作性限制是:混合端点类型时,可能要靠两次抓取再合并;headless 在本地大约慢了五倍;单种子 resume 会把已完成路径重新抓一遍;面对 IP 目标时,known-files 的召回率是 0/2。以上都是 v1.6.1 在这个样例中的结果,不是对所有网站的承诺。但它们足以定义一套生产环境评估时应该复测的检查项。
试用 Thunderbit,进行网页数据提取 Get Started Free
常见问题
Katana 的 resume 文件存在哪,恢复时会跳过我已经爬过的页面吗?
检查点实际落在了 ~/.config/katana/resume-<xid>.cfg,而不是标志帮助文本暗示的工作目录里的 resume.cfg。不会跳过已完成页面:文件里只保存了处于进行中的 seed URL,所以对单种子抓取的恢复,会把基线里的 11 条路径全部重新抓一遍,其中包括已经完成的 10 条。最终端点集合是一样的,只是节省不了那段时间。
为什么 -kf all 请求了我的 sitemap.xml,却没有抓里面的 URL?
对 IP 目标来说,这个结果更像是 scope 验证边界导致的,而不是参数用错。根据 v1.6.1 的代码,Katana 的 sitemap 解析器会在构造每个 <loc> 请求时丢失 root hostname,随后针对 IP literal 主机的 DNS scope 检查可能把这个 URL 拒掉;不过我没有在运行时打点去确认这个机制。这个现象在我试过的所有标志、深度和种子变化里都保持 0 召回。自定义的 -fs '(127.0.0.1|localhost)' 主机正则会走不同的验证分支,源码也确实预期它能救场——但我没能在自己的机器上把它和 -kf 组合确认下来,所以请把这点视为未经验证。就今天而言,我会更信任的方法是:自己提取 <loc> URLs,再把它们作为种子喂给 Katana。
汇报 Katana 覆盖率测试时,我应该保留哪些信息?
记录准确的 Katana 版本和命令,包括逐字节一致的 -fs 表达式;在运行前先定义端点类别;除了 stdout 之外,还要保留服务端命中日志;并且把实测行为和基于源码的假设分开。对于 headless 运行,还要记录浏览器构建版本——这次测试没有记录,这会影响复现。
我该用 -jc、-headless,还是两个都用?
按你需要的端点类别来选。在这个样例里,标准 -jc 找到了存放在 JavaScript 文件里的字面量,而 headless 找到了插入运行时 DOM 的端点。单独任何一种模式都不能覆盖全部类别,所以对混合目标来说,最稳妥的做法是两次运行后去重合并。
一个失败 URL 会不会把整个抓取打断? 在这个受控测试里不会。Katana 在 500 响应和死链之后都继续往下跑,并且仍然返回了其他可达路径。但这不能代替生产环境的错误统计:你还是要保留失败请求日志,并定义可接受的失败率,避免把部分成功误认为完整覆盖。


