[YouTube] Support new A/B tested comments data

Also improve current comments code by removing outdated comment
renderer data.
This commit is contained in:
AudricV 2024-03-17 15:08:58 +01:00
parent e5b30ae8c3
commit 293c3e9e47
No known key found for this signature in database
GPG Key ID: DA92EC7905614198
3 changed files with 377 additions and 71 deletions

View File

@ -0,0 +1,235 @@
package org.schabi.newpipe.extractor.services.youtube.extractors;
import com.grack.nanojson.JsonObject;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.comments.CommentsInfoItemExtractor;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.localization.TimeAgoParser;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.utils.Utils;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.List;
import java.util.Objects;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAttributedDescription;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getImagesFromThumbnailsArray;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
/**
* A {@link CommentsInfoItemExtractor} for YouTube comment data returned in a view model and entity
* updates.
*/
class YoutubeCommentsEUVMInfoItemExtractor implements CommentsInfoItemExtractor {
private static final String AUTHOR = "author";
private static final String PROPERTIES = "properties";
@Nonnull
private final JsonObject commentViewModel;
@Nullable
private final JsonObject commentRepliesRenderer;
@Nonnull
private final JsonObject commentEntityPayload;
@Nonnull
private final JsonObject engagementToolbarStateEntityPayload;
@Nonnull
private final String videoUrl;
@Nonnull
private final TimeAgoParser timeAgoParser;
YoutubeCommentsEUVMInfoItemExtractor(
@Nonnull final JsonObject commentViewModel,
@Nullable final JsonObject commentRepliesRenderer,
@Nonnull final JsonObject commentEntityPayload,
@Nonnull final JsonObject engagementToolbarStateEntityPayload,
@Nonnull final String videoUrl,
@Nonnull final TimeAgoParser timeAgoParser) {
this.commentViewModel = commentViewModel;
this.commentRepliesRenderer = commentRepliesRenderer;
this.commentEntityPayload = commentEntityPayload;
this.engagementToolbarStateEntityPayload = engagementToolbarStateEntityPayload;
this.videoUrl = videoUrl;
this.timeAgoParser = timeAgoParser;
}
@Override
public String getName() throws ParsingException {
return getUploaderName();
}
@Override
public String getUrl() throws ParsingException {
return videoUrl;
}
@Nonnull
@Override
public List<Image> getThumbnails() throws ParsingException {
return getUploaderAvatars();
}
@Override
public int getLikeCount() throws ParsingException {
final String textualLikeCount = getTextualLikeCount();
try {
if (Utils.isBlank(textualLikeCount)) {
return 0;
}
return (int) Utils.mixedNumberWordToLong(textualLikeCount);
} catch (final Exception e) {
throw new ParsingException(
"Unexpected error while converting textual like count to like count", e);
}
}
@Override
public String getTextualLikeCount() {
return commentEntityPayload.getObject("toolbar")
.getString("likeCountNotliked");
}
@Override
public Description getCommentText() throws ParsingException {
// Comments' text work in the same way as an attributed video description
return new Description(
getAttributedDescription(commentEntityPayload.getObject(PROPERTIES)
.getObject("content")), Description.HTML);
}
@Override
public String getTextualUploadDate() throws ParsingException {
return commentEntityPayload.getObject(PROPERTIES)
.getString("publishedTime");
}
@Nullable
@Override
public DateWrapper getUploadDate() throws ParsingException {
final String textualPublishedTime = getTextualUploadDate();
if (isNullOrEmpty(textualPublishedTime)) {
return null;
}
return timeAgoParser.parse(textualPublishedTime);
}
@Override
public String getCommentId() throws ParsingException {
String commentId = commentEntityPayload.getObject(PROPERTIES)
.getString("commentId");
if (isNullOrEmpty(commentId)) {
commentId = commentViewModel.getString("commentId");
if (isNullOrEmpty(commentId)) {
throw new ParsingException("Could not get comment ID");
}
}
return commentId;
}
@Override
public String getUploaderUrl() throws ParsingException {
final JsonObject author = commentEntityPayload.getObject(AUTHOR);
String channelId = author.getString("channelId");
if (isNullOrEmpty(channelId)) {
channelId = author.getObject("channelCommand")
.getObject("innertubeCommand")
.getObject("browseEndpoint")
.getString("browseId");
if (isNullOrEmpty(channelId)) {
channelId = author.getObject("avatar")
.getObject("endpoint")
.getObject("innertubeCommand")
.getObject("browseEndpoint")
.getString("browseId");
if (isNullOrEmpty(channelId)) {
throw new ParsingException("Could not get channel ID");
}
}
}
return "https://www.youtube.com/channel/" + channelId;
}
@Override
public String getUploaderName() throws ParsingException {
return commentEntityPayload.getObject(AUTHOR)
.getString("displayName");
}
@Nonnull
@Override
public List<Image> getUploaderAvatars() throws ParsingException {
return getImagesFromThumbnailsArray(commentEntityPayload.getObject("avatar")
.getObject("image")
.getArray("sources"));
}
@Override
public boolean isHeartedByUploader() {
return "TOOLBAR_HEART_STATE_HEARTED".equals(
engagementToolbarStateEntityPayload.getString("heartState"));
}
@Override
public boolean isPinned() {
return commentViewModel.has("pinnedText");
}
@Override
public boolean isUploaderVerified() throws ParsingException {
final JsonObject author = commentEntityPayload.getObject(AUTHOR);
return author.getBoolean("isVerified") || author.getBoolean("isArtist");
}
@Override
public int getReplyCount() throws ParsingException {
// As YouTube allows replies up to 750 comments, we cannot check if the count returned is a
// mixed number or a real number
// Assume it is a mixed one, as it matches how numbers of most properties are returned
final String replyCountString = commentEntityPayload.getObject("toolbar")
.getString("replyCount");
if (isNullOrEmpty(replyCountString)) {
return 0;
}
return (int) Utils.mixedNumberWordToLong(replyCountString);
}
@Nullable
@Override
public Page getReplies() throws ParsingException {
if (isNullOrEmpty(commentRepliesRenderer)) {
return null;
}
final String continuation = commentRepliesRenderer.getArray("contents")
.stream()
.filter(JsonObject.class::isInstance)
.map(JsonObject.class::cast)
.map(content -> content.getObject("continuationItemRenderer", null))
.filter(Objects::nonNull)
.findFirst()
.map(continuationItemRenderer ->
continuationItemRenderer.getObject("continuationEndpoint")
.getObject("continuationCommand")
.getString("token"))
.orElseThrow(() ->
new ParsingException("Could not get comment replies continuation"));
return new Page(videoUrl, continuation);
}
@Override
public boolean isChannelOwner() {
return commentEntityPayload.getObject(AUTHOR)
.getBoolean("isCreator");
}
@Override
public boolean hasCreatorReply() {
return commentRepliesRenderer != null
&& commentRepliesRenderer.has("viewRepliesCreatorThumbnail");
}
}

View File

@ -13,6 +13,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.localization.Localization;
import org.schabi.newpipe.extractor.localization.TimeAgoParser;
import org.schabi.newpipe.extractor.utils.JsonUtils; import org.schabi.newpipe.extractor.utils.JsonUtils;
import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.extractor.utils.Utils;
@ -21,7 +22,6 @@ import javax.annotation.Nullable;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonPostResponse; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonPostResponse;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTextFromObject; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTextFromObject;
@ -30,6 +30,9 @@ import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
public class YoutubeCommentsExtractor extends CommentsExtractor { public class YoutubeCommentsExtractor extends CommentsExtractor {
private static final String COMMENT_VIEW_MODEL_KEY = "commentViewModel";
private static final String COMMENT_RENDERER_KEY = "commentRenderer";
/** /**
* Whether comments are disabled on video. * Whether comments are disabled on video.
*/ */
@ -74,8 +77,7 @@ public class YoutubeCommentsExtractor extends CommentsExtractor {
return null; return null;
} }
final String token = contents final String token = contents.stream()
.stream()
// Only use JsonObjects // Only use JsonObjects
.filter(JsonObject.class::isInstance) .filter(JsonObject.class::isInstance)
.map(JsonObject.class::cast) .map(JsonObject.class::cast)
@ -120,6 +122,21 @@ public class YoutubeCommentsExtractor extends CommentsExtractor {
} }
} }
@Nonnull
private JsonObject getMutationPayloadFromEntityKey(@Nonnull final JsonArray mutations,
@Nonnull final String commentKey)
throws ParsingException {
return mutations.stream()
.filter(JsonObject.class::isInstance)
.map(JsonObject.class::cast)
.filter(mutation -> commentKey.equals(
mutation.getString("entityKey")))
.findFirst()
.orElseThrow(() -> new ParsingException(
"Could not get comment entity payload mutation"))
.getObject("payload");
}
@Nonnull @Nonnull
private InfoItemsPage<CommentsInfoItem> getInfoItemsPageForDisabledComments() { private InfoItemsPage<CommentsInfoItem> getInfoItemsPageForDisabledComments() {
return new InfoItemsPage<>(Collections.emptyList(), null, Collections.emptyList()); return new InfoItemsPage<>(Collections.emptyList(), null, Collections.emptyList());
@ -207,8 +224,8 @@ public class YoutubeCommentsExtractor extends CommentsExtractor {
return new InfoItemsPage<>(collector, getNextPage(jsonObject)); return new InfoItemsPage<>(collector, getNextPage(jsonObject));
} }
private void collectCommentsFrom(final CommentsInfoItemsCollector collector, private void collectCommentsFrom(@Nonnull final CommentsInfoItemsCollector collector,
final JsonObject jsonObject) @Nonnull final JsonObject jsonObject)
throws ParsingException { throws ParsingException {
final JsonArray onResponseReceivedEndpoints = final JsonArray onResponseReceivedEndpoints =
@ -233,6 +250,8 @@ public class YoutubeCommentsExtractor extends CommentsExtractor {
final JsonArray contents; final JsonArray contents;
try { try {
// A copy of the array is needed, otherwise the continuation item is removed from the
// original object which is used to get the continuation
contents = new JsonArray(JsonUtils.getArray(commentsEndpoint, path)); contents = new JsonArray(JsonUtils.getArray(commentsEndpoint, path));
} catch (final Exception e) { } catch (final Exception e) {
// No comments // No comments
@ -244,23 +263,80 @@ public class YoutubeCommentsExtractor extends CommentsExtractor {
contents.remove(index); contents.remove(index);
} }
final String jsonKey = contents.getObject(0).has("commentThreadRenderer") // The mutations object, which is returned in the comments' continuation
? "commentThreadRenderer" // It contains parts of comment data when comments are returned with a view model
: "commentRenderer"; final JsonArray mutations = jsonObject.getObject("frameworkUpdates")
.getObject("entityBatchUpdate")
.getArray("mutations");
final String videoUrl = getUrl();
final TimeAgoParser timeAgoParser = getTimeAgoParser();
final List<Object> comments; for (final Object o : contents) {
try { if (!(o instanceof JsonObject)) {
comments = JsonUtils.getValues(contents, jsonKey); continue;
} catch (final Exception e) { }
throw new ParsingException("Unable to get parse youtube comments", e);
collectCommentItem(mutations, (JsonObject) o, collector, videoUrl, timeAgoParser);
} }
}
final String url = getUrl(); private void collectCommentItem(@Nonnull final JsonArray mutations,
comments.stream() @Nonnull final JsonObject content,
.filter(JsonObject.class::isInstance) @Nonnull final CommentsInfoItemsCollector collector,
.map(JsonObject.class::cast) @Nonnull final String videoUrl,
.map(jObj -> new YoutubeCommentsInfoItemExtractor(jObj, url, getTimeAgoParser())) @Nonnull final TimeAgoParser timeAgoParser)
.forEach(collector::commit); throws ParsingException {
if (content.has("commentThreadRenderer")) {
final JsonObject commentThreadRenderer =
content.getObject("commentThreadRenderer");
if (commentThreadRenderer.has(COMMENT_VIEW_MODEL_KEY)) {
final JsonObject commentViewModel =
commentThreadRenderer.getObject(COMMENT_VIEW_MODEL_KEY)
.getObject(COMMENT_VIEW_MODEL_KEY);
collector.commit(new YoutubeCommentsEUVMInfoItemExtractor(
commentViewModel,
commentThreadRenderer.getObject("replies")
.getObject("commentRepliesRenderer"),
getMutationPayloadFromEntityKey(mutations,
commentViewModel.getString("commentKey", ""))
.getObject("commentEntityPayload"),
getMutationPayloadFromEntityKey(mutations,
commentViewModel.getString("toolbarStateKey", ""))
.getObject("engagementToolbarStateEntityPayload"),
videoUrl,
timeAgoParser));
} else if (commentThreadRenderer.has("comment")) {
collector.commit(new YoutubeCommentsInfoItemExtractor(
commentThreadRenderer.getObject("comment")
.getObject(COMMENT_RENDERER_KEY),
commentThreadRenderer.getObject("replies")
.getObject("commentRepliesRenderer"),
videoUrl,
timeAgoParser));
}
} else if (content.has(COMMENT_VIEW_MODEL_KEY)) {
final JsonObject commentViewModel = content.getObject(COMMENT_VIEW_MODEL_KEY);
collector.commit(new YoutubeCommentsEUVMInfoItemExtractor(
commentViewModel,
null,
getMutationPayloadFromEntityKey(mutations,
commentViewModel.getString("commentKey", ""))
.getObject("commentEntityPayload"),
getMutationPayloadFromEntityKey(mutations,
commentViewModel.getString("toolbarStateKey", ""))
.getObject("engagementToolbarStateEntityPayload"),
videoUrl,
timeAgoParser));
} else if (content.has(COMMENT_RENDERER_KEY)) {
// commentRenderers are directly returned for comment replies, so there is no
// commentRepliesRenderer to provide
// Also, YouTube has only one comment reply level
collector.commit(new YoutubeCommentsInfoItemExtractor(
content.getObject(COMMENT_RENDERER_KEY),
null,
videoUrl,
timeAgoParser));
}
} }
@Override @Override
@ -307,10 +383,11 @@ public class YoutubeCommentsExtractor extends CommentsExtractor {
return -1; return -1;
} }
final JsonObject countText = ajaxJson final JsonObject countText = ajaxJson.getArray("onResponseReceivedEndpoints")
.getArray("onResponseReceivedEndpoints").getObject(0) .getObject(0)
.getObject("reloadContinuationItemsCommand") .getObject("reloadContinuationItemsCommand")
.getArray("continuationItems").getObject(0) .getArray("continuationItems")
.getObject(0)
.getObject("commentsHeaderRenderer") .getObject("commentsHeaderRenderer")
.getObject("countText"); .getObject("countText");

View File

@ -22,40 +22,36 @@ import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper
public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtractor { public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtractor {
private final JsonObject json; @Nonnull
private JsonObject commentRenderer; private final JsonObject commentRenderer;
@Nullable
private final JsonObject commentRepliesRenderer;
@Nonnull
private final String url; private final String url;
@Nonnull
private final TimeAgoParser timeAgoParser; private final TimeAgoParser timeAgoParser;
public YoutubeCommentsInfoItemExtractor(final JsonObject json, public YoutubeCommentsInfoItemExtractor(@Nonnull final JsonObject commentRenderer,
final String url, @Nullable final JsonObject commentRepliesRenderer,
final TimeAgoParser timeAgoParser) { @Nonnull final String url,
this.json = json; @Nonnull final TimeAgoParser timeAgoParser) {
this.commentRenderer = commentRenderer;
this.commentRepliesRenderer = commentRepliesRenderer;
this.url = url; this.url = url;
this.timeAgoParser = timeAgoParser; this.timeAgoParser = timeAgoParser;
} }
private JsonObject getCommentRenderer() throws ParsingException {
if (commentRenderer == null) {
if (json.has("comment")) {
commentRenderer = JsonUtils.getObject(json, "comment.commentRenderer");
} else {
commentRenderer = json;
}
}
return commentRenderer;
}
@Nonnull @Nonnull
private List<Image> getAuthorThumbnails() throws ParsingException { private List<Image> getAuthorThumbnails() throws ParsingException {
try { try {
return getImagesFromThumbnailsArray(JsonUtils.getArray(getCommentRenderer(), return getImagesFromThumbnailsArray(JsonUtils.getArray(commentRenderer,
"authorThumbnail.thumbnails")); "authorThumbnail.thumbnails"));
} catch (final Exception e) { } catch (final Exception e) {
throw new ParsingException("Could not get author thumbnails", e); throw new ParsingException("Could not get author thumbnails", e);
} }
} }
@Nonnull
@Override @Override
public String getUrl() throws ParsingException { public String getUrl() throws ParsingException {
return url; return url;
@ -70,7 +66,7 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
@Override @Override
public String getName() throws ParsingException { public String getName() throws ParsingException {
try { try {
return getTextFromObject(JsonUtils.getObject(getCommentRenderer(), "authorText")); return getTextFromObject(JsonUtils.getObject(commentRenderer, "authorText"));
} catch (final Exception e) { } catch (final Exception e) {
return ""; return "";
} }
@ -79,7 +75,7 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
@Override @Override
public String getTextualUploadDate() throws ParsingException { public String getTextualUploadDate() throws ParsingException {
try { try {
return getTextFromObject(JsonUtils.getObject(getCommentRenderer(), return getTextFromObject(JsonUtils.getObject(commentRenderer,
"publishedTimeText")); "publishedTimeText"));
} catch (final Exception e) { } catch (final Exception e) {
throw new ParsingException("Could not get publishedTimeText", e); throw new ParsingException("Could not get publishedTimeText", e);
@ -90,8 +86,7 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
@Override @Override
public DateWrapper getUploadDate() throws ParsingException { public DateWrapper getUploadDate() throws ParsingException {
final String textualPublishedTime = getTextualUploadDate(); final String textualPublishedTime = getTextualUploadDate();
if (timeAgoParser != null && textualPublishedTime != null if (textualPublishedTime != null && !textualPublishedTime.isEmpty()) {
&& !textualPublishedTime.isEmpty()) {
return timeAgoParser.parse(textualPublishedTime); return timeAgoParser.parse(textualPublishedTime);
} else { } else {
return null; return null;
@ -118,7 +113,7 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
// Try first to get the exact like count by using the accessibility data // Try first to get the exact like count by using the accessibility data
final String likeCount; final String likeCount;
try { try {
likeCount = Utils.removeNonDigitCharacters(JsonUtils.getString(getCommentRenderer(), likeCount = Utils.removeNonDigitCharacters(JsonUtils.getString(commentRenderer,
"actionButtons.commentActionButtonsRenderer.likeButton.toggleButtonRenderer" "actionButtons.commentActionButtonsRenderer.likeButton.toggleButtonRenderer"
+ ".accessibilityData.accessibilityData.label")); + ".accessibilityData.accessibilityData.label"));
} catch (final Exception e) { } catch (final Exception e) {
@ -170,11 +165,11 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
*/ */
try { try {
// If a comment has no likes voteCount is not set // If a comment has no likes voteCount is not set
if (!getCommentRenderer().has("voteCount")) { if (!commentRenderer.has("voteCount")) {
return ""; return "";
} }
final JsonObject voteCountObj = JsonUtils.getObject(getCommentRenderer(), "voteCount"); final JsonObject voteCountObj = JsonUtils.getObject(commentRenderer, "voteCount");
if (voteCountObj.isEmpty()) { if (voteCountObj.isEmpty()) {
return ""; return "";
} }
@ -188,7 +183,7 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
@Override @Override
public Description getCommentText() throws ParsingException { public Description getCommentText() throws ParsingException {
try { try {
final JsonObject contentText = JsonUtils.getObject(getCommentRenderer(), "contentText"); final JsonObject contentText = JsonUtils.getObject(commentRenderer, "contentText");
if (contentText.isEmpty()) { if (contentText.isEmpty()) {
// completely empty comments as described in // completely empty comments as described in
// https://github.com/TeamNewPipe/NewPipeExtractor/issues/380#issuecomment-668808584 // https://github.com/TeamNewPipe/NewPipeExtractor/issues/380#issuecomment-668808584
@ -208,7 +203,7 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
@Override @Override
public String getCommentId() throws ParsingException { public String getCommentId() throws ParsingException {
try { try {
return JsonUtils.getString(getCommentRenderer(), "commentId"); return JsonUtils.getString(commentRenderer, "commentId");
} catch (final Exception e) { } catch (final Exception e) {
throw new ParsingException("Could not get comment id", e); throw new ParsingException("Could not get comment id", e);
} }
@ -221,27 +216,26 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
} }
@Override @Override
public boolean isHeartedByUploader() throws ParsingException { public boolean isHeartedByUploader() {
final JsonObject commentActionButtonsRenderer = getCommentRenderer() final JsonObject commentActionButtonsRenderer = commentRenderer.getObject("actionButtons")
.getObject("actionButtons")
.getObject("commentActionButtonsRenderer"); .getObject("commentActionButtonsRenderer");
return commentActionButtonsRenderer.has("creatorHeart"); return commentActionButtonsRenderer.has("creatorHeart");
} }
@Override @Override
public boolean isPinned() throws ParsingException { public boolean isPinned() {
return getCommentRenderer().has("pinnedCommentBadge"); return commentRenderer.has("pinnedCommentBadge");
} }
@Override @Override
public boolean isUploaderVerified() throws ParsingException { public boolean isUploaderVerified() throws ParsingException {
return getCommentRenderer().has("authorCommentBadge"); return commentRenderer.has("authorCommentBadge");
} }
@Override @Override
public String getUploaderName() throws ParsingException { public String getUploaderName() throws ParsingException {
try { try {
return getTextFromObject(JsonUtils.getObject(getCommentRenderer(), "authorText")); return getTextFromObject(JsonUtils.getObject(commentRenderer, "authorText"));
} catch (final Exception e) { } catch (final Exception e) {
return ""; return "";
} }
@ -250,7 +244,7 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
@Override @Override
public String getUploaderUrl() throws ParsingException { public String getUploaderUrl() throws ParsingException {
try { try {
return "https://www.youtube.com/channel/" + JsonUtils.getString(getCommentRenderer(), return "https://www.youtube.com/channel/" + JsonUtils.getString(commentRenderer,
"authorEndpoint.browseEndpoint.browseId"); "authorEndpoint.browseEndpoint.browseId");
} catch (final Exception e) { } catch (final Exception e) {
return ""; return "";
@ -258,19 +252,22 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
} }
@Override @Override
public int getReplyCount() throws ParsingException { public int getReplyCount() {
final JsonObject commentRendererJsonObject = getCommentRenderer(); if (commentRenderer.has("replyCount")) {
if (commentRendererJsonObject.has("replyCount")) { return commentRenderer.getInt("replyCount");
return commentRendererJsonObject.getInt("replyCount");
} }
return UNKNOWN_REPLY_COUNT; return UNKNOWN_REPLY_COUNT;
} }
@Override @Override
public Page getReplies() { public Page getReplies() {
if (commentRepliesRenderer == null) {
return null;
}
try { try {
final String id = JsonUtils.getString( final String id = JsonUtils.getString(
JsonUtils.getArray(json, "replies.commentRepliesRenderer.contents") JsonUtils.getArray(commentRepliesRenderer, "contents")
.getObject(0), .getObject(0),
"continuationItemRenderer.continuationEndpoint.continuationCommand.token"); "continuationItemRenderer.continuationEndpoint.continuationCommand.token");
return new Page(url, id); return new Page(url, id);
@ -280,20 +277,17 @@ public class YoutubeCommentsInfoItemExtractor implements CommentsInfoItemExtract
} }
@Override @Override
public boolean isChannelOwner() throws ParsingException { public boolean isChannelOwner() {
return getCommentRenderer().getBoolean("authorIsChannelOwner"); return commentRenderer.getBoolean("authorIsChannelOwner");
} }
@Override @Override
public boolean hasCreatorReply() throws ParsingException { public boolean hasCreatorReply() {
try { if (commentRepliesRenderer == null) {
final JsonObject commentRepliesRenderer = JsonUtils.getObject(json,
"replies.commentRepliesRenderer");
return commentRepliesRenderer.has("viewRepliesCreatorThumbnail");
} catch (final Exception e) {
return false; return false;
} }
return commentRepliesRenderer.has("viewRepliesCreatorThumbnail");
} }
} }