├── .npmignore ├── .gitignore ├── demo ├── ui.png ├── 1409037825746-1409037838093.png ├── 1409037838093-1409037882033.png ├── 1409037882033-1409037916727.png └── 1409038130483-1409038137417.png ├── CHANGELOG.md ├── package.json ├── util.js ├── README.md ├── phantomjs ├── highlight.html ├── highlight.js ├── diff.js ├── walk.js └── index.js └── index.js /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | test/ 3 | demo/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | test/ 3 | node_modules -------------------------------------------------------------------------------- /demo/ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fouber/page-monitor/HEAD/demo/ui.png -------------------------------------------------------------------------------- /demo/1409037825746-1409037838093.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fouber/page-monitor/HEAD/demo/1409037825746-1409037838093.png -------------------------------------------------------------------------------- /demo/1409037838093-1409037882033.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fouber/page-monitor/HEAD/demo/1409037838093-1409037882033.png -------------------------------------------------------------------------------- /demo/1409037882033-1409037916727.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fouber/page-monitor/HEAD/demo/1409037882033-1409037916727.png -------------------------------------------------------------------------------- /demo/1409038130483-1409038137417.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fouber/page-monitor/HEAD/demo/1409038130483-1409038137417.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Thu Sep 25 2014 2 | 3 | > Because of screenshot is png format, it will occupy so much space, so I change it into ``jpeg`` and set quality as ``80`` by default. 4 | 5 | * [Mod] save screenshot as ``jpeg`` by default, and quality is ``80`` 6 | * [Add] add options to set screenshot format, quality. 7 | 8 | If you want to save screenshot as png format, you can: 9 | 10 | 11 | ```javascript 12 | var Monitor = require('monitor'); 13 | var m = new Monitor('http://www.example.com', { 14 | render: { 15 | format: 'png' 16 | } 17 | }); 18 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "page-monitor", 3 | "version": "0.5.4", 4 | "description": "monitor pages and diff the dom change with phantomjs", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [ 10 | "monitor", 11 | "phantomjs", 12 | "phantom", 13 | "diff" 14 | ], 15 | "dependencies": { 16 | "phantomjs-prebuilt": "^2.1.14" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/fouber/page-monitor.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/fouber/page-monitor/issues" 24 | }, 25 | "homepage": "https://github.com/fouber/page-monitor", 26 | "author": "fouber ", 27 | "license": "MIT" 28 | } 29 | -------------------------------------------------------------------------------- /util.js: -------------------------------------------------------------------------------- 1 | var toString = Object.prototype.toString; 2 | var _ = module.exports = {}; 3 | 4 | /** 5 | * is 6 | * @param {*} source 7 | * @param {string} type 8 | * @returns {boolean} 9 | */ 10 | _.is = function(source, type){ 11 | return toString.call(source) === '[object ' + type + ']'; 12 | }; 13 | 14 | /** 15 | * walk object and merge 16 | * @param {Object} obj 17 | * @param {Function} callback 18 | * @param {Object} merge 19 | */ 20 | _.map = function(obj, callback, merge){ 21 | var index = 0; 22 | for(var key in obj){ 23 | if(obj.hasOwnProperty(key)){ 24 | if(merge){ 25 | callback[key] = obj[key]; 26 | } else if(callback(key, obj[key], index++)) { 27 | break; 28 | } 29 | } 30 | } 31 | }; 32 | 33 | /** 34 | * merge target into source 35 | * @param {*} source 36 | * @param {*} target 37 | * @returns {*} 38 | */ 39 | _.merge = function(source, target){ 40 | if(_.is(source, 'Object') && _.is(target, 'Object')){ 41 | _.map(target, function(key, value){ 42 | source[key] = _.merge(source[key], value); 43 | }); 44 | } else { 45 | source = target; 46 | } 47 | return source; 48 | }; 49 | 50 | /** 51 | * escape regexp chars 52 | * @param {string} str 53 | * @returns {string} 54 | */ 55 | _.escapeReg = function(str){ 56 | return str.replace(/[\.\\\+\*\?\[\^\]\$\(\){}=!<>\|:\/]/g, '\\$&'); 57 | }; 58 | 59 | /** 60 | * pad a numeric string to two digits 61 | * @param {string} str 62 | * @returns {string} 63 | */ 64 | _.pad = function(str){ 65 | return ('0' + str).substr(-2); 66 | }; 67 | 68 | /** 69 | * convert millisecond into string like `2014-09-12 14:23:03` 70 | * @param {Number} num 71 | * @returns {string} 72 | */ 73 | _.getTimeString = function(num){ 74 | var d; 75 | if(_.is(num, 'Date')){ 76 | d = num; 77 | } else { 78 | d = new Date(); 79 | d.setTime(num); 80 | } 81 | var day = [ 82 | d.getFullYear(), 83 | _.pad(d.getMonth() + 1), 84 | _.pad(d.getDate()) 85 | ].join('-'); 86 | var time = [ 87 | _.pad(d.getHours()), 88 | _.pad(d.getMinutes()), 89 | _.pad(d.getSeconds()) 90 | ].join(':'); 91 | return day + ' ' + time; 92 | }; 93 | 94 | /** 95 | * generate UUID 96 | * @returns {string} 97 | */ 98 | _.unique = function(){ 99 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 100 | var r = Math.random()*16 | 0; 101 | return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); 102 | }); 103 | }; 104 | 105 | /** 106 | * run mode, capture or diff or both 107 | * @type {{CAPTURE: number, DIFF: number}} 108 | */ 109 | _.mode = { 110 | CAPTURE: 1, // capture mode 111 | DIFF : 2 // diff mode 112 | }; 113 | 114 | /** 115 | * log type 116 | * @type {{DEBUG: string, WARNING: string, INFO: string, ERROR: string, NOTICE: string}} 117 | */ 118 | _.log = { 119 | DEBUG: '<[debug]>', 120 | WARNING: '<[warning]>', 121 | INFO: '<[info]>', 122 | ERROR: '<[error]>', 123 | NOTICE: '<[notice]>' 124 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Page Monitor 2 | 3 | > capture webpage and diff the dom change with [phantomjs](http://phantomjs.org/) 4 | 5 | ## Effects 6 | 7 | ### Element Add 8 | 9 | ![element add](./demo/1409037825746-1409037838093.png) 10 | 11 | ### Element Removed 12 | 13 | ![element removed](./demo/1409037838093-1409037882033.png) 14 | 15 | ### Text Changed 16 | 17 | ![element removed](./demo/1409037882033-1409037916727.png) 18 | 19 | ### Style Changed 20 | 21 | ![element removed](./demo/1409038130483-1409038137417.png) 22 | 23 | ## Make UI to show diffs of history 24 | 25 | > https://github.com/fouber/pmui 26 | 27 | ![ui](./demo/ui.png?v=3) 28 | 29 | ## Usage 30 | 31 | > First of all, install [phantomjs](http://phantomjs.org/download.html), page-monitor relys on [phantomjs](http://phantomjs.org/) to render webpage and genenrate screenshot. DO NOT use ``npm`` to install phantomjs on winidows, it can't be launched by ``spawn``. 32 | 33 | ```javascript 34 | var Monitor = require('page-monitor'); 35 | 36 | var url = 'http://www.google.com'; 37 | var monitor = new Monitor(url); 38 | monitor.capture(function(code){ 39 | console.log(monitor.log); // from phantom 40 | console.log('done, exit [' + code + ']'); 41 | }); 42 | ``` 43 | 44 | ## API 45 | 46 | ### Monitor 47 | 48 | ```javascript 49 | var monitor = new Monitor(url [, options]); 50 | ``` 51 | 52 | see the default options here: [https://github.com/fouber/page-monitor/blob/master/index.js](https://github.com/fouber/page-monitor/blob/master/index.js#L57-L179) , you can override any option for your monitoring. 53 | 54 | ### monitor.capture(callback [, noDiff]); 55 | 56 | caputure webpage and save screenshot, then diff with last save. 57 | 58 | ```javascript 59 | var monitor = new Monitor(url, options); 60 | monitor.on('debug', function (data) { 61 | console.log('[DEBUG] ' + data); 62 | }); 63 | monitor.on('error', function (data) { 64 | console.error('[ERROR] ' + data); 65 | }); 66 | monitor.capture(function(code){ 67 | console.log(monitor.log.info); // diff result 68 | console.log('[DONE] exit [' + code + ']'); 69 | }); 70 | ``` 71 | 72 | ### monitor.diff(left, right, callback); 73 | 74 | diff change between left(date.getTime()) and right(date.getTime()). 75 | 76 | ```javascript 77 | var monitor = new Monitor(url, options); 78 | monitor.on('debug', function (data) { 79 | console.log('[DEBUG] ' + data); 80 | }); 81 | monitor.on('error', function (data) { 82 | console.error('[ERROR] ' + data); 83 | }); 84 | monitor.diff(1408947323420, 1408947556898, function(code){ 85 | console.log(monitor.log.info); // diff result 86 | console.log('[DONE] exit [' + code + ']'); 87 | }); 88 | ``` 89 | 90 | ### events 91 | 92 | ```javascript 93 | var monitor = new Monitor(url); 94 | monitor.on('debug', function (data) { 95 | console.log('[DEBUG] ' + data); 96 | }); 97 | monitor.on('error', function (data) { 98 | console.error('[ERROR] ' + data); 99 | }); 100 | ``` 101 | 102 | * ``debug``: debug from phantom 103 | * ``notice``: console from webpage 104 | * ``info``: info from phantom 105 | * ``warning``: error from webpage 106 | * ``error``: error from phantom 107 | -------------------------------------------------------------------------------- /phantomjs/highlight.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | hello world 6 | 64 | 65 | 66 |
67 |
68 |
69 |

70 |
71 |
72 |
73 |
74 |

75 |
76 | 77 |
78 |
79 |
80 | 98 | 99 | -------------------------------------------------------------------------------- /phantomjs/highlight.js: -------------------------------------------------------------------------------- 1 | module.exports = function(token, diff, lOffset, rOffset, opt){ 2 | 3 | /** 4 | * console log debug message 5 | * @param {string} msg 6 | */ 7 | function log(msg){ 8 | console.log(token + msg); 9 | } 10 | 11 | /** 12 | * get px string 13 | * @param {string} val 14 | * @returns {string} 15 | */ 16 | function px(val){ 17 | return val + 'px'; 18 | } 19 | 20 | var CHANGE_TYPE = opt.changeType; 21 | var CHANGE_STYLE = {}; 22 | CHANGE_STYLE.ADD = opt.highlight.add; 23 | CHANGE_STYLE.REMOVE = opt.highlight.remove; 24 | CHANGE_STYLE.TEXT = opt.highlight.text; 25 | CHANGE_STYLE.STYLE = opt.highlight.style; 26 | var lContainer = document.getElementById('left'); 27 | var rContainer = document.getElementById('right'); 28 | 29 | /** 30 | * 31 | * @param {Array} rect [x, y, width, height] 32 | * @param {Object} options 33 | * @param {HTMLElement} container 34 | * @param {Boolean} useTitle 35 | * @param {Number} offsetX 36 | * @param {Number} offsetY 37 | * @returns {HTMLElement} 38 | */ 39 | function highlightElement(rect, options, container, offsetX, offsetY, useTitle){ 40 | offsetX = parseInt(offsetX || 0); 41 | offsetY = parseInt(offsetY || 0); 42 | var div = document.createElement('x-diff-div'); 43 | div.style.position = 'absolute'; 44 | div.style.display = 'block'; 45 | div.style.backgroundColor = 'rgba(0, 0, 0, 0.4)'; 46 | div.style.border = '1px dashed #333'; 47 | div.style.fontSize = '12px'; 48 | div.style.fontWeight = 'normal'; 49 | div.style.overflow = 'hidden'; 50 | div.style.color = '#fff'; 51 | div.style.boxShadow = '0 2px 5px rgba(0, 0, 0, 0.4)'; 52 | if(useTitle && options.title){ 53 | var span = document.createElement('x-diff-span'); 54 | span.innerHTML = options.title; 55 | div.appendChild(span); 56 | } 57 | for(var key in options){ 58 | if(options.hasOwnProperty(key)){ 59 | div.style[key] = options[key]; 60 | } 61 | } 62 | div.style.left = px(rect[0] - offsetX); 63 | div.style.top = px(rect[1] - offsetY); 64 | div.style.width = px(rect[2]); 65 | div.style.height = px(rect[3]); 66 | container.appendChild(div); 67 | return div; 68 | } 69 | 70 | // add lenged 71 | var lenged = document.getElementById('legend'); 72 | for(key in CHANGE_STYLE){ 73 | if(CHANGE_STYLE.hasOwnProperty(key)){ 74 | div = highlightElement([0, 0, 120, 18], CHANGE_STYLE[key], lenged, 0, 0, true); 75 | div.setAttribute('id', 's-' + key); 76 | div.style.position = 'static'; 77 | div.style.margin = '5px 8px'; 78 | div.style.display = 'inline-block'; 79 | div.style.lineHeight = '18px'; 80 | div.style.textAlign = 'center'; 81 | div.style.fontWeight = 'bold'; 82 | } 83 | } 84 | 85 | var count = { 86 | add: 0, 87 | remove: 0, 88 | style: 0, 89 | text: 0 90 | }; 91 | // highlight diffs 92 | diff.forEach(function(item){ 93 | var node = item.node; 94 | var type = item.type; 95 | switch (type){ 96 | case CHANGE_TYPE.ADD: 97 | count.add++; 98 | highlightElement(node.rect, CHANGE_STYLE.ADD, rContainer, rOffset.x, rOffset.y); 99 | break; 100 | case CHANGE_TYPE.REMOVE: 101 | count.remove++; 102 | highlightElement(node.rect, CHANGE_STYLE.REMOVE, lContainer, lOffset.x, lOffset.y); 103 | break; 104 | case CHANGE_TYPE.TEXT: 105 | count.text++; 106 | highlightElement(node.rect, CHANGE_STYLE.TEXT, rContainer, rOffset.x, rOffset.y); 107 | break; 108 | default : 109 | if(type & CHANGE_TYPE.STYLE){ 110 | count.style++; 111 | } 112 | if(type & CHANGE_TYPE.TEXT){ 113 | count.text++; 114 | } 115 | highlightElement(node.rect, CHANGE_STYLE.STYLE, rContainer, rOffset.x, rOffset.y); 116 | break; 117 | } 118 | }); 119 | 120 | for(var key in CHANGE_STYLE){ 121 | if(CHANGE_STYLE.hasOwnProperty(key)){ 122 | var div = document.getElementById('s-' + key); 123 | var span = document.createElement('x-span'); 124 | span.innerHTML = count[key.toLowerCase()] || 0; 125 | span.style.float = 'right'; 126 | span.style.backgroundColor = 'rgba(0,0,0,0.8)'; 127 | span.style.paddingLeft = '5px'; 128 | span.style.paddingRight = '5px'; 129 | span.style.height = '18px'; 130 | span.style.lineHeight = '18px'; 131 | span.style.color = '#fff'; 132 | div.appendChild(span); 133 | } 134 | } 135 | 136 | return count; 137 | 138 | }; -------------------------------------------------------------------------------- /phantomjs/diff.js: -------------------------------------------------------------------------------- 1 | /** 2 | * equal 3 | * @param {Object} left 4 | * @param {Object} right 5 | * @returns {boolean} 6 | */ 7 | function equal(left, right){ 8 | var type = typeof left; 9 | if(type === typeof right){ 10 | switch(type){ 11 | case 'object': 12 | var lKeys = Object.keys(left); 13 | var rKeys = Object.keys(right); 14 | if(lKeys.length === rKeys.length){ 15 | for(var i = 0; i < lKeys.length; i++){ 16 | var key = lKeys[i]; 17 | if(!right.hasOwnProperty(key) || (left[key] !== right[key])){ 18 | return false; 19 | } 20 | } 21 | return true; 22 | } else { 23 | return false; 24 | } 25 | break; 26 | default: 27 | return left === right; 28 | } 29 | } else { 30 | return false; 31 | } 32 | } 33 | 34 | /** 35 | * match 36 | * @param {Object} left 37 | * @param {Object} right 38 | * @returns {boolean} 39 | */ 40 | function isMatch(left, right){ 41 | return (left.name === right.name) && equal(left.attr, right.attr); 42 | } 43 | 44 | /** 45 | * common logic of `LCSHeadFirst’ and `LCSTailFirst‘ 46 | * @param {Object} old 47 | * @param {Object} cur 48 | * @param {Function} match 49 | * @param {Number} x 50 | * @param {Array} lastLine 51 | * @param {Array} currLine 52 | */ 53 | function LCSProc(old, cur, match, x, lastLine, currLine){ 54 | if(match(old, cur)){ 55 | var sequence = (lastLine[x-1] || []).slice(0); 56 | sequence.push({ l: old, r: cur }); 57 | currLine[x] = sequence; 58 | } else { 59 | var lSeq = currLine[x-1]; 60 | var tSeq = lastLine[x]; 61 | if(lSeq && tSeq){ 62 | if(lSeq.length < tSeq.length){ 63 | currLine[x] = tSeq.slice(0); 64 | } else { 65 | currLine[x] = lSeq.slice(0); 66 | } 67 | } else if(lSeq) { 68 | currLine[x] = lSeq.slice(0); 69 | } else if(tSeq) { 70 | currLine[x] = tSeq.slice(0); 71 | } 72 | } 73 | } 74 | 75 | /** 76 | * Longest common subsequence (obverse) 77 | * @param {Array} left 78 | * @param {Array} right 79 | * @param {Function} match 80 | * @returns {Array} 81 | */ 82 | function LCSHeadFirst(left, right, match){ 83 | var lastLine = []; 84 | var currLine = []; 85 | var y = left.length; 86 | var len = right.length; 87 | while(y--){ 88 | var old = left[y]; 89 | var i = len; 90 | while(i--){ 91 | var cur = right[i]; 92 | var x = len - i - 1; 93 | LCSProc(old, cur, match, x, lastLine, currLine); 94 | } 95 | lastLine = currLine; 96 | currLine = []; 97 | } 98 | return (lastLine.pop() || []); 99 | } 100 | 101 | /** 102 | * Longest common subsequence (reverse) 103 | * @param {Array} left 104 | * @param {Array} right 105 | * @param {Function} match 106 | * @returns {Array} 107 | */ 108 | function LCSTailFirst(left, right, match){ 109 | var lastLine = []; 110 | var currLine = []; 111 | left.forEach(function(old){ 112 | right.forEach(function(cur, x){ 113 | LCSProc(old, cur, match, x, lastLine, currLine); 114 | }); 115 | lastLine = currLine; 116 | currLine = []; 117 | }); 118 | return (lastLine.pop() || []); 119 | } 120 | 121 | /** 122 | * diff change 123 | * @param {Object} left 124 | * @param {Object} right 125 | * @param {Object} opt 126 | * @returns {Array} 127 | */ 128 | var diff = function(left, right, opt){ 129 | var ret = []; 130 | var change = { 131 | type: 0, 132 | node: right 133 | }; 134 | if(left.style !== right.style){ 135 | change.type |= opt.changeType.STYLE; 136 | } 137 | var LCS = opt.priority === 'head' ? LCSHeadFirst : LCSTailFirst; 138 | LCS(left.child, right.child, isMatch).forEach(function(node){ 139 | var old = node.l; 140 | var cur = node.r; 141 | cur.matched = old.matched = true; 142 | if(cur.name === '#'){ 143 | if(old.text !== cur.text){ 144 | // match node, but contents are different. 145 | change.type |= opt.changeType.TEXT; 146 | } 147 | } else { 148 | // recursive 149 | ret = ret.concat(diff(old, cur, opt)); 150 | } 151 | }); 152 | right.child.forEach(function(node){ 153 | if(!node.matched){ 154 | if(node.name === '#'){ 155 | // add text, but count as text change 156 | change.type |= opt.changeType.TEXT; 157 | } else { 158 | // add element 159 | ret.push({ 160 | type: opt.changeType.ADD, 161 | node: node 162 | }); 163 | } 164 | } 165 | }); 166 | left.child.forEach(function(node){ 167 | if(!node.matched){ 168 | if(node.name === '#'){ 169 | // remove text, but count as text change 170 | change.type |= opt.changeType.TEXT; 171 | } else { 172 | // removed element 173 | ret.push({ 174 | type: opt.changeType.REMOVE, 175 | node: node 176 | }); 177 | } 178 | } 179 | }); 180 | if(change.type){ 181 | ret.push(change); 182 | } 183 | return ret; 184 | }; 185 | 186 | module.exports = diff; -------------------------------------------------------------------------------- /phantomjs/walk.js: -------------------------------------------------------------------------------- 1 | module.exports = function(TOKEN, data){ 2 | 3 | /* 4 | * JavaScript MD5 1.0.1 5 | * https://github.com/blueimp/JavaScript-MD5 6 | * 7 | * Copyright 2011, Sebastian Tschan 8 | * https://blueimp.net 9 | * 10 | * Licensed under the MIT license: 11 | * http://www.opensource.org/licenses/MIT 12 | * 13 | * Based on 14 | * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message 15 | * Digest Algorithm, as defined in RFC 1321. 16 | * Version 2.2 Copyright (C) Paul Johnston 1999 - 2009 17 | * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet 18 | * Distributed under the BSD License 19 | * See http://pajhome.org.uk/crypt/md5 for more info. 20 | */ 21 | 22 | var md5 = function(){"use strict";function n(n,r){var t=(65535&n)+(65535&r),u=(n>>16)+(r>>16)+(t>>16);return u<<16|65535&t}function r(n,r){return n<>>32-r}function t(t,u,e,o,c,f){return n(r(n(n(u,t),n(o,f)),c),e)}function u(n,r,u,e,o,c,f){return t(r&u|~r&e,n,r,o,c,f)}function e(n,r,u,e,o,c,f){return t(r&e|u&~e,n,r,o,c,f)}function o(n,r,u,e,o,c,f){return t(r^u^e,n,r,o,c,f)}function c(n,r,u,e,o,c,f){return t(u^(r|~e),n,r,o,c,f)}function f(r,t){r[t>>5]|=128<>>9<<4)+14]=t;var f,i,a,h,g,l=1732584193,v=-271733879,d=-1732584194,C=271733878;for(f=0;f>5]>>>r%32&255);return t}function a(n){var r,t=[];for(t[(n.length>>2)-1]=void 0,r=0;r>5]|=(255&n.charCodeAt(r/8))<16&&(e=f(e,8*n.length)),t=0;16>t;t+=1)o[t]=909522486^e[t],c[t]=1549556828^e[t];return u=f(o.concat(a(r)),512+8*r.length),i(f(c.concat(u),640))}function l(n){var r,t,u="0123456789abcdef",e="";for(t=0;t>>4&15)+u.charAt(15&r);return e}function v(n){return unescape(encodeURIComponent(n))}function d(n){return h(v(n))}function C(n){return l(d(n))}function A(n,r){return g(v(n),v(r))}function m(n,r){return l(A(n,r))}function s(n,r,t){return r?t?A(r,n):m(r,n):t?d(n):C(n)}return s}(); 23 | 24 | /** 25 | * combo selectors 26 | * @param {string} selectors 27 | * @returns {string} 28 | */ 29 | function normalizeSelectors(selectors){ 30 | if(Object.prototype.toString.call(selectors) === '[object Array]'){ 31 | return selectors.join(','); 32 | } else { 33 | return String(selectors || ''); 34 | } 35 | } 36 | 37 | // walk settings 38 | var INVISIBLE_ELEMENT = data.invisibleElements; 39 | var IGNORE_CHILDREN_ELEMENT = data.ignoreChildrenElements; 40 | var STYLE_FILTERS = data.styleFilters; 41 | var ATTR_FILTERS = data.attributeFilters; 42 | // var INCLUDE_SELECTORS = normalizeSelectors(data.includeSelectors); 43 | var EXCLUDE_SELECTORS = normalizeSelectors(data.excludeSelectors); 44 | var IGNORE_CHILDREN_SELECTORS = normalizeSelectors(data.ignoreChildrenSelectors); 45 | var IGNORE_TEXT_SELECTORS = normalizeSelectors(data.ignoreTextSelectors); 46 | var IGNORE_STYLE_SELECTORS = normalizeSelectors(data.ignoreStyleSelectors); 47 | var ROOT = data.root || 'body'; 48 | 49 | // reg 50 | var invisibleElementReg = new RegExp('^(' + INVISIBLE_ELEMENT.join('|') + ')$', 'i'); 51 | var ignoreChildrenElementReg = new RegExp('^(' + IGNORE_CHILDREN_ELEMENT.join('|') + ')$', 'i'); 52 | 53 | /** 54 | * invisible 55 | * @param {HTMLElement} elem 56 | * @returns {boolean} 57 | */ 58 | function isInvisible(elem){ 59 | var tagName = elem.tagName.toLowerCase(); 60 | invisibleElementReg.lastIndex = 0; 61 | return (invisibleElementReg.test(tagName) || (tagName === 'input' && elem.type === 'hidden')); 62 | } 63 | 64 | /** 65 | * ignore child 66 | * @param {HTMLElement} elem 67 | * @returns {boolean} 68 | */ 69 | function igonreChildren(elem){ 70 | ignoreChildrenElementReg.lastIndex = 0; 71 | return ignoreChildrenElementReg.test(elem.tagName) || 72 | (IGNORE_CHILDREN_SELECTORS && elem.webkitMatchesSelector(IGNORE_CHILDREN_SELECTORS)); 73 | } 74 | 75 | /** 76 | * get computed styles of element, and hash them 77 | * @param {HTMLElement} elem 78 | * @returns {string} 79 | */ 80 | function getStyles(elem){ 81 | var ret = []; 82 | var filters = STYLE_FILTERS.slice(0); 83 | if(igonreChildren(elem)){ 84 | filters.width = true; 85 | filters.height = true; 86 | } 87 | var styles = elem.ownerDocument.defaultView.getComputedStyle( elem, null ); 88 | var display = styles.getPropertyValue('display'); 89 | var opacity = styles.getPropertyValue('opacity'); 90 | var visibility = styles.getPropertyValue('visibility'); 91 | if(display === 'none' || opacity === '0' || visibility === 'hidden'){ 92 | return false; 93 | } else { 94 | var position = styles.getPropertyValue('position'); 95 | if(position !== 'static'){ 96 | filters.push('top', 'right', 'bottom', 'left'); 97 | } 98 | filters.forEach(function(key){ 99 | ret.push(styles.getPropertyValue(key)); 100 | }); 101 | } 102 | return md5(ret.join('~')); 103 | } 104 | 105 | /** 106 | * get element bounding rect 107 | * @param {HTMLElement} elem 108 | * @returns [x, y, width, height] 109 | */ 110 | function getRect(elem){ 111 | var rect = elem.getBoundingClientRect(); 112 | var doc = elem.ownerDocument; 113 | var win = doc.defaultView; 114 | var html = doc.documentElement; 115 | var x = Math.floor(rect.left + win.pageXOffset - html.clientLeft); 116 | var y = Math.floor(rect.top + win.pageYOffset - html.clientTop); 117 | var w = Math.floor(rect.width); 118 | var h = Math.floor(rect.height); 119 | return [x, y, w, h]; 120 | } 121 | 122 | /** 123 | * get attributes of element 124 | * @param {HTMLElement} elem 125 | * @returns {Object|boolean} 126 | */ 127 | function getAttr(elem){ 128 | var ret = {}; 129 | var filters = ATTR_FILTERS.slice(0); 130 | var hasAttr = false; 131 | if(elem.tagName.toLowerCase() === 'input'){ 132 | filters.push('type'); 133 | } 134 | filters.forEach(function(key){ 135 | var attr = elem.getAttribute(key); 136 | if(attr !== null){ 137 | hasAttr = true; 138 | ret[key] = attr; 139 | } 140 | }); 141 | return hasAttr ? ret : false; 142 | } 143 | 144 | /** 145 | * filter elements 146 | * @param {HTMLElement} elem 147 | * @param {HTMLElement} parent 148 | * @returns {boolean} 149 | */ 150 | function filter(elem, parent){ 151 | var ret = true; 152 | switch (elem.nodeType){ 153 | case 1: 154 | if(EXCLUDE_SELECTORS){ 155 | ret = ret && !elem.webkitMatchesSelector(EXCLUDE_SELECTORS); 156 | } 157 | // if(INCLUDE_SELECTORS){ 158 | // ret = ret && elem.webkitMatchesSelector(INCLUDE_SELECTORS); 159 | // } 160 | break; 161 | case 3: 162 | if(IGNORE_TEXT_SELECTORS){ 163 | ret = ret && !parent.webkitMatchesSelector(IGNORE_TEXT_SELECTORS); 164 | } 165 | break; 166 | default: 167 | ret = false; 168 | break; 169 | } 170 | return ret; 171 | } 172 | 173 | /** 174 | * walk dom tree 175 | * @param {HTMLElement} elem 176 | * @returns {Object} 177 | */ 178 | function walk(elem){ 179 | var node = {}; 180 | if(elem.nodeType === 1){ // element 181 | node.name = elem.tagName.toLowerCase(); 182 | if(!isInvisible(elem)){ 183 | node.rect = getRect(elem); 184 | var attr = getAttr(elem); 185 | if(attr){ 186 | node.attr = attr; 187 | } 188 | if(IGNORE_STYLE_SELECTORS && elem.webkitMatchesSelector(IGNORE_STYLE_SELECTORS)){ 189 | node.style = ''; 190 | } else { 191 | node.style = getStyles(elem); 192 | } 193 | node.child = []; 194 | if(node.name === 'img'){ 195 | if(!(IGNORE_TEXT_SELECTORS && elem.webkitMatchesSelector(IGNORE_TEXT_SELECTORS))){ 196 | var canvas = document.createElement('canvas'); 197 | canvas.width = elem.offsetWidth; 198 | canvas.height = elem.offsetHeight; 199 | var ctx = canvas.getContext('2d'); 200 | ctx.drawImage(elem, 0, 0); 201 | // not ignore text 202 | node.child.push({ 203 | name: '#', 204 | text: md5(canvas.toDataURL()) 205 | }); 206 | } 207 | } else if(igonreChildren(elem)){ // ignore children 208 | if(!(IGNORE_TEXT_SELECTORS && elem.webkitMatchesSelector(IGNORE_TEXT_SELECTORS))){ 209 | // not ignore text 210 | node.child.push({ 211 | name: '#', 212 | text: md5(elem.innerText.replace(/\s+/g, ' ')) 213 | }); 214 | } 215 | } else { 216 | for(var i = 0, len = elem.childNodes.length; i < len; i++){ 217 | var child = elem.childNodes[i]; 218 | if(filter(child, elem)){ // recursion 219 | var vdom = arguments.callee(child); // 220 | if(typeof vdom !== 'undefined' && vdom.style !== false){ 221 | node.child.push(vdom); 222 | } 223 | } 224 | } 225 | } 226 | return node; 227 | } 228 | } else if(elem.nodeType === 3) { // text node 229 | var text = elem.nodeValue.trim(); 230 | if(text){ 231 | node.name = '#'; 232 | node.text = md5(text); 233 | return node; 234 | } 235 | } 236 | } 237 | 238 | if(data.removeSelectors && data.removeSelectors.length){ 239 | data.removeSelectors.forEach(function(selector){ 240 | var elems = document.querySelectorAll(selector); 241 | for(var i = 0, len = elems.length; i < len; i++){ 242 | var elem = elems[i]; 243 | elem.parentNode.removeChild(elem); 244 | elem = null; 245 | } 246 | }); 247 | } 248 | return walk(document.querySelector(ROOT)); 249 | 250 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var child_process = require('child_process'); 2 | var spawn = child_process.spawn; 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var Url = require('url'); 6 | var util = require("util"); 7 | var DEFAULT_DATA_DIRNAME = process.cwd(); 8 | var PHANTOMJS_SCRIPT_DIR = path.join(__dirname, 'phantomjs'); 9 | var PHANTOMJS_SCRIPT_FILE = path.join(PHANTOMJS_SCRIPT_DIR, 'index.js'); 10 | var _ = require('./util.js'); 11 | var _exists = fs.existsSync || path.existsSync; 12 | var phantomjs = require('phantomjs-prebuilt'); 13 | var binPath = phantomjs.path; 14 | 15 | /** 16 | * mkdir -p 17 | * @param {String} path 18 | * @param {Number} mode 19 | */ 20 | function mkdirp(path, mode){ 21 | if (typeof mode === 'undefined') { 22 | //511 === 0777 23 | mode = 511 & (~process.umask()); 24 | } 25 | if(_exists(path)) return; 26 | path.replace(/\\/g, '/').split('/').reduce(function(prev, next) { 27 | if(prev && !_exists(prev)) { 28 | fs.mkdirSync(prev, mode); 29 | } 30 | return prev + '/' + next; 31 | }); 32 | if(!_exists(path)) { 33 | fs.mkdirSync(path, mode); 34 | } 35 | } 36 | 37 | /** 38 | * base64 encode 39 | * @param {String|Buffer} data 40 | * @returns {String} 41 | */ 42 | function base64(data){ 43 | if(data instanceof Buffer){ 44 | //do nothing for quickly determining. 45 | } else if(data instanceof Array){ 46 | data = new Buffer(data); 47 | } else { 48 | //convert to string. 49 | data = new Buffer(String(data || '')); 50 | } 51 | return data.toString('base64'); 52 | } 53 | 54 | /** 55 | * merge settings 56 | * @param {Object} settings 57 | * @returns {*} 58 | */ 59 | function mergeSettings(settings){ 60 | var defaultSettings = { 61 | // remove phantomejs application cache before capture 62 | // @see https://github.com/fouber/page-monitor/issues/3 63 | cleanApplicationCache: false, 64 | // phantom cli options 65 | // @see http://phantomjs.org/api/command-line.html 66 | cli: { 67 | '--max-disk-cache-size' : '0', 68 | '--disk-cache' : 'false', 69 | '--ignore-ssl-errors' : 'yes' 70 | }, 71 | // webpage settings 72 | // @see http://phantomjs.org/api/webpage/ 73 | page: { 74 | viewportSize: { 75 | width: 320, 76 | height: 568 77 | }, 78 | settings: { 79 | resourceTimeout: 20000, 80 | userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X; en-us) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53' 81 | } 82 | }, 83 | walk: { 84 | invisibleElements : [ 85 | 'applet', 'area', 'audio', 'base', 'basefont', 86 | 'bdi', 'bdo', 'big', 'br', 'center', 'colgroup', 87 | 'datalist', 'form', 'frameset', 'head', 'link', 88 | 'map', 'meta', 'noframes', 'noscript', 'optgroup', 89 | 'option', 'param', 'rp', 'rt', 'ruby', 'script', 90 | 'source', 'style', 'title', 'track', 'xmp' 91 | ], 92 | ignoreChildrenElements: [ 93 | 'img', 'canvas', 'input', 'textarea', 'audio', 94 | 'video', 'hr', 'embed', 'object', 'progress', 95 | 'select', 'table' 96 | ], 97 | styleFilters: [ 98 | 'margin-left', 'margin-top', 'margin-right', 'margin-bottom', 99 | 'border-left-color', 'border-left-style', 'border-left-width', 100 | 'border-top-color', 'border-top-style', 'border-top-width', 101 | 'border-right-color', 'border-right-style', 'border-right-width', 102 | 'border-bottom-color', 'border-bottom-style', 'border-bottom-width', 103 | 'border-top-left-radius', 'border-top-right-radius', 104 | 'border-bottom-left-radius', 'border-bottom-right-radius', 105 | 'padding-left', 'padding-top', 'padding-right', 'padding-bottom', 106 | 'background-color', 'background-image', 'background-repeat', 107 | 'background-size', 'background-position', 108 | 'list-style-image', 'list-style-position', 'list-style-type', 109 | 'outline-color', 'outline-style', 'outline-width', 110 | 'font-size', 'font-family', 'font-weight', 'font-style', 'line-height', 111 | 'box-shadow', 'clear', 'color', 'display', 'float', 'opacity', 'text-align', 112 | 'text-decoration', 'text-indent', 'text-shadow', 'vertical-align', 'visibility', 113 | 'position' 114 | ], 115 | // attributes to mark an element 116 | attributeFilters: [ 'id', 'class' ], 117 | excludeSelectors: [], 118 | removeSelectors: [], // remove elements before walk 119 | ignoreTextSelectors: [], // ignore content change of text node or image change 120 | ignoreStyleSelectors: [], // ignore style change 121 | ignoreChildrenSelectors: [], // 122 | root: 'body' 123 | }, 124 | diff: { 125 | // LCS diff priority, `head` or `tail` 126 | priority: 'head', 127 | // highlight mask styles 128 | highlight: { 129 | add: { 130 | title: '新增(Added)', 131 | backgroundColor: 'rgba(127, 255, 127, 0.3)', 132 | borderColor: '#090', 133 | color: '#060', 134 | textShadow: '0 1px 1px rgba(0, 0, 0, 0.3)' 135 | }, 136 | remove: { 137 | title: '删除(Removed)', 138 | backgroundColor: 'rgba(0, 0, 0, 0.5)', 139 | borderColor: '#999', 140 | color: '#fff' 141 | }, 142 | style: { 143 | title: '样式(Style)', 144 | backgroundColor: 'rgba(255, 0, 0, 0.3)', 145 | borderColor: '#f00', 146 | color: '#f00' 147 | }, 148 | text: { 149 | title: '文本(Text)', 150 | backgroundColor: 'rgba(255, 255, 0, 0.3)', 151 | borderColor: '#f90', 152 | color: '#c30' 153 | } 154 | } 155 | }, 156 | events: { 157 | init: function(token){ 158 | /* 159 | do something before page init, 160 | @see http://phantomjs.org/api/webpage/handler/on-initialized.html 161 | */ 162 | }, 163 | beforeWalk: function(token){ 164 | /* 165 | do something before walk dom tree, 166 | retrun a number to delay screenshot 167 | */ 168 | } 169 | }, 170 | render: { 171 | format: 'jpeg', // @see http://phantomjs.org/api/webpage/method/render.html 172 | quality: 80, // @see http://phantomjs.org/api/webpage/method/render.html 173 | ext: 'jpg', // the same as format, if not specified 174 | delay: 1000, // delay(ms) before screenshot. 175 | timeout: 60 * 1000 // render timeout, max waiting time 176 | }, 177 | path: { 178 | root: DEFAULT_DATA_DIRNAME, // data and screenshot save path root 179 | 180 | // save path format, it can be a string 181 | // like this: '{hostname}/{port}/{pathname}/{query}{hash}' 182 | format: function(url, opt){ 183 | return [ 184 | opt.hostname, (opt.port ? '-' + opt.port : ''), '/', 185 | base64(opt.path + (opt.hash || '')).replace(/\//g, '.') 186 | ].join(''); 187 | } 188 | } 189 | }; 190 | 191 | // special handling of events 192 | if(settings && settings.events){ 193 | _.map(settings.events, function(key, value){ 194 | if(typeof value === 'function'){ 195 | value = value.toString().replace(/^(function\s+)anonymous(?=\()/, '$1'); 196 | settings.events[key] = value; 197 | } 198 | }); 199 | } 200 | return _.merge(defaultSettings, settings || {}); 201 | } 202 | 203 | /** 204 | * 205 | * @param {String} path 206 | * @returns {String} 207 | */ 208 | function escapePath(path){ 209 | if(path === '/'){ 210 | return '-'; 211 | } else { 212 | return path.replace(/^\//, '').replace(/^\.|[\\\/:*?"<>|]/g, '-'); 213 | } 214 | } 215 | 216 | /** 217 | * path format 218 | * @param {String|Function} pattern 219 | * @param {String} url 220 | * @param {Object} opt 221 | * @returns {String} 222 | */ 223 | function format(pattern, url, opt){ 224 | switch (typeof pattern){ 225 | case 'function': 226 | return pattern(url, opt); 227 | case 'string': 228 | var pth = []; 229 | String(pattern).split('/').forEach(function(item){ 230 | pth.push(item.replace(/\{(\w+)\}/g, function(m, $1){ 231 | return escapePath((opt[$1] || '')); 232 | })); 233 | }); 234 | return pth.join('/'); 235 | default : 236 | throw new Error('unsupport format'); 237 | } 238 | } 239 | 240 | var LOG_VALUE_MAP ={}; 241 | var logTypes = (function(){ 242 | var types = []; 243 | _.map(_.log, function(key, value){ 244 | LOG_VALUE_MAP[value] = key.toLowerCase(); 245 | types.push(_.escapeReg(value)); 246 | }); 247 | return types.join('|'); 248 | })(); 249 | var LOG_SPLIT_REG = new RegExp('(?:^|[\r\n]+)(?=' + logTypes + ')'); 250 | var LOG_TYPE_REG = new RegExp('^(' + logTypes + ')'); 251 | 252 | /** 253 | * why not events.EventEmitter? 254 | * because it can NOT emit an 'error' event, 255 | * but i need, fuck off. 256 | * @constructor 257 | */ 258 | var EventEmitter = function(){ 259 | this._listeners = {}; 260 | }; 261 | 262 | /** 263 | * add event listener 264 | * @param {string} type 265 | * @param {Function} callback 266 | */ 267 | EventEmitter.prototype.on = function(type, callback){ 268 | if(!this._listeners.hasOwnProperty(type)){ 269 | this._listeners[type] = []; 270 | } 271 | this._listeners[type].push(callback); 272 | }; 273 | 274 | /** 275 | * remove event listener 276 | * @param {string} type 277 | * @param {Function} callback 278 | */ 279 | EventEmitter.prototype.off = function(type, callback){ 280 | if(this._listeners.hasOwnProperty(type)){ 281 | var listeners = []; 282 | for(var i = 0, len = this._listeners[type].length; i < len; i++){ 283 | var listener = this._listeners[type][i]; 284 | if(listener !== callback){ 285 | listeners.push(listener); 286 | } 287 | } 288 | this._listeners[type] = listeners; 289 | } 290 | }; 291 | 292 | /** 293 | * dispatch event 294 | * @param {string} type 295 | */ 296 | EventEmitter.prototype.emit = function(type){ 297 | if(this._listeners.hasOwnProperty(type)){ 298 | var args = [].splice.call(arguments, 1); 299 | var self = this; 300 | this._listeners[type].forEach(function(callback){ 301 | callback.apply(self, args); 302 | }); 303 | } 304 | }; 305 | 306 | /** 307 | * Monitor Class Constructor 308 | * @param {String} url 309 | * @param {Object} options 310 | * @constructor 311 | */ 312 | var Monitor = function(url, options){ 313 | EventEmitter.call(this); 314 | options = mergeSettings(options); 315 | this.url = options.url = url; 316 | this.running = false; 317 | options.path.dir = path.join( 318 | options.path.root || DEFAULT_DATA_DIRNAME, 319 | format(options.path.format, url, Url.parse(url)) 320 | ); 321 | if(!fs.existsSync(options.path.dir)){ 322 | mkdirp(options.path.dir); 323 | } 324 | this.options = options; 325 | this._initLog(); 326 | }; 327 | 328 | // inherit from EventEmitter 329 | util.inherits(Monitor, EventEmitter); 330 | 331 | /** 332 | * init log 333 | * @private 334 | */ 335 | Monitor.prototype._initLog = function(){ 336 | var log = this.log = {}; 337 | _.map(_.log, function(key){ 338 | log[key.toLowerCase()] = []; 339 | }); 340 | }; 341 | 342 | /** 343 | * capture webpage and diff 344 | * @param {Function} callback 345 | * @param {Boolean} noDiff 346 | * @returns {*} 347 | */ 348 | Monitor.prototype.capture = function(callback, noDiff){ 349 | if(this.running) return; 350 | this.running = true; 351 | var self = this; 352 | var type = _.mode.CAPTURE; 353 | if(!noDiff){ 354 | type |= _.mode.DIFF; 355 | } 356 | this._initLog(); 357 | return this._phantom( 358 | [ 359 | PHANTOMJS_SCRIPT_FILE, 360 | type, 361 | this.url, 362 | JSON.stringify(this.options) 363 | ], 364 | function(code, log){ 365 | // TODO with code 366 | self.running = false; 367 | callback.call(self, code, log); 368 | } 369 | ); 370 | }; 371 | 372 | /** 373 | * diff with two times 374 | * @param {Number|String|Date} left 375 | * @param {Number|String|Date} right 376 | * @param {Function} callback 377 | * @returns {*} 378 | */ 379 | Monitor.prototype.diff = function(left, right, callback){ 380 | if(this.running) return; 381 | this.running = true; 382 | var self = this; 383 | var type = _.mode.DIFF; 384 | this._initLog(); 385 | if(_.is(left, 'Date')){ 386 | left = left.getDate(); 387 | } 388 | if(_.is(right, 'Date')){ 389 | right = right.getDate(); 390 | } 391 | return this._phantom( 392 | [ 393 | PHANTOMJS_SCRIPT_FILE, 394 | type, left, right, 395 | JSON.stringify(this.options) 396 | ], 397 | function(code, log){ 398 | // TODO with code 399 | self.running = false; 400 | callback.call(self, code, log); 401 | } 402 | ); 403 | }; 404 | 405 | /** 406 | * spawn phantom 407 | * @param {Array} args 408 | * @param {Function} callback 409 | * @returns {*} 410 | * @private 411 | */ 412 | Monitor.prototype._phantom = function(args, callback){ 413 | var arr = []; 414 | _.map(this.options.cli, function(key, value){ 415 | arr.push(key + '=' + value); 416 | }); 417 | this.emit('debug', 'cli arguments: ' + JSON.stringify(arr)); 418 | arr = arr.concat(args); 419 | var proc = spawn(binPath, arr); 420 | proc.stdout.on('data', this._parseLog.bind(this)); 421 | proc.stderr.on('data', this._parseLog.bind(this)); 422 | proc.on('exit', function(code){ 423 | callback(code); 424 | }); 425 | return proc; 426 | }; 427 | 428 | /** 429 | * parse log from phantom 430 | * @param {String} msg 431 | * @private 432 | */ 433 | Monitor.prototype._parseLog = function(msg){ 434 | var self = this; 435 | String(msg || '').split(LOG_SPLIT_REG).forEach(function(item){ 436 | item = item.trim(); 437 | if(item){ 438 | var type = 'debug'; 439 | item = item.replace(LOG_TYPE_REG, function(m, $1){ 440 | type = LOG_VALUE_MAP[$1] || type; 441 | return ''; 442 | }); 443 | self.emit(type, item); 444 | if(self.log.hasOwnProperty(type)){ 445 | self.log[type].push(item); 446 | } 447 | } 448 | }); 449 | }; 450 | 451 | module.exports = Monitor; -------------------------------------------------------------------------------- /phantomjs/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * log 3 | * @param {string} msg 4 | * @param {number} type 5 | */ 6 | var log = function(msg, type){ 7 | type = type || _.log.DEBUG; 8 | console.log(type + msg); 9 | }; 10 | 11 | // on error 12 | phantom.onError = function(msg, trace) { 13 | var msgStack = [ msg ]; 14 | if (trace && trace.length) { 15 | msgStack.push('TRACE:'); 16 | trace.forEach(function(t) { 17 | msgStack.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function +')' : '')); 18 | }); 19 | } 20 | log(msgStack.join('\n'), _.log.ERROR); 21 | phantom.exit(1); 22 | }; 23 | 24 | // deps 25 | var system = require('system'); 26 | var webpage = require('webpage'); 27 | var fs = require('fs'); 28 | var os = system.os; 29 | var IS_WIN = os.name.toLocaleLowerCase() === 'windows'; 30 | 31 | var _ = require('../util.js'); 32 | var diff = require('./diff.js'); 33 | var walk = require('./walk.js'); 34 | var highlight = require('./highlight.js'); 35 | 36 | // generate communication token 37 | var TOKEN = _.unique(); 38 | 39 | // constant values 40 | var LATEST_LOG_FILENAME = 'latest.log'; 41 | var SCREENSHOT_FILENAME = 'screenshot'; 42 | var INFO_FILENAME = 'info.json'; 43 | var TREE_FILENAME = 'tree.json'; 44 | var HIGHLIGHT_HTML_FILENAME = 'highlight.html'; 45 | 46 | /** 47 | * configure phantomjs webpage settings 48 | * @param {webpage} page 49 | * @param {*} options 50 | */ 51 | function settings(page, options){ 52 | _.map(options, function(key, value){ 53 | if(key === 'settings'){ 54 | _.map(value, function(key, value){ 55 | page.settings[key] = value; 56 | log('page.settings.' + key + ' = ' + JSON.stringify(value)); 57 | }); 58 | } else { 59 | page[key] = value; 60 | log('page.' + key + ' = ' + JSON.stringify(value)); 61 | } 62 | }); 63 | } 64 | 65 | /** 66 | * eval script in webpage 67 | * @param {webpage} page 68 | * @param {Function} fn 69 | * @param {Array} args 70 | * @returns {object} 71 | */ 72 | function evaluate(page, fn, args){ 73 | var type = typeof fn; 74 | var arr = []; 75 | switch (type){ 76 | case 'string': 77 | fn = eval('(' + fn + ')'); 78 | break; 79 | case 'function': 80 | // do nothing 81 | break; 82 | default : 83 | // TODO 84 | return; 85 | } 86 | arr.push(fn); 87 | arr.push(TOKEN); 88 | arr = arr.concat(args || []); 89 | return page.evaluate.apply(page, arr); 90 | } 91 | 92 | /** 93 | * create webpage and bind events 94 | * @param {string} url 95 | * @param {object} options 96 | * @param {Function} onload 97 | */ 98 | function createPage(url, options, onload){ 99 | var page = webpage.create(); 100 | 101 | // remove application cache db 102 | // @see https://github.com/fouber/page-monitor/issues/3 103 | if(options.cleanApplicationCache){ 104 | var path = page.offlineStoragePath + '/ApplicationCache.db'; 105 | if(fs.isFile(path)){ 106 | if(fs.remove(path) === false){ 107 | log('unable to remove application cache [' + path + ']', _.log.WARNING); 108 | } else { 109 | log('removed application cache [' + path + ']'); 110 | } 111 | } 112 | } 113 | 114 | var timer, count = 0, outTimer, 115 | delay = options.render.delay; 116 | var done = function(){ 117 | clearTimeout(timer); 118 | clearTimeout(outTimer); 119 | callback = function(){}; 120 | onload(page); 121 | }; 122 | var callback = function(){ 123 | clearTimeout(timer); 124 | if(count === 0){ 125 | timer = setTimeout(done, delay); 126 | } 127 | }; 128 | settings(page, options.page); 129 | page.settings.webSecurityEnabled = false; 130 | page.onLoadStarted = function(){ 131 | if(page.url && page.url !== 'about:blank'){ 132 | count++; 133 | //console.log('* [' + count + ']' + page.url); 134 | callback(); 135 | } 136 | }; 137 | //page.onloadFinished = function(status){ 138 | // if(status === 'success'){ 139 | // callback(); 140 | // } else { 141 | // log('load page error [' + status + ']', _.log.ERROR); 142 | // phantom.exit(1); 143 | // } 144 | //}; 145 | page.onResourceRequested = function(req){ 146 | count++; 147 | // console.log('+ [' + count + ']' + req.url); 148 | // log('+ [' + count + ']' + req.url); 149 | callback(); 150 | }; 151 | page.onResourceReceived = function(res){ 152 | if(res.stage === 'end'){ 153 | count--; 154 | // log('- [' + count + ']' + res.url); 155 | callback(); 156 | } 157 | }; 158 | page.onResourceTimeout = function(req){ 159 | count--; 160 | log('resource [' + req.url + '] timeout', _.log.WARNING); 161 | callback(); 162 | }; 163 | page.onResourceError = function(req){ 164 | log('resource [' + req.url + '] error', _.log.WARNING); 165 | page.errorReason = req.errorString; 166 | callback(); 167 | }; 168 | page.onError = function(msg, trace){ 169 | var msgStack = [ msg ]; 170 | if (trace && trace.length) { 171 | msgStack.push('TRACE:'); 172 | trace.forEach(function(t) { 173 | msgStack.push(' -> ' + t.file + ': ' + t.line + (t.function ? ' (in function "' + t.function +'")' : '')); 174 | }); 175 | } 176 | log(msgStack.join('\n'), _.log.ERROR); 177 | }; 178 | page.onInitialized = function() { 179 | if(options.events){ 180 | evaluate(page, options.events.init); 181 | } 182 | }; 183 | page.onConsoleMessage = function(msg){ 184 | if(msg.substring(0, TOKEN.length) === TOKEN){ 185 | log(msg.substring(TOKEN.length)); 186 | } else { 187 | log(msg, _.log.NOTICE); 188 | } 189 | }; 190 | page.open(url, function(status){ 191 | if(status === 'success'){ 192 | callback(); 193 | } else { 194 | log('load page error [' + page.errorReason + ']', _.log.ERROR); 195 | phantom.exit(1); 196 | } 197 | }); 198 | var timeout = options.render.timeout; 199 | if(timeout){ 200 | outTimer = setTimeout(function(){ 201 | log('render timeout [' + timeout + ']ms', _.log.ERROR); 202 | done(); 203 | }, timeout); 204 | } 205 | return page; 206 | } 207 | 208 | /** 209 | * Constructor 210 | * @param {object} options 211 | * @constructor 212 | */ 213 | var M = function(options){ 214 | this.token = TOKEN; 215 | this.options = options; 216 | this.options.diff.changeType = { 217 | ADD: 1, // 0001 218 | REMOVE: 2, // 0010 219 | STYLE: 4, // 0100 220 | TEXT: 8 // 1000 221 | }; 222 | this.root = options.path.dir; 223 | this.latest = this.root + '/' + LATEST_LOG_FILENAME; 224 | }; 225 | 226 | /** 227 | * get info of the latest save 228 | * @returns {object|boolean} 229 | */ 230 | M.prototype.getLatestTree = function(){ 231 | if(fs.exists(this.latest)){ 232 | var time = fs.read(this.latest).trim(); 233 | if(time){ 234 | var tree = this.root + '/' + time + '/' + TREE_FILENAME; 235 | if(fs.exists(tree)){ 236 | var content = fs.read(tree).trim(); 237 | return { 238 | time: time, 239 | file: tree, 240 | content: content 241 | }; 242 | } 243 | } 244 | } 245 | return false; 246 | }; 247 | 248 | var FORMAT_MAP = { 249 | png : 'png', 250 | gif : 'gif', 251 | jpeg : 'jpeg', 252 | jpg : 'jpeg', 253 | pdf : 'pdf' 254 | }; 255 | 256 | /** 257 | * get render options 258 | * @returns {{ext: string, format: 'png'|'gif'|'jpeg'|'pdf', quality: number}} 259 | */ 260 | M.prototype.getRenderOptions = function(){ 261 | var render = this.options.render || {}; 262 | var f = String(render.format).toLowerCase(); 263 | var format = FORMAT_MAP[f] || 'png'; 264 | var quality = render.quality || 80; 265 | var ext = (render.ext || f).toLowerCase(); 266 | return { 267 | ext: ext, 268 | format: format, 269 | quality: quality 270 | }; 271 | }; 272 | 273 | /** 274 | * save capture 275 | * @param {webpage} page 276 | * @param {string} url 277 | * @param {string|object} tree 278 | * @param {array} rect 279 | * @param {string|number} time 280 | * @returns {{time: number, dir: string, screenshot: string}} 281 | */ 282 | M.prototype.save = function(page, url, tree, rect, time){ 283 | time = time || Date.now(); 284 | if(_.is(tree, 'Object')){ 285 | tree = JSON.stringify(tree); 286 | } 287 | var dir = this.root + '/' + time; 288 | if(fs.makeDirectory(dir)){ 289 | log('save capture [' + dir + ']'); 290 | var opt = this.getRenderOptions(); 291 | var screenshot = dir + '/' + SCREENSHOT_FILENAME + '.' + opt.ext; 292 | log('screenshot [' + screenshot + ']'); 293 | page.evaluate(function(){ 294 | var elem = document.documentElement; 295 | elem.style.backgroundColor = '#fff'; 296 | }); 297 | page.clipRect = { 298 | left: rect[0], 299 | top: rect[1], 300 | width: rect[2], 301 | height: rect[3] 302 | }; 303 | page.render(screenshot, opt); 304 | fs.write(dir + '/' + TREE_FILENAME, tree); 305 | fs.write(dir + '/' + INFO_FILENAME, JSON.stringify({ 306 | time: time, 307 | url: url 308 | })); 309 | fs.write(this.latest, time); 310 | page.close(); 311 | return { 312 | time: time, 313 | dir: dir, 314 | screenshot: screenshot 315 | }; 316 | } else { 317 | throw new Error('unable to make directory[' + dir + ']'); 318 | } 319 | }; 320 | 321 | /** 322 | * highlight the changes 323 | * @param {string|number} left 324 | * @param {string|number} right 325 | * @param {Array} diff 326 | * @param {Array} lOffset 327 | * @param {Array} rOffset 328 | * @param {Function} callback 329 | */ 330 | M.prototype.highlight = function(left, right, diff, lOffset, rOffset, callback){ 331 | log('diff [' + left + '] width [' + right + ']'); 332 | log('has [' + diff.length + '] changes'); 333 | var render = this.getRenderOptions(); 334 | var protocol = 'file://' + (IS_WIN ? '/' : ''); 335 | var lScreenshot = protocol + this.root + '/' + left + '/' + SCREENSHOT_FILENAME + '.' + render.ext; 336 | var rScreenshot = protocol + this.root + '/' + right + '/' + SCREENSHOT_FILENAME + '.' + render.ext; 337 | var dScreenshot = this.root + '/diff/' + left + '-' + right + '.' + render.ext; 338 | var html = phantom.libraryPath + '/' + HIGHLIGHT_HTML_FILENAME; 339 | var url = protocol + html + '?'; 340 | var opt = { 341 | page : { 342 | settings: { 343 | localToRemoteUrlAccessEnabled: true, 344 | webSecurityEnabled: false 345 | } 346 | }, 347 | render: { 348 | delay: 1000 349 | } 350 | }; 351 | url += [ 352 | lScreenshot, rScreenshot, 353 | _.getTimeString(left), _.getTimeString(right) 354 | ].join('|'); 355 | log('start highlight [' + url + ']'); 356 | var self = this, options = self.options; 357 | createPage(url, opt, function(page){ 358 | log('highlight done'); 359 | var info = { 360 | left: left, 361 | right: right, 362 | screenshot: dScreenshot, 363 | count: page.evaluate(highlight, self.token, diff, lOffset, rOffset, options.diff) 364 | }; 365 | setTimeout(function(){ 366 | page.render(dScreenshot, render); 367 | callback(info); 368 | }, 200); 369 | }); 370 | }; 371 | 372 | /** 373 | * page capture 374 | * @param {string} url 375 | * @param {boolean} needDiff 376 | */ 377 | M.prototype.capture = function(url, needDiff){ 378 | if(needDiff) log('need diff'); 379 | var self = this, 380 | options = self.options; 381 | log('loading: ' + url); 382 | createPage(url, options, function(page){ 383 | log('loaded: ' + url); 384 | page.navigationLocked = true; 385 | var delay = evaluate(page, options.events.beforeWalk) || 0; 386 | log('delay before render: ' + delay + 'ms'); 387 | setTimeout(function(){ // delay 388 | log('walk tree'); 389 | var right = page.evaluate(walk, self.token, options.walk); //walk tree 390 | var rect = right.rect; 391 | var json = JSON.stringify(right); 392 | var latest = self.getLatestTree(); 393 | if(latest.content === json){ 394 | log('no change'); 395 | phantom.exit(); 396 | } else if(latest === false || !needDiff) { 397 | self.save(page, url, json, rect); 398 | phantom.exit(); 399 | } else { 400 | var left = JSON.parse(latest.content); 401 | right = JSON.parse(json); 402 | var ret = diff(left, right, options.diff); 403 | if(ret.length){ 404 | var now = Date.now(); 405 | var info = self.save(page, url, json, rect, now); 406 | var lOffset = { x: left.rect[0], y: left.rect[1] }; 407 | var rOffset = { x: right.rect[0], y: right.rect[1] }; 408 | self.highlight(latest.time, now, ret, lOffset, rOffset, function(diff){ 409 | info.diff = diff; 410 | log(JSON.stringify(info), _.log.INFO); 411 | phantom.exit(); 412 | }); 413 | } else { 414 | log('no change'); 415 | phantom.exit(); 416 | } 417 | } 418 | }, delay); 419 | }); 420 | }; 421 | 422 | /** 423 | * get tree object by time 424 | * @param {string|number} time 425 | * @returns {object|undefined} 426 | */ 427 | M.prototype.getTree = function(time){ 428 | var file = this.root + '/' + time + '/' + TREE_FILENAME; 429 | if(fs.exists(file)){ 430 | return JSON.parse(fs.read(file)); 431 | } 432 | }; 433 | 434 | /** 435 | * page diff 436 | * @param {string|number} left 437 | * @param {string|number} right 438 | */ 439 | M.prototype.diff = function(left, right){ 440 | var self = this; 441 | var options = self.options; 442 | var lTree = this.getTree(left); 443 | var rTree = this.getTree(right); 444 | if(lTree && rTree){ 445 | var ret = diff(lTree, rTree, options.diff); 446 | if(ret.length){ 447 | var lOffset = { x: lTree.rect[0], y: lTree.rect[1] }; 448 | var rOffset = { x: rTree.rect[0], y: rTree.rect[1] }; 449 | self.highlight(left, right, ret, lOffset, rOffset, function(diff){ 450 | var info = {diff: diff}; 451 | log(JSON.stringify(info), _.log.INFO); 452 | phantom.exit(); 453 | }); 454 | } else { 455 | log('no change', _.log.WARNING); 456 | phantom.exit(); 457 | } 458 | } else if(lTree){ 459 | throw new Error('missing right record [' + right + ']'); 460 | } else { 461 | throw new Error('missing left record [' + right + ']'); 462 | } 463 | }; 464 | 465 | // run mode, see _.mode@../utils.js 466 | var mode = parseInt(system.args[1]); 467 | log('mode: ' + mode.toString(2)); 468 | 469 | if(mode & _.mode.CAPTURE){ // capture 470 | var m = new M(JSON.parse(system.args[3])); 471 | m.capture(system.args[2], (mode & _.mode.DIFF) > 0); 472 | } else if(mode & _.mode.DIFF){ // diff only 473 | m = new M(JSON.parse(system.args[4])); 474 | m.diff(system.args[2], system.args[3]); 475 | } --------------------------------------------------------------------------------