I recently needed to make a screenshot of an entire web page, i.e. not just the part the fits on the screen (or in the viewport), but the whole thing from top to bottom.
Somehow I missed the possibility that there might be a zillion browser extensions for just this purpose. From writing features (acceptance tests) with Cucumber I’m familiar with PhantomJS, a “headless”, scriptable browser based on WebKit.
Here’s how it works:
$ webshot --help
Usage: webshot [OPTIONS]... URL IMAGE_FILE
Options:
-j|--jquery Inject jQuery into the page.
+-e|--exec «value» Execute script in page context.
+-f|--file «value» Execute script file in page context.
-v|--viewport «value» Viewport size WxH; default 1280x800.
(* mandatory option)
(+ repeatable option)
#! /usr/bin/env phantomjs | |
// Michael Schuerig, <michael@schuerig.de>, 2012. | |
// Portions are Copyright 2011 Valeriu Paloş (valeriu@palos.ro). (see below) | |
// This script requires PhantomJS | |
// http://phantomjs.org/download.html | |
var jQueryUrl = "//ajax.googleapis.com/ajax/libs/jquery/1.8/jquery.min.js"; | |
var page = require('webpage').create(); | |
var system = require('system'); | |
var optionsSchema = [ | |
['j', 'jquery', '', 'Inject jQuery into the page.'], | |
['e', 'exec', '+:', 'Execute script in page context.'], | |
['f', 'file', '+:', 'Execute script file in page context.'], | |
['v', 'viewport', ':', 'Viewport size WxH; default 1280x800.'] | |
]; | |
var usageBanner = "Usage: webshot [OPTIONS]... URL IMAGE_FILE\n" | |
function main(argv) { | |
var options = parseOptions(optionsSchema, argv); | |
if (!options) { | |
phantom.exit(); | |
} | |
var url = options.arguments[1], | |
outFile = options.arguments[2]; | |
setViewportSize(options.viewport); | |
installErrorLogger(); | |
var chain = openPage(url); | |
if (options.jquery) { | |
chain = chain.chain(loadJQuery()); | |
} | |
chain.done(function() { | |
timeout(200).done(function() { | |
// console.debug('Doing something on the page'); | |
var files = options.file || []; | |
for (var i = 0, l = files.length; i < l; i++) { | |
page.injectJs(files[i]); | |
} | |
var scripts = options.exec || []; | |
for (var i = 0, l = scripts.length; i < l; i++) { | |
page.evaluate(new Function(scripts[i])); | |
} | |
page.render(outFile); | |
console.log('A screenshot of ' + url + ' was saved as ' + outFile + '.'); | |
}); | |
}); | |
Deferred.finish(phantom.exit); | |
} | |
function setViewportSize(viewport) { | |
var wh = []; | |
if (viewport) { | |
wh = viewport.split('x'); | |
} | |
wh[0] = wh[0] || 1280 | |
wh[1] = wh[1] || 800; | |
page.viewportSize = { width: wh[0], height: wh[1] }; | |
} | |
function installErrorLogger() { | |
page.onError = function (msg, trace) { | |
console.log(msg); | |
trace.forEach(function(item) { | |
console.log(' ', item.file, ':', item.line); | |
}); | |
} | |
} | |
function Deferred(func) { | |
var k = function() {}, | |
onDone = k, | |
onFail = k, | |
onAlways = k, | |
status = 'pending'; | |
this.done = function(f) { | |
onDone = f; | |
return this; | |
}; | |
this.fail = function(f) { | |
onFail = f; | |
return this; | |
}; | |
this.always = function(f) { | |
onAlways = f; | |
return this; | |
}; | |
this.isResolved = function() { | |
return status === 'resolved'; | |
}; | |
this.chain = function(next) { | |
this.done(function() { | |
// console.debug('chain#done'); | |
next.resolve(); | |
}); | |
return next; | |
} | |
var run = function(success) { | |
// console.debug('Deferred#run:', success); | |
try { | |
if (success) { | |
onDone(); | |
} else { | |
status = 'failed'; | |
onFail(); | |
} | |
} finally { | |
status = 'resolved'; | |
onAlways(); | |
Deferred.startResolving(); | |
} | |
}; | |
Deferred.deferreds.push(this); | |
this.resolve = function() { | |
// console.debug('Deferred#resolve'); | |
if (status === 'pending') { | |
status = 'resolving'; | |
func(run); | |
} | |
} | |
Deferred.startResolving(); | |
} | |
Deferred.deferreds = []; | |
Deferred.onFinish = function() {}; | |
Deferred.finish = function(f) { | |
Deferred.onFinish = f; | |
}; | |
Deferred.unresolved = function() { | |
var unresolved = []; | |
var ds = Deferred.deferreds; | |
for (var i = 0, l = ds.length; i < l; i++) { | |
var d = ds[i]; | |
if (!d.isResolved()) { | |
unresolved.push(d); | |
} | |
} | |
Deferred.deferreds = unresolved; | |
return unresolved; | |
}; | |
Deferred.startResolving = function() { | |
// console.debug('Deferred.startResolving'); | |
if (!Deferred.resolving) { | |
Deferred.resolving = true; | |
window.setTimeout(Deferred.resolve, 0); | |
} | |
}; | |
Deferred.resolve = function() { | |
// console.debug('Deferred.resolve'); | |
var us = Deferred.unresolved(); | |
if (us.length > 0) { | |
us[0].resolve(); | |
Deferred.resolving = false; | |
} else { | |
Deferred.resolving = false; | |
Deferred.onFinish(); | |
} | |
}; | |
function timeout(millis) { | |
return new Deferred(function(run) { | |
window.setTimeout( | |
function() { | |
// console.debug('Timeout'); | |
run(true); | |
}, | |
millis | |
) | |
}); | |
} | |
function openPage(url) { | |
return new Deferred(function(run) { | |
// console.debug('Opening page'); | |
page.open(url, function(status) { | |
if (status !== 'success') { | |
console.log('Unable to load the URL: ' + url); | |
run(false); | |
} else { | |
// console.debug('URL loaded: ' + url); | |
run(true); | |
} | |
}); | |
}); | |
} | |
function loadJQuery() { | |
return new Deferred(function(run) { | |
// console.debug('Loading jQuery'); | |
page.includeJs(jQueryUrl, function() { | |
// console.debug('jQuery loaded'); | |
run(true); | |
}); | |
}); | |
} | |
function parseOptions(schema, argv) { | |
// From https://gist.github.com/982499 | |
/** Command-line options parser (http://valeriu.palos.ro/1026/). | |
Copyright 2011 Valeriu Paloş (valeriu@palos.ro). All rights reserved. | |
Released as Public Domain. | |
Expects the "schema" array with options definitions and produces the | |
"options" object and the "arguments" array, which will contain all | |
non-option arguments encountered (including the script name and such). | |
Syntax: | |
[«short», «long», «attributes», «brief», «callback»] | |
Attributes: | |
! - option is mandatory; | |
: - option expects a parameter; | |
+ - option may be specified multiple times (repeatable). | |
Notes: | |
- Parser is case-sensitive. | |
- The '-h|--help' option is provided implicitly. | |
- Parsed options are placed as fields in the "options" object. | |
- Non-option arguments are placed in the "arguments" array. | |
- Options and their parameters must be separated by space. | |
- Either one of «short» or «long» must always be provided. | |
- The «callback» function is optional. | |
- Cumulated short options are supported (i.e. '-tv'). | |
- If an error occurs, the process is halted and the help is shown. | |
- Repeatable options will be cumulated into arrays. | |
- The parser does *not* test for duplicate option definitions. | |
// Sample option definitions. | |
var schema = [ | |
['f', 'file', '!:', "Some file we really need.", | |
function(data) { | |
console.log("Hello: " + data); | |
} ], | |
['t', 'test', '!', 'I am needed also.'], | |
['d', '', '', 'Enable debug mode.'], | |
['', 'level', ':', 'Debug level (values 0-4).'], | |
['v', 'verbose', '+', 'Verbosity levels (can be used repeatedly).'], | |
]; | |
*/ | |
// Parse options. | |
try { | |
var tokens = []; | |
var options = {}; | |
var arguments = []; | |
for (var i = 0, item = argv[0]; i < argv.length; i++, item = argv[i]) { | |
if (item.charAt(0) == '-') { | |
if (item.charAt(1) == '-') { | |
tokens.push('--', item.slice(2)); | |
} else { | |
tokens = tokens.concat(item.split('').join('-').split('').slice(1)); | |
} | |
} else { | |
tokens.push(item); | |
} | |
} | |
while (type = tokens.shift()) { | |
if (type == '-' || type == '--') { | |
var name = tokens.shift(); | |
if (name == 'help' || name == 'h') { | |
throw 'help'; | |
continue; | |
} | |
var option = null; | |
for (var i = 0, item = schema[0]; i < schema.length; i++, item = schema[i]) { | |
if (item[type.length - 1] == name) { | |
option = item; | |
break; | |
} | |
} | |
if (!option) { | |
throw "Unknown option '" + type + name + "' encountered!"; | |
} | |
var value = true; | |
if ((option[2].indexOf(':') != -1) && !(value = tokens.shift())) { | |
throw "Option '" + type + name + "' expects a parameter!"; | |
} | |
var index = option[1] || option[0]; | |
if (option[2].indexOf('+') != -1) { | |
options[index] = options[index] instanceof Array ? options[index] : []; | |
options[index].push(value); | |
} else { | |
options[index] = value; | |
} | |
if (typeof(option[4]) == 'function') { | |
option[4](value); | |
} | |
option[2] = option[2].replace('!', ''); | |
} else { | |
arguments.push(type); | |
continue; | |
} | |
} | |
for (var i = 0, item = schema[0]; i < schema.length; i++, item = schema[i]) { | |
if (item[2].indexOf('!') != -1) { | |
throw "Option '" + (item[1] ? '--' + item[1] : '-' + item[0]) + | |
"' is mandatory and was not given!"; | |
} | |
} | |
options.arguments = arguments; | |
} catch(e) { | |
if (e == 'help') { | |
// console.log("Usage: ./«script» «options» «values»\n"); | |
console.log(usageBanner); | |
console.log("Options:"); | |
for (var i = 0, item = schema[0]; i < schema.length; i++, item = schema[i]) { | |
var names = (item[0] ? '-' + item[0] + (item[1] ? '|' : ''): ' ') + | |
(item[1] ? '--' + item[1] : ''); | |
var syntax = names + (item[2].indexOf(':') != -1 ? ' «value»' : ''); | |
syntax += syntax.length < 20 ? new Array(20 - syntax.length).join(' ') : ''; | |
console.log("\t" + (item[2].indexOf('!') != -1 ? '*' : ' ') | |
+ (item[2].indexOf('+') != -1 ? '+' : ' ') | |
+ syntax + "\t" + item[3]); | |
} | |
console.log("\n\t (* mandatory option)\n\t (+ repeatable option)\n"); | |
// process.exit(0); | |
return false; | |
} | |
console.error(e); | |
console.error("Use the '-h|--help' option for usage details."); | |
// process.exit(1); | |
return false; | |
} | |
return options; | |
} | |
main(system.args); |
Luckily, I didn’t have to write the commandline parsing myself. But it was fun to figure out how Deferreds
might be implemented.