twitter_friendly 0.3.0 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
[![Gem Version](https://badge.fury.io/rb/twitter_friendly.png)](https://badge.fury.io/rb/twitter_friendly)
|
4
4
|
[![Build Status](https://travis-ci.org/ts-3156/twitter_friendly.svg?branch=master)](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
|