Swift：Qiitaに投稿してある記事を全てMarkDownファイルとしてダウンロードするスクリプト

今自分にとって，とても需要のあるスクリプトを書きました．

# 使い方

まずは下記のSwiftスクリプトを`qiita_gyotaku.swift`という名前で保存します．次にTerminalを開いて上記のスクリプトがあるディレクトリにて，

```terminal
$ swift qiita_gyotaku.swift [Qiitaユーザ名]
```
と叩けばそのディレクトリに`Articles`という名前のフォルダが生成され，その中に指定したユーザ名の全てのQiitaの記事がMarkDownとして保存されます．保存されるファイルの名前は記事の「タイトル名.md」です．画像を埋め込んでいる記事の場合、画像の引用がQiita頼り(もっといえばオンライン頼り)になってしまうので，画像も落としてきてローカルで読めるように書き換えています．

なお，macOS Mojaveにて動作確認をしています．

# スクリプトのソース

Gistにあげてあるので`clone`できますヽ(*ﾟдﾟ)ノ
https://gist.github.com/Kyome22/9525f59f3bdc8322cf18d0d1633575cb

```swift:qiita_gyotaku.swift
import Foundation

class Article {
    
    let title: String
    var body: String
    
    init(_ title: String, _ body: String) {
        self.title = title
        self.body = body
    }
    
    var fileName: String {
        var name = title
        while name.hasPrefix(".") {
            name.removeFirst()
        }
        name = name.replacingOccurrences(of: ":", with: "：")
        name = name.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
        return name + ".md"
    }
    
}

class Gyotaku {
    
    private static func createDirectory() {
        var dir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
        dir.appendPathComponent("Articles")
        if !FileManager.default.fileExists(atPath: dir.path) {
            try? FileManager.default.createDirectory(at: dir,
                                                     withIntermediateDirectories: true,
                                                     attributes: nil)
        }
        dir.appendPathComponent("Images")
        if !FileManager.default.fileExists(atPath: dir.path) {
            try? FileManager.default.createDirectory(at: dir,
                                                     withIntermediateDirectories: true,
                                                     attributes: nil)
        }
    }

    private static func getItemsCount(userName: String) -> (urlStr: String, itemsCount: Int)? {
        let semaphore = DispatchSemaphore(value: 0)
        let urlStr = "https://qiita.com/api/v2/users/\(userName)"
        guard let url = URL(string: urlStr) else { return nil }
        var itemsCount: Int = 0
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            defer { semaphore.signal() }
            if let error = error {
                Swift.print("Failur: \(error.localizedDescription)")
                return
            }
            guard
                let data = data,
                let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String : Any],
                let count = json["items_count"] as? Int
                else {
                return
            }
            itemsCount = count
        }
        task.resume()
        semaphore.wait()
        if itemsCount == 0 {
            Swift.print("Failur: \(userName) has no article.")
            return nil
        } else if itemsCount == 1 {
            Swift.print("\(userName) has \(itemsCount) article")
        } else {
            Swift.print("\(userName) has \(itemsCount) articles")
        }
        return (urlStr, itemsCount)
    }
    
    private static func getArticle(_ urlStr: String, _ page: Int) -> [Article] {
        let semaphore = DispatchSemaphore(value: 0)
        var articles = [Article]()
        guard var components = URLComponents(string: "\(urlStr)/items") else { return articles }
        components.queryItems = [
            URLQueryItem(name: "page", value: String(page)),
            URLQueryItem(name: "per_page", value: "100")
        ]
        let task = URLSession.shared.dataTask(with: components.url!) { (data, response, error) in
            defer { semaphore.signal() }
            if let error = error {
                Swift.print("Failur: \(error.localizedDescription)")
                return
            }
            guard
                let data = data,
                let items = try? JSONSerialization.jsonObject(with: data, options: []) as? NSArray else {
                    return
            }
            articles = items.compactMap { (item) -> Article? in
                guard
                    let obj = item as? [String : Any],
                    let title = obj["title"] as? String,
                    let body = obj["body"] as? String
                    else {
                        return nil
                }
                return Article(title, body)
            }
        }
        task.resume()
        semaphore.wait()
        return articles
    }
    
    static func renewalImages(body: inout String) {
        guard let regex = try? NSRegularExpression(pattern: "!\\[.+\\]\\((.+)\\)") else {
            return
        }
        let matches = regex.matches(in: body, range: NSRange(location: 0, length: body.count))
        matches.reversed().forEach { (result) in
            let nsbody = NSString(string: body)
            let sentence = nsbody.substring(with: result.range)
            let imageURL = nsbody.substring(with: result.range(at: 1))
            if Gyotaku.getImage(urlStr: imageURL) {
                let url = URL(string: imageURL)!
                let newImageURL = "./Images/\(url.lastPathComponent)"
                let newSentence = sentence.replacingOccurrences(of: imageURL, with: newImageURL)
                body = nsbody.replacingCharacters(in: result.range, with: newSentence)
            }
        }
    }
    
    static func getImage(urlStr: String) -> Bool {
        guard let url = URL(string: urlStr) else { return false }
        let semaphore = DispatchSemaphore(value: 0)
        var imageData: Data?
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            defer { semaphore.signal() }
            if let error = error {
                Swift.print("Failur: \(error.localizedDescription)")
                return
            }
            imageData = data
        }
        task.resume()
        semaphore.wait()
        guard let data = imageData else { return false }
        let saveUrl = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
            .appendingPathComponent("Articles", isDirectory: true)
            .appendingPathComponent("Images", isDirectory: true)
            .appendingPathComponent(url.lastPathComponent)
        guard let _ = try? data.write(to: saveUrl, options: []) else {
            return false
        }
        return true
    }
    
    private static func saveArticles(_ articles: [Article]) {
        let dir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
            .appendingPathComponent("Articles", isDirectory: true)
        articles.forEach { (article) in
            let saveURL = dir.appendingPathComponent(article.fileName)
            try? article.body.write(to: saveURL, atomically: true, encoding: .utf8)
        }
        Swift.print("Complete. Check Articles directory.")
    }
    
    static func main() {
        guard CommandLine.arguments.count == 2 else {
            Swift.print("$ swift qiita_gyotaku.swift [username]")
            return
        }
        guard let response = Gyotaku.getItemsCount(userName: CommandLine.arguments[1]) else {
            return
        }
        let articles = (0 ... Int(response.itemsCount / 100)).flatMap { (i) -> [Article] in
            return Gyotaku.getArticle(response.urlStr, i + 1)
        }
        Gyotaku.createDirectory()
        articles.forEach { (article) in
            Gyotaku.renewalImages(body: &article.body)
        }
        Gyotaku.saveArticles(articles)
    }
    
}

Gyotaku.main()
```