Upwork 是互联网上规模最大的自由职业市场之一,其公开的职位搜索页面是了解客户招聘需求的稳定信号来源。每条招聘信息都包含标题、预算或时薪、所需技能列表以及发布时间,招聘人员、市场研究人员和自由职业者本身都用这些数据来追踪需求动态:哪些技能供不应求、某类别的市场行情如何、哪里正在出现新的项目机会。
本指南将向您展示如何使用 JavaScript 和 Node.js 配合 cheerio 抓取 Upwork 职位。您将构建一个可运行的小型爬虫,通过 Crawling API 获取 Upwork 公开职位搜索页面,解析每条招聘信息的标题、预算或时薪、所需技能、发布时间和链接,并将结果导出为 JSON 和 CSV 格式。整个教程的范围严格限定在公开职位信息。它不涉及自由职业者的个人资料、联系方式或任何需要登录才能访问的内容,文末的合法性部分并非例行公事,请在将此工具用于任何实际规模的场景前仔细阅读。
您将构建什么
一个 Node.js 脚本,接受一个 Upwork 公开职位搜索 URL,通过 Crawling API 获取渲染后的 HTML,并为结果页面上的每条招聘信息提取一条结构化记录。我们以搜索"SEO expert"为示例,并为每条招聘信息提取以下字段:
- 标题:职位名称,例如"SEO Expert for Technical Site Audit"。
- 预算或时薪:卡片上显示的固定价格预算或时薪范围,例如"$1,000"或"$25.00-$50.00"。
- 技能:职位要求的技能标签列表。
- 发布时间:相对发布时间,例如"Posted 3 hours ago"。
- 链接:指向该职位详情页面的 URL。
为什么普通请求在 Upwork 上会失败
如果您使用普通 HTTP 客户端请求 Upwork 职位搜索 URL,很少能获取到职位卡片数据。原因有两点:第一,Upwork 通过 JavaScript 在浏览器中渲染搜索结果,因此初始 HTML 在页面脚本运行之前几乎是一个空壳。第二,Upwork 会积极标记自动化流量:来自数据中心 IP 的请求以及不像真实浏览器的请求模式,都会在到达渲染后的列表之前被质疑、限速或屏蔽。
因此,一个可用的 Upwork 爬虫需要在单次请求中同时具备两点:一个能真正渲染页面的浏览器,以及一个被平台视为真实访客的 IP 地址。您可以自行搭建无头浏览器加轮换住宅代理池的组合,但将这些组件整合在一起并保持其正常运行,本身就占据了大部分工作量。Crawling API 将两者整合在一次调用中:您发送 URL,它在受信任的 IP 后面渲染页面,并将完整的 HTML 返回给您用 cheerio 解析。关于为什么客户端渲染会让简单爬虫失效的更多信息,请参阅如何抓取 JavaScript 网站。
Crawling API 为您提供两种 token:普通 token 和 JavaScript token。Upwork 搜索页面是客户端渲染的,因此您需要 JavaScript token(真实浏览器渲染)才能获取填充后的页面内容。使用普通 token 发出的请求通常只会返回一个空壳。
前提条件
在编写任何代码之前,您需要准备好几样东西。这些准备工作都不会花费太长时间。
基础 JavaScript 和 Node.js 知识。您应该能够编写和运行 Node 脚本,并使用 npm 安装包。如果您是 Node 新手,使用 Node.js 构建 Web 爬虫指南涵盖了本教程所假设的基础知识。
Node.js 16 或更高版本。使用 node --version 确认您的版本。如果尚未安装,请从 Node.js 官网或通过 nvm 等版本管理工具安装。
Crawlbase 账户和 token。注册后,打开您的控制台,从账户文档页面复制您的 JavaScript token。免费套餐提供 1,000 次请求,无需绑定信用卡。请像对待密码一样保管 token:它用于验证您的请求,因此请勿将其提交到版本控制系统中。
搭建项目
创建项目文件夹,初始化项目,并安装爬虫所需的两个库。
node --version mkdir upwork-jobs-scraper && cd upwork-jobs-scraper npm init -y npm install crawlbase cheerio
两个依赖项各司其职:crawlbase 是 Crawling API 的官方 Node 客户端,cheerio 以类似 jQuery 的 API 解析返回的 HTML,让您可以通过 CSS 选择器提取各个字段。在此文件夹中创建一个名为 upwork-scraper.js 的文件,并添加以下步骤中的代码。
第 1 步:获取渲染后的职位搜索页面
首先获取完整的页面。导入 CrawlingAPI 类,用您的 JavaScript token 初始化它,然后请求搜索 URL。由于 Upwork 是客户端渲染的,请给 API 传入一个短暂的等待时间,让页面脚本在 HTML 被捕获之前完成执行。在解析之前检查状态码,可以让失败情况更加明显,而不是悄无声息地出错。
const { CrawlingAPI } = require('crawlbase'); const api = new CrawlingAPI({ token: 'YOUR_CRAWLBASE_TOKEN' }); const upworkSearchURL = 'https://www.upwork.com/nx/search/jobs/?q=seo%20expert'; // Render the page in a real browser, then wait for scripts to settle const options = { ajax_wait: 'true', page_wait: 3000 }; api .get(upworkSearchURL, options) .then((response) => { if (response.statusCode === 200) { console.log(response.body.slice(0, 500)); } }) .catch((error) => console.error('API request error:', error));
使用 node upwork-scraper.js 运行脚本,您应该能在 body 顶部看到真实的 Upwork 职位标记,而不是一个精简的空壳。这确认了在您编写任何选择器之前,渲染功能已经正常工作。ajax_wait 和 page_wait 选项告诉 API 在真实浏览器中运行页面,并给其脚本时间来填充职位卡片,然后再捕获 HTML。如果您不想手动编写选择器,而是希望 API 为您解析常见字段,可以传入 autoparse 选项,这样您将直接获得结构化的 JSON。
// Ask the API to render and parse, returning JSON const options = { ajax_wait: 'true', page_wait: 3000, autoparse: 'true' }; api .get(upworkSearchURL, options) .then((response) => { if (response.statusCode === 200) { console.log(JSON.parse(response.body)); } }) .catch((error) => console.error('API request error:', error));
刚才那次请求就已经返回了一个完整渲染的 Upwork 搜索页面,您无需在本地运行无头浏览器或代理。Crawling API 在真实浏览器中运行页面,等待其 JavaScript 填充职位卡片,在服务端轮换住宅 IP,并处理 Upwork 对爬虫发起的各种挑战,因此您只需一次调用就能获得完整的 HTML(或自动解析的 JSON)。先在免费套餐中对公开职位搜索进行测试。
第 2 步:使用 cheerio 解析每条职位信息
获取到渲染后的 HTML 后,将其加载到 cheerio 中并遍历职位卡片。Upwork 在搜索结果页面上将每条招聘信息布局在一个重复的 article 容器中,因此您先选择所有卡片,再从每张卡片内部读取标题、预算或时薪、技能、发布时间和链接。防御性地读取每个字段可以防止单个缺失值导致整个运行崩溃。
const cheerio = require('cheerio'); function parseJobs(html) { const $ = cheerio.load(html); const jobs = []; const cards = $('article[data-test="JobTile"]'); cards.each((index, element) => { const card = $(element); const job = {}; // Title and link share one anchor const titleLink = card.find('[data-test="job-tile-title-link"]'); job.title = titleLink.text().trim(); const href = titleLink.attr('href'); job.link = href ? new URL(href, 'https://www.upwork.com').href : ''; // Posted time, e.g. "Posted 3 hours ago" job.posted = card .find('[data-test="job-pubilshed-date"]') .text() .replace(/\s+/g, ' ') .trim(); // Budget (fixed price) or hourly rate range const budget = card .find('[data-test="is-fixed-price"] strong') .last() .text() .trim(); const hourly = card .find('[data-test="job-type-label"]') .text() .trim(); job.budget = budget || hourly || 'Not specified'; // Required skill tags job.skills = card .find('[data-test="token"] span') .map((i, el) => $(el).text().trim()) .get() .filter(Boolean); if (job.title) jobs.push(job); }); return jobs; }
以下几个细节使代码对页面保持忠实。标题和链接来自同一个 job-tile-title-link 锚点,因此一次查找可以同时获得两者,而相对 href 会被解析为绝对 URL,使其在页面外部也能正常工作。发布时间从 job-pubilshed-date 属性中读取(Upwork 自己的拼写,保持原样以确保选择器匹配),并折叠空白字符。预算处理涵盖了两种招聘类型:固定价格职位在 is-fixed-price 块中暴露金额,而时薪职位在 job-type-label 中携带费率标签,因此解析器会取其中存在的那个。技能从 token 标签中收集到一个干净的数组中。
Upwork 的 data-test 属性比生成的类名更稳定,但它们仍然会在没有通知的情况下发生变化。请将上述选择器视为起始模板,而非固定契约。当某个字段返回空值时,请在浏览器开发工具中重新检查实时页面并更新选择器。定期维护选择器对于任何生产爬虫来说都是正常的,而不是某种故障的迹象。
第 3 步:组装完整脚本并导出 JSON 和 CSV
现在将获取和解析步骤整合到一个可运行的脚本中,然后将记录同时以 JSON 和 CSV 格式写入磁盘。关闭 autoparse 选项进行获取会返回原始渲染 HTML,这正是 cheerio 解析器所期望的输入。
const fs = require('fs'); const { CrawlingAPI } = require('crawlbase'); const cheerio = require('cheerio'); const api = new CrawlingAPI({ token: 'YOUR_CRAWLBASE_TOKEN' }); async function crawl(pageUrl) { const options = { ajax_wait: 'true', page_wait: 3000 }; const response = await api.get(pageUrl, options); if (response.statusCode === 200) return response.body; console.error(`Request failed: ${response.statusCode}`); return null; } function toCsv(rows) { const headers = ['title', 'budget', 'skills', 'posted', 'link']; const escape = (value) => `"${String(value).replace(/"/g, '""')}"`; const lines = [headers.join(',')]; for (const row of rows) { const flat = { ...row, skills: row.skills.join('; ') }; lines.push(headers.map((h) => escape(flat[h])).join(',')); } return lines.join('\n'); } async function main() { const url = 'https://www.upwork.com/nx/search/jobs/?q=seo%20expert'; const html = await crawl(url); if (!html) return; const jobs = parseJobs(html); fs.writeFileSync('upwork-jobs.json', JSON.stringify(jobs, null, 2)); fs.writeFileSync('upwork-jobs.csv', toCsv(jobs)); console.log(`Saved ${jobs.length} job postings to JSON and CSV`); } main();
将第 2 步中的 parseJobs 函数粘贴到同一个文件中,以便 main 可以调用它。使用 node upwork-scraper.js 运行,您将获得两个文件:包含完整结构化记录的 upwork-jobs.json 和可以直接在电子表格中打开的 upwork-jobs.csv。toCsv 辅助函数将技能数组展平为以分号分隔的单个单元格,对每个字段加引号,并将嵌入的引号加倍,这一点很重要,因为职位标题中经常包含逗号。
输出结果示例
JSON 文件按搜索结果顺序为每条招聘信息保存一个对象,每个对象包含标题、预算或时薪、技能、发布时间和链接。
[ { "title": "SEO Expert for Technical Site Audit", "budget": "$1,000", "skills": ["SEO", "Technical SEO", "On-Page SEO"], "posted": "Posted 3 hours ago", "link": "https://www.upwork.com/jobs/SEO-Expert-Technical-Site-Audit_~012abc" }, { "title": "Ongoing SEO Content Strategy", "budget": "Hourly: $25.00-$50.00", "skills": ["SEO", "Content Writing", "Keyword Research"], "posted": "Posted yesterday", "link": "https://www.upwork.com/jobs/Ongoing-SEO-Content-Strategy_~034def" } ]
CSV 文件以标题行镜像相同的数据行,可以直接导入 Excel、Google Sheets 或任何读取分隔文件的数据管道。
title,budget,skills,posted,link "SEO Expert for Technical Site Audit","$1,000","SEO; Technical SEO; On-Page SEO","Posted 3 hours ago","https://www.upwork.com/jobs/SEO-Expert-Technical-Site-Audit_~012abc" "Ongoing SEO Content Strategy","Hourly: $25.00-$50.00","SEO; Content Writing; Keyword Research","Posted yesterday","https://www.upwork.com/jobs/Ongoing-SEO-Content-Strategy_~034def"
扩展到多个搜索和多页结果
单个搜索只是演示;实际工作需要遍历多个查询和多个结果页面。Upwork 的搜索 URL 接受 q 查询参数和 page 参数,因此您可以构建一个搜索列表,逐页翻看每个搜索结果,用同一个函数解析每一页,并在导出之前给每行数据打上对应的查询标签。由于每个搜索结果页面共享相同的卡片结构,您已经编写好的解析器可以无需修改地适用于所有页面。
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function scrapeSearch(query, pages) { const all = []; for (let page = 1; page <= pages; page++) { const url = `https://www.upwork.com/nx/search/jobs/?q=${encodeURIComponent( query )}&page=${page}`; const html = await crawl(url); if (!html) continue; const rows = parseJobs(html).map((j) => ({ query, ...j })); all.push(...rows); await sleep(2000); } return all; } scrapeSearch('seo expert', 3).then((rows) => { console.log(`Collected ${rows.length} postings across pages`); });
这种模式可以直接应用于职位市场研究和招聘工作。关于其他招聘平台的类似方法,请参阅如何抓取 Indeed 职位以及如何用 Python 抓取 Monster 职位,这些文章涵盖了相应网站的结果页面处理方式。
保持不被屏蔽
即便渲染问题已经解决,Upwork 仍然会监视具有爬虫特征的流量。养成以下几个习惯可以保持运行的健康状态,这些习惯适用于任何难度较高的商业目标网站。
-
控制请求节奏。在每次页面抓取之间引入延迟,而不是在紧密循环中连续轰炸页面,就像上面的
sleep调用所做的那样。分散请求是保持在 Upwork 速率限制以下的最关键因素。 - 依赖轮换机制。住宅 IP 池将请求分散到许多真实用户地址上,这样单个地址就不会触发限制或挑战。Crawling API 会为您处理这些;如果您自己搭建方案,这是最需要做好的部分。
- 关注状态码。如果运行开始返回挑战或非 200 响应,这是在告诉您当前的速率或 IP 级别已经不够用了。将其视为需要退后一步的信号,而非可以忽略的噪声。
关于更广泛的策略,请参阅如何在不被屏蔽的情况下抓取网站。
抓取 Upwork 合法吗?
抓取 Upwork 是否被允许,取决于 Upwork 的服务条款、您所在的司法管辖区以及您如何使用这些数据。Upwork 的条款限制自动访问和抓取行为,因此无论您的工具有多谨慎,采集数据都可能违反这些条款。这里的任何代码都不会改变这一事实;它只是让技术层面的工作得以实现。请阅读 Upwork 的服务条款及其 robots.txt,并将两者都视为您采集内容的边界。尊重 Upwork 声明的速率预期,并将请求量保持在足够低的水平,以免给其服务器造成压力。
本指南有意将范围严格限定在公开职位信息:标题、预算或时薪、所需技能、发布时间以及职位链接,这些都是任何人无需登录即可在公开搜索页面上看到的内容。它不涉及自由职业者的个人资料、姓名、照片、联系方式、工作历史、收入或任何其他个人数据,也不涵盖登录后的任何内容。这条界线在法律上至关重要。职位信息是公开的商业列表,但自由职业者的个人资料是个人数据,一旦您开始采集有关可识别个人的数据,就会受到隐私法的约束。在欧盟的 GDPR 和加州的 CCPA 框架下,抓取和存储个人数据需要合法依据,且个人享有访问权和删除权。最安全且最具可辩护性的立场是:仅限于公开职位信息,绝不建立个人档案,绝不为未经请求的推广行为采集联系方式。
如果您的项目需要超出公开职位范围的内容,或者您希望获得有保障的结构和商业权利,请寻找官方渠道,而不是更巧妙的爬虫。Upwork 为授权数据访问提供企业和 API 项目,并附有明确的使用条款,这是您需要大量数据或合同依据时的正确工具。对于汇总的、非个人的市场研究(哪些技能有需求、某类别的市场价格、发帖量趋势),去除任何个人身份信息的公开职位信息通常已经足够,而这正是本爬虫所采集的内容。
核心要点
- Upwork 在客户端渲染搜索结果,且封锁力度极强。普通请求只会返回空壳或触发挑战,因此在解析之前必须在受信任的 IP 后面渲染页面。
-
Crawling API 在单次调用中同时完成两件事。使用 JavaScript token 配合
ajax_wait和page_wait,以便职位卡片得以填充;可以获取原始 HTML 自行解析,也可以传入autoparse: 'true'直接获得 JSON。 - cheerio 负责提取字段。选择每个职位卡片,然后读取标题、预算或时薪、技能、发布时间和链接,并预期选择器会随时间发生变化。
- 导出为 JSON 和 CSV。将结构化记录写入两种格式,展平技能数组并对 CSV 字段加引号,以防含有逗号的标题损坏数据。
- 仅限公开职位信息。仅采集公开的职位数据,绝不触碰自由职业者的个人资料或个人详情,尊重 Upwork 的服务条款和 robots.txt,并在涉及个人数据时遵守 GDPR 和 CCPA。
常见问题
我可以合法地从 Upwork 抓取哪些数据?
请仅限于公开职位信息:标题、预算或时薪、所需技能、发布时间以及每条招聘信息的链接,所有这些都无需登录即可在公开搜索页面上看到。请避免涉及自由职业者的个人资料、姓名、照片、联系方式、工作历史和收入,因为这些属于个人数据,受隐私法保护。本指南之所以将范围严格限定在公开职位信息,正是出于这个原因。
为什么普通请求会从 Upwork 返回不完整的数据?
因为 Upwork 使用 JavaScript 在客户端渲染其搜索结果,并且会对自动化流量发起挑战。来自数据中心 IP 的原始 HTTP 请求通常会返回空壳或屏蔽页面,而不是职位卡片。要获取完整页面,您必须在受信任的 IP 后面的真实浏览器中渲染它,这正是使用 JavaScript token 时 Crawling API 为您处理的事情。
我需要使用普通 token 还是 JavaScript token?
请使用 JavaScript token。Upwork 搜索页面是在浏览器中渲染的,因此使用普通 token 发出的请求只会返回一个未填充的空壳。JavaScript token 会在真实浏览器中运行页面,配合短暂的 page_wait 传入 ajax_wait,可以给页面脚本时间在 HTML 被捕获之前填充职位卡片。
我的选择器返回空值,发生了什么变化?
几乎可以肯定是 Upwork 的标记发生了变化。即使是这里使用的 data-test 属性也可能在没有通知的情况下发生变化,因此上个月还能正常工作的选择器可能会失效。请在浏览器开发工具中重新检查实时页面并更新选择器。定期维护选择器对于任何生产爬虫来说都是正常的。
我可以从 Upwork 抓取自由职业者的个人资料或联系方式吗?
不可以,本指南也未涵盖此内容。自由职业者的个人资料、姓名、联系方式、工作历史和收入属于个人数据,而非公开商业列表,因此抓取这些内容会带来 GDPR 和 CCPA 义务,并违反 Upwork 的条款。请将您的采集范围限定在公开职位信息,绝不建立个人档案,绝不为未经请求的推广采集联系方式。如需访问超出公开职位范围的授权数据,请使用 Upwork 的官方 API 或企业项目。
如何在抓取 Upwork 时避免被屏蔽?
保持较低的单 IP 请求频率,在每次页面抓取之间添加延迟,并通过轮换住宅 IP 进行路由,这样单个地址就不会触发速率限制或挑战。Crawling API 为您管理轮换和受信任的 IP 池;如果您自己搭建方案,这是最需要投入的部分。关注状态码,当您开始看到非 200 响应时及时退后一步。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
