【2021年1月版】API無しでYoutube Liveのコメントを取得する

Table of Contents

https://github.com/taizan-hokuto/pytchat
本記事で書いたコードは、ほぼ上記Pythonリポジトリの劣化品だ。

Youtube Liveチャットをプログラムから取ろう

問題点

youtubeの配信は都度都度仕様が代わり、去年の11月くらい?に実装したコードが最近全く使い物にならなくなったりした。
というわけで、2021年1月末現在使える、Youtube LiveChat取得プログラムだ。
環境はnode.js。上記のPythonコードは日本人の方が書いていて最新版のYoutubeにも対応しており、非常に参考になった。
Pythonをある程度読めるなら、こちらを参考にしたほうが良いかも知れない…本記事のNode版と違い、過去のアーカイブのチャットも拾えるようになっている。

概要

一昔前は、GETリクエストのクエリパラメータに「continuation=XXXXXX…」をつければ楽勝で取得出来た。
continuationというのは、チャットのどこから取得するかというパラメータだ。

…のだが、現行バージョンは

  1. 配信ページに飛んでページのHTMLを得る
  2. HTML内のJavascriptからcontinuation等を引っこ抜く
  3. https://www.youtube.com/youtubei/v1/live_chat/get_live_chatPOSTでパラメータを送りつける
  4. 新着のチャット等が飛んでくる この中に次回のcontinuationが含まれている

というややこしい手順を踏まねばならん。

実装

環境

typescript ^3.9 + node.jsでやっていくので、なるべく単体で使えるよう切り分けはしたが、その辺の知識が必要である。
またnode関係のライブラリとして、axiosとnode-html-parserを事前にnpm iなりyarn addなりする必要がある。

実装

詳細はコメント参照。

import axios from "axios";
import { parse } from "node-html-parser";

interface Options {
  key: string;
  continuation: string;
  visitorData: string;
  clientVersion: string;
}

export class Live {
  public channelId = "";
  public liveId = "";

  private options?: Options = undefined;

  private readonly headers = {
    "user-agent":
      "Mozilla/5.0 (Windows NT 6.3; Win64; x64) " +
      "AppleWebKit/537.36 (KHTML, like Gecko) " +
      "Chrome/86.0.4240.111 Safari/537.36"
  };
  private chatUri = "https://www.youtube.com/live_chat";
  private apiEpUri =
    "https://www.youtube.com/youtubei/v1/live_chat/get_live_chat";
  private pollingHandle?: NodeJS.Timer;

  /**
   * チャンネルIDから有効な配信URL引っこ抜いてくる
   * @param channelId
   */
  public static async getLiveFromChannelId(channelId: string) {
    // 単純にチャンネルURLの後ろに/live付ければライブのURLに飛べる
    // ついでによりパースしやすいembedページを利用する
    const uri = "https://www.youtube.com/embed/live_stream";

    const res = await axios
      .get(uri, {
        params: {
          channel: channelId
        }
      })
      .catch(err => err.response);

    if (res.status !== 200) return null;
    const root = parse(res.data).querySelectorAll("link");
    let vid = null;
    for (const v of root) {
      if (v.getAttribute("rel") === "canonical") {
        const href = v.getAttribute("href");
        if (href) {
          const match = href.match(/v=(.+)/);
          if (match && match.length >= 2) {
            vid = match[1];
          }
        }
      }
    }
    return vid;
  }

  /**
   * コメントポーリング開始
   * @param pollingIntervalMs
   */
  public begin(
    pollingIntervalMs: number,
    callbackLiveBegin: () => void,
    callbackCommentsReceived: (comments: Comment[]) => void
  ) {
    this.Logging("begin");
    if (this.liveId) {
      callbackLiveBegin();
      const closure = async () => {
        // 初回だけContinuation拾いに行く
        if (!this.options) {
          this.options = await this.fetchFirstLive(this.liveId);
        }

        // 初回パースが失敗してるかもなので馬鹿っぽいけど2度判定
        if (this.options) {
          const item = await this.fetchChat(this.options);
          if (
            item &&
            item.continuationContents &&
            item.continuationContents.liveChatContinuation
          ) {
            const {
              continuations,
              actions
            } = item.continuationContents.liveChatContinuation;

            // 新着コメントがあるならactionsアイテムがある
            if (actions) {
              const comments = this.buildCommentObject(actions);
              callbackCommentsReceived(comments);
            }

            // continuationを更新
            const icd = continuations[0].invalidationContinuationData;
            this.options.continuation = icd.continuation;
          } else {
            this.Logging("fetchChat()がゴミ返して来た");
          }
        } else {
          this.Logging("options 取得失敗");
        }
      };
      closure();
      this.pollingHandle = setInterval(closure, pollingIntervalMs);
    } else {
      throw new Error("liveIdかコールバックの設定忘れ");
    }
  }

  /**
   * コメントポーリング終了
   */
  public end() {
    if (this.pollingHandle) {
      clearInterval(this.pollingHandle);
    }
  }

  /**
   * postしてチャットアイテムを取得
   */
  async fetchChat(options: Options) {
    const params = {
      continuation: options.continuation,
      visitorData: options.visitorData,
      clientVersion: options.clientVersion
    };

    const data = this.buildOptions(
      params.continuation,
      this.headers["user-agent"],
      params.clientVersion,
      params.visitorData
    );

    const res = await axios
      .post(this.apiEpUri, data, {
        headers: this.headers,
        params: {
          key: options.key
        }
      })
      .catch(e => e.response);

    if (res.status === 200) {
      return res.data;
    } else {
      return null;
    }
  }

  /**
   * postする為のパラメータを組み立てる
   */
  buildOptions(
    continuation: string,
    userAgent: string,
    clientVersion: string,
    visitorData: string
  ) {
    const ret = {
      context: {
        client: {
          visitorData,
          userAgent,
          clientName: "WEB",
          clientVersion
        }
      },
      continuation
    };
    return ret;
  }

  /**
   * 配信からContinuous Keyを抜く ついでにAPI Keyも抜く
   * @param videoId 配信ID
   */
  async fetchFirstLive(videoId: string): Promise<Options | undefined> {
    const res = await axios.get(this.chatUri, {
      headers: this.headers,
      params: { v: videoId }
    });

    // htmlの中にjsとかも書いてあって、見た感じRegex一発で抜ける…たぶん。
    const html: string = res.data;
    const matchedKey = html.match(/"INNERTUBE_API_KEY":"(.+?)"/);
    const matchedCtn = html.match(/"continuation":"(.+?)"/);
    const matchedVisitor = html.match(/"visitorData":"(.+?)"/);
    const matchedClient = html.match(/"clientVersion":"(.+?)"/);

    const matched = (obj: RegExpMatchArray | null) => {
      if (obj && obj.length >= 2) {
        return obj[1];
      }
      return undefined;
    };

    // なお2021/01/29現在、keyは↓ので固定っぽい
    // AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8
    const ret = {
      key: matched(matchedKey),
      continuation: matched(matchedCtn),
      visitorData: matched(matchedVisitor),
      clientVersion: matched(matchedClient)
    };

    // 一個でも抜けがあったらUndef返す
    if (Object.values(ret).includes(undefined)) {
      return undefined;
    } else {
      return ret as Options;
    }
  }

  /**
   * actionItemプロパティからコメントオブジェクト切り出して返す
   * @param actions data内のactions配列
   */
  private buildCommentObject(actions: any): Comment[] {
    // actions:[{addChatItemAction:{item:{...}}}]
    const ret: Array<Comment> = [];
    for (const actionItem of actions) {
      // addChatItemActionがあればコメントかスパチャ
      if ("addChatItemAction" in actionItem) {
        const item = actionItem.addChatItemAction.item;
        // コメント共通項目(実質スパチャは通常コメの上位互換)
        let common = null;
        let isSuperChat = false;

        // 通常コメの場合
        if ("liveChatTextMessageRenderer" in item) {
          common = item.liveChatTextMessageRenderer;
        }
        // スパチャの場合
        else if ("liveChatPaidMessageRenderer" in item) {
          common = item.liveChatPaidMessageRenderer;
          isSuperChat = true;
        }

        // 共通データ整形
        if (common) {
          ret.push(
            // 自作コメントパーサを呼び出す 人によって扱いが違うので任せる
            // Comment.fromMessageRenderer(common, isSuperChat, this.liveId)
          );
        }
      }
      // addLiveChatTickerItemActionがあればスーパーステッカー
      else if ("addLiveChatTickerItemAction" in actionItem) {
        // const item = actionItem.addLiveChatTickerItemAction.item
        // STUB よく知らん そもそも自分のチャンネル収益化してねぇ
      }
    }
    return ret;
  }

  /**
   * loggingラッパ デバッグ等にどうぞ
   * @param message
   */
  private Logging(message: string) {
    // console.log(message)
  }
}

引っこ抜いてきたJsonからコメントオブジェクトをぶっこ抜くのは、創好リナさんの従来型クローラを流用するのが早いぞ。
MessageRendererというキーのオブジェクトを取得できれば、従来のやり方がそのまま使える。
https://github.com/LinaTsukusu/youtube-chat

カテゴリー: IT パーマリンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です