11 Commits

Author SHA1 Message Date
towfiqi
08f44911d7 chore(release): 0.3.3 2023-11-12 20:59:08 +06:00
towfiqi
9dce1d5b48 chore: resolves test issues 2023-11-12 20:58:03 +06:00
towfiqi
fbd23ede25 feat: Shows total keywords count in domains page 2023-11-11 12:48:31 +06:00
towfiqi
60c68bd339 feat: Adds ability to visit pages from Insight tab 2023-11-11 11:42:14 +06:00
towfiqi
2339e31af9 feat: Domains now show their favicon.
closes #130
2023-11-11 11:29:59 +06:00
towfiqi
4a60271cac fix: Resolves Website Thumbnail missing issue
You can now set your own thum.io key to fetch screenshot. More in docs.

closes #131
2023-11-11 11:17:44 +06:00
towfiqi
c870250fbd chore(release): 0.3.2 2023-11-09 20:35:49 +06:00
towfiqi
da92f11afa chore: resolves linter nag. 2023-11-09 20:35:22 +06:00
towfiqi
9b9b74af4c fix: Resolves issue with adding long tld emails
closes #127
2023-11-09 20:35:01 +06:00
Towfiq I
291aa60bbb Merge pull request #129 from SearchApi/feature/integrate-searchapi
feat: Integrates SearchAPI
fix: Resolves build issue due to missing jest types.
fix: Resolves keyword SERP fetch issue.
2023-11-09 20:01:02 +06:00
SebastjanPrachovskij
8a35e358e6 Integrate SearchApi to SerpBear
Remove unnecessary spacing

Fix keyword.id & authorization for searchapi
2023-11-09 14:50:11 +02:00
39 changed files with 571 additions and 210 deletions

View File

@@ -12,6 +12,7 @@
"no-await-in-loop": "off",
"arrow-body-style":"off",
"max-len": ["error", {"code": 150, "ignoreComments": true, "ignoreUrls": true}],
"import/no-extraneous-dependencies": "off",
"import/extensions": [
"error",
"ignorePackages",

View File

@@ -2,6 +2,27 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [0.3.3](https://github.com/towfiqi/serpbear/compare/v0.3.2...v0.3.3) (2023-11-12)
### Features
* Adds ability to visit pages from Insight tab ([60c68bd](https://github.com/towfiqi/serpbear/commit/60c68bd339db7aeed35aea035dd21691702ffee3))
* Domains now show their favicon. ([2339e31](https://github.com/towfiqi/serpbear/commit/2339e31af9e90bf918f5bcd4f23114f38cef0313)), closes [#130](https://github.com/towfiqi/serpbear/issues/130)
* Shows total keywords count in domains page ([fbd23ed](https://github.com/towfiqi/serpbear/commit/fbd23ede256062c72ec2f7e3983a0a02f0240725))
### Bug Fixes
* Resolves Website Thumbnail missing issue ([4a60271](https://github.com/towfiqi/serpbear/commit/4a60271cac1209dc02748c4d31943bb21c9ecaf2)), closes [#131](https://github.com/towfiqi/serpbear/issues/131)
### [0.3.2](https://github.com/towfiqi/serpbear/compare/v0.3.1...v0.3.2) (2023-11-09)
### Bug Fixes
* Resolves issue with adding long tld emails ([9b9b74a](https://github.com/towfiqi/serpbear/commit/9b9b74af4c249e27458d29ba052e96ab2db8b640)), closes [#127](https://github.com/towfiqi/serpbear/issues/127)
### [0.3.1](https://github.com/towfiqi/serpbear/compare/v0.3.0...v0.3.1) (2023-11-04)

View File

@@ -11,7 +11,7 @@ FROM node:lts-alpine AS builder
WORKDIR /app
COPY --from=deps /app ./
RUN rm -rf /app/data
RUN rm -rf /app/__test__
RUN rm -rf /app/__tests__
RUN npm run build

View File

@@ -18,7 +18,7 @@ SerpBear is an Open Source Search Engine Position Tracking App. It allows you to
- **Zero Cost to RUN:** Run the App on mogenius.com or Fly.io for free.
#### How it Works
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SerpApi or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword. Also, When you connect your Googel Search Console account, the app shows actual search visits for each tracked keywords. You can also discover new keywords, and find the most performing keywords, countries, pages.
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SearchApi, SerpApi or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword. Also, When you connect your Googel Search Console account, the app shows actual search visits for each tracked keywords. You can also discover new keywords, and find the most performing keywords, countries, pages.
#### Getting Started
- **Step 1:** Deploy & Run the App.
@@ -41,6 +41,7 @@ The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SerpA
| serply.io | $49/mo | 5000/mo | Yes |
| serpapi.com | From $50/mo** | From 5,000/mo** | Yes |
| spaceserp.com | $59/lifetime | 15,000/mo | Yes |
| SearchApi.io | From $40/mo | From 10,000/mo | Yes |
(*) Free upto a limit. If you are using ScrapingAnt you can lookup 10,000 times per month for free.
(**) Free up to 100 per month. Paid from 5,000 to 10,000,000+ per month.

View File

@@ -2,10 +2,11 @@ export const dummyDomain = {
ID: 1,
domain: 'compressimage.io',
slug: 'compressimage-io',
keywordCount: 0,
keywordCount: 10,
avgPosition: 24,
lastUpdated: '2022-11-11T10:00:32.243',
added: '2022-11-11T10:00:32.244',
tags: [],
tags: '',
notification: true,
notification_interval: 'daily',
notification_emails: '',
@@ -33,7 +34,7 @@ export const dummyKeywords = [
lastResult: [],
sticky: false,
updating: false,
lastUpdateError: 'false',
lastUpdateError: false as false,
},
{
ID: 2,
@@ -56,6 +57,23 @@ export const dummyKeywords = [
lastResult: [],
sticky: false,
updating: false,
lastUpdateError: 'false',
lastUpdateError: false as false,
},
];
export const dummySettings = {
scaping_api: '',
scraper_type: 'none',
notification_interval: 'never',
notification_email: '',
notification_email_from: '',
smtp_server: '',
smtp_port: '',
smtp_username: '',
smtp_password: '',
scrape_retry: false,
search_console_integrated: false,
screenshot_key: '',
available_scapers: [],
failed_queue: [],
};

View File

@@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import Modal from '../../components/common/Modal';
// jest.mock('React', () => ({
// ...jest.requireActual('React'),
// useEffect: jest.fn(),
// }));
// const mockedUseEffect = useEffect as jest.Mock<any>;
// jest.mock('../../components/common/Icon', () => () => <div data-testid="icon"/>);
describe('Modal Component', () => {
it('Renders without crashing', async () => {
render(<Modal closeModal={() => console.log() }><div></div></Modal>);
// mockedUseEffect.mock.calls[0]();
expect(document.querySelector('.modal')).toBeInTheDocument();
});
// it('Sets up the escapae key shortcut', async () => {
// render(<Modal closeModal={() => console.log() }><div></div></Modal>);
// expect(mockedUseEffect).toBeCalled();
// });
it('Displays the Given Content', async () => {
render(<Modal closeModal={() => console.log() }>
<div>
<h1>Hello Modal!!</h1>
</div>
</Modal>);
expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument();
});
it('Renders Modal Title', async () => {
render(<Modal closeModal={() => console.log() } title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument();
});
});

View File

@@ -1,11 +0,0 @@
import { render, screen } from '@testing-library/react';
import Sidebar from '../../components/common/Sidebar';
describe('Sidebar Component', () => {
it('renders without crashing', async () => {
render(<Sidebar domains={[]} showAddModal={() => console.log() } />);
expect(
await screen.findByText('SerpBear'),
).toBeInTheDocument();
});
});

View File

@@ -1,21 +0,0 @@
import { waitFor } from '@testing-library/react';
// import { useFetchDomains } from '../../services/domains';
// import { createWrapper } from '../utils';
jest.mock('next/router', () => ({
useRouter: () => ({
query: { slug: 'compressimage-io' },
push: (link:string) => { console.log('Pushed', link); },
}),
}));
describe('DomainHooks', () => {
it('useFetchDomains should fetch the Domains', async () => {
// const { result } = renderHook(() => useFetchDomains(), { wrapper: createWrapper() });
const result = { current: { isSuccess: false, data: '' } };
await waitFor(() => {
console.log('result.current: ', result.current.data);
return expect(result.current.isSuccess).toBe(true);
});
});
});

View File

@@ -0,0 +1,35 @@
import { fireEvent, render } from '@testing-library/react';
import DomainItem from '../../components/domains/DomainItem';
import { dummyDomain } from '../../__mocks__/data';
const updateThumbMock = jest.fn();
const domainItemProps = {
domain: dummyDomain,
selected: false,
isConsoleIntegrated: false,
thumb: '',
updateThumb: updateThumbMock,
};
describe('DomainItem Component', () => {
it('renders without crashing', async () => {
const { container } = render(<DomainItem {...domainItemProps} />);
expect(container.querySelector('.domItem')).toBeInTheDocument();
});
it('renders keywords count', async () => {
const { container } = render(<DomainItem {...domainItemProps} />);
const domStatskeywords = container.querySelector('.dom_stats div:nth-child(1)');
expect(domStatskeywords?.textContent).toBe('Keywords10');
});
it('renders avg position', async () => {
const { container } = render(<DomainItem {...domainItemProps} />);
const domStatsAvg = container.querySelector('.dom_stats div:nth-child(2)');
expect(domStatsAvg?.textContent).toBe('Avg position24');
});
it('updates domain thumbnail on relevant button click', async () => {
const { container } = render(<DomainItem {...domainItemProps} />);
const reloadThumbbBtn = container.querySelector('.domain_thumb button');
if (reloadThumbbBtn) fireEvent.click(reloadThumbbBtn);
expect(updateThumbMock).toHaveBeenCalledWith(dummyDomain.domain);
});
});

View File

@@ -1,8 +1,14 @@
import { fireEvent, render, screen } from '@testing-library/react';
import Keyword from '../../components/keywords/Keyword';
import { dummyKeywords } from '../data';
import { dummyKeywords } from '../../__mocks__/data';
const keywordFunctions = {
const keywordProps = {
keywordData: dummyKeywords[0],
selected: false,
index: 0,
showSCData: false,
scDataType: '',
style: {},
refreshkeyword: jest.fn(),
favoriteKeyword: jest.fn(),
removeKeyword: jest.fn(),
@@ -10,35 +16,37 @@ const keywordFunctions = {
manageTags: jest.fn(),
showKeywordDetails: jest.fn(),
};
jest.mock('react-chartjs-2', () => ({
Line: () => null,
}));
describe('Keyword Component', () => {
it('renders without crashing', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
render(<Keyword {...keywordProps} />);
expect(await screen.findByText('compress image')).toBeInTheDocument();
});
it('Should Render Position Correctly', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
render(<Keyword {...keywordProps} />);
const positionElement = document.querySelector('.keyword_position');
expect(positionElement?.childNodes[0].nodeValue).toBe('19');
});
it('Should Display Position Change arrow', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
render(<Keyword {...keywordProps} />);
const positionElement = document.querySelector('.keyword_position i');
expect(positionElement?.textContent).toBe('▲ 1');
});
it('Should Display the SERP Page URL', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
render(<Keyword {...keywordProps} />);
const positionElement = document.querySelector('.keyword_url');
expect(positionElement?.textContent).toBe('/');
});
it('Should Display the Keyword Options on dots Click', async () => {
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
const button = document.querySelector('.keyword .keyword_dots');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
const { container } = render(<Keyword {...keywordProps} />);
const button = container.querySelector('.keyword_dots');
if (button) fireEvent.click(button);
expect(document.querySelector('.keyword_options')).toBeVisible();
});
// it('Should favorite Keywords', async () => {
// render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
// render(<Keyword {...keywordProps} />);
// const button = document.querySelector('.keyword .keyword_dots');
// if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
// const option = document.querySelector('.keyword .keyword_options li:nth-child(1) a');

View File

@@ -0,0 +1,28 @@
import { fireEvent, render, screen } from '@testing-library/react';
import Modal from '../../components/common/Modal';
const closeModalMock = jest.fn();
describe('Modal Component', () => {
it('Renders without crashing', async () => {
render(<Modal closeModal={closeModalMock }><div></div></Modal>);
expect(document.querySelector('.modal')).toBeInTheDocument();
});
it('Displays the Given Content', async () => {
render(<Modal closeModal={closeModalMock}>
<div>
<h1>Hello Modal!!</h1>
</div>
</Modal>);
expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument();
});
it('Renders Modal Title', async () => {
render(<Modal closeModal={closeModalMock} title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument();
});
it('Closes the modal on close button click', async () => {
const { container } = render(<Modal closeModal={closeModalMock} title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
const closeBtn = container.querySelector('.modal-close');
if (closeBtn) fireEvent.click(closeBtn);
expect(closeModalMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,23 @@
import { fireEvent, render, screen } from '@testing-library/react';
import Sidebar from '../../components/common/Sidebar';
import { dummyDomain } from '../../__mocks__/data';
const addDomainMock = jest.fn();
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
describe('Sidebar Component', () => {
it('renders without crashing', async () => {
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
expect(screen.getByText('SerpBear')).toBeInTheDocument();
});
it('renders domain list', async () => {
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
expect(screen.getByText('compressimage.io')).toBeInTheDocument();
});
it('calls showAddModal on Add Domain button click', async () => {
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
const addDomainBtn = screen.getByTestId('add_domain');
fireEvent.click(addDomainBtn);
expect(addDomainMock).toHaveBeenCalledWith(true);
});
});

View File

@@ -1,9 +1,15 @@
import { render, screen } from '@testing-library/react';
import TopBar from '../../components/common/TopBar';
jest.mock('next/router', () => ({
useRouter: () => ({
pathname: '/',
}),
}));
describe('TopBar Component', () => {
it('renders without crashing', async () => {
render(<TopBar showSettings={() => console.log() } />);
render(<TopBar showSettings={jest.fn} showAddModal={jest.fn} />);
expect(
await screen.findByText('SerpBear'),
).toBeInTheDocument();

View File

@@ -0,0 +1,27 @@
import { renderHook, waitFor } from '@testing-library/react';
import mockRouter from 'next-router-mock';
import { useFetchDomains } from '../../services/domains';
import { createWrapper } from '../../__mocks__/utils';
import { dummyDomain } from '../../__mocks__/data';
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
fetchMock.mockIf(`${window.location.origin}/api/domains`, async () => {
return new Promise((resolve) => {
resolve({
body: JSON.stringify({ domains: [dummyDomain] }),
status: 200,
});
});
});
describe('DomainHooks', () => {
it('useFetchDomains should fetch the Domains', async () => {
const { result } = renderHook(() => useFetchDomains(mockRouter), { wrapper: createWrapper() });
// const result = { current: { isSuccess: false, data: '' } };
await waitFor(() => {
return expect(result.current.isLoading).toBe(false);
});
});
});

View File

@@ -1,17 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MockResponseInitFunction } from 'jest-fetch-mock';
import SingleDomain from '../../pages/domain/[slug]';
import { useAddDomain, useDeleteDomain, useFetchDomains, useUpdateDomain } from '../../services/domains';
import { useAddKeywords, useDeleteKeywords, useFavKeywords, useFetchKeywords, useRefreshKeywords } from '../../services/keywords';
import { dummyDomain, dummyKeywords } from '../data';
import { dummyDomain, dummyKeywords, dummySettings } from '../../__mocks__/data';
import { useFetchSettings } from '../../services/settings';
jest.mock('../../services/domains');
jest.mock('../../services/keywords');
jest.mock('../../services/settings');
jest.mock('next/router', () => ({
useRouter: () => ({
query: { slug: dummyDomain.slug },
}),
}));
jest.mock('react-chartjs-2', () => ({
Line: () => null,
}));
const useFetchDomainsFunc = useFetchDomains as jest.Mock<any>;
const useFetchKeywordsFunc = useFetchKeywords as jest.Mock<any>;
const useDeleteKeywordsFunc = useDeleteKeywords as jest.Mock<any>;
@@ -21,9 +30,12 @@ const useAddDomainFunc = useAddDomain as jest.Mock<any>;
const useAddKeywordsFunc = useAddKeywords as jest.Mock<any>;
const useUpdateDomainFunc = useUpdateDomain as jest.Mock<any>;
const useDeleteDomainFunc = useDeleteDomain as jest.Mock<any>;
const useFetchSettingsFunc = useFetchSettings as jest.Mock<any>;
describe('SingleDomain Page', () => {
const queryClient = new QueryClient();
beforeEach(() => {
useFetchSettingsFunc.mockImplementation(() => ({ data: { settings: dummySettings }, isLoading: false }));
useFetchDomainsFunc.mockImplementation(() => ({ data: { domains: [dummyDomain] }, isLoading: false }));
useFetchKeywordsFunc.mockImplementation(() => ({ keywordsData: { keywords: dummyKeywords }, keywordsLoading: false }));
useDeleteKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
@@ -38,158 +50,163 @@ describe('SingleDomain Page', () => {
jest.clearAllMocks();
});
it('Render without crashing.', async () => {
const { getByTestId } = render(<SingleDomain />);
// screen.debug(undefined, Infinity);
expect(getByTestId('domain-header')).toBeInTheDocument();
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
expect(screen.getByTestId('domain-header')).toBeInTheDocument();
});
it('Should Call the useFetchDomains hook on render.', async () => {
render(<SingleDomain />);
// screen.debug(undefined, Infinity);
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
expect(useFetchDomains).toHaveBeenCalled();
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
});
it('Should Render the Keywords', async () => {
render(<SingleDomain />);
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(2);
});
it('Should Display the Keywords Details Sidebar on Keyword Click.', async () => {
render(<SingleDomain />);
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const keywords = document.querySelectorAll('.keyword');
const firstKeyword = keywords && keywords[0].querySelector('a');
if (firstKeyword) fireEvent(firstKeyword, new MouseEvent('click', { bubbles: true }));
if (firstKeyword) fireEvent.click(firstKeyword);
const fn: MockResponseInitFunction = async () => {
return new Promise((resolve) => {
resolve({
body: JSON.stringify({ keyword: dummyKeywords[0] }),
status: 200,
});
});
};
fetchMock.mockIf(`${window.location.origin}/api/keyword?id=${dummyKeywords[0].ID}`, fn);
expect(screen.getByTestId('keywordDetails')).toBeVisible();
});
it('Should Display the AddDomain Modal on Add Domain Button Click.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=add_domain]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('add_domain');
if (button) fireEvent.click(button);
expect(screen.getByTestId('adddomain_modal')).toBeVisible();
});
it('Should Display the AddKeywords Modal on Add Keyword Button Click.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=add_keyword]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('add_keyword');
if (button) fireEvent.click(button);
expect(screen.getByTestId('addkeywords_modal')).toBeVisible();
});
it('Should display the Domain Settings on Settings Button click.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=show_domain_settings]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('show_domain_settings');
if (button) fireEvent.click(button);
expect(screen.getByTestId('domain_settings')).toBeVisible();
});
it('Device Tab change should be functioning.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=mobile_tab]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('mobile_tab');
if (button) fireEvent.click(button);
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(0);
});
it('Search Filter should function properly', async () => {
render(<SingleDomain />);
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const inputNode = screen.getByTestId('filter_input');
fireEvent.change(inputNode, { target: { value: 'compressor' } }); // triggers onChange event
if (inputNode) fireEvent.change(inputNode, { target: { value: 'compressor' } }); // triggers onChange event
expect(inputNode.getAttribute('value')).toBe('compressor');
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(1);
});
it('Country Filter should function properly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=filter_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('filter_button');
if (button) fireEvent.click(button);
expect(document.querySelector('.country_filter')).toBeVisible();
const countrySelect = document.querySelector('.country_filter .selected');
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
if (countrySelect) fireEvent.click(countrySelect);
expect(document.querySelector('.country_filter .select_list')).toBeVisible();
const firstCountry = document.querySelector('.country_filter .select_list ul li:nth-child(1)');
if (firstCountry) fireEvent(firstCountry, new MouseEvent('click', { bubbles: true }));
if (firstCountry) fireEvent.click(firstCountry);
const keywordsCount = document.querySelectorAll('.keyword').length;
expect(keywordsCount).toBe(0);
});
// Tags Filter should function properly
it('Tags Filter should Render & Function properly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=filter_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('filter_button');
if (button) fireEvent.click(button);
expect(document.querySelector('.tags_filter')).toBeVisible();
const countrySelect = document.querySelector('.tags_filter .selected');
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
if (countrySelect) fireEvent.click(countrySelect);
expect(document.querySelector('.tags_filter .select_list')).toBeVisible();
expect(document.querySelectorAll('.tags_filter .select_list ul li').length).toBe(1);
const firstTag = document.querySelector('.tags_filter .select_list ul li:nth-child(1)');
if (firstTag) fireEvent(firstTag, new MouseEvent('click', { bubbles: true }));
if (firstTag) fireEvent.click(firstTag);
expect(document.querySelectorAll('.keyword').length).toBe(1);
});
it('Sort Options Should be visible Sort Button on Click.', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=sort_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('sort_button');
if (button) fireEvent.click(button);
expect(document.querySelector('.sort_options')).toBeVisible();
});
it('Sort: Position should sort keywords accordingly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=sort_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('sort_button');
if (button) fireEvent.click(button);
// Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(1)');
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
if (topPosSortOption) fireEvent.click(topPosSortOption);
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(firstKeywordTitle).toBe('compress image');
// Test Lowest Position Sort
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
if (button) fireEvent.click(button);
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(2)');
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor');
});
it('Sort: Date Added should sort keywords accordingly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=sort_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('sort_button');
if (button) fireEvent.click(button);
// Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(3)');
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
if (topPosSortOption) fireEvent.click(topPosSortOption);
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(firstKeywordTitle).toBe('compress image');
// Test Lowest Position Sort
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
if (button) fireEvent.click(button);
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(4)');
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor');
});
it('Sort: Alphabetical should sort keywords accordingly', async () => {
render(<SingleDomain />);
const button = document.querySelector('[data-testid=sort_button]');
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
const button = screen.getByTestId('sort_button');
if (button) fireEvent.click(button);
// Test Top Position Sort
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(5)');
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
if (topPosSortOption) fireEvent.click(topPosSortOption);
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(firstKeywordTitle).toBe('compress image');
// Test Lowest Position Sort
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
if (button) fireEvent.click(button);
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(6)');
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
expect(secondKeywordTitle).toBe('image compressor');
});

View File

@@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import * as ReactQuery from 'react-query';
import { dummyDomain } from '../../__mocks__/data';
import Domains from '../../pages/domains';
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
jest.spyOn(ReactQuery, 'useQuery').mockImplementation(jest.fn().mockReturnValue(
{ data: { domains: [dummyDomain] }, isLoading: false, isSuccess: true },
));
fetchMock.mockIf(`${window.location.origin}/api/domains`, async () => {
return new Promise((resolve) => {
resolve({
body: JSON.stringify({ domains: [dummyDomain] }),
status: 200,
});
});
});
describe('Domains Page', () => {
const queryClient = new QueryClient();
it('Renders without crashing', async () => {
render(
<QueryClientProvider client={queryClient}>
<Domains />
</QueryClientProvider>,
);
expect(screen.getByTestId('domains')).toBeInTheDocument();
});
it('Renders the Domain Component', async () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<Domains />
</QueryClientProvider>,
);
expect(container.querySelector('.domItem')).toBeInTheDocument();
});
it('Should Display Add Domain Modal on relveant Button Click.', async () => {
render(<QueryClientProvider client={queryClient}><Domains /></QueryClientProvider>);
const button = screen.getByTestId('addDomainButton');
if (button) fireEvent.click(button);
expect(screen.getByTestId('adddomain_modal')).toBeVisible();
});
it('Should Display the version number in Footer.', async () => {
render(<QueryClientProvider client={queryClient}><Domains /></QueryClientProvider>);
expect(screen.getByText('SerpBear v0.0.0')).toBeVisible();
});
});

View File

@@ -2,21 +2,16 @@ import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import Home from '../../pages/index';
const routerPush = jest.fn();
jest.mock('next/router', () => ({
useRouter: () => ({
push: routerPush,
}),
}));
describe('Home Page', () => {
const queryClient = new QueryClient();
it('Renders without crashing', async () => {
// const dummyDomain = {
// ID: 1,
// domain: 'compressimage.io',
// slug: 'compressimage-io',
// keywordCount: 0,
// lastUpdated: '2022-11-11T10:00:32.243',
// added: '2022-11-11T10:00:32.244',
// tags: [],
// notification: true,
// notification_interval: 'daily',
// notification_emails: '',
// };
render(
<QueryClientProvider client={queryClient}>
<Home />
@@ -26,12 +21,12 @@ describe('Home Page', () => {
expect(await screen.findByRole('main')).toBeInTheDocument();
expect(screen.queryByText('Add Domain')).not.toBeInTheDocument();
});
it('Should Display the Add Domain Modal when there are no Domains.', async () => {
it('Should redirect to /domains route.', async () => {
render(
<QueryClientProvider client={queryClient}>
<Home />
</QueryClientProvider>,
);
expect(await screen.findByText('Add Domain')).toBeInTheDocument();
expect(routerPush).toHaveBeenCalledWith('/domains');
});
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable @next/next/no-img-element */
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
@@ -26,11 +27,11 @@ const Sidebar = ({ domains, showAddModal } : SidebarProps) => {
rounded-r-none ${((`/domain/${d.slug}` === router.asPath || `/domain/console/${d.slug}` === router.asPath
|| `/domain/insight/${d.slug}` === router.asPath)
? 'bg-white text-zinc-800 border border-r-0' : 'text-zinc-500')}`}>
<i className={'text-center leading-4 mr-2 inline-block rounded-full w-5 h-5 bg-orange-200 not-italic'}>
{d.domain.charAt(0)}
</i>
<img
className={' inline-block mr-1'}
src={`https://www.google.com/s2/favicons?domain=${d.domain}&sz=16`} alt={d.domain}
/>
{d.domain}
{/* <span>0</span> */}
</a>
</Link>
</li>)

View File

@@ -11,9 +11,10 @@ type DomainItemProps = {
selected: boolean,
isConsoleIntegrated: boolean,
thumb: string,
updateThumb: Function,
}
const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb }: DomainItemProps) => {
const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb, updateThumb }: DomainItemProps) => {
const { keywordsUpdated, slug, keywordCount = 0, avgPosition = 0, scVisits = 0, scImpressions = 0, scPosition = 0 } = domain;
// const router = useRouter();
return (
@@ -21,8 +22,20 @@ const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb }: Do
<Link href={`/domain/${slug}`} passHref={true}>
<a className='flex flex-col lg:flex-row'>
<div className={`flex-1 p-6 flex ${!isConsoleIntegrated ? 'basis-1/3' : ''}`}>
<div className="domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded border border-gray-200 overflow-hidden">
{thumb && <img src={thumb} alt={domain.domain} />}
<div className="group domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded
border border-gray-200 overflow-hidden flex justify-center relative">
<button
className=' absolute right-1 top-0 text-gray-400 p-1 transition-all
invisible opacity-0 group-hover:visible group-hover:opacity-100 hover:text-gray-600 z-10'
title='Reload Website Screenshot'
onClick={(e) => { e.preventDefault(); e.stopPropagation(); updateThumb(domain.domain); }}
>
<Icon type="reload" size={12} />
</button>
<img
className={`self-center ${!thumb ? 'max-w-[50px]' : ''}`}
src={thumb || `https://www.google.com/s2/favicons?domain=${domain.domain}&sz=128`} alt={domain.domain}
/>
</div>
<div className="domain_details flex-1">
<h3 className='font-semibold text-base mb-2'>{domain.domain}</h3>

View File

@@ -41,7 +41,7 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
let error: DomainSettingsError | null = null;
if (domainSettings.notification_emails) {
const notification_emails = domainSettings.notification_emails.split(',');
const invalidEmails = notification_emails.find((x) => /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(x) === false);
const invalidEmails = notification_emails.find((x) => /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,15})+$/.test(x) === false);
console.log('invalidEmails: ', invalidEmails);
if (invalidEmails) {
error = { type: 'email', msg: 'Invalid Email' };

View File

@@ -12,7 +12,7 @@ type SCInsightProps = {
isConsoleIntegrated: boolean,
}
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SCInsightProps) => {
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true, domain }: SCInsightProps) => {
const [activeTab, setActiveTab] = useState<string>('stats');
const insightItems = insight[activeTab as keyof InsightDataType];
@@ -108,7 +108,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SC
(item:SCInsightItem, index: number) => {
const insightItemCount = insight ? insightItems : [];
const lastItem = !!(insightItemCount && (index === insightItemCount.length));
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} />;
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} domain={domain?.domain || ''} />;
},
)
}

View File

@@ -5,10 +5,11 @@ import Icon from '../common/Icon';
type InsightItemProps = {
item: SCInsightItem,
lastItem: boolean,
type: string
type: string,
domain: string
}
const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
const { clicks, impressions, ctr, position, country = 'zzz', keyword, page, keywords = 0, countries: cntrs = 0, date } = item;
let firstItem = keyword;
if (type === 'pages') { firstItem = page; } if (type === 'stats') {
@@ -24,7 +25,7 @@ const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
<div className=' w-3/4 lg:flex-1 lg:basis-20 lg:w-auto font-semibold'>
{type === 'countries' && <span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} />}
{firstItem}
{type === 'pages' && domain ? <a href={`https://${domain}${page}`} target='_blank' rel="noreferrer">{firstItem}</a> : firstItem}
</div>
<div className='keyword_pos text-center inline-block mr-3 lg:mr-0 lg:flex-1'>

View File

@@ -54,7 +54,7 @@ const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSett
minWidth={270}
/>
</div>
{['scrapingant', 'scrapingrobot', 'serply', 'serpapi', 'spaceSerp'].includes(settings.scraper_type) && (
{['scrapingant', 'scrapingrobot', 'serply', 'serpapi', 'spaceSerp', 'searchapi'].includes(settings.scraper_type) && (
<div className="settings__section__input mr-3">
<label className={labelStyle}>Scraper API Key or Token</label>
<input

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Toaster } from 'react-hot-toast';
import useUpdateSettings, { useFetchSettings } from '../../services/settings';
import { useFetchSettings, useUpdateSettings } from '../../services/settings';
import Icon from '../common/Icon';
import NotificationSettings from './NotificationSettings';
import ScraperSettings from './ScraperSettings';

View File

@@ -1,11 +1,26 @@
// eslint-disable-next-line no-unused-vars
import 'isomorphic-fetch';
import './styles/globals.css';
import '@testing-library/jest-dom';
import { enableFetchMocks } from 'jest-fetch-mock';
// Optional: configure or set up a testing framework before each test.
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
// Used for __tests__/testing-library.js
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
window.matchMedia = (query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
});
global.ResizeObserver = require('resize-observer-polyfill');
// Enable Fetch Mocking
enableFetchMocks();

84
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "serpbear",
"version": "0.3.1",
"version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "serpbear",
"version": "0.3.1",
"version": "0.3.3",
"dependencies": {
"@googleapis/searchconsole": "^1.0.0",
"@types/react-transition-group": "^4.4.5",
@@ -45,6 +45,7 @@
"@types/cookies": "^0.7.7",
"@types/cryptr": "^4.0.1",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^29.5.8",
"@types/jsonwebtoken": "^8.5.9",
"@types/node": "18.11.0",
"@types/nodemailer": "^6.4.6",
@@ -58,6 +59,8 @@
"eslint-config-next": "12.3.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"next-router-mock": "^0.9.10",
"postcss": "^8.4.31",
"prettier": "^2.7.1",
"resize-observer-polyfill": "^1.5.1",
@@ -2131,6 +2134,48 @@
"@types/istanbul-lib-report": "*"
}
},
"node_modules/@types/jest": {
"version": "29.5.8",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz",
"integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==",
"dev": true,
"dependencies": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/jest/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@types/jest/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dev": true,
"dependencies": {
"@jest/schemas": "^29.6.3",
"ansi-styles": "^5.0.0",
"react-is": "^18.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@types/jest/node_modules/react-is": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true
},
"node_modules/@types/js-levenshtein": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.2.tgz",
@@ -4109,6 +4154,15 @@
"node": ">=6.0"
}
},
"node_modules/cross-fetch": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz",
"integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==",
"dev": true,
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -7786,6 +7840,16 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-fetch-mock": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz",
"integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==",
"dev": true,
"dependencies": {
"cross-fetch": "^3.0.4",
"promise-polyfill": "^8.1.3"
}
},
"node_modules/jest-get-type": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
@@ -9797,6 +9861,16 @@
}
}
},
"node_modules/next-router-mock": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/next-router-mock/-/next-router-mock-0.9.10.tgz",
"integrity": "sha512-bK6sRb/xGNFgHVUZuvuApn6KJBAKTPiP36A7a9mO77U4xQO5ukJx9WHlU67Tv8AuySd09pk0+Hu8qMVIAmLO6A==",
"dev": true,
"peerDependencies": {
"next": ">=10.0.0",
"react": ">=17.0.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
@@ -10792,6 +10866,12 @@
"integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
"optional": true
},
"node_modules/promise-polyfill": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
"dev": true
},
"node_modules/promise-retry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "serpbear",
"version": "0.3.1",
"version": "0.3.3",
"private": true,
"scripts": {
"dev": "next dev",
@@ -53,6 +53,7 @@
"@types/cookies": "^0.7.7",
"@types/cryptr": "^4.0.1",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^29.5.8",
"@types/jsonwebtoken": "^8.5.9",
"@types/node": "18.11.0",
"@types/nodemailer": "^6.4.6",
@@ -66,6 +67,8 @@
"eslint-config-next": "12.3.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"next-router-mock": "^0.9.10",
"postcss": "^8.4.31",
"prettier": "^2.7.1",
"resize-observer-polyfill": "^1.5.1",

View File

@@ -55,6 +55,7 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
};
export const getAppSettings = async () : Promise<SettingsType> => {
const screenshotAPIKey = process.env.SCREENSHOT_API || '69408-serpbear';
try {
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
const failedQueueRaw = await readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' });
@@ -73,6 +74,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL),
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
failed_queue: failedQueue,
screenshot_key: screenshotAPIKey,
};
} catch (error) {
console.log('Error Decrypting Settings API Keys!');
@@ -81,7 +83,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
return decryptedSettings;
} catch (error) {
console.log('[ERROR] Getting App Settings. ', error);
const settings = {
const settings: SettingsType = {
scraper_type: 'none',
notification_interval: 'never',
notification_email: '',
@@ -91,6 +93,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
smtp_username: '',
smtp_password: '',
scrape_retry: false,
screenshot_key: screenshotAPIKey,
};
const otherSettings = {
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),

View File

@@ -1,19 +1,20 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import type { NextPage } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { CSSTransition } from 'react-transition-group';
import toast, { Toaster } from 'react-hot-toast';
import TopBar from '../../components/common/TopBar';
import AddDomain from '../../components/domains/AddDomain';
import Settings from '../../components/settings/Settings';
import { useFetchSettings } from '../../services/settings';
import { useFetchDomains } from '../../services/domains';
import { fetchDomainScreenshot, useFetchDomains } from '../../services/domains';
import DomainItem from '../../components/domains/DomainItem';
import Icon from '../../components/common/Icon';
type thumbImages = { [domain:string] : string }
const SingleDomain: NextPage = () => {
const Domains: NextPage = () => {
const router = useRouter();
const [noScrapprtError, setNoScrapprtError] = useState(false);
const [showSettings, setShowSettings] = useState(false);
@@ -22,33 +23,28 @@ const SingleDomain: NextPage = () => {
const { data: appSettings } = useFetchSettings();
const { data: domainsData, isLoading } = useFetchDomains(router, true);
const totalKeywords = useMemo(() => {
let keywords = 0;
if (domainsData?.domains) {
domainsData.domains.forEach(async (domain:DomainType) => {
keywords += domain?.keywordCount || 0;
});
}
return keywords;
}, [domainsData]);
useEffect(() => {
// console.log('Domains Data: ', domainsData);
if (domainsData?.domains && domainsData.domains.length > 0) {
const domainThumbsRaw = localStorage.getItem('domainThumbs');
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
if (domainsData?.domains && domainsData.domains.length > 0 && appSettings?.settings?.screenshot_key) {
domainsData.domains.forEach(async (domain:DomainType) => {
if (domain.domain) {
if (!domThumbs[domain.domain]) {
const domainImageBlob = await fetch(`https://image.thum.io/get/auth/66909-serpbear/maxAge/96/width/200/https://${domain.domain}`).then((res) => res.blob());
if (domainImageBlob) {
const reader = new FileReader();
await new Promise((resolve, reject) => {
reader.onload = resolve;
reader.onerror = reject;
reader.readAsDataURL(domainImageBlob);
});
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain.domain]: imageBase }));
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: imageBase }));
}
} else {
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domThumbs[domain.domain] }));
const domainThumb = await fetchDomainScreenshot(domain.domain, appSettings.settings.screenshot_key);
if (domainThumb) {
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domainThumb }));
}
}
});
}
}, [domainsData]);
}, [domainsData, appSettings]);
useEffect(() => {
// console.log('appSettings.settings: ', appSettings && appSettings.settings);
@@ -57,8 +53,20 @@ const SingleDomain: NextPage = () => {
}
}, [appSettings]);
const manuallyUpdateThumb = async (domain: string) => {
if (domain && appSettings?.settings?.screenshot_key) {
const domainThumb = await fetchDomainScreenshot(domain, appSettings.settings.screenshot_key, true);
if (domainThumb) {
toast(`${domain} Screenshot Updated Successfully!`, { icon: '✔️' });
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain]: domainThumb }));
} else {
toast(`Failed to Fetch ${domain} Screenshot!`, { icon: '⚠️' });
}
}
};
return (
<div className="Domain flex flex-col min-h-screen">
<div data-testid="domains" className="Domain flex flex-col min-h-screen">
{noScrapprtError && (
<div className=' p-3 bg-red-600 text-white text-sm text-center'>
A Scrapper/Proxy has not been set up Yet. Open Settings to set it up and start using the app.
@@ -71,9 +79,12 @@ const SingleDomain: NextPage = () => {
<div className="flex flex-col w-full max-w-5xl mx-auto p-6 lg:mt-24 lg:p-0">
<div className='flex justify-between mb-2 items-center'>
<div className=' text-sm'>{domainsData?.domains?.length || 0} Domains</div>
<div className=' text-sm text-gray-600'>
{domainsData?.domains?.length || 0} Domains <span className=' text-gray-300 ml-1 mr-1'>|</span> {totalKeywords} keywords
</div>
<div>
<button
data-testid="addDomainButton"
className={'ml-2 inline-block py-2 text-blue-700 font-bold text-sm'}
onClick={() => setShowAddDomain(true)}>
<span
@@ -90,6 +101,7 @@ const SingleDomain: NextPage = () => {
selected={false}
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) }
thumb={domainThumbs[domain.domain]}
updateThumb={manuallyUpdateThumb}
// isConsoleIntegrated={false}
/>;
})}
@@ -115,8 +127,9 @@ const SingleDomain: NextPage = () => {
<footer className='text-center flex flex-1 justify-center pb-5 items-end'>
<span className='text-gray-500 text-xs'><a href='https://github.com/towfiqi/serpbear' target="_blank" rel='noreferrer'>SerpBear v{appSettings?.settings?.version || '0.0.0'}</a></span>
</footer>
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};
export default SingleDomain;
export default Domains;

View File

@@ -8,7 +8,7 @@ import Icon from '../components/common/Icon';
const Home: NextPage = () => {
const router = useRouter();
useEffect(() => {
router.push('/domains');
if (router) router.push('/domains');
}, [router]);
return (

View File

@@ -4,6 +4,7 @@ import serpapi from './services/serpapi';
import serply from './services/serply';
import spaceserp from './services/spaceserp';
import proxy from './services/proxy';
import searchapi from './services/searchapi';
export default [
scrapingRobot,
@@ -12,4 +13,5 @@ export default [
serply,
spaceserp,
proxy,
searchapi,
];

View File

@@ -0,0 +1,38 @@
const searchapi:ScraperSettings = {
id: 'searchapi',
name: 'SearchApi.io',
website: 'searchapi.io',
headers: (keyword, settings) => {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${settings.scaping_api}`,
};
},
scrapeURL: (keyword) => {
return `https://www.searchapi.io/api/v1/search?engine=google&q=${encodeURI(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}`;
},
resultObjectKey: 'organic_results',
serpExtractor: (content) => {
const extractedResult = [];
const results: SearchApiResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SearchApiResult[];
for (const { link, title, position } of results) {
if (title && link) {
extractedResult.push({
title,
url: link,
position,
});
}
}
return extractedResult;
},
};
interface SearchApiResult {
title: string,
link: string,
position: number,
}
export default searchapi;

View File

@@ -19,6 +19,36 @@ export async function fetchDomains(router: NextRouter, withStats:boolean) {
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) : {};
if (!domThumbs[domain] || forceFetch) {
try {
const screenshotURL = `https://image.thum.io/get/auth/${screenshot_key}/maxAge/96/width/200/https://${domain}`;
const domainImageRes = await fetch(screenshotURL);
const domainImageBlob = domainImageRes.status === 200 ? await domainImageRes.blob() : false;
if (domainImageBlob) {
const reader = new FileReader();
await new Promise((resolve, reject) => {
reader.onload = resolve;
reader.onerror = reject;
reader.readAsDataURL(domainImageBlob);
});
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain]: imageBase }));
return imageBase;
}
return false;
} catch (error) {
return false;
}
} else if (domThumbs[domain]) {
return domThumbs[domain];
}
return false;
}
export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
return useQuery('domains', () => fetchDomains(router, withStats));
}

View File

@@ -10,7 +10,7 @@ export function useFetchSettings() {
return useQuery('settings', () => fetchSettings());
}
const useUpdateSettings = (onSuccess:Function|undefined) => {
export const useUpdateSettings = (onSuccess:Function|undefined) => {
const queryClient = useQueryClient();
return useMutation(async (settings: SettingsType) => {
@@ -60,5 +60,3 @@ export function useClearFailedQueue(onSuccess:Function) {
},
});
}
export default useUpdateSettings;

View File

@@ -24,7 +24,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"types.d.ts"
"types.d.ts",
"./jest.setup.js"
],
"exclude": [
"node_modules"

3
types.d.ts vendored
View File

@@ -83,7 +83,8 @@ type SettingsType = {
scrape_delay?: string,
scrape_retry?: boolean,
failed_queue?: string[]
version?: string
version?: string,
screenshot_key?: string,
}
type KeywordSCDataChild = {

View File

@@ -17,11 +17,11 @@ const refreshAndUpdateKeywords = async (rawkeyword:Keyword[], settings:SettingsT
const start = performance.now();
const updatedKeywords: KeywordType[] = [];
if (['scrapingant', 'serpapi'].includes(settings.scraper_type)) {
if (['scrapingant', 'serpapi', 'searchapi'].includes(settings.scraper_type)) {
const refreshedResults = await refreshParallel(keywords, settings);
if (refreshedResults.length > 0) {
for (const keyword of rawkeyword) {
const refreshedkeywordData = refreshedResults.find((k) => k && k.ID === keyword.id);
const refreshedkeywordData = refreshedResults.find((k) => k && k.ID === keyword.ID);
if (refreshedkeywordData) {
const updatedkeyword = await updateKeywordPosition(keyword, refreshedkeywordData, settings);
updatedKeywords.push(updatedkeyword);