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
This commit is contained in:
fangshuyu-768
2026-05-19 17:53:54 +08:00
committed by GitHub
parent e6bc292575
commit 7c54f9b023
4 changed files with 189 additions and 36 deletions

View File

@@ -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
}

View File

@@ -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{

View File

@@ -25,8 +25,8 @@ lark-cli drive +export \
--doc-type doc \
--file-extension docx
# 导出 docx 为 markdown
# 注意markdown 只支持 docx,底层走 /open-apis/docs/v1/content
# 导出 docx 为 markdownLark-flavored Markdown
# 注意markdown 只支持 docx
lark-cli drive +export \
--token "<DOCX_TOKEN>" \
--doc-type docx \

View File

@@ -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)
}
}