◆ 今表示中の HTML を POST して サーバでスクリーンショット作ればいんじゃない
◆ ということで Chrome のヘッドレスモードでスクリーンショット撮る機能使ってみた

webページでもたまに印刷ボタンがあるサイトってありますよね
印刷用の画面が出てきたりするやつです

SNS 的なものじゃなくて 比較系サイトとかお店の「アクセス」ページとかそういうところで見かけると思います

普段見ないような印刷向けの CSS で作られてたりするんですが そもそもそんなページ(機能)いるの?って思うんですよね
ブラウザで普通に印刷すると幅の問題とかで崩れることはありますが 印刷したいならスクリーンショット撮って印刷するなどすればよくて わざわざ用意する意味あるの?って疑問に思います
私自身印刷用の画面を用意してくれても使った覚えがほとんどないですし


しかもここでもブラウザ問わず同じように印刷されるようにして なんてこと言う人もいるんでしょう(知らないけど

おもいつき

そこでなんとなく 『現在の DOM データ POST してサーバ側でスクリーンショット撮って画像で返せばいいんじゃない』 って思いつきました

DOM を POST して画像化するだけなのでどこにでも使えますし JavaScript で変更後の現在の状態が反映できます
ただ script タグは除いておかないと再実行されて見た目が変わってしまうので POST するデータを作るときに除去します

あとは IP 制限や認証状態でサーバ側からリソースアクセスできないとか img タグと違い canvas の中身が再現されない という制限あるくらいでしょうか
取得できれば置き換えればいいのですが セキュリティどうこうでできないことが多いのでこういう特別なのは置いておきます

そこまで真剣につくるわけでもないですし

[html]
<!doctype html>
<meta charset="utf-8"/>

<style>
div{color: #9f3;}
</style>
<div>
aaa
</div>

<input id="p" type="button" value="aaa">

<script>
function openss(){
const tree = document.documentElement.cloneNode(true)
;[...tree.querySelectorAll("script")].forEach(e => e.remove())
// https://stackoverflow.com/questions/6088972/get-doctype-of-an-html-as-string-with-javascript
const doctype = document.doctype
? "<!DOCTYPE "
+ document.doctype.name
+ (document.doctype.publicId ? ` PUBLIC "${document.doctype.publicId}"` : "")
+ (!document.doctype.publicId && document.doctype.systemId ? " SYSTEM" : "")
+ (document.doctype.systemId ? ` "${document.doctype.systemId}"` : "")
+ ">"
: ""
const html = doctype + "\n" + tree.outerHTML

const input = Object.assign(document.createElement("input"), {
value: html,
name: "html",
})
const form = Object.assign(document.createElement("form"), {
action: "http://fedora:9999",
method: "POST",
target: "_blank",
})
form.append(input)
document.body.append(form)
form.submit()
form.remove()
}

document.getElementById("p").onclick = openss
</script>


[node.js]
const http = require("http")
const querystring = require("querystring")
const chrome_launcher = require("chrome-launcher")

http.createServer(function (req, res) {
res.writeHead(200, {"Content-Type": "text/plain"});
let data = ""
req.on("readable", function(chunk) {
data += req.read() || ""
})
req.on("end", function() {
const val = querystring.parse(data)
if(val && val.html){
const datauri = "data:text/html," + encodeURIComponent(val.html)
launchChrome().then(async launcher => {
const buf = await screenshot({format: "png", delay: 3000, full: true, url: datauri})
res.end(buf, "binary")
})
}else{
res.end("")
}
})
}).listen(9999)

//https://developers.google.com/web/updates/2017/04/headless-chrome?hl=ja
function launchChrome() {
const launcher = chrome_launcher.launch({
port: 9222,
chromeFlags: ["--window-size=412,732", "--disable-gpu", "--headless"],
})

return launcher.catch(err => {
console.error(err)
})
}

// https://medium.com/@dschnr/using-headless-chrome-as-an-automated-screenshot-tool-4b07dffba79a
function screenshot(opt) {
return new Promise(resolve => {
const CDP = require("chrome-remote-interface")
const file = require("fs")

// CLI Args
const url = opt.url || "https://www.google.com"
const format = opt.format === "jpeg" ? "jpeg" : "png"
const viewportWidth = opt.viewportWidth || 1280
const viewportHeight = opt.viewportHeight || 900
const delay = opt.delay || 0
const userAgent = opt.userAgent
const fullPage = opt.full

// Start the Chrome Debugging Protocol
CDP(async function(client) {
// Extract used DevTools domains.
const {DOM, Emulation, Network, Page, Runtime} = client

// Enable events on domains we are interested in.
await Page.enable()
await DOM.enable()
await Network.enable()

// If user agent override was specified, pass to Network domain
if (userAgent) {
await Network.setUserAgentOverride({userAgent})
}

// Set up viewport resolution, etc.
const deviceMetrics = {
width: viewportWidth,
height: viewportHeight,
deviceScaleFactor: 0,
mobile: false,
fitWindow: false,
}
await Emulation.setDeviceMetricsOverride(deviceMetrics)
await Emulation.setVisibleSize({width: viewportWidth, height: viewportHeight})

// Navigate to target page
await Page.navigate({url})

// Wait for page load event to take screenshot
Page.loadEventFired(async () => {
// If the `full` CLI option was passed, we need to measure the height of
// the rendered page and use Emulation.setVisibleSize
if (fullPage) {
const {root: {nodeId: documentNodeId}} = await DOM.getDocument()
const {nodeId: bodyNodeId} = await DOM.querySelector({
selector: "body",
nodeId: documentNodeId,
})
const {model: {margin}} = await DOM.getBoxModel({nodeId: bodyNodeId})

await Emulation.setVisibleSize({width: viewportWidth, height: margin[5]})
// This forceViewport call ensures that content outside the viewport is
// rendered, otherwise it shows up as grey. Possibly a bug?
//await Emulation.forceViewport({x: 0, y: 0, scale: 1})
}

setTimeout(async function() {
const screenshot = await Page.captureScreenshot({format})
const buffer = new Buffer(screenshot.data, "base64")
resolve(buffer)
}, delay)
})
}).on("error", err => {
console.error("Cannot connect to browser:", err)
})
})
}


コピペが多いですが これで動きました
node.js 側が長いですが やってることはスクリーンショット撮って画像をレスポンスにして返すだけです

nightmare.js が動かない

最初は nightmare.js で
const Nightmare = require("nightmare")
Nightmare({show: false})
.goto(url)
.screenshot()
.then(e => console.log(e))
.end()
.catch(err => console.log(err))

こういう感じにやればいいか と思ってました

ですが 全く動きません
最初のブラウザ起動すらできていないようで エラーログすら出ない状態でした

試しに electron コマンドだけ打ってみると libxss がみつからない みたいなエラーでした

ぐぐってみると GUI なしのサーバ用 Linux だと Xvfb という仮想ディスプレイをインストールして設定しないといけないようです
最近のヘッドレス化した Chrome だといらないみたいですが electron の Chrome が古いのか nightmare.js が対応してないのでしょうか

Chrome でスクリーンショット撮る

nightmare.js ほど便利な API はないですが Chrome で動くなら Chrome でいいや と Chrome をインストールしました

Chrome でスクリーンショットを撮るには Chrome の起動→devtools を接続してスクリーンショット という流れが必要みたいです
devtools の API を経由しないと操作できないのはちょっと不便です

それに Chrome を起動して Chrome インスタンスから devtools を起動するのではなく Chrome とは別に devtools を起動して接続するというものなので 最初見たときどうなってるのかイマイチつかめませんでした
Chrome のインスタンス使ってないし devtools のインスタンス作るだけでも勝手に起動してくれてるの と思ったのですが そんなことはなく単純に接続先がないので接続エラーとなりました


ヘッドレス Chrome の紹介ページの方法で Chrome を起動したのですが chrome-launcher の API が変わっていてそのまま使えずちょっと苦労しました
スクリーンショットは見えてるところだけじゃなく全体を撮りたいので この方法にしています

こっちも Emulation.forceViewport が存在しなかったり画面全体のときの Emulation.setVisibleSize の height が body の高さとなっていて body の margin が考慮されていなくて下が切れてしまったのでちょっと修正しています

具体的には getBoxModel の返り値の height じゃなくて margin[5] を使うようにしています
margin は [左上X, 左上Y, 右上X, 右上Y, 右下X, 右下Y, 左下X, 左下Y] の 8 つの要素の配列になっていて 右下Y を使うようにしています
body のマージン含めた box の top は 0 のはずなので 右下Y が上下マージンを含めた高さになります


せっかくなので Chrome でスクリーンショット撮ってみましたけど nightmare.js などがヘッドレス Chrome に対応したら 生のヘッドレス Chrome でスクリーンショットすることは無くなりそうな予感がします
一応 devtools を操作できるので DOM からこの要素は非表示にして この CSS を追加して ということなど devtools で操作できることはできるという利点もありますが あまり使う機会なさそうですから


ところで必要なのは

  • chrome
  • chrome-launcher
  • chrome-remote-interface

これだけで libxss や Xvfb などはインストールしてません

sudo dnf install https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm
npm -g install chrome-launcher chrome-remote-interface