retjilp 0.2 → 0.3
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/README.markdown +30 -6
- data/Rakefile +30 -0
- data/doc/Retjilp.html +134 -0
- data/doc/Retjilp/Options.html +308 -0
- data/doc/Retjilp/Retweeter.html +474 -0
- data/doc/Retjilp/Runner.html +260 -0
- data/doc/_index.html +145 -0
- data/doc/class_list.html +53 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +57 -0
- data/doc/css/style.css +338 -0
- data/doc/file.AUTHORS.html +75 -0
- data/doc/file.COPYING.html +100 -0
- data/doc/file.README.html +125 -0
- data/doc/file_list.html +61 -0
- data/doc/frames.html +28 -0
- data/doc/index.html +125 -0
- data/doc/js/app.js +214 -0
- data/doc/js/full_list.js +173 -0
- data/doc/js/jquery.js +4 -0
- data/doc/method_list.html +100 -0
- data/doc/top-level-namespace.html +112 -0
- data/lib/retjilp/log.rb +14 -0
- data/lib/retjilp/retweeter.rb +22 -114
- data/lib/retjilp/runner.rb +40 -3
- data/lib/retjilp/twitter.rb +118 -0
- data/retjilp.gemspec +8 -2
- data/test/spec/retweeter_spec.rb +82 -0
- metadata +86 -7
- data/config +0 -27
- data/lib/retjilp/options.rb +0 -24
data/lib/retjilp/runner.rb
CHANGED
@@ -1,14 +1,51 @@
|
|
1
|
-
|
1
|
+
require 'json/pure'
|
2
|
+
require 'optparse'
|
3
|
+
|
4
|
+
require_relative 'log'
|
2
5
|
require_relative 'retweeter'
|
6
|
+
require_relative 'twitter'
|
3
7
|
|
4
8
|
module Retjilp
|
9
|
+
DATA_DIR = File.expand_path("~/.retjilp")
|
10
|
+
|
5
11
|
class Runner
|
6
12
|
def initialize(argv)
|
7
|
-
|
13
|
+
# Parse command-line options
|
14
|
+
OptionParser.new do |opts|
|
15
|
+
opts.banner = "Usage: retjilp [ --help ] [ --verbose | --debug ]"
|
16
|
+
opts.on("--verbose", "Run with verbose output") { Retjilp::log.level = Logger::INFO }
|
17
|
+
opts.on("--debug", "Run with debug output") { Retjilp::log.level = Logger::DEBUG }
|
18
|
+
opts.on_tail("-h", "--help", "Show this help") { puts opts ; exit }
|
19
|
+
begin
|
20
|
+
opts.parse!(argv)
|
21
|
+
rescue => e
|
22
|
+
fatal_error("Invalid option(s): #{e.message}\n" + opts.to_s)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Parse config file
|
27
|
+
config_filename = File.join(DATA_DIR, "config")
|
28
|
+
begin
|
29
|
+
@options = File.open(config_filename) { |f| JSON.load(f) }
|
30
|
+
rescue => e
|
31
|
+
fatal_error("Error parsing configuration file #{config_filename}: #{e.message}")
|
32
|
+
end
|
33
|
+
|
34
|
+
# Convert keys to symbols
|
35
|
+
@options = @options.inject({}){|m,(k,v)| m[k.to_sym] = v; m}
|
8
36
|
end
|
9
37
|
|
10
38
|
def run
|
11
|
-
|
39
|
+
consumer_key = (@options[:consumer_key] or fatal_error("Consumer key missing"))
|
40
|
+
consumer_secret = (@options[:consumer_secret] or fatal_error("Consumer secret missing"))
|
41
|
+
twitter = Twitter.new(@options[:consumer_key], @options[:consumer_secret])
|
42
|
+
Retweeter.new(twitter, @options).run
|
12
43
|
end
|
44
|
+
|
45
|
+
private
|
46
|
+
def fatal_error(msg)
|
47
|
+
STDERR.puts msg
|
48
|
+
exit(-1)
|
49
|
+
end
|
13
50
|
end
|
14
51
|
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
require_relative 'log'
|
2
|
+
|
3
|
+
module Retjilp
|
4
|
+
class Twitter
|
5
|
+
TWITTER_URI = "http://api.twitter.com"
|
6
|
+
API_VERSION = "1.1"
|
7
|
+
ACCESS_TOKEN_FILENAME = File.join(File.expand_path("~/.retjilp"), "access_token")
|
8
|
+
|
9
|
+
def initialize(consumer_key, consumer_secret)
|
10
|
+
@consumer_key = consumer_key
|
11
|
+
@consumer_secret = consumer_secret
|
12
|
+
end
|
13
|
+
|
14
|
+
def user_timeline
|
15
|
+
Retjilp::log.info("Fetching own tweets")
|
16
|
+
get "/statuses/user_timeline.json?trim_user=true&include_rts=true"
|
17
|
+
end
|
18
|
+
|
19
|
+
def list_statuses(list, options)
|
20
|
+
Retjilp::log.info("Fetching list tweets of #{list}")
|
21
|
+
since_id = options[:since_id] ? "&since_id=#{options[:since_id]}" : ""
|
22
|
+
get "/lists/statuses.json?slug=#{list}&owner_screen_name=#{@user_info['screen_name']}&include_rts=true" + since_id
|
23
|
+
end
|
24
|
+
|
25
|
+
def home_timeline(options)
|
26
|
+
since_id = options[:since_id] ? "&since_id=#{options[:since_id]}" : ""
|
27
|
+
get "/statuses/home_timeline.json?trim_user=true" + since_id
|
28
|
+
end
|
29
|
+
|
30
|
+
def retweet(id)
|
31
|
+
result = @access_token.post("/#{API_VERSION}/statuses/retweet/#{id}.json")
|
32
|
+
result.class == Net::HTTPOK or Retjilp::log.error("Error retweeting #{result.body}")
|
33
|
+
end
|
34
|
+
|
35
|
+
def login
|
36
|
+
# Request the token if the cached access token does not exist
|
37
|
+
@access_token, @user_info = cached_access_token
|
38
|
+
unless @access_token
|
39
|
+
STDIN.tty? or raise "This script must be run interactively the first time to be able to authenticate."
|
40
|
+
Retjilp::log.info("Requesting new access token")
|
41
|
+
consumer = OAuth::Consumer.new(
|
42
|
+
@consumer_key,
|
43
|
+
@consumer_secret,
|
44
|
+
:site => TWITTER_URI,
|
45
|
+
:scheme => :header,
|
46
|
+
:request_token_path => "/oauth/request_token",
|
47
|
+
:authorize_path => "/oauth/authorize",
|
48
|
+
:@access_token_path => "/oauth/@access_token",
|
49
|
+
:http_method => :post)
|
50
|
+
request_token = consumer.get_request_token(:oauth_callback => "oob")
|
51
|
+
|
52
|
+
puts "Please open #{request_token.authorize_url} in your browser, authorize Retjilp, and enter the PIN code below:"
|
53
|
+
verifier = STDIN.gets.chomp
|
54
|
+
|
55
|
+
begin
|
56
|
+
@access_token = request_token.get_access_token(:oauth_verifier => verifier)
|
57
|
+
rescue OAuth::Unauthorized
|
58
|
+
raise "Invalid PIN verification!"
|
59
|
+
end
|
60
|
+
@user_info = verify_token(@access_token) or raise "Access token not authorized!"
|
61
|
+
cached_access_token = @access_token
|
62
|
+
end
|
63
|
+
Retjilp::log.info("Logged in as #{@user_info["screen_name"]}")
|
64
|
+
end
|
65
|
+
|
66
|
+
protected
|
67
|
+
def cached_access_token
|
68
|
+
access_token = nil
|
69
|
+
user_info = nil
|
70
|
+
if File.exist?(ACCESS_TOKEN_FILENAME)
|
71
|
+
Retjilp::log.info("Loading cached access token from #{ACCESS_TOKEN_FILENAME}")
|
72
|
+
File.open(ACCESS_TOKEN_FILENAME) do |f|
|
73
|
+
begin
|
74
|
+
access_token_data = JSON.load(f)
|
75
|
+
consumer = OAuth::Consumer.new(@consumer_key, @consumer_secret, { :site => TWITTER_URI })
|
76
|
+
access_token = OAuth::AccessToken.new(consumer, access_token_data["token"], access_token_data["secret"])
|
77
|
+
unless user_info = verify_token(access_token)
|
78
|
+
Retjilp::log.warn("Cached token not authorized")
|
79
|
+
access_token = nil
|
80
|
+
end
|
81
|
+
rescue JSON::ParserError
|
82
|
+
Retjilp::log.warn("Cached token does not parse")
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
[access_token, user_info]
|
87
|
+
end
|
88
|
+
|
89
|
+
def cached_access_token=(access_token)
|
90
|
+
Retjilp::log.info("Caching token in #{ACCESS_TOKEN_FILENAME}")
|
91
|
+
File.open(ACCESS_TOKEN_FILENAME, 'w+') do |f|
|
92
|
+
access_token_data = {
|
93
|
+
"token" => access_token.token,
|
94
|
+
"secret" => access_token.secret
|
95
|
+
}
|
96
|
+
JSON.dump(access_token_data, f)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
# Helper method to verify the validity of an access token.
|
102
|
+
# Returns the user info if the token verified correctly.
|
103
|
+
def verify_token(token)
|
104
|
+
response = token.get("/#{API_VERSION}/account/verify_credentials.json")
|
105
|
+
response.class == Net::HTTPOK ? JSON.parse(response.body) : nil
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
def get(url)
|
110
|
+
full_url = "/#{API_VERSION}#{url}"
|
111
|
+
Retjilp::log.debug("-> " + full_url)
|
112
|
+
result = JSON.parse(@access_token.get("/#{API_VERSION}/#{url}").body)
|
113
|
+
Retjilp::log.debug("<- " + JSON.pretty_generate(result))
|
114
|
+
raise "Error fetching result: #{result.to_s}" if result.include? "errors"
|
115
|
+
result
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
data/retjilp.gemspec
CHANGED
@@ -5,18 +5,24 @@ Gem::Specification.new do |s|
|
|
5
5
|
s.summary = 'Automatically retweet tweets'
|
6
6
|
s.description = 'Retjilp logs into your account, scans all the tweets from your following list or another defined list for a set of matching words, and retweets the ones that match (using the native retweet API).'
|
7
7
|
s.requirements = ['']
|
8
|
-
s.version = '0.
|
8
|
+
s.version = '0.3'
|
9
9
|
s.author = 'Remko Tronçon'
|
10
10
|
s.email = 'remko@el-tramo.be'
|
11
11
|
s.homepage = 'http://el-tramo.be/blog/retjilp'
|
12
12
|
s.platform = Gem::Platform::RUBY
|
13
13
|
s.required_ruby_version = '>=1.8'
|
14
|
-
s.files = Dir['
|
14
|
+
s.files = Dir['{bin,lib,doc}/**/*'] + Dir['[A-Z]*'] + ['retjilp.gemspec']
|
15
15
|
s.executables = 'retjilp'
|
16
16
|
s.require_paths = ['lib']
|
17
17
|
s.has_rdoc = false
|
18
18
|
s.license = 'BSD'
|
19
|
+
s.test_files = Dir.glob("test/**/*.rb")
|
19
20
|
|
20
21
|
s.add_runtime_dependency('oauth')
|
21
22
|
s.add_runtime_dependency('json_pure')
|
23
|
+
|
24
|
+
s.add_development_dependency('rake')
|
25
|
+
s.add_development_dependency('rspec-core')
|
26
|
+
s.add_development_dependency('rspec-mocks')
|
27
|
+
s.add_development_dependency('rspec-expectations')
|
22
28
|
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
$LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib'))
|
2
|
+
|
3
|
+
require 'retjilp/retweeter'
|
4
|
+
require 'retjilp/twitter'
|
5
|
+
|
6
|
+
module Retjilp
|
7
|
+
describe Retweeter do
|
8
|
+
let(:twitter) { double(Twitter) }
|
9
|
+
|
10
|
+
before (:each) do
|
11
|
+
twitter.stub(:login)
|
12
|
+
end
|
13
|
+
|
14
|
+
context 'match keyword' do
|
15
|
+
let(:retweeter) { Retweeter.new(twitter, :match => ['MatchingKeyword']) }
|
16
|
+
|
17
|
+
context 'empty user timeline' do
|
18
|
+
before(:each) do
|
19
|
+
twitter.stub(:user_timeline) {[]}
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'retweets new matching items' do
|
23
|
+
twitter.stub(:home_timeline) {[{'text' => 'MatchingKeyword', 'id' => 'id1'}]}
|
24
|
+
twitter.should_receive(:retweet).with('id1')
|
25
|
+
retweeter.run
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'does not retweet non-matching items' do
|
29
|
+
twitter.stub(:home_timeline) {[{'text' => 'OtherKeyword', 'id' => 'id1'}]}
|
30
|
+
twitter.should_not_receive(:retweet)
|
31
|
+
retweeter.run
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'retweets retweets with their id' do
|
35
|
+
twitter.stub(:home_timeline) {[{'text' => 'MatchingKeyword', 'id' => 'id1', 'retweeted_status' => {'id' => 'id2'}}]}
|
36
|
+
twitter.should_receive(:retweet).with('id2')
|
37
|
+
retweeter.run
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
context 'user timeline with already retweeted items' do
|
42
|
+
before(:each) do
|
43
|
+
twitter.stub(:user_timeline) {[{}, {'retweeted_status' => {'id' => 'id1'}}]}
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'does not retweet already tweeted items' do
|
47
|
+
twitter.stub(:home_timeline) {[{'text' => 'MatchingKeyword', 'id' => 'id1'}]}
|
48
|
+
twitter.should_not_receive(:retweet)
|
49
|
+
Retweeter.new(twitter, :match => ['MatchingKeyword']).run
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
context 'match is empty' do
|
55
|
+
let(:retweeter) { Retweeter.new(twitter, :match => []) }
|
56
|
+
|
57
|
+
it 'retweets all items' do
|
58
|
+
twitter.stub(:user_timeline) {[]}
|
59
|
+
twitter.stub(:home_timeline) {[{'text' => 'OtherKeyword', 'id' => 'id1'}]}
|
60
|
+
|
61
|
+
twitter.should_receive(:retweet).with('id1')
|
62
|
+
|
63
|
+
retweeter.run
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
context 'retweet from list' do
|
68
|
+
let(:retweeter) { Retweeter.new(twitter, :retweet_from_list => 'MyList', :match => ['MatchingKeyword']) }
|
69
|
+
|
70
|
+
it 'retweets from the right list' do
|
71
|
+
twitter.stub(:user_timeline) {[]}
|
72
|
+
twitter.should_receive(:list_statuses).with('MyList', anything()).and_return([
|
73
|
+
{'text' => 'MatchingKeyword', 'id' => 'id1'},
|
74
|
+
{'text' => 'OtherKeyword', 'id' => 'id2'}
|
75
|
+
])
|
76
|
+
twitter.should_receive(:retweet).with('id1')
|
77
|
+
retweeter.run
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: retjilp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: '0.
|
4
|
+
version: '0.3'
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Remko Tronçon
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2013-03-
|
11
|
+
date: 2013-03-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: oauth
|
@@ -38,6 +38,62 @@ dependencies:
|
|
38
38
|
- - '>='
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - '>='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec-core
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - '>='
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec-mocks
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - '>='
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - '>='
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rspec-expectations
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '>='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '>='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
41
97
|
description: Retjilp logs into your account, scans all the tweets from your following
|
42
98
|
list or another defined list for a set of matching words, and retweets the ones
|
43
99
|
that match (using the native retweet API).
|
@@ -47,15 +103,37 @@ executables:
|
|
47
103
|
extensions: []
|
48
104
|
extra_rdoc_files: []
|
49
105
|
files:
|
50
|
-
- AUTHORS
|
51
106
|
- bin/retjilp
|
52
|
-
-
|
53
|
-
- COPYING
|
54
|
-
- lib/retjilp/options.rb
|
107
|
+
- lib/retjilp/log.rb
|
55
108
|
- lib/retjilp/retweeter.rb
|
56
109
|
- lib/retjilp/runner.rb
|
110
|
+
- lib/retjilp/twitter.rb
|
111
|
+
- doc/_index.html
|
112
|
+
- doc/class_list.html
|
113
|
+
- doc/css/common.css
|
114
|
+
- doc/css/full_list.css
|
115
|
+
- doc/css/style.css
|
116
|
+
- doc/file.AUTHORS.html
|
117
|
+
- doc/file.COPYING.html
|
118
|
+
- doc/file.README.html
|
119
|
+
- doc/file_list.html
|
120
|
+
- doc/frames.html
|
121
|
+
- doc/index.html
|
122
|
+
- doc/js/app.js
|
123
|
+
- doc/js/full_list.js
|
124
|
+
- doc/js/jquery.js
|
125
|
+
- doc/method_list.html
|
126
|
+
- doc/Retjilp/Options.html
|
127
|
+
- doc/Retjilp/Retweeter.html
|
128
|
+
- doc/Retjilp/Runner.html
|
129
|
+
- doc/Retjilp.html
|
130
|
+
- doc/top-level-namespace.html
|
131
|
+
- AUTHORS
|
132
|
+
- COPYING
|
133
|
+
- Rakefile
|
57
134
|
- README.markdown
|
58
135
|
- retjilp.gemspec
|
136
|
+
- test/spec/retweeter_spec.rb
|
59
137
|
homepage: http://el-tramo.be/blog/retjilp
|
60
138
|
licenses:
|
61
139
|
- BSD
|
@@ -81,4 +159,5 @@ rubygems_version: 2.0.0
|
|
81
159
|
signing_key:
|
82
160
|
specification_version: 4
|
83
161
|
summary: Automatically retweet tweets
|
84
|
-
test_files:
|
162
|
+
test_files:
|
163
|
+
- test/spec/retweeter_spec.rb
|
data/config
DELETED
@@ -1,27 +0,0 @@
|
|
1
|
-
/*
|
2
|
-
* Retjilp configuration file.
|
3
|
-
*
|
4
|
-
* Change this to reflect your setup, and put it in ~/.retjilp
|
5
|
-
*/
|
6
|
-
{
|
7
|
-
/*
|
8
|
-
* Consumer key and secret.
|
9
|
-
* Get this by registering a new (desktop) application at
|
10
|
-
* http://twitter.com/apps
|
11
|
-
*/
|
12
|
-
"consumer_key": "abcdeFghIjklMnOpQrStUv",
|
13
|
-
"consumer_secret": "abcdefgh123456789abcdefgh123456789abcdefg",
|
14
|
-
|
15
|
-
/*
|
16
|
-
* The strings that a tweet should be matched against.
|
17
|
-
* These strings are matched in lower case.
|
18
|
-
*/
|
19
|
-
"match": ["#sometag", "#someothertag", "someword"]
|
20
|
-
|
21
|
-
/*
|
22
|
-
* List name from which statuses are retweeted.
|
23
|
-
* Set this config value if you want to retweet only from
|
24
|
-
* this list instead of your following list.
|
25
|
-
*/
|
26
|
-
/* "retweet_from_list": "auto-retweet" */
|
27
|
-
}
|
data/lib/retjilp/options.rb
DELETED
@@ -1,24 +0,0 @@
|
|
1
|
-
require 'optparse'
|
2
|
-
require 'logger'
|
3
|
-
|
4
|
-
module Retjilp
|
5
|
-
class Options
|
6
|
-
attr_reader :log_level
|
7
|
-
|
8
|
-
def initialize(argv)
|
9
|
-
@log_level = Logger::WARN
|
10
|
-
OptionParser.new do |opts|
|
11
|
-
opts.banner = "Usage: retjilp [ --help ] [ --verbose | --debug ]"
|
12
|
-
opts.on("--verbose", "Run with verbose output") { @log_level = Logger::INFO }
|
13
|
-
opts.on("--debug", "Run with debug output") { @log_level = Logger::DEBUG }
|
14
|
-
opts.on_tail("-h", "--help", "Show this help") { puts opts ; exit }
|
15
|
-
begin
|
16
|
-
opts.parse!(argv)
|
17
|
-
rescue => e
|
18
|
-
STDERR.puts e.message, "\n", opts
|
19
|
-
exit(-1)
|
20
|
-
end
|
21
|
-
end.parse!(argv)
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|