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@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 */
20function 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 */
82NavigationCollector.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 */
109NavigationCollector.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 */
121NavigationCollector.Request;
122
123///////////////////////////////////////////////////////////////////////////////
124
125NavigationCollector.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