UNPKG

sajari-website

Version:

Website extensions for the Sajari API. Automatically index site content, add user profiles, render search and recommendations, etc.

998 lines (865 loc) 24.6 kB
require("./utils/polyfills"); var loaded = require("./utils/loaded"); var url = require("./utils/url"); var API = require('sajari'); var query = require('sajari/src/js/query.js'); var isArray = require('sajari/src/js/utils/isArray.js'); var renderResults = require("./views/results"); var renderOverlay = require("./views/overlay"); var md5 = require("./utils/md5"); var readable = require("./utils/readable"); var opts = { cssUrl: 'https://cdn.sajari.com/css/sj.css', // Default styling if desired tokenEndpoint: 'https://www.sajari.com', prefix: 'data-sj-', // Elements with Sajari data parameters all use this as a prefix debug: false, // If true, debug information will be logged to the console company: undefined, project: undefined, collection: undefined, index: true, // Whether to index the page or not indexed: false, // Whether or not the index call has already been sent scanOnLoad: true, // Scans the DOM and builds a list of nodes with dom.targets related tags autoQuery: false, // If true, "q" in the URL will automatically trigger a query autoQueryParam: "q", // If autoQuery is true, this is the search query parameter to look for in the URL searchInProgress: false, // Goes to true when a search is in process defaultImage: "", excludeClicks: 3, // The most recent clicks to exclude from results targets: [ 'project', 'company', 'collection', 'profile-id', 'noindex', 'noprofile', 'profile-delay', 'conv-type', 'search-query', 'search-query-word', 'search-query-go', 'search-results', 'recent', 'popular', 'related', 'best', 'search-recent', 'search-noresults', 'search-error', 'personalization', 'overlay', 'local', 'field' ] }; // Initialize DOM functionality var dom = new(require("./utils/dom"))(opts); // Initialize the users profile var profile = new(require("./profile"))({ send: true, // Only send profile data if this is true sent: false, // Switch when the automatic profile is sent delay: 120000 // Default profile sends if the user browses longer than this time in msecs. }); // Keep information on the current webpage var page = { 'e.id': document.URL, 'ec.ti': document.title, 'ec.de': dom.getMeta("description"), 'ec.ke': dom.getMeta("keywords"), meta: {} }; // Some queries are made up of sequences, but keep the same ID, this lets us keep state var ongoing = new query(); // function stack queue var stack = []; // Current page protocol is SSL var ssl = (location.protocol == "https:" ? true : false); /** * Process an array or object of options */ function processOptions(options) { if (isArray(options)) { for (var i = 0; i < options.length; i++) { stack.push(options[i]); } } else if (typeof options === 'object') { for (var j in options) { var opt = (isArray(options[j]) ? options[j].slice() : [options[j]]); opt.unshift(j); stack.push(opt); } } } /** * Safely log an error message to the console if in debug mode */ function log(text) { if (opts.debug) { console.log(text); } } /** * Flush the stack array */ function flush() { stack.push = function() { for (var i = 0; i < arguments.length; i++) try { if (typeof arguments[i] === "function") arguments[i](); else { var fn = arguments[i][0]; var args = arguments[i].slice(1); if (methods[fn] !== undefined) { methods[fn].apply(stack, args); } } } catch (e) {} }; // Clear the stack for (var i = 0; i < stack.length; i++) { stack.push(stack[i]); } } /** * Initialize a blank api queue, once initialized the queue will be cleared */ var apiStack = []; var api = { pixel: function(data, dest) { apiStack.push(['pixel', data, dest]); }, search: function(data, success, failure) { apiStack.push(['search', data, success, failure]); }, autocomplete: function(data, success, failure) { apiStack.push(['autocomplete', data, success, failure]); }, best: function(data, success, failure) { apiStack.push(['best', data, success, failure]); }, popular: function(data, success, failure) { apiStack.push(['popular', data, success, failure]); }, recent: function(data, success, failure) { apiStack.push(['recent', data, success, failure]); }, related: function(data, success, failure) { apiStack.push(['related', data, success, failure]); } }; /** * Initialize the API, clear the API stack and replace the stack with the API */ function apiInitialize(options) { if ((options.company === undefined && options.project === undefined) || options.collection === undefined) { log("company and collection cannot be undefined..."); return false; } // We have a company and a domain so we can install the API // This replaces the stack shoving queue that was here previously api = new API(opts.company || opts.project, opts.collection, { jsonp: true }); log("API stack: ", api); // Clear the API stack var r; while (r = apiStack.shift()) { var fn = r.shift(); if (api[fn] !== undefined) { api[fn].apply(api, r); } } return true; } /** * Render the results into the page */ function showResults(response, renderNode, renderType) { // Clear errors if (dom.firstNode('search-error') !== undefined) { dom.firstNode('search-error').style.display = 'none'; } var res = response.response; // Setup defaults res.showThumb = false; res.showDesc = true; res.showUrl = false; res.renderSearch = true; res.formattedMsecs = 0.000; res.showMeta = []; res.tokenEndpoint = opts.tokenEndpoint; // Handle formatting options var dynAttributes = dom.dynamicAttrs(renderNode); for (var key in dynAttributes) { if (dynAttributes.hasOwnProperty(key)) { if (key == "thumbnail") { res.showThumb = true; } if (key == "hidedesc") { res.hideDesc = true; } if (key == "showmeta") { res.showMeta = dynAttributes[key].split(/\s*,\s*/); } } } // Timing formatting if (response.msecs) { res.formattedMsecs = parseFloat(response.msecs / 1000).toFixed(3); } // Was the query a fuzzy match or autocomplete? if (res.fuzzy) { for (var i in res.fuzzy) { if (res.fuzzy.hasOwnProperty(i)) { res.fuzzyStr = res.fuzzy[i]; break; } } } // Deal with insecure images on SSL pages, default image, etc checkImages(res.results); // Add the renderType so we can differentiate search and recommendations res.renderType = renderType; // Render it renderNode.innerHTML = renderResults(res); } // Check if images are SSL for SSL pages and also replace missing images // where appropriate function checkImages(results) { var isHttp = (opts.defaultImage.indexOf('http:') === 0); var isDefault = (opts.defaultImage.length !== 0); for (var i in results) { if (results.hasOwnProperty(i)) { if (results[i].meta.image !== undefined) { if (ssl && results[i].meta.image.indexOf('http:') === 0) { // We are SSL and this is an HTTP image. Remove it results[i].meta.image = ""; } } else { // Not defined, make it blank results[i].meta.image = ""; } if (isHttp || !isDefault) { continue; // No suitable default, move on } // Replace blank images if (results[i].meta.image === "") { results[i].meta.image = opts.defaultImage; } } } } function showError(renderNode) { if (!dom.hasNode('search-results') || !dom.hasNode('search-error')) { // Put the error message in the results area if it used the overlay or they don't have an error node renderNode.innerHTML = '<p class="sj-error">Oops! An error occured while searching. Please try again.</p>'; } else if (dom.firstNode('search-error') !== undefined) { dom.firstNode('search-error').style.display = 'block'; } } /** * Render a modal overlay and return a reference to the modal contents container */ function overlay(attrs) { log(attrs); vars = { themeColor: "blue", resultsExtra: "" }; if (typeof attrs === 'object') { for (var key in attrs) { if (attrs.hasOwnProperty(key)) { // Pass any relevant parameters to the modal results vars.resultsExtra += " " + opts.prefix + key + "=" + attrs[key]; // If the theme color is different, change it if (key == "theme") { vars.themeColor = attrs[key]; } } } } // Render and inject at the end of the DOM var overlayHTML = renderOverlay(vars); var div = document.createElement('div'); div.style.display = 'none'; div.setAttribute(opts.prefix + 'overlay', ''); div.innerHTML = overlayHTML; document.body.appendChild(div); // Bind close event to the close cross dom.bind(document.getElementById("sj-o-close"), 'click', function() { div.style.display = 'none'; }); // Bind close event to the shaded background dom.bind(document.getElementById("sj-o-shade"), 'click', function() { div.style.display = 'none'; }); // Transfer dynamic props dom.copyDynamicProperties(dom.firstNode('search-query'), dom.lastNode('search-query')); dom.copyDynamicProperties(dom.firstNode('search-query'), dom.lastNode('search-results')); return div; } /** * Calls the built in search using the queries identified by attributes, and the display the results in an overlay * if no elements with attributes exist. */ function builtInSearch() { var node = this, last = this.value; // Propagate dynamic attributes from one set of elements to another // In this case people tend to configure the search query element, but // some properties need to enact on the results element. dom.copyDynamicProperties(dom.firstNode('search-query'), dom.firstNode('search-results')); // If we're already waiting on a search, exit here, no point starting another if (opts.searchInProgress) { return; } // Show the overlay if there is one if (dom.hasNode('overlay')) { log("Launching overlay..."); dom.firstNode('overlay').style.display = 'block'; } // Start the search process opts.searchInProgress = true; SJ.Search( this.value.replace(/(^ *| *$)/, ''), function(response) { opts.searchInProgress = false; showResults(response, dom.resultsNode, "search"); if (node.value !== last && hasInstantSearch(node)) { builtInSearch.apply(node); } }, function() { opts.searchInProgress = false; showError(dom.resultsNode); }, dom.dynamicAttrsUri(this) ); } /** * Install a search query input element */ function installSearchQuery(node) { // Run the search if they press enter on the input field dom.bind(node, 'keydown', function(e) { if (e.keyCode === 13) { builtInSearch.apply(this); if (e.preventDefault !== undefined) { e.preventDefault(); } } }); // Keep search queries in sync dom.bind(node, 'input', function() { dom.eachNode('search-query', function(otherNode) { if (node !== otherNode) { otherNode.value = node.value; } }); }); // Auto query if "q" parameter exists in URL if (opts.autoQuery) { var auto = url.getURLParameter(opts.autoQueryParam); if (auto) { node.value = auto; builtInSearch.apply(node); } } // Install instant search if applicable if (hasInstantSearch(node)) { dom.bind(node, 'input', function(e) { if (e.keyCode === 8 || e.keyCode === 16 || node.value !== "") { // backspace, delete, or empty query builtInSearch.apply(this); } }); } return node; } /** * Returns true if the node has instant searching turned on */ function hasInstantSearch(node) { var dynAttributes = dom.dynamicAttrs(node); for (var key in dynAttributes) { if (dynAttributes.hasOwnProperty(key)) { if (key == "search-query-instant") { return true; } } } return false; } /** * Install a search GO button element */ function installSearchGo(node, queryInput) { dom.bind(node, 'click', function(e) { e.preventDefault ? e.preventDefault() : e.returnValue = false; builtInSearch.apply(queryInput ? queryInput : dom.firstNode('search-query')); }); return node; } /** * Extend the query object to support running it */ query.prototype.run = function(success, failure) { if (this.options.func !== undefined) { if (this.options.q === undefined) { this.options.q = ""; } switch (this.options.func) { case "search": SJ.Search(this.options.q, success, failure, this.encode()); break; case "best": SJ.Best(success, failure, this.encode()); break; case "popular": SJ.Popular(success, failure, this.encode()); break; case "recent": SJ.Recent(success, failure, this.encode()); break; case "related": SJ.Related(success, failure, this.encode()); break; } } }; /** * Exclude recent clicks from the results */ query.prototype.filterClicks = function() { var arr = profile.getClickedUrls(opts.excludeClicks); for (var i = 0; i < arr.length; i++) { this.filter('url', '!=', arr[i]); } return this; }; query.prototype.mergeProfile = function() { var data = profile.get(); log(data); // encode meta if (data) { this.attrs(profile.toArgs()); } return this; }; /** * Methods is a collection of functions that can be pushed onto the stack queue */ var methods = { /** * Send free "text" and meta information into the current person's profile */ profile: function(text, meta) { // Add new meta to the user profile profile.set(text, meta); var data = { 'profile.text': text + '', 'p.ga': profile.gaId, 'p.id': profile.visitorId }; log(data); api.pixel(data, '/stats/profile'); }, // Set a new visitor ID profileid: function(id) { profile.setVisitorId(id); }, // Set a new visitor ID profileOption: function(option, value) { profile.option(option, value); }, // Send a conversion with the given type and value conversion: function(type, value) { if (value === null || value === undefined) { value = 0; } api.pixel({ 'cv.t': type + '', 'cv.v': value + '', 'p.ga': profile.gaId, 'p.id': profile.visitorId }, '/stats/conversion'); }, // Track a click through for a given "qid" click: function(qid, slot, injected) { if (injected === undefined) { injected = ''; } SJ.SendClick(qid, slot, injected, undefined); }, pageMeta: function(meta) { log("pageMeta has been deprecated"); }, // Index the page index: function() { if (!opts.index) { log('Noindex flag is set'); return; } if (opts.indexed) { log('Already indexed'); return; } opts.indexed = true; data = { 'cc.co': opts.company || opts.project, 'cc.pr': opts.collection, 'p.ga': profile.gaId, 'p.id': profile.visitorId }; // Add the page data for (var k in page) { if (k !== "meta") { data[k] = page[k]; } } // Add the page meta for (var m in page.meta) { data["meta[" + m + "]"] = page.meta[m]; } api.pixel(data, ''); log(data); }, // Default image for thumbnails defaultImage: function(img) { opts.defaultImage = img; }, // Prevent page indexing noindex: function() { opts.index = false; }, // Automatic query from url support urlquery: function() { opts.autoQuery = true; }, // Prevent CSS from loading css: function() { dom.importCss(opts.cssUrl); }, // Prevent profiling noprofile: function() { profile.send = false; }, // Set the collection collection: function(newCollection) { opts.collection = newCollection + ''; }, // Set the company company: function(newProject) { opts.project = newProject + ''; }, project: function(newProject) { opts.project = newProject + ''; }, // Don't scan the page when the script loads // Use if performance is a problem or you want to call SJ.Scan() later 'no-scan': function() { opts.scanOnLoad = false; }, // Turn debugging on and off debug: function(enabled) { opts.debug = (enabled ? true : false); }, // Open an empty overlay. For use with applications that do not have a search box, but trigger // a search with a button click or similar overlay: function(attrs) { overlay(attrs); } }; // The SJ object is exported var sj = function(options) { processOptions(options); return this; }; /** * Send a search to the API with the provided keywords. Calls the successCallback with the response, or the * failureCallback if it failed. A response with no response is considered a success, albeit empty. */ sj.prototype = { Search: function(keywords, success, failure, dynamicArgs) { ongoing.sequence(); ongoing.attrs({ 'q': keywords, 'q.id': ongoing.id, 'q.ga': profile.ga, 'p.id': profile.visitorId }); if (dom.hasNode('search-recent')) { ongoing.attr('recent', true); } if (dom.hasNode('local')) { ongoing.attr('local', true); } ongoing.attrs(dynamicArgs); log(ongoing); api.search(ongoing.encode(), success, failure); }, Popular: function(success, failure, dynamicArgs) { var q = new query({ 'attrs': { 'p.ga': profile.gaId, 'p.id': profile.visitorId } }); q.filterClicks(); q.attrs(dynamicArgs); log(q); api.popular(q.encode(), success, failure); }, Recent: function(success, failure, dynamicArgs) { var q = new query({ 'attrs': { 'p.ga': profile.gaId, 'p.id': profile.visitorId } }); q.filterClicks(); q.attrs(dynamicArgs); api.recent(q.encode(), success, failure); }, Related: function(success, failure, dynamicArgs) { var q = new query({ 'attrs': { 'url': location.href, 'title': document.title, 'description': dom.getMeta("description"), 'p.ga': profile.gaId, 'p.id': profile.visitorId } }); q.filterClicks(); q.attrs(dynamicArgs); log(q); api.related(q.encode(), success, failure); }, Best: function(success, failure, dynamicArgs) { var q = new query({ 'attrs': { 'p.ga': profile.gaId, 'p.id': profile.visitorId } }); q.filterClicks(); q.mergeProfile(); q.attrs(dynamicArgs); log(q); api.best(q, success, failure); }, Autocomplete: function(keywords, success, failure) { var data = { 'q': keywords, 'p.ga': profile.gaId, 'p.id': profile.visitorId }; log(q); api.autocomplete(data, success, failure); }, Install: function(options) { if (options === undefined) { options = {}; } // Reset and re-scan our components in the DOM // Because we are scanning, we need to reset our current shadow DOM dom.nodes = {}; // We reset externally as the scanShim function is recursive dom.scanShim(document.body); // Grab page meta and add it to the page object dom.dynamicMeta(document, page.meta); // old style, e.g. data-sj-meta-field // Add the canonical if it exists if (document.querySelectorAll !== undefined) { var can = document.querySelector("link[rel='canonical']"); if (can != undefined && can != null) { page.canonical = can.getAttribute("href"); // actual href value } } else { page.outdatedBrowser = true; // we want to ignore old browsers when reconciling data } // Add the readability MD5 hash for the page to the data sent with indexing ping var bodyText = readable(document.body); page.bodyChecksum = md5(bodyText); // Turn the meta into one long string var metaStr = ""; for (var key in page.meta) { if (page.meta.hasOwnProperty(key)) { metaStr += key + page.meta[key]; } } page.metaChecksum = md5(metaStr); // Send page for indexing if (!dom.hasNode('noindex')) { stack.push(['index']); } // Profile user automatically unless we specify not to if (dom.hasNode('noprofile')) { profile.send = false; } // Remove old overlay dom.eachNode('overlay', function(el) { el.parentNode.removeChild(el); }); processOptions(options); // Flush the queue flush(); // DOM elements can be used to specify a company and collection opts.company = dom.firstNodeVal('company', opts.company); opts.collection = dom.firstNodeVal('collection', opts.collection); if (!apiInitialize(opts)) return; // Find and Install the Search Results DIV. If it does not exist, create an overlay block to handle results instead if (!dom.hasNode('search-results')) { // No results node. Need an overlay installed dom.scanShim(overlay(dom.dynamicAttrs(this))); } dom.resultsNode = dom.firstNode('search-results'); // Set up the profiling functions if allowed if (profile.send) { log(profile); // Send the document title as profile text if (!profile.sent) { setTimeout(function() { stack.push(['profile', document.title]); profile.sent = true; }, dom.firstNodeVal('profile-delay', profile.delay)); } // Add click handlers to anchor tags to profile. This means // link anchor text is used to help profile what users are // interested in. var a = document.getElementsByTagName('a'); for (var i = 0; i < a.length; i++) { if (/^#/.test(a[i].getAttribute('href') + '')) { dom.bind(a[i], 'mousedown', function() { stack.push(['profile', (this.innerText === undefined ? this.textContent : this.innerText)]); }); } } } // Send conversions. We push onto the stack as the API is still initializing dom.eachNode('conv-type', function(node, attr) { stack.push(['conversion', node.getAttribute(attr), node.getAttribute(opts.prefix + 'conv-val')]); }); // SETUP RECOMMENDATIONS // Popular if (dom.hasNode('popular')) { SJ.Popular( function(response) { showResults(response, dom.firstNode('popular'), "popular"); }, function() {}, dom.dynamicAttrsUri(dom.firstNode('popular')) ); } // Recent if (dom.hasNode('recent')) { SJ.Recent( function(response) { showResults(response, dom.firstNode('recent'), "recent"); }, function() {}, dom.dynamicAttrsUri(dom.firstNode('recent')) ); } // Related if (dom.hasNode('related')) { SJ.Related( function(response) { showResults(response, dom.firstNode('related'), "related"); }, function() {}, dom.dynamicAttrsUri(dom.firstNode('related')) ); } // Best if (dom.hasNode('best')) { SJ.Best( function(response) { showResults(response, dom.firstNode('best'), "best"); }, function() {}, dom.dynamicAttrsUri(dom.firstNode('best')) ); } // SETUP SEARCH // Setup the search query process based on the options available. if (!dom.hasNode('search-query')) { return; } // We have search query nodes, so install event handlers accordingly dom.eachNode('search-query', function(node) { installSearchQuery(node); }); // Check for a search button, if exists, bind our search func to it if (dom.hasNode('search-query-go')) { dom.eachNode('search-query-go', function(node) { installSearchGo(node); }); } // There is not a search button, so we need other events to // to trigger searches accordingly if (dom.hasNode('search-results')) { // If there isn't a go button and they have defined a place for results dom.eachNode('search-query', function(node) { // Run the search if they put in a space character if (dom.hasNode('search-query-word')) { dom.bind(node, 'keyup', function(e) { if (e.keyCode === 32) { builtInSearch.apply(this); } }); } }); } }, /** * Mimics the behaviour of the _sj global variable */ push: function(value) { stack.push(value); }, /** * Sends a click tracking event. DEPRECATED - Will be removed at some point. Use token tracking instead. */ SendClick: function(qid, slot, injected, node) { var u = ""; if (node !== undefined) { u = node.getAttribute('href'); // This is a mousedown event } else { u = window.location.href; // No node, this must be the destination page } profile.addClickedUrl(u); if (injected === "undefined") { injected = ""; } api.pixel({ 'p.ga': profile.gaId, 'p.id': profile.visitorId, 'q.id': qid, 'q.sl': slot, 'q.in': injected, 'q.de': u }, '/stats/click'); }, /** * Start a new query */ Query: function(options) { if (typeof options === 'string') { options = { q: options }; } else if (typeof options !== 'object') { options = {}; } options.func = "search"; return (new query(options)); }, /** * Start a new recommendation */ Recommend: function(options) { if (typeof options === 'string') { options = { func: options }; } else if (typeof options !== 'object') { options = {}; } return (new query(options)); } }; /** * Import existing _sj global variable stack if it exists */ if (window._sj !== undefined && isArray(window._sj)) { processOptions(window._sj); window._sj = sj; } // Start scanning automatically with a constructed object var SJ = new sj(); (function() { apiInitialize(opts); loaded(window, function() { if (opts.scanOnLoad) { SJ.Install(); } }); })(); module.exports = SJ;