Назад к списку

Скилл, который сам снимает скриншоты приложения для лендинга

claude-codeskillsавтоматизацияmarketing
Скилл, который сам снимает скриншоты приложения для лендингаСкилл, который сам снимает скриншоты приложения для лендинга

Скриншоты на маркетинговой странице — это та работа, которую ненавидишь дважды. Первый раз — когда руками снимаешь экраны, подбираешь данные, чтобы не торчали пустые таблицы и тестовые «asdf», тащишь всё в фотошоп ради рамки браузера и retina-чёткости. Второй раз — через месяц, когда поменял интерфейс, и все картинки разом протухли. И снова по кругу.

В какой-то момент я решил, что хватит. Пусть приложение снимает само себя. А чтобы не собирать этот конвейер заново в каждом проекте — я завернул его в skill для Claude Code. Теперь «настрой маркетинговые скриншоты» работает в любом новом проекте, а не только там, где я уже всё руками настроил.

Почему скриншоты вообще боль

Если копнуть, проблем тут не одна, а целый букет:

  • Данные. На скриншоте дашборда не должно быть «Project 1», «test test» и нулей. Нужны живые, симпатичные, согласованные данные — и одни и те же на всех кадрах.
  • Рамка и композиция. Голый скриншот выглядит дёшево. Хочется показать экран в рамке ноутбука или телефона, иногда — совместить несколько кадров внахлёст, что-то обрезать, что-то подвинуть. Руками это каждый раз поход в фотошоп.
  • Retina. Снимок в 1x на современном экране — это мыло. Нужен 2x, а чтобы снять его руками, надо всякий раз городить эмуляцию устройства или масштаб браузера — и не забыть это сделать на каждом кадре.
  • Две темы. Я делаю сайты со светлой и тёмной темой. Светлый скриншот на тёмном сайте смотрится ужасно, поэтому картинки тоже адаптивные: смотришь светлую тему — кадр из приложения в светлой теме, переключился на тёмную — кадр перерисовывается в тёмной. А это сразу удваивает работу: каждый экран надо снять дважды.
  • Устаревание. Поменял кнопку — и пять картинок на лендинге врут. Вручную их никто вовремя не переснимет.

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

Идея: приложение снимает само себя

Решение простое до банальности: не рисовать моки, а снимать реальное запущенное приложение через headless-браузер. Тогда картинка по определению не врёт — это буквально твой продукт. А значит, её можно перегенерировать в любой момент одной командой.

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

Система разложилась на две части — и это ключевое решение всей затеи:

  1. Универсальный движок (scripts/screenshots/) — копируется в любой проект как есть, без правок. Он ничего не знает про конкретное приложение.
  2. Проектный конфиг и сид — маленький файл со списком экранов и скрипт, который засевает красивые демо-данные. Только это и переписываешь под новый проект.

Эталонная реализация живёт в моём тайм-трекере. Дальше — на её примере.

Движок: что делает capture.js

Движок — это набор Node-модулей на Playwright и sharp. Сердце — функция captureShot, которая снимает один кадр в отдельном, свежем браузере. Это первое неочевидное решение: холодный headless-браузер на первой загрузке dev-сервера иногда отдаёт пустую страницу, и изоляция каждого кадра в своём браузере убирает этот флак.

async function captureShot(opts) { const { baseUrl, route, viewport, deviceScaleFactor = 2, theme = 'light', prefs = {}, maskEmail, wait, settleMs = 400, screenshot = {}, outFile } = opts; const browser = await chromium.launch(); const context = await browser.newContext({ viewport, deviceScaleFactor, // retina timezoneId: 'UTC', colorScheme: theme === 'dark' ? 'dark' : 'light', }); // ... }

Внутри есть несколько важных мелочей.

Авторизация без логин-формы. Не нужно кликать через экран входа на каждом кадре. Перед навигацией в localStorage подмешиваются токены и закешированный пользователь — и приложение сразу считает себя залогиненным:

await context.addInitScript((prefsArg) => { for (const [k, v] of Object.entries(prefsArg)) { window.localStorage.setItem(k, v); } }, prefs);

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

await page.goto(`${baseUrl}${route}`, { waitUntil: 'networkidle' }); if (wait) await page.waitForSelector(wait); await page.waitForTimeout(settleMs);

Чистим кадр перед снимком. Чат-виджеты, дев-бейдж Next.js, настоящий email в углу — всё это не должно попасть на лендинг. Поэтому прямо в странице прячутся все элементы с безумным z-index, удаляется nextjs-portal, а почта заменяется на красивую вымышленную:

await page.evaluate((mask) => { for (const el of document.querySelectorAll('*')) { if (parseInt(getComputedStyle(el).zIndex, 10) > 1e6) el.style.display = 'none'; } document.querySelector('nextjs-portal')?.remove(); if (mask) { for (const el of document.querySelectorAll('*')) { if (el.children.length === 0 && el.textContent === mask.real) { el.textContent = mask.masked; // [email protected][email protected] } } } }, maskEmail);

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

async function withRetry(label, fn) { try { return await fn(25000); } catch (e) { return await fn(60000); } // cold-load flake }

Главное про движок: его не трогаешь. Скопировал — и забыл. Вся специфика проекта вынесена наружу.

Конфиг: что снимать

Весь «характер» съёмки — в одном декларативном файле screenshots.config.js. Там список кадров contentShots, функция авторизации, карта prefs для инъекции в localStorage и пара maskEmail:

const contentShots = [ { name: 'tracking-interface', route: '/track', view: 'tiles', viewport: { width: 1340, height: 760 }, wait: 'text=Deep Work', screenshot: { mode: 'crop', selector: '[data-testid="tiles-grid"]', padding: 20 } }, { name: 'feature-hierarchy', route: '/track', view: 'tree', viewport: { width: 640, height: 940 }, wait: 'text=Deep Work', screenshot: { mode: 'crop', selector: '[data-testid="activities-tree"]', maxHeight: 660 } }, { name: 'feature-corrections', route: '/history', viewport: { width: 460, height: 900 }, wait: 'text=Sleep', steps: [{ click: '[data-testid="entry-menu-trigger"]', waitFor: 'text=Replace Activity' }], screenshot: { mode: 'clip', clip: { x: 0, y: 0, width: 460, height: 640 } } }, ]; module.exports = { baseUrl: process.env.BASE_URL || 'http://localhost:3002', deviceScaleFactor: 2, maskEmail: { real: '[email protected]', masked: '[email protected]' }, authenticate, prefs, contentShots, hero, };

Добавить новый скриншот на сайт — это дописать сюда один объект и перезапустить. У каждого кадра свой режим съёмки (crop по селектору, element, фиксированный clip или весь экран), steps для кликов (открыть меню, перейти на таб) и prepare — асинхронная функция, в которой можно, например, переключить график на более «показательную» группировку перед снимком.

Сид: откуда берутся красивые данные

Самая недооценённая часть. Скриншот хорош ровно настолько, насколько хороши данные на нём. В тайм-трекере за это отвечает отдельная Django-команда — seed_demo. Она засевает выделенный демо-аккаунт целым маленьким «миром»: сферы жизни (Work, Health, Learning, Family, Rest), дерево активностей с иконками и целями (Deep Work, Meetings, Exercise, Sleep, Reading…), и — самое важное — несколько недель истории с реалистичным дневным ритмом.

class Command(BaseCommand): @transaction.atomic def handle(self, *args, **options): # Идемпотентный сброс: удаляем юзера — каскадом улетают # сферы, активности и все записи времени. CustomUser.objects.filter(email=DEMO_EMAIL).delete() user = CustomUser.objects.create_user(email=DEMO_EMAIL, ...) # ...создаём сферы, дерево активностей, цели, позиции плиток... # ~5 недель записей по дневному «ритму» (будни/выходные) # с джиттером ±20%, чтобы статистика выглядела живой rng = random.Random(20260530) # фиксированный seed → стабильный результат for day_offset in range(days - 1, -1, -1): rhythm = WEEKEND_RHYTHM if is_weekend else WEEKDAY_RHYTHM for act_key, base_minutes in rhythm: minutes = int(base_minutes * rng.uniform(0.8, 1.2)) # ...кладём TimeEntry... # на «сегодня» последний блок оставляем без end_time — # приложение покажет активный, «идущий прямо сейчас» трекинг

Здесь спрятано несколько приятных мелочей. Ритм дня описан шаблоном (сон, зарядка, глубокая работа, встречи, чтение…), к каждому блоку добавляется случайный джиттер — поэтому графики выглядят органично, а не нарисованными под линеечку. random.Random с фиксированным seed делает результат стабильным от прогона к прогону. А последний блок «сегодня» намеренно оставлен открытым, без времени окончания, — чтобы на скриншоте было видно живое состояние «трекинг идёт прямо сейчас».

Команда идемпотентная: каждый запуск удаляет демо-юзера и пересоздаёт всё с нуля, так что аккаунт не обрастает мусором, а скриншоты получаются детерминированными. (Сид — единственное, что плотно завязано на модель данных приложения, поэтому в новом проекте он пишется заново.)

Рамки: MacBook и iPhone без фотошопа

Голый скриншот на лендинге выглядит сиротливо. Поэтому отдельный модуль buildHero берёт десктопный и мобильный кадры и компонует их в один hero-композит — экран в рамке MacBook, рядом телефон в рамке iPhone. Никакого фотошопа: всё складывается через sharp:

async function buildHero({ rawDir, outDir, desktopName, mobileName }) { for (const theme of ['light', 'dark']) { const suffix = theme === 'dark' ? '-dark' : ''; // ...накладываем десктоп в рамку макбука, мобайл в рамку айфона await sharp(/* холст */) .composite([/* desktop, mobile поверх рамок */]) .png() .toFile(`${outDir}/hero${suffix}.png`); } }

На выходе — hero.png и hero-dark.png под светлую и тёмную тему сайта.

Как этим пользоваться

Весь конвейер собран в npm-скрипты, и финальный аккорд — screenshots:all, который прогоняет всё по цепочке:

"screenshots:seed": "cd ../timetracker-api && docker compose exec -T web python manage.py seed_demo", "screenshots:capture": "node scripts/capture-screenshots.js", "screenshots:hero": "node scripts/generate-hero.js", "screenshots:optimize": "node scripts/optimize-screenshots.js", "screenshots:all": "npm run screenshots:seed && npm run screenshots:capture && npm run screenshots:hero && npm run screenshots:optimize"

(В реальном проекте в цепочке есть ещё пара шагов — для блока «как это работает» и сводного графика статистики, — но суть та же.) Засеять данные → снять все кадры → собрать hero → оптимизировать. Готовые PNG падают в public/screenshots/, откуда их подхватывают лендинг и страницы продукта. Оптимизация специально стоит последней: она ужимает картинки до разумного веса, но формат остаётся PNG — чтобы не побить существующие ссылки и image-sitemap. Поменял интерфейс — прогнал команду ещё раз, и все картинки снова актуальны. Скриншоты перестают протухать, потому что это больше не артефакт ручного труда, а функция от текущего кода.

А при чём тут skill

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

Что внутри skill

Поэтому я завернул всё в skill для Claude Code. Skill — это просто папка с SKILL.md, где описано, когда навык применять и что делать. В описании — триггеры вроде «настрой маркетинговые скриншоты», «обнови картинки на лендинге», «добавь новый экран в съёмку». Внутри — инструкция и табличка, которая разделяет переносимое и специфичное:

Универсальное (копировать как есть)Своё в каждом проекте
движок scripts/screenshots/screenshots.config.js (URL, авторизация, список кадров)
тонкие скрипты-раннерысид демо-данных под модель приложения
список граблей (флак, таймзона, оверлеи)какие страницы лендинга потребляют картинки

Skill намеренно тонкий

Фокус в том, что сам skill намеренно тонкий. Он не пытается описать движок словами — он указывает на эталонную реализацию в тайм-трекере и говорит «скопируй дословно». Настоящий артефакт — это код движка, а skill лишь учит Claude, как развернуть его в новом месте и что адаптировать. Отдельно skill хранит то, ради чего всё и затевалось, — накопленные грабли: про флак холодной загрузки, про UTC, про прятанье чат-виджетов, про «оптимизировать в последнюю очередь». Это знание, которое в коде не видно, но без которого конвейер собирается с третьей попытки.

Что получилось

Теперь в любом новом проекте я просто говорю Claude «настрой маркетинговые скриншоты» — он находит эталон, копирует движок, пишет конфиг под мои экраны и сид под мою модель данных. То, что раньше было вечером возни с фотошопом, стало одной фразой.

Что дальше: связать с Figma

Скриншоты для сторов через Figma

Лендинг — это только половина истории. Вторая половина — скриншоты для сторов: Google Play и App Store требуют картинки в строго заданных размерах, да ещё и не «голый» экран, а аккуратно оформленный. Я делаю их в Figma по заранее собранным шаблонам, и это не просто рамка под нужный размер — это целая композиция: фоновая заливка или градиент, рамка устройства, крупные подписи-заголовки. Кадр приложения — лишь один слой внутри всей этой обвязки. Один и тот же скриншот ложится в темплейты под разные размеры и форматы, а Figma уже отдаёт готовые слайсы под каждый стор.

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

План такой: skill снимает свежие кадры тем же движком, через Figma-интеграцию подставляет их в существующие шаблоны — меняется только слой со скриншотом, а вся композиция (фон, рамка, подписи, выноски) остаётся на месте, — а потом вытаскивает готовые слайсы во всех нужных размерах, отдельно под Google Play и App Store. Тогда весь путь от «поменял кнопку» до «обновил картинки и на лендинге, и в обоих сторах» сворачивается в одну команду. Движок уже умеет давать честный кадр приложения — осталось дотянуть последнюю милю: аккуратно вложить его обратно в оформление и забрать готовое.

Те же скриншоты на всех языках

А дальше эту мысль можно раскручивать ещё. Движок уже умеет переключать тему перед снимком — ровно так же он может переключать язык приложения. Если автоматизировать ещё и переводы (а это отдельная задача, которую тоже хочется отдать в skill), то один прогон снимает весь комплект скриншотов под каждую локаль — и на лендинг, и в сторы, на всех языках сразу. Подписи в Figma-шаблонах при этом тоже подставляются из переводов.

Релиз под ключ: Fastlane

И финальный аккорд — Fastlane. Это инструмент автоматизации релизов под iOS и Android: он умеет заливать в Google Play и App Store не только скриншоты и метаданные, но и саму новую сборку. Если подключить и его, то цепочка замыкается целиком: snap свежих кадров → подстановка в Figma-шаблоны → выгрузка слайсов под все размеры и языки → заливка в оба стора вместе с новой версией. Выкладка новой версии «под ключ» — одной командой, без единого ручного шага между «закоммитил» и «доступно в сторе».

Что я из этого вынес

Два вывода, которые работают далеко за пределами скриншотов.

Рутину — в функцию от исходных данных

Лучший способ не делать рутину — превратить её в функцию от исходных данных. Скриншот, сгенерированный из живого приложения, не устаревает по определению. Как только артефакт перестаёт быть ручным трудом и становится результатом команды — проблема «оно протухло» просто исчезает.

Разделяй переносимое и специфичное

Движок — переносимый, конфиг и сид — специфичные. Это позволило завернуть всё в skill так, чтобы Claude поднимал систему в новом проекте сам. И хороший skill не дублирует логику словами — он указывает на готовый артефакт, а в себе хранит то, что нигде больше не записано: грабли, на которые ты уже наступил, чтобы не наступать снова.

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

© 2026 Ivan Bezdenezhnykh. Все права защищены.