feat(Text): Parse accessibility data

+ Clean up.
This commit is contained in:
Luan
2025-04-05 05:09:34 -03:00
parent 4284fa6d06
commit a95f52a477
5 changed files with 364 additions and 139 deletions

View File

@@ -0,0 +1,131 @@
import type { RawNode } from '../../types/index.js';
export interface AccessibilityId {
accessibility_id_type?:
| 'UNKNOWN'
| 'MENU_ADD_TO_WATCH_LATER'
| 'MENU_REMOVE_FROM_WATCH_LATER'
| 'MENU_ADD_TO_PLAYLIST'
| 'MENU_REMOVE_FROM_PLAYLIST'
| 'MENU_SHARE_VIDEO'
| 'MENU_SHARE_PLAYLIST'
| 'MENU_OFFLINE_VIDEO'
| 'MENU_OFFLINE_PLAYLIST'
| 'MENU_DELETE_VIDEO'
| 'MENU_DELETE_PLAYLIST'
| 'MENU_EDIT_VIDEO_METADATA'
| 'MENU_HIDE'
| 'MENU_REMOVE_FROM_HISTORY'
| 'MENU_LIKE'
| 'MENU_INFO'
| 'MENU_ADD_TO_REMOTE_QUEUE'
| 'MENU_REMOVE_FROM_REMOTE_QUEUE'
| 'MENU_CREATE_PLAYLIST'
| 'MENU_SETTINGS'
| 'MENU_PRIVACY'
| 'MENU_FEEDBACK'
| 'MENU_HELP'
| 'MENU_DELETE_CHANNEL_POST'
| 'MENU_PLAYLIST_JOIN_COLLABORATION'
| 'MENU_EDIT_PLAYLIST'
| 'MENU_OFFLINE_REMOVE'
| 'MENU_OFFLINE_PAUSE'
| 'MENU_OFFLINE_RESUME'
| 'MENU_UNSUBSCRIBE'
| 'MENU_GET_ALL_UPDATES'
| 'MENU_DISMISS'
| 'MENU_CANCEL_UPLOAD'
| 'MENU_TAKE_PHOTO'
| 'MENU_CHOOSE_PHOTO'
| 'MENU_CHOOSE_FROM_CHANNEL_ART_GALLERY'
| 'MENU_FILTER_VIDEOS_ONLY'
| 'MENU_FILTER_VIDEOS_AND_POSTS'
| 'MENU_WATCH_ON_TV'
| 'MENU_INSERT_IN_REMOTE_QUEUE'
| 'MENU_ADD_UPCOMING_EVENT_REMINDER'
| 'MENU_REMOVE_UPCOMING_EVENT_REMINDER'
| 'MENU_TOGGLE_DENSITY_MODE'
| 'MENU_OFFLINE_UPSELL'
| 'MENU_MORE_LIKE_THIS'
| 'MENU_CREATE_VIDEO'
| 'MENU_CREATE_LIVE_STREAM'
| 'MENU_CREATE_REEL_ITEM'
| 'MENU_CREATE_POST'
| 'MENU_LESS_LIKE_THIS'
| 'MENU_REEL_OVERFLOW'
| 'MENU_DELETE_REEL'
| 'MENU_EDIT_REEL'
| 'MENU_REMOVE_FROM_QUEUE'
| 'MENU_REEL_SHELF_OVERFLOW'
| 'MENU_REEL_SHELF_DISMISS'
| 'MENU_SHARE_ARTIST'
| 'MENU_ABOUT_RECOMMENDATION'
| 'MENU_REPORT'
| 'EXPLORE_DESTINATION_TRENDING'
| 'EXPLORE_DESTINATION_MUSIC'
| 'EXPLORE_DESTINATION_GAMING'
| 'EXPLORE_DESTINATION_NEWS'
| 'EXPLORE_DESTINATION_MOVIES'
| 'EXPLORE_DESTINATION_FASHION'
| 'EXPLORE_DESTINATION_LEARNING'
| 'EXPLORE_DESTINATION_STAY_HOME'
| 'MENU_ABOUT_GAMING_RECOMMENDATAION'
| 'EXPLORE_DESTINATION_LIVE'
| 'MENU_QUALITY'
| 'MENU_CAPTIONS'
| 'MENU_PLAYBACK_SPEED'
| 'MENU_SHARE_PLAYLIST_UNAVAILABLE'
| 'MENU_INFO_CARD'
| 'EXPLORE_DESTINATION_SPORTS'
| 'MENU_SINGLE_LOOP'
| 'MENU_HIDE_VIDEO'
| 'MENU_CLEAR_QUEUE'
| 'EXPLORE_DESTINATION_SHOPPING'
| 'MENU_PLAY_NEXT_IN_QUEUE'
| 'MENU_PLAY_LAST_IN_QUEUE'
| 'MENU_GO_TO_CHANNEL'
| 'EXPLORE_DESTINATION_PODCASTS'
| 'MEDIA_GENERATOR_PROMPT_INPUT'
| 'MEDIA_GENERATOR_STYLE_SHELF'
| 'MEDIA_GENERATOR_STYLE_ITEM'
| 'MEDIA_GENERATOR_CREATE_BUTTON'
| 'MEDIA_GENERATOR_T2V_ENTRYPOINT'
| 'MEDIA_GENERATOR_T2I_ENTRYPOINT'
| 'MEDIA_GENERATOR_T2M_ENTRYPOINT'
| 'MEDIA_GENERATOR_BACK_BUTTON'
| 'MEDIA_GENERATOR_HEADER'
| 'MEDIA_GENERATOR_LOADING_PROGRESS'
| 'MEDIA_GENERATOR_CANCEL_BUTTON'
| 'MEDIA_GENERATOR_IMAGE_PREVIEW'
| 'MEDIA_GENERATOR_VIDEO_PREVIEW'
| 'MEDIA_GENERATOR_DONE_BUTTON'
| 'MEDIA_GENERATOR_IMAGE_SELECTION'
| 'MEDIA_GENERATOR_SOUND_METADATA'
| 'MEDIA_GENERATOR_AUDIO_SELECT_BUTTON'
| 'MEDIA_GENERATOR_T2I2V_ENTRYPOINT'
| 'MENU_SAVE_QUEUE_TO_PLAYLIST'
| 'MEDIA_GENERATOR_ANIMATE_BUTTON'
| 'MEDIA_GENERATOR_SEGMENT_IMPORT_ENTRYPOINT';
}
export default class AccessibilityData {
public accessibility_identifier?: string;
public identifier?: AccessibilityId;
public label?: string;
constructor(data: RawNode) {
if ('accessibilityIdentifier' in data) {
this.accessibility_identifier = data.accessibilityIdentifier;
}
if ('identifier' in data) {
this.identifier = {
accessibility_id_type: data.identifier.accessibilityIdType
};
}
if ('label' in data) {
this.label = data.label;
}
}
}

View File

@@ -2,15 +2,17 @@ import type { RawNode } from '../../index.js';
import { escape, type Run } from './Text.js';
import Thumbnail from './Thumbnail.js';
export interface Emoji {
emoji_id: string;
shortcuts: string[];
search_terms: string[];
image: Thumbnail[];
is_custom: boolean;
}
export default class EmojiRun implements Run {
text: string;
emoji: {
emoji_id: string;
shortcuts: string[];
search_terms: string[];
image: Thumbnail[];
is_custom: boolean;
};
public text: string;
public emoji: Emoji;
constructor(data: RawNode) {
this.text =

View File

@@ -3,13 +3,20 @@ import type { RawNode } from '../../index.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
import EmojiRun from './EmojiRun.js';
import TextRun from './TextRun.js';
import AccessibilityData from './AccessibilityData.js';
export interface Run {
text: string;
toString(): string;
toHTML(): string;
}
export interface FormattedStringSupportedAccessibilityDatas {
accessibility_data: AccessibilityData;
}
export function escape(text: string) {
return text
.replace(/&/g, '&')
@@ -19,36 +26,78 @@ export function escape(text: string) {
.replace(/'/g, ''');
}
// Place this here, instead of in a private static property,
// To avoid the performance penalty of the private field polyfill
const TAG = 'Text';
/**
* Represents text content that may include formatting, emojis, and navigation endpoints.
*/
export default class Text {
text?: string;
runs?: (EmojiRun | TextRun)[];
endpoint?: NavigationEndpoint;
/**
* The plain text content.
*/
public text?: string;
/**
* Individual text segments with their formatting.
*/
public runs?: (EmojiRun | TextRun)[];
/**
* Navigation endpoint associated with this text.
*/
public endpoint?: NavigationEndpoint;
/**
* Accessibility data associated with this text.
*/
public accessibility?: FormattedStringSupportedAccessibilityDatas;
/**
* Indicates if the text is right-to-left.
*/
public rtl: boolean;
constructor(data: RawNode) {
if (typeof data === 'object' && data !== null && Reflect.has(data, 'runs') && Array.isArray(data.runs)) {
this.runs = data.runs.map((run: RawNode) => run.emoji ?
new EmojiRun(run) :
new TextRun(run)
if (this.isRunsData(data)) {
this.runs = data.runs.map((run: RawNode) =>
run.emoji ? new EmojiRun(run) : new TextRun(run)
);
this.text = this.runs.map((run) => run.text).join('');
} else {
this.text = data?.simpleText;
}
if (typeof data === 'object' && data !== null && Reflect.has(data, 'navigationEndpoint')) {
if (this.isObject(data) && 'accessibility' in data
&& 'accessibilityData' in data.accessibility) {
this.accessibility = {
accessibility_data: new AccessibilityData(data.accessibility.accessibilityData)
};
}
this.rtl = !!data?.rtl;
this.parseEndpoint(data);
}
private isRunsData(data: RawNode): data is { runs: RawNode[] } {
return this.isObject(data) &&
Reflect.has(data, 'runs') &&
Array.isArray(data.runs);
}
private parseEndpoint(data: RawNode): void {
if (!this.isObject(data)) return;
if ('navigationEndpoint' in data) {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
if (typeof data === 'object' && data !== null && Reflect.has(data, 'titleNavigationEndpoint')) {
} else if ('titleNavigationEndpoint' in data) {
this.endpoint = new NavigationEndpoint(data.titleNavigationEndpoint);
} else if ((this.runs?.[0] as TextRun)?.endpoint) {
this.endpoint = (this.runs?.[0] as TextRun).endpoint;
}
if (!this.endpoint) {
if ((this.runs?.[0] as TextRun)?.endpoint) {
this.endpoint = (this.runs?.[0] as TextRun)?.endpoint;
}
}
}
private isObject(data: RawNode): boolean {
return typeof data === 'object' && data !== null;
}
static fromAttributed(data: AttributedText) {
@@ -75,120 +124,127 @@ export default class Text {
length: run.length ?? content.length
}) as StyleRun & ResponseRun);
if (style_runs || command_runs || attachment_runs) {
if (style_runs) {
for (const style_run of style_runs) {
if (
style_run.italic ||
style_run.strikethrough === 'LINE_STYLE_SINGLE' ||
style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' ||
style_run.weightLabel === 'FONT_WEIGHT_BOLD'
) {
const matching_run = findMatchingRun(runs, style_run);
if (style_runs?.length)
this.processStyleRuns(runs, style_runs, data);
if (!matching_run) {
Log.warn(TAG, 'Unable to find matching run for style run. Skipping...', {
style_run,
input_data: data,
// For performance reasons, web browser consoles only expand an object, when the user clicks on it,
// So if we log the original runs object, it might have changed by the time the user looks at it.
// Deep clone, so that we log the exact state of the runs at this point.
parsed_runs: JSON.parse(JSON.stringify(runs))
});
if (command_runs?.length)
this.processCommandRuns(runs, command_runs, data);
continue;
}
if (attachment_runs?.length)
this.processAttachmentRuns(runs, attachment_runs, data);
// Comments use MEDIUM for bold text and video descriptions use BOLD for bold text
insertSubRun(runs, matching_run, style_run, {
bold: style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' || style_run.weightLabel === 'FONT_WEIGHT_BOLD',
italics: style_run.italic,
strikethrough: style_run.strikethrough === 'LINE_STYLE_SINGLE'
});
} else {
Log.debug(TAG, 'Skipping style run as it is doesn\'t have any information that we parse.', {
style_run,
input_data: data
});
}
return new Text({ runs });
}
private static processStyleRuns(runs: RawRun[], style_runs: (StyleRun & ResponseRun)[], data: AttributedText) {
for (const style_run of style_runs) {
if (
style_run.italic ||
style_run.strikethrough === 'LINE_STYLE_SINGLE' ||
style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' ||
style_run.weightLabel === 'FONT_WEIGHT_BOLD'
) {
const matching_run = findMatchingRun(runs, style_run);
if (!matching_run) {
Log.warn(TAG, 'Unable to find matching run for style run. Skipping...', {
style_run,
input_data: data,
// For performance reasons, web browser consoles only expand an object, when the user clicks on it,
// So if we log the original runs object, it might have changed by the time the user looks at it.
// Deep clone, so that we log the exact state of the runs at this point.
parsed_runs: JSON.parse(JSON.stringify(runs))
});
continue;
}
// Comments use MEDIUM for bold text and video descriptions use BOLD for bold text
insertSubRun(runs, matching_run, style_run, {
bold: style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' || style_run.weightLabel === 'FONT_WEIGHT_BOLD',
italics: style_run.italic,
strikethrough: style_run.strikethrough === 'LINE_STYLE_SINGLE'
});
} else {
Log.debug(TAG, 'Skipping style run as it is doesn\'t have any information that we parse.', {
style_run,
input_data: data
});
}
}
}
private static processCommandRuns(runs: RawRun[], command_runs: CommandRun[], data: AttributedText) {
for (const command_run of command_runs) {
if (command_run.onTap) {
const matching_run = findMatchingRun(runs, command_run);
if (!matching_run) {
Log.warn(TAG, 'Unable to find matching run for command run. Skipping...', {
command_run,
input_data: data,
// For performance reasons, web browser consoles only expand an object, when the user clicks on it,
// So if we log the original runs object, it might have changed by the time the user looks at it.
// Deep clone, so that we log the exact state of the runs at this point.
parsed_runs: JSON.parse(JSON.stringify(runs))
});
continue;
}
insertSubRun(runs, matching_run, command_run, {
navigationEndpoint: command_run.onTap
});
} else {
Log.debug(TAG, 'Skipping command run as it is missing the "doTap" property.', {
command_run,
input_data: data
});
}
}
}
private static processAttachmentRuns(runs: RawRun[], attachment_runs: AttachmentRun[], data: AttributedText) {
for (const attachment_run of attachment_runs) {
const matching_run = findMatchingRun(runs, attachment_run);
if (!matching_run) {
Log.warn(TAG, 'Unable to find matching run for attachment run. Skipping...', {
attachment_run,
input_data: data,
// For performance reasons, web browser consoles only expand an object, when the user clicks on it,
// So if we log the original runs object, it might have changed by the time the user looks at it.
// Deep clone, so that we log the exact state of the runs at this point.
parsed_runs: JSON.parse(JSON.stringify(runs))
});
continue;
}
if (command_runs) {
for (const command_run of command_runs) {
if (command_run.onTap) {
const matching_run = findMatchingRun(runs, command_run);
if (attachment_run.length === 0) {
matching_run.attachment = attachment_run;
} else {
const offset_start_index = attachment_run.startIndex - matching_run.startIndex;
if (!matching_run) {
Log.warn(TAG, 'Unable to find matching run for command run. Skipping...', {
command_run,
input_data: data,
// For performance reasons, web browser consoles only expand an object, when the user clicks on it,
// So if we log the original runs object, it might have changed by the time the user looks at it.
// Deep clone, so that we log the exact state of the runs at this point.
parsed_runs: JSON.parse(JSON.stringify(runs))
});
const text = matching_run.text.substring(offset_start_index, offset_start_index + attachment_run.length);
continue;
}
const is_custom_emoji = (/^:[^:]+:$/).test(text);
insertSubRun(runs, matching_run, command_run, {
navigationEndpoint: command_run.onTap
});
} else {
Log.debug(TAG, 'Skipping command run as it is missing the "doTap" property.', {
command_run,
input_data: data
});
}
}
}
if (attachment_run.element?.type?.imageType?.image && (is_custom_emoji || (/^(?:\p{Emoji}|\u200d)+$/u).test(text))) {
const emoji = {
image: attachment_run.element.type.imageType.image,
isCustomEmoji: is_custom_emoji,
shortcuts: is_custom_emoji ? [ text ] : undefined
};
if (attachment_runs) {
for (const attachment_run of attachment_runs) {
const matching_run = findMatchingRun(runs, attachment_run);
if (!matching_run) {
Log.warn(TAG, 'Unable to find matching run for attachment run. Skipping...', {
attachment_run,
input_data: data,
// For performance reasons, web browser consoles only expand an object, when the user clicks on it,
// So if we log the original runs object, it might have changed by the time the user looks at it.
// Deep clone, so that we log the exact state of the runs at this point.
parsed_runs: JSON.parse(JSON.stringify(runs))
});
continue;
}
if (attachment_run.length === 0) {
matching_run.attachment = attachment_run;
} else {
const offset_start_index = attachment_run.startIndex - matching_run.startIndex;
const text = matching_run.text.substring(offset_start_index, offset_start_index + attachment_run.length);
const is_custom_emoji = (/^:[^:]+:$/).test(text);
if (attachment_run.element?.type?.imageType?.image && (is_custom_emoji || (/^(?:\p{Emoji}|\u200d)+$/u).test(text))) {
const emoji = {
image: attachment_run.element.type.imageType.image,
isCustomEmoji: is_custom_emoji,
shortcuts: is_custom_emoji ? [ text ] : undefined
};
insertSubRun(runs, matching_run, attachment_run, { emoji });
} else {
insertSubRun(runs, matching_run, attachment_run, {
attachment: attachment_run
});
}
}
insertSubRun(runs, matching_run, attachment_run, { emoji });
} else {
insertSubRun(runs, matching_run, attachment_run, {
attachment: attachment_run
});
}
}
}
return new Text({ runs });
}
/**
@@ -292,7 +348,7 @@ interface StyleRun extends Partial<ResponseRun> {
value: number
}[]
}
}
};
}
interface CommandRun extends ResponseRun {

View File

@@ -3,25 +3,58 @@ import { escape, type Run } from './Text.js';
import type { RawNode } from '../../index.js';
export default class TextRun implements Run {
text: string;
endpoint?: NavigationEndpoint;
bold: boolean;
italics: boolean;
strikethrough: boolean;
deemphasize: boolean;
attachment;
public text: string;
public text_color?: number;
public endpoint?: NavigationEndpoint;
public bold: boolean;
public bracket: boolean;
public dark_mode_text_color?: number;
public deemphasize: boolean;
public italics: boolean;
public strikethrough: boolean;
public error_underline: boolean;
public underline: boolean;
public font_face?:
| 'FONT_FACE_UNKNOWN'
| 'FONT_FACE_YT_SANS_MEDIUM'
| 'FONT_FACE_ROBOTO_MEDIUM'
| 'FONT_FACE_YOUTUBE_SANS_LIGHT'
| 'FONT_FACE_YOUTUBE_SANS_REGULAR'
| 'FONT_FACE_YOUTUBE_SANS_MEDIUM'
| 'FONT_FACE_YOUTUBE_SANS_SEMIBOLD'
| 'FONT_FACE_YOUTUBE_SANS_BOLD'
| 'FONT_FACE_YOUTUBE_SANS_EXTRABOLD'
| 'FONT_FACE_YOUTUBE_SANS_BLACK'
| 'FONT_FACE_YT_SANS_BOLD'
| 'FONT_FACE_ROBOTO_REGULAR';
public attachment;
constructor(data: RawNode) {
this.text = data.text;
this.bold = Boolean(data.bold);
this.bracket = Boolean(data.bracket);
this.italics = Boolean(data.italics);
this.strikethrough = Boolean(data.strikethrough);
this.error_underline = Boolean(data.error_underline);
this.underline = Boolean(data.underline);
this.deemphasize = Boolean(data.deemphasize);
if (Reflect.has(data, 'navigationEndpoint')) {
if ('textColor' in data) {
this.text_color = data.textColor;
}
if ('navigationEndpoint' in data) {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
if ('darkModeTextColor' in data) {
this.dark_mode_text_color = data.darkModeTextColor;
}
if ('fontFace' in data) {
this.font_face = data.fontFace;
}
this.attachment = data.attachment;
}
@@ -36,7 +69,9 @@ export default class TextRun implements Run {
if (this.italics) tags.push('i');
if (this.strikethrough) tags.push('s');
if (this.deemphasize) tags.push('small');
if (this.underline) tags.push('u');
if (this.error_underline) tags.push('u');
if (!this.text?.length)
return '';
@@ -48,13 +83,13 @@ export default class TextRun implements Run {
if (this.attachment.element.type.imageType.image.sources.length) {
if (this.endpoint) {
const { url } = this.attachment.element.type.imageType.image.sources[0];
let image_el = '';
if (url) {
image_el = `<img src="${url}" style="vertical-align: middle; height: ${this.attachment.element.properties.layoutProperties.height.value}px; width: ${this.attachment.element.properties.layoutProperties.width.value}px;" alt="">`;
}
const nav_url = this.endpoint.toURL();
if (nav_url) return `<a href="${nav_url}" class="yt-ch-link">${image_el}${wrapped_text}</a>`;
}

View File

@@ -2,6 +2,7 @@
// See ./scripts/build-parser-map.js
export { default as AccessibilityContext } from './classes/misc/AccessibilityContext.js';
export { default as AccessibilityData } from './classes/misc/AccessibilityData.js';
export { default as Author } from './classes/misc/Author.js';
export { default as ChildElement } from './classes/misc/ChildElement.js';
export { default as CommandContext } from './classes/misc/CommandContext.js';