├── .gitignore ├── README.md ├── app.yaml ├── hnbutton ├── button.html └── hnbutton.go ├── static ├── hn.js └── hn.min.js └── test.html /.gitignore: -------------------------------------------------------------------------------- 1 | *.6 2 | *.8 3 | *.o 4 | *.so 5 | *.cgo?.* 6 | _cgo_* 7 | _test* 8 | *.out 9 | _obj 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Embeddable Hacker News vote / counter button 2 | 3 | ![HN Button](http://img.skitch.com/20120415-bp8igiq74w53f91swt6tcy9cx8.jpg) 4 | 5 | Async, embeddable submit + vote counter button for Hacker News. 6 | 7 | - If the story has not been posted to HN, "Submit" button is shown, otherwise latest point count is displayed. 8 | - Auto-detects Google Analytics and registers clicks events (see reports under `Traffic Sources > Social > Social Plugins`). 9 | 10 | ### Embedding the button 11 | 12 | **Step 1**, place the HN link where you want the button appear on the page: 13 | 14 | ```html 15 | 16 | Vote on HN 17 | 18 | 19 | Vote on HN 20 | ``` 21 | 22 | **Step 2**, include the (async) javascript file: 23 | 24 | ```html 25 | 26 | ``` 27 | 28 | _Note: you can safely embed multiple buttons on the same page._ 29 | 30 | ### Misc 31 | 32 | * Kudos to @sbashyal and @stbullard for the button styling (hnlike.com) 33 | * Kudos to Algolia for an [awesome HN API](http://hn.algolia.com/) 34 | * (MIT License) - Copyright (c) 2012 Ilya Grigorik 35 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | application: hnbutton 2 | version: 17 3 | runtime: go 4 | api_version: go1 5 | 6 | default_expiration: "1d" 7 | 8 | handlers: 9 | - url: /static 10 | static_dir: static 11 | 12 | - url: /.* 13 | script: _go_app 14 | -------------------------------------------------------------------------------- /hnbutton/button.html: -------------------------------------------------------------------------------- 1 | {{define "button"}} 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 |
19 | {{if .ObjectID}} 20 | 21 | {{else}} 22 | 23 | {{end}} 24 | 25 | 26 | 27 | 28 | 29 | 45 | 46 | 47 |
30 | 31 | 32 | 33 | 34 | 41 | 42 | 43 |
35 | {{if .ObjectID}} 36 | {{.Points}} 37 | {{else}} 38 | submit 39 | {{end}} 40 |
44 |
48 |
49 |
50 |
51 | 52 | 53 | {{end}} 54 | -------------------------------------------------------------------------------- /hnbutton/hnbutton.go: -------------------------------------------------------------------------------- 1 | package button 2 | 3 | import ( 4 | "appengine" 5 | "appengine/memcache" 6 | "appengine/urlfetch" 7 | "crypto/md5" 8 | "encoding/json" 9 | "fmt" 10 | "hash" 11 | "html/template" 12 | "io/ioutil" 13 | "net/http" 14 | "net/url" 15 | "time" 16 | ) 17 | 18 | var buttonTemplate, _ = template.New("page").ParseFiles("hnbutton/button.html") 19 | 20 | type hnapireply struct { 21 | NbHits int 22 | Hits []Hit 23 | } 24 | 25 | type Hit struct { 26 | ObjectID string 27 | Points int 28 | Hits int 29 | Num_comments int 30 | Author string 31 | } 32 | 33 | func Button(w http.ResponseWriter, r *http.Request) { 34 | c := appengine.NewContext(r) 35 | c.Infof("Requested URL: %v", r.URL) 36 | 37 | defer func() { 38 | if err := recover(); err != nil { 39 | c.Errorf("%s", err) 40 | c.Errorf("%s", "Traceback: %s", r) 41 | 42 | reply := map[string]string{ 43 | "error": fmt.Sprintf("%s", err), 44 | } 45 | 46 | resp, _ := json.Marshal(reply) 47 | w.WriteHeader(500) 48 | w.Write(resp) 49 | } 50 | }() 51 | 52 | query, _ := url.ParseQuery(r.URL.RawQuery) 53 | req_url, ok_url := query["url"] 54 | req_title, ok_title := query["title"] 55 | 56 | if !ok_url || !ok_title { 57 | panic("required parameters: url, title") 58 | } 59 | 60 | _, err := url.Parse(req_url[0]) 61 | if err != nil { 62 | panic("Invalid URL: " + err.Error()) 63 | } 64 | 65 | var h hash.Hash = md5.New() 66 | h.Write([]byte(req_url[0])) 67 | var hkey string = fmt.Sprintf("%x", h.Sum(nil)) 68 | 69 | c.Infof("Fetching HN data for: %s, %s\n", req_title, req_url) 70 | 71 | var item Hit 72 | if cachedItem, err := memcache.Get(c, hkey); err == memcache.ErrCacheMiss { 73 | pageData := "http://hn.algolia.com/api/v1/search?tags=story&restrictSearchableAttributes=url&query=" + 74 | url.QueryEscape(req_url[0]) 75 | 76 | client := &http.Client{ 77 | Transport: &urlfetch.Transport{ 78 | Context: c, 79 | Deadline: time.Duration(15) * time.Second, 80 | }, 81 | } 82 | 83 | resp, err := client.Get(pageData) 84 | if err != nil { 85 | panic("Cannot fetch HN data: " + err.Error()) 86 | } 87 | 88 | defer resp.Body.Close() 89 | body, _ := ioutil.ReadAll(resp.Body) 90 | var hnreply hnapireply 91 | if err := json.Unmarshal(body, &hnreply); err != nil { 92 | panic("Cannot unmarshall JSON data") 93 | } 94 | 95 | if hnreply.NbHits == 0 { 96 | item.Hits = 0 97 | } else { 98 | item.Hits = hnreply.NbHits 99 | item.ObjectID = hnreply.Hits[0].ObjectID 100 | item.Points = hnreply.Hits[0].Points 101 | item.Num_comments = hnreply.Hits[0].Num_comments 102 | item.Author = hnreply.Hits[0].Author 103 | } 104 | 105 | var sdata []byte 106 | if sdata, err = json.Marshal(item); err != nil { 107 | panic("Cannot serialize hit to JSON") 108 | } 109 | 110 | c.Debugf("Saving to memcache: %s", sdata) 111 | 112 | data := &memcache.Item{ 113 | Key: hkey, 114 | Value: sdata, 115 | Expiration: time.Duration(60) * time.Second, 116 | } 117 | 118 | if err := memcache.Set(c, data); err != nil { 119 | c.Errorf("Cannot store hit to memcache: %s", err.Error()) 120 | } 121 | 122 | } else if err != nil { 123 | panic("Error getting item from cache: %v") 124 | 125 | } else { 126 | if err := json.Unmarshal(cachedItem.Value, &item); err != nil { 127 | panic("Cannot unmarshall hit from cache") 128 | } 129 | c.Infof("Fetched from memcache: %i", item.ObjectID) 130 | } 131 | 132 | // Cache the response in the HTTP edge cache, if possible 133 | // http://code.google.com/p/googleappengine/issues/detail?id=2258 134 | w.Header().Set("Cache-Control", "public, max-age=300") 135 | w.Header().Set("Pragma", "Public") 136 | 137 | if item.Hits == 0 { 138 | c.Infof("No hits, rendering submit template") 139 | params := map[string]interface{}{"Url": req_url[0], "Title": req_title[0]} 140 | if err := buttonTemplate.ExecuteTemplate(w, "button", params); err != nil { 141 | panic("Cannot execute template") 142 | } 143 | 144 | } else { 145 | c.Infof("Points: %f, ID: %i \n", item.Points, item.ObjectID) 146 | 147 | if err := buttonTemplate.ExecuteTemplate(w, "button", item); err != nil { 148 | panic("Cannot execute template") 149 | } 150 | } 151 | } 152 | 153 | func Redirect(w http.ResponseWriter, r *http.Request) { 154 | http.Redirect(w, r, "https://github.com/igrigorik/hackernews-button", http.StatusFound) 155 | } 156 | 157 | func init() { 158 | http.HandleFunc("/button", Button) 159 | http.HandleFunc("/", Redirect) 160 | } 161 | -------------------------------------------------------------------------------- /static/hn.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, curly:true, browser:true, indent:2, maxerr:50, expr:true */ 2 | (function (w) { 3 | "use strict"; 4 | var j, 5 | d = w.document, 6 | getElementsByClassName = function(match, tag) { 7 | if (d.getElementsByClassName) { 8 | return d.getElementsByClassName(match); 9 | } 10 | var result = [], 11 | elements = d.getElementsByTagName(tag || '*'), 12 | i, elem; 13 | match = " " + match + " "; 14 | for (i = 0; i < elements.length; i++) { 15 | elem = elements[i]; 16 | if ((" " + (elem.className || elem.getAttribute("class")) + " ").indexOf(match) > -1) { 17 | result.push(elem); 18 | } 19 | } 20 | return result; 21 | }, 22 | hnAnchorElements = getElementsByClassName("hn-share-button", "a"), 23 | eventMethod = w.addEventListener ? "addEventListener" : "attachEvent", 24 | eventer = w[eventMethod], 25 | messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message", 26 | base = "//hnbutton.appspot.com"; 27 | 28 | w._gaq || (w._gaq = []); 29 | eventer(messageEvent, function (e) { 30 | if (e.origin === base && (e.data === "vote" || e.data === "submit")) { 31 | w._gaq.push(["_trackSocial", "Hacker News", e.data]); 32 | } 33 | }, false); 34 | 35 | w.HN = { 36 | render: function(anchor) { 37 | var title = anchor.getAttribute("data-title") || d.title, 38 | url = anchor.getAttribute("data-url") || w.location.href, 39 | i = d.createElement("iframe"); 40 | 41 | i.src = base + "/button?title=" + encodeURIComponent(title) + "&url=" + encodeURIComponent(url); 42 | i.scrolling = "auto"; 43 | i.frameBorder = "0"; 44 | i.width = "75px"; 45 | i.height = "20px"; 46 | i.className = "hn-share-iframe"; 47 | 48 | anchor.parentNode.insertBefore(i, anchor); 49 | anchor.parentNode.removeChild(anchor); 50 | } 51 | }; 52 | 53 | for (j = hnAnchorElements.length - 1; j >= 0; j--) 54 | w.HN.render(hnAnchorElements[j]); 55 | 56 | })(window); 57 | -------------------------------------------------------------------------------- /static/hn.min.js: -------------------------------------------------------------------------------- 1 | (function(w){var j,d=w.document,getElementsByClassName=function(match,tag){if(d.getElementsByClassName){return d.getElementsByClassName(match)}var result=[],elements=d.getElementsByTagName(tag||"*"),i,elem;match=" "+match+" ";for(i=0;i-1){result.push(elem)}}return result},hnAnchorElements=getElementsByClassName("hn-share-button","a"),eventMethod=w.addEventListener?"addEventListener":"attachEvent",eventer=w[eventMethod],messageEvent=eventMethod==="attachEvent"?"onmessage":"message",base="//hnbutton.appspot.com";w._gaq||(w._gaq=[]);eventer(messageEvent,function(e){if(e.origin===base&&(e.data==="vote"||e.data==="submit")){w._gaq.push(["_trackSocial","Hacker News",e.data])}},false);w.HN={render:function(anchor){var title=anchor.getAttribute("data-title")||d.title,url=anchor.getAttribute("data-url")||w.location.href,i=d.createElement("iframe");i.src=base+"/button?title="+encodeURIComponent(title)+"&url="+encodeURIComponent(url);i.scrolling="auto";i.frameBorder="0";i.width="75px";i.height="20px";i.className="hn-share-iframe";anchor.parentNode.insertBefore(i,anchor);anchor.parentNode.removeChild(anchor)}};for(j=hnAnchorElements.length-1;j>=0;j--){w.HN.render(hnAnchorElements[j])}})(window); 2 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Awesome Page 6 | 7 | 8 | 9 |
10 | Vote on HN 11 | 12 | Vote on HN 13 |
14 | 15 | 24 | 25 | 31 | 32 | 33 | 34 | --------------------------------------------------------------------------------