1 // Copyright (c) 2012 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 /** 6 * Implements the NavigationCollector object that powers the extension. 7 * 8 * @author mkwst (a] google.com (Mike West) 9 */ 10 11 /** 12 * Collects navigation events, and provides a list of successful requests 13 * that you can do interesting things with. Calling the constructor will 14 * automatically bind handlers to the relevant webnavigation API events, 15 * and to a `getMostRequestedUrls` extension message for internal 16 * communication between background pages and popups. 17 * 18 * @constructor 19 */ 20 function NavigationCollector() { 21 /** 22 * A list of currently pending requests, implemented as a hash of each 23 * request's tab ID, frame ID, and URL in order to ensure uniqueness. 24 * 25 * @type {Object.<string, {start: number}>} 26 * @private 27 */ 28 this.pending_ = {}; 29 30 /** 31 * A list of completed requests, implemented as a hash of each 32 * request's tab ID, frame ID, and URL in order to ensure uniqueness. 33 * 34 * @type {Object.<string, Array.<NavigationCollector.Request>>} 35 * @private 36 */ 37 this.completed_ = {}; 38 39 /** 40 * A list of requests that errored off, implemented as a hash of each 41 * request's tab ID, frame ID, and URL in order to ensure uniqueness. 42 * 43 * @type {Object.<string, Array.<NavigationCollector.Request>>} 44 * @private 45 */ 46 this.errored_ = {}; 47 48 // Bind handlers to the 'webNavigation' events that we're interested 49 // in handling in order to build up a complete picture of the whole 50 // navigation event. 51 chrome.webNavigation.onCreatedNavigationTarget.addListener( 52 this.onCreatedNavigationTargetListener_.bind(this)); 53 chrome.webNavigation.onBeforeNavigate.addListener( 54 this.onBeforeNavigateListener_.bind(this)); 55 chrome.webNavigation.onCompleted.addListener( 56 this.onCompletedListener_.bind(this)); 57 chrome.webNavigation.onCommitted.addListener( 58 this.onCommittedListener_.bind(this)); 59 chrome.webNavigation.onErrorOccurred.addListener( 60 this.onErrorOccurredListener_.bind(this)); 61 chrome.webNavigation.onReferenceFragmentUpdated.addListener( 62 this.onReferenceFragmentUpdatedListener_.bind(this)); 63 chrome.webNavigation.onHistoryStateUpdated.addListener( 64 this.onHistoryStateUpdatedListener_.bind(this)); 65 66 // Bind handler to extension messages for communication from popup. 67 chrome.extension.onRequest.addListener(this.onRequestListener_.bind(this)); 68 69 this.loadDataStorage_(); 70 } 71 72 /////////////////////////////////////////////////////////////////////////////// 73 74 /** 75 * The possible transition types that explain how the navigation event 76 * was generated (i.e. "The user clicked on a link." or "The user submitted 77 * a form"). 78 * 79 * @see http://code.google.com/chrome/extensions/trunk/history.html 80 * @enum {string} 81 */ 82 NavigationCollector.NavigationType = { 83 AUTO_BOOKMARK: 'auto_bookmark', 84 AUTO_SUBFRAME: 'auto_subframe', 85 FORM_SUBMIT: 'form_submit', 86 GENERATED: 'generated', 87 KEYWORD: 'keyword', 88 KEYWORD_GENERATED: 'keyword_generated', 89 LINK: 'link', 90 MANUAL_SUBFRAME: 'manual_subframe', 91 RELOAD: 'reload', 92 START_PAGE: 'start_page', 93 TYPED: 'typed' 94 }; 95 96 /** 97 * The possible transition qualifiers: 98 * 99 * * CLIENT_REDIRECT: Redirects caused by JavaScript, or a refresh meta tag 100 * on a page. 101 * 102 * * SERVER_REDIRECT: Redirected by the server via a 301/302 response. 103 * 104 * * FORWARD_BACK: User used the forward or back buttons to navigate through 105 * her browsing history. 106 * 107 * @enum {string} 108 */ 109 NavigationCollector.NavigationQualifier = { 110 CLIENT_REDIRECT: 'client_redirect', 111 FORWARD_BACK: 'forward_back', 112 SERVER_REDIRECT: 'server_redirect' 113 }; 114 115 /** 116 * @typedef {{url: string, transitionType: NavigationCollector.NavigationType, 117 * transitionQualifier: Array.<NavigationCollector.NavigationQualifier>, 118 * openedInNewTab: boolean, source: {frameId: ?number, tabId: ?number}, 119 * duration: number}} 120 */ 121 NavigationCollector.Request; 122 123 /////////////////////////////////////////////////////////////////////////////// 124 125 NavigationCollector.prototype = { 126 /** 127 * Returns a somewhat unique ID for a given WebNavigation request. 128 * 129 * @param {!{tabId: ?number, frameId: ?number}} data Information 130 * about the navigation event we'd like an ID for. 131 * @return {!string} ID created by combining the source tab ID and frame ID 132 * (or target tab/frame IDs if there's no source), as the API ensures 133 * that these will be unique across a single navigation event. 134 * @private 135 */ 136 parseId_: function(data) { 137 return data.tabId + '-' + (data.frameId ? data.frameId : 0); 138 }, 139 140 141 /** 142 * Creates an empty entry in the pending array if one doesn't already exist, 143 * and prepopulates the errored and completed arrays for ease of insertion 144 * later. 145 * 146 * @param {!string} id The request's ID, as produced by parseId_. 147 * @param {!string} url The request's URL. 148 */ 149 prepareDataStorage_: function(id, url) { 150 this.pending_[id] = this.pending_[id] || { 151 openedInNewTab: false, 152 source: { 153 frameId: null, 154 tabId: null 155 }, 156 start: null, 157 transitionQualifiers: [], 158 transitionType: null 159 }; 160 this.completed_[url] = this.completed_[url] || []; 161 this.errored_[url] = this.errored_[url] || []; 162 }, 163 164 165 /** 166 * Retrieves our saved data from storage. 167 * @private 168 */ 169 loadDataStorage_: function() { 170 chrome.storage.local.get({ 171 "completed": {}, 172 "errored": {}, 173 }, function(storage) { 174 this.completed_ = storage.completed; 175 this.errored_ = storage.errored; 176 }.bind(this)); 177 }, 178 179 180 /** 181 * Persists our state to the storage API. 182 * @private 183 */ 184 saveDataStorage_: function() { 185 chrome.storage.local.set({ 186 "completed": this.completed_, 187 "errored": this.errored_, 188 }); 189 }, 190 191 192 /** 193 * Resets our saved state to empty. 194 */ 195 resetDataStorage: function() { 196 this.completed_ = {}; 197 this.errored_ = {}; 198 this.saveDataStorage_(); 199 // Load again, in case there is an outstanding storage.get request. This 200 // one will reload the newly-cleared data. 201 this.loadDataStorage_(); 202 }, 203 204 205 /** 206 * Handler for the 'onCreatedNavigationTarget' event. Updates the 207 * pending request with a source frame/tab, and notes that it was opened in a 208 * new tab. 209 * 210 * Pushes the request onto the 211 * 'pending_' object, and stores it for later use. 212 * 213 * @param {!Object} data The event data generated for this request. 214 * @private 215 */ 216 onCreatedNavigationTargetListener_: function(data) { 217 var id = this.parseId_(data); 218 this.prepareDataStorage_(id, data.url); 219 this.pending_[id].openedInNewTab = data.tabId; 220 this.pending_[id].source = { 221 tabId: data.sourceTabId, 222 frameId: data.sourceFrameId 223 }; 224 this.pending_[id].start = data.timeStamp; 225 }, 226 227 228 /** 229 * Handler for the 'onBeforeNavigate' event. Pushes the request onto the 230 * 'pending_' object, and stores it for later use. 231 * 232 * @param {!Object} data The event data generated for this request. 233 * @private 234 */ 235 onBeforeNavigateListener_: function(data) { 236 var id = this.parseId_(data); 237 this.prepareDataStorage_(id, data.url); 238 this.pending_[id].start = this.pending_[id].start || data.timeStamp; 239 }, 240 241 242 /** 243 * Handler for the 'onCommitted' event. Updates the pending request with 244 * transition information. 245 * 246 * Pushes the request onto the 247 * 'pending_' object, and stores it for later use. 248 * 249 * @param {!Object} data The event data generated for this request. 250 * @private 251 */ 252 onCommittedListener_: function(data) { 253 var id = this.parseId_(data); 254 if (!this.pending_[id]) { 255 console.warn( 256 chrome.i18n.getMessage('errorCommittedWithoutPending'), 257 data.url, 258 data); 259 } else { 260 this.prepareDataStorage_(id, data.url); 261 this.pending_[id].transitionType = data.transitionType; 262 this.pending_[id].transitionQualifiers = 263 data.transitionQualifiers; 264 } 265 }, 266 267 268 /** 269 * Handler for the 'onReferenceFragmentUpdated' event. Updates the pending 270 * request with transition information. 271 * 272 * Pushes the request onto the 273 * 'pending_' object, and stores it for later use. 274 * 275 * @param {!Object} data The event data generated for this request. 276 * @private 277 */ 278 onReferenceFragmentUpdatedListener_: function(data) { 279 var id = this.parseId_(data); 280 if (!this.pending_[id]) { 281 this.completed_[data.url] = this.completed_[data.url] || []; 282 this.completed_[data.url].push({ 283 duration: 0, 284 openedInNewWindow: false, 285 source: { 286 frameId: null, 287 tabId: null 288 }, 289 transitionQualifiers: data.transitionQualifiers, 290 transitionType: data.transitionType, 291 url: data.url 292 }); 293 this.saveDataStorage_(); 294 } else { 295 this.prepareDataStorage_(id, data.url); 296 this.pending_[id].transitionType = data.transitionType; 297 this.pending_[id].transitionQualifiers = 298 data.transitionQualifiers; 299 } 300 }, 301 302 303 /** 304 * Handler for the 'onHistoryStateUpdated' event. Updates the pending 305 * request with transition information. 306 * 307 * Pushes the request onto the 308 * 'pending_' object, and stores it for later use. 309 * 310 * @param {!Object} data The event data generated for this request. 311 * @private 312 */ 313 onHistoryStateUpdatedListener_: function(data) { 314 var id = this.parseId_(data); 315 if (!this.pending_[id]) { 316 this.completed_[data.url] = this.completed_[data.url] || []; 317 this.completed_[data.url].push({ 318 duration: 0, 319 openedInNewWindow: false, 320 source: { 321 frameId: null, 322 tabId: null 323 }, 324 transitionQualifiers: data.transitionQualifiers, 325 transitionType: data.transitionType, 326 url: data.url 327 }); 328 this.saveDataStorage_(); 329 } else { 330 this.prepareDataStorage_(id, data.url); 331 this.pending_[id].transitionType = data.transitionType; 332 this.pending_[id].transitionQualifiers = 333 data.transitionQualifiers; 334 } 335 }, 336 337 338 /** 339 * Handler for the 'onCompleted` event. Pulls the request's data from the 340 * 'pending_' object, combines it with the completed event's data, and pushes 341 * a new NavigationCollector.Request object onto 'completed_'. 342 * 343 * @param {!Object} data The event data generated for this request. 344 * @private 345 */ 346 onCompletedListener_: function(data) { 347 var id = this.parseId_(data); 348 if (!this.pending_[id]) { 349 console.warn( 350 chrome.i18n.getMessage('errorCompletedWithoutPending'), 351 data.url, 352 data); 353 } else { 354 this.completed_[data.url].push({ 355 duration: (data.timeStamp - this.pending_[id].start), 356 openedInNewWindow: this.pending_[id].openedInNewWindow, 357 source: this.pending_[id].source, 358 transitionQualifiers: this.pending_[id].transitionQualifiers, 359 transitionType: this.pending_[id].transitionType, 360 url: data.url 361 }); 362 delete this.pending_[id]; 363 this.saveDataStorage_(); 364 } 365 }, 366 367 368 /** 369 * Handler for the 'onErrorOccurred` event. Pulls the request's data from the 370 * 'pending_' object, combines it with the completed event's data, and pushes 371 * a new NavigationCollector.Request object onto 'errored_'. 372 * 373 * @param {!Object} data The event data generated for this request. 374 * @private 375 */ 376 onErrorOccurredListener_: function(data) { 377 var id = this.parseId_(data); 378 if (!this.pending_[id]) { 379 console.error( 380 chrome.i18n.getMessage('errorErrorOccurredWithoutPending'), 381 data.url, 382 data); 383 } else { 384 this.prepareDataStorage_(id, data.url); 385 this.errored_[data.url].push({ 386 duration: (data.timeStamp - this.pending_[id].start), 387 openedInNewWindow: this.pending_[id].openedInNewWindow, 388 source: this.pending_[id].source, 389 transitionQualifiers: this.pending_[id].transitionQualifiers, 390 transitionType: this.pending_[id].transitionType, 391 url: data.url 392 }); 393 delete this.pending_[id]; 394 this.saveDataStorage_(); 395 } 396 }, 397 398 /** 399 * Handle request messages from the popup. 400 * 401 * @param {!{type:string}} request The external request to answer. 402 * @param {!MessageSender} sender Info about the script context that sent 403 * the request. 404 * @param {!function} sendResponse Function to call to send a response. 405 * @private 406 */ 407 onRequestListener_: function(request, sender, sendResponse) { 408 if (request.type === 'getMostRequestedUrls') 409 sendResponse({result: this.getMostRequestedUrls(request.num)}); 410 else 411 sendResponse({}); 412 }, 413 414 /////////////////////////////////////////////////////////////////////////////// 415 416 /** 417 * @return {Object.<string, NavigationCollector.Request>} The complete list of 418 * successful navigation requests. 419 */ 420 get completed() { 421 return this.completed_; 422 }, 423 424 425 /** 426 * @return {Object.<string, Navigationcollector.Request>} The complete list of 427 * unsuccessful navigation requests. 428 */ 429 get errored() { 430 return this.errored_; 431 }, 432 433 434 /** 435 * Get a list of the X most requested URLs. 436 * 437 * @param {number=} num The number of successful navigation requests to 438 * return. If 0 is passed in, or the argument left off entirely, all 439 * successful requests are returned. 440 * @return {Object.<string, NavigationCollector.Request>} The list of 441 * successful navigation requests, sorted in decending order of frequency. 442 */ 443 getMostRequestedUrls: function(num) { 444 return this.getMostFrequentUrls_(this.completed, num); 445 }, 446 447 448 /** 449 * Get a list of the X most errored URLs. 450 * 451 * @param {number=} num The number of unsuccessful navigation requests to 452 * return. If 0 is passed in, or the argument left off entirely, all 453 * successful requests are returned. 454 * @return {Object.<string, NavigationCollector.Request>} The list of 455 * unsuccessful navigation requests, sorted in decending order 456 * of frequency. 457 */ 458 getMostErroredUrls: function(num) { 459 return this.getMostErroredUrls_(this.errored, num); 460 }, 461 462 463 /** 464 * Get a list of the most frequent URLs in a list. 465 * 466 * @param {NavigationCollector.Request} list A list of URLs to parse. 467 * @param {number=} num The number of navigation requests to return. If 468 * 0 is passed in, or the argument left off entirely, all requests 469 * are returned. 470 * @return {Object.<string, NavigationCollector.Request>} The list of 471 * navigation requests, sorted in decending order of frequency. 472 * @private 473 */ 474 getMostFrequentUrls_: function(list, num) { 475 var result = []; 476 var avg; 477 // Convert the 'completed_' object to an array. 478 for (var x in list) { 479 avg = 0; 480 if (list.hasOwnProperty(x) && list[x].length) { 481 list[x].forEach(function(o) { 482 avg += o.duration; 483 }); 484 avg = avg / list[x].length; 485 result.push({ 486 url: x, 487 numRequests: list[x].length, 488 requestList: list[x], 489 average: avg 490 }); 491 } 492 } 493 // Sort the array. 494 result.sort(function(a, b) { 495 return b.numRequests - a.numRequests; 496 }); 497 // Return the requested number of results. 498 return num ? result.slice(0, num) : result; 499 } 500 }; 501