59 Commits
v2.0.2 ... main

Author SHA1 Message Date
towfiqi
54522b2261 chore(release): 2.0.7 2025-02-23 22:36:38 +06:00
towfiqi
495d872bb9 chore: code styling/formatting. 2025-02-23 22:35:53 +06:00
towfiqi
07eb4bd94f chore: Updated ReadMe. 2025-02-23 22:33:42 +06:00
towfiqi
36ed4cf800 fix: Resolves AdWords integration issue. 2025-02-23 22:27:59 +06:00
towfiqi
c8601ebb84 Merge branch 'main' of https://github.com/towfiqi/serpbear 2025-02-23 22:26:55 +06:00
Towfiq I.
56d8b660c5 Merge pull request #273 from phoehnel/dev/thorw-error-on-empty-results
Add error message, if returned search HTML does not contain required elements
2025-02-23 22:23:04 +06:00
Pascal Höhnel
c34c8260c7 improve error message in UI on proxy use 2025-01-17 09:36:41 +01:00
Pascal Höhnel
cab8f518bb also add result-check to proxy 2025-01-17 09:10:43 +01:00
Pascal Höhnel
6e47a6fba7 add error message, if returned HTML does not contain required elements 2025-01-17 08:24:03 +01:00
towfiqi
bf911b4e45 fix: resolves broken CDN images. 2024-12-10 20:18:06 +06:00
towfiqi
d7279512cf chore(release): 2.0.6 2024-11-15 10:43:12 +06:00
towfiqi
4fef1a9abc fix: Ensures Docker build uses matching npm package versions from package.json 2024-11-14 23:11:09 +06:00
towfiqi
aeed1f8559 fix: Resolves broken Docker build due to croner package version mismatch.
closes #247
2024-11-14 23:08:54 +06:00
towfiqi
12eac2b012 fix: Resolves Google Ads search volume data loading issue. 2024-11-14 18:31:40 +06:00
towfiqi
649f412303 fix: Resolves broken Proxy Scraper functionality.
closes #248
2024-11-14 18:30:56 +06:00
towfiqi
a2edabbdf9 chore: Upgrades vulnerable dependecies. 2024-11-14 18:28:43 +06:00
towfiqi
3690e97fe7 chore(release): 2.0.5 2024-11-12 16:29:07 +06:00
towfiqi
17fb2c40cc fix: Resolves "Add Domain" UI confusion. 2024-11-12 16:27:32 +06:00
towfiqi
faa3519a29 fix: Fixes misaligned Keywords table UI content. 2024-11-12 16:23:44 +06:00
towfiqi
bc02c929ba fix: Resolves broken Scrapingrobot scraper on new installs.
closes #243
2024-11-12 16:20:48 +06:00
towfiqi
d9d7c6347e fix: Fixes broken scrape result issue for keywords with special characters.
closes #221
2024-11-12 09:18:40 +06:00
towfiqi
c5714c00ae chore(release): 2.0.4 2024-11-10 12:54:43 +06:00
towfiqi
1bef7587cc fix: Fixes Docker build issue. 2024-11-10 12:54:32 +06:00
towfiqi
5507cac07f chore(release): 2.0.3 2024-11-10 10:31:27 +06:00
towfiqi
a74338fe15 feat: Displays Best position on mobile layout as well. 2024-11-09 21:44:04 +06:00
towfiqi
4c2f900d85 feat: Displays keyword's best position in email notification.
closes 202
2024-11-09 21:24:27 +06:00
towfiqi
040dab1517 fix: Fixes missing Search Console data in Email notification when its integrated through App settings. 2024-11-09 20:56:43 +06:00
towfiqi
29c455ea56 chore: Adds descriptive errorlogging for Adwords integration. 2024-11-09 20:33:29 +06:00
towfiqi
42c5e2be07 feat: Makes Content width a little wider. 2024-11-09 20:32:28 +06:00
towfiqi
d3e3760527 feat: Adds the ability to show hide columns in tracked keywords table.
closes #224
2024-11-09 20:31:02 +06:00
towfiqi
7597210ca2 fix: Resolves incorrect search trend graph in Ideas section.
closes #219
2024-11-08 13:06:53 +06:00
towfiqi
4b8730e416 chore: removes unwanted file from merged PR 2024-11-08 12:30:08 +06:00
towfiqi
34dce13143 style: updates merged PR formatting 2024-11-08 12:29:24 +06:00
towfiqi
3786438662 feat: Ability to keywords for both mobile and desktop at once.
closes #60, #66, #199
2024-11-08 12:28:13 +06:00
towfiqi
f48288473e fix: Fixes missing keyword city value in exported csv file.
closes #194
2024-11-08 10:25:16 +06:00
towfiqi
01b1b7b9e9 fix: Fixes incorrect position display in keyword detail view when position is above 100 2024-11-08 10:21:05 +06:00
towfiqi
a050536814 feat: Keywords Country filter now only shows relevant countries. 2024-11-08 10:14:12 +06:00
towfiqi
bc96dc7de5 fix: Resolves notification email's incorrect image size in some email clients.
closes #201
2024-11-08 09:56:54 +06:00
towfiqi
b35d333bfc feat: Adds ability to set Notification Email From name.
closes #222
2024-11-07 21:15:12 +06:00
towfiqi
a09eb62f5a feat: auto filter keywords if they already exist instead of throwing error.
closes #244
2024-11-07 21:04:57 +06:00
Towfiq I.
42a00dafad Merge pull request #237 from JvB94/FixUnrankedAgainIssues2
When a keyword position was ranking under 100 and then it goes over 100 the keyword position was not being updated.
2024-11-07 21:01:00 +06:00
Towfiq I.
432fc6161c Merge pull request #246 from lidarbtc/main
fix: Correct CTR calculation in InsightStats component
2024-11-07 20:13:46 +06:00
Towfiq I.
15a1224260 Merge pull request #235 from EpicKau/main
bugfix gsc data if specified in settings and not in .env
2024-11-07 19:51:29 +06:00
lidarbtc
232507e1ff fix: Correct CTR calculation in InsightStats component
The CTR calculation was incorrectly summing up individual CTR values,
resulting in inflated percentages. Now calculates CTR properly by
dividing total clicks by total impressions.

Previous Implementation:
- CTR was calculated by adding up individual CTR percentages
- This resulted in artificially high CTR values
- For example: [10%, 15%, 20%] => 45% (incorrect)

New Implementation:
- CTR is now calculated using (total clicks / total impressions) * 100
- This provides the actual click-through rate across all data
- For example: (50 clicks / 1000 impressions) * 100 = 5% (correct)

This fix ensures accurate CTR reporting in the analytics dashboard.

Changes:
- Removed CTR accumulation from reducer
- Added proper CTR calculation based on total clicks and impressions
- Maintains better statistical accuracy in reporting
2024-10-26 21:33:31 +09:00
Joni
55fa7c0148 FixUnrankedAgainIssues2 - Solve wrong position is shown when a result goes unranked again 2024-08-19 20:54:13 +02:00
Parsmedia-Alex
8152d81804 bugfix gsc data if specified in settings and not in .env 2024-07-10 18:45:44 +02:00
Towfiq I
748dc8fc61 Merge pull request #212 from AntoineKM/main
fix: update scraping robot typo in README
2024-06-30 09:36:43 +06:00
Antoine Kingue
c24b63009c fix: update scraping robot typo in README 2024-05-05 14:16:07 +02:00
Towfiq I
bf8fd5362b Merge pull request #184 from sachatrauwaen/main
Resolve issue of duplicate entries in Dicover tab
2024-04-24 20:03:59 +06:00
Towfiq I
0c3068dc80 Merge pull request #193 from abdulla783/patch-2
Remove test variable as not defined and not required at all
2024-04-02 23:41:29 +06:00
Sacha
fde2f728aa Merge branch 'towfiqi:main' into main 2024-03-28 20:42:51 +01:00
Sacha Trauwaen
40e027e1ec 1) wrap both theKeywordsCount and theKeywordsGrouped with useMemo
2) changing reduce and map together to make the code more readable.
3) ctr and position value rounded.
2024-03-28 20:41:50 +01:00
Abdulla Ansari
51da47f292 Remove test variable as not defined and not required at all
As per your request I have removed the test
2024-03-28 20:48:36 +05:30
Towfiq I
d58a716ec1 Merge pull request #192 from valka465/main
Integrate HasData to SerpBear
2024-03-28 19:24:31 +06:00
valka465
79fc6b935c HasData scraper added 2024-03-28 11:57:15 +03:00
valka465
3fc1024520 Update index.ts
HasData added
2024-03-28 11:54:38 +03:00
valka465
90f45fd1c9 Update README.md
HasData info added.
2024-03-28 11:49:06 +03:00
Sacha Trauwaen
3a05703921 fix double entrees in Discover Tab (Google Search Console) 2024-03-15 16:47:27 +01:00
Sacha Trauwaen
6aa8900577 update react dev dependencies 2024-03-15 16:44:25 +01:00
45 changed files with 2451 additions and 1174 deletions

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View File

@@ -2,13 +2,13 @@
# SerpBear
![Codacy Badge](https://app.codacy.com/project/badge/Grade/7e7a0030c3f84c6fb56a3ce6273fbc1d) ![GitHub](https://img.shields.io/github/license/towfiqi/serpbear) ![GitHub package.json version](https://img.shields.io/github/package-json/v/towfiqi/serpbear) ![Docker Pulls](https://img.shields.io/docker/pulls/towfiqi/serpbear)
![Codacy Badge](https://app.codacy.com/project/badge/Grade/7e7a0030c3f84c6fb56a3ce6273fbc1d) ![GitHub](https://img.shields.io/github/license/towfiqi/serpbear) ![GitHub package.json version](https://img.shields.io/github/package-json/v/towfiqi/serpbear) ![Docker Pulls](https://img.shields.io/docker/pulls/towfiqi/serpbear) [![StandWithPalestine](https://raw.githubusercontent.com/Safouene1/support-palestine-banner/master/StandWithPalestine.svg)](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.
![Easy to Use Search Engine Rank Tracker](https://erevanto.sirv.com/Images/serpbear/serpbear_readme_v2.gif)
![Easy to Use Search Engine Rank Tracker](https://serpbear.b-cdn.net/serpbear_readme_v2.gif)
#### 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**

View File

@@ -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: '',

View File

@@ -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: {

View File

@@ -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,
},

View File

@@ -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>
);
};

View File

@@ -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}>

View File

@@ -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'>

View File

@@ -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'];

View File

@@ -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'>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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>
</>
)}

View File

@@ -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
View File

@@ -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())

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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!`);
}

View File

@@ -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 } });

View File

@@ -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,

View File

@@ -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 })),

View File

@@ -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 });
}

View File

@@ -80,6 +80,7 @@ const SingleDomain: NextPage = () => {
showAddModal={showAddKeywords}
setShowAddModal={setShowAddKeywords}
isConsoleIntegrated={!!(appSettings && appSettings.search_console_integrated) || domainHasScAPI }
settings={appSettings}
/>
</div>
</div>

View File

@@ -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>

View File

@@ -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,
];

View 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;

View File

@@ -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');

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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
View File

@@ -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 = {

View File

@@ -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',

View File

@@ -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`;
});
}

View File

@@ -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()]}

View File

@@ -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,

View File

@@ -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;
};
/**