我如何使用 Go 爬取米其林指南
在汽车时代初期,轮胎公司Michelin创建了旅游指南,包括餐厅指南。多年来,米其林星因其高标准和非常严格的匿名测试者而变得非常有声望。米其林星令人难以置信。仅获得一个就可以改变厨师的生活;然而,失去一个也可以改变它。
受这个 Reddit 帖子的启发,我最初的意图是从官方米其林指南(CSV 文件格式)中收集餐厅数据,以便任何人都可以在[Google 我的地图]上映射来自世界各地的米其林指南餐厅(https://mymaps.google.com/)(参见示例)。
以下是我如何使用 Go 和Colly从米其林指南中收集所有餐厅详细信息的思考过程。最终数据集可免费下载此处。
概述
-
项目目标与规划
-
如何不伤害网站
-
刮板和代码演练
在开始之前,我只想指出,这不是关于如何使用 Colly 的完整教程。 Colly 非常易于使用,我强烈建议您阅读官方文档以开始使用。
既然已经不碍事了,让我们开始吧!
项目目标
这里有两个主要目标——
1.直接从米其林指南官方网站收集“高质量”数据
- 尽可能减少网站的占用空间
那么,什么是“高品质”?我希望任何人都能够直接使用数据,而无需执行任何形式的数据处理。因此,收集的数据必须一致、准确且解析正确。
我们在收集什么
在开始这个网络抓取项目之前,我确保没有提供这些数据的现有 API;至少在撰写本文时。
在浏览了主页和几个餐厅详情页面后,我最终选择了以下内容(即作为我的列标题):
-
Name -
Address -
Location -
MinPrice -
MaxPrice -
Currency -
Longitude -
Latitude -
PhoneNumber -
Url(餐厅链接:guide.michelin.com) -
WebsiteUrl(餐厅自有网站) -
Award(1 至 3 颗米其林星和 Bib Gourmand)
在这种情况下,我将省略餐厅描述(参见“米其林指南的观点”),因为我觉得它们并不是特别有用。话虽如此,如果您有兴趣,请随时提交 PR!我非常乐意与您合作。
另一方面,在地图上绘制餐厅的地址、经度和纬度时,它们特别有用。
这是我们餐厅模型的示例:
// model.go
type Restaurant struct {
Name string
Address string
Location string
MinPrice string
MaxPrice string
Currency string
Cuisine string
Longitude string
Latitude string
PhoneNumber string
Url string
WebsiteUrl string
Award string
}
进入全屏模式 退出全屏模式
估计
让我们快速估计一下刮板。首先,我们的数据集中预计出现的餐厅总数是多少?
[
](https://res.cloudinary.com/practicaldev/image/fetch/s--_WcIBWuH--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https ://dev-to-uploads.s3.amazonaws.com/uploads/articles/xhdjryciv7snuuam0hml.png)
从网站的数据来看,总共应该有 6,502 家餐厅(行)。
每个页面包含 20 家餐厅,我们的爬虫将访问约 325 个页面;每个类别的最后一页可能不包含 20 家餐厅。
正确的工具,适用于正确的工作
今天,有一些工具、框架和库可用于网络抓取或数据提取。哎呀,市场上甚至有大量的 Web Scraping SaaS(例如 Octoparse)根本不需要代码。
就个人而言,由于灵活性的原因,我更喜欢构建自己的刮板。最重要的是,使用 SaaS 通常伴随着代价以及它的第二个(通常是不言而喻的)成本——它自己的学习曲线!
开发者工具(DevTool)
为网页抓取选择正确的库或框架的部分过程是在页面上执行 DevTooling。
在打开DevTool后,我经常采取的第一步是立即禁用 JavaScript 并快速刷新页面。这有助于我快速确定内容在网站上的呈现方式。
[
](https://res.cloudinary.com/practicaldev/image/fetch/s--aiDHUrjf--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/1m4um3zrk8auypke8zvh.png)
打开 Chrome DevTool → Cmd/Ctrl + Shift + P → 禁用 JavaScript
一般来说,在网站上如何生成/呈现内容有两个主要区别:
1.服务端渲染
- JavaScript 渲染(即动态加载的内容)
对我们来说很容易,米其林指南网站内容是使用服务器端渲染加载的。
如果网站使用 JavaScript 呈现会怎样
暂时搁置一下——如果网站内容是使用 JavaScript 呈现的呢?然后,我们将无法直接抓取所需的数据。相反,我们需要检查“网络”选项卡,看看它是否正在进行任何 HTTP API 调用来检索内容数据。
否则,我们需要使用 JavaScript 渲染(无头)浏览器,例如Splash或Selenium来抓取内容。
Go Colly vs. Scrapy vs. Selenium
我最初的想法是使用Scrapy— 一个功能丰富且可扩展的 Python 网络抓取框架。然而,在这种情况下使用 Scrapy 对我来说似乎有点过头了,目标相当简单,不需要任何复杂的功能,例如使用处理 JavaScript 渲染、中间件、数据管道等。
考虑到这一点,我决定使用 Colly,这是一个用于 Golang 的快速而优雅的 Web 抓取框架,因为它的简单性和它提供的出色开发人员体验。
最后,我不喜欢 Selenium 或Puppeteer等网络抓取工具,因为它们相对“粗大”和速度快。但是,当您需要抓取不通过 HTTP API 获取数据的 JavaScript 呈现的网站时,它们是救命稻草。
最小化占地面积
网页抓取的第一条规则——不要伤害网站。我强烈推荐你到阅读 Colly 提供的这些抓取技巧。从本质上讲,这些技巧几乎与工具无关。
始终缓存您的响应
在开发过程中,重试请求通常是不可避免的。 Colly 为我们提供了轻松缓存响应的能力。通过缓存,我们可以:
-
大大减少网站的负载
-
拥有更好的开发体验,因为使用缓存重试速度更快
// app/app.go
// ...
cacheDir := filepath.Join(cachePath)
c := colly.NewCollector(
colly.CacheDir(cacheDir),
colly.AllowedDomains(allowedDomain),
)
进入全屏模式 退出全屏模式
添加请求之间的延迟
当遍历多个页面(在我们的例子中约为 325)时,在请求之间添加延迟总是一个好主意。这允许网站处理我们的请求而不会超载;我们希望绝对避免对网站造成任何形式的破坏。
// app/app.go
// ...
c.Limit(&colly.LimitRule{
Delay: 2 * time.Second,
RandomDelay: 2 * time.Second,
})
进入全屏模式 退出全屏模式
增加延迟还可以帮助减轻 IP 禁令等反抓取措施。
刮板
在本节中,我将仅介绍爬虫代码的重要部分(和注意事项)。
选择器
就个人而言,我更喜欢使用XPath来查询 HTML 页面的元素以提取数据。如果您喜欢网络抓取,我强烈建议您学习 XPath;它会让你的生活更轻松。这是我最喜欢的使用 XPath](https://devhints.io/xpath)的[备忘单。
为了避免我们的主应用程序代码中长而丑陋的 XPath 混乱,我经常喜欢将它们放入一个的单独文件中。您当然可以改用 CSS 选择器。
入口点
要开始构建我们的爬虫应用程序,我们首先确定我们的入口点,即起始 URL。在我们的例子中,我选择了所有餐厅的主页”(按奖项/荣誉的类型过滤)作为起始 URL。
// app/const.go
// ...
type startUrl struct {
Award string
Url string
}
var urls = []startUrl{
{"3 MICHELIN Stars", "https://guide.michelin.com/en/restaurants/3-stars-michelin/"},
{"2 MICHELIN Stars", "https://guide.michelin.com/en/restaurants/2-stars-michelin/"},
{"1 MICHELIN Star", "https://guide.michelin.com/en/restaurants/1-star-michelin/"},
{"Bib Gourmand", "https://guide.michelin.com/en/restaurants/bib-gourmand"},
}
进入全屏模式 退出全屏模式
为什么不直接从guide.michelin.com/en/restaurants开始呢?
通过有意地根据米其林奖的类型声明我的起始 URL,我不需要从 HTML 中提取餐厅的米其林奖。相反,我可以直接根据我的起始 URL 填写 Award 列;少了一个需要维护的 XPath (yay)!
收藏家
我们的刮板应用程序由 2 个收集器组成——
1.一(collector)从主(起始)页面解析位置、经度、纬度等信息
2.另一个(detailCollector)从每个单独的餐厅收集详细信息,例如地址、价格、电话号码等。还将行中的数据写入我们的输出 CSV 文件。
如何在 Colly 收集器之间传递上下文
由于我们只在detailCollector级别写入 CSV 文件,因此我们需要将提取的数据从collector传递到detailCollector。我是这样做的:
// app/const.go
// ...
app.collector.OnXML(restaurantXPath, func(e *colly.XMLElement) {
url := e.Request.AbsoluteURL(e.ChildAttr(restaurantDetailUrlXPath, "href"))
location := e.ChildText(restaurantLocationXPath)
longitude := e.ChildAttr(restaurantXPath, "data-lng")
latitude := e.ChildAttr(restaurantXPath, "data-lat")
e.Request.Ctx.Put("location", location)
e.Request.Ctx.Put("longitude", longitude)
e.Request.Ctx.Put("latitude", latitude)
app.detailCollector.Request(e.Request.Method, url, nil, e.Request.Ctx, nil)
})
进入全屏模式 退出全屏模式
这样,位置、经度和纬度信息就可以通过 Context 传递给我们的detailCollector(参考)。
解析器
我编写了几个实用程序解析器来从提取的原始字符串中提取特定信息。由于它们相当简单,我将不再赘述。
最后,我们的整个爬虫应用程序看起来像这样:
package app
import (
"encoding/csv"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gocolly/colly"
"github.com/gocolly/colly/extensions"
"github.com/ngshiheng/michelin-my-maps/model"
"github.com/ngshiheng/michelin-my-maps/util/logger"
"github.com/ngshiheng/michelin-my-maps/util/parser"
log "github.com/sirupsen/logrus"
)
type App struct {
collector *colly.Collector
detailCollector *colly.Collector
writer *csv.Writer
file *os.File
startUrls []startUrl
}
func New() *App {
// Initialize csv file and writer
file, err := os.Create(filepath.Join(outputPath, outputFileName))
if err != nil {
log.WithFields(log.Fields{"file": file}).Fatal("cannot create file")
}
writer := csv.NewWriter(file)
csvHeader := model.GenerateFieldNameSlice(model.Restaurant{})
if err := writer.Write(csvHeader); err != nil {
log.WithFields(log.Fields{
"file": file,
"csvHeader": csvHeader,
}).Fatal("cannot write header to file")
}
// Initialize colly collectors
cacheDir := filepath.Join(cachePath)
c := colly.NewCollector(
colly.CacheDir(cacheDir),
colly.AllowedDomains(allowedDomain),
)
c.Limit(&colly.LimitRule{
Parallelism: parallelism,
Delay: delay,
RandomDelay: randomDelay,
})
extensions.RandomUserAgent(c)
extensions.Referer(c)
dc := c.Clone()
return &App{
c,
dc,
writer,
file,
urls,
}
}
// Crawl Michelin Guide Restaurants information from app.startUrls
func (app *App) Crawl() {
defer logger.TimeTrack(time.Now(), "crawl")
defer app.file.Close()
defer app.writer.Flush()
app.collector.OnResponse(func(r *colly.Response) {
log.Info("visited ", r.Request.URL)
r.Request.Visit(r.Ctx.Get("url"))
})
app.collector.OnScraped(func(r *colly.Response) {
log.Info("finished ", r.Request.URL)
})
// Extract url of each restaurant from the main page and visit them
app.collector.OnXML(restaurantXPath, func(e *colly.XMLElement) {
url := e.Request.AbsoluteURL(e.ChildAttr(restaurantDetailUrlXPath, "href"))
location := e.ChildText(restaurantLocationXPath)
longitude := e.ChildAttr(restaurantXPath, "data-lng")
latitude := e.ChildAttr(restaurantXPath, "data-lat")
e.Request.Ctx.Put("location", location)
e.Request.Ctx.Put("longitude", longitude)
e.Request.Ctx.Put("latitude", latitude)
app.detailCollector.Request(e.Request.Method, url, nil, e.Request.Ctx, nil)
})
// Extract and visit next page links
app.collector.OnXML(nextPageArrowButtonXPath, func(e *colly.XMLElement) {
e.Request.Visit(e.Attr("href"))
})
// Extract details of each restaurant and write to csv file
app.detailCollector.OnXML(restaurantDetailXPath, func(e *colly.XMLElement) {
url := e.Request.URL.String()
websiteUrl := e.ChildAttr(restarauntWebsiteUrlXPath, "href")
name := e.ChildText(restaurantNameXPath)
address := e.ChildText(restaurantAddressXPath)
priceAndCuisine := e.ChildText(restaurantpriceAndCuisineXPath)
price, cuisine := parser.SplitUnpack(priceAndCuisine, "•")
price = parser.TrimWhiteSpaces(price)
minPrice, maxPrice, currency := parser.ParsePrice(price)
phoneNumber := e.ChildText(restarauntPhoneNumberXPath)
formattedPhoneNumber := parser.ParsePhoneNumber(phoneNumber)
restaurant := model.Restaurant{
Name: name,
Address: address,
Location: e.Request.Ctx.Get("location"),
MinPrice: minPrice,
MaxPrice: maxPrice,
Currency: currency,
Cuisine: cuisine,
Longitude: e.Request.Ctx.Get("longitude"),
Latitude: e.Request.Ctx.Get("latitude"),
PhoneNumber: formattedPhoneNumber,
Url: url,
WebsiteUrl: websiteUrl,
Award: e.Request.Ctx.Get("award"),
}
log.Debug(restaurant)
if err := app.writer.Write(model.GenerateFieldValueSlice(restaurant)); err != nil {
log.Fatalf("cannot write data %q: %s\n", restaurant, err)
}
})
// Start scraping
for _, url := range app.startUrls {
ctx := colly.NewContext()
ctx.Put("award", url.Award)
app.collector.Request(http.MethodGet, url.Url, nil, ctx, nil)
}
// Wait until threads are finished
app.collector.Wait()
app.detailCollector.Wait()
}
进入全屏模式 退出全屏模式
请随时在此处查看完整的源代码。
结束语
最初,我想通过其 API 在 Google 我的地图上映射每家获得米其林奖的餐厅。不幸的是,不仅My Maps 没有任何 API,而且最多只允许 2,000 个数据点。因此,要构建地图,您必须在我的地图上手动导入我们的 CSV。
作为一个美食家,这个项目的构建非常有趣。对我来说更有意义的是看到人们很好地利用了 Kaggle上的数据集。
如果您碰巧绘制了餐厅地图或对数据集进行了任何形式的数据分析工作,请随时与我分享!在我们结束之前,如果您有任何问题,请随时与我们联系。
今天的内容就到这里,感谢您的阅读!
本文最初发表于jerrynsh.com
更多推荐

所有评论(0)