n8n+AI智能体自动发布X:定时生成内容、配图并推送

n8n+AI智能体自动发布X:定时生成内容、配图并推送

X(原 Twitter)是 AI、开发工具和科技行业信息传播速度最快的平台之一。模型发布、开源项目、产品更新和开发者讨论,往往会先在 X 出现。但持续运营一个 X 账号并不轻松:每天都要选题、压缩内容、准备图片、控制字符数量,再选择合适时间发布。

这篇文章介绍如何使用 n8n + AI 智能体 + Data Table + X API 搭建一套自动内容发布工作流。AI 智能体负责生成内容和封面,n8n 负责读取待发布文章、整理成 X 帖子、上传图片、定时调用 X API,并记录接口返回的 Tweet ID 和文章链接。

完整流程:AI 联网获取选题 → AI 生成文章与短内容 → 自动生成封面 → Data Table 保存状态 → n8n 定时读取 → OAuth 上传图片 → X API 发布帖子 → 返回 Tweet ID。

一、这套 X 自动发布工作流能做什么

  • 每天按照指定时间自动启动,也支持手动测试。
  • content_articles Data Table 读取已经准备好的文章。
  • 只选择文章状态和图片状态都为 ready 的记录。
  • 清理 Markdown、排版提示和不适合 X 展示的内容。
  • 自动把标题、摘要和推广链接压缩到 280 个字符以内。
  • 读取文章封面文件,并上传到 X 媒体接口。
  • 将文字和图片组合成一条 X 帖子。
  • 发布成功后取得 Tweet ID,并生成公开访问链接。

当前工作流一次最多读取 8 篇满足条件的文章,按照 Data Table 中的顺序处理。实际生产环境可以把数量调整为 1 至 3 条,避免短时间连续发送大量内容。

二、AI 智能体与 X 发布工作流如何配合

需要说明的是,X 发布工作流本身主要负责“读取、格式化、上传和发布”。真正的 AI 内容生成位于它的上游。也就是说,完整系统由多个工作流协作,而不是把所有逻辑都塞进一个巨大画布。

AI 新闻智能体(联网搜索)
        ↓
horizon_summaries Data Table
        ↓
AI 生产文章
        ↓
content_articles Data Table
        ↓
AI 生成封面并写入 image_path
        ↓
推送 X 平台文章(10:30 定时)
        ↓
X Media Upload + POST /2/tweets

新闻智能体会先从可信来源整理选题,文章工作流使用大模型扩展正文,生图工作流为文章生成封面。等 article_status=readyimage_status=ready 后,X 工作流才开始处理。微信公众号内容生产部分可以参考站内的 n8n + GPT 公众号自动化教程

三、用 Data Table 连接 AI 内容和 X 发布

X 工作流读取的核心数据表是 content_articles。文章生成、生图、微信草稿和 X 发布都可以围绕这张表协作。当前工作流主要使用以下字段:

  • article_key:文章唯一标识。
  • title:文章标题,也是 X 帖子的标题部分。
  • markdown:AI 生成的完整正文。
  • image_path:本地封面图片路径。
  • article_status:文章是否已经准备完成。
  • image_status:封面是否已经生成完成。
  • error:保存处理失败原因。

工作流只读取 article_status=readyimage_status=ready 的记录。这样即使文章已经写完但图片仍在生成,也不会提前发布一条缺少封面的内容。

四、X 工作流的 10 个节点

1. 手动触发与定时触发

工作流同时保留 Manual Trigger 和 Schedule Trigger。手动触发用于配置 OAuth、检查图片路径和测试接口;定时触发默认安排在每天 10:30。正式启用前,应先完整执行一次手动测试。

2. 读取待发布文章

“读取文章”节点从 content_articles 获取状态符合要求的记录,并限制单次处理数量。这里不直接读取 Markdown 文件,而是以 Data Table 作为稳定的数据中心。

3. 清理和格式化文章

“文章格式处理”节点会删除排版建议、配图提示和多余标记,识别标题、段落、列表和代码块,同时校验文章标题、正文、唯一标识与图片路径是否完整。

4. 准备 280 字符帖子

“准备 X 平台文章数据”节点从完整正文中提取纯文本,删除 Markdown 符号和代码块,然后组合标题、摘要与链接。节点会根据剩余字符动态截断正文,最终把帖子控制在 280 个字符以内。

5. 读取并上传图片

“读取 X 平台图片”节点根据 image_path 读取封面二进制文件,随后通过 OAuth 1.0a 凭证上传图片。当前项目工作流使用 https://upload.twitter.com/1.1/media/upload.json,而 X 最新官方文档同时提供 X API v2 Media Upload。部署时应根据自己的 X API 权限和套餐确认可用端点。

6. 创建 X 帖子

图片上传成功后,接口会返回 media_id。工作流再调用官方 POST /2/tweets 接口,把 tweet_text 和图片编号组合成帖子。成功响应会包含新帖子的 ID。

7. 整理发布结果

最后一个节点会校验接口是否返回 Tweet ID,并生成 x_statusx_tweet_idx_url。当前版本只把结果保存在本次 n8n 执行输出中;正式长期运行时,建议再增加一个 Data Table Update 节点,将结果写回文章记录。

五、为什么必须配置用户身份 OAuth

普通 Bearer Token 通常代表应用本身,不能直接代替某个用户发布帖子。自动发帖需要用户身份授权。X 官方说明,OAuth 1.0a User Context 可以让开发者应用代表已授权账号执行操作;创建帖子也需要有效的用户访问令牌。

当前工作流的图片上传和帖子创建节点都使用 OAuth 1.0a,因此需要在 X Developer Console 创建 App,并准备以下信息:

  • API Key,也称 Consumer Key。
  • API Key Secret,也称 Consumer Secret。
  • Access Token。
  • Access Token Secret。
  • 具备 Read and Write 权限的用户授权。

在 n8n 中创建 X OAuth API 或通用 OAuth 1.0a 凭证,并让“上传图片到 X 平台”和“推送 X 平台文章”两个 HTTP Request 节点使用同一份凭证。OAuth 1.0a 的请求需要 HMAC-SHA1 签名,建议交给 n8n 凭证系统处理,不要在 Code 节点中自行拼接签名。X 官方的 OAuth 1.0a 文档列出了完整的 Key、Token 和签名关系。

六、定时发布和防止重复推送

Schedule Trigger 默认在每天 10:30 启动,但真正上线前不能只打开 Active。必须先解决重复发布问题。当前 content_articles 表还没有 X 专用状态字段,而且工作流最后也没有 Data Table Update 节点。如果直接每天定时读取相同的 ready 文章,存在重复发送的风险。

推荐在 Data Table 中增加以下字段:

  • x_status:pending、posted 或 failed。
  • x_tweet_id:X 返回的帖子 ID。
  • x_url:帖子公开链接。
  • x_posted_at:实际发布时间。

随后将读取条件增加为 x_status=pending,并在发帖成功后写回 posted。这样即使 n8n 重启或定时任务重复触发,也不会再次发布已经完成的文章。

七、正式启用前的完整检查清单

  • n8n 容器健康,/healthz 返回正常。
  • X Developer App 已经创建并开启 Read and Write 权限。
  • OAuth 1.0a 用户授权已经完成,不再出现 “There is no request token”。
  • 账号具备调用创建帖子和媒体上传接口所需的 API 权限或额度。
  • 两处 X HTTP 节点使用同一份 OAuth 凭证。
  • content_articles 中存在一条文章和图片都为 ready 的测试记录。
  • 封面图片路径可以从 n8n 容器内部读取。
  • 手动执行成功并返回真实 Tweet ID。
  • 增加 X 状态回写和防重复条件后,再启用 Schedule Trigger。

八、当前项目的实际验证状态

当前边界:工作流 JSON、节点连接、Data Table 读取、字符整理、媒体上传请求和创建帖子请求已经建立;但工作流目前仍为停用状态,没有配置完成的 OAuth 1.0a 凭证,也没有真实成功发布记录。因此本文介绍的是已经落地的工作流结构与部署方法,不代表当前账号已经完成端到端自动发帖。

要把它变成真正无人值守的定时发布系统,至少还需要完成 OAuth 用户授权、确认 X API 权限或额度、增加发布状态回写,并使用真实文章进行一次手动执行。只有拿到 X 返回的 Tweet ID,并在目标账号页面看到帖子,才算完成最终验证。

九、常见故障排查

OAuth 页面提示没有 Request Token

这通常说明 n8n 尚未成功取得临时 Request Token,或者 Callback URL、Consumer Key、Consumer Secret、应用权限配置不一致。重新检查 X Developer App 的回调地址和 n8n OAuth 凭证,不要把 OAuth 2.0 Bearer Token 填到 OAuth 1.0a 节点。

图片上传没有返回 media_id

检查图片路径是否能从 n8n 容器内部访问、二进制字段名称是否为 data、文件格式和大小是否符合接口要求,同时确认当前账号是否仍能使用工作流配置的媒体上传端点。

创建帖子返回 401 或 403

401 通常与签名、Token 或时间偏差有关;403 更常见于应用没有写入权限、用户未授权、接口套餐不支持或者账号受限。修改权限后应重新生成或重新授权 Access Token。

工作流每天重复发布同一篇文章

这是因为当前读取条件只检查文章和图片状态,没有检查 X 发布状态。增加 x_status 字段、成功回写节点和 pending 过滤条件即可。

十、安全和运营建议

  • API Key、Secret、Access Token 和 Token Secret 只能保存在 n8n Credential 中。
  • 不要把真实密钥写入工作流 JSON、GitHub、文章代码块或执行日志。
  • 工作流导入后默认保持停用,完成手动验证后再启用定时器。
  • 限制每天发布数量,避免连续发送相似内容。
  • AI 生成内容发布前应检查事实、链接、敏感表达和版权风险。
  • 定期检查 X Developer Console 的权限、额度和失败响应。

十一、适合哪些使用场景

这套方案适合 AI 科技资讯账号、开发者个人品牌、开源项目更新、企业产品动态、自媒体矩阵和需要跨平台分发内容的团队。上游 AI 智能体负责持续生成素材,下游 n8n 工作流根据不同平台要求改写格式,就可以把同一条内容分别投放到微信公众号、X、WordPress 或 Telegram。

十二、总结

n8n + AI 智能体的价值,不只是自动调用一次 X API,而是建立一条可以观察、重试、扩展和维护的内容生产流水线。AI 负责选题、写作和封面,Data Table 负责状态,n8n 负责定时编排,X API 负责最终发布。

真正投入生产前,需要完成 OAuth、接口权限、状态回写和真实发帖验证。完成这些步骤后,系统才能从“可导入的工作流”升级为稳定运行的自动定时发布服务。

附录:X 自动发布工作流与 Data Table Schema

下面是当前项目中“推送X平台文章”工作流的脱敏导出代码。真实凭证 ID、Data Table ID 和运行数据均已移除或替换为 REPLACE_WITH_*。导入后必须重新选择 Data Table 与 OAuth 1.0a 凭证,并保持工作流停用,直到手动测试成功。

推送 X 平台文章工作流 JSON(点击展开代码)
{
  "name": "推送X平台文章",
  "nodes": [
    {
      "parameters": {},
      "id": "245b3889-7484-40a1-840e-85f620528695",
      "name": "手动触发",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        2176,
        -224
      ]
    },
    {
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "value": "REPLACE_WITH_CONTENT_ARTICLES_TABLE_ID",
          "mode": "list",
          "cachedResultName": "content_articles"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "article_status",
              "keyValue": "ready"
            },
            {
              "keyName": "image_status",
              "keyValue": "ready"
            }
          ]
        },
        "limit": 8,
        "orderBy": true,
        "orderByDirection": "ASC"
      },
      "id": "72bd7d25-7805-45a7-8683-d914c84af06b",
      "name": "读取文章",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1936,
        32
      ]
    },
    {
      "parameters": {
        "jsCode": "\nconst allItems = $input.all();\nconst results = [];\nconst failed = [];\n\nfor (let i = 0; i < allItems.length; i++) {\n  try {\n    let content = String(allItems[i].json.markdown || '').trim();\n    const articleKey = String(allItems[i].json.article_key || '').trim();\n    const imagePath = String(allItems[i].json.image_path || '').trim();\n    const fileName = articleKey ? articleKey + '.md' : 'article.md';\n    let title = String(allItems[i].json.title || '').trim().slice(0, 64);\n\n    if (!content || !articleKey || !title || !imagePath) {\n      throw new Error('Data Table article is missing markdown, article_key, title, or image_path');\n    }\n\n    const isLayoutTipHeading = (line) => {\n      const normalized = line\n        .trim()\n        .replace(/^[-*#>\\s]+/, '')\n        .replace(/^\\d+[.)、]\\s*/, '')\n        .replace(/\\*\\*/g, '')\n        .replace(/[<《【\\[]/g, '')\n        .replace(/[>》】\\]]/g, '');\n      return /^(公众号)?(排版提示建议|排版提示|排版建议|排版贴士)([::].*)?$/.test(normalized);\n    };\n\n    const rawLines = content.split('\\n');\n    const tipLineIdx = rawLines.findIndex(isLayoutTipHeading);\n    const layoutTipMarker = /(排版提示|排版建议|排版贴士|配图位置建议|配图插入位置参考|重点内容标注建议|文末引导语|图片来源建议)/;\n    const markerLineIdx = rawLines.findIndex((line) => layoutTipMarker.test(line));\n    const cutLineIdx = [tipLineIdx, markerLineIdx].filter((idx) => idx > -1).sort((a, b) => a - b)[0] ?? -1;\n    if (cutLineIdx > -1) {\n      let beforeLines = rawLines.slice(0, cutLineIdx);\n      while (beforeLines.length > 0) {\n        const last = beforeLines[beforeLines.length - 1].trim();\n        if (last === '---' || last === '') beforeLines.pop();\n        else break;\n      }\n      content = beforeLines.join('\\n').trimEnd();\n    }\n\n    const codeBlocks = [];\n    content = content.replace(/```[\\s\\S]*?```/g, (match) => {\n      codeBlocks.push(match);\n      return `__CODE_BLOCK_${codeBlocks.length - 1}__`;\n    });\n\n    let lines = content.split('\\n');\n    let blocks = [];\n    let currentParagraph = [];\n    let firstNonEmpty = true;\n\n    const flushParagraph = () => {\n      if (currentParagraph.length > 0) {\n        blocks.push({ type: 'paragraph', text: currentParagraph.join(' ').replace(/\\s+/g, ' ').trim() });\n        currentParagraph = [];\n      }\n    };\n\n    for (let idx = 0; idx < lines.length; idx++) {\n      const trimmed = lines[idx].trim();\n\n      if (/^---+\\s*$/.test(trimmed)) {\n        flushParagraph();\n        continue;\n      }\n\n      if (firstNonEmpty && trimmed.length > 0) {\n        firstNonEmpty = false;\n        continue;\n      }\n\n      if (/^#{1,6}\\s+/.test(trimmed)) {\n        flushParagraph();\n        blocks.push({\n          type: 'subtitle',\n          text: trimmed.replace(/^#{1,6}\\s+/, '').replace(/\\*\\*/g, '').trim()\n        });\n        continue;\n      }\n\n      if (/^[-*]\\s+/.test(trimmed)) {\n        flushParagraph();\n        blocks.push({ type: 'list', text: trimmed.replace(/^[-*]\\s+/, '') });\n        continue;\n      }\n\n      if (trimmed === '') {\n        flushParagraph();\n        continue;\n      }\n\n      if (/^__CODE_BLOCK_\\d+__$/.test(trimmed)) {\n        flushParagraph();\n        const idxNum = parseInt(trimmed.match(/\\d+/)[0]);\n        blocks.push({ type: 'code', text: codeBlocks[idxNum] });\n        continue;\n      }\n\n      currentParagraph.push(trimmed);\n    }\n    flushParagraph();\n\n    blocks = blocks.map((block) => {\n      if (block.type !== 'paragraph') return block;\n\n      const text = block.text;\n\n      if (/^第[一二三四五六七八九十0-9]+[个类章节部分]/.test(text.trim())) {\n        return block;\n      }\n\n      const match = text.match(/^([^\\s。!?!?]{2,25}?[::??])\\s*(.*)$/s);\n      if (match && match[1].length <= 25 && match[1].length >= 4) {\n        if (/^[一二三四五六七八九十0-9]+类[::]$/.test(match[1])) {\n          return block;\n        }\n        const subtitleText = match[1].trim();\n        const rest = match[2].trim();\n        if (rest.length > 0) {\n          return [\n            { type: 'subtitle', text: subtitleText },\n            { type: 'paragraph', text: rest }\n          ];\n        } else {\n          return [{ type: 'subtitle', text: subtitleText }];\n        }\n      }\n      return block;\n    }).flat();\n\n    blocks = (() => {\n      const result = [];\n      for (let j = 0; j < blocks.length; j++) {\n        const cur = blocks[j];\n        const next = blocks[j + 1];\n        if (\n          cur.type === 'subtitle' &&\n          next && next.type === 'paragraph' &&\n          next.text.length <= 12 &&\n          !/[。!!.,,]/.test(next.text)\n        ) {\n          result.push({ type: 'subtitle', text: cur.text + next.text });\n          j++;\n        } else {\n          result.push(cur);\n        }\n      }\n      return result;\n    })();\n\n    let finalBlocks = [];\n    for (let j = 0; j < blocks.length; j++) {\n      const cur = blocks[j];\n      const prev = finalBlocks[finalBlocks.length - 1];\n\n      if (cur.type !== 'paragraph') {\n        finalBlocks.push(cur);\n        continue;\n      }\n\n      const isVeryShort = cur.text.length < 15;\n      if (prev && prev.type === 'paragraph' && cur.text.length < 80 && !cur.text.endsWith(':') && !cur.text.endsWith(':') && !cur.text.endsWith('?') && !cur.text.endsWith('?')) {\n        prev.text = prev.text + ' ' + cur.text;\n        continue;\n      }\n      if (prev && prev.type === 'paragraph' && isVeryShort) {\n        prev.text = prev.text + cur.text;\n        continue;\n      }\n\n      finalBlocks.push(cur);\n    }\n\n    const renderParagraph = (text) => {\n      text = text.replace(/__CODE_BLOCK_(\\d+)__/g, (m, idx) => codeBlocks[parseInt(idx)] || '');\n      return text.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n    };\n\n    let html = '';\n    for (const block of finalBlocks) {\n      if (block.type === 'subtitle') {\n        html += '<p style=\"font-weight:bold;font-size:16px;margin:30px 0 10px 0;line-height:1.6;color:#333;\">' + escapeHtml(block.text.replace(/\\*\\*/g, '').trim()) + '</p>\\n';\n      } else if (block.type === 'list') {\n        const itemText = renderParagraph(block.text);\n        html += '<p style=\"text-indent:0;margin:5px 0 5px 18px;line-height:1.8;font-size:16px;color:#333;\">\\u00b7 ' + itemText + '</p>\\n';\n      } else if (block.type === 'code') {\n        html += '<pre style=\"background:#f5f5f5;padding:12px;border-radius:4px;overflow-x:auto;font-size:14px;line-height:1.5;margin:15px 0;\">' + escapeHtml(block.text) + '</pre>\\n';\n      } else if (block.type === 'paragraph') {\n        const paraText = renderParagraph(block.text);\n        html += '<p style=\"text-indent:0;margin:15px 0;line-height:1.8;font-size:16px;color:#333;\">' + paraText + '</p>\\n';\n      }\n    }\n\n    if (!html.trim()) {\n      failed.push({ fileName, error: 'Empty content after formatting' });\n      continue;\n    }\n\n    const assertions = {\n      hasFontSize16: /font-size:16px/.test(html),\n      hasMargin30: /margin:30px 0 10px 0/.test(html),\n      hasBoldWeight: /font-weight:bold/.test(html),\n      hasColor333: /color:#333/.test(html),\n      hasSubtitles: (html.match(/font-weight:bold[^\"]*\">[^<]+/g) || []).length >= 1,\n      noLayoutTipResidue: !/(排版提示|排版建议|排版贴士|配图插入位置参考|重点内容标注建议)/.test(html)\n    };\n    const failed_asserts = Object.entries(assertions).filter(([k, v]) => !v).map(([k]) => k);\n    if (failed_asserts.length > 0) {\n      failed.push({ fileName, error: 'Assertion failed: ' + failed_asserts.join(', ') });\n      continue;\n    }\n\n    const htmlContent = '<div style=\"font-size:16px;color:#333;\">\\n' + html + '</div>';\n    results.push({\n      json: {\n        content: htmlContent,\n        title,\n        fileName,\n        itemType: 'article',\n        formatted: true,\n        article_key: articleKey,\n        image_path: imagePath\n      },\n      pairedItem: { item: i }\n    });\n  } catch (err) {\n    failed.push({ fileName: allItems[i].json.fileName || `item_${i}`, error: err.message, stack: err.stack });\n  }\n}\n\nfunction escapeHtml(text) {\n  return String(text)\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;');\n}\n\nif (failed.length > 0) {\n  console.log('[format-v3.1] Failed items:', JSON.stringify(failed));\n  throw new Error('Some articles failed to format: ' + JSON.stringify(failed));\n}\n\nif (results.length === 0) {\n  throw new Error('All articles failed to format: ' + JSON.stringify(failed));\n}\n\nreturn results;\n"
      },
      "id": "464c788f-8a69-489c-983d-b5b5dfa05f40",
      "name": "文章格式处理",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        2144,
        32
      ]
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nconst output = [];\n\nfor (const item of items) {\n  const json = item.json || {};\n  const articleKey = String(json.article_key || '').trim();\n  const title = String(json.title || '').trim();\n  const imagePath = String(json.image_path || '').trim();\n  const markdown = String(json.markdown || '').trim();\n  if (!articleKey || !title || !imagePath || !markdown) {\n    throw new Error('文章缺少 article_key、title、image_path 或 markdown: ' + articleKey);\n  }\n\n  const clean = markdown\n    .replace(/排版提示[\\s\\S]*$/i, '')\n    .replace(new RegExp('\\x60\\x60\\x60[\\s\\S]*?\\x60\\x60\\x60', 'g'), '')\n    .replace(/[#>*_\\-]/g, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim();\n  const link = 'https://macosabc.com/token';\n  const prefix = title + '\\n\\n';\n  const available = Math.max(0, 280 - prefix.length - link.length - 2);\n  const excerpt = clean.slice(0, available).trim();\n  const text = (prefix + excerpt + (clean.length > available ? '…' : '') + '\\n\\n' + link).slice(0, 280);\n\n  output.push({\n    json: { article_key: articleKey, title, image_path: imagePath, tweet_text: text },\n  });\n}\n\nif (output.length === 0) throw new Error('没有找到待推送到 X 的文章。');\nreturn output;"
      },
      "id": "54aa1a86-db11-442e-b474-643a65dbbbca",
      "name": "准备X平台文章数据",
      "type": "n8n-nodes-base.code",
      "position": [
        2656,
        32
      ],
      "typeVersion": 2
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.image_path }}",
        "options": {}
      },
      "id": "b61f10d8-def8-4570-b25e-faae42b45129",
      "name": "读取X平台图片",
      "type": "n8n-nodes-base.readWriteFile",
      "position": [
        2896,
        32
      ],
      "typeVersion": 1.1
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://upload.twitter.com/1.1/media/upload.json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "oAuth1Api",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "media",
              "inputDataFieldName": "data"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "93e2234e-3e77-4ca1-b6e4-b020134a051d",
      "name": "上传图片到X平台",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3136,
        32
      ],
      "typeVersion": 4.2
    },
    {
      "parameters": {
        "jsCode": "const uploads = $input.all();\nconst source = $('准备X平台文章数据').all();\nreturn uploads.map((upload, index) => {\n  const article = source[index]?.json || {};\n  const mediaId = String(upload.json?.media_id || '').trim();\n  if (!mediaId) throw new Error('X 图片上传没有返回 media_id: ' + (article.article_key || index));\n  return { json: { ...article, media_id: mediaId } };\n});"
      },
      "id": "6abe347c-0b3d-4d43-a8fd-39d1921d69b5",
      "name": "准备X平台推文",
      "type": "n8n-nodes-base.code",
      "position": [
        3376,
        32
      ],
      "typeVersion": 2
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.x.com/2/tweets",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "oAuth1Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $json.tweet_text, media: { media_ids: [$json.media_id] } }) }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "3ef0556c-8be1-4d86-ba41-81fbd4c92949",
      "name": "推送X平台文章",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3616,
        32
      ],
      "typeVersion": 4.2
    },
    {
      "parameters": {
        "jsCode": "const responses = $input.all();\nconst source = $('准备X平台推文').all();\nreturn responses.map((item, index) => {\n  const article = source[index]?.json || {};\n  const response = item.json || {};\n  const tweetId = String(response.data?.id || response.id || '').trim();\n  if (!tweetId) throw new Error('X 发文接口没有返回推文 ID: ' + (article.article_key || index));\n  return { json: {\n    article_key: article.article_key,\n    title: article.title,\n    x_status: 'posted',\n    x_tweet_id: tweetId,\n    x_url: 'https://x.com/i/web/status/' + tweetId,\n    error: '',\n  } };\n});"
      },
      "id": "85255f2c-738e-4285-90e6-933e829d70ec",
      "name": "记录X平台推送结果",
      "type": "n8n-nodes-base.code",
      "position": [
        3856,
        32
      ],
      "typeVersion": 2
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 10,
              "triggerAtMinute": 30
            }
          ]
        }
      },
      "id": "81174ae9-6959-4625-aaee-9858c26bbe79",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        2176,
        288
      ],
      "typeVersion": 1.3,
      "disabled": true
    }
  ],
  "connections": {
    "手动触发": {
      "main": [
        [
          {
            "node": "读取文章",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "读取文章",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "读取文章": {
      "main": [
        [
          {
            "node": "文章格式处理",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "文章格式处理": {
      "main": [
        [
          {
            "node": "准备X平台文章数据",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "准备X平台文章数据": {
      "main": [
        [
          {
            "node": "读取X平台图片",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "读取X平台图片": {
      "main": [
        [
          {
            "node": "上传图片到X平台",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "上传图片到X平台": {
      "main": [
        [
          {
            "node": "准备X平台推文",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "准备X平台推文": {
      "main": [
        [
          {
            "node": "推送X平台文章",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "推送X平台文章": {
      "main": [
        [
          {
            "node": "记录X平台推送结果",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate"
  },
  "active": false,
  "pinData": {},
  "tags": []
}
content_articles Data Table Schema(点击展开代码)
{
  "export_type": "n8n_data_table_schema",
  "schema_version": 1,
  "includes_rows": false,
  "table": {
    "name": "content_articles",
    "table_id_placeholder": "REPLACE_WITH_CONTENT_ARTICLES_TABLE_ID",
    "required_columns": [
      {
        "index": 0,
        "name": "article_key",
        "type": "string"
      },
      {
        "index": 1,
        "name": "source_run_id",
        "type": "string"
      },
      {
        "index": 2,
        "name": "summary_date",
        "type": "string"
      },
      {
        "index": 3,
        "name": "title",
        "type": "string"
      },
      {
        "index": 4,
        "name": "markdown",
        "type": "string"
      },
      {
        "index": 5,
        "name": "article_status",
        "type": "string"
      },
      {
        "index": 6,
        "name": "image_path",
        "type": "string"
      },
      {
        "index": 7,
        "name": "image_status",
        "type": "string"
      },
      {
        "index": 8,
        "name": "wechat_status",
        "type": "string"
      },
      {
        "index": 9,
        "name": "wechat_media_id",
        "type": "string"
      },
      {
        "index": 10,
        "name": "error",
        "type": "string"
      }
    ],
    "recommended_x_tracking_columns": [
      {
        "name": "x_status",
        "type": "string",
        "example": "pending | posted | failed"
      },
      {
        "name": "x_tweet_id",
        "type": "string"
      },
      {
        "name": "x_url",
        "type": "string"
      },
      {
        "name": "x_posted_at",
        "type": "string"
      }
    ]
  },
  "note": "当前工作流只在执行输出中生成 X 发布结果。正式启用定时任务前,建议增加 Data Table Update 节点并使用 recommended_x_tracking_columns 防止重复发布。"
}
THE END
喜欢就支持一下吧
点赞1W+ 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容