mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54522b2261 | ||
|
|
495d872bb9 | ||
|
|
07eb4bd94f | ||
|
|
36ed4cf800 | ||
|
|
c8601ebb84 | ||
|
|
56d8b660c5 | ||
|
|
c34c8260c7 | ||
|
|
cab8f518bb | ||
|
|
6e47a6fba7 | ||
|
|
bf911b4e45 | ||
|
|
d7279512cf | ||
|
|
4fef1a9abc | ||
|
|
aeed1f8559 | ||
|
|
12eac2b012 | ||
|
|
649f412303 | ||
|
|
a2edabbdf9 | ||
|
|
3690e97fe7 | ||
|
|
17fb2c40cc | ||
|
|
faa3519a29 | ||
|
|
bc02c929ba | ||
|
|
d9d7c6347e | ||
|
|
c5714c00ae | ||
|
|
1bef7587cc | ||
|
|
5507cac07f | ||
|
|
a74338fe15 | ||
|
|
4c2f900d85 | ||
|
|
040dab1517 | ||
|
|
29c455ea56 | ||
|
|
42c5e2be07 | ||
|
|
d3e3760527 | ||
|
|
7597210ca2 | ||
|
|
4b8730e416 | ||
|
|
34dce13143 | ||
|
|
3786438662 | ||
|
|
f48288473e | ||
|
|
01b1b7b9e9 | ||
|
|
a050536814 | ||
|
|
bc96dc7de5 | ||
|
|
b35d333bfc | ||
|
|
a09eb62f5a | ||
|
|
42a00dafad | ||
|
|
432fc6161c | ||
|
|
15a1224260 | ||
|
|
232507e1ff | ||
|
|
55fa7c0148 | ||
|
|
8152d81804 | ||
|
|
748dc8fc61 | ||
|
|
c24b63009c | ||
|
|
bf8fd5362b | ||
|
|
0c3068dc80 | ||
|
|
fde2f728aa | ||
|
|
40e027e1ec | ||
|
|
51da47f292 | ||
|
|
d58a716ec1 | ||
|
|
79fc6b935c | ||
|
|
3fc1024520 | ||
|
|
90f45fd1c9 | ||
|
|
3a05703921 | ||
|
|
6aa8900577 |
@@ -14,6 +14,8 @@
|
||||
"max-len": ["error", {"code": 150, "ignoreComments": true, "ignoreUrls": true}],
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
"no-unused-vars": "off",
|
||||
"implicit-arrow-linebreak": "off",
|
||||
"function-paren-newline": "off",
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
|
||||
60
CHANGELOG.md
60
CHANGELOG.md
@@ -2,6 +2,66 @@
|
||||
|
||||
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.
|
||||
|
||||
### [2.0.7](https://github.com/towfiqi/serpbear/compare/v2.0.6...v2.0.7) (2025-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Resolves AdWords integration issue. ([36ed4cf](https://github.com/towfiqi/serpbear/commit/36ed4cf800c1fd0e3df4e807faaa1fdb863df5e4))
|
||||
* resolves broken CDN images. ([bf911b4](https://github.com/towfiqi/serpbear/commit/bf911b4e45b9007a05ce6399838da3276161c61d))
|
||||
|
||||
### [2.0.6](https://github.com/towfiqi/serpbear/compare/v2.0.5...v2.0.6) (2024-11-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Ensures Docker build uses matching npm package versions from package.json ([4fef1a9](https://github.com/towfiqi/serpbear/commit/4fef1a9abc737da67ab1ea0c4efce8194890545e))
|
||||
* Resolves broken Docker build due to croner package version mismatch. ([aeed1f8](https://github.com/towfiqi/serpbear/commit/aeed1f8559e044bf658d930a22fa91f38cfedc6b)), closes [#247](https://github.com/towfiqi/serpbear/issues/247)
|
||||
* Resolves broken Proxy Scraper functionality. ([649f412](https://github.com/towfiqi/serpbear/commit/649f412303dd50127b3736740962863f735f76eb)), closes [#248](https://github.com/towfiqi/serpbear/issues/248)
|
||||
* Resolves Google Ads search volume data loading issue. ([12eac2b](https://github.com/towfiqi/serpbear/commit/12eac2b01235e9eae06882d6a2c50c793b890661))
|
||||
|
||||
### [2.0.5](https://github.com/towfiqi/serpbear/compare/v2.0.4...v2.0.5) (2024-11-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixes broken scrape result issue for keywords with special characters. ([d9d7c63](https://github.com/towfiqi/serpbear/commit/d9d7c6347e99e35f1e48f20b1e8f999612d69a72)), closes [#221](https://github.com/towfiqi/serpbear/issues/221)
|
||||
* Fixes misaligned Keywords table UI content. ([faa3519](https://github.com/towfiqi/serpbear/commit/faa3519a29fc61ef8bd2ce9275a6674f1c7946e0))
|
||||
* Resolves "Add Domain" UI confusion. ([17fb2c4](https://github.com/towfiqi/serpbear/commit/17fb2c40cc6b57ab2fe6aeb940dececd1a83411f))
|
||||
* Resolves broken Scrapingrobot scraper on new installs. ([bc02c92](https://github.com/towfiqi/serpbear/commit/bc02c929ba7efd6b4b6a09495af7310c155a26bd)), closes [#243](https://github.com/towfiqi/serpbear/issues/243)
|
||||
|
||||
### [2.0.4](https://github.com/towfiqi/serpbear/compare/v2.0.3...v2.0.4) (2024-11-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixes Docker build issue. ([1bef758](https://github.com/towfiqi/serpbear/commit/1bef7587cccada6df48cfa3d208bf123a5d00c30))
|
||||
|
||||
### [2.0.3](https://github.com/towfiqi/serpbear/compare/v2.0.2...v2.0.3) (2024-11-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to keywords for both mobile and desktop at once. ([3786438](https://github.com/towfiqi/serpbear/commit/3786438662f015613330912f668c161f007f3eef)), closes [#60](https://github.com/towfiqi/serpbear/issues/60) [#66](https://github.com/towfiqi/serpbear/issues/66) [#199](https://github.com/towfiqi/serpbear/issues/199)
|
||||
* Adds ability to set Notification Email From name. ([b35d333](https://github.com/towfiqi/serpbear/commit/b35d333bfcdf0765bd105f74c8b324aec6d98b22)), closes [#222](https://github.com/towfiqi/serpbear/issues/222)
|
||||
* Adds the ability to show hide columns in tracked keywords table. ([d3e3760](https://github.com/towfiqi/serpbear/commit/d3e37605279c48f7a56b2ef5125c32a00b948e05)), closes [#224](https://github.com/towfiqi/serpbear/issues/224)
|
||||
* auto filter keywords if they already exist instead of throwing error. ([a09eb62](https://github.com/towfiqi/serpbear/commit/a09eb62f5a65f584c32ddb5c46018dc404d3e1f3)), closes [#244](https://github.com/towfiqi/serpbear/issues/244)
|
||||
* Displays Best position on mobile layout as well. ([a74338f](https://github.com/towfiqi/serpbear/commit/a74338fe15bee0fbc65a5197ee56db5d6a629bc8))
|
||||
* Displays keyword's best position in email notification. ([4c2f900](https://github.com/towfiqi/serpbear/commit/4c2f900d85e56b1994630942c9ea53d0fd0b4cdb))
|
||||
* Keywords Country filter now only shows relevant countries. ([a050536](https://github.com/towfiqi/serpbear/commit/a0505368140076d6626647993a41a5f4ef9db019))
|
||||
* Makes Content width a little wider. ([42c5e2b](https://github.com/towfiqi/serpbear/commit/42c5e2be0777a1bd72aa53bdf3c211075793a6e8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Correct CTR calculation in InsightStats component ([232507e](https://github.com/towfiqi/serpbear/commit/232507e1ff7519cfc82f3818d3b757f2c427b52a))
|
||||
* Fixes incorrect position display in keyword detail view when position is above 100 ([01b1b7b](https://github.com/towfiqi/serpbear/commit/01b1b7b9e9c603e470217ad476dd54359d7d35b8))
|
||||
* Fixes missing keyword city value in exported csv file. ([f482884](https://github.com/towfiqi/serpbear/commit/f48288473e910c00e7c9178366a394e891bc47c7)), closes [#194](https://github.com/towfiqi/serpbear/issues/194)
|
||||
* Fixes missing Search Console data in Email notification when its integrated through App settings. ([040dab1](https://github.com/towfiqi/serpbear/commit/040dab15177d81874a6eb89f913a450f8a6f212d))
|
||||
* Resolves incorrect search trend graph in Ideas section. ([7597210](https://github.com/towfiqi/serpbear/commit/7597210ca2018b4abc82d441d2fc871e46295307)), closes [#219](https://github.com/towfiqi/serpbear/issues/219)
|
||||
* Resolves notification email's incorrect image size in some email clients. ([bc96dc7](https://github.com/towfiqi/serpbear/commit/bc96dc7de50844fbda19a89961c36cc65ac0b97b)), closes [#201](https://github.com/towfiqi/serpbear/issues/201)
|
||||
* update scraping robot typo in README ([c24b630](https://github.com/towfiqi/serpbear/commit/c24b63009c48c61218ea96511b11fe1a4f2ff239))
|
||||
|
||||
### [2.0.2](https://github.com/towfiqi/serpbear/compare/v2.0.1...v2.0.2) (2024-03-13)
|
||||
|
||||
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -1,5 +1,6 @@
|
||||
FROM node:lts-alpine AS deps
|
||||
|
||||
FROM node:22.11.0-alpine3.20 AS deps
|
||||
ENV NPM_VERSION=10.3.0
|
||||
RUN npm install -g npm@"${NPM_VERSION}"
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
@@ -7,8 +8,10 @@ RUN npm install
|
||||
COPY . .
|
||||
|
||||
|
||||
FROM node:lts-alpine AS builder
|
||||
FROM node:22.11.0-alpine3.20 AS builder
|
||||
WORKDIR /app
|
||||
ENV NPM_VERSION=10.3.0
|
||||
RUN npm install -g npm@"${NPM_VERSION}"
|
||||
COPY --from=deps /app ./
|
||||
RUN rm -rf /app/data
|
||||
RUN rm -rf /app/__tests__
|
||||
@@ -16,9 +19,11 @@ RUN rm -rf /app/__mocks__
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM node:lts-alpine AS runner
|
||||
FROM node:22.11.0-alpine3.20 AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV production
|
||||
ENV NPM_VERSION=10.3.0
|
||||
RUN npm install -g npm@"${NPM_VERSION}"
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN set -xe && mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
@@ -35,7 +40,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.sequelizerc ./.sequelizerc
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/entrypoint.sh ./entrypoint.sh
|
||||
RUN rm package.json
|
||||
RUN npm init -y
|
||||
RUN npm i cryptr dotenv croner @googleapis/searchconsole sequelize-cli @isaacs/ttlcache
|
||||
RUN npm i cryptr@6.0.3 dotenv@16.0.3 croner@9.0.0 @googleapis/searchconsole@1.0.5 sequelize-cli@6.6.2 @isaacs/ttlcache@1.4.1
|
||||
RUN npm i -g concurrently
|
||||
|
||||
USER nextjs
|
||||
|
||||
25
README.md
25
README.md
@@ -2,13 +2,13 @@
|
||||
|
||||
# SerpBear
|
||||
|
||||
   
|
||||
    [](https://www.youtube.com/watch?v=bjtDsd0g468&rco=1)
|
||||
|
||||
#### [Documentation](https://docs.serpbear.com/) | [Changelog](https://github.com/towfiqi/serpbear/blob/main/CHANGELOG.md) | [Docker Image](https://hub.docker.com/r/towfiqi/serpbear)
|
||||
|
||||
SerpBear is an Open Source Search Engine Position Tracking and Keyword Research App. It allows you to track your website's keyword positions in Google and get notified of their position change.
|
||||
|
||||

|
||||

|
||||
|
||||
#### Features
|
||||
|
||||
@@ -22,7 +22,7 @@ SerpBear is an Open Source Search Engine Position Tracking and Keyword Research
|
||||
|
||||
#### How it Works
|
||||
|
||||
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.
|
||||
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SearchApi, SerpApi, HasData or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword.
|
||||
|
||||
The Keyword Research and keyword generation feature works by integrating your Google Ads test accounts into SerpBear. You can also view the added keyword's monthly search volume data once you [integrate Google Ads](https://docs.serpbear.com/miscellaneous/integrate-google-ads).
|
||||
|
||||
@@ -43,15 +43,16 @@ When you [integrate Google Search Console](https://docs.serpbear.com/miscellaneo
|
||||
If you don't want to use proxies, you can use third party Scraping services to scrape Google Search results.
|
||||
|
||||
|
||||
| Service | Cost | SERP Lookup | API |
|
||||
| ---------------- | ------------- | -------------- | --- |
|
||||
| scraingrobot.com | Free | 5000/mo | Yes |
|
||||
| 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 |
|
||||
| valueserp.com | Pay As You Go | $2.50/1000 req | No |
|
||||
| serper.dev | Pay As You Go | $1.00/1000 req | No |
|
||||
| Service | Cost | SERP Lookup | API |
|
||||
| ----------------- | ------------- | -------------- | --- |
|
||||
| scrapingrobot.com | Free | 5000/mo | Yes |
|
||||
| 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 |
|
||||
| valueserp.com | Pay As You Go | $2.50/1000 req | No |
|
||||
| serper.dev | Pay As You Go | $1.00/1000 req | No |
|
||||
| hasdata.com | From $29/mo | From 10,000/mo | Yes |
|
||||
|
||||
**Tech Stack**
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export const dummySettings = {
|
||||
notification_interval: 'never',
|
||||
notification_email: '',
|
||||
notification_email_from: '',
|
||||
notification_email_from_name: 'SerpBear',
|
||||
smtp_server: '',
|
||||
smtp_port: '',
|
||||
smtp_username: '',
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { rest } from 'msw';
|
||||
import { http } from 'msw';
|
||||
import * as React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
export const handlers = [
|
||||
rest.get(
|
||||
http.get(
|
||||
'*/react-query',
|
||||
(req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
({ request, params }) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name: 'mocked-react-query',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -7,10 +7,11 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler,
|
||||
type ChartProps ={
|
||||
labels: string[],
|
||||
sreies: number[],
|
||||
noMaxLimit?: boolean
|
||||
noMaxLimit?: boolean,
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
const ChartSlim = ({ labels, sreies, noMaxLimit = false }:ChartProps) => {
|
||||
const ChartSlim = ({ labels, sreies, noMaxLimit = false, reverse = true }:ChartProps) => {
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -18,7 +19,7 @@ const ChartSlim = ({ labels, sreies, noMaxLimit = false }:ChartProps) => {
|
||||
scales: {
|
||||
y: {
|
||||
display: false,
|
||||
reverse: true,
|
||||
reverse,
|
||||
min: 1,
|
||||
max: noMaxLimit ? undefined : 100,
|
||||
},
|
||||
|
||||
@@ -10,10 +10,10 @@ type IconProps = {
|
||||
}
|
||||
|
||||
const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '' }: IconProps) => {
|
||||
const xmlnsProps = { xmlns: 'http://www.w3.org/2000/svg', xmlnsXlink: 'http://www.w3.org/1999/xlink', preserveAspectRatio: 'xMidYMid meet' };
|
||||
const xmlnsProps = { title, xmlns: 'http://www.w3.org/2000/svg', xmlnsXlink: 'http://www.w3.org/1999/xlink', preserveAspectRatio: 'xMidYMid meet' };
|
||||
|
||||
return (
|
||||
<span className={`icon inline-block relative top-[2px] ${classes}`} title={title}>
|
||||
<span className={`icon inline-block relative top-[2px] ${classes}`}>
|
||||
{type === 'logo'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 1484.32 1348.5">
|
||||
<path fill={color} d="M1406.23,604.17s-44-158.18,40.43-192.67,195,97.52,195,97.52,314-65.41,534,0c0,0,122.16-105.61,214.68-80.28,99.9,27.36,32.7,181.38,32.7,181.38s228.36,384.15,239.06,737.38c0,0-346.1,346.09-746.9,406.75,0,0-527.47-106.44-737.38-449.57C1177.88,1304.68,1169.55,1008.54,1406.23,604.17Z" transform="translate(-1177.84 -405.75)"/>
|
||||
@@ -308,6 +308,11 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
|
||||
<path fill={color} d="M7.328 43.504c.445 0 .914-.14 1.383-.469V17.957c0-.844.164-1.172.914-1.57L31.352 3.87c.07-1.547-.915-2.508-2.25-2.508c-.61 0-1.266.164-1.97.586L7.493 13.246c-2.297 1.336-2.578 1.758-2.578 4.43v22.43c0 2.015.96 3.398 2.414 3.398m9.375 5.414c.422 0 .89-.14 1.383-.469V23.371c0-.914.117-1.148.89-1.57L40.703 9.26c.07-1.523-.89-2.507-2.25-2.507c-.586 0-1.266.187-1.945.562L16.82 18.636c-2.297 1.313-2.555 1.805-2.555 4.43V45.52c0 2.015 1.008 3.398 2.438 3.398m10.031 5.719c.82 0 1.805-.328 2.977-.985l18.375-10.547c2.156-1.242 3-2.53 3-5.156l-.047-21.234c0-2.813-1.008-4.242-2.766-4.242c-.773 0-1.758.304-2.859.937L26.992 24.027c-2.203 1.29-2.977 2.602-2.977 5.157v21.234c0 2.719.961 4.219 2.72 4.219M28 50.067c-.117-.024-.164-.094-.164-.258L28 29.254c0-.89.258-1.36 1.055-1.805l17.742-10.43c.07-.046.14-.046.234-.023c.094.024.164.094.164.258l-.07 20.625c0 .773-.281 1.36-1.055 1.828L28.234 50.043a.284.284 0 0 1-.234.023"></path>
|
||||
</svg>
|
||||
}
|
||||
{type === 'lock'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<path fill={color} d="M6 22q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8h1V6q0-2.075 1.463-3.537T12 1t3.538 1.463T17 6v2h1q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22zm6-5q.825 0 1.413-.587T14 15t-.587-1.412T12 13t-1.412.588T10 15t.588 1.413T12 17M9 8h6V6q0-1.25-.875-2.125T12 3t-2.125.875T9 6z" />
|
||||
</svg>
|
||||
}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ const AddDomain = ({ closeModal, domains = [] }: AddDomainProps) => {
|
||||
}
|
||||
});
|
||||
if (invalidDomains.length > 0) {
|
||||
setNewDomainError(`Please Insert Valid Domain URL. Invalid URLs: ${invalidDomains.join(', ')}`);
|
||||
setNewDomainError(`Please Insert Valid Website URL. ${invalidDomains.length > 1 ? `Invalid URLs: ${invalidDomains.join(', ')}` : ''}`);
|
||||
} else if (domainsTobeAdded.length > 0) {
|
||||
console.log('domainsTobeAdded :', domainsTobeAdded);
|
||||
addMutate(domainsTobeAdded);
|
||||
@@ -51,11 +51,11 @@ const AddDomain = ({ closeModal, domains = [] }: AddDomainProps) => {
|
||||
return (
|
||||
<Modal closeModal={() => { closeModal(false); }} title={'Add New Domain'}>
|
||||
<div data-testid="adddomain_modal">
|
||||
<h4 className='text-sm mt-4'>Domain URL</h4>
|
||||
<h4 className='text-sm mt-4 pb-2'>Website URL(s)</h4>
|
||||
<textarea
|
||||
className={`w-full h-40 border rounded border-gray-200 p-4 outline-none
|
||||
focus:border-indigo-300 ${newDomainError ? ' border-red-400 focus:border-red-400' : ''}`}
|
||||
placeholder="Type or Paste URLs here. Insert Each URL in a New line."
|
||||
placeholder={'Type or Paste URLs here. Insert Each URL in a New line. eg: \nhttps://mysite.com/ \nhttps://anothersite.com/ '}
|
||||
value={newDomain}
|
||||
autoFocus={true}
|
||||
onChange={handleDomainInput}>
|
||||
|
||||
@@ -61,7 +61,7 @@ const KeywordIdea = (props: KeywordIdeaProps) => {
|
||||
onClick={() => showKeywordDetails()}
|
||||
className={`keyword_visits text-center hidden mt-4 mr-5 ml-5 cursor-pointer
|
||||
lg:flex-1 lg:m-0 lg:ml-10 max-w-[70px] lg:max-w-none lg:pr-5 lg:flex justify-center`}>
|
||||
{chartData.labels.length > 0 && <ChartSlim labels={chartData.labels} sreies={chartData.sreies} noMaxLimit={true} />}
|
||||
{chartData.labels.length > 0 && <ChartSlim labels={chartData.labels} sreies={chartData.sreies} noMaxLimit={true} reverse={false} />}
|
||||
</div>
|
||||
|
||||
<div className='keyword_ctr text-center inline-block ml-4 lg:flex mt-4 relative lg:flex-1 lg:m-0 justify-center'>
|
||||
|
||||
@@ -13,16 +13,20 @@ type InsightStatsProps = {
|
||||
}
|
||||
|
||||
const InsightStats = ({ stats = [], totalKeywords = 0, totalPages = 0 }:InsightStatsProps) => {
|
||||
const totalStat = useMemo(() => {
|
||||
return stats.reduce((acc, item) => {
|
||||
return {
|
||||
const totalStat = useMemo(() => {
|
||||
const totals = stats.reduce((acc, item) => {
|
||||
return {
|
||||
impressions: item.impressions + acc.impressions,
|
||||
clicks: item.clicks + acc.clicks,
|
||||
ctr: item.ctr + acc.ctr,
|
||||
position: item.position + acc.position,
|
||||
};
|
||||
}, { impressions: 0, clicks: 0, ctr: 0, position: 0 });
|
||||
}, [stats]);
|
||||
};
|
||||
}, { impressions: 0, clicks: 0, position: 0 });
|
||||
|
||||
return {
|
||||
...totals,
|
||||
ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0,
|
||||
};
|
||||
}, [stats]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import Icon from '../common/Icon';
|
||||
import Modal from '../common/Modal';
|
||||
import SelectField from '../common/SelectField';
|
||||
@@ -23,10 +23,11 @@ type KeywordsInput = {
|
||||
}
|
||||
|
||||
const AddKeywords = ({ closeModal, domain, keywords, scraperName = '', allowsCity = false }: AddKeywordsProps) => {
|
||||
const [error, setError] = useState<string>('');
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const defCountry = localStorage.getItem('default_country') || 'US';
|
||||
|
||||
const [error, setError] = useState<string>('');
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
|
||||
const [newKeywordsData, setNewKeywordsData] = useState<KeywordsInput>({ keywords: '', device: 'desktop', country: defCountry, domain, tags: '' });
|
||||
const { mutate: addMutate, isLoading: isAdding } = useAddKeywords(() => closeModal(false));
|
||||
|
||||
@@ -35,20 +36,45 @@ const AddKeywords = ({ closeModal, domain, keywords, scraperName = '', allowsCit
|
||||
return [...new Set(allTags)];
|
||||
}, [keywords]);
|
||||
|
||||
const setDeviceType = useCallback((input:string) => {
|
||||
let updatedDevice = '';
|
||||
if (newKeywordsData.device.includes(input)) {
|
||||
updatedDevice = newKeywordsData.device.replace(',', '').replace(input, '');
|
||||
} else {
|
||||
updatedDevice = newKeywordsData.device ? `${newKeywordsData.device},${input}` : input;
|
||||
}
|
||||
setNewKeywordsData({ ...newKeywordsData, device: updatedDevice });
|
||||
}, [newKeywordsData]);
|
||||
|
||||
const addKeywords = () => {
|
||||
if (newKeywordsData.keywords) {
|
||||
const keywordsArray = [...new Set(newKeywordsData.keywords.split('\n').map((item) => item.trim()).filter((item) => !!item))];
|
||||
const nkwrds = newKeywordsData;
|
||||
if (nkwrds.keywords) {
|
||||
const devices = nkwrds.device.split(',');
|
||||
const multiDevice = nkwrds.device.includes(',') && devices.length > 1;
|
||||
const keywordsArray = [...new Set(nkwrds.keywords.split('\n').map((item) => item.trim()).filter((item) => !!item))];
|
||||
const currentKeywords = keywords.map((k) => `${k.keyword}-${k.device}-${k.country}${k.city ? `-${k.city}` : ''}`);
|
||||
const keywordExist = keywordsArray.filter((k) => currentKeywords.includes(
|
||||
`${k}-${newKeywordsData.device}-${newKeywordsData.country}${newKeywordsData.city ? `-${newKeywordsData.city}` : ''}`,
|
||||
));
|
||||
if (keywordExist.length > 0) {
|
||||
|
||||
const keywordExist = keywordsArray.filter((k) =>
|
||||
devices.some((device) => currentKeywords.includes(`${k}-${device}-${nkwrds.country}${nkwrds.city ? `-${nkwrds.city}` : ''}`)),
|
||||
);
|
||||
|
||||
if (!multiDevice && (keywordsArray.length === 1 || currentKeywords.length === keywordExist.length) && keywordExist.length > 0) {
|
||||
setError(`Keywords ${keywordExist.join(',')} already Exist`);
|
||||
setTimeout(() => { setError(''); }, 3000);
|
||||
} else {
|
||||
const { device, country, domain: kDomain, tags, city } = newKeywordsData;
|
||||
const newKeywordsArray = keywordsArray.map((nItem) => ({ keyword: nItem, device, country, domain: kDomain, tags, city }));
|
||||
addMutate(newKeywordsArray);
|
||||
const newKeywords = keywordsArray.flatMap((k) =>
|
||||
devices.filter((device) =>
|
||||
!currentKeywords.includes(`${k}-${device}-${nkwrds.country}${nkwrds.city ? `-${nkwrds.city}` : ''}`),
|
||||
).map((device) => ({
|
||||
keyword: k,
|
||||
device,
|
||||
country: nkwrds.country,
|
||||
domain: nkwrds.domain,
|
||||
tags: nkwrds.tags,
|
||||
city: nkwrds.city,
|
||||
})),
|
||||
);
|
||||
addMutate(newKeywords);
|
||||
}
|
||||
} else {
|
||||
setError('Please Insert a Keyword');
|
||||
@@ -56,7 +82,7 @@ const AddKeywords = ({ closeModal, domain, keywords, scraperName = '', allowsCit
|
||||
}
|
||||
};
|
||||
|
||||
const deviceTabStyle = 'cursor-pointer px-3 py-2 rounded mr-2';
|
||||
const deviceTabStyle = 'cursor-pointer px-2 py-2 rounded';
|
||||
|
||||
return (
|
||||
<Modal closeModal={() => { closeModal(false); }} title={'Add New Keywords'} width="[420px]">
|
||||
@@ -89,13 +115,17 @@ const AddKeywords = ({ closeModal, domain, keywords, scraperName = '', allowsCit
|
||||
</div>
|
||||
<ul className='flex text-xs font-semibold text-gray-500'>
|
||||
<li
|
||||
className={`${deviceTabStyle} ${newKeywordsData.device === 'desktop' ? ' bg-indigo-50 text-gray-700' : ''}`}
|
||||
onClick={() => setNewKeywordsData({ ...newKeywordsData, device: 'desktop' })}
|
||||
><Icon type='desktop' classes={'top-[3px]'} size={15} /> <i className='not-italic hidden lg:inline-block'>Desktop</i></li>
|
||||
className={`${deviceTabStyle} mr-2 ${newKeywordsData.device.includes('desktop') ? ' bg-indigo-50 text-indigo-700' : ''}`}
|
||||
onClick={() => setDeviceType('desktop')}>
|
||||
<Icon type='desktop' classes={'top-[3px]'} size={15} /> <i className='not-italic hidden lg:inline-block'>Desktop</i>
|
||||
<Icon type='check' classes={'pl-1'} size={12} color={newKeywordsData.device.includes('desktop') ? '#4338ca' : '#bbb'} />
|
||||
</li>
|
||||
<li
|
||||
className={`${deviceTabStyle} ${newKeywordsData.device === 'mobile' ? ' bg-indigo-50 text-gray-700' : ''}`}
|
||||
onClick={() => setNewKeywordsData({ ...newKeywordsData, device: 'mobile' })}
|
||||
><Icon type='mobile' /> <i className='not-italic hidden lg:inline-block'>Mobile</i></li>
|
||||
className={`${deviceTabStyle} ${newKeywordsData.device.includes('mobile') ? ' bg-indigo-50 text-indigo-700' : ''}`}
|
||||
onClick={() => setDeviceType('mobile')}>
|
||||
<Icon type='mobile' /> <i className='not-italic hidden lg:inline-block'>Mobile</i>
|
||||
<Icon type='check' classes={'pl-1'} size={12} color={newKeywordsData.device.includes('mobile') ? '#4338ca' : '#bbb'} />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
|
||||
@@ -21,7 +21,9 @@ type KeywordProps = {
|
||||
lastItem?:boolean,
|
||||
showSCData: boolean,
|
||||
scDataType: string,
|
||||
style: Object
|
||||
style: Object,
|
||||
maxTitleColumnWidth: number,
|
||||
tableColumns? : string[]
|
||||
}
|
||||
|
||||
const Keyword = (props: KeywordProps) => {
|
||||
@@ -39,12 +41,16 @@ const Keyword = (props: KeywordProps) => {
|
||||
style,
|
||||
index,
|
||||
scDataType = 'threeDays',
|
||||
tableColumns = [],
|
||||
maxTitleColumnWidth,
|
||||
} = props;
|
||||
const {
|
||||
keyword, domain, ID, city, position, url = '', lastUpdated, country, sticky, history = {}, updating = false, lastUpdateError = false, volume,
|
||||
} = keywordData;
|
||||
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [showPositionError, setPositionError] = useState(false);
|
||||
|
||||
const turncatedURL = useMemo(() => {
|
||||
return url.replace(`https://${domain}`, '').replace(`https://www.${domain}`, '').replace(`http://${domain}`, '');
|
||||
}, [url, domain]);
|
||||
@@ -73,7 +79,7 @@ const Keyword = (props: KeywordProps) => {
|
||||
let bestPos;
|
||||
if (Object.keys(history).length > 0) {
|
||||
const historyArray = Object.keys(history).map((itemID) => ({ date: itemID, position: history[itemID] }))
|
||||
.sort((a, b) => a.position - b.position);
|
||||
.sort((a, b) => a.position - b.position).filter((el) => (el.position > 0));
|
||||
if (historyArray[0]) {
|
||||
bestPos = { ...historyArray[0] };
|
||||
}
|
||||
@@ -91,7 +97,7 @@ const Keyword = (props: KeywordProps) => {
|
||||
className={`keyword relative py-5 px-4 text-gray-600 border-b-[1px] border-gray-200 lg:py-4 lg:px-6 lg:border-0
|
||||
lg:flex lg:justify-between lg:items-center ${selected ? ' bg-indigo-50 keyword--selected' : ''} ${lastItem ? 'border-b-0' : ''}`}>
|
||||
|
||||
<div className=' w-3/4 font-semibold cursor-pointer lg:flex-1 lg:basis-20 lg:w-auto lg:flex lg:items-center'>
|
||||
<div className=' w-3/4 font-semibold cursor-pointer lg:flex-1 lg:shrink-0 lg:basis-28 lg:w-auto lg:flex lg:items-center'>
|
||||
<button
|
||||
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border
|
||||
${selected ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}`}
|
||||
@@ -100,10 +106,15 @@ const Keyword = (props: KeywordProps) => {
|
||||
<Icon type="check" size={10} />
|
||||
</button>
|
||||
<a
|
||||
className={`py-2 hover:text-blue-600 lg:flex lg:items-center lg:w-full ${showSCData ? 'lg:max-w-[180px]' : 'lg:max-w-[240px]'}`}
|
||||
onClick={() => showKeywordDetails()}>
|
||||
style={{ maxWidth: `${maxTitleColumnWidth - 35}px` }}
|
||||
className={'py-2 hover:text-blue-600 lg:flex lg:items-center w-full'}
|
||||
onClick={() => showKeywordDetails()}
|
||||
title={keyword}
|
||||
>
|
||||
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country][0]} />
|
||||
<span className=' text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-30px)]'>{keyword}{city ? ` (${city})` : ''}</span>
|
||||
<span className='inline-block text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-50px)]'>
|
||||
{keyword}{city ? ` (${city})` : ''}
|
||||
</span>
|
||||
</a>
|
||||
{sticky && <button className='ml-2 relative top-[2px]' title='Favorite'><Icon type="star-filled" size={16} color="#fbd346" /></button>}
|
||||
{lastUpdateError && lastUpdateError.date
|
||||
@@ -125,41 +136,45 @@ const Keyword = (props: KeywordProps) => {
|
||||
title={bestPosition && bestPosition.date
|
||||
? new Date(bestPosition.date).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' }) : ''
|
||||
}
|
||||
className={`keyword_best hidden bg-[#f8f9ff] w-fit min-w-[50px] h-12 p-2 text-base mt-[-20px] rounded right-5 lg:relative lg:block
|
||||
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-16 lg:grow-0 lg:right-0 text-center font-semibold`}>
|
||||
className={`keyword_best mr-1 bg-[#f8f9ff] float-right mt-8 w-14 rounded right-5 lg:relative lg:block
|
||||
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:mr-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-16 lg:grow-0 lg:right-0 text-center font-semibold
|
||||
${!tableColumns.includes('Best') ? 'lg:hidden' : ''}
|
||||
`}>
|
||||
{bestPosition ? bestPosition.position || '-' : (position || '-')}
|
||||
</div>
|
||||
|
||||
{chartData.labels.length > 0 && (
|
||||
<div
|
||||
className='hidden basis-20 grow-0 cursor-pointer lg:block'
|
||||
className={`hidden basis-20 grow-0 cursor-pointer lg:block ${!tableColumns.includes('History') ? 'lg:hidden' : ''}`}
|
||||
onClick={() => showKeywordDetails()}>
|
||||
<ChartSlim labels={chartData.labels} sreies={chartData.sreies} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`keyword_best hidden bg-[#f8f9ff] w-fit min-w-[50px] h-12 p-2 text-base mt-[-20px] rounded right-5 lg:relative lg:block
|
||||
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-24 lg:grow-0 lg:right-0 text-center`}>
|
||||
className={`hidden bg-[#f8f9ff] w-fit min-w-[50px] h-12 p-2 text-base mt-[-20px] rounded right-5 lg:relative lg:block
|
||||
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-24 lg:grow-0 lg:right-0 text-center
|
||||
${!tableColumns.includes('Volume') ? 'lg:hidden' : ''}
|
||||
`}>
|
||||
{formattedNum(volume)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`keyword_url inline-block mt-4 mr-5 ml-5 lg:flex-1 text-gray-400 lg:m-0 max-w-[70px]
|
||||
overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-none lg:pr-5`}>
|
||||
overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-none lg:pr-5 lg:pl-3`}>
|
||||
<a href={url} target="_blank" rel="noreferrer"><span className='mr-3 lg:hidden'>
|
||||
<Icon type="link-alt" size={14} color="#999" /></span>{turncatedURL || '-'}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='inline-block mt-[4] top-[-5px] relative lg:flex-1 lg:m-0 lg:top-0'>
|
||||
className='inline-block mt-[4] top-[-5px] relative lg:flex-1 lg:m-0 lg:top-0 max-w-[150px]'>
|
||||
<span className='mr-2 lg:hidden'><Icon type="clock" size={14} color="#999" /></span>
|
||||
<TimeAgo title={dayjs(lastUpdated).format('DD-MMM-YYYY, hh:mm:ss A')} date={lastUpdated} />
|
||||
</div>
|
||||
|
||||
{showSCData && (
|
||||
<div className='keyword_sc_data min-w-[170px] text-xs mt-4 pt-2 border-t border-gray-100 top-[6px]
|
||||
{showSCData && tableColumns.includes('Search Console') && (
|
||||
<div className='keyword_sc_data min-w-[170px] lg:max-w-[170px] text-xs mt-4 pt-2 border-t border-gray-100 top-[6px]
|
||||
relative flex justify-between text-center lg:flex-1 lg:text-sm lg:m-0 lg:mt-0 lg:border-t-0 lg:pt-0 lg:top-0'>
|
||||
<span className='min-w-[40px]'>
|
||||
<span className='lg:hidden'>SC Position: </span>
|
||||
|
||||
@@ -54,11 +54,14 @@ const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
|
||||
return (
|
||||
<div className="keywordDetails fixed w-full h-screen top-0 left-0 z-[99999]" onClick={closeOnBGClick} data-testid="keywordDetails">
|
||||
<div className="keywordDetails absolute w-full lg:w-5/12 bg-white customShadow top-0 right-0 h-screen" >
|
||||
<div className='keywordDetails__header p-6 border-b border-b-slate-200 text-slate-500'>
|
||||
<div className='keywordDetails__header p-6 border-b border-b-slate-200 text-slate-600'>
|
||||
<h3 className=' text-lg font-bold'>
|
||||
<span title={countries[keyword.country][0]}
|
||||
className={`fflag fflag-${keyword.country} w-[18px] h-[12px] mr-2`} /> {keyword.keyword}
|
||||
<span className='py-1 px-2 ml-2 rounded bg-blue-50 text-blue-700 text-xs font-bold'>{keyword.position}</span>
|
||||
<span
|
||||
className={`py-1 px-2 ml-2 rounded bg-blue-50 ${keyword.position === 0 ? 'text-gray-500' : 'text-blue-700'} text-xs font-bold`}>
|
||||
{keyword.position === 0 ? 'Not in First 100' : keyword.position}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
className='absolute top-2 right-2 p-2 px-3 text-gray-400 hover:text-gray-700 transition-all hover:rotate-90'
|
||||
|
||||
@@ -15,6 +15,8 @@ type KeywordFilterProps = {
|
||||
integratedConsole?: boolean,
|
||||
isConsole?: boolean,
|
||||
SCcountries?: string[];
|
||||
updateColumns?: Function,
|
||||
tableColumns?: string[]
|
||||
}
|
||||
|
||||
const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
@@ -29,10 +31,13 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
filterParams,
|
||||
isConsole = false,
|
||||
integratedConsole = false,
|
||||
updateColumns,
|
||||
SCcountries = [],
|
||||
tableColumns = [],
|
||||
} = props;
|
||||
const [sortOptions, showSortOptions] = useState(false);
|
||||
const [filterOptions, showFilterOptions] = useState(false);
|
||||
const [columnOptions, showColumnOptions] = useState(false);
|
||||
|
||||
const keywordCounts = useMemo(() => {
|
||||
const counts = { desktop: 0, mobile: 0 };
|
||||
@@ -60,14 +65,22 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
const countryOptions = useMemo(() => {
|
||||
const optionObject:{label:string, value:string}[] = [];
|
||||
|
||||
Object.keys(countries).forEach((countryISO:string) => {
|
||||
if (!isConsole || (isConsole && SCcountries.includes(countryISO))) {
|
||||
optionObject.push({ label: countries[countryISO][0], value: countryISO });
|
||||
}
|
||||
});
|
||||
if (!isConsole) {
|
||||
const allCountries = Array.from(keywords as KeywordType[])
|
||||
.map((keyword) => keyword.country)
|
||||
.reduce<string[]>((acc, country) => [...acc, country], [])
|
||||
.filter((t) => t && t.trim() !== '');
|
||||
[...new Set(allCountries)].forEach((c) => optionObject.push({ label: countries[c][0], value: c }));
|
||||
} else {
|
||||
Object.keys(countries).forEach((countryISO:string) => {
|
||||
if ((SCcountries.includes(countryISO))) {
|
||||
optionObject.push({ label: countries[countryISO][0], value: countryISO });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return optionObject;
|
||||
}, [SCcountries, isConsole]);
|
||||
}, [SCcountries, isConsole, keywords]);
|
||||
|
||||
const sortOptionChoices: SelectionOption[] = [
|
||||
{ value: 'pos_asc', label: 'Top Position' },
|
||||
@@ -79,6 +92,17 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
{ value: 'vol_asc', label: 'Lowest Search Volume' },
|
||||
{ value: 'vol_desc', label: 'Highest Search Volume' },
|
||||
];
|
||||
|
||||
const columnOptionChoices: {label: string, value: string, locked: boolean}[] = [
|
||||
{ value: 'Keyword', label: 'Keyword', locked: true },
|
||||
{ value: 'Position', label: 'Position', locked: true },
|
||||
{ value: 'URL', label: 'URL', locked: true },
|
||||
{ value: 'Updated', label: 'Updated', locked: true },
|
||||
{ value: 'Best', label: 'Best', locked: false },
|
||||
{ value: 'History', label: 'History', locked: false },
|
||||
{ value: 'Volume', label: 'Volume', locked: false },
|
||||
{ value: 'Search Console', label: 'Search Console', locked: false },
|
||||
];
|
||||
if (integratedConsole) {
|
||||
sortOptionChoices.push({ value: 'imp_desc', label: `Most Viewed${isConsole ? ' (Default)' : ''}` });
|
||||
sortOptionChoices.push({ value: 'imp_asc', label: 'Least Viewed' });
|
||||
@@ -185,6 +209,43 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{!isConsole && (
|
||||
<div className='relative'>
|
||||
<button
|
||||
data-testid="columns_button"
|
||||
className={`px-2 py-1 rounded ${columnOptions ? ' bg-indigo-100 text-blue-700' : ''}`}
|
||||
title='Show/Hide Columns'
|
||||
onClick={() => showColumnOptions(!columnOptions)}
|
||||
>
|
||||
<Icon type='eye-closed' size={18} />
|
||||
</button>
|
||||
{columnOptions && (
|
||||
<ul
|
||||
data-testid="sort_options"
|
||||
className='sort_options mt-2 border absolute w-48 min-w-[0] right-0 rounded-lg
|
||||
max-h-96 bg-white z-[9999] overflow-y-auto styled-scrollbar border-gray-200 '>
|
||||
{columnOptionChoices.map(({ value, label, locked }) => {
|
||||
return <li
|
||||
key={value}
|
||||
className={sortItemStyle(value) + (locked ? 'bg-gray-50 cursor-not-allowed pointer-events-none' : '') }
|
||||
onClick={() => { if (updateColumns) { updateColumns(value); } showColumnOptions(false); }}
|
||||
>
|
||||
<span className={' inline-block px-[3px] border border-gray-200 rounded-[4px] w-5'}>
|
||||
<Icon
|
||||
title={locked ? 'Cannot be Hidden' : ''}
|
||||
type={locked ? 'lock' : 'check'}
|
||||
color={!tableColumns.includes(value) && !locked ? 'transparent' : '#999' }
|
||||
size={12}
|
||||
/>
|
||||
</span>
|
||||
{' '}{label}
|
||||
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
|
||||
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/client/sortFilter';
|
||||
@@ -12,6 +12,8 @@ import KeywordTagManager from './KeywordTagManager';
|
||||
import AddTags from './AddTags';
|
||||
import useWindowResize from '../../hooks/useWindowResize';
|
||||
import useIsMobile from '../../hooks/useIsMobile';
|
||||
import { useUpdateSettings } from '../../services/settings';
|
||||
import { defaultSettings } from '../settings/Settings';
|
||||
|
||||
type KeywordsTableProps = {
|
||||
domain: DomainType | null,
|
||||
@@ -20,10 +22,12 @@ type KeywordsTableProps = {
|
||||
showAddModal: boolean,
|
||||
setShowAddModal: Function,
|
||||
isConsoleIntegrated: boolean,
|
||||
settings?: SettingsType
|
||||
}
|
||||
|
||||
const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
const { keywords = [], isLoading = true, isConsoleIntegrated = false } = props;
|
||||
const titleColumnRef = useRef(null);
|
||||
const { keywords = [], isLoading = true, isConsoleIntegrated = false, settings } = props;
|
||||
const showSCData = isConsoleIntegrated;
|
||||
const [device, setDevice] = useState<string>('desktop');
|
||||
const [selectedKeywords, setSelectedKeywords] = useState<number[]>([]);
|
||||
@@ -36,11 +40,27 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
const [sortBy, setSortBy] = useState<string>('date_asc');
|
||||
const [scDataType, setScDataType] = useState<string>('threeDays');
|
||||
const [showScDataTypes, setShowScDataTypes] = useState<boolean>(false);
|
||||
const [maxTitleColumnWidth, setMaxTitleColumnWidth] = useState(235);
|
||||
const { mutate: deleteMutate } = useDeleteKeywords(() => {});
|
||||
const { mutate: favoriteMutate } = useFavKeywords(() => {});
|
||||
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
|
||||
const [isMobile] = useIsMobile();
|
||||
useWindowResize(() => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400)));
|
||||
|
||||
useWindowResize(() => {
|
||||
setSCListHeight(window.innerHeight - (isMobile ? 200 : 400));
|
||||
if (titleColumnRef.current) {
|
||||
setMaxTitleColumnWidth((titleColumnRef.current as HTMLElement).clientWidth);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (titleColumnRef.current) {
|
||||
setMaxTitleColumnWidth((titleColumnRef.current as HTMLElement).clientWidth);
|
||||
}
|
||||
}, [titleColumnRef]);
|
||||
|
||||
const tableColumns = settings?.keywordsColumns || ['Best', 'History', 'Volume', 'Search Console'];
|
||||
const { mutate: updateMutate, isLoading: isUpdatingSettings } = useUpdateSettings(() => console.log(''));
|
||||
|
||||
const scDataObject:{ [k:string] : string} = {
|
||||
threeDays: 'Last Three Days',
|
||||
@@ -71,6 +91,16 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
}
|
||||
setSelectedKeywords(updatedSelectd);
|
||||
};
|
||||
|
||||
const updateColumns = (column:string) => {
|
||||
const newColumns = tableColumns.includes(column) ? tableColumns.filter((col) => col !== column) : [...tableColumns, column];
|
||||
updateMutate({ ...defaultSettings, ...settings, keywordsColumns: newColumns });
|
||||
};
|
||||
|
||||
const shouldHideColumn = useCallback((col:string) => {
|
||||
return settings?.keywordsColumns && !settings?.keywordsColumns.includes(col) ? 'lg:hidden' : '';
|
||||
}, [settings?.keywordsColumns]);
|
||||
|
||||
const Row = ({ data, index, style }:ListChildComponentProps) => {
|
||||
const keyword = data[index];
|
||||
return (
|
||||
@@ -89,6 +119,8 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
lastItem={index === (processedKeywords[device].length - 1)}
|
||||
showSCData={showSCData}
|
||||
scDataType={scDataType}
|
||||
tableColumns={tableColumns}
|
||||
maxTitleColumnWidth={maxTitleColumnWidth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -136,15 +168,19 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
keywords={keywords}
|
||||
device={device}
|
||||
setDevice={setDevice}
|
||||
updateColumns={updateColumns}
|
||||
tableColumns={tableColumns}
|
||||
integratedConsole={isConsoleIntegrated}
|
||||
/>
|
||||
)}
|
||||
<div className={`domkeywordsTable domkeywordsTable--keywords ${showSCData ? 'domkeywordsTable--hasSC' : ''}
|
||||
<div className={`domkeywordsTable domkeywordsTable--keywords
|
||||
${showSCData && tableColumns.includes('Search Console') ? 'domkeywordsTable--hasSC' : ''}
|
||||
styled-scrollbar w-full overflow-auto min-h-[60vh]`}>
|
||||
<div className=' lg:min-w-[800px]'>
|
||||
<div className={`domKeywords_head domKeywords_head--${sortBy} hidden lg:flex p-3 px-6 bg-[#FCFCFF]
|
||||
text-gray-600 justify-between items-center font-semibold border-y`}>
|
||||
<span className='domKeywords_head_keyword flex-1 basis-[4rem] w-auto '>
|
||||
<span ref={titleColumnRef} className={`domKeywords_head_keyword flex-1 basis-[4rem] w-auto lg:flex-1
|
||||
${showSCData && tableColumns.includes('Search Console') ? 'lg:basis-20' : 'lg:basis-10'} lg:w-auto lg:flex lg:items-center `}>
|
||||
{processedKeywords[device].length > 0 && (
|
||||
<button
|
||||
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border border-slate-300
|
||||
@@ -154,16 +190,20 @@ const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
<Icon type="check" size={10} />
|
||||
</button>
|
||||
)}
|
||||
Keyword
|
||||
{/* ${showSCData ? 'lg:min-w-[220px]' : 'lg:min-w-[280px]'} */}
|
||||
<span className={`inline-block lg:flex lg:items-center
|
||||
${showSCData && tableColumns.includes('Search Console') ? 'lg:max-w-[235px]' : ''}`}>
|
||||
Keyword
|
||||
</span>
|
||||
</span>
|
||||
<span className='domKeywords_head_position flex-1 basis-24 grow-0 text-center'>Position</span>
|
||||
<span className='domKeywords_head_best flex-1 basis-16 grow-0 text-center'>Best</span>
|
||||
<span className='domKeywords_head_history flex-1 basis-20 grow-0'>History (7d)</span>
|
||||
<span className='domKeywords_head_volume flex-1 basis-24 grow-0 text-center'>Volume</span>
|
||||
<span className={`domKeywords_head_best flex-1 basis-16 grow-0 text-center ${shouldHideColumn('Best')}`}>Best</span>
|
||||
<span className={`domKeywords_head_history flex-1 basis-20 grow-0 ${shouldHideColumn('History')}`}>History (7d)</span>
|
||||
<span className={`domKeywords_head_volume flex-1 basis-24 grow-0 text-center ${shouldHideColumn('Volume')}`}>Volume</span>
|
||||
<span className='domKeywords_head_url flex-1'>URL</span>
|
||||
<span className='domKeywords_head_updated flex-1 relative left-3'>Updated</span>
|
||||
{showSCData && (
|
||||
<div className='domKeywords_head_sc flex-1 min-w-[170px] mr-7 text-center'>
|
||||
<span className='domKeywords_head_updated flex-1 relative left-3 max-w-[150px]'>Updated</span>
|
||||
{showSCData && tableColumns.includes('Search Console') && (
|
||||
<div className='domKeywords_head_sc flex-1 min-w-[170px] lg:max-w-[170px] mr-7 text-center'>
|
||||
{/* Search Console */}
|
||||
<div>
|
||||
<div
|
||||
|
||||
@@ -89,6 +89,15 @@ const NotificationSettings = ({ settings, settingsError, updateSettings }:Notifi
|
||||
onChange={(value:string) => updateSettings('notification_email_from', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings__section__input mb-5">
|
||||
<InputField
|
||||
label='Email From Name'
|
||||
hasError={settingsError?.type === 'no_smtp_from'}
|
||||
value={settings?.notification_email_from_name || 'Serpbear'}
|
||||
placeholder="Serpbear"
|
||||
onChange={(value:string) => updateSettings('notification_email_from_name', value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ type SettingsError = {
|
||||
msg: string
|
||||
}
|
||||
|
||||
const defaultSettings: SettingsType = {
|
||||
export const defaultSettings: SettingsType = {
|
||||
scraper_type: 'none',
|
||||
scrape_delay: 'none',
|
||||
scrape_retry: false,
|
||||
@@ -28,9 +28,11 @@ const defaultSettings: SettingsType = {
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
notification_email_from: '',
|
||||
notification_email_from_name: 'SerpBear',
|
||||
search_console: true,
|
||||
search_console_client_email: '',
|
||||
search_console_private_key: '',
|
||||
keywordsColumns: ['Best', 'History', 'Volume', 'Search Console'],
|
||||
};
|
||||
|
||||
const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
|
||||
11
cron.js
11
cron.js
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable no-new */
|
||||
const Cryptr = require('cryptr');
|
||||
const { promises } = require('fs');
|
||||
const { readFile } = require('fs');
|
||||
const Cron = require('croner');
|
||||
const { Cron } = require('croner');
|
||||
require('dotenv').config({ path: './.env.local' });
|
||||
|
||||
const getAppSettings = async () => {
|
||||
@@ -71,7 +72,7 @@ const runAppCronJobs = () => {
|
||||
const scrape_interval = settings.scrape_interval || 'daily';
|
||||
if (scrape_interval !== 'never') {
|
||||
const scrapeCronTime = generateCronTime(scrape_interval);
|
||||
Cron(scrapeCronTime, () => {
|
||||
new Cron(scrapeCronTime, () => {
|
||||
// console.log('### Running Keyword Position Cron Job!');
|
||||
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
|
||||
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/cron`, fetchOpts)
|
||||
@@ -89,7 +90,7 @@ const runAppCronJobs = () => {
|
||||
if (notif_interval) {
|
||||
const cronTime = generateCronTime(notif_interval === 'daily' ? 'daily_morning' : notif_interval);
|
||||
if (cronTime) {
|
||||
Cron(cronTime, () => {
|
||||
new Cron(cronTime, () => {
|
||||
// console.log('### Sending Notification Email...');
|
||||
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
|
||||
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/notify`, fetchOpts)
|
||||
@@ -106,7 +107,7 @@ const runAppCronJobs = () => {
|
||||
|
||||
// Run Failed scraping CRON (Every Hour)
|
||||
const failedCronTime = generateCronTime('hourly');
|
||||
Cron(failedCronTime, () => {
|
||||
new Cron(failedCronTime, () => {
|
||||
// console.log('### Retrying Failed Scrapes...');
|
||||
|
||||
readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' }, (err, data) => {
|
||||
@@ -135,7 +136,7 @@ const runAppCronJobs = () => {
|
||||
// Run Google Search Console Scraper Daily
|
||||
if (process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) {
|
||||
const searchConsoleCRONTime = generateCronTime('daily');
|
||||
Cron(searchConsoleCRONTime, () => {
|
||||
new Cron(searchConsoleCRONTime, () => {
|
||||
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
|
||||
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/searchconsole`, fetchOpts)
|
||||
.then((res) => res.json())
|
||||
|
||||
@@ -442,6 +442,7 @@
|
||||
<tr align="left">
|
||||
<th>Keyword</th>
|
||||
<th>Position</th>
|
||||
<th>Best</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
{{keywordsTable}}
|
||||
|
||||
2904
package-lock.json
generated
2904
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "serpbear",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -18,16 +18,16 @@
|
||||
"release": "standard-version"
|
||||
},
|
||||
"dependencies": {
|
||||
"@googleapis/searchconsole": "^1.0.0",
|
||||
"@googleapis/searchconsole": "^1.0.5",
|
||||
"@isaacs/ttlcache": "^1.4.1",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"axios": "^1.1.3",
|
||||
"axios": "^1.7.7",
|
||||
"axios-retry": "^3.3.1",
|
||||
"chart.js": "^3.9.1",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cheerio": "^1.0.0",
|
||||
"concurrently": "^7.6.0",
|
||||
"cookies": "^0.8.0",
|
||||
"croner": "^5.3.5",
|
||||
"croner": "^9.0.0",
|
||||
"cryptr": "^6.0.3",
|
||||
"dayjs": "^1.11.5",
|
||||
"dotenv": "^16.0.3",
|
||||
@@ -35,7 +35,6 @@
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"msw": "^0.49.0",
|
||||
"next": "^12.3.4",
|
||||
"nodemailer": "^6.9.9",
|
||||
"react": "18.2.0",
|
||||
@@ -50,11 +49,11 @@
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sequelize": "^6.34.0",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"sqlite3": "^5.1.6",
|
||||
"umzug": "^3.6.1"
|
||||
"sqlite3": "^5.1.7",
|
||||
"umzug": "^3.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.1.4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/cookies": "^0.7.7",
|
||||
"@types/cryptr": "^4.0.1",
|
||||
@@ -63,8 +62,8 @@
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/node": "18.11.0",
|
||||
"@types/nodemailer": "^6.4.6",
|
||||
"@types/react": "18.0.21",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@types/react-timeago": "^4.1.3",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"autoprefixer": "^10.4.12",
|
||||
@@ -74,15 +73,16 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^2.6.4",
|
||||
"next-router-mock": "^0.9.10",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^2.7.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.55.0",
|
||||
"sass": "^1.80.7",
|
||||
"sequelize-cli": "^6.6.2",
|
||||
"standard-version": "^9.5.0",
|
||||
"stylelint-config-standard": "^29.0.0",
|
||||
"tailwindcss": "^3.1.8",
|
||||
"typescript": "4.8.4"
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,10 @@ const getAdwordsRefreshToken = async (req: NextApiRequest, res: NextApiResponse<
|
||||
}
|
||||
return res.status(400).send('Error Getting the Google Ads Refresh Token. Please Try Again!');
|
||||
} catch (error:any) {
|
||||
const errorMsg = error?.response?.data?.error;
|
||||
let errorMsg = error?.response?.data?.error;
|
||||
if (errorMsg.includes('redirect_uri_mismatch')) {
|
||||
errorMsg += ` Redirected URL: ${redirectURL}`;
|
||||
}
|
||||
console.log('[Error] Getting Google Ads Refresh Token! Reason: ', errorMsg);
|
||||
return res.status(400).send(`Error Saving the Google Ads Refresh Token ${errorMsg ? `. Details: ${errorMsg}` : ''}. Please Try Again!`);
|
||||
}
|
||||
|
||||
@@ -46,9 +46,11 @@ const getKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
|
||||
if (!req.query.domain && typeof req.query.domain !== 'string') {
|
||||
return res.status(400).json({ error: 'Domain is Required!' });
|
||||
}
|
||||
const settings = await getAppSettings();
|
||||
const domain = (req.query.domain as string);
|
||||
const integratedSC = process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL;
|
||||
const domainSCData = integratedSC ? await readLocalSCData(domain) : false;
|
||||
const { search_console_client_email, search_console_private_key } = settings;
|
||||
const domainSCData = integratedSC || (search_console_client_email && search_console_private_key) ? await readLocalSCData(domain) : false;
|
||||
|
||||
try {
|
||||
const allKeywords:Keyword[] = await Keyword.findAll({ where: { domain } });
|
||||
|
||||
@@ -62,9 +62,10 @@ const sendNotificationEmail = async (domain: Domain, settings: SettingsType) =>
|
||||
smtp_password = '',
|
||||
notification_email = '',
|
||||
notification_email_from = '',
|
||||
notification_email_from_name = 'SerpBear',
|
||||
} = settings;
|
||||
|
||||
const fromEmail = `SerpBear <${notification_email_from || 'no-reply@serpbear.com'}>`;
|
||||
const fromEmail = `${notification_email_from_name} <${notification_email_from || 'no-reply@serpbear.com'}>`;
|
||||
const mailerSettings:any = { host: smtp_server, port: parseInt(smtp_port, 10) };
|
||||
if (smtp_username || smtp_password) {
|
||||
mailerSettings.auth = {};
|
||||
@@ -77,7 +78,7 @@ const sendNotificationEmail = async (domain: Domain, settings: SettingsType) =>
|
||||
const domainKeywords:Keyword[] = await Keyword.findAll(query);
|
||||
const keywordsArray = domainKeywords.map((el) => el.get({ plain: true }));
|
||||
const keywords: KeywordType[] = parseKeywords(keywordsArray);
|
||||
const emailHTML = await generateEmail(domainName, keywords);
|
||||
const emailHTML = await generateEmail(domainName, keywords, settings);
|
||||
await transporter.sendMail({
|
||||
from: fromEmail,
|
||||
to: domain.notification_emails || notification_email,
|
||||
|
||||
@@ -119,6 +119,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
notification_interval: 'never',
|
||||
notification_email: '',
|
||||
notification_email_from: '',
|
||||
notification_email_from_name: 'SerpBear',
|
||||
smtp_server: '',
|
||||
smtp_port: '',
|
||||
smtp_username: '',
|
||||
@@ -128,6 +129,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
search_console: true,
|
||||
search_console_client_email: '',
|
||||
search_console_private_key: '',
|
||||
keywordsColumns: ['Best', 'History', 'Volume', 'Search Console'],
|
||||
};
|
||||
const otherSettings = {
|
||||
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
|
||||
|
||||
@@ -36,14 +36,13 @@ const updatekeywordVolume = async (req: NextApiRequest, res: NextApiResponse<Key
|
||||
keywordsToSend = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
|
||||
}
|
||||
if (domain) {
|
||||
// const allDomain = domain === 'all';
|
||||
// const allKeywords:Keyword[] = allDomain ? await Keyword.findAll() : await Keyword.findAll(allDomain ? {} : { where: { domain } });
|
||||
// keywordsToSend = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
|
||||
const allDomain = domain === 'all';
|
||||
const allKeywords:Keyword[] = allDomain ? await Keyword.findAll() : await Keyword.findAll(allDomain ? {} : { where: { domain } });
|
||||
keywordsToSend = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
|
||||
}
|
||||
|
||||
if (keywordsToSend.length > 0) {
|
||||
const keywordsVolumeData = await getKeywordsVolume(keywordsToSend);
|
||||
// console.log('keywordsVolumeData :', keywordsVolumeData);
|
||||
if (keywordsVolumeData.error) {
|
||||
return res.status(400).json({ keywords: [], error: keywordsVolumeData.error });
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ const SingleDomain: NextPage = () => {
|
||||
showAddModal={showAddKeywords}
|
||||
setShowAddModal={setShowAddKeywords}
|
||||
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || domainHasScAPI }
|
||||
settings={appSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,49 @@ const DiscoverPage: NextPage = () => {
|
||||
const { data: keywordsData, isLoading: keywordsLoading, isFetching } = useFetchSCKeywords(router, !!(domainsData?.domains?.length) && scConnected);
|
||||
|
||||
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
|
||||
const theKeywords: SearchAnalyticsItem[] = keywordsData?.data && keywordsData.data[scDateFilter] ? keywordsData.data[scDateFilter] : [];
|
||||
const theKeywords: SearchAnalyticsItem[] = useMemo(() => {
|
||||
return keywordsData?.data && keywordsData.data[scDateFilter] ? keywordsData.data[scDateFilter] : [];
|
||||
}, [keywordsData, scDateFilter]);
|
||||
|
||||
const theKeywordsCount = useMemo(() => {
|
||||
return theKeywords.reduce<Map<string, number>>((r, o) => {
|
||||
const key = `${o.device}-${o.country}-${o.keyword}`;
|
||||
const item = r.get(key) || 0;
|
||||
return r.set(key, item + 1);
|
||||
}, new Map()) || [];
|
||||
}, [theKeywords]);
|
||||
|
||||
const theKeywordsReduced : SearchAnalyticsItem[] = useMemo(() => {
|
||||
return [...theKeywords.reduce<Map<string, SearchAnalyticsItem>>((r, o) => {
|
||||
const key = `${o.device}-${o.country}-${o.keyword}`;
|
||||
const item = r.get(key) || { ...o,
|
||||
...{
|
||||
clicks: 0,
|
||||
impressions: 0,
|
||||
ctr: 0,
|
||||
position: 0,
|
||||
},
|
||||
};
|
||||
item.clicks += o.clicks;
|
||||
item.impressions += o.impressions;
|
||||
item.ctr = o.ctr + item.ctr;
|
||||
item.position = o.position + item.position;
|
||||
return r.set(key, item);
|
||||
}, new Map()).values()];
|
||||
}, [theKeywords]);
|
||||
|
||||
const theKeywordsGrouped : SearchAnalyticsItem[] = useMemo(() => {
|
||||
return [...theKeywordsReduced.map<SearchAnalyticsItem>((o: SearchAnalyticsItem) => {
|
||||
const key = `${o.device}-${o.country}-${o.keyword}`;
|
||||
const count = theKeywordsCount?.get(key) || 0;
|
||||
return { ...o,
|
||||
...{
|
||||
ctr: Math.round((o.ctr / count) * 100) / 100,
|
||||
position: Math.round(o.position / count),
|
||||
},
|
||||
};
|
||||
})];
|
||||
}, [theKeywordsReduced, theKeywordsCount]);
|
||||
|
||||
const activDomain: DomainType|null = useMemo(() => {
|
||||
let active:DomainType|null = null;
|
||||
@@ -62,7 +104,7 @@ const DiscoverPage: NextPage = () => {
|
||||
domains={theDomains}
|
||||
showAddModal={() => console.log('XXXXX')}
|
||||
showSettingsModal={setShowDomainSettings}
|
||||
exportCsv={() => exportCSV(theKeywords, activDomain.domain, scDateFilter)}
|
||||
exportCsv={() => exportCSV(theKeywordsGrouped, activDomain.domain, scDateFilter)}
|
||||
scFilter={scDateFilter}
|
||||
setScFilter={(item:string) => setSCDateFilter(item)}
|
||||
/>
|
||||
@@ -71,7 +113,7 @@ const DiscoverPage: NextPage = () => {
|
||||
<SCKeywordsTable
|
||||
isLoading={keywordsLoading || isFetching}
|
||||
domain={activDomain}
|
||||
keywords={theKeywords}
|
||||
keywords={theKeywordsGrouped}
|
||||
isConsoleIntegrated={scConnected || domainHasScAPI}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import proxy from './services/proxy';
|
||||
import searchapi from './services/searchapi';
|
||||
import valueSerp from './services/valueserp';
|
||||
import serper from './services/serper';
|
||||
import hasdata from './services/hasdata';
|
||||
|
||||
export default [
|
||||
scrapingRobot,
|
||||
@@ -18,4 +19,5 @@ export default [
|
||||
searchapi,
|
||||
valueSerp,
|
||||
serper,
|
||||
hasdata,
|
||||
];
|
||||
|
||||
44
scrapers/services/hasdata.ts
Normal file
44
scrapers/services/hasdata.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import countries from '../../utils/countries';
|
||||
|
||||
interface HasDataResult {
|
||||
title: string,
|
||||
link: string,
|
||||
position: number,
|
||||
}
|
||||
|
||||
const hasdata:ScraperSettings = {
|
||||
id: 'hasdata',
|
||||
name: 'HasData',
|
||||
website: 'hasdata.com',
|
||||
allowsCity: true,
|
||||
headers: (keyword, settings) => {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': settings.scaping_api,
|
||||
};
|
||||
},
|
||||
scrapeURL: (keyword, settings) => {
|
||||
const country = keyword.country || 'US';
|
||||
const countryName = countries[country][0];
|
||||
const location = keyword.city && countryName ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
|
||||
return `https://api.scrape-it.cloud/scrape/google/serp?q=${encodeURIComponent(keyword.keyword)}${location}&num=100&gl=${country.toLowerCase()}&deviceType=${keyword.device}`;
|
||||
},
|
||||
resultObjectKey: 'organicResults',
|
||||
serpExtractor: (content) => {
|
||||
const extractedResult = [];
|
||||
const results: HasDataResult[] = (typeof content === 'string') ? JSON.parse(content) : content as HasDataResult[];
|
||||
|
||||
for (const { link, title, position } of results) {
|
||||
if (title && link) {
|
||||
extractedResult.push({
|
||||
title,
|
||||
url: link,
|
||||
position,
|
||||
});
|
||||
}
|
||||
}
|
||||
return extractedResult;
|
||||
},
|
||||
};
|
||||
|
||||
export default hasdata;
|
||||
@@ -1,4 +1,4 @@
|
||||
import cheerio from 'cheerio';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
const proxy:ScraperSettings = {
|
||||
id: 'proxy',
|
||||
@@ -16,6 +16,13 @@ const proxy:ScraperSettings = {
|
||||
|
||||
const $ = cheerio.load(content);
|
||||
let lastPosition = 0;
|
||||
const hasValidContent = $('body').find('#main');
|
||||
if (hasValidContent.length === 0) {
|
||||
const msg = '[ERROR] Scraped search results from proxy do not adhere to expected format. Unable to parse results';
|
||||
console.log(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const mainContent = $('body').find('#main');
|
||||
const children = $(mainContent).find('h3');
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ const searchapi:ScraperSettings = {
|
||||
scrapeURL: (keyword) => {
|
||||
const country = keyword.country || 'US';
|
||||
const countryName = countries[country][0];
|
||||
const location = keyword.city && countryName ? `&location=${encodeURI(`${keyword.city},${countryName}`)}` : '';
|
||||
return `https://www.searchapi.io/api/v1/search?engine=google&q=${encodeURI(keyword.keyword)}&num=100&gl=${country}&device=${keyword.device}${location}`;
|
||||
const location = keyword.city && countryName ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
|
||||
return `https://www.searchapi.io/api/v1/search?engine=google&q=${encodeURIComponent(keyword.keyword)}&num=100&gl=${country}&device=${keyword.device}${location}`;
|
||||
},
|
||||
resultObjectKey: 'organic_results',
|
||||
serpExtractor: (content) => {
|
||||
|
||||
@@ -19,8 +19,8 @@ const serpapi:ScraperSettings = {
|
||||
},
|
||||
scrapeURL: (keyword, settings) => {
|
||||
const countryName = countries[keyword.country || 'US'][0];
|
||||
const location = keyword.city && keyword.country ? `&location=${encodeURI(`${keyword.city},${countryName}`)}` : '';
|
||||
return `https://serpapi.com/search?q=${encodeURI(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}${location}&api_key=${settings.scaping_api}`;
|
||||
const location = keyword.city && keyword.country ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
|
||||
return `https://serpapi.com/search?q=${encodeURIComponent(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}${location}&api_key=${settings.scaping_api}`;
|
||||
},
|
||||
resultObjectKey: 'organic_results',
|
||||
serpExtractor: (content) => {
|
||||
|
||||
@@ -12,7 +12,8 @@ const serper:ScraperSettings = {
|
||||
scrapeURL: (keyword, settings, countryData) => {
|
||||
const country = keyword.country || 'US';
|
||||
const lang = countryData[country][2];
|
||||
return `https://google.serper.dev/search?q=${encodeURI(keyword.keyword)}&gl=${country}&hl=${lang}&num=100&apiKey=${settings.scaping_api}`;
|
||||
console.log('Serper URL :', `https://google.serper.dev/search?q=${encodeURIComponent(keyword.keyword)}&gl=${country}&hl=${lang}&num=100&apiKey=${settings.scaping_api}`);
|
||||
return `https://google.serper.dev/search?q=${encodeURIComponent(keyword.keyword)}&gl=${country}&hl=${lang}&num=100&apiKey=${settings.scaping_api}`;
|
||||
},
|
||||
resultObjectKey: 'organic',
|
||||
serpExtractor: (content) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ const serply:ScraperSettings = {
|
||||
},
|
||||
scrapeURL: (keyword) => {
|
||||
const country = scraperCountries.includes(keyword.country.toUpperCase()) ? keyword.country : 'US';
|
||||
return `https://api.serply.io/v1/search/q=${encodeURI(keyword.keyword)}&num=100&hl=${country}`;
|
||||
return `https://api.serply.io/v1/search/q=${encodeURIComponent(keyword.keyword)}&num=100&hl=${country}`;
|
||||
},
|
||||
resultObjectKey: 'result',
|
||||
serpExtractor: (content) => {
|
||||
|
||||
@@ -15,10 +15,10 @@ const spaceSerp:ScraperSettings = {
|
||||
scrapeURL: (keyword, settings, countryData) => {
|
||||
const country = keyword.country || 'US';
|
||||
const countryName = countries[country][0];
|
||||
const location = keyword.city ? `&location=${encodeURI(`${keyword.city},${countryName}`)}` : '';
|
||||
const location = keyword.city ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
|
||||
const device = keyword.device === 'mobile' ? '&device=mobile' : '';
|
||||
const lang = countryData[country][2];
|
||||
return `https://api.spaceserp.com/google/search?apiKey=${settings.scaping_api}&q=${encodeURI(keyword.keyword)}&pageSize=100&gl=${country}&hl=${lang}${location}${device}&resultBlocks=`;
|
||||
return `https://api.spaceserp.com/google/search?apiKey=${settings.scaping_api}&q=${encodeURIComponent(keyword.keyword)}&pageSize=100&gl=${country}&hl=${lang}${location}${device}&resultBlocks=`;
|
||||
},
|
||||
resultObjectKey: 'organic_results',
|
||||
serpExtractor: (content) => {
|
||||
|
||||
@@ -15,11 +15,11 @@ const valueSerp:ScraperSettings = {
|
||||
scrapeURL: (keyword, settings, countryData) => {
|
||||
const country = keyword.country || 'US';
|
||||
const countryName = countries[country][0];
|
||||
const location = keyword.city ? `&location=${encodeURI(`${keyword.city},${countryName}`)}` : '';
|
||||
const location = keyword.city ? `&location=${encodeURIComponent(`${keyword.city},${countryName}`)}` : '';
|
||||
const device = keyword.device === 'mobile' ? '&device=mobile' : '';
|
||||
const lang = countryData[country][2];
|
||||
console.log(`https://api.valueserp.com/search?api_key=${settings.scaping_api}&q=${encodeURI(keyword.keyword)}&gl=${country}&hl=${lang}${device}${location}&num=100&output=json&include_answer_box=false&include_advertiser_info=false`);
|
||||
return `https://api.valueserp.com/search?api_key=${settings.scaping_api}&q=${encodeURI(keyword.keyword)}&gl=${country}&hl=${lang}${device}${location}&num=100&output=json&include_answer_box=false&include_advertiser_info=false`;
|
||||
console.log(`https://api.valueserp.com/search?api_key=${settings.scaping_api}&q=${encodeURIComponent(keyword.keyword)}&gl=${country}&hl=${lang}${device}${location}&num=100&output=json&include_answer_box=false&include_advertiser_info=false`);
|
||||
return `https://api.valueserp.com/search?api_key=${settings.scaping_api}&q=${encodeURIComponent(keyword.keyword)}&gl=${country}&hl=${lang}${device}${location}&num=100&output=json&include_answer_box=false&include_advertiser_info=false`;
|
||||
},
|
||||
resultObjectKey: 'organic_results',
|
||||
serpExtractor: (content) => {
|
||||
|
||||
@@ -8,6 +8,10 @@ body {
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.max-w-7xl {
|
||||
max-width: 90rem;
|
||||
}
|
||||
|
||||
.domKeywords {
|
||||
/* min-height: 70vh; */
|
||||
border-color: #e9ebff;
|
||||
@@ -301,6 +305,13 @@ body {
|
||||
background-color: #f9ded7;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 760px){
|
||||
.keyword_best:before{
|
||||
content: "Best: ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Disable LastPass Icon for Secret Field */
|
||||
[autocomplete="off"] + div[data-lastpass-icon-root="true"], [autocomplete="off"] + div[data-lastpass-infield="true"] {
|
||||
display: none;
|
||||
|
||||
2
types.d.ts
vendored
2
types.d.ts
vendored
@@ -85,6 +85,7 @@ type SettingsType = {
|
||||
notification_interval: string,
|
||||
notification_email: string,
|
||||
notification_email_from: string,
|
||||
notification_email_from_name: string,
|
||||
smtp_server: string,
|
||||
smtp_port: string,
|
||||
smtp_username?: string,
|
||||
@@ -105,6 +106,7 @@ type SettingsType = {
|
||||
adwords_refresh_token?: string,
|
||||
adwords_developer_token?: string,
|
||||
adwords_account_id?: string,
|
||||
keywordsColumns: string[]
|
||||
}
|
||||
|
||||
type KeywordSCDataChild = {
|
||||
|
||||
@@ -163,7 +163,7 @@ export const getAdwordsKeywordIdeas = async (credentials:AdwordsCredentials, adw
|
||||
}
|
||||
|
||||
try {
|
||||
// API: https://developers.google.com/google-ads/api/rest/reference/rest/v16/customers/generateKeywordIdeas
|
||||
// API: https://developers.google.com/google-ads/api/rest/reference/rest/v18/customers/generateKeywordIdeas
|
||||
const customerID = account_id.replaceAll('-', '');
|
||||
const geoTargetConstants = countries[country][3]; // '2840';
|
||||
const reqPayload: Record<string, any> = {
|
||||
@@ -178,7 +178,7 @@ export const getAdwordsKeywordIdeas = async (credentials:AdwordsCredentials, adw
|
||||
reqPayload.siteSeed = { site: domain };
|
||||
}
|
||||
|
||||
const resp = await fetch(`https://googleads.googleapis.com/v16/customers/${customerID}:generateKeywordIdeas`, {
|
||||
const resp = await fetch(`https://googleads.googleapis.com/v18/customers/${customerID}:generateKeywordIdeas`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -271,7 +271,7 @@ export const getKeywordsVolume = async (keywords: KeywordType[]): Promise<{error
|
||||
// Generate Access Token
|
||||
let accessToken = '';
|
||||
const cachedAccessToken:string|false|undefined = memoryCache.get('adwords_token');
|
||||
if (cachedAccessToken && !test) {
|
||||
if (cachedAccessToken) {
|
||||
accessToken = cachedAccessToken;
|
||||
} else {
|
||||
accessToken = await getAdwordsAccessToken(credentials);
|
||||
@@ -297,7 +297,7 @@ export const getKeywordsVolume = async (keywords: KeywordType[]): Promise<{error
|
||||
for (const country in keywordRequests) {
|
||||
if (Object.hasOwn(keywordRequests, country) && keywordRequests[country].length > 0) {
|
||||
try {
|
||||
// API: https://developers.google.com/google-ads/api/rest/reference/rest/v16/customers/generateKeywordHistoricalMetrics
|
||||
// API: https://developers.google.com/google-ads/api/rest/reference/rest/v18/customers/generateKeywordHistoricalMetrics
|
||||
const customerID = account_id.replaceAll('-', '');
|
||||
const geoTargetConstants = countries[country][3]; // '2840';
|
||||
const reqKeywords = keywordRequests[country].map((kw) => kw.keyword);
|
||||
@@ -306,7 +306,7 @@ export const getKeywordsVolume = async (keywords: KeywordType[]): Promise<{error
|
||||
geoTargetConstants: `geoTargetConstants/${geoTargetConstants}`,
|
||||
// language: `languageConstants/${language}`,
|
||||
};
|
||||
const resp = await fetch(`https://googleads.googleapis.com/v16/customers/${customerID}:generateKeywordHistoricalMetrics`, {
|
||||
const resp = await fetch(`https://googleads.googleapis.com/v18/customers/${customerID}:generateKeywordHistoricalMetrics`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -9,7 +9,7 @@ import countries from '../countries';
|
||||
const exportCSV = (keywords: KeywordType[] | SCKeywordType[], domain:string, scDataDuration = 'lastThreeDays') => {
|
||||
if (!keywords || (keywords && Array.isArray(keywords) && keywords.length === 0)) { return; }
|
||||
const isSCKeywords = !!(keywords && keywords[0] && keywords[0].uid);
|
||||
let csvHeader = 'ID,Keyword,Position,URL,Country,Device,Updated,Added,Tags\r\n';
|
||||
let csvHeader = 'ID,Keyword,Position,URL,Country,City,Device,Updated,Added,Tags\r\n';
|
||||
let csvBody = '';
|
||||
let fileName = `${domain}-keywords_serp.csv`;
|
||||
|
||||
@@ -26,9 +26,9 @@ const exportCSV = (keywords: KeywordType[] | SCKeywordType[], domain:string, scD
|
||||
});
|
||||
} else {
|
||||
keywords.forEach((keywordData) => {
|
||||
const { ID, keyword, position, url, country, device, lastUpdated, added, tags } = keywordData as KeywordType;
|
||||
const { ID, keyword, position, url, country, city, device, lastUpdated, added, tags } = keywordData as KeywordType;
|
||||
// eslint-disable-next-line max-len
|
||||
csvBody += `${ID}, ${keyword}, ${position === 0 ? '-' : position}, ${url || '-'}, ${countries[country][0]}, ${device}, ${lastUpdated}, ${added}, ${tags.join(',')}\r\n`;
|
||||
csvBody += `${ID}, ${keyword}, ${position === 0 ? '-' : position}, ${url || '-'}, ${countries[country][0]}, ${city || '-'}, ${device}, ${lastUpdated}, ${added}, ${tags.join(',')}\r\n`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ import path from 'path';
|
||||
import { getKeywordsInsight, getPagesInsight } from './insight';
|
||||
import { readLocalSCData } from './searchConsole';
|
||||
|
||||
const serpBearLogo = 'https://erevanto.sirv.com/Images/serpbear/ikAdjQq.png';
|
||||
const mobileIcon = 'https://erevanto.sirv.com/Images/serpbear/SqXD9rd.png';
|
||||
const desktopIcon = 'https://erevanto.sirv.com/Images/serpbear/Dx3u0XD.png';
|
||||
const googleIcon = 'https://erevanto.sirv.com/Images/serpbear/Sx3u0X9.png';
|
||||
const serpBearLogo = 'https://serpbear.b-cdn.net/ikAdjQq.png';
|
||||
const mobileIcon = 'https://serpbear.b-cdn.net/SqXD9rd.png';
|
||||
const desktopIcon = 'https://serpbear.b-cdn.net/Dx3u0XD.png';
|
||||
const googleIcon = 'https://serpbear.b-cdn.net/Sx3u0X9.png';
|
||||
|
||||
type SCStatsObject = {
|
||||
[key:string]: {
|
||||
@@ -68,13 +68,26 @@ const getPositionChange = (history:KeywordHistory, position:number) : number =>
|
||||
return status;
|
||||
};
|
||||
|
||||
const getBestKeywordPosition = (history: KeywordHistory) => {
|
||||
let bestPos;
|
||||
if (Object.keys(history).length > 0) {
|
||||
const historyArray = Object.keys(history).map((itemID) => ({ date: itemID, position: history[itemID] }))
|
||||
.sort((a, b) => a.position - b.position).filter((el) => (el.position > 0));
|
||||
if (historyArray[0]) {
|
||||
bestPos = { ...historyArray[0] };
|
||||
}
|
||||
}
|
||||
|
||||
return bestPos?.position || '-';
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the Email HTML based on given domain name and its keywords
|
||||
* @param {string} domainName - Keywords to scrape
|
||||
* @param {keywords[]} keywords - Keywords to scrape
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const generateEmail = async (domainName:string, keywords:KeywordType[]) : Promise<string> => {
|
||||
const generateEmail = async (domainName:string, keywords:KeywordType[], settings: SettingsType) : Promise<string> => {
|
||||
const emailTemplate = await readFile(path.join(__dirname, '..', '..', '..', '..', 'email', 'email.html'), { encoding: 'utf-8' });
|
||||
const currentDate = dayjs(new Date()).format('MMMM D, YYYY');
|
||||
const keywordsCount = keywords.length;
|
||||
@@ -88,7 +101,7 @@ const generateEmail = async (domainName:string, keywords:KeywordType[]) : Promis
|
||||
const positionChange = getPositionChange(keyword.history, keyword.position);
|
||||
const deviceIconImg = keyword.device === 'desktop' ? desktopIcon : mobileIcon;
|
||||
const countryFlag = `<img class="flag" src="https://flagcdn.com/w20/${keyword.country.toLowerCase()}.png" alt="${keyword.country}" title="${keyword.country}" />`;
|
||||
const deviceIcon = `<img class="device" src="${deviceIconImg}" alt="${keyword.device}" title="${keyword.device}" />`;
|
||||
const deviceIcon = `<img class="device" src="${deviceIconImg}" alt="${keyword.device}" title="${keyword.device}" width="18" height="18" />`;
|
||||
|
||||
if (positionChange > 0) { positionChangeIcon = '<span style="color:#5ed7c3;">▲</span>'; improved += 1; }
|
||||
if (positionChange < 0) { positionChangeIcon = '<span style="color:#fca5a5;">▼</span>'; declined += 1; }
|
||||
@@ -97,6 +110,7 @@ const generateEmail = async (domainName:string, keywords:KeywordType[]) : Promis
|
||||
keywordsTable += `<tr class="keyword">
|
||||
<td>${countryFlag} ${deviceIcon} ${keyword.keyword}</td>
|
||||
<td>${keyword.position}${posChangeIcon}</td>
|
||||
<td>${getBestKeywordPosition(keyword.history)}</td>
|
||||
<td>${timeSince(new Date(keyword.lastUpdated).getTime() / 1000)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
@@ -104,7 +118,7 @@ const generateEmail = async (domainName:string, keywords:KeywordType[]) : Promis
|
||||
const stat = `${improved > 0 ? `${improved} Improved` : ''}
|
||||
${improved > 0 && declined > 0 ? ', ' : ''} ${declined > 0 ? `${declined} Declined` : ''}`;
|
||||
const updatedEmail = emailTemplate
|
||||
.replace('{{logo}}', `<img class="logo_img" src="${serpBearLogo}" alt="SerpBear" />`)
|
||||
.replace('{{logo}}', `<img class="logo_img" src="${serpBearLogo}" alt="SerpBear" width="24" height="24" />`)
|
||||
.replace('{{currentDate}}', currentDate)
|
||||
.replace('{{domainName}}', domainName)
|
||||
.replace('{{keywordsCount}}', keywordsCount.toString())
|
||||
@@ -113,7 +127,8 @@ const generateEmail = async (domainName:string, keywords:KeywordType[]) : Promis
|
||||
.replace('{{stat}}', stat)
|
||||
.replace('{{preheader}}', stat);
|
||||
|
||||
const isConsoleIntegrated = !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL);
|
||||
const isConsoleIntegrated = !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL)
|
||||
|| (settings.search_console_client_email && settings.search_console_private_key);
|
||||
const htmlWithSCStats = isConsoleIntegrated ? await generateGoogeleConsoleStats(domainName) : '';
|
||||
const emailHTML = updatedEmail.replace('{{SCStatsTable}}', htmlWithSCStats);
|
||||
|
||||
@@ -172,7 +187,7 @@ const generateGoogeleConsoleStats = async (domainName:string): Promise<string> =
|
||||
let htmlWithSCStats = `<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="console_table">
|
||||
<tr>
|
||||
<td style="font-weight:bold;">
|
||||
<img class="google_icon" src="${googleIcon}" alt="Google"> Google Search Console Stats</h3>
|
||||
<img class="google_icon" src="${googleIcon}" alt="Google" width="13" height="13"> Google Search Console Stats</h3>
|
||||
</td>
|
||||
<td class="stat" align="right" style="font-size: 12px;">
|
||||
${startDate.getDate()} ${months[startDate.getMonth()]} - ${endDate.getDate()} ${months[endDate.getMonth()]}
|
||||
|
||||
@@ -72,14 +72,13 @@ export const updateKeywordPosition = async (keywordRaw:Keyword, udpatedkeyword:
|
||||
|
||||
if (udpatedkeyword && keyword) {
|
||||
const newPos = udpatedkeyword.position;
|
||||
const newPosition = newPos !== 0 ? newPos : keyword.position;
|
||||
const { history } = keyword;
|
||||
const theDate = new Date();
|
||||
const dateKey = `${theDate.getFullYear()}-${theDate.getMonth() + 1}-${theDate.getDate()}`;
|
||||
history[dateKey] = newPosition;
|
||||
history[dateKey] = newPos;
|
||||
|
||||
const updatedVal = {
|
||||
position: newPosition,
|
||||
position: newPos,
|
||||
updating: false,
|
||||
url: udpatedkeyword.url,
|
||||
lastResult: udpatedkeyword.result,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse, CreateAxiosDefaults } from 'axios';
|
||||
import cheerio from 'cheerio';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import HttpsProxyAgent from 'https-proxy-agent';
|
||||
import countries from './countries';
|
||||
@@ -124,14 +124,18 @@ export const scrapeKeywordFromGoogle = async (keyword:KeywordType, settings:Sett
|
||||
throw new Error(res);
|
||||
}
|
||||
} catch (error:any) {
|
||||
refreshedResults.error = scraperError;
|
||||
refreshedResults.error = scraperError || 'Unknown Error';
|
||||
if (settings.scraper_type === 'proxy' && error && error.response && error.response.statusText) {
|
||||
refreshedResults.error = `[${error.response.status}] ${error.response.statusText}`;
|
||||
} else if (settings.scraper_type === 'proxy' && error) {
|
||||
refreshedResults.error = error;
|
||||
}
|
||||
|
||||
console.log('[ERROR] Scraping Keyword : ', keyword.keyword, '. Error: ', error && error.response && error.response.statusText);
|
||||
console.log('[ERROR] Scraping Keyword : ', keyword.keyword);
|
||||
if (!(error && error.response && error.response.statusText)) {
|
||||
console.log('[ERROR_MESSAGE]: ', error);
|
||||
} else {
|
||||
console.log('[ERROR_MESSAGE]: ', error && error.response && error.response.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +152,17 @@ export const extractScrapedResult = (content: string, device: string): SearchRes
|
||||
const extractedResult = [];
|
||||
|
||||
const $ = cheerio.load(content);
|
||||
const hasValidContent = [...$('body').find('#search'), ...$('body').find('#rso')];
|
||||
if (hasValidContent.length === 0) {
|
||||
const msg = '[ERROR] Scraped search results do not adhere to expected format. Unable to parse results';
|
||||
console.log(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const hasNumberofResult = $('body').find('#search > div > div');
|
||||
const searchResultItems = hasNumberofResult.find('h3');
|
||||
let lastPosition = 0;
|
||||
console.log('Scraped search results contain ', searchResultItems.length, ' desktop results.');
|
||||
|
||||
for (let i = 0; i < searchResultItems.length; i += 1) {
|
||||
if (searchResultItems[i]) {
|
||||
@@ -161,11 +173,12 @@ export const extractScrapedResult = (content: string, device: string): SearchRes
|
||||
extractedResult.push({ title, url, position: lastPosition });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile Scraper
|
||||
if (extractedResult.length === 0 && device === 'mobile') {
|
||||
// Mobile Scraper
|
||||
if (extractedResult.length === 0 && device === 'mobile') {
|
||||
const items = $('body').find('#rso > div');
|
||||
console.log('Scraped search results contain ', items.length, ' mobile results.');
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = $(items[i]);
|
||||
const linkDom = item.find('a[role="presentation"]');
|
||||
@@ -181,7 +194,7 @@ export const extractScrapedResult = (content: string, device: string): SearchRes
|
||||
}
|
||||
}
|
||||
|
||||
return extractedResult;
|
||||
return extractedResult;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user