この記事は、LIFULL Advent Calendar 2019の19日目(2019-12-19)の記事です。
##はじめに
先日、LIFULLのSETグループから新しいOSSツールが発表されました
[Visual Testingに最適な画像差分検知ツール「Gazo-san」をOSS化しました]
(https://www.lifull.blog/entry/2019/12/16/110000)
この発表に触発され普段はバックエンド業務が多いのですが、
headless browser触って準備しておこうと思いPuppeteerの記事を書くことにしました
この記事で作成したものはこちらに。何かの参考になればと思います。
https://github.com/hirokawai/endroll
ちなみに、「Gazo-san」に関しては後日
「LIFULLその2 Advent Calendar 2019」カレンダーで記事が公開されるようで楽しみにしています。
##puppeteerで動画
とはいえ、キャプチャ撮影だけではなくもっと遊びたい。
AdventCalendarも後半戦になったので、ちょっと気が早いですがAdventCalendarの振り返り用ムービーを作ります。
動画生成元のHTMLを用意
→ Puppeteerで自動スクロール操作
→ 1frameずつscreenshot作成
→ ffmpegへストリームしてmp4作成
この手順で動画を作成していきます。
今回利用するツールのバージョンです。
% ffmpeg
ffmpeg version 4.2.1 Copyright (c) 2000-2019 the FFmpeg developers
built with Apple clang version 11.0.0 (clang-1100.0.33.8)
node_modules
+ puppeter@2.0.0
+ child_process@1.0.2
+ gsap@3.0.2
##動画生成元のHTMLを用意
Codropsから良さそうなHTMLを見つけてきます。
大変勉強させていただいています。Codropsマジで最高です。
https://github.com/codrops/SmoothScrollingImageEffects
このHTMLを参考にページを一部変更していきます。
画像がないと寂しいので、著作権フリーなunsplashの写真を取得できるpicsumから取得します。
demo.jsの一部を改変しAjaxリクエストでAdventCalendarの記事取得処理するようにします。
const getAdventCalendarRSS = () => {
return new Promise((resolve, reject) => {
var xmlHttpRequest = new XMLHttpRequest();
xmlHttpRequest.onreadystatechange = function()
{
if( this.readyState == 4 && this.status == 200 )
{
if( this.response )
{
var content = document.getElementById("content");
var idx = 1;
for (const item of this.response) {
var str = `
<div class="content--full content--alternate">
<div class="content__item content__item--wide">
<span class="content__item-number">${idx}</span>
<div class="content__item-imgwrap"><div class="content__item-img" style="background-image: url(https://picsum.photos/960/540?random=${idx});"></div></div>
<div class="content__item-deco">${item.author}</div>
<h2 class="content__item-title">12/${idx}</h2>
<p class="content__item-description">${item.title}</p>
</div>
</div>
`
var div = document.createElement('div');
div.innerHTML = str;
content.appendChild(div);
idx++;
}
xmlHttpRequest = null;
resolve();
}
}
}
xmlHttpRequest.open( 'GET', 'http://hirokawai.mock.server/rss', true );
xmlHttpRequest.responseType = 'json';
xmlHttpRequest.send( null );
});
};
// And then..
getAdventCalendarRSS().then(() => {
return preloadImages();
}).then(() => {
// Remove the loader
document.body.classList.remove('loading');
// Get the scroll position and update the lastScroll variable
getPageYScroll();
lastScroll = docScroll;
// Initialize the Smooth Scrolling
new SmoothScroll();
});
HTMLは記事取得を行いDOMがappendされるのを期待します
<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="favicon.ico">
<link rel="stylesheet" href="https://use.typekit.net/dec4mzz.css">
<link rel="stylesheet" type="text/css" href="css/base.css" />
</head>
<body class="demo-1 loading">
<main>
<div data-scroll class="page page--layout-2">
<h1 class="page__title">LIFULL ADVENT CALENDAR 2019</h1>
<div id="content" class="content">
</div>
</div>
</main>
<script src="js/imagesloaded.pkgd.min.js"></script>
<script src="js/demo.js"></script>
</body>
</html>
##Puppeteerで自動スクロール操作
いよいよPuppeteerを操作していきます
以下のコマンドで実行できるように作成します
node capture.js [filepath] [viewport-width] [viewport-height] [deviceScaleFactor]
例)
node capture.js 'file:///Users/hirokawai/Desktop/adcalendar.html' 960 540 2
capture.js実装は以下のようにします
(async() => {
// ※1) ffmegのオプションを設定してstreamに書き込んでいく
// ※2) puppeteerを起動
// ※3) スクレイピング
// ※4) timelineを設定
// ※5) 1frameずつstream処理
})();
※1) ffmegのオプションを設定してstreamに書き込んでいく
ffmpegオプションを定義し外部コマンド実行形式で処理をさせます
const ffmpegArgs = fps => [
'-y',
'-f',
'image2pipe',
'-r',
`${+fps}`,
'-i',
'-',
'-c:v',
'libx264',
'-auto-alt-ref',
'0',
'-pix_fmt',
'yuva420p',
'-metadata:s:v:0',
'alpha_mode="1"'
]
var ffmpegPath = options.ffmpeg || 'ffmpeg';
var fps = options.fps || 30;
var outFile = options.output;
const args = ffmpegArgs(fps);
args.push(outFile || '-');
const ffmpeg = spawn(ffmpegPath, args);
if (options.pipeOutput) {
ffmpeg.stdout.pipe(process.stdout);
ffmpeg.stderr.pipe(process.stderr);
}
const write = (stream, buffer) =>
new Promise((resolve, reject) => {
stream.write(buffer, error => {
if (error) reject(error);
else resolve();
});
});
※2) puppeteerを起動
puppeteerの起動オプションを設定して先程用意したHTMLファイルをロードします。
networkidleを監視し、リクエストの完了とready判定まで待ちます。
const viewportHeight = parseInt(process.argv[3])
const viewportWidth = parseInt(process.argv[4])
const browser = await puppeteer.launch({
headless: false,
devtools: true,
defaultViewport: {
width: viewportWidth,
height: viewportHeight,
deviceScaleFactor: parseFloat(process.argv[5]),
},
});
const page = await browser.newPage();
await page.goto(
process.argv[2]
, { waitUntil: 'networkidle0' }
);
await page.waitFor(() => !document.querySelector(".loading"));
※3) スクレイピング
記事のリストを作成するためにadvent calendarページをスクレイピングして取得します。
page.evaluateで該当の部分から日付・アカウント・タイトルを取得し用意したHTMLへ渡すようにします。
await page.setRequestInterception(true);でリクエストを監視しスクレイピングしたデータをインターセプトしています。
await page.goto(
"https://qiita.com/advent-calendar/2019/lifull"
, { waitUntil: 'networkidle0' }
);
const scrapingData = await page.evaluate(() => {
const dataList = [];
const nodeList = document.querySelectorAll(".adventCalendarItem");
nodeList.forEach(_node => {
const calendar = _node.children[0].innerText
const author = _node.children[1].children[0].getAttribute('href').slice(1);
const title = _node.children[1].children[1].firstElementChild.innerText
dataList.push({calendar,author,title});
})
return dataList;
});
await page.setRequestInterception(true);
page.on('request', request => {
if (request.url() === "http://hirokawai.mock.server/rss") {
request.respond({
content: 'application/json',
headers: {"Access-Control-Allow-Origin": "*"},
body: JSON.stringify(scrapingData)
});
}
else {
request.continue();
}
});
※4) timelineを設定
スクロールの制御にはGSAPのtimelineを利用します。GSAP3がリリースされ利用方法が少し変わりましたね。
(GSAP3新機能が増えていてますね!DEMOがすごい・・・ https://greensock.com/3/)
セレクタをハードコードしていて美しくはないですが、スクロール対象間の移動量を計算していきます。
// ページにスクロール制御用に利用するgsapを追加する
await page.addScriptTag({ path: './node_modules/gsap/dist/ScrollToPlugin.min.js' });
await page.addScriptTag({ path: './node_modules/gsap/dist/gsap.min.js' });
// 1記事の範囲を計算するためのclassを指定
// timelineのスクロールに利用する全ての記事の座標を取得
const $item = await page.$('.content__item-title');
const bounding_box = await $item.boundingBox();
const selector = ".content__item";
await page.waitForSelector(selector);
const items = await page.evaluate(selector => {
let positions = []
const elements = Array.from(document.querySelectorAll(selector));
for (const ele of elements) {
const {x, y, width, height} = ele.getBoundingClientRect();
positions.push({x, y, width, height})
}
return positions;
}, selector);
await page.evaluate(async (items, margin) => {
gsap.registerPlugin(ScrollToPlugin);
var tl = gsap.timeline();
tl.pause();
for (const item of items) {
// 1記事に対するスクロールを定義
tl.add(gsap.to(window, {duration: 3.5, scrollTo: item.y - margin, ease: "expo.inOut"}));
}
window.timeline = tl
return Promise.resolve()
}, items, bounding_box.height);
※5) 1frameずつstream処理
timelineを進めながらキャプチャを生成していきます。
// fpsに対する総frame数を計算する
const frames = await page.evaluate(async _fps =>
Math.ceil(window.timeline.duration() / 1 * _fps)
, fps)
let frame = 0
const nextFrame = async () => {
// timelineの進行をコントロール
await page.evaluate(async progress => {
window.timeline.progress(progress)
await new Promise(r => setTimeout(r, 16))
}, frame / frames)
// 見えている範囲をキャプチャする
let screenshot = await page.screenshot({
fullPage: false,
});
await write(ffmpeg.stdin, screenshot)
frame++
console.log(`frame ${frame} / ${frames}`)
if (frame > frames) {
console.log('done!')
ffmpeg.stdin.end()
await closed
await browser.close()
return
}
nextFrame()
}
nextFrame()
##出来上がり
出来上がりの一部です。puppeteerで動画を作成できました
puppeteerは様々なautomationが可能で非常に有用なツールですね。
個人的にはテストやスクレイピングなどのイメージがありましたが、こういった利用も面白いのではないでしょうか。
明日以降もLIFULLアドベントカレンダーは続きます。引き続きお楽しみに!
https://qiita.com/advent-calendar/2019/lifull