Skip to main content

zetapi

Zeta(ゼータ / ScatterLab)の非公式 Python API ラッパーだよ。 高レベル 129 メソッド / raw 332 エンドポイント — アプリで出来ることは、だいたい出来ると思う。

インストール

pip install zetapi

依存は requests だけ。軽いでしょ。Python は 3.10 以上。

インストール名と import 名は zetapi
from zetapi import ZetaClient

ローカルで弄りたい人はこっち:

pip install -e .

始める前に確認すること

これは非公式です

ScatterLab とは縁もゆかりもありません。中の人でもないです。 非公式 API を叩くのは規約的にたぶんアウトなので、自分のアカウントで、自己責任で遊んでね。 垢が凍っても「あーあ」って一緒に言うことしか出来ないよ。

トークンは保存して使い回して

毎回ログインし直すとセッションが積み上がってよくない気がする。 access / refresh / device_id の3点セットを保存して使い回して。それだけで寿命が伸びる。

device_id は固定して

省略すると毎回ランダム UUID が生えて、サーバーから見ると毎回新しい端末。 X-Sticky ヘッダと各種 API の deviceId に入る値なので、一度決めたらメモっとくのが吉。

実サーバーで通してあります(v0.2.0〜)

v0.1.0 は「バイトコードから読んだ仕様」だけで作っていて疎通未確認だった。 v0.2.0 で実際に叩いて確かめ、間違っていた3箇所を直した(何が変わったか)。

Let's Go!

example.py

from zetapi import ZetaClient

# ① いきなり動かしたいならこれ。ログイン不要で 1 行
zeta = ZetaClient.anonymous(language="ja")

# ② トークン持ってるならこっち
zeta = ZetaClient(
    access_token="...",
    refresh_token="...",
    device_id="...",      # 固定してね(省くと毎回別端末扱い)
    language="ja",        # ko / en も通る(JAPANESE 等に自動変換される)
)

# ③ 外部 IdP から本ログインすることも出来る
zeta = ZetaClient(device_id="...")
zeta.auth.login_external("GOOGLE", "IdPから貰ったcodeかIDトークン")
# issuer は GOOGLE / APPLE / KAKAO / LINE / FACEBOOK
print(zeta.access_token, zeta.refresh_token)   # 成功すると勝手に入ってる

# ─────────────────────────────────────────
# プロフィールとコイン
me = zeta.users.me()
print(me["username"], me["id"])

print(zeta.coin.balance())              # 残高
print(zeta.coin.daily_reward_left())    # デイリー報酬あと何回いける?
zeta.coin.claim_daily_reward()          # もらう

# ─────────────────────────────────────────
# プロット(キャラ / シナリオ)を探す
zeta.plots.search("猫", limit=10)
zeta.plots.ranking("DAILY")             # WEEKLY なども。省略時は DAILY
zeta.plots.get("PLOT_ID")
zeta.plots.similar("PLOT_ID")

zeta.plots.like("PLOT_ID")              # いいね
zeta.plots.scrap("PLOT_ID")             # お気に入り
zeta.plots.scrapped()                   # お気に入り一覧

# ─────────────────────────────────────────
# ルーム(=会話スレッド)
room = zeta.rooms.create("PLOT_ID")     # そのキャラと話し始める
room_id = room["id"]

zeta.rooms.list_v2(limit=20)            # ルーム一覧(アプリの現行版はこっち)
zeta.rooms.messages(room_id)            # 履歴
zeta.rooms.pin(room_id)                 # ピン留め
zeta.rooms.save(room_id)                # セーブ(ゲームのセーブデータ的なやつ)
zeta.rooms.load(room_id)                # ロード

# ─────────────────────────────────────────
# 本題:チャット。SSE なのでイベントが順に降ってくる
# 流れてくる文字をそのまま出したいなら iter_text(増えた分だけ来る)
for delta in zeta.chat.iter_text(zeta.chat.send(room_id, "こんにちは")):
    print(delta, end="", flush=True)

# 全部溜めて文字列で欲しいだけなら
text = zeta.chat.collect(zeta.chat.send(room_id, "元気?"))

# 生イベントが要るなら send() をそのまま回す
for event in zeta.chat.send(room_id, "やあ"):
    print(event["event"])       # IN_PROGRESS ... IN_PROGRESS, CHAT_COMPLETE

zeta.chat.regenerate(room_id, "MESSAGE_ID")   # 気に入らなかったら生成し直し
zeta.chat.options(room_id)                    # 選択肢(CYOA)を出す
zeta.chat.example_chat("PLOT_ID")             # ルーム無しで会話例だけ見る

# ─────────────────────────────────────────
# 作る側
zeta.creator.assistant_quota()          # 生成の残り回数。叩く前にこれ見て
zeta.creator.generate_characters(...)   # AI にキャラ考えさせる
zeta.creator.generate_plot(...)         # AI にプロット考えさせる
zeta.creator.dashboard()                # 自作プロットの成績表

zeta.plots.create(...)                  # 下書き作る
zeta.plots.update_status("PLOT_ID", ...)  # 公開する

zeta.lorebooks.create(...)              # 設定資料(ロアブック)
zeta.lorebooks.attach("PLOT_ID", "LOREBOOK_ID")   # プロットに差す

長いけど使い方はコメントに全部書いたから、これ読めばだいたい分かるはず。

全部叩く → zeta.raw

高レベルに無いエンドポイントは zeta.raw.<メソッド名> で 332 本ぜんぶ呼べる。 名前はアプリ内の getRoomApi みたいなやつを snake_case にしただけなので、素直。

zeta.raw.get_room(roomId="...")                       # パスパラメータはキーワードで
zeta.raw.list_notifications(params={"limit": 3})      # クエリは params=
zeta.raw.update_room(roomId="...", data={...})        # ボディは data=

zeta.raw.list_coin_transactions(params={"limit": 50})
zeta.raw.request_daily_quiz()
zeta.raw.list_contest_plots()

全メソッド一覧が欲しかったら:

python -c "from zetapi import ENDPOINTS; print('\n'.join(sorted(ENDPOINTS)))"

help(zeta.raw.get_room) すれば元のエンドポイントとレスポンス型が docstring に書いてある。迷ったら叩いて。

GET /v1/rooms/:roomId

JS 側の名前: getRoomApi
レスポンス型: RoomApiDto

もう少し詳しく

返り値は素の dict

正直に言うと、pypaypay みたいな .属性 アクセスの砂糖はまだ無い。 me["username"] って書いてね。レスポンスの型名(RoomApiDto とか)は分かってるけど 中身のフィールドまではバイトコードに残ってなかったので、変に型を偽装するよりは素で渡す方針にした。

401 は勝手に直る

refresh_token を渡しておくと 401 が来たら黙って更新して1回だけ叩き直す。 無限ループはしないので、リフレッシュも死んでたら諦めて ZetaAuthError を投げる。

zeta.auth.refresh()          # 手動で更新したい時はこれ
print(zeta.access_token)     # 更新後のが入ってる

クエリの直列化がちょっと変

アプリは axios の paramsSerializer に qs.stringify(p, {allowDots: true, arrayFormat: 'comma'}) を刺してる。つまり:

{"ids": [1, 2, 3]}      # → ids=1,2,3     (キー繰り返しじゃない)
{"a": {"b": {"c": 1}}}  # → a.b.c=1       (a[b][c] じゃない)
{"f": True}             # → f=true
{"a": None}             # → キーごと消える

ここを普通の urlencode でやると静かに違う結果になるので、zetapi/_qs.py で再現してある。

チャットが SSE

Accept: text/event-stream を投げて、レスポンスを \n\n で切って data: 行だけ JSON パースする、という素朴な作り(アプリも Web もそうしてる)。 チャンク境界がイベント境界とズレても大丈夫なようにバッファリングしてある。

イベントは 2 種類だけ。実際に流れてくるのはこういう形:

// 生成中(何十回も来る)
{"event":"IN_PROGRESS","chunkMessage":{"contents":[{"type":"TEXT","speakerName":"ナレーター","position":"NARRATOR","text":"*その声は、廃墟の奥から"}]},"index":null}

// 完了(最後に 1 回だけ)
{"event":"CHAT_COMPLETE","replyMessage":{"contents":[ ...話者ごとに分かれた最終形... ],"id":"MESSAGE-...","candidateId":"..."},"requestMessage":{...},"shouldGenerateConsecutive":false}

IN_PROGRESS の text は差分ではなく毎回「その時点の全文」。 素朴に繋ぐと同じ文が何重にもなるので、iter_text()(差分だけ)か collect()(最終形だけ)を使ってほしい。

最終的な返信は CHAT_COMPLETE の replyMessage.contents[] に、 キャラごとに speakerName / position(NARRATOR / LEFT など)付きで入っている。

言語コードに注意

X-User-Language に入るのは JAPANESE / KOREAN / ENGLISH。 ここに ja を入れると API が 500 を返す(400 じゃなくて 500 なので気付きにくい)。 language="ja" と書いても内部で JAPANESE に直すようにしてあるので普段は気にしなくていい。

from zetapi import normalize_language
normalize_language("ja")        # 'JAPANESE'
normalize_language("ENGLISH")   # 'ENGLISH'

設定いろいろ

zeta = ZetaClient(
    access_token="...",
    language="ja",              # X-User-Language(JAPANESE に正規化される)
    device_type="android",      # ios / web / pc_web にもできる
    client_type="app",          # web にもできる
    timeout=30.0,
    auto_refresh=True,          # False で 401 自動リトライを切る
    session=my_requests_session, # プロキシとか刺したい時はセッションごと差し替え
)

トークンの中身は API を叩かずに読める(JWT なので):

zeta.token_claims    # {'uid': ..., 'did': ..., 'ano': True, 'cty': 'JAPANESE', 'tz': ..., 'exp': ...}
zeta.user_id         # uid
zeta.is_anonymous    # ano

エラー

失敗したら例外が飛ぶ。種類で分けて捌けるやつ:

from zetapi import ZetaAuthError, ZetaRateLimitError, ZetaHTTPError

try:
    zeta.users.me()
except ZetaAuthError:
    print("トークン死んだ")
except ZetaRateLimitError:
    print("落ち着け")
except ZetaHTTPError as e:
    print("なんか失敗:", e.status, e.code, e.message, e.body)
ZetaError
├── ZetaHTTPError                  # 4xx / 5xx
│   ├── .status                    #   HTTP ステータス
│   ├── .code                      #   レスポンスの code
│   ├── .message                   #   レスポンスの message
│   ├── .body                      #   全文(困ったらこれを print)
│   ├── ZetaAuthError              # 401 / 403
│   └── ZetaRateLimitError         # 429
└── ZetaTokenError                 # トークン更新そのものが失敗

動作確認

オフライン(トークン不要)

py -3 tests/test_zetapi.py

76 項目、実サーバーには一切繋がない。偽セッションを刺して、 ヘッダ・URL 組み立て・クエリ直列化・401 リトライ・SSE のチャンク境界復元・ 名前空間が参照してる 82 個のエンドポイント名が実在するか、を見てる。

オンライン(トークン不要になりました)

# ① 匿名トークンを自動で取って疎通確認。何も用意しなくていい
py -3 tests/test_online.py

# ② 生きてる GET を数える(73 本を総当たり)
py -3 tests/test_online.py --sweep --json report.json

# ③ 自分のアカウントで試すなら従来どおりトークンを渡す
py -3 tests/test_online.py --token <アクセストークン> --sweep

# ④ ルーム作成 → 発言 まで通す
py -3 tests/test_online.py --plot <PLOT_ID> --write

既定は読み取りのみ。 delete / purge / logout / 課金 / ブロック / 通報 は 何を指定しても呼ばないようにフィルタしてある(tests/test_online.py の DENY_WORDS)。 --sweep の対象は「GET かつパスパラメータ無しかつ危険語なし」の 73 本だけで、 残り 259 本は自動で除外される。

--json report.json を付けると全部の結果(ステータス・エラー本文込み)が残るので、 そのまま投げてくれるのが一番早い。

匿名アカウントは実行のたびに新しく生えるので、--write で書き込んでも 自分のアカウントは汚れない。

トークンはどうやって手に入れる?

いちばん簡単:匿名トークン(ログイン不要)

Web(zeta-ai.io)は未ログインの訪問者にもアカウントを1つ発行している。 ただ GET するだけで TOKEN / REFRESH_TOKEN / DEVICE_ID が Set-Cookie で降ってくるので、 それをそのまま使う。

zeta = ZetaClient.anonymous(language="ja")
zeta.is_anonymous       # True
zeta.users.me()         # {'username': 'AntsyGoose3091', ...}  勝手に名前が付いてる

既存のクライアントに載せたいなら zeta.auth.login_anonymous()。

匿名で出来ること/出来ないこと(実際に叩いて確認した):

出来る プロフィール取得 / ランキング / 検索 / 補完 / ホーム / ジャンル / 無限プロット / ルーム作成 / チャット送信 / トークン更新
出来ない コイン残高・デイリー報酬 / スクラップ(お気に入り) / クリエイター統計 / チャットプロフィール

出来ない側は 401 ANONYMOUS_NOT_ALLOWED が返る。つまり 「読む・喋る」だけなら匿名で足りて、「所有物が絡む」と本ログインが要る。

ちゃんとログインする:外部 IdP

/v1/auth/tokens がトークン発行の唯一の入口で、受け付けるのは type: "refresh"(更新)と type: "external"(外部 IdP)の2つだけ。 anonymous や guest みたいな type は試したけど 400 で弾かれた(匿名は上の Web 経由が唯一の入口)。

メアド+パスワードでログインする口は無い。 どこかから IdP のトークンを貰ってくる必要がある。

Web 版のログイン画面が使っているクライアント ID は全部拾ってあるので、認可 URL は組み立てられる:

from zetapi import authorize_url

authorize_url("GOOGLE")   # ブラウザで開く → 認証 → ?code=... で戻ってくる
authorize_url("LINE")
authorize_url("KAKAO")
authorize_url("APPLE")

zeta.auth.login_external("GOOGLE", "<戻ってきた code>")
issuer 種別 値
GOOGLE OAuth client ID 525134294958-mljfskqips16vo9so99v3eroniqi85gf.apps.googleusercontent.com
LINE channel ID 2002675299
KAKAO JavaScript key a910522abf6591852f96f59c651723f5
APPLE Services ID io.zeta-ai

いずれも Web のバンドルに平文で置かれている公開値で、秘密鍵ではない。

Web 版は取得した code を Next.js の Server Action(externalLogin)に渡していて、 そこから先はサーバー側で /v1/auth/tokens に流している。なので login_external に code をそのまま渡して通るかは未検証(有効な IdP アカウントが要るので試せていない)。 ID トークンを持っているならそちらを渡すのが確実。ここは動作報告が欲しいところ。

もう一つ確実なのは mitmproxy 派:アプリか Web のログイン通信を横取りして accessToken / refreshToken を抜く。一番早い。

ちなみに /v1/nutty/sms みたいな SMS 認証系のエンドポイントは生えてるけど、 これは旧 Nutty からのアカウント移行用で Zeta のログインには使えない。

どの経路でも refresh_token さえ確保しとけば以降は自動更新なので、最初の一回だけ頑張れば勝ち。

復元した仕様

出典は2つ。アプリ 3.42.4(com.scatterlab.messenger)の assets/index.android.bundle と、 Web 3.44.7(zeta-ai.io)の Next.js チャンク。

項目 内容
ベースURL https://api.zeta-ai.io
認証 Authorization: Bearer <accessToken>
共通ヘッダ X-Client-Version / X-Client-Native-Version / X-Client-Type: app|web / X-Device-Type: android|ios|web|pc_web / X-User-Language: JAPANESE|KOREAN|ENGLISH / X-Sticky: <deviceId>
アクセストークン JWT。uid / did / ano(匿名か) / cty / tz / exp が入っている。有効期限は7日
トークン更新 POST /v1/auth/tokens に {deviceId, type:"refresh", refreshToken} → {accessToken, refreshToken}
ログイン 同じ口に type:"external" + externalToken:{issuer, token}
匿名発行 API に口は無い。GET https://zeta-ai.io/<lang> の Set-Cookie で降ってくる
新規登録 POST /v1/users に deviceId, name, username, birthdate, gender, chatProfileDescription, externalToken, language, marketingOptIn, existingToken, timeZone
チャット POST /v1/rooms/:roomId/messages/stream に {"type":"TEXT","text":"..."}、Accept: text/event-stream

他のホスト:image.zeta-ai.io(画像CDN)、creator-assistant.zeta-ai.io、 zeta-ai.io(Web)、web-cdn.zeta-ai.io(Web の静的ファイル)。

v0.2.0 で直したこと

実サーバーに通して初めて分かった間違いが3つあった。どれも 0.1.0 では致命的だった。

直したもの 0.1.0(誤) 0.2.0(正) 症状
X-User-Language ja JAPANESE 全リクエストが 500。0.1.0 は事実上何も動いていなかった
チャット送信ボディ {"content":{"type","text"}} {"type","text"} 400 Failed to read HTTP message
検索のクエリ名 query= keyword= 400 Required query parameter 'keyword' is not present

加えて chat.collect() は TOKEN イベントを連結する実装だったが、実際のイベントは IN_PROGRESS で中身が累積のため、連結すると壊れる。最終形を返すよう直した上で、 差分だけ流す chat.iter_text() を足した。 plots.ranking() は必須の type を送っていなかったので既定 DAILY を入れた。

新しく入ったもの:ZetaClient.anonymous() / auth.login_anonymous() / authorize_url() / token_claims / user_id / is_anonymous / normalize_language()。

分かってないところ

隠さず書いとく。

  • 本ログインの疎通が未確認。 認可 URL は組めるし login_external も実装してあるけど、 IdP から貰った code をそのまま渡して通るのか、ID トークンに交換してからなのかを 確かめられていない(有効な IdP アカウントが要るので)。動いた/動かなかったの報告が欲しい。
  • 匿名で弾かれる領域が未検証。 コイン・スクラップ・クリエイター系は ANONYMOUS_NOT_ALLOWED で入れないので、本ログインしないと確認できない。
  • 再生成・選択肢(CYOA)のボディ。 chat.regenerate() / chat.options() は ボディ無しで投げている。チャット本体が入れ子なしで通ったので同じ形かもしれないが未確認。 足りなかったら zeta.chat.send_raw(room_id, {...}) で好きなボディを丸投げして。 type は TEXT / IMAGE / CYOA / OPTION / SITUATION / INTRO。
  • 書き込み系のリクエストボディ全般。 プロット作成・ロアブック・プロフィール更新あたりは レスポンス型の名前しか分かっていない。Web のチャンクに DTO のフィールド名は残っているので、 必要になったら掘れる。
  • 課金系(/v1/zeta-pass/*, /v1/coin-products/*)はストアのレシートが要るので、 ラッパー単体では通せない。エンドポイントだけは生えてる。

余談:どうやって作ったか

APK を落として展開して、assets/index.android.bundle を hermes-dec に食わせた。

このアプリ、中身は React Native + Hermes バイトコードで、Java 側はほぼ空っぽ。 自社コードは com/scatterlab/messenger/ に 13 ファイルあるだけ。 Google Play の PairIP で保護は掛かってるんだけど、RN バンドルは暗号化されてないので素通り。

で、逆コンパイルした 87MB の擬似 JS を眺めてたら、API 定義がこういう形で丸ごと残ってた:

r1 = {'method': 'GET', 'url': '/v1/rooms/:roomId'};
r6 = r6.RoomApiDto;
r1['responseClass'] = r6;
r2['getRoomApi'] = r1;

r0 r1 みたいな仮想レジスタと switch ジャンプの塊なんだけど、 この API 定義のところだけ異常に規則的。ここまで綺麗だと拾ってくれと言われてる気がする。 なので正規表現でレジスタを追いかけて 332 件まるごと抜いた。 zetapi/endpoints.py は全部その自動生成物で、手書きしたところは1行も無い。

なお jadx は 18,302 件のデコンパイルエラーを吐いてた。Kotlin メタデータと難読化のせい。 でも今回欲しかったものは全部 JS 側にあったので、そっちは特に見なくて済んだ。ラッキー。

逆コンパイル成果物と元の APK はリポジトリに残してない(合計 400MB 超えてたので消した)。 endpoints.py を作り直したくなったら APK 取得からやり直してね。

v0.2.0 では Web 版も見た

APK だけ見ていたのが 0.1.0 の敗因だった。zeta-ai.io は Next.js で、 チャンクを辿れば 難読化されていても構造がそのまま読める。しかも Sentry 用の data-sentry-source-file が残っているので、 SocialLoginArea.tsx ExternalLogin.tsx みたいな元のファイル名で目的の場所を探せる。

Hermes バイトコードと違って、こっちには

  • リクエスト DTO のフィールド名(アプリ側には残っていなかったもの)
  • 各 IdP のクライアント ID とログインの流れ
  • API クライアントのヘッダ構成(X-Client-Type: web など)
  • TokenIssuer / Language などの enum の実際の値

が全部揃っていた。同じサービスの別クライアントを見るのが一番効いた、という話。

さらに余談

なんで zetapi なの?って思った?最初は py + Zeta で zetapy にするつもりだった。 でも PyPI を見たら神経科学の ZETA-test のパッケージが先に zetapy を取ってた。 スパイク列の統計解析をするやつで、こっちとは縁もゆかりも無い。 配布名も import 名も被せると、両方入れた人の環境で踏み潰し合って事故る。 なので Zeta + API で zetapi。以上。

コンタクト / 貢献

バグとか「このメソッド名ダサくない?」とかは Issues に投げて。 PR も歓迎。エンドポイント追加はスクリプト自動生成だったので、 今は endpoints.py を直接いじる形になる(1行足すだけ)。

実サーバーで動いたかどうかの報告が一番ありがたい。 こっちで確認できてないので。

ライセンス

MIT。好きに使っていいよ。 Zeta の商標とサービス自体は当然 ScatterLab のもので、このライブラリは無関係の第三者製です。

Metadata

Release files for zetapi 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for zetapi 0.2.0
File Size Uploaded
zetapi-0.2.0.tar.gz 62.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for zetapi 0.2.0
File Interpreter ABI Platform
zetapi-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 108.9 kB

Release files / zetapi-0.2.0.tar.gz

Download URL zetapi-0.2.0.tar.gz
Size 62.8 kB
Tags Source
SHA-256 checksum
How to use checksums
986b0c05d53c739677c7dcf224e11ca6ee5585f471b367273cd5fd5fc786ab6f
BLAKE2b-256 checksum
How to use checksums
b6f1bb846e24091b0f6b006a9c5c05320c68f0f30723cc412e89ae25c8b0d887
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.3

Release files / zetapi-0.2.0-py3-none-any.whl

Download URL zetapi-0.2.0-py3-none-any.whl
Size 46.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0708730ec365b316d13b613c5010da67370cc61b5f5747f056c5de710b491909
BLAKE2b-256 checksum
How to use checksums
d44de51b8f52b5feec93a84576724650c9cc47a933ef89563653e0ef8318637a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.3

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page