muck-contacts 2.6.1
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 +24 -0
- data/LICENSE +10 -0
- data/README +58 -0
- data/Rakefile +43 -0
- data/VERSION +1 -0
- data/contacts.gemspec +106 -0
- data/cruise_config.rb +22 -0
- data/examples/grab_contacts.rb +12 -0
- data/geminstaller.yml +8 -0
- data/lib/contacts.rb +37 -0
- data/lib/contacts/aol_importer.rb +149 -0
- data/lib/contacts/base.rb +226 -0
- data/lib/contacts/facebook.rb +24 -0
- data/lib/contacts/gmail.rb +32 -0
- data/lib/contacts/hotmail.rb +122 -0
- data/lib/contacts/json_picker.rb +16 -0
- data/lib/contacts/linked_in.rb +31 -0
- data/lib/contacts/mailru.rb +68 -0
- data/lib/contacts/outlook.rb +59 -0
- data/lib/contacts/plaxo.rb +130 -0
- data/lib/contacts/vcf.rb +25 -0
- data/lib/contacts/yahoo.rb +104 -0
- data/test/example_accounts.yml +73 -0
- data/test/test_helper.rb +37 -0
- data/test/unit/aol_contact_importer_test.rb +34 -0
- data/test/unit/facebook_contact_importer_test.rb +39 -0
- data/test/unit/gmail_contact_importer_test.rb +39 -0
- data/test/unit/hotmail_contact_importer_test.rb +41 -0
- data/test/unit/linked_in_contact_importer_test.rb +39 -0
- data/test/unit/mailru_contact_importer_test.rb +40 -0
- data/test/unit/outlook_test.rb +24 -0
- data/test/unit/test_accounts_test.rb +23 -0
- data/test/unit/vcf_test.rb +18 -0
- data/test/unit/yahoo_csv_contact_importer_test.rb +35 -0
- metadata +202 -0
@@ -0,0 +1,226 @@
|
|
1
|
+
require "cgi"
|
2
|
+
require "net/http"
|
3
|
+
require "net/https"
|
4
|
+
require "uri"
|
5
|
+
require "zlib"
|
6
|
+
require "stringio"
|
7
|
+
require "thread"
|
8
|
+
require "erb"
|
9
|
+
|
10
|
+
class Contacts
|
11
|
+
TYPES = {}
|
12
|
+
FILETYPES = {}
|
13
|
+
VERSION = "1.4.1"
|
14
|
+
|
15
|
+
class Base
|
16
|
+
def initialize(login, password, options={})
|
17
|
+
@login = login
|
18
|
+
@password = password
|
19
|
+
@connections = {}
|
20
|
+
@options = options
|
21
|
+
connect
|
22
|
+
end
|
23
|
+
|
24
|
+
def connect
|
25
|
+
raise AuthenticationError, "Login and password must not be nil, login: #{@login.inspect}, password: #{@password.inspect}" if @login.nil? || @login.empty? || @password.nil? || @password.empty?
|
26
|
+
real_connect
|
27
|
+
end
|
28
|
+
|
29
|
+
def connected?
|
30
|
+
@cookies && !@cookies.empty?
|
31
|
+
end
|
32
|
+
|
33
|
+
def contacts(options = {})
|
34
|
+
return @contacts if @contacts
|
35
|
+
if connected?
|
36
|
+
url = URI.parse(contact_list_url)
|
37
|
+
http = open_http(url)
|
38
|
+
resp, data = http.get("#{url.path}?#{url.query}",
|
39
|
+
"Cookie" => @cookies
|
40
|
+
)
|
41
|
+
|
42
|
+
if resp.code_type != Net::HTTPOK
|
43
|
+
raise ConnectionError, self.class.const_get(:PROTOCOL_ERROR)
|
44
|
+
end
|
45
|
+
|
46
|
+
parse(data, options)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def login
|
51
|
+
@attempt ||= 0
|
52
|
+
@attempt += 1
|
53
|
+
|
54
|
+
if @attempt == 1
|
55
|
+
@login
|
56
|
+
else
|
57
|
+
if @login.include?("@#{domain}")
|
58
|
+
@login.sub("@#{domain}","")
|
59
|
+
else
|
60
|
+
"#{@login}@#{domain}"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def password
|
66
|
+
@password
|
67
|
+
end
|
68
|
+
|
69
|
+
def skip_gzip?
|
70
|
+
false
|
71
|
+
end
|
72
|
+
private
|
73
|
+
|
74
|
+
def domain
|
75
|
+
@d ||= URI.parse(self.class.const_get(:URL)).host.sub(/^www\./,'')
|
76
|
+
end
|
77
|
+
|
78
|
+
def contact_list_url
|
79
|
+
self.class.const_get(:CONTACT_LIST_URL)
|
80
|
+
end
|
81
|
+
|
82
|
+
def address_book_url
|
83
|
+
self.class.const_get(:ADDRESS_BOOK_URL)
|
84
|
+
end
|
85
|
+
|
86
|
+
def open_http(url)
|
87
|
+
c = @connections[Thread.current.object_id] ||= {}
|
88
|
+
http = c["#{url.host}:#{url.port}"]
|
89
|
+
unless http
|
90
|
+
http = Net::HTTP.new(url.host, url.port)
|
91
|
+
if url.port == 443
|
92
|
+
http.use_ssl = true
|
93
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
94
|
+
end
|
95
|
+
c["#{url.host}:#{url.port}"] = http
|
96
|
+
end
|
97
|
+
http.start unless http.started?
|
98
|
+
http
|
99
|
+
end
|
100
|
+
|
101
|
+
def cookie_hash_from_string(cookie_string)
|
102
|
+
cookie_string.split(";").map{|i|i.split("=", 2).map{|j|j.strip}}.inject({}){|h,i|h[i[0]]=i[1];h}
|
103
|
+
end
|
104
|
+
|
105
|
+
def parse_cookies(data, existing="")
|
106
|
+
return existing if data.nil?
|
107
|
+
|
108
|
+
cookies = cookie_hash_from_string(existing)
|
109
|
+
|
110
|
+
data.gsub!(/ ?[\w]+=EXPIRED;/,'')
|
111
|
+
data.gsub!(/ ?expires=(.*?, .*?)[;,$]/i, ';')
|
112
|
+
data.gsub!(/ ?(domain|path)=[\S]*?[;,$]/i,';')
|
113
|
+
data.gsub!(/[,;]?\s*(secure|httponly)/i,'')
|
114
|
+
data.gsub!(/(;\s*){2,}/,', ')
|
115
|
+
data.gsub!(/(,\s*){2,}/,', ')
|
116
|
+
data.sub!(/^,\s*/,'')
|
117
|
+
data.sub!(/\s*,$/,'')
|
118
|
+
|
119
|
+
data.split(", ").map{|t|t.to_s.split(";").first}.each do |data|
|
120
|
+
k, v = data.split("=", 2).map{|j|j.strip}
|
121
|
+
if cookies[k] && v.empty?
|
122
|
+
cookies.delete(k)
|
123
|
+
elsif v && !v.empty?
|
124
|
+
cookies[k] = v
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
cookies.map{|k,v| "#{k}=#{v}"}.join("; ")
|
129
|
+
end
|
130
|
+
|
131
|
+
def remove_cookie(cookie, cookies)
|
132
|
+
parse_cookies("#{cookie}=", cookies)
|
133
|
+
end
|
134
|
+
|
135
|
+
def post(url, postdata, cookies="", referer="")
|
136
|
+
url = URI.parse(url)
|
137
|
+
http = open_http(url)
|
138
|
+
http_header = { "User-Agent" => "Mozilla/5.0 (Macintosh; U; Intel Mac OS X; en-US; rv:1.8.1) Gecko/20061010 Firefox/2.0",
|
139
|
+
"Accept-Encoding" => "gzip",
|
140
|
+
"Cookie" => cookies,
|
141
|
+
"Referer" => referer,
|
142
|
+
"Content-Type" => 'application/x-www-form-urlencoded'
|
143
|
+
}
|
144
|
+
http_header.reject!{|k, v| k == 'Accept-Encoding'} if skip_gzip?
|
145
|
+
resp, data = http.post(url.path, postdata, http_header)
|
146
|
+
data = uncompress(resp, data)
|
147
|
+
cookies = parse_cookies(resp.response['set-cookie'], cookies)
|
148
|
+
forward = resp.response['Location']
|
149
|
+
forward ||= (data =~ /<meta.*?url='([^']+)'/ ? CGI.unescapeHTML($1) : nil)
|
150
|
+
if (not forward.nil?) && URI.parse(forward).host.nil?
|
151
|
+
forward = url.scheme.to_s + "://" + url.host.to_s + forward
|
152
|
+
end
|
153
|
+
return data, resp, cookies, forward
|
154
|
+
end
|
155
|
+
|
156
|
+
def get(url, cookies="", referer="")
|
157
|
+
url = URI.parse(url)
|
158
|
+
http = open_http(url)
|
159
|
+
resp, data = http.get("#{url.path}?#{url.query}",
|
160
|
+
"User-Agent" => "Mozilla/5.0 (Macintosh; U; Intel Mac OS X; en-US; rv:1.8.1) Gecko/20061010 Firefox/2.0",
|
161
|
+
"Accept-Encoding" => "gzip",
|
162
|
+
"Cookie" => cookies,
|
163
|
+
"Referer" => referer
|
164
|
+
)
|
165
|
+
data = uncompress(resp, data)
|
166
|
+
cookies = parse_cookies(resp.response['set-cookie'], cookies)
|
167
|
+
forward = resp.response['Location']
|
168
|
+
if (not forward.nil?) && URI.parse(forward).host.nil?
|
169
|
+
forward = url.scheme.to_s + "://" + url.host.to_s + forward
|
170
|
+
end
|
171
|
+
return data, resp, cookies, forward
|
172
|
+
end
|
173
|
+
|
174
|
+
def uncompress(resp, data)
|
175
|
+
case resp.response['content-encoding']
|
176
|
+
when 'gzip'
|
177
|
+
gz = Zlib::GzipReader.new(StringIO.new(data))
|
178
|
+
data = gz.read
|
179
|
+
gz.close
|
180
|
+
resp.response['content-encoding'] = nil
|
181
|
+
# FIXME: Not sure what Hotmail was feeding me with their 'deflate',
|
182
|
+
# but the headers definitely were not right
|
183
|
+
when 'deflate'
|
184
|
+
data = Zlib::Inflate.inflate(data)
|
185
|
+
resp.response['content-encoding'] = nil
|
186
|
+
end
|
187
|
+
|
188
|
+
data
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
class ContactsError < StandardError
|
193
|
+
end
|
194
|
+
|
195
|
+
class AuthenticationError < ContactsError
|
196
|
+
end
|
197
|
+
|
198
|
+
class ConnectionError < ContactsError
|
199
|
+
end
|
200
|
+
|
201
|
+
class TypeNotFound < ContactsError
|
202
|
+
end
|
203
|
+
|
204
|
+
def self.new(type, login, password="", secret_key="", options={})
|
205
|
+
if !password.nil? && password != '' && !secret_key.nil? && secret_key != ''
|
206
|
+
password = Encryptor.decrypt(URI.unescape(password), :key => secret_key)
|
207
|
+
end
|
208
|
+
if TYPES.include?(type.to_s.intern)
|
209
|
+
TYPES[type.to_s.intern].new(login, password, options)
|
210
|
+
elsif FILETYPES.include?(type.to_s.intern)
|
211
|
+
FILETYPES[type.to_s.intern].new(login)
|
212
|
+
else
|
213
|
+
raise TypeNotFound, "#{type.inspect} is not a valid type, please choose one of the following: #{TYPES.keys.inspect} or #{FILETYPES.keys.inspect}"
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def self.guess(login, password, options={})
|
218
|
+
TYPES.inject([]) do |a, t|
|
219
|
+
begin
|
220
|
+
a + t[1].new(login, password, options).contacts
|
221
|
+
rescue AuthenticationError
|
222
|
+
a
|
223
|
+
end
|
224
|
+
end.uniq
|
225
|
+
end
|
226
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
begin
|
2
|
+
require 'mini_fb'
|
3
|
+
rescue LoadError
|
4
|
+
puts "Contact Gem: No mini_fb gem, so no facebook"
|
5
|
+
else
|
6
|
+
class Contacts
|
7
|
+
class Facebook < Base
|
8
|
+
|
9
|
+
def contacts
|
10
|
+
return @contacts if @contacts
|
11
|
+
end
|
12
|
+
|
13
|
+
|
14
|
+
def real_connect
|
15
|
+
f = MiniFB.get(@password, @login, :type => "friends")
|
16
|
+
raise "Didn't find users" unless f.data
|
17
|
+
@contacts = f.data.map do |hashie| hashie.values end
|
18
|
+
rescue Exception => e
|
19
|
+
raise AuthenticationError, "Facebook authentication failed"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
TYPES[:facebook] = Facebook
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'gdata'
|
2
|
+
|
3
|
+
class Contacts
|
4
|
+
class Gmail < Base
|
5
|
+
|
6
|
+
CONTACTS_SCOPE = 'http://www.google.com/m8/feeds/'
|
7
|
+
CONTACTS_FEED = CONTACTS_SCOPE + 'contacts/default/full/?max-results=1000'
|
8
|
+
|
9
|
+
def contacts
|
10
|
+
return @contacts if @contacts
|
11
|
+
end
|
12
|
+
|
13
|
+
def real_connect
|
14
|
+
@client = GData::Client::Contacts.new
|
15
|
+
@client.clientlogin(@login, @password, @options[:captcha_token], @options[:captcha_response])
|
16
|
+
|
17
|
+
feed = @client.get(CONTACTS_FEED).to_xml
|
18
|
+
|
19
|
+
@contacts = feed.elements.to_a('entry').collect do |entry|
|
20
|
+
title, email = entry.elements['title'].text, nil
|
21
|
+
entry.elements.each('gd:email') do |e|
|
22
|
+
email = e.attribute('address').value
|
23
|
+
end
|
24
|
+
[title, email] unless email.nil?
|
25
|
+
end
|
26
|
+
@contacts.compact!
|
27
|
+
rescue GData::Client::AuthorizationError => e
|
28
|
+
raise AuthenticationError, "Username or password are incorrect"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
TYPES[:gmail] = Gmail
|
32
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
class Contacts
|
2
|
+
class Hotmail < Base
|
3
|
+
URL = "https://login.live.com/login.srf?id=2"
|
4
|
+
OLD_CONTACT_LIST_URL = "http://%s/cgi-bin/addresses"
|
5
|
+
NEW_CONTACT_LIST_URL = "http://%s/mail/GetContacts.aspx"
|
6
|
+
CONTACT_LIST_URL = "http://mpeople.live.com/default.aspx?pg=0"
|
7
|
+
COMPOSE_URL = "http://%s/cgi-bin/compose?"
|
8
|
+
PROTOCOL_ERROR = "Hotmail has changed its protocols, please upgrade this library first. If that does not work, report this error at http://rubyforge.org/forum/?group_id=2693"
|
9
|
+
PWDPAD = "IfYouAreReadingThisYouHaveTooMuchFreeTime"
|
10
|
+
MAX_HTTP_THREADS = 8
|
11
|
+
|
12
|
+
def real_connect
|
13
|
+
data, resp, cookies, forward = get(URL)
|
14
|
+
old_url = URL
|
15
|
+
until forward.nil?
|
16
|
+
data, resp, cookies, forward, old_url = get(forward, cookies, old_url) + [forward]
|
17
|
+
end
|
18
|
+
|
19
|
+
postdata = "PPSX=%s&PwdPad=%s&login=%s&passwd=%s&LoginOptions=2&PPFT=%s" % [
|
20
|
+
CGI.escape(data.split("><").grep(/PPSX/).first[/=\S+$/][2..-3]),
|
21
|
+
PWDPAD[0...(PWDPAD.length-@password.length)],
|
22
|
+
CGI.escape(login),
|
23
|
+
CGI.escape(password),
|
24
|
+
CGI.escape(data.split("><").grep(/PPFT/).first[/=\S+$/][2..-3])
|
25
|
+
]
|
26
|
+
|
27
|
+
form_url = data.split("><").grep(/form/).first.split[5][8..-2]
|
28
|
+
data, resp, cookies, forward = post(form_url, postdata, cookies)
|
29
|
+
|
30
|
+
old_url = form_url
|
31
|
+
until cookies =~ /; PPAuth=/ || forward.nil?
|
32
|
+
data, resp, cookies, forward, old_url = get(forward, cookies, old_url) + [forward]
|
33
|
+
end
|
34
|
+
|
35
|
+
if data.index("The e-mail address or password is incorrect")
|
36
|
+
raise AuthenticationError, "Username and password do not match"
|
37
|
+
elsif data != ""
|
38
|
+
raise AuthenticationError, "Required field must not be blank"
|
39
|
+
elsif cookies == ""
|
40
|
+
raise ConnectionError, PROTOCOL_ERROR
|
41
|
+
end
|
42
|
+
|
43
|
+
data, resp, cookies, forward = get("http://mail.live.com/mail", cookies)
|
44
|
+
until forward.nil?
|
45
|
+
data, resp, cookies, forward, old_url = get(forward, cookies, old_url) + [forward]
|
46
|
+
end
|
47
|
+
|
48
|
+
@domain = URI.parse(old_url).host
|
49
|
+
@cookies = cookies
|
50
|
+
rescue AuthenticationError => m
|
51
|
+
if @attempt == 1
|
52
|
+
retry
|
53
|
+
else
|
54
|
+
raise m
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def contacts(options = {})
|
59
|
+
if connected?
|
60
|
+
url = URI.parse(contact_list_url)
|
61
|
+
data, resp, cookies, forward = get( contact_list_url, @cookies )
|
62
|
+
|
63
|
+
if resp.code_type != Net::HTTPOK
|
64
|
+
raise ConnectionError, self.class.const_get(:PROTOCOL_ERROR)
|
65
|
+
end
|
66
|
+
|
67
|
+
@contacts = []
|
68
|
+
build_contacts = []
|
69
|
+
go = true
|
70
|
+
index = 0
|
71
|
+
|
72
|
+
while(go) do
|
73
|
+
go = false
|
74
|
+
url = URI.parse(get_contact_list_url(index))
|
75
|
+
http = open_http(url)
|
76
|
+
resp, data = http.get(get_contact_list_url(index), "Cookie" => @cookies)
|
77
|
+
|
78
|
+
email_match_text_beginning = Regexp.escape("http://m.mail.live.com/?rru=compose&to=")
|
79
|
+
email_match_text_end = Regexp.escape("&")
|
80
|
+
raw_html = resp.body.split("\n").grep(/(?:e|dn)lk[0-9]+/)
|
81
|
+
raw_html.delete_at 0
|
82
|
+
raw_html.inject('') do |memo, row|
|
83
|
+
c_info = row.match(/(e|dn)lk([0-9])+/)
|
84
|
+
|
85
|
+
# Same contact, or different?
|
86
|
+
build_contacts << [] if memo != c_info[2]
|
87
|
+
|
88
|
+
# Grab info
|
89
|
+
case c_info[1]
|
90
|
+
when "e" # Email
|
91
|
+
build_contacts.last[1] = row.match(/#{email_match_text_beginning}(.*)#{email_match_text_end}/)[1]
|
92
|
+
when "dn" # Name
|
93
|
+
build_contacts.last[0] = row.match(/<a[^>]*>(.+)<\/a>/)[1]
|
94
|
+
end
|
95
|
+
|
96
|
+
# Set memo to contact id
|
97
|
+
c_info[2]
|
98
|
+
end
|
99
|
+
|
100
|
+
go = resp.body.include?("ContactList_next")
|
101
|
+
index += 1
|
102
|
+
end
|
103
|
+
|
104
|
+
build_contacts.each do |contact|
|
105
|
+
unless contact[1].nil?
|
106
|
+
# Only return contacts with email addresses
|
107
|
+
contact[1] = CGI::unescape(contact[1])
|
108
|
+
contact[1] = contact[1].gsub(/&ru.*/, '').gsub(/%40/, '@')
|
109
|
+
@contacts << contact
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
return @contacts
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def get_contact_list_url(index)
|
118
|
+
"http://mpeople.live.com/default.aspx?pg=#{index}"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
TYPES[:hotmail] = Hotmail
|
122
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
if !Object.const_defined?('ActiveSupport')
|
2
|
+
require 'json'
|
3
|
+
end
|
4
|
+
|
5
|
+
class Contacts
|
6
|
+
def self.parse_json( string )
|
7
|
+
if Object.const_defined?('ActiveSupport') and
|
8
|
+
ActiveSupport.const_defined?('JSON')
|
9
|
+
ActiveSupport::JSON.decode( string )
|
10
|
+
elsif Object.const_defined?('JSON')
|
11
|
+
JSON.parse( string )
|
12
|
+
else
|
13
|
+
raise 'Contacts requires JSON or Rails (with ActiveSupport::JSON)'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
begin
|
2
|
+
require 'oauth'
|
3
|
+
rescue LoadError
|
4
|
+
puts "No oauth gem, so no linked_in"
|
5
|
+
else
|
6
|
+
class Contacts
|
7
|
+
class LinkedIn < Base
|
8
|
+
|
9
|
+
def contacts
|
10
|
+
return @contacts if @contacts
|
11
|
+
end
|
12
|
+
|
13
|
+
|
14
|
+
def real_connect
|
15
|
+
consumer = OAuth::Consumer.new(@options[:app_id], @options[:app_secret])
|
16
|
+
access_token = OAuth::AccessToken.new(consumer, @login, @password)
|
17
|
+
raw_connections = access_token.get("http://api.linkedin.com/v1/people/~/connections", 'x-li-format' => 'json').body
|
18
|
+
parsed_connections = JSON.parse(raw_connections)
|
19
|
+
raise "Didn't find users" unless parsed_connections["values"] && parsed_connections["values"].count > 0
|
20
|
+
contacts = parsed_connections["values"]
|
21
|
+
|
22
|
+
@contacts = contacts.map do |contact|
|
23
|
+
["#{contact["firstName"]} #{contact["lastName"]}", contact["id"]]
|
24
|
+
end
|
25
|
+
rescue Exception => e
|
26
|
+
raise AuthenticationError, "linked_in authentication failed"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
TYPES[:linked_in] = LinkedIn
|
30
|
+
end
|
31
|
+
end
|