mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
433 lines
19 KiB
TypeScript
433 lines
19 KiB
TypeScript
import { readFile, writeFile } from 'fs/promises';
|
|
import Cryptr from 'cryptr';
|
|
import TTLCache from '@isaacs/ttlcache';
|
|
import { setTimeout as sleep } from 'timers/promises';
|
|
import Keyword from '../database/models/keyword';
|
|
import parseKeywords from './parseKeywords';
|
|
import countries from './countries';
|
|
import { readLocalSCData } from './searchConsole';
|
|
|
|
const memoryCache = new TTLCache({ max: 10000 });
|
|
|
|
type keywordIdeasMetrics = {
|
|
competition: IdeaKeyword['competition'],
|
|
monthlySearchVolumes: {month : string, year : string, monthlySearches : string}[],
|
|
avgMonthlySearches: string,
|
|
competitionIndex: string,
|
|
lowTopOfPageBidMicros: string,
|
|
highTopOfPageBidMicros: string
|
|
}
|
|
|
|
type keywordIdeasResponseItem = {
|
|
keywordIdeaMetrics: keywordIdeasMetrics,
|
|
text: string,
|
|
keywordAnnotations: Object
|
|
};
|
|
|
|
type IdeaSettings = {
|
|
country?: string;
|
|
city?: string;
|
|
language?: string;
|
|
keywords?: string[];
|
|
url?: string;
|
|
domain?:string;
|
|
seedType: 'auto' | 'custom' | 'tracking' | 'searchconsole'
|
|
}
|
|
|
|
type IdeaDatabaseUpdateData = {
|
|
keywords?: IdeaKeyword[],
|
|
settings?: IdeaSettings,
|
|
favorites?: IdeaKeyword[]
|
|
}
|
|
|
|
export type KeywordIdeasDatabase = {
|
|
keywords: IdeaKeyword[],
|
|
favorites: IdeaKeyword[],
|
|
settings: IdeaSettings,
|
|
updated: number
|
|
}
|
|
|
|
/**
|
|
* The function `getAdwordsCredentials` reads and decrypts Google Ads credentials from the App settings file.
|
|
* @returns {Promise<false | AdwordsCredentials>} returns either a decrypted `AdwordsCredentials` object if the settings are successfully decrypted,
|
|
* or `false` if the decryption process fails.
|
|
*/
|
|
export const getAdwordsCredentials = async (): Promise<false | AdwordsCredentials> => {
|
|
try {
|
|
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
|
|
const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
let decryptedSettings: false | AdwordsCredentials = false;
|
|
|
|
try {
|
|
const cryptr = new Cryptr(process.env.SECRET as string);
|
|
const client_id = settings.adwords_client_id ? cryptr.decrypt(settings.adwords_client_id) : '';
|
|
const client_secret = settings.adwords_client_secret ? cryptr.decrypt(settings.adwords_client_secret) : '';
|
|
const developer_token = settings.adwords_developer_token ? cryptr.decrypt(settings.adwords_developer_token) : '';
|
|
const account_id = settings.adwords_account_id ? cryptr.decrypt(settings.adwords_account_id) : '';
|
|
const refresh_token = settings.adwords_refresh_token ? cryptr.decrypt(settings.adwords_refresh_token) : '';
|
|
|
|
decryptedSettings = {
|
|
client_id,
|
|
client_secret,
|
|
developer_token,
|
|
account_id,
|
|
refresh_token,
|
|
};
|
|
} catch (error) {
|
|
console.log('Error Decrypting Settings API Keys!');
|
|
}
|
|
|
|
return decryptedSettings;
|
|
} catch (error) {
|
|
console.log('[ERROR] Getting App Settings. ', error);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* retrieves an access token using Google Ads credentials for Google API authentication.
|
|
* @param {AdwordsCredentials} credentials - The `credentials` to use to generate the access token,
|
|
* @returns {Promise<string>} the fetched access token or an empty string if failed.
|
|
*/
|
|
export const getAdwordsAccessToken = async (credentials:AdwordsCredentials) => {
|
|
const { client_id, client_secret, refresh_token } = credentials;
|
|
try {
|
|
const resp = await fetch('https://www.googleapis.com/oauth2/v3/token', {
|
|
method: 'POST',
|
|
body: new URLSearchParams({ grant_type: 'refresh_token', client_id, client_secret, refresh_token }),
|
|
});
|
|
const tokens = await resp.json();
|
|
// console.log('token :', tokens);
|
|
return tokens?.access_token || '';
|
|
} catch (error) {
|
|
console.log('[Error] Getting Google Account Access Token:', error);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* The function `getAdwordsKeywordIdeas` retrieves keyword ideas from Google Ads API based on
|
|
* provided credentials and settings.
|
|
* @param {AdwordsCredentials} credentials - an object containing Google Ads credentials needed to authenticate
|
|
* the API request.
|
|
* @param {IdeaSettings} adwordsDomainOptions - an object that contains settings and options for fetching
|
|
* keyword ideas from Google Ads.
|
|
* @param {boolean} [test=false] - a boolean flag that indicates whether the function is being run in a test mode or not.
|
|
* When `test` is set to `true`, only 1 keyword is requested from adwords.
|
|
* @returns returns an array of fetched keywords (`fetchedKeywords`) after processing the Google Ads API response.
|
|
*/
|
|
export const getAdwordsKeywordIdeas = async (credentials:AdwordsCredentials, adwordsDomainOptions:IdeaSettings, test:boolean = false) => {
|
|
if (!credentials) { return false; }
|
|
const { account_id, developer_token } = credentials;
|
|
const { country = '2840', language = '1000', keywords = [], domain = '', seedType } = adwordsDomainOptions || {};
|
|
|
|
let accessToken = '';
|
|
|
|
const cachedAccessToken:string|false|undefined = memoryCache.get('adwords_token');
|
|
if (cachedAccessToken && !test) {
|
|
accessToken = cachedAccessToken;
|
|
} else {
|
|
accessToken = await getAdwordsAccessToken(credentials);
|
|
memoryCache.delete('adwords_token');
|
|
memoryCache.set('adwords_token', accessToken, { ttl: 3300000 });
|
|
}
|
|
|
|
let fetchedKeywords:IdeaKeyword[] = [];
|
|
if (accessToken) {
|
|
const seedKeywords = [...keywords];
|
|
|
|
// Load Keywords from Google Search Console File.
|
|
if (seedType === 'searchconsole' && domain) {
|
|
const domainSCData = await readLocalSCData(domain);
|
|
if (domainSCData && domainSCData.thirtyDays) {
|
|
const scKeywords = domainSCData.thirtyDays;
|
|
const sortedSCKeywords = scKeywords.sort((a, b) => (b.impressions > a.impressions ? 1 : -1));
|
|
sortedSCKeywords.slice(0, 100).forEach((sckeywrd) => {
|
|
if (sckeywrd.keyword && !seedKeywords.includes(sckeywrd.keyword)) {
|
|
seedKeywords.push(sckeywrd.keyword);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Load all Keywords from Database
|
|
if (seedType === 'tracking' && domain) {
|
|
const allKeywords:Keyword[] = await Keyword.findAll({ where: { domain } });
|
|
const currentKeywords: KeywordType[] = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
|
|
currentKeywords.forEach((keyword) => {
|
|
if (keyword.keyword && !seedKeywords.includes(keyword.keyword)) {
|
|
seedKeywords.push(keyword.keyword);
|
|
}
|
|
});
|
|
}
|
|
|
|
try {
|
|
// API: https://developers.google.com/google-ads/api/rest/reference/rest/v16/customers/generateKeywordIdeas
|
|
const customerID = account_id.replaceAll('-', '');
|
|
const geoTargetConstants = countries[country][3]; // '2840';
|
|
const reqPayload: Record<string, any> = {
|
|
geoTargetConstants: `geoTargetConstants/${geoTargetConstants}`,
|
|
language: `languageConstants/${language}`,
|
|
pageSize: test ? '1' : '1000',
|
|
};
|
|
if (['custom', 'searchconsole', 'tracking'].includes(seedType) && seedKeywords.length > 0) {
|
|
reqPayload.keywordSeed = { keywords: seedKeywords.slice(0, 20) };
|
|
}
|
|
if (seedType === 'auto' && domain) {
|
|
reqPayload.siteSeed = { site: domain };
|
|
}
|
|
|
|
const resp = await fetch(`https://googleads.googleapis.com/v16/customers/${customerID}:generateKeywordIdeas`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'developer-token': developer_token,
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'login-customer-id': customerID,
|
|
},
|
|
body: JSON.stringify(reqPayload),
|
|
});
|
|
const ideaData = await resp.json();
|
|
|
|
if (resp.status !== 200) {
|
|
console.log('[ERROR] Google Ads Response :', ideaData?.error?.details[0]?.errors[0]?.message);
|
|
// console.log('Response from Ads :', JSON.stringify(ideaData, null, 2));
|
|
}
|
|
|
|
if (ideaData?.results) {
|
|
fetchedKeywords = extractAdwordskeywordIdeas(ideaData.results as keywordIdeasResponseItem[], { country, domain });
|
|
}
|
|
|
|
if (!test && fetchedKeywords.length > 0) {
|
|
await updateLocalKeywordIdeas(domain, { keywords: fetchedKeywords, settings: adwordsDomainOptions });
|
|
}
|
|
} catch (error) {
|
|
console.log('[ERROR] Fetching Keyword Ideas from Google Ads :', error);
|
|
}
|
|
}
|
|
|
|
return fetchedKeywords;
|
|
};
|
|
|
|
/**
|
|
* The function `extractAdwordskeywordIdeas` processes keyword ideas data and returns an array of
|
|
* IdeaKeyword objects sorted by average monthly searches.
|
|
* @param {keywordIdeasResponseItem[]} keywordIdeas - The `keywordIdeas` parameter is an array of
|
|
* objects that contain keyword ideas and their metrics.
|
|
* @param options - The `options` parameter in the `extractAdwordskeywordIdeas` function is an object
|
|
* that can contain two properties: `country` and `domain`.
|
|
* @returns returns an array of `IdeaKeyword` array sorted based on the average monthly searches in descending order.
|
|
*/
|
|
const extractAdwordskeywordIdeas = (keywordIdeas:keywordIdeasResponseItem[], options:Record<string, string>) => {
|
|
const keywords: IdeaKeyword[] = [];
|
|
if (keywordIdeas.length > 0) {
|
|
const { country = '', domain = '' } = options;
|
|
keywordIdeas.forEach((kwRaw) => {
|
|
const { text, keywordIdeaMetrics } = kwRaw;
|
|
const { competition, competitionIndex = '0', avgMonthlySearches = '0', monthlySearchVolumes = [] } = keywordIdeaMetrics || {};
|
|
if (keywordIdeaMetrics?.avgMonthlySearches) {
|
|
const searchVolumeTrend: Record<string, string> = {};
|
|
const searchVolume = parseInt(avgMonthlySearches, 10);
|
|
monthlySearchVolumes.forEach((item) => {
|
|
searchVolumeTrend[`${item.month}-${item.year}`] = item.monthlySearches;
|
|
});
|
|
if (searchVolume > 100) {
|
|
keywords.push({
|
|
uid: `${country.toLowerCase()}:${text.replaceAll(' ', '-')}`,
|
|
keyword: text,
|
|
competition,
|
|
competitionIndex: competitionIndex !== null ? parseInt(competitionIndex, 10) : 0,
|
|
monthlySearchVolumes: searchVolumeTrend,
|
|
avgMonthlySearches: searchVolume,
|
|
added: new Date().getTime(),
|
|
updated: new Date().getTime(),
|
|
country,
|
|
domain,
|
|
position: 999,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return keywords.sort((a: IdeaKeyword, b: IdeaKeyword) => (b.avgMonthlySearches > a.avgMonthlySearches ? 1 : -1));
|
|
};
|
|
|
|
/**
|
|
* Retrieves keyword search volumes from Google Ads API based on provided keywords and their countries.
|
|
* @param {KeywordType[]} keywords - The keywords that you want to get the search volume data for.
|
|
* @returns returns a Promise that resolves to an object with a `volumes` and error `proprties`.
|
|
* The `volumes` propery which outputs `false` if the request fails and outputs the volume data in `{[keywordID]: volume}` object if succeeds.
|
|
* The `error` porperty that outputs the error message if any.
|
|
*/
|
|
export const getKeywordsVolume = async (keywords: KeywordType[]): Promise<{error?: string, volumes: false | Record<number, number>}> => {
|
|
const credentials = await getAdwordsCredentials();
|
|
if (!credentials) { return { error: 'Cannot Load Google Ads Credentials', volumes: false }; }
|
|
const { client_id, client_secret, developer_token, account_id } = credentials;
|
|
if (!client_id || !client_secret || !developer_token || !account_id) {
|
|
return { error: 'Google Ads Not Integrated Properly', volumes: false };
|
|
}
|
|
|
|
// Generate Access Token
|
|
let accessToken = '';
|
|
const cachedAccessToken:string|false|undefined = memoryCache.get('adwords_token');
|
|
if (cachedAccessToken) {
|
|
accessToken = cachedAccessToken;
|
|
} else {
|
|
accessToken = await getAdwordsAccessToken(credentials);
|
|
memoryCache.delete('adwords_token');
|
|
memoryCache.set('adwords_token', accessToken, { ttl: 3300000 });
|
|
}
|
|
const fetchedKeywords:Record<number, number> = {};
|
|
|
|
if (accessToken) {
|
|
// Group keywords based on their country.
|
|
const keywordRequests: Record<string, KeywordType[]> = {};
|
|
keywords.forEach((kw) => {
|
|
const kwCountry = kw.country;
|
|
if (keywordRequests[kwCountry]) {
|
|
keywordRequests[kwCountry].push(kw);
|
|
} else {
|
|
keywordRequests[kwCountry] = [kw];
|
|
}
|
|
});
|
|
|
|
// Send Requests to adwords based on grouped countries.
|
|
// Since adwords does not allow sending country data for each keyword we are making requests for.
|
|
for (const country in keywordRequests) {
|
|
if (Object.hasOwn(keywordRequests, country) && keywordRequests[country].length > 0) {
|
|
try {
|
|
// API: https://developers.google.com/google-ads/api/rest/reference/rest/v16/customers/generateKeywordHistoricalMetrics
|
|
const customerID = account_id.replaceAll('-', '');
|
|
const geoTargetConstants = countries[country][3]; // '2840';
|
|
const reqKeywords = keywordRequests[country].map((kw) => kw.keyword);
|
|
const reqPayload: Record<string, any> = {
|
|
keywords: [...new Set(reqKeywords)],
|
|
geoTargetConstants: `geoTargetConstants/${geoTargetConstants}`,
|
|
// language: `languageConstants/${language}`,
|
|
};
|
|
const resp = await fetch(`https://googleads.googleapis.com/v16/customers/${customerID}:generateKeywordHistoricalMetrics`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'developer-token': developer_token,
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'login-customer-id': customerID,
|
|
},
|
|
body: JSON.stringify(reqPayload),
|
|
});
|
|
const ideaData = await resp.json();
|
|
|
|
if (resp.status !== 200) {
|
|
console.log('[ERROR] Google Ads Volume Request Response :', ideaData?.error?.details[0]?.errors[0]?.message);
|
|
// console.log('Response from Google Ads :', JSON.stringify(ideaData, null, 2));
|
|
}
|
|
|
|
if (ideaData?.results) {
|
|
if (Array.isArray(ideaData.results) && ideaData.results.length > 0) {
|
|
const volumeDataObj:Map<string, number> = new Map();
|
|
ideaData.results.forEach((item:{ keywordMetrics: keywordIdeasMetrics, text: string }) => {
|
|
const kwVol = item?.keywordMetrics?.avgMonthlySearches;
|
|
volumeDataObj.set(`${country}:${item.text}`, kwVol ? parseInt(kwVol, 10) : 0);
|
|
});
|
|
|
|
keywordRequests[country].forEach((keyword) => {
|
|
const keywordKey = `${keyword.country}:${keyword.keyword}`;
|
|
if (volumeDataObj.has(keywordKey)) {
|
|
const volume = volumeDataObj.get(keywordKey);
|
|
if (volume !== undefined) {
|
|
fetchedKeywords[keyword.ID] = volume;
|
|
}
|
|
}
|
|
});
|
|
// console.log('fetchedKeywords :', fetchedKeywords);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('[ERROR] Fetching Keyword Volume from Google Ads :', error);
|
|
}
|
|
if (Object.keys(keywordRequests).length > 1) {
|
|
await sleep(7000);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { volumes: fetchedKeywords };
|
|
};
|
|
|
|
/**
|
|
* Updates volume data for keywords in the Keywords database using async/await and error handling.
|
|
* @param {false | Record<number, number>} volumesData - The `volumesData` parameter can either be `false` or an object containing
|
|
* keyword IDs as keys and corresponding volume data as values.
|
|
* @returns returns a Promise that resolves to `true` if `volumesData` is not `false` else it returns `false`.
|
|
*/
|
|
export const updateKeywordsVolumeData = async (volumesData: false | Record<number, number>) => {
|
|
if (volumesData === false) { return false; }
|
|
|
|
Object.keys(volumesData).forEach(async (keywordID) => {
|
|
const keyID = parseInt(keywordID, 10);
|
|
const volumeData = volumesData && volumesData[keyID] ? volumesData[keyID] : 0;
|
|
try {
|
|
await Keyword.update({ volume: volumeData }, { where: { ID: keyID } });
|
|
} catch (error) {
|
|
console.log('');
|
|
}
|
|
});
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* The function `getLocalKeywordIdeas` reads keyword ideas data from a local JSON file based on a domain slug and returns it as a Promise.
|
|
* @param {string} domain - The `domain` parameter is the domain slug for which the keyword Ideas are fetched.
|
|
* @returns returns either a `KeywordIdeasDatabase` object if the data is successfully retrieved , or it returns `false` if
|
|
* there are no keywords found in the retrieved data or if an error occurs during the process.
|
|
*/
|
|
export const getLocalKeywordIdeas = async (domain:string): Promise<false | KeywordIdeasDatabase> => {
|
|
try {
|
|
const domainName = domain.replaceAll('-', '.').replaceAll('_', '-');
|
|
const filename = `IDEAS_${domainName}.json`;
|
|
const keywordIdeasRaw = await readFile(`${process.cwd()}/data/${filename}`, { encoding: 'utf-8' });
|
|
const keywordIdeasData = JSON.parse(keywordIdeasRaw) as KeywordIdeasDatabase;
|
|
if (keywordIdeasData.keywords) {
|
|
return keywordIdeasData;
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
// console.log('[ERROR] Getting Local Ideas. ', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* The function `updateLocalKeywordIdeas` updates a local JSON file containing keyword ideas for a specific domain with new data provided.
|
|
* @param {string} domain - The `domain` parameter is the domain slug for which the keyword Ideas are being updated.
|
|
* @param {IdeaDatabaseUpdateData} data - The `data` parameter is an object of type `IdeaDatabaseUpdateData`.
|
|
* It contains the following properties: `keywords`, `favorites` & `settings`
|
|
* @returns The function `updateLocalKeywordIdeas` returns a Promise<boolean>.
|
|
*/
|
|
export const updateLocalKeywordIdeas = async (domain:string, data:IdeaDatabaseUpdateData): Promise<boolean> => {
|
|
try {
|
|
const domainName = domain.replaceAll('-', '.').replaceAll('_', '-');
|
|
const existingIdeas = await getLocalKeywordIdeas(domain);
|
|
const filename = `IDEAS_${domainName}.json`;
|
|
const fileContent = { ...existingIdeas, updated: new Date().getTime() };
|
|
if (data.keywords && Array.isArray(data.keywords) && data.keywords.length > 0) {
|
|
fileContent.keywords = data.keywords;
|
|
}
|
|
if (data.favorites && Array.isArray(data.favorites) && data.favorites.length > 0) {
|
|
fileContent.favorites = data.favorites;
|
|
}
|
|
if (data.settings) {
|
|
fileContent.settings = data.settings;
|
|
}
|
|
|
|
await writeFile(`${process.cwd()}/data/${filename}`, JSON.stringify(fileContent, null, 2), 'utf-8');
|
|
console.log(`Data saved to ${filename} successfully!`);
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`[Error] Saving data to IDEAS_${domain}.json: ${error}`);
|
|
return false;
|
|
}
|
|
};
|