diff --git a/.nycrc b/.nycrc index bc37942d..3c30c5b8 100644 --- a/.nycrc +++ b/.nycrc @@ -1,4 +1,7 @@ { + "exclude": [ + "src/store/*" + ], "include": [ "src/*" ], diff --git a/README.md b/README.md index 12360865..b2b6a471 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![npm](https://img.shields.io/npm/v/@constructor-io/constructorio-client-javascript)](https://www.npmjs.com/package/@constructor-io/constructorio-client-javascript) ![David (path)](https://img.shields.io/david/Constructor-io/constructorio-client-javascript) -[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Constructor-io/constructorio-client-javascript/blob/master/LICENSE) +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Constructor-io/constructorio-client-javascript/blob/master/LICENSE) A JavaScript client for [Constructor.io](http://constructor.io/). [Constructor.io](http://constructor.io/) provides search as a service that optimizes results using artificial intelligence (including natural language processing, re-ranking to optimize for conversions, and user personalization). @@ -26,7 +26,7 @@ var constructorio = new ConstructorIOClient({ ## 4. Retrieve Results -After instantiating an instance of the client, three modules will be exposed as properties to help retrieve data from Constructor.io: `search`, `autocomplete`, and `recommendations`. +After instantiating an instance of the client, four modules will be exposed as properties to help retrieve data from Constructor.io: `search`, `autocomplete`, `recommendations` and `tracking`. ### Search @@ -130,6 +130,93 @@ constructorio.recommendations.getUserFeaturedItems({ parameters }).then(function | --- | --- | --- | | `results` | number | Number of results to retrieve | +### Tracker + +The tracker module can be used to send tracking events. Returns `true` when successful, or will throw an error if an issue is encountered. + +#### Send autocomplete select event +```javascript +constructorio.tracker.trackAutocompleteSelect('dogs', { + parameters +}); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `original_query` | string | ? | +| `result_id` | string | ? | +| `section` | string | ? | +| `tr` | string | ? | +| `group_id` | string | ? | +| `display_name` | string | ? | + +#### Send autocomplete search event +```javascript +constructorio.tracker.trackSearchSubmit('dogs', { + parameters +}); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `original_query` | string | ? | +| `result_id` | string | ? | +| `group_id` | string | ? | +| `display_name` | string | ? | + +#### Send search results event +```javascript +constructorio.tracker.trackSearchResultsLoaded('dogs', { + parameters +}); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `num_results` | string | ? | +| `customer_ids` | string | ? | + +#### Send search result click event +```javascript +constructorio.tracker.trackSearchResultClick('dogs', { + parameters +}); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `name` | string | ? | +| `customer_id` | string | ? | +| `result_id` | string | ? | + +#### Send conversion event +```javascript +constructorio.tracker.trackConversion('dogs', { + parameters +}); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `name` | string | ? | +| `customer_id` | string | ? | +| `result_id` | string | ? | +| `revenue` | string | ? | +| `section` | string | ? | + +#### Send purchase event +```javascript +constructorio.tracker.trackPurchase({ + parameters +}); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `customer_ids` | string | ? | +| `revenue` | string | ? | +| `section` | string | ? | + ## Development / npm commands ```bash diff --git a/package.json b/package.json index cd06dafc..96d21f85 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "eslint-plugin-import": "^2.18.2", "http-server": "^0.11.1", "jsdoc": "^3.6.3", + "jsdom": "^15.1.1", "minami": "^1.2.3", "mocha": "^6.2.0", "mocha-jsdom": "^2.0.0", @@ -50,6 +51,7 @@ "@constructor-io/constructorio-id": "^2.1.1", "es6-promise": "^4.2.8", "fetch-ponyfill": "^6.1.0", - "qs": "^6.8.0" + "qs": "^6.8.0", + "store2": "^2.9.0" } } diff --git a/spec/mocha.helpers.js b/spec/mocha.helpers.js index 25d4b479..a249ffaa 100644 --- a/spec/mocha.helpers.js +++ b/spec/mocha.helpers.js @@ -1,4 +1,54 @@ const qs = require('qs'); +const { JSDOM } = require('jsdom'); +const store = require('../src/store/store'); + +// Setup mock DOM environment +const setupDOM = () => { + const { window } = new JSDOM(); + + global.window = window; + global.document = window.document; +}; + +// Tear down mock DOM environment +const teardownDOM = () => { + delete global.window; + delete global.document; +}; + +// Trigger browser resize event +const triggerResize = () => { + const resizeEvent = document.createEvent('Event'); + + resizeEvent.initEvent('resize', true, true); + + global.window.resizeTo = (width, height) => { + global.window.innerWidth = width || global.window.innerWidth; + global.window.innerHeight = height || global.window.innerHeight; + global.window.dispatchEvent(resizeEvent); + }; + + window.resizeTo(1024, 768); +}; + +// Trigger browser unload event +const triggerUnload = () => { + const unloadEvent = document.createEvent('Event'); + + unloadEvent.initEvent('beforeunload', true, true); + + global.window.unload = () => { + global.window.dispatchEvent(unloadEvent); + }; + + window.unload(); +}; + +// Clear local and session storage +const clearStorage = () => { + store.local.clearAll(); + store.session.clearAll(); +}; // Extract query parameters as object from url const extractUrlParamsFromFetch = (fetch) => { @@ -14,5 +64,10 @@ const extractUrlParamsFromFetch = (fetch) => { }; module.exports = { + setupDOM, + teardownDOM, + triggerResize, + triggerUnload, + clearStorage, extractUrlParamsFromFetch, }; diff --git a/spec/src/modules/tracker-humanity.js b/spec/src/modules/tracker-humanity.js new file mode 100644 index 00000000..a7a0073e --- /dev/null +++ b/spec/src/modules/tracker-humanity.js @@ -0,0 +1,53 @@ +const dotenv = require('dotenv'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const store = require('../../../src/store/store'); +const trackerHumanity = require('../../../src/modules/tracker-humanity'); +const helpers = require('../../mocha.helpers'); + +chai.use(chaiAsPromised); +dotenv.config(); + +describe('ConstructorIO - Tracker - Humanity', () => { + describe('isHuman', () => { + const storageKey = '_constructorio_is_human'; + + beforeEach(() => { + global.CLIENT_VERSION = 'cio-mocha'; + + helpers.setupDOM(); + }); + + afterEach(() => { + delete global.CLIENT_VERSION; + + helpers.teardownDOM(); + helpers.clearStorage(); + }); + + it('Should not have isHuman flag set on initial instantiation', () => { + const humanity = trackerHumanity(); + + expect(humanity.isHuman()).to.equal(false); + expect(store.session.get(storageKey)).to.equal(null); + }); + + it('Should have isHuman flag set if human-like actions are detected', () => { + const humanity = trackerHumanity(); + + expect(humanity.isHuman()).to.equal(false); + helpers.triggerResize(); + expect(humanity.isHuman()).to.equal(true); + expect(store.session.get(storageKey)).to.equal(true); + }); + + it('Should have isHuman flag set if session variable is set', () => { + const humanity = trackerHumanity(); + + expect(humanity.isHuman()).to.equal(false); + store.session.set(storageKey, true); + expect(humanity.isHuman()).to.equal(true); + expect(store.session.get(storageKey)).to.equal(true); + }); + }); +}); diff --git a/spec/src/modules/tracker-requests.js b/spec/src/modules/tracker-requests.js new file mode 100644 index 00000000..1dd60f45 --- /dev/null +++ b/spec/src/modules/tracker-requests.js @@ -0,0 +1,199 @@ +/* eslint-disable no-restricted-properties, no-underscore-dangle */ +const dotenv = require('dotenv'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const store = require('../../../src/store/store'); +const trackerRequests = require('../../../src/modules/tracker-requests'); +const helpers = require('../../mocha.helpers'); + +chai.use(chaiAsPromised); +dotenv.config(); + +describe('ConstructorIO - Tracker - Requests', () => { + const storageKey = '_constructorio_requests'; + const waitInterval = 500; + + describe('queue', () => { + let defaultAgent; + + beforeEach(() => { + global.CLIENT_VERSION = 'cio-mocha'; + helpers.setupDOM(); + + defaultAgent = window.navigator.userAgent; + }); + + afterEach(() => { + window.navigator.__defineGetter__('userAgent', () => defaultAgent); + window.navigator.__defineGetter__('webdriver', () => undefined); + + delete global.CLIENT_VERSION; + + helpers.teardownDOM(); + helpers.clearStorage(); + }); + + it('Should add requests to the queue and persist on unload event', () => { + const requests = trackerRequests(); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start'); + requests.queue('https://ac.cnstrc.com/behavior?action=focus'); + requests.queue('https://ac.cnstrc.com/behavior?action=magic_number_three'); + + expect(requests.get()).to.be.an('array').length(3); + helpers.triggerUnload(); + expect(store.local.get(storageKey)).to.be.an('array').length(3); + }); + + it('Should not add requests to the queue if the user has a bot-like useragent', () => { + const requests = trackerRequests(); + + window.navigator.__defineGetter__('userAgent', () => 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Safari/537.36'); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start'); + requests.queue('https://ac.cnstrc.com/behavior?action=focus'); + requests.queue('https://ac.cnstrc.com/behavior?action=magic_number_three'); + + expect(requests.get()).to.be.an('array').length(0); + helpers.triggerUnload(); + expect(store.local.get(storageKey)).to.be.an('array').length(0); + }); + + it('Should not add requests to the queue if the user is webdriver', () => { + const requests = trackerRequests(); + + window.navigator.__defineGetter__('webdriver', () => true); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start'); + requests.queue('https://ac.cnstrc.com/behavior?action=focus'); + requests.queue('https://ac.cnstrc.com/behavior?action=magic_number_three'); + + expect(requests.get()).to.be.an('array').length(0); + helpers.triggerUnload(); + expect(store.local.get(storageKey)).to.be.an('array').length(0); + }); + }); + + describe('send', () => { + beforeEach(() => { + global.CLIENT_VERSION = 'cio-mocha'; + + helpers.setupDOM(); + }); + + afterEach(() => { + delete global.CLIENT_VERSION; + + helpers.teardownDOM(); + helpers.clearStorage(); + }); + + it('Should send all tracking requests if queue is populated and user is human', (done) => { + const requests = trackerRequests(); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start'); + requests.queue('https://ac.cnstrc.com/behavior?action=focus'); + requests.queue('https://ac.cnstrc.com/behavior?action=magic_number_three'); + + expect(requests.get()).to.be.an('array').length(3); + helpers.triggerResize(); + requests.send(); + + setTimeout(() => { + expect(requests.get()).to.be.an('array').length(0); + done(); + }, waitInterval); + }); + + it('Should not send tracking requests if queue is populated and user is not human', (done) => { + const requests = trackerRequests(); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start'); + requests.queue('https://ac.cnstrc.com/behavior?action=focus'); + requests.queue('https://ac.cnstrc.com/behavior?action=magic_number_three'); + + expect(requests.get()).to.be.an('array').length(3); + requests.send(); + + setTimeout(() => { + expect(requests.get()).to.be.an('array').length(3); + done(); + }, waitInterval); + }); + + it('Should not send tracking requests if queue is populated and user is human and page is unloading', (done) => { + const requests = trackerRequests(); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start'); + requests.queue('https://ac.cnstrc.com/behavior?action=focus'); + requests.queue('https://ac.cnstrc.com/behavior?action=magic_number_three'); + + expect(requests.get()).to.be.an('array').length(3); + helpers.triggerResize(); + helpers.triggerUnload(); + requests.send(); + + setTimeout(() => { + expect(requests.get()).to.be.an('array').length(3); + done(); + }, waitInterval); + }); + + it('Should send all tracking requests if requests exist in storage and user is human', (done) => { + store.local.set(storageKey, [ + 'https://ac.cnstrc.com/behavior?action=session_start', + 'https://ac.cnstrc.com/behavior?action=focus', + 'https://ac.cnstrc.com/behavior?action=magic_number_three', + ]); + + const requests = trackerRequests(); + + expect(requests.get()).to.be.an('array').length(3); + helpers.triggerResize(); + requests.send(); + + setTimeout(() => { + expect(requests.get()).to.be.an('array').length(0); + done(); + }, waitInterval); + }); + + it('Should not send tracking requests if requests exist in storage and user is not human', (done) => { + store.local.set(storageKey, [ + 'https://ac.cnstrc.com/behavior?action=session_start', + 'https://ac.cnstrc.com/behavior?action=focus', + 'https://ac.cnstrc.com/behavior?action=magic_number_three', + ]); + + const requests = trackerRequests(); + + expect(requests.get()).to.be.an('array').length(3); + requests.send(); + + setTimeout(() => { + expect(requests.get()).to.be.an('array').length(3); + done(); + }, waitInterval); + }); + + it('Should not send tracking requests if requests exist in storage and user is human and page is unloading', (done) => { + store.local.set(storageKey, [ + 'https://ac.cnstrc.com/behavior?action=session_start', + 'https://ac.cnstrc.com/behavior?action=focus', + 'https://ac.cnstrc.com/behavior?action=magic_number_three', + ]); + + const requests = trackerRequests(); + + expect(requests.get()).to.be.an('array').length(3); + helpers.triggerResize(); + helpers.triggerUnload(); + requests.send(); + + setTimeout(() => { + expect(requests.get()).to.be.an('array').length(3); + done(); + }, waitInterval); + }); + }); +}); diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js new file mode 100644 index 00000000..a1fe8239 --- /dev/null +++ b/spec/src/modules/tracker.js @@ -0,0 +1,667 @@ +/* eslint-disable no-unused-expressions */ +const jsdom = require('mocha-jsdom'); +const dotenv = require('dotenv'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const fetchPonyfill = require('fetch-ponyfill'); +const Promise = require('es6-promise'); +const store = require('../../../src/store/store'); +const ConstructorIO = require('../../../src/constructorio'); +const helpers = require('../../mocha.helpers'); + +chai.use(chaiAsPromised); +chai.use(sinonChai); +dotenv.config(); + +const testApiKey = process.env.TEST_API_KEY; +const { fetch } = fetchPonyfill({ Promise }); + +describe('ConstructorIO - Tracker', () => { + const clientVersion = 'cio-mocha'; + let fetchSpy; + + jsdom({ url: 'http://localhost' }); + + beforeEach(() => { + store.session.set('_constructorio_is_human', true); + + global.CLIENT_VERSION = clientVersion; + fetchSpy = sinon.spy(fetch); + }); + + afterEach(() => { + helpers.clearStorage(); + + delete global.CLIENT_VERSION; + + fetchSpy = null; + }); + + describe('sendSessionStart', () => { + it('Should respond with a valid response', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.sendSessionStart()).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('action').to.equal('session_start'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + }); + + it('Should respond with a valid response with segments', () => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + }); + + expect(tracker.sendSessionStart()).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + + it('Should respond with a valid response with user id', () => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + }); + + expect(tracker.sendSessionStart()).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + }); + + describe('sendInputFocus', () => { + it('Should respond with a valid response', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.sendInputFocus()).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('action').to.equal('focus'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + }); + + it('Should respond with a valid response with segments', () => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + }); + + expect(tracker.sendInputFocus()).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + + it('Should respond with a valid response with user id', () => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + }); + + expect(tracker.sendInputFocus()).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + }); + + describe('trackAutocompleteSelect', () => { + const term = 'Where The Wild Things Are'; + const parameters = { + original_query: 'original-query', + result_id: 'result-id', + section: 'Search Suggestions', + tr: 'click', + group_id: 'group-id', + display_name: 'display-name', + }; + + it('Should respond with a valid response when term and parameters are provided', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.trackAutocompleteSelect(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('original_query').to.equal(parameters.original_query); + expect(requestedUrlParams).to.have.property('section').to.equal(parameters.section); + expect(requestedUrlParams).to.have.property('result_id').to.equal(parameters.result_id); + expect(requestedUrlParams).to.have.property('group').to.deep.equal({ + group_id: parameters.group_id, + display_name: parameters.display_name, + }); + }); + + it('Should respond with a valid response when term, parameters and segments are provided', () => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + }); + + expect(tracker.trackAutocompleteSelect(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + + it('Should respond with a valid response when term, parameters and user id are provided', () => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + }); + + expect(tracker.trackAutocompleteSelect(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + + it('Should throw an error when invalid term is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackAutocompleteSelect([], parameters)).to.be.an('error'); + }); + + it('Should throw an error when no term is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackAutocompleteSelect(null, parameters)).to.be.an('error'); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackAutocompleteSelect(term, [])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackAutocompleteSelect(term)).to.be.an('error'); + }); + }); + + describe('trackSearchSubmit', () => { + const term = 'Where The Wild Things Are'; + const parameters = { + original_query: 'original-query', + result_id: 'result-id', + group_id: 'group-id', + display_name: 'display-name', + }; + + it('Should respond with a valid response when term and parameters are provided', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.trackSearchSubmit(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('original_query').to.equal(parameters.original_query); + expect(requestedUrlParams).to.have.property('result_id').to.equal(parameters.result_id); + expect(requestedUrlParams).to.have.property('group').to.deep.equal({ + group_id: parameters.group_id, + display_name: parameters.display_name, + }); + }); + + it('Should respond with a valid response when term, parameters and segments are provided', () => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + }); + + expect(tracker.trackSearchSubmit(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + + it('Should respond with a valid response when term, parameters and user id are provided', () => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + }); + + expect(tracker.trackSearchSubmit(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + + it('Should throw an error when invalid term is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchSubmit([], parameters)).to.be.an('error'); + }); + + it('Should throw an error when no term is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchSubmit(null, parameters)).to.be.an('error'); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchSubmit(term, [])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchSubmit(term)).to.be.an('error'); + }); + }); + + describe('trackSearchResultsLoaded', () => { + const term = 'Cat in the Hat'; + const parameters = { + num_results: 1337, + customer_ids: [1, 2, 3], + }; + + it('Should respond with a valid response when term and parameters are provided', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.trackSearchResultsLoaded(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('num_results').to.equal(parameters.num_results.toString()); + expect(requestedUrlParams).to.have.property('customer_ids').to.equal(parameters.customer_ids.join(',')); + }); + + it('Should respond with a valid response when term, parameters and segments are provided', () => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + }); + + expect(tracker.trackSearchResultsLoaded(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + + it('Should respond with a valid response when term, parameters and user id are provided', () => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + }); + + expect(tracker.trackSearchResultsLoaded(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + + it('Should throw an error when invalid term is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultsLoaded([], parameters)).to.be.an('error'); + }); + + it('Should throw an error when no term is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultsLoaded(null, parameters)).to.be.an('error'); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultsLoaded(term, [])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultsLoaded(term)).to.be.an('error'); + }); + }); + + describe('trackSearchResultClick', () => { + const term = 'Where The Wild Things Are'; + const parameters = { + name: 'name', + customer_id: 'customer-id', + result_id: 'result-id', + }; + + it('Should respond with a valid response when term and parmeters are provided', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.trackSearchResultClick(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('name').to.equal(parameters.name); + expect(requestedUrlParams).to.have.property('customer_id').to.equal(parameters.customer_id); + expect(requestedUrlParams).to.have.property('result_id').to.equal(parameters.result_id); + }); + + it('Should respond with a valid response when term, parameters and segments are provided', () => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + }); + + expect(tracker.trackSearchResultClick(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + + it('Should respond with a valid response when term, parameters and user id are provided', () => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + }); + + expect(tracker.trackSearchResultClick(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + + it('Should throw an error when invalid term is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultClick([], parameters)).to.be.an('error'); + }); + + it('Should throw an error when no term is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultClick(null, parameters)).to.be.an('error'); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultClick(term, [])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultClick(term)).to.be.an('error'); + }); + }); + + describe('trackConversion', () => { + const term = 'Where The Wild Things Are'; + const parameters = { + name: 'name', + customer_id: 'customer-id', + result_id: 'result-id', + revenue: 123, + section: 'Products', + }; + + it('Should respond with a valid response when term and parameters are provided', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.trackConversion(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('name').to.equal(parameters.name); + expect(requestedUrlParams).to.have.property('customer_id').to.equal(parameters.customer_id); + expect(requestedUrlParams).to.have.property('result_id').to.equal(parameters.result_id); + expect(requestedUrlParams).to.have.property('revenue').to.equal(parameters.revenue.toString()); + expect(requestedUrlParams).to.have.property('section').to.equal(parameters.section); + }); + + it('Should respond with a valid response and section should be defaulted when term and parameters are provided', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.trackConversion(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('section').to.equal('Products'); + }); + + it('Should respond with a valid response when term, parameters and segments are provided', () => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + }); + + expect(tracker.trackConversion(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + + it('Should respond with a valid response when term, parameters and user id are provided', () => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + }); + + expect(tracker.trackConversion(term, parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + + it('Should respond with a valid response when no term is provided, but parameters are', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackConversion(null, parameters)).to.equal(true); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultClick(term, [])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackSearchResultClick(term)).to.be.an('error'); + }); + }); + + describe('trackPurchase', () => { + const parameters = { + customer_ids: ['customer-id1', 'customer-id1', 'customer-id2'], + revenue: 123, + section: 'Products', + }; + + it('Should respond with a valid response when parameters are provided', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.trackPurchase(parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('customer_ids').to.deep.equal(parameters.customer_ids); + expect(requestedUrlParams).to.have.property('revenue').to.equal(parameters.revenue.toString()); + expect(requestedUrlParams).to.have.property('section').to.equal(parameters.section); + }); + + it('Should respond with a valid response and section should be defaulted when term and parameters are provided', () => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + }); + + expect(tracker.trackPurchase(parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('section').to.equal('Products'); + }); + + it('Should respond with a valid response parameters and segments are provided', () => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + }); + + expect(tracker.trackPurchase(parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + + it('Should respond with a valid response when parameters and user id are provided', () => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + }); + + expect(tracker.trackPurchase(parameters)).to.equal(true); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackPurchase([])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackPurchase()).to.be.an('error'); + }); + }); +}); diff --git a/src/botlist.js b/src/botlist.js new file mode 100644 index 00000000..2b03dfc5 --- /dev/null +++ b/src/botlist.js @@ -0,0 +1,361 @@ +/* eslint-disable no-useless-escape */ + +const botList = [ + 'Googlebot\/', + 'Googlebot-Mobile', + 'Googlebot-Image', + 'Googlebot-News', + 'Googlebot-Video', + 'AdsBot-Google([^-]|$)', + 'AdsBot-Google-Mobile', + 'Feedfetcher-Google', + 'Mediapartners-Google', + 'Mediapartners \(Googlebot\)', + 'APIs-Google', + 'bingbot', + 'Slurp', + '[wW]get', + 'curl', + 'LinkedInBot', + 'Python-urllib', + 'python-requests', + 'libwww', + 'httpunit', + 'nutch', + 'Go-http-client', + 'phpcrawl', + 'msnbot', + 'jyxobot', + 'FAST-WebCrawler', + 'FAST Enterprise Crawler', + 'BIGLOTRON', + 'Teoma', + 'convera', + 'seekbot', + 'Gigabot', + 'Gigablast', + 'exabot', + 'ia_archiver', + 'GingerCrawler', + 'webmon ', + 'HTTrack', + 'grub.org', + 'UsineNouvelleCrawler', + 'antibot', + 'netresearchserver', + 'speedy', + 'fluffy', + 'bibnum.bnf', + 'findlink', + 'msrbot', + 'panscient', + 'yacybot', + 'AISearchBot', + 'ips-agent', + 'tagoobot', + 'MJ12bot', + 'woriobot', + 'yanga', + 'buzzbot', + 'mlbot', + 'YandexBot', + 'yandex.com\/bots', + 'purebot', + 'Linguee Bot', + 'CyberPatrol', + 'voilabot', + 'Baiduspider', + 'citeseerxbot', + 'spbot', + 'twengabot', + 'postrank', + 'turnitinbot', + 'scribdbot', + 'page2rss', + 'sitebot', + 'linkdex', + 'Adidxbot', + 'blekkobot', + 'ezooms', + 'dotbot', + 'Mail.RU_Bot', + 'discobot', + 'heritrix', + 'findthatfile', + 'europarchive.org', + 'NerdByNature.Bot', + 'sistrix crawler', + 'Ahrefs(Bot|SiteAudit)', + 'fuelbot', + 'CrunchBot', + 'centurybot9', + 'IndeedBot', + 'mappydata', + 'woobot', + 'ZoominfoBot', + 'PrivacyAwareBot', + 'Multiviewbot', + 'SWIMGBot', + 'Grobbot', + 'eright', + 'Apercite', + 'semanticbot', + 'Aboundex', + 'domaincrawler', + 'wbsearchbot', + 'summify', + 'CCBot', + 'edisterbot', + 'seznambot', + 'ec2linkfinder', + 'gslfbot', + 'aiHitBot', + 'intelium_bot', + 'facebookexternalhit', + 'Yeti', + 'RetrevoPageAnalyzer', + 'lb-spider', + 'Sogou', + 'lssbot', + 'careerbot', + 'wotbox', + 'wocbot', + 'ichiro', + 'DuckDuckBot', + 'lssrocketcrawler', + 'drupact', + 'webcompanycrawler', + 'acoonbot', + 'openindexspider', + 'gnam gnam spider', + 'web-archive-net.com.bot', + 'backlinkcrawler', + 'coccoc', + 'integromedb', + 'content crawler spider', + 'toplistbot', + 'it2media-domain-crawler', + 'ip-web-crawler.com', + 'siteexplorer.info', + 'elisabot', + 'proximic', + 'changedetection', + 'arabot', + 'WeSEE:Search', + 'niki-bot', + 'CrystalSemanticsBot', + 'rogerbot', + '360Spider', + 'psbot', + 'InterfaxScanBot', + 'CC Metadata Scaper', + 'g00g1e.net', + 'GrapeshotCrawler', + 'urlappendbot', + 'brainobot', + 'fr-crawler', + 'binlar', + 'SimpleCrawler', + 'Twitterbot', + 'cXensebot', + 'smtbot', + 'bnf.fr_bot', + 'A6-Indexer', + 'ADmantX', + 'Facebot', + 'OrangeBot\/', + 'memorybot', + 'AdvBot', + 'MegaIndex', + 'SemanticScholarBot', + 'ltx71', + 'nerdybot', + 'xovibot', + 'BUbiNG', + 'Qwantify', + 'archive.org_bot', + 'Applebot', + 'TweetmemeBot', + 'crawler4j', + 'findxbot', + 'S[eE][mM]rushBot', + 'yoozBot', + 'lipperhey', + 'Y!J', + 'Domain Re-Animator Bot', + 'AddThis', + 'Screaming Frog SEO Spider', + 'MetaURI', + 'Scrapy', + 'Livelap[bB]ot', + 'OpenHoseBot', + 'CapsuleChecker', + 'collection@infegy.com', + 'IstellaBot', + 'DeuSu\/', + 'betaBot', + 'Cliqzbot\/', + 'MojeekBot\/', + 'netEstate NE Crawler', + 'SafeSearch microdata crawler', + 'Gluten Free Crawler\/', + 'Sonic', + 'Sysomos', + 'Trove', + 'deadlinkchecker', + 'Slack-ImgProxy', + 'Embedly', + 'RankActiveLinkBot', + 'iskanie', + 'SafeDNSBot', + 'SkypeUriPreview', + 'Veoozbot', + 'Slackbot', + 'redditbot', + 'datagnionbot', + 'Google-Adwords-Instant', + 'adbeat_bot', + 'WhatsApp', + 'contxbot', + 'pinterest', + 'electricmonk', + 'GarlikCrawler', + 'BingPreview\/', + 'vebidoobot', + 'FemtosearchBot', + 'Yahoo Link Preview', + 'MetaJobBot', + 'DomainStatsBot', + 'mindUpBot', + 'Daum\/', + 'Jugendschutzprogramm-Crawler', + 'Xenu Link Sleuth', + 'Pcore-HTTP', + 'moatbot', + 'KosmioBot', + 'pingdom', + 'PhantomJS', + 'Gowikibot', + 'PiplBot', + 'Discordbot', + 'TelegramBot', + 'Jetslide', + 'newsharecounts', + 'James BOT', + 'Barkrowler', + 'TinEye', + 'SocialRankIOBot', + 'trendictionbot', + 'Ocarinabot', + 'epicbot', + 'Primalbot', + 'DuckDuckGo-Favicons-Bot', + 'GnowitNewsbot', + 'Leikibot', + 'LinkArchiver', + 'YaK\/', + 'PaperLiBot', + 'Digg Deeper', + 'dcrawl', + 'Snacktory', + 'AndersPinkBot', + 'Fyrebot', + 'EveryoneSocialBot', + 'Mediatoolkitbot', + 'Luminator-robots', + 'ExtLinksBot', + 'SurveyBot', + 'NING\/', + 'okhttp', + 'Nuzzel', + 'omgili', + 'PocketParser', + 'YisouSpider', + 'um-LN', + 'ToutiaoSpider', + 'MuckRack', + "Jamie's Spider", + 'AHC\/', + 'NetcraftSurveyAgent', + 'Laserlikebot', + 'Apache-HttpClient', + 'AppEngine-Google', + 'Jetty', + 'Upflow', + 'Thinklab', + 'Traackr.com', + 'Twurly', + 'Mastodon', + 'http_get', + 'DnyzBot', + 'botify', + '007ac9 Crawler', + 'BehloolBot', + 'BrandVerity', + 'check_http', + 'BDCbot', + 'ZumBot', + 'EZID', + 'ICC-Crawler', + 'ArchiveBot', + '^LCC ', + 'filterdb.iss.net\/crawler', + 'BLP_bbot', + 'BomboraBot', + 'Buck\/', + 'Companybook-Crawler', + 'Genieo', + 'magpie-crawler', + 'MeltwaterNews', + 'Moreover', + 'newspaper\/', + 'ScoutJet', + '(^| )sentry\/', + 'StorygizeBot', + 'UptimeRobot', + 'OutclicksBot', + 'seoscanners', + 'Hatena', + 'Google Web Preview', + 'MauiBot', + 'AlphaBot', + 'SBL-BOT', + 'IAS crawler', + 'adscanner', + 'Netvibes', + 'acapbot', + 'Baidu-YunGuanCe', + 'bitlybot', + 'blogmuraBot', + 'Bot.AraTurka.com', + 'bot-pge.chlooe.com', + 'BoxcarBot', + 'BTWebClient', + 'ContextAd Bot', + 'Digincore bot', + 'Disqus', + 'Feedly', + 'Fetch\/', + 'Fever', + 'Flamingo_SearchEngine', + 'FlipboardProxy', + 'g2reader-bot', + 'imrbot', + 'K7MLWCBot', + 'Kemvibot', + 'Landau-Media-Spider', + 'linkapediabot', + 'vkShare', + 'Siteimprove.com', + 'BLEXBot\/', + 'DareBoost', + 'ZuperlistBot\/', + 'Miniflux\/', + 'Feedspotbot\/', + 'Diffbot\/', + 'SEOkicks', + 'tracemyfile', + 'Nimbostratus-Bot', +]; + +module.exports = botList; diff --git a/src/constructorio.js b/src/constructorio.js index 27ac40ed..2d66657a 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -5,7 +5,7 @@ const ConstructorioID = require('@constructor-io/constructorio-id'); const { search } = require('./modules/search'); const { autocomplete } = require('./modules/autocomplete'); const { recommendations } = require('./modules/recommendations'); - +const tracker = require('./modules/tracker'); const { version } = require('../package.json'); /** @@ -19,9 +19,11 @@ class ConstructorIO { * @param {object} [testCells] - User test cells * @param {string} [clientId] - Client ID, defaults to value supplied by 'constructorio-id' * @param {string} [sessionId] - Session id, defaults to value supplied by 'constructorio-id' + * @param {string} [userId] - User id * @property {object} [search] - Interface to {@link module:search} * @property {object} [autocomplete] - Interface to {@link module:autocomplete} * @property {object} [recommendations] - Interface to {@link module:recommendations} + * @property {object} [tracker] - Interface to {@link module:tracker} * @returns {class} */ constructor(options = {}) { @@ -32,6 +34,7 @@ class ConstructorIO { testCells, clientId, sessionId, + userId, fetch, } = options; @@ -48,6 +51,7 @@ class ConstructorIO { serviceUrl: serviceUrl || 'https://ac.cnstrc.com', sessionId: sessionId || session_id, clientId: clientId || client_id, + userId, segments, testCells, fetch, @@ -57,6 +61,7 @@ class ConstructorIO { this.search = search(this.options); this.autocomplete = autocomplete(this.options); this.recommendations = recommendations(this.options); + this.tracker = tracker(this.options); } } diff --git a/src/modules/tracker-humanity.js b/src/modules/tracker-humanity.js new file mode 100644 index 00000000..a6f4424f --- /dev/null +++ b/src/modules/tracker-humanity.js @@ -0,0 +1,43 @@ +const store = require('../store/store'); + +const humanEvents = [ + 'scroll', + 'resize', + 'touchmove', + 'mouseover', + 'mousemove', + 'keydown', + 'keypress', + 'keyup', + 'focus', +]; + +const trackerHumanity = () => { + const storageKey = '_constructorio_is_human'; + const isHumanStorage = !!store.session.get(storageKey); + let isHumanBoolean = isHumanStorage || false; + + // Humanity proved, remove handlers to prove humanity + const remove = () => { + isHumanBoolean = true; + + store.session.set(storageKey, true); + humanEvents.forEach((eventType) => { + window.removeEventListener(eventType, remove, true); + }); + }; + + // Add handlers to prove humanity + if (!isHumanBoolean) { + humanEvents.forEach((eventType) => { + window.addEventListener(eventType, remove, true); + }); + } + + return { + // Return boolean indicating if is human + isHuman: () => isHumanBoolean || !!store.session.get(storageKey), + }; +}; + +module.exports = trackerHumanity; diff --git a/src/modules/tracker-requests.js b/src/modules/tracker-requests.js new file mode 100644 index 00000000..c93919a2 --- /dev/null +++ b/src/modules/tracker-requests.js @@ -0,0 +1,54 @@ +const fetchPonyfill = require('fetch-ponyfill'); +const Promise = require('es6-promise'); +const store = require('../store/store'); +const utils = require('../utils'); +const trackerHumanity = require('./tracker-humanity'); + +const trackerRequests = (options) => { + const fetch = (options && options.fetch) || fetchPonyfill({ Promise }).fetch; + const humanity = trackerHumanity(); + const storageKey = '_constructorio_requests'; + let requestPending = false; + let flushScheduled = false; + const requestQueue = store.local.get(storageKey) || []; + + // Flush requests to storage on unload + window.addEventListener('beforeunload', () => { + flushScheduled = true; + + store.local.set(storageKey, requestQueue); + }); + + return { + // Add request to queue to be dispatched + queue: (request) => { + if (!utils.isBot()) { + requestQueue.push(request); + } + }, + + // Read from queue and send requests to server + // - Note: Must not be fat-arrow function to keep context + send: function send() { + if (humanity.isHuman() && requestQueue.length && !requestPending && !flushScheduled) { + const nextInQueue = requestQueue.shift(); + const request = fetch(nextInQueue); + + if (request) { + requestPending = true; + + request.finally(() => { + requestPending = false; + + this.send(); + }); + } + } + }, + + // Return current queue + get: () => requestQueue, + }; +}; + +module.exports = trackerRequests; diff --git a/src/modules/tracker.js b/src/modules/tracker.js new file mode 100644 index 00000000..ecac3e6f --- /dev/null +++ b/src/modules/tracker.js @@ -0,0 +1,396 @@ +/* eslint-disable object-curly-newline, no-underscore-dangle, camelcase */ +const qs = require('qs'); +const utils = require('../utils'); +const trackerRequests = require('./tracker-requests'); + +/** + * Interface to tracking related API calls. + * + * @module tracker + * @inner + * @returns {object} + */ +const tracker = (options) => { + const requests = trackerRequests(options); + + // Append common parameters to supplied parameters object + const createQueryString = (parameters) => { + const { apiKey, version, sessionId, clientId, userId, segments } = options; + let queryParams = Object.assign(parameters); + + if (version) { + queryParams.c = version; + } + + if (clientId) { + queryParams.i = clientId; + } + + if (sessionId) { + queryParams.s = sessionId; + } + + if (userId) { + queryParams.ui = userId; + } + + if (segments && segments.length) { + queryParams.us = segments; + } + + if (apiKey) { + queryParams.key = apiKey; + } + + queryParams._dt = Date.now(); + queryParams = utils.cleanParams(queryParams); + + return qs.stringify(queryParams, { indices: false }); + }; + + return { + /** + * Send session start event to API + * + * @function sendSessionStart + * @returns {(true|Error)} + */ + sendSessionStart: () => { + const url = `${options.serviceUrl}/behavior?`; + const queryParams = { action: 'session_start' }; + + requests.queue(`${url}${createQueryString(queryParams)}`); + requests.send(); + + return true; + }, + + /** + * Send input focus event to API + * + * @function sendInputFocus + * @returns {(true|Error)} + */ + sendInputFocus: () => { + const url = `${options.serviceUrl}/behavior?`; + const queryParams = { action: 'focus' }; + + requests.queue(`${url}${createQueryString(queryParams)}`); + requests.send(); + + return true; + }, + + /** + * Send autocomplete select event to API + * + * @function trackAutocompleteSelect + * @param {string} term - Term of selected autocomplete item + * @param {object} parameters - Additional parameters to be sent with request + * @param {string} parameters.original_query - The current autocomplete search query + * @param {string} parameters.result_id - Customer id of the selected autocomplete item + * @param {string} parameters.section - Section the selected item resides within + * @param {string} [parameters.tr] - Trigger used to select the item (click, etc.) + * @param {string} [parameters.group_id] - Group identifier of selected item + * @param {string} [parameters.display_name] - Display name of group of selected item + * @returns {(true|Error)} + */ + trackAutocompleteSelect: (term, parameters) => { + // Ensure term is provided (required) + if (term && typeof term === 'string') { + // Ensure parameters are provided (required) + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + const url = `${options.serviceUrl}/autocomplete/${utils.ourEncodeURIComponent(term)}/select?`; + const queryParams = {}; + const { + original_query, + result_id, + section, + original_section, + tr, + group_id, + display_name, + } = parameters; + + if (original_query) { + queryParams.original_query = original_query; + } + + if (tr) { + queryParams.tr = tr; + } + + if (original_section || section) { + queryParams.section = original_section || section; + } + + if (group_id) { + queryParams.group = { + group_id, + display_name, + }; + } + + if (result_id) { + queryParams.result_id = result_id; + } + + requests.queue(`${url}${createQueryString(queryParams)}`); + requests.send(); + + return true; + } + + requests.send(); + + return new Error('parameters are required of type object'); + } + + requests.send(); + + return new Error('term is a required parameter of type string'); + }, + + /** + * Send autocomplete search event to API + * + * @function trackSearchSubmit + * @param {string} term - Term of submitted autocomplete event + * @param {object} parameters - Additional parameters to be sent with request + * @param {string} parameters.original_query - The current autocomplete search query + * @param {string} parameters.result_id - Customer ID of the selected autocomplete item + * @param {string} [parameters.group_id] - Group identifier of selected item + * @param {string} [parameters.display_name] - Display name of group of selected item + * @returns {(true|Error)} + */ + trackSearchSubmit: (term, parameters) => { + // Ensure term is provided (required) + if (term && typeof term === 'string') { + // Ensure parameters are provided (required) + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + const url = `${options.serviceUrl}/autocomplete/${utils.ourEncodeURIComponent(term)}/search?`; + const queryParams = {}; + const { original_query, result_id, group_id, display_name } = parameters; + + if (original_query) { + queryParams.original_query = original_query; + } + + if (group_id) { + queryParams.group = { + group_id, + display_name, + }; + } + + if (result_id) { + queryParams.result_id = result_id; + } + + requests.queue(`${url}${createQueryString(queryParams)}`); + requests.send(); + + return true; + } + + requests.send(); + + return new Error('parameters are required of type object'); + } + + requests.send(); + + return new Error('term is a required parameter of type string'); + }, + + /** + * Send search results event to API + * + * @function trackSearchResultsLoaded + * @param {string} term - Search results query term + * @param {object} parameters - Additional parameters to be sent with request + * @param {number} parameters.num_results - Number of search results in total + * @param {array} [parameters.customer_ids] - List of customer item id's returned from search + * @returns {(true|Error)} + */ + trackSearchResultsLoaded: (term, parameters) => { + // Ensure term is provided (required) + if (term && typeof term === 'string') { + // Ensure parameters are provided (required) + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + const url = `${options.serviceUrl}/behavior?`; + const queryParams = { action: 'search-results', term }; + const { num_results, customer_ids } = parameters; + + if (num_results) { + queryParams.num_results = num_results; + } + + if (customer_ids && Array.isArray(customer_ids)) { + queryParams.customer_ids = customer_ids.join(','); + } + + requests.queue(`${url}${createQueryString(queryParams)}`); + requests.send(); + + return true; + } + + requests.send(); + + return new Error('parameters are required of type object'); + } + + requests.send(); + + return new Error('term is a required parameter of type string'); + }, + + /** + * Send click through event to API + * + * @function trackSearchResultClick + * @param {string} term - Search results query term + * @param {object} parameters - Additional parameters to be sent with request + * @param {string} parameters.name - Identifier + * @param {string} parameters.customer_id - Customer id + * @param {string} parameters.result_id - Result id + * @returns {(true|Error)} + */ + trackSearchResultClick: (term, parameters) => { + // Ensure term is provided (required) + if (term && typeof term === 'string') { + // Ensure parameters are provided (required) + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + const url = `${options.serviceUrl}/autocomplete/${utils.ourEncodeURIComponent(term)}/click_through?`; + const queryParams = {}; + const { name, customer_id, result_id } = parameters; + + if (name) { + queryParams.name = name; + } + + if (customer_id) { + queryParams.customer_id = customer_id; + } + + if (result_id) { + queryParams.result_id = result_id; + } + + requests.queue(`${url}${createQueryString(queryParams)}`); + requests.send(); + + return true; + } + + requests.send(); + + return new Error('parameters are required of type object'); + } + + requests.send(); + + return new Error('term is a required parameter of type string'); + }, + + /** + * Send conversion event to API + * + * @function trackConversion + * @param {string} term - Search results query term + * @param {object} parameters - Additional parameters to be sent with request + * @param {string} parameters.name - Identifier + * @param {string} parameters.customer_id - Customer id + * @param {string} parameters.result_id - Result id + * @param {string} parameters.revenue - Revenue + * @param {string} parameters.section - Autocomplete section + * @returns {(true|Error)} + */ + trackConversion: (term, parameters) => { + // Ensure parameters are provided (required) + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + const searchTerm = utils.ourEncodeURIComponent(term) || 'TERM_UNKNOWN'; + const url = `${options.serviceUrl}/autocomplete/${searchTerm}/conversion?`; + const queryParams = {}; + const { name, customer_id, result_id, revenue, section } = parameters; + + if (name) { + queryParams.name = name; + } + + if (customer_id) { + queryParams.customer_id = customer_id; + } + + if (result_id) { + queryParams.result_id = result_id; + } + + if (revenue) { + queryParams.revenue = revenue; + } + + if (section) { + queryParams.section = section; + } else { + queryParams.section = 'Products'; + } + + requests.queue(`${url}${createQueryString(queryParams)}`); + requests.send(); + + return true; + } + + requests.send(); + + return new Error('parameters are required of type object'); + }, + + /** + * Send purchase event to API + * + * @function trackPurchase + * @param {object} parameters - Additional parameters to be sent with request + * @param {array} parameters.customer_ids - List of customer item id's + * @param {string} parameters.revenue - Revenue + * @param {string} parameters.section - Autocomplete section + * @returns {(true|Error)} + */ + trackPurchase: (parameters) => { + // Ensure parameters are provided (required) + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + const url = `${options.serviceUrl}/autocomplete/TERM_UNKNOWN/purchase?`; + const queryParams = {}; + + const { customer_ids, revenue, section } = parameters; + + if (customer_ids) { + queryParams.customer_ids = customer_ids; + } + + if (revenue) { + queryParams.revenue = revenue; + } + + if (section) { + queryParams.section = section; + } else { + queryParams.section = 'Products'; + } + + requests.queue(`${url}${createQueryString(queryParams)}`); + requests.send(); + + return true; + } + + requests.send(); + + return new Error('parameters are required of type object'); + }, + }; +}; + +module.exports = tracker; diff --git a/src/store/store.js b/src/store/store.js new file mode 100644 index 00000000..6f534c0f --- /dev/null +++ b/src/store/store.js @@ -0,0 +1,8 @@ +const store = require('store2'); +const overflow = require('./store.overflow'); + +// Inject overflow into store +// https://raw.githubusercontent.com/nbubna/store/master/src/store.overflow.js +overflow(store, store._); + +module.exports = store; diff --git a/src/store/store.overflow.js b/src/store/store.overflow.js new file mode 100644 index 00000000..ba62e875 --- /dev/null +++ b/src/store/store.overflow.js @@ -0,0 +1,90 @@ +/* eslint-disable */ +/** + * Copyright (c) 2013 ESHA Research + * Dual licensed under the MIT and GPL licenses: + * http://www.opensource.org/licenses/mit-license.php + * http://www.gnu.org/licenses/gpl.html + * + * When quota is reached on a storage area, this shifts incoming values to + * fake storage, so they last only as long as the page does. This is useful + * because it is more burdensome for localStorage to recover from quota errors + * than incomplete caches. In other words, it is wiser to rely on store.js + * never complaining than never missing data. You should already be checking + * the integrity of cached data on every page load. + * + * Status: BETA + */ +module.exports = function (store, _) { + var _set = _.set, + _get = _.get, + _remove = _.remove, + _key = _.key, + _length = _.length, + _clear = _.clear; + + _.overflow = function (area, create) { + var name = area === _.areas.local ? '+local+' : + area === _.areas.session ? '+session+' : false; + if (name) { + var overflow = _.areas[name]; + if (create && !overflow) { + overflow = store.area(name)._area; // area() copies to _.areas + } else if (create === false) { + delete _.areas[name]; + delete store[name]; + } + return overflow; + } + }; + _.set = function (area, key, string) { + try { + _set.apply(this, arguments); + } catch (e) { + if (e.name === 'QUOTA_EXCEEDED_ERR' || + e.name === 'NS_ERROR_DOM_QUOTA_REACHED' || + e.toString().indexOf("QUOTA_EXCEEDED_ERR") !== -1 || + e.toString().indexOf("QuotaExceededError") !== -1) { + // the e.toString is needed for IE9 / IE10, cos name is empty there + return _.set(_.overflow(area, true), key, string); + } + throw e; + } + }; + _.get = function (area, key) { + var overflow = _.overflow(area); + return (overflow && _get.call(this, overflow, key)) || + _get.apply(this, arguments); + }; + _.remove = function (area, key) { + var overflow = _.overflow(area); + if (overflow) { + _remove.call(this, overflow, key); + } + _remove.apply(this, arguments); + }; + _.key = function (area, i) { + var overflow = _.overflow(area); + if (overflow) { + var l = _length.call(this, area); + if (i >= l) { + i = i - l; // make i overflow-relative + for (var j = 0, m = _length.call(this, overflow); j < m; j++) { + if (j === i) { // j is overflow index + return _key.call(this, overflow, j); + } + } + } + } + return _key.apply(this, arguments); + }; + _.length = function (area) { + var length = _length(area), + overflow = _.overflow(area); + return overflow ? length + _length(overflow) : length; + }; + _.clear = function (area) { + _.overflow(area, false); + _clear.apply(this, arguments); + }; + +}; \ No newline at end of file diff --git a/src/utils.js b/src/utils.js index dcdb4f61..9a522b4b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,12 +1,51 @@ /* eslint-disable no-param-reassign */ +const qs = require('qs'); +const botList = require('./botlist'); -/** - * Returns a thenable that throws an http error based on a fetch response - * @param {Error} An error (to preserve the stack trace) - * @param {Object} A fetch response - */ -function throwHttpErrorFromResponse(error, response) { - return response.json().then((json) => { +const utils = { + ourEncodeURIComponent: (str) => { + if (str) { + const parsedStrObj = qs.parse(`s=${str.replace(/&/g, '%26')}`); + + parsedStrObj.s = parsedStrObj.s.replace(/\s/g, ' '); + + return qs.stringify(parsedStrObj).split('=')[1]; + } + + return null; + }, + + cleanParams: (paramsObj) => { + const cleanedParams = {}; + + Object.keys(paramsObj).forEach((paramKey) => { + const paramValue = paramsObj[paramKey]; + + if (typeof paramValue === 'string') { + // Replace non-breaking spaces (or any other type of spaces caught by the regex) + // - with a regular white space + cleanedParams[paramKey] = decodeURIComponent(utils.ourEncodeURIComponent(paramValue)); + } else { + cleanedParams[paramKey] = paramValue; + } + }); + + return cleanedParams; + }, + + isBot: () => { + const { userAgent, webdriver } = window && window.navigator; + const botRegex = new RegExp(`(${botList.join('|')})`); + + return Boolean(userAgent.match(botRegex)) || Boolean(webdriver); + }, + + /** + * Returns a thenable that throws an http error based on a fetch response + * @param {Error} An error (to preserve the stack trace) + * @param {Object} A fetch response + */ + throwHttpErrorFromResponse: (error, response) => response.json().then((json) => { error.message = json.message; error.status = response.status; error.statusText = response.statusText; @@ -14,9 +53,7 @@ function throwHttpErrorFromResponse(error, response) { error.headers = response.headers; throw error; - }); -} - -module.exports = { - throwHttpErrorFromResponse, + }), }; + +module.exports = utils;