Знакомо чувство, когда читаешь документацию, а через десяток страниц уже не помнишь, что именно успел изучить? Или когда возвращаешься к старой статье и не можешь понять – ты уже видел эту ссылку или нет?

В эпоху информационного перегруза даже закладки перестают быть спасением – они просто копятся где‑то на панели, а на странице по‑прежнему нет никаких намеков, что ты здесь уже был.

Но что если заставить браузер самому отмечать ссылки, которые у вас уже сохранены? Чтобы слева от каждой знакомой ссылки возникала метка с названием папки из закладок – как тихий намек: “Ты это уже сохранял, не потеряй”.

Сегодня мы не просто поговорим об идее – мы сгенерируем через нейросеть готовое расширение для Chrome, которое сделает это за нас. А заодно разберемся, как такие инструменты создавать, тестировать и даже улучшать – шаг за шагом, от первого промпта до работающего прототипа.

Пристегнитесь, будет интересно!

Стартуем

Собрав мысли в один большой промпт, я отправился за помощью к ChatGPT 5.2:

Можно ли написать расширение для браузера Chrome?

Расширение просматривает ссылки на веб‑страницах и сравнивает их с браузерными закладками. Нужно добавлять “тег” на обычных сайтах: если ссылка на сайте имеется в закладках, то слева от неё возникает тег, имеющий имя родительской папки из закладок.

Можно ли установить такое расширение в браузере Chrome или Brave локально, без его публикации в Chrome Web Store? Кстати, нужно ли будет его добавлять заново при каждом перезапуске браузера (как в Firefox)?

Создайте код такого расширения и опишите способ установки.

Да, те же вопросы можно было бы задать Claude Sonnet 4.5, DeepSeek v3.2, Grok 4, Gemini 3 Pro – нейросетей нынче хватает. Но выбрал я ChatGPT 5.2 по простой причине: он обычно объясняет всё максимально подробно. А учитывая, что раньше я не писал браузерные расширения вообще, это было важно.


Если вам тоже интересно экспериментировать с нейросетями, попробуйте агрегатор BotHub. Там собраны ChatGPT, Claude, Grok, Gemini, Nano Banana Pro – перечислять можно бесконечно!

При регистрации по этой ссылке сервис дарит 100 000 токенов – можно протестировать разные модели на своих задачах.


Чат‑бот сгенерировал код следующих файлов:

manifest.json:

{
  "manifest_version": 3,
  "name": "Bookmark Link Tags",
  "version": "1.0.0",
  "description": "Показывает тег с именем папки закладок рядом со ссылками на страницах.",
  "permissions": ["bookmarks"],
  "host_permissions": ["http://*/*", "https://*/*"],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["http://*/*", "https://*/*"],
      "js": ["content.js"],
      "css": ["content.css"],
      "run_at": "document_idle"
    }
  ]
}

background.js:

// background.js (MV3 service worker)

let urlToFolder = new Map(); // normalizedUrl -> folderTitle
let rebuildTimer = null;

function normalizeUrl(raw) {
  try {
    const u = new URL(raw);

    // Только http/https
    if (u.protocol !== "http:" && u.protocol !== "https:") return null;

    // Убираем hash (#...)
    u.hash = "";

    // Нормализуем hostname (URL сам приводит к lower-case)
    // Убираем "стандартные" порты
    if ((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443")) {
      u.port = "";
    }

    // Убираем trailing slash (кроме корня)
    if (u.pathname.length > 1 && u.pathname.endsWith("/")) {
      u.pathname = u.pathname.slice(0, -1);
    }

    return u.toString();
  } catch {
    return null;
  }
}

async function rebuildIndex() {
  const tree = await chrome.bookmarks.getTree();
  const map = new Map();

  function walk(node, currentFolderTitle) {
    if (!node) return;

    // Если это папка, обновим currentFolderTitle на её имя
    let folderTitle = currentFolderTitle;
    if (!node.url) {
      // node без url – папка (или корень)
      folderTitle = node.title || currentFolderTitle;
    }

    // Если это закладка
    if (node.url) {
      const norm = normalizeUrl(node.url);
      if (norm) {
        // Родительская папка = currentFolderTitle (папка, внутри которой лежит закладка)
        // Если дубль – оставим первое найденное
        if (!map.has(norm)) {
          map.set(norm, currentFolderTitle || "");
        }
      }
    }

    if (node.children && node.children.length) {
      for (const child of node.children) {
        walk(child, folderTitle);
      }
    }
  }

  // У корня title обычно пустой – это нормально
  for (const root of tree) {
    walk(root, "");
  }

  urlToFolder = map;
}

function scheduleRebuild() {
  if (rebuildTimer) clearTimeout(rebuildTimer);
  rebuildTimer = setTimeout(() => {
    rebuildTimer = null;
    rebuildIndex().catch(() => {});
  }, 300);
}

// Перестраиваем индекс при старте
rebuildIndex().catch(() => {});

// И при и��менениях закладок
chrome.bookmarks.onCreated.addListener(scheduleRebuild);
chrome.bookmarks.onRemoved.addListener(scheduleRebuild);
chrome.bookmarks.onChanged.addListener(scheduleRebuild);
chrome.bookmarks.onMoved.addListener(scheduleRebuild);
chrome.bookmarks.onChildrenReordered.addListener(scheduleRebuild);
chrome.bookmarks.onImportEnded.addListener(scheduleRebuild);

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || msg.type !== "lookup" || !Array.isArray(msg.urls)) return;

  const result = {};
  for (const raw of msg.urls) {
    const norm = normalizeUrl(raw);
    if (!norm) continue;

    const folder = urlToFolder.get(norm);
    if (folder !== undefined && folder !== "") {
      // ключом оставим исходный raw, чтобы контент-скрипту было проще сопоставлять
      result[raw] = folder;
    } else if (folder === "") {
      // закладка в корне (без “папки”) – можно вернуть, например, "Закладки"
      result[raw] = "Закладки";
    }
  }

  sendResponse({ type: "result", map: result });
  return true; // async-compatible
});

content.css:

.bm-tag {
  display: inline-block;
  margin-right: 6px;
  padding: 1px 6px;
  border: 1px solid rgba(0, 0, 0, 0.25);
  border-radius: 10px;
  font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
  color: #111;
  background: rgba(255, 230, 150, 0.85);
  vertical-align: baseline;
  white-space: nowrap;
}

.bm-tag[title] {
  cursor: help;
}

content.js:

// content.js

const TAG_ATTR = "data-bm-tagged";
let scanTimer = null;

function normalizeUrl(raw) {
  try {
    const u = new URL(raw, location.href);
    if (u.protocol !== "http:" && u.protocol !== "https:") return null;
    u.hash = "";
    if ((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443")) {
      u.port = "";
    }
    if (u.pathname.length > 1 && u.pathname.endsWith("/")) {
      u.pathname = u.pathname.slice(0, -1);
    }
    return u.toString();
  } catch {
    return null;
  }
}

function getCandidateLinks() {
  const anchors = Array.from(document.querySelectorAll("a[href]"));

  const items = [];
  for (const a of anchors) {
    if (a.hasAttribute(TAG_ATTR)) continue;

    const href = a.getAttribute("href");
    if (!href) continue;

    // Приводим к абсолютному URL
    const abs = normalizeUrl(href);
    if (!abs) continue;

    // Отметим как “обработанный”, ч��обы не спамить запросами
    // (если закладки обновятся – можно будет добавить логику пересканирования по событию)
    a.setAttribute(TAG_ATTR, "1");

    items.push({ a, abs });
  }
  return items;
}

function addTag(anchor, folderTitle) {
  // Не вставляем второй раз
  if (anchor.previousSibling && anchor.previousSibling.classList && anchor.previousSibling.classList.contains("bm-tag")) {
    return;
  }

  const tag = document.createElement("span");
  tag.className = "bm-tag";
  tag.textContent = folderTitle;
  tag.title = `В закладках: ${folderTitle}`;

  // Вставляем слева от ссылки
  anchor.parentNode.insertBefore(tag, anchor);
}

async function scanOnce() {
  const items = getCandidateLinks();
  if (!items.length) return;

  // Собираем URL'ы пачкой
  const urls = items.map(x => x.abs);

  // Запрос к background
  const resp = await chrome.runtime.sendMessage({ type: "lookup", urls });

  if (!resp || resp.type !== "result" || !resp.map) return;

  // Проставляем теги
  for (const { a, abs } of items) {
    const folder = resp.map[abs];
    if (folder) addTag(a, folder);
  }
}

function scheduleScan() {
  if (scanTimer) clearTimeout(scanTimer);
  scanTimer = setTimeout(() => {
    scanTimer = null;
    scanOnce().catch(() => {});
  }, 200);
}

// Первый запуск
scheduleScan();

// Для “живых” страниц (SPA) – отслеживаем изменения DOM
const mo = new MutationObserver(() => scheduleScan());
mo.observe(document.documentElement, { childList: true, subtree: true });
Скриншот ответа

В промпте я перечислил все, что знал заранее:

  • Можно ли установить не через Chrome Web Store, а “просто так” – локально? Оказалось, что да: просто заходишь в раздел расширений, включаешь галочку “Режим разработчика”, затем “Загрузить распакованное”.

  • Впрочем, в Firefox это тоже было возможно, но крайне неудобно – всюду понатыканы ограничения безопасности. В итоге расширение “живет” ровно до момента закрытия браузера, после чего его нужно переустанавливать. К счастью, в Chrome‑браузерах расширение сохраняется после перезапуска.

Установка

Установка действительно оказалась простой: мы просто сохраняем все четыре файла в папку на диске, затем идем в настройки расширений, жмем там “Загрузить распакованное” и выбираем папку. Всё!

Если будете что‑то править по ходу разработки, не забывайте нажимать кнопку перезагрузки расширения – иначе Chrome продолжит использовать старую версию.

Теперь проверим, работает ли все это. В качестве полигона для тестов отлично подходит документация Python, например: https://docs.python.org/3/using/index.html.

Попробуем перетащить пару ссылок в созданную папку на панели инструментов: Python: Прочитал”. Ообновим страницу нажатием F5.

Итак, я добавил буквально по одной ссылке из двух групп: “7.7. The yield statement” (https://docs.python.org/3/reference/simple\_stmts.html#the-yield-statement) и “8.6. The match statement” (https://docs.python.org/3/reference/compound_stmts.html#the-match-statement).

Но почему‑то теги прицепились ко ВСЕМ элементам обеих групп. В чем же причина?

Ответ лежит на поверхности. Он кроется в функции normalizeURL() (файлы background.js, content.js):

u.hash = "";

Хеш‑часть URL (всё, что после #) безоговорочно вытирается. Обычно это полезно – многие сайты используют хеши как временные маркеры, и они не влияют на содержимое страницы. Но в случае документации Python хеш – это как раз ключевая часть ссылки (указывающая на конкретный заголовок), и потеря этой информации приводит к тому, что разные варианты ссылки становятся одинаковыми.

ChatGPT, кстати, сам упоминал это: “Сейчас сопоставление URL довольно простое: убирается #... и конечный /. Если хотите ‘считать одинаковыми’ URL с/без utm_* и тому подобное – можно расширить normalizeUrl()”.

Добавляем белый список для доменов

Полностью отключать очистку хешей – не вариант: на большинстве сайтов она действительно нужна.

Но можно пойти умнее и сделать “белый список” доменов, для которых хеши сохраняются.

Следующий мой промпт в диалоге с ChatGPT 5.2 был таким:

Можешь модифицировать скрипт? Добавь “белый список” доменов, для которых не будет происходить обнуление хеша. В виде массива строк, где каждая означает какой‑то домен. Включи в список один элемент: docs.python.org.

Нейросеть выдала замены normalizeURL() для обоих файлов. Теперь каждая превратилась в массив HASH_KEEP_DOMAIN_WHITELIST + isHashKeptHost() + normalizeUrl(). Да, изменения потребовалось внести сразу в background.js и content.js, потому что логика сопоставления ссылок должна совпадать и в фоне, и на странице.

Замены в двух файлах выглядят идентично:

background.js, content.js (замена функций):

const HASH_KEEP_DOMAIN_WHITELIST = [
  "docs.python.org"
];

function isHashKeptHost(hostname) {
  const host = String(hostname || "").toLowerCase();
  return HASH_KEEP_DOMAIN_WHITELIST.some(d => {
    const dom = d.toLowerCase();
    return host === dom || host.endsWith("." + dom); // на случай поддоменов
  });
}

function normalizeUrl(raw) {
  try {
    const u = new URL(raw);

    // Только http/https
    if (u.protocol !== "http:" && u.protocol !== "https:") return null;

    // Убираем hash (#...), кроме доменов из белого списка
    if (!isHashKeptHost(u.hostname)) {
      u.hash = "";
    }

    // Убираем "стандартные" порты
    if ((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443")) {
      u.port = "";
    }

    // Убираем trailing slash (кроме корня)
    if (u.pathname.length > 1 && u.pathname.endsWith("/")) {
      u.pathname = u.pathname.slice(0, -1);
    }

    return u.toString();
  } catch {
    return null;
  }
}
Скриншот ответа

Как оказалось, список доменов тоже нужно параллельно прописывать в обоих файлах: фоновом background.js и фронте content.js. Что ж, это не так критично, если учесть, что расширение у нас экспериментальное.

Тестируем!

Ура, всё получилось! На этот раз ссылки с хешами #the-yield-statement и #the-match-statement действительно считаются разными. Расширение работает так, как задумано.

Добавляем обновления в реальном времени

После того как расширение стало корректно сравнивать ссылки, возник закономерный вопрос: можно ли сделать так, чтобы появление “тегов” происходило в реальном времени? То есть добавил ссылку в закладки – и отметка мгновенно загорелась на странице, без F5 и наблюдения за спиннером загрузки.

Я отправил ChatGPT следующий запрос:

Возможно ли изменить скрипт следующим образом? Пусть изменения на веб‑странице визуализируются в реальном времени, когда пользователь добавляет/удаляет/перемещает/переименовывает и т. д. закладки, без необходимости обновлять страницу.

Чат‑бот выдал модификации почти всех файлов. Даже manifest.json пополнился новыми разрешениями, чтобы сервис‑воркер мог обращаться к вкладкам и рассылать обновления в открытые вкладки браузера.

Вот готовые версии новых файлов:

manifest.json:

{
  "manifest_version": 3,
  "name": "Bookmark Link Tags",
  "version": "1.0.0",
  "description": "Показывает тег с именем папки закладок рядом со ссылками на страницах.",
  "permissions": ["bookmarks", "tabs"],
  "host_permissions": ["http://*/*", "https://*/*"],
  "background": { "service_worker": "background.js" },
  "content_scripts": [
    {
      "matches": ["http://*/*", "https://*/*"],
      "js": ["content.js"],
      "css": ["content.css"],
      "run_at": "document_idle"
    }
  ]
}

background.json:

let urlToFolder = new Map(); // normalizedUrl -> folderTitle
let rebuildTimer = null;

// background.js (MV3 service worker)

const HASH_KEEP_DOMAIN_WHITELIST = [
  "docs.python.org"
];

function isHashKeptHost(hostname) {
  const host = String(hostname || "").toLowerCase();
  return HASH_KEEP_DOMAIN_WHITELIST.some(d => {
    const dom = d.toLowerCase();
    return host === dom || host.endsWith("." + dom); // на случай поддоменов
  });
}

function normalizeUrl(raw) {
  try {
    const u = new URL(raw);

    // Только http/https
    if (u.protocol !== "http:" && u.protocol !== "https:") return null;

    // Убираем hash (#...), кроме доменов из белого списка
    if (!isHashKeptHost(u.hostname)) {
      u.hash = "";
    }

    // Убираем "стандартные" порты
    if ((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443")) {
      u.port = "";
    }

    // Убираем trailing slash (кроме корня)
    if (u.pathname.length > 1 && u.pathname.endsWith("/")) {
      u.pathname = u.pathname.slice(0, -1);
    }

    return u.toString();
  } catch {
    return null;
  }
}


async function rebuildIndex() {
  const tree = await chrome.bookmarks.getTree();
  const map = new Map();

  function walk(node, currentFolderTitle) {
    if (!node) return;

    // Если это папка, обновим currentFolderTitle на её имя
    let folderTitle = currentFolderTitle;
    if (!node.url) {
      // node без url – папка (или корень)
      folderTitle = node.title || currentFolderTitle;
    }

    // Если это закладка
    if (node.url) {
      const norm = normalizeUrl(node.url);
      if (norm) {
        // Родительская папка = currentFolderTitle (папка, внутри которой лежит закладка)
        // Если дубль – оставим первое найденное
        if (!map.has(norm)) {
          map.set(norm, currentFolderTitle || "");
        }
      }
    }

    if (node.children && node.children.length) {
      for (const child of node.children) {
        walk(child, folderTitle);
      }
    }
  }

  // У корня title обычно пустой – это нормально
  for (const root of tree) {
    walk(root, "");
  }

  urlToFolder = map;
}

function scheduleRebuild() {
  if (rebuildTimer) clearTimeout(rebuildTimer);
  rebuildTimer = setTimeout(() => {
    rebuildTimer = null;
    rebuildIndex().catch(() => {});
  }, 300);
}

// background.js

async function broadcastBookmarksUpdated() {
  const tabs = await chrome.tabs.query({ url: ["http://*/*", "https://*/*"] });

  for (const tab of tabs) {
    if (!tab.id) continue;
    try {
      await chrome.tabs.sendMessage(tab.id, { type: "bookmarksUpdated" });
    } catch {
      // Контент-скрипта может не быть (служебные страницы, недоступные URL и т.п.)
    }
  }
}

async function rebuildIndex() {
  const tree = await chrome.bookmarks.getTree();
  const map = new Map();

  function walk(node, currentFolderTitle) {
    if (!node) return;

    let folderTitle = currentFolderTitle;
    if (!node.url) folderTitle = node.title || currentFolderTitle;

    if (node.url) {
      const norm = normalizeUrl(node.url);
      if (norm) {
        if (!map.has(norm)) map.set(norm, currentFolderTitle || "");
      }
    }

    if (node.children && node.children.length) {
      for (const child of node.children) walk(child, folderTitle);
    }
  }

  for (const root of tree) walk(root, "");

  urlToFolder = map;

  // ВАЖНО: уведомляем вкладки, что данные изменились
  await broadcastBookmarksUpdated();
}

// Перестраиваем индекс при старте
rebuildIndex().catch(() => {});

// И при изменениях закладок
chrome.bookmarks.onCreated.addListener(scheduleRebuild);
chrome.bookmarks.onRemoved.addListener(scheduleRebuild);
chrome.bookmarks.onChanged.addListener(scheduleRebuild);
chrome.bookmarks.onMoved.addListener(scheduleRebuild);
chrome.bookmarks.onChildrenReordered.addListener(scheduleRebuild);
chrome.bookmarks.onImportEnded.addListener(scheduleRebuild);

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || msg.type !== "lookup" || !Array.isArray(msg.urls)) return;

  const result = {};
  for (const raw of msg.urls) {
    const norm = normalizeUrl(raw);
    if (!norm) continue;

    const folder = urlToFolder.get(norm);
    if (folder !== undefined && folder !== "") {
      // ключом оставим исходный raw, чтобы контент-скрипту было проще сопоставлять
      result[raw] = folder;
    } else if (folder === "") {
      // закладка в корне (без “папки”) – можно вернуть, например, "Закладки"
      result[raw] = "Закладки";
    }
  }

  sendResponse({ type: "result", map: result });
  return true; // async-compatible
});

content.js:

const TAG_ATTR = "data-bm-tagged";
const ABS_ATTR = "data-bm-abs";

let scanTimer = null;
let refreshTimer = null;

const HASH_KEEP_DOMAIN_WHITELIST = [
  "docs.python.org"
];

function isHashKeptHost(hostname) {
  const host = String(hostname || "").toLowerCase();
  return HASH_KEEP_DOMAIN_WHITELIST.some(d => {
    const dom = d.toLowerCase();
    return host === dom || host.endsWith("." + dom);
  });
}

function normalizeUrl(raw) {
  try {
    const u = new URL(raw, location.href);

    if (u.protocol !== "http:" && u.protocol !== "https:") return null;

    // Убираем hash (#...), кроме доменов из белого списка
    if (!isHashKeptHost(u.hostname)) {
      u.hash = "";
    }

    if ((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443")) {
      u.port = "";
    }

    if (u.pathname.length > 1 && u.pathname.endsWith("/")) {
      u.pathname = u.pathname.slice(0, -1);
    }

    return u.toString();
  } catch {
    return null;
  }
}

function getCandidateLinks() {
  const anchors = Array.from(document.querySelectorAll("a[href]"));

  const items = [];
  for (const a of anchors) {
    if (a.hasAttribute(TAG_ATTR)) continue;

    const href = a.getAttribute("href");
    if (!href) continue;

    // Приводим к абсолютному URL
    const abs = normalizeUrl(href);
    if (!abs) continue;

    // Отметим как “обработанный”, чтобы не спамить запросами
    // (если закладки обновятся — можно будет добавить логику пересканирования по событию)
    a.setAttribute(TAG_ATTR, "1");

    items.push({ a, abs });
  }
  return items;
}

function addTag(anchor, folderTitle) {
  // Не вставляем второй раз
  if (anchor.previousSibling && anchor.previousSibling.classList && anchor.previousSibling.classList.contains("bm-tag")) {
    return;
  }

  const tag = document.createElement("span");
  tag.className = "bm-tag";
  tag.textContent = folderTitle;
  tag.title = `В закладках: ${folderTitle}`;

  // Вставляем слева от ссылки
  anchor.parentNode.insertBefore(tag, anchor);
}

function getTagEl(anchor) {
  const prev = anchor.previousSibling;
  if (prev && prev.nodeType === Node.ELEMENT_NODE && prev.classList.contains("bm-tag")) return prev;
  return null;
}

function removeTag(anchor) {
  const tag = getTagEl(anchor);
  if (tag) tag.remove();
}

function addOrUpdateTag(anchor, folderTitle) {
  let tag = getTagEl(anchor);
  if (!tag) {
    tag = document.createElement("span");
    tag.className = "bm-tag";
    anchor.parentNode.insertBefore(tag, anchor);
  }
  if (tag.textContent !== folderTitle) tag.textContent = folderTitle;
  tag.title = `В закладках: ${folderTitle}`;
}

function getAllAnchorsWithAbs() {
  const anchors = Array.from(document.querySelectorAll("a[href]"));
  const items = [];

  for (const a of anchors) {
    const href = a.getAttribute("href");
    if (!href) continue;

    // Храним нормализованный absolute URL на самом элементе
    let abs = a.getAttribute(ABS_ATTR);
    if (!abs) {
      abs = normalizeUrl(href);
      if (!abs) continue;
      a.setAttribute(ABS_ATTR, abs);
    }

    items.push({ a, abs });
  }
  return items;
}

async function refreshAllTags() {
  const items = getAllAnchorsWithAbs();
  if (!items.length) return;

  const urls = items.map(x => x.abs);
  const resp = await chrome.runtime.sendMessage({ type: "lookup", urls });
  const map = (resp && resp.type === "result" && resp.map) ? resp.map : {};

  for (const { a, abs } of items) {
    const folder = map[abs];

    if (folder) {
      addOrUpdateTag(a, folder);
    } else {
      // Если раньше был тег, но теперь URL нет в закладках — удаляем
      removeTag(a);
    }
  }
}

async function scanOnce() {
  const items = getCandidateLinks();
  if (!items.length) return;

  // Собираем URL'ы пачкой
  const urls = items.map(x => x.abs);

  // Запрос к background
  const resp = await chrome.runtime.sendMessage({ type: "lookup", urls });

  if (!resp || resp.type !== "result" || !resp.map) return;

  // Проставляем теги
  for (const { a, abs } of items) {
    const folder = resp.map[abs];
    if (folder) addTag(a, folder);
  }
}

function scheduleScan() {
  if (scanTimer) clearTimeout(scanTimer);
  scanTimer = setTimeout(() => {
    scanTimer = null;
    scanOnce().catch(() => {});
  }, 200);
}

// Ваш существующий "первичный" скан можно оставить,
// но лучше сделать его тонким и просто вызывать refreshAllTags().
function scheduleInitialScan() {
  if (scanTimer) clearTimeout(scanTimer);
  scanTimer = setTimeout(() => {
    scanTimer = null;
    refreshAllTags().catch(() => {});
  }, 200);
}

// Первый запуск
//scheduleScan();
scheduleInitialScan();

// Для SPA/динамики DOM: при появлении новых ссылок – перескан
const mo = new MutationObserver(() => scheduleInitialScan());
mo.observe(document.documentElement, { childList: true, subtree: true });

// РЕАЛЬНОЕ ВРЕМЯ: изменения закладок -> обновить теги без перезагрузки страницы
chrome.runtime.onMessage.addListener((msg) => {
  if (!msg || msg.type !== "bookmarksUpdated") return;

  // Небольшой debounce на случай серии событий
  if (refreshTimer) clearTimeout(refreshTimer);
  refreshTimer = setTimeout(() => {
    refreshTimer = null;

    // Важно: если ссылки на странице могли измениться,
    // можно сбросить ABS_ATTR, чтобы пересчитать URL заново:
    // (обычно не нужно, но бывает при динамическом изменении href)
    // document.querySelectorAll(`a[${ABS_ATTR}]`).forEach(a => a.removeAttribute(ABS_ATTR));

    refreshAllTags().catch(() => {});
  }, 150);
});
Скриншот ответа

Тестируем!

На этот раз тоже всё заработало с первого раза. При добавлении, удалении и перемещении закладок, а также переименовывании папки изменения визуализируются менее чем через секунду.

А что там с безопасностью?

В какой‑то момент возник вопрос: а что будет, если веб‑страница сможет прочитать содержимое добавленного мной тега? Ведь фактически это дополнительная разметка, вставленная в DOM.

В итоге всё это свелось к двум важным вещам.

1. Доступ страницы к содержимому тегов.

Ведь если добавляешь элементы на страницу – значит, сама веб‑страница сможет прочитать данные тегов?..

И тут я вспомнил, что есть такой тип элементов – Shadow DOM. Раньше я никогда не применял их, мне казалось это что‑то сложное. И более того, не всегда было понятно, зачем они вообще нужны. Но как раз таки здесь все встало на свои места: Shadow DOM нужен, чтобы скрыть информацию от посторонних глаз.

У Shadow DOM есть и другие полезные черты. К примеру, можно изолировать CSS‑стили от внешнего документа.

Кстати: shadow tree бывают открытые и закрытые. Нам, конечно же, нужны закрытые, так как именно к ним у внешней страницы не будет доступа.

Вот как это выглядит в консоли разработчика:

2. А если пользователь выделит текст?

Даже в закрытом Shadow DOM есть один недочёт: если пользователь сам выделит текст, страница всё равно может считать его через

window.getSelection().toString();

Решение оказалось простым: запретить выделение текста у тегов через

user-select: none !important;

(Да, !important здесь абсолютно оправдан.)

Попробуем применить оба изменения одновременно, чтобы получить финальный скрипт. Я отправил в диалог новый запрос:

Внеси следующие изменения.

1. Пусть внутренности тегов добавляются через Shadow DOM, чтобы их названия были недоступны самой веб‑странице.
2. Запрети выделение текста тегов.

Ответ ChatGPT 5.2 не заставил себя ждать. Изменения были внесены только в content.js, вот код нового файла:

content.js:

// content.js

const ABS_ATTR = "data-bm-abs";

let scanTimer = null;
let refreshTimer = null;

// Храним "ручку" для обновления текста внутри CLOSED Shadow DOM
const tagApi = new WeakMap();

const HASH_KEEP_DOMAIN_WHITELIST = [
  "docs.python.org"
];

function isHashKeptHost(hostname) {
  const host = String(hostname || "").toLowerCase();
  return HASH_KEEP_DOMAIN_WHITELIST.some(d => {
    const dom = d.toLowerCase();
    return host === dom || host.endsWith("." + dom);
  });
}

function normalizeUrl(raw) {
  try {
    const u = new URL(raw, location.href);

    if (u.protocol !== "http:" && u.protocol !== "https:") return null;

    // Убираем hash (#...), кроме доменов из белого списка
    if (!isHashKeptHost(u.hostname)) {
      u.hash = "";
    }

    if ((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443")) {
      u.port = "";
    }

    if (u.pathname.length > 1 && u.pathname.endsWith("/")) {
      u.pathname = u.pathname.slice(0, -1);
    }

    return u.toString();
  } catch {
    return null;
  }
}

function getTagEl(anchor) {
  const el = anchor.previousElementSibling;
  if (el && el.tagName === "BM-TAG") return el;
  return null;
}

function removeTag(anchor) {
  const tag = getTagEl(anchor);
  if (tag) tag.remove();
}

function createTagElement() {
  const el = document.createElement("bm-tag");

  // CLOSED Shadow DOM: страница не сможет прочитать текст тега через DOM
  const shadow = el.attachShadow({ mode: "closed" });

  const style = document.createElement("style");
  style.textContent = `
    :host {
      display: inline-block !important;
      margin-right: 6px !important;
      padding: 1px 6px !important;
      border: 1px solid rgba(0, 0, 0, 0.25) !important;
      border-radius: 10px !important;
      background: rgba(255, 230, 150, 0.85) !important;
      vertical-align: baseline !important;
      white-space: nowrap !important;

      /* (2) Запрет выделения текста тега */
      -webkit-user-select: none !important;
      user-select: none !important;
    }

    span {
      font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif !important;
      color: #111 !important;

      -webkit-user-select: none !important;
      user-select: none !important;
    }
  `;

  const span = document.createElement("span");

  shadow.append(style, span);

  // Никаких title/data-атрибутов с названием папки – чтобы не “утекало” в DOM
  tagApi.set(el, {
    setText: (t) => { span.textContent = String(t ?? ""); }
  });

  return el;
}

function addOrUpdateTag(anchor, folderTitle) {
  let tag = getTagEl(anchor);
  if (!tag) {
    tag = createTagElement();
    anchor.parentNode.insertBefore(tag, anchor);
  }

  let api = tagApi.get(tag);
  if (!api) {
    // Если по какой-то причине tag уже был в DOM, но WeakMap его "не знает",
    // проще пересоздать (из CLOSED shadow всё равно не вытащить span).
    tag.remove();
    tag = createTagElement();
    anchor.parentNode.insertBefore(tag, anchor);
    api = tagApi.get(tag);
  }

  api.setText(folderTitle);
}

function getAllAnchorsWithAbs() {
  const anchors = Array.from(document.querySelectorAll("a[href]"));
  const items = [];

  for (const a of anchors) {
    const href = a.getAttribute("href");
    if (!href) continue;

    let abs = a.getAttribute(ABS_ATTR);
    if (!abs) {
      abs = normalizeUrl(href);
      if (!abs) continue;
      a.setAttribute(ABS_ATTR, abs);
    }

    items.push({ a, abs });
  }
  return items;
}

async function refreshAllTags() {
  const items = getAllAnchorsWithAbs();
  if (!items.length) return;

  const urls = items.map(x => x.abs);
  const resp = await chrome.runtime.sendMessage({ type: "lookup", urls });
  const map = (resp && resp.type === "result" && resp.map) ? resp.map : {};

  for (const { a, abs } of items) {
    const folder = map[abs];

    if (folder) addOrUpdateTag(a, folder);
    else removeTag(a);
  }
}

function scheduleScan() {
  if (scanTimer) clearTimeout(scanTimer);
  scanTimer = setTimeout(() => {
    scanTimer = null;
    refreshAllTags().catch(() => {});
  }, 200);
}

// Первый запуск
scheduleScan();

// SPA/динамический DOM
const mo = new MutationObserver(() => scheduleScan());
mo.observe(document.documentElement, { childList: true, subtree: true });

// Обновление в реальном времени при изменении закладок
chrome.runtime.onMessage.addListener((msg) => {
  if (!msg || msg.type !== "bookmarksUpdated") return;

  if (refreshTimer) clearTimeout(refreshTimer);
  refreshTimer = setTimeout(() => {
    refreshTimer = null;
    refreshAllTags().catch(() => {});
  }, 150);
});
Скриншот ответа

При этом файл content.css можно удалить, так как у shadow‑элементов не имеется доступа к внешним CSS‑стилям.

Визуально почти не изменилось, но теперь структура элементов выглядит совсем иначе:

Файлы расширения Bookmark Link Tags 1.0 можно скачать здесь: https://disk.yandex.ru/d/AMiE9bj1PcBk1g.


Когда я начинал этот проект, то не думал, что всё получится так быстро. Я просто хотел решить свою проблему с закладками, а в итоге научился создавать браузерные расширения. Это был ценный опыт, который показал, что даже небольшие идеи могут привести к интересным результатам.

Сейчас, когда я открываю документацию, вижу теги рядом с ссылками, и это напоминает мне о том, как важно автоматизировать рутину. Кажется, я наконец‑то подружился со своими закладками.

А ещё я понял, что нейросети могут быть настоящими помощниками, если знать, как с ними работать. В будущем я повторю этот опыт с другими задачами.

А что вы думаете о таком подходе к разработке? И что добавили бы в расширение? Пишите в комментариях!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы обычно работаете с закладками в браузере?
33.33%Добавляю всё подряд, потом разбираюсь4
25%Храню только самое важное, регулярно чищу3
25%Пользуюсь папками и тегами для организации3
8.33%Не пользуюсь закладками вообще1
16.67%Использую сторонние сервисы (Pocket, Notion и т. д.)2
16.67%У меня своя система, которой нет в вариантах2
Проголосовали 12 пользователей. Воздержавшихся нет.