Skip to content

Instantly share code, notes, and snippets.

@JohnONolan
Last active December 16, 2025 20:24
Show Gist options
  • Select an option

  • Save JohnONolan/a04222081e5066ae2b55cdc8f4d9d5f0 to your computer and use it in GitHub Desktop.

Select an option

Save JohnONolan/a04222081e5066ae2b55cdc8f4d9d5f0 to your computer and use it in GitHub Desktop.
{
"name": "Ghost Social → Twitter",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "minutes"
}
]
}
},
"id": "0f4a4f43-26a3-4220-a59b-394f603fda04",
"name": "Every 5 Minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-1616,
896
]
},
{
"parameters": {
"url": "https://YOUR_GHOST_URL.COM/.ghost/activitypub/outbox/index",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "57b40c0b-78bc-4a80-ba45-3e903715ed31",
"name": "Fetch Outbox",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-1392,
896
]
},
{
"parameters": {
"url": "={{ $json.first }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "174fbb98-3a35-47eb-8f8e-e9e5dfd72700",
"name": "Fetch First Page",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-1184,
896
]
},
{
"parameters": {
"jsCode": "// Get the ordered items from the outbox page\nconst items = $input.first().json.orderedItems || [];\n\n// Get the static data to track what we've already posted\nconst staticData = $getWorkflowStaticData('global');\nconst postedIds = staticData.postedIds || [];\n\n// Only process posts after this date\nconst cutoffDate = new Date('2025-12-15T00:00:00Z');\n\n// Filter to only Create activities for Note/Article objects\n// that we haven't already posted\nconst newPosts = [];\n\nfor (const item of items) {\n // Skip if not a Create activity\n if (item.type !== 'Create') continue;\n \n const object = item.object;\n if (!object) continue;\n \n // Skip replies - only syndicate top-level posts\n if (object.inReplyTo) continue;\n \n // Skip if we've already posted this\n const postId = object.id || item.id;\n if (postedIds.includes(postId)) continue;\n \n // Get the published date\n const published = object.published || item.published;\n \n // Skip if published before our cutoff date (December 15, 2025)\n if (!published || new Date(published) < cutoffDate) continue;\n \n // Extract the content\n let content = '';\n let url = object.url || object.id;\n \n // Handle different object types\n if (object.type === 'Note' || object.type === 'Article') {\n // Convert line breaks to newlines, then strip HTML tags and decode entities\n content = (object.content || object.name || '')\n .replace(/<br\\s*\\/?>/gi, '\\n')\n .replace(/<\\/p>\\s*<p>/gi, '\\n\\n')\n .replace(/<[^>]*>/g, '')\n .replace(/&amp;/g, '&')\n .replace(/&lt;/g, '<')\n .replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"')\n .replace(/&#39;/g, \"'\")\n .replace(/&nbsp;/g, ' ')\n .trim();\n \n // For Articles, prefer the name/title\n if (object.type === 'Article' && object.name) {\n content = object.name;\n }\n }\n \n if (!content) continue;\n \n // Extract image attachments (up to 4 for Twitter)\n const images = [];\n const attachments = object.attachment || object.image || [];\n const attachmentArray = Array.isArray(attachments) ? attachments : [attachments];\n \n for (const att of attachmentArray) {\n if (!att) continue;\n \n // Check if it's an image\n const mediaType = att.mediaType || att.type || '';\n const attType = att.type || '';\n \n if (mediaType.startsWith('image/') || attType === 'Image') {\n const imageUrl = att.url || att.href;\n if (imageUrl && images.length < 4) {\n images.push(imageUrl);\n }\n }\n }\n \n // Format for Twitter (280 char limit)\n const maxContentLength = 280;\n let tweetText = content;\n \n if (content.length > maxContentLength) {\n tweetText = content.substring(0, maxContentLength - 3) + '...';\n }\n \n newPosts.push({\n json: {\n postId: postId,\n tweetText: tweetText,\n originalContent: content,\n url: url,\n published: published,\n images: images,\n hasImages: images.length > 0,\n imageUrl: images[0] || null\n }\n });\n}\n\n// If no new posts, return empty to stop the workflow\nif (newPosts.length === 0) {\n return [];\n}\n\nreturn newPosts;"
},
"id": "e16d5782-eaae-42e7-997f-45c831f0b724",
"name": "Process New Activities",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-960,
896
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "has-images",
"leftValue": "={{ $json.hasImages }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "61379fdb-a5aa-4517-9cee-4199d8c877df",
"name": "Has Images?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-736,
896
]
},
{
"parameters": {
"url": "={{ $json.imageUrl }}",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
}
},
"id": "abbf4d38-1a87-4520-9e30-dffff8171cd8",
"name": "Download Image",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-512,
736
]
},
{
"parameters": {
"method": "POST",
"url": "https://upload.twitter.com/1.1/media/upload.json",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "twitterOAuth1Api",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "media",
"inputDataFieldName": "data"
}
]
},
"options": {}
},
"id": "bc3f3ccd-8c0a-4cde-9d5b-b777dd1f14d1",
"name": "Upload Image to Twitter",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-304,
736
],
"credentials": {
"twitterOAuth1Api": {
"id": "YOUR_CREDENTIAL_ID",
"name": "Twitter v1 API"
}
}
},
{
"parameters": {
"text": "={{ $json.tweetText }}",
"additionalFields": {}
},
"id": "126b3462-9ff0-4e3b-81d0-20b8e41cec84",
"name": "Post Tweet (No Media)",
"type": "n8n-nodes-base.twitter",
"typeVersion": 2,
"position": [
-512,
1040
],
"credentials": {
"twitterOAuth2Api": {
"id": "YOUR_CREDENTIAL_ID",
"name": "Twitter v2 API"
}
}
},
{
"parameters": {
"jsCode": "// Mark this post as published so we don't repost it\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.postedIds) {\n staticData.postedIds = [];\n}\n\n// Get postId from whichever branch we came from\nlet postId;\ntry {\n postId = $('Process New Activities').item.json.postId;\n} catch (e) {\n postId = $input.first().json.postId;\n}\n\nstaticData.postedIds.push(postId);\n\n// Keep only the last 1000 IDs to prevent unbounded growth\nif (staticData.postedIds.length > 1000) {\n staticData.postedIds = staticData.postedIds.slice(-1000);\n}\n\nreturn $input.all();"
},
"id": "2367774a-39a4-4590-8259-aea48cd2ebb5",
"name": "Mark as Posted",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
144,
896
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.twitter.com/2/tweets",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "twitterOAuth1Api",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "text",
"value": "={{ $('Process New Activities').item.json.tweetText }}"
},
{
"name": "media",
"value": "={{ { media_ids: [$json.media_id_string] } }}"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-96,
736
],
"id": "3613ca84-cf32-4c97-8fa6-a844c572505a",
"name": "Post Tweet",
"credentials": {
"twitterOAuth1Api": {
"id": "TWITTER_CREDS",
"name": "Twitter v1 API"
}
}
}
],
"pinData": {},
"connections": {
"Every 5 Minutes": {
"main": [
[
{
"node": "Fetch Outbox",
"type": "main",
"index": 0
}
]
]
},
"Fetch Outbox": {
"main": [
[
{
"node": "Fetch First Page",
"type": "main",
"index": 0
}
]
]
},
"Fetch First Page": {
"main": [
[
{
"node": "Process New Activities",
"type": "main",
"index": 0
}
]
]
},
"Process New Activities": {
"main": [
[
{
"node": "Has Images?",
"type": "main",
"index": 0
}
]
]
},
"Has Images?": {
"main": [
[
{
"node": "Download Image",
"type": "main",
"index": 0
}
],
[
{
"node": "Post Tweet (No Media)",
"type": "main",
"index": 0
}
]
]
},
"Download Image": {
"main": [
[
{
"node": "Upload Image to Twitter",
"type": "main",
"index": 0
}
]
]
},
"Upload Image to Twitter": {
"main": [
[
{
"node": "Post Tweet",
"type": "main",
"index": 0
}
]
]
},
"Post Tweet (No Media)": {
"main": [
[
{
"node": "Mark as Posted",
"type": "main",
"index": 0
}
]
]
},
"Mark as Posted": {
"main": [
[]
]
},
"Post Tweet": {
"main": [
[
{
"node": "Mark as Posted",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner",
"executionTimeout": -1,
"availableInMCP": true
},
"versionId": "62759a1b-0bd6-4c1e-9878-a1eb931ef908",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": ""
},
"id": "",
"tags": []
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment