diff --git a/docs/reference.md b/docs/reference.md index 5fb1714b0..ea2658199 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -574,19 +574,20 @@ DESCRIPTION Manages Actor creation, deployment, and execution on the Apify platform. SUBCOMMANDS - actors start Starts Actor remotely and returns run details - immediately. - actors rm Permanently removes an Actor from your account. - actors push Deploys Actor to Apify platform using settings from - '.actor/actor.json'. - actors pull Download Actor code to current directory. Clones Git - repositories or fetches Actor files based on the source type. - actors ls Prints a list of recently executed Actors or Actors - you own. - actors info Get information about an Actor. - actors call Executes Actor remotely using your authenticated - account. - actors build Creates a new build of the Actor. + actors start Starts Actor remotely and returns run details + immediately. + actors rm Permanently removes an Actor from your account. + actors search Searches Actors in the Apify Store. + actors push Deploys Actor to Apify platform using settings + from '.actor/actor.json'. + actors pull Download Actor code to current directory. Clones + Git repositories or fetches Actor files based on the source type. + actors ls Prints a list of recently executed Actors or + Actors you own. + actors info Get information about an Actor. + actors call Executes Actor remotely using your authenticated + account. + actors build Creates a new build of the Actor. ``` ##### `apify actors ls` @@ -608,6 +609,41 @@ FLAGS --offset= Number of Actors that will be skipped. ``` +##### `apify actors search` + +```sh +DESCRIPTION + Searches Actors in the Apify Store. + + Searches the Apify Store for Actors matching the given query. Results can be + filtered by category, author, pricing model, and more. This command does not + require authentication. + +USAGE + $ apify actors search [query] [--category ] + [--json] [--limit ] [--offset ] + [--pricing-model ] [--sort-by ] + [--username ] + +ARGUMENTS + query Search query to find Actors by title, name, description, username, + or readme. + +FLAGS + --category= Filter by category (e.g. + AI). + --json Format the command output as + JSON + --limit= Maximum number of results to + return. + --offset= Number of results to skip + for pagination. + --pricing-model= Filter by pricing model. + --sort-by= Sort order for the results. + --username= Filter by Actor author + username. +``` + ##### `apify actors rm` ```sh diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 85072ee93..1e9d6d93f 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -39,6 +39,7 @@ const categories: Record = { // { command: Commands.actors }, { command: Commands.actorsLs }, + { command: Commands.actorsSearch }, { command: Commands.actorsRm }, ], 'actor-deploy': [ diff --git a/src/commands/actors/_index.ts b/src/commands/actors/_index.ts index 43a6034fc..ebec3cf92 100644 --- a/src/commands/actors/_index.ts +++ b/src/commands/actors/_index.ts @@ -6,6 +6,7 @@ import { ActorsLsCommand } from './ls.js'; import { ActorsPullCommand } from './pull.js'; import { ActorsPushCommand } from './push.js'; import { ActorsRmCommand } from './rm.js'; +import { ActorsSearchCommand } from './search.js'; import { ActorsStartCommand } from './start.js'; export class ActorsIndexCommand extends ApifyCommand { @@ -17,6 +18,7 @@ export class ActorsIndexCommand extends ApifyCommand // ActorsStartCommand, ActorsRmCommand, + ActorsSearchCommand, ActorsPushCommand, ActorsPullCommand, ActorsLsCommand, diff --git a/src/commands/actors/search.ts b/src/commands/actors/search.ts new file mode 100644 index 000000000..7443bc8a9 --- /dev/null +++ b/src/commands/actors/search.ts @@ -0,0 +1,139 @@ +import { ApifyClient } from 'apify-client'; +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; +import { CommandExitCodes } from '../../lib/consts.js'; +import { error, info, simpleLog } from '../../lib/outputs.js'; +import { getApifyClientOptions, printJsonToStdout } from '../../lib/utils.js'; + +const pricingModelLabels: Record = { + FREE: 'Free', + FLAT_PRICE_PER_MONTH: 'Subscription', + PRICE_PER_DATASET_ITEM: 'Pay per result', + PAY_PER_EVENT: 'Pay per event', +}; + +function formatPricingModel(model?: string): string { + if (!model) return chalk.gray('Unknown'); + + return pricingModelLabels[model] ?? model; +} + +function truncateDescription(description?: string, maxLength = 60): string { + if (!description) return ''; + + if (description.length <= maxLength) return description; + + return `${description.slice(0, maxLength - 1)}…`; +} + +export class ActorsSearchCommand extends ApifyCommand { + static override name = 'search' as const; + + static override description = + 'Searches Actors in the Apify Store.\n\nSearches the Apify Store for Actors matching the given query. Results can be filtered by category, author, pricing model, and more. This command does not require authentication.'; + + static override args = { + query: Args.string({ + description: 'Search query to find Actors by title, name, description, username, or readme.', + required: false, + }), + }; + + static override flags = { + 'sort-by': Flags.string({ + description: 'Sort order for the results.', + options: ['relevance', 'popularity', 'newest', 'lastUpdate'], + default: 'relevance', + }), + category: Flags.string({ + description: 'Filter by category (e.g. AI).', + }), + username: Flags.string({ + description: 'Filter by Actor author username.', + }), + 'pricing-model': Flags.string({ + description: 'Filter by pricing model.', + options: ['FREE', 'FLAT_PRICE_PER_MONTH', 'PRICE_PER_DATASET_ITEM', 'PAY_PER_EVENT'], + }), + limit: Flags.integer({ + description: 'Maximum number of results to return.', + default: 20, + }), + offset: Flags.integer({ + description: 'Number of results to skip for pagination.', + default: 0, + }), + }; + + static override enableJsonFlag = true; + + async run() { + const { query } = this.args; + const { json, sortBy, category, username, pricingModel, limit, offset } = this.flags; + + const client = new ApifyClient(getApifyClientOptions()); + + let result; + + try { + result = await client.store().list({ + search: query, + sortBy, + category, + username, + pricingModel, + limit, + offset, + }); + } catch (err) { + process.exitCode = CommandExitCodes.RunFailed; + error({ + message: `Failed to search Apify Store: ${err instanceof Error ? err.message : String(err)}`, + stdout: true, + }); + return; + } + + if (result.count === 0) { + if (json) { + printJsonToStdout(result); + return; + } + + info({ message: 'No Actors found matching your search.', stdout: true }); + return; + } + + if (json) { + printJsonToStdout(result); + return; + } + + const table = new ResponsiveTable({ + allColumns: ['Name', 'Description', 'Users (30d)', 'Pricing'], + mandatoryColumns: ['Name', 'Pricing'], + columnAlignments: { + 'Users (30d)': 'right', + Name: 'left', + }, + }); + + for (const item of result.items) { + table.pushRow({ + Name: `${item.title}\n${chalk.gray(`${item.username}/${item.name}`)}`, + Description: truncateDescription(item.description), + 'Users (30d)': chalk.cyan(`${item.stats?.totalUsers30Days ?? 0}`), + Pricing: formatPricingModel(item.currentPricingInfo?.pricingModel), + }); + } + + simpleLog({ + message: table.render(CompactMode.WebLikeCompact), + stdout: true, + }); + } +} diff --git a/test/api/commands/actors/search.test.ts b/test/api/commands/actors/search.test.ts new file mode 100644 index 000000000..dc3983781 --- /dev/null +++ b/test/api/commands/actors/search.test.ts @@ -0,0 +1,202 @@ +import { ActorsSearchCommand } from '../../../../src/commands/actors/search.js'; +import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js'; +import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; + +const { logMessages, lastLogMessage } = useConsoleSpy(); + +describe('[api] apify actors search', () => { + beforeEach(() => { + process.exitCode = undefined; + }); + + it('should return results for a broad search query', async () => { + await testRunCommand(ActorsSearchCommand, { + args_query: 'web scraper', + }); + + expect(process.exitCode).toBeUndefined(); + + const output = lastLogMessage(); + expect(output).toBeDefined(); + // Table output should contain the column headers + expect(output).toContain('Name'); + expect(output).toContain('Pricing'); + }); + + it('should show "no Actors found" for a nonsense query', async () => { + await testRunCommand(ActorsSearchCommand, { + args_query: 'xyznonexistentactor999888777', + }); + + expect(process.exitCode).toBeUndefined(); + + const output = logMessages.log.join(' '); + expect(output).toMatch(/no actors found/i); + }); + + it('should return JSON output when --json flag is used', async () => { + await testRunCommand(ActorsSearchCommand, { + args_query: 'web scraper', + flags_json: true, + flags_limit: 3, + }); + + expect(process.exitCode).toBeUndefined(); + + const output = lastLogMessage(); + const parsed = JSON.parse(output); + + expect(parsed).toHaveProperty('total'); + expect(parsed).toHaveProperty('count'); + expect(parsed).toHaveProperty('items'); + expect(parsed.items.length).toBeLessThanOrEqual(3); + + // Each item should have basic Actor properties + if (parsed.items.length > 0) { + const item = parsed.items[0]; + expect(item).toHaveProperty('name'); + expect(item).toHaveProperty('username'); + expect(item).toHaveProperty('title'); + } + }); + + it('should respect the limit flag', async () => { + await testRunCommand(ActorsSearchCommand, { + args_query: 'scraper', + flags_json: true, + flags_limit: 2, + }); + + const parsed = JSON.parse(lastLogMessage()); + expect(parsed.items.length).toBeLessThanOrEqual(2); + }); + + it('should support pagination with offset', async () => { + // Fetch first page + await testRunCommand(ActorsSearchCommand, { + args_query: 'scraper', + flags_json: true, + flags_limit: 2, + flags_offset: 0, + }); + const firstPage = JSON.parse(lastLogMessage()); + + // Fetch second page + await testRunCommand(ActorsSearchCommand, { + args_query: 'scraper', + flags_json: true, + flags_limit: 2, + flags_offset: 2, + }); + const secondPage = JSON.parse(lastLogMessage()); + + // Results should be different (different items) + if (firstPage.items.length > 0 && secondPage.items.length > 0) { + const firstPageNames = firstPage.items.map((i: { name: string }) => i.name); + const secondPageNames = secondPage.items.map((i: { name: string }) => i.name); + expect(firstPageNames).not.toEqual(secondPageNames); + } + }); + + it('should filter by category', async () => { + await testRunCommand(ActorsSearchCommand, { + flags_json: true, + flags_category: 'AI', + flags_limit: 5, + }); + + expect(process.exitCode).toBeUndefined(); + + const parsed = JSON.parse(lastLogMessage()); + expect(parsed).toHaveProperty('items'); + }); + + it('should filter by pricing model', async () => { + await testRunCommand(ActorsSearchCommand, { + flags_json: true, + flags_pricingModel: 'FREE', + flags_limit: 5, + }); + + expect(process.exitCode).toBeUndefined(); + + const parsed = JSON.parse(lastLogMessage()); + + for (const item of parsed.items) { + expect(item.currentPricingInfo?.pricingModel).toBe('FREE'); + } + }); + + it('should filter by username', async () => { + await testRunCommand(ActorsSearchCommand, { + flags_json: true, + flags_username: 'apify', + flags_limit: 5, + }); + + expect(process.exitCode).toBeUndefined(); + + const parsed = JSON.parse(lastLogMessage()); + + for (const item of parsed.items) { + expect(item.username).toBe('apify'); + } + }); + + it('should accept sort-by flag without errors', async () => { + await testRunCommand(ActorsSearchCommand, { + flags_json: true, + flags_sortBy: 'newest', + flags_limit: 5, + }); + + expect(process.exitCode).toBeUndefined(); + + const parsed = JSON.parse(lastLogMessage()); + expect(parsed.items.length).toBeGreaterThan(0); + }); + + it('should work without any query (browse all)', async () => { + await testRunCommand(ActorsSearchCommand, { + flags_json: true, + flags_limit: 3, + }); + + expect(process.exitCode).toBeUndefined(); + + const parsed = JSON.parse(lastLogMessage()); + expect(parsed.items.length).toBeGreaterThan(0); + }); + + it('should combine multiple filters', async () => { + await testRunCommand(ActorsSearchCommand, { + flags_json: true, + flags_username: 'apify', + flags_pricingModel: 'FREE', + flags_limit: 5, + }); + + expect(process.exitCode).toBeUndefined(); + + const parsed = JSON.parse(lastLogMessage()); + + for (const item of parsed.items) { + expect(item.username).toBe('apify'); + expect(item.currentPricingInfo?.pricingModel).toBe('FREE'); + } + }); + + it('should return valid JSON structure with total and offset in JSON mode', async () => { + await testRunCommand(ActorsSearchCommand, { + args_query: 'scraper', + flags_json: true, + flags_limit: 1, + flags_offset: 5, + }); + + const parsed = JSON.parse(lastLogMessage()); + expect(parsed.total).toBeGreaterThan(0); + expect(parsed.offset).toBe(5); + expect(parsed.count).toBeGreaterThan(0); + }); +});