-
Notifications
You must be signed in to change notification settings - Fork 14
/
Copy pathzendesk-helpcenter-export.rb
executable file
·331 lines (284 loc) · 11.7 KB
/
zendesk-helpcenter-export.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
require 'rubygems'
require 'httparty'
require 'fileutils'
require 'json'
require 'uri'
require 'optparse'
require 'rbconfig'
# # Ruby script to export your Zendesk helpcenter
#
# Script based on https://github.com/skipjac/pull-zendesk-forums
# (which exports the forum, not the help center article)
#
# it uses the Zendesk API to export all categories, sections, articles, article_attachments to html (and json)
# all of this in a nested folder structure
#
# - category
# - section
# - article
# - article.html
# - image-1.jpg
# - image-2.png
# - meta_data.json
#
# Bonus: it is smart in that when you rename a category, section, article it won't
# start to create duplicate folders but renames the old ones.
# The script can thus be used for both a new dump as updating an existing one.
#
# # How to use?
#
# 1. have a machine with ruby and rubygems installed
# (if you don't know how to do this, this script is probably out of your leage)
#
# 2. copy this .rb file to the place where you want to store the export
# 3. use terminal to navigate to the folder and run
#
# ruby zendesk-helpcenter-export.rb -e [email protected] -p YoUrPassWoRd -d my-zen-subdomain
#
# # Contribute
#
# Feel free to create a pull request to improve this script
#
# # Credits
#
# - thanks to https://github.com/skipjac/pull-zendesk-forums
# - Author of this script: https://github.com/pjmuller
# - License: MIT
#
class ExportHelpCenter
include HTTParty
attr :raw_data, :log_level, :output_type
LOG_LEVELS = {standard: 1, verbose: 2}
OUTPUT_TYPES = [:slugified, :id_only]
REQUIRED_INPUTS = [:email, :password, :subdomain]
def initialize(options)
exit unless invalid_inputs?(options)
# prep variables
@auth = {username: options[:email], password: options[:password]}
@log_level = options[:log_level]
@output_type = options[:output_type]
# used to make one big dumpfile of all metadata related to your helpcenter
@raw_data = {categories: [], sections: [], articles: [], article_attachments: []}
# configure Httparty base uri
self.class.base_uri "https://#{options[:subdomain]}.zendesk.com"
end
# section: loop over all categories, sections, articles and attachments
# ---------------------------------------
def to_html!
return if api_error?(categories)
categories['categories'].each do |category|
log(category['name'].upcase)
@raw_data[:categories] << category
sections(category['id'])['sections'].each do |section|
@raw_data[:sections] << section
log(" #{section['name']}")
articles(section['id'])['articles'].each do |article|
log(" #{article['name']}", :standard)
article_dir = dir_path(category, section, article)
file_path = "#{article_dir}index.html"
article['backup_path'] = file_path
@raw_data[:articles] << article
File.open(file_path, "w+") { |f| f.puts article_html_content(article) }
article_attachments(article['id'])['article_attachments'].each do |article_attachment|
@raw_data[:article_attachments] << article_attachment
# optimization, do not download attachment when already present (we could check based on the id)
download_attachment!(article_attachment, article_dir)
end
end
end
end
end
# can only be called AFTER export_html_and_images!
def to_json!
File.open("./meta_data.json", "w+") { |f| f.puts JSON.pretty_generate(raw_data) }
end
def create_table_of_contents!
File.open("./index.html", "w+") { |f| f.puts main_overview_file }
end
# Section: Article content
# ---------------------------------------
def article_html_content(article)
# add some boilerplat to make it all look nicer
# and replace all image links towards the local url
regex_find = /https:\/\/.+?zendesk.com.+?article_attachments\/(\d+?)\/(.+)\.(.+?)" alt/
regex_replace = output_type == :slugified ? '\1-\2.\3" alt' : '\1.\3" alt'
boiler_plate_html do
"""
<h1>#{article['name']}</h1>
#{article['body'].to_s.gsub(regex_find, regex_replace)}
"""
end
end
def main_overview_file
boiler_plate_html do
content = []
raw_data[:categories].each do |cat|
content << "<h1>#{cat['name']}</h1>"
raw_data[:sections].each do |section|
next if section["category_id"] != cat['id']
content << "<span class=\"wysiwyg-font-size-large\">#{section["name"]}</span><br />"
content << "<ul>"
raw_data[:articles].each do |article|
next if article["section_id"] != section['id']
content << "<li><a href='#{article['backup_path']}'>#{article['name']}</a></li>"
end
content << "</ul>"
end
end
content.join("\n")
end
end
def boiler_plate_html &block
"""
<html>
<head>
<meta charset='UTF-8'>
<link rel='stylesheet' href='http://output.jsbin.com/gefofo.css' />
</head>
<body>
<div id='container'>
#{yield}
</div>
</body>
</html>
"""
end
# section: Debugging
# ---------------------------------------
# input:
# - text: text to log
# - level: :standard / :verbose. States when it needs to be logged
def log(text, level = :standard)
# protect against bad input
return unless LOG_LEVELS.has_key?(level)
# output when the log level we are requesting
puts text if LOG_LEVELS[log_level] >= LOG_LEVELS[level]
end
def invalid_inputs?(options)
if REQUIRED_INPUTS.map{|k| options[k].nil?}.any?
puts "Missing one of required inputs.\nExpecting: #{REQUIRED_INPUTS}.\nReceived = #{options}"
return false
end
unless LOG_LEVELS.include?(options[:log_level])
puts "Log level (#{options[:log_level]}) not recognized. Should be one of the values: #{LOG_LEVELS}"
return false
end
unless OUTPUT_TYPES.include?(options[:output_type])
puts "Ouput type (#{options[:output_type]}) not recognized. Should be one of the values: #{OUTPUT_TYPES}"
return false
end
return true
end
# section: Make sure directories exist
# ---------------------------------------
# return the dir_path (string) for given resource
# and create the path if does not exist yet
def dir_path(category, section = nil, article = nil)
# each resource has an id and name attribute
# let's use this to build a path where we can store the actual data
log(" buidling dir_path for #{[category, section, article].compact.map{|r| r['name']}}", :verbose)
[category, section, article].compact.inject("./") do |dir_path, resource|
# check if we have existing folder that needs to be renamed
path_to_append = output_type == :slugified ? "#{resource['id']}-#{slugify(resource['name'])}" : "#{resource['id']}"
rename_dir_or_file_starting_with_id!(dir_path, resource['id'], path_to_append)
# build path and check if folder exists
log(" #{path_to_append} appended to #{dir_path}", :verbose)
dir_path += path_to_append + "/"
Dir.mkdir(dir_path) unless File.exists?(dir_path)
# end point is begin point of next iteration
dir_path
end
end
# input
# - "/1001-categories/", "2001", "best section"
# processing
# - look if we find a directory that starts with 2001 in /1001-categories/
# e.g. /1001-categories/2001-better-section
# and if so, rename towards
# output
# - false if nothing needs renamed
# - true if a dir was renamed
def rename_dir_or_file_starting_with_id!(current_directory, id, should_be_name_for_item)
current_name_for_item = Dir.entries(current_directory).select do |entry|
entry.start_with?(id.to_s)
end.first
# dir or file not found, nothing to rename
return false unless current_name_for_item
# dir or file exists, but already with correct name
return false if current_name_for_item == should_be_name_for_item
log(" renaming #{current_directory}#{current_name_for_item}} to #{current_directory}#{should_be_name_for_item}", :verbose)
FileUtils.mv "#{current_directory}#{current_name_for_item}", "#{current_directory}#{should_be_name_for_item}"
return true
end
def slugify(text)
text.to_s.downcase.strip.gsub(' ', '-').gsub(/[^\w-]/, '')
end
# section: API calls
# ---------------------------------------
def api(url)
options = {:basic_auth => @auth}
self.class.get("/api/v2/help_center/#{url}", options)
end
def api_error?(api_response)
if api_response.code != 200
puts "Could not connect to the Zendesk API."
puts "Most likely you provided incorrect username / password / zendesk domain."
puts "\n\n"
puts "Here is the full response of the failed zendesk API call"
puts "-------------------------------------------------------"
puts "request: #{api_response.request.path}"
puts "status: #{api_response.headers['status']}"
puts "headers: #{api_response.headers.inspect}"
puts ""
puts "response: #{api_response.response.inspect}"
puts "parsed response: #{api_response.parsed_response.inspect}"
true
else
false
end
end
# see documentation on https://developer.zendesk.com/rest_api/docs/help_center/introduction
def categories() api("categories.json") end
def sections(category_id) api("categories/#{category_id}/sections.json") end
def articles(section_id) api("sections/#{section_id}/articles.json") end
def article_attachments(article_id) api("articles/#{article_id}/attachments.json") end
def download_attachment!(article_attachment, store_in_dir)
file_name = "#{article_attachment['id']}#{output_type == :slugified ? "-#{article_attachment['file_name']}" : "#{File.extname(article_attachment['file_name'])}"}"
# rename file if it existed with same id but incorrect name
rename_dir_or_file_starting_with_id!(store_in_dir, article_attachment['id'], file_name)
# if file with same id already present, do not "redownload"
return true if Dir.entries(store_in_dir).select{|e| e.start_with?(article_attachment['id'].to_s)}.length > 0
log(" #{article_attachment['file_name']}")
begin
options = {:basic_auth => @auth}
file_contents = self.class.get(article_attachment['content_url'], options)
file_path = "#{store_in_dir}#{file_name}"
File.open(file_path, "w+") { |f| f.puts file_contents }
rescue Exception => e
log(" !!! failed download: " + article_attachment['content_url'] + ". error: #{e.message}")
end
end
end
# section: Executing the script
# ---------------------------------------
# default options (different between Microsoft windows and other OS)
is_windows = (RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/)
options = {
log_level: :standard,
output_type: is_windows ? :id_only : :slugified
}
# step 1: get the attributes through the params
OptionParser.new do |opts|
opts.banner = "Usage: zendesk-helpcenter-export.rb [options]"
opts.on('-e', '--email email', 'Email of a zendesk agent having access to the help center (e.g. [email protected])') { |email| options[:email] = email }
opts.on('-p', '--password password', 'Password') { |password| options[:password] = password }
opts.on('-d', '--subdomain subdomain', 'Zendesk subdomain (e.g. icecream)') { |subdomain| options[:subdomain] = subdomain}
opts.on('-v', '--verbose-logging', 'Verbose logging to identify possible bugs') { options[:log_level] = :verbose }
opts.on('-c', '--compact-file-names', 'Force short filenames for windows based file systems that are limited to 260 path lengths') { options[:output_type] = :id_only }
opts.on('-h', '--help', 'Displays Help') { puts opts; exit }
end.parse!
# run the class
export = ExportHelpCenter.new(options)
export.to_html!
export.to_json!
export.create_table_of_contents!