# Directory Structure
```
├── .dockerignore
├── .env
├── .env.example
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── mcp.json
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ └── get_refresh_token.js
├── smithery.json
├── smithery.yaml
└── src
├── config
│ └── googleAdsConfig.js
├── controllers
│ └── keywordController.js
├── index.js
├── mcp_fixed.js
├── mcp.js
├── routes
│ ├── competitorRoutes.js
│ ├── keywordRoutes.js
│ └── serpRoutes.js
└── services
└── keywordPlannerService.js
```
# Files
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
1 | node_modules
2 | npm-debug.log
3 | .git
4 | .gitignore
5 | .env
6 | .env.*
7 | !.env.example
8 | .DS_Store
9 | README.md
10 | .vscode
11 | .idea
12 | *.log
13 | logs
```
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
```
1 | # Server configuration
2 | PORT=3000
3 | HOST=0.0.0.0
4 | NODE_ENV=production
5 |
6 | # Google Ads API configuration
7 | GOOGLE_ADS_DEVELOPER_TOKEN=6mEoXl5C5vR0oxlHDhUFQQ
8 | GOOGLE_ADS_CLIENT_ID=your-client-id
9 | GOOGLE_ADS_CLIENT_SECRET=your-client-secret
10 | GOOGLE_ADS_REFRESH_TOKEN=your-refresh-token
11 | GOOGLE_ADS_LOGIN_CUSTOMER_ID=your-customer-id
```
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
1 | # Server Configuration
2 | PORT=3000
3 | NODE_ENV=development
4 |
5 | # Google Ads API Configuration
6 | GOOGLE_ADS_DEVELOPER_TOKEN=6mEoXl5C5vR0oxlHDhUFQQ
7 | GOOGLE_ADS_CLIENT_ID=your_client_id_here
8 | GOOGLE_ADS_CLIENT_SECRET=your_client_secret_here
9 | GOOGLE_ADS_REFRESH_TOKEN=your_refresh_token_here
10 | GOOGLE_ADS_LOGIN_CUSTOMER_ID=your_customer_id_without_dashes
11 |
12 | # SERP API Configuration
13 | SERP_API_KEY=your_serp_api_key_here
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependency directories
2 | node_modules/
3 |
4 | # Environment variables
5 | .env
6 | .env.local
7 | .env.development.local
8 | .env.test.local
9 | .env.production.local
10 |
11 | # Debug logs
12 | npm-debug.log*
13 | yarn-debug.log*
14 | yarn-error.log*
15 |
16 | # Build directories
17 | dist/
18 | build/
19 |
20 | # OS specific files
21 | .DS_Store
22 | Thumbs.db
23 |
24 | # Editor directories and files
25 | .idea/
26 | .vscode/
27 | *.suo
28 | *.ntvs*
29 | *.njsproj
30 | *.sln
31 | *.sw?
32 |
33 | # Google Ads API credentials
34 | google-ads.yaml
35 | client_secret.json
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # App SEO AI
2 |
3 | Application for SEO automation and AI-powered optimization with Google Ads Keyword Planner integration.
4 |
5 | ## Features
6 |
7 | - Keyword research using Google Ads API
8 | - SERP analysis
9 | - Competitor analysis
10 | - SEO recommendations
11 | - MCP (Model Context Protocol) integration for AI assistants
12 |
13 | ## Prerequisites
14 |
15 | - Node.js (v14 or higher)
16 | - npm or yarn
17 | - Google Ads account with API access
18 | - Google Cloud Platform project with Google Ads API enabled
19 |
20 | ## Setup
21 |
22 | ### 1. Clone the repository
23 |
24 | ```bash
25 | git clone https://github.com/ccnn2509/app-seo-ai.git
26 | cd app-seo-ai
27 | ```
28 |
29 | ### 2. Install dependencies
30 |
31 | ```bash
32 | npm install
33 | ```
34 |
35 | ### 3. Configure environment variables
36 |
37 | Copy the example environment file:
38 |
39 | ```bash
40 | cp .env.example .env
41 | ```
42 |
43 | Edit the `.env` file and fill in your Google Ads API credentials:
44 |
45 | ```
46 | # Server Configuration
47 | PORT=3000
48 | NODE_ENV=development
49 |
50 | # Google Ads API Configuration
51 | GOOGLE_ADS_DEVELOPER_TOKEN=your_developer_token
52 | GOOGLE_ADS_CLIENT_ID=your_client_id
53 | GOOGLE_ADS_CLIENT_SECRET=your_client_secret
54 | GOOGLE_ADS_REFRESH_TOKEN=your_refresh_token
55 | GOOGLE_ADS_LOGIN_CUSTOMER_ID=your_customer_id_without_dashes
56 |
57 | # SERP API Configuration (optional)
58 | SERP_API_KEY=your_serp_api_key
59 | ```
60 |
61 | ### 4. Get Google Ads API refresh token
62 |
63 | Run the following command to get a refresh token:
64 |
65 | ```bash
66 | npm run get-token
67 | ```
68 |
69 | This will open your browser and guide you through the OAuth2 authentication process. The refresh token will be automatically saved to your `.env` file.
70 |
71 | ### 5. Start the server
72 |
73 | For development:
74 |
75 | ```bash
76 | npm run dev
77 | ```
78 |
79 | For production:
80 |
81 | ```bash
82 | npm start
83 | ```
84 |
85 | The server will start on the port specified in your `.env` file (default: 3000).
86 |
87 | ## API Documentation
88 |
89 | API documentation is available at `/api-docs` when the server is running:
90 |
91 | ```
92 | http://localhost:3000/api-docs
93 | ```
94 |
95 | ## MCP Integration
96 |
97 | This project includes MCP (Model Context Protocol) integration, allowing AI assistants to use the API. The MCP configuration is in the `mcp.json` file.
98 |
99 | To use this with Smithery:
100 |
101 | 1. Go to [Smithery](https://smithery.ai/)
102 | 2. Create a new MCP server
103 | 3. Select the `app-seo-ai` repository
104 | 4. Configure the server settings
105 | 5. Deploy the server
106 |
107 | ## Available MCP Tools
108 |
109 | - `research_keywords` - Research keywords related to a given topic or seed keyword
110 | - `analyze_serp` - Analyze a SERP (Search Engine Results Page) for a given query
111 | - `analyze_competitors` - Analyze competitors for a given keyword or domain
112 | - `_health` - Health check endpoint
113 |
114 | ## Example Usage
115 |
116 | ### Research Keywords
117 |
118 | ```javascript
119 | // Example request to research keywords
120 | fetch('http://localhost:3000/api/keywords/ideas?keyword=seo%20tools&language=en')
121 | .then(response => response.json())
122 | .then(data => console.log(data));
123 | ```
124 |
125 | ### Analyze SERP
126 |
127 | ```javascript
128 | // Example request to analyze SERP
129 | fetch('http://localhost:3000/api/serp/analyze?query=best%20seo%20tools&location=United%20States')
130 | .then(response => response.json())
131 | .then(data => console.log(data));
132 | ```
133 |
134 | ### Analyze Competitors
135 |
136 | ```javascript
137 | // Example request to analyze competitors
138 | fetch('http://localhost:3000/api/competitors/analyze?domain=example.com')
139 | .then(response => response.json())
140 | .then(data => console.log(data));
141 | ```
142 |
143 | ## License
144 |
145 | MIT
```
--------------------------------------------------------------------------------
/src/mcp_fixed.js:
--------------------------------------------------------------------------------
```javascript
1 | // This file is a redirect to mcp.js
2 | // It exists because Smithery is looking for it according to the error message
3 |
4 | import app from './mcp.js';
5 |
6 | export default app;
```
--------------------------------------------------------------------------------
/src/config/googleAdsConfig.js:
--------------------------------------------------------------------------------
```javascript
1 | import dotenv from 'dotenv';
2 |
3 | dotenv.config();
4 |
5 | // Google Ads API configuration
6 | const googleAdsConfig = {
7 | developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN,
8 | client_id: process.env.GOOGLE_ADS_CLIENT_ID,
9 | client_secret: process.env.GOOGLE_ADS_CLIENT_SECRET,
10 | refresh_token: process.env.GOOGLE_ADS_REFRESH_TOKEN,
11 | login_customer_id: process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
12 | use_proto_plus: true,
13 | };
14 |
15 | export default googleAdsConfig;
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM node:18-alpine
2 |
3 | WORKDIR /app
4 |
5 | # Copy package.json and package-lock.json
6 | COPY package*.json ./
7 |
8 | # Install dependencies
9 | RUN npm install --only=production
10 |
11 | # Copy application code
12 | COPY . .
13 |
14 | # Expose the port the app runs on
15 | EXPOSE 3000
16 |
17 | # The command will be provided by smithery.yaml
18 | # This is just a default command for local testing
19 | RUN addgroup -S appgroup && adduser -S appuser -G appgroup
20 | USER appuser
21 | CMD ["node", "src/mcp.js", "--mcp"]
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
1 | # Smithery.ai configuration
2 | startCommand:
3 | type: stdio
4 | configSchema:
5 | # JSON Schema defining the configuration options for the MCP.
6 | {}
7 | commandFunction: |-
8 | (config) => ({
9 | "command": "node",
10 | "args": [
11 | "src/mcp.js",
12 | "--mcp"
13 | ],
14 | "env": {
15 | "PORT": "3000",
16 | "HOST": "0.0.0.0",
17 | "NODE_ENV": "production",
18 | "GOOGLE_ADS_DEVELOPER_TOKEN": "6mEoXl5C5vR0oxlHDhUFQQ"
19 | }
20 | })
21 |
22 | # Build configuration
23 | build:
24 | dockerfile: Dockerfile
25 | dockerBuildPath: .
```
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
```yaml
1 | version: '3'
2 |
3 | services:
4 | app:
5 | build: .
6 | ports:
7 | - "3000:3000"
8 | environment:
9 | - PORT=3000
10 | - NODE_ENV=production
11 | - GOOGLE_ADS_DEVELOPER_TOKEN=${GOOGLE_ADS_DEVELOPER_TOKEN}
12 | - GOOGLE_ADS_CLIENT_ID=${GOOGLE_ADS_CLIENT_ID}
13 | - GOOGLE_ADS_CLIENT_SECRET=${GOOGLE_ADS_CLIENT_SECRET}
14 | - GOOGLE_ADS_REFRESH_TOKEN=${GOOGLE_ADS_REFRESH_TOKEN}
15 | - GOOGLE_ADS_LOGIN_CUSTOMER_ID=${GOOGLE_ADS_LOGIN_CUSTOMER_ID}
16 | - SERP_API_KEY=${SERP_API_KEY}
17 | volumes:
18 | - ./logs:/app/logs
19 | restart: unless-stopped
```
--------------------------------------------------------------------------------
/smithery.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "SEO AI Tools",
3 | "description": "SEO automation and AI-powered optimization with Google Ads Keyword Planner integration",
4 | "version": "1.0.0",
5 | "repository": "https://github.com/ccnn2509/app-seo-ai",
6 | "main": "mcp.json",
7 | "scripts": {
8 | "start": "node src/index.js",
9 | "dev": "nodemon src/index.js"
10 | },
11 | "env": {
12 | "PORT": "3000",
13 | "NODE_ENV": "production",
14 | "GOOGLE_ADS_DEVELOPER_TOKEN": "6mEoXl5C5vR0oxlHDhUFQQ"
15 | },
16 | "buildCommand": "npm install",
17 | "startCommand": "npm start",
18 | "healthCheckPath": "/health",
19 | "resources": {
20 | "cpu": 1,
21 | "memory": "512Mi"
22 | }
23 | }
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "app-seo-ai",
3 | "version": "1.0.0",
4 | "main": "src/index.js",
5 | "type": "module",
6 | "scripts": {
7 | "start": "node src/index.js",
8 | "dev": "nodemon src/index.js",
9 | "mcp": "node src/mcp.js --mcp",
10 | "get-token": "node scripts/get_refresh_token.js"
11 | },
12 | "keywords": [
13 | "seo",
14 | "ai",
15 | "google-ads",
16 | "keyword-planner",
17 | "serp"
18 | ],
19 | "author": "",
20 | "license": "ISC",
21 | "description": "SEO automation and AI-powered optimization with Google Ads Keyword Planner integration",
22 | "dependencies": {
23 | "cors": "^2.8.5",
24 | "dotenv": "^16.4.7",
25 | "express": "^4.21.2",
26 | "google-ads-api": "^17.1.0-rest-beta",
27 | "google-auth-library": "^9.15.1",
28 | "open": "^10.1.0",
29 | "swagger-jsdoc": "^6.2.8",
30 | "swagger-ui-express": "^5.0.1"
31 | },
32 | "devDependencies": {
33 | "nodemon": "^3.1.9"
34 | }
35 | }
```
--------------------------------------------------------------------------------
/src/mcp.js:
--------------------------------------------------------------------------------
```javascript
1 | import express from 'express';
2 | import cors from 'cors';
3 | import dotenv from 'dotenv';
4 | import keywordRoutes from './routes/keywordRoutes.js';
5 | import serpRoutes from './routes/serpRoutes.js';
6 | import competitorRoutes from './routes/competitorRoutes.js';
7 |
8 | // Load environment variables
9 | dotenv.config();
10 |
11 | // Initialize express app
12 | const app = express();
13 | const PORT = process.env.PORT || 3000;
14 | const HOST = process.env.HOST || '0.0.0.0'; // Listen on all network interfaces
15 |
16 | // Middleware
17 | app.use(cors());
18 | app.use(express.json());
19 | app.use(express.urlencoded({ extended: true }));
20 |
21 | // Routes
22 | app.use('/api/keywords', keywordRoutes);
23 | app.use('/api/serp', serpRoutes);
24 | app.use('/api/competitors', competitorRoutes);
25 |
26 | // Health check endpoint
27 | app.get('/health', (req, res) => {
28 | res.status(200).json({ status: 'ok', message: 'MCP Server is running' });
29 | });
30 |
31 | // Start server
32 | app.listen(PORT, HOST, () => {
33 | console.log(`MCP Server running on ${HOST}:${PORT}`);
34 | console.log(`Health check available at http://${HOST}:${PORT}/health`);
35 | });
36 |
37 | export default app;
```
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
```javascript
1 | import express from 'express';
2 | import cors from 'cors';
3 | import dotenv from 'dotenv';
4 | import swaggerJsDoc from 'swagger-jsdoc';
5 | import swaggerUi from 'swagger-ui-express';
6 |
7 | // Import routes
8 | import keywordRoutes from './routes/keywordRoutes.js';
9 | import serpRoutes from './routes/serpRoutes.js';
10 | import competitorRoutes from './routes/competitorRoutes.js';
11 |
12 | // Load environment variables
13 | dotenv.config();
14 |
15 | // Initialize express app
16 | const app = express();
17 | const PORT = process.env.PORT || 3000;
18 |
19 | // Middleware
20 | app.use(cors());
21 | app.use(express.json());
22 | app.use(express.urlencoded({ extended: true }));
23 |
24 | // Swagger configuration
25 | const swaggerOptions = {
26 | definition: {
27 | openapi: '3.0.0',
28 | info: {
29 | title: 'App SEO AI API',
30 | version: '1.0.0',
31 | description: 'API for SEO automation and AI-powered optimization',
32 | },
33 | servers: [
34 | {
35 | url: `http://localhost:${PORT}`,
36 | description: 'Development server',
37 | },
38 | ],
39 | },
40 | apis: ['./src/routes/*.js'],
41 | };
42 |
43 | const swaggerDocs = swaggerJsDoc(swaggerOptions);
44 | app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocs));
45 |
46 | // Routes
47 | app.use('/api/keywords', keywordRoutes);
48 | app.use('/api/serp', serpRoutes);
49 | app.use('/api/competitors', competitorRoutes);
50 |
51 | // Health check endpoint
52 | app.get('/health', (req, res) => {
53 | res.status(200).json({ status: 'ok', message: 'Server is running' });
54 | });
55 |
56 | // Start server
57 | app.listen(PORT, () => {
58 | console.log(`Server running on port ${PORT}`);
59 | console.log(`API Documentation available at http://localhost:${PORT}/api-docs`);
60 | });
61 |
62 | export default app;
```
--------------------------------------------------------------------------------
/src/controllers/keywordController.js:
--------------------------------------------------------------------------------
```javascript
1 | import keywordPlannerService from '../services/keywordPlannerService.js';
2 |
3 | /**
4 | * Generate keyword ideas based on a seed keyword
5 | * @param {Object} req - Express request object
6 | * @param {Object} res - Express response object
7 | */
8 | export const generateKeywordIdeas = async (req, res) => {
9 | try {
10 | const { keyword, language, locations, limit } = req.query;
11 |
12 | if (!keyword) {
13 | return res.status(400).json({ error: 'Keyword is required' });
14 | }
15 |
16 | // Parse locations if provided
17 | const parsedLocations = locations ? JSON.parse(locations) : [2250]; // Default to US
18 | const parsedLimit = limit ? parseInt(limit) : 50;
19 |
20 | const keywordIdeas = await keywordPlannerService.generateKeywordIdeas(
21 | keyword,
22 | language || 'en',
23 | parsedLocations,
24 | parsedLimit
25 | );
26 |
27 | res.status(200).json({
28 | success: true,
29 | data: {
30 | seedKeyword: keyword,
31 | keywordIdeas,
32 | count: keywordIdeas.length,
33 | },
34 | });
35 | } catch (error) {
36 | console.error('Error in generateKeywordIdeas controller:', error);
37 | res.status(500).json({
38 | success: false,
39 | error: error.message || 'Failed to generate keyword ideas',
40 | });
41 | }
42 | };
43 |
44 | /**
45 | * Get keyword metrics for a list of keywords
46 | * @param {Object} req - Express request object
47 | * @param {Object} res - Express response object
48 | */
49 | export const getKeywordMetrics = async (req, res) => {
50 | try {
51 | const { keywords, language, locations } = req.body;
52 |
53 | if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
54 | return res.status(400).json({ error: 'Valid keywords array is required' });
55 | }
56 |
57 | // Parse locations if provided
58 | const parsedLocations = locations || [2250]; // Default to US
59 |
60 | const keywordMetrics = await keywordPlannerService.getKeywordMetrics(
61 | keywords,
62 | language || 'en',
63 | parsedLocations
64 | );
65 |
66 | res.status(200).json({
67 | success: true,
68 | data: {
69 | keywords,
70 | keywordMetrics,
71 | count: keywordMetrics.length,
72 | },
73 | });
74 | } catch (error) {
75 | console.error('Error in getKeywordMetrics controller:', error);
76 | res.status(500).json({
77 | success: false,
78 | error: error.message || 'Failed to get keyword metrics',
79 | });
80 | }
81 | };
82 |
83 | /**
84 | * Get historical metrics for keywords
85 | * @param {Object} req - Express request object
86 | * @param {Object} res - Express response object
87 | */
88 | export const getHistoricalMetrics = async (req, res) => {
89 | try {
90 | const { keywords, language, locations } = req.body;
91 |
92 | if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
93 | return res.status(400).json({ error: 'Valid keywords array is required' });
94 | }
95 |
96 | // Parse locations if provided
97 | const parsedLocations = locations || [2250]; // Default to US
98 |
99 | const historicalMetrics = await keywordPlannerService.getHistoricalMetrics(
100 | keywords,
101 | language || 'en',
102 | parsedLocations
103 | );
104 |
105 | res.status(200).json({
106 | success: true,
107 | data: {
108 | keywords,
109 | historicalMetrics,
110 | },
111 | });
112 | } catch (error) {
113 | console.error('Error in getHistoricalMetrics controller:', error);
114 | res.status(500).json({
115 | success: false,
116 | error: error.message || 'Failed to get historical metrics',
117 | });
118 | }
119 | };
120 |
121 | export default {
122 | generateKeywordIdeas,
123 | getKeywordMetrics,
124 | getHistoricalMetrics,
125 | };
```
--------------------------------------------------------------------------------
/src/routes/serpRoutes.js:
--------------------------------------------------------------------------------
```javascript
1 | import express from 'express';
2 |
3 | const router = express.Router();
4 |
5 | /**
6 | * @swagger
7 | * /api/serp/analyze:
8 | * get:
9 | * summary: Analyze a SERP (Search Engine Results Page) for a given query
10 | * tags: [SERP]
11 | * parameters:
12 | * - in: query
13 | * name: query
14 | * schema:
15 | * type: string
16 | * required: true
17 | * description: Search query to analyze
18 | * - in: query
19 | * name: location
20 | * schema:
21 | * type: string
22 | * description: Location for the search (e.g., 'United States')
23 | * - in: query
24 | * name: language
25 | * schema:
26 | * type: string
27 | * description: Language code (e.g., 'en')
28 | * responses:
29 | * 200:
30 | * description: Successful operation
31 | * content:
32 | * application/json:
33 | * schema:
34 | * type: object
35 | * properties:
36 | * success:
37 | * type: boolean
38 | * data:
39 | * type: object
40 | * 400:
41 | * description: Bad request
42 | * 500:
43 | * description: Server error
44 | */
45 | router.get('/analyze', (req, res) => {
46 | // This is a placeholder for the SERP analysis functionality
47 | // In a real implementation, this would call a service to analyze SERP data
48 |
49 | const { query, location, language } = req.query;
50 |
51 | if (!query) {
52 | return res.status(400).json({ error: 'Search query is required' });
53 | }
54 |
55 | // Mock response for now
56 | res.status(200).json({
57 | success: true,
58 | data: {
59 | query,
60 | location: location || 'United States',
61 | language: language || 'en',
62 | results: [
63 | {
64 | position: 1,
65 | title: 'Example Result 1',
66 | url: 'https://example.com/1',
67 | description: 'This is an example search result description.',
68 | features: ['Featured Snippet', 'Site Links']
69 | },
70 | {
71 | position: 2,
72 | title: 'Example Result 2',
73 | url: 'https://example.com/2',
74 | description: 'Another example search result description.',
75 | features: []
76 | }
77 | ],
78 | features: {
79 | featuredSnippet: true,
80 | peopleAlsoAsk: true,
81 | localPack: false,
82 | imageCarousel: false
83 | },
84 | stats: {
85 | totalResults: 2,
86 | paidResults: 0,
87 | organicResults: 2
88 | }
89 | }
90 | });
91 | });
92 |
93 | /**
94 | * @swagger
95 | * /api/serp/features:
96 | * get:
97 | * summary: Get SERP features for a given query
98 | * tags: [SERP]
99 | * parameters:
100 | * - in: query
101 | * name: query
102 | * schema:
103 | * type: string
104 | * required: true
105 | * description: Search query to analyze
106 | * - in: query
107 | * name: location
108 | * schema:
109 | * type: string
110 | * description: Location for the search
111 | * responses:
112 | * 200:
113 | * description: Successful operation
114 | * 400:
115 | * description: Bad request
116 | * 500:
117 | * description: Server error
118 | */
119 | router.get('/features', (req, res) => {
120 | // This is a placeholder for the SERP features functionality
121 |
122 | const { query, location } = req.query;
123 |
124 | if (!query) {
125 | return res.status(400).json({ error: 'Search query is required' });
126 | }
127 |
128 | // Mock response for now
129 | res.status(200).json({
130 | success: true,
131 | data: {
132 | query,
133 | location: location || 'United States',
134 | features: {
135 | featuredSnippet: true,
136 | peopleAlsoAsk: true,
137 | knowledgePanel: false,
138 | localPack: false,
139 | imageCarousel: true,
140 | videoCarousel: false,
141 | topStories: false,
142 | relatedSearches: true
143 | }
144 | }
145 | });
146 | });
147 |
148 | export default router;
```
--------------------------------------------------------------------------------
/src/routes/keywordRoutes.js:
--------------------------------------------------------------------------------
```javascript
1 | import express from 'express';
2 | import keywordController from '../controllers/keywordController.js';
3 |
4 | const router = express.Router();
5 |
6 | /**
7 | * @swagger
8 | * /api/keywords/ideas:
9 | * get:
10 | * summary: Generate keyword ideas based on a seed keyword
11 | * tags: [Keywords]
12 | * parameters:
13 | * - in: query
14 | * name: keyword
15 | * schema:
16 | * type: string
17 | * required: true
18 | * description: Seed keyword to generate ideas from
19 | * - in: query
20 | * name: language
21 | * schema:
22 | * type: string
23 | * description: Language code (e.g., 'en', 'fr', 'es')
24 | * - in: query
25 | * name: locations
26 | * schema:
27 | * type: string
28 | * description: JSON array of location IDs (e.g., '[2250]' for US)
29 | * - in: query
30 | * name: limit
31 | * schema:
32 | * type: integer
33 | * description: Maximum number of keyword ideas to return
34 | * responses:
35 | * 200:
36 | * description: Successful operation
37 | * content:
38 | * application/json:
39 | * schema:
40 | * type: object
41 | * properties:
42 | * success:
43 | * type: boolean
44 | * data:
45 | * type: object
46 | * 400:
47 | * description: Bad request
48 | * 500:
49 | * description: Server error
50 | */
51 | router.get('/ideas', keywordController.generateKeywordIdeas);
52 |
53 | /**
54 | * @swagger
55 | * /api/keywords/metrics:
56 | * post:
57 | * summary: Get keyword metrics for a list of keywords
58 | * tags: [Keywords]
59 | * requestBody:
60 | * required: true
61 | * content:
62 | * application/json:
63 | * schema:
64 | * type: object
65 | * required:
66 | * - keywords
67 | * properties:
68 | * keywords:
69 | * type: array
70 | * items:
71 | * type: string
72 | * language:
73 | * type: string
74 | * locations:
75 | * type: array
76 | * items:
77 | * type: integer
78 | * responses:
79 | * 200:
80 | * description: Successful operation
81 | * content:
82 | * application/json:
83 | * schema:
84 | * type: object
85 | * properties:
86 | * success:
87 | * type: boolean
88 | * data:
89 | * type: object
90 | * 400:
91 | * description: Bad request
92 | * 500:
93 | * description: Server error
94 | */
95 | router.post('/metrics', keywordController.getKeywordMetrics);
96 |
97 | /**
98 | * @swagger
99 | * /api/keywords/historical:
100 | * post:
101 | * summary: Get historical metrics for keywords
102 | * tags: [Keywords]
103 | * requestBody:
104 | * required: true
105 | * content:
106 | * application/json:
107 | * schema:
108 | * type: object
109 | * required:
110 | * - keywords
111 | * properties:
112 | * keywords:
113 | * type: array
114 | * items:
115 | * type: string
116 | * language:
117 | * type: string
118 | * locations:
119 | * type: array
120 | * items:
121 | * type: integer
122 | * responses:
123 | * 200:
124 | * description: Successful operation
125 | * content:
126 | * application/json:
127 | * schema:
128 | * type: object
129 | * properties:
130 | * success:
131 | * type: boolean
132 | * data:
133 | * type: object
134 | * 400:
135 | * description: Bad request
136 | * 500:
137 | * description: Server error
138 | */
139 | router.post('/historical', keywordController.getHistoricalMetrics);
140 |
141 | export default router;
```
--------------------------------------------------------------------------------
/src/routes/competitorRoutes.js:
--------------------------------------------------------------------------------
```javascript
1 | import express from 'express';
2 |
3 | const router = express.Router();
4 |
5 | /**
6 | * @swagger
7 | * /api/competitors/analyze:
8 | * get:
9 | * summary: Analyze competitors for a given keyword or domain
10 | * tags: [Competitors]
11 | * parameters:
12 | * - in: query
13 | * name: keyword
14 | * schema:
15 | * type: string
16 | * description: Keyword to analyze competitors for
17 | * - in: query
18 | * name: domain
19 | * schema:
20 | * type: string
21 | * description: Domain to analyze competitors for
22 | * - in: query
23 | * name: limit
24 | * schema:
25 | * type: integer
26 | * description: Maximum number of competitors to return
27 | * responses:
28 | * 200:
29 | * description: Successful operation
30 | * content:
31 | * application/json:
32 | * schema:
33 | * type: object
34 | * properties:
35 | * success:
36 | * type: boolean
37 | * data:
38 | * type: object
39 | * 400:
40 | * description: Bad request
41 | * 500:
42 | * description: Server error
43 | */
44 | router.get('/analyze', (req, res) => {
45 | // This is a placeholder for the competitor analysis functionality
46 |
47 | const { keyword, domain, limit } = req.query;
48 |
49 | if (!keyword && !domain) {
50 | return res.status(400).json({ error: 'Either keyword or domain is required' });
51 | }
52 |
53 | const parsedLimit = limit ? parseInt(limit) : 10;
54 |
55 | // Mock response for now
56 | res.status(200).json({
57 | success: true,
58 | data: {
59 | keyword: keyword || null,
60 | domain: domain || null,
61 | competitors: [
62 | {
63 | domain: 'competitor1.com',
64 | title: 'Competitor 1 Website',
65 | position: 1,
66 | metrics: {
67 | domainAuthority: 85,
68 | pageAuthority: 76,
69 | backlinks: 15000,
70 | organicTraffic: 250000
71 | },
72 | content: {
73 | wordCount: 2500,
74 | headings: 12,
75 | images: 8,
76 | videos: 1
77 | }
78 | },
79 | {
80 | domain: 'competitor2.com',
81 | title: 'Competitor 2 Website',
82 | position: 2,
83 | metrics: {
84 | domainAuthority: 78,
85 | pageAuthority: 72,
86 | backlinks: 12000,
87 | organicTraffic: 180000
88 | },
89 | content: {
90 | wordCount: 1800,
91 | headings: 9,
92 | images: 6,
93 | videos: 0
94 | }
95 | }
96 | ],
97 | count: 2,
98 | limit: parsedLimit
99 | }
100 | });
101 | });
102 |
103 | /**
104 | * @swagger
105 | * /api/competitors/backlinks:
106 | * get:
107 | * summary: Get backlink data for a competitor domain
108 | * tags: [Competitors]
109 | * parameters:
110 | * - in: query
111 | * name: domain
112 | * schema:
113 | * type: string
114 | * required: true
115 | * description: Domain to get backlink data for
116 | * - in: query
117 | * name: limit
118 | * schema:
119 | * type: integer
120 | * description: Maximum number of backlinks to return
121 | * responses:
122 | * 200:
123 | * description: Successful operation
124 | * 400:
125 | * description: Bad request
126 | * 500:
127 | * description: Server error
128 | */
129 | router.get('/backlinks', (req, res) => {
130 | // This is a placeholder for the backlink analysis functionality
131 |
132 | const { domain, limit } = req.query;
133 |
134 | if (!domain) {
135 | return res.status(400).json({ error: 'Domain is required' });
136 | }
137 |
138 | const parsedLimit = limit ? parseInt(limit) : 10;
139 |
140 | // Mock response for now
141 | res.status(200).json({
142 | success: true,
143 | data: {
144 | domain,
145 | backlinks: [
146 | {
147 | sourceDomain: 'example.com',
148 | sourceUrl: 'https://example.com/page1',
149 | targetUrl: `https://${domain}/target-page`,
150 | anchorText: 'Example anchor text',
151 | domainAuthority: 75,
152 | pageAuthority: 65,
153 | isDoFollow: true
154 | },
155 | {
156 | sourceDomain: 'another-example.com',
157 | sourceUrl: 'https://another-example.com/page2',
158 | targetUrl: `https://${domain}/another-target`,
159 | anchorText: 'Another anchor text',
160 | domainAuthority: 68,
161 | pageAuthority: 60,
162 | isDoFollow: false
163 | }
164 | ],
165 | count: 2,
166 | limit: parsedLimit,
167 | totalBacklinks: 15000
168 | }
169 | });
170 | });
171 |
172 | export default router;
```
--------------------------------------------------------------------------------
/scripts/get_refresh_token.js:
--------------------------------------------------------------------------------
```javascript
1 | import fs from 'fs';
2 | import { OAuth2Client } from 'google-auth-library';
3 | import http from 'http';
4 | import url from 'url';
5 | import open from 'open';
6 | import dotenv from 'dotenv';
7 |
8 | dotenv.config();
9 |
10 | // Configuration
11 | const PORT = 8080;
12 | const SCOPES = ['https://www.googleapis.com/auth/adwords'];
13 |
14 | // Get client ID and client secret from environment variables
15 | const CLIENT_ID = process.env.GOOGLE_ADS_CLIENT_ID;
16 | const CLIENT_SECRET = process.env.GOOGLE_ADS_CLIENT_SECRET;
17 |
18 | if (!CLIENT_ID || !CLIENT_SECRET) {
19 | console.error('Error: GOOGLE_ADS_CLIENT_ID and GOOGLE_ADS_CLIENT_SECRET must be set in .env file');
20 | process.exit(1);
21 | }
22 |
23 | // Create OAuth2 client
24 | const oauth2Client = new OAuth2Client(
25 | CLIENT_ID,
26 | CLIENT_SECRET,
27 | `http://localhost:${PORT}`
28 | );
29 |
30 | // Generate authorization URL
31 | const authorizeUrl = oauth2Client.generateAuthUrl({
32 | access_type: 'offline',
33 | scope: SCOPES,
34 | prompt: 'consent', // Force to get refresh token
35 | });
36 |
37 | // Create HTTP server to handle the OAuth2 callback
38 | const server = http.createServer(async (req, res) => {
39 | try {
40 | // Parse the URL and get the code parameter
41 | const queryParams = url.parse(req.url, true).query;
42 | const code = queryParams.code;
43 |
44 | if (code) {
45 | // Exchange the authorization code for tokens
46 | const { tokens } = await oauth2Client.getToken(code);
47 | oauth2Client.setCredentials(tokens);
48 |
49 | // Display the refresh token
50 | console.log('\nRefresh Token:', tokens.refresh_token);
51 | console.log('\nAccess Token:', tokens.access_token);
52 | console.log('\nExpiry Date:', tokens.expiry_date);
53 |
54 | // Save the refresh token to .env file
55 | if (tokens.refresh_token) {
56 | try {
57 | let envContent = fs.readFileSync('.env', 'utf8');
58 |
59 | // Check if GOOGLE_ADS_REFRESH_TOKEN already exists in .env
60 | if (envContent.includes('GOOGLE_ADS_REFRESH_TOKEN=')) {
61 | // Replace existing refresh token
62 | envContent = envContent.replace(
63 | /GOOGLE_ADS_REFRESH_TOKEN=.*/,
64 | `GOOGLE_ADS_REFRESH_TOKEN=${tokens.refresh_token}`
65 | );
66 | } else {
67 | // Add new refresh token
68 | envContent += `\nGOOGLE_ADS_REFRESH_TOKEN=${tokens.refresh_token}`;
69 | }
70 |
71 | fs.writeFileSync('.env', envContent);
72 | console.log('\nRefresh token saved to .env file');
73 | } catch (error) {
74 | console.error('Error saving refresh token to .env file:', error);
75 | }
76 | } else {
77 | console.warn('\nWarning: No refresh token received. Try again with prompt=consent parameter.');
78 | }
79 |
80 | // Send success response to browser
81 | res.writeHead(200, { 'Content-Type': 'text/html' });
82 | res.end(`
83 | <html>
84 | <body>
85 | <h1>Authentication Successful</h1>
86 | <p>You have successfully authenticated with Google Ads API.</p>
87 | <p>The refresh token has been saved to your .env file.</p>
88 | <p>You can close this window now.</p>
89 | </body>
90 | </html>
91 | `);
92 |
93 | // Close the server after a short delay
94 | setTimeout(() => {
95 | server.close();
96 | console.log('\nServer closed. You can close this terminal window.');
97 | }, 2000);
98 | } else {
99 | // Handle error case
100 | res.writeHead(400, { 'Content-Type': 'text/html' });
101 | res.end(`
102 | <html>
103 | <body>
104 | <h1>Authentication Failed</h1>
105 | <p>No authorization code received from Google.</p>
106 | <p>Please try again.</p>
107 | </body>
108 | </html>
109 | `);
110 | }
111 | } catch (error) {
112 | console.error('Error handling OAuth callback:', error);
113 | res.writeHead(500, { 'Content-Type': 'text/html' });
114 | res.end(`
115 | <html>
116 | <body>
117 | <h1>Authentication Error</h1>
118 | <p>An error occurred during authentication: ${error.message}</p>
119 | </body>
120 | </html>
121 | `);
122 | }
123 | });
124 |
125 | // Start the server
126 | server.listen(PORT, () => {
127 | console.log(`\nOAuth2 callback server listening on http://localhost:${PORT}`);
128 | console.log('\nOpening browser to authorize application...');
129 |
130 | // Open the authorization URL in the default browser
131 | open(authorizeUrl);
132 |
133 | console.log('\nIf the browser does not open automatically, please visit:');
134 | console.log(authorizeUrl);
135 | });
136 |
137 | // Handle server errors
138 | server.on('error', (error) => {
139 | console.error('Server error:', error);
140 | process.exit(1);
141 | });
```
--------------------------------------------------------------------------------
/mcp.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "SEO AI Tools",
3 | "description": "SEO automation and AI-powered optimization with Google Ads Keyword Planner integration",
4 | "version": "1.0.0",
5 | "tools": [
6 | {
7 | "name": "research_keywords",
8 | "description": "Research keywords related to a given topic or seed keyword",
9 | "parameters": {
10 | "type": "object",
11 | "required": ["keyword"],
12 | "properties": {
13 | "keyword": {
14 | "type": "string",
15 | "description": "Seed keyword to generate ideas from"
16 | },
17 | "language": {
18 | "type": "string",
19 | "description": "Language code (e.g., 'en', 'fr', 'es')"
20 | },
21 | "locations": {
22 | "type": "array",
23 | "items": {
24 | "type": "integer"
25 | },
26 | "description": "Array of location IDs (e.g., [2250] for US)"
27 | },
28 | "include_questions": {
29 | "type": "boolean",
30 | "default": false,
31 | "description": "Whether to include question-based keywords"
32 | },
33 | "include_related": {
34 | "type": "boolean",
35 | "default": false,
36 | "description": "Whether to include related keywords"
37 | },
38 | "include_suggestions": {
39 | "type": "boolean",
40 | "default": false,
41 | "description": "Whether to include Google's keyword suggestions"
42 | }
43 | }
44 | },
45 | "handler": {
46 | "type": "http",
47 | "method": "GET",
48 | "url": "/api/keywords/ideas",
49 | "query": {
50 | "keyword": "{keyword}",
51 | "language": "{language}",
52 | "locations": "{locations}",
53 | "include_questions": "{include_questions}",
54 | "include_related": "{include_related}",
55 | "include_suggestions": "{include_suggestions}"
56 | }
57 | }
58 | },
59 | {
60 | "name": "analyze_serp",
61 | "description": "Analyze a SERP (Search Engine Results Page) for a given query",
62 | "parameters": {
63 | "type": "object",
64 | "required": ["query"],
65 | "properties": {
66 | "query": {
67 | "type": "string",
68 | "description": "Search query to analyze"
69 | },
70 | "location": {
71 | "type": "string",
72 | "description": "Location for the search (e.g., 'United States')"
73 | },
74 | "language": {
75 | "type": "string",
76 | "description": "Language code (e.g., 'en')"
77 | },
78 | "device": {
79 | "type": "string",
80 | "enum": ["desktop", "mobile"],
81 | "default": "desktop",
82 | "description": "Device type for the search"
83 | },
84 | "num": {
85 | "type": "number",
86 | "default": 10,
87 | "minimum": 1,
88 | "maximum": 100,
89 | "description": "Number of results to return"
90 | }
91 | }
92 | },
93 | "handler": {
94 | "type": "http",
95 | "method": "GET",
96 | "url": "/api/serp/analyze",
97 | "query": {
98 | "query": "{query}",
99 | "location": "{location}",
100 | "language": "{language}",
101 | "device": "{device}",
102 | "num": "{num}"
103 | }
104 | }
105 | },
106 | {
107 | "name": "analyze_competitors",
108 | "description": "Analyze competitors for a given keyword or domain",
109 | "parameters": {
110 | "type": "object",
111 | "properties": {
112 | "keyword": {
113 | "type": "string",
114 | "description": "Keyword to analyze competitors for"
115 | },
116 | "domain": {
117 | "type": "string",
118 | "description": "Domain to analyze competitors for"
119 | },
120 | "include_features": {
121 | "type": "boolean",
122 | "default": false,
123 | "description": "Whether to include detailed features in the analysis"
124 | },
125 | "num_results": {
126 | "type": "number",
127 | "minimum": 1,
128 | "maximum": 100,
129 | "default": 10,
130 | "description": "Number of competitor results to return"
131 | }
132 | }
133 | },
134 | "handler": {
135 | "type": "http",
136 | "method": "GET",
137 | "url": "/api/competitors/analyze",
138 | "query": {
139 | "keyword": "{keyword}",
140 | "domain": "{domain}",
141 | "include_features": "{include_features}",
142 | "limit": "{num_results}"
143 | }
144 | }
145 | },
146 | {
147 | "name": "_health",
148 | "description": "Health check endpoint",
149 | "parameters": {
150 | "type": "object",
151 | "properties": {
152 | "random_string": {
153 | "type": "string",
154 | "description": "Dummy parameter for no-parameter tools"
155 | }
156 | },
157 | "required": ["random_string"]
158 | },
159 | "handler": {
160 | "type": "http",
161 | "method": "GET",
162 | "url": "/health"
163 | }
164 | }
165 | ]
166 | }
```
--------------------------------------------------------------------------------
/src/services/keywordPlannerService.js:
--------------------------------------------------------------------------------
```javascript
1 | import { GoogleAdsApi } from 'google-ads-api';
2 | import googleAdsConfig from '../config/googleAdsConfig.js';
3 |
4 | // Initialize Google Ads API client
5 | const client = new GoogleAdsApi({
6 | client_id: googleAdsConfig.client_id,
7 | client_secret: googleAdsConfig.client_secret,
8 | developer_token: googleAdsConfig.developer_token,
9 | refresh_token: googleAdsConfig.refresh_token,
10 | });
11 |
12 | // Get customer instance
13 | const customer = client.Customer({
14 | customer_id: googleAdsConfig.login_customer_id,
15 | });
16 |
17 | /**
18 | * Generate keyword ideas based on a seed keyword
19 | * @param {string} keyword - Seed keyword
20 | * @param {string} language - Language code (e.g., 'en')
21 | * @param {Array} locations - Array of location IDs
22 | * @param {number} limit - Maximum number of keyword ideas to return
23 | * @returns {Promise<Array>} - Array of keyword ideas with metrics
24 | */
25 | export const generateKeywordIdeas = async (keyword, language = 'en', locations = [2250], limit = 50) => {
26 | try {
27 | // Create keyword text list
28 | const keywordTexts = [keyword];
29 |
30 | // Generate keyword ideas
31 | const keywordIdeas = await customer.keywordPlanner.generateKeywordIdeas({
32 | keywordTexts,
33 | language,
34 | geoTargetConstants: locations,
35 | keywordPlanNetwork: 'GOOGLE_SEARCH_AND_PARTNERS',
36 | });
37 |
38 | // Process and format the results
39 | const formattedResults = keywordIdeas.slice(0, limit).map((idea) => {
40 | return {
41 | keyword: idea.text,
42 | avgMonthlySearches: idea.keywordIdeaMetrics?.avgMonthlySearches || 0,
43 | competition: idea.keywordIdeaMetrics?.competition || 'UNKNOWN',
44 | competitionIndex: idea.keywordIdeaMetrics?.competitionIndex || 0,
45 | lowTopOfPageBidMicros: idea.keywordIdeaMetrics?.lowTopOfPageBidMicros
46 | ? parseFloat(idea.keywordIdeaMetrics.lowTopOfPageBidMicros) / 1000000
47 | : 0,
48 | highTopOfPageBidMicros: idea.keywordIdeaMetrics?.highTopOfPageBidMicros
49 | ? parseFloat(idea.keywordIdeaMetrics.highTopOfPageBidMicros) / 1000000
50 | : 0,
51 | };
52 | });
53 |
54 | return formattedResults;
55 | } catch (error) {
56 | console.error('Error generating keyword ideas:', error);
57 | throw new Error(`Failed to generate keyword ideas: ${error.message}`);
58 | }
59 | };
60 |
61 | /**
62 | * Get keyword volume and metrics for a list of keywords
63 | * @param {Array} keywords - Array of keywords to get metrics for
64 | * @param {string} language - Language code (e.g., 'en')
65 | * @param {Array} locations - Array of location IDs
66 | * @returns {Promise<Array>} - Array of keywords with metrics
67 | */
68 | export const getKeywordMetrics = async (keywords, language = 'en', locations = [2250]) => {
69 | try {
70 | // Generate keyword ideas for the provided keywords
71 | const keywordIdeas = await customer.keywordPlanner.generateKeywordIdeas({
72 | keywordTexts: keywords,
73 | language,
74 | geoTargetConstants: locations,
75 | keywordPlanNetwork: 'GOOGLE_SEARCH_AND_PARTNERS',
76 | });
77 |
78 | // Process and format the results
79 | const formattedResults = keywordIdeas.map((idea) => {
80 | return {
81 | keyword: idea.text,
82 | avgMonthlySearches: idea.keywordIdeaMetrics?.avgMonthlySearches || 0,
83 | competition: idea.keywordIdeaMetrics?.competition || 'UNKNOWN',
84 | competitionIndex: idea.keywordIdeaMetrics?.competitionIndex || 0,
85 | lowTopOfPageBidMicros: idea.keywordIdeaMetrics?.lowTopOfPageBidMicros
86 | ? parseFloat(idea.keywordIdeaMetrics.lowTopOfPageBidMicros) / 1000000
87 | : 0,
88 | highTopOfPageBidMicros: idea.keywordIdeaMetrics?.highTopOfPageBidMicros
89 | ? parseFloat(idea.keywordIdeaMetrics.highTopOfPageBidMicros) / 1000000
90 | : 0,
91 | };
92 | });
93 |
94 | return formattedResults;
95 | } catch (error) {
96 | console.error('Error getting keyword metrics:', error);
97 | throw new Error(`Failed to get keyword metrics: ${error.message}`);
98 | }
99 | };
100 |
101 | /**
102 | * Get historical metrics for keywords
103 | * @param {Array} keywords - Array of keywords to get historical metrics for
104 | * @param {string} language - Language code (e.g., 'en')
105 | * @param {Array} locations - Array of location IDs
106 | * @returns {Promise<Array>} - Array of keywords with historical metrics
107 | */
108 | export const getHistoricalMetrics = async (keywords, language = 'en', locations = [2250]) => {
109 | try {
110 | // Create a keyword plan
111 | const keywordPlan = await customer.keywordPlans.create({
112 | name: `Keyword Plan ${Date.now()}`,
113 | });
114 |
115 | // Create a keyword plan campaign
116 | const keywordPlanCampaign = await customer.keywordPlanCampaigns.create({
117 | keywordPlan: keywordPlan.resource_name,
118 | name: 'Keyword Plan Campaign',
119 | cpcBidMicros: 1000000, // $1.00
120 | geoTargets: locations.map(locationId => ({
121 | geoTargetConstant: `geoTargetConstants/${locationId}`,
122 | })),
123 | languageConstant: `languageConstants/${language}`,
124 | });
125 |
126 | // Create a keyword plan ad group
127 | const keywordPlanAdGroup = await customer.keywordPlanAdGroups.create({
128 | keywordPlanCampaign: keywordPlanCampaign.resource_name,
129 | name: 'Keyword Plan Ad Group',
130 | cpcBidMicros: 1000000, // $1.00
131 | });
132 |
133 | // Create keyword plan keywords
134 | await Promise.all(
135 | keywords.map(keyword =>
136 | customer.keywordPlanKeywords.create({
137 | keywordPlanAdGroup: keywordPlanAdGroup.resource_name,
138 | text: keyword,
139 | cpcBidMicros: 1000000, // $1.00
140 | })
141 | )
142 | );
143 |
144 | // Generate forecast metrics
145 | const forecastMetrics = await customer.keywordPlans.generateForecastMetrics({
146 | keywordPlan: keywordPlan.resource_name,
147 | });
148 |
149 | // Clean up by removing the keyword plan
150 | await customer.keywordPlans.delete({
151 | resource_name: keywordPlan.resource_name,
152 | });
153 |
154 | return forecastMetrics;
155 | } catch (error) {
156 | console.error('Error getting historical metrics:', error);
157 | throw new Error(`Failed to get historical metrics: ${error.message}`);
158 | }
159 | };
160 |
161 | export default {
162 | generateKeywordIdeas,
163 | getKeywordMetrics,
164 | getHistoricalMetrics,
165 | };
```