В статьях я стараюсь оставлять максимальное количество ссылок на ресурсы, которые помогли мне подробнее ознакомиться с рассматриваемой темой. Мне кажется, не всегда из контекста понятно, какую именно информацию даст та или иная ссылка, поэтому я подумал о добавлении механизма их предпросмотра.
Самым очевидным может показаться отображение страницы, расположенной по ссылке, в iframe — это могло быть неким аналогом функции предварительного просмотра ссылок в Safari . Такой вариант выглядит максимально универсальным, но он слишком громоздкий и весьма неинформативный, поскольку необходимо сориентироваться на странице в маленьком окошке превью и найти ключевую мысль среди прочего контента, хотя вместо этого можно просто открыть ссылку в новой вкладке, но это всё может сильно отвлекать от прочтения исходной статьи.
Разумеется среди прочих ресурсов я нередко упоминаю википедию, особенно когда использую какие-либо термины в статье, — именно для этого кейса предпросмотр ссылок мне показался самым полезным. Зайдя в саму википедию я обнаружил, что в ней уже есть такая функциональность, и решил вдохновиться ей.
Первым делом я полез в веб-инспектор, чтобы выяснить, как работает превью ссылок в википедии. Оказалось, что для этого в ней используется собственный API , который позволяет получить некоторые данные о конкретной статье в общем формате. Например, по запросу
она отдаёт ответ примерно следующего содержания:
В примере выше я продемонстрировал только поля, которые нам нужны в рамках текущей задачи, на самом деле их там больше.
Теперь реализуем механизм запроса данных для ссылок на википедию.
Адреса статей в википедии имеют такой формат:
Адреса для получения данных о статье из API такой:
LANG — язык, на котором написана статья
ARTICLE_ID — её идентификатор
В первую очередь реализуем преобразование одного адреса в другой:
Теперь можем реализовать функцию получения данных о статье:
С википедией всё получилось довольно просто и я подумал, что было бы круто сделать превью для всех ссылок в статьях, но подобный API есть явно не у всех сайтов в интернете. Так как же быть в таком случае?
Open Graph разметка представляет собой meta -теги с атрибутами property и content , расположенные внутри head :
Среди всех возможных тегов для генерации превью будет достаточно всего трёх:
og:title — заголовок страницы
og:description — краткое описание страницы
og:image — превью-картинка страницы
Эти теги встречаются на большинстве сайтов, но, к моему удивлению, их нет в википедии, поэтому для неё оставляем механизм, реализованный ранее, а для остальных ссылок сделаем отдельную реализацию.
Для начала выделим общий интерфейс для получения данных о ссылке, чтобы одинаковым образом получать данные из любого источника:
Теперь перейдём к реализации.
Задача заключается в том, чтобы получить HTML и найти в нём Open Graph теги. Можно было бы поискать их регуляркой, но к счастью наш код предназаначен для браузера, который в свою очередь уже умеет парсить HTML в DOM, по которому мы можем искать данные с помощью селекторов. Для этого есть класс DOMParser :
Теперь реализуем итоговую функцию, которая будет получать данные для превью любой ссылки:
С общедоступным API википедии всё отлично, но вот делать fetch большинства чужих сайтов не позволяет CORS .
CORS — это механизм, работающий исключительно на стороне клиента, то есть пройтись по ссылкам можно в среде, над которой я имею больше контроля — на сервере. Можно, конечно, воспользоваться сервисом вроде opengraph.io , но зачем платить больше, если у меня уже есть своего рода сервер, хоть и одноразовый, поскольку работает только на этапе сборки.
Формулируем задачу: необходимо пройтись по всем ссылкам во всех статьях во время сборки и сохранить информацию для превью ссылок в каком-то месте, доступном на клиенте.
Для всех ссылок в статьях у меня реализован Astro-компонент , код которого выполняется исключительно на сервере (в случае SSG — во время сборки) и может совершать сетевые запросы, а значит каждая ссылка может самостоятельно запросить данные для своего превью.
Можно было бы использовать написанную ранее функцию fetchLinkPreviewData , но, к сожалению, Node.js сообщает, что «DOMParser is not defined», поэтому придётся искать другой способ парсинга HTML.
В результате недолгих поисков я наткнулся на cheerio — библиотеку для парсинга HTML в Node.js.
API этой библиотеки несколько отличается от браузерного DOMParser , поэтому для максимально бесшовного перехода я решил реализовать адаптер.
Для начала опишем интерфейс, содержащий методы DOMParser , которые нужны для решения текущей задачи:
Тогда браузерная реализация этого интерфейса будет максимально простой:
Теперь реализуем парсер на основе cheerio:
И сделаем инверсию зависимостей для fetchDefaultLinkPreviewData :
И будем динамически выбирать парсер в зависимости от окружения, в котором запущен код. Динамически импортировать ES-модули можно только асинхронно , но это не проблема, поскольку текущая поддержка top level await меня вполне устраивает:
Такой код называется изоморфным , так как может выполняться и на клиенте и на сервере.
В целом мне теперь вряд ли пригодится парсить HTML на клиенте, но я решил продемонстрировать этот код, как наглядный пример практик, о которых я рассказывал в предыдущих статьях.
Реализация механизма получения данных готова, но есть небольшой нюанс в виде замедления сборки в dev-режиме примерно в 350 раз.
Вызвано оно тем, что dev серверу приходится ходить по всем ссылкам в статье при каждом изменении в коде.
Во время разработки реальные данные не так важны, поэтому можно замокать их получение, реализовав моковую функцию для созданного ранее интерфейса:
И в dev-режиме использовать её вместо настоящих:
Думаю, вместо этого можно реализовать некий кеш, как это делает Telegram, но, пожалуй, займусь этим позже.
Осталось положить эти данные в HTML во время генерации статики. Делать я это буду в Astro-компоненте, но, думаю, смысл кода будет понятен без какой-либо подготовки:
Не знаю, насколько это уместно, но часть данных я решил положить в Aria-атрибуты.
Теперь клиент обладает всеми необходимыми данными для отображения превью ссылки и можно заняться его вёрсткой, но об этом я расскажу в следующей статье…