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 网站

JavaScript token

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:它用于验证您的请求,因此请勿将其提交到版本控制系统中。

搭建项目

创建项目文件夹,初始化项目,并安装爬虫所需的两个库。

bash
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 被捕获之前完成执行。在解析之前检查状态码,可以让失败情况更加明显,而不是悄无声息地出错。

javascript
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_waitpage_wait 选项告诉 API 在真实浏览器中运行页面,并给其脚本时间来填充职位卡片,然后再捕获 HTML。如果您不想手动编写选择器,而是希望 API 为您解析常见字段,可以传入 autoparse 选项,这样您将直接获得结构化的 JSON。

javascript
// 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));
Crawlbase Crawling API

刚才那次请求就已经返回了一个完整渲染的 Upwork 搜索页面,您无需在本地运行无头浏览器或代理。Crawling API 在真实浏览器中运行页面,等待其 JavaScript 填充职位卡片,在服务端轮换住宅 IP,并处理 Upwork 对爬虫发起的各种挑战,因此您只需一次调用就能获得完整的 HTML(或自动解析的 JSON)。先在免费套餐中对公开职位搜索进行测试。

第 2 步:使用 cheerio 解析每条职位信息

获取到渲染后的 HTML 后,将其加载到 cheerio 中并遍历职位卡片。Upwork 在搜索结果页面上将每条招聘信息布局在一个重复的 article 容器中,因此您先选择所有卡片,再从每张卡片内部读取标题、预算或时薪、技能、发布时间和链接。防御性地读取每个字段可以防止单个缺失值导致整个运行崩溃。

javascript
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 解析器所期望的输入。

javascript
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.csvtoCsv 辅助函数将技能数组展平为以分号分隔的单个单元格,对每个字段加引号,并将嵌入的引号加倍,这一点很重要,因为职位标题中经常包含逗号。

输出结果示例

JSON 文件按搜索结果顺序为每条招聘信息保存一个对象,每个对象包含标题、预算或时薪、技能、发布时间和链接。

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 或任何读取分隔文件的数据管道。

csv
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 参数,因此您可以构建一个搜索列表,逐页翻看每个搜索结果,用同一个函数解析每一页,并在导出之前给每行数据打上对应的查询标签。由于每个搜索结果页面共享相同的卡片结构,您已经编写好的解析器可以无需修改地适用于所有页面。

javascript
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_waitpage_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 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量