另一个 ETL:夜间缆车票
I’m happy to translate the article for you, but I don’t see the text you’d like translated—only the source link is included. Could you please paste the content you want translated (excluding any code blocks or URLs you want to keep unchanged)? Once I have the text, I’ll provide the Simplified Chinese translation while preserving the original formatting.
背景
我住在芝加哥,冬天我喜欢的一件事是有机会去滑雪板。我的技术不算好,但我很享受。通常我会打开多个网站,直到找到一个去的地方。每当我发现自己在做重复的手动任务——打开多个内容相似的网站来达成同一目的时,我的大脑会直接想到 ETL。
我对 night lift tickets 感兴趣,因为它们价格最优惠,我想在地图上查看这些地点,以了解它们离我家的距离。
ETL 结构
我所有的 ETL 都遵循相同的结构:
- Extract – 获取目标页面的 HTML。
- Transform – 解析 HTML 并提取价格。
- Load – 将结果保存为 JSON 文件。
Extract 和 Load 步骤对每个滑雪场都是相同的,因此它们放在共享的 common.js 文件中。唯一需要自定义的部分是 Transform 函数,它使用 CSS 选择器在 HTML 中定位价格。
Transform (Node.js)
// transform.js
const cheerio = require("cheerio");
const { extract, load, loggerInfo } = require("./common");
async function transform(html) {
const $ = cheerio.load(html);
const price = $(
$(".seasoncontainer.liftcontainer.clsDaynight.clsNight li")[2]
)
.find("p")
.text()
.replace("$", "");
return {
price: parseInt(price, 10),
};
}
async function main() {
const place = {
id: "cascademountain",
website: "https://www.cascademountain.com",
name: "Cascade Mountain",
lat: 43.502728,
lng: -89.515996,
gmaps: "https://maps.app.goo.gl/YWdnQvZiJZwPhj79A",
url: "https://www.cascademountain.com/lift-tickets/",
};
loggerInfo("etl start", { id: place.id });
const html = await extract(place.url);
const data = await transform(html);
await load(place, data);
loggerInfo("etl done", { id: place.id });
}
main().then(() => {});
CSS 选择器(.seasoncontainer.liftcontainer.clsDaynight.clsNight li)在当前站点有效,但如果类名发生变化,爬虫就会失效。在生产环境中,你应该为提取失败添加警报,甚至可以使用 AI 模型(例如 Gemini)来定位价格。
提取函数
// common.js (excerpt)
async function extract(url) {
loggerInfo("extracting", { url });
const response = await fetch(url);
const html = await response.text();
return html;
}
此函数的一个变体使用 Puppeteer 来处理阻止简单 fetch 请求的网站。
Load Function
// common.js (excerpt)
const fs = require("fs").promises;
async function load(place, extraData) {
const data = { ...place, ...extraData };
loggerInfo("load", data);
const filename = `public/sites/${data.id}.json`;
await fs.writeFile(filename, JSON.stringify(data, null, 2));
loggerInfo("saved", { filename });
}
目前数据存储在 public/sites/ 目录下的 JSON 文件中。在生产环境中,更合适的做法是使用诸如 DynamoDB 之类的数据库。
日志记录
// common.js (excerpt)
const loggerInfo = (...args) => {
console.log(...args);
};
loggerInfo 是 console.log 的一个薄包装。在实际应用中,你可以将其替换为 New Relic、Datadog 或其他日志服务。
前端使用 Next.js
在 ETL 将 all-places.json 填充完毕后,下一步是构建一个简单的网站,在地图上显示这些度假胜地。
使用 Copilot 的提示
On this Next.js project, build a page with the following requirements:
- Only one page.
- The page should show a map using Google Maps.
- The map should show a marker for each place found in `all-places.json`, displaying the price.
- The page should have Google Analytics.
- The page should let the user click a marker and open a small card with the place information: name, link to Google Maps, and link to the `url` found in the JSON.
- The UI should be simple and use inline styles.
Copilot 生成了一个起始实现,随后可以根据需要进行微调。
结论
ETL 提取夜间缆车票价,将其存储在 JSON 文件中,并为一个简约的 Next.js 前端提供数据,该前端在 Google 地图上可视化这些数据。该方法适用于少量、静态的滑雪场列表;若要扩展,需要自动化地点列表(例如通过 Google Maps API),并将数据存储迁移到合适的数据库。
欢迎访问该站点并告诉我你的想法!