suparank 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Suparank MCP - Main Entry Point
5
+ *
6
+ * Modular MCP server for AI-powered SEO content creation
7
+ *
8
+ * Usage:
9
+ * npx suparank
10
+ * node mcp-client/index.js <project-slug> <api-key>
11
+ *
12
+ * Credentials:
13
+ * Local credentials are loaded from ~/.suparank/credentials.json
14
+ * These enable additional tools: image generation, CMS publishing, webhooks
15
+ */
16
+
17
+ import { main } from './server.js'
18
+ import { log } from './utils/logging.js'
19
+
20
+ // Re-export modules for external use
21
+ export * from './config.js'
22
+ export * from './utils/index.js'
23
+ export * from './services/index.js'
24
+ export * from './tools/index.js'
25
+ export * from './handlers/index.js'
26
+ export * from './publishers/index.js'
27
+ export * from './workflow/index.js'
28
+
29
+ // Run server
30
+ main().catch((error) => {
31
+ log('Fatal error:', error)
32
+ process.exit(1)
33
+ })
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Suparank MCP - Ghost Publisher
3
+ *
4
+ * Publish content to Ghost CMS using Admin API
5
+ */
6
+
7
+ import { log, progress } from '../utils/logging.js'
8
+ import { fetchWithRetry } from '../services/api.js'
9
+ import { getCredentials } from '../services/credentials.js'
10
+ import { markdownToHtml } from '../utils/formatting.js'
11
+
12
+ /**
13
+ * Create JWT for Ghost Admin API
14
+ * @param {string} id - API key ID
15
+ * @param {string} secret - API key secret
16
+ * @returns {Promise<string>} JWT token
17
+ */
18
+ async function createGhostJWT(id, secret) {
19
+ // Simple JWT creation for Ghost
20
+ const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: id })).toString('base64url')
21
+ const now = Math.floor(Date.now() / 1000)
22
+ const payload = Buffer.from(JSON.stringify({
23
+ iat: now,
24
+ exp: now + 300, // 5 minutes
25
+ aud: '/admin/'
26
+ })).toString('base64url')
27
+
28
+ // Create signature using crypto
29
+ const crypto = await import('crypto')
30
+ const key = Buffer.from(secret, 'hex')
31
+ const signature = crypto.createHmac('sha256', key)
32
+ .update(`${header}.${payload}`)
33
+ .digest('base64url')
34
+
35
+ return `${header}.${payload}.${signature}`
36
+ }
37
+
38
+ /**
39
+ * Publish content to Ghost CMS
40
+ * @param {object} args - Publish arguments
41
+ * @param {string} args.title - Post title
42
+ * @param {string} args.content - Post content (markdown)
43
+ * @param {string} [args.status='draft'] - Publication status
44
+ * @param {string[]} [args.tags=[]] - Tag names
45
+ * @param {string} [args.featured_image_url] - Featured image URL
46
+ * @returns {Promise<object>} MCP response
47
+ */
48
+ export async function executeGhostPublish(args) {
49
+ const credentials = getCredentials()
50
+ const { api_url, admin_api_key } = credentials.ghost
51
+ const { title, content, status = 'draft', tags = [], featured_image_url } = args
52
+
53
+ progress('Publish', `Publishing to Ghost: "${title}"`)
54
+ log(`Publishing to Ghost: ${title}`)
55
+
56
+ // Create JWT for Ghost Admin API
57
+ const [id, secret] = admin_api_key.split(':')
58
+ const token = await createGhostJWT(id, secret)
59
+
60
+ // Convert markdown to HTML for proper element separation
61
+ const htmlContent = markdownToHtml(content)
62
+
63
+ // Use HTML card for proper rendering (each element separate)
64
+ const mobiledoc = JSON.stringify({
65
+ version: '0.3.1',
66
+ atoms: [],
67
+ cards: [['html', { html: htmlContent }]],
68
+ markups: [],
69
+ sections: [[10, 0]]
70
+ })
71
+
72
+ const postData = {
73
+ posts: [{
74
+ title,
75
+ mobiledoc,
76
+ status,
77
+ tags: tags.map(name => ({ name })),
78
+ feature_image: featured_image_url
79
+ }]
80
+ }
81
+
82
+ const response = await fetchWithRetry(`${api_url}/ghost/api/admin/posts/`, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Authorization': `Ghost ${token}`,
86
+ 'Content-Type': 'application/json'
87
+ },
88
+ body: JSON.stringify(postData)
89
+ }, 2, 30000) // 2 retries, 30s timeout
90
+
91
+ if (!response.ok) {
92
+ const error = await response.text()
93
+ throw new Error(`Ghost error: ${error}`)
94
+ }
95
+
96
+ const result = await response.json()
97
+ const post = result.posts[0]
98
+
99
+ return {
100
+ content: [{
101
+ type: 'text',
102
+ text: `Post published to Ghost!\n\n**Title:** ${post.title}\n**Status:** ${post.status}\n**URL:** ${post.url}\n**ID:** ${post.id}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from Ghost dashboard.' : 'The post is now live!'}`
103
+ }]
104
+ }
105
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Suparank MCP - Image Publisher
3
+ *
4
+ * AI image generation using fal.ai, Gemini, or Wiro
5
+ */
6
+
7
+ import { log, progress } from '../utils/logging.js'
8
+ import { fetchWithRetry } from '../services/api.js'
9
+ import { getCredentials } from '../services/credentials.js'
10
+ import { sessionState, saveSession } from '../services/session-state.js'
11
+ import { incrementStat } from '../services/stats.js'
12
+ import { API_ENDPOINTS } from '../config.js'
13
+
14
+ /**
15
+ * Generate an AI image using the configured provider
16
+ * @param {object} args - Image generation arguments
17
+ * @param {string} args.prompt - Image prompt
18
+ * @param {string} [args.style] - Style guidance
19
+ * @param {string} [args.aspect_ratio='16:9'] - Aspect ratio
20
+ * @returns {Promise<object>} MCP response with image URL
21
+ */
22
+ export async function executeImageGeneration(args) {
23
+ const credentials = getCredentials()
24
+ const provider = credentials.image_provider
25
+ const config = credentials[provider]
26
+
27
+ if (!config?.api_key) {
28
+ throw new Error(`${provider} API key not configured`)
29
+ }
30
+
31
+ progress('Image', `Generating with ${provider}...`)
32
+
33
+ const { prompt, style, aspect_ratio = '16:9' } = args
34
+ const fullPrompt = style ? `${prompt}, ${style}` : prompt
35
+
36
+ log(`Generating image with ${provider}: ${fullPrompt.substring(0, 50)}...`)
37
+
38
+ switch (provider) {
39
+ case 'fal':
40
+ return generateWithFal(config, fullPrompt, aspect_ratio)
41
+
42
+ case 'gemini':
43
+ return generateWithGemini(config, fullPrompt, aspect_ratio)
44
+
45
+ case 'wiro':
46
+ return generateWithWiro(config, fullPrompt, aspect_ratio)
47
+
48
+ default:
49
+ throw new Error(`Unknown image provider: ${provider}`)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Generate image with fal.ai
55
+ */
56
+ async function generateWithFal(config, fullPrompt, aspect_ratio) {
57
+ const response = await fetchWithRetry(API_ENDPOINTS.fal, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'Authorization': `Key ${config.api_key}`,
61
+ 'Content-Type': 'application/json'
62
+ },
63
+ body: JSON.stringify({
64
+ prompt: fullPrompt,
65
+ aspect_ratio: aspect_ratio,
66
+ output_format: 'png',
67
+ resolution: '1K',
68
+ num_images: 1
69
+ })
70
+ }, 2, 60000) // 2 retries, 60s timeout for image generation
71
+
72
+ if (!response.ok) {
73
+ const error = await response.text()
74
+ throw new Error(`fal.ai error: ${error}`)
75
+ }
76
+
77
+ const result = await response.json()
78
+ const imageUrl = result.images?.[0]?.url
79
+
80
+ // Store in session for orchestrated workflows
81
+ storeImageInSession(imageUrl)
82
+
83
+ // Track stats
84
+ incrementStat('images_generated')
85
+
86
+ const imageNumber = 1 + sessionState.inlineImages.length
87
+ const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
88
+ const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
89
+
90
+ return {
91
+ content: [{
92
+ type: 'text',
93
+ text: `# ✅ ${imageType} Generated (${imageNumber}/${totalImages})
94
+
95
+ **URL:** ${imageUrl}
96
+
97
+ **Prompt:** ${fullPrompt}
98
+ **Provider:** fal.ai (nano-banana-pro)
99
+ **Aspect Ratio:** ${aspect_ratio}
100
+
101
+ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber} more image(s).` : '\n**All images generated!** Proceed to publish.'}`
102
+ }]
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Generate image with Google Gemini
108
+ */
109
+ async function generateWithGemini(config, fullPrompt, aspect_ratio) {
110
+ const model = config.model || 'gemini-3-pro-image-preview'
111
+ const response = await fetch(
112
+ `${API_ENDPOINTS.gemini}/${model}:generateContent`,
113
+ {
114
+ method: 'POST',
115
+ headers: {
116
+ 'Content-Type': 'application/json',
117
+ 'x-goog-api-key': config.api_key
118
+ },
119
+ body: JSON.stringify({
120
+ contents: [{
121
+ parts: [{ text: fullPrompt }]
122
+ }],
123
+ generationConfig: {
124
+ responseModalities: ['IMAGE'],
125
+ imageConfig: {
126
+ aspectRatio: aspect_ratio,
127
+ imageSize: '1K'
128
+ }
129
+ }
130
+ })
131
+ }
132
+ )
133
+
134
+ if (!response.ok) {
135
+ const error = await response.text()
136
+ throw new Error(`Gemini error: ${error}`)
137
+ }
138
+
139
+ const result = await response.json()
140
+ const imagePart = result.candidates?.[0]?.content?.parts?.find(p => p.inlineData)
141
+ const imageData = imagePart?.inlineData?.data
142
+ const mimeType = imagePart?.inlineData?.mimeType || 'image/png'
143
+
144
+ if (!imageData) {
145
+ throw new Error('No image data in Gemini response')
146
+ }
147
+
148
+ // Return base64 data URI
149
+ const dataUri = `data:${mimeType};base64,${imageData}`
150
+
151
+ // Track stats
152
+ incrementStat('images_generated')
153
+
154
+ return {
155
+ content: [{
156
+ type: 'text',
157
+ text: `Image generated successfully!\n\n**Format:** Base64 Data URI\n**Prompt:** ${fullPrompt}\n**Provider:** Google Gemini (${model})\n**Aspect Ratio:** ${aspect_ratio}\n\n**Data URI:** ${dataUri.substring(0, 100)}...\n\n[Full base64 data: ${imageData.length} chars]`
158
+ }]
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Generate image with Wiro
164
+ */
165
+ async function generateWithWiro(config, fullPrompt, aspect_ratio) {
166
+ const crypto = await import('crypto')
167
+ const apiKey = config.api_key
168
+ const apiSecret = config.api_secret
169
+
170
+ if (!apiSecret) {
171
+ throw new Error('Wiro API secret not configured. Add api_secret to wiro config in ~/.suparank/credentials.json')
172
+ }
173
+
174
+ // Generate nonce and signature
175
+ const nonce = Math.floor(Date.now() / 1000).toString()
176
+ const signatureData = `${apiSecret}${nonce}`
177
+ const signature = crypto.createHmac('sha256', apiKey)
178
+ .update(signatureData)
179
+ .digest('hex')
180
+
181
+ const model = config.model || 'google/nano-banana-pro'
182
+
183
+ // Submit task
184
+ log(`Submitting wiro.ai task for model: ${model}`)
185
+ const submitResponse = await fetch(`${API_ENDPOINTS.wiro}/Run/${model}`, {
186
+ method: 'POST',
187
+ headers: {
188
+ 'Content-Type': 'application/json',
189
+ 'x-api-key': apiKey,
190
+ 'x-nonce': nonce,
191
+ 'x-signature': signature
192
+ },
193
+ body: JSON.stringify({
194
+ prompt: fullPrompt,
195
+ aspectRatio: aspect_ratio,
196
+ resolution: '1K',
197
+ safetySetting: 'BLOCK_ONLY_HIGH'
198
+ })
199
+ })
200
+
201
+ if (!submitResponse.ok) {
202
+ const error = await submitResponse.text()
203
+ throw new Error(`wiro.ai submit error: ${error}`)
204
+ }
205
+
206
+ const submitResult = await submitResponse.json()
207
+ if (!submitResult.result || !submitResult.taskid) {
208
+ throw new Error(`wiro.ai task submission failed: ${JSON.stringify(submitResult.errors)}`)
209
+ }
210
+
211
+ const taskId = submitResult.taskid
212
+ log(`wiro.ai task submitted: ${taskId}`)
213
+
214
+ // Poll for completion
215
+ const maxAttempts = 60 // 60 seconds max
216
+ const pollInterval = 2000 // 2 seconds
217
+
218
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
219
+ await new Promise(resolve => setTimeout(resolve, pollInterval))
220
+
221
+ // Generate new signature for poll request
222
+ const pollNonce = Math.floor(Date.now() / 1000).toString()
223
+ const pollSignatureData = `${apiSecret}${pollNonce}`
224
+ const pollSignature = crypto.createHmac('sha256', apiKey)
225
+ .update(pollSignatureData)
226
+ .digest('hex')
227
+
228
+ const pollResponse = await fetch(API_ENDPOINTS.wiroTaskDetail, {
229
+ method: 'POST',
230
+ headers: {
231
+ 'Content-Type': 'application/json',
232
+ 'x-api-key': apiKey,
233
+ 'x-nonce': pollNonce,
234
+ 'x-signature': pollSignature
235
+ },
236
+ body: JSON.stringify({ taskid: taskId })
237
+ })
238
+
239
+ if (!pollResponse.ok) {
240
+ log(`wiro.ai poll error: ${await pollResponse.text()}`)
241
+ continue
242
+ }
243
+
244
+ const pollResult = await pollResponse.json()
245
+ const task = pollResult.tasklist?.[0]
246
+
247
+ if (!task) continue
248
+
249
+ const status = task.status
250
+ log(`wiro.ai task status: ${status}`)
251
+
252
+ // Check for completion
253
+ if (status === 'task_postprocess_end') {
254
+ const imageUrl = task.outputs?.[0]?.url
255
+ if (!imageUrl) {
256
+ throw new Error('wiro.ai task completed but no output URL')
257
+ }
258
+
259
+ // Store in session for orchestrated workflows
260
+ storeImageInSession(imageUrl)
261
+
262
+ // Track stats
263
+ incrementStat('images_generated')
264
+
265
+ const imageNumber = 1 + sessionState.inlineImages.length
266
+ const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
267
+ const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
268
+
269
+ return {
270
+ content: [{
271
+ type: 'text',
272
+ text: `# ✅ ${imageType} Generated (${imageNumber}/${totalImages})
273
+
274
+ **URL:** ${imageUrl}
275
+
276
+ **Prompt:** ${fullPrompt}
277
+ **Provider:** wiro.ai (${model})
278
+ **Aspect Ratio:** ${aspect_ratio}
279
+ **Processing Time:** ${task.elapsedseconds}s
280
+
281
+ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber} more image(s).` : '\n**All images generated!** Proceed to publish.'}`
282
+ }]
283
+ }
284
+ }
285
+
286
+ // Check for failure
287
+ if (status === 'task_cancel') {
288
+ throw new Error('wiro.ai task was cancelled')
289
+ }
290
+ }
291
+
292
+ throw new Error('wiro.ai task timed out after 60 seconds')
293
+ }
294
+
295
+ /**
296
+ * Store generated image URL in session
297
+ * First image is cover, subsequent are inline
298
+ */
299
+ function storeImageInSession(imageUrl) {
300
+ if (!sessionState.imageUrl) {
301
+ sessionState.imageUrl = imageUrl
302
+ } else {
303
+ sessionState.inlineImages.push(imageUrl)
304
+ }
305
+ saveSession()
306
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Suparank MCP - Publishers Module
3
+ *
4
+ * Re-exports all publisher functions
5
+ */
6
+
7
+ // Image generation
8
+ export { executeImageGeneration } from './image.js'
9
+
10
+ // WordPress publishing
11
+ export {
12
+ executeWordPressPublish,
13
+ fetchWordPressCategories
14
+ } from './wordpress.js'
15
+
16
+ // Ghost publishing
17
+ export { executeGhostPublish } from './ghost.js'
18
+
19
+ // Webhook sending
20
+ export { executeSendWebhook } from './webhook.js'
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Suparank MCP - Webhook Publisher
3
+ *
4
+ * Send data to configured webhooks (Make.com, n8n, Zapier, Slack)
5
+ */
6
+
7
+ import { log } from '../utils/logging.js'
8
+ import { getCredentials } from '../services/credentials.js'
9
+ import { projectSlug } from '../config.js'
10
+
11
+ /**
12
+ * Send data to a webhook
13
+ * @param {object} args - Webhook arguments
14
+ * @param {string} [args.webhook_type='default'] - Webhook type
15
+ * @param {object} [args.payload={}] - Data payload
16
+ * @param {string} [args.message] - Message text (for Slack)
17
+ * @returns {Promise<object>} MCP response
18
+ */
19
+ export async function executeSendWebhook(args) {
20
+ const credentials = getCredentials()
21
+ const { webhook_type = 'default', payload = {}, message } = args
22
+ const webhooks = credentials.webhooks
23
+
24
+ // Get webhook URL
25
+ const urlMap = {
26
+ default: webhooks.default_url,
27
+ make: webhooks.make_url,
28
+ n8n: webhooks.n8n_url,
29
+ zapier: webhooks.zapier_url,
30
+ slack: webhooks.slack_url
31
+ }
32
+
33
+ const url = urlMap[webhook_type]
34
+ if (!url) {
35
+ throw new Error(`No ${webhook_type} webhook URL configured`)
36
+ }
37
+
38
+ log(`Sending webhook to ${webhook_type}: ${url}`)
39
+
40
+ // Format payload based on type
41
+ let body
42
+ const headers = { 'Content-Type': 'application/json' }
43
+
44
+ if (webhook_type === 'slack') {
45
+ body = JSON.stringify({
46
+ text: message || 'Message from Writer MCP',
47
+ ...payload
48
+ })
49
+ } else {
50
+ body = JSON.stringify({
51
+ source: 'suparank',
52
+ timestamp: new Date().toISOString(),
53
+ project: projectSlug,
54
+ data: payload,
55
+ message
56
+ })
57
+ }
58
+
59
+ const response = await fetch(url, {
60
+ method: 'POST',
61
+ headers,
62
+ body
63
+ })
64
+
65
+ if (!response.ok) {
66
+ const error = await response.text()
67
+ throw new Error(`Webhook error (${response.status}): ${error}`)
68
+ }
69
+
70
+ return {
71
+ content: [{
72
+ type: 'text',
73
+ text: `Webhook sent successfully!\n\n**Type:** ${webhook_type}\n**URL:** ${url.substring(0, 50)}...\n**Status:** ${response.status}\n\nThe data has been sent to your ${webhook_type} webhook.`
74
+ }]
75
+ }
76
+ }