Папка собранных страниц компаний говорит о том, что каждый сайт сообщает о себе. Чтобы ответить на вопрос по всем этим страницам, нужен ещё один шаг: понять, что одни и те же люди, инструменты и города встречаются на нескольких сайтах, и связать их с компаниями, которые их упоминают. Какие компании используют общую интеграцию? Кто назван более чем на одном сайте? Где пересекаются их офисы?
В этой статье мы строим конвейер на Python, который находит нужные страницы сайта компании с помощью Crawling API, получает их в виде Markdown, просит LLM извлечь сущности и отношения в фиксированную схему, проверяет, что каждая подтверждающая цитата действительно есть на странице, нормализует имена и загружает результат в Neo4j через MERGE. Затем Cypher отвечает на вопросы, охватывающие несколько компаний, а небольшой слой GraphRAG превращает вопрос на обычном языке в запрос и ответ со ссылками на страницы-источники.
Код лежит в ScraperHub/build-a-knowledge-graph-from-crawled-websites-with-neo4j: девять скриптов пошагового разбора в steps/, по одному на преобразование, и CLI из пакета с кэшированием, конфигурацией Docker и тестами в final/. Приведённые ниже числа получены из записанных запусков по Basecamp, HEY, Pipedrive и Zendesk 29 октября 2026 года.
- Каждое отношение в графе хранит URL страницы,
ridсохранённой страницы, метку времени извлечения и подтверждающую цитату, так что любое ребро можно проследить до Markdown, из которого оно получено. - LLM предлагает; приложение решает. Цитата, которой нет на захваченной странице, отбрасывает сущность, имена нормализуются в пределах метки, а
MERGEделает повторную загрузку тех же данных холостой операцией. - Поскольку Markdown хранится в Cloud Storage, новая схема или промпт повторно запускаются на тех же захватах без нового обхода.
Смоделируйте отношения, которые хотите запрашивать
Граф знаний хранит сущности как узлы и связывает их типизированными отношениями. Здесь узел 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+, Docker с Compose, аккаунт Crawlbase с его Normal token и JavaScript token, а также ключ OpenAI API. Репозиторий использует gpt-4o-mini со структурированным выводом Pydantic и запускает Neo4j 5 Community локально; версии зависимостей зафиксированы в steps/requirements.txt. Клонируйте его, создайте виртуальное окружение и скопируйте два шаблона переменных окружения:
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. Оба используют одни и те же ключи:
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:
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"]. На пяти записанных главных страницах 422 из 424 ссылок уже были абсолютными URL HTTP(S); остальные две были mailto: и tel:. Код всё равно разрешает относительные ссылки через urljoin и отбрасывает схемы, не ведущие на страницы, поэтому продолжит работать, если форма ответа изменится.
Первый скрипт оставляет ссылки на том же хосте, у которых последний сегмент пути соответствует шаблону страницы компании:
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 сам выбирает страну выхода, поэтому обрабатывайте перенаправления явно, а не полагайтесь на хост приземления. Пошаговый разбор дал:
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 и рядом с самим текстом сохраняет rid как ключ, под которым Cloud Storage хранит Markdown.
Фильтрация readability убирает навигацию и шаблонный текст, но записанные вызовы показывают, что она может удалить ровно те структурированные разделы, которые нужны этому графу:
| Страница | md_readability=true |
Обычный format=md
|
|---|---|---|
| close.com/about | 469 символов, сетка руководства пропущена | 13 722 символа |
| pipedrive.com/en/about | 504 символа | 11 187 символов |
| close.com/integrations | 260 символов | 41 290 символов |
Поэтому этап получения сначала пробует readability, затем откатывается к обычному Markdown и, наконец, к JavaScript token:
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 из пакета принимает use_js=true в исходном CSV и начинает с JavaScript token. Записанные сравнения показывают, почему рендеринг должен быть адресным: длина Markdown была одинаковой с обоими токенами для Linear, Monday и Figma и почти одинаковой для Notion, а страница интеграций Linear вернула 24 218 символов в обоих случаях, тогда как quotes.toscrape.com/js вернула 42 символа с Normal token и 887 с JavaScript token. Современные сайты компаний обычно рендерятся на сервере; это не доказывает, что так делает каждый сайт.
Две детали ответа важны для происхождения данных. Для запроса format=md с store=true значение rid приходит внутри заголовка ответа storage_url вместо отдельного заголовка, и этот URL содержит токен аккаунта, поэтому код сохраняет только rid и никогда не логирует URL. Для этих запросов он также использует httpx из-за того, что закреплённая версия Crawlbase Python SDK оставляет из ответа только original_status, cb_status и url и отбрасывает storage_url. Каждый запасной вариант выполняется отдельным запросом со своим rid, поэтому сохраняемый rid должен относиться к тому Markdown, из которого вы действительно извлекаете данные:
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 и телом, в котором говорилось, что вызов не был оплачен; повтор прошёл успешно. Клиент обхода из пакета добавляет транспортные повторы и темп запросов, но длинное тело ошибки не является содержимым страницы. Проверяйте статус перед извлечением.
Получайте любую публичную страницу как Markdown, с рендерингом в браузере, когда он нужен, и храните копию, из которой извлекали данные, в Cloud Storage под идентификатором запроса. Неудачные запросы не оплачиваются. Начните с 5 000 бесплатных запросов, без карты.
Определите контракт извлечения до вызова модели
Чтобы граф можно было запрашивать, вывод модели должен иметь фиксированный словарь и предсказуемые поля. Модель заполняет схему Pydantic, которая один к одному соответствует меткам и типам отношений, и каждая сущность и каждое отношение несут source_quote для последующей проверки:
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)
Полный модуль схемы также определяет списки верхнего уровня и демонстрирует контракт, разбирая корректную полезную нагрузку и отклоняя некорректную:
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 и пробелы, затем отбрасывает сущности, чья цитата не найдена. Фрагмент ниже содержит ту часть проверки из пошагового разбора, которая фильтрует сущности; полная функция дальше проверяет работодателей и концы отношений:
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». Записанный вывод показывает и полезное отклонение, и потерю полноты:
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
В этих строках скрыты три разных сбоя. Цитаты про Чикаго на странице не было. Trello на странице был, но модель пересказала цитату, и сопоставление не прошло. Отношение Zimple потеряло конец «компания» ещё до проверки. Все три останавливают отношение; галлюцинацией была только одна из них.
Эту границу и нужно держать в уме. Совпадение цитаты доказывает, что слова были на странице; оно не доказывает интерпретацию, и постороннее предложение может пройти проверку, подтверждая не ту сущность, должность или страну. Конвейер также добавляет отношения, которых модель не заявляла: сохранённые люди получают WORKS_AT к исходной компании, а компания получает рёбра OFFERS, INTEGRATES_WITH и LOCATED_IN к остальным сохранённым сущностям, и каждое повторно использует цитату сущности. Это выведенные связи, и они могут воссоздать отношение, чья собственная цитата только что не прошла проверку. Для всего, что имеет значение, проверяйте полное утверждение «субъект, предикат, объект» и записывайте выведенные рёбра отдельно.
Нормализуйте имена, затем загрузите через MERGE
Даже обоснованные извлечения называют один и тот же инструмент по-разному. Перед загрузкой этап нормализации убирает несколько доменных и юридических суффиксов, переводит в нижний регистр и удаляет пунктуацию, затем даёт RapidFuzz объединить имена с оценкой 90 и выше, причём названия исходных компаний имеют приоритет в качестве представителя:
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
Это приблизительное сопоставление имён, а не разрешение идентичности. Два человека могут носить одно имя, похожие по названию компании могут быть не связаны, а городу нужна страна. Реализация из пакета сопоставляет только внутри одной метки, поэтому Company и Integration с одинаковым названием остаются отдельными узлами. Домены компаний, идентификаторы людей и ключи «город плюс страна» дали бы большому графу более надёжные идентичности.
Затем загрузчик использует MERGE для того, чтобы совпадающие узлы и отношения переиспользовались, а не дублировались:
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 и меткой времени. Повторная загрузка того же извлечённого набора данных показывает идемпотентность:
After load 1: 90 nodes, 119 relationships After load 2: 90 nodes, 119 relationships
Откройте Neo4j Browser, выполните MATCH p=(c:Company)-[]->() RETURN p LIMIT 100 и посмотрите на результат. Впрочем, повторная загрузка не делает этот граф историческим или синхронизированным. Если страница перестаёт упоминать интеграцию, старое ребро остаётся, пока что-нибудь его не удалит, а более поздняя загрузка того же ребра перезаписывает на месте его цитату, rid и метку времени. Чтобы хранить историю или отзывать устаревшие утверждения, нужны версионированные наблюдения или шаг сверки по каждой странице.
Запросите связи между сайтами компаний
Когда граф загружен, три запроса Cypher спрашивают, какие компании делят интеграции, какие люди появляются на сайтах более чем одной компании и в каких странах расположены офисы более чем одной компании:
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 не даёт нескольким подтверждающим страницам раздуть число интеграций. Пошаговый разбор вернул:
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: модель получает схему и вопрос, пишет запрос, а затем отвечает по строкам результата. Ни эмбеддинги, ни векторная база данных не задействованы. Прежде чем что-либо запустится, проверка по ключевым словам отклоняет записывающие конструкции и несколько операторов подряд:
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():
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 по умолчанию из этого учебника, которыми же загружается граф, не являются ограниченной идентичностью для запросов.
Записанный вопрос показывает путь извлечения и цитирования от начала до конца:
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. Эти ссылки делают доказательства проверяемыми, пока хранимые объекты существуют; они не проверяют формулировки модели утверждение за утверждением. Модуль из пакета добавляет более широкую фильтрацию по ключевым словам, до трёх попыток генерации и срез в 50 строк, применяемый в Python уже после материализации результатов, так что лимиты и тайм-ауты на стороне базы данных по-прежнему важны на большом графе.
Повторно извлекайте сохранённые страницы без нового обхода
Схема и промпт будут меняться. Вы можете добавить инвесторов, отделить продукты от компаний, которые их делают, или ужесточить проверки отношений, и каждое изменение захочется оценить на тех же захваченных страницах, а не на новом снимке веба. Скрипт обновления получает сохранённый ответ по rid из 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
Для записанного запроса format=md полученное тело в точности совпало с исходным Markdown, а сохранённый ответ generic-extractor вернулся как тот же разобранный JSON, а не как сырой HTML. Храните и получайте то представление, которое потребляет ваше извлечение, вместо того чтобы считать каждый сохранённый объект страницей. Пошаговый разбор выбирает до трёх страниц, проверяет, что каждое сохранённое тело совпадает с сохранённым результатом обхода, и извлекает заново:
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/ установите его зависимости и сам пакет, затем запустите список из четырёх исходных компаний:
python -m pip install -r requirements.txt python -m pip install -e . python -m kg_pipeline run-all --seeds data/seed_small.csv
Записанный полный запуск конвейера сообщил:
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 считается на этапе проверки цитат, до очистки и выведенных связей, поэтому это не итоговый размер графа. Замечание о происхождении: фикстуры репозитория в точности воспроизводят вывод обнаружения и 223 сохранённых элемента; остальной консольный вывод, приведённый в этой статье, взят из записанного запуска авторов и не закоммичен как фикстура. CLI также предоставляет команды crawl, extract, load, query, ask и 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
Обычные запуски обхода и извлечения переиспользуют кэшированные результаты: обнаружение кэшируется по режиму токена и URL, Markdown по URL, извлечение по модели, версии промпта, компании и хэшу содержимого страницы, и ничто не истекает само по себе. Очищайте кэш обхода перед плановым повторным обходом или после изменения настроек рендеринга. refresh всегда делает новые вызовы извлечения, а ask никогда не кэшируется, поэтому обе команды каждый раз расходуют использование модели. Клиент использует тайм-аут в 90 секунд, до трёх попыток с экспоненциальной задержкой при временных статусах и не менее одной секунды между началами запросов: это последовательный темп, а не утверждение о параллельности вашего аккаунта.
Набор тестов содержит 112 офлайн-тестов и 16 живых интеграционных тестов. Офлайн-тесты покрывают обнаружение, проверку схемы, проверку цитат, нормализацию, фильтрацию запросов и имитированное поведение обхода и кэша на записанных фикстурах; живым нужны учётные данные и Neo4j, и некоторые из них очищают базу данных. Из каталога final/:
python -m pytest -m "not integration"
Файл final/tests/fixtures/FINDINGS.md в репозитории хранит исходные наблюдения о Crawlbase: формы ответов, сравнение readability, сравнение токенов, поведение хранилища и случай с перенаправлением. data/seed_companies.csv перечисляет 50 исходных сайтов для расширения эксперимента.
Заключение
Полезный граф знаний связывает каждое извлечённое утверждение с содержимым, которое его породило. Этот конвейер использует Crawling API для обнаружения и захвата страниц компаний, структурированное извлечение с помощью LLM для предложения сущностей и отношений и Neo4j для запросов к их связям, а на каждом ребре хранятся URL страницы, цитата и rid сохранённой страницы.
Его ограничения заданы намеренно. Наличие цитаты слабее семантической проверки, сопоставление имён слабее разрешения идентичности, а идемпотентная загрузка не то же самое, что отзыв устаревших фактов. В каждое из этих мест стоит вложиться, если граф вырастет из эксперимента с четырьмя компаниями в исследовательский инструмент. Клонируйте репозиторий, создайте аккаунт Crawlbase для этапа сбора данных и начните с исходного списка, достаточно маленького, чтобы прочитать каждое отношение, которое он порождает.
Часто задаваемые вопросы (FAQ)
Когда использовать граф знаний вместо реляционных таблиц?
Хранить эти отношения можно и там, и там. Граф окупается, когда ваши вопросы раз за разом проходят по связям между компаниями, людьми, интеграциями и местоположениями. Отчётность по фиксированной схеме и агрегация по-прежнему проще в таблицах.
Каждому ли сайту компании нужен JavaScript token?
Нет. Несколько записанных страниц отдали содержимое с Normal token, а JavaScript token стоит вдвое дороже за запрос. Используйте его, когда страница строит содержимое в браузере, проверяйте само содержимое, а не доверяйте порогу длины, и очищайте кэш Markdown, когда меняете эту настройку для исходного сайта.
Гарантирует ли совпадающая цитата-источник точность отношения?
Нет. Оно подтверждает, что нормализованная цитата встречается на захваченной странице. Цитата всё равно может не относиться к утверждению, а конвейер добавляет часть отношений путём вывода. Более сильная проверка сверяет вместе идентичности сущностей, смысл отношения и окружающий контекст.
Можно ли улучшить извлечение, не обходя сайты заново?
Да. Получите сохранённый Markdown по rid и извлеките его заново с обновлённым промптом или схемой, пока захват ещё в пределах срока хранения. В конвейере из пакета это refresh с последующим load. Это переобрабатывает существующие доказательства; для актуальной информации нужен новый обход.
Обходите любой сайт в масштабе, без борьбы с инфраструктурой.
Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда занималась конвейерами данных, а не поддержкой инфраструктуры обхода. До 5 000 запросов бесплатно, карта не нужна.

