Если расширение Chrome запускается, но не видит таблицу, карточку, цену или другой элемент после загрузки страницы, проблема чаще находится не в селекторе как таковом. Современный сайт может дорисовать интерфейс после ответа API, заменить весь узел при переходе внутри SPA, разместить данные в iframe или Shadow DOM либо хранить нужное значение только в JavaScript-состоянии. Content script при этом работает в отдельном контексте и может выполниться раньше, чем приложение подготовило данные.
Исправление начинается с определения источника данных и контекста выполнения. Не стоит добавлять случайные setTimeout на несколько секунд: на быстром компьютере задержка будет лишней, а на медленном соединении все равно не поможет. Надежное расширение ждет конкретное условие, отслеживает замену нужного узла и повторно обрабатывает страницу без дублирования результата.
Сначала определите, где находятся данные
Одинаково выглядящая информация может появляться на странице разными способами. Откройте DevTools сайта, выберите элемент и установите его реальное происхождение.
- обычный текст или атрибут в DOM основного документа;
- узел, добавленный после fetch или XHR-запроса;
- компонент SPA, который заменяется без полной перезагрузки;
- содержимое iframe того же или другого origin;
- элемент внутри открытого Shadow DOM;
- содержимое закрытого Shadow DOM;
- виртуализированный список, где в DOM присутствуют только видимые строки;
- значение в JavaScript-переменной или внутреннем store приложения;
- ответ API, который интерфейс преобразует перед отображением;
- canvas или изображение, где обычного текстового узла нет.
Если значение видно на экране, это еще не означает, что document.querySelector найдет постоянный текстовый узел. Например, виртуальная таблица удаляет строки после прокрутки, а React-компонент может заменить найденный элемент новым с тем же внешним видом.
Проверьте, запускается ли content script
У расширения несколько контекстов с отдельными журналами: content script, service worker, popup, side panel и сама страница. Сообщение в консоли service worker не доказывает запуск скрипта во вкладке. Добавьте временный диагностический маркер с версией расширения, URL, временем, frameId или признаком top frame. Не записывайте содержимое страницы, токены и персональные данные.
- URL действительно совпадает с шаблоном matches, включая поддомен и протокол;
- расширение перезагружено после изменения manifest.json;
- тест выполняется в том профиле браузера, где установлена нужная версия;
- для режима инкогнито разрешен запуск, если проверка идет в нем;
- host permissions или activeTab выданы до программной инъекции;
- content script не завершился с исключением до поиска данных;
- код выполняется в нужном frame, а не только в верхнем документе;
- страница не относится к системным URL, куда обычная инъекция запрещена.
Почему document_idle не гарантирует готовность данных
В manifest поле run_at управляет стадией внедрения content script. Значение document_idle означает, что DOM документа уже сформирован и браузер выбрал подходящий момент между document_end и временем вскоре после window.onload. Это не является сигналом готовности бизнес-данных SPA. Приложение может после загрузки получить профиль, права, каталог и только затем отрисовать нужный блок.
Выбирать document_start ради более раннего запуска тоже недостаточно: скрипт увидит еще меньше элементов и должен будет ждать изменения DOM. Правильный run_at зависит от задачи, но готовность конкретного компонента всегда проверяется отдельным условием.
Безопасное ожидание динамического элемента
Сначала выполните быстрый поиск. Если элемент уже есть, обработайте его. Если нет, установите MutationObserver на минимальный стабильный контейнер, а не на весь документ без ограничений. Обработчик должен объединять частые изменения в один проход и отключаться, когда работа завершена или страница больше не подходит.
const processed = new WeakSet(); let scheduled = false; function scan(root = document) { const items = root.querySelectorAll('[data-product-id]'); for (const item of items) { if (processed.has(item)) continue; processed.add(item); processItem(item); } } function scheduleScan() { if (scheduled) return; scheduled = true; queueMicrotask(() => { scheduled = false; scan(); }); } scan(); const observer = new MutationObserver(scheduleScan); observer.observe(document.body, { childList: true, subtree: true });WeakSet защищает только от повторной обработки того же объекта узла. Если SPA удалит элемент и создаст новый, обработка выполнится снова. Для бизнес-действия, которое нельзя повторять, используйте стабильный идентификатор сущности — например productId — и храните состояние отдельно. Не помечайте DOM-элемент до успешного завершения операции.
Как понять, что селектор больше не подходит
- селектор работает в DevTools, но не в момент запуска content script;
- класс содержит хэш сборки и меняется после обновления сайта;
- на странице несколько одинаковых блоков, а querySelector берет первый скрытый;
- элемент находится в другом frame или shadow root;
- нужный атрибут появляется позже самого узла;
- компонент заменяет элемент после гидратации;
- A/B-тест создает другую структуру для части пользователей;
- локализация меняет текст, по которому построен селектор.
Предпочитайте стабильные data-атрибуты, семантические роли и структурные связи. Текст интерфейса и длинная цепочка nth-child хрупки. Если сайт принадлежит вам, добавьте явный data-extension-hook или документированный API вместо разбора визуальной верстки.
SPA меняет адрес без новой загрузки документа
В одностраничном приложении переход между карточками может изменить history state и DOM, но не создать новый документ. Статически объявленный content script не запускается заново как при обычной навигации. Код, который обработал первую карточку и завершился, не увидит следующую.
Нужно отделить жизненный цикл документа от жизненного цикла представления. Следите за появлением корневого компонента, за изменением устойчивого идентификатора сущности или за навигацией через API расширения, если оно уже использует соответствующие разрешения. После перехода отменяйте старые асинхронные операции и запускайте обработку нового представления.
- храните текущий URL и ID сущности, а не только флаг initialized;
- делайте initialize идемпотентным;
- отключайте observer и listeners старого представления;
- используйте AbortController для отмены устаревших запросов;
- проверяйте, относится ли полученный ответ к текущей карточке;
- не создавайте второй одинаковый listener при каждом изменении DOM.
Content script не видит переменные страницы
По умолчанию content scripts работают в изолированном мире. Они видят DOM, но JavaScript-переменные страницы и объекты расширения находятся в разных средах. Поэтому window.appState в консоли сайта может существовать, а в content script быть undefined. Это штатная изоляция, защищающая страницу и расширение от взаимного изменения глобальных переменных.
Сначала проверьте, можно ли получить значение из DOM, доступного API или сообщения, которое сайт официально предоставляет. Инъекция в MAIN world нужна только при обоснованной необходимости. Код в главном мире контролируется окружением страницы, подчиняется ее CSP и не должен получать секреты расширения.
Как передать данные из MAIN world
Разделите небольшой page bridge и доверенную логику расширения. Bridge извлекает только разрешенные данные и передает сериализуемое сообщение. Content script проверяет тип, структуру, источник и ожидаемый идентификатор. Сайт способен подделать DOM-событие или window.postMessage, поэтому такие сообщения нельзя считать доверенными командами.
- не передавайте через страницу OAuth-токены и внутренние ключи расширения;
- задайте строгую схему сообщения и список разрешенных полей;
- не выполняйте код, полученный из сообщения;
- проверяйте текущий URL, frame и ID сущности;
- ограничьте объем и частоту сообщений;
- не используйте MAIN world, если достаточно DOM.
Данные находятся в iframe
По умолчанию content script работает только в верхнем frame, если конфигурация не предусматривает остальные. Для статического объявления используется all_frames, а при программной инъекции выбираются allFrames, frameIds или documentIds. Скрипт попадет только в frame, URL которого соответствует разрешенным шаблонам.
Сначала найдите frame в панели Elements или Application и зафиксируйте его URL и origin. Не добавляйте доступ ко всем сайтам ради одного виджета. Для about:, data: или blob:-frame может потребоваться match_origin_as_fallback, если frame создан подходящим origin и конфигурация действительно допускает такой сценарий.
- проверяйте top frame и дочерние frame отдельными диагностическими сообщениями;
- передавайте результат в service worker вместе с tabId, frameId и documentId;
- не объединяйте данные разных frame без идентификатора источника;
- учитывайте, что cross-origin iframe имеет собственные разрешения и жизненный цикл;
- удаляйте состояние frame после его навигации или уничтожения.
Элемент находится в Shadow DOM
Обычный document.querySelector не проходит внутрь shadow root. Для открытого Shadow DOM нужно найти host, получить shadowRoot и искать внутри него. Если компоненты вложены, обход выполняется рекурсивно и с защитой от повторного сканирования. MutationObserver верхнего документа не сообщает обо всех внутренних изменениях уже существующего shadow root — наблюдатель устанавливают и на нужный открытый root.
Закрытый shadow root не предоставляет штатного доступа через host.shadowRoot. Попытки ломать инкапсуляцию зависят от внутренней реализации сайта и легко нарушают безопасность. Для собственного сайта лучше добавить публичный DOM-hook, событие или API. Для стороннего ресурса следует пересмотреть способ интеграции.
Виртуальные списки и ленивый рендеринг
Таблица на тысячу строк может держать в DOM только двадцать видимых. Прокрутка переиспользует те же элементы для других записей, поэтому сохраненная ссылка на узел перестает соответствовать прежним данным. Считывание всех строк через querySelectorAll в таком интерфейсе принципиально не дает полный набор.
- используйте официальный экспорт или API, если нужен весь набор;
- для видимых строк связывайте результат со стабильным ID записи;
- перепроверяйте ID перед применением асинхронного результата;
- не запускайте автоматическую прокрутку без явного согласия пользователя;
- не считайте число DOM-строк числом записей.
Права и host permissions
Программная инъекция через chrome.scripting требует разрешения scripting и доступа к целевому host либо временного activeTab после действия пользователя. Шаблон URL должен учитывать реальный протокол и поддомен. После перехода на другой домен доступ не переносится автоматически.
Запрашивайте минимальные разрешения и объясняйте их назначение. Не расширяйте matches до всех HTTPS-сайтов только потому, что один клиент использует несколько поддоменов. Для необязательных интеграций подходят optional_host_permissions, запрашиваемые в момент включения функции.
Сообщения между частями расширения
Content script может найти данные, но результат потеряется по дороге в service worker или popup. Popup существует только пока открыт, а асинхронная цепочка может завершиться позже. Ответ следует направлять в постоянное хранилище или service worker, а интерфейс должен запрашивать актуальное состояние при открытии.
- задавайте requestId и entityId каждому сообщению;
- обрабатывайте chrome.runtime.lastError или отклонение Promise;
- не полагайтесь на открытую popup-страницу как на хранилище;
- проверяйте sender.tab, frameId и origin сообщения;
- не отправляйте большие снимки DOM через messaging;
- версионируйте формат сообщений между content script и service worker.
Пошаговый план исправления
- Воспроизведите сбой на одной странице и зафиксируйте URL, версию расширения и момент появления данных.
- Определите, находятся данные в обычном DOM, iframe, Shadow DOM, виртуальном списке, JavaScript state или API.
- Убедитесь, что content script действительно запустился в нужном document и frame.
- Проверьте matches, run_at, host permissions и фактически выданные разрешения.
- Выполните поиск сразу, затем добавьте ограниченное ожидание конкретного условия.
- Для динамического DOM используйте MutationObserver с объединением событий и очисткой.
- Добавьте повторную инициализацию при SPA-навигации и отмену устаревших операций.
- Для iframe настройте адресные разрешения и нужные frame, не включая лишние домены.
- Для открытого Shadow DOM наблюдайте за конкретными shadow root.
- Если нужны переменные страницы, сделайте минимальный MAIN-world bridge без секретов.
- Стабилизируйте селекторы и идентификаторы сущностей.
- Протестируйте повторный запуск, навигацию назад и несколько вкладок.
Как проверить исправление
- обычная загрузка при быстром и медленном соединении;
- данные появляются до и после запуска content script;
- переход между карточками SPA без перезагрузки;
- назад, вперед и повторное открытие той же сущности;
- элемент удаляется и создается заново;
- страница содержит несколько похожих блоков;
- данные находятся в top frame и дочернем iframe;
- открытый Shadow DOM и компонент с закрытым root;
- виртуальный список после прокрутки;
- несколько вкладок одного сайта;
- перезагрузка расширения при уже открытой странице;
- пользователь отозвал optional host permission;
- страница вернула пустой ответ API или ошибку авторизации.
Успешный тест — это не только найденное значение. Расширение должно обработать каждую сущность ровно один раз, не оставить старые observers и listeners, не перепутать ответы между карточками и корректно сообщить пользователю, если доступ к данным невозможен.
Типичные ошибки
- добавлять фиксированный setTimeout вместо ожидания условия;
- считать window.onload моментом готовности SPA;
- искать переменную страницы из isolated world;
- наблюдать весь document без фильтра и запускать тяжелый поиск на каждую мутацию;
- создавать новый MutationObserver при каждом изменении;
- использовать динамические классы и текст интерфейса как единственный селектор;
- забывать про iframe и all_frames;
- ожидать, что querySelector пройдет внутрь Shadow DOM;
- читать виртуальный список как полный набор данных;
- инъецировать весь код в MAIN world и передавать туда секреты;
- запрашивать доступ ко всем сайтам без необходимости;
- хранить состояние только в popup;
- не отменять запрос старой карточки после SPA-перехода.
Как предотвратить повторение
- разделить поиск DOM, извлечение данных и бизнес-действие;
- сделать инициализацию и обработку идемпотентными;
- использовать стабильные ID и data-hooks вместо визуальной структуры;
- хранить selectors и адаптеры по версиям поддерживаемых сайтов;
- добавить тестовые страницы для delayed render, SPA, iframe и Shadow DOM;
- логировать только технические этапы без содержимого и токенов;
- контролировать число активных observers и listeners;
- показывать понятное состояние «данные еще загружаются» или «доступ не выдан»;
- проверять расширение после обновлений поддерживаемого сайта;
- предпочитать официальный API для массовых и критичных данных.
Когда нужна помощь с расширением
Если расширение не видит данные на странице после загрузки, я могу определить реальный источник данных, проверить manifest, permissions, content script, SPA-навигацию, iframe и Shadow DOM, затем сделать устойчивую обработку без случайных задержек и дублей. Для оценки пришлите версию браузера и расширения, обезличенный URL-пример, фрагмент manifest.json, ожидаемый элемент и технические ошибки из нужного контекста. Токены, cookies, содержимое личных кабинетов и рабочий профиль браузера передавать не нужно.