From 7c54f9b023703d37ca5efc8a36319784cf010073 Mon Sep 17 00:00:00 2001 From: fangshuyu-768 Date: Tue, 19 May 2026 17:53:54 +0800 Subject: [PATCH] feat(drive): switch markdown export to V2 docs_ai fetch API (#948) Switch `drive +export --file-extension markdown` from the legacy V1 GET /open-apis/docs/v1/content API to the V2 POST /open-apis/docs_ai/v1/documents/{token}/fetch API for higher-quality Lark-flavored Markdown output. - Update DryRun and Execute paths to use V2 endpoint with JSON body - Add docx:document:readonly scope for the new API - Validate V2 response structure (fail fast on missing document/content) - Encode token in URL path via validate.EncodePathSegment - Update unit tests and add V2 response validation error path tests - Add E2E dry-run test for markdown export path - Update skill documentation --- shortcuts/drive/drive_export.go | 47 +++--- shortcuts/drive/drive_export_test.go | 135 +++++++++++++++--- .../references/lark-drive-export.md | 4 +- .../cli_e2e/drive/drive_export_dryrun_test.go | 39 +++++ 4 files changed, 189 insertions(+), 36 deletions(-) diff --git a/shortcuts/drive/drive_export.go b/shortcuts/drive/drive_export.go index e3d86d35..a588d01d 100644 --- a/shortcuts/drive/drive_export.go +++ b/shortcuts/drive/drive_export.go @@ -12,6 +12,7 @@ import ( "time" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -25,6 +26,7 @@ var DriveExport = common.Shortcut{ Scopes: []string{ "docs:document.content:read", "docs:document:export", + "docx:document:readonly", "drive:drive.metadata:readonly", }, AuthTypes: []string{"user", "bot"}, @@ -52,16 +54,15 @@ var DriveExport = common.Shortcut{ FileExtension: runtime.Str("file-extension"), SubID: runtime.Str("sub-id"), } - // Markdown export is a special case: docx markdown comes from docs content - // directly instead of the Drive export task API. + // Markdown export is a special case: docx markdown comes from the V2 + // docs_ai fetch API directly instead of the Drive export task API. if spec.FileExtension == "markdown" { + apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token)) dr := common.NewDryRunAPI(). Desc("2-step orchestration: fetch docx markdown -> write local file"). - GET("/open-apis/docs/v1/content"). - Params(map[string]interface{}{ - "doc_token": spec.Token, - "doc_type": "docx", - "content_type": "markdown", + POST(apiPath). + Body(map[string]interface{}{ + "format": "markdown", }). Set("output_dir", runtime.Str("output-dir")) if name := strings.TrimSpace(runtime.Str("file-name")); name != "" { @@ -101,23 +102,33 @@ var DriveExport = common.Shortcut{ overwrite := runtime.Bool("overwrite") // Markdown export bypasses the async export task and writes the fetched - // markdown content directly to disk. + // markdown content directly to disk. Uses the V2 docs_ai fetch API for + // higher-quality Lark-flavored Markdown output. if spec.FileExtension == "markdown" { fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token)) - data, err := runtime.CallAPI( - "GET", - "/open-apis/docs/v1/content", - map[string]interface{}{ - "doc_token": spec.Token, - "doc_type": "docx", - "content_type": "markdown", - }, + apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token)) + data, err := runtime.DoAPIJSONWithLogID( + "POST", + apiPath, nil, + map[string]interface{}{ + "format": "markdown", + }, ) if err != nil { return err } + // Extract content from the V2 response: data.document.content + doc, ok := data["document"].(map[string]interface{}) + if !ok { + return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object") + } + content, ok := doc["content"].(string) + if !ok { + return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content") + } + fileName := preferredFileName if fileName == "" { // Prefer the remote title for the exported file name, but still fall @@ -130,7 +141,7 @@ var DriveExport = common.Shortcut{ fileName = title } fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension) - savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite) + savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite) if err != nil { return err } @@ -141,7 +152,7 @@ var DriveExport = common.Shortcut{ "file_extension": spec.FileExtension, "file_name": filepath.Base(savedPath), "saved_path": savedPath, - "size_bytes": len([]byte(common.GetString(data, "content"))), + "size_bytes": len(content), }, nil) return nil } diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index 78011820..8f277a9f 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -81,16 +81,19 @@ func TestValidateDriveExportSpec(t *testing.T) { func TestDriveExportMarkdownWritesFile(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/docs/v1/content", + fetchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/docx123/fetch", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "content": "# hello\n", + "document": map[string]interface{}{ + "content": "# hello\n", + }, }, }, - }) + } + reg.Register(fetchStub) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query", @@ -118,6 +121,14 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) { t.Fatalf("unexpected error: %v", err) } + var reqBody map[string]interface{} + if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil { + t.Fatalf("unmarshal docs_ai fetch body: %v", err) + } + if reqBody["format"] != "markdown" { + t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown") + } + data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md")) if err != nil { t.Fatalf("ReadFile() error: %v", err) @@ -132,16 +143,19 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) { func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/docs/v1/content", + fetchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/docx123/fetch", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "content": "# custom\n", + "document": map[string]interface{}{ + "content": "# custom\n", + }, }, }, - }) + } + reg.Register(fetchStub) tmpDir := t.TempDir() withDriveWorkingDir(t, tmpDir) @@ -158,6 +172,14 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) { t.Fatalf("unexpected error: %v", err) } + var reqBody map[string]interface{} + if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil { + t.Fatalf("unmarshal docs_ai fetch body: %v", err) + } + if reqBody["format"] != "markdown" { + t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown") + } + data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md")) if err != nil { t.Fatalf("ReadFile() error: %v", err) @@ -179,7 +201,7 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) { }{ { name: "markdown", - wantURL: "/open-apis/docs/v1/content", + wantURL: "/open-apis/docs_ai/v1/documents/docx123/fetch", wantFileName: `"file_name": "notes.md"`, args: []string{ "+export", @@ -233,16 +255,19 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) { func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/docs/v1/content", + fetchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/docx123/fetch", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "content": "# fallback\n", + "document": map[string]interface{}{ + "content": "# fallback\n", + }, }, }, - }) + } + reg.Register(fetchStub) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query", @@ -267,6 +292,14 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) { t.Fatalf("unexpected error: %v", err) } + var reqBody map[string]interface{} + if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil { + t.Fatalf("unmarshal docs_ai fetch body: %v", err) + } + if reqBody["format"] != "markdown" { + t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown") + } + data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md")) if err != nil { t.Fatalf("ReadFile() error: %v", err) @@ -279,6 +312,76 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) { } } +func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/docx123/fetch", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + err := mountAndRunDrive(t, DriveExport, []string{ + "+export", + "--token", "docx123", + "--doc-type", "docx", + "--file-extension", "markdown", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected error for missing document object, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured exit error, got %v", err) + } + if !strings.Contains(exitErr.Detail.Message, "missing document object") { + t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message) + } +} + +func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/docx123/fetch", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "document": map[string]interface{}{}, + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + err := mountAndRunDrive(t, DriveExport, []string{ + "+export", + "--token", "docx123", + "--doc-type", "docx", + "--file-extension", "markdown", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected error for missing document.content, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured exit error, got %v", err) + } + if !strings.Contains(exitErr.Detail.Message, "missing document.content") { + t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message) + } +} + func TestDriveExportAsyncSuccess(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ diff --git a/skills/lark-drive/references/lark-drive-export.md b/skills/lark-drive/references/lark-drive-export.md index 4805e733..9d62c77a 100644 --- a/skills/lark-drive/references/lark-drive-export.md +++ b/skills/lark-drive/references/lark-drive-export.md @@ -25,8 +25,8 @@ lark-cli drive +export \ --doc-type doc \ --file-extension docx -# 导出 docx 为 markdown -# 注意:markdown 只支持 docx,底层走 /open-apis/docs/v1/content +# 导出 docx 为 markdown(Lark-flavored Markdown) +# 注意:markdown 只支持 docx lark-cli drive +export \ --token "" \ --doc-type docx \ diff --git a/tests/cli_e2e/drive/drive_export_dryrun_test.go b/tests/cli_e2e/drive/drive_export_dryrun_test.go index 22ca6f0a..1d903d45 100644 --- a/tests/cli_e2e/drive/drive_export_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_export_dryrun_test.go @@ -60,3 +60,42 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) { t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out) } } + +func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+export", + "--token", "docxMdDryRun", + "--doc-type", "docx", + "--file-extension", "markdown", + "--file-name", "my-notes", + "--output-dir", "./md-exports", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "POST" { + t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/docxMdDryRun/fetch" { + t.Fatalf("url=%q, want docs_ai fetch\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" { + t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" { + t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "output_dir").String(); got != "./md-exports" { + t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out) + } +}