Содержание урока · 10 разделов
Ошибка — не повод переписывать парсер, а повод прочитать её текст целиком. Половина материала этого урока уже встречалась по пути: дедлайны из пятнадцатого, рендер из одиннадцатого, сотни вкладок из девятнадцатого — но там они были техникой, здесь они становятся диагнозами: что именно сломалось, почему и что делать в первую минуту. Ценность дисциплины отладки измеряется ночью, когда обход молча встал: у кого-то в терминале одна строка «panic» и ноль версий, а у вас — лог шагов с временами, дамп HTML и скриншот того, что видел браузер в момент сбоя. Ниже — четыре семейства ошибок с причинами и лечениями, обёртка с логированием шагов, которую стоит вставить в парсер сегодня, и таблица для быстрых разборов.
Ошибки chromedp: четыре частых гостя#
Реальные сбои браузерных парсеров на Go сводятся к четырём семействам, и в логе они выглядят узнаваемо. Первое — context deadline exceeded: дедлайн истёк, страница не успела загрузиться или сайт медленный. Второе — «no node set» или пустой результат query: селектор не нашёл узел, чаще всего потому, что страница рендерится JavaScript'ом позже, чем код пытается читать. Третье — connection refused: Chrome не поднялся или порт отладки занят, программы вообще не с чем разговаривать. Четвёртое — tab crashed и context canceled без вашей вины: вкладка или весь браузер погибли, обычно от памяти при сотнях вкладок. Четыре семейства — не потому, что chromedp беден на ошибки, а потому, что архитектура проста: процесс Chrome, DevTools-протокол поверх вебсокета, контекст Go — и ломаться может только одно из трёх звеньев. Разбор всегда идёт сверху вниз: сначала текст ошибки, потом звено, потом причина — и только после этого код. Дальше — каждое семейство отдельно, с причиной и лечением, а в конце урока таблица для быстрых разборов.
ctx deadline exceeded: дедлайн истёк#
context deadline exceeded — самый частый гость, и почти всегда это не баг библиотеки, а арифметика из пятнадцатого урока: дедлайн шага меньше реального времени страницы. Медленный сайт, тяжёлая страница, сеть на другом краю страны — действие честно не уложилось в потолок. Классификация проста: перишаговый дедлайн истёк — ретрай с бэкоффом и свежим контекстом часто проходит; дедлайн сгорел на первом Run, который аллоцирует браузер, — погибли все последующие Run, и это уже авария конфигурации, а не сеть. Проверяйте причину через errors.Is(err, context.DeadlineExceeded), а не сравнением строк: обёртки и ретраи наворачивают слои, и только errors.Is видит сквозь них. Отдельный случай — context canceled при живом браузере и вашей невиновности: контекст отменила сама библиотека, потеряв соединение с браузером, — ретрай в мёртвый контекст бессмыслен, нужен новый NewContext от аллокатора. И помните про отсчёт: дедлайн стартует от вызова WithTimeout, а не от начала Run, — минуты между созданием контекста и Run съедаются молча. Отдельно тренируйте привычку записывать затраченное время каждого шага: потолок, выведенный из замеров, лечит таймауты навсегда, а угаданный — воспроизводит их еженедельно.
Дамп HTML и скриншот при падении шага
GOif err := chromedp.Run(stepCtx,
chromedp.Navigate(url),
chromedp.WaitVisible(".list"),
); err != nil {
// что видит браузер в момент сбоя
var html string
if derr := chromedp.Run(ctx,
chromedp.Evaluate(
`document.documentElement.outerHTML`, &html),
); derr == nil && html != "" {
_ = os.WriteFile("dump.html", []byte(html), 0o644)
}
var shot []byte
if serr := chromedp.Run(ctx,
chromedp.FullScreenshot(&shot, 90),
); serr == nil {
_ = os.WriteFile("dump.png", shot, 0o644)
}
log.Printf("%s: %v (html=%d, png=%d)",
url, err, len(html), len(shot))
}После падения шага дедлайна браузер жив под родительским контекстом — этим пользуются: тем же Run выгружают outerHTML и полный скриншот страницы. Дамп сохраняется с внешним именем, чтобы потом открыть его глазами и увидеть, что же отдал сайт: капчу, заглушку, вёрстку без вашего класса. Errors двух Run независимы: сбой дампа не затирает главную причину.
«No node set» и пустой query: рендер позже кода#
Второе семейство ловит гонку «код быстрее страницы». Query-механика умеет ждать: по умолчанию стоит условие NodeReady — узлы должны существовать, и хелпер внутри крутит проверку с миллисекундными паузами до готовности или дедлайна. Поэтому Text по селектору, которого нет вообще, будет молча ждать до конца контекста — и вернёт таймаут, а не ошибку селектора. Но между «узлы появились» и «данные дописаны» проходит время, и при перерисовке SPA узел, который вы уже прочитали, заменяется новым: действие получает пустой или протухший набор узлов — в логах это всплывает как «no node set» или пустая строка без ошибки. Evaluate добавляет третью ловушку: он ничего не дожидается сам, и мгновенный Evaluate по ещё не отрисованному DOM вернёт null или пустой массив, хотя страница через полсекунды отрисуется. Лечение одно и то же: WaitVisible на устойчивый якорь контента вместо мгновенных чтений, poll-предикаты из девятого урока для изменчивых данных, повтор после перерисовки. Это ровно механика динамического контента из одиннадцатого урока — здесь она проявляется как ошибка, там — как техника. Диагностика простая: тот же селектор через пару секунд находит узел — значит, дело во времени, а не в разметке.
Ночной парсер упал, его перезапустили скриптом — и второй запуск падает с connection refused, хотя код не менялся. Виновник — зомби: первый процесс Chrome остался висеть в памяти с занятым портом отладки, а новый аллокатор не может ни занять порт, ни найти живой браузер. Это лечится гигиеной процессов: ps aux | grep chrome перед стартом, kill зомби, проверка, что временный каталог профиля прошлой сессии убран. Системный запуск через планировщик обязан включать шаг уборки: зависший процесс с одной утечной вкладкой повторится каждую ночь — и ретраи тут бессильны, потому что проблема не в сети, а в окружении запуска.
chromedp отладка: окно и скриншоты#
chromedp отладка начинается с простого шага: выключите невидимость. В DefaultExecAllocator из второго урока прописан headless-режим — флаг Headless в списке опций, — и выключается он добавлением своей опции Flag("headless", false) к срезу аллокатора: браузер запустится окном, и вы увидите всё своими глазами — где кликает программа, что рисует сайт, не вылезла ли капча. Это самый быстрый отладчик из существующих и его недооценивают: пятнадцать секунд наблюдения отвечают на вопросы, на которые лог не ответит и за вечер. Второй инструмент — FullScreenshot(res, quality): полное изображение страницы целиком, включая то, что за пределами вьюпорта, — «что видит браузер» в отличие от того, что видит curl: скрипты исполнены, стиль применён, картинка честная. Третий — дамп HTML после падения из примера выше, открываемый офлайн. У тройки есть общее правило: собирать их надо в момент сбоя, а не после — перезапуск уже изменит состояние сайта, капча переедет, и разбор станет гаданием. Окно с браузером на рабочем столе выглядит несерьёзно рядом с профайлером, но за пятнадцать секунд наблюдения оно отвечает на вопросы, на которые профайлер не ответит никогда.
Обёртка с логом шагов: до и после
GOfunc logged(step string, act chromedp.Action) chromedp.Action {
return chromedp.ActionFunc(func(ctx context.Context) error {
start := time.Now()
log.Printf("-> %s", step)
if err := act.Do(ctx); err != nil {
log.Printf("xx %s: %v (%s)",
step, err, time.Since(start))
return err
}
log.Printf("ok %s (%s)", step, time.Since(start))
return nil
})
}
// chromedp.Run(ctx,
// logged("навигация", chromedp.Navigate(url)),
// logged("ожидание", chromedp.WaitVisible(".list")),
// logged("сбор", chromedp.Evaluate(expr, &rows)),
// )ActionFunc — адаптер, позволяющий обычной функции притворяться действием: обёртка получает шаг, пишет его имя до исполнения, ошибку с длительностью при провале и время при успехе. Act.Do(ctx) исполняет вложенное действие в том же контексте — дедлайны и отмена работают без изменений. Лог превращает падение в отчёт: видно последний успешный шаг и затраченные времена.
Ошибка, причина, лечение#
| Ошибка | Причина | Лечение |
|---|---|---|
| context deadline exceeded | дедлайн меньше реального времени страницы; медленный сайт | errors.Is на тип, ретрай с бэкоффом, потолок шага по замерам |
| context canceled без вины | chromedp потерял браузер и сам отменил контекст | не ретраить: новый NewContext от аллокатора |
| no node set / пусто | JS-рендер позже чтения; узел перерисовался | WaitVisible на якорь, poll-предикат, повтор |
| connection refused | Chrome не поднялся; порт отладки занят зомби | уборка процессов, окружение запуска, не сеть |
| tab crashed | OOM браузера: сотни вкладок, тяжёлые страницы | лимит вкладок, pool, меньше вкладок на браузер |
tab crashed и утечки вкладок: pprof на помощь#
Tab crashed — диагноз памяти: вкладка или браузер погибли, когда одной из сотен вкладок девятнадцатого урока не хватило памяти. Chrome падает по-разному — иногда гаснет вкладка, иногда весь процесс, — но для программы это выглядит одинаково: действия возвращают context canceled, а новый Run в том же контексте молчит. Симптом надёжнее причины: обход с пулом из девятнадцатого урока начал ронять задания через сорок минут — считайте это сигналом OOM, пока не доказано иное. Лечение — снижение потребления: меньше вкладок на браузер, меньше одновременно живых контекстов, обязательно defer cancel() на каждую вкладку — забытый cancel живёт до конца программы и держит вкладку. Для поиска утечек вкладок подходит стандартный арсенал Go: импорт net/http/pprof поднимает профиль — счётчик горутин, растущий от задания к заданию, почти всегда означает контексты без отмены, а heap-профиль показывает, что именно держит память. Снимайте профили на живом обходе, а не после падения: утечка видна в динамике, где две кривые расходятся. И держите лимит вкладок как константу рядом с пулом: растить параллелизм дешевле, чем лечить OOM на проде.
Песочница сайта исполняет тематические программы на чистом Go без браузера — ни tab crashed, ни connection refused, ни дедлайны на настоящем процессе в ней не воспроизвести честно, и это граница честности, а не упущение. Локальный запуск — полноценный полигон: убейте процесс Chrome руками посреди обхода, поднимите два парсера на одном порту, оборвите сеть на шаге — каждая ошибка из таблицы воспроизводится за минуту, и вы увидите настоящий текст ошибки в логе.
Чек-лист разбора падения#
- прочитать текст ошибки целиком, классифицировать errors.Is, не глазами на строку
- deadline exceeded — проверить, где стоит дедлайн и что в нём учитывается
- context canceled при живой программе — умер браузер, восстанавливать контекст
- пустой результат — ожидание на якорь до чтения, Evaluate не ждёт ничего
- connection refused — процессы и порт, не сеть: ps и уборка зомби
- tab crashed — память: лимит вкладок, cancel на каждую, pprof по горутинам
- дамп HTML и скриншот — в момент сбоя, потом окно headless=false и глазами
Проверка: context canceled без вины
Ночной обход вернул context canceled, хотя программа ничего не отменяла. Обход в этом же контексте продолжать?
Проверка: Evaluate вернул пусто сразу
Evaluate по селектору таблицы вернул пустой массив мгновенно, а тот же Text на том же селекторе висит до дедлайна. Почему разные?
Что напишет лог
Прочитайте код и выберите, что он выведет:
// обёртка logged из урока:
// "-> шаг" до, "ok шаг (время)" после,
// "xx шаг: err (время)" при ошибке
err := chromedp.Run(ctx,
logged("навигация", chromedp.Navigate(url)), // ok, 1.2s
logged("ожидание", chromedp.WaitVisible(".zzz")), // дедлайн 3s
logged("сбор", chromedp.Evaluate(expr, &rows)),
)Соберите обёртку с логом
Восстановите адаптер ActionFunc и исполнение действия.
Расставьте разбор падения по шагам
Порядок действий, когда ночной обход упал: от чтения ошибки до воспроизведения глазами.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ошибки и отладка
Что дальше: после разбора сбоев остаётся два шага до эксплуатации. Двадцать четвёртый урок поставит вежливость и закон на службу — этику скрапинга: паузы, robots.txt и границы, за которыми парсер превращается в проблему. Двадцать третий уедет с вами на сервер — Docker и серверное окружение: там connection refused станет главной ошибкой первых дней. Домашняя проверка — локально: заведите обёртку logged в парсере каталога из предыдущего урока, затем воспроизведите три аварии подряд — убитый Chrome, занятый порт, селектор мимо рендера — и убедитесь, что по одному логу с дампами вы можете рассказать, что случилось, не глядя в код.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Четыре семейства сбоев: deadline exceeded, no node set, connection refused, tab crashed — по звену на каждый: дедлайн, рендер, процесс, память.
- errors.Is вместо сравнения строк: обёртки наворачивают слои, и только errors.Is видит первопричину.
- context canceled без вашей вины — библиотека потеряла браузер: ретрай бессилен, нужен новый NewContext от аллокатора.
- Text по несуществующему селектору ждёт до дедлайна, Evaluate ничего не ждёт и возвращает пусто мгновенно.
- Лечение гонки рендера одно: WaitVisible на якорь перед чтением, poll-предикат для изменчивых данных.
- Дамп HTML и FullScreenshot собираются в момент сбоя: перезапуск изменит состояние сайта.
- headless=false — Flag("headless", false) поверх DefaultExecAllocator: браузер окном отвечает быстрее любого лога.
- Обёртка на ActionFunc пишет шаг до и после: по логу виден последний успешный шаг и затраченные времена.
- pprof на живом обходе: растущий счётчик горутин — это контексты и вкладки без cancel.
- Разбор всегда сверху вниз: текст ошибки, звено, причина — и только потом код; замеры времени шага лечат таймауты навсегда.
Домашнее задание
Вооружите парсер каталога из предыдущего урока: обёртка logged на каждом шаге, дамп HTML и FullScreenshot при падении, паузы по-прежнему на месте. Затем устройте аварии по расписанию: убейте Chrome посреди обхода и убедитесь, что по логу видно мёртвый контекст; запустите второй экземпляр на том же порту и прочитайте connection refused; поставьте селектор мимо фактического рендера и посмотрите дамп — на нём должна быть видна страница без вашего класса. Снимите pprof-профиль горутин до и после обхода из тридцати вкладок и объясните разницу. Всё — на локальном Chrome, без внешних сайтов. Финал: вставьте в лог замер времени каждого шага и решите по замерам, какой из дедлайнов в вашем парсере стоит слишком щедро, а какой — впритык.
Личные заметкиПусто
Вопросы и ответы по уроку
Как отличить таймаут своей программы от смерти браузера?
По типу ошибки и по вашей роли: errors.Is(err, context.DeadlineExceeded) — истёк дедлайн, браузер жив, ретрай уместен. errors.Is(err, context.Canceled) при том, что программа сама не вызывала cancel, — контекст отменила библиотека, потеряв браузер: контекст мёртв, восстанавливайте NewContext от аллокатора. Помогает и лог обёртки: таймаут наступает в конце ожидания с длительностью около потолка, смерть браузера — внезапно посреди шага.
Дамп HTML бесполезен: там одна заглушка без контента. Это плохой дамп?
Нет, это самый информативный дамп: заглушка вместо каталога означает, что сайт отдал антибот-страницу, капчу или просит JavaScript-навигацию — парсер попал не на контент, а на фильтр. Сравните дамп с той же страницей в обычном браузере: если глазами контент есть, а в дампе нет, вы читаете слишком рано или не ту вкладку; если заглушка и у людей — вопрос к источнику трафика и лимитам, а не к коду.
Нужен ли pprof постоянно или только при аварии?
Достаточно лёгкой формы: счётчик горутин и память в лог раз в несколько минут по runtime.NumGoroutine и runtime.ReadMemStats — это почти бесплатно и сразу видно утечку вкладок и контекстов. Полный pprof-сервер с heap-профилями включайте точечно на время разбора: он пишет из себя память и открывает порт, что на сервере с внешним доступом само по себе риск. Динамика важнее снимка: утечка — это расходящиеся кривые, а не высокое значение.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Testcontainers в Go: интеграционные тесты с Docker
Курс testcontainers-go: Postgres, Redis, Kafka и Mongo в тестах через Docker, wait-стратегии, сети, параллельность, CI и итоговый стенд. 25 уроков.
- Поднимать реальные зависимости в Docker из кода теста
- Использовать модули Postgres, Redis, MongoDB, Kafka и RabbitMQ
- Ждать готовность сервиса wait-стратегиями вместо sleep
- Собирать стенды из нескольких контейнеров и своего образа