├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json ├── phantomjs └── capture.js └── test ├── assets └── index.png └── capture.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | .DS_Store 11 | 12 | pids 13 | logs 14 | results 15 | 16 | node_modules 17 | npm-debug.log 18 | 19 | test/tmp -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.8 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Mike Moulton 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Capture - Simple screenshot tool using PhantomJS [![Build Status](https://travis-ci.org/mmoulton/capture.png)](https://travis-ci.org/mmoulton/capture) 2 | 3 | **NOTE:** This project is no longer being maintained by me. If you are interested in taking over maintenance of this project, let me know. 4 | 5 | Capture, as it's name implies, will capture a screenshot of one or more URL's using [PhantomJS](http://phantomjs.org). The format of the screenshot can be anything supported by Phantom, such as PNG, GIF, JPG, or PDF. 6 | 7 | Capture is a Node.js based library that can be used as a module within another application, or as a stand alone tool via it's command line interface (CLI). 8 | 9 | ## Install 10 | 11 | To install capture you must first have Node.js, NPM and PhantomJS installed, all of which is outside the scope of these instructions. Please see the [Node.js Website](http://nodejs.org) for details on how to install Node and NPM. Personaly I am found of Tim Caswell's excelent [NVM](https://github.com/creationix/nvm) tool for insalling and managing Node. [Homebrew](http://mxcl.github.com/homebrew/) is also an excelent tool for installing PhantomJS, Node or NPM on a Mac. 12 | 13 | Once NPM is installed, simply install Capture by executing the following from the command line: 14 | 15 | npm install capture -g 16 | 17 | 18 | ## Command Line Usage 19 | 20 | Once installed, you can explore what capture can do by simply typing `capture` into the command line. You will get the built in help that looks something like this: 21 | 22 | Capture screenshots of URLs. 23 | Usage: node ./index.js [url1 url2 ...] 24 | 25 | Options: 26 | --verbose, -v Verbose logging [boolean] 27 | --out, -o Output directory for captured screenshots [string] 28 | --format, -f Output image format (png, jpg, gif, pdf) [string] [default: "png"] 29 | --phantomjs, -P Path to phantomjs bin [string] 30 | --username, -u HTTP Basic Auth Username [string] 31 | --password, -p HTTP Basic Auth Password [string] 32 | --viewport-width, --vw Minimum viewport width [string] [default: 1024] 33 | --viewport-height, --vh Minimum viewport height [string] [default: 768] 34 | --paper-format, --pf Size of the individual PDF pages (A4, letter) [string] [default: "A4"] 35 | --paper-orientation, --po Orientation of the PDF pages (portrait, landscape) [string] [default: "portrait"] 36 | --paper-margin, --pm Margin of the PDF pages (2cm, 5mm, etc.) [string] [default: "2.5mm"] 37 | --help, -h Show this help message and exit [boolean] 38 | 39 | Executing `capture http://your-domain.com/ http://your-domain.com/about/` will create a directory in the current working directory with the following structure: 40 | 41 | your-domain-com/ 42 | index.png 43 | about/ 44 | index.png 45 | 46 | ### Reading JSON from *stdin* 47 | 48 | Capture also supports reading in JSON from *stdin*. It will do it's best to find URL's within the data structure. For example, all of the following data structures are valid input: 49 | 50 | **Array of strings:** 51 | 52 | capture << EOF 53 | [ "http://your-domain.com" ] 54 | EOF 55 | 56 | **Array of Objects with property of *url*:** 57 | 58 | capture << EOF 59 | [ { "url": "http://your-domain.com" } ] 60 | EOF 61 | 62 | **Object with property of *url*:** 63 | 64 | capture << EOF 65 | { "url": "http://your-domain.com" } 66 | EOF 67 | 68 | ## Example Use Cases 69 | 70 | Capture was written to aid in regression testing of websites where large cross-cutting changes to things such as CSS were made and we wished to understand the visual differences that might exist between an existing version of a site and the newly modified version. To accomplish this, Capture, coupled with [Crawl](http://github.com/mmoulton/crawl) allows us to take screenshots of both the old and new versions of the site, then perform image differencing on the results. One handy tool for performing the image differencing is a Mac app called [Kaleidoscope](http://www.kaleidoscopeapp.com). 71 | 72 | 73 | ## The MIT License 74 | 75 | Copyright (c) Mike Moulton 76 | 77 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 78 | 79 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 80 | 81 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 82 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /*! 4 | * Capture - simple screenshot tool using PhantomJS 5 | * Copyright(c) 2013 Mike Moulton 6 | * MIT Licensed 7 | */ 8 | 9 | module.exports = capture; 10 | 11 | var util = require('util'), 12 | path = require('path'), 13 | fs = require('fs'), 14 | urlUtil = require('url'), 15 | async = require('async'), 16 | S = require('string'); 17 | childProcess = require('child_process'); 18 | 19 | var MAX_PHANTOMJS_SPAWNS = 10, 20 | PHANTOMJS_BIN = "phantomjs", 21 | OUT_PATH = "./", 22 | OUT_FORMAT = "png", 23 | OUT_VIEWPORT_WIDTH = 1024, 24 | OUT_VIEWPORT_HEIGHT = 768, 25 | OUT_PAPER_ORIENTATION = "portrait", 26 | OUT_PAPER_FORMAT = "A4", 27 | OUT_PAPER_MARGIN = "2.5mm"; 28 | 29 | function capture(urls, options, callback) { 30 | 31 | if (typeof options === 'function') { 32 | callback = options; 33 | options = {}; 34 | } 35 | options = options || {}; 36 | 37 | var captureScript = path.join(__dirname, 'phantomjs/capture.js'), 38 | phantomPath = options.phantomBin || PHANTOMJS_BIN, 39 | outPath = options.out || OUT_PATH, 40 | format = options.format || OUT_FORMAT, 41 | viewportWidth = options.viewportWidth || OUT_VIEWPORT_WIDTH, 42 | viewportHeight = options.viewportHeight || OUT_VIEWPORT_HEIGHT, 43 | paperOrientation = options.paperOrientation || OUT_PAPER_ORIENTATION, 44 | paperFormat = options.paperFormat || OUT_PAPER_FORMAT, 45 | paperMargin = options.paperMargin || OUT_PAPER_MARGIN, 46 | username = options.username || false, 47 | password = options.password || false, 48 | verbose = options.verbose || false; 49 | 50 | // throttle phantom processes spawns to 10 51 | async.forEachLimit(urls, MAX_PHANTOMJS_SPAWNS, 52 | 53 | // For each url 54 | function(url, done) { 55 | 56 | var urlParts = urlUtil.parse(url, true), 57 | filename = urlParts.pathname, 58 | auth = urlParts.auth; 59 | 60 | if (S(filename).endsWith("/")) filename += "index"; // Append 61 | 62 | var filePath = path.resolve( 63 | process.cwd(), 64 | outPath, 65 | S(urlParts.hostname).replaceAll("\\.", "-").s, 66 | "./" + filename + "." + format), 67 | args = [captureScript, url, filePath, '--username', username, 68 | '--password', password, '--paper-orientation', paperOrientation, 69 | '--paper-margin', paperMargin, '--paper-format', paperFormat, 70 | '--viewport-width', viewportWidth, '--viewport-height', viewportHeight]; 71 | 72 | var phantom = childProcess.execFile(phantomPath, args, function(err, stdout, stderr) { 73 | if (verbose && stdout) console.log("---\nPhantomJS process stdout [%s]:\n" + stdout + "\n---", phantom.pid); 74 | if (verbose && stderr) console.log("---\nPhantomJS process stderr [%s]:\n" + stderr + "\n---", phantom.pid); 75 | if (verbose) console.log("Rendered %s to %s", url, filePath); 76 | done(err); 77 | }); 78 | if (verbose) 79 | console.log("Spawning PhantomJS process [%s] to rasterize '%s' to '%s'", phantom.pid, url, filePath); 80 | }, 81 | 82 | // Once all urls are processes 83 | function(err) { 84 | callback(err); 85 | }); 86 | 87 | } 88 | 89 | function main() { 90 | 91 | var options = {}; 92 | 93 | var optimist = require('optimist') 94 | .usage('Capture screenshots of URLs.\nUsage: $0 [url1 url2 ...]', { 95 | 'verbose': { 96 | 'type': 'boolean', 97 | 'description': 'Verbose logging', 98 | 'alias': 'v' 99 | }, 100 | 'out': { 101 | 'type': 'string', 102 | 'description': 'Output directory for captured screenshots', 103 | 'alias': 'o' 104 | }, 105 | 'format': { 106 | 'type': 'string', 107 | 'description': 'Output image format (png, jpg, gif, pdf)', 108 | 'alias': 'f', 109 | 'default': 'png' 110 | }, 111 | 'phantomjs': { 112 | 'type': 'string', 113 | 'description': 'Path to phantomjs bin', 114 | 'alias': 'P' 115 | }, 116 | 'username': { 117 | 'type': 'string', 118 | 'description': 'HTTP Basic Auth Username', 119 | 'alias': 'u' 120 | }, 121 | 'password': { 122 | 'type': 'string', 123 | 'description': 'HTTP Basic Auth Password', 124 | 'alias': 'p' 125 | }, 126 | 'viewport-width': { 127 | 'type': 'string', 128 | 'description': 'Minimum viewport width', 129 | 'alias': 'vw', 130 | 'default': OUT_VIEWPORT_WIDTH 131 | }, 132 | 'viewport-height': { 133 | 'type': 'string', 134 | 'description': 'Minimum viewport height', 135 | 'alias': 'vh', 136 | 'default': OUT_VIEWPORT_HEIGHT 137 | }, 138 | 'paper-format': { 139 | 'type': 'string', 140 | 'description': 'Size of the individual PDF pages (A4, letter)', 141 | 'alias': 'pf', 142 | 'default': OUT_PAPER_FORMAT 143 | }, 144 | 'paper-orientation': { 145 | 'type': 'string', 146 | 'description': 'Orientation of the PDF pages (portrait, landscape)', 147 | 'alias': 'po', 148 | 'default': OUT_PAPER_ORIENTATION 149 | }, 150 | 'paper-margin': { 151 | 'type': 'string', 152 | 'description': 'Margin of the PDF pages (2cm, 5mm, etc.)', 153 | 'alias': 'pm', 154 | 'default': OUT_PAPER_MARGIN 155 | }, 156 | 'help' : { 157 | 'type': 'boolean', 158 | 'description': 'Show this help message and exit', 159 | 'alias': 'h' 160 | } 161 | }); 162 | 163 | var argv = optimist.argv; 164 | 165 | if (argv.help) { 166 | optimist.showHelp(); 167 | process.exit(0); 168 | } 169 | 170 | // Use url's provided as arguments in CLI 171 | if (argv._ && argv._.length > 0) { 172 | captureUrls(argv._); 173 | } 174 | 175 | // try to read JSON from standard in 176 | else { 177 | var stdinData = ""; 178 | 179 | process.stdin.resume(); 180 | process.stdin.setEncoding('utf8'); 181 | 182 | process.stdin.on('data', function(chunk) { 183 | stdinData += chunk; 184 | }) 185 | 186 | process.stdin.on('end', function(){ 187 | parseJsonInput(stdinData); 188 | }); 189 | } 190 | 191 | function parseJsonInput(data) { 192 | try { 193 | var urls = [], 194 | parsedUrls = JSON.parse(data); 195 | 196 | function findUrl(item) { 197 | if ("string" == typeof item) { 198 | urls.push(item); 199 | } else if ("object" == typeof item && item.url) { 200 | urls.push(item.url); 201 | } else { 202 | console.error("Unable to extract url from: ", item); 203 | } 204 | } 205 | 206 | if ("object" == typeof parsedUrls) { 207 | if (parsedUrls.constructor == Array) { 208 | parsedUrls.forEach(function(item) { 209 | findUrl(item); 210 | }); 211 | } else { 212 | findUrl(parsedUrls); 213 | } 214 | } 215 | 216 | captureUrls(urls); 217 | } 218 | catch (err) { 219 | console.error("Unable to parse JSON from stdin", err); 220 | console.trace(); 221 | process.exit(1); 222 | } 223 | } 224 | 225 | function captureUrls(urls) { 226 | 227 | if (!urls || urls.length == 0) process.exit(1); 228 | 229 | var start = new Date().getTime(); 230 | 231 | capture(urls, argv, function(err) { 232 | var status = 0, 233 | end = new Date().getTime(); 234 | 235 | if (err) { 236 | console.error("Error during capture process:", err); 237 | status++; 238 | } else { 239 | console.log("Capture complete [%d miliseconds to execute]", end - start); 240 | } 241 | 242 | process.exit(status); 243 | 244 | }); 245 | 246 | } 247 | 248 | } 249 | 250 | if (require.main === module) 251 | main(); 252 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "capture", 3 | "description": "Captures Screenshots using Phantom.js", 4 | "version": "0.1.0", 5 | "author": "Mike Moulton ", 6 | "dependencies": { 7 | "optimist": "0.3.x", 8 | "async": "0.1.x", 9 | "string": "1.1.x" 10 | }, 11 | "devDependencies": { 12 | "mocha": "*", 13 | "should": "*", 14 | "wrench": "*" 15 | }, 16 | "keywords": ["capture", "phantomjs", "screenshot", "web", "website"], 17 | "repository": "git://github.com/mmoulton/capture", 18 | "main": "index", 19 | "bin": { "capture": "./index.js" }, 20 | "scripts": { 21 | "test": "mocha" 22 | }, 23 | "engines": { "node":">= 0.8.0" } 24 | } -------------------------------------------------------------------------------- /phantomjs/capture.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Capture - simple screenshot tool using PhantomJS 3 | * Copyright(c) 2013 Mike Moulton 4 | * MIT Licensed 5 | * 6 | * PhantomJS script for captureing a screenshot of a single URL 7 | * Waits for all resources to load on the page before attempting the captrue 8 | */ 9 | 10 | var page = require('webpage').create(), 11 | system = require('system'), 12 | auth, address, output, resources = {}; 13 | 14 | var TIMEOUT = 60000, 15 | RESOURCE_LOAD_WINDOW = 5, 16 | RESOURCE_CHECK_SLEEP = 10; 17 | 18 | // Remove the path to the script from the arguments if it's included 19 | var mutableArgs = JSON.parse(JSON.stringify(system.args)) 20 | if (mutableArgs[0].indexOf('capture.js') >= 0) { 21 | mutableArgs.shift(); 22 | } 23 | 24 | if (mutableArgs.length <= 2 || mutableArgs.length % 2 !== 0) { 25 | phantom.exit(1); 26 | } 27 | 28 | // Processes the arguments and set them up for Phantom 29 | options = argsToObject(mutableArgs); 30 | address = options.address; 31 | output = options.output; 32 | page.settings.userName = options.username || ''; 33 | page.settings.password = options.password || ''; 34 | 35 | page.viewportSize = { 36 | width: options.viewportWidth || 1024, 37 | height: options.viewportHeight || 768, 38 | }; 39 | 40 | page.paperSize = { 41 | format: options.paperFormat || 'A4', 42 | orientation: options.paperOrientation || 'portrait', 43 | margin: options.paperMargin || '2.5mm' 44 | }; 45 | 46 | // handle resource loads, tracking each outstanding request 47 | page.onResourceRequested = function (req) { 48 | // Match only http(s) protocols 49 | if (req.url.match(/^http(s)?:\/\//i)) { 50 | resources[req.id] = true; 51 | } 52 | }; 53 | 54 | // handle resource load completions, marking the resource as received 55 | page.onResourceReceived = function (res) { 56 | if (resources[res.id]) { 57 | delete resources[res.id]; 58 | } 59 | }; 60 | 61 | // open the page 62 | page.open(address, function (status) { 63 | 64 | var elapsedSinceLastResource = 0; 65 | 66 | // fail fast if their was an error loading the initial page 67 | if (status !== 'success') { 68 | console.log('Unable to load the address: ' + address); 69 | phantom.exit(); 70 | } 71 | 72 | var capturePage = function () { 73 | 74 | window.setTimeout(function () { 75 | // no resources currently pending, might be ready to capture 76 | if (Object.keys(resources).length == 0) { 77 | 78 | // no pending resources and waiting period has expired 79 | // so we can assume page is ready to be captured 80 | if (elapsedSinceLastResource >= RESOURCE_LOAD_WINDOW) { 81 | page.render(output); 82 | phantom.exit(); 83 | 84 | // no pending resources, but still in waiting period 85 | } else { 86 | elapsedSinceLastResource += RESOURCE_CHECK_SLEEP; 87 | } 88 | 89 | // their are pending resources, not in waiting period 90 | } else { 91 | elapsedSinceLastResource = 0; 92 | } 93 | 94 | // we did not capture this iteration, sleep 95 | capturePage(); 96 | 97 | }, RESOURCE_CHECK_SLEEP); 98 | }; 99 | 100 | // set failsafe incase of failed resource load, 404, slow pages, etc. 101 | window.setTimeout(function () { 102 | console.log('Timeout loading: ' + address); 103 | phantom.exit(); 104 | }, TIMEOUT); 105 | 106 | // capture the screenshot 107 | capturePage(); 108 | 109 | }); 110 | 111 | /** 112 | * Transform an array to an object literal 113 | * @param {Array} args Array with seperate arguments 114 | * @return {Object} 115 | */ 116 | function argsToObject(args) { 117 | var options = {}; 118 | options.address = args.shift(); 119 | options.output = args.shift(); 120 | 121 | // Pair two arguments, while transforming the key to camelCase 122 | for (i = 0; i < args.length; i = i + 2) { 123 | options[toCamelCase(args[i].substr(2))] = args[i + 1]; 124 | } 125 | 126 | return options; 127 | } 128 | 129 | 130 | /** 131 | * Take a hypen seperated string and turn it into camel case 132 | * @param {String} str The input string 133 | * @return {String} 134 | */ 135 | function toCamelCase(str) { 136 | return str.replace(/-([a-z])/g, function (match) { 137 | return match[1].toUpperCase() 138 | }); 139 | } 140 | -------------------------------------------------------------------------------- /test/assets/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmoulton/capture/794a0542603c4ff1f908d5d00c1bd3cda5c490ec/test/assets/index.png -------------------------------------------------------------------------------- /test/capture.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Capture - simple screenshot tool using PhantomJS 3 | * Copyright(c) 2013 Mike Moulton 4 | * MIT Licensed 5 | */ 6 | 7 | 8 | var capture = require('../'), 9 | assert = require('assert'), 10 | wrench = require('wrench'), 11 | http = require('http'), 12 | path = require('path'), 13 | fs = require('fs'); 14 | 15 | var httpServer; 16 | 17 | describe('capture', function() { 18 | 19 | before(function(done) { 20 | httpServer = http.createServer(function (req, res) { 21 | res.writeHead(200, {'Content-Type': 'text/html'}); 22 | res.end('

Hello World

\n'); 23 | }).listen(8899, "127.0.0.1"); 24 | done(); 25 | }); 26 | 27 | describe('#capture()', function() { 28 | 29 | it('should capture screenshot of 1 page', function(done) { 30 | capture(['http://127.0.0.1:8899/'], { out: './test/tmp' }, function(err) { 31 | assert.ok(fs.statSync(path.join(__dirname, 'tmp/127-0-0-1/index.png')).isFile()); 32 | done(); 33 | }); 34 | }); 35 | 36 | }); 37 | 38 | after(function(done) { 39 | var temp = path.join(__dirname, './tmp'); 40 | wrench.rmdirSyncRecursive(temp); 41 | done(); 42 | }); 43 | 44 | }); 45 | 46 | 47 | --------------------------------------------------------------------------------