twitter_friendly 0.3.0 → 1.0.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/Gemfile.lock +1 -1
- data/README.md +31 -20
- data/lib/twitter_friendly/cache_key.rb +35 -29
- data/lib/twitter_friendly/caching.rb +80 -0
- data/lib/twitter_friendly/caching_and_logging.rb +9 -24
- data/lib/twitter_friendly/client.rb +13 -2
- data/lib/twitter_friendly/log_subscriber.rb +9 -15
- data/lib/twitter_friendly/logger.rb +2 -2
- data/lib/twitter_friendly/rest/api.rb +0 -5
- data/lib/twitter_friendly/rest/collector.rb +41 -53
- data/lib/twitter_friendly/rest/favorites.rb +8 -3
- data/lib/twitter_friendly/rest/friends_and_followers.rb +25 -34
- data/lib/twitter_friendly/rest/lists.rb +18 -4
- data/lib/twitter_friendly/rest/search.rb +7 -3
- data/lib/twitter_friendly/rest/timelines.rb +19 -9
- data/lib/twitter_friendly/rest/tweets.rb +2 -2
- data/lib/twitter_friendly/rest/users.rb +8 -60
- data/lib/twitter_friendly/rest/utils.rb +0 -6
- data/lib/twitter_friendly/version.rb +1 -1
- metadata +3 -3
- data/lib/twitter_friendly/rest/base.rb +0 -32
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 01532d5c3cc176a719499e7680cb7df3127c45a4f745ece3f05d703158aa4dcc
|
4
|
+
data.tar.gz: 04cc82c2474a4d7e63d232f13750aeed2a2da072654605fb90c3ab2fc43ed6c4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e8f28b5369b73869cdf39cc6165550b8a75d7fe5acff766ee0f1cfd9e6d965acb15c9bd00281cc3b1a1d3424a829e1ad6c005b2befcb55e4944faaa2d7e7ed95
|
7
|
+
data.tar.gz: e905e22b816779c8ce367e1c3ed5028a957289b15b1ba7b44d42d4bfe67457f1cbafa7febb1b0c00b99b20029d1b81d44af11310329d939c0f50ac642a57c6e8
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -3,7 +3,32 @@
|
|
3
3
|
[](https://badge.fury.io/rb/twitter_friendly)
|
4
4
|
[](https://travis-ci.org/ts-3156/twitter_friendly)
|
5
5
|
|
6
|
-
|
6
|
+
The twitter_friendly is a gem to crawl many friends/followers with minimal code. When you want to get a list of friends/followers for a user, all you need to write is the below.
|
7
|
+
|
8
|
+
```
|
9
|
+
require 'twitter_friendly'
|
10
|
+
|
11
|
+
client =
|
12
|
+
TwitterFriendly::Client.new(
|
13
|
+
consumer_key: 'CONSUMER_KEY',
|
14
|
+
consumer_secret: 'CONSUMER_SECRET',
|
15
|
+
access_token: 'ACCESS_TOKEN',
|
16
|
+
access_token_secret: 'ACCESS_TOKEN_SECRET',
|
17
|
+
expires_in: 86400 # 1day
|
18
|
+
)
|
19
|
+
|
20
|
+
ids = []
|
21
|
+
|
22
|
+
begin
|
23
|
+
ids = client.follower_ids('yousuck2020')
|
24
|
+
rescue Twitter::Error::TooManyRequests => e
|
25
|
+
sleep client.rate_limit.follower_ids[:reset_in]
|
26
|
+
retry
|
27
|
+
end
|
28
|
+
|
29
|
+
puts "ids #{ids.size}"
|
30
|
+
File.write('ids.txt', ids.join("\n"))
|
31
|
+
```
|
7
32
|
|
8
33
|
- Auto pagination
|
9
34
|
- Auto caching
|
@@ -156,30 +181,16 @@ client.followers
|
|
156
181
|
Fetch the timeline of Tweets (by screen name or user ID, or by implicit authenticated user)
|
157
182
|
|
158
183
|
```ruby
|
159
|
-
client.user_timeline('
|
160
|
-
client.user_timeline(213747670)
|
161
|
-
client.user_timeline
|
184
|
+
tweets = client.user_timeline('screen_name')
|
162
185
|
|
163
|
-
|
186
|
+
tweets.size
|
164
187
|
# => 588
|
165
188
|
|
166
|
-
|
189
|
+
tweets[0][:text]
|
167
190
|
# => "Your tweet text..."
|
168
191
|
|
169
|
-
|
170
|
-
# => "
|
171
|
-
```
|
172
|
-
|
173
|
-
Fetch the timeline of Tweets from the authenticated user's home page
|
174
|
-
|
175
|
-
```ruby
|
176
|
-
client.home_timeline
|
177
|
-
```
|
178
|
-
|
179
|
-
Fetch the timeline of Tweets mentioning the authenticated user
|
180
|
-
|
181
|
-
```ruby
|
182
|
-
client.mentions_timeline
|
192
|
+
tweets[0][:user][:screen_name]
|
193
|
+
# => "screen_name"
|
183
194
|
```
|
184
195
|
|
185
196
|
## Contributing
|
@@ -2,16 +2,28 @@ require 'digest/md5'
|
|
2
2
|
|
3
3
|
module TwitterFriendly
|
4
4
|
class CacheKey
|
5
|
-
DELIM = '
|
5
|
+
DELIM = '__'
|
6
6
|
VERSION = '1'
|
7
7
|
|
8
8
|
class << self
|
9
|
-
def gen(
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
9
|
+
def gen(method_name, args, cache_options = {})
|
10
|
+
args_array = args.dup
|
11
|
+
options = args_array.extract_options!
|
12
|
+
user = method_name == :friendship? ? args_array[0, 2] : args_array[0]
|
13
|
+
|
14
|
+
key =
|
15
|
+
[version,
|
16
|
+
method_name,
|
17
|
+
method_identifier(method_name, user, options, cache_options),
|
18
|
+
options_identifier(method_name, options, cache_options)
|
19
|
+
].compact.join(DELIM)
|
20
|
+
|
21
|
+
if ENV['SAVE_CACHE_KEY']
|
22
|
+
$last_cache_key = key
|
23
|
+
puts key
|
24
|
+
end
|
25
|
+
|
26
|
+
key
|
15
27
|
end
|
16
28
|
|
17
29
|
private
|
@@ -20,17 +32,19 @@ module TwitterFriendly
|
|
20
32
|
'v' + VERSION
|
21
33
|
end
|
22
34
|
|
23
|
-
def method_identifier(method, user, options)
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
35
|
+
def method_identifier(method, user, options, cache_options)
|
36
|
+
raise ArgumentError.new('You must specify method.') unless method
|
37
|
+
case
|
38
|
+
when method == :search then "query#{DELIM}#{user}"
|
39
|
+
when method == :friendship? then "from#{DELIM}#{user[0]}#{DELIM}to#{DELIM}#{user[1]}"
|
40
|
+
when method == :list_members then "list_id#{DELIM}#{user}"
|
41
|
+
when method == :collect_with_max_id then super_operation_identifier(cache_options[:super_operation], user, options, cache_options)
|
42
|
+
when method == :collect_with_cursor then super_operation_identifier(cache_options[:super_operation], user, options, cache_options)
|
43
|
+
when user.nil? && cache_options[:hash] then "token-hash#{DELIM}#{options[:hash]}"
|
44
|
+
else user_identifier(user)
|
45
|
+
end
|
33
46
|
end
|
47
|
+
alias_method :super_operation_identifier, :method_identifier
|
34
48
|
|
35
49
|
def user_identifier(user)
|
36
50
|
case
|
@@ -43,28 +57,20 @@ module TwitterFriendly
|
|
43
57
|
end
|
44
58
|
end
|
45
59
|
|
46
|
-
def options_identifier(method, options)
|
60
|
+
def options_identifier(method, options, cache_options)
|
47
61
|
# TODO 内部的な値はすべてprefix _tf_ をつける
|
48
62
|
opt = options.except(:hash, :call_count, :call_limit, :super_operation, :super_super_operation, :recursive, :parallel)
|
49
|
-
opt[:in] =
|
63
|
+
opt[:in] = cache_options[:super_operation] if %i(collect_with_max_id collect_with_cursor).include?(method)
|
64
|
+
delim = '_'
|
50
65
|
|
51
66
|
if opt.empty?
|
52
67
|
nil
|
53
68
|
else
|
54
|
-
str = opt.map {|k, v| "#{k}
|
69
|
+
str = opt.map {|k, v| "#{k}#{delim}#{v}"}.join(delim)
|
55
70
|
"options#{DELIM}#{str}"
|
56
71
|
end
|
57
72
|
end
|
58
73
|
|
59
|
-
def extract_super_operation(options)
|
60
|
-
raise ArgumentError.new('You must specify :super_operation.') unless options[:super_operation]
|
61
|
-
if options[:super_operation].is_a?(Array)
|
62
|
-
options[:super_operation][0]
|
63
|
-
else
|
64
|
-
options[:super_operation]
|
65
|
-
end
|
66
|
-
end
|
67
|
-
|
68
74
|
def hexdigest(ary)
|
69
75
|
Digest::MD5.hexdigest(ary.join(','))
|
70
76
|
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module TwitterFriendly
|
2
|
+
module Caching
|
3
|
+
# 他のメソッドと違い再帰的に呼ばれるため、全体をキャッシュすると、すべてを再帰的にキャッシュしてしまう。
|
4
|
+
# それを防ぐために、特別にここでキャッシュの処理を登録している。
|
5
|
+
|
6
|
+
def caching_users
|
7
|
+
method_name = :users
|
8
|
+
|
9
|
+
define_method(method_name) do |*args|
|
10
|
+
if args[0].size <= TwitterFriendly::REST::Users::MAX_USERS_PER_REQUEST
|
11
|
+
options = args.dup.extract_options!
|
12
|
+
TwitterFriendly::CachingAndLogging::Instrumenter.start_processing(method_name, options)
|
13
|
+
|
14
|
+
TwitterFriendly::CachingAndLogging::Instrumenter.complete_processing(method_name, options) do
|
15
|
+
|
16
|
+
key = CacheKey.gen(method_name, args, hash: credentials_hash)
|
17
|
+
@cache.fetch(key, args: [method_name, options]) do
|
18
|
+
TwitterFriendly::CachingAndLogging::Instrumenter.perform_request(method_name, options) {super(*args)}
|
19
|
+
end
|
20
|
+
end
|
21
|
+
else
|
22
|
+
super(*args)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def caching_tweets_with_max_id(*method_names)
|
28
|
+
method_names.each do |method_name|
|
29
|
+
max_count =
|
30
|
+
case method_name
|
31
|
+
when :home_timeline then TwitterFriendly::REST::Timelines::MAX_TWEETS_PER_REQUEST
|
32
|
+
when :user_timeline then TwitterFriendly::REST::Timelines::MAX_TWEETS_PER_REQUEST
|
33
|
+
when :mentions_timeline then TwitterFriendly::REST::Timelines::MAX_TWEETS_PER_REQUEST
|
34
|
+
when :favorites then TwitterFriendly::REST::Favorites::MAX_TWEETS_PER_REQUEST
|
35
|
+
when :search then TwitterFriendly::REST::Search::MAX_TWEETS_PER_REQUEST
|
36
|
+
else raise "Unknown method #{method_name}"
|
37
|
+
end
|
38
|
+
|
39
|
+
define_method(method_name) do |*args|
|
40
|
+
options = {count: max_count}.merge(args.extract_options!)
|
41
|
+
args << options
|
42
|
+
|
43
|
+
if options[:count] <= max_count
|
44
|
+
TwitterFriendly::CachingAndLogging::Instrumenter.start_processing(method_name, options)
|
45
|
+
|
46
|
+
TwitterFriendly::CachingAndLogging::Instrumenter.complete_processing(method_name, options) do
|
47
|
+
key = CacheKey.gen(method_name, args, hash: credentials_hash)
|
48
|
+
@cache.fetch(key, args: [method_name, options]) do
|
49
|
+
TwitterFriendly::CachingAndLogging::Instrumenter.perform_request(method_name, options) {super(*args)}
|
50
|
+
end
|
51
|
+
end
|
52
|
+
else
|
53
|
+
super(*args)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def caching_resources_with_cursor(*method_names)
|
60
|
+
method_names.each do |method_name|
|
61
|
+
define_method(method_name) do |*args|
|
62
|
+
options = args.dup.extract_options!
|
63
|
+
|
64
|
+
if options.has_key?(:cursor)
|
65
|
+
TwitterFriendly::CachingAndLogging::Instrumenter.start_processing(method_name, options)
|
66
|
+
|
67
|
+
TwitterFriendly::CachingAndLogging::Instrumenter.complete_processing(method_name, options) do
|
68
|
+
key = CacheKey.gen(method_name, args, hash: credentials_hash)
|
69
|
+
@cache.fetch(key, args: [method_name, options]) do
|
70
|
+
TwitterFriendly::CachingAndLogging::Instrumenter.perform_request(method_name, options) {super(*args)}
|
71
|
+
end
|
72
|
+
end
|
73
|
+
else
|
74
|
+
super(*args)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -5,22 +5,18 @@ module TwitterFriendly
|
|
5
5
|
|
6
6
|
# TODO 1つのメソッドに対して1回しか実行されないようにする
|
7
7
|
# 全体をキャッシュさせ、さらにロギングを行う
|
8
|
-
def caching(*
|
9
|
-
|
8
|
+
def caching(*method_names)
|
9
|
+
method_names.each do |method_name|
|
10
|
+
|
10
11
|
define_method(method_name) do |*args|
|
11
|
-
options = args.extract_options!
|
12
|
+
options = args.dup.extract_options!
|
12
13
|
Instrumenter.start_processing(method_name, options)
|
13
14
|
|
14
15
|
Instrumenter.complete_processing(method_name, options) do
|
15
|
-
do_request =
|
16
|
-
Proc.new {Instrumenter.perform_request(method_name, options) {options.empty? ? super(*args) : super(*args, options)}}
|
17
16
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
user = (method_name == :friendship?) ? args[0, 2] : args[0]
|
22
|
-
key = CacheKey.gen(method_name, user, options.merge(hash: credentials_hash))
|
23
|
-
@cache.fetch(key, args: [method_name, options], &do_request)
|
17
|
+
key = CacheKey.gen(method_name, args, hash: credentials_hash)
|
18
|
+
@cache.fetch(key, args: [method_name, options]) do
|
19
|
+
Instrumenter.perform_request(method_name, options) {super(*args)}
|
24
20
|
end
|
25
21
|
end
|
26
22
|
end
|
@@ -31,12 +27,10 @@ module TwitterFriendly
|
|
31
27
|
def logging(*root_args)
|
32
28
|
root_args.each do |method_name|
|
33
29
|
define_method(method_name) do |*args|
|
34
|
-
options = args.extract_options!
|
30
|
+
options = args.dup.extract_options!
|
35
31
|
Instrumenter.start_processing(method_name, options)
|
36
32
|
|
37
|
-
Instrumenter.complete_processing(method_name, options)
|
38
|
-
options.empty? ? super(*args) : super(*args, options)
|
39
|
-
end
|
33
|
+
Instrumenter.complete_processing(method_name, options) {super(*args)}
|
40
34
|
end
|
41
35
|
end
|
42
36
|
end
|
@@ -60,14 +54,5 @@ module TwitterFriendly
|
|
60
54
|
::ActiveSupport::Notifications.instrument('request.twitter_friendly', payload) { yield(payload) }
|
61
55
|
end
|
62
56
|
end
|
63
|
-
|
64
|
-
module Utils
|
65
|
-
|
66
|
-
module_function
|
67
|
-
|
68
|
-
def cache_disabled?(options)
|
69
|
-
options.is_a?(Hash) && options.has_key?(:cache) && !options[:cache]
|
70
|
-
end
|
71
|
-
end
|
72
57
|
end
|
73
58
|
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
require 'forwardable'
|
2
2
|
|
3
3
|
require 'twitter_friendly/caching_and_logging'
|
4
|
+
require 'twitter_friendly/caching'
|
4
5
|
require 'twitter_friendly/rest/api'
|
5
6
|
require 'twitter_friendly/utils'
|
6
7
|
|
@@ -15,8 +16,13 @@ module TwitterFriendly
|
|
15
16
|
|
16
17
|
extend TwitterFriendly::CachingAndLogging
|
17
18
|
caching :user, :friendship?, :verify_credentials, :user?, :blocked_ids
|
18
|
-
logging :
|
19
|
-
|
19
|
+
logging :friends, :followers, :friend_ids_and_follower_ids, :friends_and_followers, :retweeters_ids
|
20
|
+
|
21
|
+
|
22
|
+
extend TwitterFriendly::Caching
|
23
|
+
caching_users
|
24
|
+
caching_tweets_with_max_id :home_timeline, :user_timeline, :mentions_timeline, :favorites, :search
|
25
|
+
caching_resources_with_cursor :friend_ids, :follower_ids, :memberships, :list_members
|
20
26
|
|
21
27
|
def initialize(*args)
|
22
28
|
options = args.extract_options!
|
@@ -49,6 +55,11 @@ module TwitterFriendly
|
|
49
55
|
@twitter
|
50
56
|
end
|
51
57
|
|
58
|
+
def twitter
|
59
|
+
logger.warn "DEPRECATION WARNING: Use #internal_client instead of #twitter"
|
60
|
+
internal_client
|
61
|
+
end
|
62
|
+
|
52
63
|
def subscriber_attached?
|
53
64
|
@@subscriber_attached ||= false
|
54
65
|
end
|
@@ -14,19 +14,13 @@ module TwitterFriendly
|
|
14
14
|
{args: args}.merge(payload.except(:args)).inspect
|
15
15
|
end
|
16
16
|
|
17
|
-
INDENT = ' '
|
18
|
-
|
19
|
-
def indentation(payload)
|
20
|
-
sp = payload[:super_operation]&.is_a?(Array) ? (INDENT * payload[:super_operation].size) : ''
|
21
|
-
sp + (payload[:name] == 'write' ? INDENT : '')
|
22
|
-
end
|
23
|
-
|
24
17
|
module_function
|
25
18
|
|
26
19
|
def logger
|
27
20
|
@@logger
|
28
21
|
end
|
29
22
|
|
23
|
+
# Because TwitterFriendly::Logging is not inherited, passing an instance of logger via module function.
|
30
24
|
def logger=(logger)
|
31
25
|
@@logger = logger
|
32
26
|
end
|
@@ -38,7 +32,7 @@ module TwitterFriendly
|
|
38
32
|
def start_processing(event)
|
39
33
|
debug do
|
40
34
|
payload = event.payload
|
41
|
-
name = "
|
35
|
+
name = "TF::Started #{payload[:operation]}"
|
42
36
|
|
43
37
|
if payload[:super_operation]
|
44
38
|
"#{name} in #{payload[:super_operation][0]} at #{Time.now}"
|
@@ -53,7 +47,7 @@ module TwitterFriendly
|
|
53
47
|
payload = event.payload
|
54
48
|
name = "TF::Completed #{payload[:operation]} in #{event.duration.round(1)}ms"
|
55
49
|
|
56
|
-
"#{
|
50
|
+
"#{name}#{" #{truncated_payload(payload)}" unless payload.empty?}"
|
57
51
|
end
|
58
52
|
end
|
59
53
|
|
@@ -62,9 +56,9 @@ module TwitterFriendly
|
|
62
56
|
payload = event.payload
|
63
57
|
payload.delete(:name)
|
64
58
|
operation = payload.delete(:operation)
|
65
|
-
name = " TW::#{operation.capitalize} #{payload[:args].last[:super_operation]
|
59
|
+
name = " TW::#{operation.capitalize} #{payload[:args].last[:super_operation]} in #{payload[:args][0]} (#{event.duration.round(1)}ms)"
|
66
60
|
name = color(name, BLUE, true)
|
67
|
-
" #{
|
61
|
+
" #{name}"
|
68
62
|
end
|
69
63
|
end
|
70
64
|
|
@@ -73,10 +67,10 @@ module TwitterFriendly
|
|
73
67
|
payload = event.payload
|
74
68
|
payload.delete(:name)
|
75
69
|
operation = payload.delete(:operation)
|
76
|
-
name = " TW::#{operation.capitalize} #{payload[:args][0]} (#{event.duration.round(1)}ms)"
|
70
|
+
name = " TW::#{operation.capitalize} #{payload[:args][0] if payload[:args]&.is_a?(Array)} (#{event.duration.round(1)}ms)"
|
77
71
|
c = (%i(encode decode).include?(operation.to_sym)) ? YELLOW : CYAN
|
78
72
|
name = color(name, c, true)
|
79
|
-
" #{
|
73
|
+
" #{name}#{" #{payload[:args][1] if payload[:args]&.is_a?(Array)}" unless payload.empty?}"
|
80
74
|
end
|
81
75
|
end
|
82
76
|
|
@@ -98,10 +92,10 @@ module TwitterFriendly
|
|
98
92
|
payload = event.payload
|
99
93
|
operation = payload[:super_operation] == :fetch ? :fetch : payload[:name]
|
100
94
|
hit = %i(read fetch).include?(operation.to_sym) && payload[:hit] ? ' (Hit)' : ''
|
101
|
-
name = " AS::#{operation.capitalize}#{hit} #{payload[:key].split('
|
95
|
+
name = " AS::#{operation.capitalize}#{hit} #{payload[:key].split('__')[1]} (#{event.duration.round(1)}ms)"
|
102
96
|
name = color(name, MAGENTA, true)
|
103
97
|
# :name, :expires_in, :super_operation, :hit, :race_condition_ttl, :tf_super_operation, :tf_super_super_operation
|
104
|
-
"#{
|
98
|
+
"#{name} #{(payload.slice(:key).inspect)}"
|
105
99
|
end
|
106
100
|
end
|
107
101
|
|
@@ -5,7 +5,7 @@ require 'logger'
|
|
5
5
|
module TwitterFriendly
|
6
6
|
class Logger
|
7
7
|
extend Forwardable
|
8
|
-
def_delegators :@logger, :debug, :info, :warn, :level
|
8
|
+
def_delegators :@logger, :debug, :info, :warn, :error, :fatal, :level
|
9
9
|
|
10
10
|
def initialize(options = {})
|
11
11
|
path = options[:log_dir] || File.join('.twitter_friendly')
|
@@ -15,4 +15,4 @@ module TwitterFriendly
|
|
15
15
|
@logger.level = options[:log_level] || :debug
|
16
16
|
end
|
17
17
|
end
|
18
|
-
end
|
18
|
+
end
|
@@ -1,7 +1,6 @@
|
|
1
1
|
require 'twitter_friendly/rest/utils'
|
2
2
|
require 'twitter_friendly/rest/collector'
|
3
3
|
require "twitter_friendly/rest/parallel"
|
4
|
-
require "twitter_friendly/rest/base"
|
5
4
|
require 'twitter_friendly/rest/friends_and_followers'
|
6
5
|
require 'twitter_friendly/rest/users'
|
7
6
|
require 'twitter_friendly/rest/timelines'
|
@@ -20,7 +19,6 @@ module TwitterFriendly
|
|
20
19
|
include TwitterFriendly::REST::Utils
|
21
20
|
include TwitterFriendly::REST::Collector
|
22
21
|
include TwitterFriendly::REST::Parallel
|
23
|
-
include TwitterFriendly::REST::Base
|
24
22
|
include TwitterFriendly::REST::FriendsAndFollowers
|
25
23
|
include TwitterFriendly::REST::Users
|
26
24
|
include TwitterFriendly::REST::Timelines
|
@@ -31,9 +29,6 @@ module TwitterFriendly
|
|
31
29
|
|
32
30
|
include TwitterFriendly::REST::Extension::Clusters
|
33
31
|
include TwitterFriendly::REST::Extension::Timelines
|
34
|
-
|
35
|
-
include TwitterFriendly::REST::Collector::Caching
|
36
|
-
include TwitterFriendly::REST::Users::Caching
|
37
32
|
end
|
38
33
|
end
|
39
34
|
end
|
@@ -1,22 +1,52 @@
|
|
1
1
|
module TwitterFriendly
|
2
2
|
module REST
|
3
3
|
module Collector
|
4
|
-
def
|
5
|
-
|
4
|
+
def fetch_tweets_with_max_id(method_name, max_count, *args)
|
5
|
+
options = args.dup.extract_options!
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
7
|
+
total_count = options.delete(:count) || max_count
|
8
|
+
call_count = total_count / max_count + (total_count % max_count == 0 ? 0 : 1)
|
9
|
+
options[:count] = [max_count, total_count].min
|
10
|
+
collect_options = {call_count: call_count, total_count: total_count}
|
11
|
+
|
12
|
+
collect_with_max_id([], nil, collect_options) do |max_id|
|
13
|
+
options[:max_id] = max_id unless max_id.nil?
|
14
|
+
result = send(method_name, *args)
|
15
|
+
|
16
|
+
if method_name == :search
|
17
|
+
result.attrs[:statuses]
|
18
|
+
else
|
19
|
+
if result.is_a?(Array) && result[0].respond_to?(:attrs)
|
20
|
+
result.map(&:attrs)
|
21
|
+
else
|
22
|
+
result
|
11
23
|
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# @param method_name [Symbol]
|
29
|
+
# @param user [Integer, String, nil]
|
30
|
+
#
|
31
|
+
# @option options [Integer] :count
|
32
|
+
def fetch_resources_with_cursor(method_name, *args)
|
33
|
+
options = args.dup.extract_options!
|
34
|
+
|
35
|
+
collect_with_cursor([], -1) do |next_cursor|
|
36
|
+
options[:cursor] = next_cursor unless next_cursor.nil?
|
37
|
+
send(method_name, *args)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def collect_with_max_id(collection, max_id, collect_options, &block)
|
42
|
+
tweets = yield(max_id)
|
12
43
|
return collection if tweets.nil?
|
13
44
|
|
14
45
|
collection.concat tweets
|
15
46
|
if tweets.empty? || (collect_options[:call_count] -= 1) < 1
|
16
47
|
collection.flatten
|
17
48
|
else
|
18
|
-
|
19
|
-
collect_with_max_id(user, collection, tweets.last[:id] - 1, options, collect_options, &block)
|
49
|
+
collect_with_max_id(collection, tweets.last[:id] - 1, collect_options, &block)
|
20
50
|
end
|
21
51
|
end
|
22
52
|
|
@@ -25,55 +55,13 @@ module TwitterFriendly
|
|
25
55
|
# @param cursor [Integer]
|
26
56
|
#
|
27
57
|
# @option options [Integer] :count
|
28
|
-
|
29
|
-
|
30
|
-
def collect_with_cursor(user, collection, cursor, options, &block)
|
31
|
-
key = CacheKey.gen(__method__, user, options.merge(cursor: cursor, hash: credentials_hash))
|
32
|
-
|
33
|
-
# TODO Handle {cache: false} option
|
34
|
-
response =
|
35
|
-
@cache.fetch(key, args: [__method__, options]) do
|
36
|
-
Instrumenter.perform_request(__method__, options) {yield(cursor).attrs}
|
37
|
-
end
|
58
|
+
def collect_with_cursor(collection, cursor, &block)
|
59
|
+
response = yield(cursor)
|
38
60
|
return collection if response.nil?
|
39
61
|
|
40
|
-
options[:recursive] = true
|
41
|
-
|
42
62
|
# Notice: If you call response.to_a, it automatically fetch all results and the results are not cached.
|
43
63
|
collection.concat (response[:ids] || response[:users] || response[:lists])
|
44
|
-
response[:next_cursor].zero? ? collection.flatten : collect_with_cursor(
|
45
|
-
end
|
46
|
-
|
47
|
-
module Instrumenter
|
48
|
-
|
49
|
-
module_function
|
50
|
-
|
51
|
-
# 他のメソッドと違い再帰的に呼ばれるため、全体をキャッシュすると、すべてを再帰的にキャッシュしてしまう。
|
52
|
-
# それを防ぐために、特別にここでキャッシュの処理を登録している。
|
53
|
-
|
54
|
-
def perform_request(method_name, options, &block)
|
55
|
-
payload = {operation: 'collect', args: [method_name, options.slice(:max_id, :cursor, :super_operation)]}
|
56
|
-
::ActiveSupport::Notifications.instrument('collect.twitter_friendly', payload) { yield(payload) }
|
57
|
-
end
|
58
|
-
end
|
59
|
-
|
60
|
-
module Caching
|
61
|
-
%i(
|
62
|
-
collect_with_max_id
|
63
|
-
collect_with_cursor
|
64
|
-
).each do |name|
|
65
|
-
define_method(name) do |*args, &block|
|
66
|
-
options = args.extract_options!
|
67
|
-
do_request = Proc.new { options.empty? ? super(*args, &block) : super(*args, options, &block) }
|
68
|
-
|
69
|
-
if options[:recursive]
|
70
|
-
do_request.call
|
71
|
-
else
|
72
|
-
TwitterFriendly::CachingAndLogging::Instrumenter.start_processing(name, options)
|
73
|
-
TwitterFriendly::CachingAndLogging::Instrumenter.complete_processing(name, options, &do_request)
|
74
|
-
end
|
75
|
-
end
|
76
|
-
end
|
64
|
+
response[:next_cursor].zero? ? collection.flatten : collect_with_cursor(collection, response[:next_cursor], &block)
|
77
65
|
end
|
78
66
|
end
|
79
67
|
end
|
@@ -5,9 +5,14 @@ module TwitterFriendly
|
|
5
5
|
MAX_TWEETS_PER_REQUEST = 100
|
6
6
|
|
7
7
|
def favorites(*args)
|
8
|
-
options = {
|
9
|
-
|
10
|
-
|
8
|
+
options = {count: MAX_TWEETS_PER_REQUEST}.merge(args.extract_options!)
|
9
|
+
args << options
|
10
|
+
|
11
|
+
if options[:count] <= MAX_TWEETS_PER_REQUEST
|
12
|
+
@twitter.favorites(*args)&.map(&:attrs)
|
13
|
+
else
|
14
|
+
fetch_tweets_with_max_id(__method__, MAX_TWEETS_PER_REQUEST, *args)
|
15
|
+
end
|
11
16
|
end
|
12
17
|
end
|
13
18
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'parallel'
|
2
|
+
|
1
3
|
module TwitterFriendly
|
2
4
|
module REST
|
3
5
|
module FriendsAndFollowers
|
@@ -17,14 +19,24 @@ module TwitterFriendly
|
|
17
19
|
# @option options [Integer] :count The number of tweets to return per page, up to a maximum of 5000.
|
18
20
|
def friend_ids(*args)
|
19
21
|
options = {count: MAX_IDS_PER_REQUEST}.merge(args.extract_options!)
|
20
|
-
|
21
|
-
|
22
|
+
args << options
|
23
|
+
|
24
|
+
if options.has_key?(:cursor)
|
25
|
+
@twitter.friend_ids(*args)&.attrs
|
26
|
+
else
|
27
|
+
fetch_resources_with_cursor(__method__, *args)
|
28
|
+
end
|
22
29
|
end
|
23
30
|
|
24
31
|
def follower_ids(*args)
|
25
32
|
options = {count: MAX_IDS_PER_REQUEST}.merge(args.extract_options!)
|
26
|
-
|
27
|
-
|
33
|
+
args << options
|
34
|
+
|
35
|
+
if options.has_key?(:cursor)
|
36
|
+
@twitter.follower_ids(*args)&.attrs
|
37
|
+
else
|
38
|
+
fetch_resources_with_cursor(__method__, *args)
|
39
|
+
end
|
28
40
|
end
|
29
41
|
|
30
42
|
# @return [Hash]
|
@@ -36,47 +48,26 @@ module TwitterFriendly
|
|
36
48
|
#
|
37
49
|
# @option options [Bool] :parallel
|
38
50
|
def friends(*args)
|
39
|
-
|
40
|
-
|
41
|
-
ids = friend_ids(*args, options.except(:parallel))
|
42
|
-
users(ids, options)
|
51
|
+
ids = friend_ids(*args)
|
52
|
+
users(ids)
|
43
53
|
end
|
44
54
|
|
45
55
|
def followers(*args)
|
46
|
-
|
47
|
-
|
48
|
-
ids = follower_ids(*args, options.except(:parallel))
|
49
|
-
users(ids, options)
|
56
|
+
ids = follower_ids(*args)
|
57
|
+
users(ids)
|
50
58
|
end
|
51
59
|
|
52
60
|
def friend_ids_and_follower_ids(*args)
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
if is_parallel
|
57
|
-
require 'parallel'
|
58
|
-
|
59
|
-
parallel(in_threads: 2) do |batch|
|
60
|
-
batch.friend_ids(*args, options.merge(super_operation: [__method__]))
|
61
|
-
batch.follower_ids(*args, options.merge(super_operation: [__method__]))
|
62
|
-
end
|
63
|
-
else
|
64
|
-
[friend_ids(*args, options), follower_ids(*args, options)]
|
61
|
+
parallel(in_threads: 2) do |batch|
|
62
|
+
batch.friend_ids(*args)
|
63
|
+
batch.follower_ids(*args)
|
65
64
|
end
|
66
65
|
end
|
67
66
|
|
68
67
|
def friends_and_followers(*args)
|
69
|
-
|
70
|
-
|
71
|
-
following_ids, followed_ids = friend_ids_and_follower_ids(*args, options)
|
72
|
-
unique_ids = (following_ids + followed_ids).uniq
|
73
|
-
people = _users(unique_ids).index_by { |u| u[:id] }
|
68
|
+
following_ids, followed_ids = friend_ids_and_follower_ids(*args)
|
69
|
+
people = users((following_ids + followed_ids).uniq).index_by { |u| u[:id] }
|
74
70
|
[people.slice(*following_ids).values, people.slice(*followed_ids).values]
|
75
|
-
|
76
|
-
# parallel(in_threads: 2) do |batch|
|
77
|
-
# batch.friends(*args, options)
|
78
|
-
# batch.followers(*args, options)
|
79
|
-
# end
|
80
71
|
end
|
81
72
|
end
|
82
73
|
end
|
@@ -2,6 +2,10 @@ module TwitterFriendly
|
|
2
2
|
module REST
|
3
3
|
module Lists
|
4
4
|
|
5
|
+
def list(*args)
|
6
|
+
@twitter.list(*args)&.to_hash
|
7
|
+
end
|
8
|
+
|
5
9
|
MAX_LISTS_PER_REQUEST = 1000
|
6
10
|
|
7
11
|
# @return [Hash] The lists the specified user has been added to.
|
@@ -14,8 +18,13 @@ module TwitterFriendly
|
|
14
18
|
# @option options [Integer] :count The number of tweets to return per page, up to a maximum of 5000.
|
15
19
|
def memberships(*args)
|
16
20
|
options = {count: MAX_LISTS_PER_REQUEST}.merge(args.extract_options!)
|
17
|
-
|
18
|
-
|
21
|
+
args << options
|
22
|
+
|
23
|
+
if options.has_key?(:cursor)
|
24
|
+
@twitter.memberships(*args)&.attrs
|
25
|
+
else
|
26
|
+
fetch_resources_with_cursor(__method__, *args)
|
27
|
+
end
|
19
28
|
end
|
20
29
|
|
21
30
|
MAX_MEMBERS_PER_REQUEST = 5000
|
@@ -30,8 +39,13 @@ module TwitterFriendly
|
|
30
39
|
# @option options [Integer] :count The number of tweets to return per page, up to a maximum of 5000.
|
31
40
|
def list_members(*args)
|
32
41
|
options = {count: MAX_MEMBERS_PER_REQUEST, skip_status: 1}.merge(args.extract_options!)
|
33
|
-
|
34
|
-
|
42
|
+
args << options
|
43
|
+
|
44
|
+
if options.has_key?(:cursor)
|
45
|
+
@twitter.list_members(*args)&.attrs
|
46
|
+
else
|
47
|
+
fetch_resources_with_cursor(__method__, *args)
|
48
|
+
end
|
35
49
|
end
|
36
50
|
end
|
37
51
|
end
|
@@ -6,9 +6,13 @@ module TwitterFriendly
|
|
6
6
|
|
7
7
|
def search(query, options = {})
|
8
8
|
raise ArgumentError.new('You must specify a search query.') unless query.is_a?(String)
|
9
|
-
options = {result_type:
|
10
|
-
|
11
|
-
|
9
|
+
options = {result_type: 'recent'}.merge(options)
|
10
|
+
|
11
|
+
if options[:count] <= MAX_TWEETS_PER_REQUEST
|
12
|
+
@twitter.search(query, options)&.attrs&.fetch(:statuses)
|
13
|
+
else
|
14
|
+
fetch_tweets_with_max_id(__method__, MAX_TWEETS_PER_REQUEST, query, options)
|
15
|
+
end
|
12
16
|
end
|
13
17
|
end
|
14
18
|
end
|
@@ -5,21 +5,31 @@ module TwitterFriendly
|
|
5
5
|
MAX_TWEETS_PER_REQUEST = 200
|
6
6
|
|
7
7
|
def home_timeline(options = {})
|
8
|
-
options = {include_rts: true}.merge(options)
|
9
|
-
|
10
|
-
|
8
|
+
options = {include_rts: true, count: MAX_TWEETS_PER_REQUEST}.merge(options)
|
9
|
+
if options[:count] <= MAX_TWEETS_PER_REQUEST
|
10
|
+
@twitter.home_timeline(options)&.map(&:attrs)
|
11
|
+
else
|
12
|
+
fetch_tweets_with_max_id(__method__, MAX_TWEETS_PER_REQUEST, options)
|
13
|
+
end
|
11
14
|
end
|
12
15
|
|
13
16
|
def user_timeline(*args)
|
14
|
-
options = {include_rts: true}.merge(args.extract_options!)
|
15
|
-
|
16
|
-
|
17
|
+
options = {include_rts: true, count: MAX_TWEETS_PER_REQUEST}.merge(args.extract_options!)
|
18
|
+
args << options
|
19
|
+
if options[:count] <= MAX_TWEETS_PER_REQUEST
|
20
|
+
@twitter.user_timeline(*args)&.map(&:attrs)
|
21
|
+
else
|
22
|
+
fetch_tweets_with_max_id(__method__, MAX_TWEETS_PER_REQUEST, *args)
|
23
|
+
end
|
17
24
|
end
|
18
25
|
|
19
26
|
def mentions_timeline(options = {})
|
20
|
-
options = {include_rts: true}.merge(options)
|
21
|
-
|
22
|
-
|
27
|
+
options = {include_rts: true, count: MAX_TWEETS_PER_REQUEST}.merge(options)
|
28
|
+
if options[:count] <= MAX_TWEETS_PER_REQUEST
|
29
|
+
@twitter.mentions_timeline(options)&.map(&:attrs)
|
30
|
+
else
|
31
|
+
fetch_tweets_with_max_id(__method__, MAX_TWEETS_PER_REQUEST, options)
|
32
|
+
end
|
23
33
|
end
|
24
34
|
end
|
25
35
|
end
|
@@ -14,8 +14,8 @@ module TwitterFriendly
|
|
14
14
|
def retweeters_ids(*args)
|
15
15
|
# このメソッドではページングができない
|
16
16
|
options = {count: MAX_IDS_PER_REQUEST}.merge(args.extract_options!)
|
17
|
-
|
18
|
-
|
17
|
+
args << options
|
18
|
+
@twitter.retweeters_ids(*args)
|
19
19
|
end
|
20
20
|
end
|
21
21
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'parallel'
|
2
|
+
|
1
3
|
module TwitterFriendly
|
2
4
|
module REST
|
3
5
|
module Users
|
@@ -5,8 +7,8 @@ module TwitterFriendly
|
|
5
7
|
@twitter.verify_credentials({skip_status: true}.merge(options))&.to_hash
|
6
8
|
end
|
7
9
|
|
8
|
-
def user?(
|
9
|
-
@twitter.user?(
|
10
|
+
def user?(*args)
|
11
|
+
@twitter.user?(*args)
|
10
12
|
end
|
11
13
|
|
12
14
|
def user(*args)
|
@@ -17,71 +19,17 @@ module TwitterFriendly
|
|
17
19
|
|
18
20
|
def users(values, options = {})
|
19
21
|
if values.size <= MAX_USERS_PER_REQUEST
|
20
|
-
|
21
|
-
|
22
|
-
@cache.fetch(key, args: [__method__, options]) do
|
23
|
-
Instrumenter.perform_request(args: [__method__, super_operation: options[:super_operation]]) do
|
24
|
-
@twitter.send(__method__, values, options.except(:parallel, :super_operation, :recursive))&.compact&.map(&:to_hash)
|
25
|
-
end
|
26
|
-
end
|
22
|
+
@twitter.users(values, options)
|
27
23
|
else
|
28
|
-
|
29
|
-
|
24
|
+
parallel(in_threads: 10) do |batch|
|
25
|
+
values.each_slice(MAX_USERS_PER_REQUEST) { |targets| batch.users(targets, options) }
|
26
|
+
end.flatten
|
30
27
|
end
|
31
28
|
end
|
32
29
|
|
33
30
|
def blocked_ids(*args)
|
34
31
|
@twitter.blocked_ids(*args)&.attrs&.fetch(:ids)
|
35
32
|
end
|
36
|
-
|
37
|
-
module Instrumenter
|
38
|
-
|
39
|
-
module_function
|
40
|
-
|
41
|
-
# 他のメソッドと違い再帰的に呼ばれるため、全体をキャッシュすると、すべてを再帰的にキャッシュしてしまう。
|
42
|
-
# それを防ぐために、特別にここでキャッシュの処理を登録している。
|
43
|
-
|
44
|
-
def perform_request(options, &block)
|
45
|
-
payload = {operation: 'request', args: options[:args]}
|
46
|
-
::ActiveSupport::Notifications.instrument('request.twitter_friendly', payload) { yield(payload) }
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
module Caching
|
51
|
-
%i(
|
52
|
-
users
|
53
|
-
).each do |name|
|
54
|
-
define_method(name) do |*args, &block|
|
55
|
-
options = args.extract_options!
|
56
|
-
do_request = Proc.new { options.empty? ? super(*args, &block) : super(*args, options, &block) }
|
57
|
-
|
58
|
-
if options[:recursive]
|
59
|
-
do_request.call
|
60
|
-
else
|
61
|
-
TwitterFriendly::CachingAndLogging::Instrumenter.start_processing(name, options)
|
62
|
-
TwitterFriendly::CachingAndLogging::Instrumenter.complete_processing(name, options, &do_request)
|
63
|
-
end
|
64
|
-
end
|
65
|
-
end
|
66
|
-
end
|
67
|
-
|
68
|
-
private
|
69
|
-
|
70
|
-
def _users(values, options = {})
|
71
|
-
options = {super_operation: :users, parallel: true}.merge(options)
|
72
|
-
|
73
|
-
if options[:parallel]
|
74
|
-
require 'parallel'
|
75
|
-
|
76
|
-
parallel(in_threads: 10) do |batch|
|
77
|
-
values.each_slice(MAX_USERS_PER_REQUEST) { |targets| batch.users(targets, options) }
|
78
|
-
end.flatten
|
79
|
-
else
|
80
|
-
values.each_slice(MAX_USERS_PER_REQUEST).map do |targets|
|
81
|
-
users(targets, options)
|
82
|
-
end
|
83
|
-
end&.flatten&.compact&.map(&:to_hash)
|
84
|
-
end
|
85
33
|
end
|
86
34
|
end
|
87
35
|
end
|
@@ -6,12 +6,6 @@ module TwitterFriendly
|
|
6
6
|
def credentials_hash
|
7
7
|
Digest::MD5.hexdigest(access_token + access_token_secret + consumer_key + consumer_secret)
|
8
8
|
end
|
9
|
-
|
10
|
-
def push_operations(options, operation)
|
11
|
-
options[:super_operation] = [] unless options[:super_operation]
|
12
|
-
options[:super_operation] = [options[:super_operation]] unless options[:super_operation].is_a?(Array)
|
13
|
-
options[:super_operation].prepend operation
|
14
|
-
end
|
15
9
|
end
|
16
10
|
end
|
17
11
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: twitter_friendly
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- ts-3156
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
11
|
+
date: 2019-02-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -180,13 +180,13 @@ files:
|
|
180
180
|
- lib/twitter_friendly.rb
|
181
181
|
- lib/twitter_friendly/cache.rb
|
182
182
|
- lib/twitter_friendly/cache_key.rb
|
183
|
+
- lib/twitter_friendly/caching.rb
|
183
184
|
- lib/twitter_friendly/caching_and_logging.rb
|
184
185
|
- lib/twitter_friendly/client.rb
|
185
186
|
- lib/twitter_friendly/log_subscriber.rb
|
186
187
|
- lib/twitter_friendly/logger.rb
|
187
188
|
- lib/twitter_friendly/rate_limit.rb
|
188
189
|
- lib/twitter_friendly/rest/api.rb
|
189
|
-
- lib/twitter_friendly/rest/base.rb
|
190
190
|
- lib/twitter_friendly/rest/collector.rb
|
191
191
|
- lib/twitter_friendly/rest/extension/clusters.rb
|
192
192
|
- lib/twitter_friendly/rest/extension/timelines.rb
|
@@ -1,32 +0,0 @@
|
|
1
|
-
module TwitterFriendly
|
2
|
-
module REST
|
3
|
-
module Base
|
4
|
-
def fetch_tweets_with_max_id(method_name, max_count, user, options)
|
5
|
-
total_count = options.delete(:count) || max_count
|
6
|
-
call_count = total_count / max_count + (total_count % max_count == 0 ? 0 : 1)
|
7
|
-
options[:count] = [max_count, total_count].min
|
8
|
-
super_operation = options.delete(:super_operation)
|
9
|
-
collect_options = {call_count: call_count, total_count: total_count, super_operation: super_operation}
|
10
|
-
|
11
|
-
collect_with_max_id(user, [], nil, options, collect_options) do |max_id|
|
12
|
-
options[:max_id] = max_id unless max_id.nil?
|
13
|
-
|
14
|
-
result = @twitter.send(method_name, *[user, options].compact)
|
15
|
-
(method_name == :search) ? result.attrs[:statuses] : result.map(&:attrs)
|
16
|
-
end
|
17
|
-
end
|
18
|
-
|
19
|
-
# @param method_name [Symbol]
|
20
|
-
# @param user [Integer, String, nil]
|
21
|
-
#
|
22
|
-
# @option options [Integer] :count
|
23
|
-
# @option options [String] :super_super_operation
|
24
|
-
def fetch_resources_with_cursor(method_name, user, options)
|
25
|
-
collect_with_cursor(user, [], -1, options) do |next_cursor|
|
26
|
-
options[:cursor] = next_cursor unless next_cursor.nil?
|
27
|
-
@twitter.send(method_name, user, options.except(:super_operation))
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|