mirror of
https://github.com/discourse/discourse.git
synced 2024-12-23 04:43:47 +08:00
694b5f108b
These (21 + 3 from previous PRs) are soon to be enabled in rubocop-discourse: Capybara/VisibilityMatcher Lint/DeprecatedOpenSSLConstant Lint/DisjunctiveAssignmentInConstructor Lint/EmptyConditionalBody Lint/EmptyEnsure Lint/LiteralInInterpolation Lint/NonLocalExitFromIterator Lint/ParenthesesAsGroupedExpression Lint/RedundantCopDisableDirective Lint/RedundantRequireStatement Lint/RedundantSafeNavigation Lint/RedundantStringCoercion Lint/RedundantWithIndex Lint/RedundantWithObject Lint/SafeNavigationChain Lint/SafeNavigationConsistency Lint/SelfAssignment Lint/UnreachableCode Lint/UselessMethodDefinition Lint/Void Previous PRs: Lint/ShadowedArgument Lint/DuplicateMethods Lint/BooleanSymbol RSpec/SpecFilePathSuffix
474 lines
12 KiB
Ruby
474 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Zendesk importer
|
|
#
|
|
# This one uses their API.
|
|
|
|
require "open-uri"
|
|
require "reverse_markdown"
|
|
require_relative "base"
|
|
require_relative "base/generic_database"
|
|
|
|
# Call it like this:
|
|
# RAILS_ENV=production bundle exec ruby script/import_scripts/zendesk_api.rb SOURCE_URL DIRNAME AUTH_EMAIL AUTH_TOKEN
|
|
class ImportScripts::ZendeskApi < ImportScripts::Base
|
|
BATCH_SIZE = 1000
|
|
|
|
HTTP_ERRORS = [
|
|
EOFError,
|
|
Errno::ECONNRESET,
|
|
Errno::EINVAL,
|
|
Net::HTTPBadResponse,
|
|
Net::HTTPHeaderSyntaxError,
|
|
Net::ProtocolError,
|
|
Timeout::Error,
|
|
OpenURI::HTTPError,
|
|
OpenSSL::SSL::SSLError,
|
|
]
|
|
|
|
MAX_RETRIES = 5
|
|
|
|
IMAGE_DOWNLOAD_PATH = "replace-me"
|
|
|
|
SUBDOMAIN = "replace-me"
|
|
|
|
def initialize(source_url, path, auth_email, auth_token)
|
|
super()
|
|
|
|
@source_url = source_url
|
|
@path = path
|
|
@auth_email = auth_email
|
|
@auth_token = auth_token
|
|
@db = ImportScripts::GenericDatabase.new(@path, batch_size: BATCH_SIZE, recreate: true)
|
|
end
|
|
|
|
def execute
|
|
fetch_from_api
|
|
|
|
import_categories
|
|
import_users
|
|
import_topics
|
|
import_posts
|
|
import_likes
|
|
end
|
|
|
|
def fetch_from_api
|
|
fetch_categories
|
|
fetch_topics
|
|
fetch_posts
|
|
fetch_users
|
|
|
|
@db.sort_posts_by_created_at
|
|
end
|
|
|
|
def fetch_categories
|
|
puts "", "fetching categories..."
|
|
|
|
get_from_api("/api/v2/community/topics.json", "topics", show_status: true) do |row|
|
|
@db.insert_category(
|
|
id: row["id"],
|
|
name: row["name"],
|
|
description: row["description"],
|
|
position: row["position"],
|
|
url: row["html_url"],
|
|
)
|
|
end
|
|
end
|
|
|
|
def fetch_topics
|
|
puts "", "fetching topics..."
|
|
|
|
get_from_api("/api/v2/community/posts.json", "posts", show_status: true) do |row|
|
|
if row["vote_count"] > 0
|
|
like_user_ids = fetch_likes("/api/v2/community/posts/#{row["id"]}/votes.json")
|
|
end
|
|
|
|
@db.insert_topic(
|
|
id: row["id"],
|
|
title: row["title"],
|
|
raw: row["details"],
|
|
category_id: row["topic_id"],
|
|
closed: row["closed"],
|
|
user_id: row["author_id"],
|
|
created_at: row["created_at"],
|
|
url: row["html_url"],
|
|
like_user_ids: like_user_ids,
|
|
)
|
|
end
|
|
end
|
|
|
|
def fetch_posts
|
|
puts "", "fetching posts..."
|
|
current_count = 0
|
|
total_count = @db.count_topics
|
|
start_time = Time.now
|
|
last_id = ""
|
|
|
|
batches do |offset|
|
|
rows, last_id = @db.fetch_topics(last_id)
|
|
break if rows.empty?
|
|
|
|
rows.each do |topic_row|
|
|
get_from_api(
|
|
"/api/v2/community/posts/#{topic_row["id"]}/comments.json",
|
|
"comments",
|
|
) do |row|
|
|
if row["vote_count"] > 0
|
|
like_user_ids =
|
|
fetch_likes(
|
|
"/api/v2/community/posts/#{topic_row["id"]}/comments/#{row["id"]}/votes.json",
|
|
)
|
|
end
|
|
|
|
@db.insert_post(
|
|
id: row["id"],
|
|
raw: row["body"],
|
|
topic_id: topic_row["id"],
|
|
user_id: row["author_id"],
|
|
created_at: row["created_at"],
|
|
url: row["html_url"],
|
|
like_user_ids: like_user_ids,
|
|
)
|
|
end
|
|
|
|
current_count += 1
|
|
print_status(current_count, total_count, start_time)
|
|
end
|
|
end
|
|
end
|
|
|
|
def fetch_users
|
|
puts "", "fetching users..."
|
|
|
|
user_ids = @db.execute_sql(<<~SQL).map { |row| row["user_id"] }
|
|
SELECT user_id FROM topic
|
|
UNION
|
|
SELECT user_id FROM post
|
|
UNION
|
|
SELECT user_id FROM like
|
|
SQL
|
|
|
|
current_count = 0
|
|
total_count = user_ids.size
|
|
start_time = Time.now
|
|
|
|
while !user_ids.empty?
|
|
get_from_api(
|
|
"/api/v2/users/show_many.json?ids=#{user_ids.shift(50).join(",")}",
|
|
"users",
|
|
) do |row|
|
|
@db.insert_user(
|
|
id: row["id"],
|
|
email: row["email"],
|
|
name: row["name"],
|
|
created_at: row["created_at"],
|
|
last_seen_at: row["last_login_at"],
|
|
active: row["active"],
|
|
avatar_path: row["photo"].present? ? row["photo"]["content_url"] : nil,
|
|
)
|
|
|
|
current_count += 1
|
|
print_status(current_count, total_count, start_time)
|
|
end
|
|
end
|
|
end
|
|
|
|
def fetch_likes(url)
|
|
user_ids = []
|
|
|
|
get_from_api(url, "votes") do |row|
|
|
user_ids << row["user_id"] if row["id"].present? && row["value"] == 1
|
|
end
|
|
|
|
user_ids
|
|
end
|
|
|
|
def import_categories
|
|
puts "", "creating categories"
|
|
rows = @db.fetch_categories
|
|
|
|
create_categories(rows) do |row|
|
|
{
|
|
id: row["id"],
|
|
name: row["name"],
|
|
description: row["description"],
|
|
position: row["position"],
|
|
post_create_action:
|
|
proc do |category|
|
|
url = remove_domain(row["url"])
|
|
Permalink.create(url: url, category_id: category.id) unless permalink_exists?(url)
|
|
end,
|
|
}
|
|
end
|
|
end
|
|
|
|
def import_users
|
|
puts "", "creating users"
|
|
total_count = @db.count_users
|
|
last_id = ""
|
|
|
|
batches do |offset|
|
|
rows, last_id = @db.fetch_users(last_id)
|
|
break if rows.empty?
|
|
|
|
next if all_records_exist?(:users, rows.map { |row| row["id"] })
|
|
|
|
create_users(rows, total: total_count, offset: offset) do |row|
|
|
{
|
|
id: row["id"],
|
|
email: row["email"],
|
|
name: row["name"],
|
|
created_at: row["created_at"],
|
|
last_seen_at: row["last_seen_at"],
|
|
active: row["active"] == 1,
|
|
post_create_action:
|
|
proc do |user|
|
|
if row["avatar_path"].present?
|
|
begin
|
|
UserAvatar.import_url_for_user(row["avatar_path"], user)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
end
|
|
end,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
def import_topics
|
|
puts "", "creating topics"
|
|
total_count = @db.count_topics
|
|
last_id = ""
|
|
|
|
batches do |offset|
|
|
rows, last_id = @db.fetch_topics(last_id)
|
|
break if rows.empty?
|
|
|
|
next if all_records_exist?(:posts, rows.map { |row| import_topic_id(row["id"]) })
|
|
|
|
create_posts(rows, total: total_count, offset: offset) do |row|
|
|
{
|
|
id: import_topic_id(row["id"]),
|
|
title: row["title"].present? ? row["title"].strip[0...255] : "Topic title missing",
|
|
raw:
|
|
normalize_raw(
|
|
row["raw"],
|
|
user_id_from_imported_user_id(row["user_id"]) || Discourse.system_user.id,
|
|
),
|
|
category: category_id_from_imported_category_id(row["category_id"]),
|
|
user_id: user_id_from_imported_user_id(row["user_id"]) || Discourse.system_user.id,
|
|
created_at: row["created_at"],
|
|
closed: row["closed"] == 1,
|
|
post_create_action:
|
|
proc do |post|
|
|
url = remove_domain(row["url"])
|
|
Permalink.create(url: url, topic_id: post.topic.id) unless permalink_exists?(url)
|
|
end,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
def import_topic_id(topic_id)
|
|
"T#{topic_id}"
|
|
end
|
|
|
|
def import_posts
|
|
puts "", "creating posts"
|
|
total_count = @db.count_posts
|
|
last_row_id = 0
|
|
|
|
batches do |offset|
|
|
rows, last_row_id = @db.fetch_sorted_posts(last_row_id)
|
|
break if rows.empty?
|
|
|
|
create_posts(rows, total: total_count, offset: offset) do |row|
|
|
topic = topic_lookup_from_imported_post_id(import_topic_id(row["topic_id"]))
|
|
|
|
if topic.nil?
|
|
p "MISSING TOPIC #{row["topic_id"]}"
|
|
p row
|
|
next
|
|
end
|
|
|
|
{
|
|
id: row["id"],
|
|
raw:
|
|
normalize_raw(
|
|
row["raw"],
|
|
user_id_from_imported_user_id(row["user_id"]) || Discourse.system_user.id,
|
|
),
|
|
user_id: user_id_from_imported_user_id(row["user_id"]) || Discourse.system_user.id,
|
|
topic_id: topic[:topic_id],
|
|
created_at: row["created_at"],
|
|
post_create_action:
|
|
proc do |post|
|
|
url = remove_domain(row["url"])
|
|
Permalink.create(url: url, post_id: post.id) unless permalink_exists?(url)
|
|
end,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
def import_likes
|
|
puts "", "importing likes..."
|
|
start_time = Time.now
|
|
current_count = 0
|
|
total_count = @db.count_likes
|
|
last_row_id = 0
|
|
|
|
batches do |offset|
|
|
rows, last_row_id = @db.fetch_likes(last_row_id)
|
|
break if rows.empty?
|
|
|
|
rows.each do |row|
|
|
import_id = row["topic_id"] ? import_topic_id(row["topic_id"]) : row["post_id"]
|
|
post = Post.find_by(id: post_id_from_imported_post_id(import_id)) if import_id
|
|
user = User.find_by(id: user_id_from_imported_user_id(row["user_id"]))
|
|
|
|
if post && user
|
|
begin
|
|
PostActionCreator.like(user, post) if user && post
|
|
rescue => e
|
|
puts "error acting on post #{e}"
|
|
end
|
|
else
|
|
puts "Skipping Like from #{row["user_id"]} on topic #{row["topic_id"]} / post #{row["post_id"]}"
|
|
end
|
|
|
|
current_count += 1
|
|
print_status(current_count, total_count, start_time)
|
|
end
|
|
end
|
|
end
|
|
|
|
def normalize_raw(raw, user_id)
|
|
return "<missing>" if raw.blank?
|
|
|
|
raw = raw.gsub('\n', "")
|
|
raw = ReverseMarkdown.convert(raw)
|
|
|
|
# Process images, after the ReverseMarkdown they look like
|
|
# ![](https://<sub-domain>.zendesk.com/<hash>.<image-format>)
|
|
raw.gsub!(%r{!\[\]\((https://#{SUBDOMAIN}\.zendesk\.com/hc/user_images/([^).]+\.[^)]+))\)}i) do
|
|
image_url = $1
|
|
filename = $2
|
|
attempts = 0
|
|
|
|
begin
|
|
URI
|
|
.parse(image_url)
|
|
.open do |image|
|
|
# IMAGE_DOWNLOAD_PATH is whatever image, it will be replaced with the downloaded image
|
|
File.open(IMAGE_DOWNLOAD_PATH, "wb") { |file| file.write(image.read) }
|
|
end
|
|
rescue *HTTP_ERRORS => e
|
|
if attempts < MAX_RETRIES
|
|
attempts += 1
|
|
sleep(2)
|
|
retry
|
|
else
|
|
puts "Error downloading image"
|
|
end
|
|
next
|
|
end
|
|
|
|
upl_obj = create_upload(user_id, IMAGE_DOWNLOAD_PATH, filename)
|
|
|
|
if upl_obj&.persisted?
|
|
html = html_for_upload(upl_obj, filename)
|
|
html
|
|
else
|
|
puts "Error creating image upload"
|
|
exit
|
|
end
|
|
end
|
|
|
|
raw
|
|
end
|
|
|
|
def remove_domain(url)
|
|
url.sub(@source_url, "")
|
|
end
|
|
|
|
def permalink_exists?(url)
|
|
Permalink.find_by(url: url)
|
|
end
|
|
|
|
def connection
|
|
@_connection ||=
|
|
begin
|
|
connect_uri = URI.parse(@source_url)
|
|
|
|
http = Net::HTTP.new(connect_uri.host, connect_uri.port)
|
|
http.open_timeout = 30
|
|
http.read_timeout = 30
|
|
http.use_ssl = connect_uri.scheme == "https"
|
|
|
|
http
|
|
end
|
|
end
|
|
|
|
def authorization
|
|
@_authorization ||=
|
|
begin
|
|
auth_str = "#{@auth_email}/token:#{@auth_token}"
|
|
"Basic #{Base64.strict_encode64(auth_str)}"
|
|
end
|
|
end
|
|
|
|
def get_from_api(path, array_name, show_status: false)
|
|
url = "#{@source_url}#{path}"
|
|
start_time = Time.now
|
|
|
|
while url
|
|
get = Net::HTTP::Get.new(url)
|
|
get["User-Agent"] = "Discourse Zendesk Importer"
|
|
get["Authorization"] = authorization
|
|
|
|
retry_count = 0
|
|
|
|
begin
|
|
while retry_count < 5
|
|
begin
|
|
response = connection.request(get)
|
|
puts("Retry successful!") if retry_count > 0
|
|
break
|
|
rescue => e
|
|
puts "Request failed #{url}. Waiting and will retry. #{e.class.name} #{e.message}"
|
|
sleep(20)
|
|
retry_count += 1
|
|
end
|
|
end
|
|
end
|
|
|
|
json = JSON.parse(response.body)
|
|
|
|
json[array_name].each { |row| yield row }
|
|
|
|
url = json["next_page"]
|
|
|
|
if show_status
|
|
if json["page"] && json["page_count"]
|
|
print_status(json["page"], json["page_count"], start_time)
|
|
else
|
|
print "."
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
unless ARGV.length == 4 && Dir.exist?(ARGV[1])
|
|
puts "",
|
|
"Usage:",
|
|
"",
|
|
"bundle exec ruby script/import_scripts/zendesk_api.rb SOURCE_URL DIRNAME AUTH_EMAIL AUTH_TOKEN",
|
|
""
|
|
exit 1
|
|
end
|
|
|
|
ImportScripts::ZendeskApi.new(ARGV[0], ARGV[1], ARGV[2], ARGV[3]).perform
|