Вставьте ссылку на видео и заберите обложку в нужном размере. Работает с обычными видео, с Shorts и с трансляциями. Вместо ссылки можно вставить и сам номер видео — те одиннадцать символов, которые стоят в адресе после v=.

Обложка любого видео на YouTube — это обычная картинка, которая лежит по простому адресу. Пароль для неё не нужен, достаточно знать номер видео. Забирают её обычно, чтобы посмотреть на удачные примеры перед тем как рисовать своё превью, вставить кадр в статью с разбором или поставить картинку вместо тяжёлого плеера, который иначе грузится на каждой странице.

Дальше разберём, как это устроено: из чего складывается адрес, почему у части картинок по краям чёрные полосы и что делать, если самый большой размер не открывается.

Как устроены адреса обложек

Все превью лежат на домене изображений YouTube по схеме из трёх частей: идентификатор видео, имя размера и расширение.

https://i.ytimg.com/vi/<ID видео>/<имя размера>.jpg

Есть второй домен-зеркало, img.youtube.com, с точно такими же путями. Разницы в отдаваемых картинках между ними нет, но у зеркала не выставлен один служебный заголовок, который понадобится нам дальше в разделе про скачивание, поэтому в примерах используется первый домен.

Самое полезное про эти имена: они не случайный набор. Имя собирается из двух частей — первая задаёт размер, вторая говорит, что именно на картинке.

  • Префиксы размера: пустой (120×90), mq (320×180), hq (480×360), sd (640×480), maxres (1280×720) и oar (исходные пропорции, до 1920×1080).
  • Суффикс default — это сама обложка: та картинка, которую автор загрузил вручную или выбрал из предложенных.
  • Суффиксы 1, 2, 3 — три кадра, которые YouTube выдернул из видео автоматически. Это не обложка, а именно кадры из начала, середины и конца.

Комбинация даёт готовое имя файла. Нужна обложка в максимальном размере — maxresdefault.jpg. Нужен средний кадр в среднем размере — mq2.jpg. Полная раскладка:

Имя Разрешение Пропорции Что на картинке
default.jpg 120×90 4:3, с полосами обложка
mqdefault.jpg 320×180 16:9 обложка
hqdefault.jpg 480×360 4:3, с полосами обложка
sddefault.jpg 640×480 4:3, с полосами обложка
maxresdefault.jpg 1280×720 16:9 обложка, максимум
1.jpg, 2.jpg, 3.jpg 120×90 4:3, с полосами кадры из видео
mq1.jpg … mq3.jpg 320×180 16:9 кадры из видео
hq1.jpg … hq3.jpg 480×360 4:3, с полосами кадры из видео
sd1.jpg … sd3.jpg 640×480 4:3, с полосами кадры из видео
maxres1.jpg … maxres3.jpg 1280×720 16:9 кадры из видео
oar1.jpg … oar3.jpg до 1920×1080 исходные кадры из видео, есть не у всех

Ещё одна мелочь: у двух картинок есть по второму имени, и в чужом коде они выглядят как что-то отдельное. hq720.jpg отдаёт ровно то же, что maxresdefault.jpg, а короткое 0.jpg — то же, что hqdefault.jpg. Откройте оба адреса подряд и убедитесь сами:

https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg
https://i.ytimg.com/vi/dQw4w9WgXcQ/hq720.jpg          <- та же самая картинка

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

Как достать номер видео из любой ссылки

Номер видео (его же называют ID) всегда выглядит одинаково: одиннадцать символов из латинских букв, цифр, дефиса и подчёркивания. А вот форм ссылок, в которых он прячется, штук шесть, и в каждой он лежит в другом месте.

  • youtube.com/watch?v=ID — обычная страница видео, идентификатор в параметре запроса.
  • youtu.be/ID — короткая ссылка из кнопки «Поделиться», идентификатор в самом пути.
  • youtube.com/shorts/ID — вертикальный ролик.
  • youtube.com/live/ID — прямая трансляция.
  • youtube.com/embed/ID — адрес встроенного плеера, попадается в чужой вёрстке.
  • youtube.com/v/ID — давно устаревшая форма, но в старых материалах ещё живёт.

Плюс мобильный поддомен, музыкальный поддомен и домен без cookie — и всё это ещё бывает обвешано лишними параметрами: метка источника, отметка времени, идентификатор плейлиста. Соблазн разобрать это одним регулярным выражением большой, но регулярка на такой задаче быстро становится нечитаемой. Надёжнее отдать разбор адреса встроенному классу URL, который уже умеет отделять хост от пути и разбирать параметры, а свои проверки оставить только для полезной части.

const ID_SHAPE = /^[A-Za-z0-9_-]{11}$/;
const ID_IN_PATH = { shorts: 1, embed: 1, live: 1, v: 1, e: 1 };

function videoId(raw) {
  const value = String(raw || '').trim();
  if (!value) return '';

  // Уже готовый идентификатор — отдаём как есть.
  if (ID_SHAPE.test(value)) return value;

  let link;
  try {
    // Если человек скопировал адрес без схемы, дописываем её сами.
    link = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`);
  } catch {
    return '';
  }

  const host = link.hostname.replace(/^www\./, '').toLowerCase();
  const steps = link.pathname.split('/').filter(Boolean);

  // youtu.be/ID — идентификатор стоит первым сегментом пути.
  if (host === 'youtu.be' && ID_SHAPE.test(steps[0] || '')) return steps[0];

  // watch?v=ID — самая частая форма.
  const fromQuery = link.searchParams.get('v');
  if (fromQuery && ID_SHAPE.test(fromQuery)) return fromQuery;

  // shorts/ID, live/ID, embed/ID — идентификатор следует за ключевым сегментом.
  for (let i = 0; i < steps.length - 1; i++) {
    if (ID_IN_PATH[steps[i]] && ID_SHAPE.test(steps[i + 1])) return steps[i + 1];
  }

  return '';
}

Обратите внимание на порядок проверок: сначала короткий домен, потом параметр запроса, потом сегменты пути. Так адрес трансляции с меткой «поделиться» и адрес видео внутри плейлиста разбираются одинаково успешно, а лишние параметры просто игнорируются.

И одна ловушка, на которую наступают почти все, кто пишет такой разбор через регулярку по всей строке. Адрес канала youtube.com/@SomeChannel тоже содержит подходящий по форме кусок из одиннадцати символов — регулярка радостно выкусит из имени канала первые одиннадцать букв и вернёт мусор, который потом будет тихо отдавать 404. Поэтому в коде выше поиск идёт не по всей строке, а по конкретным местам, где идентификатор действительно может лежать: если ни одна проверка не сработала, функция честно возвращает пустую строку.

Почему maxresdefault иногда отдаёт 404

Первое разочарование при работе с обложками: подставили в шаблон maxresdefault.jpg, на десяти видео всё отлично, на одиннадцатом вместо картинки битая иконка. Дело не в опечатке. Крупные размеры существуют не у каждого видео.

Мелкие размеры — default, mqdefault, hqdefault — есть практически у любого публичного видео. А вот sddefault и maxresdefault появляются только тогда, когда исходное видео достаточно большое: у видео, залитого когда-то в маленьком качестве, крупных превью просто нет и взять их неоткуда. Проверить это можно на самом первом видео сервиса, которое залито в очень скромном качестве: hqdefault у него отдаётся, а sddefault и maxresdefault — нет.

https://i.ytimg.com/vi/jNQXAC9IVRw/hqdefault.jpg       <- откроется
https://i.ytimg.com/vi/jNQXAC9IVRw/sddefault.jpg       <- ошибка, картинки нет
https://i.ytimg.com/vi/jNQXAC9IVRw/maxresdefault.jpg   <- ошибка, картинки нет

Тут важная деталь, которая сильно упрощает жизнь. У размеров с суффиксом default отсутствующий вариант — это настоящий код ответа 404, а не картинка-заглушка с кодом 200. YouTube всё-таки присылает в теле ответа серую плашку 120×90, но код честный, поэтому проверять доступность можно самым обычным способом: посмотреть на статус ответа. Разглядывать пиксели или сравнивать размер файла с известным весом заглушки, как советуют в старых материалах, тут не нужно. Оговорка «у размеров с суффиксом default» не случайная: у кадров с префиксом oar бывает и третье поведение, до которого дойдём ниже.

Отсюда рабочий подход для вёрстки: не угадывать размер заранее, а перебирать размеры по очереди, спускаясь на размер ниже. Тег <img> сообщает о неудаче через событие error, и на нём удобно переключаться на следующий вариант.

const LADDER = ['maxresdefault', 'sddefault', 'hqdefault', 'mqdefault'];

function withFallback(img, id) {
  let step = 0;

  const tryNext = () => {
    if (step >= LADDER.length) {
      img.removeEventListener('error', tryNext);
      return;                       // размеры кончились, оставляем как есть
    }
    img.src = `https://i.ytimg.com/vi/${id}/${LADDER[step++]}.jpg`;
  };

  img.addEventListener('error', tryNext);
  tryNext();
}

withFallback(document.querySelector('#cover'), 'dQw4w9WgXcQ');

Если картинка нужна не в вёрстке, а в коде, тот же перебор удобно сделать запросами и остановиться на первом удачном:

async function bestCover(id) {
  for (const name of LADDER) {
    const url = `https://i.ytimg.com/vi/${id}/${name}.jpg`;
    const res = await fetch(url, { method: 'GET' });
    if (res.ok) return { url, name, blob: await res.blob() };
  }
  return null;                      // видео приватное, удалено или ID неверный
}

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

Чёрные полосы, чистые 16:9 и кадры до 1920×1080

Второй сюрприз обнаруживается, когда скачанную картинку открываешь в редакторе. Видео на YouTube давно широкоформатные, а часть размеров превью так и осталась в пропорциях 4:3 — и широкий кадр в них не обрезан, а вписан с чёрными полосами сверху и снизу. Так ведут себя default, hqdefault и sddefault. Чистые 16:9 без полос дают только mqdefault и maxresdefault.

Особенно обидно попасть на это с hqdefault: имя намекает на высокое качество, размер приличный, и полосы замечаешь уже после того, как картинка ушла в макет. Полезной площади в этих 480×360 остаётся 480×270, остальное — пустота.

Считается это в одну строчку. Кадр всегда широкоформатный, то есть его высота — это 9/16 от ширины файла. Дальше просто сравниваем с высотой самого файла:

const picHeight = (width * 9) / 16;          // высота самого кадра
const waste = (height - picHeight) / height; // какая доля высоты пустая

// hqdefault, 480 × 360:  кадр 480 × 270, waste = 0.25

И тут вылезает симпатичная закономерность: доля пустоты у всех 4:3-размеров одинаковая и не зависит от того, крупная картинка или мелкая. Ширина в формуле сокращается, остаётся (9/16) / (3/4) = 0.75 — то есть под кадр всегда уходит три четверти высоты, а четверть съедают полосы. Что у default в 120×90, что у sddefault в 640×480.

Практический вывод: под вёрстку и любые макеты берите maxresdefault или mqdefault, а размеры в 4:3 оставьте для случаев, когда картинка всё равно уедет в маленький квадратный аватар списка. Если полосы всё-таки достались, обрезать их можно и на стороне CSS — фиксированные пропорции контейнера плюс обрезка по краям, про это есть подробный разбор свойства пропорций.

.cover {
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

.cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;   /* полосы уходят за границы контейнера */
}

А теперь про кадры, а не про обложку. Кроме пяти размеров с суффиксом default, у каждого видео есть три автоматических кадра — из начала, середины и конца. Берутся они тем же именем, только с цифрой вместо default, и доступны в любом из размеров:

https://i.ytimg.com/vi/dQw4w9WgXcQ/maxres1.jpg
https://i.ytimg.com/vi/dQw4w9WgXcQ/maxres2.jpg
https://i.ytimg.com/vi/dQw4w9WgXcQ/maxres3.jpg

Не перепутайте назначение: это кадры из видео, а не обложка. Если автор нарисовал превью в редакторе, с подписями и стрелками, в этих файлах его не будет — там окажется то, что происходило на экране. Зато когда нужен чистый кадр без авторской графики (под фон, под коллаж, под свою заготовку превью), брать надо именно их.

И есть отдельная группа, про которую почти нигде не пишут: префикс oar, от original aspect ratio. Это те же три кадра, но не подогнанные под стандартную рамку — сервис отдаёт их в тех пропорциях и в том разрешении, в которых было само видео. Иногда это заметно выгоднее обычных имён.

Какое видео maxresdefault oar2
Широкоформатное видео в 4K 1280×720 1920×1080
Вертикальный Shorts 1280×720, кадр вписан в широкую рамку 1080×1920, настоящая вертикаль
Старое видео 4:3 в 240p нет, 404 320×240, настоящие 4:3
Обычное широкоформатное видео 1280×720 чаще всего нет, 404

Последняя строка таблицы — самая важная, и именно на ней ломаются готовые скрипты, которые нашли эти имена в чужом коде и стали дёргать всегда. Проверка на выборке из одиннадцати роликов: группа oar отдалась у пяти — у старого 4:3-видео, у ремастера широкоэкранного 4K и у трёх Shorts, — а у всех шести обычных современных широкоформатных видео пришёл 404. Логика в имени: сохранять отдельной картинкой нечего, если видео и так укладывается в стандартную рамку 16:9 без запаса по разрешению. Внешнему наблюдателю точное правило не видно, но закономерность читается однозначно.

Практический вывод из этого простой. Нужен кадр гарантированно — берите числовые имена вроде maxres2: они отдались у всех проверенных видео. Нужен максимум пикселей или вертикаль у Shorts — пробуйте сначала oar2, а при 404 спускайтесь на maxres2. Ровно так устроен блок с кадрами в инструменте наверху страницы: если группа oar у видео отсутствует, он пишет об этом строкой, а не показывает три пустые плитки.

И третье поведение той же группы, из-за которого проверка по коду ответа перестаёт работать. Иногда все три адреса отдаются с кодом 200, но в теле приходит сплошной чёрный прямоугольник 1280×720. Причём все три файла оказываются одинаковыми.

https://i.ytimg.com/vi/7h_PztB_oDg/oar1.jpg
https://i.ytimg.com/vi/7h_PztB_oDg/oar2.jpg
https://i.ytimg.com/vi/7h_PztB_oDg/oar3.jpg

Откройте любой из трёх — увидите чёрный прямоугольник. И все три файла при этом одинаковые до последнего байта: ровно 5680 байт каждый.

Отличить такую заглушку от настоящего тёмного кадра по заголовкам и коду ответа невозможно — только по пикселям. В браузере это делается через <canvas>: уменьшаем картинку до крошечного размера, читаем пиксели и смотрим, есть ли хоть один заметно светлее нуля.

const BLANK_LEVEL = 12;   // заглушка приходит абсолютно чёрной

function looksBlank(img) {
  const width = 32;
  const height = Math.round((img.naturalHeight / img.naturalWidth) * width);

  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;

  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, width, height);

  // Картинка пришла из blob-адреса, то есть со своего источника,
  // поэтому canvas не «испачкан» и пиксели читаются.
  const { data } = ctx.getImageData(0, 0, width, height);

  for (let i = 0; i < data.length; i += 4) {
    if (data[i] > BLANK_LEVEL || data[i + 1] > BLANK_LEVEL || data[i + 2] > BLANK_LEVEL) {
      return false;
    }
  }
  return true;
}

Порог можно ставить смело: у заглушки максимальное значение канала равно нулю, а у самого тёмного настоящего кадра из проверенных — 152 при средней яркости 14. Запас огромный, так что честный кадр под фильтр не попадёт. Единственное ограничение: этот приём работает, только когда картинка получена запросом и лежит в blob:-адресе. Чужая картинка, вставленная прямо в <img> без разрешения на чтение, делает canvas «испачканным», и getImageData бросает ошибку — поэтому для WebP-пути, у которого нет CORS-заголовка, проверка недоступна.

И оговорка про всю группу. Официальной документации на эти имена нет: они не описаны ни в справочнике интерфейса, ни где-либо ещё на стороне сервиса. Полагаться на них в продакшене как на гарантию нельзя — сервис вправе перестать их отдавать без предупреждения. Для разовой выгрузки это неважно, а вот в коде, который будет работать месяцами, под oar обязателен тот же фолбэк, что и под остальные размеры.

WebP: тот же кадр вдвое легче

У каждой обложки есть близнец в формате WebP. Путь отличается одним сегментом и расширением:

https://i.ytimg.com/vi_webp/<ID видео>/maxresdefault.webp

Картинка та же, вес примерно вдвое меньше. Замеры на одном и том же видео:

Имя JPEG WebP Экономия
maxresdefault 65,3 КБ 28,6 КБ 56 %
sddefault 31,0 КБ 14,5 КБ 53 %
hqdefault 21,0 КБ 10,4 КБ 50 %
mqdefault 10,3 КБ 6,7 КБ 35 %

Для галереи превью или ленты видео это заметная разница, поэтому WebP-путь стоит подставлять первым, а JPEG держать как запасной вариант. Элемент <picture> сделает выбор сам, без единой строчки скрипта:

<picture>
  <source
    srcset="https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp"
    type="image/webp">
  <img
    src="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
    width="1280" height="720" loading="lazy" alt="Обложка видео">
</picture>

А вот сюрприз, из-за которого готовые скрипты для скачивания обложек часто спотыкаются именно на WebP. Два пути на одном и том же домене настроены по-разному: /vi/ разрешает читать свои картинки чужим страницам, а /vi_webp/ — нет. Заметно это становится только когда пробуешь прочитать картинку скриптом.

// С пути /vi/ запрос проходит: картинку можно прочитать скриптом
await fetch('https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg');

// А с /vi_webp/ браузер оборвёт его с ошибкой доступа
await fetch('https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp');

Разница в одну строку заголовка, а последствия ощутимые: показать WebP-обложку тегом <img> можно (картинкам разрешение не требуется), а вот прочитать её содержимое скриптом со своей страницы — уже нельзя. Почему так и что с этим делать — в следующем разделе. Заодно, если выбираете формат под свои собственные картинки, а не под чужие обложки, посмотрите сравнение JPEG, PNG, WebP и AVIF.

Почему браузер не скачивает чужую картинку по ссылке

Логика подсказывает: чтобы дать кнопку «Скачать», достаточно ссылки с атрибутом download. Атрибут ровно для этого и придуман — он говорит браузеру не открывать файл, а сохранить его, да ещё и позволяет задать имя.

<!-- Выглядит правильно, но обложку не скачает -->
<a href="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
   download="cover.jpg">Скачать обложку</a>

По такой ссылке картинка просто откроется в новой вкладке, а заданное имя файла браузер проигнорирует. Это не баг и не защита со стороны YouTube: атрибут действует только на своём собственном источнике. В справочнике MDN ограничение сформулировано одной строкой: download работает только для адресов того же источника плюс для схем blob: и data:. Ограничение придумали не на пустом месте: иначе любая страница могла бы навязать посетителю сохранение файла с чужого домена под своим именем.

Поддержка браузерами
chrome
Chrome
14
firefox
Firefox
20
edge
Edge
13
safari
Safari
10.1
opera
Opera
15

Обходной путь подсказан в самом ограничении: схема blob: разрешена. Значит, если сначала забрать картинку скриптом, положить её в объект-обёртку над двоичными данными и получить на него локальный адрес, атрибут снова начнёт работать — ведь адрес будет уже свой, а не чужой.

Забрать картинку с чужого домена скриптом получится только с разрешения этого домена. Разрешение выдаётся тем самым заголовком, который мы разглядывали в предыдущем разделе, а механизм такой проверки называется CORS — cross-origin resource sharing, то есть обмен ресурсами между разными источниками. Именно поэтому вся схема работает на пути /vi/ и не работает на /vi_webp/.

async function saveCover(id, name = 'maxresdefault') {
  const url = `https://i.ytimg.com/vi/${id}/${name}.jpg`;

  // Путь /vi/ отдаёт access-control-allow-origin: *, поэтому запрос проходит.
  const res = await fetch(url);
  if (!res.ok) throw new Error(`нет такого размера: ${res.status}`);

  const blob = await res.blob();
  const local = URL.createObjectURL(blob);   // адрес вида blob:https://…

  const link = document.createElement('a');
  link.href = local;                          // теперь источник свой
  link.download = `${id}-${name}.jpg`;        // и имя файла сработает
  document.body.appendChild(link);
  link.click();
  link.remove();

  URL.revokeObjectURL(local);                 // отпускаем память
}

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

Ровно по этой схеме работает иконка скачивания в инструменте в начале статьи — и работает она только с JPEG. У WebP-версии инструмент даёт обычную ссылку, которая открывает картинку в новой вкладке: запрос к /vi_webp/ браузер не пропустит, значит ни вес файла узнать, ни в blob: положить, ни скачать одним нажатием там нельзя. Обратите внимание, в чём именно ограничение: сам файл открыт всем — его спокойно откроет и вкладка браузера, и тег <img>, и любая программа-загрузчик. Нельзя другое — чтобы скрипт чужой страницы прочитал его содержимое. Поэтому WebP сохраняет сам читатель, из вкладки, а не кнопка за него.

Официальные способы: oEmbed и API

Всё описанное выше опирается на предсказуемость адресов, а не на договорённость с сервисом. Если обложка нужна в проекте, который должен работать годами, стоит знать два официальных способа.

Первый и самый простой — oEmbed. Это общий стандарт: сервис отдаёт короткую справку о видео по одному адресу, без регистрации и ключей. Заодно приезжают название видео и автор — удобно, чтобы назвать скачанный файл по-человечески вместо набора символов.

const target = encodeURIComponent('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
const res = await fetch(`https://www.youtube.com/oembed?url=${target}&format=json`);
const data = await res.json();

console.log(data.title);          // название видео
console.log(data.author_name);    // название канала
console.log(data.thumbnail_url);  // адрес обложки, обычно hqdefault

Приятная деталь: этот адрес тоже разрешает запросы со сторонних страниц, так что вызывать его можно прямо из браузера. Ограничение одно, зато существенное: в ответе приезжает средний размер, а не максимальный. Максимум придётся собирать самому — заменой имени файла в полученном адресе.

// Меняем средний размер из ответа oEmbed на максимальный.
const maxres = data.thumbnail_url.replace(/\/[^/]+\.jpg$/, '/maxresdefault.jpg');

Приватное или удалённое видео этот адрес не отдаст вовсе — ответ придёт с ошибкой. Это, кстати, самый простой способ отличить «неверный идентификатор» от «просто нет крупного размера».

Второй способ — официальный API YouTube, то есть служебный адрес, по которому сервис сам рассказывает о видео. Он требует ключа и ограничен по числу запросов в сутки, зато отвечает описанной в документации структурой, а не набором угаданных адресов. Обложки лежат в разделе snippet.thumbnails под задокументированными именами: default (120×90), medium (320×180), high (480×360), standard (640×480) и maxres (1280×720) — те же пять размеров, что и в прямых адресах, только под другими названиями.

const url = new URL('https://www.googleapis.com/youtube/v3/videos');
url.searchParams.set('part', 'snippet');
url.searchParams.set('id', 'dQw4w9WgXcQ');
url.searchParams.set('key', process.env.YOUTUBE_API_KEY);

const { items } = await (await fetch(url)).json();
const shots = items[0].snippet.thumbnails;

// В ответе приезжают только реально существующие размеры,
// поэтому проверять на 404 уже не нужно.
console.log(Object.keys(shots));       // [ 'default', 'medium', 'high', 'standard', 'maxres' ]
console.log(shots.maxres?.url);

Главное преимущество здесь не в самих адресах, а в том, что сервис перечисляет только существующие размеры. Весь перебор размеров с проверкой ответа отпадает: чего нет в ответе, того нет у видео. Ключ при этом должен лежать на сервере: в коде страницы его увидит любой желающий.

Когда обложек нужно много — например, собрать превью всего своего канала и посмотреть, что цепляет, а что нет, — удобнее пройти по списку номеров тем же перебором размеров, что и выше:

const ids = ['dQw4w9WgXcQ', 'jNQXAC9IVRw'];

for (const id of ids) {
  const found = await bestCover(id);   // функция из раздела про 404
  console.log(id, found ? found.name : 'обложки нет');
}

Здесь важно не забыть про проверку кода ответа. Если складывать файлы подряд, не глядя на ответ, в папке окажутся картинки-заглушки вместо обложек, и разбираться, что скачалось, а что нет, придётся глазами.

Что можно делать со скачанной обложкой

Техническая доступность и право на использование — разные вещи, и про это стоит сказать прямо. Обложка — часть материала автора канала: он её либо нарисовал, либо выбрал кадр из своего видео. То, что картинка отдаётся по открытой ссылке без пароля, не переводит её в общее пользование.

Спокойные сценарии, ради которых обложки обычно и качают:

  • собрать подборку удачных превью и разобрать, как они устроены, перед тем как рисовать своё;
  • вставить кадр в обзор или разбор конкретного видео, со ссылкой на источник;
  • сделать заглушку под встроенный плеер, чтобы тяжёлый iframe грузился только после клика;
  • забрать собственные обложки со своего канала — для архива или переоформления.

Чего лучше не делать: выкладывать чужую обложку как своё изображение, ставить её на превью своего видео, продавать в наборах картинок или подставлять в рекламу так, будто автор видео что-то одобрил. Отдельно про заглушку для плеера: подход хороший и для скорости страницы полезный, но картинку в этом случае лучше не копировать к себе, а оставить ссылку на домен изображений YouTube — так вопросов о том, чья это картинка, не возникает вовсе.

Шпаргалка

  • Обложка любого размера: https://i.ytimg.com/vi/<ID>/<имя>.jpg.
  • Имя = префикс размера (пусто, mq, hq, sd, maxres, oar) плюс суффикс: default для обложки, 13 для кадров.
  • Максимум обложки — maxresdefault.jpg, 1280×720. Крупнее бывают только кадры oar1oar3, до 1920×1080, но они есть далеко не у всех видео.
  • Кадр гарантированно отдаётся по числовому имени (maxres2, hq2); oar2 пробуем первым только когда нужен максимум или вертикаль.
  • У адреса три состояния, а не два: картинка, 404 и — у oar — сплошная чёрная заглушка с кодом 200, которую видно только по пикселям.
  • Без чёрных полос: mqdefault и maxresdefault. С полосами: default, hqdefault, sddefault.
  • У Shorts вертикаль лежит в oar-группе, а maxresdefault отдаёт кадр, вписанный в широкую рамку.
  • Крупные размеры есть не у всех видео — перебирайте размеры по очереди, пока какой-нибудь не откроется.
  • WebP-близнец: тот же путь с vi_webp и расширением .webp, примерно вдвое легче, но скриптом его не прочитать.
  • Атрибут download на чужую ссылку не действует — нужен запрос, объект двоичных данных и локальный адрес.
  • Название и автора видео отдаёт oEmbed без ключа; полный перечень существующих размеров — официальный API.