lonely_coder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source :rubygems
2
+ gemspec
3
+
4
+ group :test do
5
+ gem 'rspec'
6
+ gem 'webmock'
7
+ gem 'vcr'
8
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,58 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ lonely_coder (0.1.0)
5
+ activesupport (>= 3.2.1)
6
+ mechanize (>= 2.0.0)
7
+
8
+ GEM
9
+ remote: http://rubygems.org/
10
+ specs:
11
+ activesupport (3.2.2)
12
+ i18n (~> 0.6)
13
+ multi_json (~> 1.0)
14
+ addressable (2.2.7)
15
+ crack (0.3.1)
16
+ diff-lcs (1.1.3)
17
+ domain_name (0.5.2)
18
+ unf (~> 0.0.3)
19
+ i18n (0.6.0)
20
+ mechanize (2.3)
21
+ domain_name (~> 0.5, >= 0.5.1)
22
+ mime-types (~> 1.17, >= 1.17.2)
23
+ net-http-digest_auth (~> 1.1, >= 1.1.1)
24
+ net-http-persistent (~> 2.5, >= 2.5.2)
25
+ nokogiri (~> 1.4)
26
+ ntlm-http (~> 0.1, >= 0.1.1)
27
+ webrobots (~> 0.0, >= 0.0.9)
28
+ mime-types (1.18)
29
+ multi_json (1.1.0)
30
+ net-http-digest_auth (1.2)
31
+ net-http-persistent (2.5.2)
32
+ nokogiri (1.5.2)
33
+ ntlm-http (0.1.1)
34
+ rspec (2.9.0)
35
+ rspec-core (~> 2.9.0)
36
+ rspec-expectations (~> 2.9.0)
37
+ rspec-mocks (~> 2.9.0)
38
+ rspec-core (2.9.0)
39
+ rspec-expectations (2.9.0)
40
+ diff-lcs (~> 1.1.3)
41
+ rspec-mocks (2.9.0)
42
+ unf (0.0.5)
43
+ unf_ext
44
+ unf_ext (0.0.4)
45
+ vcr (2.0.0)
46
+ webmock (1.8.4)
47
+ addressable (>= 2.2.7)
48
+ crack (>= 0.1.7)
49
+ webrobots (0.0.13)
50
+
51
+ PLATFORMS
52
+ ruby
53
+
54
+ DEPENDENCIES
55
+ lonely_coder!
56
+ rspec
57
+ vcr
58
+ webmock
data/README.mdown ADDED
@@ -0,0 +1,72 @@
1
+ Lonely Coder
2
+ ===================
3
+
4
+ ,d88b.d88b,
5
+ 88888888888
6
+ `Y8888888Y'
7
+ `Y888Y'
8
+ `Y'
9
+
10
+ Lonely coder seeks nice boy
11
+ -----------------------------
12
+ require 'lonely_coder'
13
+ okc = OKCupid.new('thetrek','thisisntmypasswordweirdo')
14
+
15
+ sweet_guys = []
16
+
17
+ ['Ann Arbor, MI', 'New York, New York',
18
+ 'Chicago, Illinois', 'San Francisco, CA'].each do |maybe_here|
19
+ sweet_guys += okc.search({
20
+ :min_age => 25,
21
+ :max_age => 35, # just someone around my age, yo
22
+ :gentation => 'Guys who like guys', # don't hate
23
+ :order_by => 'Match %', # I want us to get along
24
+ :match_limit => 80, # let's make it last
25
+ :last_login => 'last month',
26
+ :location => maybe_here
27
+ :radius => 25, # acceptable values are 25, 50, 100, 250, 500, nil
28
+ :require_photo => true, # I like a goofy smile
29
+ :relationship_status => 'single' # the heart aches, but not for friends
30
+ }).results
31
+ end
32
+
33
+ sweet_guys.count # oof, 812. Let the coffee dates begin!
34
+
35
+ What is this nonsense?
36
+ -----------------------------
37
+ A ruby gem for interacting with [OKCupid](http://www.okcupid.com/). OKCupid doesn't actually have an API, so we use mechanize to interact with the site. Screen scraping isn't terribly fast, but it's pretty zippy compared to the other option – doing nothing.
38
+
39
+ How does it work?
40
+ -----------------------------
41
+ You'll need an OKCupid account (it's free) because most of the site is behind authentication. You can create a new connection to the site.
42
+
43
+ require 'lonely_coder'
44
+ okc = OKCupid.new('yourusername', 'yourpassword')
45
+
46
+ Once you have a connection you can check out a person's profile
47
+
48
+ trek = okc.profile_for('thetrek')
49
+ trek.match # => 99. Damn, he's pretty sweet
50
+ trek.location # => 'Ann Arbor, Michigan'
51
+ `open #{trek.profile_thumb_urls.first}` # HOT. DAMN.
52
+
53
+ Go ahead and ask for `username`, `match`, `friend`, `enemy`, `location`, `age`, `sex`, `orientation`, `single`, `last_online`, `ethnicity`, `height`, `body_type`, `diet`, `smokes`, `drinks`, `drugs`, `religion`, `sign`, `education`, `job`, `income`, `offspring`, `pets`, `speaks`, and `profile_thumb_urls`
54
+
55
+ If you don't have particular username in mind, you can search. Check `lib/magic_constants.rb` for the crazy [Magic Numbers](http://en.wikipedia.org/wiki/Magic_number_(programming\)).
56
+
57
+ search = okc.search({
58
+ :min_age => 18,
59
+ :max_age => 99,
60
+ :gentation => 'guys who like guys',
61
+ :ethnicity => ['human']
62
+ }) # search object
63
+
64
+ # fires the loading of the first 10, but only limited profiles. If you'd like full
65
+ # deets, use okc.profile_for with a results username.
66
+ search.results
67
+ search.load_next_page # loads the next 10 results
68
+
69
+ What if things don't go well
70
+ -----------------------------
71
+
72
+ okc.love(1000) # ♥
@@ -0,0 +1,41 @@
1
+ class OKCupid
2
+
3
+ def authenticate(username, password)
4
+ @authentication = Authentication.new(username, password, @browser)
5
+ end
6
+
7
+ class Authentication
8
+ def initialize(username, password, browser)
9
+ change_to_using_simpler_parser(browser)
10
+
11
+ browser.post("https://www.okcupid.com/login", {
12
+ username: username,
13
+ password: password
14
+ })
15
+
16
+ @success = browser.page.uri.path == '/home'
17
+
18
+ restore_default_parser(browser)
19
+ end
20
+
21
+ def success?
22
+ @success
23
+ end
24
+
25
+ def change_to_using_simpler_parser(browser)
26
+ browser.pluggable_parser.html = AuthenticationParser
27
+ end
28
+
29
+ def restore_default_parser(browser)
30
+ browser.pluggable_parser.html = Mechanize::Page
31
+ end
32
+ end
33
+
34
+ class AuthenticationParser < Mechanize::Page
35
+ # We're only using page uri to determine successful login, so
36
+ # there's not a lot of value in passing a body string to nokogiri
37
+ def initialize(uri = nil, response = nil, body = nil, code =nil)
38
+ super(uri, response, nil, code)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,99 @@
1
+ class OKCupid
2
+ module MagicNumbers
3
+ # Used to build OKCupid search urls. These are the wacky values
4
+ # that OKCupid expects.
5
+ Ethnicity = {
6
+ "asian" => 2,
7
+ "black" => 8,
8
+ "hispanic/latin" => 128,
9
+ "indian" => 32,
10
+ "middle eastern" => 4,
11
+ "native american" => 16,
12
+ "pacific islander" => 64,
13
+ "white" => 256,
14
+ "human" => 512
15
+ }
16
+
17
+ Gentation = {
18
+ "girls who like guys" => 34,
19
+ "guys who like girls" => 17,
20
+ "girls who like girls" => 40,
21
+ "guys who like guys" => 20,
22
+ "both who like bi guys" => 54,
23
+ "both who like bi girls" => 57,
24
+ "straight girls only" => 2,
25
+ "Straight guys only" => 1,
26
+ "gay girls only" => 8,
27
+ "gay guys only" => 4,
28
+ "bi girls only" => 32,
29
+ "bi guys only" => 16,
30
+ "everybody" => 63
31
+ }
32
+
33
+ Filters = {
34
+ # "account_status" => 29,
35
+ "age" => 2,
36
+ # "body_type" => 30,
37
+ # "cats" => 17,
38
+ # "children" => 18,
39
+ # "community_award" => 31,
40
+ # "diet" => 54,
41
+ # "dogs" => 16,
42
+ # "drinking" => 12,
43
+ # "drugs" => 13,
44
+ # "education" => 19,
45
+ # "eligible" => 7,
46
+ "ethnicity" => 9,
47
+ "gentation" => 0,
48
+ # "height" => 10,
49
+ # "jobtype" => 15,
50
+ # "join_date" => 6,
51
+ # "languages" => 22,
52
+ "last_login" => 5,
53
+ # "looking_for" => 32,
54
+ # "money" => 14,
55
+ # "not_looking_for" => 34,
56
+ # "num_ques_ans" => 33,
57
+ # "personality" => 20,
58
+ # "prof_score" => 28,
59
+ "radius" => 3,
60
+ "relationship_status" => 35,
61
+ # "religion" => 8,
62
+ "require_photo" => 1,
63
+ # "sign" => 21,
64
+ # "smoking" => 11,
65
+ # "v_first_contact" => 27,
66
+ # "v_looks" => 23,
67
+ # "v_personality" => 25,
68
+
69
+ # added by us
70
+ 'match_limit' => 'match_limit',
71
+ 'order_by' => 'order_by',
72
+ 'location' => 'location'
73
+ }
74
+
75
+ RelationshipStatus = {
76
+ 'single' => 2,
77
+ 'not single' => 12,
78
+ 'any' => 0
79
+ }
80
+
81
+ OrderBy = {
82
+ 'match %' => 'MATCH',
83
+ 'friend %' => 'FRIEND',
84
+ 'enemy %' => 'ENEMY',
85
+ 'special blend' => 'SPECIAL_BLEND',
86
+ 'join' => 'JOIN',
87
+ 'last login' => 'LOGIN'
88
+ }
89
+
90
+ LastLogin = {
91
+ "now" => 3600,
92
+ "last day" => 86400,
93
+ "last week" => 604800,
94
+ "last month" => 2678400,
95
+ "last year" => 31536000,
96
+ "last decade" => 315360000
97
+ }
98
+ end
99
+ end
@@ -0,0 +1,99 @@
1
+ # encoding: UTF-8
2
+ class OKCupid
3
+
4
+ def profile_for(username)
5
+ Profile.by_username(username, @browser)
6
+ end
7
+
8
+ class Profile
9
+ attr_accessor :username, :match, :friend, :enemy, :location,
10
+ :age, :sex, :orientation, :single, :small_avatar_url
11
+
12
+ # extended profile details
13
+ attr_accessor :last_online, :ethnicity, :height, :body_type, :diet, :smokes,
14
+ :drinks, :drugs, :religion, :sign, :education, :job, :income,
15
+ :offspring, :pets, :speaks, :profile_thumb_urls
16
+
17
+
18
+ # Scraping is never pretty.
19
+ def self.from_search_result(html)
20
+
21
+ username = html.search('span.username').text
22
+ age, sex, orientation, single = html.search('p.aso').text.split('/')
23
+
24
+ percents = html.search('div.percentages')
25
+ match = percents.search('p.match .percentage').text.to_i
26
+ friend = percents.search('p.friend .percentage').text.to_i
27
+ enemy = percents.search('p.enemy .percentage').text.to_i
28
+
29
+ location = html.search('p.location').text
30
+ small_avatar_url = html.search('a.user_image img').attribute('src').value
31
+
32
+ OKCupid::Profile.new({
33
+ username: username,
34
+ age: OKCupid.strip(age),
35
+ sex: OKCupid.strip(sex),
36
+ orientation: OKCupid.strip(orientation),
37
+ single: OKCupid.strip(single),
38
+ match: match,
39
+ friend: friend,
40
+ enemy: enemy,
41
+ location: location,
42
+ small_avatar_url: small_avatar_url
43
+ })
44
+ end
45
+
46
+ def Profile.by_username(username, browser)
47
+ html = browser.get("http://www.okcupid.com/profile/#{username}")
48
+
49
+ percents = html.search('#percentages')
50
+ match = percents.search('span.match').text.to_i
51
+ friend = percents.search('span.friend').text.to_i
52
+ enemy = percents.search('span.enemy').text.to_i
53
+
54
+ basic = html.search('#aso_loc')
55
+ age = basic.search('#ajax_age').text
56
+ sex = basic.search('#ajax_gender').text
57
+ orientation = basic.search('#ajax_orientation').text
58
+ single = basic.search('#ajax_status').text
59
+ location = basic.search('#ajax_location').text
60
+
61
+ profile_thumb_urls = html.search('#profile_thumbs img').collect {|img| img.attribute('src').value}
62
+
63
+ attributes = {
64
+ username: username,
65
+ match: match,
66
+ friend: friend,
67
+ enemy: enemy,
68
+ age: age,
69
+ sex: sex,
70
+ orientation: orientation,
71
+ location: location,
72
+ single: single,
73
+ profile_thumb_urls: profile_thumb_urls
74
+ }
75
+
76
+ details_div = html.search('#profile_details dl')
77
+
78
+ details_div.each do |node|
79
+ value = OKCupid.strip(node.search('dd').text)
80
+ next if value == '—'
81
+
82
+ attr_name = node.search('dt').text.downcase.gsub(' ','_')
83
+ attributes[attr_name] = value
84
+ end
85
+
86
+ self.new(attributes)
87
+ end
88
+
89
+ def initialize(attributes)
90
+ attributes.each do |attr,val|
91
+ self.send("#{attr}=", val)
92
+ end
93
+ end
94
+
95
+ def ==(other)
96
+ self.username == other.username
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,199 @@
1
+ require 'uri'
2
+
3
+ class OKCupid
4
+ def search(options={})
5
+ Search.new(options, @browser)
6
+ end
7
+
8
+ class Search
9
+ class FilterError < StandardError; end
10
+
11
+ attr_reader :filters
12
+
13
+ def self.location_id_for(query)
14
+ uri = URI("http://www.okcupid.com/locquery?func=query&query=#{URI.encode(query)}")
15
+ JSON.parse(Net::HTTP.get(uri))['results'][0]['locid'].to_s
16
+ end
17
+
18
+ def initialize(options, browser = Mechanize.new)
19
+ @browser = browser
20
+ options = defaults.merge(options)
21
+ parse(options)
22
+ end
23
+
24
+ def parse(options)
25
+ combine_ages(options)
26
+ check_for_required_options(options)
27
+ remove_match_limit(options)
28
+
29
+ @filters = []
30
+ options.each do |name,value|
31
+
32
+ if OKCupid.const_defined?("#{name.to_s.camelize}Filter")
33
+ @filters << OKCupid.const_get("#{name.to_s.camelize}Filter").new(name, value)
34
+ else
35
+ @filters << Filter.new(name, value)
36
+ end
37
+ end
38
+ end
39
+
40
+ def remove_match_limit(options)
41
+ @match_limit = options.delete(:match_limit)
42
+ end
43
+
44
+ def check_for_required_options(options)
45
+ raise(FilterError, 'gentation is a required option') unless options.has_key?(:gentation)
46
+ end
47
+
48
+ def combine_ages(options)
49
+ age = [options.delete(:min_age), options.delete(:max_age)]
50
+ options[:age] = age
51
+ end
52
+
53
+ # Default values for search:
54
+ # match_limit 80
55
+ # min_age 18
56
+ # max_age 99
57
+ # order_by 'match %'
58
+ # last_login 'last month'
59
+ # location 'Near me'
60
+ # to search 'anywhere', use 'Near me' and omit a radius
61
+ # radius 25
62
+ # require_photo true
63
+ # relationship_status 'single'
64
+ def defaults
65
+ {
66
+ :match_limit => 80,
67
+ :min_age => 18,
68
+ :max_age => 99,
69
+ :order_by => 'Match %',
70
+ :last_login => 'last month',
71
+ :location => 'Near me',
72
+ :radius => 25, # acceptable values are 25, 50, 100, 250, 500, nil
73
+ :require_photo => true,
74
+ :relationship_status => 'single'
75
+ }
76
+ end
77
+
78
+ def results
79
+ return @results if @results
80
+
81
+ page = @browser.get(url)
82
+ @results = page.search('.match_row').collect do |node|
83
+ OKCupid::Profile.from_search_result(node)
84
+ end
85
+ end
86
+
87
+ def load_next_page
88
+ @browser.pluggable_parser.html = SearchPaginationParser
89
+ page = @browser.get("#{url}&low=11&count=10&ajax_load=1")
90
+ @results += page.search('.match_row').collect do |node|
91
+ OKCupid::Profile.from_search_result(node)
92
+ end
93
+ @browser.pluggable_parser.html = Mechanize::Page
94
+ self
95
+ end
96
+
97
+ def url
98
+ '/match?' + filters.compact.to_enum(:each_with_index).map {|filter,index| filter.to_param(index+1)}.join('&')
99
+ end
100
+ end
101
+
102
+ class Filter
103
+ class NoSuchFilter < StandardError; end
104
+ class BadValue < StandardError; end
105
+
106
+ attr_reader :name, :value, :code
107
+
108
+ def initialize(name, value)
109
+ @code = MagicNumbers::Filters[name.to_s]
110
+ raise(NoSuchFilter, name) unless @code
111
+
112
+ @name = name.to_s
113
+ @value = value
114
+ @encoded_value = lookup(@value)
115
+ unless @encoded_value
116
+ raise(BadValue, "#{@value.inspect} is not a possible value for #{@name}. Try one of #{allowed_values.map(&:inspect).join(', ')}")
117
+ end
118
+ end
119
+
120
+ def allowed_values
121
+ MagicNumbers.const_get(@name.camelize).keys
122
+ end
123
+
124
+ def lookup(value)
125
+ MagicNumbers.const_get(@name.camelize)[value.downcase]
126
+ end
127
+
128
+ def to_param(n)
129
+ "filter#{n}=#{@code},#{@encoded_value}"
130
+ end
131
+ end
132
+
133
+ class EthnicityFilter < Filter
134
+ def lookup(values)
135
+ # lookup the race values and sum them. I think OKC is doing some kind of base2 math on them
136
+ values.collect {|v| MagicNumbers::Ethnicity[v.downcase]}.inject(0, :+)
137
+ end
138
+ end
139
+
140
+ class LocationFilter < Filter
141
+ def lookup(value)
142
+ ''
143
+ end
144
+
145
+ def to_param(n)
146
+
147
+ # to do: 'anywhere' needs to remove the radius filter
148
+ if @value.is_a?(String)
149
+ if @value.downcase == 'near me'
150
+ "locid=0"
151
+ else
152
+ "lquery=#{URI.escape(@value)}"
153
+ end
154
+ else
155
+ "locid=#{@value}"
156
+ end
157
+ end
158
+ end
159
+
160
+ class OrderByFilter < Filter
161
+ def to_param(n)
162
+ "matchOrderBy=#{@encoded_value}"
163
+ end
164
+ end
165
+
166
+ # we fake this by paginating results ourselves.
167
+ # class MatchLimitFilter < Filter
168
+ # def lookup(value)
169
+ # 'MATCH'
170
+ # end
171
+ #
172
+ # def to_param(n)
173
+ # nil
174
+ # end
175
+ # end
176
+
177
+ class RequirePhotoFilter < Filter
178
+ def lookup(value)
179
+ value ? 1 : 0
180
+ end
181
+ end
182
+
183
+ class RadiusFilter < Filter
184
+ def lookup(value)
185
+ value.nil? ? '' : value
186
+ end
187
+
188
+ def to_param(n)
189
+ return nil if @encoded_value === ''
190
+ super
191
+ end
192
+ end
193
+
194
+ class AgeFilter < Filter
195
+ def lookup(value)
196
+ "#{value[0]},#{value[1]}"
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,12 @@
1
+ require 'json'
2
+ class OKCupid
3
+ # OKCupid's ajax pagination follows pjax pattern and returns json
4
+ # with page fragments. We switch to this custom parser when
5
+ # interaction with search.
6
+ class SearchPaginationParser < Mechanize::Page
7
+ def initialize(uri = nil, response = nil, body = nil, code =nil)
8
+ body = JSON.parse(body)['html']
9
+ super(uri, response, body, code)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,38 @@
1
+ #encoding: UTF-8
2
+
3
+ # Hey there.
4
+ # ,d88b.d88b,
5
+ # 88888888888
6
+ # `Y8888888Y'
7
+ # `Y888Y'
8
+ # `Y' - trek
9
+ #
10
+ require 'mechanize'
11
+
12
+ class OKCupid
13
+ BaseUrl = 'http://www.okcupid.com'
14
+ VERSION = '0.1.0'
15
+
16
+ def initialize(username=nil, password=nil)
17
+ @browser = Mechanize.new
18
+ @browser.user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.79 Safari/535.11'
19
+ authenticate(username, password)
20
+ end
21
+
22
+ WhiteSpace = "\302\240"
23
+ def self.strip(str)
24
+ str.gsub(WhiteSpace, ' ').strip
25
+ end
26
+
27
+ def love(n=20)
28
+ ' ♥ ' * n
29
+ end
30
+ end
31
+
32
+ require 'active_support/core_ext/string/inflections'
33
+
34
+ require 'lonely_coder/magic_constants'
35
+ require 'lonely_coder/profile'
36
+ require 'lonely_coder/search'
37
+ require 'lonely_coder/search_pagination_parser'
38
+ require 'lonely_coder/authentication'
@@ -0,0 +1,26 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Authentication' do
4
+ it "returns true if successful" do
5
+ VCR.use_cassette('successful_authentication', :erb => {username: ENV['OKC_USERNAME'], password: ENV['OKC_PASSWORD']}) do
6
+ auth = OKCupid::Authentication.new(ENV['OKC_USERNAME'], ENV['OKC_PASSWORD'], Mechanize.new)
7
+ auth.success?.should == true
8
+ end
9
+ end
10
+
11
+ it "returns false if not successful" do
12
+ VCR.use_cassette('failed_authentication') do
13
+ auth = OKCupid::Authentication.new('thisisnotauser', 'thisisnotapassword', Mechanize.new)
14
+ auth.success?.should == false
15
+ end
16
+ end
17
+
18
+
19
+ it "restores Mechanize::Page as a parser after authenticating" do
20
+ @browser = Mechanize.new
21
+ VCR.use_cassette('successful_authentication', :erb => {username: ENV['OKC_USERNAME'], password: ENV['OKC_PASSWORD']}) do
22
+ auth = OKCupid::Authentication.new(ENV['OKC_USERNAME'], ENV['OKC_PASSWORD'], @browser)
23
+ end
24
+ @browser.pluggable_parser['text/html'].should == Mechanize::Page
25
+ end
26
+ end