1// Copyright (c) 2011 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.experimental.webNavigation.onBeforeRetarget.addListener(
52      this.onBeforeRetargetListener_.bind(this));
53  chrome.experimental.webNavigation.onBeforeNavigate.addListener(
54      this.onBeforeNavigateListener_.bind(this));
55  chrome.experimental.webNavigation.onCompleted.addListener(
56      this.onCompletedListener_.bind(this));
57  chrome.experimental.webNavigation.onCommitted.addListener(
58      this.onCommittedListener_.bind(this));
59  chrome.experimental.webNavigation.onErrorOccurred.addListener(
60      this.onErrorOccurredListener_.bind(this));
61
62  // Bind handler to extension messages for communication from popup.
63  chrome.extension.onRequest.addListener(this.onRequestListener_.bind(this));
64}
65
66///////////////////////////////////////////////////////////////////////////////
67
68/**
69 * The possible transition types that explain how the navigation event
70 * was generated (i.e. "The user clicked on a link." or "The user submitted
71 * a form").
72 *
73 * @see http://code.google.com/chrome/extensions/trunk/history.html
74 * @enum {string}
75 */
76NavigationCollector.NavigationType = {
77  AUTO_BOOKMARK: 'auto_bookmark',
78  AUTO_SUBFRAME: 'auto_subframe',
79  FORM_SUBMIT: 'form_submit',
80  GENERATED: 'generated',
81  KEYWORD: 'keyword',
82  KEYWORD_GENERATED: 'keyword_generated',
83  LINK: 'link',
84  MANUAL_SUBFRAME: 'manual_subframe',
85  RELOAD: 'reload',
86  START_PAGE: 'start_page',
87  TYPED: 'typed'
88};
89
90/**
91 * The possible transition qualifiers:
92 *
93 * * CLIENT_REDIRECT: Redirects caused by JavaScript, or a refresh meta tag
94 *   on a page.
95 *
96 * * SERVER_REDIRECT: Redirected by the server via a 301/302 response.
97 *
98 * * FORWARD_BACK: User used the forward or back buttons to navigate through
99 *   her browsing history.
100 *
101 * @enum {string}
102 */
103NavigationCollector.NavigationQualifier = {
104  CLIENT_REDIRECT: 'client_redirect',
105  FORWARD_BACK: 'forward_back',
106  SERVER_REDIRECT: 'server_redirect'
107};
108
109/**
110 * @typedef {{url: string, transitionType: NavigationCollector.NavigationType,
111 *     transitionQualifier: Array.<NavigationCollector.NavigationQualifier>,
112 *     openedInNewTab: boolean, sourceUrl: ?string, duration: number}}
113 */
114NavigationCollector.Request;
115
116///////////////////////////////////////////////////////////////////////////////
117
118NavigationCollector.prototype = {
119  /**
120   * Returns a somewhat unique ID for a given WebNavigation request.
121   *
122   * @param {!{tabId: number, frameId: number, url: string}} data Information
123   *     about the navigation event we'd like an ID for.
124   * @return {!string} ID created by combining the tab ID and frame ID (as the
125   *     API ensures that these will be unique across a single navigation
126   *     event)
127   * @private
128   */
129  parseId_: function(data) {
130    return data.tabId + '-' + data.frameId;
131  },
132
133
134  /**
135   * Creates an empty entry in the pending array, and prepopulates the
136   * errored and completed arrays for ease of insertion later.
137   *
138   * @param {!string} id The request's ID, as produced by parseId_.
139   * @param {!string} url The request's URL.
140   */
141  prepareDataStorage_: function(id, url) {
142    this.pending_[id] = this.pending_[id] || {
143      openedInNewTab: false,
144      sourceUrl: null,
145      start: null,
146      transitionQualifiers: [],
147      transitionType: null
148    };
149    this.completed_[url] = this.completed_[url] || [];
150    this.errored_[url] = this.errored_[url] || [];
151  },
152
153
154  /**
155   * Handler for the 'onBeforeRetarget' event. Updates the pending request
156   * with a sourceUrl, and notes that it was opened in a new tab.
157   *
158   * Pushes the request onto the
159   * 'pending_' object, and stores it for later use.
160   *
161   * @param {!Object} data The event data generated for this request.
162   * @private
163   */
164  onBeforeRetargetListener_: function(data) {
165    var id = this.parseId_(data);
166    this.prepareDataStorage_(id, data.url);
167    this.pending_[id].openedInNewTab = true;
168    this.pending_[id].sourceUrl = data.sourceUrl;
169    this.pending_[id].start = data.timeStamp;
170  },
171
172
173  /**
174   * Handler for the 'onBeforeNavigate' event. Pushes the request onto the
175   * 'pending_' object, and stores it for later use.
176   *
177   * @param {!Object} data The event data generated for this request.
178   * @private
179   */
180  onBeforeNavigateListener_: function(data) {
181    var id = this.parseId_(data);
182    this.prepareDataStorage_(id, data.url);
183    this.pending_[id].start = this.pending_[id].start || data.timeStamp;
184  },
185
186
187  /**
188   * Handler for the 'onCommitted' event. Updates the pending request with
189   * transition information.
190   *
191   * Pushes the request onto the
192   * 'pending_' object, and stores it for later use.
193   *
194   * @param {!Object} data The event data generated for this request.
195   * @private
196   */
197  onCommittedListener_: function(data) {
198    var id = this.parseId_(data);
199    if (!this.pending_[id]) {
200      console.warn(
201          chrome.i18n.getMessage('errorCommittedWithoutPending'),
202          data.url,
203          data);
204    } else {
205      this.pending_[id].transitionType = data.transitionType;
206      this.pending_[id].transitionQualifiers =
207          data.transitionQualifiers;
208    }
209  },
210
211
212  /**
213   * Handler for the 'onCompleted` event. Pulls the request's data from the
214   * 'pending_' object, combines it with the completed event's data, and pushes
215   * a new NavigationCollector.Request object onto 'completed_'.
216   *
217   * @param {!Object} data The event data generated for this request.
218   * @private
219   */
220  onCompletedListener_: function(data) {
221    var id = this.parseId_(data);
222    if (!this.pending_[id]) {
223      console.warn(
224          chrome.i18n.getMessage('errorCompletedWithoutPending'),
225          data.url,
226          data);
227    } else {
228      this.completed_[data.url].push({
229        duration: (data.timeStamp - this.pending_[id].start),
230        openedInNewWindow: this.pending_[id].openedInNewWindow,
231        sourceUrl: this.pending_[id].sourceUrl,
232        transitionQualifiers: this.pending_[id].transitionQualifiers,
233        transitionType: this.pending_[id].transitionType,
234        url: data.url
235      });
236      delete this.pending_[id];
237    }
238  },
239
240
241  /**
242   * Handler for the 'onErrorOccurred` event. Pulls the request's data from the
243   * 'pending_' object, combines it with the completed event's data, and pushes
244   * a new NavigationCollector.Request object onto 'errored_'.
245   *
246   * @param {!Object} data The event data generated for this request.
247   * @private
248   */
249  onErrorOccurredListener_: function(data) {
250    var id = this.parseId_(data);
251    if (!this.pending_[id]) {
252      console.error(
253          chrome.i18n.getMessage('errorErrorOccurredWithoutPending'),
254          data.url,
255          data);
256    } else {
257      this.errored_[data.url].push({
258        duration: (data.timeStamp - this.pending_[id].start),
259        openedInNewWindow: this.pending_[id].openedInNewWindow,
260        sourceUrl: this.pending_[id].sourceUrl,
261        transitionQualifiers: this.pending_[id].transitionQualifiers,
262        transitionType: this.pending_[id].transitionType,
263        url: data.url
264      });
265      delete this.pending_[id];
266    }
267  },
268
269  /**
270   * Handle request messages from the popup.
271   *
272   * @param {!{type:string}} request The external request to answer.
273   * @param {!MessageSender} sender Info about the script context that sent
274   *     the request.
275   * @param {!function} sendResponse Function to call to send a response.
276   * @private
277   */
278  onRequestListener_: function(request, sender, sendResponse) {
279    if (request.type === 'getMostRequestedUrls')
280      sendResponse({result: this.getMostRequestedUrls(request.num)});
281    else
282      sendResponse({});
283  },
284
285///////////////////////////////////////////////////////////////////////////////
286
287  /**
288   * @return {Object.<string, NavigationCollector.Request>} The complete list of
289   *     successful navigation requests.
290   */
291  get completed() {
292    return this.completed_;
293  },
294
295
296  /**
297   * @return {Object.<string, Navigationcollector.Request>} The complete list of
298   *     unsuccessful navigation requests.
299   */
300  get errored() {
301    return this.errored_;
302  },
303
304
305  /**
306   * Get a list of the X most requested URLs.
307   *
308   * @param {number=} num The number of successful navigation requests to
309   *     return. If 0 is passed in, or the argument left off entirely, all
310   *     successful requests are returned.
311   * @return {Object.<string, NavigationCollector.Request>} The list of
312   *     successful navigation requests, sorted in decending order of frequency.
313   */
314  getMostRequestedUrls: function(num) {
315    return this.getMostFrequentUrls_(this.completed, num);
316  },
317
318
319  /**
320   * Get a list of the X most errored URLs.
321   *
322   * @param {number=} num The number of unsuccessful navigation requests to
323   *     return. If 0 is passed in, or the argument left off entirely, all
324   *     successful requests are returned.
325   * @return {Object.<string, NavigationCollector.Request>} The list of
326   *     unsuccessful navigation requests, sorted in decending order
327   *     of frequency.
328   */
329  getMostErroredUrls: function(num) {
330    return this.getMostErroredUrls_(this.errored, num);
331  },
332
333
334  /**
335   * Get a list of the most frequent URLs in a list.
336   *
337   * @param {NavigationCollector.Request} list A list of URLs to parse.
338   * @param {number=} num The number of navigation requests to return. If
339   *     0 is passed in, or the argument left off entirely, all requests
340   *     are returned.
341   * @return {Object.<string, NavigationCollector.Request>} The list of
342   *     navigation requests, sorted in decending order of frequency.
343   * @private
344   */
345  getMostFrequentUrls_: function(list, num) {
346    var result = [];
347    var avg;
348    // Convert the 'completed_' object to an array.
349    for (var x in list) {
350      avg = 0;
351      if (list.hasOwnProperty(x)) {
352        list[x].forEach(function(o) {
353          avg += o.duration;
354        });
355        avg = avg / list[x].length;
356        result.push({
357          url: x,
358          numRequests: list[x].length,
359          requestList: list[x],
360          average: avg
361        });
362      }
363    }
364    // Sort the array.
365    result.sort(function(a, b) {
366      return b.numRequests - a.numRequests;
367    });
368    // Return the requested number of results.
369    return num ? result.slice(0, num) : result;
370  }
371};
372