twitter_friendly 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/.gitignore +4 -0
- data/.rspec +2 -0
- data/.travis.yml +41 -0
- data/Gemfile.lock +44 -5
- data/README.md +163 -11
- data/bin/test/friends_and_followers.rb +95 -0
- data/gemfiles/ar_4.2.gemfile +5 -0
- data/gemfiles/ar_5.1.gemfile +5 -0
- data/gemfiles/ar_5.2.gemfile +5 -0
- data/lib/twitter_friendly.rb +12 -0
- data/lib/twitter_friendly/cache.rb +48 -0
- data/lib/twitter_friendly/cache_key.rb +64 -0
- data/lib/twitter_friendly/caching.rb +86 -0
- data/lib/twitter_friendly/client.rb +52 -0
- data/lib/twitter_friendly/log_subscriber.rb +109 -0
- data/lib/twitter_friendly/logger.rb +18 -0
- data/lib/twitter_friendly/rate_limit.rb +81 -0
- data/lib/twitter_friendly/rest/api.rb +34 -0
- data/lib/twitter_friendly/rest/base.rb +32 -0
- data/lib/twitter_friendly/rest/collector.rb +78 -0
- data/lib/twitter_friendly/rest/favorites.rb +15 -0
- data/lib/twitter_friendly/rest/friends_and_followers.rb +64 -0
- data/lib/twitter_friendly/rest/lists.rb +22 -0
- data/lib/twitter_friendly/rest/parallel.rb +30 -0
- data/lib/twitter_friendly/rest/search.rb +16 -0
- data/lib/twitter_friendly/rest/timelines.rb +15 -0
- data/lib/twitter_friendly/rest/tweets.rb +13 -0
- data/lib/twitter_friendly/rest/users.rb +49 -0
- data/lib/twitter_friendly/rest/utils.rb +11 -0
- data/lib/twitter_friendly/serializer.rb +79 -0
- data/lib/twitter_friendly/utils.rb +6 -0
- data/lib/twitter_friendly/version.rb +1 -1
- data/twitter_friendly.gemspec +11 -2
- metadata +127 -10
data/lib/twitter_friendly.rb
CHANGED
@@ -1,4 +1,16 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_support/core_ext'
|
3
|
+
require 'twitter'
|
4
|
+
|
1
5
|
require "twitter_friendly/version"
|
6
|
+
require "twitter_friendly/utils"
|
7
|
+
require "twitter_friendly/logger"
|
8
|
+
require "twitter_friendly/log_subscriber"
|
9
|
+
require "twitter_friendly/serializer"
|
10
|
+
require "twitter_friendly/cache_key"
|
11
|
+
require "twitter_friendly/cache"
|
12
|
+
require "twitter_friendly/rate_limit"
|
13
|
+
require "twitter_friendly/client"
|
2
14
|
|
3
15
|
module TwitterFriendly
|
4
16
|
class Error < StandardError; end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
module TwitterFriendly
|
5
|
+
class Cache
|
6
|
+
extend Forwardable
|
7
|
+
def_delegators :@client, :clear, :cleanup
|
8
|
+
|
9
|
+
def initialize(*args)
|
10
|
+
options = args.extract_options!
|
11
|
+
|
12
|
+
path = options[:cache_dir] || File.join('.twitter_friendly', 'cache')
|
13
|
+
FileUtils.mkdir_p(path) unless File.exists?(path)
|
14
|
+
@client = ::ActiveSupport::Cache::FileStore.new(path, expires_in: 1.hour, race_condition_ttl: 5.minutes)
|
15
|
+
end
|
16
|
+
|
17
|
+
def fetch(method, user, options = {}, &block)
|
18
|
+
key = CacheKey.gen(method, user, options.except(:args))
|
19
|
+
super_operation = options[:args].length >= 2 && options[:args][1][:super_operation]
|
20
|
+
|
21
|
+
block_result = nil
|
22
|
+
blk =
|
23
|
+
Proc.new do
|
24
|
+
block_result = yield
|
25
|
+
encode(block_result, args: options[:args])
|
26
|
+
end
|
27
|
+
|
28
|
+
fetch_result =
|
29
|
+
if super_operation
|
30
|
+
@client.fetch(key, tf_super_operation: super_operation, &blk)
|
31
|
+
else
|
32
|
+
@client.fetch(key, &blk)
|
33
|
+
end
|
34
|
+
|
35
|
+
block_result ? block_result : decode(fetch_result, args: options[:args])
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def encode(obj, options)
|
41
|
+
Serializer.encode(obj, options)
|
42
|
+
end
|
43
|
+
|
44
|
+
def decode(str, options)
|
45
|
+
Serializer.decode(str, options)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'digest/md5'
|
2
|
+
|
3
|
+
module TwitterFriendly
|
4
|
+
class CacheKey
|
5
|
+
DELIM = ':'
|
6
|
+
VERSION = '1'
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def gen(method, user, options = {})
|
10
|
+
[version,
|
11
|
+
method,
|
12
|
+
method_identifier(method, user, options),
|
13
|
+
options_identifier(method, options)
|
14
|
+
].compact.join(DELIM)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def version
|
20
|
+
'v' + VERSION
|
21
|
+
end
|
22
|
+
|
23
|
+
def method_identifier(method, user, options)
|
24
|
+
case
|
25
|
+
when method == :search then "query#{DELIM}#{user}"
|
26
|
+
when method == :friendship? then "from#{DELIM}#{user[0]}#{DELIM}to#{DELIM}#{user[1]}"
|
27
|
+
when method == :list_members then "list_id#{DELIM}#{user}"
|
28
|
+
when method == :collect_with_max_id then method_identifier(options[:super_operation], user, options)
|
29
|
+
when method == :collect_with_cursor then method_identifier(options[:super_operation], user, options)
|
30
|
+
when user.nil? && options[:hash].present? then "token-hash#{DELIM}#{options[:hash]}"
|
31
|
+
else user_identifier(user)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def user_identifier(user)
|
36
|
+
case
|
37
|
+
when user.kind_of?(Integer) then "id#{DELIM}#{user}"
|
38
|
+
when user.kind_of?(String) then "screen_name#{DELIM}#{user}"
|
39
|
+
when user.kind_of?(Array) && user.empty? then 'The_#users_is_called_with_an_empty_array'
|
40
|
+
when user.kind_of?(Array) && user[0].kind_of?(Integer) then "ids#{DELIM}#{user.size}-#{hexdigest(user)}"
|
41
|
+
when user.kind_of?(Array) && user[0].kind_of?(String) then "screen_names#{DELIM}#{user.size}-#{hexdigest(user)}"
|
42
|
+
else raise "#{__method__}: No matches #{user.inspect}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def options_identifier(method, options)
|
47
|
+
# TODO 内部的な値はすべてprefix _tf_ をつける
|
48
|
+
opt = options.except(:hash, :call_count, :call_limit, :super_operation, :super_super_operation, :recursive, :parallel)
|
49
|
+
opt[:in] = options[:super_operation] if %i(collect_with_max_id collect_with_cursor).include?(method)
|
50
|
+
|
51
|
+
if opt.empty?
|
52
|
+
nil
|
53
|
+
else
|
54
|
+
str = opt.map {|k, v| "#{k}=#{v}"}.join('&')
|
55
|
+
"options#{DELIM}#{str}"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def hexdigest(ary)
|
60
|
+
Digest::MD5.hexdigest(ary.join(','))
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module TwitterFriendly
|
2
|
+
module Caching
|
3
|
+
%i(
|
4
|
+
verify_credentials
|
5
|
+
user?
|
6
|
+
user
|
7
|
+
users
|
8
|
+
blocked_ids
|
9
|
+
friendship?
|
10
|
+
).each do |name|
|
11
|
+
define_method(name) do |*args|
|
12
|
+
options = args.extract_options!
|
13
|
+
Instrumenter.start_processing(name, options)
|
14
|
+
|
15
|
+
Instrumenter.complete_processing(name, options) do
|
16
|
+
do_request =
|
17
|
+
Proc.new {Instrumenter.perform_request(name, options) {options.empty? ? super(*args) : super(*args, options)}}
|
18
|
+
|
19
|
+
if Utils.cache_disabled?(options)
|
20
|
+
do_request.call
|
21
|
+
else
|
22
|
+
user = (name == :friendship?) ? args[0, 2] : args[0]
|
23
|
+
@cache.fetch(name, user, options.merge(args: [name, options], hash: credentials_hash), &do_request)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# これらのメソッドは、内部で呼ぶメソッドをキャッシュするため、
|
30
|
+
# 全体をキャッシュすることはない。
|
31
|
+
%i(
|
32
|
+
friend_ids
|
33
|
+
follower_ids
|
34
|
+
friends
|
35
|
+
followers
|
36
|
+
friend_ids_and_follower_ids
|
37
|
+
friends_and_followers
|
38
|
+
search
|
39
|
+
favorites
|
40
|
+
home_timeline
|
41
|
+
user_timeline
|
42
|
+
mentions_timeline
|
43
|
+
memberships
|
44
|
+
list_members
|
45
|
+
retweeters_ids
|
46
|
+
).each do |name|
|
47
|
+
define_method(name) do |*args|
|
48
|
+
options = args.extract_options!
|
49
|
+
Instrumenter.start_processing(name, options)
|
50
|
+
|
51
|
+
Instrumenter.complete_processing(name, options) do
|
52
|
+
options.empty? ? super(*args) : super(*args, options)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
module Instrumenter
|
58
|
+
|
59
|
+
module_function
|
60
|
+
|
61
|
+
def start_processing(operation, options)
|
62
|
+
payload = {operation: operation}.merge(options)
|
63
|
+
::ActiveSupport::Notifications.instrument('start_processing.twitter_friendly', payload) {}
|
64
|
+
end
|
65
|
+
|
66
|
+
def complete_processing(operation, options)
|
67
|
+
payload = {operation: operation}.merge(options)
|
68
|
+
::ActiveSupport::Notifications.instrument('complete_processing.twitter_friendly', payload) { yield(payload) }
|
69
|
+
end
|
70
|
+
|
71
|
+
def perform_request(caller, options, &block)
|
72
|
+
payload = {operation: 'request', args: [caller, options]}
|
73
|
+
::ActiveSupport::Notifications.instrument('request.twitter_friendly', payload) { yield(payload) }
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
module Utils
|
78
|
+
|
79
|
+
module_function
|
80
|
+
|
81
|
+
def cache_disabled?(options)
|
82
|
+
options.is_a?(Hash) && options.has_key?(:cache) && !options[:cache]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
require 'twitter_friendly/rest/api'
|
4
|
+
|
5
|
+
module TwitterFriendly
|
6
|
+
class Client
|
7
|
+
extend Forwardable
|
8
|
+
def_delegators :@twitter, :access_token, :access_token_secret, :consumer_key, :consumer_secret
|
9
|
+
|
10
|
+
include TwitterFriendly::REST::API
|
11
|
+
include TwitterFriendly::RateLimit
|
12
|
+
|
13
|
+
def initialize(*args)
|
14
|
+
options = args.extract_options!
|
15
|
+
|
16
|
+
@logger = TwitterFriendly::Logger.new(options)
|
17
|
+
|
18
|
+
unless subscriber_attached?
|
19
|
+
if @logger.level == ::Logger::DEBUG
|
20
|
+
@@subscriber_attached = true
|
21
|
+
TwitterFriendly::Logging.logger = @logger
|
22
|
+
TwitterFriendly::TFLogSubscriber.attach_to :twitter_friendly
|
23
|
+
TwitterFriendly::ASLogSubscriber.attach_to :active_support
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
@cache = TwitterFriendly::Cache.new(options)
|
28
|
+
@twitter = Twitter::REST::Client.new(options)
|
29
|
+
end
|
30
|
+
|
31
|
+
def cache
|
32
|
+
@cache
|
33
|
+
end
|
34
|
+
|
35
|
+
def logger
|
36
|
+
@logger
|
37
|
+
end
|
38
|
+
|
39
|
+
def internal_client
|
40
|
+
@twitter
|
41
|
+
end
|
42
|
+
|
43
|
+
def subscriber_attached?
|
44
|
+
@@subscriber_attached ||= false
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def cache(cache_dir = nil)
|
49
|
+
TwitterFriendly::Cache.new(cache_dir: cache_dir)
|
50
|
+
end
|
51
|
+
module_function :cache
|
52
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
module TwitterFriendly
|
2
|
+
module Logging
|
3
|
+
def truncated_payload(payload)
|
4
|
+
return payload.inspect if !payload.has_key?(:args) || !payload[:args].is_a?(Array) || payload[:args].empty? || !payload[:args][0].is_a?(Array)
|
5
|
+
|
6
|
+
args = payload[:args].dup
|
7
|
+
args[0] =
|
8
|
+
if args[0].size > 3
|
9
|
+
"[#{args[0].take(3).join(', ')} ... #{args[0].size}]"
|
10
|
+
else
|
11
|
+
args[0].inspect
|
12
|
+
end
|
13
|
+
|
14
|
+
{args: args}.merge(payload.except(:args)).inspect
|
15
|
+
end
|
16
|
+
|
17
|
+
def nested_indent(payload)
|
18
|
+
(payload[:super_operation] ? ' ' : '') + (payload[:super_super_operation] ? ' ' : '') + (payload[:tf_super_operation] ? ' ' : '')
|
19
|
+
end
|
20
|
+
|
21
|
+
module_function
|
22
|
+
|
23
|
+
def logger
|
24
|
+
@@logger
|
25
|
+
end
|
26
|
+
|
27
|
+
def logger=(logger)
|
28
|
+
@@logger = logger
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
class TFLogSubscriber < ::ActiveSupport::LogSubscriber
|
33
|
+
include Logging
|
34
|
+
|
35
|
+
def start_processing(event)
|
36
|
+
payload = event.payload
|
37
|
+
name = "#{nested_indent(payload)}TF::Started #{payload.delete(:operation)}"
|
38
|
+
debug do
|
39
|
+
if payload[:super_operation]
|
40
|
+
"#{name} in #{payload[:super_operation]} at #{Time.now}"
|
41
|
+
else
|
42
|
+
"#{name} at #{Time.now}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def complete_processing(event)
|
48
|
+
payload = event.payload
|
49
|
+
name = "TF::Completed #{payload.delete(:operation)} in #{event.duration.round(1)}ms"
|
50
|
+
debug do
|
51
|
+
"#{nested_indent(payload)}#{name}#{" #{truncated_payload(payload)}" unless payload.empty?}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def twitter_friendly_any(event)
|
56
|
+
payload = event.payload
|
57
|
+
payload.delete(:name)
|
58
|
+
operation = payload.delete(:operation)
|
59
|
+
name =
|
60
|
+
if operation.to_sym == :collect
|
61
|
+
" TW::#{operation.capitalize} #{payload[:args].last[:super_operation]} in #{payload[:args][0]} (#{event.duration.round(1)}ms)"
|
62
|
+
else
|
63
|
+
" TW::#{operation.capitalize} #{payload[:args][0]} (#{event.duration.round(1)}ms)"
|
64
|
+
end
|
65
|
+
c =
|
66
|
+
if %i(encode decode).include?(operation.to_sym)
|
67
|
+
YELLOW
|
68
|
+
elsif %i(collect).include?(operation.to_sym)
|
69
|
+
BLUE
|
70
|
+
else
|
71
|
+
CYAN
|
72
|
+
end
|
73
|
+
name = color(name, c, true)
|
74
|
+
debug { " #{nested_indent(payload)}#{name}#{" #{truncated_payload(payload)}" unless payload.empty?}" }
|
75
|
+
end
|
76
|
+
|
77
|
+
%w(request encode decode collect).each do |operation|
|
78
|
+
class_eval <<-METHOD, __FILE__, __LINE__ + 1
|
79
|
+
def #{operation}(event)
|
80
|
+
event.payload[:name] = '#{operation}'
|
81
|
+
twitter_friendly_any(event)
|
82
|
+
end
|
83
|
+
METHOD
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
class ASLogSubscriber < ::ActiveSupport::LogSubscriber
|
88
|
+
include Logging
|
89
|
+
|
90
|
+
def cache_any(event)
|
91
|
+
payload = event.payload
|
92
|
+
operation = payload[:super_operation] == :fetch ? :fetch : payload[:name]
|
93
|
+
hit = %i(read fetch).include?(operation.to_sym) && payload[:hit]
|
94
|
+
name = " AS::#{operation.capitalize}#{' (Hit)' if hit} #{payload[:key].split(':')[1]} (#{event.duration.round(1)}ms)"
|
95
|
+
name = color(name, MAGENTA, true)
|
96
|
+
debug { "#{nested_indent(payload)}#{name} #{(payload.except(:name, :expires_in, :super_operation, :hit, :race_condition_ttl, :tf_super_operation).inspect)}" }
|
97
|
+
end
|
98
|
+
|
99
|
+
# Ignore generate and fetch_hit
|
100
|
+
%w(read write delete exist?).each do |operation|
|
101
|
+
class_eval <<-METHOD, __FILE__, __LINE__ + 1
|
102
|
+
def cache_#{operation}(event)
|
103
|
+
event.payload[:name] = '#{operation}'
|
104
|
+
cache_any(event)
|
105
|
+
end
|
106
|
+
METHOD
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
require 'fileutils'
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
module TwitterFriendly
|
6
|
+
class Logger
|
7
|
+
extend Forwardable
|
8
|
+
def_delegators :@logger, :debug, :info, :warn, :level
|
9
|
+
|
10
|
+
def initialize(options = {})
|
11
|
+
path = options[:log_dir] || File.join('.twitter_friendly')
|
12
|
+
FileUtils.mkdir_p(path) unless File.exists?(path)
|
13
|
+
|
14
|
+
@logger = ::Logger.new(File.join(path, 'twitter_friendly.log'))
|
15
|
+
@logger.level = options[:log_level] || :debug
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module TwitterFriendly
|
2
|
+
module RateLimit
|
3
|
+
def rate_limit
|
4
|
+
RateLimit.new(perform_get('/1.1/application/rate_limit_status.json'))
|
5
|
+
rescue => e
|
6
|
+
logger.warn e.inspect
|
7
|
+
nil
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def perform_get(*args)
|
13
|
+
internal_client.send(:perform_get, *args)
|
14
|
+
end
|
15
|
+
|
16
|
+
class RateLimit
|
17
|
+
def initialize(status)
|
18
|
+
@status = status
|
19
|
+
end
|
20
|
+
|
21
|
+
def resources
|
22
|
+
@status[:resources]
|
23
|
+
end
|
24
|
+
|
25
|
+
def verify_credentials
|
26
|
+
extract_remaining_and_reset_in(resources[:account][:'/account/verify_credentials'])
|
27
|
+
end
|
28
|
+
|
29
|
+
def friend_ids
|
30
|
+
extract_remaining_and_reset_in(resources[:friends][:'/friends/ids'])
|
31
|
+
end
|
32
|
+
|
33
|
+
def follower_ids
|
34
|
+
extract_remaining_and_reset_in(resources[:followers][:'/followers/ids'])
|
35
|
+
end
|
36
|
+
|
37
|
+
def users
|
38
|
+
extract_remaining_and_reset_in(resources[:users][:'/users/lookup'])
|
39
|
+
end
|
40
|
+
|
41
|
+
def friends(parallel: true)
|
42
|
+
if parallel
|
43
|
+
{friend_ids: friend_ids, users: users}
|
44
|
+
else
|
45
|
+
extract_remaining_and_reset_in(resources[:friends][:'/friends/list'])
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def followers(parallel: true)
|
50
|
+
if parallel
|
51
|
+
{follower_ids: follower_ids, users: users}
|
52
|
+
else
|
53
|
+
extract_remaining_and_reset_in(resources[:followers][:'/followers/list'])
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def to_h
|
58
|
+
{
|
59
|
+
verify_credentials: verify_credentials,
|
60
|
+
friend_ids: friend_ids,
|
61
|
+
follower_ids: follower_ids,
|
62
|
+
users: users
|
63
|
+
}
|
64
|
+
end
|
65
|
+
|
66
|
+
def inspect
|
67
|
+
'verify_credentials ' + verify_credentials.inspect +
|
68
|
+
' friend_ids ' + friend_ids.inspect +
|
69
|
+
' follower_ids ' + follower_ids.inspect +
|
70
|
+
' users ' + users.inspect
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def extract_remaining_and_reset_in(limit)
|
76
|
+
{remaining: limit[:remaining], reset_in: (Time.at(limit[:reset]) - Time.now).round}
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|