///|
/// `youtube.com/live_chat?v=…` のページから取り出した、キー無しでチャットを読むための設定。
/// ブラウザと同じ経路(innertube)なので Data API のキーもクォータも要らないが、非公式でページ構造が変わると壊れる。
pub(all) struct InnertubeConfig {
/// ページに埋め込まれている公開キー(`INNERTUBE_API_KEY`)
api_key : String
client_version : String
/// 最初の継続トークン
continuation : String
} derive(Debug, Eq)
///|
/// `"NAME":"value"` の形で埋め込まれた値を取り出す。
fn embedded_string(html : String, name : String) -> String? {
let marker = "\"\{name}\":\""
guard html.find(marker) is Some(start) else { return None }
let rest = html[start + marker.length():]
guard rest.find("\"") is Some(end) else { return None }
Some(rest[:end].to_owned())
}
///|
/// `window["ytInitialData"] = {…};` の JSON を取り出す。
fn initial_data(html : String) -> Json raise YouTubeError {
let marker = "ytInitialData\"] = "
guard html.find(marker) is Some(start) else {
raise TypeMismatch("ytInitialData")
}
let rest = html[start + marker.length():]
guard rest.find(";") is Some(end) else {
raise TypeMismatch("ytInitialData")
}
@json.parse(rest[:end].to_owned()) catch {
_ => raise TypeMismatch("ytInitialData")
}
}
///|
/// live_chat ページから innertube の設定を取り出す。
pub fn parse_live_chat_page_html(
html : String,
) -> InnertubeConfig raise YouTubeError {
guard embedded_string(html, "INNERTUBE_API_KEY") is Some(api_key) else {
raise TypeMismatch("INNERTUBE_API_KEY")
}
guard embedded_string(html, "INNERTUBE_CLIENT_VERSION")
is Some(client_version) else {
raise TypeMismatch("INNERTUBE_CLIENT_VERSION")
}
guard initial_data(html) is Object({ "contents": Object(contents), .. }) else {
raise TypeMismatch("ytInitialData.contents")
}
match contents.get("liveChatRenderer") {
Some(Object(renderer)) =>
match continuation_of(renderer) {
Some((continuation, _)) => { api_key, client_version, continuation, }
None => raise TypeMismatch("liveChatRenderer.continuations")
}
_ => {
// チャットが無効・視聴者限定のときは messageRenderer に文言が入る
let reason = match contents.get("messageRenderer") {
Some(Object({ "text": text, .. })) => runs_text(text).0
_ => ""
}
raise ChatUnavailable(reason)
}
}
}
///|
/// `continuations[0]` から継続トークンとポーリング間隔を取り出す。
/// invalidation / timed / reload の 3 種類がある。
fn continuation_of(renderer : Map[String, Json]) -> (String, Int)? {
guard renderer.get("continuations") is Some(Array([Object(first), ..])) else {
return None
}
for
kind in [
"invalidationContinuationData", "timedContinuationData", "reloadContinuationData",
] {
if first.get(kind)
is Some(Object({ "continuation": String(token), .. } as data)) {
let timeout = match data.get("timeoutMs") {
Some(Number(n, ..)) => n.to_int()
_ => DEFAULT_POLLING_INTERVAL_MS
}
return Some((token, timeout))
}
}
None
}
///|
pub fn innertube_live_chat_url(config : InnertubeConfig) -> String {
"https://www.youtube.com/youtubei/v1/live_chat/get_live_chat?key=\{config.api_key}&prettyPrint=false"
}
///|
pub fn innertube_live_chat_body(
config : InnertubeConfig,
continuation : String,
) -> Json {
{
"context": {
"client": {
"clientName": "WEB",
"clientVersion": config.client_version,
"hl": "ja",
},
},
"continuation": continuation,
}
}
///|
/// `message.runs` を本文にする。絵文字は文字そのもの、カスタム絵文字は `:name:` にしてスタンプ名も返す。
fn runs_text(message : Json) -> (String, Array[String]) {
let text = StringBuilder()
let emotes : Array[String] = []
guard message is Object({ "runs": Array(runs), .. }) else { return ("", []) }
for run in runs {
match run {
Object({ "text": String(t), .. }) => text.write_string(t)
Object({ "emoji": Object(emoji), .. }) => {
let custom = emoji.get("isCustomEmoji") is Some(True)
match (custom, emoji.get("shortcuts"), emoji.get("emojiId")) {
(true, Some(Array([String(shortcut), ..])), _) => {
text.write_string(shortcut)
if !emotes.contains(shortcut) {
emotes.push(shortcut)
}
}
(_, _, Some(String(id))) => text.write_string(id)
_ => ()
}
}
_ => ()
}
}
(text.to_string(), emotes)
}
///|
/// `get_live_chat` のレスポンスを分解する。
/// 通常のテキストとスーパーチャットを読み、システムメッセージやバナーは読み飛ばす。
pub fn parse_get_live_chat(json : Json) -> LiveChatPage raise YouTubeError {
guard json
is Object(
{
"continuationContents": Object(
{ "liveChatContinuation": Object(continuation), .. }
),
..
}
) else {
raise TypeMismatch("continuationContents")
}
let comments : Array[Comment] = []
let actions = match continuation.get("actions") {
Some(Array(actions)) => actions
_ => []
}
for action in actions {
guard action
is Object(
{ "addChatItemAction": Object({ "item": Object(item), .. }), .. }
) else {
continue
}
let renderer = match
(
item.get("liveChatTextMessageRenderer"),
item.get("liveChatPaidMessageRenderer"),
) {
(Some(Object(r)), _) | (_, Some(Object(r))) => r
_ => continue
}
guard renderer.get("authorExternalChannelId") is Some(String(channel_id)) &&
renderer.get("message") is Some(message) else {
continue
}
let (text, emotes) = runs_text(message)
guard !text.is_empty() else { continue }
let author = match renderer.get("authorName") {
Some(Object({ "simpleText": String(name), .. })) => name
_ => ""
}
let at = match renderer.get("timestampUsec") {
Some(String(usec)) => parse_int64_digits(usec) / 1000
_ => 0
}
comments.push(
Comment::new(
participant_id("youtube", channel_id),
text,
author~,
at~,
emotes~,
),
)
}
let (next_page_token, polling_interval_ms) = match
continuation_of(continuation) {
Some((token, timeout)) => (Some(token), timeout)
None => (None, DEFAULT_POLLING_INTERVAL_MS)
}
{ comments, next_page_token, polling_interval_ms, }
}
///|
fn parse_int64_digits(s : String) -> Int64 {
let mut n : Int64 = 0
for c in s {
guard c is ('0'..='9') else { return 0 }
n = n * 10 + (c.to_int() - '0').to_int64()
}
n
}