feat(Search): Add support for refinement chips (#1167)

Also, this removes the old code used for refinement cards. Doesn't look like YouTube uses them anymore.
This commit is contained in:
Luan
2026-05-12 17:27:55 -03:00
committed by GitHub
parent 430fc70888
commit f748b8b362
2 changed files with 48 additions and 29 deletions

View File

@@ -141,7 +141,7 @@ export interface IStreamingData {
export type IPlayerResponse = Pick<IParsedResponse, 'captions' | 'cards' | 'endscreen' | 'microformat' | 'annotations' | 'playability_status' | 'streaming_data' | 'player_config' | 'playback_tracking' | 'storyboards' | 'video_details'>;
export type INextResponse = Pick<IParsedResponse, 'contents' | 'contents_memo' | 'continuation_contents' | 'continuation_contents_memo' | 'current_video_endpoint' | 'on_response_received_endpoints' | 'on_response_received_endpoints_memo' | 'player_overlays' | 'engagement_panels'>;
export type IBrowseResponse = Pick<IParsedResponse, 'background' | 'continuation_contents' | 'continuation_contents_memo' | 'on_response_received_actions' | 'on_response_received_actions_memo' | 'on_response_received_endpoints' | 'on_response_received_endpoints_memo' | 'contents' | 'contents_memo' | 'header' | 'header_memo' | 'metadata' | 'microformat' | 'alerts' | 'sidebar' | 'sidebar_memo'>;
export type ISearchResponse = Pick<IParsedResponse, 'header' | 'header_memo' | 'contents' | 'contents_memo' | 'on_response_received_commands' | 'continuation_contents' | 'continuation_contents_memo' | 'refinements' | 'estimated_results'>;
export type ISearchResponse = Pick<IParsedResponse, 'header' | 'header_memo' | 'contents' | 'contents_memo' | 'on_response_received_commands' | 'on_response_received_commands_memo' | 'continuation_contents' | 'continuation_contents_memo' | 'refinements' | 'estimated_results'>;
export type IResolveURLResponse = Pick<IParsedResponse, 'endpoint'>;
export type IGetTranscriptResponse = Pick<IParsedResponse, 'actions' | 'actions_memo'>;
export type IGetNotificationsMenuResponse = Pick<IParsedResponse, 'actions' | 'actions_memo'>;

View File

@@ -1,20 +1,19 @@
import Feed from '../../core/mixins/Feed.js';
import { InnertubeError } from '../../utils/Utils.js';
import HorizontalCardList from '../classes/HorizontalCardList.js';
import ItemSection from '../classes/ItemSection.js';
import SearchHeader from '../classes/SearchHeader.js';
import SearchRefinementCard from '../classes/SearchRefinementCard.js';
import SearchSubMenu from '../classes/SearchSubMenu.js';
import SectionList from '../classes/SectionList.js';
import UniversalWatchCard from '../classes/UniversalWatchCard.js';
import AppendContinuationItemsAction from '../classes/actions/AppendContinuationItemsAction.js';
import ChipCloudChip from '../classes/ChipCloudChip.js';
import type NavigationEndpoint from '../classes/NavigationEndpoint.js';
import { ReloadContinuationItemsCommand } from '../continuations.js';
import { observe } from '../helpers.js';
import type { ApiResponse, Actions } from '../../core/index.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { ISearchResponse } from '../types/index.js';
import { ReloadContinuationItemsCommand } from '../index.js';
import AppendContinuationItemsAction from '../classes/actions/AppendContinuationItemsAction.js';
export default class Search extends Feed<ISearchResponse> {
public header?: SearchHeader;
@@ -23,23 +22,28 @@ export default class Search extends Feed<ISearchResponse> {
public estimated_results: number;
public sub_menu?: SearchSubMenu;
public watch_card?: UniversalWatchCard;
public refinement_cards?: HorizontalCardList | null;
constructor(actions: Actions, data: ApiResponse | ISearchResponse, already_parsed = false) {
super(actions, data, already_parsed);
const contents =
this.page.contents_memo?.getType(SectionList)[0].contents ||
this.page.on_response_received_commands_memo?.getType(SectionList)[0]?.contents ||
this.page.on_response_received_commands?.[0].as(AppendContinuationItemsAction, ReloadContinuationItemsCommand).contents;
if (!contents)
throw new InnertubeError('No contents found in search response');
if (this.page.header)
this.header = this.page.header.item().as(SearchHeader);
if (this.page.on_response_received_commands && !this.page.header) {
const headerSlot = this.page.on_response_received_commands.as(ReloadContinuationItemsCommand).find(
(command) => command.is(ReloadContinuationItemsCommand) && command.slot === 'RELOAD_CONTINUATION_SLOT_HEADER'
);
this.header = headerSlot?.contents?.firstOfType(SearchHeader);
} else {
this.header = this.page.header?.item().as(SearchHeader);
}
this.results = observe(contents.filterType(ItemSection).flatMap((section) => section.contents));
this.refinements = this.page.refinements || [];
this.estimated_results = this.page.estimated_results || 0;
@@ -47,39 +51,54 @@ export default class Search extends Feed<ISearchResponse> {
this.sub_menu = this.page.contents_memo.getType(SearchSubMenu)[0];
this.watch_card = this.page.contents_memo.getType(UniversalWatchCard)[0];
}
this.refinement_cards = this.results?.firstOfType(HorizontalCardList);
}
/**
* Applies given refinement card and returns a new {@link Search} object. Use {@link refinement_card_queries} to get a list of available refinement cards.
* Applies a refinement filter to the search results.
*
* Use {@link Search.refinement_filters} to get a list of available refinements.
*
* @example
* ```ts
* const results = await yt.search('PilotRedSun');
* // Narrow down to only YouTube Shorts
* const shortsOnly = await results.applyRefinement('Shorts');
* ```
* @param refinementFilter - The text label of the chip or the {@link ChipCloudChip} node itself.
*/
async selectRefinementCard(card: SearchRefinementCard | string): Promise<Search> {
let target_card: SearchRefinementCard | undefined;
async applyRefinement(refinementFilter: string | ChipCloudChip): Promise<Search> {
let endpoint: NavigationEndpoint | undefined;
if (typeof card === 'string') {
if (!this.refinement_cards) throw new InnertubeError('No refinement cards found.');
target_card = this.refinement_cards?.cards.find((refinement_card): refinement_card is SearchRefinementCard => {
return refinement_card.is(SearchRefinementCard) && refinement_card.query === card;
});
if (!target_card)
throw new InnertubeError(`Refinement card "${card}" not found`, { available_cards: this.refinement_card_queries });
} else if (card.type === 'SearchRefinementCard') {
target_card = card;
if (typeof refinementFilter === 'string') {
const chipBar = this.header?.chip_bar;
if (!chipBar) throw new InnertubeError('No chip bar found in search header');
const targetChip = chipBar.chips.find((chip) => chip.text === refinementFilter);
if (!targetChip) throw new InnertubeError(`Refinement filter "${refinementFilter}" not found`, { available_filters: this.refinement_filters });
endpoint = targetChip.endpoint;
if (!endpoint && targetChip.is_selected) return this; // The 'All' filter doesn't have an endpoint when it's selected.
} else if (refinementFilter.is(ChipCloudChip)) {
if (!refinementFilter.endpoint && refinementFilter.is_selected) return this;
endpoint = refinementFilter.endpoint;
} else {
throw new InnertubeError('Invalid refinement card!');
throw new InnertubeError('Invalid filter type');
}
const page = await target_card.endpoint.call<ISearchResponse>(this.actions, { parse: true });
if (!endpoint)
throw new InnertubeError('Could not find endpoint for the specified filter');
const page = await endpoint.call<ISearchResponse>(this.actions, { parse: true });
return new Search(this.actions, page, true);
}
/**
* Returns a list of refinement card queries.
* Returns a list of available refinement filters. Use {@link Search.applyRefinement} to apply a filter.
*/
get refinement_card_queries(): string[] {
return this.refinement_cards?.cards.as(SearchRefinementCard).map((card) => card.query) || [];
get refinement_filters(): string[] {
return this.header?.chip_bar?.chips.map((chip) => chip.text) || [];
}
/**