{
  "name": "content-orchestrator-v4-anime-shortthreads",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 21 * * *"
            }
          ]
        }
      },
      "id": "1a000000-0000-0000-0000-000000000001",
      "name": "Schedule 21:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Fetch latest fb-post-* Gist (Code node \u2014 bypasses HTTP node sendHeaders schema bug)\nconst response = await this.helpers.httpRequest({\n  method: 'GET',\n  url: 'https://api.github.com/gists?per_page=10',\n  headers: {\n    'Authorization': 'token ' + $env.GIST_PAT,\n    'Accept': 'application/vnd.github+json',\n    'User-Agent': 'n8n-content-orchestrator'\n  },\n  json: true\n});\n\nconst arr = Array.isArray(response) ? response : [];\nconst fbGist = arr.find(g => (g.description || '').startsWith('fb-post-'));\nif (!fbGist) {\n  throw new Error('No fb-post-* Gist found in latest 10. Cloud routine \u6c92\u5beb Gist? Available: ' + arr.slice(0, 3).map(g => g.description || '(no desc)').join(' | '));\n}\nconst fileEntry = Object.values(fbGist.files)[0];\nreturn [{\n  json: {\n    gist_id: fbGist.id,\n    gist_html_url: fbGist.html_url,\n    raw_url: fileEntry.raw_url,\n    filename: fileEntry.filename,\n    description: fbGist.description,\n    created_at: fbGist.created_at\n  }\n}];"
      },
      "id": "2a000000-0000-0000-0000-000000000002",
      "name": "Pick Latest fb-post Gist",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.raw_url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        }
      },
      "id": "3a000000-0000-0000-0000-000000000003",
      "name": "Fetch Raw Markdown",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        680,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Parse YAML frontmatter + extract draft + optional Threads short version\nconst raw = $input.first().json.data || $input.first().json;\nconst rawText = typeof raw === 'string' ? raw : JSON.stringify(raw);\n\nconst fmMatch = rawText.match(/^---\\s*\\n([\\s\\S]*?)\\n---\\s*\\n([\\s\\S]*)$/);\nif (!fmMatch) throw new Error('No YAML frontmatter found in Gist content');\n\nconst fmText = fmMatch[1];\nconst body = fmMatch[2];\n\n// Simple YAML parser (flat keys + arrays)\nconst meta = {};\nfmText.split('\\n').forEach(line => {\n  const m = line.match(/^([a-z_]+):\\s*(.*)$/);\n  if (m) {\n    let val = m[2].trim();\n    if (val.startsWith('[') && val.endsWith(']')) {\n      val = val.slice(1, -1).split(',').map(s => s.trim().replace(/^[\"']|[\"']$/g, '')).filter(s => s);\n    } else if (val.startsWith('\"') && val.endsWith('\"')) {\n      val = val.slice(1, -1);\n    } else if (!isNaN(val) && val !== '') {\n      val = Number(val);\n    }\n    meta[m[1]] = val;\n  }\n});\n\n// \u984d\u5916\u6293 post_text_threads \u77ed\u7248\uff08cloud routine \u7528 YAML | \u591a\u884c\u5b57\u4e32\u5beb\u5728 frontmatter\uff09\nlet postTextThreads = null;\nconst threadsMatch = fmText.match(/post_text_threads:\\s*\\|\\s*\\n([\\s\\S]+?)(?=\\n[a-z_]+:\\s|$(?![\\s\\S]))/);\nif (threadsMatch) {\n  postTextThreads = threadsMatch[1].replace(/^  /gm, '').trim();\n}\n\nconst draftMatch = body.match(/##\\s*(?:\u8349\u7a3f|draft)\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)/i);\nlet draft = draftMatch ? draftMatch[1].trim() : body.trim();\n\n\n// Defensive: strip cloud-routine internal markers that should have been removed\n// e.g.: \"\u2500\u2500\u2500\u2500\u2500\u2500 \u4ee5\u4e0a 3 \u7bc0\u70ba Threads \u622a\u65b7\u908a\u754c \u2500\u2500\u2500\u2500\u2500\u2500\"\ndraft = draft.replace(/^.*\u4ee5\u4e0a\\s*\\d+\\s*\u7bc0\u70ba\\s*Threads\\s*\u622a\u65b7\u908a\u754c.*$/gm, '');\n// Collapse 3+ consecutive newlines back to 2\ndraft = draft.replace(/\\n{3,}/g, '\\n\\n').trim();\n\nreturn [{\n  json: {\n    metadata: meta,\n    post_text: draft,\n    post_text_chars: draft.length,\n    post_text_threads: postTextThreads,\n    post_text_threads_chars: postTextThreads ? postTextThreads.length : 0,\n    format: meta.format || 'text',\n    gist_id: $('Pick Latest fb-post Gist').first().json.gist_id,\n    gist_url: $('Pick Latest fb-post Gist').first().json.gist_html_url\n  }\n}];"
      },
      "id": "4a000000-0000-0000-0000-000000000004",
      "name": "Parse + Extract Draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Resolve image: Drive folder scan \u2192 ComfyUI local SDXL anime mascot\n// 2026-06-08: \u63db ComfyUI \u53d6\u4ee3 Pollinations\uff08\u8b8a\u4ed8\u8cbb + \u4e0d\u7a69\uff09\nconst meta = $input.first().json.metadata;\nconst upstream = $input.first().json;\nconst topic = meta.image_topic_for_gen || meta.topic;\n\nlet image_base64 = null;\nlet image_source = 'none';\nlet image_error = null;\nlet drive_file_used = null;\n\nconst wfData = $getWorkflowStaticData('global');\nwfData.processed_drive_ids = wfData.processed_drive_ids || [];\n\nconst COMFY_BASE = $env.COMFYUI_BASE_URL || 'http://host.docker.internal:8188';\nconst COMFY_CKPT = $env.COMFYUI_CHECKPOINT || 'animagine-xl-4.0.safetensors';\n\nfunction buildComfyWorkflow(topicText, seed) {\n  // Hybrid seed: MASCOT_SEED anchors character; day-of-year shifts background/light for daily variation\n  const MASCOT_SEED = 42;\n  const today = new Date();\n  const dayOfYear = Math.floor((today - new Date(today.getFullYear(), 0, 0)) / 86400000);\n  const finalSeed = MASCOT_SEED + dayOfYear;\n\n  // Cinematography-vocabulary prompt \u2014 borrowed from ai-media-generator skill (5 concrete > 20 generic)\n  const POSITIVE = \"Asian woman in her late 20s, chin-length to waist-length wavy chestnut brown hair, soft side bangs, \" +\n    \"((light brown cat ears:1.4)), ((kemonomimi)), fluffy ear fur detail, \" +\n    \"warm brown eyes, sharp catchlights, long eyelashes, light freckles across nose bridge, gentle pink lips, calm slight smile, looking at viewer, \" +\n    \"upper body portrait, \" +\n    \"navy blue collared formal shirt, \" +\n    \"black leather choker with circular obsidian tech pendant, \" +\n    \"((large matte black over-ear headphones:1.3)), glowing circular sensor on ear cup, subtle cybernetic trim, \" +\n    \"small data-port chain earrings, \" +\n    \"85mm portrait, f/2 shallow depth of field, \" +\n    \"((Rembrandt lighting 4:1 contrast)), soft window light from camera left, gentle rim light, \" +\n    \"((golden hour warmth)), \" +\n    \"((teal and amber color grade)), Kodak Vision3 500T grain, \" +\n    \"Lubezki cinematography, \u738b\u5bb6\u885b mood, \" +\n    \"((thin cyan fiber optic strands in background bokeh)), \" +\n    \"tiny holographic UI fragments floating softly out of focus, \" +\n    \"((miniature companion robot drone hovering at shoulder height:1.2)), single glowing lens eye, \" +\n    \"AI semiconductor industry topic, \" + topicText + \", \" +\n    \"masterpiece, best quality, photorealistic, detailed skin texture\";\n\n  const NEGATIVE = \"worst quality, low quality, bad anatomy, bad hands, extra fingers, blurry, \" +\n    \"watermark, signature, text, nsfw, 2girls, \" +\n    \"((short hair)), blonde, black hair, blue hair, pink hair, \" +\n    \"((flat shading)), ((cel shading)), ((2d anime drawing)), ((manga sketch)), chibi, \" +\n    \"((heavy makeup)), plastic skin, glossy skin, \" +\n    \"((cyberpunk city)), ((neon overload)), blade runner cliche, harsh neon, \" +\n    \"full robot face, mecha helmet, mask covering face, \" +\n    \"lens flare overload, glitch, pixelation\";\n\n  return {\n    \"3\": {\"inputs\":{\"seed\":finalSeed,\"steps\":20,\"cfg\":4.5,\"sampler_name\":\"dpmpp_sde\",\"scheduler\":\"karras\",\"denoise\":1,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]},\"class_type\":\"KSampler\"},\n    \"4\": {\"inputs\":{\"ckpt_name\":COMFY_CKPT},\"class_type\":\"CheckpointLoaderSimple\"},\n    \"5\": {\"inputs\":{\"width\":1024,\"height\":1024,\"batch_size\":1},\"class_type\":\"EmptyLatentImage\"},\n    \"6\": {\"inputs\":{\"text\":POSITIVE,\"clip\":[\"4\",1]},\"class_type\":\"CLIPTextEncode\"},\n    \"7\": {\"inputs\":{\"text\":NEGATIVE,\"clip\":[\"4\",1]},\"class_type\":\"CLIPTextEncode\"},\n    \"8\": {\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]},\"class_type\":\"VAEDecode\"},\n    \"9\": {\"inputs\":{\"filename_prefix\":\"n8n_orchestrator\",\"images\":[\"8\",0]},\"class_type\":\"SaveImage\"}\n  };\n}\n\n\n\ntry {\n  // ---- Step 1: Drive folder scan (priority) ----\n  if ($env.DRIVE_API_KEY && $env.DRIVE_INBOX_FOLDER_ID) {\n    const listResp = await this.helpers.httpRequest({\n      method: 'GET',\n      url: 'https://www.googleapis.com/drive/v3/files',\n      qs: {\n        q: `'${$env.DRIVE_INBOX_FOLDER_ID}' in parents and trashed=false and mimeType contains 'image/'`,\n        key: $env.DRIVE_API_KEY,\n        fields: 'files(id,name,mimeType,createdTime)',\n        orderBy: 'createdTime'\n      },\n      json: true\n    });\n    const files = (listResp.files || []).filter(f => f.mimeType && f.mimeType.startsWith('image/'));\n    if (files.length > 0) {\n      const fresh = files.find(f => !wfData.processed_drive_ids.includes(f.id));\n      if (fresh) {\n        const buf = await this.helpers.httpRequest({\n          method: 'GET',\n          url: `https://www.googleapis.com/drive/v3/files/${fresh.id}?alt=media&key=${$env.DRIVE_API_KEY}`,\n          encoding: 'arraybuffer',\n          json: false,\n          returnFullResponse: false\n        });\n        image_base64 = Buffer.from(buf).toString('base64');\n        image_source = `drive:${fresh.id}`;\n        drive_file_used = { id: fresh.id, name: fresh.name };\n        wfData.processed_drive_ids.push(fresh.id);\n      }\n    }\n  }\n\n  // ---- Step 2: ComfyUI local SDXL anime generation ----\n  if (!image_base64 && topic) {\n    let positivePrompt = typeof topic === 'string' ? topic : String(topic);\n    const seed = Math.floor(Math.random() * 1e15);\n    const workflow = buildComfyWorkflow(positivePrompt, seed);\n    const clientId = 'n8n-orchestrator';\n\n    // 1. POST /prompt\n    const submitResp = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `${COMFY_BASE}/prompt`,\n      body: { prompt: workflow, client_id: clientId },\n      json: true,\n      timeout: 30000\n    });\n    const promptId = submitResp.prompt_id;\n    if (!promptId) throw new Error('ComfyUI no prompt_id: ' + JSON.stringify(submitResp).slice(0, 300));\n\n    // 2. Poll /history (SDXL 28 steps on 3060 \u2248 25-40s warm, +140s cold start)\n    let history = null;\n    const maxPollSec = 240;\n    for (let i = 0; i < maxPollSec / 2; i++) {\n      await new Promise(r => setTimeout(r, 2000));\n      const hr = await this.helpers.httpRequest({\n        method: 'GET',\n        url: `${COMFY_BASE}/history/${promptId}`,\n        json: true,\n        timeout: 10000\n      });\n      if (hr[promptId] && hr[promptId].outputs && Object.keys(hr[promptId].outputs).length > 0) {\n        history = hr[promptId];\n        break;\n      }\n    }\n    if (!history) throw new Error('ComfyUI poll timeout (>240s)');\n\n    // 3. Find output image\n    let imgFile = null;\n    for (const nodeId of Object.keys(history.outputs)) {\n      const out = history.outputs[nodeId];\n      if (out.images && out.images.length > 0) {\n        imgFile = out.images[0];\n        break;\n      }\n    }\n    if (!imgFile) throw new Error('ComfyUI no images in history');\n\n    // 4. GET /view \u2192 image bytes\n    const buf = await this.helpers.httpRequest({\n      method: 'GET',\n      url: `${COMFY_BASE}/view`,\n      qs: { filename: imgFile.filename, subfolder: imgFile.subfolder || '', type: 'output' },\n      encoding: 'arraybuffer',\n      json: false,\n      returnFullResponse: false,\n      timeout: 30000\n    });\n    image_base64 = Buffer.from(buf).toString('base64');\n    image_source = 'comfyui:dreamshaper-realistic';\n    image_error = `prompt_seed: ${seed}, prompt: ${positivePrompt.slice(0, 100)}`;\n  }\n\n  if (!image_base64) {\n    image_error = image_error || 'No new Drive image and ComfyUI not invoked';\n  }\n} catch (e) {\n  image_error = `comfyui: ${e.message}`;\n}\n\nreturn [{\n  json: {\n    ...upstream,\n    image_base64,\n    image_source,\n    image_error,\n    has_image: !!image_base64,\n    drive_file_used,\n    processed_count: wfData.processed_drive_ids.length\n  }\n}];"
      },
      "id": "5a000000-0000-0000-0000-000000000005",
      "name": "Resolve Image",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1120,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Upload image to ImgBB (anonymous) \u2192 public URL for IG/Threads to fetch\nconst upstream = $input.first().json;\nconst { image_base64, has_image } = upstream;\n\nlet image_url = null;\nlet imgur_id = null;\nlet imgur_error = null;\n\nif (has_image && $env.IMGUR_CLIENT_ID) {\n  try {\n    const resp = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `https://api.imgbb.com/1/upload?key=${$env.IMGUR_CLIENT_ID}`,\n      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n      body: { image: image_base64 },\n      json: true\n    });\n    if (resp && resp.success && resp.data) {\n      image_url = resp.data.url || (resp.data.image && resp.data.image.url);\n      imgur_id = resp.data.id;\n    } else {\n      imgur_error = 'ImgBB returned non-success: ' + JSON.stringify(resp).slice(0, 500);\n    }\n  } catch (e) {\n    imgur_error = e.message;\n  }\n} else if (!has_image) {\n  imgur_error = 'no image to upload';\n} else {\n  imgur_error = 'IMGUR_CLIENT_ID (ImgBB) not set';\n}\n\n// Strip image_base64 from downstream output to keep payload small\nconst { image_base64: _strip, ...rest } = upstream;\nreturn [{\n  json: {\n    ...rest,\n    image_url,\n    imgur_id,\n    imgur_error\n  }\n}];"
      },
      "id": "6a000000-0000-0000-0000-000000000006",
      "name": "Upload to Imgur",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1340,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Publish to FB Page: photo if image_url, else text-only. AI \u751f\u5716\u6642\u81ea\u52d5\u52a0\u6a19\u8a3b.\nconst upstream = $input.first().json;\nconst { post_text, image_url } = upstream;\nconst V = $env.FB_GRAPH_VERSION || 'v24.0';\nconst PAGE_ID = $env.FB_PAGE_ID;\nconst SYS_TOKEN = $env.FB_SYSTEM_USER_TOKEN;\n\nconst imageSource = upstream.image_source || '';\nconst aiNote = (imageSource.startsWith('comfyui') || imageSource.startsWith('pollinations.ai')) ? '\\n\\n\uff08\u5716\u7247 AI \u751f\u6210\uff09' : '';\nconst fullText = (post_text || '') + aiNote;\n\nlet fb_result = { success: false };\n\ntry {\n  const accounts = await this.helpers.httpRequest({\n    method: 'GET',\n    url: `https://graph.facebook.com/${V}/me/accounts`,\n    qs: { fields: 'id,name,access_token', limit: 100, access_token: SYS_TOKEN },\n    json: true\n  });\n  const page = (accounts.data || []).find(p => String(p.id) === String(PAGE_ID));\n  if (!page) throw new Error(`Page ${PAGE_ID} not in /me/accounts`);\n  const pageToken = page.access_token;\n\n  let post;\n  if (image_url) {\n    post = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `https://graph.facebook.com/${V}/${PAGE_ID}/photos`,\n      body: { url: image_url, caption: fullText, access_token: pageToken },\n      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n      json: true\n    });\n  } else {\n    post = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `https://graph.facebook.com/${V}/${PAGE_ID}/feed`,\n      body: { message: fullText, access_token: pageToken },\n      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n      json: true\n    });\n  }\n\n  const postId = post.post_id || post.id || '';\n  const [pageNum, postPart] = (postId || '').split('_');\n  fb_result = {\n    success: true,\n    post_id: postId,\n    permalink: postPart ? `https://www.facebook.com/${pageNum}/posts/${postPart}` : `https://www.facebook.com/${PAGE_ID}`,\n    type: image_url ? 'photo' : 'text',\n    ai_disclosure: !!aiNote\n  };\n} catch (e) {\n  fb_result = { success: false, error: e.message };\n}\n\nreturn [{ json: { ...upstream, fb_result } }];"
      },
      "id": "7a000000-0000-0000-0000-000000000007",
      "name": "Publish FB Page",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1560,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Publish to Instagram. AI \u751f\u5716\u6642\u81ea\u52d5\u52a0\u6a19\u8a3b.\nconst upstream = $input.first().json;\nconst { post_text, image_url, metadata } = upstream;\nconst V = $env.FB_GRAPH_VERSION || 'v24.0';\nconst IG_ID = $env.IG_BUSINESS_ACCOUNT_ID;\nconst SYS_TOKEN = $env.FB_SYSTEM_USER_TOKEN;\n\nlet ig_result = { success: false, skipped: false };\n\nif (!image_url) {\n  ig_result = { success: false, skipped: true, reason: 'IG requires image, none resolved' };\n} else if (!IG_ID || !SYS_TOKEN) {\n  ig_result = { success: false, skipped: true, reason: 'IG env not configured' };\n} else {\n  try {\n    const imageSource = upstream.image_source || '';\n    const aiNote = (imageSource.startsWith('comfyui') || imageSource.startsWith('pollinations.ai')) ? '\\n\\n\uff08\u5716\u7247 AI \u751f\u6210\uff09' : '';\n    const tags = (metadata && Array.isArray(metadata.tags)) ? metadata.tags.map(t => '#' + String(t).replace(/^#/, '')).join(' ') : '';\n    const caption = (post_text || '') + aiNote + (tags ? '\\n\\n' + tags : '');\n\n    const container = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `https://graph.facebook.com/${V}/${IG_ID}/media`,\n      qs: {\n        image_url: image_url,\n        caption: caption,\n        access_token: SYS_TOKEN\n      },\n      json: true\n    });\n    const creationId = container.id;\n    if (!creationId) throw new Error('No creation_id from /media: ' + JSON.stringify(container).slice(0, 300));\n\n    let status = 'IN_PROGRESS';\n    for (let i = 0; i < 10; i++) {\n      await new Promise(r => setTimeout(r, 3000));\n      const statusResp = await this.helpers.httpRequest({\n        method: 'GET',\n        url: `https://graph.facebook.com/${V}/${creationId}`,\n        qs: { fields: 'status_code', access_token: SYS_TOKEN },\n        json: true\n      });\n      status = statusResp.status_code;\n      if (status === 'FINISHED') break;\n      if (status === 'ERROR') throw new Error('IG container error: ' + JSON.stringify(statusResp));\n    }\n    if (status !== 'FINISHED') throw new Error('IG container not ready after polling: ' + status);\n\n    const publishResp = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `https://graph.facebook.com/${V}/${IG_ID}/media_publish`,\n      qs: { creation_id: creationId, access_token: SYS_TOKEN },\n      json: true\n    });\n\n    ig_result = {\n      success: true,\n      media_id: publishResp.id,\n      permalink: `https://www.instagram.com/p/${publishResp.id}/`,\n      ai_disclosure: !!aiNote\n    };\n  } catch (e) {\n    // Deep dump every property of the error for diagnosis\n    const errProps = {};\n    try {\n      for (const k of Object.getOwnPropertyNames(e)) {\n        try { errProps[k] = JSON.parse(JSON.stringify(e[k])); }\n        catch { errProps[k] = String(e[k]).substring(0, 500); }\n      }\n    } catch {}\n    // Also dig into nested response / cause\n    const meta_error = e.response?.data || e.response?.body || e.body || e.cause?.error || e.cause || null;\n    ig_result = {\n      success: false,\n      error: e.message,\n      error_name: e.name,\n      meta_error: meta_error,\n      all_props: Object.keys(errProps),\n      err_dump: JSON.stringify(errProps).substring(0, 3000)\n    };\n  }\n}\n\nreturn [{ json: { ...upstream, ig_result } }];"
      },
      "id": "8a000000-0000-0000-0000-000000000008",
      "name": "Publish Instagram",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1780,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Publish to Threads: prefer post_text_threads (cloud routine short version), else truncate long version. AI \u751f\u5716\u6642\u52a0\u6a19\u8a3b.\nconst upstream = $input.first().json;\nconst { post_text, image_url, post_text_threads } = upstream;\nconst T_TOKEN = $env.THREADS_LONG_LIVED_TOKEN;\nconst T_USER = $env.THREADS_USER_ID;\n\nlet threads_result = { success: false, skipped: false };\n\nfunction truncateThreads(text, max = 480) {\n  if (!text) return '';\n  if (text.length <= max) return text;\n  let sectionMarkers = [];\n  let idx = 0;\n  while ((idx = text.indexOf('\u258b', idx)) !== -1) { sectionMarkers.push(idx); idx++; }\n  if (sectionMarkers.length === 0) {\n    const re = /\\n\\*\\*[^\\n*]{2,40}\\*\\*\\n/g;\n    let m;\n    while ((m = re.exec(text)) !== null) { sectionMarkers.push(m.index + 1); }\n  }\n  if (sectionMarkers.length >= 4) {\n    const cut = text.slice(0, sectionMarkers[3]).trim();\n    if (cut.length <= max && cut.length > max * 0.5) return cut;\n  }\n  if (sectionMarkers.length >= 3) {\n    const cut = text.slice(0, sectionMarkers[2]).trim();\n    if (cut.length <= max && cut.length > max * 0.5) return cut;\n  }\n  if (sectionMarkers.length >= 2) {\n    const cut = text.slice(0, sectionMarkers[1]).trim();\n    if (cut.length <= max && cut.length > max * 0.4) return cut;\n  }\n  const cut = text.slice(0, max);\n  const boundaries = ['\u3002', '. ', '\uff01', '!', '\uff1f', '?', '\\n\\n', '\\n'];\n  let lastIdx = -1;\n  for (const b of boundaries) {\n    const i = cut.lastIndexOf(b);\n    if (i > lastIdx) lastIdx = i + b.length;\n  }\n  if (lastIdx > max * 0.6) return cut.slice(0, lastIdx).trim();\n  return cut.slice(0, max - 3).trim() + '...';\n}\n\nif (!T_TOKEN || !T_USER) {\n  threads_result = { success: false, skipped: true, reason: 'Threads env not configured' };\n} else {\n  try {\n    const imageSource = upstream.image_source || '';\n    const aiNote = (imageSource.startsWith('comfyui') || imageSource.startsWith('pollinations.ai')) ? '\\n\uff08\u5716\u7247 AI \u751f\u6210\uff09' : '';\n    const aiNoteLen = aiNote.length;\n    const longVersion = post_text || '';\n    const shortVersion = post_text_threads || null;\n    const maxLen = 480 - aiNoteLen;\n\n    let text;\n    let usedShortVersion = false;\n    let wasTruncated = false;\n\n    if (shortVersion && shortVersion.length > 0 && shortVersion.length <= maxLen) {\n      text = shortVersion + aiNote;\n      usedShortVersion = true;\n    } else {\n      const truncated = truncateThreads(longVersion, maxLen);\n      text = truncated + aiNote;\n      wasTruncated = longVersion.length > truncated.length;\n    }\n\n    const mediaType = image_url ? 'IMAGE' : 'TEXT';\n\n    const containerQs = {\n      media_type: mediaType,\n      text: text,\n      access_token: T_TOKEN\n    };\n    if (image_url) containerQs.image_url = image_url;\n\n    const container = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `https://graph.threads.net/v1.0/${T_USER}/threads`,\n      qs: containerQs,\n      json: true\n    });\n    const containerId = container.id;\n    if (!containerId) throw new Error('No creation_id from Threads /threads: ' + JSON.stringify(container).slice(0, 300));\n\n    await new Promise(r => setTimeout(r, image_url ? 5000 : 2000));\n\n    const publishResp = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `https://graph.threads.net/v1.0/${T_USER}/threads_publish`,\n      qs: { creation_id: containerId, access_token: T_TOKEN },\n      json: true\n    });\n\n    threads_result = {\n      success: true,\n      thread_id: publishResp.id,\n      type: mediaType,\n      used_short_version: usedShortVersion,\n      long_chars: longVersion.length,\n      published_chars: text.length,\n      truncated: wasTruncated,\n      ai_disclosure: !!aiNote\n    };\n  } catch (e) {\n    threads_result = { success: false, error: e.message };\n  }\n}\n\nreturn [{ json: { ...upstream, threads_result } }];"
      },
      "id": "9a000000-0000-0000-0000-000000000009",
      "name": "Publish Threads",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2000,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Aggregate final log\nconst u = $input.first().json;\nreturn [{\n  json: {\n    timestamp: new Date().toISOString(),\n    gist_id: u.gist_id,\n    gist_url: u.gist_url,\n    chars: u.post_text_chars,\n    chars_threads_short: u.post_text_threads_chars,\n    image: { source: u.image_source, has_image: u.has_image, error: u.image_error, url: u.image_url, imgur_error: u.imgur_error },\n    fb: u.fb_result,\n    ig: u.ig_result,\n    threads: u.threads_result,\n    summary: [\n      u.fb_result && u.fb_result.success ? 'FB OK' : ('FB FAIL: ' + (u.fb_result && u.fb_result.error || 'unknown')),\n      u.ig_result && u.ig_result.success ? 'IG OK' : (u.ig_result && u.ig_result.skipped ? 'IG SKIPPED' : ('IG FAIL: ' + (u.ig_result && u.ig_result.error || 'unknown'))),\n      u.threads_result && u.threads_result.success ? 'Threads OK' : (u.threads_result && u.threads_result.skipped ? 'Threads SKIPPED' : ('Threads FAIL: ' + (u.threads_result && u.threads_result.error || 'unknown')))\n    ].join(' | ')\n  }\n}];"
      },
      "id": "10000000-0000-0000-0000-00000000000a",
      "name": "Final Log",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2220,
        300
      ]
    }
  ],
  "connections": {
    "Schedule 21:00": {
      "main": [
        [
          {
            "node": "Pick Latest fb-post Gist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Latest fb-post Gist": {
      "main": [
        [
          {
            "node": "Fetch Raw Markdown",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Raw Markdown": {
      "main": [
        [
          {
            "node": "Parse + Extract Draft",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse + Extract Draft": {
      "main": [
        [
          {
            "node": "Resolve Image",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve Image": {
      "main": [
        [
          {
            "node": "Upload to Imgur",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to Imgur": {
      "main": [
        [
          {
            "node": "Publish FB Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publish FB Page": {
      "main": [
        [
          {
            "node": "Publish Instagram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publish Instagram": {
      "main": [
        [
          {
            "node": "Publish Threads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publish Threads": {
      "main": [
        [
          {
            "node": "Final Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate"
  },
  "staticData": {
    "node:Schedule 21:": {
      "recurrenceRules": []
    },
    "node:Schedule 21:00": {
      "recurrenceRules": []
    },
    "global": {
      "processed_drive_ids": []
    }
  },
  "tags": [],
  "versionId": ""
}