Расширение браузера: чтение выделения по действию пользователя
Popup и worker уже умеют обменяться простой командой. Теперь расширению нужны данные выбранной страницы: название, адрес и выделенная читателем фраза. Введём activeTab и scripting, сохранив явное действие пользователя. Результат урока — после нажатия «Сохранить страницу» popup получает структурированный снимок документа, но ещё не сохраняет постоянную запись. На этой ступени слово кнопки опережает будущую операцию; строка результата честно показывает только чтение.
activeTab даёт временный доступ после вызова action пользователем. Мы не объявляем постоянный доступ ко всем сайтам и не используем
Выбранная вкладка
Popup вызывает tabs.query с active=true и currentWindow=true. Результат — массив найденных вкладок; берём первую. Перед использованием проверяем, что id является целым числом. Вкладка может исчезнуть или оказаться недоступной между чтением и выполнением скрипта, поэтому операция обёрнута в try/catch. Значение tab.id не является разрешением само по себе: браузер дополнительно проверяет полномочия scripting для выбранной страницы.
scripting.executeScript получает target.tabId и files=["content.js"]. Мы указываем только собственный заранее известный файл. Не принимаем имя скрипта, функцию или URL кода из текста сайта. Это ключевая граница: страница может предоставить содержание заметки, но не решает, какую privileged операцию выполнит расширение. В проекте нет динамического исполнения полученного JavaScript и моста window.postMessage, который позволял бы странице передавать произвольные команды worker.
По умолчанию выбранный content script работает в изолированном мире. Он видит DOM страницы, но не использует общие переменные её JavaScript как свой интерфейс. Наша IIFE читает document.title, location.href и window.getSelection. В конце она возвращает обычный объект, который становится результатом выполнения файла. В worker нет DOM, поэтому именно этот контекст получает текст документа. Изолированный мир не делает само содержание надёжным: заголовок всё ещё задаёт чужой сайт.
Полные файлы шага
Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/browser-extension-lab/lesson-04/.
manifest.json
{
"manifest_version": 3,
"name": "Заметки читателя",
"version": "0.1.0",
"minimum_chrome_version": "102",
"description": "Локальные заметки о самостоятельно выбранных страницах",
"action": {
"default_popup": "popup.html",
"default_title": "Заметки читателя"
},
"background": {
"service_worker": "worker.js",
"type": "module"
},
"permissions": [
"activeTab",
"scripting"
]
}
content.js
(() => {
// Executed once in the isolated world after the user's popup action.
const selected = window.getSelection()?.toString() ?? "";
return {
title: document.title.slice(0, 200),
url: location.href,
quote: selected.slice(0, 2000)
};
})();
popup.js
const status = document.querySelector("#status");
document.querySelector("#settings").hidden = true;
document.querySelector("#save").addEventListener("click", async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!Number.isInteger(tab?.id)) throw new Error("Нет активной вкладки");
const frames = await chrome.scripting.executeScript({
target: { tabId: tab.id }, files: ["content.js"]
});
const page = frames.find((frame) => frame.frameId === 0)?.result;
status.textContent = page ? `${page.title}: ${page.quote}` : "Нет результата";
} catch (error) { status.textContent = `Страница недоступна: ${error.message}`; }
});
Ожидаемый результат
Это ожидаемый договор для самостоятельного исследования; расширение не устанавливалось и не запускалось.
Собственная обычная вкладка + выделение → title, url, quote основного кадра.
Служебная страница → понятный отказ.
Постоянная запись пока не выполняется.
Ограниченный снимок текста
title обрезается до двухсот символов, quote — до двух тысяч. Эти ограничения сдерживают размер данных ещё в момент чтения, но позднее будут проверены повторно принимающим worker. Мы не сохраняем весь HTML и не копируем страницу целиком. Пользователь выбирает фразу, а расширение получает текст выделения. Если выделения нет, quote становится пустой строкой. Отсутствие цитаты не означает ошибку: собственная заметка может быть полезна и без неё.
Результаты executeScript содержат сведения по кадрам. Выбираем frameId=0, то есть основной документ. Мы не включаем allFrames и не обещаем собирать выделение из каждого встроенного iframe. Это конкретный предел учебного механизма. Если фраза выбрана внутри отдельного кадра, результат основного документа может не совпасть с ожиданием человека. Решение такой задачи потребовало бы другого выбора целей и повторной оценки разрешений, а не незаметного расширения доступа.
Адрес берётся из location.href. Пока он может содержать query и hash; перед сохранением введём нормализацию и отдельный выбор приватности. Уже сейчас полезно понимать, что URL может содержать пользовательские параметры. Поэтому учебные действия выполняются на своих обычных страницах без секретов. Мы не используем историю браузера или аккаунты пользователя и не собираем данные в фоне на каждом переходе. Чтение связано с конкретным нажатием в popup.
Асинхронный ответ и отказ
await означает, что строка результата появляется после завершения executeScript. Если функция ещё не вернулась, нельзя объявлять полученные данные доступными. Popup берёт page из результата основного кадра и проверяет его наличие. Затем textContent показывает название и цитату буквально. Страница с заголовком, похожим на разметку, не получает HTML-исполнение внутри расширения. Это продолжение безопасного вывода из прежней главы, теперь применённое к чужому источнику текста.
Ошибочный сценарий — действие на chrome://extensions или другой служебной поверхности. Ожидается сообщение «Страница недоступна» с причиной отказа API. Мы не пытаемся обойти ограничение браузера и не добавляем дополнительные полномочия ради такого примера. Если вкладка обычная, но закрылась во время операции, также возможен отказ. Интерфейс должен различать отсутствие завершённого чтения и успешно полученный объект, а не показывать старую цитату как свежий результат.
Ожидаемый положительный сценарий использует собственную страницу с заголовком «Учебная библиотека» и выделением «Диапазон исключает верхнюю границу». Popup должен получить эти строки, а постоянное хранилище остаётся пустым, поскольку его ещё нет. Автор не устанавливал расширение, не выделял текст в браузере и не создавал скриншот этого результата. Полный content.js на этой странице является исходником ожидаемого механизма, а не записью проведённого опыта.
Действие не означает вечный доступ
Временное полномочие activeTab связано с вызовом расширения и текущей страницей. Переходы могут менять его область и доступность; не держим полученный tab.id как вечное разрешение на будущий сайт. Каждый новый пользовательский сценарий снова находит активную вкладку и обрабатывает отказ. Такой подход соответствует назначению заметок: читатель выбирает конкретную страницу, а расширение реагирует сейчас. Для постоянного автоматического наблюдения понадобился бы другой продуктовый договор.
Следующий урок примет полученный объект в worker и проверит все поля сообщения. Это необходимо даже при собственном content.js: данные документа всё равно чужие, а граница получателя должна быть явной. Мы получили ограниченный снимок страницы через пользовательское действие и знаем его пределы: основной кадр, текстовое выделение, обычные веб-адреса и возможный отказ браузера. Эти свойства станут исходным состоянием дальнейшего сохранения.
Название страницы не является её устойчивым идентификатором. Два документа могут иметь одинаковый title, а один сайт может менять его после загрузки. Поэтому снимок сохраняет и URL, а будущая заметка получит отдельный UUID. Выделенная цитата тоже не является ключом записи: человек может дважды отметить одну фразу с разными собственными пояснениями. Это разделение позволяет позднее объяснить дубли по намерению SAVE и точные повторы импортированного UUID, не пытаясь угадывать идентичность заметки по совпадению текста.
Документация и исходники
Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Заметок читателя» содержит все двенадцать состояний. Установка, запуск и публикация не выполнялись.