diff --git a/examples/posts/.gitignore b/examples/posts/.gitignore new file mode 100644 index 0000000000..483a9c42c3 --- /dev/null +++ b/examples/posts/.gitignore @@ -0,0 +1 @@ +package-lock.json \ No newline at end of file diff --git a/examples/posts/package.json b/examples/posts/package.json new file mode 100644 index 0000000000..4e589403a7 --- /dev/null +++ b/examples/posts/package.json @@ -0,0 +1,18 @@ +{ + "name": "posts", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": {}, + "devDependencies": { + "@types/jsdom": "^27.0.0", + "typescript": "^5.0.4" + }, + "dependencies": { + "bgutils-js": "^4.0.3", + "cookie-eater": "^1.0.0", + "image-size": "^2.0.2", + "jsdom": "^28.1.0", + "youtubei.js": "file:../../" + } +} diff --git a/examples/posts/src/index.ts b/examples/posts/src/index.ts new file mode 100644 index 0000000000..d767ae1665 --- /dev/null +++ b/examples/posts/src/index.ts @@ -0,0 +1,30 @@ +import { Innertube, UniversalCache } from 'youtubei.js'; +import { botguard_solver, get_channel_id, image_path_to_post_image, youtube_cookies } from './utils.ts'; + +(async () => { + const COOKIES = await youtube_cookies(); + + const yt = await Innertube.create({ cache: new UniversalCache(false), cookie: COOKIES }); + const CHANNEL_ID = await get_channel_id(yt); + + const posts_manager = yt.posts(botguard_solver); + const create_post_result = await posts_manager.create({ + comment_text: 'Test post 001', + // scheduled_publish_time_seconds: String(Math.floor(Date.now() / 1000) + (60 * 60)) // upload in a hour + }, CHANNEL_ID, + { + type: "IMAGE_POLL", + options: [ + {text: "Yes", image: { + source: await image_path_to_post_image(""), + preview_coordinates: {left: 0, top: 0, right: 1, bottom: 1} + }}, + {text: "No", image: { + source: await image_path_to_post_image(""), + preview_coordinates: {left: 0, top: 0, right: 1, bottom: 1} + }} + ] + }); + + console.log(create_post_result); +})(); \ No newline at end of file diff --git a/examples/posts/src/utils.ts b/examples/posts/src/utils.ts new file mode 100644 index 0000000000..f2b0c868a1 --- /dev/null +++ b/examples/posts/src/utils.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises'; +import { BotGuardClient } from "bgutils-js/botguard"; +import { USER_AGENT } from "bgutils-js/utils"; +import { imageSize } from 'image-size'; +import { JSDOM, VirtualConsole } from "jsdom"; +import Innertube, { type Types } from 'youtubei.js'; +import { get_cookies } from 'cookie-eater'; + +export const botguard_solver: Types.BotGuardSolver = { + solve: async(botguard_challenge, binding) => { + const virtual_console = new VirtualConsole(); + const dom = new JSDOM('', { url: "https://www.youtube.com", referrer: "https://www.youtube.com/", userAgent: USER_AGENT, resources: "usable", runScripts: "dangerously", virtualConsole: virtual_console }); + + Object.assign(globalThis, { window: dom.window, document: dom.window.document, location: dom.window.location, origin: dom.window.origin }); + + if (!("navigator" in globalThis)) { + Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator }); + } + + Object.defineProperty(dom.window.HTMLCanvasElement.prototype, "getContext", { value: () => null, writable: true }); + + let interpreter_url = botguard_challenge.interpreter_url.private_do_not_access_or_else_safe_script_wrapped_value ?? botguard_challenge.interpreter_url.private_do_not_access_or_else_trusted_resource_url_wrapped_value ?? ""; + + if (interpreter_url.startsWith("//")) interpreter_url = `https:${interpreter_url}`; + + const bg_script_response = await fetch(interpreter_url); + const interpreter_javascript = await bg_script_response.text(); + + new Function(interpreter_javascript)(); + + const botguard = await BotGuardClient.create({ program: botguard_challenge.program, globalName: botguard_challenge.global_name, globalObject: globalThis }); + + const botguard_response = await botguard.snapshot({ contentBinding: { atr_challenge: binding } }); + return botguard_response; + } +}; + +export async function get_channel_id(yt: Innertube, index?: number): Promise { + const account_info = await yt.account.getInfo(true); + const account = account_info[index ?? 0]; + if (account === undefined) throw new Error("No accounts found"); + const resolve_url = `https://www.youtube.com/${account.channel_handle.text}`; + const resolved = await yt.resolveURL(resolve_url); + const channel_id = resolved.payload.browseId as string; + return channel_id; +} + +export async function image_path_to_post_image(image_path: string): Promise { + const body = await readFile(image_path); + const { width, height, type } = imageSize(body); + return { + body, + size: body.length, + width, + height, + base64: `data:image/${type ?? 'png'};base64,${body.toString('base64')}` + }; +} + +export async function youtube_cookies(): Promise { + const HOST_NAME = '.youtube.com'; + const cookies_map = await get_cookies([HOST_NAME]); + const cookies = cookies_map?.[HOST_NAME].toString() ?? undefined; + + if(cookies) console.log(`Successfully found ${cookies_map?.[HOST_NAME].getCookies().length} cookies`); + return cookies; +} \ No newline at end of file diff --git a/examples/posts/tsconfig.json b/examples/posts/tsconfig.json new file mode 100644 index 0000000000..be4b0dfa6c --- /dev/null +++ b/examples/posts/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "strict": true, + "moduleResolution": "bundler", + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true + } +} \ No newline at end of file diff --git a/protos/generated/misc/params.ts b/protos/generated/misc/params.ts index a8a99121b3..95b1540982 100644 --- a/protos/generated/misc/params.ts +++ b/protos/generated/misc/params.ts @@ -285,6 +285,21 @@ export interface ClipParams_ClipParamsData { clipMetadata: string; } +export interface CreateBackstagePostParams { + channelId: string; + unkParam1: number; + f6: CreateBackstagePostParams_Field6 | undefined; + f9: CreateBackstagePostParams_Field9 | undefined; +} + +export interface CreateBackstagePostParams_Field6 { + unkParam3: number; +} + +export interface CreateBackstagePostParams_Field9 { + unkParam1: number; +} + function createBaseVisitorData(): VisitorData { return { id: "", timestamp: 0 }; } @@ -2741,6 +2756,150 @@ export const ClipParams_ClipParamsData: MessageFns = }, }; +function createBaseCreateBackstagePostParams(): CreateBackstagePostParams { + return { channelId: "", unkParam1: 0, f6: undefined, f9: undefined }; +} + +export const CreateBackstagePostParams: MessageFns = { + encode(message: CreateBackstagePostParams, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.channelId !== "") { + writer.uint32(10).string(message.channelId); + } + if (message.unkParam1 !== 0) { + writer.uint32(16).int32(message.unkParam1); + } + if (message.f6 !== undefined) { + CreateBackstagePostParams_Field6.encode(message.f6, writer.uint32(50).fork()).join(); + } + if (message.f9 !== undefined) { + CreateBackstagePostParams_Field9.encode(message.f9, writer.uint32(74).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): CreateBackstagePostParams { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseCreateBackstagePostParams(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.channelId = reader.string(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.unkParam1 = reader.int32(); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.f6 = CreateBackstagePostParams_Field6.decode(reader, reader.uint32()); + continue; + } + case 9: { + if (tag !== 74) { + break; + } + + message.f9 = CreateBackstagePostParams_Field9.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, +}; + +function createBaseCreateBackstagePostParams_Field6(): CreateBackstagePostParams_Field6 { + return { unkParam3: 0 }; +} + +export const CreateBackstagePostParams_Field6: MessageFns = { + encode(message: CreateBackstagePostParams_Field6, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.unkParam3 !== 0) { + writer.uint32(24).int32(message.unkParam3); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): CreateBackstagePostParams_Field6 { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseCreateBackstagePostParams_Field6(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 3: { + if (tag !== 24) { + break; + } + + message.unkParam3 = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, +}; + +function createBaseCreateBackstagePostParams_Field9(): CreateBackstagePostParams_Field9 { + return { unkParam1: 0 }; +} + +export const CreateBackstagePostParams_Field9: MessageFns = { + encode(message: CreateBackstagePostParams_Field9, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.unkParam1 !== 0) { + writer.uint32(8).int32(message.unkParam1); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): CreateBackstagePostParams_Field9 { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseCreateBackstagePostParams_Field9(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.unkParam1 = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, +}; + export interface MessageFns { encode(message: T, writer?: BinaryWriter): BinaryWriter; decode(input: BinaryReader | Uint8Array, length?: number): T; diff --git a/protos/generated/youtube/api/pfiinnertube/user_info.ts b/protos/generated/youtube/api/pfiinnertube/user_info.ts index 85db5ee6d8..b936c85fd9 100644 --- a/protos/generated/youtube/api/pfiinnertube/user_info.ts +++ b/protos/generated/youtube/api/pfiinnertube/user_info.ts @@ -28,6 +28,19 @@ export interface UserInfo_DelegatePurchases { } export interface UserInfo_DelegationContext { + externalChannelId?: string | undefined; + roleType?: UserInfo_DelegationContext_RoleType | undefined; +} + +export interface UserInfo_DelegationContext_RoleType { + channelRoleType?: UserInfo_DelegationContext_RoleType_ChannelRoleType | undefined; +} + +export enum UserInfo_DelegationContext_RoleType_ChannelRoleType { + CHANNEL_ROLE_TYPE_UNKNOWN = 0, + CREATOR_CHANNEL_ROLE_TYPE_OWNER = 8, + // TODO fill in the rest off the role types + UNRECOGNIZED = -1, } export interface UserInfo_CredentialTransferToken { @@ -221,11 +234,17 @@ export const UserInfo_DelegatePurchases: MessageFns }; function createBaseUserInfo_DelegationContext(): UserInfo_DelegationContext { - return {}; + return { externalChannelId: undefined, roleType: undefined }; } export const UserInfo_DelegationContext: MessageFns = { - encode(_: UserInfo_DelegationContext, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + encode(message: UserInfo_DelegationContext, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.externalChannelId !== undefined) { + writer.uint32(18).string(message.externalChannelId); + } + if (message.roleType !== undefined) { + UserInfo_DelegationContext_RoleType.encode(message.roleType, writer.uint32(42).fork()).join(); + } return writer; }, @@ -236,6 +255,59 @@ export const UserInfo_DelegationContext: MessageFns while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { + case 2: { + if (tag !== 18) { + break; + } + + message.externalChannelId = reader.string(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.roleType = UserInfo_DelegationContext_RoleType.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, +}; + +function createBaseUserInfo_DelegationContext_RoleType(): UserInfo_DelegationContext_RoleType { + return { channelRoleType: undefined }; +} + +export const UserInfo_DelegationContext_RoleType: MessageFns = { + encode(message: UserInfo_DelegationContext_RoleType, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.channelRoleType !== undefined) { + writer.uint32(8).int32(message.channelRoleType); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UserInfo_DelegationContext_RoleType { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUserInfo_DelegationContext_RoleType(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.channelRoleType = reader.int32() as any; + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -275,4 +347,4 @@ export const UserInfo_CredentialTransferToken: MessageFns { encode(message: T, writer?: BinaryWriter): BinaryWriter; decode(input: BinaryReader | Uint8Array, length?: number): T; -} +} \ No newline at end of file diff --git a/protos/misc/params.proto b/protos/misc/params.proto index b4d671b956..178b27ec43 100644 --- a/protos/misc/params.proto +++ b/protos/misc/params.proto @@ -278,3 +278,18 @@ message ClipParams { ClipParamsData clip_params_data = 50; } + +message CreateBackstagePostParams { + string channel_id = 1; + int32 unk_param_1 = 2; + + message Field6 { + int32 unk_param_3 = 3; + } + Field6 f6 = 6; + + message Field9 { + int32 unk_param_1 = 1; + } + Field9 f9 = 9; +} diff --git a/protos/youtube/api/pfiinnertube/user_info.proto b/protos/youtube/api/pfiinnertube/user_info.proto index 9ae94541cd..d3a84b71ea 100644 --- a/protos/youtube/api/pfiinnertube/user_info.proto +++ b/protos/youtube/api/pfiinnertube/user_info.proto @@ -14,6 +14,20 @@ message UserInfo { message KidsParent { } message DelegatePurchases { } - message DelegationContext { } + + message DelegationContext { + optional string external_channel_id = 2; + optional RoleType role_type = 5; + + message RoleType { + optional ChannelRoleType channel_role_type = 1; + + enum ChannelRoleType { + CHANNEL_ROLE_TYPE_UNKNOWN = 0; + CREATOR_CHANNEL_ROLE_TYPE_OWNER = 8; + } + } + } + message CredentialTransferToken { } } \ No newline at end of file diff --git a/src/Innertube.ts b/src/Innertube.ts index 208ba486d4..ea3fc02ea8 100644 --- a/src/Innertube.ts +++ b/src/Innertube.ts @@ -1,7 +1,7 @@ -import Session from './core/Session.js'; +import Session, { type ClientType, type PartialContext } from './core/Session.js'; import { Kids, Music, Studio } from './core/clients/index.js'; -import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.js'; +import { AccountManager, BotGuardManager, InteractionManager, PlaylistManager } from './core/managers/index.js'; import { Feed, TabbedFeed } from './core/mixins/index.js'; import { @@ -24,7 +24,7 @@ import NavigationEndpoint from './parser/classes/NavigationEndpoint.js'; import type Format from './parser/classes/misc/Format.js'; import * as Constants from './utils/Constants.js'; -import { generateRandomString, InnertubeError, throwIfMissing, u8ToBase64 } from './utils/Utils.js'; +import { generateRandomString, InnertubeError, parseLooseJSON, throwIfMissing, u8ToBase64 } from './utils/Utils.js'; import type { ApiResponse } from './core/Actions.js'; import type { @@ -34,9 +34,11 @@ import type { GetVideoInfoOptions, InnerTubeClient, InnerTubeConfig, - SearchFilters + SearchFilters, + BotGuardSolver, + BotGuardLogBinding } from './types/index.js'; -import type { IBrowseResponse, IParsedResponse } from './parser/index.js'; +import type { IBrowseResponse, IGetChallengeResponse, IParsedResponse, RawNode } from './parser/index.js'; import { CommunityPostCommentsParam, @@ -51,6 +53,8 @@ import { SearchFilter_Filters_UploadDate, SearchFilter_Prioritize } from '../protos/generated/misc/params.js'; +import PostManager from './core/managers/PostManager.js'; +import { parseResponse } from './parser/parser.js'; /** * Provides access to various services and modules in the YouTube API. @@ -556,17 +560,48 @@ export default class Innertube { /** * Fetches an attestation challenge. */ - async getAttestationChallenge(engagement_type: EngagementType, ids?: Record[]) { + async getAttestationChallenge(engagement_type: EngagementType, ids?: Record[], args?: {one_time_context?: PartialContext, eacr_token?: string, client?: InnerTubeClient} ) { const payload: Record = { engagementType: engagement_type }; - + if (ids) payload.ids = ids; + if (args?.eacr_token) + payload.eacrToken = args.eacr_token; + if (args?.client) + payload.client = args.client; + if (args?.one_time_context) + payload.one_time_context = args.one_time_context; return this.actions.execute('/att/get', { parse: true, ...payload }); } + async initialData(page_url: string) { + const url = new URL(page_url); + const inital_data = await this.session.http.fetch(url.href, { + method: 'GET' + }); + const html = await inital_data.text(); + + const ytcfg_regex = /ytcfg\.set\(({.+?})\);/s; + const attestation_data_regex = /window\.ytAtN\(\s*({[\s\S]*?})\s*\)/; + + const ytcfg_string = ytcfg_regex.exec(html)?.[1]; + const attestation_data_string = attestation_data_regex.exec(html)?.[1]; + + const ytcfg = ytcfg_string ? JSON.parse(ytcfg_string) as RawNode : null; + if (ytcfg?.EATS) this.#session.eats = ytcfg.EATS; + + const yt_atn: {R: RawNode, T: string}|null = attestation_data_string ? parseLooseJSON(attestation_data_string) : null; + + return { + ytcfg: ytcfg, + atn: yt_atn?.R ? parseResponse(yt_atn.R) : null, + eacr_token: yt_atn ? yt_atn.T : null + }; + } + /** * Utility method to call an endpoint without having to use {@link Actions}. */ @@ -611,6 +646,13 @@ export default class Innertube { return new PlaylistManager(this.#session.actions); } + /** + * An interface for managing posts. + */ + posts(botguard_solver: BotGuardSolver) { + return new PostManager(this, this.#session.actions, botguard_solver); + } + /** * An interface for directly interacting with certain YouTube features. */ @@ -618,6 +660,13 @@ export default class Innertube { return new InteractionManager(this.#session.actions); } + /** + * An interface for interacting with BotGuard + */ + get botguard() { + return new BotGuardManager(this); + } + /** * An internal class used to dispatch requests. */ diff --git a/src/core/Actions.ts b/src/core/Actions.ts index 31841407cb..7c9b5e0a68 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -1,7 +1,10 @@ import type { IBrowseResponse, + IESRChallengeResponse, IGetChallengeResponse, IGetNotificationsMenuResponse, + IGetSessionTokenResponse, + IGetWebReauthURLResponse, INextResponse, IParsedResponse, IPlayerHeartbeatResponse, @@ -14,7 +17,7 @@ import type { import { NavigateAction, Parser } from '../parser/index.js'; import { InnertubeError } from '../utils/Utils.js'; -import type { Session } from './index.js'; +import type { PartialContext, Session } from './index.js'; export interface ApiResponse { success: boolean; @@ -44,6 +47,9 @@ export type ParsedResponse = T extends '/navigation/resolve_url' ? IResolveURLResponse : T extends '/notification/get_notification_menu' ? IGetNotificationsMenuResponse : T extends '/att/get' ? IGetChallengeResponse : + T extends '/att/esr' ? IESRChallengeResponse : + T extends '/security/get_web_reauth_url' ? IGetWebReauthURLResponse : + T extends '/ars/grst' ? IGetSessionTokenResponse : IParsedResponse; export default class Actions { @@ -86,21 +92,24 @@ export default class Actions { parse: true; protobuf?: false; serialized_data?: any; - skip_auth_check?: boolean + skip_auth_check?: boolean; + one_time_context?: PartialContext; }): Promise>; async execute(endpoint: T, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any; - skip_auth_check?: boolean + skip_auth_check?: boolean; + one_time_context?: PartialContext; }): Promise; async execute(endpoint: T, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any; - skip_auth_check?: boolean + skip_auth_check?: boolean; + one_time_context?: PartialContext; }): Promise | ApiResponse> { let data; @@ -148,6 +157,11 @@ export default class Actions { if (data?.client === 'YTMUSIC') { data.isAudioOnly = true; } + + if (data?.client === 'WEB_CREATOR') { + if (!data.one_time_context) data.one_time_context = {}; + data.one_time_context.request = { eats: this.session.eats, ...data.one_time_context?.request }; + } } else if (args) { data = args.serialized_data; } @@ -164,8 +178,11 @@ export default class Actions { } }); + const response_json = await response.json(); + if (response_json?.eats) this.session.eats = response_json.eats; + if (args?.parse) { - let parsed_response = Parser.parseResponse>(await response.json()); + let parsed_response = Parser.parseResponse>(response_json); // Handle redirects if (this.#isBrowse(parsed_response) && parsed_response.on_response_received_actions?.[0]?.type === 'navigateAction') { @@ -182,7 +199,7 @@ export default class Actions { return { success: response.ok, status_code: response.status, - data: await response.json() + data: response_json }; } diff --git a/src/core/Session.ts b/src/core/Session.ts index 1c1f5c2c20..4548b186c5 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -93,6 +93,16 @@ export type Context = { enableSafetyMode: boolean; lockedSafetyMode: boolean; onBehalfOfUser?: string; + delegationContext?: { + externalChannelId: string, + roleType: { + channelRoleType: + 'CREATOR_CHANNEL_ROLE_TYPE_OWNER' | 'CREATOR_CHANNEL_ROLE_TYPE_MANAGER' | 'CREATOR_CHANNEL_ROLE_TYPE_EDITOR' | 'CREATOR_CHANNEL_ROLE_TYPE_EDITOR_LIMITED' | + 'CREATOR_CHANNEL_ROLE_TYPE_SUBTITLE_EDITOR' | 'CREATOR_CHANNEL_ROLE_TYPE_VIEWER' | 'CREATOR_CHANNEL_ROLE_TYPE_VIEWER_LIMITED' | 'CREATOR_CHANNEL_ROLE_TYPE_UNSPECIFIED' | + 'CREATOR_CHANNEL_ROLE_TYPE_MODERATOR' | 'CREATOR_CHANNEL_ROLE_TYPE_CUSTOM' + } + } + serializedDelegationContext?: string; }; thirdParty?: { embedUrl: string; @@ -100,9 +110,26 @@ export type Context = { request?: { useSsl: boolean; internalExperimentFlags: any[]; + eats?: string; + returnLogEntry?: boolean; + sessionInfo?: { token: string }; + attestationResponseData?: { + challenge: string; + webResponse: string; + }; + reauthRequestInfo?: { + encodedReauthProofToken: string; + }; }; } +export type PartialContext = { + client?: Partial; + user?: Partial; + thirdParty?: Partial; + request?: Partial; +}; + type ContextData = { hl: string; gl: string; @@ -261,6 +288,7 @@ export default class Session extends EventEmitter { public logged_in: boolean; public actions: Actions; public user_agent?: string; + public eats?: string; constructor( public context: Context, diff --git a/src/core/managers/BotGuardManager.ts b/src/core/managers/BotGuardManager.ts new file mode 100644 index 0000000000..fb7463c75c --- /dev/null +++ b/src/core/managers/BotGuardManager.ts @@ -0,0 +1,284 @@ +import type Innertube from '../../Innertube.js'; +import type RunAttestationCommand from '../../parser/classes/commands/RunAttestationCommand.js'; +import type { AttIdsRaw } from '../../parser/classes/commands/RunAttestationCommand.js'; +import type { IGetChallengeResponse, RawNode } from '../../parser/index.js'; +import type { BotGuardChallengeInfo, BotGuardSolver, BotGuardSolverChallenge, BotGuardLogBinding, BotGuardSessionTokenBinding } from '../../types/BotGuard.js'; +import type { EngagementType, InnerTubeClient } from '../../types/Misc.js'; +import { channelUserDelegationContext } from '../../utils/Context.js'; +import { InnertubeError } from '../../utils/Utils.js'; +import type { PartialContext } from '../index.js'; + +export interface ChallengeSolverArgsBase { + content_binding: (challenge: string, engagement_type: EngagementType, ids: AttIdsRaw[]) => T; + atn_page_url?: string; + eacr_token?: string; + client?: InnerTubeClient; + ytcfg?: RawNode; + one_time_context?: PartialContext; +} + +export interface ChallengeSolverArgsRunAttestationCommand extends ChallengeSolverArgsBase { + run_attestation_command: RunAttestationCommand; +} + +export interface ChallengeSolverArgsEngagement extends ChallengeSolverArgsBase { + engagement_type: EngagementType; + ids: AttIdsRaw[]; +} + +export type ChallengeSolverArgs = ChallengeSolverArgsEngagement | ChallengeSolverArgsRunAttestationCommand; +export type ChallengeNoBindingSolverArgs = Omit, 'content_binding'> | Omit, 'content_binding'>; +export type ChallengeFetchingArgs = Omit, 'content_binding'>; + +export type StudioActionsEngagementType = Extract; + +export default class BotGuardManager { + readonly #innertube: Innertube; + #botguard_challenge_info_cache: Record; + + constructor(innertube: Innertube) { + this.#innertube = innertube; + this.#botguard_challenge_info_cache = {}; + } + + #challengeExpired(challenge?: string) { + if (!challenge) return true; + const params = new URLSearchParams(challenge); + const issued_seconds = Number(params.get('c')); + const ttl_seconds = Number(params.get('t')); + if ((issued_seconds + ttl_seconds) * 1000 > Date.now()) return false; + return true; + } + + #innerCacheKey(engagement_type: EngagementType, ids: AttIdsRaw[], atn_page_url?: string) { + return engagement_type + JSON.stringify(ids) + (atn_page_url ? atn_page_url : ''); + } + + #cleanCache() { + for (const key of Object.keys(this.#botguard_challenge_info_cache)) { + if (this.#challengeExpired(this.#botguard_challenge_info_cache[key].challenge)) { + delete this.#botguard_challenge_info_cache[key]; + } + } + } + + #insertCache(challenge_info: BotGuardChallengeInfo, args: ChallengeFetchingArgs) { + this.#cleanCache(); + + const inner_cache_key = this.#innerCacheKey(args.engagement_type, args.ids, args.atn_page_url); + this.#botguard_challenge_info_cache[inner_cache_key] = challenge_info; + } + + #checkCache(args: ChallengeFetchingArgs): BotGuardChallengeInfo|null { + this.#cleanCache(); + + const inner_cache_key = this.#innerCacheKey(args.engagement_type, args.ids, args.atn_page_url); + if (!this.#challengeExpired(this.#botguard_challenge_info_cache[inner_cache_key]?.challenge)) + return this.#botguard_challenge_info_cache[inner_cache_key]; + return null; + } + + #challengeResponseToBotGuardChallengeInfo(challenge_response: IGetChallengeResponse, ytcfg?: RawNode): BotGuardChallengeInfo { + if (!challenge_response.challenge) throw new InnertubeError('Failed to get API attestation challenge info'); + if (challenge_response.bg_challenge) { + if (ytcfg) (challenge_response.bg_challenge as BotGuardSolverChallenge).ytcfg = ytcfg; + return challenge_response as BotGuardChallengeInfo; + } + if (challenge_response.botguard_data) return { + bg_challenge: { + global_name: 'trayride', + client_experiments_state_blob: '', + interpreter_hash: '', + interpreter_url: challenge_response.botguard_data.interpreter_url, + program: challenge_response.botguard_data.program, + ytcfg: ytcfg + }, + challenge: challenge_response.challenge + }; + throw new InnertubeError('Unable to parse challenge_response to botguard_challenge_info'); + } + + async #getApiChallenge(args: ChallengeFetchingArgs): Promise { + const cache_check = this.#checkCache(args); + if (cache_check) return cache_check; + + const challenge_response = await this.#innertube.getAttestationChallenge(args.engagement_type, args.ids, { + eacr_token: args.eacr_token, + client: args.client, + one_time_context: args.one_time_context + }); + const botguard_challenge_info = this.#challengeResponseToBotGuardChallengeInfo(challenge_response, args.ytcfg); + + this.#insertCache(botguard_challenge_info, args); + return botguard_challenge_info; + } + async #getPageChallenge(args: ChallengeFetchingArgs): Promise { + if (!args.atn_page_url) throw new InnertubeError('Assertion failed; \'atn_page_url\' was supposed to not be empty'); + const cache_check = this.#checkCache(args); + if (cache_check) return cache_check; + + const initial_data = await this.#innertube.initialData(args.atn_page_url); + if (!initial_data.atn && !initial_data.eacr_token) throw new InnertubeError(`Was unable to find a challenge in atn_page_url: ${args.atn_page_url}`); + const challenge_response = initial_data.atn ?? await this.#getApiChallenge({ ...args, engagement_type: 'ENGAGEMENT_TYPE_UNBOUND', eacr_token: initial_data.eacr_token! }); + const botguard_challenge_info = this.#challengeResponseToBotGuardChallengeInfo(challenge_response, initial_data.ytcfg ?? args.ytcfg); + + this.#insertCache(botguard_challenge_info, args); + return botguard_challenge_info; + } + + /** + * Fetches a BotGuard challenge. + * @param args - BotGuard challenge fetching args + */ + async getChallenge(args: ChallengeFetchingArgs): Promise { + if (!args.atn_page_url) return await this.#getApiChallenge(args); + return await this.#getPageChallenge(args); + }; + + #normalizeChallengeSolverArgs(args: ChallengeSolverArgs): ChallengeSolverArgsEngagement { + if (!('run_attestation_command' in args)) return args; + return { + engagement_type: args.run_attestation_command.engagement_type, + ids: args.run_attestation_command.raw_ids ?? [], + ...args + }; + } + + /** + * Fetches a challenge, runs it and returns its response. + * @param botguard_solver - The BotGuard challenge solver + * @param args - BotGuard challenge fetching and solving args + */ + async run(botguard_solver: BotGuardSolver, args: ChallengeSolverArgs) { + const normalized_args = this.#normalizeChallengeSolverArgs(args); + const challenge = await this.getChallenge(normalized_args); + return { + web_response: await botguard_solver.solve(challenge.bg_challenge, args.content_binding(challenge.challenge, normalized_args.engagement_type, normalized_args.ids)), + challenge + }; + } + + /** + * Fetches a challenge and logs its attestation. + * @param botguard_solver - The BotGuard challenge solver + * @param args - BotGuard challenge fetching args + */ + async log(botguard_solver: BotGuardSolver, args: ChallengeNoBindingSolverArgs) { + const log_content_binding_fn = (challenge: string, engagement_type: EngagementType, ids: AttIdsRaw[]): BotGuardLogBinding => { + const spread_ids = Object.assign({}, ...ids); + return { + c: challenge, + e: engagement_type, + ...spread_ids + }; + }; + const full_args = { ...args, content_binding: log_content_binding_fn } as ChallengeSolverArgs; + const normalized_opts = this.#normalizeChallengeSolverArgs(full_args); + const result = await this.run(botguard_solver, normalized_opts); + return await this.#innertube.actions.execute('/att/log', { + ...(args.client ? { client: args.client } : {}), + challenge: result.challenge.challenge, + engagementType: normalized_opts.engagement_type, + ids: normalized_opts.ids, + webResponse: result.web_response + }); + } + + /** + * Fetches the attestation challenge for various YouTube Studio actions + */ + async studioAttestationChallenge(){ + const challenge = await this.getChallenge({ + atn_page_url: 'https://studio.youtube.com/', + client: 'WEB_CREATOR', + engagement_type: 'ENGAGEMENT_TYPE_UNBOUND', + ids: [] + }); + return challenge; + } + + /** + * Fetches and solves the attestation for YouTube Studio + * @param botguard_solver - The BotGuard challenge solver + * @param engagement_type - The challenge engagement type + * @param ids - The challenge ids + */ + async studioAttestationResponseData(botguard_solver: BotGuardSolver, engagement_type: StudioActionsEngagementType, ids: AttIdsRaw[]){ + const challenge = await this.studioAttestationChallenge(); + const spread_ids = Object.assign({}, ...ids); + const binding = { + c: challenge.challenge, + e: engagement_type, + ...spread_ids + }; + const web_response = await botguard_solver.solve(challenge.bg_challenge, binding); + return { + challenge: challenge.challenge, + webResponse: web_response + }; + } + + /** + * Fetches and solves the entire session attestation routine for YouTube Studio + * @param botguard_solver - The BotGuard challenge solver + * @param channel_id - Channel ID of the target Studio session + */ + async studioSessionToken(botguard_solver: BotGuardSolver, channel_id: string) { + const session_token_binding_fn = (challenge: string): BotGuardSessionTokenBinding => ({ atr_challenge: challenge }); + const user_one_time_context = { user: channelUserDelegationContext(channel_id) }; + + // get initial eats & cache unbound challenge + await this.studioAttestationChallenge(); + + const creator_studio_result = await this.run(botguard_solver, { + content_binding: session_token_binding_fn, + one_time_context: user_one_time_context, + client: 'WEB_CREATOR', + engagement_type: 'ENGAGEMENT_TYPE_CREATOR_STUDIO_ACTION', + ids: [ { externalChannelId: channel_id } ] + }); + + const evaluate_session_risk_response = await this.#innertube.actions.execute('/att/esr', { + parse: true, + client: 'WEB_CREATOR', + challenge: creator_studio_result.challenge.challenge, + botguardResponse: creator_studio_result.web_response, + xguardClientStatus: 0, + one_time_context: user_one_time_context + }); + + if (evaluate_session_risk_response.session_token) return evaluate_session_risk_response.session_token; + + let grst_ctx = evaluate_session_risk_response.ctx; + let reauth_proof_token: string = ''; + if (evaluate_session_risk_response.should_fetch_reauth_session_token === true) { + const web_reauth_url = await this.#innertube.actions.execute('/security/get_web_reauth_url', { + parse: true, + client: 'WEB_CREATOR', + challenge: creator_studio_result.challenge.challenge, + botguardResponse: creator_studio_result.web_response, + continueUrl: 'https://studio.youtube.com/reauth', + flow: 'REAUTH_FLOW_YT_STUDIO_COLD_LOAD', + ivctx: evaluate_session_risk_response.ctx, + one_time_context: user_one_time_context + }); + + if (web_reauth_url.plt) throw new InnertubeError('Session has expired, try refreshing your login credentials then try again.'); + if (web_reauth_url.session_risk_ctx) grst_ctx = web_reauth_url.session_risk_ctx; + if (web_reauth_url.encoded_reauth_proof_token) reauth_proof_token = web_reauth_url.encoded_reauth_proof_token; + } + + const reauth_session_token = await this.#innertube.actions.execute('/ars/grst', { + parse: true, + client: 'WEB_CREATOR', + ctx: grst_ctx, + one_time_context: { + ...user_one_time_context, + request: { reauthRequestInfo: { encodedReauthProofToken: reauth_proof_token } } + } + }); + + if (reauth_session_token.session_token === undefined) throw new InnertubeError('/ars/grst did not return a session token'); + return reauth_session_token.session_token; + } +} \ No newline at end of file diff --git a/src/core/managers/PostManager.ts b/src/core/managers/PostManager.ts new file mode 100644 index 0000000000..4246c7ffcb --- /dev/null +++ b/src/core/managers/PostManager.ts @@ -0,0 +1,149 @@ +import type { Actions } from '../index.js'; +import type Innertube from '../../Innertube.js'; +import { InnertubeError } from '../../utils/Utils.js'; +import type { BotGuardLogBinding, BotGuardSolver } from '../../types/BotGuard.js'; +import { AddBackstagePostAction, BackstagePost, RunAttestationCommand } from '../../parser/nodes.js'; +import { encodeCreateBackstagePostParams } from '../../utils/ProtoUtils.js'; +import type { CreatePostImage, CreatePostExtraOptions, CreatePostPayload, ImagesAttachment_PostImageData, PollAttachmentData_Option, CreatePostPayloadBase, CreatePostBaseOptions } from '../../types/CreatePost.ts'; +import type BotGuardManager from './BotGuardManager.js'; + +export interface CreatePostResponse { + post_id: string; + post?: BackstagePost; +} + +export default class PostManager { + readonly #innertube: Innertube; + readonly #actions: Actions; + readonly #botguard_solver: BotGuardSolver; + readonly #botguard: BotGuardManager; + + constructor(innertube: Innertube, actions: Actions, botguard_solver: BotGuardSolver) { + this.#innertube = innertube; + this.#actions = actions; + this.#botguard_solver = botguard_solver; + this.#botguard = this.#innertube.botguard; + } + + async #uploadImage(image: CreatePostImage, channel_id: string){ + const base_url = 'https://www.youtube.com/'; + const image_get_upload_url_response = await this.#actions.session.http.fetch('/channel_image_upload/posts', { + baseURL: base_url, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + 'x-goog-upload-command': 'start', + 'x-goog-upload-header-content-length': String(image.source.size), + 'x-goog-upload-protocol': 'resumable', + 'x-youtube-channelid': channel_id + } + }); + + const upload_url = image_get_upload_url_response.headers.get('x-goog-upload-url'); + if (!upload_url) throw new InnertubeError('Failed to get image upload url.'); + + const image_upload_response = await this.#actions.session.http.fetch(upload_url, { + baseURL: base_url, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + 'x-goog-upload-command': 'upload, finalize', + 'x-goog-upload-offset': '0', + 'x-youtube-channelid': channel_id + }, + body: image.source.body + }); + const image_upload_json = await image_upload_response.json() as { encryptedBlobId: string }; + return image_upload_json.encryptedBlobId; + } + async #buildPost(opts: CreatePostBaseOptions, channel_id: string, extra_opts?: CreatePostExtraOptions): Promise { + if (opts.scheduled_publish_time_seconds && !Number.isInteger(Number(opts.scheduled_publish_time_seconds))) + throw new Error('opts.scheduled_publish_time_seconds must be an integer'); + const base_post: CreatePostPayloadBase = { + commentText: opts.comment_text, + scheduledPublishTimeSec: opts.scheduled_publish_time_seconds, + createBackstagePostParams: encodeCreateBackstagePostParams(channel_id) + }; + if (!extra_opts) return base_post; + switch (extra_opts.type) { + case 'VIDEO': return { + ...base_post, + videoAttachment: { videoId: extra_opts.video_id } + }; + case 'POLL': return { + ...base_post, + pollAttachment: { choices: extra_opts.choices } + }; + case 'QUIZ': return { + ...base_post, + quizAttachmentData: { options: extra_opts.choices.map((choice) => ( + { + pollOption: { + text: choice.text, + explanation: choice.explanation + }, + isCorrect: choice.is_correct + })) + } + }; + case 'IMAGE': { + const images_data: ImagesAttachment_PostImageData[] = await Promise.all( + extra_opts.images.map(async(image) => ( + { + encryptedBlobId: await this.#uploadImage(image, channel_id), + previewCoordinates: image.preview_coordinates + } + ))); + return { + ...base_post, + imagesAttachment: { imagesData: images_data } + }; + } + case 'IMAGE_POLL': { + const data: PollAttachmentData_Option[] = await Promise.all( + extra_opts.options.map(async(opt, index) => ( + { + image: { + encryptedBlobId: await this.#uploadImage(opt.image, channel_id), + id: index, + imageBlob: {}, + localImageSrc: { + privateDoNotAccessOrElseWrappedUrl: opt.image.source.base64 + }, + naturalHeight: opt.image.source.height, + naturalWidth: opt.image.source.width, + previewCoordinates: opt.image.preview_coordinates + }, + text: opt.text + } + ))); + return { + ...base_post, + pollAttachmentData: { options: data } + }; + } + default: throw new Error('Unknown CreatePostOptions type'); + } + } + + async create(opts: Omit, channel_id: string, extra_opts?: CreatePostExtraOptions): Promise> + async create(opts: Required, channel_id: string, extra_opts?: CreatePostExtraOptions): Promise> + async create(opts: CreatePostBaseOptions, channel_id: string, extra_opts?: CreatePostExtraOptions): Promise { + if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); + const create_post_response = await this.#actions.execute('/backstage/create_post', { + parse: true, + ...(await this.#buildPost(opts, channel_id, extra_opts)) + }); + if (!create_post_response.actions?.is_array) throw new InnertubeError('create_post_response doesn\'t have any actions'); + const attestation_command = create_post_response.actions.array().firstOfType(RunAttestationCommand); + if (!attestation_command) throw new InnertubeError('Post didn\'t send an attestation command'); + + this.#botguard.log(this.#botguard_solver, { run_attestation_command: attestation_command, atn_page_url: `https://www.youtube.com/channel/${channel_id}/posts` }); + const add_backstage_post_action = create_post_response.actions.array().firstOfType(AddBackstagePostAction); + + return { + post_id: attestation_command.ids?.[0].external_post_id ?? '', + post: add_backstage_post_action?.renderer?.post.as(BackstagePost) + }; + } +} \ No newline at end of file diff --git a/src/core/managers/index.ts b/src/core/managers/index.ts index 1fcb25d1ad..956cbac1d9 100644 --- a/src/core/managers/index.ts +++ b/src/core/managers/index.ts @@ -1,3 +1,4 @@ export { default as AccountManager } from './AccountManager.js'; export { default as PlaylistManager } from './PlaylistManager.js'; -export { default as InteractionManager } from './InteractionManager.js'; \ No newline at end of file +export { default as InteractionManager } from './InteractionManager.js'; +export { default as BotGuardManager } from './BotGuardManager.js'; \ No newline at end of file diff --git a/src/parser/classes/BackstagePostDialog.ts b/src/parser/classes/BackstagePostDialog.ts new file mode 100644 index 0000000000..a4027ba216 --- /dev/null +++ b/src/parser/classes/BackstagePostDialog.ts @@ -0,0 +1,527 @@ +import { YTNode } from '../helpers.js'; +import { Parser, type RawNode, YTNodes } from '../index.js'; +import { Thumbnail } from '../misc.js'; +import NavigationEndpoint from './NavigationEndpoint.js'; +import AccessibilityData, { type AccessibilitySupportedDatas } from './misc/AccessibilityData.js'; +import Text from './misc/Text.js'; + +type PostAttachmentType = 'UNKNOWN' + | 'NONE' | 'IMAGE' | 'VIDEO_LINK' + | 'POLL' | 'PLAYLIST_LINK' | 'POST_LINK' + | 'CLIP' | 'QUIZ' | 'UPLOADED_VIDEOS'; + +type BackstagePostType = 'UNKNOWN' + | 'TEXT' | 'POLL' | 'IMAGE' + | 'VIDEO' | 'REPOST'; + +type BackstagePostDialogPurpose = 'UNKNOWN' | 'CREATE' | 'EDIT'; + +type BackstagePostDialogStartingState = 'UNSPECIFIED' + | 'TEXT' | 'TEXT_POLL' | 'IMAGE' + | 'IMAGE_POLL' | 'QUIZ'; + +export interface PollEditorInfo { + add_option_button?: YTNodes.Button | null; + custom_option_placeholder_text?: Text[]; + header_icon_type?: string; + header_text?: Text; + max_poll_option_chars?: number; + max_poll_options?: number; + max_poll_question_chars?: number; + min_poll_option_chars?: number; + min_poll_options?: number; + option_default_text?: Text; + option_placeholder_text?: Text; + poll_option_data?: { + char_count_error_text?: Text; + chars_remaining_alt_label?: Text; + remove_option_alt_text?: Text; + }; + poll_question_char_count_error_text?: Text; + question_placeholder_text?: Text; +}; + +export interface QuizEditorInfo { + add_option_button?: YTNodes.Button | null; + explanation_placeholder_text?: Text; + mark_answer_button?: YTNodes.Button | null; + max_quiz_explanation_chars?: number; + max_quiz_option_chars?: number; + max_quiz_options?: number; + min_quiz_explanation_chars?: number; + min_quiz_option_chars?: number; + min_quiz_options?: number; + option_placeholder_text?: Text; + remove_option_button_a11y_text?: AccessibilitySupportedDatas; +}; + +export default class BackstagePostDialog extends YTNode { + static type = 'BackstagePostDialog'; + + access_restriction_selector_key?: string; + advanced_tier_features: { + external_links: { + allow_external_links?: boolean, + external_links_rfa_entity_key?: string, + verification_banner_button_label?: Text, + verification_banner_message?: Text, + verification_button?: YTNodes.Button | null, + verification_command?: NavigationEndpoint + }, + rate_limit?: { + rate_limit_commands?: { + command?: NavigationEndpoint, + error_label?: string + }[] + } + }; + attachment_editors?: { + poll_editor_renderer?: YTNode | null; + quiz_editor_renderer?: YTNode | null; + }; + attachment_element_renderer?: YTNode | null; + attachment_dismiss_button?: YTNode | null; + attachment_type: PostAttachmentType; + author_text: Text; + author_thumbnail: Thumbnail[]; + backstage_attachment?: YTNode | null; + cancel_button: YTNodes.Button | null; + char_limit?: number; + creator_onboarding_promo_renderer?: YTNode | null; + creator_survey_command?: NavigationEndpoint; + dialog_banner?: YTNode | null; + editable_text: Text; + enable_async_image_upload?: boolean; + enable_creation_modes_ui?: boolean; + error_message: Text; + expire_icon_button?: YTNode | null; + extract_tags_endpoint?: NavigationEndpoint; + fan_community_data?: { + fan_community_guidelines_element_renderer?: YTNode | null; + fan_community_guidelines_state_key?: string; + host_channel_name?: string; + show_fan_community_guidelines_command?: NavigationEndpoint; + }; + fan_community_guidelines_state_key?: string; + gallery_teaser_data?: { + max_image_count?: number; + min_image_count?: number; + }; + header_renderer?: YTNode | null; + image_button?: YTNode | null; + image_carousel_editor?: YTNode | null; + image_editor_renderer?: YTNode | null; + image_poll_button?: YTNode | null; + image_poll_button_tooltip?: Text; + image_poll_editor_info?: PollEditorInfo; + image_poll_editor_renderer?: YTNode | null; + image_post_placeholder_text: Text; + media_generation_data?: { + text_generation?: { + open_text_transform_panel_command?: NavigationEndpoint; + text_transform_entity_key?: string; + }; + text_to_image_generation?: { + gallery_teaser_entry_point?: YTNode | null; + }; + }; + metadata_editor?: YTNode | null; + multi_image_button_tooltip?: Text; + on_show_command?: NavigationEndpoint; + placeholder_text: Text; + poll_button?: YTNode | null; + poll_editor_info?: PollEditorInfo; + poll_post_placeholder_text: Text; + poll_type_selection_renderer?: YTNode | null; + post_button: YTNodes.Button | null; + post_creation_data_key?: string; + post_creation_disabled_annotation?: Text; + post_creation_disabled_text?: Text; + post_ephemerality_dialog_renderer?: YTNode | null; + post_ephemerality_settings_entity_key?: string; + post_options_menu?: YTNode | null; + prefilled_attachment?: { + poll_data?: { + choices?: string[]; + }; + }; + prefilled_image_data?: { + encrypted_blob_id?: string; + source_external_video_id?: string; + }; + prefilled_placeholder_text?: Text; + prefilled_type?: BackstagePostType; + purpose: BackstagePostDialogPurpose; + quiz_button?: YTNode | null; + quiz_editor_info?: QuizEditorInfo; + quiz_post_placeholder_text: Text; + rate_limit_error_message: Text; + schedule_button?: YTNode | null; + schedule_icon_button?: YTNode | null; + scheduled_publish_time_bar?: YTNode | null; + scheduled_publish_time_sec?: string; + scheduling_date_time_picker?: YTNode | null; + select_tagged_video_button?: YTNode | null; + selected_video_entity_key?: string; + serialized_creation_item_info: string; + show_aadc_notice_command?: NavigationEndpoint; + show_audience_picker_command?: NavigationEndpoint; + show_ephemerality_bottom_sheet_command?: NavigationEndpoint; + show_fan_community_guidelines_command?: NavigationEndpoint; + starting_state?: BackstagePostDialogStartingState; + timestamp_entity_key?: string; + title: Text; + tracking_params?: string; + user_mention_suggestions_endpoint?: NavigationEndpoint; + video_link_button?: YTNode | null; + video_link_post_placeholder_text: Text; + visibility_label: Text; + + constructor(data: RawNode) { + super(); + + this.author_thumbnail = Thumbnail.fromResponse(data.authorThumbnail); + this.post_button = Parser.parseItem(data.postButton, YTNodes.Button); + this.cancel_button = Parser.parseItem(data.cancelButton, YTNodes.Button); + this.placeholder_text = new Text(data.placeholderText); + this.editable_text = new Text(data.editableText); + this.title = new Text(data.title); + this.image_post_placeholder_text = new Text(data.imagePostPlaceholderText); + this.poll_post_placeholder_text = new Text(data.pollPostPlaceholderText); + this.video_link_post_placeholder_text = new Text(data.videoLinkPostPlaceholderText); + this.error_message = new Text(data.errorMessage); + this.purpose = data.purpose?.replace('BACKSTAGE_POST_DIALOG_PURPOSE_', ''); + this.serialized_creation_item_info = data.serializedCreationItemInfo; + this.author_text = new Text(data.authorText); + this.visibility_label = new Text(data.visibilityLabel); + this.attachment_type = data.attachmentType?.replace('POST_ATTACHMENT_TYPE_ENUM_', ''); + this.rate_limit_error_message = new Text(data.rateLimitErrorMessage); + this.quiz_post_placeholder_text = new Text(data.quizPostPlaceholderText); + + this.advanced_tier_features = { + external_links: { + allow_external_links: data.advancedTierFeatures?.externalLinks?.allowExternalLinks, + external_links_rfa_entity_key: data.advancedTierFeatures?.externalLinks?.externalLinksRfaEntityKey, + verification_banner_button_label: Reflect.has(data.advancedTierFeatures?.externalLinks ?? {}, 'verificationBannerButtonLabel') ? + new Text(data.advancedTierFeatures.externalLinks.verificationBannerButtonLabel) : undefined, + verification_banner_message: Reflect.has(data.advancedTierFeatures?.externalLinks ?? {}, 'verificationBannerMessage') ? + new Text(data.advancedTierFeatures.externalLinks.verificationBannerMessage) : undefined, + verification_button: Parser.parseItem(data.advancedTierFeatures?.externalLinks?.verificationButton, YTNodes.Button), + verification_command: Reflect.has(data.advancedTierFeatures?.externalLinks ?? {}, 'verificationCommand') ? + new NavigationEndpoint(data.advancedTierFeatures.externalLinks.verificationCommand) : undefined + }, + rate_limit: Reflect.has(data.advancedTierFeatures ?? {}, 'rateLimit') ? { + rate_limit_commands: data.advancedTierFeatures.rateLimit.rateLimitCommands?.map((entry: RawNode) => ({ + command: Reflect.has(entry, 'command') ? new NavigationEndpoint(entry.command) : undefined, + error_label: entry.errorLabel + })) + } : undefined + }; + + if (Reflect.has(data, 'attachmentDismissButton')) { + this.attachment_dismiss_button = Parser.parseItem(data.attachmentDismissButton, YTNodes.Button); + } + + if (Reflect.has(data, 'attachmentEditors')) { + this.attachment_editors = { + poll_editor_renderer: Parser.parseItem(data.attachmentEditors.pollEditorRenderer), + quiz_editor_renderer: Parser.parseItem(data.attachmentEditors.quizEditorRenderer) + }; + } + + if (Reflect.has(data, 'attachmentElementRenderer')) { + this.attachment_element_renderer = Parser.parseItem(data.attachmentElementRenderer); + } + + if (Reflect.has(data, 'backstageAttachment')) { + this.backstage_attachment = Parser.parseItem(data.backstageAttachment); + } + + if (Reflect.has(data, 'charLimit')) { + this.char_limit = data.charLimit; + } + + if (Reflect.has(data, 'creatorOnboardingPromoRenderer')) { + this.creator_onboarding_promo_renderer = Parser.parseItem(data.creatorOnboardingPromoRenderer); + } + + if (Reflect.has(data, 'creatorSurveyCommand')) { + this.creator_survey_command = new NavigationEndpoint(data.creatorSurveyCommand); + } + + if (Reflect.has(data, 'dialogBanner')) { + this.dialog_banner = Parser.parseItem(data.dialogBanner); + } + + if (Reflect.has(data, 'enableAsyncImageUpload')) { + this.enable_async_image_upload = data.enableAsyncImageUpload; + } + + if (Reflect.has(data, 'enableCreationModesUi')) { + this.enable_creation_modes_ui = data.enableCreationModesUi; + } + + if (Reflect.has(data, 'expireIconButton')) { + this.expire_icon_button = Parser.parseItem(data.expireIconButton); + } + + if (Reflect.has(data, 'extractTagsEndpoint')) { + this.extract_tags_endpoint = new NavigationEndpoint(data.extractTagsEndpoint); + } + + if (Reflect.has(data, 'fanCommunityData')) { + this.fan_community_data = { + fan_community_guidelines_element_renderer: Parser.parseItem(data.fanCommunityData.fanCommunityGuidelinesElementRenderer), + fan_community_guidelines_state_key: data.fanCommunityData.fanCommunityGuidelinesStateKey, + host_channel_name: data.fanCommunityData.hostChannelName, + show_fan_community_guidelines_command: Reflect.has(data.fanCommunityData, 'showFanCommunityGuidelinesCommand') ? + new NavigationEndpoint(data.fanCommunityData.showFanCommunityGuidelinesCommand) : undefined + }; + } + + if (Reflect.has(data, 'fanCommunityGuidelinesStateKey')) { + this.fan_community_guidelines_state_key = data.fanCommunityGuidelinesStateKey; + } + + if (Reflect.has(data, 'galleryTeaserData')) { + this.gallery_teaser_data = { + max_image_count: data.galleryTeaserData.maxImageCount, + min_image_count: data.galleryTeaserData.minImageCount + }; + } + + if (Reflect.has(data, 'headerRenderer')) { + this.header_renderer = Parser.parseItem(data.headerRenderer); + } + + if (Reflect.has(data, 'imageButton')) { + this.image_button = Parser.parseItem(data.imageButton); + } + + if (Reflect.has(data, 'imageCarouselEditor')) { + this.image_carousel_editor = Parser.parseItem(data.imageCarouselEditor); + } + + if (Reflect.has(data, 'imageEditorRenderer')) { + this.image_editor_renderer = Parser.parseItem(data.imageEditorRenderer); + } + + if (Reflect.has(data, 'imagePollButton')) { + this.image_poll_button = Parser.parseItem(data.imagePollButton); + } + + if (Reflect.has(data, 'imagePollButtonTooltip')) { + this.image_poll_button_tooltip = new Text(data.imagePollButtonTooltip); + } + + if (Reflect.has(data, 'imagePollEditorInfo')) { + this.image_poll_editor_info = this.#parsePollEditorInfo(data.imagePollEditorInfo); + } + + if (Reflect.has(data, 'imagePollEditorRenderer')) { + this.image_poll_editor_renderer = Parser.parseItem(data.imagePollEditorRenderer); + } + + if (Reflect.has(data, 'mediaGenerationData')) { + this.media_generation_data = { + text_generation: Reflect.has(data.mediaGenerationData, 'textGeneration') ? { + open_text_transform_panel_command: Reflect.has(data.mediaGenerationData.textGeneration, 'openTextTransformPanelCommand') ? + new NavigationEndpoint(data.mediaGenerationData.textGeneration.openTextTransformPanelCommand) : undefined, + text_transform_entity_key: data.mediaGenerationData.textGeneration.textTransformEntityKey + } : undefined, + text_to_image_generation: Reflect.has(data.mediaGenerationData, 'textToImageGeneration') ? { + gallery_teaser_entry_point: Parser.parseItem(data.mediaGenerationData.textToImageGeneration.galleryTeaserEntryPoint) + } : undefined + }; + } + + if (Reflect.has(data, 'metadataEditor')) { + this.metadata_editor = Parser.parseItem(data.metadataEditor); + } + + if (Reflect.has(data, 'multiImageButtonTooltip')) { + this.multi_image_button_tooltip = new Text(data.multiImageButtonTooltip); + } + + if (Reflect.has(data, 'onShowCommand')) { + this.on_show_command = new NavigationEndpoint(data.onShowCommand); + } + + if (Reflect.has(data, 'pollButton')) { + this.poll_button = Parser.parseItem(data.pollButton); + } + + if (Reflect.has(data, 'pollEditorInfo')) { + this.poll_editor_info = this.#parsePollEditorInfo(data.pollEditorInfo); + } + + if (Reflect.has(data, 'pollTypeSelectionRenderer')) { + this.poll_type_selection_renderer = Parser.parseItem(data.pollTypeSelectionRenderer); + } + + if (Reflect.has(data, 'postCreationDataKey')) { + this.post_creation_data_key = data.postCreationDataKey; + } + + if (Reflect.has(data, 'postCreationDisabledAnnotation')) { + this.post_creation_disabled_annotation = new Text(data.postCreationDisabledAnnotation); + } + + if (Reflect.has(data, 'postCreationDisabledText')) { + this.post_creation_disabled_text = new Text(data.postCreationDisabledText); + } + + if (Reflect.has(data, 'postEphemeralityDialogRenderer')) { + this.post_ephemerality_dialog_renderer = Parser.parseItem(data.postEphemeralityDialogRenderer); + } + + if (Reflect.has(data, 'postEphemeralitySettingsEntityKey')) { + this.post_ephemerality_settings_entity_key = data.postEphemeralitySettingsEntityKey; + } + + if (Reflect.has(data, 'postOptionsMenu')) { + this.post_options_menu = Parser.parseItem(data.postOptionsMenu); + } + + if (Reflect.has(data, 'prefilledAttachment')) { + this.prefilled_attachment = { + poll_data: Reflect.has(data.prefilledAttachment, 'pollData') ? { + choices: data.prefilledAttachment.pollData.choices + } : undefined + }; + } + + if (Reflect.has(data, 'prefilledImageData')) { + this.prefilled_image_data = { + encrypted_blob_id: data.prefilledImageData.encryptedBlobId, + source_external_video_id: data.prefilledImageData.sourceExternalVideoId + }; + } + + if (Reflect.has(data, 'prefilledPlaceholderText')) { + this.prefilled_placeholder_text = new Text(data.prefilledPlaceholderText); + } + + if (Reflect.has(data, 'prefilledType')) { + this.prefilled_type = data.prefilledType?.replace('BACKSTAGE_POST_TYPE_', ''); + } + + if (Reflect.has(data, 'quizButton')) { + this.quiz_button = Parser.parseItem(data.quizButton); + } + + if (Reflect.has(data, 'quizEditorInfo')) { + this.quiz_editor_info = { + add_option_button: Parser.parseItem(data.quizEditorInfo.addOptionButton, YTNodes.Button), + explanation_placeholder_text: Reflect.has(data.quizEditorInfo, 'explanationPlaceholderText') ? + new Text(data.quizEditorInfo.explanationPlaceholderText) : undefined, + mark_answer_button: Parser.parseItem(data.quizEditorInfo.markAnswerButton, YTNodes.Button), + max_quiz_explanation_chars: data.quizEditorInfo.maxQuizExplanationChars, + max_quiz_option_chars: data.quizEditorInfo.maxQuizOptionChars, + max_quiz_options: data.quizEditorInfo.maxQuizOptions, + min_quiz_explanation_chars: data.quizEditorInfo.minQuizExplanationChars, + min_quiz_option_chars: data.quizEditorInfo.minQuizOptionChars, + min_quiz_options: data.quizEditorInfo.minQuizOptions, + option_placeholder_text: Reflect.has(data.quizEditorInfo, 'optionPlaceholderText') ? + new Text(data.quizEditorInfo.optionPlaceholderText) : undefined, + remove_option_button_a11y_text: Reflect.has(data.quizEditorInfo, 'removeOptionButtonA11yText') && + Reflect.has(data.quizEditorInfo.removeOptionButtonA11yText, 'accessibilityData') ? { + accessibility_data: new AccessibilityData(data.quizEditorInfo.removeOptionButtonA11yText.accessibilityData) + } : undefined + }; + } + + if (Reflect.has(data, 'scheduleButton')) { + this.schedule_button = Parser.parseItem(data.scheduleButton); + } + + if (Reflect.has(data, 'scheduleIconButton')) { + this.schedule_icon_button = Parser.parseItem(data.scheduleIconButton); + } + + if (Reflect.has(data, 'scheduledPublishTimeBar')) { + this.scheduled_publish_time_bar = Parser.parseItem(data.scheduledPublishTimeBar); + } + + if (Reflect.has(data, 'scheduledPublishTimeSec')) { + this.scheduled_publish_time_sec = data.scheduledPublishTimeSec; + } + + if (Reflect.has(data, 'schedulingDateTimePicker')) { + this.scheduling_date_time_picker = Parser.parseItem(data.schedulingDateTimePicker); + } + + if (Reflect.has(data, 'selectTaggedVideoButton')) { + this.select_tagged_video_button = Parser.parseItem(data.selectTaggedVideoButton); + } + + if (Reflect.has(data, 'selectedVideoEntityKey')) { + this.selected_video_entity_key = data.selectedVideoEntityKey; + } + + if (Reflect.has(data, 'showAadcNoticeCommand')) { + this.show_aadc_notice_command = new NavigationEndpoint(data.showAadcNoticeCommand); + } + + if (Reflect.has(data, 'showAudiencePickerCommand')) { + this.show_audience_picker_command = new NavigationEndpoint(data.showAudiencePickerCommand); + } + + if (Reflect.has(data, 'showEphemeralityBottomSheetCommand')) { + this.show_ephemerality_bottom_sheet_command = new NavigationEndpoint(data.showEphemeralityBottomSheetCommand); + } + + if (Reflect.has(data, 'showFanCommunityGuidelinesCommand')) { + this.show_fan_community_guidelines_command = new NavigationEndpoint(data.showFanCommunityGuidelinesCommand); + } + + if (Reflect.has(data, 'startingState')) { + this.starting_state = data.startingState?.replace('BACKSTAGE_POST_DIALOG_STARTING_STATE_', ''); + } + + if (Reflect.has(data, 'timestampEntityKey')) { + this.timestamp_entity_key = data.timestampEntityKey; + } + + if (Reflect.has(data, 'trackingParams')) { + this.tracking_params = data.trackingParams; + } + + if (Reflect.has(data, 'userMentionSuggestionsEndpoint')) { + this.user_mention_suggestions_endpoint = new NavigationEndpoint(data.userMentionSuggestionsEndpoint); + } + + if (Reflect.has(data, 'videoLinkButton')) { + this.video_link_button = Parser.parseItem(data.videoLinkButton); + } + } + + #parsePollEditorInfo(raw: RawNode): PollEditorInfo { + return { + add_option_button: Parser.parseItem(raw.addOptionButton, YTNodes.Button), + custom_option_placeholder_text: Reflect.has(raw, 'customOptionPlaceholderText') ? + raw.customOptionPlaceholderText.map((text: RawNode) => new Text(text)) : undefined, + header_icon_type: Reflect.has(raw, 'headerIcon') && Reflect.has(raw.headerIcon, 'iconType') ? + raw.headerIcon.iconType : undefined, + header_text: Reflect.has(raw, 'headerText') ? new Text(raw.headerText) : undefined, + max_poll_option_chars: raw.maxPollOptionChars, + max_poll_options: raw.maxPollOptions, + max_poll_question_chars: raw.maxPollQuestionChars, + min_poll_option_chars: raw.minPollOptionChars, + min_poll_options: raw.minPollOptions, + option_default_text: Reflect.has(raw, 'optionDefaultText') ? new Text(raw.optionDefaultText) : undefined, + option_placeholder_text: Reflect.has(raw, 'optionPlaceholderText') ? new Text(raw.optionPlaceholderText) : undefined, + poll_option_data: Reflect.has(raw, 'pollOptionData') ? { + char_count_error_text: Reflect.has(raw.pollOptionData, 'charCountErrorText') ? + new Text(raw.pollOptionData.charCountErrorText) : undefined, + chars_remaining_alt_label: Reflect.has(raw.pollOptionData, 'charsRemainingAltLabel') ? + new Text(raw.pollOptionData.charsRemainingAltLabel) : undefined, + remove_option_alt_text: Reflect.has(raw.pollOptionData, 'removeOptionAltText') ? + new Text(raw.pollOptionData.removeOptionAltText) : undefined + } : undefined, + poll_question_char_count_error_text: Reflect.has(raw, 'pollQuestionCharCountErrorText') ? + new Text(raw.pollQuestionCharCountErrorText) : undefined, + question_placeholder_text: Reflect.has(raw, 'questionPlaceholderText') ? new Text(raw.questionPlaceholderText) : undefined + }; + } +} diff --git a/src/parser/classes/actions/AddBackstagePostAction.ts b/src/parser/classes/actions/AddBackstagePostAction.ts new file mode 100644 index 0000000000..b9c0f8f271 --- /dev/null +++ b/src/parser/classes/actions/AddBackstagePostAction.ts @@ -0,0 +1,15 @@ +import { Parser } from '../../index.js'; +import { YTNode } from '../../helpers.js'; +import type { RawNode } from '../../index.js'; +import BackstagePostThread from '../BackstagePostThread.js'; + +export default class AddBackstagePostAction extends YTNode { + static type = 'AddBackstagePostAction'; + + renderer?: BackstagePostThread; + + constructor(data: RawNode) { + super(); + this.renderer = Parser.parseItem(data.renderer).as(BackstagePostThread); + } +} \ No newline at end of file diff --git a/src/parser/classes/actions/UpdatePostsTotalCountAction.ts b/src/parser/classes/actions/UpdatePostsTotalCountAction.ts new file mode 100644 index 0000000000..2cf320fbe9 --- /dev/null +++ b/src/parser/classes/actions/UpdatePostsTotalCountAction.ts @@ -0,0 +1,21 @@ +import { YTNode } from '../../helpers.js'; +import type { RawNode } from '../../types/RawResponse.js'; + +export default class UpdatePostsTotalCountAction extends YTNode { + static type = 'UpdatePostsTotalCountAction'; + + update_type?: 'UNKNOWN'|'ADD'|'REMOVE'; + number?: number; + + constructor(data: RawNode) { + super(); + + if (Reflect.has(data, 'updateType')) { + this.update_type = data.updateType.replace('UPDATE_POSTS_TOTAL_COUNT_TYPE_', ''); + } + + if (Reflect.has(data, 'number')) { + this.number = data.number; + } + } +} \ No newline at end of file diff --git a/src/parser/classes/commands/RunAttestationCommand.ts b/src/parser/classes/commands/RunAttestationCommand.ts index d0c1625d22..89cdae2fc8 100644 --- a/src/parser/classes/commands/RunAttestationCommand.ts +++ b/src/parser/classes/commands/RunAttestationCommand.ts @@ -1,5 +1,6 @@ +import type { EngagementType } from '../../../types/Misc.js'; import { YTNode } from '../../helpers.js'; -import type { RawNode } from '../../types/index.js'; +import type { IGetChallengeResponse, RawNode } from '../../types/index.js'; export type AttIds = { encrypted_video_id?: string; @@ -12,16 +13,30 @@ export type AttIds = { share_id?: string; } +export type AttIdsRaw = { + encryptedVideoId?: string; + externalChannelId?: string; + commentId?: string; + externalOwnerId?: string; + artistId?: string; + playlistId?: string; + externalPostId?: string; + shareId?: string; +} + export default class RunAttestationCommand extends YTNode { + static page_attestation_cache: Record = {}; static type = 'RunAttestationCommand'; - - public engagement_type: string; + + public engagement_type: EngagementType; public ids?: AttIds[]; + public raw_ids?: AttIdsRaw[]; constructor(data: RawNode) { super(); this.engagement_type = data.engagementType; if (Reflect.has(data, 'ids')) { + this.raw_ids = data.ids; this.ids = data.ids.map((id: RawNode) => ({ encrypted_video_id: id.encryptedVideoId, external_channel_id: id.externalChannelId, diff --git a/src/parser/nodes.ts b/src/parser/nodes.ts index e898f6aa32..5ace068591 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -8,6 +8,7 @@ export { default as AccountItem } from './classes/AccountItem.js'; export { default as AccountItemSection } from './classes/AccountItemSection.js'; export { default as AccountItemSectionHeader } from './classes/AccountItemSectionHeader.js'; export { default as AccountSectionList } from './classes/AccountSectionList.js'; +export { default as AddBackstagePostAction } from './classes/actions/AddBackstagePostAction.js'; export { default as AppendContinuationItemsAction } from './classes/actions/AppendContinuationItemsAction.js'; export { default as ChangeEngagementPanelVisibilityAction } from './classes/actions/ChangeEngagementPanelVisibilityAction.js'; export { default as GetMultiPageMenuAction } from './classes/actions/GetMultiPageMenuAction.js'; @@ -16,6 +17,7 @@ export { default as SendFeedbackAction } from './classes/actions/SendFeedbackAct export { default as SignalAction } from './classes/actions/SignalAction.js'; export { default as UpdateChannelSwitcherPageAction } from './classes/actions/UpdateChannelSwitcherPageAction.js'; export { default as UpdateEngagementPanelAction } from './classes/actions/UpdateEngagementPanelAction.js'; +export { default as UpdatePostsTotalCountAction } from './classes/actions/UpdatePostsTotalCountAction.js'; export { default as UpdateSubscribeButtonAction } from './classes/actions/UpdateSubscribeButtonAction.js'; export { default as ActiveAccountHeader } from './classes/ActiveAccountHeader.js'; export { default as AddToPlaylist } from './classes/AddToPlaylist.js'; @@ -30,6 +32,7 @@ export { default as AvatarView } from './classes/AvatarView.js'; export { default as BackgroundPromo } from './classes/BackgroundPromo.js'; export { default as BackstageImage } from './classes/BackstageImage.js'; export { default as BackstagePost } from './classes/BackstagePost.js'; +export { default as BackstagePostDialog } from './classes/BackstagePostDialog.js'; export { default as BackstagePostThread } from './classes/BackstagePostThread.js'; export { default as BadgeView } from './classes/BadgeView.js'; export { default as BrowseFeedActions } from './classes/BrowseFeedActions.js'; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 2bda7466a8..2747b538ec 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -41,6 +41,7 @@ import CommentView from './classes/comments/CommentView.js'; import MusicThumbnail from './classes/MusicThumbnail.js'; import OpenPopupAction from './classes/actions/OpenPopupAction.js'; import AppendContinuationItemsAction from './classes/actions/AppendContinuationItemsAction.js'; +import RunAttestationCommand from './classes/commands/RunAttestationCommand.js'; import type { IParsedResponse, IRawResponse, RawData, RawNode } from './types/index.js'; const TAG = 'Parser'; @@ -509,6 +510,50 @@ export function parseResponse(data: parsed_data.challenge = data.challenge; } + if (data.botguardData) { + const interpreter_url = { + private_do_not_access_or_else_trusted_resource_url_wrapped_value: data.botguardData.interpreterSafeUrl?.privateDoNotAccessOrElseTrustedResourceUrlWrappedValue, + private_do_not_access_or_else_safe_script_wrapped_value: data.botguardData.interpreterSafeUrl?.privateDoNotAccessOrElseSafeScriptWrappedValue + }; + + parsed_data.botguard_data = { + interpreter_url, + program: data.botguardData.program + }; + } + + if (data.ctx) { + parsed_data.ctx = data.ctx; + } + + if (data.shouldFetchReauthSessionToken !== undefined) { + parsed_data.should_fetch_reauth_session_token = data.shouldFetchReauthSessionToken; + } + + if (data.encodedReauthProofToken) { + parsed_data.encoded_reauth_proof_token = data.encodedReauthProofToken; + } + + if (data.sessionRiskCtx) { + parsed_data.session_risk_ctx = data.sessionRiskCtx; + } + + if (data.sessionToken) { + parsed_data.session_token = data.sessionToken; + } + + if (data.webReauthUrl) { + parsed_data.web_reauth_url = data.webReauthUrl; + } + + if (data.plt) { + parsed_data.plt = data.plt; + } + + if (data.requireChallenge) { + parsed_data.require_challenge = data.requireChallenge; + } + if (data.playerResponse) { parsed_data.player_response = parseResponse(data.playerResponse); } @@ -532,6 +577,11 @@ export function parseResponse(data: parsed_data.target_id = data.targetId; } + const challenge_prompt = data.responseContext?.webResponseContextExtensionData?.challenge; + if (challenge_prompt) { + parsed_data.challenge_prompt = challenge_prompt; + } + if ('pollDelayMs' in data) { parsed_data.poll_delay_ms = data.pollDelayMs; } @@ -782,7 +832,9 @@ export function parseRR(actions: RawNode[]) { return new AppendContinuationItemsAction(action.appendContinuationItemsAction); else if (action.openPopupAction) return new OpenPopupAction(action.openPopupAction); - }).filter((item) => item) as (AppendContinuationItemsAction | OpenPopupAction | NavigateAction | ShowMiniplayerCommand | ReloadContinuationItemsCommand)[]); + else if (action.runAttestationCommand) + return new RunAttestationCommand(action.runAttestationCommand); + }).filter((item) => item) as (AppendContinuationItemsAction | OpenPopupAction | NavigateAction | ShowMiniplayerCommand | ReloadContinuationItemsCommand | RunAttestationCommand)[]); } export function parseActions(data: RawData) { diff --git a/src/parser/types/ParsedResponse.ts b/src/parser/types/ParsedResponse.ts index df5fad4a3d..a16fe67a0e 100644 --- a/src/parser/types/ParsedResponse.ts +++ b/src/parser/types/ParsedResponse.ts @@ -2,7 +2,8 @@ import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../helpers. import type { ReloadContinuationItemsCommand, Continuation, GridContinuation, ItemSectionContinuation, LiveChatContinuation, MusicPlaylistShelfContinuation, MusicShelfContinuation, - PlaylistPanelContinuation, SectionListContinuation, ContinuationCommand, ShowMiniplayerCommand, NavigateAction + PlaylistPanelContinuation, SectionListContinuation, ContinuationCommand, ShowMiniplayerCommand, NavigateAction, + IYoutubeApiInnertubeChallengePrompt } from '../index.js'; import type PlayerCaptionsTracklist from '../classes/PlayerCaptionsTracklist.js'; @@ -21,11 +22,21 @@ import type EngagementPanelSectionList from '../classes/EngagementPanelSectionLi import type AppendContinuationItemsAction from '../classes/actions/AppendContinuationItemsAction.js'; import type MusicThumbnail from '../classes/MusicThumbnail.js'; import type OpenPopupAction from '../classes/actions/OpenPopupAction.js'; +import type RunAttestationCommand from '../classes/commands/RunAttestationCommand.js'; export interface IParsedResponse { background?: MusicThumbnail; challenge?: string; bg_challenge?: IBotguardChallenge; + botguard_data?: IBotguardData; + ctx?: string; + should_fetch_reauth_session_token?: boolean; + encoded_reauth_proof_token?: string; + session_risk_ctx?: string; + session_token?: string; + web_reauth_url?: string; + plt?: string; + require_challenge?: boolean; actions?: SuperParsedResult; actions_memo?: Memo; content?: YTNode; @@ -38,11 +49,11 @@ export interface IParsedResponse { live_chat_item_context_menu_supported_renderers?: YTNode; live_chat_item_context_menu_supported_renderers_memo?: Memo; items_memo?: Memo; - on_response_received_actions?: ObservedArray; + on_response_received_actions?: ObservedArray; on_response_received_actions_memo?: Memo; - on_response_received_endpoints?: ObservedArray; + on_response_received_endpoints?: ObservedArray; on_response_received_endpoints_memo?: Memo; - on_response_received_commands?: ObservedArray; + on_response_received_commands?: ObservedArray; on_response_received_commands_memo?: Memo; continuation?: Continuation; continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation | @@ -84,6 +95,7 @@ export interface IParsedResponse { player_response?: IPlayerResponse; heartbeat_params?: IHeartbeatParams; watch_next_response?: INextResponse; + challenge_prompt?: IYoutubeApiInnertubeChallengePrompt; heartbeat_server_data?: string; stop_heartbeat?: boolean; poll_delay_ms?: string; @@ -112,6 +124,11 @@ export interface IBotguardChallenge { client_experiments_state_blob: string; } +export interface IBotguardData { + interpreter_url: ITrustedResource; + program: string; +} + export interface IPlaybackTracking { videostats_watchtime_url: string; videostats_playback_url: string; @@ -165,5 +182,8 @@ export type IGetTranscriptResponse = Pick; export type IUpdatedMetadataResponse = Pick; export type IGuideResponse = Pick; -export type IGetChallengeResponse = Pick; +export type IGetChallengeResponse = Pick; +export type IESRChallengeResponse = Pick; +export type IGetWebReauthURLResponse = Pick; +export type IGetSessionTokenResponse = Pick; export type IShowEngagementPanelResponse = Pick; \ No newline at end of file diff --git a/src/parser/types/RawResponse.ts b/src/parser/types/RawResponse.ts index 5518c6ef60..6fb1d513fa 100644 --- a/src/parser/types/RawResponse.ts +++ b/src/parser/types/RawResponse.ts @@ -1483,7 +1483,7 @@ type IYoutubeApiInnertubeUstreamerContext = { contentVideoUstreamerContext?: IYoutubeApiInnertubeContentVideoUstreamerContext; }; -type IYoutubeApiInnertubeChallengePrompt = { +export type IYoutubeApiInnertubeChallengePrompt = { ctx?: string; plt?: string; type?: diff --git a/src/parser/youtube/Channel.ts b/src/parser/youtube/Channel.ts index f78e06c132..22bd3c0a20 100644 --- a/src/parser/youtube/Channel.ts +++ b/src/parser/youtube/Channel.ts @@ -42,6 +42,7 @@ import type { IBrowseResponse } from '../types/index.js'; import type OpenPopupAction from '../classes/actions/OpenPopupAction.js'; import type { ObservedArray } from '../helpers.js'; import { observe } from '../helpers.js'; +import type { RunAttestationCommand } from '../nodes.js'; export default class Channel extends TabbedFeed { public header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader | PageHeader; @@ -429,7 +430,7 @@ export default class Channel extends TabbedFeed { } export class ChannelListContinuation extends Feed { - contents?: AppendContinuationItemsAction | OpenPopupAction | NavigateAction | ShowMiniplayerCommand | ReloadContinuationItemsCommand; + contents?: AppendContinuationItemsAction | OpenPopupAction | NavigateAction | ShowMiniplayerCommand | ReloadContinuationItemsCommand | RunAttestationCommand; constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) { super(actions, data, already_parsed); @@ -449,7 +450,7 @@ export class ChannelListContinuation extends Feed { export class FilteredChannelList extends FilterableFeed { public filter?: ChipView | ListItemView; public secondary_filter?: ChipView; - public contents?: AppendContinuationItemsAction | OpenPopupAction | NavigateAction | ShowMiniplayerCommand | ReloadContinuationItemsCommand; + public contents?: AppendContinuationItemsAction | OpenPopupAction | NavigateAction | ShowMiniplayerCommand | ReloadContinuationItemsCommand | RunAttestationCommand; constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) { super(actions, data, already_parsed); diff --git a/src/types/BotGuard.ts b/src/types/BotGuard.ts new file mode 100644 index 0000000000..d0ef615b0d --- /dev/null +++ b/src/types/BotGuard.ts @@ -0,0 +1,24 @@ +import type { AttIdsRaw } from '../parser/classes/commands/RunAttestationCommand.js'; +import type { IBotguardChallenge, IGetChallengeResponse } from '../parser/index.js'; +import type { EngagementType } from './Misc.js'; + +export interface BotGuardSolverChallenge extends IBotguardChallenge { + ytcfg?: Record; +} + +export interface BotGuardChallengeInfo extends Pick, 'bg_challenge' | 'challenge'> { + bg_challenge: BotGuardSolverChallenge; +} + +export interface BotGuardLogBinding extends AttIdsRaw { + c: string; + e: EngagementType; +} + +export interface BotGuardSessionTokenBinding { + atr_challenge: string; +} + +export interface BotGuardSolver { + solve: (botguard_challenge: BotGuardSolverChallenge, binding: T) => Promise; +} \ No newline at end of file diff --git a/src/types/CreatePost.ts b/src/types/CreatePost.ts new file mode 100644 index 0000000000..4d71724119 --- /dev/null +++ b/src/types/CreatePost.ts @@ -0,0 +1,109 @@ +export interface PostImage { + body: BodyInit; + size: number; + height: number; + width: number; + base64: string; +}; + +/* + all coords 0-1 + very left = 0 + very right = 1 + very top = 0 + very bottom = 1 +*/ +export interface PreviewCoordinates { + top: number; + right: number; + bottom: number; + left: number; +} + +// The Create Post body ---- +export interface PollAttachmentData_LocalImageSrc { + privateDoNotAccessOrElseWrappedUrl: string; +} +export type PollAttachmentData_ImageBlob = object; +export interface PollAttachmentData_Image { + id: number; + imageBlob: PollAttachmentData_ImageBlob; + localImageSrc: PollAttachmentData_LocalImageSrc; + naturalWidth: number; + naturalHeight: number; + previewCoordinates: PreviewCoordinates; + encryptedBlobId: string; +} +export interface PollAttachmentData_Option { + text: string; + image: PollAttachmentData_Image; +} +export interface PollAttachmentData { + pollAttachmentData: { options: PollAttachmentData_Option[] }; +} + +export interface ImagesAttachment_PostImageData { + encryptedBlobId: string; + previewCoordinates: PreviewCoordinates; +} +export interface ImagesAttachment { + imagesAttachment: { imagesData: ImagesAttachment_PostImageData[] }; +} + +export interface QuizAttachmentData_PollOption { + text: string; + explanation: string; +} +export interface QuizAttachmentData_Option { + pollOption: QuizAttachmentData_PollOption; + isCorrect: boolean; +} +export interface QuizAttachmentData { + quizAttachmentData: { options: QuizAttachmentData_Option[]; }; +} + +export interface PollAttachment { + pollAttachment: { choices: string[] }; +} + +export interface VideoAttachment { + videoAttachment: { videoId: string }; +} + +export interface CreatePostPayloadBase { + commentText: string; + createBackstagePostParams: string; + scheduledPublishTimeSec?: string; +} +export type CreatePostPayload = CreatePostPayloadBase | (CreatePostPayloadBase & (VideoAttachment|PollAttachment|QuizAttachmentData|ImagesAttachment|PollAttachmentData)); + +// The actual 'Create Post' interface ---- +export interface CreatePostImage { + source: PostImage; + preview_coordinates: PreviewCoordinates; +} +export interface CreatePostOptions_ImagePoll { + type: 'IMAGE_POLL'; + options: {text: string, image: CreatePostImage}[]; +} +export interface CreatePostOptions_Image { + type: 'IMAGE'; + images: CreatePostImage[]; +} +export interface CreatePostOptions_Quiz { + type: 'QUIZ'; + choices: (QuizAttachmentData_PollOption & {is_correct: boolean})[]; +} +export interface CreatePostOptions_Poll { + type: 'POLL'; + choices: string[]; +} +export interface CreatePostOptions_Video { + type: 'VIDEO'; + video_id: string; +} +export interface CreatePostBaseOptions { + comment_text: string; + scheduled_publish_time_seconds?: string; +}; +export type CreatePostExtraOptions = (CreatePostOptions_Video|CreatePostOptions_Poll|CreatePostOptions_Quiz|CreatePostOptions_Image|CreatePostOptions_ImagePoll); \ No newline at end of file diff --git a/src/types/Misc.ts b/src/types/Misc.ts index 128ea49ce1..b9e67c5fc7 100644 --- a/src/types/Misc.ts +++ b/src/types/Misc.ts @@ -2,7 +2,14 @@ import type { SessionOptions } from '../core/index.js'; export type InnerTubeConfig = SessionOptions; export type InnerTubeClient = 'IOS' | 'WEB' | 'MWEB' | 'ANDROID' | 'ANDROID_VR' | 'VISIONOS' | 'YTMUSIC' | 'YTMUSIC_ANDROID' | 'YTSTUDIO_ANDROID' | 'TV' | 'TV_SIMPLY' |'TV_EMBEDDED' | 'YTKIDS' | 'WEB_EMBEDDED' | 'WEB_CREATOR'; -export type EngagementType = 'ENGAGEMENT_TYPE_UNBOUND' | 'ENGAGEMENT_TYPE_VIDEO_LIKE' | 'ENGAGEMENT_TYPE_VIDEO_DISLIKE' | 'ENGAGEMENT_TYPE_SUBSCRIBE' | 'ENGAGEMENT_TYPE_PLAYBACK' | 'ENGAGEMENT_TYPE_YPC_GET_PREMIUM_PAGE' | 'ENGAGEMENT_TYPE_YPC_GET_DOWNLOAD_ACTION'; +export type EngagementType = 'ENGAGEMENT_TYPE_UNKNOWN' | 'ENGAGEMENT_TYPE_PLAYBACK' | 'ENGAGEMENT_TYPE_SUBSCRIBE' + | 'ENGAGEMENT_TYPE_CREATOR_STUDIO_ACTION' | 'ENGAGEMENT_TYPE_COMMENT_POST' | 'ENGAGEMENT_TYPE_LIVESTREAM_REMINDER' + | 'ENGAGEMENT_TYPE_VIDEO_UPLOAD' | 'ENGAGEMENT_TYPE_LIVE_CHAT_COMMENT' | 'ENGAGEMENT_TYPE_UNBOUND' + | 'ENGAGEMENT_TYPE_PLAYLIST_CREATE' | 'ENGAGEMENT_TYPE_PLAYLIST_EDIT' | 'ENGAGEMENT_TYPE_PHONE_VERIFY' + | 'ENGAGEMENT_TYPE_VIDEO_METADATA_UPDATE' | 'ENGAGEMENT_TYPE_POST_POLL_VOTE' | 'ENGAGEMENT_TYPE_VIDEO_LIKE' + | 'ENGAGEMENT_TYPE_VIDEO_DISLIKE' | 'ENGAGEMENT_TYPE_CHANNEL_SETTINGS_UPDATE' | 'ENGAGEMENT_TYPE_CREATOR_DELEGATES_UPDATE' + | 'ENGAGEMENT_TYPE_COMMENT_LIKE' | 'ENGAGEMENT_TYPE_COMMENT_DISLIKE' | 'ENGAGEMENT_TYPE_PHONE_VERIFY_REQUEST_CODE' + | 'ENGAGEMENT_TYPE_SHARE' | 'ENGAGEMENT_TYPE_POST_CREATE' | 'ENGAGEMENT_TYPE_VIDEO_TRANSCRIPT_REQUEST'; export type UploadDate = 'all' | 'today' | 'week' | 'month' | 'year'; export type SearchType = 'all' | 'video' | 'shorts' | 'channel' | 'playlist' | 'movie'; diff --git a/src/types/index.ts b/src/types/index.ts index ec6da7eeb0..9f7908f67c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,7 @@ export type { default as PlatformShim } from './PlatformShim.js'; +export type * from './BotGuard.js'; +export type * from './CreatePost.js'; export type * from './Cache.js'; export type * from './PlatformShim.js'; export type * from './Misc.js'; diff --git a/src/utils/Context.ts b/src/utils/Context.ts new file mode 100644 index 0000000000..cf7ec4cf2d --- /dev/null +++ b/src/utils/Context.ts @@ -0,0 +1,19 @@ +import { UserInfo_DelegationContext, UserInfo_DelegationContext_RoleType_ChannelRoleType } from '../../protos/generated/youtube/api/pfiinnertube/user_info.js'; +import type { Context, PartialContext } from '../core/index.js'; +import { u8ToBase64 } from './Utils.js'; + +export function channelUserDelegationContext(channel_id: string): PartialContext['user'] { + const delegation_context: Required = { + externalChannelId: channel_id, + roleType: { + channelRoleType: 'CREATOR_CHANNEL_ROLE_TYPE_OWNER' + } + }; + return { + delegationContext: delegation_context, + serializedDelegationContext: u8ToBase64(UserInfo_DelegationContext.encode({ + externalChannelId: channel_id, + roleType: { channelRoleType: UserInfo_DelegationContext_RoleType_ChannelRoleType.CREATOR_CHANNEL_ROLE_TYPE_OWNER } + }).finish()) + }; +} \ No newline at end of file diff --git a/src/utils/HTTPClient.ts b/src/utils/HTTPClient.ts index 18aff55120..d5a0681000 100644 --- a/src/utils/HTTPClient.ts +++ b/src/utils/HTTPClient.ts @@ -7,8 +7,8 @@ import { getCookie } from './Utils.js'; -import type { Context, Session } from '../core/index.js'; -import type { FetchFunction } from '../types/index.js'; +import type { Context, PartialContext, Session } from '../core/index.js'; +import type { FetchFunction, InnerTubeClient } from '../types/index.js'; export interface HTTPClientInit { baseURL?: string; @@ -46,8 +46,12 @@ export default class HTTPClient { const innertube_url = Constants.URLS.API.PRODUCTION_1 + session.api_version; const baseURL = init?.baseURL || innertube_url; + const input_is_raw_url = typeof input === 'string' && (/^https?:\/\//i).test(input); + const request_url = typeof input === 'string' - ? new URL(`${baseURL}${baseURL.endsWith('/') || input.startsWith('/') ? '' : '/'}${input}`) + ? (input_is_raw_url + ? new URL(input) + : new URL(`${baseURL.replace(/\/+$/, '')}/${input.replace(/^\/+/, '')}`)) : input instanceof URL ? input : new URL(input.url, baseURL); const headers = @@ -61,9 +65,6 @@ export default class HTTPClient { this.#setupCommonHeaders(request_headers, session, request_url); - request_url.searchParams.set('prettyPrint', 'false'); - request_url.searchParams.set('alt', 'json'); - const content_type = request_headers.get('Content-Type'); let request_body = body; @@ -73,6 +74,11 @@ export default class HTTPClient { baseURL === innertube_url || baseURL === Constants.URLS.YT_UPLOAD; + if (!input_is_raw_url && is_innertube_req) { + request_url.searchParams.set('prettyPrint', 'false'); + request_url.searchParams.set('alt', 'json'); + } + // Copy context into payload when possible if (content_type === 'application/json' && is_innertube_req && (typeof body === 'string')) { const { @@ -138,6 +144,8 @@ export default class HTTPClient { request_headers.set('Cookie', cookie); } + } else if (session.logged_in && this.#cookie && input_is_raw_url) { + request_headers.set('Cookie', this.#cookie); } const request = new Platform.shim.Request(request_url, input instanceof Platform.shim.Request ? input : init); @@ -158,11 +166,11 @@ export default class HTTPClient { } #processJsonPayload(json_body: string, session: Session): ProcessedJsonPayload { - const parsed_payload = JSON.parse(json_body); + const parsed_payload: { [key: string]: any, client?: InnerTubeClient, one_time_context?: PartialContext } = JSON.parse(json_body); // Deep copy since we're going to be modifying it. const adjusted_context = JSON.parse(JSON.stringify(session.context)) as Context; - this.#adjustContext(adjusted_context, parsed_payload.client); + this.#adjustContext(adjusted_context, parsed_payload.client, parsed_payload.one_time_context); const new_payload = { ...parsed_payload, @@ -174,6 +182,7 @@ export default class HTTPClient { const clientNameId = Constants.CLIENT_NAME_IDS[clientNameFromAdjustedContext]; delete new_payload.client; + delete new_payload.one_time_context; const isWebKids = new_payload.context.client.clientName === Constants.CLIENTS.WEB_KIDS.NAME; @@ -203,7 +212,16 @@ export default class HTTPClient { } } - #adjustContext(ctx: Context, client?: string): void { + #adjustContext(ctx: Context, client?: string, otx?: PartialContext): void { + if (otx) { + ctx.client = { ...ctx.client, ...otx.client }; + ctx.user = { ...ctx.user, ...otx.user }; + if (ctx.request || otx.request) + ctx.request = { ...ctx.request, ...otx.request } as Context['request']; + if (ctx.thirdParty || otx.thirdParty) + ctx.thirdParty = { ...ctx.thirdParty, ...otx.thirdParty } as Context['thirdParty']; + } + if (!client) return; @@ -333,9 +351,10 @@ export default class HTTPClient { case 'WEB_CREATOR': ctx.client.clientName = Constants.CLIENTS.WEB_CREATOR.NAME; ctx.client.clientVersion = Constants.CLIENTS.WEB_CREATOR.VERSION; + if (ctx.request) ctx.request.returnLogEntry = true; break; default: break; } } -} +} \ No newline at end of file diff --git a/src/utils/ProtoUtils.ts b/src/utils/ProtoUtils.ts index 06af9c7720..d5b6af48fc 100644 --- a/src/utils/ProtoUtils.ts +++ b/src/utils/ProtoUtils.ts @@ -1,5 +1,5 @@ import { base64ToU8, u8ToBase64 } from './Utils.js'; -import { NextParams, PeformCommentActionParams, VisitorData } from '../../protos/generated/misc/params.js'; +import { CreateBackstagePostParams, NextParams, PeformCommentActionParams, VisitorData } from '../../protos/generated/misc/params.js'; export type CommentActionParamsArgs = { comment_id?: string, @@ -51,4 +51,14 @@ export function encodeCommentActionParams(type: number, args: CommentActionParam export function encodeNextParams(video_ids: string[], playlist_title?: string): string { const writer = NextParams.encode({ videoId: video_ids, playlistTitle: playlist_title }); return encodeURIComponent(u8ToBase64(writer.finish()).replace(/\+/g, '-').replace(/\//g, '_')); +} + +export function encodeCreateBackstagePostParams(channel_id: string): string { + const writer = CreateBackstagePostParams.encode({ + channelId: channel_id, + unkParam1: 1, + f6: { unkParam3: 5 }, + f9: { unkParam1: 2 } + }); + return u8ToBase64(writer.finish()); } \ No newline at end of file diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index afe61e2378..9babbc8648 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -299,4 +299,64 @@ export function getNsigProcessorFn(n?: string | null, sp?: string | null, s?: st } return process("${n || ''}", "${sp || ''}", "${s || ''}");`; +} + +export function parseLooseJSON(looseJson: string) { + let jsonStr = looseJson.replace(/,\s*([\]}])/g, '$1'); + + jsonStr = jsonStr.replace( + /'((?:[^'\\]|\\[\s\S])*)'/g, + (_match, innerStr) => { + const unescaped = innerStr.replace(/\\'/g, '\''); + return JSON.stringify(unescaped); + } + ); + + // just in case + jsonStr = jsonStr.replace(/([{,]\s*)([a-zA-Z0-9_$]+)\s*:/g, '$1"$2":'); + + const parsedData = JSON.parse(jsonStr); + + /** + * \x41 -> A basically. + */ + const decodeHexEscapes = (value: string): string => { + return value.replace(/\\x([0-9A-Fa-f]{2})/g, (_match, hex) => { + return String.fromCharCode(parseInt(hex, 16)); + }); + }; + const decodeUnicodeEscapes = (value: string): string => { + return value.replace(/\\u([0-9A-Fa-f]{4})/g, (_match, unicode) => { + return String.fromCharCode(parseInt(unicode, 16)); + }); + }; + + const normalizeValue = (value: any): any => { + if (typeof value === 'string') { + const decodedValue = decodeHexEscapes(value); + const trimmed = decodedValue.trim(); + + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + return normalizeValue(JSON.parse(decodedValue)); + } catch { + return decodedValue; + } + } + return decodeUnicodeEscapes(decodedValue); + } + + if (Array.isArray(value)) { + return value.map(normalizeValue); + } + + if (value && typeof value === 'object') { + for (const key in value) { + value[key] = normalizeValue(value[key]); + } + } + return value; + }; + + return normalizeValue(parsedData); } \ No newline at end of file diff --git a/tests/main.test.ts b/tests/main.test.ts index 33a741efe5..308882191a 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -298,6 +298,27 @@ describe('YouTube.js Tests', () => { // expect(existsSync(`./${id}.mp4`)).toBeTruthy(); // }); + + // test('Innertube#botguard.getChallenge.apiChallenge', async () => { + // const challenge = await innertube.botguard.getChallenge({engagement_type: "ENGAGEMENT_TYPE_UNBOUND", ids: []}); + // expect(challenge).toBeDefined(); + // }); + // test('Innertube#botguard.getChallenge.pageChallenge_initialChallenge', async () => { + // const challenge = await innertube.botguard.getChallenge({ + // engagement_type: "ENGAGEMENT_TYPE_VIDEO_LIKE", + // ids: [{encryptedVideoId: 'wf4kRfGzflo'}], + // atn_page_url: 'https://www.youtube.com' + // }); + // expect(challenge).toBeDefined(); + // }); + // test('Innertube#botguard.getChallenge.pageChallenge_eacrTokenChallenge', async () => { + // const challenge = await innertube.botguard.getChallenge({ + // engagement_type: "ENGAGEMENT_TYPE_VIDEO_LIKE", + // ids: [{encryptedVideoId: 'wf4kRfGzflo'}], + // atn_page_url: 'https://music.youtube.com' + // }); + // expect(challenge).toBeDefined(); + // }); }); describe('YouTube Music', () => {