-
[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 실패했다는 메일이 옵니다.
그래서 스크립트를 변경하려 했지만 은근히 이 메일이 블로그를 더 써야겠다는 동기부여가 되더라구요 ㅋㅋㅋ
참고 부탁드리겠습니다!
읽어주셔서 감사합니다.
'스위프트' 카테고리의 다른 글
[iOS] ViewController Life Cycle (+ ViewIsAppearing) (0) 2023.09.02 [iOS] RxSwift를 이용하여 키보드 컨트롤하기 (NotificationCenter) (0) 2023.08.22 [iOS] Life Cycle (App, Scene 생명 주기) (0) 2023.07.30 [iOS] UITextField를 RxDelegateProxy를 이용하여 사용해보자 (0) 2023.07.29 RxSwift KeyBoard (RxKeyBoard) 간단한 사용법 (0) 2021.10.07