ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Swift] 티스토리 블로그를 자동으로 Github에 업데이트 (Git Actions)
    스위프트 2023. 8. 7. 18:22

    Overview


    Swift를 통해 Tistory 글을 긁어와 깃허브에 자동으로 업데이트해봅시다.

    아래는 이미지는 결과물입니다.

    따라 하기 귀찮다면 https://github.com/hogumachu/hogumachu 의 main.swift 파일과 /.github/workflows/main.yml 을 보고 수정하시면 됩니다.

    깃허브 블로그 포스트 업데이트된 결과

     

    RSS 긁어오기


    자신의 블로그 주소 + RSS를 입력하면 정보를 가져올 수 있습니다.

    저의 경우 https://hogumachu.tistory.com/rss 를 통해 값을 가져왔습니다.

    func load(url urlString: String) {
        guard let url = URL(string: urlString) else { return }
        URLSession.shared.dataTask(with: URLRequest(url: url)) { data, response, error in
            guard let data, let content = String(data: data, encoding: .utf8) else { return }
            print(content)
        }.resume()
    }

    파싱 하기


    RSS를 보시면 <item> Content </item>으로 아이템이 그룹 되어있고

    <title> Title </title> 형태로 타이틀과 <link> Link </link> 형태로 링크가 존재합니다.

    정규식을 이용하여 값을 가져옵니다.

    enum Regex {
        static let item = "<item>\\s*(.*?)\\s*</item>"
        static let title = "<title>(.*?)</title>"
        static let link = "<link>(.*?)</link>"
    }
    
    // RSS에서 Item을 모두 가져옴
    func generateItems(content: String) -> [String] {
        guard let regex = try? NSRegularExpression(pattern: Regex.item) else { return [] }
        let matches = regex.matches(in: content, range: NSRange(location: 0, length: content.count))
        return matches
            .compactMap { Range($0.range(at: 1), in: content) }
            .map { String(content[$0]) }
    }
    
    // Item에 있는 Title을 가져옴
    func generateTitle(content: String) -> String? {
        guard let regex = try? NSRegularExpression(pattern: Regex.title, options: []),
              let match = regex.firstMatch(in: content, options: [], range: NSRange(location: 0, length: content.count)),
              let range = Range(match.range(at: 1), in: content)
        else {
            return nil
        }
        let title = String(content[range])
        return title
    }
    
    // Item에 있는 Link를 가져옴
    func generateLink(content: String) -> String? {
        guard let regex = try? NSRegularExpression(pattern: Regex.link, options: []),
              let match = regex.firstMatch(in: content, options: [], range: NSRange(location: 0, length: content.count)),
              let range = Range(match.range(at: 1), in: content)
        else {
            return nil
        }
        let link = String(content[range])
        return link
    }

    Post 만들기


    필수는 아니지만 다루기 편할 것 같아서 Post 구조체를 하나 만들었습니다.

    마크다운에 값을 넣기 위해 따로 기능도 추가해 줬습니다.

    struct Post {
    
        let title: String
        let link: String
    
        init?(title: String?, link: String?) {
            guard let title, let link else { return nil }
            self.title = title
            self.link = link
        }
    
        func makeMarkdownContent() -> String {
            return "[\(title)](\(link))"
        }
    
    }

    포스트 만들기


    구현한 기능을 모두 합쳐 포스트 정보를 만들어줍니다.

    func makeMarkdownContents(content: String) -> [String] {
        return generateItems(content: content)
            .compactMap { Post(title: generateTitle(content: $0), link: generateLink(content: $0)) }
            .map { $0.makeMarkdownContent() }
    }

    기존에 있는 README 가져오기


    기존에 존재하는 README.md 파일을 가져와 변경합시다.

    enum Constants {
        
        static let blogTitle = "## Blog"
        static let readmeFileName = "README.md"
        
    }
    
    // 현재 swift파일과 README.md 파일이 동일한 경로에 존재
    let readmePath = URL(fileURLWithPath: #file)
            .deletingLastPathComponent()
            .appending(component: Constants.readmeFileName)
    
    let data = try! Data(contentsOf: readmePath)
    let content = {
        let content = String(data: data, encoding: .utf8)!
        if content.contains(Constants.blogTitle) {
            return content
                .components(separatedBy: Constants.blogTitle)
                .dropLast()
                .joined()
        }
        return content
    }()
    
    
    let postContent = contents.map { "* \($0)" }
        .joined(separator: "\n")
    
    let newContent = content + Constants.blogTitle + "\n" + postContent
    let newData = newContent.data(using: .utf8)!
    try! FileManager.default.removeItem(at: readmePath)
    FileManager.default.createFile(atPath: readmePath.path(), contents: newData)

    Git Actions 스크립트 작성


    스크립트를 작성해 줍시다.

    cron으로 하루에 한 번 자동으로 업데이트하도록 합시다.

    macOS의 버전은 13으로 해줍시다 (macOS 13 이상부터 FileManager의 특정 기능을 지원하므로 설정함)

    name: Blog Update
    
    on:
      schedule:
        - cron: '0 0 * * *'
    
    jobs:
      run_swift_script:
        runs-on: macos-13
        steps:
          - name: Checkout repository
            uses: actions/checkout@v3
    
          - name: Run Swift Script
            run: |
              swift main.swift
          - name: Push changes
            run: |
              git config --global user.name "Buildbot"
              git config --global user.email "buildbot@users.noreply.github.com"
              git add .
              git commit -m "[BOT] Update Post"
              git push

    마무리


    // main.swift
    
    import Foundation
    
    enum Constants {
        
        static let blogLink = "https://hogumachu.tistory.com/rss"
        static let blogTitle = "## Blog"
        static let maxPostCount = 6
        static let readmeFileName = "README.md"
        
    }
    
    struct Post {
    
        let title: String
        let link: String
    
        init?(title: String?, link: String?) {
            guard let title, let link else { return nil }
            self.title = title
            self.link = link
        }
    
        func makeMarkdownContent() -> String {
            return "[\(title)](\(link))"
        }
    
    }
    
    final class TistoryUpdater {
    
        private enum Regex {
            static let item = "<item>\\s*(.*?)\\s*</item>"
            static let title = "<title>(.*?)</title>"
            static let link = "<link>(.*?)</link>"
        }
    
        func load(url urlString: String, completion: (([String]) -> Void)? = nil) {
            guard let url = URL(string: urlString) else { return }
            URLSession.shared.dataTask(with: URLRequest(url: url)) { data, response, error in
                guard let data, let content = String(data: data, encoding: .utf8) else { return }
                let contents = self.makeMarkdownContents(content: content.replacingOccurrences(of: "\n", with: " "))
                completion?(contents)
            }.resume()
        }
    
        func makeMarkdownContents(content: String) -> [String] {
            return generateItems(content: content)
                .compactMap { Post(title: generateTitle(content: $0), link: generateLink(content: $0)) }
                .prefix(Constants.maxPostCount)
                .map { $0.makeMarkdownContent() }
        }
    
        func generateItems(content: String) -> [String] {
            guard let regex = try? NSRegularExpression(pattern: Regex.item) else { return [] }
            let matches = regex.matches(in: content, range: NSRange(location: 0, length: content.count))
            return matches
                .compactMap { Range($0.range(at: 1), in: content) }
                .map { String(content[$0]) }
        }
    
        private func generateTitle(content: String) -> String? {
            guard let regex = try? NSRegularExpression(pattern: Regex.title, options: []),
                  let match = regex.firstMatch(in: content, options: [], range: NSRange(location: 0, length: content.count)),
                  let range = Range(match.range(at: 1), in: content)
            else {
                return nil
            }
            let title = String(content[range])
            return title
        }
    
        private func generateLink(content: String) -> String? {
            guard let regex = try? NSRegularExpression(pattern: Regex.link, options: []),
                  let match = regex.firstMatch(in: content, options: [], range: NSRange(location: 0, length: content.count)),
                  let range = Range(match.range(at: 1), in: content)
            else {
                return nil
            }
            let link = String(content[range])
            return link
        }
    
    }
    
    let parser = TistoryUpdater()
    parser.load(url: Constants.blogLink) { contents in
        let readmePath = URL(fileURLWithPath: #file)
            .deletingLastPathComponent()
            .appending(component: Constants.readmeFileName)
    
        let data = try! Data(contentsOf: readmePath)
        let content = {
            let content = String(data: data, encoding: .utf8)!
            if content.contains(Constants.blogTitle) {
                return content
                    .components(separatedBy: Constants.blogTitle)
                    .dropLast()
                    .joined()
            }
            return content
        }()
        
        
        let postContent = contents.map { "* \($0)" }
            .joined(separator: "\n")
        
        let newContent = content + Constants.blogTitle + "\n" + postContent
        let newData = newContent.data(using: .utf8)!
        try! FileManager.default.removeItem(at: readmePath)
        FileManager.default.createFile(atPath: readmePath.path, contents: newData)
    }
    
    sleep(5)

    README.md 파일과 동일한 경로에 main.swift 파일이 존재해야 합니다. (FileManager의 Directory 변경을 통해 수정 가능)

     

    코드에 대한 모든 내용은 아래 링크에서 확인할 수 있습니다.

    https://github.com/hogumachu/hogumachu

     


    2023.08.10 추가 내용


    만약 블로그에 변경사항이 없으면 Github 측에서 Actions 실패했다는 메일이 옵니다.

    그래서 스크립트를 변경하려 했지만 은근히 이 메일이 블로그를 더 써야겠다는 동기부여가 되더라구요 ㅋㅋㅋ

    Github Actions 실패시 오는 메일

    참고 부탁드리겠습니다!

     

    읽어주셔서 감사합니다.

Designed by Tistory.