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
JavaScript
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;