diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 5c2de45..a7ca8fc 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -114,6 +114,15 @@ export { seoAuditNode, SeoAuditInputSchema, SeoAuditOutputSchema, + dataforseoGetBacklinksNode, + DataforseoGetBacklinksInputSchema, + DataforseoGetBacklinksOutputSchema, + dataforseoPeopleAlsoAskNode, + DataforseoPeopleAlsoAskInputSchema, + DataforseoPeopleAlsoAskOutputSchema, + dataforseoSerpNode, + DataforseoSerpInputSchema, + DataforseoSerpOutputSchema, // Apollo searchContactsNode, SearchContactsInputSchema, @@ -202,6 +211,12 @@ export type { SeoAuditInput, SeoAuditOutput, SeoIssue, + DataforseoGetBacklinksInput, + DataforseoGetBacklinksOutput, + DataforseoPeopleAlsoAskInput, + DataforseoPeopleAlsoAskOutput, + DataforseoSerpInput, + DataforseoSerpOutput, SearchContactsInput, SearchContactsOutput, DiscordSendMessageInput, @@ -284,6 +299,9 @@ import { soraVideoNode, seoKeywordResearchNode, seoAuditNode, + dataforseoGetBacklinksNode, + dataforseoPeopleAlsoAskNode, + dataforseoSerpNode, searchContactsNode, discordSendMessageNode, discordSendWebhookNode, @@ -334,6 +352,9 @@ export const builtInNodes = [ soraVideoNode, seoKeywordResearchNode, seoAuditNode, + dataforseoGetBacklinksNode, + dataforseoPeopleAlsoAskNode, + dataforseoSerpNode, searchContactsNode, discordSendMessageNode, discordSendWebhookNode, diff --git a/packages/nodes/src/integrations/dataforseo/__tests__/dataforseo-extended.test.ts b/packages/nodes/src/integrations/dataforseo/__tests__/dataforseo-extended.test.ts new file mode 100644 index 0000000..f16b648 --- /dev/null +++ b/packages/nodes/src/integrations/dataforseo/__tests__/dataforseo-extended.test.ts @@ -0,0 +1,327 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { dataforseoGetBacklinksNode, DataforseoGetBacklinksInputSchema } from '../backlinks.js' +import { dataforseoPeopleAlsoAskNode, DataforseoPeopleAlsoAskInputSchema } from '../people-also-ask.js' +import { dataforseoSerpNode, DataforseoSerpInputSchema } from '../serp.js' + +const mockFetch = vi.fn() +vi.mock('../../../utils/http.js', () => ({ + fetchWithRetry: (...args: unknown[]) => mockFetch(...args), +})) + +function makeContext(apiToken = 'dGVzdDp0ZXN0') { + return { + userId: 'test-user', + workflowExecutionId: 'test-run', + variables: {}, + resolveNestedPath: () => undefined, + credentials: { dataForSeo: { apiToken } }, + } +} + +function mockApiSuccess(items: unknown[], extra: Record = {}) { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status_code: 20000, + status_message: 'Ok', + tasks: [{ result: [{ items, ...extra }] }], + }), + }) +} + +function mockApiError(status = 400, text = 'Bad Request') { + mockFetch.mockResolvedValueOnce({ + ok: false, + status, + text: async () => text, + }) +} + +beforeEach(() => { + mockFetch.mockReset() +}) + +// ============================================================================= +// dataforseoGetBacklinksNode +// ============================================================================= + +describe('dataforseoGetBacklinksNode', () => { + it('should have type dataforseo_get_backlinks', () => { + expect(dataforseoGetBacklinksNode.type).toBe('dataforseo_get_backlinks') + }) + + it('should have category integration', () => { + expect(dataforseoGetBacklinksNode.category).toBe('integration') + }) + + it('input schema: should accept valid input', () => { + const result = DataforseoGetBacklinksInputSchema.safeParse({ target: 'example.com', limit: 50 }) + expect(result.success).toBe(true) + }) + + it('input schema: should reject empty target', () => { + const result = DataforseoGetBacklinksInputSchema.safeParse({ target: '' }) + expect(result.success).toBe(false) + }) + + it('input schema: should reject limit above 1000', () => { + const result = DataforseoGetBacklinksInputSchema.safeParse({ target: 'example.com', limit: 1001 }) + expect(result.success).toBe(false) + }) + + it('executor: should return success false when apiToken is missing', async () => { + const ctx = { + userId: 'test-user', + workflowExecutionId: 'test-run', + variables: {}, + resolveNestedPath: () => undefined, + credentials: {}, + } + const result = await dataforseoGetBacklinksNode.executor( + { target: 'example.com', limit: 100 }, + ctx as never + ) + expect(result.success).toBe(false) + expect((result as { success: false; error: string }).error).toContain('API token not configured') + }) + + it('executor: should return backlinks on success', async () => { + mockApiSuccess([ + { url_from: 'https://a.com/page', url_to: 'https://example.com', domain_from: 'a.com', dofollow: true, anchor: 'click here' }, + { url_from: 'https://b.com/post', url_to: 'https://example.com/about', domain_from: 'b.com', dofollow: false, anchor: null }, + ]) + const result = await dataforseoGetBacklinksNode.executor( + { target: 'example.com', limit: 100 }, + makeContext() as never + ) + expect(result.success).toBe(true) + const output = (result as { success: true; output: { backlinks: unknown[]; target: string } }).output + expect(output.backlinks.length).toBe(2) + expect(output.target).toBe('example.com') + }) + + it('executor: should return success false on API HTTP error', async () => { + mockApiError(429, 'Rate limit exceeded') + const result = await dataforseoGetBacklinksNode.executor( + { target: 'example.com', limit: 100 }, + makeContext() as never + ) + expect(result.success).toBe(false) + expect((result as { success: false; error: string }).error).toContain('429') + }) + + it('executor: should return success false when DataForSEO status_code is not 20000', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ status_code: 40400, status_message: 'Task not found' }), + }) + const result = await dataforseoGetBacklinksNode.executor( + { target: 'example.com', limit: 100 }, + makeContext() as never + ) + expect(result.success).toBe(false) + expect((result as { success: false; error: string }).error).toContain('Task not found') + }) + + it('executor: should include totalCount from API response', async () => { + mockApiSuccess( + [{ url_from: 'https://a.com', url_to: 'https://example.com', domain_from: 'a.com', dofollow: true, anchor: null }], + { total_count: 500 } + ) + const result = await dataforseoGetBacklinksNode.executor( + { target: 'example.com', limit: 100 }, + makeContext() as never + ) + expect(result.success).toBe(true) + const output = (result as { success: true; output: { totalCount: number } }).output + expect(output.totalCount).toBe(500) + }) +}) + +// ============================================================================= +// dataforseoPeopleAlsoAskNode +// ============================================================================= + +describe('dataforseoPeopleAlsoAskNode', () => { + it('should have type dataforseo_people_also_ask', () => { + expect(dataforseoPeopleAlsoAskNode.type).toBe('dataforseo_people_also_ask') + }) + + it('input schema: should accept valid minimal input', () => { + const result = DataforseoPeopleAlsoAskInputSchema.safeParse({ keyword: 'typescript tutorial' }) + expect(result.success).toBe(true) + }) + + it('input schema: should reject empty keyword', () => { + const result = DataforseoPeopleAlsoAskInputSchema.safeParse({ keyword: '' }) + expect(result.success).toBe(false) + }) + + it('executor: should return success false when apiToken is missing', async () => { + const ctx = { + userId: 'test-user', + workflowExecutionId: 'test-run', + variables: {}, + resolveNestedPath: () => undefined, + credentials: {}, + } + const result = await dataforseoPeopleAlsoAskNode.executor( + { keyword: 'typescript tutorial', location: 'United States', language: 'en' }, + ctx as never + ) + expect(result.success).toBe(false) + expect((result as { success: false; error: string }).error).toContain('API token not configured') + }) + + it('executor: should return questions on success', async () => { + const paaItems = [ + { + type: 'people_also_ask', + items: [ + { type: 'people_also_ask_element', title: 'What is TypeScript?', featured_snippet: { description: 'A typed superset', url: 'https://typescriptlang.org' } }, + { type: 'people_also_ask_element', title: 'How to install TypeScript?', featured_snippet: { description: 'npm install -g typescript', url: 'https://typescriptlang.org/download' } }, + ], + }, + { + type: 'people_also_ask', + items: [ + { type: 'people_also_ask_element', title: 'Is TypeScript free?', featured_snippet: { description: 'Yes, it is open source', url: 'https://github.com/microsoft/TypeScript' } }, + { type: 'people_also_ask_element', title: 'What is tsc?', featured_snippet: { description: 'TypeScript compiler', url: 'https://typescriptlang.org/docs' } }, + ], + }, + ] + mockApiSuccess(paaItems) + const result = await dataforseoPeopleAlsoAskNode.executor( + { keyword: 'typescript tutorial', location: 'United States', language: 'en' }, + makeContext() as never + ) + expect(result.success).toBe(true) + const output = (result as { success: true; output: { questions: unknown[]; keyword: string } }).output + expect(output.questions.length).toBe(4) + expect(output.keyword).toBe('typescript tutorial') + }) + + it('executor: should return success false on API error', async () => { + mockApiError(500, 'Internal Server Error') + const result = await dataforseoPeopleAlsoAskNode.executor( + { keyword: 'typescript tutorial', location: 'United States', language: 'en' }, + makeContext() as never + ) + expect(result.success).toBe(false) + }) + + it('executor: should handle empty PAA results gracefully', async () => { + mockApiSuccess([]) + const result = await dataforseoPeopleAlsoAskNode.executor( + { keyword: 'typescript tutorial', location: 'United States', language: 'en' }, + makeContext() as never + ) + expect(result.success).toBe(true) + const output = (result as { success: true; output: { questions: unknown[]; totalQuestions: number } }).output + expect(output.questions).toEqual([]) + expect(output.totalQuestions).toBe(0) + }) +}) + +// ============================================================================= +// dataforseoSerpNode +// ============================================================================= + +describe('dataforseoSerpNode', () => { + it('should have type dataforseo_serp', () => { + expect(dataforseoSerpNode.type).toBe('dataforseo_serp') + }) + + it('input schema: should accept valid minimal input', () => { + const result = DataforseoSerpInputSchema.safeParse({ keyword: 'best SEO tools' }) + expect(result.success).toBe(true) + }) + + it('input schema: should accept device desktop and mobile', () => { + const desktop = DataforseoSerpInputSchema.safeParse({ keyword: 'seo', device: 'desktop' }) + const mobile = DataforseoSerpInputSchema.safeParse({ keyword: 'seo', device: 'mobile' }) + expect(desktop.success).toBe(true) + expect(mobile.success).toBe(true) + }) + + it('input schema: should reject depth below 10', () => { + const result = DataforseoSerpInputSchema.safeParse({ keyword: 'seo', depth: 5 }) + expect(result.success).toBe(false) + }) + + it('input schema: should reject depth above 100', () => { + const result = DataforseoSerpInputSchema.safeParse({ keyword: 'seo', depth: 101 }) + expect(result.success).toBe(false) + }) + + it('executor: should return success false when apiToken is missing', async () => { + const ctx = { + userId: 'test-user', + workflowExecutionId: 'test-run', + variables: {}, + resolveNestedPath: () => undefined, + credentials: {}, + } + const result = await dataforseoSerpNode.executor( + { keyword: 'best SEO tools', location: 'United States', device: 'desktop', depth: 10 }, + ctx as never + ) + expect(result.success).toBe(false) + expect((result as { success: false; error: string }).error).toContain('API token not configured') + }) + + it('executor: should return SERP results on success', async () => { + mockApiSuccess([ + { type: 'organic', rank_group: 1, rank_absolute: 1, title: 'Result 1', url: 'https://site1.com', domain: 'site1.com' }, + { type: 'organic', rank_group: 2, rank_absolute: 2, title: 'Result 2', url: 'https://site2.com', domain: 'site2.com' }, + { type: 'organic', rank_group: 3, rank_absolute: 3, title: 'Result 3', url: 'https://site3.com', domain: 'site3.com' }, + ]) + const result = await dataforseoSerpNode.executor( + { keyword: 'best SEO tools', location: 'United States', device: 'desktop', depth: 10 }, + makeContext() as never + ) + expect(result.success).toBe(true) + const output = (result as { success: true; output: { results: Array<{ position: number; title: string; url: string }> } }).output + expect(output.results.length).toBe(3) + for (const r of output.results) { + expect(r).toHaveProperty('position') + expect(r).toHaveProperty('title') + expect(r).toHaveProperty('url') + } + }) + + it('executor: should filter out non-organic items', async () => { + mockApiSuccess([ + { type: 'organic', rank_group: 1, rank_absolute: 1, title: 'Organic Result', url: 'https://organic.com', domain: 'organic.com' }, + { type: 'people_also_ask', rank_group: 2, rank_absolute: 2, title: 'PAA Box', url: 'https://other.com' }, + { type: 'featured_snippet', rank_group: 0, rank_absolute: 0, title: 'Featured', url: 'https://featured.com' }, + ]) + const result = await dataforseoSerpNode.executor( + { keyword: 'best SEO tools', location: 'United States', device: 'desktop', depth: 10 }, + makeContext() as never + ) + expect(result.success).toBe(true) + const output = (result as { success: true; output: { results: unknown[] } }).output + expect(output.results.length).toBe(1) + }) + + it('executor: should return correct device in output', async () => { + mockApiSuccess([]) + const result = await dataforseoSerpNode.executor( + { keyword: 'best SEO tools', location: 'United States', device: 'mobile', depth: 10 }, + makeContext() as never + ) + expect(result.success).toBe(true) + const output = (result as { success: true; output: { device: string } }).output + expect(output.device).toBe('mobile') + }) + + it('executor: should return success false on API error', async () => { + mockApiError(503, 'Service Unavailable') + const result = await dataforseoSerpNode.executor( + { keyword: 'best SEO tools', location: 'United States', device: 'desktop', depth: 10 }, + makeContext() as never + ) + expect(result.success).toBe(false) + }) +}) diff --git a/packages/nodes/src/integrations/dataforseo/backlinks.ts b/packages/nodes/src/integrations/dataforseo/backlinks.ts new file mode 100644 index 0000000..e8b3234 --- /dev/null +++ b/packages/nodes/src/integrations/dataforseo/backlinks.ts @@ -0,0 +1,165 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; + +// Constants +const DATAFORSEO_BASE_URL = 'https://api.dataforseo.com/v3'; + +// Types + +interface DataForSEOBacklinkItem { + url_from: string; + url_to: string; + domain_from: string; + dofollow: boolean; + anchor: string | null; + page_from_rank?: number; + domain_from_rank?: number; + first_seen?: string; + last_seen?: string; +} + +interface DataForSEOBacklinksResponse { + status_code: number; + status_message: string; + tasks?: Array<{ + result?: Array<{ + items?: DataForSEOBacklinkItem[]; + total_count?: number; + }>; + }>; +} + +// Schemas + +export const DataforseoGetBacklinksInputSchema = z.object({ + target: z.string().min(1, 'Target domain or URL is required'), + limit: z.number().int().min(1).max(1000).optional().default(100), + filters: z.object({ + dofollow: z.boolean().optional(), + anchorContains: z.string().optional(), + }).optional(), +}); + +export type DataforseoGetBacklinksInput = z.infer; + +export const DataforseoGetBacklinksOutputSchema = z.object({ + backlinks: z.array(z.object({ + urlFrom: z.string(), + urlTo: z.string(), + domainFrom: z.string(), + dofollow: z.boolean(), + anchor: z.string().nullable(), + pageFromRank: z.number().optional(), + domainFromRank: z.number().optional(), + firstSeen: z.string().optional(), + lastSeen: z.string().optional(), + })), + totalCount: z.number(), + target: z.string(), +}); + +export type DataforseoGetBacklinksOutput = z.infer; + +// Node Definition + +/** + * DataForSEO Get Backlinks Node + * + * Retrieves backlinks for a domain or URL using DataForSEO API. + * + * Requires `context.credentials.dataForSeo.apiToken` to be provided. + */ +export const dataforseoGetBacklinksNode = defineNode({ + type: 'dataforseo_get_backlinks', + name: 'DataForSEO Get Backlinks', + description: 'Retrieve backlinks for a domain or URL using DataForSEO', + category: 'integration', + inputSchema: DataforseoGetBacklinksInputSchema, + outputSchema: DataforseoGetBacklinksOutputSchema, + estimatedDuration: 10, + capabilities: { supportsRerun: true }, + + executor: async (input, context) => { + try { + const apiToken = context.credentials?.dataForSeo?.apiToken; + if (!apiToken) { + return { + success: false, + error: 'DataForSEO API token not configured. Please provide context.credentials.dataForSeo.apiToken.', + }; + } + + // Build filters array + const filters: Array = []; + + if (input.filters?.dofollow !== undefined) { + filters.push(['dofollow', '=', input.filters.dofollow]); + } + + if (input.filters?.anchorContains) { + if (filters.length > 0) { + filters.push('and'); + } + filters.push(['anchor', 'like', `%${input.filters.anchorContains}%`]); + } + + const requestBody = [{ + target: input.target, + limit: input.limit ?? 100, + ...(filters.length > 0 && { filters }), + }]; + + const response = await fetchWithRetry( + `${DATAFORSEO_BASE_URL}/backlinks/backlinks/live`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`DataForSEO API error: ${response.status} - ${errorText}`); + } + + const data: DataForSEOBacklinksResponse = await response.json(); + + if (data.status_code !== 20000) { + throw new Error(`DataForSEO API error: ${data.status_message}`); + } + + const items: DataForSEOBacklinkItem[] = data.tasks?.[0]?.result?.[0]?.items || []; + const totalCount: number = data.tasks?.[0]?.result?.[0]?.total_count ?? items.length; + + return { + success: true, + output: { + backlinks: items.map(item => ({ + urlFrom: item.url_from, + urlTo: item.url_to, + domainFrom: item.domain_from, + dofollow: item.dofollow, + anchor: item.anchor ?? null, + pageFromRank: item.page_from_rank, + domainFromRank: item.domain_from_rank, + firstSeen: item.first_seen, + lastSeen: item.last_seen, + })), + totalCount, + target: input.target, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/dataforseo/index.ts b/packages/nodes/src/integrations/dataforseo/index.ts index 55cdee3..ac0678c 100644 --- a/packages/nodes/src/integrations/dataforseo/index.ts +++ b/packages/nodes/src/integrations/dataforseo/index.ts @@ -14,3 +14,27 @@ export { type SeoAuditOutput, type SeoIssue, } from './seo-audit.js'; + +export { + dataforseoGetBacklinksNode, + DataforseoGetBacklinksInputSchema, + DataforseoGetBacklinksOutputSchema, + type DataforseoGetBacklinksInput, + type DataforseoGetBacklinksOutput, +} from './backlinks.js'; + +export { + dataforseoPeopleAlsoAskNode, + DataforseoPeopleAlsoAskInputSchema, + DataforseoPeopleAlsoAskOutputSchema, + type DataforseoPeopleAlsoAskInput, + type DataforseoPeopleAlsoAskOutput, +} from './people-also-ask.js'; + +export { + dataforseoSerpNode, + DataforseoSerpInputSchema, + DataforseoSerpOutputSchema, + type DataforseoSerpInput, + type DataforseoSerpOutput, +} from './serp.js'; diff --git a/packages/nodes/src/integrations/dataforseo/people-also-ask.ts b/packages/nodes/src/integrations/dataforseo/people-also-ask.ts new file mode 100644 index 0000000..5d8faf3 --- /dev/null +++ b/packages/nodes/src/integrations/dataforseo/people-also-ask.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; + +// Constants +const DATAFORSEO_BASE_URL = 'https://api.dataforseo.com/v3'; + +// Types + +interface DataForSEOPaaItem { + type: string; + title?: string; + seed_keyword?: string; + items?: Array<{ + type: string; + title?: string; + featured_snippet?: { + description?: string; + url?: string; + }; + }>; +} + +interface DataForSEOPaaResponse { + status_code: number; + status_message: string; + tasks?: Array<{ + result?: Array<{ + items?: DataForSEOPaaItem[]; + }>; + }>; +} + +// Schemas + +export const DataforseoPeopleAlsoAskInputSchema = z.object({ + keyword: z.string().min(1, 'Keyword is required'), + location: z.string().optional().default('United States'), + language: z.string().optional().default('en'), +}); + +export type DataforseoPeopleAlsoAskInput = z.infer; + +export const DataforseoPeopleAlsoAskOutputSchema = z.object({ + keyword: z.string(), + questions: z.array(z.object({ + question: z.string(), + answer: z.string().optional(), + sourceUrl: z.string().optional(), + })), + totalQuestions: z.number(), +}); + +export type DataforseoPeopleAlsoAskOutput = z.infer; + +// Node Definition + +/** + * DataForSEO People Also Ask Node + * + * Gets "People Also Ask" questions for a keyword using DataForSEO SERP API. + * + * Requires `context.credentials.dataForSeo.apiToken` to be provided. + */ +export const dataforseoPeopleAlsoAskNode = defineNode({ + type: 'dataforseo_people_also_ask', + name: 'DataForSEO People Also Ask', + description: 'Get "People Also Ask" questions for a keyword using DataForSEO', + category: 'integration', + inputSchema: DataforseoPeopleAlsoAskInputSchema, + outputSchema: DataforseoPeopleAlsoAskOutputSchema, + estimatedDuration: 10, + capabilities: { supportsRerun: true }, + + executor: async (input, context) => { + try { + const apiToken = context.credentials?.dataForSeo?.apiToken; + if (!apiToken) { + return { + success: false, + error: 'DataForSEO API token not configured. Please provide context.credentials.dataForSeo.apiToken.', + }; + } + + const requestBody = [{ + keyword: input.keyword, + location_name: input.location ?? 'United States', + language_code: input.language ?? 'en', + device: 'desktop', + os: 'windows', + depth: 2, + }]; + + const response = await fetchWithRetry( + `${DATAFORSEO_BASE_URL}/serp/google/people_also_ask/live/regular`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`DataForSEO API error: ${response.status} - ${errorText}`); + } + + const data: DataForSEOPaaResponse = await response.json(); + + if (data.status_code !== 20000) { + throw new Error(`DataForSEO API error: ${data.status_message}`); + } + + const items: DataForSEOPaaItem[] = data.tasks?.[0]?.result?.[0]?.items || []; + + // Filter to items where type === 'people_also_ask' + const paaItems = items.filter(item => item.type === 'people_also_ask'); + + const questions = paaItems.flatMap(item => + (item.items || []) + .filter(q => q.type === 'people_also_ask_element' && q.title) + .map(q => ({ + question: q.title!, + answer: q.featured_snippet?.description, + sourceUrl: q.featured_snippet?.url, + })) + ); + + return { + success: true, + output: { + keyword: input.keyword, + questions, + totalQuestions: questions.length, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/dataforseo/serp.ts b/packages/nodes/src/integrations/dataforseo/serp.ts new file mode 100644 index 0000000..2886ffd --- /dev/null +++ b/packages/nodes/src/integrations/dataforseo/serp.ts @@ -0,0 +1,151 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; + +// Constants +const DATAFORSEO_BASE_URL = 'https://api.dataforseo.com/v3'; + +// Types + +interface DataForSEOSerpItem { + type: string; + rank_group: number; + rank_absolute: number; + title?: string; + description?: string; + url?: string; + domain?: string; + breadcrumb?: string; +} + +interface DataForSEOSerpResponse { + status_code: number; + status_message: string; + tasks?: Array<{ + result?: Array<{ + items?: DataForSEOSerpItem[]; + se_results_count?: number; + keyword?: string; + }>; + }>; +} + +// Schemas + +export const DataforseoSerpInputSchema = z.object({ + keyword: z.string().min(1, 'Keyword is required'), + location: z.string().optional().default('United States'), + device: z.enum(['desktop', 'mobile']).optional().default('desktop'), + depth: z.number().int().min(10).max(100).optional().default(10), +}); + +export type DataforseoSerpInput = z.infer; + +export const DataforseoSerpOutputSchema = z.object({ + keyword: z.string(), + results: z.array(z.object({ + position: z.number(), + title: z.string(), + description: z.string().optional(), + url: z.string(), + domain: z.string().optional(), + breadcrumb: z.string().optional(), + })), + totalResults: z.number(), + device: z.string(), +}); + +export type DataforseoSerpOutput = z.infer; + +// Node Definition + +/** + * DataForSEO SERP Node + * + * Gets Google search engine results page (SERP) for a keyword using DataForSEO. + * + * Requires `context.credentials.dataForSeo.apiToken` to be provided. + */ +export const dataforseoSerpNode = defineNode({ + type: 'dataforseo_serp', + name: 'DataForSEO SERP', + description: 'Get Google search engine results page (SERP) for a keyword using DataForSEO', + category: 'integration', + inputSchema: DataforseoSerpInputSchema, + outputSchema: DataforseoSerpOutputSchema, + estimatedDuration: 10, + capabilities: { supportsRerun: true }, + + executor: async (input, context) => { + try { + const apiToken = context.credentials?.dataForSeo?.apiToken; + if (!apiToken) { + return { + success: false, + error: 'DataForSEO API token not configured. Please provide context.credentials.dataForSeo.apiToken.', + }; + } + + const requestBody = [{ + keyword: input.keyword, + location_name: input.location ?? 'United States', + language_code: 'en', + device: input.device ?? 'desktop', + os: input.device === 'mobile' ? 'android' : 'windows', + depth: input.depth ?? 10, + }]; + + const response = await fetchWithRetry( + `${DATAFORSEO_BASE_URL}/serp/google/organic/live/regular`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`DataForSEO API error: ${response.status} - ${errorText}`); + } + + const data: DataForSEOSerpResponse = await response.json(); + + if (data.status_code !== 20000) { + throw new Error(`DataForSEO API error: ${data.status_message}`); + } + + const items: DataForSEOSerpItem[] = data.tasks?.[0]?.result?.[0]?.items || []; + const seResultsCount: number = data.tasks?.[0]?.result?.[0]?.se_results_count ?? 0; + + // Only organic results + const organicItems = items.filter(item => item.type === 'organic'); + + return { + success: true, + output: { + keyword: input.keyword, + results: organicItems.map(item => ({ + position: item.rank_absolute, + title: item.title ?? '', + description: item.description, + url: item.url ?? '', + domain: item.domain, + breadcrumb: item.breadcrumb, + })), + totalResults: seResultsCount, + device: input.device ?? 'desktop', + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/index.ts b/packages/nodes/src/integrations/index.ts index 00f7f75..b115f7c 100644 --- a/packages/nodes/src/integrations/index.ts +++ b/packages/nodes/src/integrations/index.ts @@ -78,6 +78,21 @@ export { type SeoAuditInput, type SeoAuditOutput, type SeoIssue, + dataforseoGetBacklinksNode, + DataforseoGetBacklinksInputSchema, + DataforseoGetBacklinksOutputSchema, + type DataforseoGetBacklinksInput, + type DataforseoGetBacklinksOutput, + dataforseoPeopleAlsoAskNode, + DataforseoPeopleAlsoAskInputSchema, + DataforseoPeopleAlsoAskOutputSchema, + type DataforseoPeopleAlsoAskInput, + type DataforseoPeopleAlsoAskOutput, + dataforseoSerpNode, + DataforseoSerpInputSchema, + DataforseoSerpOutputSchema, + type DataforseoSerpInput, + type DataforseoSerpOutput, } from './dataforseo/index.js' // Apollo integrations