GoodFirms 是一个 B2B 目录平台,专为买家与 IT 服务商、软件公司及代理机构之间的对接而生。每条公开列表均包含驱动竞争分析、市场规模评估与合作伙伴发现的结构化字段:公司名称、评分、服务类别、所在地、时薪区间及完整资料页链接。对于绘制某一垂直领域版图或构建候选供应商名单的人来说,这些公开目录数据就是原始素材,而手动跨数十家机构收集这些数据既费时又容易出错。

本指南将向你展示如何以可靠的方式使用 Python 抓取 GoodFirms。你将构建一个小而可运行的爬虫,通过 Crawling API 获取已渲染的 GoodFirms 页面,从分类列表中收集公司记录,使用 BeautifulSoup 解析字段,处理分页并导出整洁的 JSON 和 CSV 文件。整个演示始终聚焦于公开商业列表数据,文末的合法性章节并非套话,在将本工具指向任何真实流量之前请务必阅读。

你将构建什么

一个 Python 脚本,接收一个 GoodFirms 公开分类 URL,遍历分页搜索列表,对每家公司提取结构化记录,再进一步访问各公司的详情页获取更深层字段。以下以伦敦网站开发机构为运行示例,抓取这些字段:

  • 公司名称 目录卡片上显示的注册公司名。
  • 评分 列表上的公开评审分数。
  • 服务类别 该公司所列的服务分类或标语。
  • 所在地 卡片上显示的城市和国家。
  • 时薪 公司资料页中的时薪区间。
  • 资料页 URL 指向完整资料页的规范链接。

为什么普通请求在 GoodFirms 上会失败

用普通 HTTP 客户端请求 GoodFirms 分类或资料 URL,你往往会得到状态码 200,但响应体中只有一小部分列表数据。有两个因素对你不利。第一,GoodFirms 的大量目录网格和资料详情是通过 JavaScript 在浏览器中加载的,因此初始 HTML 只是一个薄壳,只有页面脚本运行后才能填充完整。从这第一次响应中提取公司卡片,你可能只能捕获到部分集合或遗漏延迟渲染的字段。第二,一个繁忙的 B2B 目录会监控自动化流量:来自数据中心 IP 或非浏览器请求模式的访问,在到达已渲染内容之前就会被限速、封锁或发起挑战。

因此,一个可用的 GoodFirms 爬虫需要在单次请求中同时做到两件事:一个能渲染页面的浏览器,以及一个平台视为真实访客的 IP。你可以自行使用无头浏览器加轮换住宅代理来实现这一点,但维护这套系统是大部分的工作量所在。Crawling API 将两者融合进一次调用:发送带 JavaScript token 的 URL,它在可信 IP 后方渲染页面,并返回已完成的 HTML 供解析。关于客户端渲染为何会破坏简单爬虫的背景知识,可参阅JavaScript 网站爬取指南

为什么需要 JS token

Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript (JS) token 会先在真实浏览器中渲染页面。由于 GoodFirms 的目录和资料页有部分内容在客户端加载,JS token 是此处的安全默认选择:它返回已完成的标记,而非普通请求所得的薄壳,让 BeautifulSoup 有实质内容可解析。

前提条件

在编写任何代码之前,你需要准备好几样东西,都不会花太长时间。

基础 Python 知识。 你应当能够编写并运行 Python 脚本,以及使用 pip 安装包。如果你对解析部分不熟悉,BeautifulSoup 指南是本教程的好伴侣,更广泛的Python 网站抓取教程则涵盖了基础知识。

Python 3.8 或更高版本。 使用 python --version 确认你的版本。如果没有,请从 python.org 安装或通过 Anaconda 等发行版安装,并确保 Python 在你的 PATH 中。

Crawlbase 账户和 JS token。 注册账户,打开控制台,从账户文档页面复制你的 JavaScript (JS) token。Crawlbase 提供 1,000 次免费请求,足够完成本指南的学习。请将 token 视为密码:它用于验证你的请求,因此不要将其提交到版本控制中。

设置项目

创建虚拟环境以隔离项目依赖,然后安装爬虫所需的库。

bash
python --version

python -m venv goodfirms_env
source goodfirms_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,请用 goodfirms_env\Scripts\activate 代替 source 那行来激活环境。两个依赖项各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML 以便按 CSS 选择器提取各字段。jsoncsv 均随标准库附带,导出步骤无需额外安装。

第一步:获取已渲染的 GoodFirms 页面

首先获取一个完整页面。导入 CrawlingAPI 类,用你的 JS token 初始化它,然后请求一个 GoodFirms 分类 URL。传入 ajax_waitpage_wait,让 API 在捕获页面前等待动态内容加载完毕。在解析前检查 Crawlbase 的 pc_status,可以让失败情况显而易见而非悄无声息。

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

OPTIONS = {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/122.0",
    "ajax_wait": "true",
    "page_wait": 5000,
}

def crawl(page_url):
    response = api.get(page_url, OPTIONS)
    if response["headers"]["pc_status"] == "200":
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

if __name__ == "__main__":
    listing_url = "https://www.goodfirms.co/companies/web-development-agency/london"
    html = crawl(listing_url)
    print(html[:500] if html else "No HTML returned")

两个等待选项对于客户端渲染目标至关重要。ajax_wait 告知 API 等待异步内容加载,page_wait 在页面加载后固定等待指定毫秒数,以确保延迟渲染的卡片在捕获前出现。五秒是一个合理的起点;如果结果返回较少,可适当增大。用 python goodfirms_scraper.py 运行脚本,你应当看到真实的 GoodFirms 目录标记,而非普通请求返回的薄壳。这可以在编写任何选择器之前确认渲染是否正常工作。

Crawlbase Crawling API

GoodFirms 需要在一次调用中同时完成页面渲染和可信 IP,而这正是上面 ajax_waitpage_wait 选项所实现的。Crawling API 接收 JS token,在真实浏览器中运行页面,在服务端轮换住宅 IP,并将已完成的 HTML 交给你,省去了自行运营无头浏览器集群和代理池的麻烦。先用免费额度对公开分类页面进行测试。

第二步:确定列表选择器

在编写解析器之前,在浏览器开发者工具中检查分类页面(右键选择"检查",或按 Ctrl + Shift + I),找出包裹每家公司的元素。在 GoodFirms 搜索列表中,每个公司卡片位于一个列表项中,字段对应以下选择器:

  • 公司名称 位于 class 为 firm-name<h3> 中。
  • 所在地 是 class 为 firm-location<div>
  • 服务类别 是嵌套在 firm-content 下、class 为 tagline<div>
  • 评分 出现在 class 为 rating-number<span> 中。
  • 资料页 URLfirm-urls 内、class 为 visit-profile<a>href

有了这些,将渲染后的 HTML 加载到 BeautifulSoup 中,并从每个卡片中提取各字段。每次查找都有防护,确保缺失字段时返回默认值而不是让运行崩溃。

python
from bs4 import BeautifulSoup

def extract_company(card):
    name = card.select_one("h3.firm-name")
    location = card.select_one("div.firm-location")
    category = card.select_one("div.firm-content > div.tagline")
    rating = card.select_one("span.rating-number")
    link = card.select_one("div.firm-urls > a.visit-profile")

    return {
        "name": name.get_text(strip=True) if name else "",
        "location": location.get_text(strip=True) if location else "",
        "category": category.get_text(strip=True) if category else "",
        "rating": rating.get_text(strip=True) if rating else "No rating",
        "profile_url": link["href"] if link else "",
    }

def parse_listings(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select("ul.firm-directory-list > li.firm-wrapper")
    return [extract_company(card) for card in cards]

容器选择器 ul.firm-directory-list > li.firm-wrapper 从目录列表向下定位到每个公司卡片,extract_company 从中读取五个字段。内联防护确保某个卡片缺少评分时不会中断循环,而是回退到 "No rating"

选择器会漂移

目录网站会在不通知的情况下修改标记,生成的 class 名称在不同访问之间也可能发生变化。将此处的选择器视为起始模板,而非合同。当列表返回为空时,在浏览器开发者工具中重新检查实时页面并更新选择器。定期维护选择器对于任何生产爬虫来说都是正常的,并不意味着出了什么问题。

第三步:处理列表页的分页

一个分类页面只是结果集的一部分。GoodFirms 使用 page 查询参数进行分页,因此你需要遍历每一页并收集记录。在 fetch 函数外面加一个小型重试包装器,可以避免单页加载缓慢导致整个运行中断。

python
import time

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        html = crawl(page_url)
        if html:
            return html
        if attempt < max_retries:
            print(f"Retrying ({attempt + 1}/{max_retries})...")
            time.sleep(1)
    print(f"Unable to fetch {page_url}")
    return None

def scrape_all_pages(base_url, num_pages=5):
    all_companies = []
    for page in range(1, num_pages + 1):
        url = f"{base_url}?page={page}"
        print(f"Scraping page {page}...")
        html = fetch_html(url)
        if html:
            all_companies.extend(parse_listings(html))
        time.sleep(2)
    return all_companies

fetch_html 在失败时最多重试两次,每次附带短暂停顿,成功则返回 HTML,放弃后返回 Nonescrape_all_pages 拼接 page 参数,解析卡片,并通过 num_pages 上限控制爬取数量,防止大型分类无限延伸。两页之间的 time.sleep(2) 控制运行节奏。

第四步:组装完整的列表爬虫

现在将各部分串接成一个可运行的脚本:遍历各页,收集公司记录,并将结果导出为 JSON 和 CSV。

python
import csv
import json
import time
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

OPTIONS = {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/122.0",
    "ajax_wait": "true",
    "page_wait": 5000,
}

def crawl(page_url):
    response = api.get(page_url, OPTIONS)
    if response["headers"]["pc_status"] == "200":
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        html = crawl(page_url)
        if html:
            return html
        if attempt < max_retries:
            time.sleep(1)
    return None

def extract_company(card):
    name = card.select_one("h3.firm-name")
    location = card.select_one("div.firm-location")
    category = card.select_one("div.firm-content > div.tagline")
    rating = card.select_one("span.rating-number")
    link = card.select_one("div.firm-urls > a.visit-profile")

    return {
        "name": name.get_text(strip=True) if name else "",
        "location": location.get_text(strip=True) if location else "",
        "category": category.get_text(strip=True) if category else "",
        "rating": rating.get_text(strip=True) if rating else "No rating",
        "profile_url": link["href"] if link else "",
    }

def parse_listings(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select("ul.firm-directory-list > li.firm-wrapper")
    return [extract_company(card) for card in cards]

def scrape_all_pages(base_url, num_pages=5):
    all_companies = []
    for page in range(1, num_pages + 1):
        url = f"{base_url}?page={page}"
        print(f"Scraping page {page}...")
        html = fetch_html(url)
        if html:
            all_companies.extend(parse_listings(html))
        time.sleep(2)
    return all_companies

def save_outputs(records):
    with open("goodfirms_companies.json", "w") as f:
        json.dump(records, f, indent=2)
    if not records:
        return
    with open("goodfirms_companies.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=records[0].keys())
        writer.writeheader()
        writer.writerows(records)

def main():
    base_url = "https://www.goodfirms.co/companies/web-development-agency/london"
    companies = scrape_all_pages(base_url, num_pages=3)
    save_outputs(companies)
    print(f"Saved {len(companies)} companies")

if __name__ == "__main__":
    main()

该脚本最多遍历三个分类页面,将每页解析为记录,并用两秒延时控制循环节奏。save_outputs 以第一条记录的键名作为标题,同时写出 JSON 和 CSV,让你的下游工具可以使用任意格式。根据你的目标垂直领域和城市调整 num_pages 和分类 URL。

输出结果示例

python goodfirms_scraper.py 运行完整脚本,你将获得每家公司的整洁结构化记录,可直接用于分析、入库或制作电子表格。

json
[
  {
    "name": "Unified Infotech",
    "location": "London, United Kingdom",
    "category": "Driving Digital Transformation with Advanced Tech",
    "rating": "5.0",
    "profile_url": "https://www.goodfirms.co/company/unified-infotech"
  },
  {
    "name": "instinctools",
    "location": "London, United Kingdom",
    "category": "Building Custom Software Solutions",
    "rating": "4.9",
    "profile_url": "https://www.goodfirms.co/company/instinctools"
  }
]

对应的 CSV 包含相同的列,每家公司一行,可直接导入 pandas 或任何电子表格,按评分、地点或服务类别进行筛选。

第五步:抓取公司详情页

列表给你广度,详情页给你深度。每个 profile URL 指向一个包含公司完整描述、时薪区间、团队规模、成立年份和服务的页面。以同样的方式检查详情页,更深层的字段对应以下选择器:

  • 公司名称 是带 itemprop="name"<h1>
  • 公司介绍 是 class 为 profile-summary-text<div>
  • 时薪div.profile-pricing 内的 <span>
  • 员工人数div.profile-employees 内的 <span>
  • 成立年份div.profile-founded 内的 <span>
  • 服务 来自 ul.services-chart-list 中每个 <button>data-name 属性。
python
import re
import json
import time
from bs4 import BeautifulSoup

def text_of(soup, selector, default="N/A"):
    el = soup.select_one(selector)
    return el.get_text(strip=True) if el else default

def extract_profile(html, url):
    soup = BeautifulSoup(html, "html.parser")
    summary = soup.select_one("div.profile-summary-text")
    description = re.sub(r"\s+", " ", summary.get_text(strip=True)) if summary else "N/A"
    services = [b["data-name"] for b in soup.select("ul.services-chart-list button[data-name]")]

    return {
        "name": text_of(soup, 'h1[itemprop="name"]'),
        "profile_url": url,
        "description": description,
        "hourly_rate": text_of(soup, "div.profile-pricing > span"),
        "no_of_employees": text_of(soup, "div.profile-employees > span"),
        "year_founded": text_of(soup, "div.profile-founded > span"),
        "services": services,
    }

def scrape_profiles(profile_urls):
    profiles = []
    for url in profile_urls:
        print(f"Scraping profile: {url}")
        html = fetch_html(url)
        if html:
            profiles.append(extract_profile(html, url))
        time.sleep(2)
    return profiles

if __name__ == "__main__":
    profile_urls = [
        "https://www.goodfirms.co/company/unified-infotech",
        "https://www.goodfirms.co/company/instinctools",
    ]
    data = scrape_profiles(profile_urls)
    with open("goodfirms_profiles.json", "w") as f:
        json.dump(data, f, indent=2)
    print(f"Saved {len(data)} profiles")

此处复用了列表爬虫中的 fetch_html 包装器,渲染、重试和 JS token 都一并沿用。re.sub(r"\s+", " ") 调用将公司介绍中常见的多余空白压缩掉,服务列表则从每个图表按钮的 data-name 属性中读取。典型的详情记录如下:

json
{
  "name": "Unified Infotech",
  "profile_url": "https://www.goodfirms.co/company/unified-infotech",
  "description": "Unified Infotech is a digital transformation partner serving enterprises with custom web, mobile, and software solutions...",
  "hourly_rate": "$50 - $99/hr",
  "no_of_employees": "50 - 249",
  "year_founded": "2010",
  "services": [
    "Web Development",
    "Software Development",
    "Web Designing (UI/UX)",
    "Mobile App Development",
    "E-commerce Development"
  ]
}

将列表步骤中获取的 profile_url 值传入 scrape_profiles,即可在同一次运行中获取每家公司的时薪区间和服务信息,将广度与深度合并到一个数据集中。

规模化时保持不被封锁

即使渲染已处理好,繁忙的目录仍会监控爬虫形态的流量。在任何商业目标上进行较长时间运行时,以下几个习惯有助于保持健康状态。

  • 控制请求节奏。 在紧密循环中大量请求列表是被限速或发起挑战的最快方式。上面的两秒延时是下限,而非上限。对于较大的任务适当加宽,并分散目标而非以全速爬取单一分类。
  • 依赖轮换。 住宅 IP 池将请求分散到众多真实用户地址,使单个地址不会触发速率限制。Crawling API 会替你处理这个问题;如果你自行搭建,这是需要着重处理的部分。
  • 关注状态码。 运行开始返回非 200 的 pc_status 值,说明当前的速率或 IP 级别已经不够用了。将此视为退让的信号,而不是可以忽略的噪音。

对于较大规模的爬取,异步 Crawler 可以将请求排入队列并通过 webhook 返回结果,适合在不保持长连接的情况下运行多个分类页面。关于更广泛的操作指南,请参阅如何不被封锁地抓取网站。如果你要绘制更广泛的 B2B 供应商版图,同样的方法同样适用于抓取 ClutchSuperpages 以及其他本地商业目录

抓取 GoodFirms 是否合法?

抓取 GoodFirms 是否被允许,取决于其服务条款、你所在的司法管辖区以及你对数据的使用方式。GoodFirms 在其条款中限制了自动访问和批量采集,因此无论你的工具多么谨慎,抓取行为都可能与其条款相抵触。此处的代码并不改变这一点,只是让技术层面运作起来。在开始之前,请阅读 GoodFirms 的服务条款和 robots.txt,遵守他们声明的速率限制和爬取指令,并将请求量控制在不对其服务器造成负担的范围内。

范围和礼貌同样重要。请只针对公开的商业列表数据:任何访客无需账户即可看到的公司名称、评分、服务类别、地点、时薪区间和资料链接。公司详情页也可能显示联系人信息,一旦你收集或存储任何可识别个人的内容,数据保护法律即适用。在 GDPR 下,处理个人数据需要合法依据,个人可要求删除;如果你将收集到的联系信息用于推广,GDPR 和美国 CAN-SPAM 法案等制度均有约束:你需要同意或其他合法依据、准确的发件人信息以及有效的退出机制。如有人或企业要求停止联系,你必须尊重该要求。不要抓取登录墙后面的任何内容,也不要大量转发 GoodFirms 自有的编辑内容或评论文字,这些内容受版权保护。

本指南故意将范围限定在公开列表和资料页面,因为这是保持工作可辩护的边界。它不涵盖任何需要账户的内容、批量采集个人联系方式,或任何绕过身份验证的尝试。仅限公开商业数据。如果你的项目需要更多,正确的路径是获得许可:GoodFirms 通过自有渠道和合作伙伴安排发布数据,请先确认官方 API 或授权数据源是否覆盖你的使用场景。这才是商业或批量使用的正确途径,而不是更聪明的爬虫。

回顾

核心要点

  • GoodFirms 的部分页面是客户端渲染的。 普通请求可能返回薄壳,因此请在解析前使用 JS token 渲染页面。
  • 你需要渲染和可信 IP 的结合。 带 JS token 的 Crawling API 可在一次调用中完成两者;ajax_waitpage_wait 控制等待内容的时长。
  • 分两层工作。 从分类列表中解析公司名称、评分、服务、地点和资料链接,然后访问各 profile URL 获取时薪区间、团队规模和服务列表。
  • 分页并导出。 遍历 page 查询参数至设定上限,用短暂延时控制运行节奏,并将记录写出为 JSON 和 CSV。
  • 仅针对公开商业数据。 遵守 GoodFirms 的服务条款和 robots.txt,将任何个人联系信息视为受 GDPR 和 CAN-SPAM 约束的数据,需要合法依据和有效的退出机制,且不得触碰登录内容或受版权保护的编辑内容。

常见问题

为什么普通请求只返回部分 GoodFirms 数据?

因为 GoodFirms 的目录网格和资料详情有部分是通过 JavaScript 在客户端加载的。初始 HTML 可能只是一个薄壳,只有脚本运行后才会填充完整。因此,普通请求可能返回状态 200,但卡片或资料字段缺失。需要先渲染页面才能获取完整数据,这正是 Crawling API 的 JS token 所处理的。

抓取 GoodFirms 需要普通 token 还是 JS token?

使用 JS token。普通 token 只获取静态 HTML,可能遗漏 GoodFirms 中在浏览器中渲染的部分。JS token 先在真实浏览器中运行页面,确保公司卡片和资料字段在 BeautifulSoup 解析时已完整呈现。

我可以从 GoodFirms 抓取哪些数据?

公开的商业列表字段:公司名称、评分、服务类别或标语、地点、时薪区间、团队规模、成立年份以及资料链接。请仅限于任何访客无需账户即可看到的数据,并将任何已命名个人的联系方式视为超出本指南涵盖范围的个人数据。

我的选择器返回空结果。发生了什么?

几乎可以肯定是 GoodFirms 的标记发生了变化。firm-namefirm-locationrating-number 等 class 名称以及 profile-pricingservices-chart-list 等资料容器,都可能在没有通知的情况下发生变化,导致上个月还有效的选择器失效。请在浏览器开发者工具中重新检查实时页面并更新选择器。定期维护选择器对于任何生产爬虫来说都是正常的。

如何处理分类下的分页?

GoodFirms 在分类 URL 后附加 page 查询参数。在循环中遍历各页,解析每页上的公司卡片,通过 num_pages 上限控制爬取范围,并在两页之间添加短暂延时。上面的 scrape_all_pages 函数展示了完整的循环逻辑。

我可以将抓取到的 GoodFirms 数据用于推广或商业目的吗?

这是一个法律问题,而非技术问题。你收集到的任何联系信息都是个人数据,因此推广行为受到 GDPR 和美国 CAN-SPAM 法案等制度的约束:你需要合法依据或同意、准确的发件人信息以及有效的退出机制,且个人可要求停止联系。GoodFirms 的服务条款也限制了对其内容的再利用。请在此类用途之前审阅相关条款,确认官方 API 或授权数据源是否覆盖你的使用场景,并在基于这些数据构建产品或推广名单之前寻求法律建议。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

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