From 97caa0b70287a36c46738e8132d71315406b0dbf Mon Sep 17 00:00:00 2001 From: Yangliang Li Date: Mon, 29 Jun 2026 00:57:26 +0800 Subject: [PATCH] feat(cli): add youtube and instagram source commands --- internal/command/root.go | 2 + internal/command/root_sources.go | 36 ++++++++ internal/command/root_sources_test.go | 110 +++++++++++++++++++++++++ internal/command/schema_test_helper.go | 16 ++++ 4 files changed, 164 insertions(+) diff --git a/internal/command/root.go b/internal/command/root.go index 3969a2f..fbab536 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -170,11 +170,13 @@ func (r *Root) newCommand(runCtx *runContext) *cobra.Command { r.newDouyinCommand(runCtx), r.newGoogleAdsCommand(runCtx), r.newGoogleTrendsCommand(runCtx), + r.newInstagramCommand(runCtx), r.newMetaAdsCommand(runCtx), r.newRedditCommand(runCtx), r.newTiktokCommand(runCtx), r.newTrendCloudCommand(runCtx), r.newXiaohongshuCommand(runCtx), + r.newYouTubeCommand(runCtx), ) return rootCmd diff --git a/internal/command/root_sources.go b/internal/command/root_sources.go index 06a7528..6e328f5 100644 --- a/internal/command/root_sources.go +++ b/internal/command/root_sources.go @@ -151,6 +151,25 @@ func (r *Root) newMetaAdsCommand(runCtx *runContext) *cobra.Command { ) } +func (r *Root) newInstagramCommand(runCtx *runContext) *cobra.Command { + return newStaticSourceCommand( + "instagram", + "Instagram profile, media, search, and comments", + "Instagram profile, media, search, and comments.\n\nUse this command to search Instagram users/reels/hashtags, inspect public profiles and posts/reels, and collect comments and replies.", + " apimux instagram search_users --query pimax\n apimux instagram get_user_posts --username pimaxofficial\n apimux instagram get_post_comments --url https://www.instagram.com/p/CODE/", + "search_users, search_reels, search_hashtags, get_user_profile, get_user_posts, get_user_reels, get_post_detail, get_post_comments, get_comment_replies", + newSchemaBoundCapabilityCommand(runCtx, "instagram.search_users", "search_users", "Search Instagram users", "instagram search_users"), + newSchemaBoundCapabilityCommand(runCtx, "instagram.search_reels", "search_reels", "Search Instagram reels", "instagram search_reels"), + newSchemaBoundCapabilityCommand(runCtx, "instagram.search_hashtags", "search_hashtags", "Search Instagram hashtags", "instagram search_hashtags"), + newSchemaBoundCapabilityCommand(runCtx, "instagram.get_user_profile", "get_user_profile", "Fetch one Instagram public profile", "instagram get_user_profile"), + newSchemaBoundCapabilityCommand(runCtx, "instagram.get_user_posts", "get_user_posts", "List Instagram user posts", "instagram get_user_posts"), + newSchemaBoundCapabilityCommand(runCtx, "instagram.get_user_reels", "get_user_reels", "List Instagram user reels", "instagram get_user_reels"), + newSchemaBoundCapabilityCommand(runCtx, "instagram.get_post_detail", "get_post_detail", "Fetch one Instagram post or reel detail", "instagram get_post_detail"), + newSchemaBoundCapabilityCommand(runCtx, "instagram.get_post_comments", "get_post_comments", "List Instagram post comments", "instagram get_post_comments"), + newSchemaBoundCapabilityCommand(runCtx, "instagram.get_comment_replies", "get_comment_replies", "List Instagram comment replies", "instagram get_comment_replies"), + ) +} + func (r *Root) newRedditCommand(runCtx *runContext) *cobra.Command { return newStaticSourceCommand( "reddit", @@ -182,6 +201,23 @@ func (r *Root) newTiktokCommand(runCtx *runContext) *cobra.Command { ) } +func (r *Root) newYouTubeCommand(runCtx *runContext) *cobra.Command { + return newStaticSourceCommand( + "youtube", + "YouTube video, channel, comment, and transcript data", + "YouTube video, channel, comment, and transcript data.\n\nUse this command to search YouTube videos, inspect videos/channels, list comments and replies, and fetch captions or transcript text.", + " apimux youtube search_videos --query 'Pimax VR'\n apimux youtube get_video_comments --url https://www.youtube.com/watch?v=VIDEO_ID\n apimux youtube get_channel_videos --handle @PimaxOfficial", + "search_videos, get_video_detail, get_video_comments, get_comment_replies, get_channel_detail, get_channel_videos, get_video_transcript", + newSchemaBoundCapabilityCommand(runCtx, "youtube.search_videos", "search_videos", "Search YouTube videos", "youtube search_videos"), + newSchemaBoundCapabilityCommand(runCtx, "youtube.get_video_detail", "get_video_detail", "Fetch one YouTube video detail", "youtube get_video_detail"), + newSchemaBoundCapabilityCommand(runCtx, "youtube.get_video_comments", "get_video_comments", "List YouTube video comments", "youtube get_video_comments"), + newSchemaBoundCapabilityCommand(runCtx, "youtube.get_comment_replies", "get_comment_replies", "List YouTube comment replies", "youtube get_comment_replies"), + newSchemaBoundCapabilityCommand(runCtx, "youtube.get_channel_detail", "get_channel_detail", "Fetch one YouTube channel detail", "youtube get_channel_detail"), + newSchemaBoundCapabilityCommand(runCtx, "youtube.get_channel_videos", "get_channel_videos", "List YouTube channel videos or Shorts", "youtube get_channel_videos"), + newSchemaBoundCapabilityCommand(runCtx, "youtube.get_video_transcript", "get_video_transcript", "Fetch YouTube captions or transcript text", "youtube get_video_transcript"), + ) +} + func (r *Root) newXiaohongshuCommand(runCtx *runContext) *cobra.Command { return newStaticSourceCommand( "xiaohongshu", diff --git a/internal/command/root_sources_test.go b/internal/command/root_sources_test.go index 082eefd..8ac9bb4 100644 --- a/internal/command/root_sources_test.go +++ b/internal/command/root_sources_test.go @@ -1219,3 +1219,113 @@ func TestXiaohongshuGetNoteDetailCallsService(t *testing.T) { } assertDataOnlyOutputContains(t, stdout.String(), `"note_id":"67c1f4f1000000001a01b6d3"`) } + +func TestYouTubeSearchVideosCallsService(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if maybeServeSchema(w, r) { + return + } + if r.URL.Path != "/v1/capabilities/youtube.search_videos" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var req struct { + Params map[string]any `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request body: %v", err) + } + if req.Params["query"] != "Pimax VR" || req.Params["count"] != float64(10) { + t.Fatalf("unexpected params: %#v", req.Params) + } + _, _ = w.Write([]byte(`{"ok":true,"data":[],"meta":{"capability":"youtube.search_videos","cursor":"next"}}`)) + })) + defer server.Close() + + var stdout bytes.Buffer + var stderr bytes.Buffer + + root := NewRoot(&stdout, &stderr) + exitCode, err := root.Execute(context.Background(), []string{ + "--base-url", server.URL, + "youtube", "search_videos", + "--query", "Pimax VR", + "--count", "10", + }) + if err != nil { + t.Fatalf("execute root: %v", err) + } + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d, stderr=%s", exitCode, stderr.String()) + } + assertDataOnlyOutputContains(t, stdout.String(), `"data":[]`) +} + +func TestInstagramPostCommentsCallsService(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if maybeServeSchema(w, r) { + return + } + if r.URL.Path != "/v1/capabilities/instagram.get_post_comments" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var req struct { + Params map[string]any `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request body: %v", err) + } + if req.Params["url"] != "https://www.instagram.com/p/CODE/" || req.Params["count"] != float64(20) { + t.Fatalf("unexpected params: %#v", req.Params) + } + _, _ = w.Write([]byte(`{"ok":true,"data":[{"comment_id":"c1","text":"nice"}],"meta":{"capability":"instagram.get_post_comments"}}`)) + })) + defer server.Close() + + var stdout bytes.Buffer + var stderr bytes.Buffer + + root := NewRoot(&stdout, &stderr) + exitCode, err := root.Execute(context.Background(), []string{ + "--base-url", server.URL, + "instagram", "get_post_comments", + "--url", "https://www.instagram.com/p/CODE/", + "--count", "20", + }) + if err != nil { + t.Fatalf("execute root: %v", err) + } + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d, stderr=%s", exitCode, stderr.String()) + } + assertDataOnlyOutputContains(t, stdout.String(), `"comment_id":"c1"`) +} + +func TestInstagramSearchUsersHelpPrintsSchemaFlags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if maybeServeSchema(w, r) { + return + } + t.Fatalf("unexpected request path: %s", r.URL.Path) + })) + defer server.Close() + + var stdout bytes.Buffer + var stderr bytes.Buffer + + root := NewRoot(&stdout, &stderr) + exitCode, err := root.Execute(context.Background(), []string{ + "--base-url", server.URL, + "instagram", "search_users", + "--help", + }) + if err != nil { + t.Fatalf("execute root: %v", err) + } + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d, stderr=%s, stdout=%s", exitCode, stderr.String(), stdout.String()) + } + output := stdout.String() + if !strings.Contains(output, "Search Instagram users") || !strings.Contains(output, "--query string") || !strings.Contains(output, "--count int") { + t.Fatalf("expected instagram search_users help with schema flags, got %s", output) + } +} diff --git a/internal/command/schema_test_helper.go b/internal/command/schema_test_helper.go index c38faac..e795b10 100644 --- a/internal/command/schema_test_helper.go +++ b/internal/command/schema_test_helper.go @@ -92,6 +92,22 @@ func testSchemas() map[string]schema.CapabilitySchema { "tiktok.shop_product_info": {Name: "tiktok.shop_product_info", Parameters: []schema.CapabilityParam{{Name: "product_id", Type: "string", Required: true, FlagName: "product-id"}, {Name: "region", Type: "string"}}}, "tiktok.search_products": {Name: "tiktok.search_products", Parameters: []schema.CapabilityParam{{Name: "keyword", Type: "string", Required: true}, {Name: "region", Type: "string", Enum: []string{"US", "GB", "SG", "MY", "PH", "TH", "VN", "ID"}}, {Name: "cursor", Type: "string"}, {Name: "offset", Type: "integer"}, {Name: "sort_type", Type: "integer", FlagName: "sort-type"}, {Name: "count", Type: "integer"}}}, "tiktok.product_reviews": {Name: "tiktok.product_reviews", Parameters: []schema.CapabilityParam{{Name: "product_id", Type: "string", Required: true, FlagName: "product-id"}, {Name: "region", Type: "string", Enum: []string{"US", "GB", "SG", "MY", "PH", "TH", "VN", "ID"}}, {Name: "page", Type: "integer"}, {Name: "sort", Type: "string", Enum: []string{"default", "latest"}}, {Name: "media_filter", Type: "string", Enum: []string{"all", "media", "verified"}, FlagName: "media-filter"}, {Name: "star", Type: "string", Enum: []string{"all", "1", "2", "3", "4", "5"}}, {Name: "count", Type: "integer"}}}, + "youtube.search_videos": {Name: "youtube.search_videos", Parameters: []schema.CapabilityParam{{Name: "query", Type: "string", Required: true}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "youtube.get_video_detail": {Name: "youtube.get_video_detail", Parameters: []schema.CapabilityParam{{Name: "video_id", Type: "string", FlagName: "video-id"}, {Name: "url", Type: "string"}}}, + "youtube.get_video_comments": {Name: "youtube.get_video_comments", Parameters: []schema.CapabilityParam{{Name: "video_id", Type: "string", FlagName: "video-id"}, {Name: "url", Type: "string"}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "youtube.get_comment_replies": {Name: "youtube.get_comment_replies", Parameters: []schema.CapabilityParam{{Name: "comment_id", Type: "string", Required: true, FlagName: "comment-id"}, {Name: "video_id", Type: "string", FlagName: "video-id"}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "youtube.get_channel_detail": {Name: "youtube.get_channel_detail", Parameters: []schema.CapabilityParam{{Name: "channel_id", Type: "string", FlagName: "channel-id"}, {Name: "handle", Type: "string"}, {Name: "url", Type: "string"}}}, + "youtube.get_channel_videos": {Name: "youtube.get_channel_videos", Parameters: []schema.CapabilityParam{{Name: "channel_id", Type: "string", FlagName: "channel-id"}, {Name: "handle", Type: "string"}, {Name: "url", Type: "string"}, {Name: "content_type", Type: "string", Enum: []string{"video", "shorts", "all"}, FlagName: "content-type"}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "youtube.get_video_transcript": {Name: "youtube.get_video_transcript", Parameters: []schema.CapabilityParam{{Name: "video_id", Type: "string", Required: true, FlagName: "video-id"}, {Name: "language", Type: "string"}, {Name: "format", Type: "string", Enum: []string{"segments", "plain"}}}}, + "instagram.search_users": {Name: "instagram.search_users", Parameters: []schema.CapabilityParam{{Name: "query", Type: "string", Required: true}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "instagram.search_reels": {Name: "instagram.search_reels", Parameters: []schema.CapabilityParam{{Name: "query", Type: "string", Required: true}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "instagram.search_hashtags": {Name: "instagram.search_hashtags", Parameters: []schema.CapabilityParam{{Name: "query", Type: "string", Required: true}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "instagram.get_user_profile": {Name: "instagram.get_user_profile", Parameters: []schema.CapabilityParam{{Name: "username", Type: "string"}, {Name: "user_id", Type: "string", FlagName: "user-id"}}}, + "instagram.get_user_posts": {Name: "instagram.get_user_posts", Parameters: []schema.CapabilityParam{{Name: "username", Type: "string"}, {Name: "user_id", Type: "string", FlagName: "user-id"}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "instagram.get_user_reels": {Name: "instagram.get_user_reels", Parameters: []schema.CapabilityParam{{Name: "username", Type: "string"}, {Name: "user_id", Type: "string", FlagName: "user-id"}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "instagram.get_post_detail": {Name: "instagram.get_post_detail", Parameters: []schema.CapabilityParam{{Name: "shortcode", Type: "string"}, {Name: "media_id", Type: "string", FlagName: "media-id"}, {Name: "url", Type: "string"}}}, + "instagram.get_post_comments": {Name: "instagram.get_post_comments", Parameters: []schema.CapabilityParam{{Name: "shortcode", Type: "string"}, {Name: "media_id", Type: "string", FlagName: "media-id"}, {Name: "url", Type: "string"}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, + "instagram.get_comment_replies": {Name: "instagram.get_comment_replies", Parameters: []schema.CapabilityParam{{Name: "comment_id", Type: "string", Required: true, FlagName: "comment-id"}, {Name: "media_id", Type: "string", FlagName: "media-id"}, {Name: "cursor", Type: "string"}, {Name: "count", Type: "integer"}}}, "meta_ads.search_ads": {Name: "meta_ads.search_ads", Parameters: []schema.CapabilityParam{{Name: "q", Type: "string", Required: true}, {Name: "country", Type: "string"}, {Name: "ad_type", Type: "string", Enum: []string{"all", "political_and_issue_ads", "housing_ads", "employment_ads", "credit_ads"}, FlagName: "ad-type"}, {Name: "active_status", Type: "string", Enum: []string{"active", "inactive", "all"}, FlagName: "active-status"}, {Name: "media_type", Type: "string", Enum: []string{"all", "video", "image", "meme", "image_and_meme", "none"}, FlagName: "media-type"}, {Name: "platforms", Type: "string"}, {Name: "start_date", Type: "string", FlagName: "start-date"}, {Name: "end_date", Type: "string", FlagName: "end-date"}, {Name: "next_page_token", Type: "string", FlagName: "next-page-token"}}}, "meta_ads.get_ad_detail": {Name: "meta_ads.get_ad_detail", Parameters: []schema.CapabilityParam{{Name: "ad_id", Type: "string", Required: true, FlagName: "ad-id"}}}, "douyin.search_videos": {Name: "douyin.search_videos", Parameters: []schema.CapabilityParam{{Name: "keyword", Type: "string", Required: true}, {Name: "sort_type", Type: "string", Enum: []string{"comprehensive", "likes", "latest"}, FlagName: "sort-type"}, {Name: "publish_time", Type: "string", Enum: []string{"all", "1d", "1w", "6m"}, FlagName: "publish-time"}, {Name: "filter_duration", Type: "string", Enum: []string{"all", "under_1m", "1m_5m", "over_5m"}, FlagName: "filter-duration"}, {Name: "content_type", Type: "string", Enum: []string{"all", "video", "image", "article"}, FlagName: "content-type"}, {Name: "cursor", Type: "integer"}}},