Генерация превью для ссылок: получаем данные

В статьях я стараюсь оставлять максимальное количество ссылок на ресурсы, которые помогли мне подробнее ознакомиться с рассматриваемой темой. Мне кажется, не всегда из контекста понятно, какую именно информацию даст та или иная ссылка, поэтому я подумал о добавлении механизма их предпросмотра.
Самым очевидным может показаться отображение страницы, расположенной по ссылке, в iframe
— это могло быть неким аналогом функции
Разумеется среди прочих ресурсов я нередко упоминаю википедию, особенно когда использую какие-либо термины в статье, — именно для этого кейса предпросмотр ссылок мне показался самым полезным. Зайдя в саму википедию я обнаружил, что в ней уже есть такая функциональность, и решил вдохновиться ей.
Wikipedia API
Первым делом я полез в веб-инспектор, чтобы выяснить, как работает превью ссылок в википедии. Оказалось, что для этого в ней используется
https://ru.wikipedia.org/api/rest_v1/page/summary/JavaScript
она отдаёт ответ примерно следующего содержания:
{ "title": "JavaScript", "thumbnail": { "source": "https://upload.wikimedia.org/wikipedia/commons/..." }, "extract": "JavaScript — мультипарадигменный язык программирования..."}
В примере выше я продемонстрировал только поля, которые нам нужны в рамках текущей задачи, на самом деле их там больше.
Теперь реализуем механизм запроса данных для ссылок на википедию.
Адреса статей в википедии имеют такой формат:
https://<LANG>.wikipedia.org/wiki/<ARTICLE_ID>
Адреса для получения данных о статье из API такой:
https://<LANG>.wikipedia.org/api/rest_v1/page/summary/<ARTICLE_ID>
LANG
— язык, на котором написана статьяARTICLE_ID
— её идентификатор
В первую очередь реализуем преобразование одного адреса в другой:
export const WIKIPEDIA_URL_REGEXP = /https:\/\/(.+)\.wikipedia\.org\/wiki\/(.+)/;
export function getWikipediaApiUrl(articleUrl: string): string | undefined { const articleLinkMatch = href.match(WIKIPEDIA_URL_REGEXP);
if (!articleLinkMatch) { return undefined; }
const [_, lang, articleId] = articleLinkMatch;
return `https://${lang}.wikipedia.org/api/rest_v1/page/summary/${articleId}`;}
Теперь можем реализовать функцию получения данных о статье:
export interface LinkPreviewData { title: string; description: string; image?: string;}
export async function fetchWikipediaLinkPreviewData( href: string): Promise<LinkPreviewData | undefined> { const apiUrl = getWikipediaApiUrl(href);
if (!apiUrl) { throw new Error(`"${href}" is not Wikipedia article url`); }
const { title, thumbnail, extract } = await fetch(apiUrl).then((response) => response.json() );
// Для статей без заголовка или краткого описания отображать превью нет смысла if (title && extract) { return { title, description: extract, image: thumbnail?.source, }; }}
С википедией всё получилось довольно просто и я подумал, что было бы круто сделать превью для всех ссылок в статьях, но подобный API есть явно не у всех сайтов в интернете. Так как же быть в таком случае?
Open Graph
Существует инструмент, созданный именно для задачи предпросмотра ссылок —
Open Graph разметка представляет собой meta
-теги с атрибутами property
и content
, расположенные внутри head
:
<head> <meta property="og:title" content="Hacknote.js" /></head>
Среди всех возможных тегов для генерации превью будет достаточно всего трёх:
-
og:title
— заголовок страницы -
og:description
— краткое описание страницы -
og:image
— превью-картинка страницы
Эти теги встречаются на большинстве сайтов, но, к моему удивлению, их нет в википедии, поэтому для неё оставляем механизм, реализованный ранее, а для остальных ссылок сделаем отдельную реализацию.
Для начала выделим общий интерфейс для получения данных о ссылке, чтобы одинаковым образом получать данные из любого источника:
/** * Получить данные для превью ссылки * * @param href - адрес, на который ведёт ссылка * @returns промис с данными для превью ссылки, если этих данных достаточно */export type FetchLinkPreviewData = ( href: string) => Promise<LinkPreviewData | undefined>;
Теперь перейдём к реализации.
Задача заключается в том, чтобы получить HTML и найти в нём Open Graph теги. Можно было бы поискать их регуляркой, но к счастью наш код предназаначен для браузера, который в свою очередь уже умеет парсить HTML в DOM, по которому мы можем искать данные с помощью селекторов. Для этого есть класс
export const fetchDefaultLinkPreviewData: FetchLinkPreviewData = async ( href) => { const html = await fetch(href).then((response) => response.text());
const parser = new DOMParser();
const dom = parser.parseFromString(html, "text/html");
const title = dom .querySelector('meta[property="og:title"]') ?.getAttribute?.("content");
const description = dom .querySelector('meta[property="og:description"]') ?.getAttribute?.("content");
const image = dom .querySelector('meta[property="og:image"]') ?.getAttribute?.("content");
if (title && description) { return { title, description, image, }; }};
Теперь реализуем итоговую функцию, которая будет получать данные для превью любой ссылки:
export const fetchLinkPreviewData: FetchLinkPreviewData = (href) => { // Выбираем способ получения данных на основе адреса if (WIKIPEDIA_URL_REGEXP.test(href)) { return fetchWikipediaLinkPreviewData(href); } else { return fetchDefaultLinkPreviewData(href); }};
Вроде бы всё просто и логично…
…было, пока я не запустил этот код.
CORS
С общедоступным API википедии всё отлично, но вот делать fetch
большинства чужих сайтов не позволяет
CORS — это механизм, работающий исключительно на стороне клиента, то есть пройтись по ссылкам можно в среде, над которой я имею больше контроля — на сервере. Можно, конечно, воспользоваться сервисом вроде
Сёрфим по интернету на бекенде
Формулируем задачу: необходимо пройтись по всем ссылкам во всех статьях во время сборки и сохранить информацию для превью ссылок в каком-то месте, доступном на клиенте.
Для всех ссылок в статьях у меня реализован
Можно было бы использовать написанную ранее функцию fetchLinkPreviewData
, но, к сожалению, Node.js сообщает, что «DOMParser is not defined», поэтому придётся искать другой способ парсинга HTML.
В результате недолгих поисков я наткнулся на
API этой библиотеки несколько отличается от браузерного DOMParser
, поэтому для максимально бесшовного перехода я решил реализовать адаптер.
Для начала опишем интерфейс, содержащий методы DOMParser
, которые нужны для решения текущей задачи:
export interface HtmlElementApi { getAttribute(name: string): string | undefined; getText(): string | undefined;}
export interface HtmlDocumentApi { querySelector(selector: string): HtmlElementApi | undefined;}
export interface HtmlParser { parse(html: string): HtmlDocumentApi;}
Тогда браузерная реализация этого интерфейса будет максимально простой:
import type { HtmlDocumentApi, HtmlParser } from "./HTMLParser";
export class BrowserHtmlParser implements HtmlParser { private _domParser = new DOMParser();
public parse(html: string): HtmlDocumentApi { return this._domParser.parseFromString(html, "text/html"); }}
Теперь реализуем парсер на основе cheerio:
import { load as loadHtml } from "cheerio";
import type { HtmlDocumentApi, HtmlParser } from "./HTMLParser";
export class NodeHtmlParser implements HtmlParser { public parse(html: string): HtmlDocumentApi { const cheerio = loadHtml(html);
return { querySelector(selector) { const element = cheerio(selector);
return { getAttribute(name) { return element.attr(name); }, getText() { return element.text(); }, }; }, }; }}
И сделаем инверсию зависимостей для fetchDefaultLinkPreviewData
:
export function fetchDefaultLinkPreviewData( href: string, // Теперь функция получения данных из Open Graph разметки // зависит от парсера HTML не напрямую, а через абстракцию parser: HTMLParser): LinkPreviewData { // ...}
И будем динамически выбирать парсер в зависимости от окружения, в котором запущен код. Динамически импортировать ES-модули можно
// В Node.js ведь нет глобальной переменной "document" — верно?const isBrowser = typeof document !== "undefined";
// Выбираем, откуда импортировать парсерconst HtmlParser = isBrowser ? await import("./htmlParser/BrowserHtmlParser").then( ({ BrowserHtmlParser }) => BrowserHtmlParser ) : await import("./htmlParser/NodeHtmlParser").then( ({ NodeHtmlParser }) => NodeHtmlParser );
// Создаём выбранный парсерconst parser = new HtmlParser();
export const fetchLinkPreviewData: FetchLinkPreviewData = (href) => { if (WIKIPEDIA_URL_REGEXP.test(href)) { return fetchWikipediaLinkPreviewData(href); } else { return fetchDefaultLinkPreviewData( href, // Передаём выбранный парсер в качестве зависимости parser ); }};
Такой код называется
В целом мне теперь вряд ли пригодится парсить HTML на клиенте, но я решил продемонстрировать этот код, как наглядный пример практик, о которых я рассказывал в предыдущих статьях.
Реализация механизма получения данных готова, но есть небольшой нюанс в виде замедления сборки в dev-режиме примерно в 350 раз.
Вызвано оно тем, что dev серверу приходится ходить по всем ссылкам в статье при каждом изменении в коде.
Во время разработки реальные данные не так важны, поэтому можно замокать их получение, реализовав моковую функцию для созданного ранее интерфейса:
export const fetchMockLinkPreviewData: FetchLinkPreviewData = async () => { return { title: "Hacknote.js", description: "Lorem ipsum dolor sit amet.", image: "/hacknote-js/images/logo.png", };};
И в dev-режиме использовать её вместо настоящих:
const isBrowser = typeof document !== "undefined";
const HtmlParser = isBrowser ? await import("./htmlParser/BrowserHtmlParser").then( ({ BrowserHtmlParser }) => BrowserHtmlParser ) : await import("./htmlParser/NodeHtmlParser").then( ({ NodeHtmlParser }) => NodeHtmlParser );
const parser = new HtmlParser();
export const fetchLinkPreviewData: FetchLinkPreviewData = (href) => { // Astro добавляет свои переменные окружения в import.meta.url if (import.meta.env.DEV) { return fetchMockLinkPreviewData(href); }
if (WIKIPEDIA_URL_REGEXP.test(href)) { return fetchWikipediaLinkPreviewData(href); } else { return fetchDefaultLinkPreviewData(href, parser); }};
Думаю, вместо этого можно реализовать некий кеш, как это делает Telegram, но, пожалуй, займусь этим позже.
Рендерим результат
Осталось положить эти данные в HTML во время генерации статики. Делать я это буду в Astro-компоненте, но, думаю, смысл кода будет понятен без какой-либо подготовки:
---import { fetchLinkPreviewData } from "@api/linkPreview";
export interface Props { href: string;}
const { href } = Astro.props;
const previewData = await fetchLinkPreviewData(href);---
<a href={href} aria-label={previewData?.title} aria-description={previewData?.description} data-thumbnail={previewData?.image}> <slot /></a>
Не знаю, насколько это уместно, но часть данных я решил положить в Aria-атрибуты.
Показываем превью
Теперь клиент обладает всеми необходимыми данными для отображения превью ссылки и можно заняться его вёрсткой, но об этом я расскажу в следующей статье…