markov_twitter 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +230 -0
- data/bin/console +25 -0
- data/bin/markov_twitter +8 -0
- data/lib/markov_twitter/authenticator.rb +16 -0
- data/lib/markov_twitter/markov_builder/node.rb +192 -0
- data/lib/markov_twitter/markov_builder.rb +236 -0
- data/lib/markov_twitter/test_helper_methods.rb +175 -0
- data/lib/markov_twitter/tweet_reader.rb +20 -0
- data/lib/markov_twitter.rb +41 -0
- data/lib/version.rb +4 -0
- metadata +167 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 4cf59089020057ea5529984594a60fe7397d58f3
|
4
|
+
data.tar.gz: 2b9396221a1a511e0f7a2b324cadcc52595569b0
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 5346cb8d500dd6cc45f58bcbb1ff0025803c0f9559c30e0dec4f5c42f1afcc5ff8a009c501706df5010f7504b185d8fac4c5b53486b1873d5dd588bae848b2a3
|
7
|
+
data.tar.gz: c29df5a5f48de8089e193a2f884730880b4343fa3bd4682138dcbf9fbf824aa39898d00b18966129bbf406c14746f1d293a59eef282b4cdf37a96da968190b9c
|
data/README.md
ADDED
@@ -0,0 +1,230 @@
|
|
1
|
+
# markov_twitter
|
2
|
+
|
3
|
+
## setup: _installation_
|
4
|
+
|
5
|
+
Either:
|
6
|
+
|
7
|
+
```sh
|
8
|
+
gem install markov_twitter
|
9
|
+
```
|
10
|
+
|
11
|
+
or add it to a Gemfile:
|
12
|
+
|
13
|
+
```rb
|
14
|
+
gem "markov_twitter"
|
15
|
+
```
|
16
|
+
|
17
|
+
After doing this, require it as usual:
|
18
|
+
|
19
|
+
```rb
|
20
|
+
require "markov_twitter"
|
21
|
+
```
|
22
|
+
|
23
|
+
## setup: _twitter integration_
|
24
|
+
|
25
|
+
The source code of the gem (available on github [here](http://github.com/maxpleaner/markov_twitter)) includes a `.env.example` file which includes two environment variables. Both of them need to be changed to the values provided by Twitter. To get these credentials, create an application on the Twitter developer console. Then create a file identical to `.env.example` but named `.env` in the root of your project, and add the credentials there. Finally, add the [dotenv](https://github.com/bkeepers/dotenv) gem and call `Dotenv.load` right afterward.
|
26
|
+
|
27
|
+
The two environment variables that are needed are `TWITTER_API_KEY` and `TWITTER_SECRET_KEY`. They can alternatively be set on a per-invocation basis using the [env](https://ss64.com/bash/env.html) command in bash, e.g.:
|
28
|
+
|
29
|
+
```sh
|
30
|
+
env TWITTER_API_KEY=foo TWITTER_SECRET_KEY=bar ruby script.rb
|
31
|
+
```
|
32
|
+
|
33
|
+
Note that the callback URL or any of the OAuth stuff on the Twitter dev console is unnecessary. Specifically this requires only [application-only authentication](https://developer.twitter.com/en/docs/basics/authentication/overview/application-only).
|
34
|
+
|
35
|
+
## usage: _TweetReader_
|
36
|
+
|
37
|
+
First, initialize a [MarkovTwitter::Authenticator](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/Authenticator):
|
38
|
+
|
39
|
+
```rb
|
40
|
+
authenticator = MarkovTwitter::Authenticator.new(
|
41
|
+
api_key: ENV.fetch("TWITTER_API_KEY"),
|
42
|
+
secret_key: ENV.fetch("TWITTER_SECRET_KEY")
|
43
|
+
)
|
44
|
+
```
|
45
|
+
|
46
|
+
Then initialize [MarkovTwitter::TweetReader](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/TweetReader):
|
47
|
+
|
48
|
+
```rb
|
49
|
+
tweet_reader = MarkovTwitter::TweetReader.new(
|
50
|
+
client: authenticator.client
|
51
|
+
)
|
52
|
+
```
|
53
|
+
|
54
|
+
Lastly, fetch some tweets for an arbitrary username. Note that the [get_tweets](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/TweetReader:get_tweets) method will return the most recently 20 tweets only. This gem doesn't have a way to fetch more tweets than that.
|
55
|
+
|
56
|
+
```rb
|
57
|
+
tweets = tweet_reader.get_tweets(username: "@accidental575")
|
58
|
+
puts tweets.map(&:text).first # the newest
|
59
|
+
# => "Jets fan who stands for /\nnational anthem sits on /\nAmerican flag /\n#accidentalhaiku by @Deadspin \nhttps://t.co/INsLlMB31G"
|
60
|
+
```
|
61
|
+
|
62
|
+
## usage: _MarkovBuilder_
|
63
|
+
|
64
|
+
[MarkovTwitter::MarkovBuilder](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder) gets passed the list of tweet strings to its initialize:
|
65
|
+
|
66
|
+
```rb
|
67
|
+
chain = MarkovTwitter::MarkovBuilder.new(
|
68
|
+
phrases: tweets.map(&:text)
|
69
|
+
)
|
70
|
+
```
|
71
|
+
|
72
|
+
It internally stores the words in a [#nodes](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder:nodes) dict where keys are strings and values are [Node](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node) instances. A Node is created from each whitespace-separated entity. Punctuation is treated like any other non-whitespace character.
|
73
|
+
|
74
|
+
The linkages between words are automatically created ([Node#linkages](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:linkages)) and it's possible to evaluate the chain right away, producing a randomly generated sentence. There are three built in methods to evaluate the chain, but more can be constructed using lower-level methods. There are two ways these methods differ:
|
75
|
+
|
76
|
+
1. Do they build the result by walking along the :next or :prev nodes (forward or backward)?
|
77
|
+
|
78
|
+
2. How do they pick the first node, and how do they choose a node when there are no more linkages along the given direction (:prev or :next)?
|
79
|
+
|
80
|
+
Here are those three methods:
|
81
|
+
|
82
|
+
1. [evaluate](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder:evaluate)
|
83
|
+
|
84
|
+
- traverses rightward along :next
|
85
|
+
- when starting or stuck, picks any random word
|
86
|
+
|
87
|
+
```rb
|
88
|
+
5.times.map { chain.evaluate length: 10 }
|
89
|
+
# => [
|
90
|
+
# "by @FlayrahNews https://t.co/LbxzPQ5Zqv back. / together with dung! / American",
|
91
|
+
# "thought/ #accidentalhaiku by @news_24_365 https://t.co/kkfz5S3Kut pumpkin / Wes Anderson's Isle",
|
92
|
+
# "has been in a lot about / #accidentalhaiku by @UrbanLion_",c
|
93
|
+
# "them, my boyfriend used my friends. Or as / #accidentalhaiku",
|
94
|
+
# "25 years... / feeling it today. / to write /"
|
95
|
+
# ]
|
96
|
+
```
|
97
|
+
|
98
|
+
2. [evaluate_favoring_end](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder:evaluate_favoring_end)
|
99
|
+
|
100
|
+
- traverses leftward along :prev
|
101
|
+
- when starting or stuck, picks a word that was at the end of one of the original phrases.
|
102
|
+
- reverses the result before returning
|
103
|
+
|
104
|
+
```rb
|
105
|
+
5.times.map { chain.evaluate_favoring_end length: 10 }
|
106
|
+
# => [
|
107
|
+
# "revolution / to improve care, / #accidentalhaiku by @Deadspin https://t.co/INsLlMB31G",
|
108
|
+
# "to save the songs you thought/ #accidentalhaiku by @Mary_Mulan https://t.co/ixw2EQamHq",
|
109
|
+
# "adventure / together with dung! / #accidentalhaiku by @Deadspin https://t.co/INsLlMB31G",
|
110
|
+
# "harder / for / creativity? / #accidentalhaiku by @AlbertBrooks https://t.co/DzXbGeYh0Z",
|
111
|
+
# "/ Asking for 25 years... / #accidentalhaiku by @StratfordON https://t.co/k81u693AbV"
|
112
|
+
# ]
|
113
|
+
```
|
114
|
+
|
115
|
+
3. [evaluate_favoring_start](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder:evaluate_favoring_start)
|
116
|
+
|
117
|
+
- traverses rightward along :next
|
118
|
+
- when starting or stuck, picks a word that was at the start of one of the original phrases.
|
119
|
+
|
120
|
+
```rb
|
121
|
+
5.times.map { chain.evaluate_favoring_start length: 10 }
|
122
|
+
# => [
|
123
|
+
# "RT if you listened to / to get lost /",
|
124
|
+
# "Jets fan who stands for / #accidentalhaiku by @theloniousdev https://t.co/6Rb5F8XySy # ",
|
125
|
+
# "The first trailer for / and never come back. # /",
|
126
|
+
# "Zooey Deschanel / and never come back. / house in # ",
|
127
|
+
# "Oh my friends. Or as / #accidentalhaiku by @timkaine https://t.co/4pgknpmom5 # "
|
128
|
+
# ]
|
129
|
+
```
|
130
|
+
|
131
|
+
Note that it is possible to manually change the lists of start nodes and end nodes using [MarkovBuilder#start_nodes](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder:start_nodes) and [MarkovBuilder#end_nodes](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder:end_nodes)
|
132
|
+
|
133
|
+
## advanced usage: _custom evaluator_
|
134
|
+
|
135
|
+
The three previously mentioned methods all use [_evaluate](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder:_evaluate) under the hood. This method supports any permutation of the following keyword args (all except start_node and probability_bounds are required).
|
136
|
+
|
137
|
+
- **length**
|
138
|
+
number of nodes in the result
|
139
|
+
- **direction**
|
140
|
+
:next or :prev
|
141
|
+
- **start_node**
|
142
|
+
the node to use at the beginning
|
143
|
+
- **probability_bounds**
|
144
|
+
_Array<Int1,Int2>_ where _0 <= Int1 <= Int2 <= 100_
|
145
|
+
This is essentially used to "stack the dice", so to speak. Internally, smaller probabilities are checked first. So if A has 50% likelihood and B/C/D/E/F each have 10% likelihood, then B/C/D/E/F can be guaranted by using [0,50] as probability_bounds. This 'stacked' probability is applied any time the program chooses a :next or :prev option.
|
146
|
+
- **node_finder**
|
147
|
+
A lambda which gets run when the evaluator is starting or stuck. It gets passed random nodes one-by-one. The first one for which the block returns a truthy value is used.
|
148
|
+
|
149
|
+
Note that [_evaluate](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder:_evaluate) returns nodes and so the values must be manually fetched and joined. Here's an example of providing a custom node_finder lambda so that all phrases in the result start with "the":
|
150
|
+
|
151
|
+
```rb
|
152
|
+
5.times.map do
|
153
|
+
nodes = chain._evaluate(
|
154
|
+
direction: :next,
|
155
|
+
length: 10,
|
156
|
+
node_finder: -> (node) {
|
157
|
+
node.value.downcase == "the"
|
158
|
+
}
|
159
|
+
)
|
160
|
+
nodes.map(&:value).join " "
|
161
|
+
end
|
162
|
+
# => [
|
163
|
+
# "the rain / #accidentalhaiku by @theloniousdev https://t.co/6Rb5F8XySy The first trailer",
|
164
|
+
# "The first trailer for / #accidentalhaiku by @shiku___ https://t.co/ZutjdsopAo the",
|
165
|
+
# "the songs you thought/ #accidentalhaiku by @Mary_Mulan https://t.co/ixw2EQamHq The first",
|
166
|
+
# "The first trailer for / #accidentalhaiku by @UrbanLion_ https://t.co/bvM6eeXGj5 The",
|
167
|
+
# "the rain / and start / I THOUGHT MY BOYFRIEND"
|
168
|
+
# ]
|
169
|
+
```
|
170
|
+
|
171
|
+
## advanced usage: _linkage manipulation_
|
172
|
+
|
173
|
+
There are manipulations available at the [Node](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node) level (accessible through the [MarkovBuilder#nodes](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder:nodes) dict). Keep in mind that there is only a single Node for each unique string. There can be many references to it from other nodes' linkages, but since there is still only a single object, each unique string only has a single set of :next and :previous linkages emanating from it.
|
174
|
+
|
175
|
+
Although the core linkage data is accessible in [Node#linkages](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:linkages) and [Node#total_num_inputs](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:total_num_inputs), they should not be manipulated directly via these references. Rather, use one of the following methods which are automatically balancing in terms of keeping :next and :previous probabilities mirrored and ensuring that the probabilities sum to 1. That is to say, if I add _node1_ as the :next linkage of _node2_, then _node1_ will have its :prev probabilities balanced and _node2_ will have its :next probabilities balanced.
|
176
|
+
|
177
|
+
1. [#add_next_linkage(child_node)](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:add_next_linkage)
|
178
|
+
adds a linkage in the :next direction or increases its likelihood
|
179
|
+
2. [#add_prev_linkage(parent_node)](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:add_prev_linkage)
|
180
|
+
adds a linkage in the :prev direction or increases its likelihood
|
181
|
+
3. [#remove_next_linkage(child_node)](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:remove_next_linkage)
|
182
|
+
removes a linkage in the :next direction or decreases its likelihood
|
183
|
+
4. [#remove_prev_linkage(parent_node)](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:remove_prev_linkage)
|
184
|
+
removes a linkage in the :prev direction or decreases its likelihood
|
185
|
+
5. [#add_linkage!(direction, other_node, probability)](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:add_linkage!)
|
186
|
+
Force-sets the probability of a linkage. Adjusts the other probabilities so they still sum to 1.
|
187
|
+
6. [#remove_linkage!(direction, other_node)](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:remove_linkage!)
|
188
|
+
Completely removes a linkage as an option. Adjusts other probabilities so they still sum to 1.
|
189
|
+
|
190
|
+
All of these methods can be safely run many times. Note that `remove_next_linkage` and `remove_prev_linkage` do _not_ completely remove the node from the list of options. They just decrement its probability an amount determined by [Node#total_num_inputs](http://rubydoc.info/gems/markov_twitter/MarkovTwitter/MarkovBuilder/Node:total_num_inputs).
|
191
|
+
|
192
|
+
## development: code organization
|
193
|
+
|
194
|
+
The gem boilerplate was scaffolded using a gem I made, [gemmyrb](http://github.com/maxpleaner/gemmyrb).
|
195
|
+
|
196
|
+
Test scripts are in the [spec/](http://github.com/maxpleaner/markov_twitter/tree/master/spec) folder, although some helper methods are written into the application code at [lib/markov_twitter/test_helper_methods.rb](http://github.com/maxpleaner/markov_twitter/tree/master/lib/markov_twitter/test_helper_methods.rb).
|
197
|
+
|
198
|
+
The application code is in [lib/](http://github.com/maxpleaner/markov_twitter/tree/lib).
|
199
|
+
|
200
|
+
Documentation is built with [yard](https://github.com/lsegal/yard) into [doc/](http://github.com/maxpleaner/markov_twitter/tree/master/doc) - it's viewable [on rubydoc](http://rubydoc.info/gems/markov_twitter).
|
201
|
+
|
202
|
+
## development: tests
|
203
|
+
|
204
|
+
To run the tests, install markov_twitter with the development dependencies:
|
205
|
+
|
206
|
+
```rb
|
207
|
+
gem install markov_twitter --development
|
208
|
+
```
|
209
|
+
|
210
|
+
Then run `rspec` in the root of the repo.
|
211
|
+
|
212
|
+
There are 40 test cases at time of writing.
|
213
|
+
|
214
|
+
By default, Webmock will prevent any real HTTP calls for the twitter-related tests, but this can be disabled (and real Twitter data used) by running the test suite with an environment variable:
|
215
|
+
|
216
|
+
```sh
|
217
|
+
env DISABLE_WEBMOCK=true rspec
|
218
|
+
```
|
219
|
+
|
220
|
+
## development: building docs
|
221
|
+
|
222
|
+
Docs are built with `yard` from the command line. It has 100% documentation at time of writing. If when building, it shows that something is undocumented, run `yard --list-undoc` to find out where it is.
|
223
|
+
|
224
|
+
## development: todos
|
225
|
+
|
226
|
+
Things which would be interesting to add:
|
227
|
+
|
228
|
+
- dictionary-based search and replace
|
229
|
+
- part-of-speech-based search and replace
|
230
|
+
|
data/bin/console
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'pry'
|
4
|
+
require 'dotenv'
|
5
|
+
require 'markov_twitter'
|
6
|
+
|
7
|
+
Dotenv.load
|
8
|
+
|
9
|
+
authenticator = MarkovTwitter::Authenticator.new(
|
10
|
+
api_key: ENV.fetch("TWITTER_API_KEY"),
|
11
|
+
secret_key: ENV.fetch("TWITTER_SECRET_KEY")
|
12
|
+
)
|
13
|
+
|
14
|
+
tweet_reader = MarkovTwitter::TweetReader.new(
|
15
|
+
client: authenticator.client
|
16
|
+
)
|
17
|
+
|
18
|
+
tweets = tweet_reader.get_tweets(username: "@accidental575")
|
19
|
+
|
20
|
+
chain = MarkovTwitter::MarkovBuilder.new(
|
21
|
+
phrases: tweets.map(&:text)
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
Pry.start
|
data/bin/markov_twitter
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# Wrapper for the twitter gem's client.
|
2
|
+
class MarkovTwitter::Authenticator
|
3
|
+
|
4
|
+
# @return [Twitter::Client]
|
5
|
+
attr_reader :client
|
6
|
+
|
7
|
+
# @param api_key [String] should be stored in ENV var.
|
8
|
+
# @param secret_key [String] should be stored in ENV var.
|
9
|
+
def initialize(api_key:, secret_key:)
|
10
|
+
@client = Twitter::REST::Client.new do |config|
|
11
|
+
config.consumer_key = api_key
|
12
|
+
config.consumer_secret = secret_key
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
@@ -0,0 +1,192 @@
|
|
1
|
+
class MarkovTwitter::MarkovBuilder
|
2
|
+
|
3
|
+
# Represents a single node in a Markov chain.
|
4
|
+
class Node
|
5
|
+
|
6
|
+
# @return [String] a single token, such as a word.
|
7
|
+
attr_reader :value
|
8
|
+
|
9
|
+
# @return [Hash<Symbol, Hash<String, Float>>]
|
10
|
+
# the :next and :previous linkages.
|
11
|
+
# - Outer hash is keyed by the direction (:next, :prev).
|
12
|
+
# - Inner hash represents possible traversals -
|
13
|
+
# also keyed by string value, its values are probabilities
|
14
|
+
# representing the likelihood of choosing that route.
|
15
|
+
attr_accessor :linkages
|
16
|
+
|
17
|
+
# @return [Hash<Symbol>, Integer]
|
18
|
+
# the total number of inputs added in each direction.
|
19
|
+
# - also used to re-calculate probabilities.
|
20
|
+
attr_accessor :total_num_inputs
|
21
|
+
|
22
|
+
# @return [Hash<String,Node>]
|
23
|
+
# a reference to the attr of the parent MarkovBuilder
|
24
|
+
attr_reader :nodes
|
25
|
+
|
26
|
+
# @param value [String] for example, a word.
|
27
|
+
# @param nodes [Hash<String,Node>].
|
28
|
+
def initialize(value:, nodes:)
|
29
|
+
@value = value
|
30
|
+
@linkages = { next: Hash.new(0), prev: Hash.new(0) }
|
31
|
+
@total_num_inputs = { next: 0, prev: 0 }
|
32
|
+
@nodes = nodes
|
33
|
+
end
|
34
|
+
|
35
|
+
# Adds a single node to the :next linkages and updates probabilities.
|
36
|
+
# Also updates the opposite direction,
|
37
|
+
# e.g. :prev will be updated if something is added to :next.
|
38
|
+
# @param direction [Symbol] either :next or :prev.
|
39
|
+
# @param other_node [Node]
|
40
|
+
# @param mirror_change [Boolean]
|
41
|
+
# whether to update the opposite direction, defaults to true.
|
42
|
+
# @return [void]
|
43
|
+
def add_and_adjust_probabilities(direction, other_node, mirror_change=true)
|
44
|
+
@nodes[other_node.value] ||= other_node
|
45
|
+
total_num_inputs[direction] += 1
|
46
|
+
unit = get_probability_unit(direction)
|
47
|
+
probability_multiplier = (total_num_inputs[direction] - 1) * unit
|
48
|
+
linkages[direction].each_key do |node_key|
|
49
|
+
linkages[direction][node_key] *= probability_multiplier
|
50
|
+
end
|
51
|
+
linkages[direction][other_node.value] += unit
|
52
|
+
# Add a linkage in the opposite direction to keep :next and :prev in sync
|
53
|
+
update_opposite_direction(direction, other_node, __method__) if mirror_change
|
54
|
+
end
|
55
|
+
|
56
|
+
# Determines the weight of a single insertion by looking up the total
|
57
|
+
# number of insertions in that direction.
|
58
|
+
# @param direction [Symbol] :next or :prev
|
59
|
+
# @return [Float] between 0 and 1.
|
60
|
+
def get_probability_unit(direction)
|
61
|
+
unless total_num_inputs[direction] > 0
|
62
|
+
raise ArgumentError, "no inputs were added in <direction>"
|
63
|
+
end
|
64
|
+
1.0 / total_num_inputs[direction]
|
65
|
+
end
|
66
|
+
|
67
|
+
# Removes a single node from the :prev linkages and updates probabilities.
|
68
|
+
# Safe to run if the other_node is not actually present in the linkages.
|
69
|
+
# Mirrors the change in the opposite direction, to keep :prev and :next in sync.
|
70
|
+
# @param direction [Symbol] either :next or :prev
|
71
|
+
# @param other_node [Node] the node to be removed.
|
72
|
+
# @param mirror_change [Boolean]
|
73
|
+
# whether to update the opposite direction, defaults to true
|
74
|
+
# @return [void]
|
75
|
+
def remove_and_adjust_probabilities(direction, other_node, mirror_change=true)
|
76
|
+
return unless linkages[direction].has_key? other_node.value
|
77
|
+
unit = get_probability_unit(direction)
|
78
|
+
if linkages[direction][other_node.value] - unit <= 0
|
79
|
+
delete_linkage!(direction, other_node)
|
80
|
+
else
|
81
|
+
linkages[direction][other_node.value] -= unit
|
82
|
+
num_per_direction = total_num_inputs[direction]
|
83
|
+
linkages[direction].each_key do |node_key|
|
84
|
+
linkages[direction][node_key] *= (
|
85
|
+
num_per_direction / (num_per_direction - 1.0)
|
86
|
+
)
|
87
|
+
end
|
88
|
+
total_num_inputs[direction] -= 1
|
89
|
+
end
|
90
|
+
# Add a linkage in the opposite direction to keep :next and :prev in sync
|
91
|
+
update_opposite_direction(direction, other_node, __method__) if mirror_change
|
92
|
+
end
|
93
|
+
|
94
|
+
# Force-removes a linkage, re-adjusting other probabilities
|
95
|
+
# but potentially breaking their proportionality.
|
96
|
+
# Can be safely run for non-existing nodes.
|
97
|
+
# Adjusts the linkages in the opposite direction accordingly.
|
98
|
+
# @param direction [Symbol]
|
99
|
+
# @param other_node [Node]
|
100
|
+
# @param mirror_change [Boolean]
|
101
|
+
# whether to update the opposite direction, defaults to true
|
102
|
+
# @return [void]
|
103
|
+
def delete_linkage!(direction, other_node, mirror_change=true)
|
104
|
+
return unless linkages[direction].has_key? other_node.value
|
105
|
+
probability = linkages[direction][other_node.value]
|
106
|
+
# delete the linkage
|
107
|
+
linkages[direction].delete other_node.value
|
108
|
+
# distribute the probability evenly among the other options.
|
109
|
+
amt_to_add = probability / linkages[direction].keys.length
|
110
|
+
linkages[direction].each_key do |key|
|
111
|
+
linkages[direction][key] += amt_to_add
|
112
|
+
end
|
113
|
+
# decrement the total count
|
114
|
+
total_num_inputs[direction] -= 1
|
115
|
+
# Add a linkage in the opposite direction to keep :next and :prev in sync
|
116
|
+
update_opposite_direction(direction, other_node, __method__) if mirror_change
|
117
|
+
end
|
118
|
+
|
119
|
+
# Force-adds a linkage at a specific probability.
|
120
|
+
# Readjusts other probabilities but may break their proportionality.
|
121
|
+
# Updates the opposite direction to keep :next and :prev in sync
|
122
|
+
# @param direction [Symbol]
|
123
|
+
# @param other_node [Node]
|
124
|
+
# @param probability [Float] between 0 and 1.
|
125
|
+
# @param mirror_change [Boolean] whether to update the opposite direction.
|
126
|
+
# @return [void]
|
127
|
+
def add_linkage!(direction, other_node, probability, mirror_change=true)
|
128
|
+
raise ArgumentError, "invalid probability" unless probability.between?(0,1)
|
129
|
+
# first remove any existing node there and distribute the probability.
|
130
|
+
delete_linkage!(direction, other_node)
|
131
|
+
# Re-adjust each probability to account for the added value
|
132
|
+
linkages[direction].each_key do |key|
|
133
|
+
linkages[direction][key] *= (1 - probability)
|
134
|
+
# remove the linkage if it's probability is zero
|
135
|
+
if linkages[direction][key].zero?
|
136
|
+
delete_linkage!(direction, @nodes[key])
|
137
|
+
end
|
138
|
+
end
|
139
|
+
# Add the new value and set its probability
|
140
|
+
binding.pry if other_node.value == "dog"
|
141
|
+
linkages[direction][other_node.value] = probability
|
142
|
+
# increment the total count
|
143
|
+
total_num_inputs[direction] += 1
|
144
|
+
# Add a linkage in the opposite direction to keep :next and :prev in sync
|
145
|
+
if mirror_change
|
146
|
+
update_opposite_direction(direction, other_node, __method__, probability)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# Calls given method_name on other_node, passing the opposite direction
|
151
|
+
# to the one given as an argument. This keeps :next and :prev in sync.
|
152
|
+
# @param direction [Symbol]
|
153
|
+
# something should have already been added/removed here
|
154
|
+
# @param other_node [Node] the node which was added/removed
|
155
|
+
# @param method_name [Symbol] the method to invoke on other_node
|
156
|
+
# @return [void]
|
157
|
+
def update_opposite_direction(direction, other_node, method_name, *other_args)
|
158
|
+
other_direction = (%i{next prev} - [direction])[0]
|
159
|
+
other_node.send(method_name, other_direction, self, *other_args, false)
|
160
|
+
end
|
161
|
+
|
162
|
+
# Adds another node to the :next linkages, updating probabilities.
|
163
|
+
# @param child_node [Node] to be added.
|
164
|
+
# @return [void]
|
165
|
+
def add_next_linkage(child_node, mirror_change=true)
|
166
|
+
add_and_adjust_probabilities(:next, child_node)
|
167
|
+
end
|
168
|
+
|
169
|
+
# Adds another node to the :prev linkages, updating probabilities.
|
170
|
+
# @param parent_node [Node] to be added.
|
171
|
+
# @return [void]
|
172
|
+
def add_prev_linkage(parent_node, mirror_change=true)
|
173
|
+
add_and_adjust_probabilities(:prev, parent_node)
|
174
|
+
end
|
175
|
+
|
176
|
+
# Removes a node from the :next linkages, updating probabilities.
|
177
|
+
# @param child_node [Node] to be removed.
|
178
|
+
# @return [void]
|
179
|
+
def remove_next_linkage(child_node, mirror_change=true)
|
180
|
+
remove_and_adjust_probabilities(:next, child_node)
|
181
|
+
end
|
182
|
+
|
183
|
+
# Removes a node from the :prev linkages, updating probabilities.
|
184
|
+
# @param parent_node [Node] to be removed.
|
185
|
+
# @return [void]
|
186
|
+
def remove_prev_linkage(parent_node, mirror_change=true)
|
187
|
+
remove_and_adjust_probabilities(:prev, parent_node)
|
188
|
+
end
|
189
|
+
|
190
|
+
end
|
191
|
+
|
192
|
+
end
|
@@ -0,0 +1,236 @@
|
|
1
|
+
# Builds a Markov chain from phrases passed as input.
|
2
|
+
# A "phrase" is defined here as a tweet.
|
3
|
+
class MarkovTwitter::MarkovBuilder
|
4
|
+
|
5
|
+
# Regex used to split the phrase into tokens.
|
6
|
+
# It splits on any number of whitespace\in sequence.
|
7
|
+
# Sequences of punctuation characters are treated like any other word.
|
8
|
+
SeparatorCharacterRegex = /\s+/
|
9
|
+
|
10
|
+
# The base dictionary for nodes.
|
11
|
+
# There is only a single copy of each node created,
|
12
|
+
# although they are referenced in Node#linkages as well.
|
13
|
+
# @return [Hash<String, Node>]
|
14
|
+
attr_reader :nodes
|
15
|
+
|
16
|
+
# The nodes that were found at the start of phrases
|
17
|
+
# @return [Set<Node>]
|
18
|
+
attr_reader :start_nodes
|
19
|
+
|
20
|
+
# The nodes that were found at the end of phrases
|
21
|
+
# @return [Set<Node>]
|
22
|
+
attr_reader :end_nodes
|
23
|
+
|
24
|
+
# lambdas which can be used during evaluation to find the first node,
|
25
|
+
# or the next node when "stuck" (meaning there is no :next/:prev node).
|
26
|
+
# @return [Lambda<Node>]
|
27
|
+
# the lambda should return true for a node that is suitable.
|
28
|
+
def node_finders
|
29
|
+
@node_finders ||= {
|
30
|
+
random: -> (node) { true },
|
31
|
+
favor_start: -> (node) { start_nodes.include? node.value },
|
32
|
+
favor_end: -> (node) { end_nodes.include? node.value },
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
# Splits a phrase into tokens.
|
37
|
+
# @param phrase [String]
|
38
|
+
# @return [Array<String>]
|
39
|
+
def self.split_phrase(phrase)
|
40
|
+
phrase.split(SeparatorCharacterRegex)
|
41
|
+
end
|
42
|
+
|
43
|
+
# @param phrases [Array<String>] e.g. sentences or tweets.
|
44
|
+
# processes the phrases to populate @nodes.
|
45
|
+
def initialize(phrases: [])
|
46
|
+
@nodes = {}
|
47
|
+
@start_nodes = Set.new
|
48
|
+
@end_nodes = Set.new
|
49
|
+
phrases.each &method(:process_phrase)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Splits a phrase into tokens, adds them to @nodes, and creates linkages.
|
53
|
+
# @param phrase [String] e.g. a sentence or tweet.
|
54
|
+
# @return [void]
|
55
|
+
def process_phrase(phrase)
|
56
|
+
node_vals = self.class.split_phrase(phrase)
|
57
|
+
last_node = nil
|
58
|
+
node_vals.length.times do |i|
|
59
|
+
nodes = node_vals[i..(i+1)].compact.map do |node_val|
|
60
|
+
construct_node(node_val)
|
61
|
+
end
|
62
|
+
@start_nodes.add(nodes[0].value) if i == 0
|
63
|
+
last_node = nodes.last
|
64
|
+
add_nodes(*nodes)
|
65
|
+
end
|
66
|
+
@end_nodes.add last_node.value
|
67
|
+
end
|
68
|
+
|
69
|
+
# Adds a sequence of two tokens to @nodes and creates linkages.
|
70
|
+
# if node_val2 is nil, it won't be added and linkages won't be created
|
71
|
+
# @param node1 [Node]
|
72
|
+
# @param node2 [Node]
|
73
|
+
# @return [void]
|
74
|
+
def add_nodes(node1, node2=nil)
|
75
|
+
unless node1.is_a?(Node)
|
76
|
+
raise ArgumentError, "first arg passed to add_nodes is not a Node"
|
77
|
+
end
|
78
|
+
@nodes[node1.value] ||= node1
|
79
|
+
if node2
|
80
|
+
@nodes[node2.value] ||= node2
|
81
|
+
add_linkages(*@nodes.values_at(*[node1,node2].map(&:value)))
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Builds a single node which contains a reference to @nodes.
|
86
|
+
# Note that this does do the inverse (it doesn't add the node to @nodes)
|
87
|
+
# @param value [String]
|
88
|
+
# @return [Node]
|
89
|
+
def construct_node(value)
|
90
|
+
Node.new(value: value, nodes: @nodes)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Adds bidirectional linkages beween two nodes.
|
94
|
+
# the Node class re-calculates the probabilities internally
|
95
|
+
# and mirrors the change on :prev.
|
96
|
+
# @param node1 [Node] the parent.
|
97
|
+
# @param node2 [Node] the child.
|
98
|
+
# @return [void]
|
99
|
+
def add_linkages(node1, node2)
|
100
|
+
node1.add_next_linkage(node2, mirror_change=true)
|
101
|
+
end
|
102
|
+
|
103
|
+
# The default evaluation method to produce a run case.
|
104
|
+
# Goes in forward direction with with random nodes as start points.
|
105
|
+
# See also #evaluate_favoring_start and #evaluate_favoring_end.
|
106
|
+
# See #_evaluate for paramspecs
|
107
|
+
# The passed node_node_finder lambda picks a totally random new node.
|
108
|
+
# @return [String] the result of #_evaluate joined by whitespace.
|
109
|
+
def evaluate(length:, probability_bounds: [0,100], root_node: nil)
|
110
|
+
_evaluate(
|
111
|
+
length: length,
|
112
|
+
probability_bounds: probability_bounds,
|
113
|
+
root_node: root_node,
|
114
|
+
direction: :next,
|
115
|
+
node_finder: node_finders[:random]
|
116
|
+
).map(&:value).join(" ")
|
117
|
+
end
|
118
|
+
|
119
|
+
# See #_evaluate for paramspec.
|
120
|
+
# The passed node_node_finder lambda picks a node contained in @start_nodes
|
121
|
+
# An error is raised if no nodes match this condition.
|
122
|
+
# @return [String] the result of #_evaluate joined by whitespace.
|
123
|
+
def evaluate_favoring_start(length:, probability_bounds: [0,100], root_node: nil)
|
124
|
+
node_finder = node_finders[:favor_start]
|
125
|
+
has_possible_start_node = nodes.values.any? &node_finder
|
126
|
+
unless has_possible_start_node
|
127
|
+
raise ArgumentError, "@start_nodes is empty; can't evaluate favoring start"
|
128
|
+
end
|
129
|
+
_evaluate(
|
130
|
+
length: length,
|
131
|
+
probability_bounds: probability_bounds,
|
132
|
+
root_node: root_node,
|
133
|
+
direction: :next,
|
134
|
+
node_finder: node_finder
|
135
|
+
).map(&:value).join(" ")
|
136
|
+
end
|
137
|
+
|
138
|
+
# See #_evaluate for paramspec.
|
139
|
+
# The passed node_node_finder lambda picks a node contained in @end_nodes
|
140
|
+
# An error is raised if no nodes match this condition.
|
141
|
+
# @return [String] the result of #_evaluate reversed and joined by whitespace.
|
142
|
+
def evaluate_favoring_end(length:, probability_bounds: [0,100], root_node: nil)
|
143
|
+
node_finder = node_finders[:favor_end]
|
144
|
+
has_possible_end_node = nodes.values.any? &node_finder
|
145
|
+
unless has_possible_end_node
|
146
|
+
raise ArgumentError, "@end_nodes is empty; can't evaluate favoring end"
|
147
|
+
end
|
148
|
+
_evaluate(
|
149
|
+
length: length,
|
150
|
+
probability_bounds: probability_bounds,
|
151
|
+
root_node: root_node,
|
152
|
+
direction: :prev,
|
153
|
+
node_finder: node_finder
|
154
|
+
).map(&:value).reverse.join(" ")
|
155
|
+
end
|
156
|
+
|
157
|
+
# An "evaluation" of the markov chain. e.g. a run case.
|
158
|
+
# Passes random values through the probability sequences.
|
159
|
+
# @param length [Integer] the number of tokens in the result.
|
160
|
+
# @param probability_bounds [Array<Integer, Integer>]
|
161
|
+
# optional, can limit the probability to a range where
|
162
|
+
# 0 <= min <= result <= max <= 100.
|
163
|
+
# @param node_finder [Lambda<Node>]
|
164
|
+
# during iteration, if the current node has no linkages in <direction>,
|
165
|
+
# a new node is selected from the nodes dict. The first randomly-picked
|
166
|
+
# node which this lambda returns a truthy value for is selected.
|
167
|
+
# @return [Array<Node>] the result tokens in order.
|
168
|
+
def _evaluate(
|
169
|
+
length:,
|
170
|
+
probability_bounds: [0,100],
|
171
|
+
root_node: nil,
|
172
|
+
direction:,
|
173
|
+
node_finder:
|
174
|
+
)
|
175
|
+
length.times.reduce([]) do |result_nodes|
|
176
|
+
root_node ||= get_new_start_point(node_finder)
|
177
|
+
result_nodes.push root_node
|
178
|
+
root_node = pick_linkage(
|
179
|
+
root_node.linkages[direction],
|
180
|
+
probability_bounds,
|
181
|
+
)
|
182
|
+
result_nodes
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
# Gets a random node as a potential start point.
|
187
|
+
# @param node_finder [lambda<Node>]
|
188
|
+
# any returned node will return a truthy value from this.
|
189
|
+
# @return [Node] or nil if one couldn't be found.
|
190
|
+
def get_new_start_point(node_finder)
|
191
|
+
nodes.values.shuffle.find(&node_finder)
|
192
|
+
end
|
193
|
+
|
194
|
+
# validates the given probability bounds
|
195
|
+
# @param bounds [Array<Integer, Integer>]
|
196
|
+
# @return [Boolean] indicating whether it is valid
|
197
|
+
def check_probability_bounds(bounds)
|
198
|
+
bounds1, bounds2 = bounds
|
199
|
+
bounds_diff = bounds2 - bounds1
|
200
|
+
if (
|
201
|
+
(bounds_diff < 0) || (bounds_diff > 100) ||
|
202
|
+
(bounds1 < 0) || (bounds2 > 100)
|
203
|
+
)
|
204
|
+
raise ArgumentError, "wasn't given 0 <= bounds1 <= bounds2 <= 100"
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Given "linkages" which includes all possibly node traversals in
|
209
|
+
# a predetermined direction, pick one based on their probabilities.
|
210
|
+
# @param linkages [Hash<String, Float>] key=token, val=probability
|
211
|
+
# @param probability_bounds [Array<Integer,Integer>]
|
212
|
+
# Optional, can limit the probability to a range where
|
213
|
+
# 0 <= min <= result <= max <= 100.
|
214
|
+
# This gets divided by 100 before being compared to the linkage values.
|
215
|
+
#
|
216
|
+
# @return [Node] or nil if one couldn't be found.
|
217
|
+
def pick_linkage(linkages, probability_bounds=[0,100])
|
218
|
+
check_probability_bounds(probability_bounds)
|
219
|
+
bounds1, bounds2 = probability_bounds
|
220
|
+
# pick a random number between the bounds.
|
221
|
+
random_num = (rand(bounds2 - bounds1) + bounds1) * 0.01
|
222
|
+
# offset is the accumulation of probabilities seen during iteration.
|
223
|
+
offset = 0
|
224
|
+
# sort to lowest first
|
225
|
+
sorted = linkages.sort_by { |name, prob| prob }
|
226
|
+
# find the first linkage value that satisfies offset < N(rand) < val.
|
227
|
+
new_key = sorted.find do |(key, probability)|
|
228
|
+
# increment the offset each time.
|
229
|
+
random_num.between?(offset, probability + offset).tap do
|
230
|
+
offset += probability
|
231
|
+
end
|
232
|
+
end
|
233
|
+
nodes[new_key&.first]
|
234
|
+
end
|
235
|
+
|
236
|
+
end
|
@@ -0,0 +1,175 @@
|
|
1
|
+
|
2
|
+
# Methods which are included into test case via refinement,
|
3
|
+
# so they don't interfere with application code
|
4
|
+
# and don't require a namespace.
|
5
|
+
module MarkovTwitter::TestHelperMethods
|
6
|
+
|
7
|
+
# alias
|
8
|
+
Authenticator = MarkovTwitter::Authenticator
|
9
|
+
|
10
|
+
# Builds an authenticator instance with valid credentials.
|
11
|
+
# Will raise an error unless the expected ENV vars are defined.
|
12
|
+
# @return [Authenticator]
|
13
|
+
def build_valid_authenticator
|
14
|
+
Authenticator.new(
|
15
|
+
api_key: ENV.fetch("TWITTER_API_KEY"),
|
16
|
+
secret_key: ENV.fetch("TWITTER_SECRET_KEY")
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Builds an authenticator instance with invalid credentials.
|
21
|
+
# Should raise errors on subsequent operations.
|
22
|
+
# @return [Authenticator]
|
23
|
+
def build_invalid_authenticator
|
24
|
+
Authenticator.new(api_key: "", secret_key: "")
|
25
|
+
end
|
26
|
+
|
27
|
+
# This is a twitter user I've created that has a fixed set of tweets.
|
28
|
+
# It's there to make sure that fetching tweets works correctly.
|
29
|
+
# @return [String]
|
30
|
+
def get_sample_username
|
31
|
+
"max_p_sample"
|
32
|
+
end
|
33
|
+
|
34
|
+
# The expected tweets of the user I manually posted to on Twitter.
|
35
|
+
# @return [Array<String>]
|
36
|
+
def get_sample_user_tweets
|
37
|
+
[
|
38
|
+
"don't ever change",
|
39
|
+
"A long-term goal of mine is to create a water-based horror game. I've done some work on building this in Unity already.",
|
40
|
+
"Many amazing looking animals can be kept in reasonably simple environments, but some require elaborate setups .",
|
41
|
+
"I enjoy creating terrariums but it's a lot of work to keep everything balanced so that all the critters survive .",
|
42
|
+
"Although I haven't had a cat myself, I have had aquariums, terrariums, and rodents at different points .",
|
43
|
+
"i have personally never owned a pet cat, and I'm a bit allergic, but I still enjoy their company .",
|
44
|
+
"carnivorous by nature, cats hunt many other wild animals such as gophers and mice. As a result, some people would prefer less outdoor cats .",
|
45
|
+
"you have now unsubscribed to cat facts. respond with UNSUBSCRIBE to unsubscribe .",
|
46
|
+
"egyption hairless cats are less allergenic than most other cats. they don't have hair and are probably less oily .",
|
47
|
+
"the cat in the hat ate and sat. it got fat and couldn't catch a rat ."
|
48
|
+
]
|
49
|
+
end
|
50
|
+
|
51
|
+
# Converts strings into a specific structure that is used internally by
|
52
|
+
# the twitter gem. Used for stubbing with Webmock.
|
53
|
+
# @param strings [Array<String>]
|
54
|
+
# @return [Array<Hash>] where each hash has :id and :text keys.
|
55
|
+
def to_tweet_objects(strings)
|
56
|
+
strings.map { |string| { id: 0, text: string } }
|
57
|
+
end
|
58
|
+
|
59
|
+
# Sample tweets for which the content does not matter.
|
60
|
+
# these are only used to test the pagination of Twitter results.
|
61
|
+
# @return [Array<String>]
|
62
|
+
def get_stubbed_many_tweets_user_tweets
|
63
|
+
20.times.map &:to_s
|
64
|
+
end
|
65
|
+
|
66
|
+
# This user should raise an error when the twitter gem looks them up.
|
67
|
+
# @return [String]
|
68
|
+
def get_invalid_username
|
69
|
+
"3u9r4j8fjniecn875jdpwqk32mdiy4584vuniwcoekpd932"
|
70
|
+
end
|
71
|
+
|
72
|
+
# A twitter user which has many tweets.
|
73
|
+
# Used to test pagination of search results.
|
74
|
+
# @return [String]
|
75
|
+
def get_many_tweets_username
|
76
|
+
"SFist"
|
77
|
+
end
|
78
|
+
|
79
|
+
# Makes twitter's oauth request succeed.
|
80
|
+
# Returns without doing anything if DisableWebmock=true in ENV.
|
81
|
+
# @return [void]
|
82
|
+
def stub_twitter_token_request_with_valid_credentials
|
83
|
+
return if ENV["DisableWebmock"] == "true"
|
84
|
+
stub_request(
|
85
|
+
:post, "https://api.twitter.com/oauth2/token"
|
86
|
+
).to_return(status: 200, body: "", headers: {})
|
87
|
+
end
|
88
|
+
|
89
|
+
# Makes twitter's oauth request fail.
|
90
|
+
# Returns without doing anything if DisableWebmock=true in ENV.
|
91
|
+
# @return [void]
|
92
|
+
def stub_twitter_token_request_with_invalid_credentials
|
93
|
+
return if ENV["DisableWebmock"] == "true"
|
94
|
+
stub_request(
|
95
|
+
:post, "https://api.twitter.com/oauth2/token"
|
96
|
+
).to_return(status: 403, body: "", headers: {})
|
97
|
+
end
|
98
|
+
|
99
|
+
# Makes the twitter user lookup request succeed.
|
100
|
+
# Returns without doing anything if DisableWebmock=true in ENV.
|
101
|
+
# @return [void]
|
102
|
+
def stub_twitter_user_lookup_request_with_valid_username(username)
|
103
|
+
return if ENV["DisableWebmock"] == "true"
|
104
|
+
stub_request( :get,
|
105
|
+
"https://api.twitter.com/1.1/users/show.json?screen_name=#{username}"
|
106
|
+
).to_return(status: 200, body: {id: 0}.to_json, headers: {})
|
107
|
+
end
|
108
|
+
|
109
|
+
# Makes the twitter user lookup request fail.
|
110
|
+
# Returns without doing anything if DisableWebmock=true in ENV.
|
111
|
+
# @param username [String]
|
112
|
+
# @return [void]
|
113
|
+
def stub_twitter_user_lookup_request_with_invalid_username(username)
|
114
|
+
return if ENV["DisableWebmock"] == "true"
|
115
|
+
stub_request( :get,
|
116
|
+
"https://api.twitter.com/1.1/users/show.json?screen_name=#{username}"
|
117
|
+
).to_return(status: 404, body: {id: 0}.to_json, headers: {})
|
118
|
+
end
|
119
|
+
|
120
|
+
# Makes the twitter user timeline request succeed.
|
121
|
+
# returns without doing anything if DisableWebmock=true in ENV.
|
122
|
+
# @param tweets_to_return [Array<Hash>]
|
123
|
+
# - the hashes must have keys "id" and "text".
|
124
|
+
# @return [void]
|
125
|
+
def stub_twitter_user_timeline_request(tweets_to_return)
|
126
|
+
return if ENV["DisableWebmock"] == "true"
|
127
|
+
stub_request(
|
128
|
+
:get,
|
129
|
+
"https://api.twitter.com/1.1/statuses/user_timeline.json?user_id=0"
|
130
|
+
).to_return(status: 200, body: tweets_to_return.to_json, headers: {})
|
131
|
+
end
|
132
|
+
|
133
|
+
# Validates that the linkages on a node are as expected.
|
134
|
+
# @param node [MarkovBuilder::Node]
|
135
|
+
# @param _next [Hash<String,Float>] mapping key to probability.
|
136
|
+
# @param prev [Hash<String,Float>] mapping key to probability.
|
137
|
+
# @param total_num_inputs [Hash<Symbol,Integer>] keyed by direction.
|
138
|
+
def validate_linkages(node, _next: nil, prev: nil, total_num_inputs: nil)
|
139
|
+
precision = 0.00001
|
140
|
+
if _next
|
141
|
+
node.linkages[:next].each do |name, linkage|
|
142
|
+
expect(linkage).to be_within(precision).of(_next[name])
|
143
|
+
end
|
144
|
+
end
|
145
|
+
if prev
|
146
|
+
node.linkages[:prev].each do |name, linkage|
|
147
|
+
expect(linkage).to be_within(precision).of(prev[name])
|
148
|
+
end
|
149
|
+
end
|
150
|
+
if total_num_inputs
|
151
|
+
expect(node.total_num_inputs).to eq(total_num_inputs)
|
152
|
+
end
|
153
|
+
rescue
|
154
|
+
binding.pry
|
155
|
+
end
|
156
|
+
|
157
|
+
# A sample phrase used to test manipulations on the markov chain.
|
158
|
+
# @return [String]
|
159
|
+
def get_sample_phrase_1
|
160
|
+
"the cat in the hat"
|
161
|
+
end
|
162
|
+
|
163
|
+
# A sample phrase used to test manipulations on the markov chain.
|
164
|
+
# @return [String]
|
165
|
+
def get_sample_phrase_2
|
166
|
+
"the bat in the flat"
|
167
|
+
end
|
168
|
+
|
169
|
+
# This module can be added with `using MarkovTwitter::TestHelperMethods`.
|
170
|
+
# It can also be added globally with `Object.include MarkovTwitter::TestHelperMethods`.
|
171
|
+
refine Object do
|
172
|
+
include MarkovTwitter::TestHelperMethods
|
173
|
+
end
|
174
|
+
|
175
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# Fetches the latest tweets.
|
2
|
+
class MarkovTwitter::TweetReader
|
3
|
+
|
4
|
+
# @return [Twitter::REST::Client]
|
5
|
+
attr_reader :client
|
6
|
+
|
7
|
+
# @param client [Twitter::REST::Client]
|
8
|
+
def initialize(client:)
|
9
|
+
@client = client
|
10
|
+
end
|
11
|
+
|
12
|
+
# @param username [String] must exist om twitter or this will raise an error
|
13
|
+
# @return [Array<Hash>]
|
14
|
+
# - the hashes will have :text and :id keys
|
15
|
+
def get_tweets(username:)
|
16
|
+
user = client.user(username)
|
17
|
+
client.user_timeline(user)
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
|
2
|
+
###############################################################################
|
3
|
+
# #
|
4
|
+
# /\/\ __ _ _ __| | _______ __ #
|
5
|
+
# / \ / _` | '__| |/ / _ \ \ / / #
|
6
|
+
# / /\/\ \ (_| | | | < (_) \ V / #
|
7
|
+
# \/ \/\__,_|_| |_|\_\___/ \_/ #
|
8
|
+
# _____ _ _ _ #
|
9
|
+
# /__ \__ _(_) |_| |_ ___ _ __ #
|
10
|
+
# / /\/\ \ /\ / / | __| __/ _ \ '__ #
|
11
|
+
# / / \ V V /| | |_| || __/ | #
|
12
|
+
# \/ \_/\_/ |_|\__|\__\___|_| #
|
13
|
+
# #
|
14
|
+
###############################################################################
|
15
|
+
|
16
|
+
# =============================================================================
|
17
|
+
# Dependencies
|
18
|
+
# =============================================================================
|
19
|
+
|
20
|
+
# Using a gem to interact with twitter saves a lot of work.
|
21
|
+
require 'twitter'
|
22
|
+
|
23
|
+
# Extensions to Ruby core language.
|
24
|
+
require 'active_support/all'
|
25
|
+
|
26
|
+
# =============================================================================
|
27
|
+
# Top level namespace.
|
28
|
+
# =============================================================================
|
29
|
+
|
30
|
+
class MarkovTwitter; end
|
31
|
+
|
32
|
+
# =============================================================================
|
33
|
+
# Individual components.
|
34
|
+
# =============================================================================
|
35
|
+
|
36
|
+
require "markov_twitter/tweet_reader"
|
37
|
+
require "markov_twitter/authenticator"
|
38
|
+
require "markov_twitter/markov_builder"
|
39
|
+
require "markov_twitter/markov_builder/node"
|
40
|
+
|
41
|
+
require "markov_twitter/test_helper_methods"
|
data/lib/version.rb
ADDED
metadata
ADDED
@@ -0,0 +1,167 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: markov_twitter
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- max pleaner
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-10-12 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: thor
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: twitter
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: activesupport
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pry-byebug
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: dotenv
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: webmock
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rspec
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: yard
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description: ''
|
126
|
+
email: maxpleaner@gmail.com
|
127
|
+
executables:
|
128
|
+
- console
|
129
|
+
- markov_twitter
|
130
|
+
extensions: []
|
131
|
+
extra_rdoc_files: []
|
132
|
+
files:
|
133
|
+
- README.md
|
134
|
+
- bin/console
|
135
|
+
- bin/markov_twitter
|
136
|
+
- lib/markov_twitter.rb
|
137
|
+
- lib/markov_twitter/authenticator.rb
|
138
|
+
- lib/markov_twitter/markov_builder.rb
|
139
|
+
- lib/markov_twitter/markov_builder/node.rb
|
140
|
+
- lib/markov_twitter/test_helper_methods.rb
|
141
|
+
- lib/markov_twitter/tweet_reader.rb
|
142
|
+
- lib/version.rb
|
143
|
+
homepage: http://github.com/maxpleaner/markov_twitter
|
144
|
+
licenses:
|
145
|
+
- MIT
|
146
|
+
metadata: {}
|
147
|
+
post_install_message:
|
148
|
+
rdoc_options: []
|
149
|
+
require_paths:
|
150
|
+
- lib
|
151
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
152
|
+
requirements:
|
153
|
+
- - "~>"
|
154
|
+
- !ruby/object:Gem::Version
|
155
|
+
version: '2.3'
|
156
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
157
|
+
requirements:
|
158
|
+
- - ">="
|
159
|
+
- !ruby/object:Gem::Version
|
160
|
+
version: 2.6.13
|
161
|
+
requirements: []
|
162
|
+
rubyforge_project:
|
163
|
+
rubygems_version: 2.6.13
|
164
|
+
signing_key:
|
165
|
+
specification_version: 4
|
166
|
+
summary: markov chains from twitter posts
|
167
|
+
test_files: []
|