一个装满抓取下来的公司页面的文件夹,只能告诉你每个网站如何描述自己。要跨这些页面回答一个问题,还需要多走一步:识别出同一批人、工具和城市出现在多个站点上,并把它们与提到它们的公司连接起来。哪些公司共用同一个集成?谁的名字出现在不止一个网站上?它们的办公地点在哪里重叠?

本文构建一条 Python 流水线:用 Crawling API 发现公司网站上的相关页面,以 Markdown 形式抓取这些页面,让 LLM 按固定模式抽取实体和关系,校验每条支撑引文确实出现在页面上,对名称做规范化,最后把结果载入 Neo4j,写入方式是 MERGE。随后由 Cypher 回答跨公司的问题,再由一个小型 GraphRAG 层把一句自然语言提问转成查询,并给出引用来源页面的答案。

代码位于 ScraperHub/build-a-knowledge-graph-from-crawled-websites-with-neo4j:九个分步演练脚本位于 steps/,每个脚本对应一步转换;带缓存、Docker 配置和测试的打包版 CLI 则位于 final/。下文的数字来自2026年10月29日对 Basecamp、HEY、Pipedrive 和 Zendesk 所做的记录运行。

简而言之
  • 图中的每一条关系都保留页面 URL、已存页面的 rid、抽取时间戳以及支撑它的引文,因此任何一条边都能追溯到生成它的 Markdown。
  • LLM 负责提议,应用负责裁决。引文不在捕获页面上的实体会被丢弃,名称按标签逐一规范化,而 MERGE 让同一批数据的第二次载入成为空操作。
  • 由于 Markdown 保存在 Cloud Storage 中,新的模式或提示词可以针对同一批捕获内容重新运行,无需重新抓取。
每个阶段都有自己的证据边界。Crawlbase 负责抓取并保存页面;应用负责解释页面并拥有图谱。

为你想查询的关系建模

知识图谱把实体存为节点,并用带类型的关系把它们连接起来。在这里,一个 Person 可以 WORKS_AT 一家 Company,而一家 Company 可以 INTEGRATES_WITH 一个 Integration。关系型表也能存放同样的事实;选择 Neo4j 的理由在于,一次遍历会变成一个看得见的模式。(a:Company)-[:INTEGRATES_WITH]->(i)<-[:INTEGRATES_WITH]-(b:Company) 读作“两家公司连接到同一个集成”,而且随着问题增加跳数,它依然易读。

词表刻意保持很小:五个标签(Company,Person,Product,Integration,Location)和五种关系类型(WORKS_AT,OFFERS,INTEGRATES_WITH,PARTNERS_WITH,LOCATED_IN)。小词表在抽取时更容易校验,之后让模型写 Cypher 时也更容易约束。

准备演练环境和数据库

你需要 Python 3.11+、带 Compose 的 Docker、一个拥有 Normal token 和 JavaScript token 的 Crawlbase 账号,以及一个 OpenAI API 密钥。仓库使用 gpt-4o-mini 配合 Pydantic 结构化输出,并在本地运行 Neo4j 5 Community;依赖版本固定于 steps/requirements.txt。克隆仓库,创建虚拟环境,然后复制两个环境变量模板:

bash
git clone https://github.com/ScraperHub/build-a-knowledge-graph-from-crawled-websites-with-neo4j.git
cd build-a-knowledge-graph-from-crawled-websites-with-neo4j
python -m venv .venv
source .venv/bin/activate
cp steps/.env.example steps/.env
cp final/.env.example final/.env

在启动数据库之前,把两个 .env 文件都填上同一个 Neo4j 密码。演练脚本读取 steps/.env;Docker Compose 和打包版 CLI 读取 final/.env。两者使用相同的键:

text
CRAWLBASE_TOKEN=YOUR_CRAWLBASE_TOKEN
CRAWLBASE_JS_TOKEN=YOUR_CRAWLBASE_JS_TOKEN
OPENAI_API_KEY=YOUR_OPENAI_API_KEY
OPENAI_MODEL=gpt-4o-mini
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=YOUR_NEO4J_PASSWORD

然后安装演练依赖并启动 Neo4j:

bash
pip install -r steps/requirements.txt
cd final && docker compose up -d --wait

按编号顺序运行 steps/下的脚本。每个脚本都把输出以 JSON 形式写到 steps/.cache/下,后面的脚本读取前面脚本写下的内容。请使用专门的教程数据库,因为 06_load_into_neo4j.py 会在演示两次连续载入之前清空所有已有图数据。Neo4j Browser 位于 http://localhost:7474。

从首页链接发现公司页面

发现阶段用 scraper=generic-extractor,也就是 Crawlbase 抓取器之一,去请求每个首页,并读取它返回的链接。在照着文档写代码之前,有一个来自记录运行的发现值得了解:文档的模式列出的字段包括 main_content 和 headings,而实际响应携带的则是 title,meta,content,images 和 links,以及其他一些字段。SDK 把解析后的页面暴露为 response["json"]。在五个记录的首页中,424 个链接里有 422 个已经是绝对的 HTTP(S) URL;另外两个是 mailto: 和 tel:。代码仍会用 urljoin 解析相对链接并丢弃非页面协议,因此即使响应形状变化,它也能继续工作。

第一个脚本保留同主机链接中最后一段路径匹配公司页面模式的那些:

python
PAGE_TYPES = {
    "about": r"^(about|company|our-story)",
    "team": r"^(team|our-team|leadership|people)",
    "partners": r"^partner",
    "integrations": r"^(integration|marketplace)",
    "careers": r"^(career|jobs)",
}
MAX_PAGES = 8


def host(url: str) -> str:
    return (urlsplit(url).hostname or "").lower().removeprefix("www.")


def page_type(url: str) -> str | None:
    segments = [s for s in urlsplit(url).path.lower().split("/") if s]
    for name, pattern in PAGE_TYPES.items():
        if segments and re.match(pattern, segments[-1]):
            return name
    return None

匹配最后一段意味着 /company/legal 不会被当成“关于我们”页面,哪怕它的父目录叫 company。限制只作用于发现出的页面;首页单独抓取。这是有边界的首页链接发现:分页之后的页面、更深层的导航或招聘子域名需要额外规则。

SDK 调用把种子首页和响应实际落地的 URL 都传给发现阶段,两者各有用途。在一次记录的 Zendesk 请求中,站点重定向到了 zendesk.de 的同时,它的导航仍然链接到 zendesk.com;按最终主机过滤会丢掉所有相关链接,按种子主机过滤则能保留它们,同时最终 URL 仍作为解析相对路径的基准。Crawlbase 会替你选择出口国家,所以要显式处理重定向,而不是假定落地主机。演练产生了:

text
Basecamp: 52 links, 2 company pages (landed on https://basecamp.com/)
  about         https://basecamp.com/about
  integrations  https://basecamp.com/integrations
HEY: 54 links, 0 company pages (landed on https://www.hey.com/)
Pipedrive: 103 links, 4 company pages (landed on https://www.pipedrive.com/)
  integrations  https://www.pipedrive.com/en/marketplace
  about         https://www.pipedrive.com/en/about
  careers       https://www.pipedrive.com/en/jobs
  partners      https://www.pipedrive.com/en/partner
Zendesk: 125 links, 4 company pages (landed on https://www.zendesk.com/)
  integrations  https://www.zendesk.com/marketplace
  ...

本次运行中,HEY 的首页没有匹配到任何公司页面 URL,所以只有它的首页进入下一步。这说明的是规则的覆盖范围,而不是站点本身。打包版的发现模块还会把浅路径排在前面,并把选择分散到不同页面类型上。

抓取 Markdown,并保留每条事实背后的输入

抽取需要真正的页面,包括首页摘要会省略的团队网格和集成目录。抓取阶段以 format=md 加 store=true发起请求,并把 Cloud Storage 存放该 Markdown 所用的 rid 与文本本身放在一起保留。

可读性过滤会裁掉导航和样板内容,但记录的调用显示,它也可能恰好删掉这个图谱需要的结构化部分:

页面 md_readability=true 纯 format=md
close.com/about 469 个字符,领导团队网格被省略 13,722 个字符
pipedrive.com/en/about 504 个字符 11,187 个字符
close.com/integrations 260 个字符 41,290 个字符

因此抓取步骤先尝试可读性模式,再回退到纯 Markdown,最后回退到 JavaScript token:

python
def fetch_page(url: str) -> dict:
    """Readability first, plain Markdown if that drops the content, JS token if the page renders in the browser."""
    for token, readability, min_chars in ((CRAWLBASE_TOKEN, True, 1500),
                                          (CRAWLBASE_TOKEN, False, 300),
                                          (CRAWLBASE_JS_TOKEN, False, 0)):
        response = fetch(url, token, readability)
        if len(response.text) >= min_chars:
            break
    rid = httpx.URL(response.headers.get("storage_url", "")).params.get("rid", "")
    return {"url": url, "markdown": response.text, "rid": rid, "readability": readability,
            "js": token == CRAWLBASE_JS_TOKEN}

1,500 和 300 个字符的阈值是应用层的启发式规则:一个很长的导航外壳可以通过阈值却不含你要的数据,而一个很短的正常页面也可能触发不必要的重试。对于你已经知道需要浏览器渲染的目标,打包版 CLI 接受种子 CSV 中的 use_js=true 标记,并直接从 JavaScript token 开始。记录的对比说明了为什么渲染应当按目标决定:Linear、Monday 和 Figma 在两种 token 下的 Markdown 长度完全相同,Notion 也很接近,Linear 的集成页面两种方式都返回 24,218 个字符,而 quotes.toscrape.com/js 用 Normal token 返回 42 个字符,用 JavaScript token 返回 887 个。现代公司网站往往在服务端渲染;但这不能证明每个站点都如此。

响应中有两个细节与溯源相关。对于一个 format=md 请求,若带上 store=true,那么 rid 会出现在 storage_url 响应头里,而不是作为独立的响应头返回;而且这个 URL 包含账号 token,所以代码只保留 rid 这一项,从不记录该 URL。这些抓取还改用 httpx 来完成,因为固定版本的 Crawlbase Python SDK 只会从响应中保留 original_status,cb_status 和 url 这三个字段,并丢弃 storage_url。每次回退都是一次单独的请求,拥有自己的 rid,因此你保留的 rid 必须属于你实际用来抽取的那份 Markdown:

text
Basecamp     3253 chars  readability  rid=abac8926164450fab0456a27  https://basecamp.com/about
Basecamp    16676 chars  plain        rid=86c17c449811ab0e0f861dd3  https://basecamp.com/integrations
Pipedrive   17742 chars  plain        rid=d2ef101d7ac8d30bf3a26a5a  https://www.pipedrive.com/en/marketplace
Pipedrive   11187 chars  plain        rid=d8d8e67054e10a90c81fa2cb  https://www.pipedrive.com/en/about
Zendesk      8267 chars  plain        rid=78b15c345cde1256878b45bd  https://www.zendesk.com/company

有一次记录的请求返回了 HTTP 520,带着 cb_status: 0 和一个说明本次调用未计费的响应体;重试后成功。打包版抓取客户端加入了传输层重试和请求节奏控制,但一个很长的错误响应体并不是页面内容。抽取之前先检查状态。

Crawlbase Crawling API

把任意公开页面抓取为 Markdown,需要时在浏览器中渲染,并把你抽取所用的那份副本按请求 ID 保存在 Cloud Storage 中。失败的请求不计费。注册即可获得最多 5,000 次免费请求,无需绑卡。

在调用模型之前定义抽取契约

要让图谱可查询,模型的输出需要固定的词表和可预测的字段。它填充一个与标签和关系类型一一对应的 Pydantic 模式,每个实体和关系都带有一个 source_quote 字段,供后续检查使用:

python
class Evidence(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str = Field(min_length=1)
    source_quote: str = Field(min_length=3, description="Exact words copied from the page.")


class Company(Evidence): pass
class Product(Evidence): pass
class Integration(Evidence): pass


class Person(Evidence):
    title: str | None
    employer: str | None


class Location(Evidence):
    country: str | None


class Relationship(BaseModel):
    model_config = ConfigDict(extra="forbid")
    source: str
    type: Literal["WORKS_AT", "OFFERS", "INTEGRATES_WITH", "PARTNERS_WITH", "LOCATED_IN"]
    target: str
    source_quote: str = Field(min_length=3)

完整的模式模块还定义了顶层列表,并通过解析一个合法载荷、拒绝一个非法载荷来演示这份契约:

text
Good output parses: Paulo Cunha
Bad output rejected with 2 errors:
   people.0.source_quote -> Field required
   relationships.0.type -> Input should be 'WORKS_AT', 'OFFERS', 'INTEGRATES_WITH', 'PARTNERS_WITH' or 'LOCATED_IN'

结构化输出只约束形状,仅此而已。它们无法确定某个人就职于种子公司,也无法确定某个被点名的工具就是一个集成。这些规则由抽取提示词提供:使用种子公司的名称,排除客户证言,抽取具名工具而不是“500+ 个集成”这类计数,并且逐字复制支撑文本而不做改写。模型最多接收每个页面的 40,000 个字符,这限定了输入,但也可能丢失长目录末尾的条目;带重叠的分块加上一轮去重可以扩大覆盖范围,而本实现选择了截断。

在创建关系之前先检查引文

模式校验之后,引文守卫会检查每条支撑引文是否出现在捕获的 Markdown 中。它先规范化 Unicode、Markdown 标记和空白,然后丢弃引文缺失的实体。下面的片段是演练版守卫中过滤实体的部分;完整函数还会继续检查雇主和关系端点:

python
def guard(result, page: str, company: str):
    """Keep entities whose quote is on the page and relationships whose endpoints survived."""
    page_text = normalize(page)
    found = lambda quote: len(quote) > 2 and normalize(quote).rstrip(".,;:!?") in page_text  # noqa: E731
    dropped = []
    for field in ("companies", "people", "products", "integrations", "locations"):
        items = getattr(result, field)
        dropped += [f"{field}: {e.name}" for e in items if not found(e.source_quote)]
        setattr(result, field, [e for e in items if found(e.source_quote)])

雇主过滤会移除模型挂到明显不同公司名下的人;没有雇主值的人会保留。打包版抽取模块还会检查端点标签,并把“Stripe for Pipedrive”这类集成名称标准化。记录的输出同时显示了有用的拒绝和损失的召回:

text
https://basecamp.com/integrations: 19 relationships, 4 dropped
   dropped integrations: Trello
   dropped locations: Chicago
   dropped relationship: Basecamp INTEGRATES_WITH Trello
   dropped relationship: Basecamp LOCATED_IN Chicago
https://www.pipedrive.com/en/partner: 1 relationships, 1 dropped
   dropped relationship: Pipedrive PARTNERS_WITH Zimple

这几行里藏着三种不同的失败。Chicago 的引文根本不在页面上。Trello 在页面上,但模型改写了它的引文,所以匹配失败。Zimple 的关系在检查运行之前就丢失了它的公司端点。三者都会阻止一条关系的生成;其中只有一个是幻觉。

引文匹配证明文本被捕获了,不证明主张为真。推断出的连接是应用逻辑,应当如实记录。

这就是要牢记的边界。引文匹配证明这些词出现在页面上;它不证明解释是对的,而且一个无关的句子也可能通过检查,却支撑了错误的实体、头衔或国家。流水线还会添加模型没有陈述的关系:保留下来的人会得到一条指向种子公司的 WORKS_AT 关系,公司则会得到指向其他保留实体的 OFFERS,INTEGRATES_WITH 和 LOCATED_IN 边,每条边都复用该实体的引文。这些是推断出的连接,它们可能重新造出一条自身引文刚刚失败的关系。对于任何有实际影响的用途,请核实完整的主语-谓语-宾语主张,并把推断出的边单独记录。

规范化名称,然后用 MERGE 载入

即便是有依据的抽取,也会用不同方式称呼同一个工具。载入之前,规范化步骤先剥掉少数域名和法律后缀,转小写并去掉标点,然后让 RapidFuzz 合并得分在 90 分及以上的名称,并优先用种子公司名作为代表:

python
def key(name: str) -> str:
    """"Stripe, Inc." and "stripe.com" both become "stripe"."""
    name = re.sub(r"\.(com|io|ai|co|app)$", "", name.lower().strip())
    return " ".join(t for t in re.sub(r"[^\w\s&+]", " ", name).split() if t not in SUFFIXES)
text
'Stripe' vs 'Stripe, Inc.': score 100, merged=True
'Salesforce' vs 'Salesforce.com': score 100, merged=True
'Notion' vs 'Motion': score 83, merged=False

这是近似的名称匹配,不是身份消解。两个人可能同名,名字相近的企业可能毫无关系,而一个城市需要国家。打包版实现只在同一标签内匹配,所以叫同一个名字的 Company 和 Integration 仍然是两个独立节点。公司域名、人员标识符和城市-国家键能为更大的图谱提供更强的身份。

随后加载器使用 MERGE 语句,让匹配到的节点和关系被复用而不是重复创建:

python
NODE = """
UNWIND $rows AS row
MERGE (n:{label} {{name: row.name}})
SET n += row.props,
    n.source_urls = CASE WHEN row.url IN coalesce(n.source_urls, []) THEN n.source_urls
                    ELSE coalesce(n.source_urls, []) + row.url END,
    n.rids = CASE WHEN row.rid IN coalesce(n.rids, []) THEN n.rids ELSE coalesce(n.rids, []) + row.rid END
"""

REL = """
UNWIND $rows AS row
MATCH (a:{source} {{name: row.source}}), (b:{target} {{name: row.target}})
MERGE (a)-[r:{type} {{source_url: row.url}}]->(b)
SET r.rid = row.rid, r.extracted_at = row.extracted_at, r.source_quote = row.source_quote
"""

每个标签都有一条唯一性约束,约束字段是 name。一条关系的身份由它的端点、类型和 source_url共同决定,所以由两个页面支撑的同一个连接会变成两条边,各自带有自己的引文以及各自的 rid 和时间戳。把同一份抽取数据集载入两次,可以看到幂等性:

text
After load 1: 90 nodes, 119 relationships
After load 2: 90 nodes, 119 relationships
MERGE 让重复载入成为空操作。它不会撤销某个页面后来不再陈述的事实。

打开 Neo4j Browser,运行 MATCH p=(c:Company)-[]->() RETURN p LIMIT 100 查看结果。不过,重复载入并不会让它变成历史图谱或同步图谱。如果某个页面不再提及某个集成,旧边会一直留着,直到有什么把它删掉;而之后再载入同一条边,会就地覆盖它的引文以及 rid 和时间戳。要保留历史或撤销陈旧主张,需要带版本的观测记录或逐页对账步骤。

跨公司网站查询连接

图谱载入后,三条 Cypher 查询分别询问:哪些公司共用集成,哪些人出现在不止一家公司的网站上,以及哪些国家拥有不止一家公司的办公地点:

python
QUERIES = {
    "Shared integrations": """
        MATCH (a:Company)-[:INTEGRATES_WITH]->(i:Integration)<-[:INTEGRATES_WITH]-(b:Company)
        WHERE a.name < b.name
        RETURN a.name AS company_a, b.name AS company_b,
               count(DISTINCT i) AS shared, collect(DISTINCT i.name)[..6] AS integrations
        ORDER BY shared DESC LIMIT 5""",
    "People on two companies' sites": """
        MATCH (p:Person)-[r:WORKS_AT]->(c:Company)
        WITH p, collect(DISTINCT c.name) AS companies, collect(DISTINCT r.source_url) AS pages
        WHERE size(companies) >= 2
        RETURN p.name AS person, companies, pages""",
    "Geographic clusters": """
        MATCH (c:Company)-[:LOCATED_IN]->(l:Location)
        WITH l.country AS country, collect(DISTINCT c.name) AS companies, collect(DISTINCT l.name) AS cities
        WHERE size(companies) >= 2
        RETURN country, companies, cities ORDER BY size(companies) DESC""",
}

a.name < b.name 让每对公司只返回一次,而 DISTINCT 则防止多个支撑页面夸大集成数量。演练返回了:

text
Shared integrations:
   {"company_a": "Basecamp", "company_b": "Pipedrive", "shared": 1, "integrations": ["Zapier"]}

People on two companies' sites:
   {"person": "Jason Fried", "companies": ["HEY", "Basecamp"], "pages": ["https://www.hey.com/", "https://basecamp.com/", "https://basecamp.com/about"]}

Geographic clusters:
   {"country": "Germany", "companies": ["Pipedrive", "Zendesk"], "cities": ["Berlin"]}
   {"country": "Portugal", "companies": ["Pipedrive", "Zendesk"], "cities": ["Lisbon"]}

读这些行时要记住模型。Jason Fried 同时出现在 Basecamp 和 HEY 的网站上,但两个品牌都属于 37signals,所以两个 Company 节点并不意味着两个雇主。地理查询按国家分组,并收集匹配公司的城市;它并不表示每家公司在每个列出的城市都有办公室。仓库记录的完整流水线运行为同一对公司找到了四个共用集成:Asana、Stripe、Trello 和 Zapier。不同运行之间的结果会有差异,因为页面、模型输出和两套实现都不一样。

加入 GraphRAG,用自然语言提问

Cypher 让图谱可以被检视;直接提问则更友好。GraphRAG 脚本使用 text-to-Cypher:模型接收模式和问题,写出查询,然后根据返回的行作答。不涉及任何嵌入向量,也没有向量数据库。在任何东西运行之前,一个关键字检查会拒绝写入子句和多条语句:

python
WRITE_WORDS = re.compile(r"\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP|LOAD|FOREACH|CALL)\b", re.I)


class Query(BaseModel):
    cypher: str


def check_read_only(cypher: str) -> str:
    """Reject write clauses. String literals are blanked first so names like 'Merge Labs' pass."""
    code = re.sub(r"'[^']*'|\"[^\"]*\"", "''", cypher).strip().rstrip(";")
    if ";" in code or WRITE_WORDS.search(code) or not re.match(r"(?i)\s*(MATCH|OPTIONAL|WITH|UNWIND)", code):
        raise ValueError(f"Blocked query: {cypher}")
    return cypher.strip().rstrip(";")

随后,查询运行在 execute_read():

python
with GraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH) as driver, driver.session() as session:
    rows = session.execute_read(lambda tx: [r.data() for r in tx.run(cypher)])

这两行都不是安全边界。正则表达式不是 Cypher 解析器,而 Neo4j 驱动文档 也明确说 execute_read() 并不必然意味着访问控制。部署后的问答服务应当在数据库或部署边界上强制只读访问,而教程默认的 neo4j 凭据同时也用于载入图谱,并不是受限的查询身份。

打包版会把错误、空结果和缺失的溯源信息送回模型,最多尝试三次。正则和只读事务是卫生措施,不是授权。

记录的提问端到端展示了检索和引用路径:

text
Safety check works: Blocked query: MATCH (n) DETACH DELETE n

Cypher:
MATCH (a:Company)-[r1:INTEGRATES_WITH]->(i)<-[r2:INTEGRATES_WITH]-(b:Company)
WHERE toLower(a.name) = toLower('Basecamp') AND toLower(b.name) = toLower('Pipedrive')
UNWIND [r1, r2] AS r
RETURN i.name AS integration, startNode(r).name AS company, r.source_url AS source_url, r.rid AS rid LIMIT 25

Rows: 2

Answer:
Basecamp and Pipedrive have the integration "Zapier" in common. This is supported by the following sources: [https://basecamp.com/integrations, rid: 86c17c449811ab0e0f861dd3] and [https://www.pipedrive.com/en/marketplace, rid: d2ef101d7ac8d30bf3a26a5a].

查询为每家公司与 Zapier 的连接各返回一行溯源记录,所以答案可以引用两个页面和两个 rid。只要存储对象还存在,这些引用就让证据可供检视;但它们不会逐条核实模型的措辞。打包版模块加入了更宽的关键字过滤、最多三次生成尝试,以及在结果物化之后于 Python 中截取 50 行的切片,所以在大图谱上,数据库侧的限制和超时依然重要。

无需再次抓取即可重新抽取已存页面

模式和提示词会变。你可能会加入投资者,把产品从生产它们的公司中拆分出来,或者收紧关系检查,并且你希望针对同一批捕获页面评估每次改动,而不是针对一份新的网页快照。刷新脚本按 rid 取回一条已存响应,来源是 Cloud Storage:

python
def get_stored(rid: str) -> str:
    response = httpx.get(STORAGE, params={"token": CRAWLBASE_TOKEN, "rid": rid}, timeout=60)
    response.raise_for_status()
    print(f"  rid={rid} stored_at={response.headers.get('stored_at')} url={response.headers.get('url')}")
    return response.text

对于记录的 format=md 请求,取回的响应体与原始 Markdown 完全一致,而一条已存的 generic-extractor 响应取回时仍是它原本的解析后 JSON,而不是原始 HTML。请存储并取回你的抽取所消费的那种表示形式,而不要假定每个存储对象都是页面。演练选取最多三个页面,断言每个已存响应体与保存的抓取结果一致,然后再次抽取:

text
rid=abac8926164450fab0456a27 stored_at=2026-10-29T10:16:17+01:00 url=https://basecamp.com/about
re-extracted 2 relationships, dropped 0
rid=86c17c449811ab0e0f861dd3 stored_at=2026-10-29T10:16:33+01:00 url=https://basecamp.com/integrations
re-extracted 20 relationships, dropped 2

重新抽取改变的是对快照的解释;它不会获取当前内容。实时更新意味着重新抓取、再抽取、再载入。已存页面保留 30 天;存储一个页面花费半个积分,取回则免费。在长期可复现性重要的场合请保留你自己的快照,并查看 Cloud Storage 保留策略 了解当前条款。

运行完整流水线

打包版 CLI 把九个转换步骤合并成若干命令。进入 final/,安装其依赖和软件包,然后运行四家公司的种子列表:

bash
python -m pip install -r requirements.txt
python -m pip install -e .
python -m kg_pipeline run-all --seeds data/seed_small.csv

记录的完整流水线运行报告如下:

text
Fetched 14 pages. Saved to .../final/output/pages.json
Extracted 14 pages: kept 223 items, dropped 11 by the quote guard.
Merged 0 near-duplicate names.
Graph now has 130 nodes and 159 relationships.

针对同一个数据库再跑一次,这些计数保持不变。这与演练的 90 个节点、119 条关系的图谱是两次独立的运行,而且 kept 这个数字是在引文守卫处统计的,早于清理和推断连接,所以它不是最终的图谱大小。关于来源的一点说明:仓库的 fixtures 能精确复现发现阶段的输出和 223 个保留条目;本文引用的其他控制台输出来自作者的记录运行,没有作为 fixture 提交。CLI 还提供 crawl,extract,load,query,ask 和 refresh:

bash
python -m kg_pipeline ask "Which integrations do Basecamp and Pipedrive have in common?"
python -m kg_pipeline refresh
python -m kg_pipeline load --seeds data/seed_small.csv

普通的抓取和抽取运行会复用缓存结果:发现阶段按 token 模式和 URL 缓存,Markdown 按 URL 缓存,抽取按模型、提示词版本、公司和页面内容哈希缓存,并且没有任何缓存会自行过期。在计划的重新抓取之前,或在更改渲染设置之后,请清空抓取缓存。refresh 总是发起新的抽取调用,而 ask 从不缓存,所以两者每次都会消耗模型用量。客户端使用 90 秒超时,对瞬时状态码最多尝试三次并采用指数退避,且两次请求开始之间至少间隔一秒:这是顺序节奏控制,不代表你账号的并发能力。

测试套件包含 112 个离线测试和 16 个在线集成测试。离线测试覆盖发现、模式校验、引文检查、规范化、查询过滤,以及基于记录 fixtures 的模拟抓取和缓存行为;在线测试需要凭据和一个 Neo4j 实例,其中一些会清空数据库。进入 final/:

bash
python -m pytest -m "not integration"

仓库中的 final/tests/fixtures/FINDINGS.md 保存了底层的 Crawlbase 观测记录:响应形状、可读性对比、token 对比、存储行为和重定向案例。data/seed_companies.csv 列出了 50 个种子站点,供扩展实验之用。

结论

有用的知识图谱会把每条抽取出的主张与产生它的内容连接起来。这条流水线用 Crawling API 发现并捕获公司页面,用结构化 LLM 抽取提议实体和关系,用 Neo4j 查询它们之间的连接,并在每条边上保留页面 URL、引文和已存页面的 rid 这三项信息。

它的局限是刻意明示的。引文存在弱于语义验证,名称匹配弱于身份消解,幂等载入也不等于撤销陈旧事实。如果图谱要从四家公司的实验成长为研究工具,这些都是值得投入的地方。克隆 仓库,注册一个 Crawlbase 账号 用于采集阶段,然后从一份小到你能读完它产生的每一条关系的种子列表开始。

常见问题(FAQ)

什么时候应该用知识图谱而不是关系型表?

两者都能存放这些关系。当你的问题反复在公司、人员、集成和地点之间沿连接遍历时,图谱才会显出价值。固定模式的报表和聚合在表里仍然更容易。

每个公司网站都需要 JavaScript token 吗?

不需要。好几个记录的页面用 Normal token 就能拿到内容,而 JavaScript token 每次请求的费用是前者的两倍。当页面在浏览器中构建内容时再使用它,检查内容本身而不是相信长度阈值,并在更改某个种子的设置后清空 Markdown 缓存。

来源引文匹配能保证关系准确吗?

不能。它只确认规范化后的引文出现在捕获的页面上。引文仍可能与主张无关,而且流水线会通过推断添加一些关系。更强的验证会同时检查实体身份、关系语义和上下文。

不重新抓取网站也能改进抽取吗?

可以。按 rid 取回已存的 Markdown,并在捕获仍处于保留期内时,用修订后的提示词或模式重新抽取。在打包版流水线中,这对应先执行 refresh 再执行 load。它重新处理的是已有证据;要获取当前信息需要重新抓取。

开始构建

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

Crawlbase 负责处理代理、指纹和验证码,让您的团队专注于构建数据管道,而不是维护爬取基础设施。免费提供最多 5,000 次请求,无需绑定信用卡。

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