/
core.cljs
307 lines (266 loc) · 10.6 KB
/
core.cljs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
; Copyright (c) Rich Hickey. All rights reserved.
; The use and distribution terms for this software are covered by the
; Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php)
; which can be found in the file epl-v10.html at the root of this distribution.
; By using this software in any fashion, you are agreeing to be bound by
; the terms of this license.
; You must not remove this notice, or any other, from this software.
(ns twitterbuzz.core
(:require [twitterbuzz.dom-helpers :as dom]
[clojure.string :as string]
[goog.string :as gstring]
[goog.net.Jsonp :as jsonp]
[goog.Timer :as timer]
[goog.events :as events]
[goog.events.EventType :as event-type]
[goog.dom.classes :as classes]))
(def results-per-page 100)
(def max-missing-query 20)
(def initial-state {:max-id 0
:graph {}
:listeners {}
:tweet-count 0
:search-tag nil
:ignore-mentions #{}})
(def state (atom initial-state))
(defn add-listener
"Add a listener to the graph."
[graph k f]
(let [l (-> graph :listeners k)]
(assoc-in graph [:listeners k] (conj l f))))
(defn register
"Register a function to be called when new data arrives specifying
the event to receive updates for."
[event f]
(swap! state add-listener event f))
(def twitter-uri (goog.Uri. "http://search.twitter.com/search.json"))
(defn search-tag
"Get the current tag value from the page."
[]
(.-value (dom/get-element :twitter-search-tag)))
(defn retrieve
"Send request to twitter."
[payload callback error-callback]
(.send (goog.net.Jsonp. twitter-uri)
payload
callback
error-callback))
(defn send-event
"For the given event, call every listener for that event, passing the
message."
([event]
(send-event event nil))
([event message]
(doseq [f (-> @state :listeners event)]
(f message))))
(defn parse-mentions
"Given a map representing a single tweet, return all mentions that
are found within the tweet text. Twitter usernames are not case
sensitive so mentioned usernames are always returned in lower case."
[tweet]
(map #(string/lower-case (apply str (drop 1 %)))
(re-seq (re-pattern "@\\w+") (:text tweet))))
(defn add-mentions
"Add the user to the mentions map for first user she mentions,
clearing the mentions map of user."
[graph user mentions]
(if-let [mention (first mentions)]
(let [graph (assoc graph mention (get graph mention {:username mention}))
node (get graph mention)
mentions-map (get node :mentions {})
graph (assoc-in graph [mention :mentions user] (inc (get mentions-map user 0)))]
(assoc-in graph [user :mentions] {}))
graph))
(defn update-graph
"Given a graph and a sequence of new tweets in chronological order,
update the graph."
[graph tweet-maps]
(reduce (fn [acc tweet]
(let [user (string/lower-case (:from_user tweet))
mentions (parse-mentions tweet)
node (get acc user {:mentions {}})]
(-> (assoc acc user
(assoc node :last-tweet (:text tweet)
:image-url (:profile_image_url tweet)
:username (:from_user tweet)))
(add-mentions user mentions))))
graph
(map #(select-keys % [:text :from_user :profile_image_url]) tweet-maps)))
(defn num-mentions [user]
(reduce + (vals (:mentions user))))
(defn update-state
"Given an old state, maximum id and a new sequence of tweets, return
an updated state."
[old-state max-id tweets]
(-> old-state
(assoc :max-id max-id)
(update-in [:tweet-count] #(+ % (count tweets)))
(assoc :graph (update-graph (:graph old-state) (reverse tweets)))))
(defn new-tweets [max-id tweets]
(filter #(> (:id %) max-id) tweets))
(defn new-tweets-callback
"Given a json object, update the state with any new information and
fire events."
[json]
(let [{:keys [max_id results]} (js->clj json :keywordize-keys true)
tweets (new-tweets (:max-id @state) results)]
(do (swap! state update-state max_id tweets)
(send-event :new-tweets tweets)
(send-event :graph-update (:graph @state)))))
(defn set-tweet-status [css-class message]
(doto (dom/set-text :tweet-status message)
(classes/set (name css-class))))
(defn error-callback [error]
(set-tweet-status :error "Twitter error"))
(defn add-missing-tweets
"Add missing data to the graph."
[graph tweets]
(let [new-tweets (reduce (fn [acc next-tweet]
(assoc acc (string/lower-case (:from_user next-tweet))
next-tweet))
{}
(sort-by :id tweets))]
(reduce (fn [acc [node-name {:keys [from_user text profile_image_url]}]]
(if-let [old-tweet (get graph node-name)]
(if (:last-tweet old-tweet)
acc
(assoc acc node-name
(merge old-tweet {:last-tweet text
:image-url profile_image_url
:username from_user})))
acc))
graph
new-tweets)))
(defn ignored
"Given a list of the usernames for missing tweets and the tweets
which are the result of a query for this missing data, return a set of
twitter usernames which will be ignored moving forward.
Names may be ignored because the twitter user does not exist or
just doesn't tweet."
[missing tweets]
(when (< (count tweets) results-per-page)
(let [users (set (map #(string/lower-case (:from_user %)) tweets))
missing (map string/lower-case missing)]
(reduce (fn [acc next-missing]
(if (contains? users next-missing)
acc
(conj acc next-missing)))
#{}
missing))))
(defn add-missing-callback
"Update the graph and the ignore-mentions list when data is received
from a missing user query."
[missing json]
(let [response (js->clj json :keywordize-keys true)
tweets (:results response)]
(if-let [error (:error response)]
(set-tweet-status :error error)
(do (swap! state (fn [old-state]
(assoc old-state
:graph (add-missing-tweets (:graph old-state) tweets)
:ignore-mentions (into (:ignore-mentions old-state)
(ignored missing tweets)))))
(send-event :new-tweets [])
(send-event :graph-update (:graph @state))))))
(defn missing-tweets
"Return a list of usernames with missing tweets in the graph."
[graph]
(->> (map second graph)
(remove :last-tweet)
(map :username)
(remove empty?)
(remove (:ignore-mentions @state))))
(defn fetch-mentioned-tweets
"Query twitter for usernames which are currently missing data in the
graph. Limit this query to max-missing-query names."
[missing]
(let [q (apply str (interpose " OR " (map #(str "from:" %)
(take max-missing-query missing))))]
(set-tweet-status :okay "Fetching mentioned tweets")
(retrieve (doto (js-obj)
(aset "q" q)
(aset "rpp" results-per-page))
#(add-missing-callback missing %)
error-callback)))
(defn fetch-new-tweets
"Use the current search tag to fetch new tweets from twitter."
[]
(when-let [tag (:search-tag @state)]
(set-tweet-status :okay "Fetching tweets")
(retrieve (doto (js-obj)
(aset "q" tag)
(aset "rpp" results-per-page))
new-tweets-callback
error-callback)))
(defn fetch-tweets
"If there are missing tweets then fetch them, if not fetch new tweets."
[]
(let [missing (missing-tweets (:graph @state))]
(if (seq missing)
(fetch-mentioned-tweets missing)
(fetch-new-tweets))))
(defn poll
"Request new data from twitter once every 24 seconds. This will put
you at the 150 request/hour rate limit. We can speed it up for the demo."
[]
(let [timer (goog.Timer. 24000)]
(do (fetch-tweets)
(. timer (start))
(events/listen timer goog.Timer/TICK fetch-tweets))))
(defn do-track-button-clicked
"When the track button is clicked, reset to the initial state
keeping only the event listeners."
[]
(do (let [listeners (:listeners @state)]
(reset! state (assoc initial-state :listeners listeners :search-tag (search-tag))))
(fetch-tweets)
(send-event :track-clicked)))
(defn start-app
"Start polling and listen for UI events."
[]
(do (poll)
(events/listen (dom/get-element :twitter-search-button)
"click"
do-track-button-clicked)
(events/listen (dom/get-element :twitter-search-tag)
event-type/CHANGE
do-track-button-clicked)))
(start-app)
(defn link [url s]
(str "<a href='" url "' target='_twitterbuzz'>" s "</a>"))
(defn markup
"Add markup to tweet text to activate links."
[s]
(let [markup-f (fn [s] (let [w (string/trim s)]
(cond (gstring/startsWith w "http://")
(link w w)
(gstring/startsWith w "@")
(link (str "http://twitter.com/#!/" (re-find #"\w+" w)) w)
:else s)))]
(string/join " " (map markup-f (string/split s #"[ ]")))))
(comment
(parse-mentions {:text "What's up @sue: and @Larry"})
(add-mentions {} "jim" ["sue"])
(add-mentions {"sue" {}} "jim" ["sue"])
(def tweets [{:profile_image_url "url1"
:from_user "Jim"
:text "I like cookies!"}
{:profile_image_url "url2"
:from_user "sue"
:text "Me to @jim."}
{:profile_image_url "url3"
:from_user "bob"
:text "You shouldn't eat so many cookies @sue"}
{:profile_image_url "url4"
:from_user "sam"
:text "@Bob that was a cruel thing to say to @Sue."}
{:profile_image_url "url5"
:from_user "ted"
:text "@foo is awesome!"}])
(def graph (update-graph {} tweets))
(count graph)
(num-mentions (get graph "sue"))
(num-mentions (get graph "bob"))
(num-mentions (get graph "sam"))
(take 1 (reverse (sort-by #(num-mentions (second %)) (seq graph))))
)