LoginSignup
5
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-03-25

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

使い方

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

$ swift qiita_gyotaku.swift [Qiitaユーザ名]

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

なお,macOS Mojaveにて動作確認をしています.

スクリプトのソース

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

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()
5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4