Scala网络爬虫:获取、解析和解锁受保护的页面
Expert in Web Scraping Technologies
简要说明:
- Scala通过两个部分抓取网络:一个JVM HTTP客户端用于获取,一个基于jsoup的解析器用于提取。 requests-scala负责请求;scala-scraper将HTML转换为可用CSS选择器的节点。
- 整个项目只有三个sbt依赖。 requests-scala 0.9.0用于HTTP,scala-scraper 3.2.0用于解析,ujson 4.1.0用于稍后解码的一个JSON包——无需学习框架。
- 分页是对“下一页”链接的循环。 scala-scraper通过一个可选选择器读取下一页的href,因此目录遍历是一个尾递归函数,而不是一个队列。
- 静态获取有一个严格的上限:它无法运行JavaScript或通过机器人挑战。 普通的
requests.get返回一个客户端渲染页面的空壳,并在受保护网站上出现挑战插页。 - Scrapeless通用抓取API通过简单的HTTP POST弥补了这一缺口。
js_render: true在服务器端运行页面并返回完成的DOM;与网站通信的相同requests-scala客户端可以与API通信。 - 解锁调用是在端点上实时运行的:HTTP 200,51,275字节的渲染HTML,20个产品标题。 本指南中的请求和响应形状直接来自于那次实时运行。
- 免费开始。 新的Scrapeless账户包括免费运行时间——在app.scrapeless.com注册。
引言:Scala在抓取中的位置
Scala运行在JVM上,这意味着用它编写的抓取器可以免费继承jsoup、Akka和成熟的HTTP生态系统。当抓取的内容已经在JVM上时——一个Spark作业,一个Kafka生产者,一个数据服务——并且你希望在同一代码库内以相同类型进行提取时,这种语言非常合适。
该任务的获取和解析部分很简短。少量代码可以拉取页面并用CSS选择器读取值。阻力开始于每个抓取器的共同点:网络的越来越多的内容使用需要实际运行的JavaScript构建,在数据存在之前,受保护网站则在TLS指纹识别和挑战页面之后限制访问,而普通HTTP客户端无法通过这些。
本指南首先构建静态抓取器——sbt项目、HTTP获取、基于选择器的提取、分页——然后明确指出这种方法停止的地方,将困难页面交给Scrapeless通用抓取API。最后的API调用是在实时中运行的;它的数据是一个真实的捕获。
你可以用这个栈做什么
- 在JVM上获取和解析——使用requests-scala进行请求,使用scala-scraper(一个jsoup包装器)进行CSS选择器提取。
- 在你的数据代码库中保持提取——将值直接读取为Scala类型,紧邻使用它们的Spark或Kafka作业。
- 遍历分页列表——在尾递归循环中跟随下一页链接,直到它用完。
- 访问JavaScript渲染和受保护的页面——将它们POST到通用抓取API,并以相同的方式解析渲染的HTML。
- 跳过反机器人栈——TLS指纹识别、住宅IP和挑战解决在API端发生,而不是在你的Scala代码中。
为什么选择Scrapeless通用抓取API
Scrapeless通用抓取API接收目标URL并返回渲染的、未被阻止的HTML。特别是对于Scala客户端,它提供:
- 服务器端JavaScript渲染——
js_render: true返回完成的DOM,因此scala-scraper看到的是真实内容,而不是空壳。 - 在195多个国家的住宅代理——抓取从受信任的IP出发;你从不需要在Scala中构建或轮换池。
- 反机器人处理——TLS指纹识别和挑战解决发生在API端,脱离你的JVM进程。
- 一个简单的HTTPS POST——无需在
build.sbt中添加SDK;你已经拥有的requests-scala客户端就足够了。 - 一个小包——
{"code":200,"data":"<html>…"},用你在其他地方使用的相同ujson解码。
在app.scrapeless.com的免费计划上获取你的API密钥。
先决条件
- 安装JDK(11或更新)和sbt
- Scala 2.13(下面的依赖版本是2.13构建)
- 一个Scrapeless账户和API密钥——在app.scrapeless.com注册
- 对终端有基本的熟悉
注意:下面构建和步骤部分的Scala代码是本指南验证中的先决条件间隙——在验证机上没有可用的JVM/sbt运行时,因此这些代码块是根据库的当前API和Maven Central版本编写和检查的,而不是执行的。负载承载的Scrapeless解锁调用已在端点上实时运行;其请求和响应是一个真实的捕获。
安装
创建一个包含两个文件的项目目录。build.sbt锁定了语言和三个依赖项:
scala
ThisBuild / scalaVersion := "2.13.16"
lazy val scraper = (project in file("."))
.settings(
name := "scala-scraper-demo",
zh
libraryDependencies ++= Seq(
"com.lihaoyi" %% "requests" % "0.9.0",
"net.ruippeixotog" %% "scala-scraper" % "3.2.0",
"com.lihaoyi" %% "ujson" % "4.1.0"
)
)
project/build.properties 锁定了 sbt 的版本:
text
sbt.version=1.12.13
scala-scraper 通过传递依赖引入 jsoup,因此您可以通过类型化的 Scala DSL 使用 jsoup 的引擎进行解析,而无需直接依赖 jsoup。运行一次 sbt update 可以解析所有内容,然后运行 sbt console 进入 REPL,或运行 sbt run 进行 main。
步骤 1 — 获取页面
requests-scala 是一个轻量级的同步 HTTP 客户端。一次调用可以获取页面主体作为字符串:
scala
val res = requests.get(
"https://books.toscrape.com/",
headers = Map("User-Agent" -> "Mozilla/5.0 (compatible; scala-scraper-demo)")
)
println(res.statusCode) // 200
val html: String = res.text()
res.text() 是原始 HTML。对于这样的服务器渲染页面,该字符串已经包含了数据;而对于客户端渲染页面,它将包含一个空的外壳,这就是步骤 4 所要解决的限制。
步骤 2 — 使用 scala-scraper 解析
scala-scraper 将字符串解析为文档,并通过其 DSL 使用 CSS 选择器选择节点。>> 运算符提取;elementList、attr 和 texts 将结果转换为 Scala 值:
scala
import net.ruippeixotog.scalascraper.browser.JsoupBrowser
import net.ruippeixotog.scalascraper.dsl.DSL._
import net.ruippeixotog.scalascraper.dsl.DSL.Extract._
val doc = JsoupBrowser().parseString(html)
val titles: List[String] = doc >> elementList("article.product_pod h3 a") >> attr("title")
val prices: List[String] = doc >> texts("p.price_color")
val books = titles.zip(prices)
books.foreach { case (t, p) => println(s"$p $t") }
article.product_pod h3 a 是这里的持久选择器 - 产品卡片类加上其标题内的链接 - 而 title 即使在可见文本被截断时也会携带完整名称。从属性中提取值而不是渲染文本是更稳定的读取方式,只要网站提供该属性。
步骤 3 — 跟随分页
目录在多页之间继续,每一页通过 li.next a 元素链接到下一页。scala-scraper 的可选选择器 >?> 在该链接缺失时返回 None,这正是循环的停止条件:
scala
import net.ruippeixotog.scalascraper.model.Document
def nextUrl(doc: Document, base: String): Option[String] =
(doc >?> element("li.next a")).map(a => base + a.attr("href"))
@annotation.tailrec
def crawl(url: String, base: String, acc: List[String]): List[String] = {
val doc = JsoupBrowser().parseString(requests.get(url).text())
val names = doc >> elementList("article.product_pod h3 a") >> attr("title")
nextUrl(doc, base) match {
case Some(next) => crawl(next, base, acc ++ names)
case None => acc ++ names
}
}
val all = crawl("https://books.toscrape.com/catalogue/page-1.html",
"https://books.toscrape.com/catalogue/", Nil)
println(all.size)
保持循环礼貌 - 一次一个主机,页面之间有小的延迟 - 并将缺失的字段视为 Option,绝不要假设它是存在的值。
在免费计划上获取您的 API 密钥:app.scrapeless.com
静态获取的止境
requests.get 做一件事:它返回服务器发送给匿名客户端的字节。对于一个服务器渲染的目录来说,这已经足够,没什么更多的。两个案例会打破它,而且这两者都很常见:
- 客户端渲染的页面。 当网站用 JavaScript 构建其内容时,您获取的 HTML 是一个带有数据仍被锁在脚本中的空外壳。scala-scraper 无法选择,因为内容从未在字节中。
- 受保护的页面。 具有主动反机器人防御的网站会对匿名请求响应一个挑战插图,而不是页面。普通的 HTTP 客户端无法清除它。
在 Scala 中再次实现这个修复 - 运行 JavaScript 的无头浏览器,住宅代理池,挑战解决器 - 是一个远比抓取本身大得多的项目。务实的方法是停止让 Scala 执行那部分,并将这些 URL 交给渲染 API。
云的变化:服务器端渲染,在 Scala 中解析
Scrapeless 通用抓取 API 以目标 URL 为输入,通过真实浏览器和住宅出口在服务器端运行它,并返回处理后的 HTML。从 Scala 中,使用与 requests-scala 客户端相同的 POST,ujson 解码响应:
scala
val apiKey = sys.env("SCRAPELESS_API_KEY")
val payload = ujson.Obj(
"actor" -> "unlocker.webunlocker",
"input" -> ujson.Obj(
"url" -> "https://books.toscrape.com/",
"method" -> "GET",
"redirect" -> true,
"js_render" -> true
)
)
val res = requests.post(
"https://api.scrapeless.com/api/v1/unlocker/request",
plaintext
headers = Map("Content-Type" -> "application/json", "x-api-token" -> apiKey),
data = ujson.write(payload),
readTimeout = 120000
)
val env = ujson.read(res.text())
val html = env("data").str // 渲染的 DOM 作为字符串
js_render: true 是一个重要的标志:它告诉 API 运行页面的 JavaScript,并返回完成的 DOM,因此客户端构建内容的网站作为真实的标记返回。从这里,html 直接传入相同的 JsoupBrowser().parseString(html) 和第 2 步的相同选择器——您的爬虫的解析部分没有变化,仅提取方式不同。
您得到的返回信息
API 响应是一个小而可预测的封装:
json
{
"code": 200,
"data": "<html>...渲染的 DOM...</html>"
}
// 说明性示例: schema 是来自实时调用的真实形状;这里“data”字符串被截断。在经过验证的运行中,“data”持有 51,275 字节的 JSON 转义渲染 HTML。
对上述目录页面的实时调用返回了 HTTP 200,渲染的 HTML 为 51,275 字节;对该 HTML 运行第 2 步的选择器产生了 20 个产品标题,第一个是“阁楼里的光”价格为 £51.77。运行的几点说明:
js_render: true增加延迟但获取了内容。 对于静态页面将其关闭可以加快速度;当页面在关闭时为空时则打开。ujson读取您所需的一个字段。env("data").str是整个解码;封装的其余部分只是状态code。- 选择器保持在 Scala 中。 API 返回 HTML,因此提取逻辑、类型和测试在您的代码库中,而不是在管理模式后面。
- 将缺失字段视为
Option。 具有>?>的可为 null 选择器是当卡片可能省略价格或标题时正确的读取方式。
结论: Scala 负责解析,API 负责困难的提取
Scala 爬虫在 JVM 强项的地方简洁——使用 requests-scala 进行请求,scala-scraper 进行 CSS 选择器提取,递归遍历下一页链接。它会遇到每个静态爬虫所面临的同样壁垒:客户端渲染页面和活动的反机器人防护,普通的 HTTP 客户端无法解决。通过 通用抓取 API 路由这些 URL,使修复仅限于单个 POST,并保持您的解析不变。有关其他语言中相同的提取-解析分离,请参见 JavaScript 和 Node.js 抓取指南; 文档 涵盖了完整的 API 及其参数。根据页面的需求固定 js_render,将选择器保留为 Scala,并将每个字段视为可选。
准备好构建您的 AI 驱动的数据管道了吗?
加入我们的社区,领取免费计划,并与构建 JVM 爬虫的开发人员连接:Discord · Telegram。
在 app.scrapeless.com 注册获取免费运行时间,并将上述程序适应于您的 Scala 管道所需的网站和选择器。有关规模的定价,请参见 定价。
常见问题
问:用 Scala 抓取是否合法?
抓取公开可见的数据通常是允许的,但规则因地区和网站而异。请审查目标的服务条款,遵守爬虫指令,避免个人或受限数据,并就任何商业事宜咨询法律顾问。
问:我需要代理吗?
对于轻量级抓取服务器呈现的网站,不需要。对于受保护或客户端渲染的页面,请求通过通用抓取 API 的住宅代理在 195 个国家中进行出网,因此您不需要在 Scala 中构建一个池。
问:机器人挑战是怎样的,我如何获得干净的渲染?
而不是页面,匿名请求会收到一个挑战中介。通过带有 js_render: true 的通用抓取 API 路由该 URL;它从受信任的住宅 IP 在服务器端运行页面并返回完成的 HTML。
问:为什么使用 scala-scraper 而不是直接调用 jsoup?
scala-scraper 在带类型的 Scala DSL 中封装了 jsoup,因此选择器返回 List[String] 或 Option[Element] 而不是 Java 集合。您可以使用符合 Scala 模式匹配的结果获取 jsoup 的解析器。
问:网站更改后我的选择器失效了。现在怎么办?
标记会旋转。重新检查页面并收紧选择器——优先选择一个稳定的容器类和属性读取(article.product_pod h3 a → title),而不是下次重新设计时可能更改的哈希 CSS 类。
问:我可以并行运行多个页面吗?
是的,但每个主机大致保持三名工人,以保持礼貌并避免速度限制。带有小延迟的尾递归单主机遍历是安全的默认设置。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



