見出し画像

Playwrightでスクショを自動保存してマスターデータの代替する noteUIDev#2

前回はFigmaでDesign Tokenを抽出する話を書きましたが、

今回は、UXエンジニアとしてプロダクトの課題解決に取り組んだ話です。

マスターデータがない

noteのデザインチームではFigmaでデザインデータを管理しています。デザイナーはそれぞれの施策をひとつのFigmaプロジェクトで作業して、4半期ごとに新しく作り変えています。施策ごとにそれぞれがページを作っていくので、手が入ったページやセクションのみがそのFigmaファイルに残されている状態になっています。

なので、現在それぞれがどこで何が起きているか?ということはとてもわかりやすいですが、サイト全体が現状どうなっているか?ということは把握できません。マスターデータがないので、実装されてデプロイされた本番環境自体がマスターである、というような認識の運用をしています。

という感じで、デザインのマスターデータがない、というのはいろんなチームでもあるあるじゃないでしょうか。

つらい、簡単に解決したい

デザインされたものでとくにコンポーネントライブラリ化(デザインシステムチームなどが管理)されていないものについては、管理しきれず変化を追跡しにくい状態になっています。

どこかでだれかが作ったけど、どうだっけ、ぐぬぬ… みたいなことがよく起きているのをさくっと解消できないものか、ということで、定期的に本番環境のスクリーンショットを撮り溜めていくことにしました。

もちろん愚直にスクショを撮っていくのは途方もないので、Github ActionsでPlaywrightのスクリプトを動かしてもらって、あとは寝ておけばokな状態に実装します。

Playwright

Playwrightは、とにかくいろいろと便利なE2Eのテストツールです。ブラウザ上の操作でテストコードを生成してくれる拡張機能がすごいのですが、今回はページを開いてスクリーンショットを撮るために使います。

システムの設計

設計したスクショ貯めるくんは、以下のようなフローで動作します。

  1. Github Actionsで一連のスクリプトを定期的に実行

  2. Playwrightを使って、指定したページを開く

  3. 各ページのスクリーンショットを撮る

  4. 撮ったスクショを保存し、それぞれをタイムスタンプでマーク

  5. それらをプレビューするためのhtmlを生成する

スクリーンショット周りの処理

Playwrightの基本的な機能を活用して行います。具体的なコードをTypeScriptで書いていきます。

import sharp from 'sharp'
import type { Browser, Page, ViewportSize } from 'playwright'
import { chromium, devices } from 'playwright'
import fs from 'fs'
import path from 'path'

export const urls = [
  'https://note.com',
  'https://note.com/login',
]

const iPhone = devices['iPhone 12 Pro Max']

const createDirectory = (directoryName: string) => {
  fs.mkdirSync(directoryName, { recursive: true })
}

const openAndCapturePage = async (
  browser: Browser,
  folderName: string,
  url: string,
  viewport: ViewportSize,
  userAgent?: string
): Promise<void> => {
  const sanitizedUrl = url.replace(/^https?:\/\//, '').replace(/[^a-zA-Z0-9]/g, '_')
  const page: Page = await browser.newPage({ userAgent, viewport })
  await page.goto(url, { waitUntil: 'networkidle' })
  const webpFileName = sanitizedUrl + '.webp'
  const webpFilePath = path.join(folderName, webpFileName)
  const buffer = await page.screenshot({ fullPage: true })
  await sharp(buffer).webp().toFile(webpFilePath)
  await page.close()
}

const createScreenshotFolders: (timestamp: string) => {
  folderNameDesktop: string
  folderNameMobile: string
} = (timestamp: string) => {
  const folderNameDesktop = path.join('screenshots', timestamp, 'desktop')
  const folderNameMobile = path.join('screenshots', timestamp, 'mobile')

  createDirectory(folderNameDesktop)
  createDirectory(folderNameMobile)

  return { folderNameDesktop, folderNameMobile }
}

const main = async () => {
  const browser = await chromium.launch()
  const timestamp = new Date().toISOString().split('.')[0].replace(/:/g, '-')
  const { folderNameDesktop, folderNameMobile } = createScreenshotFolders(timestamp)

  for (const url of urls) {
    await openAndCapturePage(browser, folderNameDesktop, url, { width: 3024 / 2, height: 1964 / 2 }) // MBP 14inch
    await openAndCapturePage(
      browser,
      folderNameMobile,
      url,
      { ...iPhone.viewport },
      iPhone.userAgent
    )
  }

  await browser.close()
}
main()

ポイントを絞って実装を説明します。まずは openAndCapturePage 関数の処理詳細です。この関数は、指定されたURLのページを開き、そのスクリーンショットを撮影し、それをWebP形式の画像として保存します。

各ステップ:

  1. URLのサニタイズ : URLからプロトコル部分を削除し、非英数字の文字をアンダースコアに置換しています。これにより、URLをファイル名として使用できます。

  2. ページを開く : browser.newPageメソッドを使用して新しいページを開きます。このとき、ユーザーエージェントとビューポートサイズをオプションとして指定しています。

  3. ページの読み込み : page.gotoを使用して指定されたURLのページに移動します。 waitUntil: 'networkidle' オプションにより、ネットワークが静止状態になるまで(APIなどを)待つようにしています。

  4. スクリーンショットの撮影 : page.screenshot メソッドを使用してページのスクリーンショットを撮影します。 fullPage: trueオプションにより、ページ全体のスクリーンショットを撮影します。

  5. スクリーンショットの保存 : sharpライブラリを使用して、撮影したスクリーンショットをWebP形式のファイルとして保存します。

  6. ページのクローズ : page.closeメソッドを使用してページを閉じます。

続いてmain関数を解説します。指定されたすべてのURLのページのスクリーンショットをデスクトップとモバイルの両方のビューポートで撮影し、それをファイルとして保存する、というタスクを非同期に行います。

  1. ブラウザの起動 : chromium.launchメソッドを使用してChromiumブラウザを起動します。このブラウザインスタンスは、後続の処理で使用されます。

  2. タイムスタンプの生成 : 現在の日時からISO形式の文字列を生成し、その中のコロンをハイフンに置換しています。これにより、ファイル名として使用できるタイムスタンプを生成しています。

  3. スクリーンショットフォルダの作成 : createScreenshotFolder 関数を使用して、デスクトップとモバイルの両方のスクリーンショットを保存するためのフォルダを作成します。

  4. 各URLのページのスクリーンショットの撮影 : openAndCapturePage関数を使用して、各URLのスクリーンショットを撮影します。デスクトップとモバイルの両方のビューポートで撮影を行います。

  5. ブラウザのクローズ : すべてのスクリーンショットの撮影が完了したら、 browser.close メソッドでブラウザを閉じます。

これで、次のような構造でスナップショットを保存できました。

.
├── desktop
│   ├── note_com.webp
│   ├── note_com_login.webp
└── mobile
    ├── note_com.webp
    └── note_com_login.webp

このように簡単なコードで実装できました。これを定期実行するだけでも良かったのですが、スクショをプレビューできる環境がないとなかなか使いにくいかなということで、HTMLを作ってS3にアップロードすることにします。

プレビュー環境

なにかしらフレームワークに乗せてあげてもよさそうですが、簡易的にHTMLを組み立てて表示できるようにスクラッチで書きます。

  • URL別(過去のスクショが並んでいる)

  • 撮影日別(登録したページのスクショが並んでいる)

保存した画像のファイルパスを利用して、それぞれの詳細ページと、それらに遷移するためのindexを生成します。

import fs from 'fs'
import path from 'path'

const screenshotsDir = 'screenshots'
const outDir = 'out'
const htmlHead = `
  <!DOCTYPE html>
  <html lang="ja">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Screenshots Gallery</title>
    <link rel="stylesheet" href="style.css">
  </head>
`

const css = `
  .container {
    display: flex;
    gap: 1rem;
  }
  .item {
    padding: 1rem 2rem;
    border: 1px solid #eee;
    background: #fafafa;
  }
  a {
    color: #282828;
    text-decoration: underline;
  }
  a:hover {
    color: #888;
    text-decoration: none;
  }
  html {
    padding: 2rem;
  }
`

const filterDSStore = (item: string) => item !== '.DS_Store'

/**
 * 指定された日付のパスを使って、
 * 指定されたデバイス用のHTMLコンテンツを生成します。
 */
const generateDeviceHtml = (date: string, device: string, deviceDir: string): string => {
  const urls = fs.readdirSync(deviceDir).filter(filterDSStore)
  let html = `<h2>${device}</h2><div class="container">`
  html +=
    urls
      .map((urlFile) => {
        const originalUrl = deriveOriginalUrl(urlFile)
        const imageUrl = path.join(screenshotsDir, date, device, urlFile)
        return `<div class="item"><h3>${originalUrl}</h3><a href="https://${originalUrl}" target="_blank"><img src="${imageUrl}" alt="${urlFile}" loading="lazy" /></a></div>\n`
      })
      .join('') + '</div>\n'
  html += `</div>\n`
  return html
}

/**
 * 日付に対してのHTML文字列を生成します。
 */
const generateDateHtml = (date: string): string => {
  const dateDir = path.join(screenshotsDir, date)
  const devices = fs.readdirSync(dateDir).filter(filterDSStore)
  const dateHtml = devices
    .map((device) => {
      const deviceDir = path.join(dateDir, device)
      return generateDeviceHtml(date, device, deviceDir)
    })
    .join('')

  return `${htmlHead}\n<h1>${formatDate(date)}</h1>\n${dateHtml}`
}

/**
 * デスクトップ/モバイル用のURLと、そのURLで利用可能なファイルを
 * コンテンツとしてオブジェクトを生成します。
 */
const generateUrlsObject = (): Record<string, Record<string, string[]>> => {
  const urls: Record<string, Record<string, string[]>> = {}

  fs.readdirSync(screenshotsDir)
    .reverse()
    .forEach((dateDir) => {
      if (dateDir === '.DS_Store') return
      ;['desktop', 'mobile'].forEach((device) => {
        fs.readdirSync(path.join(screenshotsDir, dateDir, device)).forEach((urlFile) => {
          if (urlFile === '.DS_Store') return

          const url = urlFile.replace(/_/g, '/').replace('.webp', '').replace(/,/g, '.')

          if (!urls[url]) {
            urls[url] = { desktop: [], mobile: [] }
          }

          const imageUrl = path.join(screenshotsDir, dateDir, device, urlFile)
          const dateContent = `<div class="item"><h3>${formatDate(
            dateDir
          )}</h3><a href="${imageUrl}" target="_blank"><img src="${imageUrl}" alt="${dateDir}" loading="lazy" /></a></div>`

          urls[url][device].push(dateContent)
        })
      })
    })
  return urls
}

/**
 * URLs に基づいて、各URLのHTMLファイルを作成します。
 * そして、各HTMLファイルを指すリンクを追加します。
 */
const writeUrlPages = (urls: Record<string, Record<string, string[]>>): string => {
  let indexContent = `<h2>By Page</h2>`

  Object.keys(urls).forEach((url) => {
    const fileName = url.replace(/\//g, '_').replace(/\./g, ',') + '.html'
    const originalUrl = url.replace('/', '.')

    let content = `${htmlHead}\n<h1>${originalUrl}</h1>`
    content += '<h2>Desktop</h2><div class="container">' + urls[url]['desktop'].join('') + '</div>'
    content += '<h2>Mobile</h2><div class="container">' + urls[url]['mobile'].join('') + '</div>'
    fs.writeFileSync(path.join(outDir, fileName), content)

    indexContent += `<a href="${fileName}">${originalUrl}</a><br>`
  })
  return indexContent
}

/**
 * 日付ごとにHTMLファイルを作成し、指定されたディレクトリ・パスに書き込む。
 * そして、各HTMLファイルを指すリンクを追加します。
 */
const generateIndexHtml = (dates: string[], outDir: string): string => {
  const indexHtml = dates
    .reverse()
    .map((date) => {
      const dateHtml = generateDateHtml(date)
      const dateHtmlPath = `${outDir}/${date}.html`
      fs.writeFileSync(dateHtmlPath, dateHtml)
      return `<a href="${date}.html">${formatDate(date)}</a><br/>\n`
    })
    .join('')

  return `${htmlHead}<h2>By Date</h2>${indexHtml}`
}

function formatDate(date: string): string {
  const dateObject = new Date(date.replace(/(\d{2})-(\d{2})-(\d{2})$/, '$1:$2:$3'))
  return `${dateObject.getFullYear()}-${String(dateObject.getMonth() + 1).padStart(
    2,
    '0'
  )}-${String(dateObject.getDate()).padStart(2, '0')} ${String(dateObject.getHours()).padStart(
    2,
    '0'
  )}:${String(dateObject.getMinutes()).padStart(2, '0')}`
}

function deriveOriginalUrl(filename: string): string {
  return filename.replace('.webp', '').replace(/_/, '.').replace(/_/g, '/')
}

function createDirIfNotExists(dir: string) {
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir)
  }
}

function copyDir(src: string, dest: string) {
  if (!fs.existsSync(dest)) {
    fs.mkdirSync(dest)
  }
  const files = fs.readdirSync(src)
  for (const file of files) {
    const current = fs.lstatSync(path.join(src, file))
    if (current.isDirectory()) {
      copyDir(path.join(src, file), path.join(dest, file))
    } else {
      fs.copyFileSync(path.join(src, file), path.join(dest, file))
    }
  }
}

createDirIfNotExists(outDir)
copyDir(screenshotsDir, path.join(outDir, screenshotsDir))
fs.writeFileSync(path.join(outDir, 'style.css'), css)

const dates = fs.readdirSync(screenshotsDir).filter(filterDSStore)
const indexHtml = generateIndexHtml(dates, outDir)
const urls = generateUrlsObject()
const indexContent = writeUrlPages(urls)
const fullIndexContent = indexContent + indexHtml

fs.writeFileSync(path.join(outDir, 'index.html'), fullIndexContent)

この部分に関しては、それぞれのやりたいことに合わせて実装すると思うので、解説は省きます。

(こんな書くならなにか使えばよかった、Astroとかでよかった説)

note.com のスクリーンショットが貯まっていく様子
プレビュー画面のようす

Github Actions

これらのスクリプトをGitHub Actions上のcronで動かせるようにします。また、任意のタイミングでも実行できるようにしておきます。このあたり簡単に設定できるGitHub Actionsが便利ですね。

name: Take Screenshot
on:
  workflow_dispatch:
  schedule:
    - cron: '0 11 * * 1,3,5' # 日本時間 午後8時 月・水・金

env:
  AWS-REGION: xxx
  DEPLOY-SRC-PATH: "./out"
  DEPLOY-DIST-PATH: "xxx"
  NODE_VERSION: 20.5.0

jobs:
  screenshot:
    runs-on: ["self-hosted"]
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Setup pnpm
        uses: pnpm/action-setup@v2

      - name: Install dependencies
        run: pnpm i

      - name: Install Playwright dependencies
        run: npx playwright install-deps

      - name: Build
        run: pnpm run build # スクショを撮る

      - name: Take screenshot
        run: pnpm start # HTMLを生成アップロード

      - name: Commit and push
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "🤖 GitHub Action"
          git add .
          git commit -m "Add screenshot" || echo "No changes to commit"
          git push

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-region: ${{ env.AWS-REGION }}

      - name: Publish to S3 ✈️
        run: aws s3 sync ${{ env.DEPLOY-SRC-PATH }}  ${{ env.DEPLOY-DIST-PATH }}
  1. GitHubリポジトリのコードをチェックアウト

  2. Node.jsとpnpm(パッケージマネージャ)をセットアップ

  3. プロジェクトの依存関係とPlaywrightの依存関係をインストール

  4. プロジェクトをビルド

  5. スクリーンショットを撮影します

  6. その結果をコミットし、リモートリポジトリにプッシュ、撮影したスクリーンショットがリポジトリに保存

  7. 最後にS3に公開

結果と考察

このスナップショット自動化システムを導入して、簡易的ですが、マスターデータの代替として本番環境の現状を保存し、デザインの変化を追跡できるようになりました。自動化とプレビュー環境の導入で、ざっと見で変化や過去のリリースを把握できるようになったことは大きなメリットです。

noteのそれぞれの開発チームでは、日々たくさんの新機能やABテストやカイゼンがリリースされています。別チームや異なるプロジェクトではリリースノートなどで共有されていますが、加えて具体的なUIの見た目の変更点で(デザイン的に)直感的に把握できるようになりました。ただ、プレビューが現状かなり簡易的なので、なにかしらいい感じにできればなと思います。

さらに、これからやっていきたいこととして、今回の仕組みでは、ログインなどのユーザーの状態や属性で変化する範囲のスナップショットは含めていません。このあたりも必要に応じて機能を追加できればと考えています。


ということで、Playwrightでマスターデータなくてつらい問題をサクッと解決することができました。スクリプトは定期実行されるので管理の必要もなく、手放しで資産が増え続けるので時間が経てば経つほど、複利的な効果もあるかもと思っていたり。Playwrightはいろんな使い方ができるのですが、次回はこのスナップショットを使ったVRTについて解説してみようと思います。

おわります。