feat: Adds the ability to setup Search Console through the UI.

- Adds the ability to add domain specific Search Console API Info through the Domain Settings panel.
- Adds the ability to add global Search Console API Info through the App Settings Panel.
- Adds better Search Console Error logging.
- Changes the App Settings Sidebar UI.
- Changers the Domain Settings Modal UI.
- Replaces html Input field with custom InputField component.
- Adds a new /domain api route to get the full domain info which includes the domain level Search console API.

closes #59, #146
This commit is contained in:
towfiqi
2024-02-08 22:14:24 +06:00
parent b2e97b2ebe
commit f04b10cf6b
22 changed files with 435 additions and 137 deletions

View File

@@ -253,6 +253,16 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
<path d="M15.75 9h3v2.25h-3z" fill={color} />
</svg>
}
{type === 'email'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path fill={color} d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zm-2 0l-8 5l-8-5zm0 12H4V8l8 5l8-5z" />
</svg>
}
{type === 'scraper'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 16 16">
<path fill={color} d="M1 3.5A2.5 2.5 0 0 1 3.5 1h7A2.5 2.5 0 0 1 13 3.5v1.53a4.538 4.538 0 0 0-1-.004V5H2v5.5A1.5 1.5 0 0 0 3.5 12h2.954l-.72.72a2.52 2.52 0 0 0-.242.28H3.5A2.5 2.5 0 0 1 1 10.5zm7.931 3.224l-.577-.578a.5.5 0 1 0-.708.708l.745.744c.144-.306.324-.6.54-.874M2 4h10v-.5A1.5 1.5 0 0 0 10.5 2h-7A1.5 1.5 0 0 0 2 3.5zm4.354 2.854a.5.5 0 1 0-.708-.708l-2 2a.5.5 0 0 0 0 .708l2 2a.5.5 0 0 0 .708-.708L4.707 8.5zm6.538-.83c.366.042.471.48.21.742l-.975.975a1.507 1.507 0 1 0 2.132 2.132l.975-.975c.261-.261.7-.156.742.21a3.518 3.518 0 0 1-4.676 3.723l-2.726 2.727a1.507 1.507 0 1 1-2.132-2.132L9.168 10.7a3.518 3.518 0 0 1 3.724-4.676" />
</svg>
}
{type === 'city'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 48 48">
<g fill="none">

View File

@@ -10,10 +10,10 @@ type InputFieldProps = {
const InputField = ({ label = '', value = '', placeholder = '', onChange, hasError = false }: InputFieldProps) => {
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
return (
<div className="field--input w-full relative">
<div className="field--input w-full relative flex justify-between items-center">
<label className={labelStyle}>{label}</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none
className={`p-2 border border-gray-200 rounded focus:outline-none w-[210px]
focus:border-blue-200 ${hasError ? ' border-red-400 focus:border-red-400' : ''} `}
type={'text'}
value={value}

View File

@@ -6,10 +6,11 @@ type ModalProps = {
children: React.ReactNode,
width?: string,
title?: string,
verticalCenter?: boolean,
closeModal: Function,
}
const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
const Modal = ({ children, width = '1/2', closeModal, title, verticalCenter = false }:ModalProps) => {
useOnKey('Escape', closeModal);
const closeOnBGClick = (e:React.SyntheticEvent) => {
@@ -21,8 +22,9 @@ const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
return (
<div className='modal fixed top-0 left-0 bg-white/[.7] w-full h-screen z-50' onClick={closeOnBGClick}>
<div
className={`modal__content max-w-[340px] absolute top-1/4 left-0 right-0 ml-auto mr-auto w-${width}
lg:max-w-md bg-white shadow-md rounded-md p-5 border-t-[1px] border-gray-100 text-base`}>
className={`modal__content max-w-[340px] absolute left-0 right-0 ml-auto mr-auto w-${width}
lg:max-w-md bg-white shadow-md rounded-md p-5 border-t-[1px] border-gray-100 text-base
${verticalCenter ? ' top-1/2 translate-y-[-50%]' : 'top-1/4'}`}>
{title && <h3 className=' font-semibold mb-3'>{title}</h3>}
<button
className='modal-close absolute right-2 top-2 p-2 cursor-pointer text-gray-400 transition-all

View File

@@ -14,15 +14,15 @@ const SecretField = ({ label = '', value = '', placeholder = '', onChange, hasEr
const [showValue, setShowValue] = useState(false);
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
return (
<div className="settings__section__secret mb-5 relative">
<div className="settings__section__secret mb-5 relative flex justify-between items-center">
<label className={labelStyle}>{label}</label>
<span
className="absolute top-8 right-0 px-2 py-1 cursor-pointer text-gray-400 select-none"
className="absolute top-1 right-0 px-2 py-1 cursor-pointer text-gray-400 select-none"
onClick={() => setShowValue(!showValue)}>
<Icon type={showValue ? 'eye-closed' : 'eye'} size={18} />
</span>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none
className={`w-[210px] p-2 border border-gray-200 rounded focus:outline-none
focus:border-blue-200 ${hasError ? ' border-red-400 focus:border-red-400' : ''} `}
type={showValue ? 'text' : 'password'}
value={value}

View File

@@ -9,6 +9,7 @@ type SelectFieldProps = {
defaultLabel: string,
options: SelectionOption[],
selected: string[],
label?: string,
multiple?: boolean,
updateField: Function,
minWidth?: number,
@@ -28,6 +29,7 @@ const SelectField = (props: SelectFieldProps) => {
maxHeight = 96,
rounded = 'rounded-3xl',
flags = false,
label = '',
emptyMsg = '' } = props;
const [showOptions, setShowOptions] = useState<boolean>(false);
@@ -66,12 +68,13 @@ const SelectField = (props: SelectFieldProps) => {
};
return (
<div className="select font-semibold text-gray-500">
<div className="select font-semibold text-gray-500 relative flex justify-between items-center">
{label && <label className='mb-2 font-semibold inline-block text-sm text-gray-700 capitalize'>{label}</label>}
<div
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none w-[180px] min-w-[${minWidth}px]
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none w-[210px] min-w-[${minWidth}px]
${showOptions ? 'border-indigo-200' : ''}`}
onClick={() => setShowOptions(!showOptions)}>
<span className={`w-[${minWidth - 30}px] inline-block truncate mr-2 capitalize`}>
<span className={'w-full inline-block truncate mr-2 capitalize'}>
{selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel}
</span>
{multiple && selected.length > 2
@@ -80,7 +83,7 @@ const SelectField = (props: SelectFieldProps) => {
</div>
{showOptions && (
<div
className={`select_list mt-1 border absolute min-w-[${minWidth}px]
className={`select_list mt-1 border absolute min-w-[${minWidth}px] top-[30px] right-0 w-[210px]
${rounded === 'rounded-3xl' ? 'rounded-lg' : rounded} max-h-${maxHeight} bg-white z-50 overflow-y-auto styled-scrollbar`}>
{options.length > 20 && (
<div className=''>

View File

@@ -2,7 +2,7 @@ import { useRouter } from 'next/router';
import { useState } from 'react';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
import { useDeleteDomain, useUpdateDomain } from '../../services/domains';
import { useDeleteDomain, useFetchDomain, useUpdateDomain } from '../../services/domains';
import InputField from '../common/InputField';
import SelectField from '../common/SelectField';
@@ -24,17 +24,21 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
const [domainSettings, setDomainSettings] = useState<DomainSettings>(() => ({
notification_interval: domain && domain.notification_interval ? domain.notification_interval : 'never',
notification_emails: domain && domain.notification_emails ? domain.notification_emails : '',
search_console: domain && domain.search_console ? JSON.parse(domain.search_console) : { property_type: 'domain', url: '' },
search_console: domain && domain.search_console ? JSON.parse(domain.search_console) : {
property_type: 'domain', url: '', client_email: '', private_key: '',
},
}));
const { mutate: updateMutate } = useUpdateDomain(() => closeModal(false));
const { mutate: deleteMutate } = useDeleteDomain(() => {
closeModal(false);
router.push('/domains');
const { mutate: updateMutate, error: domainUpdateError, isLoading: isUpdating } = useUpdateDomain(() => closeModal(false));
const { mutate: deleteMutate } = useDeleteDomain(() => { closeModal(false); router.push('/domains'); });
// Get the Full Domain Data along with the Search Console API Data.
useFetchDomain(router, domain && domain.domain ? domain.domain : '', (domainObj:DomainType) => {
const currentSearchConsoleSettings = domainObj.search_console && JSON.parse(domainObj.search_console);
setDomainSettings({ ...domainSettings, search_console: currentSearchConsoleSettings || domainSettings.search_console });
});
const updateDomain = () => {
console.log('Domain: ');
let error: DomainSettingsError | null = null;
if (domainSettings.notification_emails) {
const notification_emails = domainSettings.notification_emails.split(',');
@@ -55,23 +59,24 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
}
};
const tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm select-none';
const tabStyle = `inline-block px-4 py-2 rounded-md mr-3 cursor-pointer text-sm select-none z-10
text-gray-600 border border-b-0 relative top-[1px] rounded-b-none`;
return (
<div>
<Modal closeModal={() => closeModal(false)} title={'Domain Settings'} width="[500px]">
<Modal closeModal={() => closeModal(false)} title={'Domain Settings'} width="[500px]" verticalCenter={currentTab === 'searchconsole'} >
<div data-testid="domain_settings" className=" text-sm">
<div className='mt-5 mb-5 '>
<div className=' mt-3 mb-5 border border-slate-200 px-2 py-4 pb-0
relative left-[-20px] w-[calc(100%+40px)] border-l-0 border-r-0 bg-[#f8f9ff]'>
<ul>
<li
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-blue-50 text-blue-600' : ''}`}
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'} `}
onClick={() => setCurrentTab('notification')}>
Notification
<Icon type='email' /> Notification
</li>
<li
className={`${tabStyle} ${currentTab === 'searchconsole' ? ' bg-blue-50 text-blue-600' : ''}`}
className={`${tabStyle} ${currentTab === 'searchconsole' ? ' bg-white text-blue-600 border-slate-200' : 'border-transparent'}`}
onClick={() => setCurrentTab('searchconsole')}>
Search Console
<Icon type='google' /> Search Console
</li>
</ul>
</div>
@@ -97,7 +102,7 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
defaultLabel="Select Search Console Property Type"
updateField={(updated:['domain'|'url']) => setDomainSettings({
...domainSettings,
search_console: { ...domainSettings.search_console, property_type: updated[0] || 'domain' },
search_console: { ...(domainSettings.search_console as DomainSearchConsole), property_type: updated[0] || 'domain' },
})}
multiple={false}
rounded={'rounded'}
@@ -109,17 +114,46 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
label='Property URL (Required)'
onChange={(url:string) => setDomainSettings({
...domainSettings,
search_console: { ...domainSettings.search_console, url },
search_console: { ...(domainSettings.search_console as DomainSearchConsole), url },
})}
value={domainSettings?.search_console?.url || ''}
placeholder='Search Console Property URL. eg: https://mywebsite.com/'
/>
</div>
)}
<div className="mb-4 flex justify-between items-center w-full">
<InputField
label='Search Console Client Email'
onChange={(client_email:string) => setDomainSettings({
...domainSettings,
search_console: { ...(domainSettings.search_console as DomainSearchConsole), client_email },
})}
value={domainSettings?.search_console?.client_email || ''}
placeholder='myapp@appspot.gserviceaccount.com'
/>
</div>
<div className="mb-4 flex flex-col justify-between items-center w-full">
<label className='mb-2 font-semibold block text-sm text-gray-700 capitalize w-full'>Search Console Private Key</label>
<textarea
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
focus:outline-none h-[100px] focus:border-blue-200`}
value={domainSettings?.search_console?.private_key || ''}
placeholder={'-----BEGIN PRIVATE KEY-----/ssssaswdkihad....'}
onChange={(event) => setDomainSettings({
...domainSettings,
search_console: { ...(domainSettings.search_console as DomainSearchConsole), private_key: event.target.value },
})}
/>
</div>
</>
)}
</div>
{!isUpdating && (domainUpdateError as Error)?.message && (
<div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{(domainUpdateError as Error).message}</div>
)}
{!isUpdating && settingsError?.msg && (
<div className='w-full mt-4 p-3 text-sm bg-red-50 text-red-700'>{settingsError.msg}</div>
)}
</div>
<div className="flex justify-between border-t-[1px] border-gray-100 mt-8 pt-4 pb-0">
@@ -129,9 +163,9 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
<Icon type="trash" /> Remove Domain
</button>
<button
className='text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white'
onClick={() => updateDomain()}>
Update Settings
className={`text-sm font-semibold py-2 px-5 rounded cursor-pointer bg-blue-700 text-white ${isUpdating ? 'cursor-not-allowed' : ''}`}
onClick={() => !isUpdating && updateDomain()}>
{isUpdating && <Icon type='loading' />} Update Settings
</button>
</div>
</Modal>

View File

@@ -1,6 +1,7 @@
import React from 'react';
import SelectField from '../common/SelectField';
import SecretField from '../common/SecretField';
import InputField from '../common/InputField';
type NotificationSettingsProps = {
settings: SettingsType,
@@ -18,8 +19,8 @@ const NotificationSettings = ({ settings, settingsError, updateSettings }:Notifi
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Notification Frequency</label>
<SelectField
label='Notification Frequency'
multiple={false}
selected={[settings.notification_interval]}
options={[
@@ -32,68 +33,61 @@ const NotificationSettings = ({ settings, settingsError, updateSettings }:Notifi
updateField={(updated:string[]) => updated[0] && updateSettings('notification_interval', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={270}
minWidth={220}
/>
</div>
{settings.notification_interval !== 'never' && (
<>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Notification Emails</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError?.type === 'no_email' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.notification_email}
placeholder={'test@gmail.com'}
onChange={(event) => updateSettings('notification_email', event.target.value)}
<InputField
label='Notification Emails'
hasError={settingsError?.type === 'no_email'}
value={settings?.notification_email}
placeholder={'test@gmail.com, test2@test.com'}
onChange={(value:string) => updateSettings('notification_email', value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Server</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError?.type === 'no_smtp_server' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_server || ''}
onChange={(event) => updateSettings('smtp_server', event.target.value)}
<InputField
label='SMTP Server'
hasError={settingsError?.type === 'no_smtp_server'}
value={settings?.smtp_server || ''}
placeholder={'test@gmail.com, test2@test.com'}
onChange={(value:string) => updateSettings('smtp_server', value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Port</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_port' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_port || ''}
onChange={(event) => updateSettings('smtp_port', event.target.value)}
<InputField
label='SMTP Port'
hasError={settingsError?.type === 'no_smtp_port'}
value={settings?.smtp_port || ''}
placeholder={'2234'}
onChange={(value:string) => updateSettings('smtp_port', value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Username</label>
<input
className={'w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200'}
type="text"
<InputField
label='SMTP Username'
hasError={settingsError?.type === 'no_smtp_port'}
value={settings?.smtp_username || ''}
onChange={(event) => updateSettings('smtp_username', event.target.value)}
onChange={(value:string) => updateSettings('smtp_username', value)}
/>
</div>
<div className="settings__section__input mb-5">
<SecretField
label='SMTP Password'
value={settings?.smtp_password || ''}
onChange={(value:string) => updateSettings('smtp_password', value)}
/>
</div>
<SecretField
label='SMTP Password'
value={settings?.smtp_password || ''}
onChange={(value:string) => updateSettings('smtp_password', value)}
/>
<div className="settings__section__input mb-5">
<label className={labelStyle}>From Email Address</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError?.type === 'no_smtp_from' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
<InputField
label='From Email Address'
hasError={settingsError?.type === 'no_smtp_from'}
value={settings?.notification_email_from || ''}
placeholder="no-reply@mydomain.com"
onChange={(event) => updateSettings('notification_email_from', event.target.value)}
/>
onChange={(value:string) => updateSettings('notification_email_from', value)}
/>
</div>
</>
)}

View File

@@ -45,15 +45,15 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__select mb-5">
<label className={labelStyle}>Scraping Method</label>
<SelectField
label='Scraping Method'
options={scraperOptions}
selected={[settings.scraper_type || 'none']}
defaultLabel="Select Scraper"
updateField={(updatedTime:[string]) => updateSettings('scraper_type', updatedTime[0])}
multiple={false}
rounded={'rounded'}
minWidth={270}
minWidth={220}
/>
</div>
{settings.scraper_type !== 'none' && settings.scraper_type !== 'proxy' && (
@@ -80,8 +80,8 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
)}
{settings.scraper_type !== 'none' && (
<div className="settings__section__input mb-5">
<label className={labelStyle}>Scraping Frequency</label>
<SelectField
label='Scraping Frequency'
multiple={false}
selected={[settings?.scrape_interval || 'daily']}
options={scrapingOptions}
@@ -89,14 +89,14 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_interval', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={270}
minWidth={220}
/>
<small className=' text-gray-500 pt-2 block'>This option requires Server/Docker Instance Restart to take Effect.</small>
</div>
)}
<div className="settings__section__input mb-5">
<label className={labelStyle}>Delay Between Each keyword Scrape</label>
<SelectField
label='keyword Scrape Delay'
multiple={false}
selected={[settings?.scrape_delay || '0']}
options={delayOptions}
@@ -104,7 +104,7 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_delay', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={270}
minWidth={220}
/>
<small className=' text-gray-500 pt-2 block'>This option requires Server/Docker Instance Restart to take Effect.</small>
</div>

View File

@@ -0,0 +1,49 @@
import React from 'react';
import ToggleField from '../common/ToggleField';
import InputField from '../common/InputField';
type SearchConsoleSettingsProps = {
settings: SettingsType,
settingsError: null | {
type: string,
msg: string
},
updateSettings: Function,
}
const SearchConsoleSettings = ({ settings, settingsError, updateSettings }:SearchConsoleSettingsProps) => {
return (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
{/* <div className="settings__section__input mb-5">
<ToggleField
label='Enable Goolge Search Console'
value={settings?.scrape_retry ? 'true' : '' }
onChange={(val) => updateSettings('scrape_retry', val)}
/>
</div> */}
<div className="settings__section__input mb-4 flex justify-between items-center w-full">
<InputField
label='Search Console Client Email'
onChange={(client_email:string) => updateSettings('search_console_client_email', client_email)}
value={settings.search_console_client_email}
placeholder='myapp@appspot.gserviceaccount.com'
/>
</div>
<div className="settings__section__input mb-4 flex flex-col justify-between items-center w-full">
<label className='mb-2 font-semibold block text-sm text-gray-700 capitalize w-full'>Search Console Private Key</label>
<textarea
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
focus:outline-none h-[100px] focus:border-blue-200`}
value={settings.search_console_private_key}
placeholder={'-----BEGIN PRIVATE KEY-----/ssssaswdkihad....'}
onChange={(event) => updateSettings('search_console_private_key', event.target.value)}
/>
</div>
</div>
</div>
);
};
export default SearchConsoleSettings;

View File

@@ -5,6 +5,7 @@ import Icon from '../common/Icon';
import NotificationSettings from './NotificationSettings';
import ScraperSettings from './ScraperSettings';
import useOnKey from '../../hooks/useOnKey';
import SearchConsoleSettings from './SearchConsoleSettings';
type SettingsProps = {
closeSettings: Function,
@@ -16,7 +17,7 @@ type SettingsError = {
msg: string
}
const defaultSettings = {
const defaultSettings: SettingsType = {
scraper_type: 'none',
scrape_delay: 'none',
scrape_retry: false,
@@ -27,6 +28,9 @@ const defaultSettings = {
smtp_username: '',
smtp_password: '',
notification_email_from: '',
search_console: true,
search_console_client_email: '',
search_console_private_key: '',
};
const Settings = ({ closeSettings }:SettingsProps) => {
@@ -81,30 +85,38 @@ const Settings = ({ closeSettings }:SettingsProps) => {
}
};
const tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm';
const tabStyle = `inline-block px-3 py-2 rounded-md cursor-pointer text-xs lg:text-sm lg:mr-3 lg:px-4 select-none z-10
text-gray-600 border border-b-0 relative top-[1px] rounded-b-none`;
const tabStyleActive = 'bg-white text-blue-600 border-slate-200';
return (
<div className="settings fixed w-full h-screen top-0 left-0 z-50" onClick={closeOnBGClick}>
<div className="absolute w-full max-w-xs bg-white customShadow top-0 right-0 h-screen" data-loading={isLoading} >
<div className="absolute w-full max-w-md bg-white customShadow top-0 right-0 h-screen" data-loading={isLoading} >
{isLoading && <div className='absolute flex content-center items-center h-full'><Icon type="loading" size={24} /></div>}
<div className='settings__header p-6 border-b border-b-slate-200 text-slate-500'>
<div className='settings__header px-5 py-4 text-slate-500'>
<h3 className=' text-black text-lg font-bold'>Settings</h3>
<button
className=' absolute top-2 right-2 p-2 px-3 text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
className=' absolute top-2 right-2 p-2 px- text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
onClick={() => closeSettings()}>
<Icon type='close' size={24} />
</button>
</div>
<div className=' px-4 mt-4 '>
<div className='border border-slate-200 px-3 py-4 pb-0 border-l-0 border-r-0 bg-[#f8f9ff]'>
<ul>
<li
className={`${tabStyle} ${currentTab === 'scraper' ? ' bg-blue-50 text-blue-600' : ''}`}
className={`${tabStyle} ${currentTab === 'scraper' ? tabStyleActive : 'border-transparent '}`}
onClick={() => setCurrentTab('scraper')}>
Scraper
<Icon type='scraper' /> Scraper
</li>
<li
className={`${tabStyle} ${currentTab === 'notification' ? ' bg-blue-50 text-blue-600' : ''}`}
className={`${tabStyle} ${currentTab === 'notification' ? tabStyleActive : 'border-transparent'}`}
onClick={() => setCurrentTab('notification')}>
Notification
<Icon type='email' /> Notification
</li>
<li
className={`${tabStyle} ${currentTab === 'searchconsole' ? tabStyleActive : 'border-transparent'}`}
onClick={() => setCurrentTab('searchconsole')}>
<Icon type='google' size={14} /> Search Console
</li>
</ul>
</div>
@@ -115,6 +127,9 @@ const Settings = ({ closeSettings }:SettingsProps) => {
{currentTab === 'notification' && settings && (
<NotificationSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
)}
{currentTab === 'searchconsole' && settings && (
<SearchConsoleSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
)}
<div className=' border-t-[1px] border-gray-200 p-2 px-3'>
<button
onClick={() => performUpdate()}

48
pages/api/domain.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import Cryptr from 'cryptr';
import db from '../../database/database';
import Domain from '../../database/models/domain';
import verifyUser from '../../utils/verifyUser';
type DomainGetResponse = {
domain?: DomainType | null
error?: string|null,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const authorized = verifyUser(req, res);
if (authorized === 'authorized' && req.method === 'GET') {
await db.sync();
return getDomain(req, res);
}
return res.status(401).json({ error: authorized });
}
const getDomain = async (req: NextApiRequest, res: NextApiResponse<DomainGetResponse>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') {
return res.status(400).json({ error: 'Domain Name is Required!' });
}
try {
const query = { domain: req.query.domain as string };
const foundDomain:Domain| null = await Domain.findOne({ where: query });
const parsedDomain = foundDomain?.get({ plain: true }) || false;
if (parsedDomain && parsedDomain.search_console) {
try {
const cryptr = new Cryptr(process.env.SECRET as string);
const scData = JSON.parse(parsedDomain.search_console);
scData.client_email = scData.client_email ? cryptr.decrypt(scData.client_email) : '';
scData.private_key = scData.private_key ? cryptr.decrypt(scData.private_key) : '';
parsedDomain.search_console = JSON.stringify(scData);
} catch (error) {
console.log('[Error] Parsing Search Console Keys.');
}
}
return res.status(200).json({ domain: parsedDomain });
} catch (error) {
console.log('[ERROR] Getting Domain: ', error);
return res.status(400).json({ error: 'Error Loading Domain' });
}
};

View File

@@ -1,10 +1,11 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import Cryptr from 'cryptr';
import db from '../../database/database';
import Domain from '../../database/models/domain';
import Keyword from '../../database/models/keyword';
import getdomainStats from '../../utils/domains';
import verifyUser from '../../utils/verifyUser';
import { removeLocalSCData } from '../../utils/searchConsole';
import { checkSerchConsoleIntegration, removeLocalSCData } from '../../utils/searchConsole';
type DomainsGetRes = {
domains: DomainType[]
@@ -53,7 +54,13 @@ export const getDomains = async (req: NextApiRequest, res: NextApiResponse<Domai
const withStats = !!req?.query?.withstats;
try {
const allDomains: Domain[] = await Domain.findAll();
const formattedDomains: DomainType[] = allDomains.map((el) => el.get({ plain: true }));
const formattedDomains: DomainType[] = allDomains.map((el) => {
const domainItem:DomainType = el.get({ plain: true });
const scData = domainItem?.search_console ? JSON.parse(domainItem.search_console) : {};
const { client_email, private_key } = scData;
const searchConsoleData = scData ? { ...scData, client_email: client_email ? 'true' : '', private_key: private_key ? 'true' : '' } : {};
return { ...domainItem, search_console: JSON.stringify(searchConsoleData) };
});
const theDomains: DomainType[] = withStats ? await getdomainStats(formattedDomains) : allDomains;
return res.status(200).json({ domains: theDomains });
} catch (error) {
@@ -112,6 +119,17 @@ export const updateDomain = async (req: NextApiRequest, res: NextApiResponse<Dom
try {
const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } });
// Validate Search Console API Data
if (domainToUpdate && search_console?.client_email && search_console?.private_key) {
const theDomainObj = domainToUpdate.get({ plain: true });
const isSearchConsoleAPIValid = await checkSerchConsoleIntegration({ ...theDomainObj, search_console: JSON.stringify(search_console) });
if (!isSearchConsoleAPIValid.isValid) {
return res.status(400).json({ domain: null, error: isSearchConsoleAPIValid.error });
}
const cryptr = new Cryptr(process.env.SECRET as string);
search_console.client_email = search_console.client_email ? cryptr.encrypt(search_console.client_email.trim()) : '';
search_console.private_key = search_console.private_key ? cryptr.encrypt(search_console.private_key.trim()) : '';
}
if (domainToUpdate) {
domainToUpdate.set({ notification_interval, notification_emails, search_console: JSON.stringify(search_console) });
await domainToUpdate.save();
@@ -119,6 +137,6 @@ export const updateDomain = async (req: NextApiRequest, res: NextApiResponse<Dom
return res.status(200).json({ domain: domainToUpdate });
} catch (error) {
console.log('[ERROR] Updating Domain: ', req.query.domain, error);
return res.status(400).json({ domain: null, error: 'Error Updating Domain' });
return res.status(400).json({ domain: null, error: 'Error Updating Domain. An Unknown Error Occured.' });
}
};

View File

@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database';
import { getCountryInsight, getKeywordsInsight, getPagesInsight } from '../../utils/insight';
import { fetchDomainSCData, readLocalSCData } from '../../utils/searchConsole';
import { fetchDomainSCData, getSearchConsoleApiInfo, readLocalSCData } from '../../utils/searchConsole';
import verifyUser from '../../utils/verifyUser';
import Domain from '../../database/models/domain';
@@ -24,9 +24,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiResponse<SCInsightRes>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
if (!!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) === false) {
return res.status(200).json({ data: null, error: 'Google Search Console Not Integrated' });
}
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
const getInsightFromSCData = (localSCData: SCDomainDataType): InsightDataType => {
const { stats = [] } = localSCData;
@@ -53,8 +50,11 @@ const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiRe
const query = { domain: domainname };
const foundDomain:Domain| null = await Domain.findOne({ where: query });
const domainObj: DomainType = foundDomain && foundDomain.get({ plain: true });
const scData = await fetchDomainSCData(domainObj);
const scDomainAPI = await getSearchConsoleApiInfo(domainObj);
if (!(scDomainAPI.client_email && scDomainAPI.private_key)) {
return res.status(200).json({ data: null, error: 'Google Search Console is not Integrated.' });
}
const scData = await fetchDomainSCData(domainObj, scDomainAPI);
const response = getInsightFromSCData(scData);
return res.status(200).json({ data: response });
} catch (error) {

View File

@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import db from '../../database/database';
import Domain from '../../database/models/domain';
import { fetchDomainSCData, readLocalSCData } from '../../utils/searchConsole';
import { fetchDomainSCData, getSearchConsoleApiInfo, readLocalSCData } from '../../utils/searchConsole';
import verifyUser from '../../utils/verifyUser';
type searchConsoleRes = {
@@ -31,13 +31,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const getDomainSearchConsoleData = async (req: NextApiRequest, res: NextApiResponse<searchConsoleRes>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
if (!!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) === false) {
return res.status(200).json({ data: null, error: 'Google Search Console Not Integrated' });
}
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
const localSCData = await readLocalSCData(domainname);
console.log(localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length);
if (localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length) {
return res.status(200).json({ data: localSCData });
}
@@ -45,7 +40,11 @@ const getDomainSearchConsoleData = async (req: NextApiRequest, res: NextApiRespo
const query = { domain: domainname };
const foundDomain:Domain| null = await Domain.findOne({ where: query });
const domainObj: DomainType = foundDomain && foundDomain.get({ plain: true });
const scData = await fetchDomainSCData(domainObj);
const scDomainAPI = await getSearchConsoleApiInfo(domainObj);
if (!(scDomainAPI.client_email && scDomainAPI.private_key)) {
return res.status(200).json({ data: null, error: 'Google Search Console is not Integrated.' });
}
const scData = await fetchDomainSCData(domainObj, scDomainAPI);
return res.status(200).json({ data: scData });
} catch (error) {
console.log('[ERROR] Getting Search Console Data for: ', domainname, error);

View File

@@ -42,9 +42,11 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
}
try {
const cryptr = new Cryptr(process.env.SECRET as string);
const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api) : '';
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password) : '';
const securedSettings = { ...settings, scaping_api, smtp_password };
const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api.trim()) : '';
const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password.trim()) : '';
const search_console_client_email = settings.search_console_client_email ? cryptr.encrypt(settings.search_console_client_email.trim()) : '';
const search_console_private_key = settings.search_console_private_key ? cryptr.encrypt(settings.search_console_private_key.trim()) : '';
const securedSettings = { ...settings, scaping_api, smtp_password, search_console_client_email, search_console_private_key };
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(securedSettings), { encoding: 'utf-8' });
return res.status(200).json({ settings });
@@ -67,11 +69,16 @@ export const getAppSettings = async () : Promise<SettingsType> => {
const cryptr = new Cryptr(process.env.SECRET as string);
const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : '';
const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : '';
const search_console_client_email = settings.search_console_client_email ? cryptr.decrypt(settings.search_console_client_email) : '';
const search_console_private_key = settings.search_console_private_key ? cryptr.decrypt(settings.search_console_private_key) : '';
decryptedSettings = {
...settings,
scaping_api,
smtp_password,
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL),
search_console_client_email,
search_console_private_key,
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL)
|| !!(search_console_client_email && search_console_private_key),
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id, allowsCity: !!scraper.allowsCity })),
failed_queue: failedQueue,
screenshot_key: screenshotAPIKey,
@@ -94,6 +101,9 @@ export const getAppSettings = async () : Promise<SettingsType> => {
smtp_password: '',
scrape_retry: false,
screenshot_key: screenshotAPIKey,
search_console: true,
search_console_client_email: '',
search_console_private_key: '',
};
const otherSettings = {
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),

View File

@@ -37,6 +37,11 @@ const SingleDomain: NextPage = () => {
return active;
}, [router.query.slug, domainsData]);
const domainHasScAPI = useMemo(() => {
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
return !!(doaminSc?.client_email && doaminSc?.private_key);
}, [activDomain]);
const { keywordsData, keywordsLoading } = useFetchKeywords(router, activDomain?.domain || '', setKeywordSPollInterval, keywordSPollInterval);
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords;
@@ -72,7 +77,7 @@ const SingleDomain: NextPage = () => {
keywords={theKeywords}
showAddModal={showAddKeywords}
setShowAddModal={setShowAddKeywords}
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) }
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || domainHasScAPI }
/>
</div>
</div>

View File

@@ -39,6 +39,11 @@ const DiscoverPage: NextPage = () => {
return active;
}, [router.query.slug, domainsData]);
const domainHasScAPI = useMemo(() => {
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
return !!(doaminSc?.client_email && doaminSc?.private_key);
}, [activDomain]);
return (
<div className="Domain ">
{activDomain && activDomain.domain
@@ -65,7 +70,7 @@ const DiscoverPage: NextPage = () => {
isLoading={keywordsLoading || isFetching}
domain={activDomain}
keywords={theKeywords}
isConsoleIntegrated={scConnected}
isConsoleIntegrated={scConnected || domainHasScAPI}
/>
</div>
</div>

View File

@@ -39,6 +39,11 @@ const InsightPage: NextPage = () => {
return active;
}, [router.query.slug, domainsData]);
const domainHasScAPI = useMemo(() => {
const doaminSc = activDomain?.search_console ? JSON.parse(activDomain.search_console) : {};
return !!(doaminSc?.client_email && doaminSc?.private_key);
}, [activDomain]);
return (
<div className="Domain ">
{activDomain && activDomain.domain
@@ -65,7 +70,7 @@ const InsightPage: NextPage = () => {
isLoading={false}
domain={activDomain}
insight={theInsight}
isConsoleIntegrated={scConnected}
isConsoleIntegrated={scConnected || domainHasScAPI}
/>
</div>
</div>

View File

@@ -35,6 +35,17 @@ const Domains: NextPage = () => {
return keywords;
}, [domainsData]);
const domainSCAPiObj = useMemo(() => {
const domainsSCAPI:{ [ID:string] : boolean } = {};
if (domainsData?.domains) {
domainsData.domains.forEach(async (domain:DomainType) => {
const doaminSc = domain?.search_console ? JSON.parse(domain.search_console) : {};
domainsSCAPI[domain.ID] = doaminSc.client_email && doaminSc.private_key;
});
}
return domainsSCAPI;
}, [domainsData]);
useEffect(() => {
if (domainsData?.domains && domainsData.domains.length > 0 && appSettings.screenshot_key) {
domainsData.domains.forEach(async (domain:DomainType) => {
@@ -94,7 +105,7 @@ const Domains: NextPage = () => {
key={domain.ID}
domain={domain}
selected={false}
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) }
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || !!domainSCAPiObj[domain.ID] }
thumb={domainThumbs[domain.domain]}
updateThumb={manuallyUpdateThumb}
// isConsoleIntegrated={false}

View File

@@ -19,6 +19,19 @@ export async function fetchDomains(router: NextRouter, withStats:boolean): Promi
return res.json();
}
export async function fetchDomain(router: NextRouter, domainName: string): Promise<{domain: DomainType}> {
if (!domainName) { throw new Error('No Domain Name Provided!'); }
const res = await fetch(`${window.location.origin}/api/domain?domain=${domainName}`, { method: 'GET' });
if (res.status >= 400 && res.status < 600) {
if (res.status === 401) {
console.log('Unauthorized!!');
router.push('/login');
}
throw new Error('Bad response from server');
}
return res.json();
}
export async function fetchDomainScreenshot(domain: string, screenshot_key:string, forceFetch = false): Promise<string | false> {
const domainThumbsRaw = localStorage.getItem('domainThumbs');
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
@@ -53,6 +66,14 @@ export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
return useQuery('domains', () => fetchDomains(router, withStats));
}
export function useFetchDomain(router: NextRouter, domainName:string, onSuccess: Function) {
return useQuery('domain', () => fetchDomain(router, domainName), {
onSuccess: async (data) => {
console.log('Domain Loaded!!!', data.domain);
onSuccess(data.domain);
} });
}
export function useAddDomain(onSuccess:Function) {
const router = useRouter();
const queryClient = useQueryClient();
@@ -89,10 +110,11 @@ export function useUpdateDomain(onSuccess:Function) {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'PUT', headers, body: JSON.stringify(domainSettings) };
const res = await fetch(`${window.location.origin}/api/domains?domain=${domain.domain}`, fetchOpts);
const responseObj = await res.json();
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
throw new Error(responseObj?.error || 'Bad response from server');
}
return res.json();
return responseObj;
}, {
onSuccess: async () => {
console.log('Settings Updated!!!');
@@ -100,8 +122,8 @@ export function useUpdateDomain(onSuccess:Function) {
onSuccess();
queryClient.invalidateQueries(['domains']);
},
onError: () => {
console.log('Error Updating Domain Settings!!!');
onError: (error) => {
console.log('Error Updating Domain Settings!!!', error);
toast('Error Updating Domain Settings', { icon: '⚠️' });
},
});

11
types.d.ts vendored
View File

@@ -64,8 +64,10 @@ type countryCodeData = {
}
type DomainSearchConsole = {
property_type?: 'domain' | 'url',
url?: string
property_type: 'domain' | 'url',
url: string,
client_email:string,
private_key:string,
}
type DomainSettings = {
@@ -85,7 +87,6 @@ type SettingsType = {
smtp_port: string,
smtp_username?: string,
smtp_password?: string,
search_console_integrated?: boolean,
available_scapers?: { label: string, value: string, allowsCity?: boolean }[],
scrape_interval?: string,
scrape_delay?: string,
@@ -93,6 +94,10 @@ type SettingsType = {
failed_queue?: string[]
version?: string,
screenshot_key?: string,
search_console: boolean,
search_console_client_email: string,
search_console_private_key: string,
search_console_integrated?: boolean,
}
type KeywordSCDataChild = {

View File

@@ -1,4 +1,5 @@
import { auth, searchconsole_v1 } from '@googleapis/searchconsole';
import Cryptr from 'cryptr';
import { readFile, writeFile, unlink } from 'fs/promises';
import { getCountryCodeFromAlphaThree } from './countries';
@@ -7,24 +8,32 @@ export type SCDomainFetchError = {
errorMsg: string,
}
type SCAPISettings = { client_email: string, private_key: string }
type fetchConsoleDataResponse = SearchAnalyticsItem[] | SearchAnalyticsStat[] | SCDomainFetchError;
/**
* function that retrieves data from the Google Search Console API based on the provided domain name, number of days, and optional type.
* Retrieves data from the Google Search Console API based on the provided domain name, number of days, and optional type.
* @param {DomainType} domain - The domain for which you want to fetch search console data.
* @param {number} days - The `days` parameter is the number of days of data you want to fetch from the Search Console.
* @param {string} [type] - The `type` parameter is an optional parameter that specifies the type of data to fetch from the Search Console API.
* @param {number} days - number of days of data you want to fetch from the Search Console.
* @param {string} [type] - (optional) specifies the type of data to fetch from the Search Console.
* @param {SCAPISettings} [api] - (optional) specifies the Seach Console API Information.
* @returns {Promise<fetchConsoleDataResponse>}
*/
const fetchSearchConsoleData = async (domain:DomainType, days:number, type?:string): Promise<fetchConsoleDataResponse> => {
const fetchSearchConsoleData = async (domain:DomainType, days:number, type?:string, api?:SCAPISettings): Promise<fetchConsoleDataResponse> => {
if (!domain) return { error: true, errorMsg: 'Domain Not Provided!' };
if (!api?.private_key || !api?.client_email) return { error: true, errorMsg: 'Search Console API Data Not Avaialable.' };
const domainName = domain.domain;
const domainSettings = domain.search_console ? JSON.parse(domain.search_console) : { property_type: 'domain', url: '' };
const defaultSCSettings = { property_type: 'domain', url: '', client_email: '', private_key: '' };
const domainSettings = domain.search_console ? JSON.parse(domain.search_console) : defaultSCSettings;
const sCPrivateKey = api?.private_key || process.env.SEARCH_CONSOLE_PRIVATE_KEY || '';
const sCClientEmail = api?.client_email || process.env.SEARCH_CONSOLE_CLIENT_EMAIL || '';
try {
const authClient = new auth.GoogleAuth({
credentials: {
private_key: process.env.SEARCH_CONSOLE_PRIVATE_KEY ? process.env.SEARCH_CONSOLE_PRIVATE_KEY.replaceAll('\\n', '\n') : '',
client_email: process.env.SEARCH_CONSOLE_CLIENT_EMAIL ? process.env.SEARCH_CONSOLE_CLIENT_EMAIL : '',
private_key: (sCPrivateKey).replaceAll('\\n', '\n'),
client_email: (sCClientEmail || '').trim(),
},
scopes: [
'https://www.googleapis.com/auth/webmasters.readonly',
@@ -73,10 +82,12 @@ const fetchSearchConsoleData = async (domain:DomainType, days:number, type?:stri
}
return finalRows;
} catch (error:any) {
} catch (err:any) {
const qType = type === 'stats' ? '(stats)' : `(${days}days)`;
console.log(`[ERROR] Search Console API Error for ${domainName} ${qType} : `, error?.response?.status, error?.response?.statusText);
return { error: true, errorMsg: `${error?.response?.status}: ${error?.response?.statusText}` };
const errorMsg = err?.response?.status && `${err?.response?.statusText}. ${err?.response?.data?.error_description}`;
console.log(`[ERROR] Search Console API Error for ${domainName} ${qType} : `, errorMsg || err?.code);
// console.log('SC ERROR :', err);
return { error: true, errorMsg: errorMsg || err?.code };
}
};
@@ -87,12 +98,13 @@ const fetchSearchConsoleData = async (domain:DomainType, days:number, type?:stri
* @returns The function `fetchDomainSCData` is returning a Promise that resolves to an object of type
* `SCDomainDataType`.
*/
export const fetchDomainSCData = async (domain:DomainType): Promise<SCDomainDataType> => {
export const fetchDomainSCData = async (domain:DomainType, scAPI?: SCAPISettings): Promise<SCDomainDataType> => {
const days = [3, 7, 30];
const scDomainData:SCDomainDataType = { threeDays: [], sevenDays: [], thirtyDays: [], lastFetched: '', lastFetchError: '', stats: [] };
if (domain.domain) {
if (domain.domain && scAPI) {
const theDomain = domain;
for (const day of days) {
const items = await fetchSearchConsoleData(domain, day);
const items = await fetchSearchConsoleData(theDomain, day, undefined, scAPI);
scDomainData.lastFetched = new Date().toJSON();
if (Array.isArray(items)) {
if (day === 3) scDomainData.threeDays = items as SearchAnalyticsItem[];
@@ -102,7 +114,7 @@ export const fetchDomainSCData = async (domain:DomainType): Promise<SCDomainData
scDomainData.lastFetchError = items.errorMsg;
}
}
const stats = await fetchSearchConsoleData(domain, 30, 'stat');
const stats = await fetchSearchConsoleData(theDomain, 30, 'stat', scAPI);
if (stats && Array.isArray(stats) && stats.length > 0) {
scDomainData.stats = stats as SearchAnalyticsStat[];
}
@@ -167,6 +179,57 @@ export const integrateKeywordSCData = (keyword: KeywordType, SCData:SCDomainData
return { ...keyword, scData: finalSCData };
};
/**
* Retrieves the Search Console API information for a given domain.
* @param {DomainType} domain - The `domain` parameter is of type `DomainType`, which represents a
* domain object. It likely contains information about a specific domain, such as its name, search
* console settings, etc.
* @returns an object of type `SCAPISettings`.
*/
export const getSearchConsoleApiInfo = async (domain: DomainType): Promise<SCAPISettings> => {
const scAPIData = { client_email: '', private_key: '' };
// Check if the Domain Has the API Data
const domainSCSettings = domain.search_console && JSON.parse(domain.search_console);
if (domainSCSettings && domainSCSettings.private_key) {
if (!domainSCSettings.private_key.includes('BEGIN PRIVATE KEY')) {
const cryptr = new Cryptr(process.env.SECRET as string);
scAPIData.client_email = domainSCSettings.client_email ? cryptr.decrypt(domainSCSettings.client_email) : '';
scAPIData.private_key = domainSCSettings.private_key ? cryptr.decrypt(domainSCSettings.private_key) : '';
} else {
scAPIData.client_email = domainSCSettings.client_email;
scAPIData.private_key = domainSCSettings.private_key;
}
}
// Check if the App Settings Has the API Data
if (!scAPIData?.private_key) {
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {};
const cryptr = new Cryptr(process.env.SECRET as string);
scAPIData.client_email = settings.search_console_client_email ? cryptr.decrypt(settings.search_console_client_email) : '';
scAPIData.private_key = settings.search_console_private_key ? cryptr.decrypt(settings.search_console_private_key) : '';
}
if (!scAPIData?.private_key && process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) {
scAPIData.client_email = process.env.SEARCH_CONSOLE_CLIENT_EMAIL;
scAPIData.private_key = process.env.SEARCH_CONSOLE_PRIVATE_KEY;
}
return scAPIData;
};
/**
* Checks if the provided domain level Google Search Console API info is valid.
* @param {DomainType} domain - The domain that represents the domain for which the SC API info is being checked.
* @returns an object of type `{ isValid: boolean, error: string }`.
*/
export const checkSerchConsoleIntegration = async (domain: DomainType): Promise<{ isValid: boolean, error: string }> => {
const res = { isValid: false, error: '' };
const { client_email = '', private_key = '' } = domain?.search_console ? JSON.parse(domain.search_console) : {};
const response = await fetchSearchConsoleData(domain, 3, undefined, { client_email, private_key });
if (Array.isArray(response)) { res.isValid = true; }
if ((response as SCDomainFetchError)?.errorMsg) { res.error = (response as SCDomainFetchError).errorMsg; }
return res;
};
/**
* The function reads and returns the domain-specific data stored in a local JSON file.
* @param {string} domain - The `domain` parameter is a string that represents the domain for which the SC data is being read.