mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
feat: Adds keyword search volume data feature for tracked keywords.
- Adds a volume field in the keyword table. - Adds a button in the Adwords Integration screen to update all the tracked keywords. - When a new keyword is added, the volume data is automatically fetched. - Adds ability to sort keywords based on search volume.
This commit is contained in:
parent
4d15989b28
commit
2a1fc0e43d
@ -22,6 +22,7 @@ export const dummyKeywords = [
|
|||||||
lastUpdated: '2022-11-15T10:49:53.113',
|
lastUpdated: '2022-11-15T10:49:53.113',
|
||||||
added: '2022-11-11T10:01:06.951',
|
added: '2022-11-11T10:01:06.951',
|
||||||
position: 19,
|
position: 19,
|
||||||
|
volume: 10000,
|
||||||
history: {
|
history: {
|
||||||
'2022-11-11': 21,
|
'2022-11-11': 21,
|
||||||
'2022-11-12': 24,
|
'2022-11-12': 24,
|
||||||
@ -45,6 +46,7 @@ export const dummyKeywords = [
|
|||||||
lastUpdated: '2022-11-15T10:49:53.119',
|
lastUpdated: '2022-11-15T10:49:53.119',
|
||||||
added: '2022-11-15T10:01:06.951',
|
added: '2022-11-15T10:01:06.951',
|
||||||
position: 29,
|
position: 29,
|
||||||
|
volume: 1200,
|
||||||
history: {
|
history: {
|
||||||
'2022-11-11': 33,
|
'2022-11-11': 33,
|
||||||
'2022-11-12': 34,
|
'2022-11-12': 34,
|
||||||
|
@ -36,7 +36,7 @@ const ChartSlim = ({ labels, sreies, noMaxLimit = false }:ChartProps) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return <div className='w-[100px] h-[30px] rounded border border-gray-200'>
|
return <div className='w-[80px] h-[30px] rounded border border-gray-200'>
|
||||||
<Line
|
<Line
|
||||||
datasetIdKey='XXX'
|
datasetIdKey='XXX'
|
||||||
options={options}
|
options={options}
|
||||||
|
@ -6,6 +6,7 @@ import countries from '../../utils/countries';
|
|||||||
import ChartSlim from '../common/ChartSlim';
|
import ChartSlim from '../common/ChartSlim';
|
||||||
import KeywordPosition from './KeywordPosition';
|
import KeywordPosition from './KeywordPosition';
|
||||||
import { generateTheChartData } from '../../utils/client/generateChartData';
|
import { generateTheChartData } from '../../utils/client/generateChartData';
|
||||||
|
import { formattedNum } from '../../utils/client/helpers';
|
||||||
|
|
||||||
type KeywordProps = {
|
type KeywordProps = {
|
||||||
keywordData: KeywordType,
|
keywordData: KeywordType,
|
||||||
@ -40,7 +41,7 @@ const Keyword = (props: KeywordProps) => {
|
|||||||
scDataType = 'threeDays',
|
scDataType = 'threeDays',
|
||||||
} = props;
|
} = props;
|
||||||
const {
|
const {
|
||||||
keyword, domain, ID, city, position, url = '', lastUpdated, country, sticky, history = {}, updating = false, lastUpdateError = false,
|
keyword, domain, ID, city, position, url = '', lastUpdated, country, sticky, history = {}, updating = false, lastUpdateError = false, volume,
|
||||||
} = keywordData;
|
} = keywordData;
|
||||||
const [showOptions, setShowOptions] = useState(false);
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
const [showPositionError, setPositionError] = useState(false);
|
const [showPositionError, setPositionError] = useState(false);
|
||||||
@ -99,7 +100,7 @@ const Keyword = (props: KeywordProps) => {
|
|||||||
<Icon type="check" size={10} />
|
<Icon type="check" size={10} />
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
className='py-2 hover:text-blue-600 lg:flex lg:items-center lg:w-full lg:max-w-[200px]'
|
className={`py-2 hover:text-blue-600 lg:flex lg:items-center lg:w-full ${showSCData ? 'lg:max-w-[180px]' : 'lg:max-w-[240px]'}`}
|
||||||
onClick={() => showKeywordDetails()}>
|
onClick={() => showKeywordDetails()}>
|
||||||
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country][0]} />
|
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country][0]} />
|
||||||
<span className=' text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-30px)]'>{keyword}{city ? ` (${city})` : ''}</span>
|
<span className=' text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-30px)]'>{keyword}{city ? ` (${city})` : ''}</span>
|
||||||
@ -131,12 +132,18 @@ const Keyword = (props: KeywordProps) => {
|
|||||||
|
|
||||||
{chartData.labels.length > 0 && (
|
{chartData.labels.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className='hidden basis-32 grow-0 cursor-pointer lg:block'
|
className='hidden basis-20 grow-0 cursor-pointer lg:block'
|
||||||
onClick={() => showKeywordDetails()}>
|
onClick={() => showKeywordDetails()}>
|
||||||
<ChartSlim labels={chartData.labels} sreies={chartData.sreies} />
|
<ChartSlim labels={chartData.labels} sreies={chartData.sreies} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`keyword_best hidden bg-[#f8f9ff] w-fit min-w-[50px] h-12 p-2 text-base mt-[-20px] rounded right-5 lg:relative lg:block
|
||||||
|
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-24 lg:grow-0 lg:right-0 text-center`}>
|
||||||
|
{formattedNum(volume)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`keyword_url inline-block mt-4 mr-5 ml-5 lg:flex-1 text-gray-400 lg:m-0 max-w-[70px]
|
className={`keyword_url inline-block mt-4 mr-5 ml-5 lg:flex-1 text-gray-400 lg:m-0 max-w-[70px]
|
||||||
overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-none lg:pr-5`}>
|
overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-none lg:pr-5`}>
|
||||||
|
@ -76,6 +76,8 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
|||||||
{ value: 'date_desc', label: 'Oldest' },
|
{ value: 'date_desc', label: 'Oldest' },
|
||||||
{ value: 'alpha_asc', label: 'Alphabetically(A-Z)' },
|
{ value: 'alpha_asc', label: 'Alphabetically(A-Z)' },
|
||||||
{ value: 'alpha_desc', label: 'Alphabetically(Z-A)' },
|
{ value: 'alpha_desc', label: 'Alphabetically(Z-A)' },
|
||||||
|
{ value: 'vol_asc', label: 'Lowest Search Volume' },
|
||||||
|
{ value: 'vol_desc', label: 'Highest Search Volume' },
|
||||||
];
|
];
|
||||||
if (integratedConsole) {
|
if (integratedConsole) {
|
||||||
sortOptionChoices.push({ value: 'imp_desc', label: `Most Viewed${isConsole ? ' (Default)' : ''}` });
|
sortOptionChoices.push({ value: 'imp_desc', label: `Most Viewed${isConsole ? ' (Default)' : ''}` });
|
||||||
@ -170,8 +172,8 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
|||||||
{sortOptions && (
|
{sortOptions && (
|
||||||
<ul
|
<ul
|
||||||
data-testid="sort_options"
|
data-testid="sort_options"
|
||||||
className='sort_options mt-2 border absolute min-w-[0] right-0 rounded-lg
|
className='sort_options mt-2 border absolute w-48 min-w-[0] right-0 rounded-lg
|
||||||
max-h-96 bg-white z-[9999] w-44 overflow-y-auto styled-scrollbar'>
|
max-h-96 bg-white z-[9999] overflow-y-auto styled-scrollbar'>
|
||||||
{sortOptionChoices.map((sortOption) => {
|
{sortOptionChoices.map((sortOption) => {
|
||||||
return <li
|
return <li
|
||||||
key={sortOption.value}
|
key={sortOption.value}
|
||||||
|
@ -158,9 +158,10 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
|||||||
</span>
|
</span>
|
||||||
<span className='domKeywords_head_position flex-1 basis-24 grow-0 text-center'>Position</span>
|
<span className='domKeywords_head_position flex-1 basis-24 grow-0 text-center'>Position</span>
|
||||||
<span className='domKeywords_head_best flex-1 basis-16 grow-0 text-center'>Best</span>
|
<span className='domKeywords_head_best flex-1 basis-16 grow-0 text-center'>Best</span>
|
||||||
<span className='domKeywords_head_history flex-1 basis-32 grow-0 '>History (7d)</span>
|
<span className='domKeywords_head_history flex-1 basis-20 grow-0'>History (7d)</span>
|
||||||
|
<span className='domKeywords_head_volume flex-1 basis-24 grow-0 text-center'>Volume</span>
|
||||||
<span className='domKeywords_head_url flex-1'>URL</span>
|
<span className='domKeywords_head_url flex-1'>URL</span>
|
||||||
<span className='domKeywords_head_updated flex-1'>Updated</span>
|
<span className='domKeywords_head_updated flex-1 relative left-3'>Updated</span>
|
||||||
{showSCData && (
|
{showSCData && (
|
||||||
<div className='domKeywords_head_sc flex-1 min-w-[170px] mr-7 text-center'>
|
<div className='domKeywords_head_sc flex-1 min-w-[170px] mr-7 text-center'>
|
||||||
{/* Search Console */}
|
{/* Search Console */}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTestAdwordsIntegration } from '../../services/adwords';
|
import { useMutateKeywordsVolume, useTestAdwordsIntegration } from '../../services/adwords';
|
||||||
import Icon from '../common/Icon';
|
import Icon from '../common/Icon';
|
||||||
import SecretField from '../common/SecretField';
|
import SecretField from '../common/SecretField';
|
||||||
|
|
||||||
@ -24,7 +24,10 @@ const AdWordsSettings = ({ settings, settingsError, updateSettings, performUpdat
|
|||||||
} = settings || {};
|
} = settings || {};
|
||||||
|
|
||||||
const { mutate: testAdWordsIntegration, isLoading: isTesting } = useTestAdwordsIntegration();
|
const { mutate: testAdWordsIntegration, isLoading: isTesting } = useTestAdwordsIntegration();
|
||||||
|
const { mutate: getAllVolumeData, isLoading: isUpdatingVolume } = useMutateKeywordsVolume();
|
||||||
|
|
||||||
const cloudProjectIntegrated = adwords_client_id && adwords_client_secret && adwords_refresh_token;
|
const cloudProjectIntegrated = adwords_client_id && adwords_client_secret && adwords_refresh_token;
|
||||||
|
const hasAllCredentials = adwords_client_id && adwords_client_secret && adwords_refresh_token && adwords_developer_token && adwords_account_id;
|
||||||
|
|
||||||
const udpateAndAuthenticate = () => {
|
const udpateAndAuthenticate = () => {
|
||||||
if (adwords_client_id && adwords_client_secret) {
|
if (adwords_client_id && adwords_client_secret) {
|
||||||
@ -40,11 +43,17 @@ const AdWordsSettings = ({ settings, settingsError, updateSettings, performUpdat
|
|||||||
};
|
};
|
||||||
|
|
||||||
const testIntegration = () => {
|
const testIntegration = () => {
|
||||||
if (adwords_client_id && adwords_client_secret && adwords_refresh_token && adwords_developer_token && adwords_account_id) {
|
if (hasAllCredentials) {
|
||||||
testAdWordsIntegration({ developer_token: adwords_developer_token, account_id: adwords_account_id });
|
testAdWordsIntegration({ developer_token: adwords_developer_token, account_id: adwords_account_id });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateVolumeData = () => {
|
||||||
|
if (hasAllCredentials) {
|
||||||
|
getAllVolumeData({ domain: 'all' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
@ -98,9 +107,9 @@ const AdWordsSettings = ({ settings, settingsError, updateSettings, performUpdat
|
|||||||
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
|
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
|
||||||
<button
|
<button
|
||||||
className={`py-2 px-5 w-full text-sm font-semibold rounded bg-indigo-50 text-blue-700 border border-indigo-100
|
className={`py-2 px-5 w-full text-sm font-semibold rounded bg-indigo-50 text-blue-700 border border-indigo-100
|
||||||
${adwords_client_id && adwords_client_secret && adwords_refresh_token ? 'cursor-pointer' : 'cursor-not-allowed opacity-40'}
|
${hasAllCredentials ? 'cursor-pointer' : 'cursor-not-allowed opacity-40'}
|
||||||
hover:bg-blue-700 hover:text-white transition`}
|
hover:bg-blue-700 hover:text-white transition`}
|
||||||
title='Insert All the data in the above fields to Authenticate'
|
title={hasAllCredentials ? '' : 'Insert All the data in the above fields to Test the Integration'}
|
||||||
onClick={testIntegration}>
|
onClick={testIntegration}>
|
||||||
{isTesting && <Icon type='loading' />}
|
{isTesting && <Icon type='loading' />}
|
||||||
<Icon type='adwords' size={14} /> Test AdWords Integration
|
<Icon type='adwords' size={14} /> Test AdWords Integration
|
||||||
@ -108,6 +117,24 @@ const AdWordsSettings = ({ settings, settingsError, updateSettings, performUpdat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='mt-4 mb-4 border-b border-gray-100 pt-4 pb-0 relative'>
|
||||||
|
{!hasAllCredentials && <div className=' absolute w-full h-full z-50' />}
|
||||||
|
<h4 className=' mb-3 font-semibold text-blue-700'>Update Keyword Volume Data</h4>
|
||||||
|
<div className={!hasAllCredentials ? 'opacity-40' : ''}>
|
||||||
|
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
|
||||||
|
<p>Update Volume data for all your Tracked Keywords.</p>
|
||||||
|
</div>
|
||||||
|
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
|
||||||
|
<button
|
||||||
|
className={`py-2 px-5 w-full text-sm font-semibold rounded bg-indigo-50 text-blue-700 border border-indigo-100
|
||||||
|
${hasAllCredentials ? 'cursor-pointer' : 'cursor-not-allowed opacity-40'}
|
||||||
|
hover:bg-blue-700 hover:text-white transition`}
|
||||||
|
onClick={updateVolumeData}>
|
||||||
|
<Icon type={isUpdatingVolume ? 'loading' : 'reload'} size={isUpdatingVolume ? 16 : 12} /> Update Keywords Volume
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className='mb-4 text-xs'>
|
<p className='mb-4 text-xs'>
|
||||||
<a target='_blank' rel='noreferrer' href='https://docs.serpbear.com/keyword-research' className=' underline text-blue-600'>Integrate Google Adwords</a> to get Keyword Ideas & Search Volume.{' '}
|
<a target='_blank' rel='noreferrer' href='https://docs.serpbear.com/keyword-research' className=' underline text-blue-600'>Integrate Google Adwords</a> to get Keyword Ideas & Search Volume.{' '}
|
||||||
</p>
|
</p>
|
||||||
|
@ -0,0 +1,35 @@
|
|||||||
|
// Migration: Adds volume field to the keyword table.
|
||||||
|
|
||||||
|
// CLI Migration
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
return queryInterface.sequelize.transaction(async (t) => {
|
||||||
|
try {
|
||||||
|
const keywordTableDefinition = await queryInterface.describeTable('keyword');
|
||||||
|
if (keywordTableDefinition) {
|
||||||
|
if (!keywordTableDefinition.volume) {
|
||||||
|
await queryInterface.addColumn('keyword', 'volume', {
|
||||||
|
type: Sequelize.DataTypes.STRING, allowNull: false, defaultValue: 0,
|
||||||
|
}, { transaction: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('error :', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: (queryInterface) => {
|
||||||
|
return queryInterface.sequelize.transaction(async (t) => {
|
||||||
|
try {
|
||||||
|
const keywordTableDefinition = await queryInterface.describeTable('keyword');
|
||||||
|
if (keywordTableDefinition) {
|
||||||
|
if (keywordTableDefinition.volume) {
|
||||||
|
await queryInterface.removeColumn('keyword', 'volume', { transaction: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('error :', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
@ -47,6 +47,9 @@ class Keyword extends Model {
|
|||||||
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
|
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
|
||||||
history!: string;
|
history!: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0 })
|
||||||
|
volume!: number;
|
||||||
|
|
||||||
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
|
@Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) })
|
||||||
url!: string;
|
url!: string;
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import verifyUser from '../../utils/verifyUser';
|
|||||||
import parseKeywords from '../../utils/parseKeywords';
|
import parseKeywords from '../../utils/parseKeywords';
|
||||||
import { integrateKeywordSCData, readLocalSCData } from '../../utils/searchConsole';
|
import { integrateKeywordSCData, readLocalSCData } from '../../utils/searchConsole';
|
||||||
import refreshAndUpdateKeywords from '../../utils/refresh';
|
import refreshAndUpdateKeywords from '../../utils/refresh';
|
||||||
|
import { getKeywordsVolume, updateKeywordsVolumeData } from '../../utils/adwords';
|
||||||
|
|
||||||
type KeywordsGetResponse = {
|
type KeywordsGetResponse = {
|
||||||
keywords?: KeywordType[],
|
keywords?: KeywordType[],
|
||||||
@ -103,8 +104,20 @@ const addKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
|
|||||||
const newKeywords:Keyword[] = await Keyword.bulkCreate(keywordsToAdd);
|
const newKeywords:Keyword[] = await Keyword.bulkCreate(keywordsToAdd);
|
||||||
const formattedkeywords = newKeywords.map((el) => el.get({ plain: true }));
|
const formattedkeywords = newKeywords.map((el) => el.get({ plain: true }));
|
||||||
const keywordsParsed: KeywordType[] = parseKeywords(formattedkeywords);
|
const keywordsParsed: KeywordType[] = parseKeywords(formattedkeywords);
|
||||||
|
|
||||||
|
// Queue the SERP Scraping Process
|
||||||
const settings = await getAppSettings();
|
const settings = await getAppSettings();
|
||||||
refreshAndUpdateKeywords(newKeywords, settings); // Queue the SERP Scraping Process
|
refreshAndUpdateKeywords(newKeywords, settings);
|
||||||
|
|
||||||
|
// Update the Keyword Volume
|
||||||
|
const { adwords_account_id, adwords_client_id, adwords_client_secret, adwords_developer_token } = settings;
|
||||||
|
if (adwords_account_id && adwords_client_id && adwords_client_secret && adwords_developer_token) {
|
||||||
|
const keywordsVolumeData = await getKeywordsVolume(keywordsParsed);
|
||||||
|
if (keywordsVolumeData.volumes !== false) {
|
||||||
|
await updateKeywordsVolumeData(keywordsVolumeData.volumes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({ keywords: keywordsParsed });
|
return res.status(201).json({ keywords: keywordsParsed });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[ERROR] Adding New Keywords ', error);
|
console.log('[ERROR] Adding New Keywords ', error);
|
||||||
|
@ -93,6 +93,7 @@ const getKeywordSearchResults = async (req: NextApiRequest, res: NextApiResponse
|
|||||||
country: req.query.country as string,
|
country: req.query.country as string,
|
||||||
domain: '',
|
domain: '',
|
||||||
lastUpdated: '',
|
lastUpdated: '',
|
||||||
|
volume: 0,
|
||||||
added: '',
|
added: '',
|
||||||
position: 111,
|
position: 111,
|
||||||
sticky: false,
|
sticky: false,
|
||||||
|
67
pages/api/volume.ts
Normal file
67
pages/api/volume.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
import db from '../../database/database';
|
||||||
|
import Keyword from '../../database/models/keyword';
|
||||||
|
import verifyUser from '../../utils/verifyUser';
|
||||||
|
import parseKeywords from '../../utils/parseKeywords';
|
||||||
|
import { getKeywordsVolume, updateKeywordsVolumeData } from '../../utils/adwords';
|
||||||
|
|
||||||
|
type KeywordsRefreshRes = {
|
||||||
|
keywords?: KeywordType[]
|
||||||
|
error?: string|null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
await db.sync();
|
||||||
|
const authorized = verifyUser(req, res);
|
||||||
|
if (authorized !== 'authorized') {
|
||||||
|
return res.status(401).json({ error: authorized });
|
||||||
|
}
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
return updatekeywordVolume(req, res);
|
||||||
|
}
|
||||||
|
return res.status(502).json({ error: 'Unrecognized Route.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatekeywordVolume = async (req: NextApiRequest, res: NextApiResponse<KeywordsRefreshRes>) => {
|
||||||
|
const { keywords = [], domain = '', update = true } = req.body || {};
|
||||||
|
if (keywords.length === 0 && !domain) {
|
||||||
|
return res.status(400).json({ error: 'Please provide keyword Ids or a domain name.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let keywordsToSend: KeywordType[] = [];
|
||||||
|
if (keywords.length > 0) {
|
||||||
|
const allKeywords:Keyword[] = await Keyword.findAll({ where: { ID: { [Op.in]: keywords } } });
|
||||||
|
keywordsToSend = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
|
||||||
|
}
|
||||||
|
if (domain) {
|
||||||
|
// const allDomain = domain === 'all';
|
||||||
|
// const allKeywords:Keyword[] = allDomain ? await Keyword.findAll() : await Keyword.findAll(allDomain ? {} : { where: { domain } });
|
||||||
|
// keywordsToSend = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keywordsToSend.length > 0) {
|
||||||
|
const keywordsVolumeData = await getKeywordsVolume(keywordsToSend);
|
||||||
|
// console.log('keywordsVolumeData :', keywordsVolumeData);
|
||||||
|
if (keywordsVolumeData.error) {
|
||||||
|
return res.status(400).json({ keywords: [], error: keywordsVolumeData.error });
|
||||||
|
}
|
||||||
|
if (keywordsVolumeData.volumes !== false) {
|
||||||
|
if (update) {
|
||||||
|
const updated = await updateKeywordsVolumeData(keywordsVolumeData.volumes);
|
||||||
|
if (updated) {
|
||||||
|
return res.status(200).json({ keywords });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Error Fetching Keywords Volume Data from Google Adwords' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json({ keywords: [], error: 'Error Updating Keywords Volume data' });
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[Error] updating keywords Volume Data: ', error);
|
||||||
|
return res.status(400).json({ error: 'Error Updating Keywords Volume data' });
|
||||||
|
}
|
||||||
|
};
|
@ -99,3 +99,30 @@ export function useMutateFavKeywordIdeas(router:NextRouter, onSuccess?: Function
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMutateKeywordsVolume(onSuccess?: Function) {
|
||||||
|
return useMutation(async (data:Record<string, any>) => {
|
||||||
|
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
|
||||||
|
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ ...data }) };
|
||||||
|
const res = await fetch(`${window.location.origin}/api/volume`, fetchOpts);
|
||||||
|
if (res.status >= 400 && res.status < 600) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
throw new Error(errorData?.error ? errorData.error : 'Bad response from server');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}, {
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
toast('Keyword Volume Data Loaded Successfully! Reloading Page...', { icon: '✔️' });
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(false);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.log('Error Loading Keyword Volume Data!!!', error);
|
||||||
|
toast('Error Loading Keyword Volume Data', { icon: '⚠️' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -16,6 +16,7 @@ module.exports = {
|
|||||||
'w-[240px]',
|
'w-[240px]',
|
||||||
'min-w-[270px]',
|
'min-w-[270px]',
|
||||||
'min-w-[180px]',
|
'min-w-[180px]',
|
||||||
|
'max-w-[180px]',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
|
1
types.d.ts
vendored
1
types.d.ts
vendored
@ -32,6 +32,7 @@ type KeywordType = {
|
|||||||
lastUpdated: string,
|
lastUpdated: string,
|
||||||
added: string,
|
added: string,
|
||||||
position: number,
|
position: number,
|
||||||
|
volume: number,
|
||||||
sticky: boolean,
|
sticky: boolean,
|
||||||
history: KeywordHistory,
|
history: KeywordHistory,
|
||||||
lastResult: KeywordLastResult[],
|
lastResult: KeywordLastResult[],
|
||||||
|
143
utils/adwords.ts
143
utils/adwords.ts
@ -1,6 +1,7 @@
|
|||||||
import { readFile, writeFile } from 'fs/promises';
|
import { readFile, writeFile } from 'fs/promises';
|
||||||
import Cryptr from 'cryptr';
|
import Cryptr from 'cryptr';
|
||||||
import TTLCache from '@isaacs/ttlcache';
|
import TTLCache from '@isaacs/ttlcache';
|
||||||
|
import { setTimeout as sleep } from 'timers/promises';
|
||||||
import Keyword from '../database/models/keyword';
|
import Keyword from '../database/models/keyword';
|
||||||
import parseKeywords from './parseKeywords';
|
import parseKeywords from './parseKeywords';
|
||||||
import countries from './countries';
|
import countries from './countries';
|
||||||
@ -8,15 +9,17 @@ import { readLocalSCData } from './searchConsole';
|
|||||||
|
|
||||||
const memoryCache = new TTLCache({ max: 10000 });
|
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 = {
|
type keywordIdeasResponseItem = {
|
||||||
keywordIdeaMetrics: {
|
keywordIdeaMetrics: keywordIdeasMetrics,
|
||||||
competition: IdeaKeyword['competition'],
|
|
||||||
monthlySearchVolumes: {month : string, year : string, monthlySearches : string}[],
|
|
||||||
avgMonthlySearches: string,
|
|
||||||
competitionIndex: string,
|
|
||||||
lowTopOfPageBidMicros: string,
|
|
||||||
highTopOfPageBidMicros: string
|
|
||||||
},
|
|
||||||
text: string,
|
text: string,
|
||||||
keywordAnnotations: Object
|
keywordAnnotations: Object
|
||||||
};
|
};
|
||||||
@ -250,6 +253,130 @@ const extractAdwordskeywordIdeas = (keywordIdeas:keywordIdeasResponseItem[], opt
|
|||||||
return keywords.sort((a: IdeaKeyword, b: IdeaKeyword) => (b.avgMonthlySearches > a.avgMonthlySearches ? 1 : -1));
|
return keywords.sort((a: IdeaKeyword, b: IdeaKeyword) => (b.avgMonthlySearches > a.avgMonthlySearches ? 1 : -1));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves keyword search volumes from Google Adwords 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 Adwords Credentials', volumes: false }; }
|
||||||
|
const { client_id, client_secret, developer_token, account_id } = credentials;
|
||||||
|
if (!client_id || !client_secret || !developer_token || !account_id) {
|
||||||
|
return { error: 'Adwords Not Integrated Properly', volumes: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Access Token
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
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] Adwords Volume Request Response :', ideaData?.error?.details[0]?.errors[0]?.message);
|
||||||
|
console.log('Response from AdWords :', 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 Adwords :', 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.
|
* 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.
|
* @param {string} domain - The `domain` parameter is the domain slug for which the keyword Ideas are fetched.
|
||||||
|
@ -28,6 +28,12 @@ export const sortKeywords = (theKeywords:KeywordType[], sortBy:string, scDataTyp
|
|||||||
case 'alpha_desc':
|
case 'alpha_desc':
|
||||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (a.keyword > b.keyword ? 1 : -1));
|
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (a.keyword > b.keyword ? 1 : -1));
|
||||||
break;
|
break;
|
||||||
|
case 'vol_asc':
|
||||||
|
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (b.volume - a.volume));
|
||||||
|
break;
|
||||||
|
case 'vol_desc':
|
||||||
|
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (a.volume - b.volume));
|
||||||
|
break;
|
||||||
case 'imp_desc':
|
case 'imp_desc':
|
||||||
if (scDataType) {
|
if (scDataType) {
|
||||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => {
|
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => {
|
||||||
|
Loading…
Reference in New Issue
Block a user