├── .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 | 
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 |
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 |
14 |
15 |
24 |
25 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------