Compare commits

...

4 Commits

Author SHA1 Message Date
github-actions[bot]
cd69ce73c1 chore(main): release 9.3.0 (#635)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-11 18:19:12 -03:00
LuanRT
1c08bfe113 feat(CommentView): Implement comment interaction methods 2024-04-11 18:04:45 -03:00
LuanRT
a624963384 docs(Comments): Update API ref 2024-04-11 18:03:04 -03:00
LuanRT
66e34f9388 fix(CommentThread): Replies not being parsed correctly 2024-04-11 16:05:59 -03:00
11 changed files with 248 additions and 68 deletions

View File

@@ -1,5 +1,17 @@
# Changelog
## [9.3.0](https://github.com/LuanRT/YouTube.js/compare/v9.2.1...v9.3.0) (2024-04-11)
### Features
* **CommentView:** Implement comment interaction methods ([1c08bfe](https://github.com/LuanRT/YouTube.js/commit/1c08bfe113804c69fbc4e49b42442d9a63d73be6))
### Bug Fixes
* **CommentThread:** Replies not being parsed correctly ([66e34f9](https://github.com/LuanRT/YouTube.js/commit/66e34f9388429a2088d5c5835d19eebdc881c957))
## [9.2.1](https://github.com/LuanRT/YouTube.js/compare/v9.2.0...v9.2.1) (2024-04-09)

View File

@@ -5,8 +5,8 @@ A `CommentThread` represents a top-level comment and its replies.
## API
* CommentThread
* [.comment](#comment) ⇒ `Comment`
* [.replies](#replies) ⇒ `Comment[]`
* [.comment](#comment) ⇒ `Comment | CommentView`
* [.replies](#replies) ⇒ `(Comment | CommentView)[]`
* [.getReplies](#getreplies) ⇒ `function`
* [.getContinuation](#getcontinuation) ⇒ `function`
* [.has_continuation](#hascontinuation) ⇒ `boolean`
@@ -14,7 +14,7 @@ A `CommentThread` represents a top-level comment and its replies.
<a name="comment"></a>
### comment
The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
The top-level comment. **Note:** More about the `Comment` node [here](./Comment.md) (OUTDATED! `Comment` has been replaced by [`CommentView`](./CommentView.md) nodes).
**Type:** [`Comment`](../../src/parser/classes/comments/Comment.ts)
@@ -22,7 +22,7 @@ The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
### replies
An array of replies to the top-level comment. (not populated until [`getReplies()`](#getreplies) is called).
**Type:** [`Comment[]`](../../src/parser/classes/comments/Comment.ts)
**Type:** [`(Comment | CommentView)[]`](../../src/parser/classes/comments/Comment.ts)
<a name="getreplies"></a>
### getReplies()

View File

@@ -0,0 +1,48 @@
## CommentView
Contains information about a single comment. A [`CommentView`](../../src/parser/classes/comments/CommentView.ts) can be a top-level comment or a reply to a top-level comment.
## API
* Comment
* [.like](#like) ⇒ `function`
* [.unlike](#like) ⇒ `function`
* [.dislike](#dislike) ⇒ `function`
* [.undislike](#dislike) ⇒ `function`
* [.reply](#reply) ⇒ `function`
* [.translate](#translate) ⇒ `function`
<a name="like"></a>
### like()
Likes the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="unlike"></a>
### unlike()
Unlikes the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="dislike"></a>
### dislike()
Dislikes the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="undislike"></a>
### undislike()
Undislikes the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="reply"></a>
### reply(comment_text: string)
Replies to the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="translate"></a>
### translate(target_language: string)
Translates the comment to the given language.
**Returns:** `Promise.<ApiResponse & { content?: string }>`

View File

@@ -59,7 +59,4 @@ Returns whether there are more comments to be fetched.
### page
Returns original InnerTube response (sanitized).
**Returns:** `ParsedResponse`
## Example
See [`index.ts`](./index.ts).
**Returns:** `ParsedResponse`

View File

@@ -1,45 +0,0 @@
import { Innertube, UniversalCache } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
const comment_section = await yt.getComments('a-rqu-hjobc');
console.info(`This video has ${comment_section.header?.comments_count.toString() || 'N/A'} comments.\n`);
for (const thread of comment_section.contents) {
const comment = thread.comment;
if (comment) {
console.info(
`${comment.is_pinned ? '[Pinned]' : ''}`,
`${comment.is_member ? `${comment.sponsor_comment_badge?.tooltip}` : ''}`,
`${comment.author.name}${comment.published}\n`,
`${comment.content.toString()}`, '\n',
`Likes: ${comment.vote_count}`, '\n'
);
if (thread.has_replies) {
console.info('Replies:', '\n');
let comment_thread = await thread.getReplies();
while (true) {
for (const reply of comment_thread?.replies || []) {
console.info(
`> ${reply.author.name}${reply.published}\n`,
`${reply.content.toString()}`, '\n',
`Likes: ${reply.vote_count}`, '\n'
);
}
try {
comment_thread = await comment_thread.getContinuation();
} catch { break; };
}
}
}
console.log('\n');
}
})();

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "youtubei.js",
"version": "9.2.1",
"version": "9.3.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "9.2.1",
"version": "9.3.0",
"funding": [
"https://github.com/sponsors/LuanRT"
],

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "9.2.1",
"version": "9.3.0",
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",

View File

@@ -90,7 +90,7 @@ export default class CommentThread extends YTNode {
if (!response.on_response_received_endpoints_memo)
throw new InnertubeError('Unexpected response.', response);
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment, CommentView).map((comment) => {
comment.setActions(this.#actions);
return comment;
}));

View File

@@ -1,15 +1,26 @@
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import type Actions from '../../../core/Actions.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
import Author from '../misc/Author.js';
import Text from '../misc/Text.js';
import CommentReplyDialog from './CommentReplyDialog.js';
import { InnertubeError } from '../../../utils/Utils.js';
import * as Proto from '../../../proto/index.js';
import type Actions from '../../../core/Actions.js';
import type { ApiResponse } from '../../../core/Actions.js';
import type { RawNode } from '../../index.js';
export default class CommentView extends YTNode {
static type = 'CommentView';
#actions?: Actions;
like_command?: NavigationEndpoint;
dislike_command?: NavigationEndpoint;
unlike_command?: NavigationEndpoint;
undislike_command?: NavigationEndpoint;
reply_command?: NavigationEndpoint;
comment_id: string;
is_pinned: boolean;
keys: {
@@ -32,6 +43,8 @@ export default class CommentView extends YTNode {
};
author?: Author;
test: any;
is_liked?: boolean;
is_disliked?: boolean;
is_hearted?: boolean;
@@ -51,7 +64,7 @@ export default class CommentView extends YTNode {
};
}
applyMutations(comment?: RawNode, toolbar_state?: RawNode) {
applyMutations(comment?: RawNode, toolbar_state?: RawNode, toolbar_surface?: RawNode) {
if (comment) {
this.content = Text.fromAttributed(comment.properties.content);
this.published_time = comment.properties.publishedTime;
@@ -78,8 +91,148 @@ export default class CommentView extends YTNode {
if (toolbar_state) {
this.is_hearted = toolbar_state.heartState === 'TOOLBAR_HEART_STATE_HEARTED';
this.is_liked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_LIKED';
this.is_disliked = toolbar_state.likeState === 'TOOLBAR_HEART_STATE_DISLIKED';
this.is_disliked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_DISLIKED';
}
if (toolbar_surface && !Reflect.has(toolbar_surface, 'prepareAccountCommand')) {
this.like_command = new NavigationEndpoint(toolbar_surface.likeCommand);
this.dislike_command = new NavigationEndpoint(toolbar_surface.dislikeCommand);
this.unlike_command = new NavigationEndpoint(toolbar_surface.unlikeCommand);
this.undislike_command = new NavigationEndpoint(toolbar_surface.undislikeCommand);
this.reply_command = new NavigationEndpoint(toolbar_surface.replyCommand);
}
}
/**
* Likes the comment.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the like command is not found.
*/
async like(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.like_command)
throw new InnertubeError('Like command not found.');
if (this.is_liked)
throw new InnertubeError('This comment is already liked.', { comment_id: this.comment_id });
return this.like_command.call(this.#actions);
}
/**
* Dislikes the comment.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the dislike command is not found.
*/
async dislike(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.dislike_command)
throw new InnertubeError('Dislike command not found.');
if (this.is_disliked)
throw new InnertubeError('This comment is already disliked.', { comment_id: this.comment_id });
return this.dislike_command.call(this.#actions);
}
/**
* Unlikes the comment.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the unlike command is not found.
*/
async unlike(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.unlike_command)
throw new InnertubeError('Unlike command not found.');
if (!this.is_liked)
throw new InnertubeError('This comment is not liked.', { comment_id: this.comment_id });
return this.unlike_command.call(this.#actions);
}
/**
* Undislikes the comment.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the undislike command is not found.
*/
async undislike(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.undislike_command)
throw new InnertubeError('Undislike command not found.');
if (!this.is_disliked)
throw new InnertubeError('This comment is not disliked.', { comment_id: this.comment_id });
return this.undislike_command.call(this.#actions);
}
/**
* Replies to the comment.
* @param comment_text - The text of the reply.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the reply command is not found.
*/
async reply(comment_text: string): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.reply_command)
throw new InnertubeError('Reply command not found.');
const dialog = this.reply_command.dialog?.as(CommentReplyDialog);
if (!dialog)
throw new InnertubeError('Reply dialog not found.');
const reply_button = dialog.reply_button;
if (!reply_button)
throw new InnertubeError('Reply button not found in the dialog.');
if (!reply_button.endpoint)
throw new InnertubeError('Reply button endpoint not found.');
return reply_button.endpoint.call(this.#actions, { commentText: comment_text });
}
/**
* Translates the comment to the specified target language.
* @param target_language - The target language to translate the comment to, e.g. 'en', 'ja'.
* @returns A Promise that resolves to an ApiResponse object with the translated content, if available.
* @throws if the Actions instance is not set for this comment or if the comment content is not found.
*/
async translate(target_language: string): Promise<ApiResponse & { content?: string }> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.content)
throw new InnertubeError('Comment content not found.', { comment_id: this.comment_id });
// Emojis must be removed otherwise InnerTube throws a 400 status code at us.
const text = this.content.toString().replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, '');
const payload = {
text,
target_language
};
const action = Proto.encodeCommentActionParams(22, payload);
const response = await this.#actions.execute('comment/perform_comment_action', { action, client: 'ANDROID' });
// XXX: Should move this to Parser#parseResponse
const mutations = response.data.frameworkUpdates?.entityBatchUpdate?.mutations;
const content = mutations?.[0]?.payload?.commentEntityPayload?.translatedContent?.content;
return { ...response, content };
}
setActions(actions: Actions | undefined) {

View File

@@ -710,7 +710,10 @@ export function applyCommentsMutations(memo: Memo, mutations: RawNode[]) {
.find((mutation) => mutation.payload?.engagementToolbarStateEntityPayload?.key === comment_view.keys.toolbar_state)
?.payload?.engagementToolbarStateEntityPayload;
comment_view.applyMutations(comment_mutation, toolbar_state_mutation);
const engagement_toolbar = mutations.find((mutation) => mutation.entityKey === comment_view.keys.toolbar_surface)
?.payload?.engagementToolbarSurfaceEntityPayload;
comment_view.applyMutations(comment_mutation, toolbar_state_mutation, engagement_toolbar);
}
}
}

View File

@@ -1,6 +1,8 @@
import { createWriteStream, existsSync } from 'node:fs';
import { Innertube, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
jest.useRealTimers();
describe('YouTube.js Tests', () => {
let innertube: Innertube;
@@ -99,7 +101,7 @@ describe('YouTube.js Tests', () => {
let comments: YT.Comments;
beforeAll(async () => {
comments = await innertube.getComments('gmX-ceF-N1k');
comments = await innertube.getComments('bUHZ2k9DYHY');
expect(comments).toBeDefined();
expect(comments.header).toBeDefined();
expect(comments.contents).toBeDefined();
@@ -112,10 +114,20 @@ describe('YouTube.js Tests', () => {
expect(incremental_continuation.contents.length).toBeGreaterThan(0);
});
test('CommentThread#getReplies', async () => {
let comment_thread = comments.contents.first();
let loaded_comment_thread = await comment_thread.getReplies();
expect(loaded_comment_thread.replies).toBeDefined();
describe('CommentThread#getReplies', () => {
let loaded_comment_thread: YTNodes.CommentThread;
beforeAll(async () => {
let comment_thread = comments.contents.first();
loaded_comment_thread = await comment_thread.getReplies();
expect(loaded_comment_thread.replies).toBeDefined();
});
test('CommentThread#getContinuation', async () => {
const incremental_continuation = await loaded_comment_thread.getContinuation();
expect(incremental_continuation.replies).toBeDefined();
expect(incremental_continuation.replies?.length).toBeGreaterThan(0);
});
});
});