lonely_coder 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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