プロフィール

髭山髭人(ひげひと)

自分の書いた記事が、一人でも誰かの役に立てば...
活動信条の一つとして「貴方のメモは、誰かのヒント」というのがあります。

このサイトについて

本家HP packetroom.net から切り離した いわゆる技術メモ用のブログで、無料レンタルサーバーにて運用しています。広告表示はその義務なのでご容赦。
XREA さんには長年お世話になっています

C#からPixivSketchLiveの配信枠を取る

概要とか

Pixiv(が提供しているSKETCH)の配信サービス PixivSketchLive にて、配信枠を取るだけのツールを作りたかったので いろいろ探った時とかの覚書。

そもそもAPI自体非公開だろうし、仕様がホイホイ変わるかもしれないので その辺りはご容赦

公式クライアントからの挙動メモ

ライブ配信の項目をクリックすると、入力フォームの表示と共に複数データが得られる

  • https://sketch.pixiv.net/api/lives/availability.json
{
 "data":
  {
   "availability":true,
   "closed_live_availability":true
  },
 "errors":[],
 "_links":{},
 "rand":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"  // 32文字の16進数ランダム文字列
}

よくわからんかった (゚ω゚)

  • https://sketch.pixiv.net/api/rewards/contract.json (404)

こちらは多分リワード受け取りに関する情報だと思う。
自分は受け取り利用規約の同意を見送っているので、単純に蹴られているだけかも?

既に配信中の場合は

  • https://sketch.pixiv.net/api/lives/mine.json

ここに繋ぐとJSONが返る。その中身次第で、自身が既に配信中であるかどうか確認できる
配信中で無いのなら、それっぽい中身にならない(雑な表現)

{
 "data":{}, // ← 配信中であれば、ここに沢山情報が入るっぽい
 "errors":[],
 "_links":{},
 "rand":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"  // 32文字の16進数ランダム文字列
}

開始すると...

  • wss://sketch.pixiv.net/ws/lives?live_id=[チャンネル番号] (wss接続)
  • https://sketch.pixiv.net/api/lives.json (POST)
  • https://sketch.pixiv.net/@higehito/lives/[チャンネル番号] (GET)

に接続された。

枠を取りたい場合、https://sketch.pixiv.net/api/lives.json へ諸々をPOSTすれば良いと判断

投げつけるフォームデータの一例

name: 配信タイトル (20字)
description: 配信説明 (300字)
is_single: false
publicity: closed
adult_level: normal
source: web
enable_gifting: false
thumbnail : サムネデータ(C#コード中で後述)

配信枠のタイトルおよび配信説明文は クライアント側で文字数制限があるので、サーバーでもバリデート掛けられてると思う。試してないけど。

Request Headers で必要そうなもの

cookie の PHPSESSID は言わずもがな、

accept: application/vnd.sketch-v4+json
origin: https://sketch.pixiv.net
referer: https://sketch.pixiv.net/

多分この3つは設定必須

本系Webページのクライアントjsにおけるサムネ画像管理箇所は?

それっぽいな?と思った箇所の引用メモ

Live.js

async updateLive({ context, payload }) {
    const nextLive = payload.getIn(['entity', 'live']).toJS();
    const body = new FormData();
    nextLive.name && body.append('name', nextLive.name);
    body.append('description', nextLive.description || '');
    nextLive.adult_level && body.append('adult_level', nextLive.adult_level);
    nextLive.thumbnail.file && body.append('thumbnail', nextLive.thumbnail.file);
    await Promise.all([
      context.fetch(`/api/lives/${nextLive.id}.json`, { method: 'PUT', body }),
      ...(nextLive.remove_thumbnail
        ? [context.fetch(`/api/lives/${nextLive.id}/thumbnail.json`, { method: 'DELETE' })]
        : []),
    ]);
  },

client.xxxxxx.js

updateLive: function(e) {
            var n = e.context
              , t = e.payload;
            return w(regeneratorRuntime.mark(function e() {
                var a, r;
                return regeneratorRuntime.wrap(function(e) {
                    for (; ; )
                        switch (e.prev = e.next) {
                        case 0:
                            return a = t.getIn(["entity", "live"]).toJS(),
                            r = new FormData,
                            a.name && r.append("name", a.name),
                            r.append("description", a.description || ""),
                            a.adult_level && r.append("adult_level", a.adult_level),
                            a.thumbnail.file && r.append("thumbnail", a.thumbnail.file),
                            e.next = 8,
                            Promise.all([n.fetch("/api/lives/".concat(a.id, ".json"), {
                                method: "PUT",
                                body: r
                            })].concat(h(a.remove_thumbnail ? [n.fetch("/api/lives/".concat(a.id, "/thumbnail.json"), {
                                method: "DELETE"
                            })] : [])));
                        case 8:
                        case "end":
                            return e.stop()
                        }
                }, e)
            }))()
        },

配信の終了

https://sketch.pixiv.net/api/lives/[チャンネルID].json
的な、チャンネルを対象にして DELETE メソッドで投げるっぽい?
自分は枠さえ取れればよかったので、ここは未検証

レスポンスは、

{ 
 "data":{},
 "errors":[],
 "_links":{},
 "rand":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"  // 32文字の16進数ランダム文字列
}

のような JSONだった

C#から枠を取る

簡単な流れ

  • Cookie から "PHPSESSID" を持ってきてセット
  • POST 用に、複数のパラメタも用意しておく
    • 必要ならサムネイル(画像)データを別途付与
  • RequestHeader は多分3つ必要
    • accept : application/vnd.sketch-v4+json
    • Origin : https://sketch.pixiv.net
    • referer : https://sketch.pixiv.net/
  • これらを投げつけると JSON が返ってくる
    • 後は中身を見て、配信の成否判断とか、取れた場合の枠情報等をよしなに調理する

準備

記事の本質とはズレるので、Cookie読み取り処理等は省いてます

// サムネイルデータは別途用意してね
System.Drawing.Image Thumbnail = null;

// Cookieから持ってきてね
var _cookieSessionId = "1234567_xxxxxxxxxxxxxxxxxxxxxxxx";

MultipartFormDataContent form = new MultipartFormDataContent();

// cookieを使う為に前もって用意
var clientHandler = new HttpClientHandler();
clientHandler.UseCookies = true;

// アクセス時に引用するcookie情報を作成
var cookieContainer = new CookieContainer();
cookieContainer.Add(new Uri("https://sketch.pixiv.net"), new Cookie("PHPSESSID", _cookieSessionId));

// cookie を HttpClientHandler に仕込む
clientHandler.CookieContainer = cookieContainer;

// 仕込み済みの HttpClientHandler を引数に、HttpClient インスタンスを作成。あとヘッダ付与
var client = new HttpClient(clientHandler);
client.DefaultRequestHeaders.Add("accept", "application/vnd.sketch-v4+json");
client.DefaultRequestHeaders.Add("Origin", "https://sketch.pixiv.net");
client.DefaultRequestHeaders.Add("referer", "https://sketch.pixiv.net/");

// 構築
form.Add(new StringContent(Title), "name");
form.Add(new StringContent(Description), "description");
form.Add(new StringContent(Single.ToString()), "is_single");
form.Add(new StringContent(PublicityType), "publicity");
form.Add(new StringContent(AdultLevelType), "adult_level");
form.Add(new StringContent(SourceType), "source");
form.Add(new StringContent(Gifting.ToString()), "enable_gifting");
// サムネあれば開きつつ、バイト配列に変換
if (Thumbnail != null)
{
    var img = Thumbnail;
    var _converter = new System.Drawing.ImageConverter();
    byte[] file_bytes = (byte[])_converter.ConvertTo(img, typeof(byte[]));
    // ここのファイル名、何でも良いのかな?
    form.Add(new ByteArrayContent(file_bytes, 0, file_bytes.Length), "thumbnail", "image.jpg");
}

// 接続・結果読み取り
var resAsyncTaskResult = client.PostAsync(SketchApiUrl, form).Result;
var resBodyTaskresult = resAsyncTaskResult.Content.ReadAsStringAsync().Result;

JSON応答を解析

Newtonsoft.Json ライブラリ使ってます。
jsに比べれば面倒だけど、C#でJSON弄るのはなんかもうコレ!って感じに自分はなっちゃってます。

上に記したコードの resBodyTaskresult をそのままJSONであるものとしてパースをかけつつ諸々を判断していきます。

  • JSONが返ってくる前提として進めて良さそう
  • "errors" キーが返ってくるけど、エラーが無い場合でもこのキーは存在する模様
    • 不備があればJSONの"errors" に含まれる配列内に "message" キーとその値にエラー内容が(英文で)入るっぽい
    • 不備が無ければ "errors" の配列は空[]になるのかな?
  • 正常に枠が取れれば、JSONは それっぽい中身のオブジェクトになる
    • "data.live.id" に配信番組のID
    • "live.owner.user.unique_name" に、配信オーナー(実質あなた)のユニーク名が入る
    • これらを汎用URLとして繋げれば、取ったばかりの配信枠URLが得られる

...という訳で、
↓ 以下は自分が使ってたコードの一部を雑に抜いただけなんですけど、雰囲気だけ伝われば...

try
{
    // 例外前提でパース。data を最初にとって、
    // エラー用メッセージ data.errors.message
    // 次に data.live.id
    var _resJson = JObject.Parse(resBodyTaskresult);
    JToken _data = new JObject();
    if (_resJson.TryGetValue("data", out _data) == false)
    {
        // "応答したJSONの data パース自体に失敗";
        return false;
    }

    JToken _JT_Errors = _resJson.SelectToken("errors");
    // サーバーからのエラーメッセージは、正常処理でも空のオブジェクトとして存在している。
    // 大抵のケースでは「既に配信中である」旨
    if (_JT_Errors != null && _JT_Errors.Type == JTokenType.Array)
    {
        // {"data":{},"errors":[{"message":"you cannot create open room","code":null}],"rand":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}
        var _ErrArray = (JArray)_JT_Errors;
        string _rawErrMsg = String.Empty;
        if (_ErrArray != null && _ErrArray.Count > 0)
        {
            foreach (var aa in _ErrArray)
            {
                var _emsg = aa.SelectToken("message");
                if (_emsg != null)
                {
                    _rawErrMsg += "\n" + (string)((JValue)_emsg).Value;
                }
            }
            // "サーバーからのエラーメッセージ" + _rawErrMsg;
            return false;
        }
    }

    JToken _JT_ChannnelId = _data.SelectToken("live.id");
    JToken _JT_OwnerUniqueName = _data.SelectToken("live.owner.user.unique_name");
    // 配信IDかユニークネームの取得に失敗
    if (_JT_ChannnelId == null)
    {
        // "応答したJSONの 配信ID パースに失敗";
        return false;
    }
    else if (_JT_OwnerUniqueName == null)
    {
        // "応答したJSONの owner unique_name パースに失敗";
        return false;
    }

    // 本来ならここに到達できているはず。
    string _channelID = (string)((JValue)_JT_ChannnelId).Value;
    string _ownerUniqueName = (string)((JValue)_JT_OwnerUniqueName).Value;

    return true;
}
catch (Newtonsoft.Json.JsonReaderException exc)
{
    // JObject.Parse に失敗した時
    // "応答したJSONのパースに失敗\n" + exc.Message;
}
catch (Exception exc)
{
    //  その他例外あれば。
}