bot_twitter_ebooks 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitattributes +1 -0
- data/.gitignore +200 -0
- data/.rspec +1 -0
- data/.travis.yml +7 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +168 -0
- data/Rakefile +2 -0
- data/bin/ebooks +454 -0
- data/bot_twitter_ebooks.gemspec +38 -0
- data/data/adjectives.txt +1466 -0
- data/data/nouns.txt +2193 -0
- data/lib/bot_twitter_ebooks.rb +22 -0
- data/lib/bot_twitter_ebooks/archive.rb +117 -0
- data/lib/bot_twitter_ebooks/bot.rb +481 -0
- data/lib/bot_twitter_ebooks/model.rb +336 -0
- data/lib/bot_twitter_ebooks/nlp.rb +195 -0
- data/lib/bot_twitter_ebooks/suffix.rb +104 -0
- data/lib/bot_twitter_ebooks/sync.rb +52 -0
- data/lib/bot_twitter_ebooks/version.rb +3 -0
- data/skeleton/Gemfile +4 -0
- data/skeleton/Procfile +1 -0
- data/skeleton/bots.rb +66 -0
- data/skeleton/corpus/.gitignore +0 -0
- data/skeleton/gitignore +1 -0
- data/skeleton/image/.gitignore +0 -0
- data/skeleton/model/.gitignore +0 -0
- data/skeleton/stopwords.txt +843 -0
- data/spec/bot_spec.rb +216 -0
- data/spec/data/elonmusk.json +12866 -0
- data/spec/data/elonmusk.model +2414 -0
- data/spec/memprof.rb +37 -0
- data/spec/model_spec.rb +88 -0
- data/spec/spec_helper.rb +6 -0
- metadata +310 -0
@@ -0,0 +1,336 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# encoding: utf-8
|
3
|
+
|
4
|
+
require 'json'
|
5
|
+
require 'set'
|
6
|
+
require 'digest/md5'
|
7
|
+
require 'csv'
|
8
|
+
|
9
|
+
module Ebooks
|
10
|
+
class Model
|
11
|
+
# @return [Array<String>]
|
12
|
+
# An array of unique tokens. This is the main source of actual strings
|
13
|
+
# in the model. Manipulation of a token is done using its index
|
14
|
+
# in this array, which we call a "tiki"
|
15
|
+
attr_accessor :tokens
|
16
|
+
|
17
|
+
# @return [Array<Array<Integer>>]
|
18
|
+
# Sentences represented by arrays of tikis
|
19
|
+
attr_accessor :sentences
|
20
|
+
|
21
|
+
# @return [Array<Array<Integer>>]
|
22
|
+
# Sentences derived from Twitter mentions
|
23
|
+
attr_accessor :mentions
|
24
|
+
|
25
|
+
# @return [Array<String>]
|
26
|
+
# The top 200 most important keywords, in descending order
|
27
|
+
attr_accessor :keywords
|
28
|
+
|
29
|
+
# Generate a new model from a corpus file
|
30
|
+
# @param path [String]
|
31
|
+
# @return [Ebooks::Model]
|
32
|
+
def self.consume(path)
|
33
|
+
Model.new.consume(path)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Generate a new model from multiple corpus files
|
37
|
+
# @param paths [Array<String>]
|
38
|
+
# @return [Ebooks::Model]
|
39
|
+
def self.consume_all(paths)
|
40
|
+
Model.new.consume_all(paths)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Load a saved model
|
44
|
+
# @param path [String]
|
45
|
+
# @return [Ebooks::Model]
|
46
|
+
def self.load(path)
|
47
|
+
model = Model.new
|
48
|
+
model.instance_eval do
|
49
|
+
props = Marshal.load(File.open(path, 'rb') { |f| f.read })
|
50
|
+
@tokens = props[:tokens]
|
51
|
+
@sentences = props[:sentences]
|
52
|
+
@mentions = props[:mentions]
|
53
|
+
@keywords = props[:keywords]
|
54
|
+
end
|
55
|
+
model
|
56
|
+
end
|
57
|
+
|
58
|
+
# Save model to a file
|
59
|
+
# @param path [String]
|
60
|
+
def save(path)
|
61
|
+
File.open(path, 'wb') do |f|
|
62
|
+
f.write(Marshal.dump({
|
63
|
+
tokens: @tokens,
|
64
|
+
sentences: @sentences,
|
65
|
+
mentions: @mentions,
|
66
|
+
keywords: @keywords
|
67
|
+
}))
|
68
|
+
end
|
69
|
+
self
|
70
|
+
end
|
71
|
+
|
72
|
+
# Append a generated model to existing model file instead of overwriting it
|
73
|
+
# @param path [String]
|
74
|
+
def append(path)
|
75
|
+
existing = File.file?(path)
|
76
|
+
if !existing
|
77
|
+
log "No existing model found at #{path}"
|
78
|
+
return
|
79
|
+
else
|
80
|
+
#read-in and deserialize existing model
|
81
|
+
props = Marshal.load(File.open(path,'rb') { |old| old.read })
|
82
|
+
old_tokens = props[:tokens]
|
83
|
+
old_sentences = props[:sentences]
|
84
|
+
old_mentions = props[:mentions]
|
85
|
+
old_keywords = props[:keywords]
|
86
|
+
|
87
|
+
#append existing properties to new ones and overwrite with new model
|
88
|
+
File.open(path, 'wb') do |f|
|
89
|
+
f.write(Marshal.dump({
|
90
|
+
tokens: @tokens.concat(old_tokens),
|
91
|
+
sentences: @sentences.concat(old_sentences),
|
92
|
+
mentions: @mentions.concat(old_mentions),
|
93
|
+
keywords: @keywords.concat(old_keywords)
|
94
|
+
}))
|
95
|
+
end
|
96
|
+
end
|
97
|
+
self
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
def initialize
|
102
|
+
@tokens = []
|
103
|
+
|
104
|
+
# Reverse lookup tiki by token, for faster generation
|
105
|
+
@tikis = {}
|
106
|
+
end
|
107
|
+
|
108
|
+
# Reverse lookup a token index from a token
|
109
|
+
# @param token [String]
|
110
|
+
# @return [Integer]
|
111
|
+
def tikify(token)
|
112
|
+
if @tikis.has_key?(token) then
|
113
|
+
return @tikis[token]
|
114
|
+
else
|
115
|
+
(@tokens.length+1)%1000 == 0 and puts "#{@tokens.length+1} tokens"
|
116
|
+
@tokens << token
|
117
|
+
return @tikis[token] = @tokens.length-1
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Convert a body of text into arrays of tikis
|
122
|
+
# @param text [String]
|
123
|
+
# @return [Array<Array<Integer>>]
|
124
|
+
def mass_tikify(text)
|
125
|
+
sentences = NLP.sentences(text)
|
126
|
+
|
127
|
+
sentences.map do |s|
|
128
|
+
tokens = NLP.tokenize(s).reject do |t|
|
129
|
+
# Don't include usernames/urls as tokens
|
130
|
+
t.include?('@') || t.include?('http')
|
131
|
+
end
|
132
|
+
|
133
|
+
tokens.map { |t| tikify(t) }
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Consume a corpus into this model
|
138
|
+
# @param path [String]
|
139
|
+
def consume(path)
|
140
|
+
content = File.read(path, :encoding => 'utf-8')
|
141
|
+
|
142
|
+
if path.split('.')[-1] == "json"
|
143
|
+
log "Reading json corpus from #{path}"
|
144
|
+
lines = JSON.parse(content).map do |tweet|
|
145
|
+
tweet['text']
|
146
|
+
end
|
147
|
+
elsif path.split('.')[-1] == "csv"
|
148
|
+
log "Reading CSV corpus from #{path}"
|
149
|
+
content = CSV.parse(content)
|
150
|
+
header = content.shift
|
151
|
+
text_col = header.index('text')
|
152
|
+
lines = content.map do |tweet|
|
153
|
+
tweet[text_col]
|
154
|
+
end
|
155
|
+
else
|
156
|
+
log "Reading plaintext corpus from #{path} (if this is a json or csv file, please rename the file with an extension and reconsume)"
|
157
|
+
lines = content.split("\n")
|
158
|
+
end
|
159
|
+
|
160
|
+
consume_lines(lines)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Consume a sequence of lines
|
164
|
+
# @param lines [Array<String>]
|
165
|
+
def consume_lines(lines)
|
166
|
+
log "Removing commented lines and sorting mentions"
|
167
|
+
|
168
|
+
statements = []
|
169
|
+
mentions = []
|
170
|
+
lines.each do |l|
|
171
|
+
next if l.start_with?('#') # Remove commented lines
|
172
|
+
next if l.include?('RT') || l.include?('MT') # Remove soft retweets
|
173
|
+
|
174
|
+
if l.include?('@')
|
175
|
+
mentions << NLP.normalize(l)
|
176
|
+
else
|
177
|
+
statements << NLP.normalize(l)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
text = statements.join("\n").encode('UTF-8', :invalid => :replace)
|
182
|
+
mention_text = mentions.join("\n").encode('UTF-8', :invalid => :replace)
|
183
|
+
|
184
|
+
lines = nil; statements = nil; mentions = nil # Allow garbage collection
|
185
|
+
|
186
|
+
log "Tokenizing #{text.count("\n")} statements and #{mention_text.count("\n")} mentions"
|
187
|
+
|
188
|
+
@sentences = mass_tikify(text)
|
189
|
+
@mentions = mass_tikify(mention_text)
|
190
|
+
|
191
|
+
log "Ranking keywords"
|
192
|
+
@keywords = NLP.keywords(text).top(200).map(&:to_s)
|
193
|
+
log "Top keywords: #{@keywords[0]} #{@keywords[1]} #{@keywords[2]}"
|
194
|
+
|
195
|
+
self
|
196
|
+
end
|
197
|
+
|
198
|
+
# Consume multiple corpuses into this model
|
199
|
+
# @param paths [Array<String>]
|
200
|
+
def consume_all(paths)
|
201
|
+
lines = []
|
202
|
+
paths.each do |path|
|
203
|
+
content = File.read(path, :encoding => 'utf-8')
|
204
|
+
|
205
|
+
if path.split('.')[-1] == "json"
|
206
|
+
log "Reading json corpus from #{path}"
|
207
|
+
l = JSON.parse(content).map do |tweet|
|
208
|
+
tweet['text']
|
209
|
+
end
|
210
|
+
lines.concat(l)
|
211
|
+
elsif path.split('.')[-1] == "csv"
|
212
|
+
log "Reading CSV corpus from #{path}"
|
213
|
+
content = CSV.parse(content)
|
214
|
+
header = content.shift
|
215
|
+
text_col = header.index('text')
|
216
|
+
l = content.map do |tweet|
|
217
|
+
tweet[text_col]
|
218
|
+
end
|
219
|
+
lines.concat(l)
|
220
|
+
else
|
221
|
+
log "Reading plaintext corpus from #{path}"
|
222
|
+
l = content.split("\n")
|
223
|
+
lines.concat(l)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
consume_lines(lines)
|
227
|
+
end
|
228
|
+
|
229
|
+
# Correct encoding issues in generated text
|
230
|
+
# @param text [String]
|
231
|
+
# @return [String]
|
232
|
+
def fix(text)
|
233
|
+
NLP.htmlentities.decode text
|
234
|
+
end
|
235
|
+
|
236
|
+
# Check if an array of tikis comprises a valid tweet
|
237
|
+
# @param tikis [Array<Integer>]
|
238
|
+
# @param limit Integer how many chars we have left
|
239
|
+
def valid_tweet?(tikis, limit)
|
240
|
+
tweet = NLP.reconstruct(tikis, @tokens)
|
241
|
+
tweet.length <= limit && !NLP.unmatched_enclosers?(tweet)
|
242
|
+
end
|
243
|
+
|
244
|
+
# Generate some text
|
245
|
+
# @param limit [Integer] available characters
|
246
|
+
# @param generator [SuffixGenerator, nil]
|
247
|
+
# @param retry_limit [Integer] how many times to retry on invalid tweet
|
248
|
+
# @return [String]
|
249
|
+
def make_statement(limit=140, generator=nil, retry_limit=10)
|
250
|
+
responding = !generator.nil?
|
251
|
+
generator ||= SuffixGenerator.build(@sentences)
|
252
|
+
|
253
|
+
retries = 0
|
254
|
+
tweet = ""
|
255
|
+
|
256
|
+
while (tikis = generator.generate(3, :bigrams)) do
|
257
|
+
#log "Attempting to produce tweet try #{retries+1}/#{retry_limit}"
|
258
|
+
break if (tikis.length > 3 || responding) && valid_tweet?(tikis, limit)
|
259
|
+
|
260
|
+
retries += 1
|
261
|
+
break if retries >= retry_limit
|
262
|
+
end
|
263
|
+
|
264
|
+
if verbatim?(tikis) && tikis.length > 3 # We made a verbatim tweet by accident
|
265
|
+
#log "Attempting to produce unigram tweet try #{retries+1}/#{retry_limit}"
|
266
|
+
while (tikis = generator.generate(3, :unigrams)) do
|
267
|
+
break if valid_tweet?(tikis, limit) && !verbatim?(tikis)
|
268
|
+
|
269
|
+
retries += 1
|
270
|
+
break if retries >= retry_limit
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
tweet = NLP.reconstruct(tikis, @tokens)
|
275
|
+
|
276
|
+
if retries >= retry_limit
|
277
|
+
log "Unable to produce valid non-verbatim tweet; using \"#{tweet}\""
|
278
|
+
end
|
279
|
+
|
280
|
+
fix tweet
|
281
|
+
end
|
282
|
+
|
283
|
+
# Test if a sentence has been copied verbatim from original
|
284
|
+
# @param tikis [Array<Integer>]
|
285
|
+
# @return [Boolean]
|
286
|
+
def verbatim?(tikis)
|
287
|
+
@sentences.include?(tikis) || @mentions.include?(tikis)
|
288
|
+
end
|
289
|
+
|
290
|
+
# Finds relevant and slightly relevant tokenized sentences to input
|
291
|
+
# comparing non-stopword token overlaps
|
292
|
+
# @param sentences [Array<Array<Integer>>]
|
293
|
+
# @param input [String]
|
294
|
+
# @return [Array<Array<Array<Integer>>, Array<Array<Integer>>>]
|
295
|
+
def find_relevant(sentences, input)
|
296
|
+
relevant = []
|
297
|
+
slightly_relevant = []
|
298
|
+
|
299
|
+
tokenized = NLP.tokenize(input).map(&:downcase)
|
300
|
+
|
301
|
+
sentences.each do |sent|
|
302
|
+
tokenized.each do |token|
|
303
|
+
if sent.map { |tiki| @tokens[tiki].downcase }.include?(token)
|
304
|
+
relevant << sent unless NLP.stopword?(token)
|
305
|
+
slightly_relevant << sent
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
310
|
+
[relevant, slightly_relevant]
|
311
|
+
end
|
312
|
+
|
313
|
+
# Generates a response by looking for related sentences
|
314
|
+
# in the corpus and building a smaller generator from these
|
315
|
+
# @param input [String]
|
316
|
+
# @param limit [Integer] characters available for response
|
317
|
+
# @param sentences [Array<Array<Integer>>]
|
318
|
+
# @return [String]
|
319
|
+
def make_response(input, limit=140, sentences=@mentions)
|
320
|
+
# Prefer mentions
|
321
|
+
relevant, slightly_relevant = find_relevant(sentences, input)
|
322
|
+
|
323
|
+
if relevant.length >= 3
|
324
|
+
generator = SuffixGenerator.build(relevant)
|
325
|
+
make_statement(limit, generator)
|
326
|
+
elsif slightly_relevant.length >= 5
|
327
|
+
generator = SuffixGenerator.build(slightly_relevant)
|
328
|
+
make_statement(limit, generator)
|
329
|
+
elsif sentences.equal?(@mentions)
|
330
|
+
make_response(input, limit, @sentences)
|
331
|
+
else
|
332
|
+
make_statement(limit)
|
333
|
+
end
|
334
|
+
end
|
335
|
+
end
|
336
|
+
end
|
@@ -0,0 +1,195 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'fast-stemmer'
|
3
|
+
require 'highscore'
|
4
|
+
require 'htmlentities'
|
5
|
+
|
6
|
+
module Ebooks
|
7
|
+
module NLP
|
8
|
+
# We deliberately limit our punctuation handling to stuff we can do consistently
|
9
|
+
# It'll just be a part of another token if we don't split it out, and that's fine
|
10
|
+
PUNCTUATION = ".?!,"
|
11
|
+
|
12
|
+
# Lazy-load NLP libraries and resources
|
13
|
+
# Some of this stuff is pretty heavy and we don't necessarily need
|
14
|
+
# to be using it all of the time
|
15
|
+
|
16
|
+
# Lazily loads an array of stopwords
|
17
|
+
# Stopwords are common words that should often be ignored
|
18
|
+
# @return [Array<String>]
|
19
|
+
def self.stopwords
|
20
|
+
@stopwords ||= File.exists?('stopwords.txt') ? File.read('stopwords.txt').split : []
|
21
|
+
end
|
22
|
+
|
23
|
+
# Lazily loads an array of known English nouns
|
24
|
+
# @return [Array<String>]
|
25
|
+
def self.nouns
|
26
|
+
@nouns ||= File.read(File.join(DATA_PATH, 'nouns.txt')).split
|
27
|
+
end
|
28
|
+
|
29
|
+
# Lazily loads an array of known English adjectives
|
30
|
+
# @return [Array<String>]
|
31
|
+
def self.adjectives
|
32
|
+
@adjectives ||= File.read(File.join(DATA_PATH, 'adjectives.txt')).split
|
33
|
+
end
|
34
|
+
|
35
|
+
# Lazily load part-of-speech tagging library
|
36
|
+
# This can determine whether a word is being used as a noun/adjective/verb
|
37
|
+
# @return [EngTagger]
|
38
|
+
def self.tagger
|
39
|
+
require 'engtagger'
|
40
|
+
@tagger ||= EngTagger.new
|
41
|
+
end
|
42
|
+
|
43
|
+
# Lazily load HTML entity decoder
|
44
|
+
# @return [HTMLEntities]
|
45
|
+
def self.htmlentities
|
46
|
+
@htmlentities ||= HTMLEntities.new
|
47
|
+
end
|
48
|
+
|
49
|
+
### Utility functions
|
50
|
+
|
51
|
+
# Normalize some strange unicode punctuation variants
|
52
|
+
# @param text [String]
|
53
|
+
# @return [String]
|
54
|
+
def self.normalize(text)
|
55
|
+
htmlentities.decode text.gsub('“', '"').gsub('”', '"').gsub('’', "'").gsub('…', '...')
|
56
|
+
end
|
57
|
+
|
58
|
+
# Split text into sentences
|
59
|
+
# We use ad hoc approach because fancy libraries do not deal
|
60
|
+
# especially well with tweet formatting, and we can fake solving
|
61
|
+
# the quote problem during generation
|
62
|
+
# @param text [String]
|
63
|
+
# @return [Array<String>]
|
64
|
+
def self.sentences(text)
|
65
|
+
text.split(/\n+|(?<=[.?!])\s+/)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Split a sentence into word-level tokens
|
69
|
+
# As above, this is ad hoc because tokenization libraries
|
70
|
+
# do not behave well wrt. things like emoticons and timestamps
|
71
|
+
# @param sentence [String]
|
72
|
+
# @return [Array<String>]
|
73
|
+
def self.tokenize(sentence)
|
74
|
+
regex = /\s+|(?<=[#{PUNCTUATION}]\s)(?=[a-zA-Z])|(?<=[a-zA-Z])(?=[#{PUNCTUATION}]+\s)/
|
75
|
+
sentence.split(regex)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Get the 'stem' form of a word e.g. 'cats' -> 'cat'
|
79
|
+
# @param word [String]
|
80
|
+
# @return [String]
|
81
|
+
def self.stem(word)
|
82
|
+
Stemmer::stem_word(word.downcase)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Use highscore gem to find interesting keywords in a corpus
|
86
|
+
# @param text [String]
|
87
|
+
# @return [Highscore::Keywords]
|
88
|
+
def self.keywords(text)
|
89
|
+
# Preprocess to remove stopwords (highscore's blacklist is v. slow)
|
90
|
+
text = NLP.tokenize(text).reject { |t| stopword?(t) }.join(' ')
|
91
|
+
|
92
|
+
text = Highscore::Content.new(text)
|
93
|
+
|
94
|
+
text.configure do
|
95
|
+
#set :multiplier, 2
|
96
|
+
#set :upper_case, 3
|
97
|
+
#set :long_words, 2
|
98
|
+
#set :long_words_threshold, 15
|
99
|
+
#set :vowels, 1 # => default: 0 = not considered
|
100
|
+
#set :consonants, 5 # => default: 0 = not considered
|
101
|
+
#set :ignore_case, true # => default: false
|
102
|
+
set :word_pattern, /(?<!@)(?<=\s)[\p{Word}']+/ # => default: /\w+/
|
103
|
+
#set :stemming, true # => default: false
|
104
|
+
end
|
105
|
+
|
106
|
+
text.keywords
|
107
|
+
end
|
108
|
+
|
109
|
+
# Builds a proper sentence from a list of tikis
|
110
|
+
# @param tikis [Array<Integer>]
|
111
|
+
# @param tokens [Array<String>]
|
112
|
+
# @return [String]
|
113
|
+
def self.reconstruct(tikis, tokens)
|
114
|
+
text = ""
|
115
|
+
last_token = nil
|
116
|
+
tikis.each do |tiki|
|
117
|
+
next if tiki == INTERIM
|
118
|
+
token = tokens[tiki]
|
119
|
+
text += ' ' if last_token && space_between?(last_token, token)
|
120
|
+
text += token
|
121
|
+
last_token = token
|
122
|
+
end
|
123
|
+
text
|
124
|
+
end
|
125
|
+
|
126
|
+
# Determine if we need to insert a space between two tokens
|
127
|
+
# @param token1 [String]
|
128
|
+
# @param token2 [String]
|
129
|
+
# @return [Boolean]
|
130
|
+
def self.space_between?(token1, token2)
|
131
|
+
p1 = self.punctuation?(token1)
|
132
|
+
p2 = self.punctuation?(token2)
|
133
|
+
if p1 && p2 # "foo?!"
|
134
|
+
false
|
135
|
+
elsif !p1 && p2 # "foo."
|
136
|
+
false
|
137
|
+
elsif p1 && !p2 # "foo. rah"
|
138
|
+
true
|
139
|
+
else # "foo rah"
|
140
|
+
true
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Is this token comprised of punctuation?
|
145
|
+
# @param token [String]
|
146
|
+
# @return [Boolean]
|
147
|
+
def self.punctuation?(token)
|
148
|
+
(token.chars.to_set - PUNCTUATION.chars.to_set).empty?
|
149
|
+
end
|
150
|
+
|
151
|
+
# Is this token a stopword?
|
152
|
+
# @param token [String]
|
153
|
+
# @return [Boolean]
|
154
|
+
def self.stopword?(token)
|
155
|
+
@stopword_set ||= stopwords.map(&:downcase).to_set
|
156
|
+
@stopword_set.include?(token.downcase)
|
157
|
+
end
|
158
|
+
|
159
|
+
# Determine if a sample of text contains unmatched brackets or quotes
|
160
|
+
# This is one of the more frequent and noticeable failure modes for
|
161
|
+
# the generator; we can just tell it to retry
|
162
|
+
# @param text [String]
|
163
|
+
# @return [Boolean]
|
164
|
+
def self.unmatched_enclosers?(text)
|
165
|
+
enclosers = ['**', '""', '()', '[]', '``', "''"]
|
166
|
+
enclosers.each do |pair|
|
167
|
+
starter = Regexp.new('(\W|^)' + Regexp.escape(pair[0]) + '\S')
|
168
|
+
ender = Regexp.new('\S' + Regexp.escape(pair[1]) + '(\W|$)')
|
169
|
+
|
170
|
+
opened = 0
|
171
|
+
|
172
|
+
tokenize(text).each do |token|
|
173
|
+
opened += 1 if token.match(starter)
|
174
|
+
opened -= 1 if token.match(ender)
|
175
|
+
|
176
|
+
return true if opened < 0 # Too many ends!
|
177
|
+
end
|
178
|
+
|
179
|
+
return true if opened != 0 # Mismatch somewhere.
|
180
|
+
end
|
181
|
+
|
182
|
+
false
|
183
|
+
end
|
184
|
+
|
185
|
+
# Determine if a2 is a subsequence of a1
|
186
|
+
# @param a1 [Array]
|
187
|
+
# @param a2 [Array]
|
188
|
+
# @return [Boolean]
|
189
|
+
def self.subseq?(a1, a2)
|
190
|
+
!a1.each_index.find do |i|
|
191
|
+
a1[i...i+a2.length] == a2
|
192
|
+
end.nil?
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|