この記事は Swift 愛好会 Advent Calendar 2017 20 日目の記事です。
今期、「ブレンド S」が(まあまあ)人気ます。
特に海外では OP のミームとして、「S」を差し替える動画や GIF アニメーションが大変流行ってます。
知らない方はまず通常の OP をご覧ください:
https://www.youtube.com/watch?v=LOajYHKEHG8
次に、たくさんの「"S" stands for??」ミームをご覧ください:
https://www.youtube.com/watch?v=6aI92gujgCE
https://www.youtube.com/results?search_query=blend+s+op+meme
というわけで、今回はこんなプログラムを作ります:
- ターミナル開く
-
blend-s
を叩く -
Smile
Sweet
Sister
Sadistic
Surprise
Service
の順に単語が表示されます - Wikipedia から適当な S から始まる単語を引っ張ってきて表示したあと、その記事をブラウザーで開きます
- 以上
作り方は簡単です。まず要件としては以下のものが必要です:
- ターミナルで動くプログラムの作成
-
S
で始まる単語たちを表示する仕組み - Wikipedia から
S
で始まる記事を取得する仕組み - リンクをブラウザーで開いてもらう仕組み
簡単ですね。では、一つずつ解説していきます
ターミナルで動くプログラムの作成
これはいうまでもなく、Xcode 開いて、新規作成から macOS の項目行ってターミナルプログラムを選びます。言語は当然 Swift です
S
で始まる単語たちを表示する仕組み
これも簡単ですね、文字列の配列を作ってそれを順番に表示すればいいです。ただし、最初の 6 単語は決まってますが、最後の単語がどれになるのかはわかりませんので、それをもらう仕組みを用意する必要があります。作り方は自由ですが、筆者個人的には struct
作って Sequence
に対応させる作り方が好きです。その方が自由に init
や他の仕組みを簡単に作れるし、Sequence
にさえ対応していれば for
ループも使えます。というわけで、Ses
を作ります:
import Foundation
// 最初の 6 単語
private let pres: [String] = [
"Smile",
"Sweet",
"Sister",
"Sadistic",
"Surprise",
"Service"
]
struct Ses {
// 中身を隠蔽します
fileprivate let ses: [String]
}
extension Ses {
// 外から一つ単語もらって、それを最初の 6 単語と一緒に自分の配列の中に打ち込みます
init(additionalS: String) {
let ses = pres + [additionalS]
self.ses = ses
}
}
extension Ses: Sequence {
// `Sequence` に対応させます。
// `Sequence` の対応はとても簡単で、`Element` の型と、戻り値が `IteratorProtocol` に対応した `makeIterator()` を作れば OK です。
// `Element` の型は `IteratorProtocol` の `associatedType` でも使われますので、`makeIterator()` だけ定義すれば済みます
func makeIterator() -> Array<String>.Iterator {
return self.ses.makeIterator()
}
}
これで、let ses = Ses(additionalS: "Something"); for s in ses { print(s) }
が使えるようになります。ね、簡単でしょ?
Wikipedia から S
で始まる記事を取得する仕組み
これを達成するには 2 つのミッションがあります:Wikipedia の記事取得 API と、その API からデータを落とすものです
Wikipedia の API についてはここに公式解説があります:
https://www.mediawiki.org/wiki/API:Main_page
そして、ここで実際使われるものは下記のようなアドレスです:
https://en.wikipedia.org/w/api.php?format=json&action=query&list=random&rnnamespace=0&rnlimit=max
Wikipedia の API に SELECT
みたいなのがないので、とりあえずなるべくいっぱい記事とってきてローカルで選別することにします。上記の URL は Wikipedia の英語版からランダム(list=random
)な記事(rnnamespace=0
)を取れるだけとって(rnlimit=max
、実際運用は 500 記事まで取れる)、データは JSON でもらう(format=json
)API です。記事取得したら取得した記事から条件に合う最初の記事を見つけ出して返し、なければもう一回同じ API で再度取得を試みます。
とってきたものは JSON データですので、ここは Swift 4 の Codable
の威力の発揮どころです。まず取れたデータは下記のようなフォーマットです
{
"batchcomplete": "",
"continue": {
"rncontinue": "0.857078833517|0.857169334638|43179513|0",
"continue": "-||"
},
"limits": {
"random": 500
},
"query": {
"random": [
{
"id": 3837267,
"ns": 0,
"title": "Ashland, West Virginia"
},
{
"id": 38954445,
"ns": 0,
"title": "Shudu Lake"
},
// ...
]
}
}
この中に、我々が実際必要なのは query
内の random
内の要素の id
と title
だけです。というわけでこれらを取れる Decodable
を定義します:
private struct WikipediaData: Decodable {
// JSON データの "query"
let query: Query
}
private struct Query: Decodable {
// JSON データ "query" の中の "random"
let random: [Article]
}
struct Article: Decodable {
// JSON データ "query" 内の "random" 配列の中の実際のデータ
// "ns" は要らないのでここでプロパティーも作る必要がない
// このデータだけ他のパーツに渡したいので `private` で宣言しない
let id: Int
let title: String
}
こうすれば JSON データから必要なものがこのように作れます:
// `data` は `URLSessionDataTask` から取得した生データです
let wikipediaData = try JSONDecoder().decode(WikipediaData.self, from: data)
URLSessionDataTask
の作成について、ぶっちゃけこのまま書くの大変だし読みづらいので、Eltaso から Downloader のソースをパクってきます:
private class Downloader {
// リザルトを `.success` と `.failure` で分けます
enum Result {
enum Error: Swift.Error {
case taskError(Swift.Error)
case contentError(Swift.Error)
case invalidURL(url: URL)
}
case success(data: Data, response: URLResponse)
case failure(error: Error)
}
private init() {
}
static let shared = Downloader()
// 落としたデータを `completionHandler` で処理してもらいます
func downloadData(from url: URL, completionHandler: @escaping (_ result: Result) -> Void) {
let session = URLSession.shared
let task = session.downloadTask(with: url, completionHandler: { (localURL, response, error) in
guard let localURL = localURL, let response = response else {
if let error = error {
completionHandler(.failure(error: .taskError(error)))
} else {
completionHandler(.failure(error: .invalidURL(url: url)))
}
return
}
do {
let data = try Data(contentsOf: localURL)
completionHandler(.success(data: data, response: response))
} catch let error {
completionHandler(.failure(error: .contentError(error)))
}
})
task.resume()
}
}
これで Downloader.shared.downloadData(from: url) { result in /* ... */ }
で使えますのでかなり便利です。
さて、データは落としてきたが、選別がまだできていません。というわけで選別の処理を実装します。落としてきたデータに random
という Article
の配列があるので、この配列の中から適切な Article
を選んで返し、なければ再帰的にもう一回データダウンロードするだけで OK なので、このように処理書けます:
class WikipediaRandomArticalRetriever {
// 落としたデータを `Article` として処理してもらいます
enum DownloadResult {
case success(article: Article)
case failure(error: Swift.Error)
}
// Wikipedia からランダムな記事を取得して、`predicate` 条件に合う最初の項目を見つけ出して `completion` で処理してもらいmす
func getArticle(first predicate: @escaping (Article) throws -> Bool, completion: @escaping (DownloadResult) -> Void) {
self.downloader.downloadData(from: self.retrievingURL) { (result) in
switch result {
case .success(data: let data, response: _):
// Wikipedia から無事データの取得が成功したらそのデータをそのまま下の方の `getArticle` に渡します
self.getArticle(first: predicate, from: data, completion: completion)
case .failure(error: let error):
completion(.failure(error: error))
}
}
}
private func getArticle(first predicate: @escaping (Article) throws -> Bool, from data: Data, completion: @escaping (DownloadResult) -> Void) {
do {
取得した生 JSON データを `WikipediaData` に変換します
let wikipediaData = try JSONDecoder().decode(WikipediaData.self, from: data)
// 落とした "random" 配列から `predicate` 条件に合うな最初の項目を取得します
guard let article = try wikipediaData.article(first: predicate) else {
// もし条件に合う項目がなかったらもう一回上の `getArticle` を呼び出します
self.getArticle(first: predicate, completion: completion)
return
}
// 取得した項目は `Article` として `completion` に処理してもらいます
completion(.success(article: article))
} catch let error {
completion(.failure(error: error))
}
}
}
extension WikipediaData {
func article(first predicate: (Article) throws -> Bool) rethrows -> Article? {
// 配列の中から一番最初に `predicate` 条件に合う項目を返します
let article = try self.query.random.first(where: predicate)
return article
}
}
これで、選別要件を predicate
として渡せば、条件にあった Article
が取れるようになります。ここでは敢えていきなり S で始まる記事を探さずに汎用な API として公開してますので、他の用途でも使えるから便利です。ちなみに S で始まる記事を探すのは単純に predicate
を { article in return article.title.hasPrefix("S") }
にするだけで取れます。最後合体するときにまたこれが登場します。
リンクをブラウザーで開いてもらう仕組み
開くこと自体は意外と単純です。NSWorkspace
というものがあるので、こいつの open
メソッドに URL
渡せば、システムのデフォルトのブラウザー(多分)がそれを開いてくれます。
面倒なのはむしろ URL
の生成です。直接文字列から URL
を生成する場合、URL クエリがある(アドレスに ?
や &
がある)場合は URL
がこれらをエスケープしてくれちゃうので正しいアドレスになりません。というわけでここはむしろ URLComponents
を使って URL
を生成します。ちなみに id
からアクセスできるアドレスは ?curid=id
のクエリで作れます。
// NSWorkspace を使うので、Cocoa の import が必要です
import Cocoa
// ベースの URL 文字列
private let openingURLString = "http://en.wikipedia.org"
class WikipediaArticleOpener {
private let openingURL: URL
init() {
guard let url = URL(string: openingURLString) else {
fatalError("Preset URL String invalid: \(openingURLString)")
}
self.openingURL = url
}
// `id` をもらってページを開きます
func openArticle(id: Int) {
// もらった `id` から `URLQueryItem` を作ります
let idQueryItem = URLQueryItem(name: "curid", value: id.description)
// `URLQueryItem` を `URL` に追加して実際の開きたい `URL` を作ります
let openingURL = self.openingURL.appendingQueryItem(idQueryItem)
// 新しい `URL` を `NSWorkspace` で開きます
NSWorkspace.shared.open(openingURL)
}
}
private extension URL {
// `URLQueryItem` を追加して新しい `URL` を作ります
func appendingQueryItem(_ item: URLQueryItem) -> URL {
// 自分自身から `URLComponents` を作ります
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
assertionFailure("Failed to make URLComponents from URL: \(self)")
return self
}
// 作った `URLComponents` にもらった `URLQueryItem` を追加します
components.append(item)
// `URLQueryItem` を追加した新しい `URLComponents` から新しい `URL` を作ります
guard let url = components.url else {
assertionFailure("Failed to add URLQueryItem URL to URL: \(self)")
return self
}
return url
}
}
private extension URLComponents {
// `URLComponents` に `URLQueryItem` を追加する仕組みを作ります
mutating func append(_ item: URLQueryItem) {
var queryItems = self.queryItems ?? []
queryItems.append(item)
self.queryItems = queryItems
}
}
合体
さて、必要なものは全て作りました。あとはこれらを合体です。
まずは Wikipedia から S で始まる記事を探して、記事の title
を Ses
に、記事の id
を WikipediaArticleOpener
に渡します。ダウンロードはバックグラウンドスレッドで行うので、メインスレッドが走り終わったときにプログラムを終了しないようにメインスレッドで最後 dispatchMain()
を一回呼び出す必要があります。そして本当に処理が終わったときに exit(Int)
を呼び出す必要があります。正常に終了したら 0
、異常があれば 0
以外の数字を引数に使うのが作法です。ちなみに print
の時間間隔が 0.77 秒にしているのは、筆者が実際プログラム走らせながら OP 動画を見て大体これくらいが一番タイミングぴったり合う時間だからです。
// 取得した記事データのリザルトの型を作ります
enum Result {
case success(Article)
case failure(Error)
}
// Wikipedia からランダムな記事を取得して `completion` に処理してもらいます
private func getArticleStartsWithS(completion: ((_ result: Result) -> Void)? = nil) {
// `WikipediaRandomArticalRetriever` を使ってタイトルが "S" で始まる最初の記事を探します
WikipediaRandomArticalRetriever().getArticle(first: { $0.title.hasPrefix("S") }) { (result) in
switch result {
case .success(article: let article):
// 成功したら `completion` で処理してもらいます
completion?(.success(article))
case .failure(error: let error):
completion?(.failure(error))
}
}
}
// "S" で始まる単語を出力します
private func printSes(with title: String) {
// 取得した `title` で `Ses` を作ります
let ses = Ses(additionalS: title)
for s in ses {
// 0.77 秒ごとに単語を出力します
print(s)
Thread.sleep(forTimeInterval: 0.77)
}
}
// Wikipedia から "S" で始まる記事を探します
getArticleStartsWithS { result in
switch result {
case .success(let article):
// "S" で始まる記事が見つかったらそれを出力してブラウザーに開いてもらいます
printSes(with: article.title)
// 全部出力が終わったら `Article` の `id` を `WikipediaArticleOpener` に渡してページを開いてもらいます
WikipediaArticleOpener().openArticle(id: article.id)
// 正常終了します
exit(0)
case .failure(let error):
print(error)
exit(1)
}
}
// 非同期処理でプログラムが自動で終了させないために `dispatchMain()` を使います
dispatchMain()
ここまでできたらもう完成です。あとは実際プログラムを作って環境パスにバイナリを放り投げれば使えます。Xcode の Archive 使ってもいいですし、個人的には Terminal で .xcodeproj が入ってるディレクトリーまで cd
して xcodebuild
で作るのが好きです。そうするとできたものは build
フォルダーの中にできます。
ちなみにプロジェクト自体は GitHub で公開してますのでよろしければぜひ落として遊んでみてください。バイナリー作るのがだるいって人がもし入れば GitHub からも直接バイナリをダウンロードできます。