zendeskdumper 0.0.0 → 0.1.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/bin/zendeskdumper +83 -0
- data/lib/zendeskdumper.rb +212 -0
- data/lib/zendeskdumper/meta.rb +20 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 337035df0e670d759100d5031ebd1b65c1585cc5
|
4
|
+
data.tar.gz: 59e5ff9c8a89cafdc04ba926ac37381135ddab10
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 197f764b04756f3ea00f909132c4f3579415bb132da29904d2ce2d505741fa558d08947c1764d35416d0d0c75a1df2d91816019fbc9eea7cf6fa439a39a6786f
|
7
|
+
data.tar.gz: ce7cf9fac323826ba1fb85fa09988055c16654ba80203b7d4a5b0a36c92884631bdc25c8d8f3c08d0456ccae541a277c9c8fe11b1959d9f12b142ab7a7805d94
|
data/bin/zendeskdumper
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# Copyright © 2017 Taylor C. Richberger <taywee@gmx.com>
|
3
|
+
# This code is released under the license described in the LICENSE file
|
4
|
+
|
5
|
+
require 'logger'
|
6
|
+
require 'optparse'
|
7
|
+
require 'pathname'
|
8
|
+
require 'set'
|
9
|
+
require 'yaml'
|
10
|
+
|
11
|
+
require 'zendeskdumper'
|
12
|
+
require 'zendeskdumper/meta'
|
13
|
+
|
14
|
+
|
15
|
+
def main!
|
16
|
+
options = {directory: Pathname.new('.')}
|
17
|
+
|
18
|
+
opts = OptionParser.new
|
19
|
+
def opts.abort(output, code: 1)
|
20
|
+
STDERR.puts "ERROR: #{output}"
|
21
|
+
STDERR.puts self
|
22
|
+
exit code
|
23
|
+
end
|
24
|
+
|
25
|
+
opts.banner = 'Usage: zendeskdumper [options] DOMAIN'
|
26
|
+
opts.version = ZenDeskDumper::Meta::VERSION
|
27
|
+
|
28
|
+
opts.on_tail('-h', '--help', 'Show this help message') do
|
29
|
+
puts opts
|
30
|
+
exit
|
31
|
+
end
|
32
|
+
opts.on_tail('-V', '--version', 'Show the version') do
|
33
|
+
puts opts.version
|
34
|
+
exit
|
35
|
+
end
|
36
|
+
|
37
|
+
opts.on('-u', '--user USERNAME:PASSWORD', 'Specify username and password') do |user|
|
38
|
+
unless user.include? ':'
|
39
|
+
opts.abort '-u, --user needs at least one colon'
|
40
|
+
end
|
41
|
+
|
42
|
+
options.update([:username, :password].zip(user.split(':', 2)).to_h)
|
43
|
+
end
|
44
|
+
opts.on('-c', '--credentials FILE', 'Credentials YAML file with username and password fields') do |filename|
|
45
|
+
set = Set[:username, :password]
|
46
|
+
# Should only update username and password
|
47
|
+
options.update(YAML.load_file(filename).select {|key| set.include? key})
|
48
|
+
end
|
49
|
+
opts.on('-d', '--directory DIR', 'Directory to dump the files (default: .)') do |dir|
|
50
|
+
options[:directory] = Pathname.new(dir)
|
51
|
+
end
|
52
|
+
opts.parse!
|
53
|
+
|
54
|
+
if ARGV.empty?
|
55
|
+
opts.abort 'ERROR: DOMAIN is mandatory'
|
56
|
+
end
|
57
|
+
|
58
|
+
unless options.key? :username and options.key? :password
|
59
|
+
opts.abort 'Credentials must be specified with either -u or -c, and must specify username and password'
|
60
|
+
end
|
61
|
+
|
62
|
+
domain = ARGV.pop
|
63
|
+
|
64
|
+
logger = Logger.new(STDERR)
|
65
|
+
|
66
|
+
ZenDeskDumper::Dumper.new(
|
67
|
+
username: options[:username],
|
68
|
+
password: options[:password],
|
69
|
+
domain: domain,
|
70
|
+
).dump do |filename, content|
|
71
|
+
path = options[:directory] + filename
|
72
|
+
dir = path.dirname
|
73
|
+
unless dir.directory?
|
74
|
+
logger.info "creating #{dir}"
|
75
|
+
dir.mkpath
|
76
|
+
end
|
77
|
+
logger.info "dumping #{path}"
|
78
|
+
File::write(path, content)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
|
83
|
+
main! if __FILE__ == $0
|
data/lib/zendeskdumper.rb
CHANGED
@@ -0,0 +1,212 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'net/http'
|
3
|
+
require 'pathname'
|
4
|
+
|
5
|
+
module ZenDeskDumper
|
6
|
+
# A special dumper class. The "dump" method will use a passed-in block to
|
7
|
+
# generate, that way it can actually be used to directly write into an archive
|
8
|
+
# or something of the sort if desired. All path names are kept relative
|
9
|
+
class Dumper
|
10
|
+
attr_accessor :domain, :username, :password
|
11
|
+
|
12
|
+
def initialize(domain:, username:, password:)
|
13
|
+
@logger = Logger.new(STDERR)
|
14
|
+
|
15
|
+
@domain = domain
|
16
|
+
@username = username
|
17
|
+
@password = password
|
18
|
+
|
19
|
+
@threadlimit = 50
|
20
|
+
end
|
21
|
+
|
22
|
+
# Main method to call for this type; runs the full dumper. Uses a passed-in
|
23
|
+
# block to generate all files.
|
24
|
+
def dump(&block)
|
25
|
+
@logger.info 'pulling users'
|
26
|
+
dump_users(&block)
|
27
|
+
@logger.info 'pulling organizations'
|
28
|
+
dump_organizations(&block)
|
29
|
+
@logger.info 'pulling tickets'
|
30
|
+
dump_tickets(&block)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Run a list of items, yield to the block, and join on the thread limit (and
|
34
|
+
# before finishing
|
35
|
+
def checkthreads(list)
|
36
|
+
threads = []
|
37
|
+
list.each do |item|
|
38
|
+
threads << Thread.new do
|
39
|
+
yield item
|
40
|
+
end
|
41
|
+
if threads.size >= @threadlimit
|
42
|
+
threads.each(&:join)
|
43
|
+
threads = []
|
44
|
+
end
|
45
|
+
end
|
46
|
+
threads.each(&:join)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Get method which runs requests and optionally sleeps if necessary based on
|
50
|
+
# ratelimiting
|
51
|
+
def get(uri)
|
52
|
+
request = Net::HTTP::Get.new uri
|
53
|
+
request.basic_auth(@username, @password)
|
54
|
+
response = Net::HTTP::start(uri.host, uri.port, use_ssl: true) do |http|
|
55
|
+
http.request request
|
56
|
+
end
|
57
|
+
if Integer(response.code) == 429
|
58
|
+
# Make sure to allow some decent grace period
|
59
|
+
time = Integer(response['Retry-After']) + 60
|
60
|
+
@logger.debug "Hitting rate limiting, sleeping for #{time} seconds"
|
61
|
+
sleep time
|
62
|
+
return get uri
|
63
|
+
end
|
64
|
+
# Raise errors when necessary
|
65
|
+
response.value
|
66
|
+
response
|
67
|
+
end
|
68
|
+
|
69
|
+
# Get an attachment file, following redirects, and return its body
|
70
|
+
def get_attachment(uri, limit=10)
|
71
|
+
raise ArgumentError, 'too many redirects' if limit == 0
|
72
|
+
|
73
|
+
request = Net::HTTP::Get.new uri
|
74
|
+
@logger.debug "getting attachment from uri #{uri}"
|
75
|
+
request.basic_auth(@username, @password)
|
76
|
+
response = Net::HTTP::start(uri.host, uri.port, use_ssl: true) do |http|
|
77
|
+
http.request request
|
78
|
+
end
|
79
|
+
if response.is_a? Net::HTTPRedirection
|
80
|
+
@logger.debug "redirecting to #{response['location']}"
|
81
|
+
return get_attachment URI(response['location']), limit - 1
|
82
|
+
end
|
83
|
+
response.value
|
84
|
+
response.body
|
85
|
+
end
|
86
|
+
|
87
|
+
# get all pages from a paged api and yield their parsed JSON body into a block
|
88
|
+
def getpages(uri)
|
89
|
+
pagenumber = 1
|
90
|
+
loop do
|
91
|
+
response = get uri
|
92
|
+
body = JSON.parse(response.body)
|
93
|
+
yield body, pagenumber
|
94
|
+
next_page = body['next_page']
|
95
|
+
break if next_page.nil?
|
96
|
+
# If the next_page was the same as this one, it will loop forever
|
97
|
+
next_uri = URI(next_page)
|
98
|
+
break if uri == next_uri
|
99
|
+
|
100
|
+
uri = next_uri
|
101
|
+
pagenumber += 1
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def dumppages(uri, basepath, formatname)
|
106
|
+
getpages uri do |page, pagenum|
|
107
|
+
path = basepath + formatname % pagenum
|
108
|
+
yield path, JSON.fast_generate(page)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def dump_users(&block)
|
113
|
+
uri = URI('https:/api/v2/users.json')
|
114
|
+
uri.hostname = @domain
|
115
|
+
getpages uri do |page|
|
116
|
+
checkthreads(page['users']) do |user|
|
117
|
+
begin
|
118
|
+
dump_user(user['id'], &block)
|
119
|
+
rescue Net::HTTPServerException, Net::HTTPFatalError
|
120
|
+
@logger.error "could not find user #{user}"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def dump_user(id, &block)
|
127
|
+
# Get user meta details
|
128
|
+
uri = URI("https:/api/v2/users/#{id}.json")
|
129
|
+
uri.hostname = @domain
|
130
|
+
response = get uri
|
131
|
+
basepath = Pathname.new('users') + id.to_s
|
132
|
+
yield basepath + 'user.json', response.body
|
133
|
+
|
134
|
+
# Get user groups
|
135
|
+
uri = URI("https:/api/v2/users/#{id}/groups.json")
|
136
|
+
uri.hostname = @domain
|
137
|
+
dumppages(uri, basepath, 'groups-%03d.json', &block)
|
138
|
+
end
|
139
|
+
|
140
|
+
def dump_organizations(&block)
|
141
|
+
uri = URI('https:/api/v2/organizations.json')
|
142
|
+
uri.hostname = @domain
|
143
|
+
getpages uri do |page|
|
144
|
+
checkthreads(page['organizations']) do |organization|
|
145
|
+
begin
|
146
|
+
dump_organization(organization['id'], &block)
|
147
|
+
rescue Net::HTTPServerException, Net::HTTPFatalError
|
148
|
+
@logger.error "could not find organization #{organization}"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def dump_organization(id, &block)
|
155
|
+
# get organization meta details
|
156
|
+
uri = URI("https:/api/v2/organizations/#{id}.json")
|
157
|
+
uri.hostname = @domain
|
158
|
+
response = get uri
|
159
|
+
basepath = Pathname.new('organizations') + id.to_s
|
160
|
+
yield basepath + 'organization.json', response.body
|
161
|
+
|
162
|
+
# Get users
|
163
|
+
uri = URI("https:/api/v2/organizations/#{id}/users.json")
|
164
|
+
uri.hostname = @domain
|
165
|
+
dumppages(uri, basepath, 'users-%03d.json', &block)
|
166
|
+
end
|
167
|
+
|
168
|
+
def dump_tickets(&block)
|
169
|
+
uri = URI('https:/api/v2/incremental/tickets.json?start_time=0')
|
170
|
+
uri.hostname = @domain
|
171
|
+
getpages uri do |page|
|
172
|
+
checkthreads(page['tickets']) do |ticket|
|
173
|
+
begin
|
174
|
+
dump_ticket(ticket['id'], &block)
|
175
|
+
rescue Net::HTTPServerException, Net::HTTPFatalError
|
176
|
+
@logger.error "could not find ticket #{ticket}"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def dump_ticket(id)
|
183
|
+
# Yield base ticket details
|
184
|
+
uri = URI("https:/api/v2/tickets/#{id}.json")
|
185
|
+
uri.hostname = @domain
|
186
|
+
response = get uri
|
187
|
+
basepath = Pathname.new('tickets') + id.to_s
|
188
|
+
yield basepath + 'ticket.json', response.body
|
189
|
+
|
190
|
+
# Yield out all comments
|
191
|
+
uri = URI("https:/api/v2/tickets/#{id}/comments.json")
|
192
|
+
uri.hostname = @domain
|
193
|
+
getpages uri do |page, pagenum|
|
194
|
+
path = basepath + 'comments-%03d.json' % pagenum
|
195
|
+
yield path, JSON.fast_generate(page)
|
196
|
+
page['comments'].each do |comment|
|
197
|
+
comment['attachments'].each do |attachment|
|
198
|
+
basepath = Pathname.new('attachments') + attachment['id'].to_s
|
199
|
+
@logger.debug "getting attachment json file"
|
200
|
+
yield basepath + 'attachment.json', JSON.fast_generate(attachment)
|
201
|
+
begin
|
202
|
+
@logger.debug "getting attachment file #{basepath + 'files' + attachment['file_name']}"
|
203
|
+
yield basepath + 'files' + attachment['file_name'], get_attachment(URI(attachment['content_url']))
|
204
|
+
rescue Net::HTTPServerException, Net::HTTPFatalError
|
205
|
+
@logger.error "could not find attachment file #{attachment}"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# This is simply here in case we want any of this data available from within the
|
2
|
+
# running library, especially for generating the useable command line
|
3
|
+
module ZenDeskDumper
|
4
|
+
module Meta
|
5
|
+
NAME = 'zendeskdumper'
|
6
|
+
VERSION = '0.1.0'
|
7
|
+
DATE = '2018-01-02'
|
8
|
+
SUMMARY = 'A simple dumper for a ZenDesk domain.'
|
9
|
+
DESCRIPTION = 'A simple dumper for a ZenDesk domain. Attempts to pull all users, tickets, comments, and attachments'
|
10
|
+
AUTHORS = ['Taylor C. Richberger']
|
11
|
+
EMAIL = 'tcr@absolute-performance.com'
|
12
|
+
FILES = [
|
13
|
+
'bin/zendeskdumper',
|
14
|
+
'lib/zendeskdumper.rb',
|
15
|
+
'lib/zendeskdumper/meta.rb',
|
16
|
+
]
|
17
|
+
HOMEPAGE = 'https://rubygems.org/gem/zendeskdumper'
|
18
|
+
LICENSE = 'MIT'
|
19
|
+
end
|
20
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: zendeskdumper
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Taylor C. Richberger
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2018-01-02 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: A simple dumper for a ZenDesk domain. Attempts to pull all users, tickets,
|
14
14
|
comments, and attachments
|
@@ -17,7 +17,9 @@ executables: []
|
|
17
17
|
extensions: []
|
18
18
|
extra_rdoc_files: []
|
19
19
|
files:
|
20
|
+
- bin/zendeskdumper
|
20
21
|
- lib/zendeskdumper.rb
|
22
|
+
- lib/zendeskdumper/meta.rb
|
21
23
|
homepage: https://rubygems.org/gem/zendeskdumper
|
22
24
|
licenses:
|
23
25
|
- MIT
|