在 HTTP 請求節點中發送任意長度的文件陣列

說明問題/錯誤/問題

我需要執行一個 HTTP 請求,其中伺服器期望我在同一個欄位資料下提供多個檔案。檔案的數量不是靜態的,所以我無法將每個檔案對應到 n8n 節點中的一個欄位。這甚至可能透過 http 節點實現嗎,還是我應該使用程式碼節點?

錯誤訊息是什麼(如有)?

我試過這樣做,但顯然如果我有超過 1 個檔案,我會收到一個錯誤,說 id 為的二進位檔案不存在。

請分享您的工作流程

{
  "nodes": [
    {
      "parameters": {},
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "id": "fb16aeab-57ba-485f-8c6e-7bc015bd1e28",
      "name": "When clicking 'Execute workflow'"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=http://example.org/project/tag",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "data",
              "inputDataFieldName": "={{ Object.keys($binary).filter((k) =
> k !== 'params').join(',') }}"
            },
            {
              "parameterType": "formBinaryData",
              "name": "params",
              "inputDataFieldName": "params"
            },
            {
              "name": "aggregate",
              "value": "true"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        448,
        0
      ],
      "id": "e2323076-8b22-4872-8eb3-7b1710a8a987",
      "name": "Async Tagging"
    },
    {
      "parameters": {
        "jsCode": "const source = $('When Executed by Another Workflow').first();\nconst sourceBinary = { ...(source.binary || {}) };\nconst paramsPayload = {\n  projectId: [source.json.fields.projectID],\n  language: source.json.fields.language,\n  filterNestedConcepts: source.json.fields.FilterNestedConcepts,\n  numberOfConcepts: source.json.fields.LabelsPerPage,\n  numberOfTerms: source.json.fields.LabelsPerPage,\n};\n\nsourceBinary.params = {\n  data: Buffer.from(JSON.stringify(paramsPayload), 'utf8').toString('base64'),\n  mimeType: 'application/json',\n  fileName: 'params.json',\n  fileExtension: 'json',\n};\nreturn [\n  {\n    binary: sourceBinary,\n  },\n];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        224,
        0
      ],
      "id": "100a0875-9bce-4790-be6c-da7a31407523",
      "name": "Prepare Extraction Input"
    }
  ],
  "connections": {
    "When clicking 'Execute workflow'": {
      "main": [
        [
          {
            "node": "Prepare Extraction Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Extraction Input": {
      "main": [
        [
          {
            "node": "Async Tagging",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "meta": {
    "instanceId": "982a83c4fa14b8860ea8391b8f766ad449d7431aac64126d0b9fc936877b58be"
  }
}

關於您的 n8n 設定的資訊

  • n8n 版本: 2.21.7
  • 資料庫(預設:SQLite): postgres
  • n8n EXECUTIONS_PROCESS 設定(預設:own, main): own
  • 執行 n8n 的方式(Docker、npm、n8n cloud、桌面應用程式): docker
  • 作業系統: macOS

@Vidomina

試試這個

  1. 複製上方的 JSON 區塊。
  2. 開啟你的 n8n 編輯器。
  3. Ctrl+V(Mac 上按 Cmd+V)將節點直接貼到畫布。
  4. **測試方式:**點擊「執行工作流程」。
  5. 用於實際資料的方式:
    • 刪除**「模擬資料產生器」**節點。
    • 將你的實際資料節點連接到**「建構 Multipart 本文」**節點。
    • 確保你的資料節點提供二進位檔案(程式碼會自動找到所有二進位鍵,排除任何名為 params 的鍵)。

您好,@kjooleng 感謝您的建議!

我遇到了 2 個問題:首先,您的 Construct Multipart Body 對文本效果很好,但 pdf 和 docx 等文件完全被這個方法搞亂了。

我將其修改為:

{
"nodes": \[
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": \[
48,
32
\],
"id": "fb16aeab-57ba-485f-8c6e-7bc015bd1e28",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"jsCode": "const source = $('When Executed by Another Workflow').first();\\nconst sourceBinary = { ...(source.binary || {}) };\\nconst paramsPayload = {\\n  projectId: \[source.json.fields.projectID\],\\n  language: source.json.fields.language,\\n  filterNestedConcepts: source.json.fields.FilterNestedConcepts,\\n  numberOfConcepts: source.json.fields.LabelsPerPage,\\n  numberOfTerms: source.json.fields.LabelsPerPage,\\n};\\n\\nsourceBinary.params = {\\n  data: Buffer.from(JSON.stringify(paramsPayload), 'utf8').toString('base64'),\\n  mimeType: 'application/json',\\n  fileName: 'params.json',\\n  fileExtension: 'json',\\n};\\nreturn \[\\n  {\\n    binary: sourceBinary,\\n  },\\n\];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": \[
272,
32
\],
"id": "100a0875-9bce-4790-be6c-da7a31407523",
"name": "Prepare Extraction Input"
},
{
"parameters": {
"method": "POST",
"url": "https://service.127.0.0.1.nip.io/extractor/api/tag/async",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendBody": true,
"contentType": "binary",
"options": {
"response": {
"response": {
"fullResponse": true,
"neverError": true
}
}
}
},
"id": "ad21249d-27c4-4880-b90a-d747b712cf39",
"name": "TaggingSuggested",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": \[
688,
32
\],
"credentials": {
"httpBasicAuth": {
"id": "abCwyM6KmpPSdwff",
"name": "cred"
}
}
},
{
"parameters": {
"jsCode": "return (async () =\\ {\\n  const CRLF = '\\r\\n';\\n  const items = $input.all();\\n  const result = [ ];\\n\\n  for (let itemIndex = 0; itemIndex \\ items.length; itemIndex++) {\\n    const item = items\[itemIndex\];\\n    const boundary = `----n8n${Math.random().toString(36).slice(2, 11)}`;\\n    const chunks = [ ];\\n\\n    const pushText = (text) =\\ chunks.push(Buffer.from(text, 'utf8'));\\n\\n    pushText(`--${boundary}${CRLF}`);\\n    pushText(`Content-Disposition: form-data; name=\"aggregate\"${CRLF}${CRLF}`);\\n    pushText(`true${CRLF}`);\\n\\n    if (item.binary?.params) {\\n      const paramsMeta = item.binary.params;\\n      const paramsBuf = await this.helpers.getBinaryDataBuffer(itemIndex, 'params');\\n      const fileName = paramsMeta.fileName || 'params.json';\\n      const mimeType = paramsMeta.mimeType || 'application/json';\\n\\n      pushText(`--${boundary}${CRLF}`);\\n      pushText(`Content-Disposition: form-data; name=\"params\"; filename=\"${fileName}\"${CRLF}`);\\n      pushText(`Content-Type: ${mimeType}${CRLF}${CRLF}`);\\n      chunks.push(paramsBuf);\\n      pushText(CRLF);\\n    }\\n\\n    if (item.binary) {\\n      for (const key of Object.keys(item.binary).filter((k) =\\ k !== 'params')) {\\n        const fileMeta = item.binary\[key\];\\n        const fileBuf = await this.helpers.getBinaryDataBuffer(itemIndex, key);\\n        const fileName = fileMeta.fileName || key;\\n        const mimeType = fileMeta.mimeType || 'application/octet-stream';\\n\\n        pushText(`--${boundary}${CRLF}`);\\n        pushText(`Content-Disposition: form-data; name=\"data\"; filename=\"${fileName}\"${CRLF}`);\\n        pushText(`Content-Type: ${mimeType}${CRLF}${CRLF}`);\\n        chunks.push(fileBuf);\\n        pushText(CRLF);\\n      }\\n    }\\n\\n    pushText(`--${boundary}--${CRLF}`);\\n\\n    const body = Buffer.concat(chunks);\\n    const contentType = `multipart/form-data; boundary=${boundary}`;\\n\\n    result.push({\\n      json: {\\n        ...(item.json || {}),\\n        contentType,\\n      },\\n      binary: {\\n        multipartBody: await this.helpers.prepareBinaryData(\\n          body,\\n          'request.bin',\\n          contentType\\n        ),\\n      },\\n    });\\n  }\\n\\n  return result;\\n})();"
},
"id": "505a7281-fd86-41fa-8c28-1cc51187e8b7",
"name": "Construct Multipart Body1",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": \[
480,
32
\]
}
\],
"connections": {
"When clicking 'Execute workflow'": {
"main": \[
\[
{
"node": "Prepare Extraction Input",
"type": "main",
"index": 0
}
\]
\]
},
"Prepare Extraction Input": {
"main": \[
\[
{
"node": "Construct Multipart Body1",
"type": "main",
"index": 0
}
\]
\]
},
"Construct Multipart Body1": {
"main": \[
\[
{
"node": "TaggingSuggested",
"type": "main",
"index": 0
}
\]
\]
}
},
"pinData": {},
"meta": {
"instanceId": "982a83c4fa14b8860ea8391b8f766ad449d7431aac64126d0b9fc936877b58be"
}
}

第二個問題是,當我現在將請求提交給該服務時,我收到:
不支持的 Content-Type

我還沒有開始調試它。

這是修改後的 json

這讓您可以驗證邏輯對元資料 (JSON) 和二進位內容(模擬您的 PDF/文件)都能運作,而無需提供您的實際檔案。

感謝你編輯的模擬數據生成器。
我調查了為什麼服務返回
Content-Type is not supported,看起來 HTTP 節點沒有發送任何東西。

我試著將其設置為 n8n-binary + 文件名 multipartBody,但我收到一個錯誤「source.on is not a function」— 我可能在 JavaScript 方面搞砸了什麼,因為我對 n8n 的函數不太熟悉。我會查閱文檔,試著弄清楚,但如果我遺漏了什麼明顯的東西,歡迎提出任何意見。

使用此特定的 Code Node 和這些確切的 HTTP Request 設定

return (async () => {
  const CRLF = '\r\n';
  const items = $input.all();
  const result = [];

  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    // 1. 建立唯一的邊界
    const boundary = `----n8nboundary${Math.random().toString(36).slice(2, 11)}`;
    const chunks = [];

    const pushText = (text) => chunks.push(Buffer.from(text, 'utf8'));

    // 2. 手動添加「aggregate」欄位
    // 由於我們處於「Binary」模式,這必須在緩衝區中
    pushText(`--${boundary}${CRLF}`);
    pushText(`Content-Disposition: form-data; name="aggregate"${CRLF}`);
    pushText(`${CRLF}true${CRLF}`);

    // 3. 處理「params」檔案
    if (item.binary?.params) {
      const paramsMeta = item.binary.params;
      const paramsBuf = await this.helpers.getBinaryDataBuffer(i, 'params');
      const fileName = paramsMeta.fileName || 'params.json';
      const mimeType = paramsMeta.mimeType || 'application/json';

      pushText(`--${boundary}${CRLF}`);
      pushText(`Content-Disposition: form-data; name="params"; filename="${fileName}"${CRLF}`);
      pushText(`Content-Type: ${mimeType}${CRLF}${CRLF}`);
      chunks.push(paramsBuf);
      pushText(CRLF);
    }

    // 4. 處理所有其他檔案(對應到「data」)
    if (item.binary) {
      const dataKeys = Object.keys(item.binary).filter((k) => k !== 'params');
      for (const key of dataKeys) {
        const fileMeta = item.binary[key];
        const fileBuf = await this.helpers.getBinaryDataBuffer(i, key);
        const fileName = fileMeta.fileName || key;
        const mimeType = fileMeta.mimeType || 'application/octet-stream';

        pushText(`--${boundary}${CRLF}`);
        pushText(`Content-Disposition: form-data; name="data"; filename="${fileName}"${CRLF}`);
        pushText(`Content-Type: ${mimeType}${CRLF}${CRLF}`);
        chunks.push(fileBuf);
        pushText(CRLF);
      }
    }

    // 5. 關閉邊界
    pushText(`--${boundary}--${CRLF}`);

    const body = Buffer.concat(chunks);
    
    // 伺服器需要看到的確切標頭
    const contentTypeHeader = `multipart/form-data; boundary=${boundary}`;

    result.push({
      json: item.json,
      binary: {
        multipartBody: await this.helpers.prepareBinaryData(
          body,
          'request.bin',
          contentTypeHeader
        ),
      },
    });
  }

  return result;
})();

不要在「Body Parameters」中添加任何內容。按照以下方式配置 http request:

  • Method(方法): POST
  • URL: your-api-endpoint
  • Send Body(發送 Body): true
  • Body Content Type(Body 內容類型): Binary這是最重要的部分
  • Input Data Field Name(輸入資料欄位名稱): multipartBody
  • Options(選項):(保留空白)

你使用的是哪個版本的 n8n。我試過幾個版本,但只能在
1.123.65 上穩定運行。遺憾的是,當我能夠運行它時,n8n 實際上嘗試上傳正文,將每一位都作為自己的 form-data 發送:

Host: javeRequestCatcher.127.0.0.1.nip.io
Accept: application/json,text/html,application/xhtml+xml,application/xml,text/*;q=0.9, image/*;q=0.8, */*;q=0.7
Accept-Encoding: gzip, compress, deflate, br
Connection: close
Content-Length: 69898
Content-Type: multipart/form-data; boundary=--------------------------6c56103870e43e66b91b529d
User-Agent: axios/1.18.0

----------------------------6c56103870e43e66b91b529d
Content-Disposition: form-data; name="0"

45
----------------------------6c56103870e43e66b91b529d
Content-Disposition: form-data; name="1"

45
 
<略過許多行>

13
----------------------------6c56103870e43e66b91b529d
Content-Disposition: form-data; name="668"

10
----------------------------6c56103870e43e66b91b529d--

在我想說的每個 2.xx 版本上,我試過的都只是得到 source.on is not a function 的錯誤

這樣會有幫助嗎?

  1. 刪除 HTTP Request 節點。

  2. 將此程式碼放在 Code Node 中。

const axios = require('axios');
const FormData = require('form-data');

const item = $input.first();
const form = new FormData();

// 1. Add 'aggregate'
form.append('aggregate', 'true');

// 2. Add 'params'
if (item.binary?.params) {
  const buf = await this.helpers.getBinaryDataBuffer(0, 'params');
  form.append('params', buf, { filename: 'params.json', contentType: 'application/json' });
}

// 3. Add all other files to 'data'
if (item.binary) {
  const keys = Object.keys(item.binary).filter(k =\\ k !== 'params');
  for (const key of keys) {
    const buf = await this.helpers.getBinaryDataBuffer(0, key);
    const file = item.binary[key];
    form.append('data', buf, { filename: file.fileName || key, contentType: file.mimeType });
  }
}

// 4. Execute request directly
try {
  const response = await axios.post('http://example.org/project/tag', form, {
    headers: form.getHeaders(),
  });
  return { json: response.data };
} catch (error) {
  return { json: { error: error.message } };
}
  1. 進入你的「設定」(或「環境變數」)並確保 NODE_FUNCTION_ALLOW_EXTERNAL 包含 axiosform-data(如果你的特定執行個體限制它們的話 — 不過它們通常已內建)。如果是 n8n Cloud 或標準 Docker,axios 是內建的。

這在第二次端點需要身份驗證時就行不通了——自訂程式碼節點沒有權限使用已保存的憑證。

經過一番腦力激盪後,我最終創建了一個自訂節點。目前的實現是快速且粗糙的,但如果有時間的話,我會將其改進至標準水準並作為社群節點發佈。

感謝你