Last active
November 10, 2022 01:53
-
-
Save westc/b64f7fbbbc13fdd22f29b6f2c8449198 to your computer and use it in GitHub Desktop.
A simple search engine that only uses JavaScript and Google sheets. NO DATABASE REQUIRED!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Custom Search Engine Using Google Sheets</title> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js"></script> | |
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.3/js/bootstrap.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.3/vue.min.js"></script> | |
<script type="text/javascript"> | |
$(function() { | |
var GSHEET_JSON_URL = 'https://spreadsheets.google.com/feeds/list/1c2aJmDdLkbjW0ErfUB0sfiQ-pt6zdW5KWZgREWs0zvM/1/public/values?alt=json'; | |
var vueSearch = new Vue({ | |
el: '#vueSearch', | |
data: { | |
search: '', | |
links: [], | |
pages: [], | |
pageNumber: 1, | |
results: [], | |
perPage: 10, | |
loading: true, | |
error: null | |
}, | |
methods: { | |
changePage: function(number) { | |
location.href = '#vueSearch'; | |
this.pageNumber = number; | |
this.updateResults(); | |
}, | |
updateResults: function() { | |
var self = this; | |
// Clear all previous paging data | |
self.pages.splice(0, Infinity); | |
// Get an array of search terms as regexes | |
var searchTerms = []; | |
self.search.replace( | |
/([+\-])?(?:"([^"]+)"|(\/((?:[^\/]|\\.)+)\/(i?)|[\S]+))/g, | |
function(m, must, quoted, nonQuoted, rgxText, rgxOpts) { | |
var realRgxText = rgxText; | |
if (rgxText) { | |
try { | |
new RegExp(rgxText); | |
} | |
catch (e) { | |
rgxText = null; | |
} | |
} | |
rgxText = rgxText || ( | |
((quoted ? /^\b/.test(quoted) : !realRgxText) ? '\\b' : '') | |
+ YourJS.quoteRegExp(quoted || nonQuoted) | |
+ ((quoted && /\b$/.test(quoted)) ? '\\b' : '') | |
); | |
searchTerms.push({ | |
type: realRgxText ? 'regex' : 'string', | |
rgx: new RegExp(rgxText, (realRgxText ? rgxOpts : 'i') + 'g'), | |
must: must == '+' | |
? true | |
: must == '-' | |
? false | |
: undefined | |
}); | |
} | |
); | |
// Get the resulting list of links based on the scores. | |
var results = self.links | |
.reduce(function(scores, link, linkIndex) { | |
var score = searchTerms.reduce(function(score, searchTerm) { | |
if (score !== false) { | |
var matchesTitle = (link.title.match(searchTerm.rgx) || '').length; | |
var matchesURL = (link.url.match(searchTerm.rgx) || '').length; | |
var matchesDescr = ((link.description || '').match(searchTerm.rgx) || '').length; | |
var matchesKeywords = ((link.keywords || '').match(searchTerm.rgx) || '').length; | |
var matchesAny = !!(matchesTitle || matchesURL || matchesDescr || matchesKeywords); | |
return searchTerm.must !== !matchesAny | |
? searchTerm.must !== false | |
? matchesTitle * 8 + matchesURL * 4 + matchesDescr * 2 + matchesKeywords * 1 | |
: 1 | |
: false; | |
} | |
return score; | |
}, searchTerms.length ? 0 : 1); | |
if (score) { | |
scores.push({ link: link, score: score, index: linkIndex }); | |
} | |
return scores; | |
}, []) | |
.sort(function(a, b) { return (b.score - a.score) || (b.index - a.index); }) | |
.map(function(score) { return score.link; }); | |
// Setup the paging data | |
var pageCount = Math.max(1, Math.ceil(results.length / self.perPage)); | |
var pageNumber = self.pageNumber = YourJS.clamp(self.pageNumber, 1, pageCount); | |
if (pageNumber > 1) { | |
self.pages.push(self.getPageObject(1, results)); | |
} | |
if (pageNumber > 2) { | |
self.pages.push(self.getPageObject(pageNumber - 1, results)); | |
} | |
self.pages.push(self.getPageObject(pageNumber, results)); | |
if (pageNumber + 1 < pageCount) { | |
self.pages.push(self.getPageObject(pageNumber + 1, results)); | |
} | |
if (pageNumber < pageCount) { | |
self.pages.push(self.getPageObject(pageCount, results)); | |
} | |
// Set self.results to only the results that are shown in the current page. | |
self.results = results.slice((pageNumber - 1) * self.perPage, pageNumber * self.perPage).map(function(link) { | |
link = jQuery.extend({}, link); | |
return { | |
title: self.markTerms(link.title, searchTerms), | |
description: self.markTerms(link.description, searchTerms), | |
url: link.url | |
}; | |
}); | |
}, | |
markTerms: function(strIn, terms) { | |
return terms | |
.reduce(function(str, term) { | |
return (YourJS.matchAll(strIn, term.rgx) || []).reduce(function(str, termMatch) { | |
return str.replace( | |
new RegExp('((?:[\\x01\\x02]*[^\\x01\\x02]){' + termMatch.index + '})((?:[\\x01\\x02]*[^\\x01\\x02]){' + (termMatch[0].length) + '})'), | |
'$1\x01$2\x02' | |
); | |
}, str); | |
}, strIn) | |
.replace(/\x01/g, '<div class="highlight">').replace(/\x02/g, '</div>'); | |
}, | |
getPageObject: function(pageNumber, results) { | |
return { | |
text: pageNumber, | |
number: pageNumber, | |
caption: Math.min(results.length, 1 + (pageNumber - 1) * this.perPage) + ' to ' + Math.min(results.length, pageNumber * this.perPage) | |
}; | |
} | |
}, | |
watch: { | |
search: function(newValue, oldValue) { | |
this.pageNumber = 1; | |
this.updateResults(); | |
} | |
}, | |
mounted: function () { | |
$.ajax({ | |
dataType: "json", | |
url: GSHEET_JSON_URL + '&cache-buster=' + Math.random(), | |
success: function(data) { | |
if (data && data.feed && data.feed.entry && data.feed.entry[0]) { | |
var missing = ['url', 'title', 'description', 'keywords'].filter(function(name) { | |
return !data.feed.entry[0]['gsx$' + name]; | |
}); | |
if (missing[0]) { | |
vueSearch.error = 'You must add the "`' + missing.join('`", "`').replace(/, (?!.*,)/, ', and ') + '`" field' + (missing[1] ? 's' : '') + '.'; | |
return; | |
} | |
} | |
else { | |
vueSearch.error = 'The specified GSHEET_JSON_URL is invalid:<br>`' + GSHEET_JSON_URL + '`'; | |
return; | |
} | |
data.feed.entry.forEach(function(entry) { | |
vueSearch.links.push({ | |
url: entry.gsx$url.$t, | |
title: entry.gsx$title.$t, | |
description: entry.gsx$description.$t, | |
keywords: entry.gsx$keywords.$t | |
}); | |
}); | |
vueSearch.loading = false; | |
vueSearch.updateResults(); | |
}, | |
error: function() { | |
vueSearch.error = 'The specified GSHEET_JSON_URL does not contain JSON:<br>`' + GSHEET_JSON_URL + '`'; | |
vueSearch.loading = false; | |
} | |
}); | |
// Set focus to search box | |
$('#txtQuery').select(); | |
} | |
}); | |
}); | |
/* | |
YourJS - Your Very Own JS Library | |
http://yourjs.com | |
Copyright (c) 2015-2017 Christopher West | |
Licensed under the MIT license. | |
*/ | |
(function(h,c,k){function l(a,b){return function(){return a[b].apply(a,arguments)}}var g=this,m;(function(a,b){m=function(){b||(b=1,g[c]=a);return d}})(g[c]);var n=l([].slice,"call");var d={alias:l,clamp:function(a,b,f){return a<b?b:a>f?f:a},info:function(){return{name:c,version:h,toString:d.toString}},matchAll:function(a,b,f){var c=0,d=[];a.replace(b,function(e){e=n(arguments,0,-1);e.index=e.pop();e.input=a;e.source=b;f&&(e=f(e,++c));e!==k&&d.push(e)});return d.length?d:null},noConflict:m,quoteRegExp:function(a, | |
b){var c=a.replace(/[[\](){}.+*^$|\\?-]/g,"\\$&");return""===b||b?new RegExp(c,!0===b?"":b):c},slice:n,toString:function(){return"YourJS v"+h+" ("+c+")"}};[].forEach(function(a){a()});"undefined"!==typeof exports?("undefined"!==typeof module&&module.exports&&(exports=module.exports=d),(exports[c]=d)[c]=k):g[c]=d})("2.2.0","YourJS"); | |
</script> | |
<link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.3/css/bootstrap.min.css"> | |
<style type="text/css"> | |
body { | |
padding: 10px; | |
} | |
[v-cloak] { display: none; } | |
div.highlight { | |
display: inline; | |
box-shadow: 0 0 0.5em red; | |
padding: 0.125em; | |
border-radius: 0.5em; | |
} | |
.btn { | |
background-image: linear-gradient(to bottom, rgba(255,255,255,0.5), rgba(255,255,255,0.2) 49%, rgba(0,0,0,0.15) 51%, rgba(0,0,0,0.05)); | |
background-repeat: repeat-x; | |
} | |
</style> | |
</head> | |
<body> | |
<h1>Custom Search Engine Using Google Sheets</h1> | |
<p class="lead">A simple search engine built just JavaScript and <a href="https://docs.google.com/spreadsheets/d/1c2aJmDdLkbjW0ErfUB0sfiQ-pt6zdW5KWZgREWs0zvM/edit?usp=sharing" target="_blank">this Google sheet document</a>. The full version of this search can be found at <a href="http://gotochriswest.com/search">GoToChrisWest.com</a>.</p> | |
<div id="vueSearch" v-cloak> | |
<div class="input-group mb-3"> | |
<div class="input-group-prepend"> | |
<label class="input-group-text" for="txtQuery">Search Terms</label> | |
</div> | |
<input type="text" id="txtQuery" v-model="search" class="form-control" placeholder="whatever you want to search for..."> | |
</div> | |
<div v-for="(link, linkIndex) in results" v-bind:style="linkIndex + 1 < results.length ? 'margin-bottom: 1em;' : ''"> | |
<div style="font-size: 1.25em;"><a v-bind:href="link.url" target="_blank" v-html="link.title"></a></div> | |
<div v-html="link.description"></div> | |
<div style="color: #080;">{{ link.url }}</div> | |
</div> | |
<div v-if="error" class="alert alert-danger text-center lead" v-html="error.replace(/`([^`]+)`/g, '<code>$1</code>')">{{error}}</div> | |
<div v-else-if="loading" class="text-center lead">Loading search results from Google Sheets...</div> | |
<div v-else-if="!results.length" class="text-center lead"> | |
No results were found for <code>{{ search }}</code>. | |
</div> | |
<div class="text-center"> | |
<div class="d-inline-block"> | |
<div class="input-group mb-3" v-if="pages.length"> | |
<div class="input-group-prepend"> | |
<span class="input-group-text" for="txtQuery">Pages</span> | |
</div> | |
<div class="input-group-append"> | |
<button v-for="page in pages" v-bind:class="'btn btn-outline-secondary' + (page.number == pageNumber ? ' active' : '')" type="button" style="float: initial;" v-on:click="changePage(page.number)" v-bind:title="page.caption">{{ page.text }}</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</body> | |
</html> |
Figured out my own question for how to filter. You can preview it here on stack overflow if anyone ever needs it. I may build a fork if I ever get the time.
Thanks for the great code! There is a typo, instead of "matchesTitle * 8 + matchesURL * 4 + matchesDescr * 2 + matchesKeywords * 1" it should be "matchesTitle * 8 + matchesURL * 4 + matchesDescr * 2 + matchesKeywords * 1+score", otherwise search engine only cares about the last word
Something changed about google sheets APIs, I get the following error: The specified GSHEET_JSON_URL does not contain JSON:
https://spreadsheets.google.com/feeds/list/1c2aJmDdLkbjW0ErfUB0sfiQ-pt6zdW5KWZgREWs0zvM/1/public/values?alt=json
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Where could you filter the JSON results? Such as
entry.filter(entry => entry.gsx$somedataname.$t.length > 7 );
?