Un dossier de pages d'entreprise scrapées vous dit ce que chaque site raconte sur lui-même. Répondre à une question qui traverse ces pages demande une étape de plus : reconnaître que les mêmes personnes, outils et villes apparaissent sur plusieurs sites, et les relier aux entreprises qui les mentionnent. Quelles entreprises partagent une intégration ? Qui est cité sur plus d'un site ? Où leurs bureaux se recoupent-ils ?
Ce billet construit un pipeline Python qui découvre les pages pertinentes d'un site d'entreprise avec la Crawling API, les récupère en Markdown, demande à un LLM d'en extraire les entités et les relations dans un schéma fixe, vérifie que chaque citation d'appui figure vraiment sur la page, normalise les noms et charge le résultat dans Neo4j avec MERGE. Cypher répond ensuite aux questions inter-entreprises, et une petite couche GraphRAG transforme une question en langage courant en requête et en réponse qui cite ses pages sources.
Le code se trouve dans ScraperHub/build-a-knowledge-graph-from-crawled-websites-with-neo4j, avec neuf scripts pas à pas sous steps/, un par transformation, et la CLI packagée avec cache, configuration Docker et tests sous final/. Les chiffres ci-dessous proviennent de runs enregistrés contre Basecamp, HEY, Pipedrive et Zendesk le 29 octobre 2026.
- Chaque relation du graphe conserve l'URL de la page, l'identifiant de page stockée
rid, l'horodatage de l'extraction et la citation qui la soutient, si bien que n'importe quelle arête peut être retracée jusqu'au Markdown qui l'a produite. - Le LLM propose, l'application décide. Une citation absente de la page capturée fait tomber l'entité, les noms sont normalisés par label, et
MERGErend un second chargement des mêmes données sans effet. - Comme le Markdown est conservé dans Cloud Storage, un nouveau schéma ou un nouveau prompt se rejoue sur les mêmes captures sans nouveau crawl.
Modélisez les relations que vous voulez interroger
Un graphe de connaissances stocke les entités sous forme de nœuds et les relie par des relations typées. Ici, une Person peut WORKS_AT une Company, et une Company peut INTEGRATES_WITH une Integration. Des tables relationnelles peuvent contenir les mêmes faits ; la raison d'utiliser Neo4j est qu'une traversée devient un motif visible. (a:Company)-[:INTEGRATES_WITH]->(i)<-[:INTEGRATES_WITH]-(b:Company) se lit « deux entreprises reliées à la même intégration », et reste lisible à mesure que les questions ajoutent des sauts.
Le vocabulaire est volontairement réduit : cinq labels (Company, Person, Product, Integration, Location) et cinq types de relation (WORKS_AT, OFFERS, INTEGRATES_WITH, PARTNERS_WITH, LOCATED_IN). Un petit vocabulaire est plus facile à valider au moment de l'extraction et plus facile à contraindre quand un modèle écrit du Cypher plus tard.
Préparez le parcours et la base de données
Il vous faut Python 3.11+, Docker avec Compose, un compte Crawlbase avec ses tokens Normal et JavaScript, et une clé API OpenAI. Le dépôt utilise gpt-4o-mini avec les sorties structurées Pydantic et fait tourner Neo4j 5 Community en local ; les versions des dépendances sont épinglées dans steps/requirements.txt. Clonez-le, créez un environnement virtuel et copiez les deux modèles d'environnement :
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
Renseignez les deux fichiers .env avec le même mot de passe Neo4j avant de démarrer la base. Les scripts du parcours lisent steps/.env, tandis que Docker Compose et la CLI packagée lisent final/.env. Les deux utilisent les mêmes clés :
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
Installez ensuite les dépendances du parcours et démarrez Neo4j :
pip install -r steps/requirements.txt cd final && docker compose up -d --wait
Exécutez les scripts dans l'ordre numérique depuis steps/. Chaque script écrit sa sortie en JSON sous steps/.cache/, et les suivants lisent ce que les précédents ont écrit. Utilisez une base dédiée au tutoriel : 06_load_into_neo4j.py efface toutes les données existantes du graphe avant de démontrer deux chargements consécutifs. Neo4j Browser est accessible sur http://localhost:7474.
Découvrez les pages d'entreprise à partir des liens de la page d'accueil
La découverte demande chaque page d'accueil avec scraper=generic-extractor, l'un des scrapers Crawlbase, et lit les liens qu'il renvoie. Un constat des runs enregistrés mérite d'être connu avant d'écrire du code contre la doc : le schéma documenté liste des champs comme main_content et headings, alors que les réponses réelles contenaient title, meta, content, images et links, entre autres. Le SDK expose la page parsée sous response["json"]. Sur cinq pages d'accueil enregistrées, 422 des 424 liens étaient déjà des URL HTTP(S) absolues ; les deux autres étaient mailto: et tel:. Le code résout tout de même les liens relatifs avec urljoin et écarte les schémas qui ne sont pas des pages, donc il continue de fonctionner si la forme change.
Le premier script garde les liens du même hôte dont le dernier segment de chemin correspond à un motif de page d'entreprise :
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
Faire correspondre le dernier segment signifie que /company/legal n'est pas une page « à propos » simplement parce que son dossier parent s'appelle company. La limite s'applique aux pages découvertes ; la page d'accueil est récupérée séparément. C'est une découverte bornée aux liens de la page d'accueil : les pages derrière une pagination, une navigation plus profonde ou un sous-domaine carrières demandent des règles supplémentaires.
L'appel SDK transmet à la découverte à la fois la page d'accueil de départ et l'URL sur laquelle la réponse a réellement atterri, et les deux servent à des fins différentes. Dans une requête Zendesk enregistrée, le site a redirigé vers zendesk.de alors que sa navigation pointait toujours vers zendesk.com. Filtrer sur l'hôte final écartait tous les liens pertinents, filtrer sur l'hôte de départ les gardait, et l'URL finale restait la base pour résoudre les chemins relatifs. Crawlbase choisit le pays de sortie pour vous, alors gérez les redirections explicitement plutôt que de présumer l'hôte d'arrivée. Le parcours a produit :
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 ...
La page d'accueil de HEY n'a correspondu à aucune URL de page d'entreprise dans ce run, donc seule sa page d'accueil est passée à la suite. Cela décrit la couverture de la règle, pas le site. Le module de découverte packagé trie aussi les chemins peu profonds en premier et répartit ses sélections entre les types de pages.
Récupérez le Markdown et conservez l'entrée derrière chaque fait
L'extraction a besoin des pages elles-mêmes, y compris les grilles d'équipe et les annuaires d'intégrations qu'un résumé de page d'accueil omet. L'étape de récupération demande format=md avec store=true, et conserve le rid sous lequel Cloud Storage garde le Markdown, à côté du texte lui-même.
Le filtrage readability élague la navigation et le boilerplate, mais les appels enregistrés montrent qu'il peut aussi retirer exactement les sections structurées dont ce graphe a besoin :
| Page | md_readability=true |
Simple format=md
|
|---|---|---|
| close.com/about | 469 caractères, grille de direction omise | 13 722 caractères |
| pipedrive.com/en/about | 504 caractères | 11 187 caractères |
| close.com/integrations | 260 caractères | 41 290 caractères |
L'étape de récupération essaie donc readability d'abord, se rabat sur le Markdown simple, puis enfin sur le token JavaScript :
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}
Les seuils de 1 500 et 300 caractères sont des heuristiques applicatives : une longue coquille de navigation peut les passer sans les données que vous voulez, et une page courte mais légitime peut déclencher une nouvelle tentative inutile. Pour les cibles dont vous savez déjà qu'elles se rendent dans le navigateur, la CLI packagée accepte use_js=true dans le CSV de départ et commence par le token JavaScript. Les comparaisons enregistrées montrent pourquoi le rendu doit dépendre de la cible : la longueur du Markdown était identique avec les deux tokens pour Linear, Monday et Figma, et proche pour Notion, et la page d'intégrations de Linear a renvoyé 24 218 caractères dans les deux cas, tandis que quotes.toscrape.com/js a renvoyé 42 caractères avec le token Normal et 887 avec le token JavaScript. Les sites d'entreprise modernes ont tendance à être rendus côté serveur ; cela ne prouve pas que tous le sont.
Deux détails de la réponse comptent pour la provenance. Pour une requête format=md avec store=true, le rid arrive dans l'en-tête de réponse storage_url plutôt que dans un en-tête à part, et cette URL contient le token du compte, donc le code ne garde que le rid et ne journalise jamais l'URL. Il utilise aussi httpx pour ces récupérations parce que le SDK Python Crawlbase épinglé ne conserve que original_status, cb_status et url d'une réponse et écarte storage_url. Chaque repli est une requête distincte avec son propre rid, donc le rid que vous conservez doit appartenir au Markdown dont vous extrayez réellement :
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
Une requête enregistrée a renvoyé HTTP 520 avec cb_status: 0 et un corps indiquant que l'appel n'avait pas été facturé ; une nouvelle tentative a réussi. Le client de crawl packagé ajoute des retries de transport et un espacement des requêtes, mais un long corps d'erreur n'est pas du contenu de page. Vérifiez le statut avant d'extraire.
Récupérez n'importe quelle page publique en Markdown, rendue dans un navigateur quand elle en a besoin, et conservez dans Cloud Storage, sous un identifiant de requête, la copie dont vous avez extrait. Les requêtes échouées ne sont pas facturées. Commencez avec jusqu'à 5 000 requêtes gratuites, sans carte.
Définissez le contrat d'extraction avant d'appeler le modèle
Pour que le graphe soit interrogeable, la sortie du modèle a besoin d'un vocabulaire fixe et de champs prévisibles. Elle remplit un schéma Pydantic qui correspond un à un aux labels et aux types de relation, et chaque entité et chaque relation porte un source_quote pour la vérification qui suit :
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)
Le module de schéma complet définit aussi les listes de premier niveau et démontre le contrat en parsant une charge utile valide et en rejetant une charge invalide :
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'
Les sorties structurées imposent la forme, rien de plus. Elles n'établissent pas qu'une personne travaille chez l'entreprise de départ ni qu'un outil nommé est une intégration. Le prompt d'extraction fournit ces règles : utiliser le nom de l'entreprise de départ, exclure les témoignages clients, extraire des outils nommés plutôt que des décomptes comme « 500+ intégrations », et copier le texte d'appui sans le paraphraser. Le modèle reçoit au plus 40 000 caractères de chaque page, ce qui borne l'entrée mais peut perdre des entrées vers la fin d'un long annuaire ; un découpage avec chevauchement et une passe de déduplication étendraient la couverture, et cette implémentation tronque.
Vérifiez les citations avant de créer des relations
Après la validation du schéma, le garde-fou de citation vérifie que chaque citation d'appui apparaît dans le Markdown capturé. Il normalise l'Unicode, le balisage Markdown et les espaces, puis écarte les entités dont la citation manque. L'extrait ci-dessous est la partie filtrage des entités du garde-fou du parcours ; la fonction complète vérifie ensuite les employeurs et les extrémités des relations :
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)])
Le filtrage par employeur retire les personnes que le modèle a rattachées à une entreprise clairement différente ; les personnes sans valeur d'employeur restent. Le module d'extraction packagé vérifie en plus les labels des extrémités et standardise les noms d'intégration comme « Stripe for Pipedrive ». La sortie enregistrée montre à la fois un rejet utile et du rappel perdu :
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
Trois échecs différents se cachent dans ces lignes. La citation sur Chicago était absente de la page. Trello était sur la page, mais le modèle a paraphrasé sa citation, donc la correspondance a échoué. La relation Zimple a perdu son extrémité entreprise avant que la vérification ne s'exécute. Les trois bloquent une relation ; une seule était une hallucination.
C'est la frontière à garder en tête. Une correspondance de citation prouve que les mots étaient sur la page ; elle ne prouve pas l'interprétation, et une phrase sans rapport peut passer tout en soutenant la mauvaise entité, le mauvais titre ou le mauvais pays. Le pipeline ajoute aussi des relations que le modèle n'a pas énoncées : les personnes retenues reçoivent WORKS_AT vers l'entreprise de départ, et l'entreprise reçoit des arêtes OFFERS, INTEGRATES_WITH et LOCATED_IN vers les autres entités retenues, chacune réutilisant la citation de l'entité. Ce sont des liens inférés, et ils peuvent recréer une relation dont la propre citation vient d'échouer. Pour tout ce qui a des conséquences, vérifiez l'affirmation complète sujet-prédicat-objet et enregistrez les arêtes inférées séparément.
Normalisez les noms, puis chargez avec MERGE
Même des extractions bien ancrées nomment le même outil de différentes façons. Avant le chargement, l'étape de normalisation retire quelques suffixes de domaine et de forme juridique, passe en minuscules et supprime la ponctuation, puis laisse RapidFuzz fusionner les noms qui obtiennent un score de 90 ou plus, les noms des entreprises de départ ayant la priorité comme représentant :
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)
'Stripe' vs 'Stripe, Inc.': score 100, merged=True 'Salesforce' vs 'Salesforce.com': score 100, merged=True 'Notion' vs 'Motion': score 83, merged=False
C'est de l'appariement approximatif de noms, pas de la résolution d'identité. Deux personnes peuvent porter le même nom, des entreprises aux noms proches peuvent n'avoir aucun lien, et une ville a besoin d'un pays. L'implémentation packagée n'apparie qu'au sein d'un même label, donc une Company et une Integration portant le même nom restent des nœuds distincts. Des domaines d'entreprise, des identifiants de personne et des clés ville-pays donneraient à un graphe plus grand des identités plus solides.
Le chargeur utilise ensuite MERGE pour que les nœuds et relations correspondants soient réutilisés plutôt que dupliqués :
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 """
Chaque label a une contrainte d'unicité sur name. L'identité d'une relation est formée de ses extrémités, de son type et de son source_url, donc la même connexion soutenue par deux pages devient deux arêtes, chacune avec sa propre citation, son propre rid et son propre horodatage. Charger deux fois le même jeu de données extrait montre l'idempotence :
After load 1: 90 nodes, 119 relationships After load 2: 90 nodes, 119 relationships
Ouvrez Neo4j Browser et exécutez MATCH p=(c:Company)-[]->() RETURN p LIMIT 100 pour voir le résultat. Le chargement répété n'en fait pas pour autant un graphe historique ou synchronisé. Si une page cesse de mentionner une intégration, l'ancienne arête reste jusqu'à ce que quelque chose la retire, et un chargement ultérieur de la même arête écrase en place sa citation, son rid et son horodatage. Des observations versionnées ou une étape de réconciliation par page sont ce qui conserve l'historique ou retire les affirmations périmées.
Interrogez les connexions entre les sites d'entreprise
Une fois le graphe chargé, trois requêtes Cypher demandent quelles entreprises partagent des intégrations, quelles personnes apparaissent sur le site de plus d'une entreprise, et quels pays abritent des implantations de plus d'une entreprise :
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 renvoie chaque paire d'entreprises une seule fois, et DISTINCT empêche plusieurs pages d'appui de gonfler le nombre d'intégrations. Le parcours a renvoyé :
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"]}
Lisez les lignes en gardant le modèle en tête. Jason Fried apparaît à la fois sur les sites de Basecamp et de HEY, mais les deux marques appartiennent à 37signals, donc deux nœuds Company ne signifient pas deux employeurs. La requête géographique regroupe par pays et collecte les villes à travers les entreprises appariées ; elle ne dit pas que chaque entreprise a un bureau dans chaque ville listée. Le run complet enregistré du dépôt a trouvé quatre intégrations partagées pour la même paire, Asana, Stripe, Trello et Zapier, et les résultats diffèrent d'un run à l'autre parce que les pages, les sorties du modèle et les deux implémentations diffèrent.
Ajoutez GraphRAG pour les questions en langage courant
Cypher rend le graphe inspectable ; une question est plus conviviale. Le script GraphRAG utilise le text-to-Cypher : le modèle reçoit le schéma et la question, écrit une requête, puis répond à partir des lignes. Ni embeddings ni base vectorielle ne sont impliqués. Avant toute exécution, une vérification par mots-clés rejette les clauses d'écriture et les instructions multiples :
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(";")
La requête s'exécute ensuite dans execute_read():
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)])
Aucune de ces deux lignes n'est une frontière de sécurité. Une expression régulière n'est pas un parseur Cypher, et la documentation du driver Neo4j dit clairement que execute_read() n'implique pas nécessairement un contrôle d'accès. Un service de questions déployé doit imposer l'accès en lecture seule à la frontière de la base de données ou du déploiement, et les identifiants neo4j par défaut du tutoriel, qui chargent aussi le graphe, ne sont pas une identité de requête restreinte.
La question enregistrée montre le chemin de récupération et de citation de bout en bout :
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].
La requête renvoie une ligne de provenance par connexion d'entreprise à Zapier, donc la réponse peut citer les deux pages et les deux rid. Ces références rendent la preuve inspectable tant que les objets stockés existent ; elles ne vérifient pas la formulation du modèle affirmation par affirmation. Le module packagé ajoute un filtrage par mots-clés plus large, jusqu'à trois tentatives de génération, et une tranche de 50 lignes appliquée en Python après matérialisation des résultats, donc les limites et délais côté base de données comptent toujours sur un grand graphe.
Ré-extrayez les pages stockées sans nouveau crawl
Le schéma et le prompt vont changer. Vous pourriez ajouter des investisseurs, séparer les produits des entreprises qui les fabriquent, ou durcir les vérifications de relations, et vous voulez évaluer chaque changement sur les mêmes pages capturées plutôt que sur un nouvel instantané du web. Le script de rafraîchissement récupère une réponse stockée par rid depuis Cloud Storage:
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
Pour la requête format=md enregistrée, le corps récupéré correspondait exactement au Markdown d'origine, et une réponse generic-extractor stockée est revenue sous la forme du JSON parsé qu'elle avait été, pas en HTML brut. Stockez et récupérez la représentation que votre extraction consomme plutôt que de supposer que chaque objet stocké est une page. Le parcours sélectionne jusqu'à trois pages, vérifie que chaque corps stocké correspond au crawl sauvegardé, et extrait à nouveau :
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
La ré-extraction change l'interprétation d'un instantané ; elle ne récupère pas le contenu actuel. Une mise à jour en direct, c'est un nouveau crawl, puis l'extraction, puis le chargement. Les pages stockées sont conservées 30 jours ; en stocker une coûte un demi-crédit et la récupérer ne coûte rien. Gardez vos propres instantanés là où la reproductibilité à long terme compte, et consultez la politique de rétention de Cloud Storage pour les conditions en vigueur.
Exécutez le pipeline complet
La CLI packagée réunit les neuf transformations en commandes. Depuis final/, installez ses dépendances et le package, puis lancez la liste de départ à quatre entreprises :
python -m pip install -r requirements.txt python -m pip install -e . python -m kg_pipeline run-all --seeds data/seed_small.csv
Le run complet enregistré a rapporté :
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.
Un second run sur la même base a conservé ces comptes. C'est un run distinct du graphe du parcours à 90 nœuds et 119 relations, et le chiffre kept est compté au garde-fou de citation, avant le nettoyage et les liens inférés, donc ce n'est pas la taille finale du graphe. Une remarque sur la provenance : les fixtures du dépôt reproduisent exactement la sortie de découverte et les 223 éléments conservés ; le reste de la sortie console citée dans ce billet est le run enregistré des auteurs et n'est pas commité comme fixture. La CLI expose aussi crawl, extract, load, query, ask et refresh:
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
Les runs de crawl et d'extraction ordinaires réutilisent les résultats en cache : la découverte met en cache par mode de token et URL, le Markdown par URL, l'extraction par modèle, version de prompt, entreprise et hash du contenu de page, et rien n'expire de soi-même. Videz le cache de crawl avant un recrawl planifié ou après avoir changé les réglages de rendu. refresh fait toujours de nouveaux appels d'extraction et ask n'est jamais mis en cache, donc les deux consomment de l'usage modèle à chaque fois. Le client utilise un délai de 90 secondes, jusqu'à trois tentatives avec backoff exponentiel pour les statuts transitoires, et au moins une seconde entre les débuts de requêtes : un espacement séquentiel, pas une affirmation sur la concurrence de votre compte.
La suite de tests compte 112 tests hors ligne et 16 tests d'intégration en direct. Les premiers couvrent la découverte, la validation du schéma, les vérifications de citations, la normalisation, le filtrage des requêtes et le comportement simulé du crawl et du cache sur des fixtures enregistrées ; les seconds ont besoin d'identifiants et d'un Neo4j, et certains vident la base. Depuis final/:
python -m pytest -m "not integration"
Le fichier final/tests/fixtures/FINDINGS.md du dépôt conserve les observations Crawlbase sous-jacentes : formes des réponses, comparaison readability, comparaison des tokens, comportement du stockage et le cas de redirection. data/seed_companies.csv liste 50 sites de départ pour étendre l'expérience.
Conclusion
Un graphe de connaissances utile relie chaque affirmation extraite au contenu qui l'a produite. Ce pipeline utilise la Crawling API pour découvrir et capturer les pages d'entreprise, l'extraction structurée par LLM pour proposer des entités et des relations, et Neo4j pour interroger leurs connexions, avec sur chaque arête une URL de page, une citation et le rid de la page stockée.
Ses limites sont explicites par conception. La présence d'une citation est plus faible qu'une vérification sémantique, l'appariement de noms est plus faible qu'une résolution d'identité, et le chargement idempotent n'est pas la même chose que retirer les faits périmés. Chacun de ces points est un endroit où investir si le graphe passe d'une expérience à quatre entreprises à un outil de recherche. Clonez le dépôt, créez un compte Crawlbase pour l'étape d'acquisition, et commencez avec une liste de départ assez petite pour que vous puissiez lire chaque relation qu'elle produit.
Foire aux questions (FAQ)
Quand utiliser un graphe de connaissances plutôt que des tables relationnelles ?
Les deux peuvent stocker ces relations. Un graphe devient rentable quand vos questions traversent à répétition les connexions entre entreprises, personnes, intégrations et lieux. Le reporting à schéma fixe et l'agrégation restent plus simples dans des tables.
Chaque site d'entreprise a-t-il besoin du token JavaScript ?
Non. Plusieurs pages enregistrées ont exposé leur contenu avec le token Normal, et le token JavaScript coûte deux fois plus par requête. Utilisez-le quand une page construit son contenu dans le navigateur, inspectez le contenu plutôt que de vous fier à un seuil de longueur, et videz le cache Markdown quand vous changez le réglage d'un site de départ.
Une citation source qui correspond garantit-elle une relation exacte ?
Non. Elle confirme que la citation normalisée apparaît sur la page capturée. La citation peut quand même n'avoir aucun rapport avec l'affirmation, et le pipeline ajoute certaines relations par inférence. Une vérification plus solide contrôle ensemble les identités des entités, le sens de la relation et le contexte environnant.
Puis-je améliorer l'extraction sans recrawler les sites web ?
Oui. Récupérez le Markdown stocké par rid et ré-extrayez-le avec le prompt ou le schéma révisé tant que la capture est encore dans sa fenêtre de rétention. Dans le pipeline packagé, c'est refresh suivi de load. Cela retraite des preuves existantes ; une information à jour demande un nouveau crawl.
Crawlez n'importe quel site à grande échelle, sans combattre l'infrastructure.
Crawlbase gère les proxies, les empreintes et les CAPTCHA pour que votre équipe livre des pipelines de données au lieu de maintenir l'infrastructure de crawl. Jusqu'à 5 000 requêtes gratuites, sans carte bancaire requise.

