🔖

新しいMac版Kindleで書籍情報を取得する(Swift)

2023/10/02に公開

はじめに

以前の Mac 版 Kindle アプリは下記の XML ファイルから書籍情報を取得できましたが新しい Kindle アプリでは XML ファイルが見当たらなかったのでどこかに書籍情報がないか探してみました。

https://apps.apple.com/jp/app/kindle-classic/id405399194

/Library/Containers/com.amazon.Kindle/Data/Library/Application Support/Kindle/Cache/KindleSyncMetadataCache.xml

探してみたところ下記の sqlite ファイルから書籍情報が取得できそうでした。

https://apps.apple.com/jp/app/kindle/id302584613

/Library/Containers/com.amazon.Lassen/Data/Library/Protected/BookData.sqlite

ここから漫画だけ抽出して iOS アプリで一覧表示してみました。

kindle

環境

  • macOS 13.5.2
  • Kindle 6.85.2
  • Xcode 15
  • iOS 17

書籍情報

BookData.sqlite には下記の 13 このテーブルがありました。

tables

それぞれ何が入ってるのかよくわかりませんでしたが ZBOOK テーブルに書籍情報がありそうでした。ZBOOK テーブルのデータは下記です。

cid name type
0 Z_PK INTEGER
1 Z_ENT INTEGER
2 Z_OPT INTEGER
3 ZISHIDDENBYUSER INTEGER
4 ZRAWAUTOSHELVE INTEGER
5 ZRAWBOOKSTATE INTEGER
6 ZRAWBOOKTYPE INTEGER
7 ZRAWBOOKUPGRADESNEEDED INTEGER
8 ZRAWCURRENTPOSITION INTEGER
9 ZRAWERL INTEGER
10 ZRAWFILESIZE INTEGER
11 ZRAWHASCOMPANION INTEGER
12 ZRAWHASFIXEDMOPHIGHLIGHTS INTEGER
13 ZRAWINBOOKCOVERCHECKED INTEGER
14 ZRAWISARCHIVABLE INTEGER
15 ZRAWISDICTIONARY INTEGER
16 ZRAWISENCRYPTED INTEGER
17 ZRAWISHIDDEN INTEGER
18 ZRAWISKEPT INTEGER
19 ZRAWISMULTIMEDIA INTEGER
20 ZRAWISPENDINGVERIFICATION INTEGER
21 ZRAWISSMDCREATEDDICTIONARY INTEGER
22 ZRAWISTRANSLATIONDICTIONARY INTEGER
23 ZRAWISUNREAD INTEGER
24 ZRAWLASTACCESSTIME INTEGER
25 ZRAWLASTOPENSUCCEEDED INTEGER
26 ZRAWLASTVIEW INTEGER
27 ZRAWMAXLOCATION INTEGER
28 ZRAWMAXPOSITION INTEGER
29 ZRAWNCXINFOSTORED INTEGER
30 ZRAWPROGRESSDOTCATEGORY INTEGER
31 ZRAWPUBLICATIONDATE INTEGER
32 ZRAWREADSTATE INTEGER
33 ZRAWREADSTATEORIGIN INTEGER
34 ZRAWREADINGMODE INTEGER
35 ZRAWUSERVISIBLELABELING INTEGER
36 ZCOMPANION INTEGER
37 ZALTERNATESORTTITLE VARCHAR
38 ZBOOKID VARCHAR
39 ZBUNDLEPATH VARCHAR
40 ZCONTENTTAGS VARCHAR
41 ZDICTIONARYLOCALEID VARCHAR
42 ZDICTIONARYSHORTTITLE VARCHAR
43 ZDICTIONARYSOURCELANG VARCHAR
44 ZDISPLAYTITLE VARCHAR
45 ZGROUPID VARCHAR
46 ZLANGUAGE VARCHAR
47 ZLONGCURRENTPOSITION VARCHAR
48 ZLONGMAXPOSITION VARCHAR
49 ZMIMETYPE VARCHAR
50 ZPARENTASIN VARCHAR
51 ZPATH VARCHAR
52 ZPERASINGUID VARCHAR
53 ZRAWPUBLISHER VARCHAR
54 ZSHELF VARCHAR
55 ZSORTTITLE VARCHAR
56 ZWATERMARK VARCHAR
57 ZALTERNATESORTAUTHOR BLOB
58 ZDISPLAYAUTHOR BLOB
59 ZEXTENDEDMETADATA BLOB
60 ZORIGINS BLOB
61 ZSORTAUTHOR BLOB
62 ZSYNCMETADATAATTRIBUTES BLOB
63 ZRAWTITLEDETAILSJSON BLOB

アプリ作成

どのデータがなにかわからないので必要なものだけ抽出します。おそらくそうだろうと思われる下記を抽出して一覧表示してみます。

  • Z_PK:ID
  • ZRAWPUBLICATIONDATE:発売日
  • ZBOOKID:ASIN
  • ZCONTENTTAGS:カテゴリー
  • ZDISPLAYTITLE:タイトル
  • ZRAWPUBLISHER:出版社
  • ZSORTTITLE:タイトルカナ

DB作成

そのままでもいいのですが扱いやすいように SwiftData を使って DB を作成します。

BookData.sqlite から上記データを下記の Book に変換します。

import Foundation
import SwiftData

@Model
final class Book {

    @Attribute(.unique) var id: Int
    var asin: String
    var title: String
    var titlePronunciation: String
    var publicationDate: Date?
    var contentTags: String
    var publisher: String

    init(id: Int,
         asin: String,
         title: String,
         titlePronunciation: String,
         publicationDate: Date?,
         contentTags: String,
         publisher: String) {
        self.id = id
        self.asin = asin
        self.title = title
        self.titlePronunciation = titlePronunciation
        self.publicationDate = publicationDate
        self.contentTags = contentTags
        self.publisher = publisher
    }
}

BookData.sqlite をプロジェクトファイルに追加して下記のように SQLite3 を使ってデータを読み込みます。

import Foundation
import SQLite3

final class Database {

    struct MetaData {
        let id: Int
        let publicationDate: Date?
        let asin: String
        var contentTags: String
        let title: String
        var publisher: String
        let titlePronunciation: String

        init(row: [Any?]) {
            self.id = (row[0] as? Int) ?? 0
            let time = (row[1] as? Int) ?? 0
            self.publicationDate = time == 0 ? nil : Date(timeIntervalSince1970: TimeInterval(time))
            self.asin = (row[2] as? String) ?? ""
            self.contentTags = (row[3] as? String) ?? ""
            self.title = (row[4] as? String) ?? ""
            self.publisher = (row[5] as? String) ?? ""
            self.titlePronunciation = (row[6] as? String) ?? ""
        }

        func convertedBook() -> Book {
            let asinValue = asin.replacingOccurrences(of: "A:", with: "")
            return .init(
                id: id,
                asin: asinValue.components(separatedBy: "-").first!,
                title: title, titlePronunciation: titlePronunciation,
                publicationDate: publicationDate, contentTags: contentTags,
                publisher: publisher
            )
        }
    }

    func fetchBooks() -> [Book] {
        var db: OpaquePointer?
        defer {
            sqlite3_close(db)
        }

        let filePath = Bundle.main.path(forResource: "BookData", ofType: "sqlite")
        guard sqlite3_open(filePath, &db) == SQLITE_OK else {
            return []
        }

        var stmt: OpaquePointer?
        defer {
            sqlite3_finalize(stmt)
        }

        let sql = "SELECT Z_PK, ZRAWPUBLICATIONDATE, ZBOOKID, ZCONTENTTAGS, ZDISPLAYTITLE, ZRAWPUBLISHER, ZSORTTITLE FROM ZBOOK;"
        guard sqlite3_prepare_v2(db, (sql as NSString).utf8String, -1, &stmt, nil) == SQLITE_OK else {
            return []
        }

        var metaDataList = [MetaData]()
        while SQLITE_ROW == sqlite3_step(stmt) {
            metaDataList.append(.init(row: [
                _getIntValue(0), _getIntValue(1),
                _getStringValue(2), _getStringValue(3),
                _getStringValue(4), _getStringValue(5),
                _getStringValue(6)
            ]))
        }

        func _getIntValue(_ column: Int32) -> Int? {
            if sqlite3_column_type(stmt, column) == SQLITE_NULL {
                return nil
            } else {
                return Int(sqlite3_column_int(stmt, column))
            }
        }

        func _getStringValue(_ column: Int32) -> String? {
            if sqlite3_column_type(stmt, column) == SQLITE_NULL {
                return nil
            } else {
                return String(cString: sqlite3_column_text(stmt, column))
            }
        }

        return metaDataList.map { $0.convertedBook() }
    }
}

ポイントは日付の変換処理です。DB 内の日付は Unixtime になっているので下記のように Date(timeIntervalSince1970:) で変換します。

let time = (row[1] as? Int) ?? 0
self.publicationDate = time == 0 ? nil : Date(timeIntervalSince1970: TimeInterval(time))

表示

あとは漫画を抽出して一覧表示するだけです。漫画は ZCONTENTTAGS;MANGA と設定されているのでそのデータのみ抽出します。

import SwiftUI
import SwiftData

@main
struct KindleBooksApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: Book.self)
        }
    }
}

struct ContentView: View {

    @Environment(\.modelContext) private var context
    @Query(filter: #Predicate<Book>  { $0.contentTags == ";MANGA" }, sort: \Book.titlePronunciation)
    private var books: [Book]

    var body: some View {
        NavigationStack {
            VStack {
                if books.isEmpty {
                    addButton
                } else {
                    List {
                        ForEach(books, id: \.id) { book in
                            NavigationLink {
                                DetailView(book: book)
                            } label: {
                                VStack {
                                    Text(book.title)
                                }
                            }
                        }
                    }
                }
            }.navigationTitle("一覧")
        }
    }

    private var addButton: some View {
        return  Button {
            let books = Database().fetchBooks()
            books.forEach { context.insert($0) }
            do {
                try context.save()
            } catch let error {
                print(error)
            }
        } label: {
            Text("データ取得")
        }
    }
}

struct DetailView: View {

    let book: Book

    var dateFormat: DateFormatter = {
        let format = DateFormatter()
        format.dateFormat = "yyyy/MM/dd"
        return format
    }()

    var body: some View {
        VStack(spacing: 8) {
            Text("ID: \(book.id)")
            Text("ASIN: \(book.asin)")
            Text("タイトル: \(book.title)")
            Text("タイトルカナ: \(book.titlePronunciation)")
            if let date = book.publicationDate {
                Text("発売日: \(dateFormat.string(from: date))")
            }
            Text("タグ: \(book.contentTags)")
            Text("出版社: \(book.publisher)")
        }
        .padding(16)
        .navigationTitle("詳細")
    }
}

データ取得ボタン押下で書籍情報を作成し下記で漫画を抽出して五十音順にソートしています。

@Query(filter: #Predicate<Book>  { $0.contentTags == ";MANGA" }, sort: \Book.titlePronunciation)

これで完成🎉

おわりに

XML から sqlite に変わり色々な情報が取得できるようになりました!

ただ ZBOOK テーブルと Kindle アプリでは少しだけ表示データが違う気がするので他の場所にあるのかもしれないです(最新のやつは ZBOOK テーブルになさそうでした。。。)。

とりあえずはそれっぽいデータが取得できたのでよしとします。

参考サイト

Discussion